게시판 웹 등록·조회
누적한 도메인·서비스를 REST 컨트롤러에 연결해 POST 등록, GET 조회, 위치 헤더, ProblemDetail 오류 계약을 실제 HTTP로 검증합니다.
지금까지 만든 코드는 테스트에서만 호출할 수 있었습니다. 이번 글에서는 바깥의 클라이언트가 JSON으로 게시글을 등록하고 식별자로 조회할 수 있게 합니다. 웹 계층은 도메인 객체를 그대로 내보내지 않고, 요청 JSON을 CreatePostRequest로 역직렬화한 뒤 CreatePostCommand로 바꾸고, 저장된 Post를 PostResponse로 바꾸는 세 가지 변환을 맡습니다. 번거로워 보여도 이렇게 하면 API의 형식과 내부 모델이 따로 바뀔 수 있습니다. 나중에 도메인에 첨부 파일이나 관리용 상태를 추가하더라도 그 필드가 저절로 바깥에 공개되지 않습니다.
웹 입력 DTO 규칙
Bean Validation을 쓰려면 build.gradle의 dependencies에 스타터 한 줄을 추가합니다. spring-boot-starter-webmvc에는 검증 구현체가 들어 있지 않으므로, 이 스타터가 jakarta.validation 애노테이션과 그 구현체인 Hibernate Validator를 함께 가져옵니다. 버전을 적지 않아도 되는 것은 앞에서 가져온 Boot BOM이 정해 주기 때문입니다.
implementation 'org.springframework.boot:spring-boot-starter-validation'@NotBlank와 @Size는 HTTP 입력의 기본 형식을 빠르게 걸러 냅니다. 도메인의 1~720자 규칙도 그대로 남겨 둡니다. 두 검사가 일부 겹치더라도, 웹이 아닌 다른 진입점에서 도메인이 무방비해지면 안 되기 때문입니다.
package board.web;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import board.application.CreatePostCommand;
public record CreatePostRequest(
@NotBlank @Size(max = 50) String authorId,
@NotBlank @Size(max = 80) String title,
@NotBlank @Size(max = 720) String content,
@NotNull LocalDate publishedOn
) {
public CreatePostRequest {
authorId = stripIfPresent(authorId);
title = stripIfPresent(title);
content = stripIfPresent(content);
}
CreatePostCommand toCommand() {
return new CreatePostCommand(
authorId, title, content, publishedOn);
}
private static String stripIfPresent(String value) {
return value == null ? null : value.strip();
}
}컴팩트 생성자는 null은 그대로 두고 문자열만 먼저 strip()합니다. 검증은 객체가 만들어진 뒤에 실행되므로 @NotBlank와 @Size는 공백을 지운 값을 검사하고, 50·80·720 상한도 PostDraft와 같은 String.length(), 곧 UTF-16 코드 단위 기준이 됩니다. 웹 검증에서 실패하면 toCommand()가 호출되지 않으므로 PostDraft도 만들어지지 않습니다. 반대로 웹을 거치지 않고 CreatePostCommand를 직접 만드는 진입점에서는 PostService가 만드는 PostDraft가 구조 규칙을 다시 확인합니다. publishedOn의 @NotNull은 값이 있는지만 확인하고, 미래 날짜인지는 서비스가 주입받은 Clock으로 계산한 today로 저장소 호출 전에 검사합니다.
package board.web;
import java.time.LocalDate;
import board.domain.Post;
public record PostResponse(
long id,
String authorId,
String title,
String content,
LocalDate publishedOn
) {
static PostResponse from(Post post) {
return new PostResponse(
post.id(),
post.authorId(),
post.title(),
post.content(),
post.publishedOn());
}
}toCommand와 from은 값을 복사할 뿐이지만, 변환을 한곳에 모아 둡니다. 컨트롤러가 필드마다 객체를 만드는 코드를 되풀이하지 않아도 되고, 응답에 어떤 속성을 공개하는지 찾아보기도 쉬워집니다.
201 응답과 리소스 위치
POST /api/posts는 저장된 게시글을 본문에 담고, 새 게시글을 다시 조회할 주소를 Location: /api/posts/{id} 헤더로 함께 돌려줍니다. 아래 컨트롤러에서는 register의 @Valid @RequestBody와 반환 타입 ResponseEntity를 봅니다. @RequestBody가 요청 본문 JSON을 CreatePostRequest로 바꾸고, @Valid가 그 객체의 제약을 통과시켜야 메서드 본문이 실행됩니다. 조회 메서드 두 개는 상태 코드와 헤더를 따로 정하지 않으므로 응답 객체를 그대로 반환하며, 이때 상태는 200입니다.
package board.web;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
import board.application.PostService;
@RestController
@RequestMapping("/api/posts")
public final class PostController {
private final PostService service;
public PostController(PostService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<PostResponse> register(
@Valid @RequestBody CreatePostRequest request
) {
var saved = service.register(request.toCommand());
var location = URI.create("/api/posts/" + saved.id());
return ResponseEntity.created(location)
.body(PostResponse.from(saved));
}
@GetMapping("/{id}")
public PostResponse find(@PathVariable("id") long id) {
return PostResponse.from(service.find(id));
}
@GetMapping
public List<PostResponse> findAll() {
return service.findAll().stream()
.map(PostResponse::from)
.toList();
}
}PostController는 생성자가 하나뿐이므로 Spring이 그 생성자로 PostService 빈을 주입합니다. 응답 본문에 어떤 필드를 공개할지는 PostResponse가 정하고, 201 상태와 Location 헤더는 ResponseEntity.created(location)이 정합니다. 애플리케이션을 실행하고 등록 요청을 보내 봅니다.
curl -i -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"authorId":"member-1","title":"Spring MVC","content":"DispatcherServlet이 요청을 컨트롤러에 연결합니다.","publishedOn":"2026-07-13"}'HTTP/1.1 201
Location: /api/posts/1
Content-Type: application/json
{"id":1,"authorId":"member-1","title":"Spring MVC","content":"DispatcherServlet이 요청을 컨트롤러에 연결합니다.","publishedOn":"2026-07-13"}응답 본문의 속성은 레코드에 선언한 순서대로 나옵니다. Jackson 3은 생성자로 값을 받는 속성, 곧 레코드의 컴포넌트를 선언 순서대로 먼저 쓰기 때문입니다. publishedOn이 "2026-07-13"처럼 날짜 문자열로 나오는 것도 Jackson 3이 날짜를 숫자 타임스탬프 대신 ISO-8601 문자열로 쓰는 것을 기본값으로 삼기 때문입니다. Location은 생성된 내용이 본문에 있다는 것을 넘어, 새 리소스를 다시 조회할 표준 위치를 알려 줍니다.
게시글 REST 등록 흐름과 endpoint 계약
DTO 정규화·웹 검증·command·서비스 검사·저장·201 응답의 값과 호출 경계가 연속으로 바뀐다.
VERTICAL SLICE · BODY, STATUS, AND LOCATION
JSON을 그대로 저장하지 않고 각 경계의 언어로 변환한다
웹 형식 검증, application 시간·중복 정책, 저장 결과, HTTP 응답 책임은 서로 다른 단계입니다. 성공 응답에서는 PostResponse가 본문 필드를, 컨트롤러의 ResponseEntity가 201과 Location을 결정합니다.
POST /api/posts · SUPPORTED PATH
등록 요청은 다섯 경계를 순서대로 지난다
-
JSON →
CreatePostRequestJackson이 canonical constructor를 호출해 역직렬화를 완성합니다. compact constructor에서
strip()한 값이 필드에 배정된 뒤 Spring MVC가 생성된 request 객체의 50·80·720UTF-16code-unit 상한과 필수값을 검사합니다. -
CreatePostRequest.toCommand()웹 DTO를 웹 애노테이션이 없는
CreatePostCommand로 바꿉니다. 웹 검증 실패 시 이 단계에 도달하지 않습니다. -
PostService.register(command)PostDraft구조 재검사 → 호출당today한 번 계산 → 미래 날짜 gate → 중복exists→save순서입니다. -
저장 결과 →
Postrepository의 지원 저장 경로가 양수 ID를 부여한 게시글을 반환합니다.
-
PostResponse+ HTTP metadata응답 DTO는 공개 본문 필드를 고르고,
ResponseEntity.created(location)이 201과 새 리소스 URI를 설정합니다.
| 동작 | 경로 | 성공 계약 | 현재 실행 증거 | 아직 미실행 |
|---|---|---|---|---|
| POST | /api/posts |
201 + Location + PostResponse |
201, 위치, ID·제목 JSON과 공백 content 필드 400 | 순차 중복 409는 연습 경계 |
| ITEM GET | /api/posts/{id} |
200 + PostResponse |
등록 상태를 이어 받은 본문 조회와 미존재 404 | 응답 전체 필드 equality |
| LIST GET | /api/posts |
200 + JSON 배열 | 현재 transcript에 없음 | 목록 JSON과 표현 협상 |
endpoint별 성공 계약과 현재 실행 증거
POST
경로: /api/posts
성공 계약: 201 + Location + PostResponse
현재 실행 증거: 201, 위치, ID·제목 JSON과 공백 content 필드 400
아직 미실행: 순차 중복 409는 연습 경계
ITEM GET
경로: /api/posts/{id}
성공 계약: 200 + PostResponse
현재 실행 증거: 등록 상태를 이어 받은 본문 조회와 미존재 404
아직 미실행: 응답 전체 필드 equality
LIST GET
경로: /api/posts
성공 계약: 200 + JSON 배열
현재 실행 증거: 현재 transcript에 없음
아직 미실행: 목록 JSON과 표현 협상
웹 검증이 먼저 실패하면 command와 PostDraft는 만들어지지 않습니다. 비웹 command 진입에는 PostDraft 구조 규칙과 service 시간 gate가 독립된 최종 경계로 남습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 화살표는 유효한 등록 요청의 값 변환과 지원 호출 순서입니다. DTO가 strip한 뒤 웹 검증을 통과해야 command를 만들며 PostService는 구조·시간·중복 조건을 확인한 뒤 저장합니다. 실패는 이 정상 경로를 끝내고 별도 오류 응답으로 갑니다. ResponseEntity가 status와 Location을, PostResponse가 공개 본문 필드를 결정합니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 REST 등록 흐름과 endpoint 계약 |
| 73 | 등록 상태를 이어받은 본문 조회와 미존재 404 |
| 84 | : 등록 상태를 이어받은 본문 조회와 미존재 404 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 웹 입력 정규화 DTO가 strip하고 웹 검증을 통과합니다. | 입력: DTO · 통과: command 생성 | 웹 입력이 명령으로 좁혀집니다. |
| 서비스 조건과 저장 PostService가 구조·시간·중복을 확인한 뒤 저장합니다. | 실패: 오류 응답 · 정상: 저장 | 서비스 계약을 통과해야 저장합니다. |
| 공개 등록 응답 ResponseEntity와 PostResponse가 응답을 정합니다. | status: 201 · Location: 생성 자원 · 본문: 공개 필드 | 저장 결과가 공개 HTTP 표현이 됩니다. |
게시글 REST 등록 흐름과 endpoint 계약
DTO 정규화·웹 검증·command·서비스 검사·저장·201 응답의 값과 호출 경계가 연속으로 바뀐다.
VERTICAL SLICE · BODY, STATUS, AND LOCATION
JSON을 그대로 저장하지 않고 각 경계의 언어로 변환한다
웹 형식 검증, application 시간·중복 정책, 저장 결과, HTTP 응답 책임은 서로 다른 단계입니다. 성공 응답에서는 PostResponse가 본문 필드를, 컨트롤러의 ResponseEntity가 201과 Location을 결정합니다.
POST /api/posts · SUPPORTED PATH
등록 요청은 다섯 경계를 순서대로 지난다
-
JSON →
CreatePostRequestJackson이 canonical constructor를 호출해 역직렬화를 완성합니다. compact constructor에서
strip()한 값이 필드에 배정된 뒤 Spring MVC가 생성된 request 객체의 50·80·720UTF-16code-unit 상한과 필수값을 검사합니다. -
CreatePostRequest.toCommand()웹 DTO를 웹 애노테이션이 없는
CreatePostCommand로 바꿉니다. 웹 검증 실패 시 이 단계에 도달하지 않습니다. -
PostService.register(command)PostDraft구조 재검사 → 호출당today한 번 계산 → 미래 날짜 gate → 중복exists→save순서입니다. -
저장 결과 →
Postrepository의 지원 저장 경로가 양수 ID를 부여한 게시글을 반환합니다.
-
PostResponse+ HTTP metadata응답 DTO는 공개 본문 필드를 고르고,
ResponseEntity.created(location)이 201과 새 리소스 URI를 설정합니다.
| 동작 | 경로 | 성공 계약 | 현재 실행 증거 | 아직 미실행 |
|---|---|---|---|---|
| POST | /api/posts |
201 + Location + PostResponse |
201, 위치, ID·제목 JSON과 공백 content 필드 400 | 순차 중복 409는 연습 경계 |
| ITEM GET | /api/posts/{id} |
200 + PostResponse |
등록 상태를 이어 받은 본문 조회와 미존재 404 | 응답 전체 필드 equality |
| LIST GET | /api/posts |
200 + JSON 배열 | 현재 transcript에 없음 | 목록 JSON과 표현 협상 |
endpoint별 성공 계약과 현재 실행 증거
POST
경로: /api/posts
성공 계약: 201 + Location + PostResponse
현재 실행 증거: 201, 위치, ID·제목 JSON과 공백 content 필드 400
아직 미실행: 순차 중복 409는 연습 경계
ITEM GET
경로: /api/posts/{id}
성공 계약: 200 + PostResponse
현재 실행 증거: 등록 상태를 이어 받은 본문 조회와 미존재 404
아직 미실행: 응답 전체 필드 equality
LIST GET
경로: /api/posts
성공 계약: 200 + JSON 배열
현재 실행 증거: 현재 transcript에 없음
아직 미실행: 목록 JSON과 표현 협상
웹 검증이 먼저 실패하면 command와 PostDraft는 만들어지지 않습니다. 비웹 command 진입에는 PostDraft 구조 규칙과 service 시간 gate가 독립된 최종 경계로 남습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 화살표는 유효한 등록 요청의 값 변환과 지원 호출 순서입니다. DTO가 strip한 뒤 웹 검증을 통과해야 command를 만들며 PostService는 구조·시간·중복 조건을 확인한 뒤 저장합니다. 실패는 이 정상 경로를 끝내고 별도 오류 응답으로 갑니다. ResponseEntity가 status와 Location을, PostResponse가 공개 본문 필드를 결정합니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 REST 등록 흐름과 endpoint 계약 |
| 73 | 등록 상태를 이어받은 본문 조회와 미존재 404 |
| 84 | : 등록 상태를 이어받은 본문 조회와 미존재 404 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 웹 입력 정규화 DTO가 strip하고 웹 검증을 통과합니다. | 입력: DTO · 통과: command 생성 | 웹 입력이 명령으로 좁혀집니다. |
| 서비스 조건과 저장 PostService가 구조·시간·중복을 확인한 뒤 저장합니다. | 실패: 오류 응답 · 정상: 저장 | 서비스 계약을 통과해야 저장합니다. |
| 공개 등록 응답 ResponseEntity와 PostResponse가 응답을 정합니다. | status: 201 · Location: 생성 자원 · 본문: 공개 필드 | 저장 결과가 공개 HTTP 표현이 됩니다. |
그림은 등록 요청이 거치는 다섯 단계를 보여 줍니다. JSON은 CreatePostRequest로 바뀌며 공백이 지워지고 웹 검증을 받으며, toCommand()로 웹 애노테이션이 없는 명령이 됩니다. PostService는 구조, 날짜, 중복을 확인한 뒤에야 저장하고, 저장소가 양수 식별자를 붙인 Post를 돌려주면 PostResponse와 ResponseEntity가 응답을 만듭니다. 그림 아래의 표처럼, 목록 조회 GET /api/posts는 코드는 있지만 이 글의 테스트에서는 아직 실행하지 않습니다.
예외의 HTTP 변환
지금은 PostNotFoundException을 처리하는 곳이 없어서, 없는 게시글을 조회하면 서버 오류인 500처럼 보일 수 있습니다. 하지만 이것은 서버의 결함이 아니라 요청한 리소스가 없다는 뜻이므로 404로 바꿔야 합니다. 같은 기준으로 중복 등록은 현재 상태와 충돌하므로 409, 잘못된 도메인 값은 400으로 나타냅니다. 오류 본문은 RFC 9457의 문제 세부 정보(Problem Details) 형식을 따르는 Spring의 ProblemDetail로 만듭니다.
package board.web;
import java.net.URI;
import java.util.Locale;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import board.application.DuplicatePostException;
import board.application.PostNotFoundException;
@RestControllerAdvice
public final class ApiExceptionHandler {
@ExceptionHandler(PostNotFoundException.class)
ProblemDetail notFound(PostNotFoundException exception) {
return problem(HttpStatus.NOT_FOUND, "POST_NOT_FOUND",
exception.getMessage());
}
@ExceptionHandler(DuplicatePostException.class)
ProblemDetail duplicate(DuplicatePostException exception) {
return problem(HttpStatus.CONFLICT, "POST_DUPLICATE",
exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalidRequest(
MethodArgumentNotValidException exception
) {
return problem(HttpStatus.BAD_REQUEST, "INVALID_POST",
"request validation failed");
}
@ExceptionHandler(IllegalArgumentException.class)
ProblemDetail invalidPost(IllegalArgumentException exception) {
return problem(HttpStatus.BAD_REQUEST, "INVALID_POST",
exception.getMessage());
}
private ProblemDetail problem(
HttpStatus status,
String code,
String detail
) {
var problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setType(URI.create(
"https://andongmin.com/problems/"
+ code.toLowerCase(Locale.ROOT)
.replace('_', '-')));
problem.setTitle(status.getReasonPhrase());
problem.setProperty("code", code);
return problem;
}
}@Valid 검증이 실패하면 MethodArgumentNotValidException이 발생하는데, 이 예외의 긴 바인딩 메시지를 그대로 보여 주지 않고 고정된 detail을 씁니다. IllegalArgumentException 처리기는 지금 만든 기능에서 도메인의 구조 검사 실패를 400으로 바꾸는 역할을 합니다. 다른 프로그래밍 오류까지 같은 예외 타입으로 들어오기 시작하면, 더 구체적인 애플리케이션 예외를 만들어 범위를 좁혀야 합니다.
type은 서버의 기본 로케일(locale)과 상관없이 소문자와 하이픈으로 된 URI가 되고, title은 HTTP 이유 문구를 씁니다. instance를 따로 지정하지 않으면 Spring MVC가 현재 요청 경로로 채우며, setProperty("code", code)로 넣은 확장 속성은 Spring이 등록한 Jackson 믹스인(mixin) 덕분에 본문 최상위의 code 필드로 나옵니다. 없는 게시글을 조회하면 다음과 같은 응답이 옵니다. 본문은 읽기 쉽게 줄을 나누었습니다.
HTTP/1.1 404
Content-Type: application/problem+json
{
"detail": "post not found: 999",
"instance": "/api/posts/999",
"status": 404,
"title": "Not Found",
"type": "https://andongmin.com/problems/post-not-found",
"code": "POST_NOT_FOUND"
}이번에는 속성이 알파벳순으로 나옵니다. ProblemDetail은 레코드가 아니라 세터로 값을 받는 클래스여서, 생성자로 받는 속성이 아닌 나머지 속성을 알파벳순으로 쓰는 Jackson 3의 기본값이 적용되기 때문입니다. 확장 속성인 code는 맨 뒤에 붙습니다. 이처럼 속성의 순서는 직렬화 설정에 따라 바뀔 수 있으므로, 클라이언트는 순서가 아니라 속성의 이름에 의존해야 합니다.
예외 클래스들이 HTTP를 전혀 모른다는 점도 확인해 두세요. 애플리케이션 계층은 "없음"과 "중복"을 서로 다른 예외로 구분할 뿐이고, 상태 코드와 응답 형식은 웹 계층의 어드바이스가 정합니다.
MVC 파이프라인 테스트
컨트롤러 메서드를 직접 호출하는 테스트로는 @RequestBody 역직렬화, @Valid, 요청 매핑, 메시지 변환기를 확인할 수 없습니다. 그래서 정적 콘텐츠·MVC·JSON 응답에서처럼 RestTestClient를 독립형 MockMvc에 연결해, 서버 포트를 열지 않고 HTTP 요청과 응답을 거치게 합니다.
package board.web;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;
import board.application.PostService;
import board.infrastructure.MemoryPostRepository;
class PostControllerTest {
private RestTestClient client;
@BeforeEach
void setUp() {
var clock = Clock.fixed(
Instant.parse("2026-07-13T09:00:00Z"),
ZoneOffset.UTC);
var service = new PostService(
new MemoryPostRepository(), clock);
var controller = new PostController(service);
client = RestTestClient.bindToController(controller)
.configureServer(builder ->
builder.setControllerAdvice(
new ApiExceptionHandler()))
.build();
}
@Test
void 등록하고_Location으로_조회한다() {
client.post()
.uri("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.body("""
{
"authorId": "member-1",
"title": "Spring MVC",
"content": "DispatcherServlet이 요청을 컨트롤러에 연결합니다.",
"publishedOn": "2026-07-13"
}
""")
.exchange()
.expectStatus().isCreated()
.expectHeader().valueEquals(
"Location", "/api/posts/1")
.expectBody()
.jsonPath("$.id").isEqualTo(1)
.jsonPath("$.title").isEqualTo("Spring MVC");
client.get()
.uri("/api/posts/1")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.content").isEqualTo(
"DispatcherServlet이 요청을 컨트롤러에 연결합니다.");
}
@Test
void 없는_게시글은_404_problem_detail을_반환한다() {
client.get()
.uri("/api/posts/999")
.exchange()
.expectStatus().isNotFound()
.expectHeader().contentType(
MediaType.APPLICATION_PROBLEM_JSON)
.expectBody()
.jsonPath("$.code").isEqualTo("POST_NOT_FOUND");
}
@Test
void 공백_content는_400_problem_detail을_반환한다() {
client.post()
.uri("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.body("""
{
"authorId": "member-1",
"title": "Spring MVC",
"content": " ",
"publishedOn": "2026-07-13"
}
""")
.exchange()
.expectStatus().isBadRequest()
.expectHeader().contentType(
MediaType.APPLICATION_PROBLEM_JSON)
.expectBody()
.jsonPath("$.code").isEqualTo("INVALID_POST")
.jsonPath("$.detail").isEqualTo(
"request validation failed")
.jsonPath("$.instance").isEqualTo("/api/posts");
}
}@BeforeEach가 테스트마다 새 저장소와 서비스를 만들므로, 각 테스트는 비어 있는 저장소에서 시작하고 첫 게시글의 식별자는 언제나 1입니다. 독립형 설정은 Bean Validation 구현체가 클래스 경로에 있으면 검증기도 함께 등록하므로 @Valid가 동작합니다. 세 테스트가 모두 통과하면 BUILD SUCCESSFUL로 끝납니다.
첫 테스트는 유효한 JSON이 역직렬화되고 매핑되어 201과 Location, 응답 JSON이 나오는 것, 그리고 같은 서비스 상태로 이어서 보낸 GET이 저장된 본문을 돌려주는 것을 확인합니다. 둘째 테스트는 직접 등록한 어드바이스가 404와 application/problem+json, POST_NOT_FOUND를 만드는지 확인합니다. 셋째 테스트는 공백뿐인 content가 정규화된 뒤 @Valid에서 거부되어 고정된 detail과 400, INVALID_POST, 자동으로 채워진 instance가 나오는지 확인합니다.
Standalone MVC 테스트의 성공·오류 분기와 보장 범위
standalone MVC 안의 매핑·변환·검증·controller 또는 advice 분기에서 마지막 도달 경계를 따라갈 수 있다.
STANDALONE MVC · TESTED VS NOT LOADED
공통 MVC 경계 뒤에서 성공과 오류가 서로 다른 branch로 간다
RestTestClient.bindToController(controller)는 실제 서버 포트를 열지 않습니다. 제공한 controller·advice와 mock servlet, standalone 기본 converter·validator를 조립해 그 범위만 실행합니다.
COMMON REQUEST PATH
공통 매핑 뒤 method별 입력 경계가 갈라진다
-
method · URI · headers
RestTestClient가 요청을 만들고 standalone MockMvc의 mock servlet에 전달합니다. -
controller mapping → method별 변환
mock
DispatcherServlet이 제공된PostController매핑을 선택합니다. POST는 JSON request-body converter와@Valid를, GET은 path-variable 변환을 실행합니다.
SUCCESS BRANCH
controller 반환
등록 성공은 PostResponse 본문과 ResponseEntity의 201·Location으로 나갑니다.
테스트는 등록 뒤 같은 service 상태로 GET을 보내 저장된 content도 확인합니다.
ERROR BRANCH ONLY
명시적 advice 변환
validation 또는 application 예외가 날 때만 등록한 ApiExceptionHandler가 status와 ProblemDetail을 만듭니다.
성공 응답은 advice를 지나지 않습니다. 테스트는 공백 content 400과 미존재 게시글 404를 실행합니다.
| 경계 | 현재 실행 증거 | 실제 보장 | 보장하지 않음 |
|---|---|---|---|
| SUCCESS | 201, Location, id·title JSON, 후속 GET content |
제공된 controller의 매핑·converter와 같은 service 상태 | 목록 endpoint와 전체 응답 필드 |
| VALIDATION | 공백 content → 400, 고정 detail, application/problem+json, INVALID_POST, 현재 경로 instance |
정규화 후 @Valid 거부와 명시적 validation advice 변환 |
필드 오류 목록과 malformed JSON 계약 |
| NOT FOUND | 미존재 ID → 404, ProblemDetail content type과 POST_NOT_FOUND |
application 예외의 supplied advice 번역 | advice component scan·자동 발견 |
| DEFERRED | 현재 transcript에 없음 | 아직 없음 | 중복 409, 목록, 표현 협상, 동시성 |
| RUNTIME | standalone mock servlet 실행 | 테스트에 제공한 controller·advice와 standalone defaults | Boot scan/config, app ObjectMapper 설정, filter·security·interceptor, 실제 server/network |
현재 standalone 테스트 증거와 보장하지 않는 경계
SUCCESS
현재 실행 증거: 201, Location, id·title JSON, 후속 GET content
실제 보장: 제공된 controller의 매핑·converter와 같은 service 상태
보장하지 않음: 목록 endpoint와 전체 응답 필드
VALIDATION
현재 실행 증거: 공백 content → 400, 고정 detail, application/problem+json, INVALID_POST, 현재 경로 instance
실제 보장: 정규화 후 @Valid 거부와 명시적 validation advice 변환
보장하지 않음: 필드 오류 목록과 malformed JSON 계약
NOT FOUND
현재 실행 증거: 미존재 ID → 404, ProblemDetail content type과 POST_NOT_FOUND
실제 보장: application 예외의 supplied advice 번역
보장하지 않음: advice component scan·자동 발견
DEFERRED
현재 실행 증거: 현재 transcript에 없음
실제 보장: 아직 없음
보장하지 않음: 중복 409, 목록, 표현 협상, 동시성
RUNTIME
현재 실행 증거: standalone mock servlet 실행
실제 보장: 테스트에 제공한 controller·advice와 standalone defaults
보장하지 않음: Boot scan/config, app ObjectMapper 설정, filter·security·interceptor, 실제 server/network
테스트 경계는 넓을수록 좋은 것이 아니라 주장과 맞아야 합니다. 이 구성은 HTTP 모양의 MVC 계약을 빠르게 확인하지만 실제 Boot 애플리케이션 전체를 대신하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 요청은 mock DispatcherServlet에서 매핑과 method별 입력 변환을 거칩니다. POST 입력 검증 실패는 controller 호출 없이 supplied advice로 갑니다. 입력이 유효하면 controller와 service를 실행하고 정상 반환은 성공 응답으로, application 예외는 advice의 ProblemDetail로 갑니다. standalone mock 범위이며 실제 서버나 Boot scan을 실행하는 도식이 아닙니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Standalone MVC 테스트의 성공·오류 분기와 보장 범위 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| mock 매핑·입력 변환 standalone DispatcherServlet이 method별 입력을 해석합니다. | 환경: standalone mock · 검증 실패: controller 미호출 | 유효한 입력만 controller에 도달합니다. |
| controller와 service 정상 입력을 supplied controller와 service에 전달합니다. | 정상: 업무 반환 · application 예외: advice | 업무 결과에 따라 응답 경계가 갈립니다. |
| 성공 또는 ProblemDetail 정상은 성공 응답이고 예외는 supplied advice의 ProblemDetail입니다. | 성공: 정상 응답 · 실패: ProblemDetail · 실제 서버: 범위 밖 | mock 요청의 결과가 확정됩니다. |
Standalone MVC 테스트의 성공·오류 분기와 보장 범위
standalone MVC 안의 매핑·변환·검증·controller 또는 advice 분기에서 마지막 도달 경계를 따라갈 수 있다.
STANDALONE MVC · TESTED VS NOT LOADED
공통 MVC 경계 뒤에서 성공과 오류가 서로 다른 branch로 간다
RestTestClient.bindToController(controller)는 실제 서버 포트를 열지 않습니다. 제공한 controller·advice와 mock servlet, standalone 기본 converter·validator를 조립해 그 범위만 실행합니다.
COMMON REQUEST PATH
공통 매핑 뒤 method별 입력 경계가 갈라진다
-
method · URI · headers
RestTestClient가 요청을 만들고 standalone MockMvc의 mock servlet에 전달합니다. -
controller mapping → method별 변환
mock
DispatcherServlet이 제공된PostController매핑을 선택합니다. POST는 JSON request-body converter와@Valid를, GET은 path-variable 변환을 실행합니다.
SUCCESS BRANCH
controller 반환
등록 성공은 PostResponse 본문과 ResponseEntity의 201·Location으로 나갑니다.
테스트는 등록 뒤 같은 service 상태로 GET을 보내 저장된 content도 확인합니다.
ERROR BRANCH ONLY
명시적 advice 변환
validation 또는 application 예외가 날 때만 등록한 ApiExceptionHandler가 status와 ProblemDetail을 만듭니다.
성공 응답은 advice를 지나지 않습니다. 테스트는 공백 content 400과 미존재 게시글 404를 실행합니다.
| 경계 | 현재 실행 증거 | 실제 보장 | 보장하지 않음 |
|---|---|---|---|
| SUCCESS | 201, Location, id·title JSON, 후속 GET content |
제공된 controller의 매핑·converter와 같은 service 상태 | 목록 endpoint와 전체 응답 필드 |
| VALIDATION | 공백 content → 400, 고정 detail, application/problem+json, INVALID_POST, 현재 경로 instance |
정규화 후 @Valid 거부와 명시적 validation advice 변환 |
필드 오류 목록과 malformed JSON 계약 |
| NOT FOUND | 미존재 ID → 404, ProblemDetail content type과 POST_NOT_FOUND |
application 예외의 supplied advice 번역 | advice component scan·자동 발견 |
| DEFERRED | 현재 transcript에 없음 | 아직 없음 | 중복 409, 목록, 표현 협상, 동시성 |
| RUNTIME | standalone mock servlet 실행 | 테스트에 제공한 controller·advice와 standalone defaults | Boot scan/config, app ObjectMapper 설정, filter·security·interceptor, 실제 server/network |
현재 standalone 테스트 증거와 보장하지 않는 경계
SUCCESS
현재 실행 증거: 201, Location, id·title JSON, 후속 GET content
실제 보장: 제공된 controller의 매핑·converter와 같은 service 상태
보장하지 않음: 목록 endpoint와 전체 응답 필드
VALIDATION
현재 실행 증거: 공백 content → 400, 고정 detail, application/problem+json, INVALID_POST, 현재 경로 instance
실제 보장: 정규화 후 @Valid 거부와 명시적 validation advice 변환
보장하지 않음: 필드 오류 목록과 malformed JSON 계약
NOT FOUND
현재 실행 증거: 미존재 ID → 404, ProblemDetail content type과 POST_NOT_FOUND
실제 보장: application 예외의 supplied advice 번역
보장하지 않음: advice component scan·자동 발견
DEFERRED
현재 실행 증거: 현재 transcript에 없음
실제 보장: 아직 없음
보장하지 않음: 중복 409, 목록, 표현 협상, 동시성
RUNTIME
현재 실행 증거: standalone mock servlet 실행
실제 보장: 테스트에 제공한 controller·advice와 standalone defaults
보장하지 않음: Boot scan/config, app ObjectMapper 설정, filter·security·interceptor, 실제 server/network
테스트 경계는 넓을수록 좋은 것이 아니라 주장과 맞아야 합니다. 이 구성은 HTTP 모양의 MVC 계약을 빠르게 확인하지만 실제 Boot 애플리케이션 전체를 대신하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 요청은 mock DispatcherServlet에서 매핑과 method별 입력 변환을 거칩니다. POST 입력 검증 실패는 controller 호출 없이 supplied advice로 갑니다. 입력이 유효하면 controller와 service를 실행하고 정상 반환은 성공 응답으로, application 예외는 advice의 ProblemDetail로 갑니다. standalone mock 범위이며 실제 서버나 Boot scan을 실행하는 도식이 아닙니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Standalone MVC 테스트의 성공·오류 분기와 보장 범위 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| mock 매핑·입력 변환 standalone DispatcherServlet이 method별 입력을 해석합니다. | 환경: standalone mock · 검증 실패: controller 미호출 | 유효한 입력만 controller에 도달합니다. |
| controller와 service 정상 입력을 supplied controller와 service에 전달합니다. | 정상: 업무 반환 · application 예외: advice | 업무 결과에 따라 응답 경계가 갈립니다. |
| 성공 또는 ProblemDetail 정상은 성공 응답이고 예외는 supplied advice의 ProblemDetail입니다. | 성공: 정상 응답 · 실패: ProblemDetail · 실제 서버: 범위 밖 | mock 요청의 결과가 확정됩니다. |
그림은 독립형 MVC 테스트 안에서 요청이 갈라지는 모습을 보여 줍니다. 요청은 가짜(mock) DispatcherServlet에서 매핑과 입력 변환을 거치는데, POST는 JSON 변환과 @Valid를, GET은 경로 변수 변환을 실행합니다. 입력 검증에 실패하면 컨트롤러를 호출하지 않고 바로 어드바이스로 가고, 입력이 유효하면 컨트롤러와 서비스가 실행되어 정상 반환은 성공 응답으로, 애플리케이션 예외는 어드바이스의 ProblemDetail로 갑니다. 성공 응답은 어드바이스를 지나지 않습니다.
그림 아래의 표처럼 이 테스트가 실행하는 것은 테스트에 넘겨준 컨트롤러와 어드바이스, 가짜 서블릿, 독립형 설정의 기본 변환기와 검증기뿐입니다. 실제 Boot의 컴포넌트 스캔과 BoardConfig, 애플리케이션의 Jackson 사용자 설정, 필터와 보안, 인터셉터, 실제 서버와 네트워크는 확인하지 않습니다. 409 중복, 목록 조회, 표현 협상도 아직 테스트하지 않았습니다.
빈 content 필드 검증 위치
빈 문자열이나 공백뿐인 content는 요청 객체의 컴팩트 생성자가 strip()한 뒤 @NotBlank에서 먼저 거부됩니다. 공백을 지운 뒤 721 코드 단위인 본문도 @Size에서 먼저 거부되므로, 같은 웹 요청에서는 PostDraft가 만들어지지 않습니다. 웹을 거치지 않고 명령을 만드는 진입점에서는 PostDraft가 같은 720 상한을 마지막으로 지킵니다. 그러니 두 검사가 겹친다고 해서 하나가 쓸모없는 것은 아닙니다. 웹 검증은 필드마다 알맞은 오류를 알려 주기 좋고, 도메인 검증은 웹이 아닌 진입점에도 같은 규칙을 적용합니다. MethodArgumentNotValidException의 필드 오류를 표준 오류 목록으로 정리하는 방법은 이후 오류 처리를 다루는 장에서 봅니다.
정리
- 웹 계층은 요청 JSON을 요청 DTO와 애플리케이션 명령으로, 저장 결과를 응답 DTO로 바꾸어 API 형식과 내부 모델을 분리합니다.
- 요청 레코드의 컴팩트 생성자에서 공백을 지운 뒤
@Valid가 검사하므로, 웹 검증과 도메인 검증이 같은 기준으로 길이를 셉니다. - 등록에 성공하면
ResponseEntity.created로 201과Location헤더를,PostResponse로 공개할 본문을 돌려줍니다. - 애플리케이션 예외는 HTTP를 모르고,
@RestControllerAdvice가 404, 409, 400과ProblemDetail본문으로 바꿉니다. - Jackson 3은 레코드 컴포넌트를 선언 순서대로, 나머지 속성은 알파벳순으로 쓰므로 클라이언트는 속성 순서에 의존하지 않습니다.
연습 문제
1. 중복 등록은 409
같은 요청을 두 번 POST해 두 번째 응답이 409와 POST_DUPLICATE를 돌려주는지 확인하는 테스트를 추가하세요. 첫 응답의 Location과 두 번째 오류의 instance도 함께 확인합니다.
해설 보기
첫 요청의 결과가 저장소에 남아 있어야 하므로, 두 요청은 한 테스트 메서드 안에서 같은 client로 보냅니다.
@Test
void 같은_게시글을_다시_등록하면_409를_반환한다() {
var body = """
{
"authorId": "member-1",
"title": "Spring MVC",
"content": "DispatcherServlet이 요청을 컨트롤러에 연결합니다.",
"publishedOn": "2026-07-13"
}
""";
client.post().uri("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.exchange()
.expectStatus().isCreated()
.expectHeader().valueEquals(
"Location", "/api/posts/1");
client.post().uri("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.exchange()
.expectStatus().isEqualTo(409)
.expectBody()
.jsonPath("$.code").isEqualTo("POST_DUPLICATE")
.jsonPath("$.instance").isEqualTo("/api/posts");
}두 번째 요청에서 content만 바꾸어 보내도 지금의 중복 기준은 회원, 제목, 작성일이므로 409가 나옵니다. 이 정책이 제품의 의도와 맞는지는 요구사항을 다시 확인해야 할 문제이며, 이 테스트는 지금의 결정을 분명하게 기록해 둡니다.
이것으로 HTTP 요청부터 저장소까지 이어지는 첫 기능이 완성되었습니다. 다음 글 회원가입·게시판 첫 흐름에서는 새 기술을 더하지 않고, 회원가입부터 게시글 작성과 조회까지 사용자가 실제로 밟는 순서대로 지금까지 만든 조각을 연결합니다.