API 응답/에러 처리 구조 공통화 - HTTP 상태코드를 제대로 쓰기까지
모든 API가 200을 반환하던 레거시 구조에서, HTTP 상태코드를 올바르게 활용하는 공통 응답/에러 체계로 전면 개선한 과정. 모니터링 정상화, 디버깅 효율화, 개발자 간 커뮤니케이션 비용 절감까지.
배경
서비스 초기부터 쌓여온 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 구조다: