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

안동민 개발노트

본문 시작
8장 : 웹 상태와 경계 처리

게시판 웹 통합

로그인 세션, 요청 트레이싱, 현재 구성원 리졸버, 검증, 서비스 트랜잭션, ProblemDetail과 attachment 상태를 한 요청에 연결하고 실패별 부수 효과를 검증합니다.

개별 기능을 이해해도 애플리케이션에 합칠 때 순서가 어긋나면 보안과 오류 계약이 깨집니다. 게시판 게시글 작성은 트레이싱 필터에서 시작해 인증 컨텍스트, DTO 변환과 검증, 트랜잭션, 응답, 예외 리졸버를 지나갑니다. 각 단계는 앞 단계가 보장한 값만 믿고 실패하면 뒤 단계의 부수 효과가 실행되지 않아야 합니다.


요청 파이프라인 경계

POST /api/posts의 클라이언트 입력은 JSON과 세션 쿠키입니다. 필터는 요청 ID를 만들지만 인증을 증명하지 않습니다. 세션 리더와 리졸버가 서버 측 인증을 AuthenticatedMember로 바꾸고, Jackson과 Bean 검증이 요청 DTO를 검증합니다. 서비스는 현재 회원과 명령을 트랜잭션에서 처리합니다.

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

컨트롤러 명령 조립

컨트롤러에서 세션 속성 문자열을 다시 읽거나 리포지토리로 권한을 확인하지 않습니다. 리졸버가 제공한 회원 ID와 DTO를 애플리케이션 명령으로 변환하고 HTTP 응답을 결정합니다.

src/main/java/board/web/IntegratedPostController.java
package board.web;

import java.net.URI;

import jakarta.validation.Valid;

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
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);
    }
}

clientRequestId는 네트워크 재시도의 중복 등록을 막는 멱등성 키입니다. 현재 구성원과 함께 고유 범위를 만들고 페이로드 지문이 다르면 409로 처리합니다. HTTP 요청 ID는 관찰 가능성용이므로 멱등성 키로 재사용하지 않습니다.

API 응답에 엔티티를 그대로 직렬화하지 않습니다. 버전, 정규 ID, 필요한 표시 값만 응답 DTO로 반환합니다. ETag는 이후 조건부 수정에 사용하며 문자열 인용 형식을 계약 테스트로 확인합니다.

서비스 트랜잭션 경계

인증됐다는 사실만으로 모든 게시글을 수정할 수 있는 것은 아닙니다. 등록에서는 회원 활성 상태, 일일 한도, 멱등성 키를 트랜잭션 안에서 확인합니다. 동일 요청이 재전송되면 기존 결과를 반환하고 다른 페이로드로 키를 재사용하면 충돌을 알립니다.

src/main/java/board/application/CreatePostService.java
package board.application;

import java.time.Clock;

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 PostResponse.from(previous.get());
        }

        var member = members.requiredActive(command.memberId());
        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());
        return PostResponse.from(posts.save(post));
    }
}

시계를 주입해 미래 날짜와 생성 시각 테스트를 고정합니다. 회원의 날짜 경계 정책도 명시합니다.

안정적 오류 코드

익명은 authentication-required 401, 비활성 회원은 member-inactive 403, 일일 한도는 daily-limit-exceeded 409 또는 422, 예상 밖 DB 장애는 internal-error 500으로 매핑합니다. HTML 경로는 로그인 리다이렉트나 오류 뷰를 사용할 수 있지만 API 상태 의미는 유지합니다.

src/main/java/board/web/DailyLimitAdvice.java
package board.web;

import java.net.URI;

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
public final class DailyLimitAdvice {
    @ExceptionHandler(DailyLimitExceededException.class)
    ProblemDetail dailyLimit(DailyLimitExceededException exception) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.CONFLICT,
                "오늘 등록할 수 있는 본문 길이를 초과했습니다.");
        problem.setType(URI.create(
                "https://board.example/problems/daily-limit"));
        problem.setTitle("일일 본문 길이 한도 초과");
        problem.setProperty("code", "daily-limit-exceeded");
        problem.setProperty("limitCharacters", exception.limitCharacters());
        problem.setProperty("usedCharacters", exception.usedCharacters());
        return problem;
    }
}

필드 검증 문제와 도메인 충돌 문제는 형태를 완전히 다르게 만들지 않습니다. 공통 타입·제목·상태·상세·코드·요청 ID를 유지하고 필요한 확장 필드만 더합니다. 클라이언트 SDK와 문서에서는 판별 공용체로 표현할 수 있습니다.

통합 테스트 관찰 항목

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

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
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.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
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.test.web.servlet.MockMvc;

@WebMvcTest(IntegratedPostController.class)
class PostCreationFlowTest {
    @Autowired
    MockMvc mvc;

    @MockitoBean
    CreatePostUseCase register;

    @Test
    void 잘못된_body는_인증됐어도_use_case를_호출하지_않는다()
            throws Exception {
        mvc.perform(post("/api/posts")
                        .session(authenticated(41L))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"title":"","content":"",
                                 "publishedOn":"2026-07-14",
                                 "clientRequestId":"req-41"}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code")
                        .value("request-validation-failed"));

        verifyNoInteractions(register);
    }

    @Test
    void 정상_요청은_201_Location_ETag를_반환한다()
            throws Exception {
        when(register.handle(any())).thenReturn(
                new PostResponse(91L, 3L, "Spring", "Spring MVC 본문"));

        mvc.perform(post("/api/posts")
                        .session(authenticated(41L))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"title":"Spring","content":"Spring MVC 본문",
                                 "publishedOn":"2026-07-14",
                                 "clientRequestId":"req-42"}
                                """))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "/api/posts/91"))
                .andExpect(header().string("ETag", "\"3\""));

        verify(register, times(1)).handle(any());
    }

    private MockHttpSession authenticated(long memberId) {
        var session = new MockHttpSession();
        session.setAttribute(
                "authenticatedMember",
                new AuthenticatedMember(memberId));
        return session;
    }
}
통합 요청 결과
invalid body -> 400, use-case calls 0
valid body -> 201
Location = /api/posts/91
ETag = "3"
use-case calls = 1
request ID present in response and log = true

슬라이스 테스트에는 실제 인터셉터와 인자 리졸버 구성을 포함해야 합니다. 그렇지 않으면 세션을 넣었는데도 컨트롤러 파라미터가 만들어지지 않거나 테스트용 리졸버가 운영 환경 누락을 숨길 수 있습니다. 최소 한 개의 전체 애플리케이션 테스트로 등록 연결을 확인합니다.

운영 준비 기준

DB와 객체 저장소 호출에는 타임아웃을 두고 무조건 자동 재시도하지 않습니다. 등록 명령은 멱등성 키가 있을 때만 안전한 재시도 범위를 넓힐 수 있습니다. 타임아웃 뒤 서버에서 커밋됐는지 모르는 상태가 가장 중요하므로 같은 키로 결과 조회가 가능해야 합니다.

로그에는 게시글 제목과 본문 원문을 기본으로 넣지 않습니다. 요청 ID, 인증된 구성원의 내부 식별자, 정규화된 경로, 결과 코드, 지속 시간을 구조화해 남기고 접근 권한과 보존 기간을 정합니다. 메트릭 레이블에는 구성원 ID와 세션 ID를 넣지 않습니다.

첨부 파일 업로드는 게시글 작성 트랜잭션과 하나의 거대한 요청으로 묶지 않습니다. 게시글을 먼저 만든 뒤 별도 멱등 업로드로 연결하거나 대기 애그리거트 상태를 사용합니다. 하나가 실패했을 때 재시도 단위가 분명해집니다.

연습 문제

사용자가 등록 버튼을 연속 두 번 눌러 같은 멱등성 키로 두 요청이 동시에 도착하는 상황을 구현하세요. 페이로드가 같으면 같은 201 리소스를, 다르면 409 문제를 반환하고 DB에는 한 행만 남겨야 합니다. 둘 중 한 요청이 클라이언트 타임아웃이어도 재조회할 수 있어야 합니다.

해설 보기

(member_id, idempotency_key) 고유 제약 조건과 페이로드 해시를 저장합니다. 첫 트랜잭션이 삽입하고 두 번째가 제약 조건 충돌하면 별도 읽기 경계에서 기존 행을 조회합니다. 해시가 같으면 재생 응답, 다르면 키 재사용 충돌입니다.

package board.application;

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, 커밋의 실제 수명주기를 다룹니다.