2026.03.18

Spring Boot 예외 처리, 왜 이렇게까지 해야 할까?

Spring Boot로 백엔드 개발을 진행하면서 가장 먼저 느낀 문제는

API마다 에러 응답 형태가 제각각이라는 점이었다.

예를 들자면,

  • 어떤 API는 "error"
  • 어떤 API는 "잘못된 요청입니다"
  • 어떤 API는 HTTP 상태코드만 내려줌

이게 처음에는 별 문제 없어 보였는데,

프로젝트를 조금 진행하다 보니까 바로 문제가 터졌다.

  • 프론트엔드 개발자가 에러 상황을 설명하기 어려워했고
  • 결국 “이 API는 왜 이런 응답이 와요?” 같은 대화가 계속 생겼다
  • 나도 에러 원인을 보려면 해당 API 코드를 직접 들어가서 다 확인해야 했다

그래서!

일관된 예외 처리와 공통 응답 구조가 필요하다고 판단했다.


기존 방식의 한계

처음에는 그냥 간단하게 try-catch로 처리했다.

try {
    // 비즈니스 로직
} catch (Exceptione) {
    return"에러 발생";
}

근데 이게 진짜 빠르게 한계를 드러냈다.

  • 예외가 발생할 때마다 try-catch를 계속 써야 하고
  • 코드가 점점 중복되고
  • 응답 형태도 계속 달라지고
  • HTTP 상태코드도 제대로 활용 못 했다

무엇보다 제일 큰 문제는

코드가 너무 지저분해졌다.

try-catch가 여기저기 퍼지니까

나중에는 내가 짠 코드인데도 한눈에 안 들어왔다…😩


해결 전략

그래서 “이걸 어떻게 정리해야 하지?” 고민하다가

다음과 같은 방향으로 구조를 잡아야겠다고 생각했다.

  • 예외 처리는 한 곳에서 모아서 처리하자 (중앙 집중화)
  • 응답 형태는 무조건 통일하자
  • 비즈니스 로직에서는 예외를 “던지기만” 하자
  • HTTP 상태코드는 명확하게 쓰자

즉, “로직과 예외 처리를 분리하자” 였다.


내가 구성한 방식

나는 그래서 다음과 같이 정리하기로 했다.

  • ErrorCode : 에러 정의 (상태코드 + 메시지 + 에러 코드)
  • CustomException : 비즈니스 예외
  • GlobalExceptionHandler : 예외를 한 번에 처리
  • ExceptionResponseDto : 응답 형식 통일

1. ErrorCode – 에러를 “정의”해서 사용하기

기존에는 단순 문자열로 에러를 처리했지만,

지금은 에러를 Enum으로 관리하도록 바꿨다.

→ 단순 메시지가 아니라

  • HTTP 상태코드
  • 에러 코드 (ex. USER_001)
  • 메시지

까지 함께 묶어서 관리한다.

USER_NOT_FOUND(404,"USER_001","유저를 찾을 수 없습니다.")

이렇게 바꾸니까 확실히 달라진 점이 있었다.

  • 에러가 어디서 어떻게 정의되는지 한눈에 보이고
  • 프론트에서도 code 기준으로 분기 처리가 가능해졌고
  • 무엇보다 “에러를 그냥 문자열로 던지지 않게” 된다

👉→에러도 하나의 데이터처럼 관리하게 된 느낌이었다.


2. CustomException – 예외에 의미를 담기

서비스 로직에서는 이제 단순 Exception이 아니라

의미 있는 예외를 던지도록 했다.

throw new CustomException(ErrorCode.USER_NOT_FOUND);

이 방식이 좋았던 이유는 하나였다.

코드만 봐도 어떤 상황인지 바로 이해된다.

굳이 메시지를 보지 않아도

“아, 유저 못 찾은 상황이구나”가 바로 읽힌다.


3. GlobalExceptionHandler – 예외는 한 곳에서 처리

예외는 컨트롤러에서 처리하지 않고

전역에서 한 번에 처리하도록 구성했다.

@ExceptionHandler(CustomException.class)
public ResponseEntity<ExceptionResponseDto> handleCustomException(CustomExceptione)

여기서 핵심은

예외를 “처리”하는 곳을 하나로 모았다는 것

이렇게 하니까

  • 컨트롤러에서 try-catch가 거의 사라졌고
  • 응답 형태가 완전히 통일되었고
  • 예외 흐름을 한 곳에서 관리할 수 있게 됐다

추가로

  • RuntimeException
  • Validation(@Valid) 에러까지 같이 처리하면서

👉 “예외 처리의 끝은 여기다” 라는 느낌으로 만들었다.


4. 응답 구조 – 프론트와 약속 맞추기

응답은 다음과 같은 구조로 통일했다.

{
  "status":400,
  "message":"잘못된 요청입니다.",
  "code":"POST_003",
  "field":"title"
}

여기서 중요하게 생각한 건 두 가지였다.

  • 프론트가 일관되게 처리할 수 있어야 한다
  • 에러 원인을 추적할 수 있어야 한다

그래서

  • code → 프론트 분기용
  • field → validation 위치 확인용

까지 포함시켰다.

→ 실제로 validation 에러까지 field로 내려주니까

프론트에서 UX 처리하기 훨씬 좋아졌다.


적용하고 나서 느낀 점

이 구조를 적용하고 나서 가장 크게 느낀 건

“예외 처리는 기능이 아니라 설계다” 라는 점이었다.

처음에는 단순히 에러를 잘 내려주기 위한 작업이라고 생각했는데,

막상 적용해보니까

  • 코드가 훨씬 읽기 쉬워졌고
  • 협업이 편해졌고
  • 유지보수 난이도가 확 내려갔다

정리

내가 작성한 내용이 정답이라고는 생각하지 않는다.

하지만 더 많은 코드들을 보면서 직접 적용해보면 직관적이고 유지보수하기 좋은 개발을 할 수 있게 되지 않을까라는 생각이 들었다.

예외 처리는 나중에 붙이는게 아니라 처음부터 잘 설계하는 것이 프로젝트 진행시 더 빠르게 유지보수가 가능할 것이라는 생각이 들었다.