게시판 웹 등록·조회
누적한 도메인·서비스를 REST 컨트롤러에 연결해 POST 등록, GET 조회, 위치 헤더, ProblemDetail 오류 계약을 실제 HTTP로 검증합니다.
지금까지의 코드는 테스트에서만 호출할 수 있었습니다. 이제 외부 클라이언트가 JSON으로 게시글을 등록하고 식별자로 조회하게 만듭니다. 웹 계층은 도메인 객체를 그대로 노출하지 않고 세 종류의 변환을 담당합니다.
- 요청 JSON을
CreatePostRequest로 역직렬화한다. - 요청 DTO를
CreatePostCommand로 바꾼다. - 저장된
Post을PostResponse로 바꾼다.
이 변환이 번거로워 보여도 API 계약과 내부 모델의 변경 속도를 분리합니다. 나중에 첨부 파일이나 관리 상태를 도메인에 추가해도 모든 필드를 자동으로 외부에 공개하지 않습니다.
웹 입력 DTO 규칙
Bean 검증을 사용하려면 의존성을 한 줄 추가합니다.
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
) {
CreatePostCommand toCommand() {
return new CreatePostCommand(
authorId, title, content, publishedOn);
}
}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}를 함께 반환합니다.
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 long id) {
return PostResponse.from(service.find(id));
}
@GetMapping
public List<PostResponse> findAll() {
return service.findAll().stream()
.map(PostResponse::from)
.toList();
}
}애플리케이션을 실행하고 등록 요청을 보냅니다.
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"}Location은 “생성 결과는 응답 본문에 있다”를 넘어 새 리소스를 다시 조회할 표준 위치를 알려 줍니다.
예외의 HTTP 변환
현재 PostNotFoundException을 처리하지 않으면 500으로 보일 수 있습니다.
서버 결함이 아니라 요청한 리소스가 없다는 의미이므로 404로 바꿔야 합니다.
중복 등록은 현재 상태와 충돌하므로 409, 잘못된 도메인 값은 400으로 표현합니다.
package board.web;
import java.net.URI;
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({
IllegalArgumentException.class,
MethodArgumentNotValidException.class
})
ProblemDetail badRequest(Exception 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()));
problem.setTitle(code);
problem.setProperty("code", code);
return problem;
}
}존재하지 않는 게시글을 조회합니다.
HTTP/1.1 404
Content-Type: application/problem+json
{
"type": "https://andongmin.com/problems/post-not-found",
"title": "POST_NOT_FOUND",
"status": 404,
"detail": "post not found: 999",
"instance": "/api/posts/999",
"code": "POST_NOT_FOUND"
}예외 클래스가 HTTP를 알지 않는 점을 확인하세요. 애플리케이션 계층은 “없음”과 “중복”을 구분하고 웹 어드바이스가 상태 코드와 표현을 결정합니다.
MVC 파이프라인 테스트
컨트롤러 메서드를 직접 호출하면 @RequestBody 역직렬화, @Valid, 매핑, 메시지 컨버터를 검증할 수 없습니다.
Spring Framework 7의 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");
}
}PostControllerTest > 등록하고_Location으로_조회한다() PASSED
PostControllerTest > 없는_게시글은_404_problem_detail을_반환한다() PASSED빈 본문 입력 검증 위치
빈 content는 DTO의 @NotBlank에서 먼저 거부됩니다.
721자 본문은 DTO의 @Size와 PostDraft의 상한에서 모두 거부됩니다.
웹 검증은 필드별 안내를 만들고 도메인 검증은 웹이 아닌 진입점까지 같은 불변식을 지킵니다.
이 차이는 중복 검증이 쓸모없다는 뜻이 아닙니다.
웹 검증은 필드별 피드백을 만들기 좋고 도메인 검증은 모든 진입점의 불변식을 지킵니다.
이후 오류 장에서 MethodArgumentNotValidException의 필드 오류를 표준 오류 목록으로 정리합니다.
연습 문제
같은 요청을 두 번 POST해 두 번째 응답이 409와 POST_DUPLICATE를 반환하는 테스트를 추가하세요.
첫 응답의 Location과 두 번째 오류의 instance도 함께 확인합니다.
해설 보기
같은 JSON을 두 번 보내되 첫 요청의 상태를 저장소에 남겨야 하므로 두 요청은 같은 client와 서비스를 사용합니다.
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();
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 수직 슬라이스가 완성되었습니다. 마지막 문서에서는 새 기술을 더하지 않고 회원가입부터 게시글 작성·조회까지 사용자가 실제로 밟는 순서로 지금까지 만든 조각을 연결합니다. 데이터베이스 저장과 AOP는 기본 웹 흐름이 완성된 뒤 후반 장에서 다룹니다.