게시판 웹 통합
로그인 세션부터 트랜잭션·ProblemDetail까지 게시판 요청 한 건의 전체 흐름을 연결합니다.
개별 기능을 이해해도 애플리케이션에 합칠 때 순서가 어긋나면 보안과 오류 계약이 깨집니다.
게시판 게시글 작성은 트레이싱 필터에서 시작해 인증 컨텍스트, DTO 변환과 검증, 트랜잭션, 응답, 예외 리졸버를 지나갑니다.
각 단계는 앞 단계가 보장한 값만 믿고 실패하면 뒤 단계의 부수 효과가 실행되지 않아야 합니다.
게시글 등록은 추적·인증·검증·트랜잭션을 한 흐름으로 잇는다
추적·인증·JSON 검증·command·mock use case·201이 한 등록 요청의 경계를 이룬다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
등록 요청의 HTTP 검사와 서비스 정책 검사를 잇는다
MVC slice는 인증·DTO·명령·응답을 확인하고 use case를 mock으로 대체합니다. 서비스 단위 테스트는 재생과 게시일별 한도를 직접 확인합니다. 실제 DB·트랜잭션·동시 재시도는 추가 통합 범위입니다.
ACTUAL MVC / AUTH
익명 401이면 use case 0회
인증 실패 응답의 code는 authentication-required입니다. 추적 헤더는 있지만 이 401 JSON 본문에는 requestId가 없습니다. 제목·본문 검증 실패는 표준 400이며 역시 use case를 호출하지 않습니다.
ACTUAL MVC / 201
등록 명령과 응답을 정확히 비교
정상 fixture는 회원 41과 정리한 제목·본문·게시일·client key를 mock use case에 전달합니다. mock 결과 id=91, version=3으로 201, Location=/api/posts/91과 따옴표를 포함한 ETag="3"을 확인합니다.
ACTUAL MVC + SERVICE UNIT
700 + 40 > 720이면 등록 중단
서비스 단위 테스트는 명령의 publishedOn에 대한 사용량을 조회하고 한도 초과 시 저장하지 않습니다. MVC는 mock이 던진 예외를 409, daily-limit-exceeded, 720·700·40과 기존 추적 ID로 응답하는 advice를 검사합니다.
ACTUAL SERVICE UNIT
같은 키·payload는 먼저 재생
서비스는 회원·활성 상태·한도 조회 전에 기존 client key를 찾습니다. 동일 필드이면 기존 id=91, version=3을 반환하며 회원 repository와 새 저장을 호출하지 않습니다. 실제 코드는 필드를 비교하며 별도 payload hash를 저장하지 않습니다.
TRANSACTION / ADDITIONAL
직접 만든 서비스의 mock unit은 DB 검사가 아니다
서비스 handle에는 @Transactional이 있지만 제공된 unit은 직접 만든 서비스와 mock repository를 사용합니다. Spring proxy, DB adapter, 유일 제약, 롤백과 동시 재시도의 원자성을 실행해 확인한 것은 아닙니다.
PUBLIC ERRORS / ADDITIONAL
추적 ID와 client key를 분리
추적 ID는 관찰용이고 client key는 멱등성용입니다. 비활성 회원·다른 payload 재사용에 대한 공개 오류 advice와 실제 전체 애플리케이션 등록 검사는 추가 범위입니다. 재생 결과 enum helper도 아직 사용되지 않습니다.
추적과 인증 뒤 JSON 검증과 명령 조립을 거쳐 use case를 호출하고 201을 응답한다. MVC의 use case는 mock이다. 서비스 단위 테스트는 같은 payload 재생과 한도 초과를 확인하며 실제 DB 원자성이나 동시성은 검증하지 않는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 추적과 인증 뒤 JSON 검증과 명령 조립을 거쳐 use case를 호출하고 201을 응답한다. MVC의 use case는 mock이다. 서비스 단위 테스트는 같은 payload 재생과 한도 초과를 확인하며 실제 DB 원자성이나 동시성은 검증하지 않는다.
- 원래 데스크톱 6노드와 5관계의 성공 경로다. 추적과 로그인 뒤 JSON 검증/title strip 명령 조립/mock use case의 id 91, version 3으로 201 Location ETag를 확인한다. 서비스 policy unit은 별도이며 실제 DB transaction commit/동시 재시도 원자성은 실행하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 등록은 추적·인증·검증·트랜잭션을 한 흐름으로 잇는다 |
| 16 | 등록 요청의 HTTP 흐름과 mock use case |
| 17 | 원래 데스크톱 6노드와 5관계의 성공 경로다. 추적과 로그인 뒤 JSON 검증/title strip 명령 조립/mock use case의 id 91, version 3으로 201 Location ETag를 확인한다. 서비스 policy unit은 별도이며 실제 DB transaction commit/동시 재시도 원자성은 실행하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 추적·인증·JSON 검증 등록 요청의 추적과 인증 뒤 DTO를 검증합니다. | 입력: JSON · 실패: use case 전 종료 | 허용된 값만 명령 조립에 갑니다. |
| command와 use case 조립한 command로 MVC mock use case를 호출합니다. | MVC use case: mock · 명령: 검증 입력 | 웹 경계의 전달 계약을 확인합니다. |
| 등록 응답과 범위 201로 응답하고 별도 service test가 재생·한도를 확인합니다. | 응답: 201 · DB 원자·동시성: 이 테스트 아님 | HTTP 성공과 서비스 검증 범위를 구분합니다. |
게시글 등록은 추적·인증·검증·트랜잭션을 한 흐름으로 잇는다
추적·인증·JSON 검증·command·mock use case·201이 한 등록 요청의 경계를 이룬다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
등록 요청의 HTTP 검사와 서비스 정책 검사를 잇는다
MVC slice는 인증·DTO·명령·응답을 확인하고 use case를 mock으로 대체합니다. 서비스 단위 테스트는 재생과 게시일별 한도를 직접 확인합니다. 실제 DB·트랜잭션·동시 재시도는 추가 통합 범위입니다.
ACTUAL MVC / AUTH
익명 401이면 use case 0회
인증 실패 응답의 code는 authentication-required입니다. 추적 헤더는 있지만 이 401 JSON 본문에는 requestId가 없습니다. 제목·본문 검증 실패는 표준 400이며 역시 use case를 호출하지 않습니다.
ACTUAL MVC / 201
등록 명령과 응답을 정확히 비교
정상 fixture는 회원 41과 정리한 제목·본문·게시일·client key를 mock use case에 전달합니다. mock 결과 id=91, version=3으로 201, Location=/api/posts/91과 따옴표를 포함한 ETag="3"을 확인합니다.
ACTUAL MVC + SERVICE UNIT
700 + 40 > 720이면 등록 중단
서비스 단위 테스트는 명령의 publishedOn에 대한 사용량을 조회하고 한도 초과 시 저장하지 않습니다. MVC는 mock이 던진 예외를 409, daily-limit-exceeded, 720·700·40과 기존 추적 ID로 응답하는 advice를 검사합니다.
ACTUAL SERVICE UNIT
같은 키·payload는 먼저 재생
서비스는 회원·활성 상태·한도 조회 전에 기존 client key를 찾습니다. 동일 필드이면 기존 id=91, version=3을 반환하며 회원 repository와 새 저장을 호출하지 않습니다. 실제 코드는 필드를 비교하며 별도 payload hash를 저장하지 않습니다.
TRANSACTION / ADDITIONAL
직접 만든 서비스의 mock unit은 DB 검사가 아니다
서비스 handle에는 @Transactional이 있지만 제공된 unit은 직접 만든 서비스와 mock repository를 사용합니다. Spring proxy, DB adapter, 유일 제약, 롤백과 동시 재시도의 원자성을 실행해 확인한 것은 아닙니다.
PUBLIC ERRORS / ADDITIONAL
추적 ID와 client key를 분리
추적 ID는 관찰용이고 client key는 멱등성용입니다. 비활성 회원·다른 payload 재사용에 대한 공개 오류 advice와 실제 전체 애플리케이션 등록 검사는 추가 범위입니다. 재생 결과 enum helper도 아직 사용되지 않습니다.
추적과 인증 뒤 JSON 검증과 명령 조립을 거쳐 use case를 호출하고 201을 응답한다. MVC의 use case는 mock이다. 서비스 단위 테스트는 같은 payload 재생과 한도 초과를 확인하며 실제 DB 원자성이나 동시성은 검증하지 않는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 추적과 인증 뒤 JSON 검증과 명령 조립을 거쳐 use case를 호출하고 201을 응답한다. MVC의 use case는 mock이다. 서비스 단위 테스트는 같은 payload 재생과 한도 초과를 확인하며 실제 DB 원자성이나 동시성은 검증하지 않는다.
- 원래 데스크톱 6노드와 5관계의 성공 경로다. 추적과 로그인 뒤 JSON 검증/title strip 명령 조립/mock use case의 id 91, version 3으로 201 Location ETag를 확인한다. 서비스 policy unit은 별도이며 실제 DB transaction commit/동시 재시도 원자성은 실행하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 등록은 추적·인증·검증·트랜잭션을 한 흐름으로 잇는다 |
| 16 | 등록 요청의 HTTP 흐름과 mock use case |
| 17 | 원래 데스크톱 6노드와 5관계의 성공 경로다. 추적과 로그인 뒤 JSON 검증/title strip 명령 조립/mock use case의 id 91, version 3으로 201 Location ETag를 확인한다. 서비스 policy unit은 별도이며 실제 DB transaction commit/동시 재시도 원자성은 실행하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 추적·인증·JSON 검증 등록 요청의 추적과 인증 뒤 DTO를 검증합니다. | 입력: JSON · 실패: use case 전 종료 | 허용된 값만 명령 조립에 갑니다. |
| command와 use case 조립한 command로 MVC mock use case를 호출합니다. | MVC use case: mock · 명령: 검증 입력 | 웹 경계의 전달 계약을 확인합니다. |
| 등록 응답과 범위 201로 응답하고 별도 service test가 재생·한도를 확인합니다. | 응답: 201 · DB 원자·동시성: 이 테스트 아님 | HTTP 성공과 서비스 검증 범위를 구분합니다. |
요청 파이프라인 경계
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는 이후 조건부 수정에 사용하며 문자열 인용 형식을 계약 테스트로 확인합니다.
서비스 트랜잭션 경계
인증됐다는 사실만으로 모든 게시글을 수정할 수 있는 것은 아닙니다.
등록에서는 회원 활성 상태, 선택한 게시일별 본문 한도, 멱등성 키를 트랜잭션 안에서 확인합니다.
현재 코드에서 회원 활성 상태와 한도 검사는 새로 등록하는 경로에 있습니다. 기존 같은 키·같은 payload를 찾으면 그 전에 재생 결과를 반환하며 원 단위 테스트도 회원 repository 호출 0회를 검사합니다. 정지된 회원의 재생 조회를 제한해야 하는 서비스라면 인증·조회 정책에서 현재 자격을 별도로 확인해야 합니다. 이 예제의 재생 경로가 활성 상태를 다시 읽는 것으로 해석하지 않습니다.
동일 요청이 재전송되면 기존 결과를 반환하고 다른 페이로드로 키를 재사용하면 충돌을 알립니다.
Boot의 기본 class 기반 프록시는 대상 클래스를 상속하므로 이 서비스는 final로 선언하지 않습니다. 인터페이스를 통한 JDK 프록시는 별도 설정으로 사용할 수 있습니다. 뒤의 직접 new 단위 테스트는 서비스 로직을 확인하며 트랜잭션 프록시 연결을 검증하지 않습니다.
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 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 계약이므로 그 경계를 테스트할 때는 검증기 시계도 별도로 고정합니다.
예시
사용량 680에서 서로 다른 키의 두 요청이 각각 ASCII 본문 40자를 등록하려고 합니다. 두 요청이 모두 같은 680을 읽으면 각 판단은 680+40=720으로 통과해도 합산 결과는 760이 될 수 있습니다. 멱등성 키의 고유 제약은 서로 다른 키의 한도 경쟁까지 막지 않습니다. 실제 저장 어댑터는 회원·게시일별 한도를 필요한 잠금·조건부 갱신 또는 격리 계약으로 직렬화하고 충돌·재시도를 검증해야 합니다. @Transactional 표기와 mock 호출만으로 그 원자성을 확보했다고 판단하지 않습니다.
현재 한도 버킷은 서버 현재 날짜가 아니라 검증을 통과한 publishedOn, 즉 사용자가 선택한 게시일입니다.
안정적 오류 코드
이 통합 슬라이스가 고정하는 API 오류는 두 가지입니다.
앞 절 인터셉터와 인자 해석의 인증 인터셉터가 익명 /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를 비교합니다.
불안정한 대기 대신 래치와 트랜잭션 완료 신호를 사용합니다.
독립 확인
각 경우를 별도 fixture로 시작합니다. A는 회원 41·키 request-0001에 원 예제의 저장된 게시글 91/version 3이 있고 모든 payload 필드가 같습니다. B도 그 저장된 행이 있지만 content만 다른 값입니다. C는 기존 행이 없고 회원은 비활성입니다. A의 회원 repository가 조회된다면 비활성을 돌려주게 설정했다고 할 때 각 경우의 회원 조회와 새 save 횟수·결과를 예측하세요. B·C가 이 절의 API에서 이미 안정적인 409 코드로 매핑되는지도 구분합니다.
재생·충돌·신규 정책의 실제 순서를 읽는 풀이
A는 기존 필드 일치를 먼저 확인해 게시글 91/version 3을 반환합니다. 회원 조회와 새 저장은 모두 0회이므로 설정한 비활성 결과도 읽지 않습니다. B는 기존 행의 requireSamePayload()에서 IdempotencyKeyConflictException이며 회원 조회·저장은 0회입니다. C는 기존 행이 없어 회원 조회 1회 뒤 MemberInactiveException이며 사용량 조회와 저장은 0회입니다.
두 예외의 공개 handler·exact HTTP 테스트는 현재 이 절에 추가되지 않았습니다. 따라서 B·C를 daily-limit-exceeded 409로 합치거나 이미 정해진 다른 409 코드를 예상하지 않습니다. 이 절의 고정 409는 사용량과 시도량이 한도를 초과하는 예외 하나입니다. MVC mock의 응답 검사와 직접 new 서비스의 정책 검사도 실제 Spring 프록시·DB 커밋·동시성 검증과 분리합니다.
이 장에서 브라우저 상태와 서블릿·MVC 경계를 하나의 웹 흐름으로 묶었습니다.
다음 장은 같은 게시판을 JDBC와 트랜잭션에 연결해 연결, SQL, 커밋의 실제 수명 주기를 다룹니다.