이전 글에서 Validation 실패가 전역에서 처리되는 구조를 봤다.
그럼 자연스럽게 다음 질문이 나온다.
- “검증 말고, 서비스 로직에서 터지는 에러는?”
- “없는 리소스를 조회하면?”
- “예외마다 응답 형태가 달라지면 어떻게 하지?”
그래서 이번 단계에서는 👉 컨트롤러/서비스에서 발생한 예외를 한 곳으로 모아, 응답 포맷을 통일하는 구조를 만든다.
0. 전체 흐름
이 글의 핵심은 이 흐름이다.
클라이언트 요청
↓
Controller (요청 매핑, 서비스 호출)
↓
Service (비즈니스 판단)
↓
예외 발생 (throw)
↓
DispatcherServlet
↓
@RestControllerAdvice + @ExceptionHandler
↓
JSON 에러 응답 (400 / 404 / 500)
컨트롤러와 서비스는 예외를 “처리하지 않는다”.
예외는 위로 던지고, 응답으로 바꾸는 책임은 전역 예외 처리기가 가진다.
1. 이 글에서 해결하려는 문제
컨트롤러/서비스 곳곳에서 예외는 언제든 발생할 수 있다.
- 잘못된 요청 → 400
- 없는 데이터 → 404
- 예상 못 한 오류 → 500
아무 구조 없이 두면:
- 응답 포맷이 제각각
- 어떤 API는 message 있고, 어떤 API는 없고
- 프론트/클라이언트에서 공통 처리하기 어려움
어떤 예외가 터지든, 에러 응답은 항상 같은 JSON 구조로 내려보내기
2. 기본 개념: 전역 예외 처리기
@ControllerAdvice / @RestControllerAdvice
- 모든 컨트롤러에서 발생한 예외를 가로채는 클래스
- 컨트롤러마다 try-catch를 쓰지 않기 위한 장치
@RestControllerAdvice
public class GlobalExceptionHandler {
}
- @RestControllerAdvice
- @ControllerAdvice + @ResponseBody
- 에러 응답도 JSON으로 내려보내기 위함
@ExceptionHandler
- 특정 예외 타입을 잡아서
- HTTP 응답으로 변환하는 메서드
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> handleIllegalArgument(...) { }
3. 에러 응답 형태부터 고정
먼저 공통 에러 응답 DTO를 정의한다.
public record ErrorResponse(
int status,
String message
) {}
지금 단계에서는 단순하게:
- status: HTTP 상태 코드
- message: 에러 설명
(필드 에러 목록, 에러 코드 등은 다음 단계에서 확장)
4. 전역 예외 처리 클래스 구현
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(
IllegalArgumentException ex
) {
ErrorResponse body = new ErrorResponse(
400,
ex.getMessage()
);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleServerError(
Exception ex
) {
ErrorResponse body = new ErrorResponse(
500,
"서버 내부 오류가 발생했습니다."
);
return ResponseEntity.status(500).body(body);
}
}
여기서 핵심
- 컨트롤러/서비스는 예외를 잡지 않는다
- 예외를 HTTP 응답으로 바꾸는 책임은 전부 여기 있다
- Exception.class는 최종 안전망
5. 서비스 → 예외 → 전역 처리 흐름 예시
5-1. 커스텀 예외 정의 (404 용도)
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}
5-2. 서비스에서 예외를 “던진다”
@Service
public class ShiftService {
public Shift getById(Long id) {
return repository.findById(id)
.orElseThrow(() ->
new NotFoundException("해당 근무 기록이 없습니다.")
);
}
}
여기서 서비스는:
- HTTP 상태 코드 모름
- JSON 응답 구조 모름
- 비즈니스 의미만 표현 → “이 데이터는 존재하지 않는다”
5-3. 컨트롤러는 예외를 처리하지 않는다
@GetMapping("/{id}")
public ShiftResponse get(@PathVariable Long id) {
return service.getById(id); // 예외 나면 여기서 멈춤
}
- try-catch 없음
- 예외는 그대로 위로 전달됨
5-4. 전역 예외 처리기가 응답으로 변환
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
NotFoundException ex
) {
ErrorResponse body = new ErrorResponse(
404,
ex.getMessage()
);
return ResponseEntity.status(404).body(body);
}
6. 실제 요청 → 응답 흐름 요약
- 클라이언트가 /api/shifts/10 요청
- 컨트롤러 → 서비스 호출
- 서비스에서 NotFoundException 발생
- 컨트롤러는 잡지 않고 예외 전파
- DispatcherServlet이 예외 감지
- @RestControllerAdvice에서 해당 예외 매칭
- JSON 에러 응답 생성 후 반환
7. Validation과 Exception의 관계
- Validation 실패
→ 전역 예외 처리에서 400 변환
→ MethodArgumentNotValidException - 비즈니스 오류
→ 커스텀 예외 (NotFoundException 등) - 예상 못 한 오류
→ Exception.class → 500
👉 모든 에러가 하나의 전역 처리 파이프라인으로 모인다
8. 요약 3줄
- 컨트롤러/서비스는 예외를 처리하지 않는다
- 예외는 위로 던지고, 전역에서 HTTP 응답으로 바꾼다
- 그래서 에러 응답 구조를 한 곳에서 통제할 수 있다
'백엔드' 카테고리의 다른 글
| [스프링 로드맵] F-1. 테스트란 (0) | 2026.01.20 |
|---|---|
| [스프링 로드맵] E-2. 전역 예외 처리 - 에러 코드와 로그 기준 (0) | 2026.01.20 |
| [스프링 로드맵] D-2. Validation 응답 설계 - BindingResult vs 예외 처리 (0) | 2026.01.20 |
| [스프링 로드맵] D-1. Validation 기본 - DTO 검증이 필요한 이유 (0) | 2026.01.20 |
| [스프링 로드맵] C-3. Repository 실전 - JpaRepository가 해주는 것들 (1) | 2026.01.19 |