
API를 개발하다 보면 다양한 예외 상황을 맞닥뜨린다.
이런 예외들을 각각의 컨트롤러마다 개별적으로 처리하게 되면 어떤 문제가 생길까?
가장 큰 문제는 코드 중복이다. 똑같은 try-catch 블록을 모든 엔드포인트에 반복해서 작성해야 하고, 나중에 에러 메시지를 바꾸려면 수십 개의 파일을 일일히 수정해야 한다.
거기다 개발자들마다 예외를 처리하는 방식도 달라서 응답 형식도 제각각이 될 수 있다.
비즈니스 로직과 예외 처리 코드가 뒤섞인다는 문제도 있다.
컨트롤러나 서비스 코드를 익을 때 정작 중요한 비즈니스 로직은 안보이고 try-catch 블록만 가득한 경험도 많이 했다.
이제 기본적인 예외 아키텍처에 대해서 알아보자.
예외 처리 아키텍처
우리 프로젝트는 예외를 3단계 계층 구조로 설계했다.
레고 블록을 쌓듯이 작은 단위부터 큰 단위까지 체계적으로 구성했다.
첫 번째 층은 ErrorCode (enum)이다.
- 모든 에러 정보를 한 곳에서 관리한다. 에러 코드, HTTP 상태 코드, 메시지를 enum으로 정의해두면 중복을 방지하고 모든 에러를 한눈에 파악할 수 있다.
두 번째 층은 BusinessException과 InfrastructureException (추상 클래스)이다.
- 예외를 성격에 따라 두 가지로 분류한다.
- BusinessException은 "클라이언트가 잘못했다" (4xx), InfrastructureException은 "서버에 문제가 생겼다" (5xx)를 의미한다.
세 번째 층은 구체적인 예외 클래스들이다.
- 실제 비즈니스 요구사항에 맞는 예외들이다.
- CampaignNotFoundException같은 내가 만든 클래스들이 있다.
마지막으로 GlobalExceptionHandler이다.
- 발생한 모든 예외를 catch해서 적절한 ApiResponse로 변환한다.
- 개발자는 이 핸들러의 존재를 의식하지 않아도 자동으로 예외가 처리된다.
ErrorCode: 모든 에러 정보의 중심
ErrorCode enum은 에러의 카탈로그이다.
우리 시스템에서 발생할 수 있는 모든 에러를 한 곳에 정리해둔 것이다.
각각의 에러는 세 가지 정보를 가진다.
- HttpStatus: 실제 HTTP 응답 코드 (404, 409, 500 등)
- code: 프론트엔드가 사용할 커스텀 에러 코드 (CAMPAIGN_001, KAFKA_001 등)
- message: 사용자에게 보여줄 기본 메시지
이렇게 하면 에러 코드의 중복이 원천적으로 방지된다. enum은 같은 값을 중복으로 정의할 수 없기 때문.
두번 째로 에러 메시지를 일괄 변경할 수 있다.
내 프로젝트에서 "존재하지 않는 캠페인입니다."를 "캠페인을 찾을 수 없습니다."로 바꾸고 싶다면 이 파일에서 한 곳만 수정하면 된다.
셋째, 모든 에러를 한 눈에 파악할 수 있다. 새로 팀에 합류한 개발자가 우리 시스템에 어떤 에러들이 있는지 물어본다면 이 파일 하나만 보여주면 된다.
예외 계층 구조: Business vs Infrastructure
예외를 두 가지로 나누는 것은 단순히 분류를 위한 것이 아니다. 대응 방식이 다르기 때문이다.
BusinessException: 클라이언트가 고칠 수 있는 오류
BusinessException은 4xx 계열의 HTTP 상태 코드를 반환한다. 내가 뭔가를 잘못했다는 뜻이다.
예를 들어 사용자가 존재하지 않는 ID를 요청했다면 올바른 ID로 다시 요청하면 된다.
이런 예외는 로그를 warn 레벨로 남긴다.
시스템에 문제가 있는 건 아니니까 심각한 오류는 아니다.
InfrastructureException: 서버가 고쳐야 하는 오류
InfrastructureException은 5xx 계열이다. 서버에 뭔가 문제가 생겼다는 의미이다.
내 프로젝트에선 Kafka 브로커가 다운됐다거나 DB 연결이 끊어졌거나 외부 API가 응답하지 않는 등의 상황이다.
이런 예외는 로그를 error 레벨로 남기고 운영팀으로 알림을 보내야 한다. 사용자가 아무리 기다려도 해결되지 않기 때문이다.
우리 프로젝트에선 InfrastructureException에 requiresAlert() 메서드를 추가해서 이 예외가 발생하면 slack이나 이메일로 알림을 보낼 수 있도록 설계했다.
// BusinessException: warn 로그 + 사용자에게 정확한 메시지 전달
throw new CampaignNotFoundException(campaignId);
// → "캠페인을 찾을 수 없습니다. (ID: 999)"
// InfrastructureException: error 로그 + 운영팀 알림 + 사용자에게는 일반 메시지
throw new KafkaPublishException(topic, cause);
// → "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
사용자한테 보여주는 메시지도 다르다.
비즈니스 예외는 구체적인 내용을 알려줘도 되지만 인프라 예외는 내부 시스템 정보를 노출하지 않기 위해 일반적인 메시지를 반환한다.
구체적인 커스텀 예외 클래스
이제 실제 비즈니스 로직에서 사용할 예외를 만든다. 각 예외 클래스는 명확한 의도를 표현해야 한다.
예를 들어 throw new RuntimeException("캠페인을 찾을 수 없습니다")라고 쓰는 것과
throw new CampaignNotFoundException(campaignId)라고 쓰는 것 중에 어떤 게 명확할까?
후자 같은 경우엔 코드만 봐도 "아 캠페인이 없어서 예외가 발생했구나"라는 걸 바로 알 수 있따.
거기다가 IDE의 자동완성도 지원되고 컴파일 타임에 오타도 잡을 수 있다.
이 예외는 ErrorCode를 받아서 부모 클래스에 전달하고, 필요하면 추가 정보(여기선 캠페인id)를 포함한 커스텀 메시지를 만든다.
GlobalExceptionHandler: 모든 예외의 종착역
이제 핵심인 GlobalExceptionHandler를 살펴보자.
@RestControllerAdvice 어노테이션을 붙이면 이 클래스는 애플리케이션 전체의 예외를 가로챌 수 있다.
동작 원리
어떤 컨트롤러에서든 BusinessException이 throw되면 Spring이 자동으로 이 핸들러의 handleBusinessException 메서드를 호출한다.
개발자는 컨트롤러에 try-catch를 쓸 필요가 없다. 그냥 예외를 던지면 끝이다!
더 중요한 건 응답 형식이 통일된다는 점이다.
모든 예외가 ApiResponse 형태로 반환되니까 프론트엔드는 하나의 형식만 파싱하면 된다.
에러 처리 로직도 간단해진다.
실제로 어떻게 달라지는지 보자.
Before
@GetMapping("/{id}")
public ResponseEntity<?> getCampaign(@PathVariable Long id) {
try {
Campaign campaign = campaignService.findById(id);
return ResponseEntity.ok(ApiResponse.success(campaign));
} catch (NoSuchElementException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.fail("CAMPAIGN_001", "캠페인을 찾을 수 없습니다"));
} catch (Exception e) {
log.error("에러 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail("SERVER_001", "서버 오류"));
}
}
지금 딱 봐도 컨트롤러가 너무 복잡하다. 비즈니스 로직보다 예외 처리 코드가 더 많다.
모든 엔드포인트에 이따구로 try-catch를 반복하는건 말이 안된다.
After
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Campaign>> getCampaign(@PathVariable Long id) {
Campaign campaign = campaignService.findById(id);
return ResponseEntity.ok(ApiResponse.success(campaign));
}
엄청 간단해졌다. 예외 처리 코드가 하나도 없지만 동작은 완벽하다.
서비스에서 CampaignNotFoundException이 발생하면 GlobalExceptionHandler가 자동으로 처리하고, 클라이언트는 적절한 HTTP 404 응답을 받는다.
지금 보면 캠페인을 찾고, 참여를 저장한다는 로직이 보인다.
각 단계에서 문제가 생기면 적절한 예외를 던지고 나머지는 프레임워크가 알아서 처리한다.
이런 식으로 예외 처리를 체계적으로 설계하면 코드가 굉장히 깔끔해진다.
컨트롤러와 서비스는 비즈니스 로직에만 집중하고, try-catch 블록은 사라지고, 모든 에러는 일관된 형식으로 변환된다.
처음엔 좀 귀찮긴 한데 한 번 구조를 잡아두면 그 이후부턴 정말 편하다.
무엇보다 팀 전체가 같은 방식으로 예외를 처리한다는게 굉장히 마음이 편하다.
'Backend > Spring Boot' 카테고리의 다른 글
| Spring AOP의 프록시와 자기 호출(Self-Invocation) 문제 (1) | 2026.01.31 |
|---|---|
| Spring Boot API 응답 규격화: ResponseEntity와 공통 DTO 설계 (0) | 2025.12.28 |
| Spring Boot 4.0.1에서 Jackson이 깨진 이유 (1) | 2025.12.26 |
| Spring Boot REST API의 데이터 흐름 이해하기 (0) | 2025.10.12 |
| 직렬화/역직렬화 파헤치기: Jackson, HttpMessageConverter, DTO 패턴 (0) | 2025.10.07 |