넓게 잡으면 무엇이 사라지는가
catch (Exception e) 한 줄은 편하지만 세 가지를 동시에 버립니다. 첫째, 오류의 종류입니다. 네트워크 타임아웃과 입력 파싱 실패와 코드 버그가 같은 블록으로 들어오면 재시도해야 할 것과 즉시 포기해야 할 것을 구분할 수 없습니다. 둘째, 원인 사슬입니다. 잡은 예외를 로그만 찍고 새 예외를 던지면 원래 스택 트레이스가 끊깁니다. Java와 C#은 원인 예외를 생성자에 넘겨야 하고, Python은 raise ... from, Go는 fmt.Errorf의 %w 동사로 사슬을 잇습니다. 셋째, 프로그래밍 오류입니다. 널 참조나 배열 범위 초과 같은 버그성 예외까지 삼켜 버리면 잘못된 상태로 계속 굴러갑니다.
언어별로 특히 위험한 지점이 갈립니다. Python의 except Exception은 KeyboardInterrupt와 SystemExit은 잡지 않지만 except:는 그것까지 잡아 프로세스를 종료 불가 상태로 만듭니다. JavaScript는 던지는 값이 반드시 Error일 필요가 없어서 문자열이나 객체가 날아올 수 있고, 그래서 TypeScript는 catch 변수를 unknown으로 다루는 옵션을 제공합니다. C++은 소멸자에서 예외가 새 나가면 프로그램이 종료되며, 예외를 끈 빌드 설정을 쓰는 코드베이스도 흔해서 라이브러리는 오류 코드 반환을 병행하는 경우가 많습니다.
계열을 넘나들 때의 번역 규칙
- 예외 → 반환값 — Java나 Python 코드를 Go로 옮길 때, 상위 몇 단계를 한 번에 건너뛰던 throw는 매 단계의
if err != nil과 명시적 전파로 풀립니다. 옮기다 보면 중간에 정리해야 할 리소스가 드러나는데, 그것이 원래 예외 코드에 숨어 있던 누수인 경우가 많습니다.
- 반환값 → 예외 — 반대 방향에서는 무시된 오류가 문제입니다. Go에서
_로 버려진 err가 예외 언어에서는 그냥 사라지지 않고 위로 올라가 호출 체인을 끊습니다. 어디까지 올려 보낼지 경계를 먼저 정해야 합니다.
- 정리 코드 — Go는
defer, Java는 try-with-resources, C#은 using, Python은 with, Kotlin은 use, C++은 소멸자와 RAII가 같은 역할을 합니다. 이 장치를 쓰지 않고 finally에서 손으로 닫는 코드는 예외가 두 번 겹칠 때 새는 지점이 됩니다.
- 비어 있는 catch — 어떤 언어에서든 최악입니다. 무시하기로 했다면 왜 무시해도 되는지 한 줄 주석을 남겨야 나중에 이 코드를 지워도 되는지 판단할 수 있습니다.
실무에서 유용한 구분은 "재시도 가능한 실패"와 "계약 위반"입니다. 앞의 것은 값으로 다루어 호출자에게 판단을 넘기고, 뒤의 것은 즉시 크게 실패시키는 편이 낫습니다. Go의 errors.Is·errors.As, Java의 예외 계층, Python의 커스텀 예외 클래스는 모두 이 구분을 코드로 표현하기 위한 도구입니다. 오류 타입을 문자열 메시지로만 구분하는 코드는 나중에 반드시 문자열 비교로 분기하게 됩니다.
언어별 상세 패턴은 Go 에러 처리, Rust 에러 처리, Java 예외 처리, Python 예외 처리 문서에서 이어 보실 수 있습니다.
로그와 오류 메시지
어느 계열을 쓰든 마지막에 남는 것은 사람이 읽을 메시지입니다. 여기서 자주 낭비되는 정보가 맥락입니다. "파일을 열 수 없습니다"는 어떤 파일인지, 어느 단계에서인지 알려 주지 않아 재현이 불가능합니다. 오류를 위로 전달할 때마다 그 층이 알고 있는 정보를 한 겹씩 덧붙이면, 최종 로그가 실패 경로 전체를 설명하게 됩니다. Go의 %w 감싸기와 Java의 원인 예외 체인이 정확히 이 목적을 위한 장치입니다.
동시에 넣지 말아야 할 것도 있습니다. 비밀번호, 토큰, 개인정보가 예외 메시지에 섞여 그대로 로그에 남는 사고가 흔합니다. 예외를 그대로 사용자 응답에 노출하면 내부 경로나 스택 구조까지 함께 나갑니다. 사용자에게 보여 줄 메시지와 내부 로그에 남길 메시지를 처음부터 분리해 두는 것이 안전합니다.