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

안동민 개발노트

본문 시작
6장 : Spring MVC 요청·응답

게시판 CRUD 웹

게시글 생성·목록·상세·전체 대체·삭제를 하나의 Spring MVC 컨트롤러에 연결하고 위치, ETag·If-Match, 201·200·204·404·412를 수직 흐름으로 검증합니다.

개별 애노테이션을 이해한 뒤에는 한 리소스의 생애를 끝까지 연결해야 합니다. 게시판 게시글을 생성하고 조회하고 버전 조건으로 수정하고 삭제합니다. 컨트롤러는 HTTP 표현을, 서비스는 사용 사례와 트랜잭션을, 리포지토리는 저장을 담당합니다.


컬렉션·항목 URI

Method·URI행동성공
POST /api/posts서버 ID로 생성201 + 위치
GET /api/posts조건에 맞는 목록200
GET /api/posts/{id}상세 조회200 + ETag
PUT /api/posts/{id}전체 대체200 + 새 ETag
DELETE /api/posts/{id}삭제204

PUT은 제목, 본문, publishedOn 전체 표현을 받습니다. 일부 필드만 바꾸려면 패치 작업을 별도 설계합니다. POST 생성 재시도에는 멱등성 키를 적용할 수 있습니다.

컨트롤러의 HTTP 책임

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.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/posts")
class PostController {
    private final PostService service;

    PostController(PostService service) {
        this.service = service;
    }

    @PostMapping
    ResponseEntity<PostResponse> create(
            @Valid @RequestBody
                    CreatePostRequest request) {
        var saved = service.register(
                request.toCommand());
        return ResponseEntity
                .created(URI.create(
                        "/api/posts/" + saved.id()))
                .eTag(etag(saved))
                .body(PostResponse.from(saved));
    }

    @GetMapping
    List<PostResponse> list() {
        return service.findAll()
                .stream()
                .map(PostResponse::from)
                .toList();
    }

    @GetMapping("/{id}")
    ResponseEntity<PostResponse> get(
            @PathVariable long id) {
        var post = service.required(id);
        return ResponseEntity.ok()
                .eTag(etag(post))
                .body(PostResponse.from(post));
    }

    @PutMapping("/{id}")
    ResponseEntity<PostResponse> replace(
            @PathVariable long id,
            @RequestHeader(HttpHeaders.IF_MATCH)
                    String ifMatch,
            @Valid @RequestBody
                    ReplacePostRequest request) {
        var expectedVersion =
                PostEtag.parseVersion(id, ifMatch);
        var replaced = service.replace(
                id,
                expectedVersion,
                request.toCommand());
        return ResponseEntity.ok()
                .eTag(etag(replaced))
                .body(PostResponse.from(replaced));
    }

    @DeleteMapping("/{id}")
    ResponseEntity<Void> delete(
            @PathVariable long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }

    private static String etag(Post post) {
        return "\\"post-" + post.id()
                + "-v" + post.version() + "\\"";
    }
}

ETag 파서는 ID와 버전 형식을 검증하고 약한 태그 허용 여부를 정합니다. 원시 If-Match에서 숫자만 대충 추출하지 않습니다. 여러 엔티티 태그와 와일드카드 지원이 필요하면 프레임워크의 사전 조건 지원이나 검증된 파서를 사용합니다.

If-Match 경쟁 방지

Alice와 Bob이 버전 1을 읽고 서로 다른 수정을 보내면 마지막 쓰기가 앞 변경을 조용히 덮을 수 있습니다. 두 요청이 If-Match: "post-42-v1"을 보내고 첫 수정이 버전 2를 만들면 둘째는 사전 조건 실패입니다.

API에서 이 실패를 412 사전 조건 실패로 반환할 수 있습니다. DB 갱신도 where id=? and version=?처럼 원자적으로 버전을 확인해야 합니다. 컨트롤러에서 먼저 조회해 비교한 뒤 조건 없는 갱신을 하면 두 요청 사이 경쟁이 남습니다.

src/main/java/board/application/PostService.java
public Post replace(
        long id,
        long expectedVersion,
        ReplacePostCommand command) {
    var draft = command.toDraft();
    var updated = repository.replaceIfVersion(
            id, expectedVersion, draft);
    return updated.orElseThrow(() -> {
        if (!repository.existsById(id)) {
            return new PostNotFoundException(id);
        }
        return new PostVersionConflictException(
                id, expectedVersion);
    });
}

존재 확인을 후속으로 해 404와 412를 구분할 때도 경쟁이 있을 수 있습니다. 리포지토리가 갱신 결과와 현재 상태를 트랜잭션 안에서 반환하거나 정책상 두 경우를 하나의 충돌로 표현할 수 있습니다. API 의미와 데이터베이스 보장을 함께 설계합니다.

리소스 수명별 오류 상태

src/main/java/board/web/CrudExceptionHandler.java
@RestControllerAdvice
class CrudExceptionHandler {
    @ExceptionHandler(PostNotFoundException.class)
    ResponseEntity<ProblemDetail> notFound(
            PostNotFoundException failure) {
        var problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND,
                "Post was not found");
        problem.setProperty(
                "code", "POST_NOT_FOUND");
        return ResponseEntity.status(404).body(problem);
    }

    @ExceptionHandler(
            PostVersionConflictException.class)
    ResponseEntity<ProblemDetail> staleVersion(
            PostVersionConflictException failure) {
        var problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.PRECONDITION_FAILED,
                "The post has changed");
        problem.setProperty(
                "code", "POST_VERSION_MISMATCH");
        return ResponseEntity.status(412).body(problem);
    }
}

데이터베이스 낙관적 잠금 예외 이름을 그대로 웹 어드바이스에 노출하지 않고 애플리케이션 예외로 번역합니다. 클라이언트는 412를 받으면 최신 GET과 ETag를 가져와 사용자에게 병합 또는 재시도를 제안합니다.

CRUD MVC 흐름

src/test/java/board/web/PostCrudFlowTest.java
package board.web;

import static org.springframework.http.MediaType
        .APPLICATION_JSON;

import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.client
        .RestTestClient;

class PostCrudFlowTest {
    @Test
    void create_get_replace_delete를_한_resource로_연결한다() {
        var service =
                new InMemoryVersionedPostService();
        var client = RestTestClient
                .bindToController(
                        new PostController(service))
                .configureServer(builder ->
                        builder.setControllerAdvice(
                                new CrudExceptionHandler()))
                .build();

        client.post()
                .uri("/api/posts")
                .contentType(APPLICATION_JSON)
                .body(json("HTTP CRUD", "POST로 게시글을 생성합니다."))
                .exchange()
                .expectStatus().isCreated()
                .expectHeader()
                .valueEquals(
                        "Location", "/api/posts/1")
                .expectHeader()
                .valueEquals(
                        "ETag", "\\"post-1-v1\\"");

        client.get()
                .uri("/api/posts/1")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.content").isEqualTo("POST로 게시글을 생성합니다.");

        client.put()
                .uri("/api/posts/1")
                .header(
                        "If-Match", "\\"post-1-v1\\"")
                .contentType(APPLICATION_JSON)
                .body(json("HTTP CRUD", "PUT으로 게시글 전체를 교체합니다."))
                .exchange()
                .expectStatus().isOk()
                .expectHeader()
                .valueEquals(
                        "ETag", "\\"post-1-v2\\"")
                .expectBody()
                .jsonPath("$.content").isEqualTo("PUT으로 게시글 전체를 교체합니다.");

        client.put()
                .uri("/api/posts/1")
                .header(
                        "If-Match", "\\"post-1-v1\\"")
                .contentType(APPLICATION_JSON)
                .body(json("stale", "오래된 버전으로 수정합니다."))
                .exchange()
                .expectStatus().isEqualTo(412)
                .expectBody()
                .jsonPath("$.code")
                .isEqualTo("POST_VERSION_MISMATCH");

        client.delete()
                .uri("/api/posts/1")
                .exchange()
                .expectStatus().isNoContent()
                .expectBody().isEmpty();

        client.get()
                .uri("/api/posts/1")
                .exchange()
                .expectStatus().isNotFound();
    }
}
실행 결과
PostCrudFlowTest
  > create_get_replace_delete를_한_resource로_연결한다() PASSED
POST = 201 Location /api/posts/1 ETag v1
PUT If-Match v1 = 200 ETag v2
stale PUT v1 = 412
DELETE = 204
GET after delete = 404
BUILD SUCCESSFUL

CRUD의 공통 경계

컨트롤러마다 직접 리포지토리를 호출하면 인가, 찾을 수 없음, 버전, 감사 정책이 흩어집니다. 서비스 사용 사례가 회원 동일성과 트랜잭션을 받습니다.

목록은 무제한 전체 반환 대신 페이지네이션과 안정적인 정렬을 둡니다. 삭제가 실제 행 제거인지 논리 삭제인지와 관계없이 API에는 “리소스가 더 이상 조회되지 않음”으로 보이지만 감사·보존 기간 정책은 애플리케이션에 남습니다.

삭제 반복의 둘째 요청을 404로 할지 204로 계속 성공 처리할지 계약을 정합니다. 최종 리소스 없음이라는 멱등 효과는 같지만 클라이언트 관찰이 다릅니다.

연습 문제

목록 페이지네이션과 본문 패치를 추가하세요. 패치는 기존 본문에 암묵적으로 덧붙이지 말고 content를 새 문자열로 교체하며 If-Match를 필수로 요구합니다. 같은 패치와 버전을 두 번 보내면 둘째는 412여야 합니다.

해설 보기

패치 DTO는 필드 존재 여부와 null을 구분해야 합니다. content만 지원한다면 작업을 좁게 정의합니다.

public record ChangeContentRequest(
        @NotBlank @Size(max = 720)
        String content) {
}

@PatchMapping(
        path = "/{id}/content",
        consumes = "application/json")
ResponseEntity<PostResponse> changeContent(
        @PathVariable long id,
        @RequestHeader("If-Match") String etag,
        @Valid @RequestBody
                ChangeContentRequest request) {
    // parse version and atomic update
}

페이지네이션 링크에는 현재 필터와 안정적인 정렬 커서를 보존합니다. 테스트는 페이지 경계, 유효하지 않은 크기, ETag 오래된, 검증 실패가 서비스 호출 전인지 각각 확인합니다.

다음 문서에서는 완성한 CRUD에서 404·400·415·406·500 증상을 받았을 때 DispatcherServlet 파이프라인을 뒤에서 앞으로 역추적하는 진단 절차를 만든다.