이전 글에서 예외를 값으로 다루는 접근법을 소개했다. Result 패턴과 Railway-Oriented Programming이 어떤 원리인지, 왜 필요한지를 다뤘다.
글을 쓰고 나서 드는 생각이 있었다. 원리를 아는 것과 실제 서비스에 적용하는 것 사이에는 꽤 큰 간극이 있다. 특히 스프링 기반 서비스에서 @ExceptionHandler가 이미 잘 동작하고 있는데 왜 굳이 Result를 도입해야 하는가?
마침 사내에서 에러 처리에 대한 논의가 있었다. 알 수 없는 에러가 발생하여 대응이 어렵거나 에러 로그를 남기는 기준조차 사람마다 달라 에러 식별도 어려운 등 실제 문제를 해결해야 했다. 코드 레벨의 예외 처리를 넘어 조직이 에러를 어떻게 분류하고 대응할지에 대한 합의가 필요하다.
이 글은 스프링 기반 서비스에서 에러 처리 전략을 세우려는 팀을 대상으로 한다. Result 패턴의 문법보다는 에러를 분류하고 핸들링하고 전파하는 전체 과정에 초점을 맞춘다. 에러도 도메인 모델이나 API처럼 설계의 대상이라는 컨셉이 이 글의 바탕이다.
에러 처리의 핵심은 결국 두 가지다. 경우의 수를 빠짐없이 파악하고 각각을 적절하게 핸들링하는 것. 고객이 장바구니의 상품을 주문하는 서비스를 예제로 아래의 과정을 처음부터 끝까지 따라간다.
- 파악 - 이 API에서 발생할 수 있는 모든 실패를 빠짐없이 열거한다.
- 분류 - 예측된 비즈니스 케이스(BusinessError)와 인프라 장애(InfraException)를 나눈다.
- 핸들링 - 비즈니스 에러는 값(Result)으로 인프라 장애는 예외(throw)로 처리한다.
- 전파 - 서비스 경계에서 에러를 번역하고 API 응답과 문서로 호출자에게 전달한다.
- 관찰가능성(observability) - 로그 레벨과 알림 정책을 분리하여 봐야 할 에러만 보이게 한다.
1. 경우의 수 파악
에러 처리의 출발점은 패턴 선택도 라이브러리 도입도 아니다. **"이 API에서 발생할 수 있는 모든 실패를 빠짐없이 열거하기"**가 먼저다.
예제로 사용할 서비스를 소개한다. 고객이 장바구니의 상품을 주문하는 placeOrder API다. 고객 정보를 확인하고 주문 상태를 검증하고 결제를 요청한다. 이 API가 어떤 실패를 맞닥뜨릴 수 있는지 흐름을 따라가며 하나씩 짚어본다.
- 고객을 조회했으나 고객 정보가 없거나 비활성 고객이다.
- 주문 상태를 확인했더니 이미 처리 중이다.
- 신규 주문 접수가 중단되어 있다.
- 장바구니가 비어 있다.
- PG사 통신 오류 / 재고 시스템 오류 / 결제 로직 오류 등 결제 실행 시 장애가 발생했다.
- 어디서든 발생 가능한 예상하지 못한 버그 (NPE, DB Connection 오류 등).
이렇게 나열하면 총 7가지 실패 시나리오가 보인다. 이 목록이 곧 이 API의 에러 계약이다. 에러 계약이란 이 API가 호출자에게 반환할 수 있는 모든 실패 케이스의 명세다. 함수 시그니처가 입출력 타입을 약속하듯 에러 계약은 실패 시나리오를 약속한다. 클라이언트는 성공 응답 하나와 이 7가지 실패 중 하나를 받을 수 있다.
경우의 수를 파악했으면 다음은 각각의 성격을 판단할 차례다.
2. 분류
열거한 실패들은 성격이 다르다. "고객이 비활성 상태다"는 도메인 규칙에 의한 예측된 거절이고 "PG사가 타임아웃을 냈다"는 외부 시스템의 일시적 장애다. 같은 "실패"지만 원인도 다르고 대응 방법도 다르다. 이 성격 차이에 따라 분류하면 크게 두 가지로 나뉜다.
| 실패 시나리오 | 분류 | 이유 |
|---|---|---|
| 고객 없음/비활성 | BusinessError | 고객 상태에 따른 예측된 케이스 |
| 주문 이미 처리 중 | BusinessError | 상태 규칙 위반 |
| 기능 비활성화 | BusinessError | 설정에 따른 예측된 케이스 |
| 장바구니 비어있음 | BusinessError | 데이터 상태에 따른 예측된 케이스 |
| PG사 통신 오류 | InfraException | 외부 시스템 일시적 장애 |
| 재고 시스템 오류 | InfraException | 인프라 장애 |
| 결제 로직 오류 | InfraException | 코드 버그로 인한 오류 |
"결제 로직 오류"의 분류는 실무에서 가장 경계가 모호한 지점이다. "잔액 부족"이나 "한도 초과"는 BusinessError지만 "결제 데이터 직렬화 실패"는 코드 버그다. 위 5번 항목의 세 가지 결제 오류 중 이 "결제 로직 오류"를 코드에서는 PaymentLogicException으로 표현한다. 비즈니스 규칙 위반이 아닌 결제 모듈 내부의 기술적 오류다. 분류가 애매한 경우, **"클라이언트가 요청을 수정하면 성공할 수 있는가?"**를 기준으로 판단하면 된다. "잔액 부족"은 충전 후 재요청하면 성공하므로 BusinessError다. "결제 데이터 직렬화 실패"는 클라이언트가 뭘 바꾸든 서버 코드가 수정되기 전까지 같은 결과이므로 InfraException이다.
그리고 목록에 없는 것 — 예상하지 못한 NPE나 ClassCastException — 은 Unknown으로 분류한다.
| 분류 | 성격 |
|---|---|
| BusinessError | 예측된 비즈니스 케이스. 도메인 규칙 위반 |
| InfraException | 원인을 아는 인프라 장애. 비즈니스 케이스는 아니지만 예측된 실패 |
| Unknown | 예상하지 못한 버그 |
분류 기준은 단순하다. 예측된 비즈니스 케이스면 BusinessError, 원인을 아는 인프라 장애면 InfraException, 나머지는 Unknown.
여기서 중요한 점은 BusinessError든 InfraException이든 둘 다 예측된 실패라는 거다. 비즈니스 케이스냐 아니냐의 차이일 뿐 호출자에게 전달되어야 하는 경우의 수라는 점에서는 같다. 진짜 "예측 못한" 건 Unknown뿐이고 그것도 fallback으로 잡는다.
왜 BusinessError와 InfraException을 나누는가
분류 자체는 핸들러를 나누는 것만으로도 가능하다. BusinessException -> 400, InfraException -> 500으로 분리하면 에러 채널이 섞이는 문제는 해결된다.
그런데 핵심은 그게 아니다. BusinessError를 예외(throw)가 아닌 값(Result)으로 처리하는 것이 분류의 진짜 목적이다.
"고객의 비활성 상태"는 예외적 상황이 아니다. 고객의 정상적인 상태 범위 안에 있는 예측된 비즈니스 케이스다. 이런 실패를 throw로 처리하면 호출자가 이를 빠뜨려도 컴파일러가 잡아주지 않는다. Result로 반환하면 호출자는 성공과 실패를 반드시 처리해야 한다. 에러가 정상 비즈니스 플로우 안에 녹아든다.
반면 "DB가 죽었다"는 비즈니스 케이스가 아니다. 서비스 코드에서 할 수 있는 것도 없고 공통 핸들러가 일괄 처리하면 된다. 이런 에러까지 Result로 감싸면 서비스 코드가 인프라 에러 처리로 오염된다.
정리하면 비즈니스 케이스인가 아닌가가 분류 기준이고 분류에 따라 **처리 메커니즘(Result vs throw)**이 달라진다.
BusinessError -> Result로 값 처리, 호출자가 반드시 처리
InfraException -> 예외로 throw, 공통 핸들러가 일괄 처리
Unknown -> 예외로 throw, fallback 핸들러3. 핸들링
분류가 끝났으면 각각을 실제 코드로 표현해보자.