본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
1장 : 회원가입과 게시판 시작

게시판 웹 등록·조회

누적한 도메인·서비스를 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
) {
    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 변환

현재 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를 알지 않는 점을 확인하세요. 애플리케이션 계층은 “없음”과 “중복”을 구분하고 웹 어드바이스가 상태 코드와 표현을 결정합니다.

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

빈 본문 입력 검증 위치

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