
Service에서 예외가 발생하면, 해당 Service를 호출한 Controller까지 예외가 전파된다. 이때 서버는 성공 응답 대신, 상황에 맞는 HTTP 상태코드와 응답 바디(에러 응답)를 내려줘야 한다.
그런데 Controller마다 try-catch로 예외를 처리하고 에러 응답을 만들기 시작하면, 컨트롤러가 “요청 처리 + 예외 처리”를 모두 떠안게 되어 코드가 지저분해지고 가독성이 떨어질 수 있다. 그래서 예외 처리를 공통화하기 위해 @RestControllerAdvice(전역 예외 핸들러)를 도입한다.
@RestControllerAdvice에서는 @ExceptionHandler로 예외 타입별로 분기한 뒤, ResponseEntity<?> 형태로 상태코드 + 에러 바디를 일관되게 반환한다. 또한 전역 예외 핸들러는 Service/Controller에서 던진 예외뿐 아니라, @Valid 검증 실패처럼 스프링이 발생시키는 예외도 함께 처리하도록 핸들러를 정의할 수 있다.
프론트엔드(FE)에 일관된 에러 포맷/메시지를 제공하기 위해 CommonErrorDto 같은 공통 에러 응답 DTO를 만들어 바디로 내려주는 방식도 일반적으로 많이 사용한다.
예외 처리를 “전역으로 빼자”는 결론만 보면 뜬금없을 수 있어서, 먼저 어떤 상황에서 누가(어느 계층이) 예외를 터뜨리는 게 적절한지를 정리해두면 흐름이 명확해진다.

findByEmail(email)로 조회한 뒤,isPresent()로 존재 여부를 확인하고 IllegalArgumentException을 던짐.findById(id)로 조회한 뒤,orElseThrow()로 예외를 발생시키는 게 적합하다. NoSuchElementException@NotBlank, @Size 등 Validation 어노테이션 추가 + Controller에서 @Valid로 검증 트리거MethodArgumentNotValidException을 발생시킴컨트롤러마다 에러 응답이 제각각이면 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<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를 쓰는 식으로 선택할 수 있다.
@RestControllerAdvice는 모든 컨트롤러에서 던져진 예외를 가로채서 처리한다. 즉, Controller는 정상 흐름(성공 응답)에 집중하고, 실패 응답은 여기서 일괄적으로 책임진다.
@RestControllerAdvice
public class CommonExceptionHandler {
예: 이메일 중복, 잘못된 입력값 등 “요청이 정책상 허용되지 않는 경우”
@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);
}
throw new IllegalArgumentException("이미 가입된 이메일입니다.") 같은 예외를 던지면@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);
}
회원 상세 조회/삭제에서 “해당 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);
}
마지막은 예상하지 못한 예외에 대한 안전망이다. 구체적인 예외 핸들러가 먼저 실행되고, 나머지는 여기로 모인다.
@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()를 그대로 노출하지 않고, “일반화된 메시지 + 서버 로그에 상세 기록” 형태로 가는 경우도 많다.
전역 예외 핸들러를 도입하면 컨트롤러는 아래처럼 단순해진다.
200/201 + 정상 DTO/바디 반환즉, Controller는 더 깔끔해지고, 응답 정책은 한 곳에서 통일된다.