게시판 웹 통합
로그인 세션부터 트랜잭션·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 성공과 서비스 검증 범위를 구분합니다. |
그림은 추적 필터(그림의 TraceFilter, 코드의 RequestTraceFilter)가 요청 ID를 만들고, 로그인 주체를 확인하고, JSON을 @Valid로 검증하고, 제목을 strip()해 명령을 조립한 뒤 사용 사례를 호출해 201과 Location, ETag "3"을 돌려주는 성공 경로를 보여 줍니다. MVC 테스트에서 사용 사례는 모의 객체이고, 같은 내용의 재생과 한도 초과는 별도의 서비스 단위 테스트가 확인합니다. 실제 DB와 트랜잭션 커밋, 동시 재시도의 원자성은 이 그림의 테스트가 실행하지 않습니다.
요청 파이프라인의 단계
POST /api/posts에서 클라이언트가 보내는 입력은 JSON과 세션 쿠키입니다. 필터는 요청 ID를 만들 뿐 인증을 증명하지 않습니다. 세션 리더와 리졸버가 서버 쪽 인증을 AuthenticatedMember로 바꾸고, Jackson과 Bean Validation이 요청 DTO를 검증하며, 서비스가 현재 회원과 명령을 트랜잭션 안에서 처리합니다.
| 단계 | 성공했을 때의 출력 | 대표적인 실패 | 뒤 단계의 실행 |
|---|---|---|---|
| 추적 필터 | 요청 ID | 내부 생성 실패 | 중단 |
| 세션 인증 | 현재 회원 | 401 | 컨트롤러 실행 안 함 |
| JSON 변환 | 요청 DTO | 잘못된 형식의 400 | 검증 실행 안 함 |
| Bean Validation | 올바른 DTO | 필드 400 | 서비스 실행 안 함 |
| 애플리케이션 서비스 | 저장된 게시글 | 정책 예외 | 트랜잭션 롤백 |
| 응답 어댑터 | 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로 약속하는 일은, 범위를 정한 핸들러와 정확한 테스트를 더하는 단계에서 확정합니다. HTTP 요청 ID는 관찰을 위한 값이므로 멱등성 키로 다시 쓰지 않습니다.
API 응답에 엔티티를 그대로 직렬화하지 않고, 버전과 정규 ID, 필요한 표시 값만 응답 DTO로 돌려줍니다. ETag는 이후의 조건부 수정에 쓰며, 문자열이 따옴표로 감싸진 형식인지 테스트로 확인합니다.
서비스의 트랜잭션
인증되었다고 모든 게시글을 고칠 수 있는 것은 아닙니다. 등록에서는 회원의 활성 상태와 선택한 게시일별 본문 한도, 멱등성 키를 트랜잭션 안에서 확인합니다.
지금 코드에서 회원의 활성 상태와 한도 검사는 새로 등록하는 경로에만 있습니다. 기존에 같은 키와 같은 내용을 찾으면 그보다 먼저 재생 결과를 돌려주며, 단위 테스트도 회원 저장소의 호출이 0회인지 검사합니다. 정지된 회원의 재생 조회를 막아야 하는 서비스라면, 인증이나 조회 정책에서 현재 자격을 따로 확인해야 합니다. 이 예제의 재생 경로가 활성 상태를 다시 읽는다고 해석하지 않습니다.
같은 요청이 다시 오면 기존 결과를 돌려주고, 다른 내용으로 키를 다시 쓰면 충돌을 알립니다. Boot의 기본 클래스 기반 프록시는 대상 클래스를 상속하므로 이 서비스는 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을 붙이고 모의 호출을 확인했다고 그 원자성이 확보된 것은 아닙니다.
지금의 한도 기준은 서버의 현재 날짜가 아니라, 검증을 통과한 publishedOn, 즉 사용자가 고른 게시일입니다.
바뀌지 않는 오류 코드
이 통합 예제가 고정하는 API 오류는 두 가지입니다. 인터셉터와 인자 해석의 인증 인터셉터가 익명 /api/** 요청을 authentication-required 401로 끝내고, 이 글의 범위를 정한 어드바이스가 게시일별 한도 초과를 daily-limit-exceeded 409로 매핑합니다. 본문 검증은 Spring MVC의 표준 400 의미를 그대로 둡니다.
비활성 회원과 멱등성 키 충돌, 예상하지 못한 장애는 각각의 공개 예외 타입과 범위를 정한 핸들러, 실행 테스트를 더하기 전까지는 바뀌지 않는 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;
}
}게시일별 한도의 응답은 type과 title, status, detail, code, 요청 ID와 한도 수치를 모두 고정합니다. 새 오류 코드를 더할 때도 핸들러와 정확한 기대 결과의 테스트를 함께 더한 뒤, 클라이언트가 구분하는 목록에 넣습니다.
통합 테스트에서 확인하는 것
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슬라이스 테스트에는 실제 인터셉터와 인자 리졸버 구성을 포함해야 합니다. 그렇지 않으면 세션을 넣었는데도 컨트롤러 파라미터가 만들어지지 않거나, 테스트용 리졸버가 운영 환경의 누락을 가릴 수 있습니다. 최소한 하나의 전체 애플리케이션 테스트로 등록이 제대로 연결되었는지 확인합니다.
그런데 1편부터 이 글까지의 파일만 한 프로젝트에 모으면, 그 전체 애플리케이션 테스트와 BoardApplication은 컨텍스트를 만드는 단계에서 실패합니다. 이 장의 컨트롤러와 서비스는 생성자가 하나뿐이어서 Spring이 그 생성자로 의존성을 주입하는데, 생성자가 요구하는 포트 가운데 구현 빈이 하나도 없는 것이 있기 때문입니다. 슬라이스 테스트에서는 @WebMvcTest가 일반 @Component를 스캔하지 않고 @MockitoBean이 포트를 채우므로 이 누락이 드러나지 않습니다. 시작 로그에는 먼저 만들다 실패한 빈 하나만 나타나므로, 그것만 채우고 다시 실행하면 다음 누락이 나옵니다. 이 장에서 구현이 없는 포트는 다음과 같습니다.
| 빈 | 생성자가 요구하는 포트 | 정의한 글 |
|---|---|---|
LoginController | LoginController.CredentialVerifier | 1편 |
CurrentMemberController | MemberPostQuery | 4편 |
ProblemPostController, ProblemPostPageController | ProblemPostQuery | 6편 |
AttachmentController | AttachmentService | 8편 |
CreatePostService | MemberRepository, PostRepository, Clock | 이 글 |
실제 저장소는 다음 장부터 연결하므로, 여기서는 저장소가 아직 없다는 사실을 그대로 드러내는 가장 작은 구현을 둡니다. 로그인 검증기는 등록된 회원이 없으므로 모든 로그인을 실패로 돌려주며, 실패 이유도 나누지 않습니다. 게시글 조회 포트는 빈 저장소처럼 PostNotFoundException을 던지고, 나머지 포트는 호출되면 저장소가 연결되지 않았다는 예외를 던집니다. 시계는 실제 UTC 시계 하나만 정의합니다. AttachmentService와 MemberRepository, PostRepository는 패키지 전용 인터페이스이므로, 그 구현은 각 인터페이스와 같은 패키지에 둡니다.
package board;
import java.time.Clock;
import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import board.web.auth.LoginController.CredentialVerifier;
import board.web.auth.MemberPostQuery;
import board.web.problem.PostNotFoundException;
import board.web.problem.ProblemPostQuery;
@Configuration(proxyBeanMethods = false)
public class BoardPortConfiguration {
@Bean
Clock clock() {
return Clock.systemUTC();
}
@Bean
CredentialVerifier credentialVerifier() {
return (email, password) -> Optional.empty();
}
@Bean
MemberPostQuery memberPostQuery() {
return memberId -> {
throw new IllegalStateException(
"member post store is not connected");
};
}
@Bean
ProblemPostQuery problemPostQuery() {
return new ProblemPostQuery() {
@Override
public PostSummary required(long postId) {
throw new PostNotFoundException(postId);
}
@Override
public void createDuplicate(long postId) {
throw new PostNotFoundException(postId);
}
};
}
}package board.web.attachment;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class AttachmentPortConfiguration {
@Bean
AttachmentService attachmentService() {
return (memberId, metadata, file) -> {
throw new IllegalStateException(
"attachment storage is not connected");
};
}
}package board.application.postcreation;
import java.time.LocalDate;
import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class PostCreationPortConfiguration {
@Bean
MemberRepository memberRepository() {
return memberId -> {
throw notConnected();
};
}
@Bean
PostRepository postRepository() {
return new PostRepository() {
@Override
public Optional<Post> findByIdempotencyKey(
long memberId, String clientRequestId) {
throw notConnected();
}
@Override
public int totalCharacters(long memberId, LocalDate publishedOn) {
throw notConnected();
}
@Override
public Post save(Post post) {
throw notConnected();
}
};
}
private static IllegalStateException notConnected() {
return new IllegalStateException("post store is not connected");
}
}모든 로그인이 거부되므로 BoardApplication으로 띄운 애플리케이션에는 인증 세션이 생기지 않고, @LoginRequired 핸들러는 인터셉터의 401이나 로그인 리다이렉트에서 멈춥니다. 그래서 회원 게시글 조회와 첨부, 게시글 등록의 자리 구현은 실행 중에 호출되지 않습니다. 띄운 애플리케이션에서는 익명 API의 401과 없는 게시글 조회의 404처럼 인증이 필요 없는 경로를 확인하고, 인증된 요청의 흐름은 지금처럼 세션을 넣은 슬라이스 테스트로 확인합니다. 이 구현들은 비밀번호 검증이나 저장을 흉내 내지 않으며, 이 장의 빌드에는 데이터 소스와 트랜잭션 관리자도 없습니다.
다음 테스트는 이 조립을 고정합니다. @SpringBootTest는 BoardApplication의 구성을 찾아 같은 컴포넌트 스캔과 자동 구성으로 컨텍스트를 만들되, 기본값인 모의 웹 환경에서는 내장 서버를 띄우지 않습니다. 생성자의 포트가 하나라도 빠지면 테스트 메서드에 들어가기 전에 컨텍스트 생성이 실패합니다.
package board;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import board.application.postcreation.CreatePostService;
import board.application.postcreation.CreatePostUseCase;
import board.web.attachment.AttachmentController;
import board.web.auth.CurrentMemberController;
import board.web.auth.LoginController;
import board.web.postcreation.IntegratedPostController;
import board.web.problem.ProblemPostController;
import board.web.problem.ProblemPostPageController;
@SpringBootTest
class BoardApplicationStartupTest {
@Autowired
ApplicationContext context;
@Test
void 전체_구성은_포트가_필요한_컨트롤러와_서비스를_모두_만든다() {
assertThat(context.getBeanNamesForType(LoginController.class))
.hasSize(1);
assertThat(context.getBeanNamesForType(CurrentMemberController.class))
.hasSize(1);
assertThat(context.getBeanNamesForType(ProblemPostController.class))
.hasSize(1);
assertThat(context.getBeanNamesForType(
ProblemPostPageController.class)).hasSize(1);
assertThat(context.getBeanNamesForType(AttachmentController.class))
.hasSize(1);
assertThat(context.getBeanNamesForType(
IntegratedPostController.class)).hasSize(1);
assertThat(context.getBean(CreatePostUseCase.class))
.isInstanceOf(CreatePostService.class);
}
}운영 준비의 기준
DB와 객체 저장소 호출에는 타임아웃을 두고, 무조건 자동으로 재시도하지 않습니다. 등록 명령은 멱등성 키가 있을 때만 안전하게 재시도할 수 있는 범위가 넓어집니다. 타임아웃 뒤 서버에서 커밋되었는지 모르는 상태가 가장 중요하므로, 같은 키로 결과를 조회할 수 있어야 합니다.
로그에는 게시글 제목과 본문의 원문을 기본으로 넣지 않습니다. 요청 ID와 인증된 회원의 내부 식별자, 정규화된 경로, 결과 코드, 지속 시간을 구조화해 남기고, 접근 권한과 보존 기간을 정합니다. 메트릭 레이블에는 회원 ID와 세션 ID를 넣지 않습니다.
첨부 파일 업로드를 게시글 작성 트랜잭션과 하나의 큰 요청으로 묶지 않습니다. 게시글을 먼저 만든 뒤 별도의 멱등 업로드로 연결하거나, 대기 상태의 애그리거트를 씁니다. 그래야 하나가 실패했을 때 무엇을 다시 시도할지 분명해집니다.
| 저장해 두는 값 | 하는 일 | 같은 키로 재시도했을 때 |
|---|---|---|
| 회원 ID와 클라이언트 키 | 재시도 범위를 회원 안으로 제한 | 같은 행의 후보를 조회 |
| 내용의 해시 | 원래 명령과 같은지 확인 | 같으면 기존 결과를 재생 |
| 저장된 리소스 ID | 타임아웃 뒤 커밋 여부 확인 | 같은 Location 반환 |
| 다른 내용의 해시 | 키를 잘못 다시 쓴 것을 감지 | 409 충돌 |
정리
- 게시글 등록은 추적 필터와 세션 인증, JSON 변환과 검증, 서비스, 응답 어댑터를 차례로 지나며, 앞 단계가 실패하면 뒤 단계는 실행되지 않습니다.
- 요청 ID는 관찰용이고 클라이언트의 멱등성 키는 중복 등록 방지용이므로, 둘을 섞어 쓰지 않습니다.
- 서비스는 기존 키와 같은 내용을 먼저 재생하고, 새 등록일 때만 회원 상태와 게시일별 한도를 확인합니다.
@Transactional과 모의 테스트만으로는 서로 다른 키끼리의 한도 경쟁을 막지 못하므로, 저장소에서 잠금이나 조건부 갱신으로 직렬화합니다.- 공개 오류 코드는 핸들러와 정확한 테스트를 함께 더했을 때만 약속하며, 로그와 메트릭에는 원문과 고카디널리티 값을 넣지 않습니다.
연습 문제
1. 연속 두 번 누른 등록 버튼
사용자가 등록 버튼을 연속으로 두 번 눌러, 같은 멱등성 키를 가진 두 요청이 동시에 도착하는 상황을 구현하려고 합니다. 내용이 같으면 같은 리소스를 가리키는 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를 비교합니다. 불안정한 대기 대신 래치와 트랜잭션 완료 신호를 씁니다.
2. 재생과 충돌, 신규 등록의 실제 순서
각 경우는 별도의 예제에서 시작합니다. A는 회원 41과 키 request-0001에 저장된 게시글 91, 버전 3이 있고 모든 내용 필드가 같습니다. B도 그 행이 있지만 content만 다릅니다. C는 기존 행이 없고 회원은 비활성입니다. A에서 회원 저장소가 조회된다면 비활성을 돌려주게 설정했다고 할 때, 각 경우의 회원 조회와 새 save 횟수, 결과를 예측하세요. B와 C가 이 글의 API에서 이미 바뀌지 않는 409 코드로 매핑되는지도 구분하세요.
해설 보기
A는 기존 행의 필드가 같은지 먼저 확인해 게시글 91, 버전 3을 돌려줍니다. 회원 조회와 새 저장은 모두 0회이므로 설정한 비활성 결과도 읽지 않습니다. B는 기존 행의 requireSamePayload()에서 IdempotencyKeyConflictException이 나며, 회원 조회와 저장은 0회입니다. C는 기존 행이 없어 회원을 1회 조회한 뒤 MemberInactiveException이 나며, 사용량 조회와 저장은 0회입니다.
두 예외의 공개 핸들러와 정확한 HTTP 테스트는 아직 이 글에 없습니다. 따라서 B와 C를 daily-limit-exceeded 409로 묶거나, 이미 정해진 다른 409 코드가 있다고 예상하지 않습니다. 이 글이 고정한 409는 사용량과 시도량이 한도를 넘는 예외 하나뿐입니다. MVC 모의 객체의 응답 검사와 직접 만든 서비스의 정책 검사도, 실제 Spring 프록시와 DB 커밋, 동시성 검증과 따로 봅니다.
이것으로 8장을 마칩니다. 8장에서는 쿠키 로그인의 취약점과 세션의 수명 주기에서 시작해, 필터와 인터셉터, 인자 리졸버, 오류 디스패치와 API 예외 응답, 타입 변환과 파일 업로드를 거쳐, 브라우저의 상태와 서블릿·MVC의 단계를 하나의 게시판 요청 흐름으로 묶었습니다. 다음 장의 JDBC 데이터베이스 연결부터는 같은 게시판을 JDBC와 트랜잭션에 연결해, 연결과 SQL, 커밋의 실제 수명 주기를 다룹니다.