본문으로 건너뛰기

안동민 개발노트

본문 시작

게시판 웹 등록·조회

누적한 도메인·서비스를 REST 컨트롤러에 연결해 POST 등록, GET 조회, 위치 헤더, ProblemDetail 오류 계약을 실제 HTTP로 검증합니다.

지금까지의 코드는 테스트에서만 호출할 수 있었습니다.

이제 외부 클라이언트가 JSON으로 게시글을 등록하고 식별자로 조회하게 만듭니다.

웹 계층은 도메인 객체를 그대로 노출하지 않고 세 종류의 변환을 담당합니다.

  1. 요청 JSON을 CreatePostRequest로 역직렬화한다.
  2. 요청 DTO를 CreatePostCommand로 바꾼다.
  3. 저장된 PostPostResponse로 바꾼다.

이 변환이 번거로워 보여도 API 계약과 내부 모델의 변경 속도를 분리합니다.

나중에 첨부 파일이나 관리 상태를 도메인에 추가해도 모든 필드를 자동으로 외부에 공개하지 않습니다.


웹 입력 DTO 규칙

Bean 검증을 사용하려면 의존성을 한 줄 추가합니다.

build.gradle - dependencies에 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'

@NotBlank@Size는 HTTP 입력의 기본 형식을 빠르게 거릅니다.

도메인의 본문 길이 1~720자 규칙은 그대로 남습니다.

두 검증이 일부 겹치더라도 도메인이 다른 진입점에서 무방비가 되어서는 안 됩니다.

src/main/java/board/web/CreatePostRequest.java
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();
    }
}

compact constructor는 null을 그대로 두고 문자열만 먼저 strip()합니다.

따라서 @NotBlank@Size는 정규화된 값을 검사하고, 50·80·720 상한은 PostDraft와 같은 String.length()의 UTF-16 code unit 기준을 사용합니다.

웹 검증이 실패하면 toCommand()가 호출되지 않아 PostDraft도 만들어지지 않습니다.

반대로 비웹 진입점에서 CreatePostCommand를 직접 만들면 PostService가 생성하는 PostDraft가 구조 규칙을 다시 확인합니다.

publishedOn@NotNull은 구조만 확인합니다. 미래 날짜 여부는 주입된 Clock으로 계산한 today를 사용해 서비스가 저장소 호출 전에 검사합니다.

src/main/java/board/web/PostResponse.java
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());
    }
}

toCommandfrom은 단순 복사처럼 보이지만 경계를 한곳에 고정합니다.

컨트롤러가 필드별 생성 코드를 반복하지 않고, 응답에 어떤 속성을 공개하는지 검색하기 쉬워집니다.


201 응답과 리소스 위치

POST /api/posts는 저장된 게시글을 본문에 담고 Location: /api/posts/{id}를 함께 반환합니다.

src/main/java/board/web/PostController.java
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();
    }
}

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"}

Location은 “생성 결과는 응답 본문에 있다”를 넘어 새 리소스를 다시 조회할 표준 위치를 알려 줍니다.

게시글 JSON이 정규화된 request DTO와 application command, PostService의 구조·시간·중복·저장 경계를 거쳐 식별자가 있는 Post와 공개 응답으로 변환되는 흐름 및 세 API endpoint의 현재 테스트 증거를 설명합니다.

VERTICAL SLICE · BODY, STATUS, AND LOCATION

JSON을 그대로 저장하지 않고 각 경계의 언어로 변환한다

웹 형식 검증, application 시간·중복 정책, 저장 결과, HTTP 응답 책임은 서로 다른 단계입니다. 성공 응답에서는 PostResponse가 본문 필드를, 컨트롤러의 ResponseEntity가 201과 Location을 결정합니다.

POST /api/posts · SUPPORTED PATH

등록 요청은 다섯 경계를 순서대로 지난다

  1. JSON → CreatePostRequest

    Jackson이 canonical constructor를 호출해 역직렬화를 완성합니다. compact constructor에서 strip()한 값이 필드에 배정된 뒤 Spring MVC가 생성된 request 객체의 50·80·720 UTF-16 code-unit 상한과 필수값을 검사합니다.

  2. CreatePostRequest.toCommand()

    웹 DTO를 웹 애노테이션이 없는 CreatePostCommand로 바꿉니다. 웹 검증 실패 시 이 단계에 도달하지 않습니다.

  3. PostService.register(command)

    PostDraft 구조 재검사 → 호출당 today 한 번 계산 → 미래 날짜 gate → 중복 existssave 순서입니다.

  4. 저장 결과 → Post

    repository의 지원 저장 경로가 양수 ID를 부여한 게시글을 반환합니다.

  5. PostResponse + HTTP metadata

    응답 DTO는 공개 본문 필드를 고르고, ResponseEntity.created(location)이 201과 새 리소스 URI를 설정합니다.

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가 독립된 최종 경계로 남습니다.


예외의 HTTP 변환

현재 PostNotFoundException을 처리하지 않으면 500으로 보일 수 있습니다.

서버 결함이 아니라 요청한 리소스가 없다는 의미이므로 404로 바꿔야 합니다.

중복 등록은 현재 상태와 충돌하므로 409, 잘못된 도메인 값은 400으로 표현합니다.

src/main/java/board/web/ApiExceptionHandler.java
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;
    }
}

검증 예외에는 프레임워크가 만든 긴 바인딩 메시지를 그대로 노출하지 않고 안정된 detail을 사용합니다.

현재 IllegalArgumentException handler는 이 수직 슬라이스의 도메인 구조 검증 실패를 400으로 바꾸는 경계입니다.

다른 프로그래밍 오류가 같은 예외 타입으로 들어오기 시작하면 더 구체적인 애플리케이션 예외로 범위를 좁혀야 합니다.

type은 기본 locale과 무관하게 소문자 kebab-case URI가 되고, title은 HTTP reason phrase를 사용합니다.

instance를 직접 지정하지 않으면 Spring MVC가 현재 요청 경로를 채웁니다.

ProblemDetail의 Jackson mixin은 setProperty("code", code)로 추가한 확장 속성을 최상위 code 필드로 렌더링합니다.

존재하지 않는 게시글을 조회합니다.

GET /api/posts/999
HTTP/1.1 404
Content-Type: application/problem+json

{
  "type": "https://andongmin.com/problems/post-not-found",
  "title": "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 경계를 실행합니다.

src/test/java/board/web/PostControllerTest.java
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");
    }
}
테스트 결과
PostControllerTest > 등록하고_Location으로_조회한다() PASSED
PostControllerTest > 없는_게시글은_404_problem_detail을_반환한다() PASSED
PostControllerTest > 공백_content는_400_problem_detail을_반환한다() PASSED

세 테스트가 실행한 경계는 다음과 같습니다.

  • 유효한 JSON의 역직렬화와 매핑, 201·Location·응답 JSON, 같은 서비스 상태를 사용한 후속 GET
  • 명시적으로 등록한 advice가 만드는 404 application/problem+jsonPOST_NOT_FOUND
  • 공백 content의 정규화, @Valid 거부와 고정 detail, 400 application/problem+json, INVALID_POST, 자동 instance

독립형 RestTestClient는 제공한 컨트롤러와 advice, mock servlet, standalone 기본 converter·validator만 실행합니다.

실제 Boot component scan과 BoardConfig, 애플리케이션 ObjectMapper 사용자 설정, filter·security·interceptor, 실제 서버와 네트워크는 검증하지 않습니다.

409 중복, 목록 조회, 표현 협상은 아직 이 transcript의 실행 증거가 아닙니다.

독립형 RestTestClient 요청이 mock servlet의 공통 매핑을 지난 뒤 POST에서는 JSON converter와 validator를, GET에서는 path-variable 변환을 실행하고 성공 응답 또는 명시적으로 등록한 advice의 ProblemDetail 오류 응답으로 갈라지는 흐름과 테스트 보장 한계를 설명합니다.

STANDALONE MVC · TESTED VS NOT LOADED

공통 MVC 경계 뒤에서 성공과 오류가 서로 다른 branch로 간다

RestTestClient.bindToController(controller)는 실제 서버 포트를 열지 않습니다. 제공한 controller·advice와 mock servlet, standalone 기본 converter·validator를 조립해 그 범위만 실행합니다.

COMMON REQUEST PATH

공통 매핑 뒤 method별 입력 경계가 갈라진다

  1. method · URI · headers

    RestTestClient가 요청을 만들고 standalone MockMvc의 mock servlet에 전달합니다.

  2. 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를 실행합니다.

현재 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 애플리케이션 전체를 대신하지 않습니다.


content 필드 검증 위치

빈 값이나 공백뿐인 content는 request compact constructor가 strip()한 뒤 DTO의 @NotBlank에서 먼저 거부됩니다.

정규화 후 721 UTF-16 code unit인 본문도 DTO의 @Size에서 먼저 거부되므로 같은 웹 요청에서는 PostDraft가 만들어지지 않습니다.

웹을 거치지 않고 command를 만든 진입점에서는 PostDraft가 같은 720 상한을 최종 구조 규칙으로 지킵니다.

이 차이는 중복 검증이 쓸모없다는 뜻이 아닙니다.

웹 검증은 필드별 피드백을 만들기 좋고 도메인 검증은 비웹 진입점에도 같은 문자열 구조 규칙을 적용합니다.

이후 오류 장에서 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()
        .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 수직 슬라이스가 완성되었습니다.

마지막 문서에서는 새 기술을 더하지 않고 회원가입부터 게시글 작성·조회까지 사용자가 실제로 밟는 순서로 지금까지 만든 조각을 연결합니다.

데이터베이스 저장과 AOP는 기본 웹 흐름이 완성된 뒤 후반 장에서 다룹니다.