donghakim.dev — zsh
← ls ../blog

모든 예외를 RuntimeException 으로 감쌌더니 '매진' 이 '알 수 없는 오류' 로 나갔다

의미 있는 에러 코드를 generic 예외로 재포장하면, 호출자는 재시도할지 포기할지 판단할 정보를 상실합니다

모든 예외를 RuntimeException 으로 감쌌더니 '매진' 이 '알 수 없는 오류' 로 나갔다 · cover

입사하고 남의 코드를 읽기 시작했을 때 가장 자주 보이던 패턴이 있었습니다. try { ... } catch (Exception e) { }, 혹은 예외를 포착해서 로그 한 줄 기록하고 정상 흐름처럼 계속 진행하는 코드였습니다. 에러를 처리한다기보다 에러를 안 보이게 만드는 쪽에 가까웠습니다. 그때는 "차라리 빠르게 실패시키는 게 낫지 않나" 하고 막연히 생각했는데, 그 생각이 실제 사고로 확인된 케이스를 하나 정리합니다.

증상

외부 여행사 파트너가 예약 생성 API 를 호출하면 종종 이런 응답을 수신했습니다.

{ "code": "UNKNOWN_ERROR", "message": "관리자에게 문의해주세요." }

문제는 이 응답에 실패 원인에 대한 정보가 없다는 점이었습니다. 파트너 입장에서는 지금 상황이 시스템 장애인지, 아니면 그냥 자리가 없는 것인지 구분할 수가 없습니다. 그래서 같은 건으로 계속 재시도하거나 문의를 접수했고, 이런 일이 여러 파트너·여러 편에서 매일 수 회씩 반복됐습니다.

원인: 세 가지가 겹쳐 있었다

1) 실제 원인은 좌석 매진

예약 생성은 내부적으로 가격 검증과 좌석 홀딩을 같이 수행하는데, 가격은 통과하고 좌석 홀딩에서 실패하고 있었습니다. 좌석 재고는 전 채널이 공유하는 단일 소스라, 웹·콜센터·다른 파트너가 그사이에 판매하면 실재고가 0 이 됩니다. 즉 데이터 문제도 가격 문제도 아니고 순수하게 "이미 팔렸다" 였습니다.

2) 캐시가 "예약 가능" 으로 보여줬다

조회는 TTL 이 설정된 캐시로 응답하고 있었습니다. 화면에 보이는 잔여 좌석 수는 최대 10분 전 스냅샷이라, 잔여가 1~2석인 임박 편은 그 10분 사이에 다른 채널에서 판매돼 실재고가 0 이 될 수 있습니다. 사용자는 "가능" 을 보고 예약을 클릭했는데 서버에서는 매진으로 실패하는 구조였습니다.

3) 그리고 마스킹: 의미 있는 코드가 catch-all 코드로 바뀌었다

여기가 핵심입니다. 컨트롤러가 이렇게 돼 있었습니다.

try {
    // ... 예약 생성 ...
} catch (Exception e) {
    throw new RuntimeException(e);   // 전부 이걸로 감쌈
}

공통 예외 핸들러는 우리 도메인 예외(BaseException) 일 때만 그 안의 코드·메시지를 그대로 응답에 포함하고, 그 밖의 예외는 generic 핸들러에서 처리해 무조건 catch-all 코드(UNKNOWN_ERROR) 를 반환하도록 돼 있었습니다. 서비스 계층은 분명히 "잔여 좌석이 부족합니다" 라는 매진 예외를 발생시켰는데, 컨트롤러가 그 예외를 RuntimeException 으로 감싸는 순간 도메인 정보가 소실되고 catch-all 코드로 응답된 것입니다.

문제가 하나 더 있었습니다. 핸들러는 응답 메시지를 예외에 담긴 문자열이 아니라 코드 기준으로 조회했습니다. 그래서 서비스가 설정한 메시지("잔여 좌석이 부족합니다")는 로그에만 기록되고, 응답에는 코드에 매핑된 "관리자에게 문의해주세요" 가 반환됐습니다.

예외를 감싸면 실패 이유가 사라진다

입사 초에 봤던 "예외를 삼키는" 코드와 이 사고는 같은 문제였습니다. 예외를 모두 감싸도 실패 자체는 제거되지 않습니다. 실패는 똑같이 발생하는데 왜 실패했는지만 소실됩니다. 그리고 그 정보가 가장 필요한 사람은 호출자입니다.

  • 매진이면 → 재조회하고 다른 좌석을 선택하면 됩니다.
  • 시스템 장애면 → 잠시 대기했다 재시도해야 합니다.

둘은 완전히 다른 행동인데, 응답이 똑같이 "관리자에게 문의" 면 호출자는 아무 판단도 못 하고 의미 없는 재시도만 반복합니다. "빠른 실패가 낫다" 는 말도 프로세스를 빨리 중단하라는 뜻보다는, 호출자가 실패 원인을 구분할 수 있게 응답하라는 뜻에 가까웠습니다.

해결

두 가지를 했습니다.

  1. 전용 에러 코드를 신설했습니다. 매진 상황을 catch-all 이 아니라 SEAT_SOLD_OUT 이라는 별도 코드로 발생시키게 했습니다.
  2. 컨트롤러의 마스킹을 제거했습니다. BaseException 은 감싸지 않고 그대로 다시 던지고, 그 밖의 예외만 RuntimeException 으로 감쌉니다.
try {
    // ... 예약 생성 ...
} catch (BaseException e) {
    throw e;                        // 의미 있는 도메인 예외는 그대로 통과
} catch (Exception e) {
    throw new RuntimeException(e);  // 정말 알 수 없는 것만 감싼다
}

결과적으로 파트너는 이제 이런 응답을 수신합니다.

{ "code": "SEAT_SOLD_OUT", "message": "선택하신 운임의 잔여 좌석이 매진되었습니다. 다시 조회 후 예약해 주세요." }
#api#cache#error-handling#exception#fail-fast#troubleshooting