스프링 초급 (7) - Validation & Exception: 실패를 한곳에서 처리하기
처리하지 않은 예외가 500으로 나가는 문제에서 출발해 @Valid와 @RestControllerAdvice로 실패 응답을 통일하는 과정을 다룹니다.
스프링 초급 시리즈의 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가 뭘 주는지 코드만 봐서는 알 수 없습니다
이 방법은 오래 붙잡지 않고 넘어갑니다. 대신 두 가지를 나눠서 해결합니다.
- 형식 검증 — “이메일 형식인가”, “이름이 비었나” →
@Valid로 컨트롤러 앞에서 거른다 - 예외 처리 — 그래도 발생한 예외 →
@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편에서는 지금까지 코드 안에 박아둔 값들을 설정 파일로 빼고, 환경마다 다르게 동작시키는 방법을 봅니다.