지금까지 만든 TaskBoard에 이런 요청을 한번 던져 본다고 해 보자.
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": ""}'
제목이 텅 비어 있다. 누가 봐도 잘못된 요청이다. 그런데 서버는 어떻게 반응할까? 운이 나쁘면 빈 제목짜리 할 일이 아무렇지 않게 목록에 들어앉는다. 운이 더 나쁘면 어딘가에서 NullPointerException이 터지고, 클라이언트에는 500 에러와 함께 자바 스택 트레이스가 주르륵 쏟아진다. React 앱에서 이 응답을 받았다고 상상해 보자. 사용자에게 뭐라고 보여 줄 것인가? “서버에서 java.lang.NullPointerException이 발생했습니다”라고? 생각만 해도 아찔하다.
지금까지 만든 API는 분명 동작은 한다. 정상적인 요청을 주면 정상적인 응답을 돌려준다. 하지만 딱 거기까지다. 잘못된 요청을 어떻게 거를지, 에러가 났을 때 클라이언트에게 무슨 말을 돌려줄지, 정작 내 React 앱에서 이 API를 부르면 왜 빨간 CORS 에러가 뜨는지, 이 셋은 아직 손도 대지 않았다. 동작하지만 엉성한 API다. 이번 장에서 그 엉성함을 걷어내고, 험한 요청이 와도 품위를 잃지 않는 견고한 API로 키워 보자. 마지막에는 그 견고함을 눈이 아니라 코드로 증명하는 첫 테스트까지 작성한다.
ch05-1 역할별 패키지로 나누기명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl http://localhost:8080/api/tasks응답은 4장과 같다.
엔티티를 그대로 내보내면 안 될까? — DTO라는 칸막이
먼저 작은 질문 하나. 4장에서 만든 Task라는 객체를 요청을 받을 때도 쓰고 응답을 돌려줄 때도 쓰면 안 될까? 어차피 같은 할 일인데, 굳이 따로 만들 이유가 있을까?
원문 보정 · 앱 보충4장까지의 TaskBoard는 할 일을
List<String>, 곧 문자열 목록으로만 다뤘다.Task객체는 아직 만들지 않았다. 여기서는 할 일 하나를 나타내는Task클래스가 있다고 가정하고 읽자. 데이터베이스와 연결된Task엔티티는 6장에서 만든다.
Task 객체를 요청 바디로도, 응답 바디로도 그대로 쓰면 어떤 일이 생길 수 있을까?
그래서 칸막이를 하나 세운다. 데이터를 담아 계층 사이를 오가는 전용 객체, DTO(Data Transfer Object)다. 안에서 데이터를 다루는 객체(엔티티)와 바깥과 주고받는 객체(DTO)를 갈라놓는다. 요청을 받는 DTO와 응답을 돌려주는 DTO도 따로 둔다.
자바 17부터 들어온 레코드(record)를 쓰면 이런 DTO를 아주 간결하게 만들 수 있다. 먼저 요청용 DTO다.
package com.example.taskboard.dto;
public record TaskCreateRequest(String title, String description) {
}
이게 전부다. record는 “값을 담는 것이 전부인 불변 객체”를 한 줄로 선언하게 해 준다. 프런트에서 요청 바디의 모양을 타입스크립트 interface로 정의하던 그 감각과 비슷하다. “이 엔드포인트는 title과 description을 받는다”는 계약을 코드로 못 박는 셈이다.
응답용 DTO도 만들자.
package com.example.taskboard.dto;
public record TaskResponse(Long id, String title, String description, boolean done) {
}
요청용엔 id가 없고 응답용엔 있다는 점에 주목하자. id는 서버가 정하는 값이지 클라이언트가 보내는 값이 아니기 때문이다. 들어오는 모양과 나가는 모양을 따로 설계할 수 있다는 점이 DTO를 나누는 가장 큰 실익이다. 기억해 두자. 검증과 노출의 경계는 엔티티가 아니라 DTO에서 그어야 한다.
interface TaskCreateRequest { title: string; description?: string }record TaskCreateRequest(String title, String description)id 등)는 Spring Boot 기본 설정에서 에러 없이 조용히 버려진다.ch05-2 DTO와 POST /api/tasks (아직 검증 없음)명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun1. 서버가 정한 id. 요청에 "id": 999를 끼워 보낸다.
curl -i -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"id": 999, "title": "Spring 공부", "description": "5장"}'HTTP/1.1 201
{"id":1,"title":"Spring 공부","description":"5장","done":false}
TaskCreateRequest에 id가 없으니 999는 에러 없이 조용히 버려지고, 응답의 id는 서버가 정한 값이다. 이게 요청 DTO와 응답 DTO를 나누는 이유다.
2. 아직 엉성한 곳. 빈 제목을 보낸다.
curl -i -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": ""}'HTTP/1.1 201
{"id":2,"title":"","description":null,"done":false}
빈 제목짜리 할 일이 아무렇지 않게 만들어졌다. 다음 단계에서 막는다.
@Valid — 검증을 프레임워크에 맡기자
칸막이를 세웠으니 이제 그 자리에서 험한 요청을 걸러 내자. 처음의 빈 제목 문제로 돌아가 보자. title이 비어 있으면 거절해야 한다. 가장 손쉽게 떠오르는 방법은 컨트롤러 안에서 직접 if로 검사하는 것이다.
if (request.title() == null || request.title().isBlank()) {
// 에러 처리...
}
이렇게 해도 동작은 한다. 하지만 검증 규칙이 한둘이 아니라면? 제목은 1자 이상 100자 이하, 설명은 500자 이하, 게다가 엔드포인트마다 비슷한 검사가 반복된다면? 컨트롤러는 금세 if 덩어리로 뒤덮이고, 정작 중요한 비즈니스 로직은 그 틈에 파묻힌다. 번거롭고, 빠뜨리기 쉽고, 무엇보다 지저분하다.
그렇다면 어떻게 해야 할까? 이 반복을 프레임워크에 맡기는 방법이 있다. Jakarta Bean Validation(구현체는 Hibernate Validator)이다. DTO의 각 필드에 “이 필드는 이런 조건을 만족해야 한다”는 표식만 붙여 두면, 검증은 Spring이 알아서 해 준다. 앞서 만든 요청 DTO에 규칙을 붙여 보자.
package com.example.taskboard.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TaskCreateRequest(
@NotBlank(message = "제목은 비어 있을 수 없습니다.")
@Size(max = 100, message = "제목은 100자를 넘을 수 없습니다.")
String title,
@Size(max = 500, message = "설명은 500자를 넘을 수 없습니다.")
String description
) {
}
import 문을 잠깐 눈여겨보자. javax가 아니라 jakarta.validation.constraints다. 3장에서 약속한 신선도 감각이 여기서도 살아 있어야 한다. (이 어노테이션을 쓰려면 build.gradle에 spring-boot-starter-validation 의존성이 필요하다. 3장에서 Spring Web만 넣었다면 여기서 한 줄 더 보태자.)
z.object({ title: z.string().trim().min(1).max(100) })@NotBlank @Size(max = 100) String titleschema.parse()를 직접 불러야 검사한다. Bean Validation은 어노테이션만 달아서는 아무 일도 없고, 컨트롤러 파라미터에 @Valid가 붙어야 검사가 돈다. 빠뜨려도 컴파일은 되고 요청은 그냥 통과한다.이제 컨트롤러에서 이 검증을 발동시키자. 받는 파라미터 앞에 @Valid를 붙이기만 하면 된다.
package com.example.taskboard.controller;
import com.example.taskboard.dto.TaskCreateRequest;
import com.example.taskboard.dto.TaskResponse;
import com.example.taskboard.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping
public ResponseEntity<TaskResponse> create(@Valid @RequestBody TaskCreateRequest request) {
TaskResponse created = taskService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
생성자 주입으로 TaskService를 받는 모양은 4장에서 익힌 그대로다. 새로 등장한 건 @Valid @RequestBody다. @RequestBody는 “요청 바디의 JSON을 이 객체로 바꿔 달라”는 뜻이고(이 변환은 Jackson이 맡는다. 프런트에서 await res.json()으로 손수 파싱하던 일을 서버에선 자동으로 해 준다), @Valid는 “그렇게 만든 객체가 DTO에 적힌 규칙을 지키는지 검사해 달라”는 뜻이다.
빈 제목을 POST했다. create() 메서드 안의 taskService.create(request)는 실행될까?
ch05-3 Bean Validation과 @Valid명령과 기대 출력 펼치기
실행해 보기
build.gradle이 바뀌었으니 의존성을 새로 받으며 뜬다.
./gradlew bootRun
curl -i -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": ""}'HTTP/1.1 400
{"timestamp":"…","status":400,"error":"Bad Request","path":"/api/tasks"}
빈 제목이 이제 막힌다. create() 본문은 한 줄도 실행되지 않았다. Spring이 JSON을 DTO로 바꾸고 검사까지 마친 뒤에야 메서드를 부르기 때문이다.
서버 로그에는 MethodArgumentNotValidException이 WARN으로 남는다.
그런데 응답만 봐서는 어느 필드가 왜 틀렸는지 알 수 없다. 프런트는 입력 칸 아래에 무슨 메시지를 띄워야 할까? 다음 단계에서 에러의 모양을 정한다.
직접 깨뜨려 보기: @Valid를 빠뜨리면
TaskController에서 @Valid만 지우고 다시 띄운 뒤 같은 요청을 보낸다. 컴파일도 되고, 요청도 201로 통과한다.
DTO에 규칙을 달아 두는 것만으로는 아무 일도 일어나지 않는다. 검사는 @Valid가 있어야 돈다. 확인했으면 git checkout -- .으로 되돌린다.
ResponseEntity와 상태코드 — 응답을 내가 설계한다
여기서 1장의 이야기를 다시 꺼내자. 그때 “상태코드는 받는 것이 아니라 내가 결정하는 책임”이라고 했다. 이제 그 책임을 실제로 행사할 때다.
방금 컨트롤러 코드를 다시 보자. 반환 타입이 그냥 TaskResponse가 아니라 ResponseEntity<TaskResponse>다. 이 ResponseEntity가 “응답 전체를 내 손으로 빚는” 도구다. 바디뿐 아니라 상태코드와 헤더까지 내가 직접 정해 실어 보낼 수 있다.
새 할 일을 만들었을 때 돌려준 상태코드를 보자. HttpStatus.CREATED, 즉 201이다. 그냥 200이 아니다. 왜 굳이 구별할까? 프런트 입장에서 생각해 보자. 응답 바디를 일일이 까 보지 않아도 상태코드 201만 보고 “아, 새로 만들어졌구나” 하고 알 수 있다면 얼마나 깔끔한가. 상태코드는 그 자체로 하나의 약속이고 언어다.
1장에서 본 상태코드 표(200·201·400·404 등)를 이제 ResponseEntity로 직접 실어 보내는 셈이다. 생성엔 201, 조회·수정엔 200, 검증 실패엔 400, 없는 자원엔 404. 여기에 하나만 새로 더하자. 409 Conflict — 요청은 멀쩡하지만 현재 상태와 충돌할 때(예: 이미 있는 것을 또 만들려 할 때) 쓴다. 이걸 “받는 코드”가 아니라 “내가 의도적으로 고르는 코드”로 보기 시작하면, API 설계의 결이 완전히 달라진다. 프런트에서 res.status를 읽어 분기하던 우리가, 이제는 그 status를 결정하는 쪽에 선 것이다.
전역 예외 처리 — 에러도 일관된 모양으로
자, 가장 찜찜했던 지점으로 돌아가자. 검증에 실패하면 MethodArgumentNotValidException이 던져진다고 했다. 그런데 이 예외를 아무 데서도 받아 주지 않으면, Spring의 기본 에러 처리로 흘러가 두서없는 응답이 된다. 어떨 땐 장황한 자바 메시지가, 어떨 땐 빈 응답이 클라이언트에 전달된다. 엔드포인트마다 에러 모양이 제각각이라면, React 앱의 에러 처리 코드는 어디에 장단을 맞춰야 할지 난감해진다.
그렇다면 어떻게 해야 할까? 에러 응답의 모양을 한 군데에서 통일하면 된다. 컨트롤러마다 try-catch를 흩뿌리지 말고, 모든 컨트롤러의 예외를 한곳에서 가로채는 장치를 두자. 그 장치가 @RestControllerAdvice다.
먼저 클라이언트에게 돌려줄 에러 응답의 모양부터 DTO로 정하자.
package com.example.taskboard.dto;
import java.time.LocalDateTime;
import java.util.Map;
public record ErrorResponse(
LocalDateTime timestamp,
int status,
String message,
Map<String, String> fieldErrors
) {
}
타임스탬프, 상태코드, 사람이 읽을 메시지, 그리고 필드별로 무엇이 잘못됐는지 알려 주는 지도. 프런트는 이제 어떤 에러가 와도 이 네 가지를 기대하면 된다. 약속이 고정되면 클라이언트 쪽 에러 처리가 훨씬 단순해진다.
이제 예외를 받아 이 모양으로 빚어내는 핸들러를 만들자.
package com.example.taskboard.exception;
import com.example.taskboard.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
ErrorResponse body = new ErrorResponse(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST.value(),
"입력값 검증에 실패했습니다.",
fieldErrors
);
return ResponseEntity.badRequest().body(body);
}
}
코드가 조금 길어 보여도 하는 일은 단순하다. @RestControllerAdvice는 “이 클래스는 모든 컨트롤러를 지켜보다가 예외를 가로채는 곳이다”라는 표식이고, @ExceptionHandler(MethodArgumentNotValidException.class)는 “그중에서도 검증 실패 예외가 던져지면 이 메서드가 처리한다”는 뜻이다. 메서드 안에서는 깨진 규칙들을 모아 필드별 메시지로 정리하고, 400 상태코드와 함께 일관된 모양으로 돌려준다.
이제 다시 빈 제목으로 요청을 던지면, 클라이언트는 이런 깔끔한 응답을 받는다.
{
"timestamp": "2026-05-25T14:30:00",
"status": 400,
"message": "입력값 검증에 실패했습니다.",
"fieldErrors": {
"title": "제목은 비어 있을 수 없습니다."
}
}
처음의 끔찍한 스택 트레이스와 비교하면 천지 차이다. 프런트는 fieldErrors.title을 그대로 입력 칸 아래 에러 메시지로 띄우면 된다. 검증 실패 말고 404·409 같은 예외도 @ExceptionHandler를 하나씩 더 얹어 처리할 수 있다. 핵심은 하나다. 에러의 모양을 한곳에서 다스리자. 그래야 클라이언트가 신뢰할 수 있는 약속이 된다.
이 장의 TaskCreateRequest, GlobalExceptionHandler가 붙은 서버에 JSON을 직접 보내 보세요. 본문은 고쳐 써도 됩니다.
요청 본문 (JSON)
응답
- 해볼 것
- 빈 제목을 보내 필드별 메시지가 담긴 400 받기
id를 몰래 실어 보내도 서버가 정한 id가 돌아오는 것 확인하기@Valid를 떼고 빈 제목이 201로 들어앉는 것 보기- JSON 문법을 깨뜨려, 우리 핸들러가 잡지 못하는 400 보기
- 핸들러를 끄고 검증 실패 응답의 모양 비교하기
ch05-4 전역 예외 처리로 에러 모양 통일명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl -i -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": ""}'HTTP/1.1 400
{"timestamp":"…","status":400,"message":"입력값 검증에 실패했습니다.","fieldErrors":{"title":"제목은 비어 있을 수 없습니다."}}
이전 단계의 {"error":"Bad Request"}와 비교해 보자. 프런트는 이제 fieldErrors.title을 그대로 입력 칸 아래에 띄우면 된다.
규칙이 여러 개 깨지면 fieldErrors에 필드마다 하나씩 모인다. 제목을 101자, 설명을 501자로 보내 보자.
curl -s -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d "{\"title\": \"$(printf 'a%.0s' $(seq 101))\", \"description\": \"$(printf 'b%.0s' $(seq 501))\"}"{"timestamp":"…","status":400,"message":"입력값 검증에 실패했습니다.","fieldErrors":{"description":"설명은 500자를 넘을 수 없습니다.","title":"제목은 100자를 넘을 수 없습니다."}}왜 브라우저만 막고 curl은 통과할까 — CORS는 서버의 책임
이제 1장에서 심어 둔 씨앗을 거둘 차례다. 1장에서는 프런트 개발자라면 누구나 한 번쯤 본 빨간 콘솔 에러 blocked by CORS policy를 “증상”으로만 꺼내 두고, “이걸 막는 것도 푸는 것도 결국 서버의 책임”이라는 말과 함께 5장으로 미뤄 두었다. 이제 약속한 자리에 왔다.
상황을 그려 보자. React 개발 서버는 localhost:3000에서 돌고, 방금 만든 Spring 서버는 localhost:8080에서 돈다. React 앱에서 fetch('http://localhost:8080/api/tasks')를 부르면 콘솔에 그 익숙한 빨간 줄이 뜬다. 요청이 막힌 것이다. 그런데 묘한 점이 하나 있다. 똑같은 주소를 curl이나 Postman으로 때리면 멀쩡히 응답이 온다. 같은 서버, 같은 엔드포인트인데 브라우저에서만 막힌다. 왜 그럴까?
React 앱의 fetch('http://localhost:8080/api/tasks')가 CORS 에러로 막혔다. 이 GET 요청은 Spring 서버에 도착했을까?
그렇다면 이 빗장을 어떻게 풀까? 빗장을 거는 건 브라우저지만, “이 출처는 믿어도 된다”고 허락해 주는 건 서버다. 서버가 응답에 “나는 localhost:3000에서 오는 요청을 허용한다”는 헤더를 실어 보내면, 브라우저는 그제야 빗장을 풀고 자바스크립트가 응답을 읽도록 허락한다. 1장에서 심은 씨앗, “막는 것도 푸는 것도 서버의 책임”이 이런 뜻이었다.
Spring에서 이 허락을 선언하는 방법은 둘이다. 특정 컨트롤러나 메서드 위에 @CrossOrigin을 붙이거나, CorsConfigurationSource라는 빈을 하나 두어 전역으로 한 번에 정하는 것이다. 좁은 범위만 잠깐 열 때는 @CrossOrigin이 편하지만, 이 책은 전역 설정을 권한다. CORS 정책은 한곳에서 일관되게 다스려야 빠뜨리거나 어긋나는 일이 없기 때문이다.
package com.example.taskboard.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
setAllowedOrigins로 “이 출처에서 오는 요청은 허용한다”고 못 박는다. 여기선 React 개발 서버인 localhost:3000을 적었다. setAllowedMethods로는 허용할 HTTP 메서드를 정하는데, 여기 OPTIONS가 끼어 있는 걸 눈여겨보자. 브라우저는 본 요청(예: DELETE)을 보내기 전에 “이런 요청을 보내도 되겠니?” 하고 슬쩍 물어보는 예비 요청(preflight)을 OPTIONS로 던지고, 서버가 “괜찮아”라고 답해야 진짜 요청을 보낸다. 그래서 허용 메서드에 OPTIONS를 빠뜨리면 이 예비 단계에서 막혀 버린다. “분명 CORS 설정을 했는데 왜 또 막히지?” 싶으면 십중팔구 이 preflight 언저리를 의심해 볼 만하다.
React에서 fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })를 부르면, 브라우저가 서버로 처음 보내는 요청은?
원문 보정 · 앱 보충두 가지를 바로잡자. 첫째, Spring Security가 없는 지금 단계에서는
CorsConfigurationSource빈을 등록하는 것만으로 CORS가 열리지 않는다. 이 빈을 찾아 쓰는 건 Spring Security의http.cors()이고, Spring MVC는 이 빈을 보지 않는다. 5장 시점에서는 같은CorsConfig안에@Bean public CorsFilter corsFilter() { return new CorsFilter(corsConfigurationSource()); }(org.springframework.web.filter.CorsFilter)를 하나 더 두거나,WebMvcConfigurer의addCorsMappings로 등록하자. 이때corsFilter(CorsConfigurationSource source)처럼 매개변수로 받으면, Spring MVC가 이미 같은 타입의 빈을 하나 들고 있어서 4장에서 본 “2개 발견” 오류로 시작이 실패한다.둘째, Spring은 preflight를 검사할 때
OPTIONS자체가 아니라Access-Control-Request-Method에 적힌 본 요청 메서드(예:DELETE)가allowedMethods에 있는지를 본다. 그래서OPTIONS를 빼도 preflight는 통과하고, 넣어 둬도 해는 없다. 정말로 막히는 건 본 요청 메서드를 빠뜨렸을 때다. 또DELETE만이 아니라Content-Type: application/json을 실은POST도 preflight 대상이다.
ch05-5 원서의 CorsConfig (아직 CORS가 열리지 않음)명령과 기대 출력 펼치기
실행해 보기
React 앱이 Content-Type: application/json으로 POST하기 전에 브라우저가 보내는 preflight를 curl로 흉내 낸다.
./gradlew bootRun
curl -i -X OPTIONS http://localhost:8080/api/tasks \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Invalid CORS request
설정을 넣었는데도 거절된다. Access-Control-Allow-Origin 헤더도 없다.
「앱 보충」 원서대로 하면 여기서 막힌다(5장 5절의 원문 보정 상자).
CorsConfigurationSource 빈을 찾아 쓰는 건 Spring Security의 http.cors()다. Security가 없는 지금은 Spring MVC가 이 빈을 보지 않는다.
React에서 확인하기 (선택)
허용한 출처가 http://localhost:3000이니, 그 출처의 페이지에서 불러야 한다.
React 개발 서버를 3000번 포트로 띄우거나, React 앱이 없다면 빈 폴더에서 npx serve -l 3000으로 아무 페이지나 연다. 그 페이지의 개발자 도구 콘솔에서 실행한다.
await fetch('http://localhost:8080/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'React에서 보낸 할 일' }),
})Network 탭에 OPTIONS 요청이 먼저 보이고, 콘솔에는 blocked by CORS policy가 뜬다.
Spring 서버는 localhost:8080, 허용 출처는 이 장의 CorsConfig대로 localhost:3000 하나입니다. 세 번째 서버 설정은 아래 보정 상자의 CorsFilter 등록까지 마친 상태입니다.
Origin: localhost:3000
CORS 처리 없음
- 해볼 것
- 같은 GET 요청이 curl로는 성공하고 브라우저에서는 막히는 것 보기
- 브라우저는 막혔는데 서버 로그에는 200이 찍히는 경우 찾기
- CORS를 제대로 켜고 localhost:3000에서 JSON POST 성공하기
- Vite 개발 서버(localhost:5173)에서 부르면 막히는 것 확인하기
- curl에 허용되지 않은 Origin을 실어, 서버가 직접 403을 내는 것 보기
ch05-6 CorsFilter로 CORS 열기명령과 기대 출력 펼치기
실행해 보기
이전 단계와 같은 요청을 보내고 응답을 비교한다.
./gradlew bootRun
curl -i -X OPTIONS http://localhost:8080/api/tasks \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: content-type
403이 200이 되고, 브라우저가 기다리던 Access-Control-Allow-* 헤더가 붙었다. ch05-5의 React 확인을 다시 해 보면 이번엔 POST까지 나가고 201을 받는다.
허용하지 않은 출처로 바꿔 보내면 여전히 막힌다.
curl -i -X OPTIONS http://localhost:8080/api/tasks \
-H "Origin: http://evil.example" \
-H "Access-Control-Request-Method: POST"HTTP/1.1 403
직접 깨뜨려 보기: 매개변수로 주입받으면
corsFilter()를 "4장에서 배운 대로" 매개변수로 받게 바꿔 본다.
@Bean
public CorsFilter corsFilter(CorsConfigurationSource source) {
return new CorsFilter(source);
}시작이 실패한다.
Parameter 0 of method corsFilter in com.example.taskboard.config.CorsConfig required a single bean, but 2 were found:
- corsConfigurationSource: defined by method 'corsConfigurationSource' in class path resource [com/example/taskboard/config/CorsConfig.class]
- mvcHandlerMappingIntrospector: defined by method 'mvcHandlerMappingIntrospector' in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]
Spring MVC가 이미 CorsConfigurationSource 타입의 빈(mvcHandlerMappingIntrospector)을 들고 있어서, 같은 타입이 둘이 된다. 4장의 "빈을 찾을 수 없음"과 짝을 이루는 "빈이 너무 많음" 오류다. 확인했으면 git checkout -- .으로 되돌린다.
server.proxy / Next.js rewrites로 /api를 8080에 넘기기CorsConfig로 허용 출처를 선언하기지금 일부러 비워 둔 설정이 하나 있다. 쿠키 같은 인증 정보를 실어도 되는지 정하는 allowCredentials다. 아직 인증이 없으니 건드리지 않았다. 다만 와일드카드("*")와 함께 켜면 브라우저가 거부하는 함정이 있는데, 이건 11장에서 세션·쿠키 인증과 함께 다시 만난다.
마지막으로 한 가지 주의. AI에게 CORS 설정을 부탁하면, 종종 allowedOrigins("*")에 인증까지 허용하는 식으로 모든 걸 활짝 열어 버린 위험한 코드를 준다. 편하지만 보안 구멍이다. AI는 설정의 뼈대를 잡아 주는 동반자이되, 무엇을 얼마나 열지는 끝까지 우리가 정한다. 3장부터 이어 온 원칙이 여기서도 그대로다.
“React(localhost:3000)에서 부를 수 있게 할 일 생성 API랑 CORS 설정 만들어줘. 제목은 필수야.” (Spring Boot 3.5 기준)
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 4개입니다.
첫 테스트 — MockMvc는 supertest다
여기까지 오면 API는 제법 견고해졌다. 검증도 하고, 에러도 일관되게 돌려주고, CORS도 풀었다. 그런데 이걸 매번 어떻게 확인하고 있었나? 서버를 띄우고, 브라우저나 curl로 요청을 던져 보고, 눈으로 응답을 확인했다. 손으로 말이다.
여기서 잠시 멈추고 생각해 보자. 코드를 고칠 때마다 이 손 검사를 전부 다시 해야 한다면? 엔드포인트가 다섯, 열 개로 늘어나면? 검증 규칙이 제대로 도는지, 201이 잘 나오는지, 400 에러 모양이 맞는지를 매번 손으로 확인하는 건 금세 번거로워지고, 결국 “귀찮으니 대충 넘기자”가 된다. 그 순간 버그가 슬며시 끼어든다.
그래서 이 확인을 코드로 자동화한다. 바로 테스트다. “테스트”라는 말에 지레 겁먹을 필요는 없다. 프런트에서 이미 해 본 일이다. Node 백엔드를 만져 봤다면 supertest를 떠올려 보자. 가짜로 서버를 띄워 요청을 던지고, 돌아온 상태코드와 바디를 단언(assert)하던 그 도구다.
// supertest (Node) — 이미 익숙한 모양
await request(app)
.post('/api/tasks')
.send({ title: '' })
.expect(400);
Spring에도 정확히 이 모양을 하는 도구가 있다. MockMvc다. 진짜 서버를 8080 포트에 띄우지 않고도, 컨트롤러에 가짜 요청을 던지고 응답을 단언할 수 있다. supertest와 판박이다.
package com.example.taskboard.controller;
import com.example.taskboard.service.TaskService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TaskController.class)
class TaskControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void 제목이_비어있으면_400을_돌려준다() throws Exception {
String body = """
{ "title": "", "description": "설명" }
""";
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.fieldErrors.title").exists());
}
}
supertest 예제와 나란히 두고 보면 구조가 똑같다. perform(post(...))로 요청을 던지고(supertest의 .post().send()), andExpect(status().isBadRequest())로 상태코드를 단언하고(.expect(400)), jsonPath로 응답 바디 안을 들여다본다. 모양만 자바일 뿐, 생각은 완전히 같다. “이런 요청을 던지면, 이런 응답이 와야 한다.”
클래스 위의 @WebMvcTest(TaskController.class)는 “이 테스트는 컨트롤러 한 겹(슬라이스)만 띄워서 검사한다”는 뜻이다. DB나 다른 무거운 부품은 부르지 않고 웹 계층만 올리니 테스트가 빠르고 가볍다. 성공 경로(정상 요청에 201)도 같은 방식으로 단언할 수 있다. 다만 @WebMvcTest는 웹 계층만 띄우므로, 이때는 컨트롤러가 부르는 TaskService를 가짜(mock)로 채워 줘야 한다. 그 방법은 손이 조금 더 가니, 여기서는 “같은 방식으로 단언할 수 있다”는 점만 확인하고 넘어가자.
ch05-7 원서의 첫 MockMvc 테스트 (실패하는 것이 정상)명령과 기대 출력 펼치기
실행해 보기
./gradlew test이 단계에서는 테스트가 실패한다. 일부러 남긴 빨간불이다.
TaskControllerTest > 제목이_비어있으면_400을_돌려준다() FAILED
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException at ConstructorResolver.java:804
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException at DefaultListableBeanFactory.java:2322
2 tests completed, 1 failed
build/reports/tests/test/index.html을 브라우저로 열면 전체 메시지를 볼 수 있다.
「앱 보충」 왜 실패할까? @WebMvcTest(TaskController.class)는 웹 계층만 띄우고 @Service는 띄우지 않는다.
그런데 TaskController의 생성자는 TaskService를 요구한다. 컨테이너가 빈을 못 찾아 테스트용 컨텍스트를 만들다 실패한다.
4장 "직접 깨뜨려 보기 1"의 required a bean of type … that could not be found와 같은 원인이다.
원문 보정 · 앱 보충사실 위 테스트는 400 경우조차 그대로는 돌지 않는다.
@WebMvcTest는@Service를 띄우지 않는데,TaskController의 생성자가TaskService를 요구하니 컨텍스트를 만들다NoSuchBeanDefinitionException으로 실패한다(4장의 “빈을 찾을 수 없음”과 같은 원인이다). 테스트 클래스에@MockitoBean private TaskService taskService;(org.springframework.test.context.bean.override.mockito.MockitoBean) 한 줄을 더하면 통과한다. AI나 옛 글이 주는@MockBean은 Spring Boot 3.4부터 deprecated다.
지금은 테스트 하나가 별것 아닌 것처럼 보일 수 있다. 하지만 이 작은 습관이 13장에서 큰 무기가 된다. 13장에서는 AI와 함께 기능 하나를 스펙부터 구현까지 완주하는데, 그 마지막 단계가 “테스트로 검증하기”다. 기억해 두자. 견고함은 두 눈이 아니라 코드로 증명할 때 비로소 무너지지 않는다.
ch05-8 @MockitoBean으로 테스트 통과시키기명령과 기대 출력 펼치기
실행해 보기
./gradlew testBUILD SUCCESSFUL
build/reports/tests/test/index.html에서 TaskControllerTest의 테스트 2개가 모두 통과한 것을 확인한다.
직접 깨뜨려 보기: 테스트가 정말 지켜 주나
TaskController의 @Valid를 지우고 ./gradlew test를 다시 돌린다. 이번엔 손으로 curl을 치지 않아도, 빈 제목 테스트가 빨간불로 알려 준다.
확인했으면 git checkout -- .으로 되돌린다.
마무리
이번 장에서 우리는 “동작은 하지만 엉성한” API를 “험한 요청에도 품위를 잃지 않는” API로 키웠다. 엔티티를 그대로 노출하지 않도록 DTO라는 칸막이를 세웠고, @Valid로 검증을 프레임워크에 위임했다. ResponseEntity로 상태코드를 의도적으로 골랐고, @RestControllerAdvice로 에러의 모양을 한곳에서 다스렸다. 1장에서 심어 둔 CORS의 씨앗을 거뒀고, MockMvc로 첫 테스트를 작성했다.
이 모든 걸 관통하는 감각은 하나다. API의 모든 출력(상태코드·에러·허용 출처)은 내가 의도적으로 설계하는 것이지, 우연히 그렇게 되는 것이 아니다. 1장에서 “내가 결정하는 책임”이라 불렀던 말이, 이번 장에서 비로소 코드로 실현된 셈이다.
다만 할 일들은 아직 메모리에 산다. 서버를 끄면 깨끗이 사라진다. 다음 장에서는 그 마지막 찜찜함을 끝낸다. JPA를 붙여 메모리에 살던 TaskBoard를 데이터베이스로 옮겨 심되, 흥미롭게도 API 계약은 단 한 줄도 바꾸지 않은 채 그 아래 저장소만 갈아 끼운다. 오늘 DTO로 안과 밖을 갈라 둔 덕분에 가능한 일이다. 오늘의 칸막이가 내일의 갈아 끼우기를 떠받친다. 그럼, 메모리에서 데이터베이스로 건너가 보자.
1. 요청 DTO TaskCreateRequest에 id 필드를 두지 않은 이유는?
2. DTO에 @NotBlank를 달았는데 빈 제목이 그대로 저장된다. 가장 먼저 의심할 곳은?
3. curl로는 되는데 브라우저에서만 CORS 에러가 나는 이유는?
4. @WebMvcTest(TaskController.class)가 띄우지 않는 것은?
이 장의 실습은 단계마다 커밋으로 준비해 두었고, 각 단계는 본문의 해당 자리에 있습니다. 처음이라면 레포부터 받습니다.
git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
cd toby-react-to-spring-taskboard
git checkout ch05-1ch05-1역할별 패키지로 나누기ch05-2DTO와POST /api/tasks(아직 검증 없음)ch05-3Bean Validation과@Validch05-4전역 예외 처리로 에러 모양 통일ch05-5원서의CorsConfig(아직 CORS가 열리지 않음)ch05-6CorsFilter로 CORS 열기ch05-7원서의 첫 MockMvc 테스트 (실패하는 것이 정상)ch05-8@MockitoBean으로 테스트 통과시키기