배경 서비스 초기부터 쌓여온 API 응답 코드에는 독특한 관례가 있었다. 모든 응답이 HTTP 200이었다. 에러가 발생해도 200을 반환하고, 실제 에러 정보는 JSON body 안에 문자열로 담았다. 빠르게 개발하던 시기에 자연스럽게 자리 잡은 패턴이었다. 문제는 서비스가 성장하면서 드러났다. 모니터링 시스템을 붙이고, 프론트엔드 팀과 API 스펙을 맞추고, 장애 대응 프로세스를 정비하는 과정에서 이 구조가 곳곳에서 발목을 잡기 시작했다. 기존 구조의 문제점 CommonClass - 레거시 응답 래퍼 먼저 기존 응답 구조를 보면: 모니터링이 작동하지 않는다 Grafana, Prometheus 같은 모니터링 도구는 HTTP 상태코드를 기반으로 에러율을 측정한다. 모든 응답이 200이면 에러율은 항상 0%다. 서버에서 에러가 쏟아지고 있어도 대시보드는 초록불이었다. 실제 에러를 추적하려면 응답 body를 파싱해서 resultCd 값을 꺼내야 했는데, 표준 모니터링 도구는 이런 방식을 지원하지 않는다. 응답 포맷이 제각각이다 같은 "성공 응답"을 만드는 방법이 최소 3가지였다: 정해진 방식이 없으니 각자 편한 방법을 쓰게 되고, 코드베이스 전체로 보면 같은 일을 하는 코드가 여러 형태로 흩어져 있었다. 에러 코드의 의미가 모호하다 HTTP 304는 캐시 관련 상태코드인데, 여기서는 "데이터를 찾을 수 없다"는 의미로 쓰고 있었다. resultCd가 HTTP 상태코드처럼 생겼지만 실제 의미는 달랐다. API를 사용하는 쪽에서는 이 숫자가 HTTP 표준인지, 자체 정의인지 구분할 방법이 없었다. 컨트롤러가 에러 응답까지 직접 만든다 컨트롤러마다 null 체크, 에러 응답 생성, 성공 응답 포맷팅을 직접 하고 있었다. 비슷한 코드가 모든 컨트롤러에 복사되어 있었고, 응답 포맷을 바꾸려면 모든 컨트롤러를 찾아서 수정해야 했다. 전환 과정 한 번에 설계해서 한 번에 적용한 것이 아니라, 문제를 인식하고 시도하고 개선하는 과정의 연속이었다. Phase 1 - CommonClass 탄생 가장 처음 응답 포맷을 통일하려는 시도였다. 모든 컨트롤러에서 Map<String, Object>를 직접 생성하던 코드를 CommonClass.ResponseResult()라는 정적 메서드로 추출했다. 반복 코드를 줄인 것은 의미가 있었지만, 근본적인 문제 - HTTP 상태코드를 제대로 쓰지 않는 것 - 는 그대로였다. 여전히 모든 응답이 HTTP 200이었고, resultCd는 문자열이었다. Phase 2 - 인프라만 만들고 켜지 못했던 시간 본격적인 에러 처리 체계를 만들기 시작했다. ErrorResponse, BaseException, GlobalExceptionHandler를 스캐폴딩했지만 - 활성화하지는 못했다. @RestControllerAdvice가 주석 처리되어 있었다. 전역 예외 핸들러를 켜면 기존에 컨트롤러마다 try-catch로 처리하던 에러 흐름이 깨질 수 있었고, 운영 중인 서비스에서 그 영향 범위를 확신할 수 없었다. 한동안 비활성 상태로 남았다. Phase 3 - 공통 에러 정의와 활성화 세 단계로 나눠 핵심 인프라가 활성화됐다: ApiErrorCode enum 생성 + GlobalExceptionHandler 활성화 - 처음에는 INVALID_PAYMENT, INTERNAL_SERVER_ERROR 딱 2개의 에러 코드로 시작했다. 핸들러의 @RestControllerAdvice 주석이 해제됐다. Slack 연동 - BaseException 발생 시 AOP로 Slack 알림을 보내는 기능이 추가됐다. 장애 감지의 첫 자동화. ApiResponse<T> 통합 - 기존의 ErrorResponse(에러 전용 DTO)를 삭제하고, 성공과 에러 모두 동일한 ApiResponse<T> 구조로 통합했다. 이 시점에서 현재의 응답 포맷이 확정됐다. 이후 ApiErrorCode는 2개에서 120개 이상으로 확장됐고, 새로 만드는 모든 API는 ApiResponse 기반으로 작성됐다. 참고로 2단계에서 추가했던 Slack 연동은 이후 제거했다. Grafana, Loki 같은 모니터링 시스템이 도입되면서 예외 발생 시 자동 알림이 인프라 레벨에서 처리됐고, 애플리케이션 코드에서 직접 Slack을 호출할 이유가 없어졌다. 설계 원칙 전환 과정을 거치며 세 가지 원칙이 정립됐다. HTTP 상태코드가 실제 상태를 반영한다 - 200이면 진짜 성공, 404면 진짜 없음 응답 포맷은 하나만 존재한다 - 성공이든 에러든 동일한 구조 비즈니스 코드에서 에러 응답을 직접 만들지 않는다 - throw만 하면 인프라가 처리 개발자가 할 일은 두 가지뿐이다. 성공이면 ApiResponse.success()로 감싸고, 실패면 예외를 던진다. 에러 응답 포맷은 전역 예외 핸들러가 알아서 만든다. 구현 공통 응답 래퍼 - ApiResponse success(Consumer<Void>)는 서비스 호출 같은 사이드 이펙트를 실행하고 빈 성공 응답을 반환할 때 쓴다. successEntity()는 ResponseEntity로 한 번 더 감싸는 보일러플레이트를 줄여준다. 각각 Consumer와 data를 조합하는 오버로딩도 있다. 성공과 실패 모두 같은 JSON 구조다: resultCd를 문자열에서 int로 바꾼 것도 의도적이다. 타입 자체가 달라지면서 레거시 코드와의 혼용이 구조적으로 방지된다. 에러 코드 중앙 관리 - ApiErrorCode 모든 비즈니스 에러를 하나의 enum에서 관리한다. 각 에러 코드가 어떤 HTTP 상태코드로 매핑되는지 선언적으로 정의되어 있다. INVALID_PAYMENT, INTERNAL_SERVER_ERROR 딱 2개로 시작한 enum이 120개 이상으로 확장됐다. 새로운 에러가 필요하면 여기에 한 줄만 추가하면 된다. 도메인별로 정리되어 있어서, "결제에서 어떤 에러가 발생할 수 있는지"를 enum 하나만 보면 파악할 수 있다. 커스텀 예외 - BaseException 비즈니스 로직에서는 에러 상황에서 throw만 하면 된다. 실제 사용은 이런 식이다: 전역 예외 핸들러 - GlobalExceptionHandler 모든 예외를 한 곳에서 잡아서 통일된 응답으로 변환하는 핵심 인프라다. 두 핸들러의 차이가 중요하다. BaseException은 개발자가 의도적으로 던진 비즈니스 에러이므로, ApiErrorCode에 정의된 메시지가 그대로 클라이언트에 전달된다. "이미 등록된 카드입니다", "쿠폰을 찾을 수 없습니다" 같은 사용자 친화적 메시지다. 반면 RuntimeException은 예상하지 못한 에러 - NullPointerException, ArrayIndexOutOfBoundsException 같은 것이다. 이런 에러의 내부 메시지를 클라이언트에 노출하면 보안 문제가 될 수 있다. 초기에는 e.getMessage()를 그대로 클라이언트에 반환했는데, 내부 스택 정보나 DB 쿼리 같은 민감한 정보가 노출될 수 있었다. 이를 인지한 뒤 createSafeMessage()로 안전한 일반 메시지로 치환하도록 개선했다: 예외 타입별 자동 매핑 BaseException으로 감싸지 않은 일반 예외도 합리적인 HTTP 상태코드가 나가도록 매핑 테이블을 둔다. Service 레이어에서 throw new IllegalArgumentException("잘못된 입력")만 던져도 자동으로 400 응답이 나간다. 모든 에러를 BaseException으로 감쌀 필요가 없다는 뜻이다. 이 매핑 테이블도 운영하면서 점진적으로 확장됐다. 처음에는 IllegalArgumentException 정도만 있었는데, DateTimeParseException이 500으로 나가는 것을 운영 중에 발견해서 400으로 매핑을 추가하는 식이었다. 운영에서 발견되는 패턴을 기반으로 매핑이 늘어났다. 상태코드별 차등 로깅 ApiErrorCode 중 일부는 HTTP 2xx를 사용한다 - 예를 들어 ALREADY_PROCESSING(HttpStatus.ACCEPTED)은 "이미 진행 중"이라는 비즈니스 상태를 202로 표현한다. 이런 경우는 에러가 아니므로 INFO로 기록한다. 4xx는 클라이언트의 잘못이므로 WARN, 5xx는 서버의 잘못이므로 ERROR로 기록한다. Grafana Loki에서 level=error로 필터링하면 서버 문제만 바로 볼 수 있다. 4xx는 무시해도 되는 로그가 아니지만, 5xx와 섞여 있으면 진짜 중요한 에러를 놓치기 쉽다. 5xx의 경우 findApplicationErrorSource()로 자사 패키지 내 에러 발생 위치를 최대 5단계까지 추적하여 로그에 남긴다. Spring 프레임워크의 수십 줄짜리 스택 트레이스를 뒤질 필요 없이, 애플리케이션 코드의 에러 지점만 바로 확인할 수 있다. OpenTelemetry Span에도 error.message를 기록하기 때문에, Tempo 같은 분산 추적 도구에서도 어느 요청에서 에러가 발생했는지 즉시 파악할 수 있다. 컨트롤러가 달라졌다 Before 가장 많은 코드가 변한 곳은 예약 관련 컨트롤러였다. 예약/변경/취소 API는 발생 가능한 에러 유형이 많아서, 컨트롤러 메서드 하나가 수십 줄에 달했다. 에러 유형별로 catch 블록이 늘어나고, 각 블록에서 응답 포맷을 직접 만들고, HTTP 상태코드도 직접 매핑했다. 새로운 에러 유형이 추가될 때마다 catch 블록이 하나 더 생겼다. After 컨트롤러는 성공 케이스만 다룬다. Service에서 throw new BaseException(ApiErrorCode.CUSTOMER_NOT_FOUND)이 던져지면 GlobalExceptionHandler가 HTTP 404 응답을 만든다. throw new BaseException(ApiErrorCode.ORDER_ALREADY_PROCESSING)이면 HTTP 429가 나간다. 에러 처리 코드가 컨트롤러에서 완전히 사라졌다. 컨트롤러 메서드가 짧아지니 코드 리뷰도 빨라졌고, "에러 응답 포맷을 맞춰주세요"라는 리뷰 코멘트도 사라졌다. 개선 효과 | | Before | After |