Spring 예외 처리 완벽 가이드: @ExceptionHandler, @ControllerAdvice, 공통 에러 응답까지

Spring 예외 처리 완벽 가이드: @ExceptionHandler, @ControllerAdvice, 공통 에러 응답까지

“예외 처리를 어떻게 설계하세요?”는 Spring 면접에서 자주 나오는 질문이다. 단순히 try-catch로 잡는 것이 아니라, 어디서 어떤 예외를 던지고, 어디서 잡아서, 클라이언트에게 어떤 형식으로 응답할 것인가 — 이것이 예외 처리 설계다. 이 글은 Spring MVC에서 예외가 전파되는 흐름부터 실무에서 바로 쓸 수 있는 공통 에러 응답 구조까지 모두 다룬다.


1. 왜 예외 처리를 따로 설계해야 하는가

1.1 설계 없이 예외를 처리하면 생기는 문제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ❌ 문제 1: Controller마다 중복되는 try-catch
@RestController
public class UserController {
    @GetMapping("/api/users/{id}")
    public ResponseEntity<?> getUser(@PathVariable Long id) {
        try {
            return ResponseEntity.ok(userService.findById(id));
        } catch (UserNotFoundException e) {
            return ResponseEntity.status(404).body(Map.of("message", "사용자 없음"));
        } catch (Exception e) {
            return ResponseEntity.status(500).body(Map.of("message", "서버 에러"));
        }
    }
}

@RestController
public class OrderController {
    @GetMapping("/api/orders/{id}")
    public ResponseEntity<?> getOrder(@PathVariable Long id) {
        try {
            return ResponseEntity.ok(orderService.findById(id));
        } catch (OrderNotFoundException e) {
            return ResponseEntity.status(404).body(Map.of("message", "주문 없음"));
            // → 응답 형식이 UserController와 다를 수 있음!
        } catch (Exception e) {
            return ResponseEntity.status(500).body(Map.of("message", "서버 에러"));
        }
    }
}
1
2
3
4
5
문제점:
● 모든 Controller에 try-catch 중복
● 에러 응답 형식이 Controller마다 제각각
● 새로운 예외 유형이 추가되면 모든 Controller 수정
● Controller 본연의 역할(요청-응답 매핑)이 예외 처리에 가려짐

1.2 예외 처리 설계의 목표

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────────────────────────────────────────────┐
│                    예외 처리 설계 목표                                │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. Controller는 비즈니스 로직에만 집중                              │
│     → try-catch 없이 깔끔한 코드                                   │
│                                                                     │
│  2. 예외 처리 로직은 한 곳에 모음                                    │
│     → @ControllerAdvice로 전역 처리                                │
│                                                                     │
│  3. 에러 응답 형식은 통일                                            │
│     → 모든 API가 같은 구조의 에러 JSON 반환                        │
│                                                                     │
│  4. HTTP 상태 코드는 의미에 맞게                                    │
│     → 404는 리소스 없음, 400은 잘못된 요청, 500은 서버 에러        │
│                                                                     │
│  5. 로그는 적절한 레벨로                                            │
│     → 비즈니스 예외는 WARN, 시스템 예외는 ERROR                    │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

2. Spring MVC에서 예외가 전파되는 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌─────────────────────────────────────────────────────────────────────┐
│                    예외 전파 흐름                                     │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Repository                                                         │
│    │ DataAccessException (Spring이 자동 변환)                       │
│    ▼                                                                │
│  Service                                                            │
│    │ throw BusinessException (비즈니스 규칙 위반)                   │
│    ▼                                                                │
│  Controller ← 예외가 여기까지 전파됨 (try-catch 없으면)            │
│    │                                                                │
│    ▼                                                                │
│  DispatcherServlet                                                  │
│    │                                                                │
│    ├─→ ① 해당 Controller의 @ExceptionHandler 검색                  │
│    │     (있으면 실행)                                              │
│    │                                                                │
│    ├─→ ② @ControllerAdvice의 @ExceptionHandler 검색                │
│    │     (있으면 실행)                                              │
│    │                                                                │
│    └─→ ③ 둘 다 없으면 Spring 기본 에러 처리 (/error)              │
│          → Whitelabel Error Page 또는 기본 JSON                    │
│                                                                     │
│  우선순위: Controller 내 핸들러 > ControllerAdvice 핸들러           │
│  같은 레벨에서: 구체적 예외 > 상위 예외                             │
│    (UserNotFoundException > BusinessException > Exception)          │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

핵심은 Controller에서 예외를 잡지 않으면 DispatcherServlet이 적절한 핸들러를 찾아서 처리한다는 것이다. 개발자는 핸들러만 잘 등록하면 된다.


3. @ExceptionHandler 기본 사용법

3.1 Controller 레벨

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping("/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        return userService.findById(id); // 예외가 발생하면 아래 핸들러로
    }

    // 이 Controller 안에서 발생한 UserNotFoundException만 처리
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("USER_NOT_FOUND", e.getMessage()));
    }
}

문제점: OrderController에서도 같은 예외를 처리하려면 핸들러를 복사해야 한다.

3.2 여러 예외를 한 핸들러로

1
2
3
4
5
6
@ExceptionHandler({UserNotFoundException.class, OrderNotFoundException.class})
public ResponseEntity<ErrorResponse> handleNotFound(RuntimeException e) {
    return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", e.getMessage()));
}

4. @RestControllerAdvice와 전역 예외 처리

4.1 @ControllerAdvice vs @RestControllerAdvice

1
2
3
4
5
6
7
8
// @ControllerAdvice: 뷰(HTML)를 반환하는 경우
// @RestControllerAdvice: JSON을 반환하는 경우 (= @ControllerAdvice + @ResponseBody)

// REST API 서버라면 @RestControllerAdvice를 사용
@RestControllerAdvice
public class GlobalExceptionHandler {
    // 모든 Controller에서 발생하는 예외를 여기서 처리
}

4.2 전역 예외 핸들러 전체 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 1. 비즈니스 예외 — 우리가 의도적으로 던진 예외
     */
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        log.warn("[{}] {}", e.getErrorCode(), e.getMessage());
        return ResponseEntity
                .status(e.getErrorCode().getStatus())
                .body(ErrorResponse.of(e.getErrorCode(), e.getMessage()));
    }

    /**
     * 2. @Valid 검증 실패 — @RequestBody 바인딩 에러
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException e) {
        log.warn("Validation failed: {}", e.getMessage());
        List<FieldErrorDetail> fieldErrors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> new FieldErrorDetail(
                        error.getField(),
                        error.getRejectedValue(),
                        error.getDefaultMessage()))
                .toList();

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponse.ofValidation(fieldErrors));
    }

    /**
     * 3. @RequestParam, @PathVariable 타입 불일치
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException e) {
        String message = String.format("'%s' 파라미터의 값 '%s'이(가) 올바르지 않습니다",
                e.getName(), e.getValue());
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponse.of("TYPE_MISMATCH", message));
    }

    /**
     * 4. 지원하지 않는 HTTP 메서드
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<ErrorResponse> handleMethodNotAllowed(
            HttpRequestMethodNotSupportedException e) {
        return ResponseEntity
                .status(HttpStatus.METHOD_NOT_ALLOWED)
                .body(ErrorResponse.of("METHOD_NOT_ALLOWED", e.getMessage()));
    }

    /**
     * 5. 요청 본문을 읽을 수 없는 경우 (잘못된 JSON 등)
     */
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleNotReadable(
            HttpMessageNotReadableException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponse.of("INVALID_REQUEST_BODY",
                        "요청 본문을 읽을 수 없습니다. JSON 형식을 확인해주세요"));
    }

    /**
     * 6. 필수 파라미터 누락
     */
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResponseEntity<ErrorResponse> handleMissingParam(
            MissingServletRequestParameterException e) {
        String message = String.format("필수 파라미터 '%s'이(가) 누락되었습니다", e.getParameterName());
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponse.of("MISSING_PARAMETER", message));
    }

    /**
     * 7. 최후의 방어선 — 예상하지 못한 예외
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        log.error("Unexpected exception occurred", e); // 스택 트레이스 전체 로깅
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ErrorResponse.of("INTERNAL_ERROR",
                        "서버 내부 오류가 발생했습니다"));
        // ★ 클라이언트에게 절대 내부 정보 노출하지 않음!
    }
}

5. MethodArgumentNotValidException 처리와 Validation 에러 응답

5.1 요청 DTO에 검증 어노테이션 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Getter
@NoArgsConstructor
public class UserCreateRequest {

    @NotBlank(message = "이름은 필수입니다")
    @Size(min = 2, max = 20, message = "이름은 2~20자여야 합니다")
    private String name;

    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "이메일 형식이 올바르지 않습니다")
    private String email;

    @NotNull(message = "나이는 필수입니다")
    @Min(value = 1, message = "나이는 1 이상이어야 합니다")
    @Max(value = 150, message = "나이는 150 이하여야 합니다")
    private Integer age;

    @NotBlank(message = "비밀번호는 필수입니다")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$",
             message = "비밀번호는 영문+숫자 조합 8자 이상이어야 합니다")
    private String password;
}
1
2
3
4
5
6
// Controller에서 @Valid를 붙이면 검증 자동 실행
@PostMapping("/api/users")
public UserResponse createUser(@RequestBody @Valid UserCreateRequest request) {
    return userService.create(request);
    // 검증 실패 시 MethodArgumentNotValidException이 자동으로 던져짐
}

5.2 검증 에러 응답 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 프론트엔드가 어떤 필드에 어떤 에러가 있는지 바로   있는 구조
{
    "code": "VALIDATION_ERROR",
    "message": "입력값 검증에 실패했습니다",
    "fieldErrors": [
        {
            "field": "email",
            "rejectedValue": "invalid-email",
            "message": "이메일 형식이 올바르지 않습니다"
        },
        {
            "field": "age",
            "rejectedValue": -1,
            "message": "나이는 1 이상이어야 합니다"
        }
    ]
}
1
2
3
4
5
6
7
@Getter
@AllArgsConstructor
public class FieldErrorDetail {
    private String field;
    private Object rejectedValue;
    private String message;
}

5.3 @Validated와 그룹 검증

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 생성 시에는 비밀번호 필수, 수정 시에는 선택적으로 하고 싶다면?
public interface CreateGroup {}
public interface UpdateGroup {}

public class UserRequest {
    @NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
    private String name;

    @NotBlank(groups = CreateGroup.class) // 생성 시에만 필수
    private String password;
}

@PostMapping("/api/users")
public UserResponse create(@RequestBody @Validated(CreateGroup.class) UserRequest request) {
    // ...
}

@PutMapping("/api/users/{id}")
public UserResponse update(@RequestBody @Validated(UpdateGroup.class) UserRequest request) {
    // ...
}

6. 비즈니스 예외 vs 시스템 예외 구분

6.1 두 가지 예외의 성격

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────────────────────────────────────────┐
│            비즈니스 예외 vs 시스템 예외                               │
├──────────────────────┬──────────────────────────────────────────────┤
│   비즈니스 예외       │   시스템 예외                                │
│   (Business)         │   (System/Infra)                             │
├──────────────────────┼──────────────────────────────────────────────┤
│ 예상된 실패 상황      │ 예상치 못한 장애                             │
│ 개발자가 의도적 throw │ 프레임워크/인프라에서 발생                    │
│                      │                                              │
│ "사용자 없음"         │ DB 연결 실패                                 │
│ "잔액 부족"          │ NullPointerException                         │
│ "이미 처리된 주문"    │ 외부 API 타임아웃                            │
│ "중복 이메일"         │ 디스크 가득 참                               │
│                      │                                              │
│ 로그: WARN           │ 로그: ERROR + 스택트레이스                   │
│ 응답: 구체적 메시지   │ 응답: "서버 오류가 발생했습니다"             │
│ 상태: 4xx            │ 상태: 500                                    │
│                      │                                              │
│ 클라이언트가 대응 가능│ 클라이언트가 대응 불가                       │
│ (입력 수정, 재시도)   │ (운영팀이 대응)                              │
└──────────────────────┴──────────────────────────────────────────────┘

6.2 예외 클래스 설계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ErrorCode: 모든 에러 코드를 한 곳에서 관리
@Getter
@AllArgsConstructor
public enum ErrorCode {

    // 400 Bad Request
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력값입니다"),

    // 401 Unauthorized
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다"),
    INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다"),
    EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다"),

    // 403 Forbidden
    ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 없습니다"),

    // 404 Not Found
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다"),
    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "주문을 찾을 수 없습니다"),
    PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "상품을 찾을 수 없습니다"),

    // 409 Conflict
    DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다"),
    ORDER_ALREADY_CANCELLED(HttpStatus.CONFLICT, "이미 취소된 주문입니다"),
    INSUFFICIENT_STOCK(HttpStatus.CONFLICT, "재고가 부족합니다"),

    // 500 Internal Server Error
    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다");

    private final HttpStatus status;
    private final String message;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 비즈니스 예외 (최상위)
@Getter
public class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public BusinessException(ErrorCode errorCode, String customMessage) {
        super(customMessage);
        this.errorCode = errorCode;
    }
}

// 엔티티를 찾을 수 없을 때
public class EntityNotFoundException extends BusinessException {
    public EntityNotFoundException(ErrorCode errorCode) {
        super(errorCode);
    }
}

// 중복 데이터
public class DuplicateException extends BusinessException {
    public DuplicateException(ErrorCode errorCode) {
        super(errorCode);
    }
}

// 비즈니스 규칙 위반
public class BusinessRuleException extends BusinessException {
    public BusinessRuleException(ErrorCode errorCode) {
        super(errorCode);
    }
}

6.3 Service에서 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final UserRepository userRepository;
    private final ProductRepository productRepository;

    @Transactional
    public OrderResponse createOrder(Long userId, OrderCreateRequest request) {
        // 404: 사용자 없음
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND));

        // 404: 상품 없음
        Product product = productRepository.findById(request.getProductId())
                .orElseThrow(() -> new EntityNotFoundException(ErrorCode.PRODUCT_NOT_FOUND));

        // 409: 재고 부족
        if (product.getStock() < request.getQuantity()) {
            throw new BusinessRuleException(ErrorCode.INSUFFICIENT_STOCK);
        }

        product.decreaseStock(request.getQuantity());
        Order order = Order.create(user, product, request.getQuantity());
        return OrderResponse.from(orderRepository.save(order));
    }

    @Transactional
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new EntityNotFoundException(ErrorCode.ORDER_NOT_FOUND));

        // 409: 이미 취소된 주문
        if (order.isCancelled()) {
            throw new BusinessRuleException(ErrorCode.ORDER_ALREADY_CANCELLED);
        }

        order.cancel();
    }
}

7. 공통 에러 응답 포맷 설계

7.1 응답 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Getter
public class ErrorResponse {

    private final String code;
    private final String message;
    private final List<FieldErrorDetail> fieldErrors;

    private ErrorResponse(String code, String message, List<FieldErrorDetail> fieldErrors) {
        this.code = code;
        this.message = message;
        this.fieldErrors = fieldErrors;
    }

    // 일반 에러
    public static ErrorResponse of(ErrorCode errorCode) {
        return new ErrorResponse(errorCode.name(), errorCode.getMessage(), null);
    }

    // 커스텀 메시지 에러
    public static ErrorResponse of(ErrorCode errorCode, String message) {
        return new ErrorResponse(errorCode.name(), message, null);
    }

    // 문자열 코드 에러
    public static ErrorResponse of(String code, String message) {
        return new ErrorResponse(code, message, null);
    }

    // Validation 에러
    public static ErrorResponse ofValidation(List<FieldErrorDetail> fieldErrors) {
        return new ErrorResponse("VALIDATION_ERROR", "입력값 검증에 실패했습니다", fieldErrors);
    }
}

7.2 성공 응답과 에러 응답 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//  성공 응답 (200 OK)
{
    "id": 1,
    "name": "홍길동",
    "email": "hong@example.com"
}

//  에러 응답 (404 Not Found)
{
    "code": "USER_NOT_FOUND",
    "message": "사용자를 찾을 수 없습니다",
    "fieldErrors": null
}

//  에러 응답 (400 Bad Request  Validation)
{
    "code": "VALIDATION_ERROR",
    "message": "입력값 검증에 실패했습니다",
    "fieldErrors": [
        {
            "field": "email",
            "rejectedValue": "not-email",
            "message": "이메일 형식이 올바르지 않습니다"
        }
    ]
}

//  에러 응답 (500 Internal Server Error)
{
    "code": "INTERNAL_ERROR",
    "message": "서버 내부 오류가 발생했습니다",
    "fieldErrors": null
}

7.3 프론트엔드 처리 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 프론트엔드에서 에러 응답 처리 (Axios 예시)
try {
    const response = await axios.post('/api/users', userData);
} catch (error) {
    const { code, message, fieldErrors } = error.response.data;

    if (code === 'VALIDATION_ERROR') {
        // 필드별 에러 메시지 표시
        fieldErrors.forEach(err => {
            showFieldError(err.field, err.message);
        });
    } else {
        // 일반 에러 메시지 표시
        showToast(message);
    }
}

이처럼 응답 구조가 통일되어 있으면 프론트엔드에서 에러를 일관되게 처리할 수 있다.


8. HTTP 상태 코드를 어떻게 나눌까

8.1 실무에서 자주 쓰는 상태 코드 매핑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌─────────────────────────────────────────────────────────────────────┐
│                HTTP 상태 코드 매핑 가이드                             │
├──────────┬──────────────┬───────────────────────────────────────────┤
│ 상태코드 │ 의미          │ 사용 상황                                 │
├──────────┼──────────────┼───────────────────────────────────────────┤
│ 200      │ OK           │ 조회, 수정 성공                           │
│ 201      │ Created      │ 리소스 생성 성공 (POST)                   │
│ 204      │ No Content   │ 삭제 성공 (응답 Body 없음)                │
├──────────┼──────────────┼───────────────────────────────────────────┤
│ 400      │ Bad Request  │ 요청 데이터 오류, Validation 실패         │
│          │              │ JSON 파싱 실패, 필수 파라미터 누락        │
├──────────┼──────────────┼───────────────────────────────────────────┤
│ 401      │ Unauthorized │ 인증 실패 (로그인 안 함, 토큰 만료)       │
├──────────┼──────────────┼───────────────────────────────────────────┤
│ 403      │ Forbidden    │ 인가 실패 (권한 없음, 관리자 전용)        │
│          │              │ "로그인은 했지만 권한이 부족"              │
├──────────┼──────────────┼───────────────────────────────────────────┤
│ 404      │ Not Found    │ 요청한 리소스 없음                        │
│          │              │ 존재하지 않는 사용자/주문/상품             │
├──────────┼──────────────┼───────────────────────────────────────────┤
│ 409      │ Conflict     │ 리소스 상태 충돌                          │
│          │              │ 중복 이메일, 이미 처리된 주문, 재고 부족  │
├──────────┼──────────────┼───────────────────────────────────────────┤
│ 500      │ Internal     │ 서버 내부 오류 (예상 못한 예외)           │
│          │ Server Error │ NPE, DB 연결 실패 등                     │
└──────────┴──────────────┴───────────────────────────────────────────┘

8.2 401 vs 403 구분

1
2
3
4
5
6
7
401 Unauthorized (인증 실패):
  "너 누구야? 로그인부터 해."
  → 로그인 안 함, 토큰 없음, 토큰 만료, 토큰 위조

403 Forbidden (인가 실패):
  "너 누군지는 알겠는데, 이건 권한이 없어."
  → 일반 사용자가 관리자 API 접근, 다른 사용자 정보 수정 시도

8.3 400 vs 409 구분

1
2
3
4
5
6
7
400 Bad Request (잘못된 요청):
  요청 형식 자체가 잘못됨
  → Validation 실패, JSON 파싱 에러, 필수 파라미터 누락, 타입 불일치

409 Conflict (상태 충돌):
  요청 형식은 맞지만 현재 서버 상태와 충돌
  → 이미 존재하는 이메일로 가입, 이미 취소된 주문 취소, 재고 부족

9. 예외를 Controller/Service 중 어디서 처리해야 하는가

9.1 결론부터

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌─────────────────────────────────────────────────────────────────────┐
│                계층별 예외 처리 책임                                  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Controller                                                         │
│  ├── 예외를 직접 처리하지 않음 ★                                    │
│  ├── @ControllerAdvice에 위임                                       │
│  ├── 입력 검증(@Valid)만 트리거                                     │
│  └── try-catch 쓰지 않음                                           │
│                                                                     │
│  Service                                                            │
│  ├── 비즈니스 규칙 위반 시 예외를 throw ★                           │
│  ├── 어떤 예외를 던질지는 Service가 결정                            │
│  ├── catch하는 곳이 아니라 throw하는 곳                             │
│  └── 외부 서비스 호출 실패 → 비즈니스 예외로 변환                   │
│                                                                     │
│  Repository                                                         │
│  ├── Spring이 DataAccessException으로 자동 변환                     │
│  └── 직접 예외를 다룰 일이 거의 없음                                │
│                                                                     │
│  @ControllerAdvice                                                  │
│  ├── 모든 예외를 최종적으로 처리하는 곳 ★                           │
│  ├── 예외 → HTTP 응답으로 변환                                     │
│  └── 로깅 담당                                                      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

9.2 잘못된 예 vs 올바른 예

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ Controller에서 try-catch
@PostMapping("/api/orders")
public ResponseEntity<?> createOrder(@RequestBody @Valid OrderRequest request) {
    try {
        OrderResponse response = orderService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    } catch (InsufficientStockException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(new ErrorResponse("INSUFFICIENT_STOCK", e.getMessage()));
    }
}

// ✅ Controller는 깔끔하게, 예외 처리는 @ControllerAdvice에서
@PostMapping("/api/orders")
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(@RequestBody @Valid OrderRequest request) {
    return orderService.create(request);
    // InsufficientStockException → GlobalExceptionHandler가 처리
}

9.3 Service에서 예외를 잡아야 하는 경우

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class PaymentService {

    private final ExternalPaymentClient paymentClient;

    public PaymentResult pay(PaymentRequest request) {
        try {
            // 외부 API 호출
            return paymentClient.charge(request);
        } catch (PaymentGatewayException e) {
            // 외부 예외 → 우리 비즈니스 예외로 변환
            log.error("결제 API 호출 실패: {}", e.getMessage(), e);
            throw new BusinessException(ErrorCode.PAYMENT_FAILED,
                    "결제 처리 중 오류가 발생했습니다");
        }
    }
}

외부 API의 예외를 그대로 전파하면 구현 세부사항이 노출된다. Service에서 비즈니스 예외로 변환하는 것이 올바른 패턴이다.


10. 전체 구조 정리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
┌─────────────────────────────────────────────────────────────────────┐
│               Spring 예외 처리 아키텍처 전체도                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌─── 예외 정의 ────────────────────────────────────────────┐      │
│  │  ErrorCode (enum)                                        │      │
│  │  BusinessException → EntityNotFoundException             │      │
│  │                    → DuplicateException                   │      │
│  │                    → BusinessRuleException                │      │
│  └──────────────────────────────────────────────────────────┘      │
│                                                                     │
│  ┌─── 예외 발생 ────────────────────────────────────────────┐      │
│  │  Service: throw new EntityNotFoundException(...)         │      │
│  │  Service: throw new BusinessRuleException(...)           │      │
│  │  @Valid: MethodArgumentNotValidException (자동)           │      │
│  └──────────────────────────────────────────────────────────┘      │
│                           │                                         │
│                           ▼                                         │
│  ┌─── 예외 처리 ────────────────────────────────────────────┐      │
│  │  @RestControllerAdvice GlobalExceptionHandler            │      │
│  │    ├── BusinessException        → ErrorCode의 상태·메시지│      │
│  │    ├── MethodArgumentNotValid   → 400 + fieldErrors     │      │
│  │    ├── TypeMismatch             → 400                   │      │
│  │    ├── MethodNotSupported       → 405                   │      │
│  │    ├── NotReadable              → 400                   │      │
│  │    └── Exception                → 500 (최후 방어선)     │      │
│  └──────────────────────────────────────────────────────────┘      │
│                           │                                         │
│                           ▼                                         │
│  ┌─── 응답 ────────────────────────────────────────────────┐       │
│  │  ErrorResponse { code, message, fieldErrors }            │      │
│  │  → 모든 에러가 동일한 JSON 구조로 응답                   │      │
│  └──────────────────────────────────────────────────────────┘      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

면접 예상 질문 & 답변

Q1. @ExceptionHandler와 @ControllerAdvice의 차이를 설명해주세요.

@ExceptionHandler특정 Controller 내에서 발생하는 예외를 처리합니다. 해당 Controller에서만 동작하므로 다른 Controller에서 같은 예외가 발생하면 처리하지 못합니다.

@ControllerAdvice모든 Controller에서 발생하는 예외를 한 곳에서 처리할 수 있게 합니다. 여기에 @ExceptionHandler를 정의하면 전역으로 동작합니다.

같은 예외에 대해 둘 다 핸들러가 있으면, Controller 내 핸들러가 우선합니다. 실무에서는 대부분 @RestControllerAdvice로 전역 처리하고, Controller에는 예외 처리 코드를 두지 않습니다.

Q2. Spring에서 예외 처리를 어떻게 설계하셨나요?

ErrorCode enum에 에러 코드·HTTP 상태·메시지를 정의하고, BusinessException을 상속한 커스텀 예외 계층을 만들었습니다. Service에서 비즈니스 규칙 위반 시 해당 예외를 throw합니다.

@RestControllerAdvice에서 BusinessException, MethodArgumentNotValidException, Exception 등을 유형별로 핸들링하며, 모든 에러는 ErrorResponse라는 통일된 형식으로 응답합니다. Controller에는 try-catch를 두지 않아 비즈니스 로직에만 집중할 수 있습니다.

Q3. 401과 403의 차이를 설명해주세요.

401 Unauthorized는 “인증 실패”입니다. 로그인하지 않았거나, 토큰이 없거나, 만료된 경우입니다. “너 누구야?”에 해당합니다.

403 Forbidden은 “인가 실패”입니다. 인증은 되었지만(로그인은 했지만) 해당 리소스에 대한 권한이 없는 경우입니다. 예를 들어 일반 사용자가 관리자 API에 접근하면 403입니다.

Q4. 비즈니스 예외와 시스템 예외를 왜 구분하나요?

비즈니스 예외는 “사용자 없음”, “재고 부족”처럼 예상된 실패로, 클라이언트가 대응할 수 있습니다. 로그는 WARN으로 남기고, 구체적인 에러 메시지를 응답합니다.

시스템 예외는 “DB 연결 실패”, “NPE”처럼 예상치 못한 장애로, 클라이언트가 대응할 수 없습니다. 로그는 ERROR + 스택 트레이스로 남기고, 응답에는 내부 정보를 노출하지 않고 “서버 오류가 발생했습니다”만 내보냅니다.

이 구분이 있어야 로그 모니터링에서 실제 장애(ERROR)와 정상적인 비즈니스 실패(WARN)를 분리할 수 있습니다.

Q5. 예외는 Controller에서 잡아야 하나요, Service에서 잡아야 하나요?

Controller에서는 try-catch를 사용하지 않습니다. Service에서 예외를 throw하면 @ControllerAdvice가 잡아서 처리합니다.

Service는 예외를 던지는 역할입니다. 비즈니스 규칙 위반 시 적절한 커스텀 예외를 throw합니다. 다만, 외부 API 호출처럼 인프라 예외를 비즈니스 예외로 변환해야 하는 경우에는 Service에서 catch하여 변환합니다.

Q6. MethodArgumentNotValidException은 무엇인가요?

Controller 파라미터에 @Valid를 붙였을 때, 검증에 실패하면 Spring이 자동으로 던지는 예외입니다. @RequestBody로 받는 DTO의 @NotBlank, @Email, @Size 등의 검증 어노테이션 조건을 만족하지 못하면 발생합니다.

@ControllerAdvice에서 이 예외를 잡아서 getBindingResult().getFieldErrors()어떤 필드가 어떤 이유로 실패했는지 추출하고, 구조화된 에러 응답을 내보냅니다.

Q7. 에러 응답 포맷을 통일해야 하는 이유는?

에러 응답 형식이 API마다 다르면 프론트엔드에서 API마다 다른 에러 처리 로직을 작성해야 합니다. 구조가 통일되면 공통 에러 핸들러를 하나만 만들면 됩니다.

일반적으로 code, message, fieldErrors 정도의 필드를 갖는 구조를 사용합니다. code로 에러 유형을 구분하고, message로 사용자에게 보여줄 메시지를 전달하며, Validation 에러 시에는 fieldErrors로 필드별 에러 정보를 전달합니다.


마무리

Spring 예외 처리 설계의 핵심을 정리하면:

  • Controller는 깔끔하게: try-catch 없이, 예외는 자동 전파
  • Service는 throw만: 비즈니스 규칙 위반 시 커스텀 예외를 throw
  • @RestControllerAdvice가 잡아서: 예외 유형별로 적절한 HTTP 상태 코드와 에러 응답 생성
  • ErrorResponse로 통일: 모든 에러가 같은 JSON 구조로 응답
  • 로그 수준 구분: 비즈니스 예외는 WARN, 시스템 예외는 ERROR

이 구조를 잡아두면 새로운 예외가 추가되어도 ErrorCode에 항목 하나, BusinessException 하위 클래스 하나만 만들면 끝이다. Controller와 GlobalExceptionHandler는 수정할 필요가 없다.