요청 검증과 예외 처리

한 줄 정의

Validation은 외부 입력을 시스템 경계에서 검증하는 것이고, 예외 처리는 발생한 예외를 적절한 계층에서 일관된 응답으로 변환하는 것이다.

실무에서 왜 중요한가

검증과 예외 처리를 체계적으로 하지 않으면 다음 문제가 생긴다.

  • 잘못된 입력이 Service 계층까지 도달해서 예상치 못한 에러가 발생한다.
  • API마다 에러 응답 형식이 달라서 클라이언트가 일관된 처리를 할 수 없다.
  • 예외를 삼켜서(catch 후 무시) 원인 추적이 불가능하다.
  • 모든 메서드에 try-catch를 넣어서 코드가 복잡해진다.
  • 검증 에러와 시스템 에러를 구분하지 않는다.

Bean Validation

기본 사용

public class OrderRequest {
 
    @NotBlank(message = "상품명은 필수입니다")
    private String productName;
 
    @Min(value = 1, message = "수량은 1 이상이어야 합니다")
    private int quantity;
 
    @NotNull(message = "배송 주소는 필수입니다")
    private Address address;
}
@RestController
public class OrderController {
 
    @PostMapping("/api/orders")
    public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody OrderRequest request) {
        // @Valid가 있으면 Bean Validation 자동 실행
        // 검증 실패 시 MethodArgumentNotValidException 발생
        return ResponseEntity.ok(orderService.createOrder(request));
    }
}

주요 검증 어노테이션

어노테이션용도
@NotNullnull 불가
@NotBlanknull, 빈 문자열, 공백 불가 (String 전용)
@NotEmptynull, 빈 값 불가 (String, Collection)
@Size(min, max)길이/크기 범위
@Min, @Max숫자 최소/최대
@Email이메일 형식
@Pattern(regexp)정규식 패턴

중첩 객체 검증

public class OrderRequest {
 
    @Valid // 중첩 객체도 검증하려면 @Valid 필요
    @NotNull
    private Address address;
}

@Valid를 중첩 객체 필드에 붙여야 내부 필드도 검증된다.

그룹별 검증

public class UserRequest {
 
    @NotBlank(groups = Create.class)
    private String name;
 
    @NotBlank(groups = Update.class)
    private String nickname;
 
    public interface Create {}
    public interface Update {}
}
@PostMapping
public ResponseEntity<?> create(@Validated(Create.class) @RequestBody UserRequest request) { }

생성과 수정 시 다른 필드를 검증해야 할 때 사용한다. @Validated에 그룹을 지정한다.

예외 처리 전략

계층별 역할

Controller    → 입력 검증 예외, 요청 형식 오류
Service       → 비즈니스 규칙 위반 (잔액 부족, 중복 주문 등)
Repository    → 데이터 접근 예외
Global        → @RestControllerAdvice에서 일관된 응답 변환

커스텀 비즈니스 예외

public class BusinessException extends RuntimeException {
 
    private final ErrorCode errorCode;
 
    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
 
    public ErrorCode getErrorCode() {
        return errorCode;
    }
}
 
public enum ErrorCode {
 
    ORDER_NOT_FOUND("주문을 찾을 수 없습니다", HttpStatus.NOT_FOUND),
    INSUFFICIENT_BALANCE("잔액이 부족합니다", HttpStatus.BAD_REQUEST),
    DUPLICATE_ORDER("중복 주문입니다", HttpStatus.CONFLICT);
 
    private final String message;
    private final HttpStatus status;
 
    // constructor, getters
}

글로벌 예외 핸들러

@RestControllerAdvice
public class GlobalExceptionHandler {
 
    // Bean Validation 실패
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.joining(", "));
 
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("VALIDATION_ERROR", message));
    }
 
    // 비즈니스 예외
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
        ErrorCode code = e.getErrorCode();
        return ResponseEntity.status(code.getStatus())
            .body(new ErrorResponse(code.name(), code.getMessage()));
    }
 
    // 예상치 못한 예외
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        log.error("Unexpected error", e);
        return ResponseEntity.internalServerError()
            .body(new ErrorResponse("INTERNAL_ERROR", "서버 오류가 발생했습니다."));
    }
}

일관된 에러 응답 형식

public record ErrorResponse(String code, String message) { }
{
    "code": "ORDER_NOT_FOUND",
    "message": "주문을 찾을 수 없습니다"
}

모든 API가 동일한 에러 응답 구조를 사용해야 클라이언트가 일관되게 처리할 수 있다.

자주 나는 실수

  • @Valid를 빠뜨려서 검증이 실행되지 않는다.
  • 중첩 객체에 @Valid를 붙이지 않아서 내부 필드가 검증되지 않는다.
  • 예외를 catch하고 로그만 남긴 뒤 무시한다 (예외 삼키기).
  • API마다 에러 응답 형식이 달라서 클라이언트가 파싱할 수 없다.
  • 비즈니스 예외와 시스템 예외를 구분하지 않고 모두 500으로 응답한다.
  • @ExceptionHandler의 우선순위(구체적인 예외가 먼저)를 모른다.

핵심 요약

입력 검증은 @Valid와 Bean Validation으로 Controller 계층에서 처리합니다. 중첩 객체에도 @Valid를 붙여야 내부 필드가 검증됩니다.

예외 처리는 @RestControllerAdvice에서 글로벌하게 처리하고, 일관된 에러 응답 형식을 사용합니다. 비즈니스 예외는 커스텀 예외와 ErrorCode로 구분하고, 예상치 못한 예외는 로그를 남기고 500으로 응답합니다.

꼬리 질문

점검 퀴즈

아래 문항은 개념을 실제로 설명할 수 있는지 점검하기 위한 것이다. 선택지를 누르면 정답 여부와 이유가 표시된다.

객관식@Valid와 @Validated의 차이로 가장 적절한 것은?

OX모든 검증 로직은 DTO 애노테이션으로만 처리해야 하고 Service에서는 절대 검증하면 안 된다.

객관식글로벌 예외 핸들러를 설계할 때 중요한 기준은?

관련 문서