성공하는 요청만 생각하면 부족하다
지금까지는 게시글을 만들고, 조회하고, 저장하는 정상 흐름을 중심으로 봤습니다. 하지만 실제 사용자는 항상 올바른 값만 보내지 않습니다.
예를 들어 게시글 작성 API에 이런 요청이 들어올 수 있습니다.
{
"title": "",
"content": ""
}또는 존재하지 않는 게시글을 조회할 수도 있습니다.
GET /posts/9999좋은 애플리케이션은 성공할 때만 잘 동작하는 코드가 아닙니다. 실패했을 때도 이유를 알려 주고, 서버가 흔들리지 않게 막아 주는 코드입니다. 이번 장에서는 이 역할을 하는 입력 검증과 예외 처리를 배웁니다.
입력 검증은 문 앞에서 신분증을 확인하는 일과 비슷합니다. 안으로 들여보내기 전에 "필수 값이 있는지", "길이가 너무 길지 않은지", "형식이 맞는지"를 확인합니다. 예외 처리는 건물 안에서 문제가 생겼을 때 안내 데스크가 상황에 맞게 안내문을 주는 일과 비슷합니다.
AI 생성 이미지
Bean Validation으로 입력 규칙 선언하기
Bean Validation은 자바 객체의 필드에 검증 규칙을 붙이는 표준 방식입니다. Spring Boot에서는 요청 DTO에 애너테이션을 붙여서 사용할 수 있습니다.
먼저 build.gradle에 검증 의존성이 필요합니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
}Spring Boot 3 기준으로 검증 애너테이션은 jakarta.validation.constraints 패키지에서 가져옵니다.
게시글 작성 요청 DTO를 다음처럼 수정해 봅시다.
package com.example.board.post.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record PostCreateRequest(
@NotBlank(message = "제목은 필수입니다.")
@Size(max = 100, message = "제목은 100자 이하로 입력해 주세요.")
String title,
@NotBlank(message = "내용은 필수입니다.")
@Size(max = 5000, message = "내용은 5000자 이하로 입력해 주세요.")
String content
) {
}여기서 record는 값을 담는 DTO를 짧게 작성하는 자바 문법입니다. 일반 클래스로 작성해도 됩니다.
자주 쓰는 검증 애너테이션은 다음과 같습니다.
@NotNull: 값이null이면 안 됩니다.@NotBlank: 문자열이null, 빈 문자열, 공백만 있는 문자열이면 안 됩니다.@Size: 문자열이나 컬렉션의 길이, 개수를 제한합니다.@Email: 이메일 형식인지 확인합니다.@Min,@Max: 숫자의 최소값과 최대값을 제한합니다.
게시글 제목처럼 문자열 입력에는 보통 @NotBlank가 더 자연스럽습니다. @NotNull은 "" 같은 빈 문자열을 막지 못하기 때문입니다.
@Valid로 검증 실행하기
DTO에 규칙을 붙였다고 바로 검증이 실행되지는 않습니다. 컨트롤러에서 검증하고 싶은 요청 객체 앞에 @Valid를 붙여야 합니다.
package com.example.board.post.controller;
import com.example.board.post.dto.PostCreateRequest;
import com.example.board.post.dto.PostResponse;
import com.example.board.post.service.PostService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/posts")
public class PostApiController {
private final PostService postService;
public PostApiController(PostService postService) {
this.postService = postService;
}
@PostMapping
public ResponseEntity<PostResponse> createPost(
@Valid @RequestBody PostCreateRequest request
) {
PostResponse response = postService.createPost(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}이제 제목이나 내용이 비어 있으면 컨트롤러 메서드 본문이 실행되기 전에 검증 오류가 발생합니다. 즉, 잘못된 값이 서비스 계층까지 내려가지 못하게 막습니다.
이 구조는 중요합니다. 컨트롤러는 외부 요청의 입구입니다. 입구에서 기본적인 형식을 확인하면 서비스는 더 중요한 비즈니스 규칙에 집중할 수 있습니다.
BindingResult로 검증 결과 직접 다루기
검증 오류가 났을 때 스프링이 바로 예외를 던지게 할 수도 있지만, 컨트롤러에서 직접 검증 결과를 확인할 수도 있습니다. 이때 사용하는 객체가 BindingResult입니다.
BindingResult는 검증 결과가 적힌 검사표라고 생각하면 됩니다.
@PostMapping("/check")
public ResponseEntity<?> checkPost(
@Valid @RequestBody PostCreateRequest request,
BindingResult bindingResult
) {
if (bindingResult.hasErrors()) {
List<FieldErrorResponse> errors = bindingResult.getFieldErrors()
.stream()
.map(error -> new FieldErrorResponse(
error.getField(),
error.getDefaultMessage()
))
.toList();
return ResponseEntity.badRequest().body(errors);
}
return ResponseEntity.ok("입력값이 올바릅니다.");
}오류 응답에 사용할 간단한 DTO도 만들 수 있습니다.
public record FieldErrorResponse(
String field,
String message
) {
}잘못된 요청을 보내면 다음과 비슷한 JSON을 응답할 수 있습니다.
[
{
"field": "title",
"message": "제목은 필수입니다."
},
{
"field": "content",
"message": "내용은 필수입니다."
}
]주의할 점이 있습니다. BindingResult는 반드시 검증 대상 바로 뒤에 와야 합니다.
// 올바른 순서
public ResponseEntity<?> create(@Valid @RequestBody PostCreateRequest request,
BindingResult bindingResult) {
}중간에 다른 파라미터를 끼워 넣으면 스프링이 검증 결과를 제대로 연결하지 못할 수 있습니다. 또한 BindingResult를 사용하면 검증 오류를 이 메서드 안에서 직접 처리합니다. 반대로 BindingResult를 두지 않으면 검증 오류는 예외로 바뀌고, 뒤에서 만들 전역 예외 처리기가 처리하게 됩니다.
화면을 반환하는 @Controller에서도 원리는 같습니다.
@PostMapping("/posts/form")
public String createForm(
@Valid @ModelAttribute PostCreateRequest request,
BindingResult bindingResult
) {
if (bindingResult.hasErrors()) {
return "post-form";
}
postService.createPost(request);
return "redirect:/posts";
}API에서는 JSON으로 오류를 돌려주고, 화면에서는 같은 입력 화면으로 돌려보내 오류 메시지를 보여 줍니다. 표현 방식은 다르지만 핵심은 같습니다. 잘못된 입력을 서비스로 넘기지 않는 것입니다.
비즈니스 예외와 시스템 예외 구분하기
검증으로 모든 문제를 막을 수는 없습니다. 형식은 맞지만 업무 규칙상 처리할 수 없는 상황도 있습니다.
예를 들어 다음 상황을 봅시다.
제목이 비어 있음: 입력 검증 오류
존재하지 않는 게시글 조회: 비즈니스 예외
데이터베이스 연결 실패: 시스템 예외
개발자가 잘못 작성한 코드로
NullPointerException발생: 시스템 예외
비즈니스 예외는 애플리케이션 규칙상 예상할 수 있는 실패입니다. "없는 게시글입니다", "이미 사용 중인 이메일입니다" 같은 경우입니다.
시스템 예외는 서버 내부 문제에 가깝습니다. 데이터베이스 장애, 외부 API 장애, 코드 버그 등이 여기에 속합니다.
둘을 구분하는 이유는 응답 방식이 다르기 때문입니다. 비즈니스 예외는 사용자에게 비교적 구체적인 메시지를 줄 수 있습니다. 반면 시스템 예외는 내부 오류 내용을 그대로 노출하면 안 됩니다.
게시글을 찾지 못했을 때 사용할 예외를 만들어 봅시다.
package com.example.board.post.exception;
public class PostNotFoundException extends RuntimeException {
public PostNotFoundException(Long postId) {
super("게시글을 찾을 수 없습니다. id=" + postId);
}
}서비스에서는 null을 반환하지 말고 예외를 던집니다.
@Transactional(readOnly = true)
public PostResponse getPost(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
return PostResponse.from(post);
}null을 반환하면 호출한 쪽에서 매번 null 확인을 해야 합니다. 확인을 놓치면 다른 곳에서 더 알기 어려운 오류가 납니다. 반면 예외를 던지면 "이 지점에서 게시글이 없었다"는 사실이 분명해집니다.
@ControllerAdvice로 예외 응답 통일하기
각 컨트롤러마다 try-catch를 반복하면 코드가 금방 지저분해집니다. 스프링은 공통 예외 처리를 위해 @ControllerAdvice를 제공합니다.
@ControllerAdvice는 여러 컨트롤러 옆에 서 있는 공통 안내 데스크입니다. 컨트롤러에서 예외가 발생하면 이곳에서 잡아 응답으로 바꿉니다.
먼저 공통 오류 응답 DTO를 만듭니다.
package com.example.board.common.error;
import java.util.List;
public record ErrorResponse(
String code,
String message,
List<FieldErrorResponse> errors
) {
public static ErrorResponse of(String code, String message) {
return new ErrorResponse(code, message, List.of());
}
public static ErrorResponse of(
String code,
String message,
List<FieldErrorResponse> errors
) {
return new ErrorResponse(code, message, errors);
}
}필드 오류 DTO도 같은 패키지에 둘 수 있습니다.
package com.example.board.common.error;
public record FieldErrorResponse(
String field,
String message
) {
}이제 전역 예외 처리 클래스를 작성합니다.
package com.example.board.common.error;
import com.example.board.post.exception.PostNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.List;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex
) {
List<FieldErrorResponse> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new FieldErrorResponse(
error.getField(),
error.getDefaultMessage()
))
.toList();
ErrorResponse response = ErrorResponse.of(
"VALIDATION_ERROR",
"입력값을 확인해 주세요.",
errors
);
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleUnreadableMessage(
HttpMessageNotReadableException ex
) {
ErrorResponse response = ErrorResponse.of(
"INVALID_REQUEST_BODY",
"요청 본문을 읽을 수 없습니다. JSON 형식이나 값의 타입을 확인해 주세요."
);
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(PostNotFoundException.class)
public ResponseEntity<ErrorResponse> handlePostNotFound(
PostNotFoundException ex
) {
ErrorResponse response = ErrorResponse.of(
"POST_NOT_FOUND",
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
ErrorResponse response = ErrorResponse.of(
"INTERNAL_SERVER_ERROR",
"서버 내부 오류가 발생했습니다."
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}여기서 @RestControllerAdvice는 @ControllerAdvice에 @ResponseBody가 합쳐진 형태입니다. REST API에서 오류를 JSON으로 반환할 때 편리합니다.
MethodArgumentNotValidException은 @Valid 검증에 실패했을 때 주로 발생합니다. HttpMessageNotReadableException은 JSON 문법이 틀렸거나 숫자 자리에 문자열을 넣는 것처럼 요청 본문을 객체로 바꿀 수 없을 때 발생할 수 있습니다. 이런 요청도 사용자가 보낸 값의 문제이므로 보통 400 Bad Request로 응답하는 편이 자연스럽습니다.
마지막의 Exception 처리는 예상하지 못한 오류를 위한 안전망입니다. 다만 실무에서는 여기서 오류 로그를 꼭 남겨야 합니다. 사용자에게는 내부 내용을 숨기더라도, 개발자는 로그를 보고 원인을 찾아야 하기 때문입니다.
이제 컨트롤러는 성공 흐름에 집중할 수 있습니다. 검증 오류, 잘못된 요청 본문, 게시글 없음, 예상하지 못한 서버 오류는 GlobalExceptionHandler가 공통 형식으로 처리합니다.
오류 응답은 예측 가능해야 한다
클라이언트가 사용하기 좋은 API는 오류 응답 모양이 일정합니다.
예를 들어 검증 오류는 다음처럼 내려갈 수 있습니다.
{
"code": "VALIDATION_ERROR",
"message": "입력값을 확인해 주세요.",
"errors": [
{
"field": "title",
"message": "제목은 필수입니다."
}
]
}JSON 형식이 잘못되었거나 요청 본문을 읽을 수 없을 때는 다음처럼 응답할 수 있습니다.
{
"code": "INVALID_REQUEST_BODY",
"message": "요청 본문을 읽을 수 없습니다. JSON 형식이나 값의 타입을 확인해 주세요.",
"errors": []
}없는 게시글 조회는 다음처럼 내려갈 수 있습니다.
{
"code": "POST_NOT_FOUND",
"message": "게시글을 찾을 수 없습니다. id=9999",
"errors": []
}이렇게 응답 구조를 맞추면 프론트엔드나 모바일 앱에서도 오류를 처리하기 쉽습니다. code로 오류 종류를 판단하고, message를 사용자에게 보여 주며, errors로 필드별 입력 오류를 표시할 수 있습니다.
자주 만나는 실수
첫째, @Valid를 빼먹으면 DTO에 붙인 검증 애너테이션이 실행되지 않습니다.
둘째, Spring Boot 3에서는 javax.validation이 아니라 jakarta.validation을 사용해야 합니다. 자동 완성으로 import할 때 패키지를 확인하세요.
셋째, BindingResult의 위치가 중요합니다. 검증 대상 바로 뒤에 두어야 합니다.
넷째, JSON 문법 오류나 타입 불일치 오류는 Bean Validation까지 도달하지 못할 수 있습니다. 예를 들어 숫자를 받아야 하는 필드에 문자열을 보내면 DTO 검증 전에 요청 본문 변환 단계에서 실패할 수 있습니다. 이런 경우도 별도의 예외 처리로 400 Bad Request를 돌려주는 것이 좋습니다.
다섯째, 시스템 예외 메시지를 그대로 사용자에게 보여 주면 안 됩니다. 데이터베이스 정보나 서버 내부 구조가 노출될 수 있습니다. 예상하지 못한 예외는 "서버 내부 오류가 발생했습니다."처럼 일반적인 메시지로 감싸는 편이 안전합니다.
이번 장 정리
입력 검증은 잘못된 값이 애플리케이션 깊은 곳까지 들어가지 못하게 막는 첫 번째 방어선입니다. Bean Validation을 사용하면 @NotBlank, @Size 같은 애너테이션으로 규칙을 DTO에 선언할 수 있습니다.
BindingResult를 사용하면 검증 오류를 직접 확인해 화면이나 JSON 응답으로 전달할 수 있습니다. REST API에서는 @ControllerAdvice와 @ExceptionHandler를 함께 사용해 예외 응답을 공통 형식으로 통일하는 것이 좋습니다.
이제 게시판 API는 단순히 정상 요청만 처리하는 수준을 넘어섰습니다. 잘못된 입력, 잘못된 요청 본문, 없는 데이터, 예상하지 못한 오류까지 구분해서 응답할 수 있는 더 믿을 수 있는 애플리케이션이 되었습니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.