모든 예외를 RuntimeException 으로 감쌌더니 '매진' 이 '알 수 없는 오류' 로 나갔다
의미 있는 에러 코드를 generic 예외로 재포장하면, 호출자는 재시도할지 포기할지 판단할 정보를 잃습니다

입사하고 남의 코드를 읽기 시작했을 때 가장 자주 보이던 패턴이 있었습니다. try { ... } catch (Exception e) { }, 혹은 예외를 잡아서 로그 한 줄 남기고 정상처럼 흘려보내는 코드였습니다. 에러를 처리한다기보다 에러를 안 보이게 만드는 쪽에 가까웠습니다. 그때는 "차라리 빠르게 실패시키는 게 낫지 않나" 하고 막연히 생각했는데, 그 생각이 실제 사고로 확인된 케이스를 하나 정리합니다.
증상
외부 여행사 파트너가 예약 생성 API 를 호출하면 종종 이런 응답을 받았습니다.
{ "code": "UNKNOWN_ERROR", "message": "관리자에게 문의해주세요." }
문제는 이 응답이 아무것도 말해주지 않는다는 점이었습니다. 파트너 입장에서는 지금 상황이 시스템 장애인지, 아니면 그냥 자리가 없는 건지 구분할 수가 없습니다. 그래서 같은 건으로 계속 재시도하거나 문의를 넣었고, 이게 여러 파트너·여러 편에서 매일 수 회씩 반복됐습니다.
원인 — 세 겹이 겹쳐 있었다
1) 진짜 원인은 좌석 매진
예약 생성은 내부적으로 가격 검증과 좌석 홀딩을 같이 수행하는데, 가격은 통과하고 좌석 홀딩에서 실패하고 있었습니다. 좌석 재고는 전 채널이 공유하는 단일 소스라, 웹·콜센터·다른 파트너가 그새 팔아버리면 실재고가 0 이 됩니다. 즉 데이터 문제도 가격 문제도 아니고 순수하게 "이미 팔렸다" 였습니다.
2) 캐시가 "예약 가능" 으로 보여줬다
조회는 TTL 이 걸린 캐시로 서빙되고 있었습니다. 화면에 보이는 잔여 좌석 수는 최대 10분 전 스냅샷이라, 잔여가 1~2석인 임박 편은 그 10분 사이에 다른 채널에서 팔려 실재고가 0 이 될 수 있습니다. 사용자는 "가능" 을 보고 예약을 눌렀는데 서버는 매진을 만나는 구조였습니다.
3) 그리고 마스킹 — 의미 있는 코드가 뭉개졌다
여기가 핵심입니다. 컨트롤러가 이렇게 돼 있었습니다.
try {
// ... 예약 생성 ...
} catch (Exception e) {
throw new RuntimeException(e); // 전부 이걸로 감쌈
}
공통 예외 핸들러는 우리 도메인 예외(BaseException) 일 때만 그 안의 코드·메시지를 살려서 내보내고, 그 밖의 예외는 generic 핸들러로 빠져 무조건 catch-all 코드(UNKNOWN_ERROR) 를 반환하도록 돼 있었습니다. 서비스 계층은 분명히 "잔여 좌석이 부족합니다" 라는 매진 예외를 던졌는데, 컨트롤러가 그걸 RuntimeException 으로 감싸는 순간 도메인 정보가 사라지고 catch-all 로 떨어진 겁니다.
한 겹이 더 있었습니다. 핸들러는 응답 메시지를 예외에 담긴 문자열이 아니라 코드 기준으로 조회했습니다. 그래서 서비스가 넣어준 친절한 메시지("잔여 좌석이 부족합니다")는 로그에만 남고, 응답에는 코드에 매핑된 "관리자에게 문의해주세요" 가 나갔습니다.
예외를 감싸는 건 실패가 아니라 이유를 감춘다
입사 초에 봤던 "예외를 삼키는" 코드와 이 사고는 결국 같은 문제였습니다. 예외를 통째로 감싸는 것은 실패 자체를 없애주지 않습니다. 실패는 똑같이 나는데 왜 실패했는지만 사라집니다. 그리고 그 정보가 가장 필요한 사람은 호출자입니다.
- 매진이면 → 재조회하고 다른 좌석을 잡으면 됩니다.
- 시스템 장애면 → 잠시 기다렸다 재시도해야 합니다.
둘은 완전히 다른 행동인데, 응답이 똑같이 "관리자에게 문의" 면 호출자는 아무 판단도 못 하고 무의미한 재시도만 반복합니다. "빠른 실패가 낫다" 는 말의 진짜 의미도 여기 있었습니다. 빨리 죽으라는 게 아니라, 실패를 정확한 모양으로 드러내라는 쪽이었습니다.
해결
두 가지를 했습니다.
- 전용 에러 코드를 신설했습니다. 매진 상황을 catch-all 이 아니라
SEAT_SOLD_OUT같은 자기 이름을 가진 코드로 던지게 했습니다. - 컨트롤러의 마스킹을 걷어냈습니다.
try {
// ... 예약 생성 ...
} catch (BaseException e) {
throw e; // 의미 있는 도메인 예외는 그대로 통과
} catch (Exception e) {
throw new RuntimeException(e); // 정말 알 수 없는 것만 감싼다
}
결과적으로 파트너는 이제 이런 응답을 받습니다.
{ "code": "SEAT_SOLD_OUT", "message": "선택하신 운임의 잔여 좌석이 매진되었습니다. 다시 조회 후 예약해 주세요." }