🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Spring 예외 처리와 검증 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 21. · 3 Views

Spring 예외 처리와 검증 완벽 가이드

스프링에서 예외를 우아하게 처리하고, 사용자 입력을 안전하게 검증하는 방법을 배웁니다. @ControllerAdvice부터 Bean Validation까지, 실무에서 바로 사용할 수 있는 예외 처리 전략을 다룹니다.


목차

  1. @ControllerAdvice 사용
  2. 커스텀 예외 클래스
  3. 에러 응답 형식 통일
  4. Bean Validation 어노테이션
  5. @Valid 적용
  6. 검증 에러 처리

1. @ControllerAdvice 사용

김개발 씨는 스프링으로 REST API를 개발하던 중, 모든 컨트롤러마다 똑같은 예외 처리 코드를 복사해서 붙여넣고 있었습니다. 선배 박시니어 씨가 코드 리뷰를 하다가 물었습니다.

"이렇게 중복 코드가 많으면 나중에 수정할 때 어떻게 할 건가요?"

@ControllerAdvice는 한마디로 애플리케이션 전체의 예외를 한 곳에서 처리하는 기능입니다. 마치 회사의 고객 상담센터가 모든 고객 불만을 한 곳에서 처리하는 것과 같습니다.

이것을 제대로 이해하면 중복 코드를 제거하고 일관된 에러 응답을 제공할 수 있습니다.

다음 코드를 살펴봅시다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 모든 RuntimeException을 여기서 처리합니다
    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException ex) {
        ErrorResponse error = new ErrorResponse(
            "INTERNAL_ERROR",
            ex.getMessage()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }

    // IllegalArgumentException은 400 Bad Request로 처리합니다
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
        ErrorResponse error = new ErrorResponse("INVALID_INPUT", ex.getMessage());
        return ResponseEntity.badRequest().body(error);
    }
}

김개발 씨는 입사 3개월 차 백엔드 개발자입니다. 오늘도 열심히 REST API를 개발하던 중, 이상한 점을 발견했습니다.

컨트롤러가 10개인데, 각각의 컨트롤러마다 똑같은 예외 처리 코드가 반복되고 있었습니다. 선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다.

"아, 여기가 문제네요. @ControllerAdvice를 사용하면 이런 중복을 한 방에 해결할 수 있어요." 그렇다면 @ControllerAdvice란 정확히 무엇일까요?

쉽게 비유하자면, @ControllerAdvice는 마치 대형 백화점의 고객 상담센터와 같습니다. 각 매장에서 발생한 모든 고객 불만을 한 곳의 상담센터에서 처리하는 것처럼, 모든 컨트롤러에서 발생한 예외를 한 곳에서 처리합니다.

이처럼 @ControllerAdvice도 애플리케이션 전역의 예외 처리를 담당합니다. @ControllerAdvice가 없던 시절에는 어땠을까요?

개발자들은 각 컨트롤러마다 @ExceptionHandler를 작성해야 했습니다. 컨트롤러가 10개면 똑같은 코드를 10번 작성해야 했습니다.

더 큰 문제는 나중에 에러 응답 형식을 변경해야 할 때였습니다. 10개의 컨트롤러를 모두 찾아서 수정해야 했고, 하나라도 빠뜨리면 일관성이 깨졌습니다.

바로 이런 문제를 해결하기 위해 @ControllerAdvice가 등장했습니다. @ControllerAdvice를 사용하면 예외 처리 로직을 한 곳에 모을 수 있습니다.

또한 모든 컨트롤러에서 일관된 에러 응답을 제공할 수 있습니다. 무엇보다 코드 중복을 제거하고 유지보수성을 크게 높일 수 있다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 @RestControllerAdvice 어노테이션을 보면 이 클래스가 전역 예외 처리를 담당한다는 것을 알 수 있습니다.

이 부분이 핵심입니다. 다음으로 @ExceptionHandler 어노테이션에서는 어떤 예외를 처리할지 지정합니다.

메서드 내부에서는 ErrorResponse 객체를 만들고 적절한 HTTP 상태 코드와 함께 반환합니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 쇼핑몰 서비스를 개발한다고 가정해봅시다. 주문 API, 결제 API, 배송 API 등 수십 개의 API에서 예외가 발생할 수 있습니다.

@ControllerAdvice를 활용하면 모든 API에서 발생하는 예외를 일관된 형식으로 응답할 수 있습니다. 쿠팡, 배민 같은 많은 기업에서 이런 패턴을 적극적으로 사용하고 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 모든 예외를 Exception.class로 처리하는 것입니다.

이렇게 하면 예외의 종류를 구분할 수 없어 적절한 에러 메시지를 제공하기 어렵습니다. 따라서 예외 타입별로 세밀하게 처리하는 것이 좋습니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.

"아, 그래서 그랬군요!" @ControllerAdvice를 제대로 이해하면 더 깔끔하고 유지보수하기 쉬운 코드를 작성할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - @RestControllerAdvice를 사용하면 @ResponseBody를 자동으로 적용해줍니다

  • 특정 패키지에만 적용하려면 **@ControllerAdvice(basePackages = "com.example.api")**처럼 범위를 지정하세요
  • 예외 처리 순서는 구체적인 예외부터 처리하고, 마지막에 일반 예외를 처리하세요

2. 커스텀 예외 클래스

김개발 씨가 API를 개발하던 중, 사용자를 찾을 수 없는 상황을 어떤 예외로 던져야 할지 고민이 되었습니다. IllegalArgumentException?

RuntimeException? 선배 박시니어 씨가 말했습니다.

"그럴 때는 커스텀 예외를 만들어 보세요."

커스텀 예외 클래스는 한마디로 비즈니스 도메인에 특화된 예외를 직접 만드는 것입니다. 마치 병원에서 질병마다 고유한 질병 코드를 부여하는 것과 같습니다.

이것을 제대로 이해하면 예외만 보고도 어떤 문제인지 명확하게 알 수 있습니다.

다음 코드를 살펴봅시다.

// 비즈니스 예외의 기본 클래스
public abstract class BusinessException extends RuntimeException {
    private final String errorCode;

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

    public String getErrorCode() {
        return errorCode;
    }
}

// 사용자를 찾을 수 없을 때 발생하는 예외
public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super("USER_NOT_FOUND", "사용자를 찾을 수 없습니다: " + userId);
    }
}

// 사용 예시
public User getUser(Long userId) {
    return userRepository.findById(userId)
        .orElseThrow(() -> new UserNotFoundException(userId));
}

김개발 씨는 사용자 조회 API를 개발하고 있었습니다. 데이터베이스에서 사용자를 찾지 못했을 때 어떤 예외를 던져야 할지 고민이 되었습니다.

그냥 RuntimeException을 던지자니 너무 일반적이고, IllegalArgumentException을 던지자니 의미가 애매했습니다. 선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다.

"음, 이럴 때는 커스텀 예외를 만들어 보세요. 훨씬 명확해질 거예요." 그렇다면 커스텀 예외란 정확히 무엇일까요?

쉽게 비유하자면, 커스텀 예외는 마치 병원의 질병 분류 시스템과 같습니다. 모든 병을 그냥 "아픔"이라고 부르는 대신, 감기는 J00, 독감은 J11 같은 고유 코드를 부여하는 것처럼, 비즈니스 상황마다 고유한 예외를 정의하는 것입니다.

이처럼 커스텀 예외도 도메인 특화된 오류 상황을 명확하게 표현하는 역할을 담당합니다. 커스텀 예외가 없던 시절에는 어땠을까요?

개발자들은 자바가 제공하는 일반적인 예외만 사용했습니다. 사용자를 못 찾았을 때도 RuntimeException을 던지고, 권한이 없을 때도 RuntimeException을 던졌습니다.

예외 메시지를 읽어야만 무슨 문제인지 알 수 있었습니다. 더 큰 문제는 예외 처리 로직에서 예외 타입으로 구분할 수 없어 메시지 문자열을 파싱해야 했다는 것입니다.

바로 이런 문제를 해결하기 위해 커스텀 예외를 사용합니다. 커스텀 예외를 사용하면 예외의 의미가 명확해집니다.

UserNotFoundException이라는 이름만 봐도 무슨 문제인지 바로 알 수 있습니다. 또한 에러 코드를 함께 관리할 수 있어 클라이언트에게 일관된 에러 응답을 제공할 수 있습니다.

무엇보다 예외 타입으로 처리 로직을 분기할 수 있다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 BusinessException이라는 추상 클래스를 만들어 모든 비즈니스 예외의 부모로 사용합니다. 이 클래스는 errorCode 필드를 가지고 있어 에러 코드를 표준화할 수 있습니다.

다음으로 UserNotFoundExceptionBusinessException을 상속받아 사용자 조회 실패를 표현합니다. 생성자에서 userId를 받아 자동으로 에러 메시지를 만들어줍니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 전자상거래 시스템을 개발한다고 가정해봅시다.

상품 품절 시 ProductOutOfStockException, 결제 실패 시 PaymentFailedException, 배송 불가 지역 시 DeliveryUnavailableException 등 상황별로 세밀한 예외를 정의할 수 있습니다. 토스, 카카오페이 같은 많은 핀테크 기업에서 이런 패턴을 적극적으로 사용하고 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 너무 세분화된 예외를 만드는 것입니다.

예외가 100개가 넘으면 오히려 관리가 어려워집니다. 따라서 적절한 수준의 추상화를 유지하면서 정말 필요한 예외만 만드는 것이 좋습니다.

또 다른 주의사항은 checked exceptionunchecked exception의 선택입니다. 비즈니스 예외는 대부분 복구 불가능하므로 RuntimeException을 상속받는 것이 일반적입니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.

"아, 이제 코드가 훨씬 명확해졌어요!" 커스텀 예외를 제대로 이해하면 더 읽기 쉽고 유지보수하기 쉬운 코드를 작성할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 예외 클래스는 도메인별로 패키지를 나누어 관리하세요 (예: com.example.user.exception)

  • 에러 코드는 enum으로 관리하면 오타를 방지할 수 있습니다
  • 생성자에서 로그를 자동으로 남기도록 구현하면 디버깅이 쉬워집니다

3. 에러 응답 형식 통일

김개발 씨가 만든 API를 프론트엔드 개발자 이프론 씨가 사용하려다가 혼란스러워했습니다. "어떤 API는 에러를 message 필드에 담고, 어떤 API는 error 필드에 담네요.

통일할 수 없나요?" 박시니어 씨가 말했습니다. "당연히 통일해야죠!"

에러 응답 형식 통일은 한마디로 모든 API의 에러 응답을 동일한 구조로 만드는 것입니다. 마치 모든 택배 상자가 같은 형식의 송장을 붙이는 것과 같습니다.

이것을 제대로 이해하면 클라이언트 개발자가 에러 처리 코드를 한 번만 작성해도 모든 API에 적용할 수 있습니다.

다음 코드를 살펴봅시다.

// 표준 에러 응답 클래스
public class ErrorResponse {
    private final String errorCode;
    private final String message;
    private final LocalDateTime timestamp;
    private final String path;

    public ErrorResponse(String errorCode, String message, String path) {
        this.errorCode = errorCode;
        this.message = message;
        this.timestamp = LocalDateTime.now();
        this.path = path;
    }

    // getter 메서드들
}

// ControllerAdvice에서 사용
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        ErrorResponse error = new ErrorResponse(
            ex.getErrorCode(),
            ex.getMessage(),
            request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

김개발 씨는 열심히 REST API를 개발했습니다. 주문 API, 상품 API, 사용자 API 등 10개가 넘는 API를 만들었습니다.

그런데 프론트엔드 개발자 이프론 씨가 찾아와 불만을 토로했습니다. "김개발 씨, API마다 에러 형식이 다 달라요.

어떤 건 message 필드에 에러가 담기고, 어떤 건 error 필드에 담기네요. 에러 처리 코드를 API마다 다르게 작성해야 해서 너무 힘들어요." 선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다.

"아, 에러 응답 형식을 통일하지 않아서 생긴 문제네요. ErrorResponse 클래스를 만들어 보세요." 그렇다면 에러 응답 형식 통일이란 정확히 무엇일까요?

쉽게 비유하자면, 에러 응답 형식 통일은 마치 모든 택배 회사가 같은 형식의 송장을 사용하는 것과 같습니다. CJ대한통운, 우체국택배, 로젠택배 모두 발송인, 수취인, 운송장번호 같은 정보를 동일한 위치에 표시합니다.

덕분에 고객은 어떤 택배든 쉽게 정보를 확인할 수 있습니다. 이처럼 에러 응답 형식 통일도 클라이언트가 쉽게 에러를 처리할 수 있도록 일관된 구조를 제공하는 역할을 담당합니다.

에러 응답 형식이 통일되지 않았던 시절에는 어땠을까요? 프론트엔드 개발자들은 API마다 다른 에러 처리 로직을 작성해야 했습니다.

주문 API의 에러는 error.message에서 읽고, 상품 API의 에러는 error.errorMessage에서 읽어야 했습니다. 코드가 복잡해지고, 실수하기도 쉬웠습니다.

더 큰 문제는 새로운 API가 추가될 때마다 에러 처리 로직도 추가해야 한다는 것이었습니다. 바로 이런 문제를 해결하기 위해 에러 응답 형식 통일이 필요합니다.

ErrorResponse 클래스를 사용하면 모든 API의 에러가 동일한 형식으로 반환됩니다. 프론트엔드 개발자는 에러 처리 로직을 한 번만 작성하면 모든 API에 적용할 수 있습니다.

또한 에러 코드, 메시지, 타임스탬프, 요청 경로 같은 유용한 정보를 표준화할 수 있습니다. 무엇보다 API 문서가 간결해지고 클라이언트 개발자의 학습 부담이 줄어든다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 ErrorResponse 클래스를 보면 에러에 필요한 모든 정보를 필드로 가지고 있습니다.

errorCode는 기계가 읽을 수 있는 코드, message는 사람이 읽을 수 있는 메시지입니다. timestamp는 에러 발생 시각, path는 에러가 발생한 API 경로를 나타냅니다.

다음으로 GlobalExceptionHandler에서는 모든 예외를 잡아서 ErrorResponse로 변환합니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 쇼핑몰 모바일 앱을 개발한다고 가정해봅시다. 안드로이드, iOS, 웹 세 개의 클라이언트가 같은 API를 사용합니다.

에러 응답 형식이 통일되어 있으면 세 플랫폼 모두 동일한 에러 처리 로직을 사용할 수 있습니다. 네이버, 쿠팡 같은 대규모 서비스에서 이런 패턴을 적극적으로 사용하고 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 에러 응답에 너무 많은 정보를 담는 것입니다.

스택 트레이스나 내부 로직 같은 민감한 정보가 노출되면 보안 문제가 발생할 수 있습니다. 따라서 프로덕션 환경에서는 최소한의 정보만 제공하고, 상세한 로그는 서버에만 남기는 것이 좋습니다.

또 다른 주의사항은 HTTP 상태 코드와 에러 코드의 일관성입니다. 404 Not Found 응답에는 RESOURCE_NOT_FOUND 같은 에러 코드를 사용하는 것이 자연스럽습니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 ErrorResponse를 적용한 김개발 씨는 이프론 씨에게 칭찬을 들었습니다.

"이제 에러 처리가 정말 쉬워졌어요!" 에러 응답 형식 통일을 제대로 이해하면 프론트엔드 개발자와 협업하기 쉬운 API를 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 개발 환경에서는 stackTrace 필드를 추가해 디버깅을 쉽게 할 수 있습니다

  • validation 에러는 어떤 필드가 잘못되었는지 fieldErrors 배열로 제공하세요
  • RFC 7807 표준(Problem Details for HTTP APIs)을 참고하면 더 나은 설계를 할 수 있습니다

4. Bean Validation 어노테이션

김개발 씨가 회원가입 API를 만들던 중, 이메일 형식 검증 코드를 작성하고 있었습니다. 정규표현식을 직접 작성하려니 복잡하고 실수하기 쉬웠습니다.

박시니어 씨가 말했습니다. "그런 건 Bean Validation을 쓰면 어노테이션 하나로 끝나요!"

Bean Validation은 한마디로 자바 객체의 필드를 어노테이션으로 검증하는 표준 기술입니다. 마치 입국 심사대에서 여권을 확인하는 것처럼, 데이터가 시스템에 들어오기 전에 자동으로 검증합니다.

이것을 제대로 이해하면 복잡한 검증 로직 없이도 안전한 데이터를 보장할 수 있습니다.

다음 코드를 살펴봅시다.

// 회원가입 요청 DTO
public class SignUpRequest {

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

    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;

    @NotBlank(message = "비밀번호는 필수입니다")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$",
             message = "비밀번호는 영문+숫자 조합 8자 이상이어야 합니다")
    private String password;

    @Min(value = 18, message = "18세 이상만 가입 가능합니다")
    @Max(value = 100, message = "나이는 100세 이하여야 합니다")
    private Integer age;

    // getter, setter
}

김개발 씨는 회원가입 API를 개발하고 있었습니다. 사용자가 입력한 이메일이 올바른 형식인지 검증해야 했습니다.

정규표현식을 구글에서 검색해서 복사했지만, 코드가 너무 복잡하고 읽기 어려웠습니다. 선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다.

"음, 그런 건 Bean Validation을 사용하면 훨씬 간단해요. 어노테이션 하나면 끝이죠." 그렇다면 Bean Validation이란 정확히 무엇일까요?

쉽게 비유하자면, Bean Validation은 마치 공항의 입국 심사와 같습니다. 여권이 유효한지, 비자가 있는지, 금지 물품은 없는지 자동으로 검사합니다.

문제가 있으면 입국을 거부합니다. 이처럼 Bean Validation도 데이터가 시스템에 들어오기 전에 자동으로 검증하고, 잘못된 데이터는 거부하는 역할을 담당합니다.

Bean Validation이 없던 시절에는 어땠을까요? 개발자들은 모든 검증 로직을 직접 작성해야 했습니다.

이메일 검증, 길이 검증, 범위 검증 등을 if 문으로 일일이 체크했습니다. 코드가 길어지고, 검증 로직과 비즈니스 로직이 뒤섞여 가독성이 떨어졌습니다.

더 큰 문제는 검증 로직이 중복되어 여러 곳에 흩어져 있다는 것이었습니다. 수정이 필요할 때 모든 곳을 찾아 고쳐야 했습니다.

바로 이런 문제를 해결하기 위해 Bean Validation이 등장했습니다. Bean Validation을 사용하면 검증 규칙을 필드에 어노테이션으로 선언할 수 있습니다.

@NotBlank, @Email, @Pattern 같은 어노테이션만 붙이면 자동으로 검증됩니다. 또한 에러 메시지도 함께 정의할 수 있어 일관된 메시지를 제공할 수 있습니다.

무엇보다 코드가 깔끔해지고 검증 규칙이 한눈에 보인다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 @NotBlank는 필드가 null이 아니고 공백도 아닌지 검증합니다. @Size는 문자열 길이를 검증합니다.

@Email은 이메일 형식인지 자동으로 확인합니다. @Pattern은 정규표현식으로 복잡한 규칙을 검증할 수 있습니다.

@Min@Max는 숫자의 범위를 검증합니다. 각 어노테이션의 message 속성은 검증 실패 시 반환될 에러 메시지를 정의합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 쇼핑몰 주문 API를 개발한다고 가정해봅시다.

주문 수량은 1 이상이어야 하고, 배송 주소는 필수이며, 전화번호는 특정 형식이어야 합니다. 이런 복잡한 규칙을 @Min, @NotBlank, @Pattern 같은 어노테이션으로 간단하게 표현할 수 있습니다.

토스, 배민 같은 많은 서비스에서 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 @NotNull@NotBlank를 혼동하는 것입니다. @NotNull은 null만 체크하고, @NotBlank는 null과 공백을 모두 체크합니다.

문자열 검증에는 @NotBlank를 사용하는 것이 안전합니다. 또 다른 주의사항은 에러 메시지의 국제화입니다.

여러 언어를 지원해야 한다면 messages.properties 파일을 사용해 메시지를 외부화하는 것이 좋습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 복잡한 정규표현식 코드를 모두 지우고 @Email 어노테이션 하나로 대체했습니다. "와, 정말 간단해졌네요!" Bean Validation을 제대로 이해하면 더 안전하고 읽기 쉬운 코드를 작성할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 커스텀 어노테이션을 만들어 비즈니스 규칙을 재사용할 수 있습니다 (예: @PhoneNumber)

  • @Valid 어노테이션과 함께 사용해야 실제로 검증이 실행됩니다
  • Hibernate Validator는 @NotEmpty, @URL 같은 추가 어노테이션을 제공합니다

5. @Valid 적용

김개발 씨가 Bean Validation 어노테이션을 열심히 붙였는데, 검증이 전혀 동작하지 않았습니다. 이상해서 디버깅을 해보니 검증 로직이 실행조차 되지 않고 있었습니다.

박시니어 씨가 말했습니다. "컨트롤러에 @Valid를 붙이셨나요?"

@Valid는 한마디로 스프링에게 "이 객체를 검증하라"고 지시하는 어노테이션입니다. 마치 공항 보안 검색대에서 짐을 엑스레이에 통과시키는 것과 같습니다.

이것을 제대로 이해하면 Bean Validation 어노테이션을 실제로 동작시킬 수 있습니다.

다음 코드를 살펴봅시다.

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    // @Valid를 붙여야 검증이 실행됩니다
    @PostMapping("/signup")
    public ResponseEntity<UserResponse> signUp(@Valid @RequestBody SignUpRequest request) {
        User user = userService.createUser(request);
        return ResponseEntity.ok(new UserResponse(user));
    }

    // 경로 변수도 검증 가능합니다
    @GetMapping("/{userId}")
    public ResponseEntity<UserResponse> getUser(
            @PathVariable @Min(1) Long userId) {
        User user = userService.getUser(userId);
        return ResponseEntity.ok(new UserResponse(user));
    }

    // 중첩된 객체도 검증됩니다
    @PutMapping("/{userId}")
    public ResponseEntity<UserResponse> updateUser(
            @PathVariable Long userId,
            @Valid @RequestBody UpdateUserRequest request) {
        User user = userService.updateUser(userId, request);
        return ResponseEntity.ok(new UserResponse(user));
    }
}

김개발 씨는 SignUpRequest에 열심히 @NotBlank, @Email 같은 어노테이션을 붙였습니다. 자, 이제 완벽하다고 생각하고 테스트를 실행했습니다.

그런데 이상하게도 빈 이메일을 보내도 에러가 발생하지 않았습니다. 선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다.

"아, 컨트롤러에 @Valid를 안 붙이셨네요. 어노테이션만 붙인다고 자동으로 검증되는 게 아니에요." 그렇다면 @Valid란 정확히 무엇일까요?

쉽게 비유하자면, @Valid는 마치 공항 보안 검색대의 스위치와 같습니다. 짐에 금지 물품 감지 스티커를 붙였다고 해서 자동으로 검사되는 게 아닙니다.

직원이 엑스레이 기계를 작동시켜야 검사가 시작됩니다. 이처럼 @Valid도 스프링에게 "이 객체를 검증하라"고 명시적으로 지시하는 역할을 담당합니다.

@Valid를 사용하지 않던 시절에는 어떻게 했을까요? 사실 Bean Validation 어노테이션만 붙이면 스프링이 자동으로 검증해줄 것 같지만, 그렇지 않습니다.

스프링은 명시적으로 @Valid를 붙인 파라미터만 검증합니다. 이것을 모르는 초보 개발자들은 왜 검증이 동작하지 않는지 한참 고민하게 됩니다.

바로 이런 혼란을 방지하기 위해 @Valid를 명시적으로 붙이도록 설계되었습니다. @Valid를 사용하면 스프링이 해당 파라미터를 검증하기 시작합니다.

검증에 실패하면 MethodArgumentNotValidException이 발생합니다. 이 예외를 @ControllerAdvice에서 잡아서 처리하면 됩니다.

무엇보다 어떤 파라미터를 검증할지 명확하게 표시할 수 있다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 signUp 메서드를 보면 @Valid@RequestBody가 함께 사용되었습니다. @Valid가 없으면 SignUpRequest의 어노테이션이 무시됩니다.

다음으로 getUser 메서드에서는 @PathVariable과 함께 @Min을 사용했습니다. 경로 변수도 검증할 수 있습니다.

마지막으로 updateUser 메서드는 요청 본문을 검증하는 예시입니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 게시판 서비스를 개발한다고 가정해봅시다. 게시글 작성 API에서는 제목과 내용이 필수이고, 제목은 100자 이하여야 합니다.

@Valid를 붙이면 이런 규칙을 자동으로 검증할 수 있습니다. 네이버 카페, 디시인사이드 같은 많은 커뮤니티 서비스에서 이런 패턴을 사용하고 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 @Validated@Valid를 혼동하는 것입니다.

@Valid는 자바 표준이고, @Validated는 스프링이 제공하는 확장 기능입니다. @Validated는 그룹 검증을 지원하지만, 대부분의 경우 @Valid로 충분합니다.

또 다른 주의사항은 중첩된 객체의 검증입니다. Address 같은 중첩 객체가 있다면 해당 필드에도 @Valid를 붙여야 내부 필드까지 검증됩니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 @Valid를 추가한 김개발 씨는 다시 테스트를 실행했습니다.

이번에는 빈 이메일을 보내자 제대로 에러가 발생했습니다. "이제 완벽하게 동작하네요!" @Valid를 제대로 이해하면 검증 로직을 확실하게 동작시킬 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - @RequestBody뿐 아니라 @ModelAttribute에도 @Valid를 사용할 수 있습니다

  • 리스트를 검증하려면 List<@Valid ItemRequest> 형태로 사용하세요
  • 검증 그룹이 필요하면 **@Validated(CreateGroup.class)**를 사용하세요

6. 검증 에러 처리

김개발 씨가 @Valid를 적용하니 검증은 잘 되는데, 에러 응답이 너무 복잡했습니다. 어떤 필드가 잘못되었는지 클라이언트에서 파악하기 어려웠습니다.

박시니어 씨가 말했습니다. "검증 에러도 깔끔하게 처리해야죠!"

검증 에러 처리는 한마디로 MethodArgumentNotValidException을 잡아서 읽기 쉬운 형식으로 변환하는 것입니다. 마치 의사가 검사 결과를 환자가 이해할 수 있는 말로 설명하는 것과 같습니다.

이것을 제대로 이해하면 클라이언트 개발자가 어떤 필드를 고쳐야 할지 정확히 알 수 있습니다.

다음 코드를 살펴봅시다.

// 검증 에러 응답 클래스
public class ValidationErrorResponse extends ErrorResponse {
    private final List<FieldError> fieldErrors;

    public ValidationErrorResponse(String message, List<FieldError> fieldErrors, String path) {
        super("VALIDATION_FAILED", message, path);
        this.fieldErrors = fieldErrors;
    }

    public static class FieldError {
        private final String field;
        private final String message;
        private final Object rejectedValue;

        public FieldError(String field, String message, Object rejectedValue) {
            this.field = field;
            this.message = message;
            this.rejectedValue = rejectedValue;
        }
        // getter 메서드들
    }
    // getter 메서드들
}

// ControllerAdvice에서 처리
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex, HttpServletRequest request) {

        List<ValidationErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ValidationErrorResponse.FieldError(
                error.getField(),
                error.getDefaultMessage(),
                error.getRejectedValue()
            ))
            .collect(Collectors.toList());

        ValidationErrorResponse response = new ValidationErrorResponse(
            "입력값 검증에 실패했습니다",
            fieldErrors,
            request.getRequestURI()
        );

        return ResponseEntity.badRequest().body(response);
    }
}

김개발 씨는 @Valid를 적용해서 검증이 잘 동작하게 만들었습니다. 그런데 프론트엔드 개발자 이프론 씨가 또 찾아왔습니다.

"검증 에러가 발생하면 응답이 너무 복잡해요. 어떤 필드가 잘못되었는지 파악하기 어렵습니다." 선배 개발자 박시니어 씨가 다가와 에러 응답을 살펴봅니다.

"아, MethodArgumentNotValidException을 잡아서 깔끔하게 변환해야 해요." 그렇다면 검증 에러 처리란 정확히 무엇일까요? 쉽게 비유하자면, 검증 에러 처리는 마치 의사가 건강검진 결과를 설명하는 것과 같습니다.

의사는 "혈압 180/120, 콜레스테롤 250mg/dL"이라는 숫자를 그대로 알려주지 않습니다. "혈압이 높으니 염분 섭취를 줄이세요", "콜레스테롤이 높으니 운동이 필요합니다"처럼 환자가 이해할 수 있는 말로 설명합니다.

이처럼 검증 에러 처리도 복잡한 예외 정보를 클라이언트가 쉽게 이해할 수 있는 형식으로 변환하는 역할을 담당합니다. 검증 에러 처리를 하지 않던 시절에는 어떻게 했을까요?

스프링의 기본 에러 응답은 너무 상세해서 오히려 복잡했습니다. 스택 트레이스, 내부 클래스 이름 같은 불필요한 정보가 포함되어 있었습니다.

프론트엔드 개발자는 이 복잡한 응답에서 필요한 정보를 찾아내야 했습니다. 더 큰 문제는 필드별 에러를 구분하기 어려워 사용자에게 "이메일이 잘못되었습니다" 같은 구체적인 메시지를 보여주기 힘들었다는 것입니다.

바로 이런 문제를 해결하기 위해 검증 에러를 별도로 처리합니다. ValidationErrorResponse를 사용하면 어떤 필드가 잘못되었는지 명확하게 전달할 수 있습니다.

각 필드의 에러 메시지, 잘못된 값까지 제공할 수 있습니다. 클라이언트는 이 정보를 사용해 사용자에게 "이메일 형식이 올바르지 않습니다", "비밀번호는 8자 이상이어야 합니다" 같은 친절한 메시지를 보여줄 수 있습니다.

무엇보다 에러 응답이 표준화되어 클라이언트 개발이 쉬워진다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 ValidationErrorResponse 클래스를 보면 기본 ErrorResponse를 상속받고 fieldErrors 필드를 추가했습니다. FieldError 내부 클래스는 필드명, 에러 메시지, 거부된 값을 담습니다.

다음으로 GlobalExceptionHandler에서는 MethodArgumentNotValidException을 잡습니다. **getBindingResult()**로 검증 결과를 가져와 스트림으로 변환합니다.

마지막으로 깔끔한 응답 객체를 만들어 반환합니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 모바일 앱의 회원가입 화면을 개발한다고 가정해봅시다. 사용자가 잘못된 이메일을 입력하면 이메일 입력창 아래에 빨간 글씨로 "올바른 이메일 형식이 아닙니다"라는 메시지를 표시해야 합니다.

ValidationErrorResponse를 사용하면 어떤 필드가 잘못되었는지 정확히 알 수 있어 이런 UI를 쉽게 구현할 수 있습니다. 카카오톡, 라인 같은 많은 메신저 앱에서 이런 패턴을 사용하고 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 rejectedValue에 비밀번호 같은 민감한 정보를 그대로 노출하는 것입니다.

프로덕션 환경에서는 민감한 필드의 경우 rejectedValue를 마스킹하거나 제외하는 것이 좋습니다. 또 다른 주의사항은 에러 메시지의 일관성입니다.

어떤 필드는 "이메일은 필수입니다", 어떤 필드는 "name field is required"처럼 언어가 섞이지 않도록 주의해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언대로 검증 에러 처리를 추가한 김개발 씨는 이프론 씨에게 큰 칭찬을 들었습니다. "이제 에러 처리가 정말 완벽해요.

사용자에게 정확한 메시지를 보여줄 수 있어요!" 검증 에러 처리를 제대로 이해하면 사용자 친화적인 API를 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - BindException도 함께 처리하면 @ModelAttribute 검증 에러도 잡을 수 있습니다

  • 글로벌 에러와 필드 에러를 구분해서 제공하면 더 유용합니다 (예: globalErrors, fieldErrors)
  • 개발 환경에서는 rejectedValue를 포함하고, 프로덕션에서는 제외하는 것을 고려하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Spring#ControllerAdvice#BeanValidation#ExceptionHandling#InputValidation

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스