포스트

스프링 초급 (7) - Validation & Exception: 실패를 한곳에서 처리하기

처리하지 않은 예외가 500으로 나가는 문제에서 출발해 @Valid와 @RestControllerAdvice로 실패 응답을 통일하는 과정을 다룹니다.

스프링 초급 (7) - Validation & Exception: 실패를 한곳에서 처리하기

스프링 초급 시리즈의 7편입니다. 전체 목차는 0편에 있습니다.

실패하면 지금 어떻게 나가나

6편까지 만든 서비스에 규칙을 하나 넣습니다. 이미 가입된 이메일이면 가입시키지 않습니다.

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

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public Member join(String name, String email) {
        if (memberRepository.existsByEmail(email)) {
            throw new IllegalStateException("이미 가입된 이메일: " + email);
        }
        return memberRepository.save(new Member(name, email));
    }
}

예외를 던지기만 하고 아무 처리도 안 했습니다. 이 상태로 같은 이메일로 두 번 가입해 봅니다.

1
2
3
curl -i -X POST localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"민아","email":"abcd1234@gmail.com"}'
1
2
HTTP/1.1 500
Content-Type: application/json
1
2
3
4
5
6
{
  "timestamp": "2026-07-30T02:22:31.482+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/members"
}

문제가 두 개입니다.

첫째, 상태 코드가 500입니다. 500은 “서버가 고장났다”는 뜻입니다. 하지만 여기서 고장난 건 없습니다. 클라이언트가 규칙에 어긋나는 요청을 보냈을 뿐입니다. 이건 409(충돌) 여야 맞습니다.

이 차이가 왜 중요하냐면, 클라이언트가 상태 코드를 보고 행동을 정하기 때문입니다. 500이면 “잠시 후 다시 시도”가 맞고, 409면 “다른 이메일을 입력하세요”가 맞습니다. 코드가 틀리면 대응도 틀립니다.

둘째, 왜 실패했는지가 응답에 없습니다. 서비스에서 분명히 "이미 가입된 이메일: ..."이라고 적었는데 응답 어디에도 없습니다. 클라이언트는 사용자에게 아무 안내도 못 합니다.

그렇다고 예외 메시지를 그대로 다 내보내면 안 됩니다. DB 오류가 났을 때 SQLException 메시지를 그대로 내보내면 테이블 이름과 컬럼 이름이 그대로 노출됩니다.

필요한 건 “우리가 정한 만큼만, 정한 형태로” 내보내는 것입니다.

컨트롤러마다 try-catch를 넣으면

가장 먼저 떠오르는 방법입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@PostMapping
public ResponseEntity<?> join(@RequestBody MemberCreateRequest request) {
    try {
        Member member = memberService.join(request.name(), request.email());
        return ResponseEntity.ok(MemberResponse.from(member));
    } catch (IllegalStateException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(Map.of("message", e.getMessage()));
    } catch (IllegalArgumentException e) {
        return ResponseEntity.badRequest()
                .body(Map.of("message", e.getMessage()));
    }
}

동작은 합니다. 그런데 API가 20개가 되면 이렇게 됩니다.

  • 같은 catch가 20번 반복됩니다. 상태 코드를 바꾸려면 20군데를 고쳐야 합니다
  • 응답 형태가 조금씩 갈라집니다. 어디는 message, 어디는 error, 어디는 그냥 문자열이 됩니다. 클라이언트가 API마다 다르게 파싱해야 합니다
  • 정작 하려던 일이 안 보입니다. 실제 로직은 두 줄인데 try-catch에 파묻혔습니다
  • 반환 타입이 ResponseEntity<?>가 됩니다. 이 API가 뭘 주는지 코드만 봐서는 알 수 없습니다

이 방법은 오래 붙잡지 않고 넘어갑니다. 대신 두 가지를 나눠서 해결합니다.

  1. 형식 검증 — “이메일 형식인가”, “이름이 비었나” → @Valid로 컨트롤러 앞에서 거른다
  2. 예외 처리 — 그래도 발생한 예외 → @RestControllerAdvice로 한곳에 모은다

검증을 컨트롤러 밖으로

먼저 검증입니다. “이름이 비었는지”를 확인하는 코드가 컨트롤러나 서비스에 있으면 이렇게 됩니다.

1
2
3
4
5
6
7
8
9
public Member join(String name, String email) {
    if (name == null || name.isBlank()) {
        throw new IllegalArgumentException("이름은 필수입니다");
    }
    if (email == null || !email.contains("@")) {
        throw new IllegalArgumentException("이메일 형식이 아닙니다");
    }
    // 여기부터 진짜 하려던 일
}

이런 검증은 어느 API에서든 똑같습니다. 매번 손으로 쓸 이유가 없습니다.

의존성을 추가합니다.

1
implementation 'org.springframework.boot:spring-boot-starter-validation'

DTO에 규칙을 애노테이션으로 선언합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.demo.member;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record MemberCreateRequest(

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

        @NotBlank(message = "이메일은 필수입니다")
        @Email(message = "이메일 형식이 아닙니다")
        String email
) {}

자주 쓰는 것들입니다.

애노테이션 의미
@NotNull null이 아니어야 한다 (빈 문자열은 통과)
@NotBlank null도 아니고 공백만 있어도 안 된다 (문자열 전용)
@Email 이메일 형식이어야 한다
@Size(max = 20) 길이 제한
@Min / @Max 숫자 범위

jakarta.validation에서 가져옵니다. 예제에 javax.validation이 보인다면 Spring Boot 2.x 시절 코드입니다.

이제 컨트롤러 파라미터에 @Valid를 붙입니다.

1
2
3
4
5
@PostMapping
public MemberResponse join(@RequestBody @Valid MemberCreateRequest request) {
    Member member = memberService.join(request.name(), request.email());
    return MemberResponse.from(member);
}

@Valid 하나로 끝입니다. 스프링이 요청 JSON을 MemberCreateRequest로 바꾼 직후, 컨트롤러 메서드 본문에 들어오기 전에 규칙을 검사합니다. 통과하지 못하면 메서드는 아예 실행되지 않습니다.

if (name == null) 같은 줄이 전부 사라졌고, 검증 규칙은 DTO 선언부에 모여 있어 한눈에 보입니다.

확인 1: 검증에 걸리면 무슨 예외가 나오나

일부러 잘못된 값을 보냅니다. 이름은 빈 문자열, 이메일은 형식에 안 맞게 보냅니다.

1
2
3
curl -i -X POST localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"not-an-email"}'

서버 로그입니다.

1
2
3
4
5
6
7
8
9
10
WARN o.s.w.s.m.s.DefaultHandlerExceptionResolver :
Resolved [org.springframework.web.bind.MethodArgumentNotValidException:
Validation failed for argument [0] in public
com.example.demo.member.MemberResponse
com.example.demo.member.MemberController.join(
com.example.demo.member.MemberCreateRequest) with 2 errors:
[Field error in object 'memberCreateRequest' on field 'name':
 rejected value []; default message [이름은 필수입니다]]
[Field error in object 'memberCreateRequest' on field 'email':
 rejected value [not-an-email]; default message [이메일 형식이 아닙니다]]]

두 가지를 확인합니다.

  • 예외 이름이 MethodArgumentNotValidException 입니다. 이 이름을 알아야 아래에서 잡을 수 있습니다
  • 우리가 적은 메시지가 default message에 그대로 있고, rejected value에 어떤 값이 거절됐는지도 있습니다

그런데 클라이언트가 받는 응답은 이렇습니다.

1
HTTP/1.1 400
1
2
3
4
5
6
{
  "timestamp": "2026-07-30T02:31:02.117+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/members"
}

상태 코드는 400으로 알아서 잘 나갑니다. 여기까지는 @Valid가 해줬습니다. 그런데 서버 로그에는 있던 필드별 메시지가 응답에는 없습니다. 로그는 우리가 보는 것이고 응답은 클라이언트가 보는 것인데, 정작 필요한 쪽에 정보가 없습니다.

이걸 응답에 담는 게 다음 단계입니다.

실패 응답의 형태를 먼저 정한다

코드를 짜기 전에 “실패했을 때 무엇을 돌려줄지”를 정합니다. 세 가지를 만듭니다.

1. 에러 코드 목록 — 상태 코드와 메시지를 한 쌍으로 묶어 열거형에 모읍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public enum ErrorCode {

    INVALID_INPUT(HttpStatus.BAD_REQUEST, "M001", "입력값이 올바르지 않습니다"),
    MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M002", "회원을 찾을 수 없습니다"),
    DUPLICATE_EMAIL(HttpStatus.CONFLICT, "M003", "이미 가입된 이메일입니다"),
    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C001", "서버 오류가 발생했습니다");

    private final HttpStatus status;
    private final String code;
    private final String message;

    ErrorCode(HttpStatus status, String code, String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }

    public HttpStatus getStatus() { return status; }
    public String getCode() { return code; }
    public String getMessage() { return message; }
}

이 파일 하나만 보면 이 서비스가 낼 수 있는 실패가 전부 보입니다. 상태 코드를 바꿀 일이 생겨도 여기 한 줄만 고칩니다.

2. 우리가 던질 예외 — 에러 코드를 들고 다니는 예외를 만듭니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;

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

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

RuntimeException을 상속했습니다. throws를 선언하지 않아도 되고, 스프링이 트랜잭션을 롤백하는 기본 대상이기도 합니다.

3. 응답 그릇 — 실패 응답의 JSON 모양을 하나로 정합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public record ErrorResponse(
        String code,
        String message,
        List<FieldErrorDetail> errors
) {
    public static ErrorResponse of(ErrorCode errorCode) {
        return new ErrorResponse(errorCode.getCode(), errorCode.getMessage(), List.of());
    }

    public static ErrorResponse of(ErrorCode errorCode, List<FieldErrorDetail> errors) {
        return new ErrorResponse(errorCode.getCode(), errorCode.getMessage(), errors);
    }

    public record FieldErrorDetail(String field, String message) {}
}

errors는 검증 실패일 때만 채웁니다. 나머지 경우엔 빈 배열입니다. 어떤 실패든 응답의 키는 항상 code, message, errors 세 개입니다.

서비스는 이제 커스텀 예외를 던집니다.

1
2
3
4
5
6
public Member join(String name, String email) {
    if (memberRepository.existsByEmail(email)) {
        throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
    }
    return memberRepository.save(new Member(name, email));
}

한곳에서 처리하기

@RestControllerAdvice모든 컨트롤러에서 빠져나온 예외를 가로채는 클래스입니다. 여기에 @ExceptionHandler로 “이 예외는 이렇게 응답한다”를 적어둡니다.

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
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // 1. 우리가 의도적으로 던진 예외
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
        ErrorCode errorCode = e.getErrorCode();
        log.warn("business exception: {} - {}", errorCode.getCode(), e.getMessage());

        return ResponseEntity.status(errorCode.getStatus())
                .body(ErrorResponse.of(errorCode));
    }

    // 2. @Valid 검증 실패
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException e) {

        List<ErrorResponse.FieldErrorDetail> details = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(fieldError -> new ErrorResponse.FieldErrorDetail(
                        fieldError.getField(),
                        fieldError.getDefaultMessage()))
                .toList();

        return ResponseEntity.badRequest()
                .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, details));
    }

    // 3. 예상하지 못한 나머지 전부
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        log.error("unexpected exception", e);        // 스택트레이스는 로그에만

        return ResponseEntity.internalServerError()
                .body(ErrorResponse.of(ErrorCode.INTERNAL_ERROR));   // 응답에는 안 담는다
    }
}

두 번째 핸들러에서 e.getBindingResult().getFieldErrors()가 하는 일이 확인 1에서 로그로 봤던 그 내용입니다. Field error in object ... on field 'name'fieldError.getField()이고, default message [이름은 필수입니다]fieldError.getDefaultMessage()입니다. 로그에만 있던 정보를 응답으로 옮기는 것뿐입니다.

컨트롤러는 그대로 둡니다. try-catch가 하나도 없고 반환 타입도 MemberResponse로 명확합니다.

1
2
3
4
5
@PostMapping
public MemberResponse join(@RequestBody @Valid MemberCreateRequest request) {
    Member member = memberService.join(request.name(), request.email());
    return MemberResponse.from(member);
}

컨트롤러에는 성공했을 때의 경로만 남았습니다.

확인 2: 처리 전후 응답 비교

같은 요청 두 개를 처리 전후로 나란히 봅니다.

중복 이메일로 가입

1
2
3
curl -i -X POST localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"민아","email":"abcd1234@gmail.com"}'

처리 전

1
HTTP/1.1 500
1
2
3
4
5
6
{
  "timestamp": "2026-07-30T02:22:31.482+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/members"
}

처리 후

1
HTTP/1.1 409
1
2
3
4
5
{
  "code": "M003",
  "message": "이미 가입된 이메일입니다",
  "errors": []
}

검증 실패 (빈 이름 + 잘못된 이메일) — 확인 1과 같은 요청입니다.

처리 전

1
2
3
4
5
6
{
  "timestamp": "2026-07-30T02:31:02.117+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/members"
}

처리 후

1
2
3
4
5
6
7
8
{
  "code": "M001",
  "message": "입력값이 올바르지 않습니다",
  "errors": [
    { "field": "name",  "message": "이름은 필수입니다" },
    { "field": "email", "message": "이메일 형식이 아닙니다" }
  ]
}

클라이언트가 어느 입력창에 어떤 메시지를 띄울지 응답만 보고 정할 수 있게 됐습니다. errors[0].field"name"이니 이름 입력창 아래에 "이름은 필수입니다"를 띄우면 됩니다.

두 응답의 키가 code, message, errors로 똑같다는 점도 보세요. 클라이언트는 실패 응답을 파싱하는 코드를 한 번만 짜면 됩니다.

예상 못한 예외는 다르게 다룬다

핸들러 세 개는 역할이 다릅니다.

대상 로그 응답에 담는 것
의도한 예외 (BusinessException) warn 에러 코드와 메시지 그대로
검증 실패 안 남겨도 됨 어느 필드가 왜 틀렸는지
예상 못한 예외 (Exception) error + 스택트레이스 일반 메시지만

세 번째가 중요합니다. NullPointerException이나 SQLException의 메시지를 응답에 그대로 담으면 이런 게 나갑니다.

1
2
Table "MEMBER" not found; SQL statement:
select m1_0.id, m1_0.email, m1_0.name from member m1_0 where m1_0.id=?

테이블 이름, 컬럼 이름, 쿼리 구조가 전부 보입니다. 공격자에게 알려주기 좋은 정보입니다.

그래서 이렇게 나눕니다.

  • 로그에는 전부 — 우리가 고쳐야 하니까 스택트레이스까지 남깁니다. log.error("...", e)처럼 예외 객체를 마지막 인자로 넘기면 스택트레이스가 함께 남습니다
  • 응답에는 최소한만"서버 오류가 발생했습니다" 한 줄이면 충분합니다

@ExceptionHandler(Exception.class)는 말 그대로 모든 예외를 잡습니다. 그래서 구체적인 핸들러를 반드시 함께 둬야 합니다. 스프링은 더 구체적인 타입의 핸들러를 먼저 선택하므로, BusinessException 핸들러가 있으면 그쪽이 우선입니다. 이 핸들러 하나만 두면 검증 실패까지 전부 500으로 뭉뚱그려집니다.

정리

  • 처리하지 않은 예외는 500으로 나가고, 왜 실패했는지가 응답에 담기지 않는다
  • 상태 코드가 틀리면 클라이언트의 대응도 틀린다. 규칙 위반은 4xx, 서버 고장은 5xx다
  • 컨트롤러마다 try-catch를 넣으면 중복되고 응답 형태가 API마다 갈라진다
  • 형식 검증은 @Valid + DTO 애노테이션으로 옮긴다. 실패하면 MethodArgumentNotValidException이 발생한다
  • ErrorCode 하나만 보면 이 서비스가 낼 수 있는 실패가 전부 보인다
  • @RestControllerAdvice로 모으면 컨트롤러에는 성공 경로만 남고, 실패 응답의 키가 하나로 통일된다
  • 예상 못한 예외는 로그에 스택트레이스, 응답에는 일반 메시지로 나눈다

다음 8편에서는 지금까지 코드 안에 박아둔 값들을 설정 파일로 빼고, 환경마다 다르게 동작시키는 방법을 봅니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.