본문으로 건너뛰기

안동민 개발노트

본문 시작

게시판 웹 등록·조회

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

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

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

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

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

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

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

POST 한 번이 네 경계를 통과한다

JSON을 그대로 저장하지 않고 DTO·command·domain·response로 의도를 변환한다.

  1. 1. CreatePostRequest

    CreatePostRequest JSON 형식과 Bean Validation

  2. 2. CreatePostCommand

    CreatePostCommand 웹과 독립된 사용 사례 입력

  3. 3. PostService

    PostService 불변식·중복·저장 순서

  4. 4. PostResponse

    PostResponse 201 · Location · 공개 필드


웹 입력 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
) {
    CreatePostCommand toCommand() {
        return new CreatePostCommand(
                authorId, title, content, publishedOn);
    }
}
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 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 계약을 분리한다

성공과 실패는 상태 코드, 헤더, 본문을 함께 읽어야 다음 클라이언트 행동이 정해진다.

요청성공 계약대표 실패
POST /posts201 + Location400 · 409
GET /posts/{id}200 + JSON404
GET /posts200 + JSON 배열표현 협상 실패

예외의 HTTP 변환

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

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

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

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

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

GET /api/posts/999
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를 알지 않는 점을 확인하세요.

애플리케이션 계층은 “없음”과 “중복”을 구분하고 웹 어드바이스가 상태 코드와 표현을 결정합니다.

예외는 경계를 건너며 HTTP 의미를 얻는다

도메인과 application 예외가 web advice에서 ProblemDetail로 번역된다.

  1. 400 · IllegalArgument

    유효하지 않은 게시글 입력

  2. 409 · DuplicatePost

    현재 상태와 충돌

  3. 404 · PostNotFound

    요청한 리소스 미존재


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");
    }
}
테스트 결과
PostControllerTest > 등록하고_Location으로_조회한다() PASSED
PostControllerTest > 없는_게시글은_404_problem_detail을_반환한다() PASSED
RestTestClient는 서버 없이 MVC 경계를 실행한다

직접 메서드 호출보다 넓고 실제 포트 테스트보다 작은 범위에서 매핑과 변환을 확인한다.

  1. 1. Test request

    Test request method · URI · headers · JSON body

  2. 2. DispatcherServlet

    DispatcherServlet mapping · validation · converter

  3. 3. ControllerAdvice

    ControllerAdvice 예외를 ProblemDetail로 변환

  4. 4. Expectations

    Expectations status · Location · JSON path


빈 본문 입력 검증 위치

content는 DTO의 @NotBlank에서 먼저 거부됩니다.

721자 본문은 DTO의 @SizePostDraft의 상한에서 모두 거부됩니다.

웹 검증은 필드별 안내를 만들고 도메인 검증은 웹이 아닌 진입점까지 같은 불변식을 지킵니다.

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

웹 검증은 필드별 피드백을 만들기 좋고 도메인 검증은 모든 진입점의 불변식을 지킵니다.

이후 오류 장에서 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는 기본 웹 흐름이 완성된 뒤 후반 장에서 다룹니다.