[Spring] Author API 실습: 전역 예외처리 및 에러 응답 포맷 세팅

이지연·2026년 1월 20일
post-thumbnail

Service에서 예외가 발생하면, 해당 Service를 호출한 Controller까지 예외가 전파된다. 이때 서버는 성공 응답 대신, 상황에 맞는 HTTP 상태코드와 응답 바디(에러 응답)를 내려줘야 한다.

그런데 Controller마다 try-catch로 예외를 처리하고 에러 응답을 만들기 시작하면, 컨트롤러가 “요청 처리 + 예외 처리”를 모두 떠안게 되어 코드가 지저분해지고 가독성이 떨어질 수 있다. 그래서 예외 처리를 공통화하기 위해 @RestControllerAdvice(전역 예외 핸들러)를 도입한다.

@RestControllerAdvice에서는 @ExceptionHandler로 예외 타입별로 분기한 뒤, ResponseEntity<?> 형태로 상태코드 + 에러 바디를 일관되게 반환한다. 또한 전역 예외 핸들러는 Service/Controller에서 던진 예외뿐 아니라, @Valid 검증 실패처럼 스프링이 발생시키는 예외도 함께 처리하도록 핸들러를 정의할 수 있다.

프론트엔드(FE)에 일관된 에러 포맷/메시지를 제공하기 위해 CommonErrorDto 같은 공통 에러 응답 DTO를 만들어 바디로 내려주는 방식도 일반적으로 많이 사용한다.


검증/예외가 필요한 상황 및 각 시나리오별 코드관점 정리

예외 처리를 “전역으로 빼자”는 결론만 보면 뜬금없을 수 있어서, 먼저 어떤 상황에서 누가(어느 계층이) 예외를 터뜨리는 게 적절한지를 정리해두면 흐름이 명확해진다.

회원가입: 이메일 중복

  • Repository에서 findByEmail(email)로 조회한 뒤,
  • 중복 값이 있으면(Service에서) 예외를 발생시키는 게 적합하다.
    • 구현 관점: isPresent()로 존재 여부를 확인하고 IllegalArgumentException을 던짐.
  • 참고: DB에 unique 제약을 걸어도 되지만, 이 경우 예외가 DB → Repository → Service → Controller까지 그대로 전파될 수 있다. 학습 단계에서는 “서비스 정책으로 중복을 먼저 체크하고 예외를 제어”하는 흐름이 이해하기 좋다.

회원삭제(또는 상세조회): id가 없을 때

  • Repository에서 findById(id)로 조회한 뒤,
  • 값이 없으면(Service에서) orElseThrow()로 예외를 발생시키는 게 적합하다.
    • 구현 관점: NoSuchElementException

비밀번호 길이/패턴/null 등 “입력값 검증”

  • 1차적으로는 FE에서 입력 단계에서 검증하는 게 사용자 경험(UX)상 유리하다.
  • 하지만 서버도 반드시 검증해야 하며(클라이언트는 믿을 수 없기 때문), 서버에서는 보통 Controller 계층에서 DTO Validation으로 처리한다.
    • 구현 관점: DTO에 @NotBlank, @Size 등 Validation 어노테이션 추가 + Controller에서 @Valid로 검증 트리거
    • 검증 실패 시 스프링이 MethodArgumentNotValidException을 발생시킴

공통 에러 응답 DTO: CommonErrorDto

컨트롤러마다 에러 응답이 제각각이면 FE에서 에러 처리 로직이 복잡해진다. 그래서 “에러 응답의 최소 규격”을 DTO로 통일한다.

@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class CommonErrorDto {
    private int status_code;
    private String error_message;
}
  • status_code: HTTP 상태코드를 바디에서도 확인할 수 있게 넣는 필드
  • error_message: 사용자/클라이언트에게 전달할 에러 메시지

확장하려면 timestamp, path, errorCode(업무코드) 등을 추가해도 좋다.


ResponseEntity<?>란?

ResponseEntity<T>는 Spring에서 HTTP 응답을 만들 때 사용하는 래퍼(Wrapper) 객체로, 상태코드(Status Code) + 헤더(Headers) + 바디(Body) 를 한 번에 담아 반환할 수 있다.

  • T는 응답 바디의 타입이다.
    • 예: ResponseEntity<AuthorDetailDto>, ResponseEntity<CommonErrorDto>
  • ResponseEntity<?>에서 ?는 “아직 바디 타입을 특정하지 않겠다”는 의미의 와일드카드로, 성공 시에는 DTO, 실패 시에는 에러 DTO처럼 상황에 따라 서로 다른 타입을 반환해야 할 때 편하게 쓸 수 있다.

전역 예외 핸들러에서는 예외 종류에 따라 상태코드가 달라져야 하므로, 아래처럼 ResponseEntity.status(...).body(...)로 상태코드와 바디를 명확히 제어하는 패턴이 자주 사용된다.

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(commonErrorDto);

반대로 단순 조회 API처럼 “항상 200 OK + 동일 타입 바디”만 반환한다면 ResponseEntity 없이 DTO만 반환해도 되고(스프링이 기본 200으로 처리), 상태코드/헤더를 명시적으로 제어하고 싶을 때 ResponseEntity를 쓰는 식으로 선택할 수 있다.


전역 예외 핸들러: CommonExceptionHandler

@RestControllerAdvice는 모든 컨트롤러에서 던져진 예외를 가로채서 처리한다. 즉, Controller는 정상 흐름(성공 응답)에 집중하고, 실패 응답은 여기서 일괄적으로 책임진다.

@RestControllerAdvice
public class CommonExceptionHandler {

1) 비즈니스 예외: IllegalArgumentException → 400

예: 이메일 중복, 잘못된 입력값 등 “요청이 정책상 허용되지 않는 경우”

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> illegalArgumentException(IllegalArgumentException e) {
    e.printStackTrace();
    CommonErrorDto dto = CommonErrorDto.builder()
            .status_code(400)
            .error_message(e.getMessage())
            .build();
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(dto);
}
  • Service에서 throw new IllegalArgumentException("이미 가입된 이메일입니다.") 같은 예외를 던지면
  • Controller에서 잡지 않아도
  • 전역 핸들러가 받아서 400 + 에러바디로 변환해준다.

2) 검증 예외: MethodArgumentNotValidException → 400

@RequestBody @Valid에서 DTO 검증이 실패하면 스프링이 MethodArgumentNotValidException을 발생시킨다. 이런 “프레임워크가 발생시키는 예외”도 전역 예외 핸들러에서 같이 처리할 수 있다.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> methodArgumentNotValidException(MethodArgumentNotValidException e) {
    e.printStackTrace();
    CommonErrorDto dto = CommonErrorDto.builder()
            .status_code(400)
            .error_message(e.getFieldError().getDefaultMessage())
            .build();
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(dto);
}
  • FE는 어떤 컨트롤러에서 실패했는지와 상관없이 항상 같은 포맷의 에러를 받는다.
  • 현재 코드는 “첫 번째 필드 에러 메시지”만 내려주는 단순 버전이라 학습/실습 단계에 적합하다.

3) 리소스 없음: NoSuchElementException → 404 (권장)

회원 상세 조회/삭제에서 “해당 id가 없음” 같은 케이스는 Service에서 NoSuchElementException을 던지는 흐름이 자연스럽다.

@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<?> noSuchElementException(NoSuchElementException e) {
    e.printStackTrace();
    CommonErrorDto dto = CommonErrorDto.builder()
            .status_code(404)
            .error_message(e.getMessage())
            .build();
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(dto);
}

4) 그 외 모든 예외: Exception → 500

마지막은 예상하지 못한 예외에 대한 안전망이다. 구체적인 예외 핸들러가 먼저 실행되고, 나머지는 여기로 모인다.

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public CommonErrorDto exception(Exception e) {
    e.printStackTrace();
    return CommonErrorDto.builder()
            .status_code(500)
            .error_message(e.getMessage())
            .build();
}

실무에서는 보안/운영 관점에서 e.getMessage()를 그대로 노출하지 않고, “일반화된 메시지 + 서버 로그에 상세 기록” 형태로 가는 경우도 많다.


결과: Controller가 “성공 흐름”만 남는다

전역 예외 핸들러를 도입하면 컨트롤러는 아래처럼 단순해진다.

  • 성공 시: 200/201 + 정상 DTO/바디 반환
  • 실패 시: 예외를 던지면 전역 핸들러가 상태코드/에러바디를 공통 포맷으로 만들어 반환

즉, Controller는 더 깔끔해지고, 응답 정책은 한 곳에서 통일된다.

profile
Eazy하게

0개의 댓글