예상 읽기 시간: 20~30분
Day 1에서는 Spring Boot 프로젝트의 큰 지도를 잡았습니다. Day 2에서는 프론트엔드와 백엔드가 맞춰야 하는 API 계약, 즉 URL, HTTP Method, 요청 DTO, 응답 DTO를 읽었습니다.
오늘은 그 API 계약이 틀렸을 때 백엔드가 어떻게 반응해야 하는지 봅니다.
사용자가 빈 제목으로 글을 작성하거나, 잘못된 이메일을 입력하거나, 존재하지 않는 게시글을 열거나, 권한 없는 방에 접근할 때 백엔드는 그냥 "실패"만 말하면 부족합니다. 프론트엔드는 사용자가 무엇을 고쳐야 하는지 보여 줘야 하고, 개발자는 어떤 요청이 왜 실패했는지 추적할 수 있어야 합니다.
그래서 오늘의 핵심 질문은 이것입니다.
AI가 만든 백엔드가 잘못된 입력과 실패 상황을 예측 가능한 모양으로 돌려주는가?
오늘 글을 읽고 나면 아래 질문에 답할 수 있어야 합니다.
@Valid, @NotBlank, @Size, @Email 같은 어노테이션은 무엇을 보장하는가?400, 401, 403, 404, 409, 500 같은 HTTP 상태 코드는 어떻게 읽는가?@ControllerAdvice와 @ExceptionHandler는 왜 필요한가?입력 검증이라고 하면 처음에는 보안 기능처럼 느껴질 수 있습니다. 물론 보안과도 관련이 있습니다. 하지만 더 넓게 보면 검증은 서비스가 자기 규칙을 지키기 위한 입구입니다.
예를 들어 게시글 작성 화면을 생각해 봅시다.
{
"title": "",
"content": "오늘 공부한 내용",
"categoryId": 3
}
제목이 비어 있습니다. 이 요청을 그대로 저장하면 DB에는 제목 없는 게시글이 생깁니다. 화면에서는 목록이 이상하게 보일 수 있고, 검색이나 공유 링크에서도 문제가 생길 수 있습니다.
이때 백엔드가 해야 할 일은 단순합니다.
이 요청은 우리 서비스 규칙에 맞지 않는다.
저장하지 않는다.
프론트엔드가 사용자에게 보여 줄 수 있는 설명을 돌려준다.
검증은 사용자를 혼내기 위한 기능이 아닙니다. 잘못된 데이터가 서비스 안쪽으로 들어오지 못하게 막고, 사용자가 고칠 수 있는 방식으로 알려 주는 기능입니다.
AI가 만든 백엔드 코드를 볼 때도 이렇게 생각하면 됩니다.
Day 2에서 DTO는 API 입출력 전용 데이터 모양이라고 했습니다. 검증 규칙도 보통 요청 DTO에 붙습니다.
public record CreatePostRequest(
@NotBlank(message = "제목은 필수입니다.")
@Size(max = 100, message = "제목은 100자 이하여야 합니다.")
String title,
@NotBlank(message = "본문은 필수입니다.")
String content,
@NotNull(message = "카테고리는 필수입니다.")
Long categoryId
) {
}
여기서 문법을 전부 외울 필요는 없습니다. 읽을 때는 이렇게 보면 됩니다.
CreatePostRequest는 게시글 작성 요청의 모양이다.
title은 비어 있으면 안 되고 100자 이하여야 한다.
content는 비어 있으면 안 된다.
categoryId는 null이면 안 된다.
AI가 만든 코드에서 이런 어노테이션이 없으면 무조건 틀렸다고 말할 수는 없습니다. 하지만 의심할 수는 있습니다.
특히 아래 요청 DTO에는 검증 규칙이 없는지 확인하는 습관을 들이면 좋습니다.
사용자가 직접 입력하는 값이거나 서비스 상태를 바꾸는 요청이라면 검증 규칙이 필요할 가능성이 높습니다.
@Valid는 "이 DTO의 검증 규칙을 실행해라"라는 신호다DTO에 검증 어노테이션을 붙였다고 해서 항상 자동으로 실행되는 것은 아닙니다. Controller에서 그 DTO를 받을 때 보통 @Valid를 붙입니다.
@PostMapping("/api/posts")
public PostResponse createPost(@Valid @RequestBody CreatePostRequest request) {
return postService.createPost(request);
}
읽는 법은 이렇습니다.
POST /api/posts 요청이 오면 JSON 바디를 CreatePostRequest로 바꾼다.
그리고 CreatePostRequest에 붙어 있는 검증 규칙을 먼저 검사한다.
통과하면 postService.createPost로 넘긴다.
여기서 중요한 점은 순서입니다.
검증이 실패하면 보통 Service까지 가지 않습니다. 즉, 제목이 비어 있는 요청은 게시글 저장 로직으로 들어가기 전에 Controller 입구에서 막힙니다.
AI가 만든 코드를 리뷰할 때는 Controller와 DTO를 같이 봐야 합니다.
@Valid가 붙어 있는가?@RequestBody가 붙어 있어 JSON 바디를 읽는 구조인가?DTO만 보고 "검증이 있네"라고 끝내면 안 됩니다. Controller에서 실행 신호가 빠졌을 수도 있습니다.
백엔드 실패를 모두 하나로 보면 에러 처리가 복잡하게 느껴집니다. 먼저 두 종류를 나누면 훨씬 읽기 쉬워집니다.
요청 모양 자체가 잘못된 경우입니다.
제목이 비어 있음
이메일 형식이 아님
비밀번호가 너무 짧음
페이지 번호가 음수임
필수 값이 없음
이런 실패는 대체로 Controller 입구에서 잡힙니다. HTTP 상태 코드는 보통 400 Bad Request입니다.
요청 모양은 맞지만, 서비스 규칙상 처리할 수 없는 경우입니다.
존재하지 않는 게시글 ID
이미 사용 중인 이메일
삭제 권한이 없는 댓글
재고보다 많은 주문 수량
이미 마감된 모집글 신청
이런 실패는 Service에서 판단하는 경우가 많습니다. HTTP 상태 코드는 상황에 따라 다릅니다.
404 Not Found409 Conflict401 Unauthorized403 ForbiddenAI가 만든 코드를 볼 때는 실패 위치를 이렇게 추적하면 됩니다.
입력 모양 문제인가? -> DTO/Controller 검증
서비스 규칙 문제인가? -> Service 예외
DB 저장 중 문제인가? -> Repository/DB 예외, 보통 직접 노출하면 안 됨
이 구분이 되어 있으면 긴 코드에서도 어디를 봐야 하는지 덜 흔들립니다.
프론트엔드는 에러 응답을 받았을 때 상태 코드를 먼저 봅니다. 상태 코드는 사람이 읽는 문장보다 더 빠르게 "어떤 종류의 실패인지" 알려 줍니다.
자주 보는 상태 코드를 낮은 단계로 정리하면 이렇습니다.
| 상태 코드 | 의미 | 프론트엔드 관점 |
|---|---|---|
400 | 요청 자체가 잘못됨 | 입력값을 고치라고 안내 |
401 | 로그인/토큰이 없음 또는 만료 | 로그인 화면으로 보내거나 재로그인 요청 |
403 | 로그인은 했지만 권한이 없음 | 접근 불가 안내 |
AI가 만든 백엔드에서 모든 실패를 500으로 돌려주면 위험합니다. 프론트엔드는 사용자가 입력을 고쳐야 하는지, 다시 로그인해야 하는지, 없는 페이지로 간 것인지 알 수 없습니다.
반대로 모든 실패를 400으로 돌려주는 것도 좋지 않습니다. 존재하지 않는 게시글과 잘못된 이메일 형식은 같은 문제가 아닙니다.
리뷰할 때는 이런 질문을 던지면 됩니다.
400인가?404인가?409인가?401과 403을 구분하는가?성공 응답만 API 계약이 아닙니다. 에러 응답도 계약입니다.
나쁜 예를 먼저 봅시다.
"error"
또는 이런 응답도 프론트엔드 입장에서는 애매합니다.
{
"message": "잘못된 요청입니다."
}
무엇이 잘못되었는지, 어떤 필드를 고쳐야 하는지, 개발자가 로그와 연결할 수 있는 정보가 있는지 알기 어렵습니다.
조금 더 읽기 좋은 에러 응답은 이런 모양입니다.
{
"code": "VALIDATION_ERROR",
"message": "입력값을 확인해 주세요.",
"fields": [
{
"field": "title",
"message": "제목은 필수입니다."
},
{
"field": "categoryId",
"message": "카테고리는 필수입니다."
}
]
}
이 응답을 프론트엔드는 이렇게 사용할 수 있습니다.
title 필드 아래에 "제목은 필수입니다." 표시
categoryId 선택 영역 아래에 "카테고리는 필수입니다." 표시
상단에는 "입력값을 확인해 주세요." 표시
즉, 에러 응답은 사용자 경험과 직접 연결됩니다.
AI가 만든 코드에서 에러 응답 DTO를 찾을 때는 보통 이런 이름을 찾으면 됩니다.
ErrorResponseApiErrorResponseErrorDtoFieldErrorResponseProblemDetailSpring Boot 3에서는 ProblemDetail을 쓰는 코드도 볼 수 있습니다. 이름이 무엇이든 핵심은 같습니다.
에러 응답의 모양이 일관적인가?
프론트엔드가 필드별 메시지를 읽을 수 있는가?
내부 예외 메시지나 stack trace를 그대로 내보내지 않는가?
@ControllerAdvice는 에러 처리의 중앙 안내 데스크다여러 Controller가 있으면 에러 처리 코드가 흩어지기 쉽습니다.
PostController에서 검증 실패 처리
UserController에서 검증 실패 처리
CommentController에서 검증 실패 처리
...
이렇게 되면 Controller마다 에러 응답 모양이 달라질 수 있습니다. 어떤 API는 { message: ... }, 어떤 API는 { error: ... }, 어떤 API는 그냥 문자열을 반환할 수도 있습니다.
Spring에서는 전역 예외 처리 클래스를 만들어 이런 처리를 한 곳으로 모을 수 있습니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
ErrorResponse response = ErrorResponse.from(ex);
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(PostNotFoundException.class)
public ResponseEntity<ErrorResponse> handlePostNotFound ex
읽는 법은 이렇습니다.
어떤 Controller에서든 MethodArgumentNotValidException이 발생하면 handleValidation이 처리한다.
어떤 Controller에서든 PostNotFoundException이 발생하면 handlePostNotFound가 처리한다.
각 예외는 정해진 상태 코드와 정해진 ErrorResponse 모양으로 바뀐다.
AI가 만든 코드에서 GlobalExceptionHandler, ExceptionHandler, ControllerAdvice 같은 이름을 찾으면 에러 처리의 중심을 찾는 데 도움이 됩니다.
예를 들어 게시글 상세 조회를 생각해 봅시다.
public PostResponse getPost(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
return PostResponse.from(post);
}
이 코드는 문법보다 흐름을 읽으면 됩니다.
postId로 게시글을 찾는다.
없으면 PostNotFoundException을 던진다.
있으면 PostResponse로 바꿔서 반환한다.
여기서 PostNotFoundException은 Service가 판단한 비즈니스 실패입니다. 그리고 이 예외는 GlobalExceptionHandler에서 404 Not Found 응답으로 바뀌어야 합니다.
흐름을 연결하면 이렇게 됩니다.
Controller
-> Service 호출
-> Repository에서 게시글 찾기
-> 없음
-> PostNotFoundException 발생
-> GlobalExceptionHandler가 잡음
-> 404 + ErrorResponse 반환
AI 코드 리뷰에서는 이 연결이 끊겨 있지 않은지 확인해야 합니다.
AI가 만든 코드가 겉으로 보기에는 그럴듯해도, 실패 케이스 테스트가 없으면 중요한 버그가 남기 쉽습니다.
예를 들어 게시글 작성 API의 성공 테스트만 있으면 이런 문제를 놓칠 수 있습니다.
400이어야 하는데 500이 나온다.Spring Boot에서는 Controller 테스트에서 이런 실패를 확인할 수 있습니다.
@Test
void 제목이_비어있으면_400을_반환한다() throws Exception {
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "",
"content": "본문",
"categoryId": 1
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").
이 테스트를 직접 다 작성할 필요는 없습니다. 하지만 AI가 테스트를 만들어 줬다면 읽을 수 있어야 합니다.
읽는 포인트는 네 가지입니다.
어떤 요청을 보냈는가?
어떤 잘못된 값을 넣었는가?
어떤 상태 코드를 기대하는가?
응답 JSON의 어떤 필드를 확인하는가?
테스트 이름이 한국어든 영어든 핵심은 같습니다. 실패 케이스가 테스트에 포함되어 있는지 확인해야 합니다.
AI가 만든 Spring Boot 코드에서 자주 보는 문제를 체크리스트처럼 정리해 보겠습니다.
@Valid가 없다DTO에 @NotBlank가 있어도 Controller에서 검증을 실행하지 않으면 기대한 대로 동작하지 않을 수 있습니다.
확인 질문:
@RequestBody 앞이나 요청 DTO 앞에 @Valid가 있는가?
RuntimeException으로 던진다throw new RuntimeException("게시글을 찾을 수 없습니다.");
이렇게만 되어 있으면 전역 핸들러에서 어떤 상태 코드로 바꿔야 할지 구분하기 어렵습니다.
확인 질문:
PostNotFoundException, DuplicateEmailException처럼 의미 있는 예외 타입이 있는가?
DB 내부 메시지에는 테이블명, 컬럼명, 제약조건 이름 같은 정보가 들어갈 수 있습니다. 사용자에게 그대로 보여 줄 필요가 없습니다.
확인 질문:
내부 예외 메시지를 그대로 response.message에 넣고 있지 않은가?
프론트엔드는 에러 응답 모양이 일정해야 공통 처리 코드를 만들 수 있습니다.
확인 질문:
실패 응답이 항상 ErrorResponse 또는 같은 형식으로 내려오는가?
검증 실패는 필드별 안내가 중요합니다. 그런데 전역 핸들러가 모든 검증 실패를 "잘못된 요청입니다" 하나로만 바꾸면 화면에서 구체적인 안내를 하기 어렵습니다.
확인 질문:
fields 배열 또는 fieldErrors 같은 구조로 어떤 필드가 문제인지 내려오는가?
로그인하지 않은 사용자는 401, 로그인했지만 권한이 없는 사용자는 403으로 나누는 것이 보통입니다.
확인 질문:
토큰 없음/만료와 권한 부족을 구분하는가?
진규가 프론트엔드 작업을 하면서 백엔드 코드를 리뷰해야 한다면, 에러 처리는 특히 화면과 연결해서 보면 좋습니다.
예를 들어 프론트엔드 코드가 이런 응답을 기대한다고 해 봅시다.
type ApiError = {
code: string;
message: string;
fields?: { field: string; message: string }[];
};
그런데 백엔드가 실제로는 이렇게 돌려주면 문제가 생깁니다.
{
"errorCode": "VALIDATION_ERROR",
"errorMessage": "입력값을 확인해 주세요."
}
의미는 비슷하지만 이름이 다릅니다. 프론트엔드의 공통 에러 처리 코드는 code와 message를 찾는데, 백엔드는 errorCode와 errorMessage를 보내고 있습니다.
이런 문제는 백엔드 문법을 몰라도 잡을 수 있습니다. API 계약 관점에서 보면 됩니다.
프론트엔드 타입 이름과 백엔드 JSON 필드 이름이 같은가?
필드별 에러 배열 이름이 같은가?
상태 코드별로 프론트엔드 분기와 백엔드 응답이 맞는가?
에러 응답은 성공 응답보다 덜 신경 쓰기 쉽지만, 실제 사용자는 에러 상황에서 더 많은 안내를 필요로 합니다.
긴 백엔드 코드를 처음부터 끝까지 읽으려고 하면 힘듭니다. 대신 실패 요청 하나를 정해서 따라가면 구조가 보입니다.
예를 들어 "빈 제목으로 게시글 작성" 요청을 따라갑니다.
1. POST /api/posts로 요청이 들어온다.
2. Controller가 CreatePostRequest를 받는다.
3. @Valid가 CreatePostRequest의 @NotBlank를 실행한다.
4. title이 비어 있어서 MethodArgumentNotValidException이 발생한다.
5. GlobalExceptionHandler가 예외를 잡는다.
6. ErrorResponse(code=VALIDATION_ERROR, fields=[title...])를 만든다.
7. HTTP 400으로 프론트엔드에 반환한다.
8. 프론트엔드는 title 입력칸 아래에 메시지를 보여 준다.
이 흐름 중 하나라도 빠지면 실제 화면에서 어색한 버그가 생길 수 있습니다.
@Valid가 빠지면 저장 로직까지 들어갈 수 있습니다.AI에게 백엔드 코드를 맡겼다면, 이런 식으로 대표 실패 케이스를 하나씩 추적해 달라고 시키는 것도 좋습니다.
백엔드 코드를 생성한 뒤 AI에게 막연히 "에러 처리 괜찮아?"라고 묻기보다, 조금 더 구체적으로 요청하면 결과가 좋아집니다.
예시 프롬프트입니다.
이 Spring Boot API에서 게시글 작성 요청의 검증/에러 흐름을 검토해 줘.
특히 아래를 확인해 줘.
1. 요청 DTO에 필수값/길이 검증이 있는지
2. Controller에서 @Valid가 실제로 적용되는지
3. 검증 실패가 400 + 일관된 ErrorResponse로 내려가는지
4. 존재하지 않는 categoryId가 404 또는 적절한 비즈니스 에러로 처리되는지
5. 프론트엔드가 필드별 메시지를 표시할 수 있는지
6. 실패 케이스 테스트가 있는지
문법 설명보다 실제 요청 흐름 기준으로 문제를 찾아 줘.
이런 요청은 "코드 예쁘게 고쳐 줘"보다 훨씬 실무적인 결과를 줍니다. AI가 만든 코드를 다시 AI로 검토할 때도 기준이 필요합니다.
AI가 만든 Spring Boot 백엔드에서 검증과 에러 응답을 볼 때는 아래 순서로 확인하면 됩니다.
[요청 DTO]
- 필수값에 @NotNull, @NotBlank 등이 있는가?
- 문자열 길이와 숫자 범위가 표현되어 있는가?
- 사용자 입력이 많은 API일수록 검증이 빠져 있지 않은가?
[Controller]
- 요청 DTO에 @Valid가 붙어 있는가?
- @RequestBody, @PathVariable, @RequestParam의 위치가 API 계약과 맞는가?
[Service]
- 없는 데이터, 중복, 권한 부족 같은 비즈니스 실패를 구분하는가?
- 의미 있는 커스텀 예외를 쓰는가?
[GlobalExceptionHandler]
- 검증 실패를 400으로 처리하는가?
- 비즈니스 예외를 상황에 맞는 상태 코드로 바꾸는가?
- 에러 응답 모양이 API마다 일관적인가?
[프론트엔드 연결]
- 에러 JSON 필드 이름이 프론트엔드 타입과 맞는가?
- 필드별 메시지를 화면에 표시할 수 있는가?
[테스트]
- 성공 케이스만 있는 것이 아니라 실패 케이스도 있는가?
- 상태 코드와 응답 JSON을 같이 검증하는가?
오늘은 백엔드의 검증과 에러 응답을 봤습니다. 이 주제는 문법보다 서비스 품질에 가깝습니다. 사용자가 잘못 입력했을 때 무엇을 알려 줄지, 프론트엔드가 어떤 기준으로 화면을 바꿀지, 개발자가 어떤 실패를 추적할 수 있을지가 모두 여기서 결정됩니다.
핵심은 네 가지입니다.
@Valid는 그 규칙을 실제로 실행하게 한다.다음 Day에서는 DB 접근을 읽기 위한 최소 지도를 잡겠습니다. Entity, Repository, JPA, 테이블, 연관관계가 어떤 식으로 연결되는지 보고, AI가 만든 DB 관련 코드에서 어디를 조심해야 하는지 살펴보겠습니다.
404| 대상이 없음 |
| 없는 글/없는 페이지 안내 |
409 | 현재 상태와 충돌 | 이미 사용 중, 이미 처리됨 등 안내 |
500 | 서버 내부 오류 | 사용자에게 일반 오류 안내, 개발자는 로그 확인 |