배경
API 개발이 완료되고 클라이언트 연동을 앞둔 시점에서, 발생하는 에러를 체계적으로 관리하고 알림을 받을 필요성이 대두되었습니다. 특히 4xx 클라이언트 에러와 5xx 서버 에러를 구분하여 슬랙(Slack)으로 알림을 전송하는 시스템을 구축하고자 했습니다.
이를 통해 클라이언트 개발자는 4xx 에러 발생 시 원인을 빠르게 파악하여 API 사용법을 수정할 수 있고, 서버 개발자는 5xx 에러 발생 시 별도로 애플리케이션 로그를 확인하는 번거로움 없이 즉각적으로 원인을 파악하는 것을 목표로 삼았습니다. 또한, OOM(OutOfMemoryError)과 같은 심각한 이슈 발생 시에도 관련 정보(요청 내용, 스택 트레이스, 메모리 상태)를 종합하여 신속하게 대응하고자 했습니다.


발생한 문제 및 해결 과정
문제 1: 분산되어 있던 예외 처리 로직
초기에는 405(Method Not Allowed), 415(Unsupported Media Type), 유효성 검사(Validation) 실패 등 다양한 에러 응답이 컨트롤러, 필터, 기본 핸들러 등 여러 곳에 흩어져 있었습니다. 이로 인해 일관된 형식의 응답과 통합된 알림 전송이 어려웠습니다.
✅ 해결GlobalExceptionHandler를 중심으로 예외 처리 로직을 통합하고, 모든 응답을 일관된 ApiResponseWrapper 객체로 감싸도록 개선했습니다. 누락되었던 아래 예외 핸들러들을 추가 및 보완하여 모든 에러가 단일한 흐름을 타도록 설계했습니다.
HttpRequestMethodNotSupportedException(405)HttpMediaTypeNotSupportedException(415)MethodArgumentNotValidException,ConstraintViolationException(400)NoResourceFoundException(404)RuntimeException,Exception(500)OutOfMemoryError(503으로 처리)
모든 예외 핸들러는 처리 후 에러 정보를 담은 이벤트를 발행하도록 연결하여, 알림 로직이 중앙에서 처리되도록 구성했습니다.
문제 2: 가독성이 낮은 슬랙 알림 메시지
단순 텍스트 형태의 슬랙 알림은 정보의 우선순위를 파악하기 어렵고 가독성이 떨어지는 문제가 있었습니다.
✅ 해결
슬랙의 Block Kit을 활용하여 알림 메시지를 구조화했습니다. 이를 통해 각 정보가 명확히 구분되어 가독성을 크게 높였습니다.
- Header: 5xx 에러는 “🚨 API 에러 발생”, 4xx 에러는 “⚠️ 클라이언트 에러 발생”으로 구분
- Fields: Status, Error Code, Path, Method 등 핵심 정보 요약
- Message: 에러 메시지와 유효성 검사 실패 상세 내용
- Context: 요청 IP, User-Agent
- Request Body: 코드 블록으로 가공 (최대 1,800자)
- Stack Trace: 코드 블록으로 가공 (최대 2,500자)
또한, application.yml 설정 파일에 monitoring.slack.notify-4xx, monitoring.slack.notify-5xx 옵션을 두어 상황에 따라 4xx와 5xx 에러 알림을 선택적으로 켜고 끌 수 있도록 구현했습니다.
문제 3: 모든 예외 경로에서 요청 정보(Request Body) 누락
Spring Security 단에서 발생하는 401(Unauthorized)/403(Forbidden) 에러나 필터 체인 초입에서 발생하는 예외는 GlobalExceptionHandler에 도달하기 전에 처리되어 알림이 누락되었습니다. 또한, 요청 본문(Request Body)은 스트림(Stream) 특성상 한 번만 읽을 수 있어, 여러 로직을 거치는 과정에서 비어 있는 상태로 전달되는 경우가 많았습니다.
✅ 해결
- 인증/인가 예외 처리:
CustomAuthenticationEntryPoint(401)와CustomAccessDeniedHandler(403)에도 에러 이벤트를 발행하는 로직을 추가하여, 보안 관련 예외도 동일한 포맷으로 알림을 받을 수 있도록 개선했습니다. - 요청 본문 캐싱:
Filter를 최상위 순서(@Order(1))로 등록하여 모든 요청의 본문을 캐싱하도록 구현했습니다. JSON 타입의 요청에 한해 최대 1MB까지 바이트 배열로 저장하고, 실제 사용 시점에 문자열로 변환(지연 로딩)하여 성능 저하를 최소화했습니다. 이를 통해 어떤 예외 상황에서도 안정적으로 요청 본문 데이터를 확보할 수 있게 되었습니다.
문제 4: 비효율적인 WebClient 사용과 환경별 설정 부재
슬랙 알림을 보낼 때마다 WebClient를 새로 생성(build())하여 연결 풀과 옵션을 재설정하는 비효율이 있었습니다. 또한, 로컬/개발/운영 환경에 따라 실제 알림을 보내거나 로그만 남기는 등의 분기가 필요했습니다.
✅ 해결
- WebClient 싱글톤 관리:
WebClientConfig에@Bean으로slackWebClient를 등록하여 애플리케이션 전역에서 단일 인스턴스를 사용하도록 변경했습니다. - 프로필 기반 구현체 분리:
SlackNotifier인터페이스를 정의하고,@Profile애너테이션을 활용해 각 환경에 맞는 구현체를 만들었습니다.- Local: 실제 전송 없이 로그만 출력
- Dev/Prod: Webhook을 통해 실제 슬랙 메시지 전송Í
최종 구조 요약
- 애플리케이션 전역에서 예외가 발생하면
GlobalExceptionHandler또는 커스텀 핸들러가 이를 감지합니다. - 감지된 예외 정보(요청 정보, 스택 트레이스 등)를 담아
ApiErrorPublisher가 이벤트를 발행합니다. ApiErrorEventListener는 해당 이벤트를 비동기(@Async)로 수신하여 알림 전송 로직이 API 응답 시간에 영향을 주지 않도록 합니다.SlackNotifier는 현재 활성화된 프로필(local, dev, prod)에 맞는 구현체를 통해 슬랙 Block Kit 형식으로 알림을 전송합니다.
회고
이번 프로젝트에서 가장 까다로웠던 부분은 “모든 예외 경로에서 요청 본문을 안정적으로 확보하는 것”이었습니다. 필터 순서, HttpServletRequest 래핑 전략, 지연 로딩, 캐시 크기 제한 등을 종합적으로 적용하며 문제를 해결해 나갔습니다. 이제 에러가 발생하면 누가, 어디서, 무엇을, 어떻게 했는지가 구조화된 슬랙 알림 하나에 모두 담겨, 중요한 문제에만 집중하여 빠르게 대응할 수 있는 환경이 구축되었습니다.
참고한 글
'프로덕트 문제 해결 기록' 카테고리의 다른 글
| WebClient와 CachingFilter 사용 시 InputStream 재사용 오류 해결 (0) | 2025.09.03 |
|---|---|
| [ThatzFit] AOP로 로깅 일원화 (0) | 2025.09.02 |
| [ThatzFit] Spring Data JPA 비관적 락 리팩토링: 문제 해결 기록 (1) | 2025.09.01 |
| [ThatzFit] 트랜잭션 최적화: 쓰기 경계 분리 (1) | 2025.09.01 |
| [WIMI] Spring에서 "No acceptable representation" 오류 (0) | 2025.03.28 |