본문으로 건너뛰기

안동민 개발노트

본문 시작

게시판 웹 통합

로그인 세션부터 트랜잭션·ProblemDetail까지 게시판 요청 한 건의 전체 흐름을 연결합니다.

개별 기능을 이해해도 애플리케이션에 합칠 때 순서가 어긋나면 보안과 오류 계약이 깨집니다.

게시판 게시글 작성은 트레이싱 필터에서 시작해 인증 컨텍스트, DTO 변환과 검증, 트랜잭션, 응답, 예외 리졸버를 지나갑니다.

각 단계는 앞 단계가 보장한 값만 믿고 실패하면 뒤 단계의 부수 효과가 실행되지 않아야 합니다.

게시글 등록은 추적·인증·검증·트랜잭션을 한 흐름으로 잇는다

REQUEST PIPELINE · IDEMPOTENCY · PROBLEM DETAIL

게시글 등록은 추적·인증·검증·트랜잭션을 한 흐름으로 잇는다

필터가 발급한 요청 ID와 서버 세션의 회원만 다음 경계로 전달된다. DTO 검증을 통과한 명령은 멱등성·활성 상태·게시일별 한도를 한 트랜잭션에서 판정하고, 성공과 도메인 실패가 같은 추적 ID를 공유한다.

게시글 등록은 추적·인증·검증·트랜잭션을 한 흐름으로 잇는다 POST 요청과 세션이 RequestTraceFilter, 로그인 인터셉터와 CurrentMember resolver, JSON 및 Bean Validation, 컨트롤러 명령 조립, 서비스 트랜잭션을 차례로 지나 201 응답이 된다. 게시일별 한도 예외는 전용 advice가 409 ProblemDetail로 바꾸고 같은 요청 ID를 응답 헤더와 본문에 연결한다. POST /api/posts · ONE REQUEST-LIFETIME TRACE HTTP INPUT JSON + session client key 별도 FILTER RequestTraceFilter attr · MDC · header finally 복원 AUTH CONTEXT LoginInterceptor @CurrentMember 세션 회원만 주입 READ + VALIDATE Jackson + @Valid 실패 400 use case 0회 CONTROLLER CreatePost Command member + DTO title trim TRANSACTION CreatePostService idempotency · active date quota · save HTTP OUTPUT 201 Created Location · ETag X-Request-Id POST CREATION ADVICE 409 ProblemDetail date quota · exact counts requestId = filter attribute BOUNDARY OUTCOMES 인증 실패 인터셉터가 컨트롤러 전에 302 또는 API 401로 중단 입력 실패 MethodArgumentNotValidException 400 · service 호출 없음 도메인 충돌 전용 advice가 안정 code와 동일 requestId를 반환 LEGEND 커밋 가능한 주 경로 도메인 실패 응답 요청 수명 추적 문맥
  1. REQUEST TRACE

    필터가 안전한 X-Request-Id를 응답·요청 속성·MDC에 게시한다

    REQUEST와 ERROR dispatch에서도 같은 요청 수명 ID를 쓰고 각 dispatch 뒤 이전 문맥을 복원합니다.

  2. AUTH CONTEXT

    세션 리더와 인터셉터가 서버가 검증한 회원을 게시한다

    @CurrentMember resolver는 정확히 AuthenticatedMember 타입만 주입합니다.

  3. READ + VALIDATE

    Jackson과 Bean Validation이 transport 계약을 확정한다

    실패하면 400으로 끝나고 use case는 한 번도 호출되지 않습니다.

  4. CONTROLLER

    회원과 검증된 DTO를 CreatePostCommand로 조립한다

    HTTP 요청 ID와 재시도용 client key는 서로 다른 책임으로 유지합니다.

  5. TRANSACTION

    서비스가 멱등성·활성 상태·게시일별 한도·저장을 판정한다

    같은 키와 payload는 기존 결과를 재생하고 다른 payload는 충돌로 끝냅니다.

  6. 201 CREATED

    성공 응답은 Location·ETag·X-Request-Id를 반환한다

    엔티티가 아니라 공개 가능한 response DTO만 직렬화합니다.

  7. 409 PROBLEM DETAIL

    게시일별 한도 advice가 안정 code와 정확한 수치를 반환한다

    본문 requestId는 필터가 게시한 요청 속성에서만 읽으며 fallback ID를 만들지 않습니다.

요청 ID는 관찰 가능성, client request key는 멱등성을 소유한다. 두 값을 섞지 않아야 로그 연결과 안전한 재시도를 동시에 검증할 수 있다.


요청 파이프라인 경계

POST /api/posts의 클라이언트 입력은 JSON과 세션 쿠키입니다.

필터는 요청 ID를 만들지만 인증을 증명하지 않습니다.

세션 리더와 리졸버가 서버 측 인증을 AuthenticatedMember로 바꾸고, Jackson과 Bean 검증이 요청 DTO를 검증합니다.

서비스는 현재 회원과 명령을 트랜잭션에서 처리합니다.

경계성공 출력대표 실패뒤 단계 실행
트레이스 필터요청 ID내부 생성 실패중단
세션 인증현재 구성원401컨트롤러 미실행
JSON 변환요청 DTO잘못된 형식 400검증 미실행
Bean 검증유효한 DTO필드 400서비스 미실행
애플리케이션 서비스저장된 게시글정책 예외트랜잭션 롤백
Response 어댑터201 + 위치직렬화 500커밋 시점 점검

컨트롤러 명령 조립

컨트롤러에서 세션 속성 문자열을 다시 읽거나 리포지토리로 권한을 확인하지 않습니다.

리졸버가 제공한 회원 ID와 DTO를 애플리케이션 명령으로 변환하고 HTTP 응답을 결정합니다.

src/main/java/board/application/postcreation/CreatePostUseCase.java
package board.application.postcreation;

import java.time.LocalDate;
import java.util.Objects;

public interface CreatePostUseCase {
    PostResponse handle(CreatePostCommand command);

    record CreatePostCommand(
            long memberId,
            String title,
            String content,
            LocalDate publishedOn,
            String clientRequestId
    ) {
        public CreatePostCommand {
            if (memberId <= 0) {
                throw new IllegalArgumentException("memberId must be positive");
            }
            Objects.requireNonNull(publishedOn, "publishedOn");
            if (title == null || title.isBlank() || title.length() > 80) {
                throw new IllegalArgumentException("invalid title");
            }
            if (content == null || content.isBlank()
                    || content.length() > 720) {
                throw new IllegalArgumentException("invalid content");
            }
            if (clientRequestId == null
                    || !clientRequestId.matches("[A-Za-z0-9_-]{8,64}")) {
                throw new IllegalArgumentException("invalid clientRequestId");
            }
        }
    }

    record PostResponse(long id, long version, String title, String content) {
        public PostResponse {
            if (id <= 0 || version < 0) {
                throw new IllegalArgumentException("invalid response identity");
            }
            Objects.requireNonNull(title, "title");
            Objects.requireNonNull(content, "content");
        }
    }
}
src/main/java/board/web/postcreation/IntegratedPostController.java
package board.web.postcreation;

import static board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import static board.application.postcreation.CreatePostUseCase.PostResponse;

import java.net.URI;
import java.time.LocalDate;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

import board.application.postcreation.CreatePostUseCase;
import board.web.auth.CurrentMember;
import board.web.auth.LoginRequired;
import board.web.auth.SessionMemberReader.AuthenticatedMember;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@LoginRequired
public final class IntegratedPostController {
    private final CreatePostUseCase register;

    public IntegratedPostController(CreatePostUseCase register) {
        this.register = register;
    }

    @PostMapping("/api/posts")
    ResponseEntity<PostResponse> create(
            @CurrentMember AuthenticatedMember member,
            @Valid @RequestBody CreatePostRequest request
    ) {
        var command = new CreatePostCommand(
                member.id(),
                request.title().strip(),
                request.content(),
                request.publishedOn(),
                request.clientRequestId());
        PostResponse response = register.handle(command);
        return ResponseEntity
                .created(URI.create("/api/posts/" + response.id()))
                .eTag("\"" + response.version() + "\"")
                .body(response);
    }

    public record CreatePostRequest(
            @NotBlank @Size(max = 80) String title,
            @NotBlank @Size(max = 720) String content,
            @NotNull @PastOrPresent LocalDate publishedOn,
            @NotBlank
            @Pattern(regexp = "[A-Za-z0-9_-]{8,64}")
            String clientRequestId
    ) {
    }
}

clientRequestId는 네트워크 재시도의 중복 등록을 막는 멱등성 키입니다.

현재 구성원과 함께 고유 범위를 만들고 페이로드 지문이 다르면 서비스가 키 충돌을 거부합니다.

공개 409 계약은 범위 지정 handler와 exact test를 추가하는 단계에서 확정합니다.

HTTP 요청 ID는 관찰 가능성용이므로 멱등성 키로 재사용하지 않습니다.

API 응답에 엔티티를 그대로 직렬화하지 않습니다.

버전, 정규 ID, 필요한 표시 값만 응답 DTO로 반환합니다.

ETag는 이후 조건부 수정에 사용하며 문자열 인용 형식을 계약 테스트로 확인합니다.


서비스 트랜잭션 경계

인증됐다는 사실만으로 모든 게시글을 수정할 수 있는 것은 아닙니다.

등록에서는 회원 활성 상태, 선택한 게시일별 본문 한도, 멱등성 키를 트랜잭션 안에서 확인합니다.

동일 요청이 재전송되면 기존 결과를 반환하고 다른 페이로드로 키를 재사용하면 충돌을 알립니다.

src/main/java/board/application/postcreation/CreatePostService.java
package board.application.postcreation;
import static board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import static board.application.postcreation.CreatePostUseCase.PostResponse;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public final class CreatePostService
        implements CreatePostUseCase {
    private final MemberRepository members;
    private final PostRepository posts;
    private final Clock clock;
    public CreatePostService(
            MemberRepository members,
            PostRepository posts,
            Clock clock
    ) {
        this.members = members;
        this.posts = posts;
        this.clock = clock;
    }
    @Override
    @Transactional
    public PostResponse handle(CreatePostCommand command) {
        var previous = posts.findByIdempotencyKey(
                command.memberId(), command.clientRequestId());
        if (previous.isPresent()) {
            previous.get().requireSamePayload(command);
            return response(previous.get());
        }
        var member = members.required(command.memberId());
        member.requireActive();
        int used = posts.totalCharacters(
                member.id(), command.publishedOn());
        member.requireDailyLimit(used, command.content().length());
        var post = Post.register(
                member.id(),
                command.title(),
                command.content(),
                command.publishedOn(),
                command.clientRequestId(),
                clock.instant());
        Post saved = posts.save(post);
        return response(saved);
    }
    private PostResponse response(Post post) {
        return new PostResponse(
                post.id(), post.version(), post.title(), post.content());
    }
}
interface MemberRepository {
    Member required(long memberId);
}
interface PostRepository {
    Optional<Post> findByIdempotencyKey(
            long memberId, String clientRequestId);
    int totalCharacters(long memberId, LocalDate publishedOn);
    Post save(Post post);
}
record Member(long id, boolean active, int dailyCharacterLimit) {
    Member {
        if (id <= 0 || dailyCharacterLimit < 1) {
            throw new IllegalArgumentException("invalid member state");
        }
    }
    void requireActive() {
        if (!active) {
            throw new MemberInactiveException();
        }
    }
    void requireDailyLimit(int usedCharacters, int attemptedCharacters) {
        if ((long) usedCharacters + attemptedCharacters
                > dailyCharacterLimit) {
            throw new DailyLimitExceededException(
                    dailyCharacterLimit,
                    usedCharacters,
                    attemptedCharacters);
        }
    }
}
record Post(
        long id,
        long version,
        long memberId,
        String title,
        String content,
        LocalDate publishedOn,
        String clientRequestId,
        Instant createdAt
) {
    static Post register(
            long memberId,
            String title,
            String content,
            LocalDate publishedOn,
            String clientRequestId,
            Instant createdAt
    ) {
        return new Post(
                0L, 0L, memberId, title, content,
                publishedOn, clientRequestId, createdAt);
    }
    void requireSamePayload(CreatePostCommand command) {
        if (memberId != command.memberId()
                || !title.equals(command.title())
                || !content.equals(command.content())
                || !publishedOn.equals(command.publishedOn())
                || !clientRequestId.equals(command.clientRequestId())) {
            throw new IdempotencyKeyConflictException(clientRequestId);
        }
    }
}
final class MemberInactiveException extends RuntimeException {
}
final class IdempotencyKeyConflictException extends RuntimeException {
    IdempotencyKeyConflictException(String clientRequestId) {
        super("idempotency key reused with another payload: "
                + Objects.requireNonNull(clientRequestId));
    }
}
src/test/java/board/application/postcreation/CreatePostServiceTest.java
package board.application.postcreation;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Optional;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import board.application.postcreation.CreatePostUseCase.PostResponse;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class CreatePostServiceTest {
    private static final Instant NOW = Instant.parse(
            "2026-07-14T03:00:00Z");

    @Mock
    MemberRepository members;

    @Mock
    PostRepository posts;

    Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);

    @Test
    void 같은_멱등성_key와_payload는_저장하지_않고_기존_결과를_재생한다() {
        var command = command("Spring MVC 본문", "request-0001");
        var existing = new Post(
                91L, 3L, 41L, "Spring", "Spring MVC 본문",
                LocalDate.of(2026, 7, 14), "request-0001", NOW);
        when(posts.findByIdempotencyKey(41L, "request-0001"))
                .thenReturn(Optional.of(existing));
        var service = new CreatePostService(members, posts, clock);

        assertThat(service.handle(command)).isEqualTo(
                new PostResponse(91L, 3L, "Spring", "Spring MVC 본문"));
        verify(posts, never()).save(any());
        verifyNoInteractions(members);
    }

    @Test
    void 실제_사용량과_시도량이_한도를_넘으면_save하지_않는다() {
        var command = command(
                "1234567890123456789012345678901234567890",
                "request-0002");
        when(posts.findByIdempotencyKey(41L, "request-0002"))
                .thenReturn(Optional.empty());
        when(members.required(41L)).thenReturn(new Member(41L, true, 720));
        when(posts.totalCharacters(41L, LocalDate.of(2026, 7, 14)))
                .thenReturn(700);
        var service = new CreatePostService(members, posts, clock);

        Throwable thrown = catchThrowable(() -> service.handle(command));

        assertThat(thrown).isInstanceOf(DailyLimitExceededException.class);
        var exceeded = (DailyLimitExceededException) thrown;
        assertThat(exceeded.limitCharacters()).isEqualTo(720);
        assertThat(exceeded.usedCharacters()).isEqualTo(700);
        assertThat(exceeded.attemptedCharacters()).isEqualTo(40);
        verify(posts, never()).save(any());
    }

    private CreatePostCommand command(String content, String requestId) {
        return new CreatePostCommand(
                41L,
                "Spring",
                content,
                LocalDate.of(2026, 7, 14),
                requestId);
    }
}

서비스에 주입한 Clock은 저장할 createdAt만 결정적으로 고정합니다.

@PastOrPresent의 미래 날짜 판정은 Bean Validation ClockProvider 계약이므로 그 경계를 테스트할 때는 검증기 시계도 별도로 고정합니다.

현재 한도 버킷은 서버 현재 날짜가 아니라 검증을 통과한 publishedOn, 즉 사용자가 선택한 게시일입니다.


안정적 오류 코드

이 통합 슬라이스가 고정하는 API 오류는 두 가지입니다.

G2의 인증 인터셉터가 익명 /api/** 요청을 authentication-required 401로 끝내고, 이 절의 범위 지정 advice가 게시일별 한도를 daily-limit-exceeded 409로 매핑합니다.

본문 검증은 Spring MVC의 표준 400 의미를 유지합니다.

비활성 회원, 멱등성 키 충돌, 예상 밖 장애는 각각의 공개 예외 타입과 범위 지정 handler, 실행 테스트를 추가하기 전까지 안정 API 코드라고 약속하지 않습니다.

HTML 경로는 로그인 리다이렉트나 오류 뷰를 사용할 수 있지만 API 상태 의미는 유지합니다.

src/main/java/board/application/postcreation/DailyLimitExceededException.java
package board.application.postcreation;

public final class DailyLimitExceededException extends RuntimeException {
    private final int limitCharacters;
    private final int usedCharacters;
    private final int attemptedCharacters;

    public DailyLimitExceededException(
            int limitCharacters,
            int usedCharacters,
            int attemptedCharacters
    ) {
        super("daily character limit exceeded");
        if (limitCharacters < 1
                || usedCharacters < 0
                || attemptedCharacters < 1
                || (long) usedCharacters + attemptedCharacters
                        <= limitCharacters) {
            throw new IllegalArgumentException(
                    "values must describe an exceeded limit");
        }
        this.limitCharacters = limitCharacters;
        this.usedCharacters = usedCharacters;
        this.attemptedCharacters = attemptedCharacters;
    }

    public int limitCharacters() {
        return limitCharacters;
    }

    public int usedCharacters() {
        return usedCharacters;
    }

    public int attemptedCharacters() {
        return attemptedCharacters;
    }
}
src/main/java/board/web/postcreation/DailyLimitAdvice.java
package board.web.postcreation;

import java.net.URI;

import jakarta.servlet.http.HttpServletRequest;

import board.application.postcreation.DailyLimitExceededException;
import board.web.RequestTraceFilter;

import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice(assignableTypes = IntegratedPostController.class)
@Order(Ordered.HIGHEST_PRECEDENCE)
public final class DailyLimitAdvice {
    @ExceptionHandler(DailyLimitExceededException.class)
    ProblemDetail dailyLimit(
            DailyLimitExceededException exception,
            HttpServletRequest request
    ) {
        Object trace = request.getAttribute(
                RequestTraceFilter.REQUEST_ID_ATTRIBUTE);
        if (!(trace instanceof String requestId) || requestId.isBlank()) {
            throw new IllegalStateException(
                    "RequestTraceFilter must run before MVC");
        }
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.CONFLICT,
                "선택한 게시일에 등록할 수 있는 본문 길이를 초과했습니다.");
        problem.setType(URI.create(
                "https://board.example/problems/post-daily-limit"));
        problem.setTitle("게시글 게시일별 본문 한도 초과");
        problem.setProperty("code", "daily-limit-exceeded");
        problem.setProperty("limitCharacters", exception.limitCharacters());
        problem.setProperty("usedCharacters", exception.usedCharacters());
        problem.setProperty(
                "attemptedCharacters", exception.attemptedCharacters());
        problem.setProperty("requestId", requestId);
        return problem;
    }
}

게시일별 한도 응답은 타입·제목·상태·상세·코드·요청 ID와 한도 수치를 모두 고정합니다.

새 오류 코드를 추가할 때도 handler와 exact test oracle을 함께 추가한 뒤 클라이언트의 판별 공용체에 포함합니다.


통합 테스트 관찰 항목

src/test/java/board/web/postcreation/PostCreationFlowTest.java
package board.web.postcreation;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.time.LocalDate;
import board.application.postcreation.CreatePostUseCase;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import board.application.postcreation.CreatePostUseCase.PostResponse;
import board.application.postcreation.DailyLimitExceededException;
import board.web.RequestTraceFilter;
import board.web.auth.AuthWebMvcConfiguration;
import board.web.auth.SessionMemberReader;
import board.web.auth.SessionMemberReader.AuthenticatedMember;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.MethodArgumentNotValidException;
@WebMvcTest(IntegratedPostController.class)
@Import({
        AuthWebMvcConfiguration.class,
        RequestTraceFilter.class,
        DailyLimitAdvice.class
})
class PostCreationFlowTest {
    @Autowired
    MockMvc mvc;
    @MockitoBean
    CreatePostUseCase register;
    @Test
    void 익명_API_요청은_401_problem으로_끝나고_use_case를_호출하지_않는다()
            throws Exception {
        mvc.perform(post("/api/posts")
                        .header(RequestTraceFilter.REQUEST_ID_HEADER,
                                "anonymous-0001"))
                .andExpect(status().isUnauthorized())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(header().string(
                        RequestTraceFilter.REQUEST_ID_HEADER,
                        "anonymous-0001"))
                .andExpect(jsonPath("$.type").value(
                        "https://board.example/problems/authentication-required"))
                .andExpect(jsonPath("$.title").value("인증 필요"))
                .andExpect(jsonPath("$.status").value(401))
                .andExpect(jsonPath("$.detail").value("로그인이 필요합니다."))
                .andExpect(jsonPath("$.code")
                        .value("authentication-required"))
                .andExpect(jsonPath("$.requestId").doesNotExist());
        verifyNoInteractions(register);
    }
    @Test
    void 잘못된_body는_인증됐어도_use_case를_호출하지_않는다()
            throws Exception {
        var result = mvc.perform(post("/api/posts")
                        .header(RequestTraceFilter.REQUEST_ID_HEADER,
                                "validation-0001")
                        .session(authenticated(41L))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"title":"","content":"",
                                 "publishedOn":"2026-07-14",
                                 "clientRequestId":"request-0001"}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(header().string(
                        RequestTraceFilter.REQUEST_ID_HEADER,
                        "validation-0001"))
                .andReturn();
        assertThat(result.getResolvedException())
                .isInstanceOf(MethodArgumentNotValidException.class);
        verifyNoInteractions(register);
    }
    @Test
    void 정상_요청은_201_Location_ETag와_같은_request_id를_반환한다()
            throws Exception {
        when(register.handle(any())).thenReturn(
                new PostResponse(91L, 3L, "Spring", "Spring MVC 본문"));
        mvc.perform(post("/api/posts")
                        .header(RequestTraceFilter.REQUEST_ID_HEADER,
                                "creation-0002")
                        .session(authenticated(41L))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"title":" Spring ",
                                 "content":"Spring MVC 본문",
                                 "publishedOn":"2026-07-14",
                                 "clientRequestId":"request-0002"}
                                """))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "/api/posts/91"))
                .andExpect(header().string("ETag", "\"3\""))
                .andExpect(header().string(
                        RequestTraceFilter.REQUEST_ID_HEADER,
                        "creation-0002"));
        var command = ArgumentCaptor.forClass(CreatePostCommand.class);
        verify(register).handle(command.capture());
        assertThat(command.getValue()).isEqualTo(new CreatePostCommand(
                41L,
                "Spring",
                "Spring MVC 본문",
                LocalDate.of(2026, 7, 14),
                "request-0002"));
    }
    @Test
    void 게시일별_한도는_409_ProblemDetail과_request_id로_고정된다()
            throws Exception {
        when(register.handle(any())).thenThrow(
                new DailyLimitExceededException(720, 700, 40));
        mvc.perform(post("/api/posts")
                        .header(RequestTraceFilter.REQUEST_ID_HEADER,
                                "creation-0003")
                        .session(authenticated(41L))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"title":"Spring",
                                 "content":"1234567890123456789012345678901234567890",
                                 "publishedOn":"2026-07-14",
                                 "clientRequestId":"request-0003"}
                                """))
                .andExpect(status().isConflict())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(header().string(
                        RequestTraceFilter.REQUEST_ID_HEADER,
                        "creation-0003"))
                .andExpect(jsonPath("$.type").value(
                        "https://board.example/problems/post-daily-limit"))
                .andExpect(jsonPath("$.title")
                        .value("게시글 게시일별 본문 한도 초과"))
                .andExpect(jsonPath("$.status").value(409))
                .andExpect(jsonPath("$.detail")
                        .value("선택한 게시일에 등록할 수 있는 본문 길이를 초과했습니다."))
                .andExpect(jsonPath("$.code")
                        .value("daily-limit-exceeded"))
                .andExpect(jsonPath("$.limitCharacters").value(720))
                .andExpect(jsonPath("$.usedCharacters").value(700))
                .andExpect(jsonPath("$.attemptedCharacters").value(40))
                .andExpect(jsonPath("$.requestId")
                        .value("creation-0003"));
        verify(register).handle(any());
    }
    private MockHttpSession authenticated(long memberId) {
        var session = new MockHttpSession();
        session.setAttribute(
                SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE,
                new AuthenticatedMember(memberId));
        return session;
    }
}
통합 요청 결과
anonymous API -> 401 code=authentication-required, use-case calls 0
anonymous X-Request-Id = anonymous-0001
invalid body -> 400 MethodArgumentNotValidException, use-case calls 0
valid body -> 201
Location = /api/posts/91
ETag = "3"
X-Request-Id = creation-0002
captured command memberId = 41, title = Spring
published-date limit -> 409 code=daily-limit-exceeded
published-date limit requestId = creation-0003

슬라이스 테스트에는 실제 인터셉터와 인자 리졸버 구성을 포함해야 합니다.

그렇지 않으면 세션을 넣었는데도 컨트롤러 파라미터가 만들어지지 않거나 테스트용 리졸버가 운영 환경 누락을 숨길 수 있습니다.

최소 한 개의 전체 애플리케이션 테스트로 등록 연결을 확인합니다.


운영 준비 기준

DB와 객체 저장소 호출에는 타임아웃을 두고 무조건 자동 재시도하지 않습니다.

등록 명령은 멱등성 키가 있을 때만 안전한 재시도 범위를 넓힐 수 있습니다.

타임아웃 뒤 서버에서 커밋됐는지 모르는 상태가 가장 중요하므로 같은 키로 결과 조회가 가능해야 합니다.

로그에는 게시글 제목과 본문 원문을 기본으로 넣지 않습니다.

요청 ID, 인증된 구성원의 내부 식별자, 정규화된 경로, 결과 코드, 지속 시간을 구조화해 남기고 접근 권한과 보존 기간을 정합니다.

메트릭 레이블에는 구성원 ID와 세션 ID를 넣지 않습니다.

첨부 파일 업로드는 게시글 작성 트랜잭션과 하나의 거대한 요청으로 묶지 않습니다.

게시글을 먼저 만든 뒤 별도 멱등 업로드로 연결하거나 대기 애그리거트 상태를 사용합니다.

하나가 실패했을 때 재시도 단위가 분명해집니다.

증거책임같은 키 재시도 결과
회원 ID + client key재시도 범위를 회원 안으로 제한같은 행 후보 조회
payload hash원래 명령과 동일한지 증명같으면 기존 결과 재생
저장된 resource ID타임아웃 뒤 커밋 여부 확인같은 Location 반환
다른 payload hash키 오용 감지409 충돌

연습 문제

사용자가 등록 버튼을 연속 두 번 눌러 같은 멱등성 키로 두 요청이 동시에 도착하는 상황을 구현하세요.

페이로드가 같으면 같은 201 리소스를, 다르면 409 문제를 반환하고 DB에는 한 행만 남겨야 합니다.

둘 중 한 요청이 클라이언트 타임아웃이어도 재조회할 수 있어야 합니다.

해설 보기

(member_id, idempotency_key) 고유 제약 조건과 페이로드 해시를 저장합니다.

첫 트랜잭션이 삽입하고 두 번째가 제약 조건 충돌하면 별도 읽기 경계에서 기존 행을 조회합니다.

해시가 같으면 재생 응답, 다르면 키 재사용 충돌입니다.

src/main/java/board/application/postcreation/IdempotentResult.java
package board.application.postcreation;

public record IdempotentResult<T>(Kind kind, T value) {
    public enum Kind {
        CREATED, REPLAYED
    }

    public static <T> IdempotentResult<T> created(T value) {
        return new IdempotentResult<>(Kind.CREATED, value);
    }

    public static <T> IdempotentResult<T> replayed(T value) {
        return new IdempotentResult<>(Kind.REPLAYED, value);
    }
}

동시성 테스트는 두 스레드를 장벽에서 동시에 해제하고 리포지토리 행 개수와 두 리소스 ID를 비교합니다.

불안정한 대기 대신 래치와 트랜잭션 완료 신호를 사용합니다.

이 장에서 브라우저 상태와 서블릿·MVC 경계를 하나의 웹 흐름으로 묶었습니다.

다음 장은 같은 게시판를 JDBC와 트랜잭션에 연결해 연결, SQL, 커밋의 실제 수명주기를 다룹니다.