게시판 웹 통합
로그인 세션부터 트랜잭션·ProblemDetail까지 게시판 요청 한 건의 전체 흐름을 연결합니다.
개별 기능을 이해해도 애플리케이션에 합칠 때 순서가 어긋나면 보안과 오류 계약이 깨집니다.
게시판 게시글 작성은 트레이싱 필터에서 시작해 인증 컨텍스트, DTO 변환과 검증, 트랜잭션, 응답, 예외 리졸버를 지나갑니다.
각 단계는 앞 단계가 보장한 값만 믿고 실패하면 뒤 단계의 부수 효과가 실행되지 않아야 합니다.
REQUEST PIPELINE · IDEMPOTENCY · PROBLEM DETAIL
게시글 등록은 추적·인증·검증·트랜잭션을 한 흐름으로 잇는다
필터가 발급한 요청 ID와 서버 세션의 회원만 다음 경계로 전달된다. DTO 검증을 통과한 명령은 멱등성·활성 상태·게시일별 한도를 한 트랜잭션에서 판정하고, 성공과 도메인 실패가 같은 추적 ID를 공유한다.
REQUEST TRACE
필터가 안전한 X-Request-Id를 응답·요청 속성·MDC에 게시한다
REQUEST와 ERROR dispatch에서도 같은 요청 수명 ID를 쓰고 각 dispatch 뒤 이전 문맥을 복원합니다.
AUTH CONTEXT
세션 리더와 인터셉터가 서버가 검증한 회원을 게시한다
@CurrentMemberresolver는 정확히AuthenticatedMember타입만 주입합니다.READ + VALIDATE
Jackson과 Bean Validation이 transport 계약을 확정한다
실패하면 400으로 끝나고 use case는 한 번도 호출되지 않습니다.
CONTROLLER
회원과 검증된 DTO를 CreatePostCommand로 조립한다
HTTP 요청 ID와 재시도용 client key는 서로 다른 책임으로 유지합니다.
TRANSACTION
서비스가 멱등성·활성 상태·게시일별 한도·저장을 판정한다
같은 키와 payload는 기존 결과를 재생하고 다른 payload는 충돌로 끝냅니다.
201 CREATED
성공 응답은 Location·ETag·X-Request-Id를 반환한다
엔티티가 아니라 공개 가능한 response DTO만 직렬화합니다.
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 응답을 결정합니다.
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");
}
}
}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는 이후 조건부 수정에 사용하며 문자열 인용 형식을 계약 테스트로 확인합니다.
서비스 트랜잭션 경계
인증됐다는 사실만으로 모든 게시글을 수정할 수 있는 것은 아닙니다.
등록에서는 회원 활성 상태, 선택한 게시일별 본문 한도, 멱등성 키를 트랜잭션 안에서 확인합니다.
동일 요청이 재전송되면 기존 결과를 반환하고 다른 페이로드로 키를 재사용하면 충돌을 알립니다.
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));
}
}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 상태 의미는 유지합니다.
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;
}
}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을 함께 추가한 뒤 클라이언트의 판별 공용체에 포함합니다.
통합 테스트 관찰 항목
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) 고유 제약 조건과 페이로드 해시를 저장합니다.
첫 트랜잭션이 삽입하고 두 번째가 제약 조건 충돌하면 별도 읽기 경계에서 기존 행을 조회합니다.
해시가 같으면 재생 응답, 다르면 키 재사용 충돌입니다.
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, 커밋의 실제 수명주기를 다룹니다.