예외
고친 사람 github-actions[bot]
예외는 하던 일을 더 이어 갈 수 없게 됐다고 알리고, 그 뒤처리를 미리 정해 둔 코드로 넘기는 장치입니다. 알린 쪽은 뒷일을 책임지지 않고, 넘겨받은 쪽이 무엇을 할지 정합니다. 같은 이름을 두 층에서 씁니다. 프로그램이 스스로 던지는 예외가 하나이고, 프로세서가 명령을 실행하다 일으키는 예외가 다른 하나입니다.
쉽고 빠른 이해
예외는 「이 일은 여기까지」라고 알리고 뒤처리를 남에게 넘기는 장치입니다. 문자열 "12x" 를
정수로 바꿔 달라는 요청을 받은 함수가 답을 못 낼 때 예외를 던집니다.
이게 없으면 실패를 오직 반환값으로만 알려야 합니다. 부른 쪽이 값을 받을 때마다 실패인지 검사해야 합니다. 한 번 빠뜨리면 잘못된 값이 다음 계산으로 흘러갑니다.
이렇게 돕니다:
- 일을 더 못 이어 가는 쪽이 예외를 만들어 던집니다
- 자기를 부른 함수들을 거슬러 올라가며 이 예외를 받겠다고 적어 둔 코드를 찾습니다
- 찾으면 거기서 이어 가고, 못 찾으면 프로그램이 멈춥니다
대가는 흐름이 눈에 안 보인다는 것입니다. 코드가 어디서 어디로 뛰는지가 줄 순서에 안 드러납니다. 흔히 일어나는 실패까지 예외로 알리면 정상 흐름이 뒤처리에 묻힙니다.
상세
주방에서 요리를 하다 재료가 떨어지면 요리사는 만들던 접시에서 손을 뗍니다. 남은 조리 단계는 밟지 않습니다. 그 소식이 홀 직원을 거쳐 주문한 손님에게까지 올라가서야 다른 메뉴로 할지 그만둘지가 정해집니다.
코드에서도 같습니다. 문자열 "12x" 를 정수로 바꿔 달라는 함수 호출이 그런 경우입니다.
이 함수는 답을 지어낼 수 없습니다. 아무 값이나 돌려주면 부른 쪽이 속습니다. 그래서 답을
내는 대신 예외를 던져 뒤처리를 미리 정해 둔 코드에 넘깁니다.
먼저 예외 없이 실패를 알릴 때의 부담을 보고, 프로그램 예외와 프로세서 예외를 차례로 본 뒤 둘을 나란히 놓습니다. 마지막으로 무엇을 예외로 던질지 가릅니다.
실패를 값으로만 알릴 때
예외가 없으면 함수는 실패를 반환값에 실어 보냅니다. C 언어의 표준 함수들이 그렇게 합니다.
파일을 여는 fopen 은 못 열면 빈 포인터를 돌려주고, 왜 안 됐는지는 errno 라는 전역
변수에 남깁니다.
이 방식은 부른 쪽에 두 가지 부담을 지웁니다. 하나는 값을 받을 때마다 실패인지 검사하는 일입니다. 검사를 빠뜨려도 프로그램은 멈추지 않고 그냥 돌기 때문에, 잘못된 값이 몇 단계를 지나서야 엉뚱한 곳에서 터집니다.
다른 하나는 실패를 위로 나르는 일입니다. 다섯 단계를 거쳐 부르는 구조라면 중간 세 함수는 자기와 상관없는 실패 코드를 받아서 그대로 위로 올려 주기만 합니다. 그 코드가 본래 하려던 일보다 길어집니다.
예외는 이 두 부담을 옮깁니다. 실패를 알리는 통로를 반환값과 따로 내서, 중간 함수가 아무것도 안 적어도 실패가 위로 올라갑니다.
프로그램이 던지는 예외
먼저 말부터 정합니다. 실패를 알리는 쪽이 예외를 던지고, 처리하는 쪽이 받습니다. 던져지는 것은 무엇이 왜 실패했는지를 담은 값입니다. 이것을 예외 객체라고 부릅니다.
아래는 정수로 바꿀 수 없는 문자열을 넘겼을 때 실행이 어디로 가는지를 보인 것입니다.
try:
n = int("12x") # 여기서 던진다
print(n) # 건너뛴다
except ValueError:
print("숫자 아님") # 이 줄이 대신 돈다
try 로 감싼 구간에서 예외가 나면 그 줄에서 실행이 끊깁니다. 남은 줄은 건너뛰고, 그 예외를
받겠다고 적어 둔 구간으로 바로 넘어갑니다. 예외가 안 나면 받는 구간은 돌지 않습니다.
받는 코드가 같은 함수 안에 없으면 자기를 부른 함수로 한 단계 넘어갑니다. 지금 어떤 함수들이 서로를 부른 채 쌓여 있는지를 호출 스택이라고 합니다. 예외는 이 스택을 거슬러 올라가며 받는 코드를 찾습니다. 이 과정을 스택 되감기라고 부릅니다.
아래는 숫자 변환 함수가 던진 예외가 자기를 부른 함수들을 두 단계 거슬러 main 까지 가는
모습입니다.
flowchart TD
C["숫자변환 · 예외를 던짐"]
B["주문저장 · 받는 코드 없음"]
A["main · 받겠다고 적어 둠"]
C -->|되감기| B
B -->|되감기| A
A --> D["받는 코드가 처리한다"]
끝까지 거슬러 가도 받는 코드가 없으면 프로그램이 멈춥니다. 이때 대개 어느 함수들을 거쳐 여기까지 왔는지가 스택 추적으로 출력됩니다. 되감기를 하며 이름을 모아 둔 덕분입니다.
언어마다 예외를 얼마나 강제하는지가 다릅니다. 자바는 예외를 두 갈래로 가릅니다. 한쪽은 메서드가 던질 수 있다고 미리 선언해야 하고 컴파일러가 그 선언을 검사합니다. 다른 한쪽은 검사하지 않아서 아무 데서나 올라올 수 있습니다. 앞쪽을 검사 예외라고 부릅니다.
예외가 나든 안 나든 반드시 해야 하는 뒷정리도 있습니다. 열어 둔 파일을 닫는 일이 그런 뒷정리입니다. 되감기가 지나가면 그 함수의 나머지 줄은 안 돕니다. 그래서 파이썬과 자바는 이런 뒷정리를 finally 절에 따로 적게 합니다.
프로세서가 일으키는 예외
프로세서, 곧 CPU(Central Processing Unit, 중앙 처리 장치)는 명령어를 한 줄씩 꺼내 실행합니다. 그 명령을 끝까지 수행할 수 없는 상황을 CPU 자신이 알아채면 그것도 예외입니다. 0으로 나누기, 없는 메모리 주소 읽기, 권한이 모자란 명령 실행이 그런 상황입니다.
이때 CPU 는 하던 흐름을 멈추고, 운영체제의 핵심부인 커널이 미리 등록해 둔 처리기로 제어를 넘깁니다. 넘어가기 전에 무엇 때문인지를 가리키는 번호와 돌아올 주소를 적어 둡니다. 이렇게 정해진 처리기로 넘어가는 동작을 트랩이라고 부릅니다.
인터럽트와 가르는 기준은 원인입니다. 예외는 지금 실행 중인 명령 때문에 생깁니다. 인터럽트는 키보드 입력이나 타이머처럼 바깥 사건 때문에 생깁니다. 어떤 명령을 돌고 있었는지와는 상관이 없습니다.
처리기가 하는 일은 원인에 따라 갈립니다. 고칠 수 있으면 고친 뒤 끊긴 명령을 다시 실행합니다. 프로그램이 쓰려는 메모리가 아직 안 올라와 있어 생기는 페이지 폴트가 그렇습니다. 커널이 그 내용을 채워 넣으면 프로그램은 아무 일도 없었던 듯 이어 갑니다.
아래는 처리기가 원인에 따라 어떻게 갈리는지를 보인 것입니다.
flowchart TD
A["명령을 실행하다 이상을 알아챔"] --> B["커널의 처리기로 넘어간다"]
B --> C{"고칠 수 있나"}
C -->|고쳤다| D["끊긴 명령을 다시 실행"]
C -->|못 고친다| E["프로그램에 알리고 멈춘다"]
일부러 예외를 일으키기도 합니다. 프로그램이 커널의 기능을 부르는 시스템 콜이 그렇습니다. 전용 명령으로 트랩을 일으켜 커널로 넘어가고, 일이 끝나면 다음 명령부터 이어 갑니다.
못 고치는 예외는 프로그램에 전달됩니다. 커널이 그 프로그램에 정해진 번호의 알림인 시그널을 보냅니다. 프로그램이 따로 받아 두지 않았으면 거기서 종료됩니다.
언어가 딸려 주는 실행 지원 코드인 런타임이 이 시그널을 받아 자기 예외로 바꿔 던지기도 합니다. 그러면 같은 사건이 아래층에서는 프로세서 예외로, 위층에서는 프로그램이 던지는 예외(언어 예외)로 두 번 나타납니다.
아래는 한 사건이 프로세서에서 커널을 거쳐 프로그램까지 가며 이름을 바꾸는 경로입니다.
flowchart TD
subgraph 프로세서
A["명령을 실행하다 이상을 알아챔"] --> B["트랩"]
end
subgraph 커널
B --> C["처리기 · 고칠 수 있으면 여기서 끝난다"]
C --> E["못 고치면 시그널을 보낸다"]
end
subgraph 프로그램
E --> F["런타임이 시그널을 받아 예외 객체로 바꾼다"]
F --> G["받는 코드가 처리한다"]
E --> H["받아 둔 것이 없으면 종료"]
end
두 층을 나란히 놓기
두 예외가 무엇을 공유하고 어디서 갈리는지를 한 표로 놓으면 이렇습니다.
| 프로그램이 던지는 예외 | 프로세서가 일으키는 예외 | |
|---|---|---|
| 누가 일으키나 | 코드가 스스로 던진다 | CPU 가 알아채고 일으킨다 |
| 누가 받나 | 같은 프로그램의 받는 코드 | 커널에 등록된 처리기 |
| 무엇이 넘어가나 | 예외 객체 | 원인 번호와 돌아올 주소 |
| 처리한 뒤 | 받은 곳에서 이어 간다 | 끊긴 명령을 다시 실행하거나 멈춘다 |
| 아무도 안 받으면 | 프로그램이 종료된다 | 커널이 시그널을 보내 끝낸다 |
닮은 것은 얼개입니다. 이상을 알아챈 쪽과 뒤처리하는 쪽을 떼어 놓고, 그 사이를 미리 정해 둔 통로로 잇습니다. 갈리는 것은 그 통로를 누가 관리하느냐입니다. 언어 예외는 프로그램이 자기 안에서 관리하고, 프로세서 예외는 커널이 관리합니다.
예외로 던질 일과 값으로 돌려줄 일
예외는 공짜가 아닙니다. 첫째 대가는 흐름이 코드 순서에 안 드러난다는 것입니다. 어느 줄에서 어디로 뛸지가 그 줄만 봐서는 안 보입니다. 받는 코드를 찾아 스택을 거슬러 올라가야 알 수 있습니다.
둘째 대가는 흔한 실패까지 예외로 알릴 때 옵니다. 찾는 값이 없는 조회처럼 늘 일어나는 일을 예외로 던지면, 정상 흐름이 뒤처리 코드에 묻혀 무엇이 본래 하려던 일인지 흐려집니다.
셋째는 비용입니다. 예외를 만들 때 대개 거쳐 온 함수 이름을 모으므로, 값 하나를 돌려주는 것보다 손이 더 갑니다.
받아 놓고 아무것도 안 하는 뒤처리 코드도 흔한 함정입니다. 받기만 하고 비워 두면 실패가 그대로 사라져서, 나중에 결과가 이상해도 어디서 어긋났는지를 못 찾습니다.
Go는 아예 예외 구문을 두지 않습니다. 함수가 결과와 오류를 함께 돌려주고, 부른 쪽이 오류를 확인하게 합니다. 실패를 알리는 통로를 반환값으로 되돌린 선택입니다.
가르는 기준은 하나입니다. 부른 쪽이 바로 손쓸 수 있고 자주 일어나는 실패는 값으로 돌려줍니다. 손쓸 수 없어 위로 올려야 하는 실패는 예외로 던집니다.
관련 항목
예외를 던지고 받는 데 쓰는 구문
예외 처리 · try-catch · throw · finally · 스택 되감기 · 스택 추적
예외로 알려지는 실패의 종류
런타임 오류 · 검사 예외 · 널 포인터 역참조 · 스택 오버플로 · 정수 오버플로 · 세그멘테이션 폴트
프로세서 층에서 예외와 함께 도는 장치
트랩 · 인터럽트 · 시스템 콜 · 페이지 폴트 · 시그널 · 문맥 교환 · 커널 모드와 유저 모드
예외 대신 실패를 알리는 다른 수단
오류 코드 · Result 타입 · Option 타입 · panic · 단언
예외를 실어 나르는 실행 환경과 구조
런타임 · JVM · 호출 스택 · 스택 프레임 · 커널 · CPU · 예외 테이블
예외를 다루는 방식이 갈리는 언어
다른 이름: exception · 익셉션