본문으로 건너뛰기

안동민 개발노트

본문 시작

HTTP 무상태성과 연결

HTTP 요청의 독립성, REST의 서버 세션 제약, 명시적인 리소스 상태를 구분하고 연결을 재사용해도 현재 요청에서 인증과 권한을 다시 판단하는 게시판 API를 구현합니다.

HTTP가 무상태라는 말은 “서버가 데이터를 저장하지 않는다”거나 “요청마다 전송 연결을 끊는다”는 뜻이 아닙니다.

RFC 9110의 HTTP 무상태성은 각 요청 메시지의 의미를 다른 요청이나 그 메시지가 실린 연결과 무관하게 해석할 수 있다는 프로토콜 성질입니다.

REST의 무상태 제약은 한 단계 더 나아가 다음 요청을 이해하는 데 필요한 서버 측 세션 문맥을 남기지 않고, 각 요청이 처리에 필요한 정보를 운반하도록 요구합니다.

게시글, 회원, 버전, 캐시, 폐기 목록처럼 소유권과 수명이 명시된 서버 상태는 두 설명과 모순되지 않습니다.

현재 요청의 method·target·verified principal·조건이 명시적 입력으로 도착하고, 어느 서버 인스턴스든 명시적으로 소유한 resource·policy state에 이를 적용하며, singleton field와 socket history는 금지된 숨은 대화 상태이고 공유된 opaque session record는 별도 session context임을 설명합니다.

REQUEST · DECISION · STATE OWNER

요청의 처리 조건은 현재 메시지에 있고 서버 상태는 명시적으로 소유한다

현재 요청이 사용자 의미를 완성하고, 서버는 이름 붙인 상태에 그 의미를 적용합니다. 연결이나 인스턴스가 바뀌어도 method·target·검증된 principal·조건이 같으면 같은 판단을 재현할 수 있어야 합니다. 공유 저장 위치만으로 session context가 업무 상태가 되지는 않습니다.

CURRENT REQUEST · EXPLICIT INPUT

현재 메시지가 이번 처리의 조건을 완성한다

  1. method와 target이 연산 대상을 지정한다

    GET /posts/42PATCH /posts/42는 같은 resource를 가리켜도 서로 다른 의도를 전달합니다.

  2. resource·filter·precondition을 명시한다

    path·query·header·body가 이번 조회와 변경 조건을 운반합니다. 누락된 입력을 이전 요청에서 채우지 않습니다.

  3. 식별자는 먼저 verified principal이 된다

    credential이나 session identifier 자체를 권한으로 쓰지 않습니다. 검증과 정책 확인을 거친 principal만 현재 요청의 입력이 됩니다.

ANY INSTANCE · SAME DECISION

어느 인스턴스든 같은 명시적 판단을 수행한다

  1. request-local immutable command로 고정한다

    method·target·principal·조건을 파싱하고 검증해 이번 처리 동안 바뀌지 않는 값으로 전달합니다.

  2. 명시적으로 소유한 상태에 적용한다

    resource의 업무 상태와 cache·revocation·rate-limit 정책 상태를 이름 있는 저장소와 서비스 경계에서 읽고 갱신합니다.

  3. instance와 connection은 의미를 바꾸지 않는다

    다른 서버 인스턴스나 재사용·교체된 연결도 같은 입력과 상태 버전에는 같은 사용자 의미를 적용합니다.

STATE OWNERSHIP · NOT LOCATION

저장 위치가 아니라 소유권과 수명이 상태를 분류한다

  1. business state는 resource가 소유한다

    게시글 내용·버전·주문 결과처럼 다음 요청에도 필요한 사실은 명시적인 영속 resource 상태입니다.

  2. policy state도 별도 계약을 가진다

    cache·revocation·rate limit은 공유될 수 있지만 소유자, key, 유효 기간, 일관성 규칙이 있는 정책 상태입니다.

  3. session context와 숨은 대화를 구분한다

    공유 저장소의 opaque session record도 session 상태입니다. controller singleton field나 socket history로 이전 요청을 기억하는 숨은 대화는 금지합니다.

판단 흐름: 현재 요청의 명시적 입력 → request-local command → 소유자가 분명한 resource·policy state


클라이언트와 서버는 계약으로 분리됩니다

클라이언트는 요청을 만들고 표현을 해석합니다.

서버는 리소스와 업무 규칙을 소유합니다.

브라우저, 모바일 앱, 배치 작업이 같은 게시판 HTTP 계약을 사용할 수 있지만 URI, 메서드, 미디어 타입, 표현 스키마, 상태 코드는 함께 합의해야 합니다.

서버가 JSON 필드를 갑자기 삭제하거나 클라이언트가 지원하지 않는 미디어 타입을 보내면 전송 연결이 성공해도 계약은 깨집니다.

독립적으로 진화한다는 말은 무관하게 바꿔도 된다는 뜻이 아니라, 구현을 감춘 채 명시적인 인터페이스에서 만난다는 뜻입니다.


현재 요청에서 검증된 주체를 복원합니다

원시 X-Member-Id 값은 회원이 직접 주장할 수 있으므로 인증된 주체가 아닙니다.

다음 예제는 Authorization 헤더를 검증하는 책임을 MemberAuthenticator에 분리하고, 컨트롤러가 검증 결과인 회원 ID만 쿼리에 전달합니다.

실제 서비스에서는 Spring Security 필터 체인이나 신뢰 가능한 인증 계층이 서명, 만료, 발급자, 대상 서비스를 검증한 뒤 같은 역할의 principal을 만듭니다.

여기서 alice-token 같은 값은 테스트용 고정 자격 증명이지 운영 토큰 형식이나 암호 검증을 흉내 낸 것이 아닙니다.

src/main/java/board/web/PostSummary.java
package board.web;
public record PostSummary(
        long id,
        long memberId,
        String title) {
}
src/main/java/board/web/PostQuery.java
package board.web;
import java.util.List;
@FunctionalInterface
public interface PostQuery {
    List<PostSummary> findAll(long memberId);
}
src/main/java/board/web/MemberAuthenticator.java
package board.web;
import java.util.OptionalLong;
@FunctionalInterface
public interface MemberAuthenticator {
    OptionalLong authenticate(String authorizationHeader);
}
src/main/java/board/web/StatelessPostController.java
package board.web;
import java.util.List;
import java.util.OptionalLong;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts")
public final class StatelessPostController {
    private static final String BEARER_CHALLENGE =
            "Bearer realm=\"board-api\"";
    private final MemberAuthenticator authenticator;
    private final PostQuery query;
    public StatelessPostController(
            MemberAuthenticator authenticator,
            PostQuery query) {
        this.authenticator = authenticator;
        this.query = query;
    }
    @GetMapping
    public ResponseEntity<List<PostSummary>> list(
            @RequestHeader(
                            name = HttpHeaders.AUTHORIZATION,
                            required = false)
                    String authorization) {
        if (authorization == null) {
            return unauthorized();
        }
        OptionalLong memberId =
                authenticator.authenticate(authorization);
        if (memberId.isEmpty()) {
            return unauthorized();
        }
        return ResponseEntity.ok(
                query.findAll(memberId.getAsLong()));
    }
    private static ResponseEntity<List<PostSummary>> unauthorized() {
        return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .header(
                        HttpHeaders.WWW_AUTHENTICATE,
                        BEARER_CHALLENGE)
                .build();
    }
}

401 응답은 유효한 인증 자격 증명이 없다는 뜻입니다.

RFC 9110에 따라 서버가 401을 만들면 WWW-Authenticate challenge도 보내야 합니다.

인증은 성공했지만 특정 리소스 정책이 요청을 거부하면 403을 사용할 수 있고, 리소스 존재를 감출 정책이라면 일관되게 404를 선택할 수 있습니다.

이 목록 API는 인증된 회원 자신의 게시글만 쿼리하므로 근거 없는 403 분기를 추가하지 않습니다.


MVC 경계에서 7 → 9 → 7을 실행합니다

Spring Framework 6.2.11의 MockMvcBuilders.standaloneSetup은 실제 TCP 서버를 열지 않고 지정한 컨트롤러와 MVC 인프라를 DispatcherServlet 경계에서 실행합니다.

따라서 헤더 바인딩, 상태, 응답 헤더, Jackson 변환은 검증하지만 운영 필터 체인, Spring Security 설정, 실제 네트워크 연결은 이 테스트의 증거가 아닙니다.

다섯 테스트는 요청 순서와 동시 실행을 모두 확인합니다.

src/test/java/board/web/StatelessPostControllerTest.java
package board.web;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class StatelessPostControllerTest {
    private static final String CHALLENGE =
            "Bearer realm=\"board-api\"";
    @Test
    void 현재_요청의_bearer로_7_9_7을_독립적으로_조회한다()
            throws Exception {
        Fixture fixture = fixture();
        assertMember(fixture, "Bearer alice-token", null, 7);
        assertMember(fixture, "Bearer bob-token", null, 9);
        assertMember(
                fixture,
                "Bearer alice-token",
                "9",
                7);
        assertEquals(
                List.of(7L, 9L, 7L),
                fixture.queriedMembers());
    }
    @Test
    void authorization이_없으면_challenge와_401을_반환한다()
            throws Exception {
        Fixture fixture = fixture();
        MvcResult result = perform(
                fixture.mockMvc(), null, null);
        assertUnauthorized(result);
        assertEquals(0, fixture.queryCalls().get());
    }
    @Test
    void 유효하지_않은_bearer는_query를_호출하지_않는다()
            throws Exception {
        Fixture fixture = fixture();
        MvcResult result = perform(
                fixture.mockMvc(),
                "Bearer forged-token",
                "7");
        assertUnauthorized(result);
        assertEquals(0, fixture.queryCalls().get());
    }
    @Test
    void 공유_필드는_두_요청의_member를_결정적으로_섞는다()
            throws Exception {
        StatefulMemberController controller =
                new StatefulMemberController();
        CountDownLatch aliceSelected = new CountDownLatch(1);
        CountDownLatch bobSelected = new CountDownLatch(1);
        ExecutorService pool = Executors.newFixedThreadPool(2);
        try {
            Future<Long> alice = pool.submit(() -> {
                controller.select(7);
                aliceSelected.countDown();
                await(bobSelected);
                return controller.current();
            });
            Future<Long> bob = pool.submit(() -> {
                await(aliceSelected);
                controller.select(9);
                bobSelected.countDown();
                return controller.current();
            });
            assertEquals(9L, alice.get(1, SECONDS));
            assertEquals(9L, bob.get(1, SECONDS));
        } finally {
            stop(pool);
        }
    }
    @Test
    void 같은_stateless_instance도_동시_요청을_독립적으로_처리한다()
            throws Exception {
        StatelessPostController controller = controller(
                new AtomicInteger(),
                new CopyOnWriteArrayList<>());
        CountDownLatch ready = new CountDownLatch(2);
        CountDownLatch start = new CountDownLatch(1);
        ExecutorService pool = Executors.newFixedThreadPool(2);
        try {
            Future<Long> alice = pool.submit(() -> memberFrom(
                    controller,
                    "Bearer alice-token",
                    ready,
                    start));
            Future<Long> bob = pool.submit(() -> memberFrom(
                    controller,
                    "Bearer bob-token",
                    ready,
                    start));
            assertTrue(ready.await(1, SECONDS));
            start.countDown();
            assertEquals(7L, alice.get(1, SECONDS));
            assertEquals(9L, bob.get(1, SECONDS));
        } finally {
            stop(pool);
        }
    }
    private static Fixture fixture() {
        AtomicInteger queryCalls = new AtomicInteger();
        List<Long> queriedMembers =
                new CopyOnWriteArrayList<>();
        StatelessPostController controller =
                controller(queryCalls, queriedMembers);
        ObjectMapper objectMapper = new ObjectMapper();
        MockMvc mockMvc = MockMvcBuilders
                .standaloneSetup(controller)
                .setMessageConverters(
                        new MappingJackson2HttpMessageConverter(
                                objectMapper))
                .build();
        return new Fixture(
                mockMvc,
                objectMapper,
                queryCalls,
                queriedMembers);
    }
    private static StatelessPostController controller(
            AtomicInteger queryCalls,
            List<Long> queriedMembers) {
        MemberAuthenticator authenticator = authorization -> switch (
                authorization) {
            case "Bearer alice-token" -> OptionalLong.of(7);
            case "Bearer bob-token" -> OptionalLong.of(9);
            default -> OptionalLong.empty();
        };
        PostQuery query = memberId -> {
            queryCalls.incrementAndGet();
            queriedMembers.add(memberId);
            return List.of(new PostSummary(
                    memberId * 100,
                    memberId,
                    "HTTP"));
        };
        return new StatelessPostController(authenticator, query);
    }
    private static void assertMember(
            Fixture fixture,
            String authorization,
            String forgedMemberId,
            long expectedMemberId) throws Exception {
        MvcResult result = perform(
                fixture.mockMvc(),
                authorization,
                forgedMemberId);
        assertEquals(200, result.getResponse().getStatus());
        assertEquals(
                MediaType.APPLICATION_JSON_VALUE,
                result.getResponse().getContentType());
        JsonNode body = fixture.objectMapper().readTree(
                result.getResponse().getContentAsByteArray());
        assertTrue(body.isArray());
        assertEquals(1, body.size());
        assertEquals(
                expectedMemberId,
                body.get(0).get("memberId").longValue());
        assertEquals(
                expectedMemberId * 100,
                body.get(0).get("id").longValue());
    }
    private static MvcResult perform(
            MockMvc mockMvc,
            String authorization,
            String forgedMemberId) throws Exception {
        MockHttpServletRequestBuilder request = get("/api/posts")
                .accept(MediaType.APPLICATION_JSON);
        if (authorization != null) {
            request.header(
                    HttpHeaders.AUTHORIZATION,
                    authorization);
        }
        if (forgedMemberId != null) {
            request.header("X-Member-Id", forgedMemberId);
        }
        return mockMvc.perform(request).andReturn();
    }
    private static void assertUnauthorized(MvcResult result)
            throws Exception {
        assertEquals(401, result.getResponse().getStatus());
        assertEquals(
                CHALLENGE,
                result.getResponse().getHeader(
                        HttpHeaders.WWW_AUTHENTICATE));
        assertEquals(
                0,
                result.getResponse().getContentAsByteArray().length);
    }
    private static long memberFrom(
            StatelessPostController controller,
            String authorization,
            CountDownLatch ready,
            CountDownLatch start) throws Exception {
        ready.countDown();
        await(start);
        ResponseEntity<List<PostSummary>> response =
                controller.list(authorization);
        List<PostSummary> body = Objects.requireNonNull(
                response.getBody());
        return body.get(0).memberId();
    }
    private static void await(CountDownLatch latch)
            throws InterruptedException {
        if (!latch.await(1, SECONDS)) {
            throw new IllegalStateException("latch timeout");
        }
    }
    private static void stop(ExecutorService pool)
            throws InterruptedException {
        pool.shutdownNow();
        assertTrue(pool.awaitTermination(1, SECONDS));
    }
    private record Fixture(
            MockMvc mockMvc,
            ObjectMapper objectMapper,
            AtomicInteger queryCalls,
            List<Long> queriedMembers) {
    }
    private static final class StatefulMemberController {
        private long currentMemberId;
        void select(long memberId) {
            currentMemberId = memberId;
        }
        long current() {
            return currentMemberId;
        }
    }
}

세 번째 정상 요청은 위조한 X-Member-Id: 9도 함께 보내지만, 컨트롤러는 검증된 Bearer 주체 7만 사용합니다.

별도 scope를 지정하지 않은 Spring controller bean은 컨테이너마다 singleton입니다.

싱글톤은 공유해도 괜찮지만 요청마다 달라지는 회원, 검색 조건, 임시 결과를 인스턴스 필드에 쓰면 여러 요청이 같은 메모리를 경쟁합니다.

실패 테스트는 두 Java 17 platform thread와 latch로 Alice가 7을 저장한 뒤 Bob이 9로 덮어쓰는 순서를 고정합니다.

수정 컨트롤러는 생성 시 받은 불변 협력자만 보유하고 현재 요청에서 얻은 값을 지역 변수로 유지하므로 같은 인스턴스의 동시 호출도 섞이지 않습니다.


상태는 위치가 아니라 의미와 소유권으로 분류합니다

서버 메모리나 데이터베이스에 있다는 이유만으로 모든 상태가 같은 종류가 되지는 않습니다.

분류게시판 예요청 독립성과의 관계
리소스·업무 상태게시글, 회원, 버전, 고유 제약 조건명시적인 리소스와 업무 진실이며 서버에 지속될 수 있음
정책·인프라 상태캐시, 토큰 폐기 목록, 요청률 카운터, 멱등성 기록키, 원본, 수명, 일관성 정책을 명시하고 현재 요청으로 조회
요청 지역 상태검증된 principal, command, 검증 결과한 요청 안에서 만들고 응답 뒤 버림
서버 세션 문맥opaque session ID가 가리키는 장바구니 단계나 선택 회원공유 저장소에 옮겨도 다음 요청이 서버 문맥에 의존하므로 stateful
숨은 인스턴스·연결 상태controller field의 회원 ID, “이 연결은 이미 로그인됨”경합, 인스턴스 이동, 연결 재사용에서 의미가 섞이므로 제거

쿠키는 상태 분류가 아니라 전달 장치입니다.

서명된 자기완결 자격 증명을 쿠키에 담으면 요청이 검증할 claims를 함께 운반할 수 있습니다.

반대로 opaque 세션 ID만 보내고 서버 레코드에서 다음 화면 단계나 선택 회원을 복원하면 애플리케이션은 서버 세션 문맥을 유지합니다.

세션 저장소를 모든 인스턴스가 공유하면 sticky session을 줄이고 장애 전환은 쉬워지지만 REST의 무상태 제약으로 바뀌지는 않습니다.


연결 재사용과 요청 독립성은 동시에 성립합니다

요청 메시지를 독립적으로 해석한다는 규칙은 요청마다 새 전송 연결을 만들라는 규칙이 아닙니다.

HTTP/1.1 지속 연결이 여러 요청·응답 교환을 재사용하고, HTTP/2가 TCP/TLS 연결 하나에서 동시에 열린 stream의 frame을 교차 전송하며, HTTP/3가 QUIC 연결에서 요청마다 클라이언트가 연 양방향 request stream을 사용하는 동안, 전송 연결의 재사용·종료와 애플리케이션 principal·권한 판단이 서로 다른 경계임을 설명합니다.

CONNECTION · REQUEST · AUTHORIZATION

연결은 재사용해도 요청의 인증·권한 판단은 독립적이다

전송 연결의 수명은 사용자 대화 상태가 아닙니다. 하나의 연결을 여러 교환이나 stream이 공유해도 각 요청은 현재 인증 정보로 principal을 확인하고 권한을 판정합니다.

HTTP/1.1 · PERSISTENT

지속 연결 안에서 여러 요청·응답 교환이 재사용된다

  1. 여러 교환이 연결을 이어 쓴다

    HTTP/1.1은 지속 연결이 기본이며, 완결된 요청·응답 교환 뒤 같은 연결을 다음 교환에 재사용할 수 있습니다.

  2. close는 전송 재사용을 끝낸다

    Connection: close는 현재 응답 뒤 연결을 더 쓰지 않겠다는 뜻이며 애플리케이션의 로그인·권한 상태를 결정하지 않습니다.

  3. 교환 순서를 identity로 쓰지 않는다

    같은 연결의 앞선 요청이 누구였는지 추론하지 않고, 현재 요청이 제공한 인증 정보로 principal을 확인합니다.

HTTP/2 · MULTIPLEXED

TCP/TLS 연결 하나가 동시에 열린 stream을 함께 운반한다

  1. stream마다 요청·응답 의미를 유지한다

    각 HTTP 요청·응답 교환은 자신의 stream에 속하며 다른 stream과 애플리케이션 의미가 섞이지 않습니다.

  2. frame은 stream 사이에서 교차 전송된다

    하나의 TCP/TLS 연결이 여러 stream의 frame을 번갈아 운반하므로 여러 교환이 동시에 진행될 수 있습니다.

  3. 연결 상태를 principal로 올리지 않는다

    stream ID와 연결 수준 설정은 프로토콜 제어 정보입니다. 서버는 이를 애플리케이션 principal이나 권한으로 사용하지 않습니다.

HTTP/3 · QUIC STREAMS

QUIC 연결 안에서 요청마다 양방향 request stream을 사용한다

  1. 요청·응답 교환 하나가 stream 하나를 쓴다

    각 요청·응답 교환은 클라이언트가 연 하나의 양방향 request stream을 사용하고, 교환이 끝나면 그 stream의 전송도 마칩니다.

  2. 연결 제어는 공유하고 진행은 분리한다

    요청 stream들은 같은 QUIC 연결과 연결 수준 제어 상태를 공유하지만, 한 stream의 차단이나 packet loss가 다른 stream의 진행을 직접 막지는 않습니다.

  3. 재사용·종료와 권한을 분리한다

    QUIC 연결을 이어 쓰거나 닫는 결정은 전송 자원 수명이며, 각 요청의 authorization은 현재 인증 정보와 리소스 정책으로 판정합니다.

포함 관계: 전송 연결 > 요청·응답 교환 또는 request stream. 연결 수명과 애플리케이션 principal·authorization의 수명은 서로 다릅니다.

HTTP/1.1은 지속 연결이 기본이라 한 연결에서 여러 요청과 응답을 순서대로 교환할 수 있습니다.

HTTP/2는 한 TCP/TLS 연결 안에 여러 stream을 동시에 열고 frame을 교차 전송합니다.

HTTP/3은 QUIC 연결에서 요청·응답 교환 하나를 client-initiated bidirectional request stream 하나에 대응시킵니다.

stream들은 연결 수준의 flow control, 압축, 보안 문맥 같은 전송 상태를 공유할 수 있지만 애플리케이션이 이전 요청의 회원 선택을 공유해도 된다는 뜻은 아닙니다.

서버는 같은 연결의 두 요청이 같은 사용자의 것이라고 일반적으로 가정해서는 안 됩니다.

Bearer 예제처럼 요청마다 자격 증명을 전달하거나, 검증된 전송 보안 문맥에서 현재 요청의 principal을 복원해야 합니다.

“첫 요청이 로그인했으니 이 socket의 다음 요청도 회원 7”이라는 애플리케이션 규칙은 프록시 연결 재사용, HTTP/2 다중화, 연결 종료와 재수립에서 깨집니다.

기존 연결을 재사용하면 새로운 TCP/TLS 또는 QUIC 연결 설정 비용을 줄일 수 있습니다.

이름 해석 cache, 유휴 종료, 최대 동시 연결, 연결 획득 대기, 응답 제한은 서로 다른 정책이며 사용하는 HTTP client의 구체적인 API로 설정해야 합니다.

전송 재시도 안전성은 ch4-1의 멱등성·결과 기록 경계가 소유하며, 연결 재사용만으로 보장되지 않습니다.


수평 확장과 장애 복구에서 확인할 기준

설계수평 확장·장애 전환 때 결과개선
controller field에 현재 회원 저장요청 경합으로 혼합되고 인스턴스가 바뀌면 유실현재 요청에서 검증된 principal을 지역 값으로 사용
로컬 메모리만 업무 진실로 사용인스턴스마다 결과가 달라지고 재시작 때 유실공유 durable store 또는 명시적인 partition 소유권
로컬 cache만 원본으로 사용stale 값과 cold start가 업무 결과를 바꿈원본 저장소, TTL, 무효화, 실패 정책 명시
opaque session record를 특정 인스턴스에만 저장affinity와 인스턴스 생존에 의존필요하면 공유 세션 저장소로 복구하되 stateful임을 명시하거나 요청 계약 재설계
현재 요청에 resource ID·principal·조건 포함어느 인스턴스도 같은 규칙을 적용할 수 있음민감 정보 최소화, 서명·만료·권한 검증, 공유 상태 일관성 관리

무상태 요청은 동적 라우팅, 병렬 처리, 관측, 부분 장애 복구의 기준을 단순하게 만듭니다.

그러나 데이터베이스 경합, 캐시 일관성, 멱등성 기록, 인증 키와 폐기 정책까지 자동으로 해결하지는 않습니다.

“어느 인스턴스도 처리 가능”은 동일한 입력과 일관된 정책 상태를 읽을 수 있다는 설계 목표이지, 시간과 동시 변경이 있어도 응답 바이트가 항상 같다는 보장은 아닙니다.


연습 문제

MemberAuthenticator에 만료된 자격 증명과 잘못된 발급자 사례를 추가하고 둘 다 401과 같은 Bearer challenge를 반환하면서 PostQuery는 호출하지 않는지 검증하세요.

그다음 게시글 단건 조회 계약을 별도로 설계해 인증 실패 401, 인증 성공 뒤 권한 거부 403, 존재 은닉 정책의 404를 서로 섞지 않도록 테스트 이름과 전제 조건을 작성하세요.

해설 보기

인증 실패 사례는 MemberAuthenticatorOptionalLong.empty()를 반환하도록 추가하면 현재 실패 경로를 그대로 재사용할 수 있습니다.

응답은 401, WWW-Authenticate: Bearer realm="board-api", 빈 본문, 쿼리 0회를 함께 확인해야 합니다.

단건 조회의 403과 404는 인증이 끝난 뒤 리소스 정책이 결정합니다.

존재 은닉을 선택했다면 같은 권한 조건에서 상태와 응답 형태를 일관되게 유지해야 하며, 컨트롤러 필드나 이전 요청 결과로 판단을 보완해서는 안 됩니다.

MockMvc standalone 검증은 이 컨트롤러의 MVC 계약만 다루므로 실제 Spring Security filter chain은 별도 통합 테스트에서 확인합니다.

다음 문서에서는 독립적인 요청과 응답이 HTTP/1.1 시작줄, 헤더, 빈 줄, 본문으로 어떻게 경계 지어지는지 원시 메시지와 MVC 응답을 함께 읽습니다.