HTTP 무상태성과 연결
HTTP 요청의 독립성, REST의 서버 세션 제약, 명시적인 리소스 상태를 구분하고 연결을 재사용해도 현재 요청에서 인증과 권한을 다시 판단하는 게시판 API를 구현합니다.
HTTP가 무상태(stateless)라는 말은 "서버가 데이터를 저장하지 않는다"거나 "요청마다 전송 연결을 끊는다"는 뜻이 아닙니다. RFC 9110이 말하는 무상태성은 요청 메시지 하나의 의미를 다른 요청이나 그 메시지를 실은 연결과 상관없이 해석할 수 있다는 프로토콜의 성질입니다. REST의 무상태 제약은 한 걸음 더 나아가, 다음 요청을 이해하는 데 필요한 서버 쪽 세션 문맥을 남기지 않고 요청마다 처리에 필요한 정보를 함께 보내도록 요구합니다. 게시글이나 회원, 버전, 캐시, 폐기 목록처럼 누가 소유하고 얼마나 사는지가 분명한 서버 상태는 이 두 설명과 어긋나지 않습니다.
요청의 처리 조건은 현재 메시지에 있고 서버 상태는 명시적으로 소유한다
credentials를 검증한 principal과 현재 request 입력으로 좁혀 자원·정책에 적용하며 인증 실패가 downstream을 막는다.
REQUEST · DECISION · STATE OWNER
요청의 처리 조건은 현재 메시지에 있고 서버 상태는 명시적으로 소유한다
현재 요청이 사용자 의미를 완성하고, 서버는 이름 붙인 상태에 그 의미를 적용합니다. 연결이나 인스턴스가 바뀌어도 method·target·검증된 principal·조건을 같은 상태 버전과 정책에 적용할 수 있어야 합니다. 공유 저장 위치만으로 session context가 업무 상태가 되지는 않습니다.
CURRENT REQUEST · EXPLICIT INPUT
현재 메시지가 이번 처리의 조건을 완성한다
method와 target이 연산 대상을 지정한다
GET /posts/42와PATCH /posts/42는 같은 resource를 가리켜도 서로 다른 의도를 전달합니다.resource·filter·precondition을 명시한다
path·query·header·body가 이번 조회와 변경 조건을 운반합니다. 누락된 입력을 이전 요청에서 채우지 않습니다.
식별자는 먼저 verified principal이 된다
credential이나 session identifier 자체를 권한으로 쓰지 않습니다. 검증과 정책 확인을 거친 principal만 현재 요청의 입력이 됩니다.
ANY INSTANCE · SAME DECISION
어느 인스턴스든 같은 명시적 판단을 수행한다
request-local immutable command로 고정한다
method·target·principal·조건을 파싱하고 검증해 이번 처리 동안 바뀌지 않는 값으로 전달합니다.
명시적으로 소유한 상태에 적용한다
resource의 업무 상태와 cache·revocation·rate-limit 정책 상태를 이름 있는 저장소와 서비스 경계에서 읽고 갱신합니다.
instance와 connection은 의미를 바꾸지 않는다
다른 서버 인스턴스나 재사용·교체된 연결도 같은 입력과 상태 버전에는 같은 사용자 의미를 적용합니다.
STATE OWNERSHIP · NOT LOCATION
저장 위치가 아니라 소유권과 수명이 상태를 분류한다
business state는 resource가 소유한다
게시글 내용·버전·주문 결과처럼 다음 요청에도 필요한 사실은 명시적인 영속 resource 상태입니다.
policy state도 별도 계약을 가진다
cache·revocation·rate limit은 공유될 수 있지만 소유자, key, 유효 기간, 일관성 규칙이 있는 정책 상태입니다.
session context와 숨은 대화를 구분한다
공유 저장소의 opaque session record도 session 상태입니다. controller singleton field나 socket history로 이전 요청을 기억하는 숨은 대화는 금지합니다.
판단 흐름: 현재 요청의 명시적 입력 → request-local command → 소유자가 분명한 resource·policy state
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 자격 증명은 인증 협력자가 검증한 principal로 변환한다. method, target, 조건은 같은 현재 요청에서 가져와 요청 지역 입력을 만든다. 인증 실패면 원 예제의 조회를 실행하지 않고 401 challenge를 반환한다. 성공 입력은 명시적으로 소유한 resource·policy 상태에 적용하며 이전 singleton 필드나 socket history를 사용자 입력으로 삼지 않는다.
- 동일한 5노드와 5관계를 배치했다. 현재 요청의 method와 target은 인증 principal과 함께 요청 지역 입력이 된다. 성공 상태 적용과 검증 실패 401은 서로 다른 경로다. 401이 성공 상태 적용 뒤에 실행되는 것이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 자격 증명은 인증 협력자가 검증한 principal로 변환한다. method, target, 조건은 같은 현재 요청에서 가져와 요청 지역 입력을 만든다. 인증 실패면 원 예제의 조회를 실행하지 않고 401 challenge를 반환한다. 성공 입력은 명시적으로 소유한 resource·policy 상태에 적용하며 이전 singleton 필드나 socket history를 사용자 입력으로 삼지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 현재 입력과 인증 credentials를 인증 협력자의 principal로 검증합니다. | 실패: 401 challenge · 조회: 실패 시 없음 | 검증한 주체만 업무 입력이 됩니다. |
| 요청 지역 입력 현재 method·target·조건과 principal을 결합합니다. | 자료: 현재 요청 · singleton 과거 필드: 사용 안 함 | 사용자 입력이 요청 내부 값으로 좁혀집니다. |
| 소유한 자원·정책 적용 명시적 resource·policy 상태에 입력을 적용합니다. | 자원: 명시적 소유 · socket history: 사용자 입력 아님 | 현재 입력이 서버 소유 상태와 만납니다. |
요청의 처리 조건은 현재 메시지에 있고 서버 상태는 명시적으로 소유한다
credentials를 검증한 principal과 현재 request 입력으로 좁혀 자원·정책에 적용하며 인증 실패가 downstream을 막는다.
REQUEST · DECISION · STATE OWNER
요청의 처리 조건은 현재 메시지에 있고 서버 상태는 명시적으로 소유한다
현재 요청이 사용자 의미를 완성하고, 서버는 이름 붙인 상태에 그 의미를 적용합니다. 연결이나 인스턴스가 바뀌어도 method·target·검증된 principal·조건을 같은 상태 버전과 정책에 적용할 수 있어야 합니다. 공유 저장 위치만으로 session context가 업무 상태가 되지는 않습니다.
CURRENT REQUEST · EXPLICIT INPUT
현재 메시지가 이번 처리의 조건을 완성한다
method와 target이 연산 대상을 지정한다
GET /posts/42와PATCH /posts/42는 같은 resource를 가리켜도 서로 다른 의도를 전달합니다.resource·filter·precondition을 명시한다
path·query·header·body가 이번 조회와 변경 조건을 운반합니다. 누락된 입력을 이전 요청에서 채우지 않습니다.
식별자는 먼저 verified principal이 된다
credential이나 session identifier 자체를 권한으로 쓰지 않습니다. 검증과 정책 확인을 거친 principal만 현재 요청의 입력이 됩니다.
ANY INSTANCE · SAME DECISION
어느 인스턴스든 같은 명시적 판단을 수행한다
request-local immutable command로 고정한다
method·target·principal·조건을 파싱하고 검증해 이번 처리 동안 바뀌지 않는 값으로 전달합니다.
명시적으로 소유한 상태에 적용한다
resource의 업무 상태와 cache·revocation·rate-limit 정책 상태를 이름 있는 저장소와 서비스 경계에서 읽고 갱신합니다.
instance와 connection은 의미를 바꾸지 않는다
다른 서버 인스턴스나 재사용·교체된 연결도 같은 입력과 상태 버전에는 같은 사용자 의미를 적용합니다.
STATE OWNERSHIP · NOT LOCATION
저장 위치가 아니라 소유권과 수명이 상태를 분류한다
business state는 resource가 소유한다
게시글 내용·버전·주문 결과처럼 다음 요청에도 필요한 사실은 명시적인 영속 resource 상태입니다.
policy state도 별도 계약을 가진다
cache·revocation·rate limit은 공유될 수 있지만 소유자, key, 유효 기간, 일관성 규칙이 있는 정책 상태입니다.
session context와 숨은 대화를 구분한다
공유 저장소의 opaque session record도 session 상태입니다. controller singleton field나 socket history로 이전 요청을 기억하는 숨은 대화는 금지합니다.
판단 흐름: 현재 요청의 명시적 입력 → request-local command → 소유자가 분명한 resource·policy state
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 자격 증명은 인증 협력자가 검증한 principal로 변환한다. method, target, 조건은 같은 현재 요청에서 가져와 요청 지역 입력을 만든다. 인증 실패면 원 예제의 조회를 실행하지 않고 401 challenge를 반환한다. 성공 입력은 명시적으로 소유한 resource·policy 상태에 적용하며 이전 singleton 필드나 socket history를 사용자 입력으로 삼지 않는다.
- 동일한 5노드와 5관계를 배치했다. 현재 요청의 method와 target은 인증 principal과 함께 요청 지역 입력이 된다. 성공 상태 적용과 검증 실패 401은 서로 다른 경로다. 401이 성공 상태 적용 뒤에 실행되는 것이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 자격 증명은 인증 협력자가 검증한 principal로 변환한다. method, target, 조건은 같은 현재 요청에서 가져와 요청 지역 입력을 만든다. 인증 실패면 원 예제의 조회를 실행하지 않고 401 challenge를 반환한다. 성공 입력은 명시적으로 소유한 resource·policy 상태에 적용하며 이전 singleton 필드나 socket history를 사용자 입력으로 삼지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 현재 입력과 인증 credentials를 인증 협력자의 principal로 검증합니다. | 실패: 401 challenge · 조회: 실패 시 없음 | 검증한 주체만 업무 입력이 됩니다. |
| 요청 지역 입력 현재 method·target·조건과 principal을 결합합니다. | 자료: 현재 요청 · singleton 과거 필드: 사용 안 함 | 사용자 입력이 요청 내부 값으로 좁혀집니다. |
| 소유한 자원·정책 적용 명시적 resource·policy 상태에 입력을 적용합니다. | 자원: 명시적 소유 · socket history: 사용자 입력 아님 | 현재 입력이 서버 소유 상태와 만납니다. |
그림은 요청 하나가 처리되는 과정을 보여 줍니다. 현재 요청의 자격 증명은 인증 협력 객체가 검증해 회원(principal)으로 바꾸고, 같은 요청의 메서드와 대상, 조건과 합쳐 요청 안에서만 쓰는 입력을 만듭니다. 이 입력을 소유자가 분명한 리소스와 정책 상태에 적용하며, 인증에 실패하면 조회를 실행하지 않고 401을 돌려줍니다. 지난 요청의 회원 식별자나 싱글톤 필드, 소켓의 이력으로 입력을 채우지 않는다는 점이 핵심입니다.
이번 글의 실행 단위
앞 글의 Java 17·Spring 6.2.11 별도 단위에 제목의 경로대로 운영 파일 네 개와 StatelessPostControllerTest를 추가합니다. Spring MVC와 가짜 서블릿 테스트에는 같은 버전의 spring-webmvc와 spring-test, 서블릿 API, 그리고 코드가 임포트하는 Jackson 2가 필요하고, JUnit Jupiter도 테스트 실행기에 연결해야 합니다. 게시판 프로젝트의 다른 버전 묶음으로 이 단위의 결과를 대신하지 않습니다.
먼저 PostSummary, PostQuery, MemberAuthenticator가 각각 무엇을 하는지 읽고, 컨트롤러가 인증에 성공한 값을 조회에 넘기는 곳을 찾아봅니다. 그다음 아래 테스트에서 HTTP 입력을 만드는 경우와 Java 메서드를 직접 호출하는 경우를 나누어 따라갑니다. 실제 서버 실행이나 운영 토큰 발급은 이번 글의 범위가 아닙니다.
클라이언트와 서버는 약속한 인터페이스로 나뉩니다
클라이언트는 요청을 만들고 받은 표현을 해석하며, 서버는 리소스와 업무 규칙을 맡습니다. 브라우저와 모바일 앱, 배치 작업이 같은 게시판 HTTP API를 쓸 수 있지만, URI와 메서드, 미디어 타입, 표현의 형식, 상태 코드는 서로 합의해야 합니다. 서버가 JSON 필드를 갑자기 지우거나, 클라이언트가 지원하지 않는 미디어 타입을 보내면 전송 연결은 성공해도 약속은 깨집니다. 둘이 따로 발전할 수 있다는 말은 서로 상관없이 바꿔도 된다는 뜻이 아니라, 구현은 감추고 분명한 인터페이스에서 만난다는 뜻입니다.
현재 요청에서 검증된 주체를 복원합니다
X-Member-Id 같은 헤더의 값은 회원이 마음대로 보낼 수 있으므로 인증된 주체가 아닙니다. 다음 예제는 Authorization 헤더를 검증하는 일을 MemberAuthenticator에 맡기고, 컨트롤러는 검증 결과인 회원 식별자만 조회에 넘깁니다. 실제 서비스에서는 Spring Security 필터 체인이나 신뢰할 수 있는 인증 계층이 서명과 만료, 발급자, 대상 서비스를 검증한 뒤 같은 역할의 회원 정보를 만듭니다. 여기서 쓰는 alice-token 같은 값은 테스트용 고정 자격 증명일 뿐, 운영 토큰의 형식이나 암호 검증을 흉내 낸 것이 아닙니다.
package board.web;
public record PostSummary(
long id,
long memberId,
String title) {
}package board.web;
import java.util.List;
@FunctionalInterface
public interface PostQuery {
List<PostSummary> findAll(long memberId);
}package board.web;
import java.util.OptionalLong;
@FunctionalInterface
public interface MemberAuthenticator {
OptionalLong authenticate(String authorizationHeader);
}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 설정, 실제 네트워크 연결은 확인하지 않습니다.
다섯 테스트는 요청 순서와 동시 실행을 모두 확인합니다. 앞의 세 테스트는 MockMvc로 헤더 바인딩과 응답 상태, JSON, 조회 순서를 확인하고, 뒤의 두 테스트는 컨트롤러 메서드를 두 Java 스레드에서 직접 호출해 공유 필드와 요청 지역 값의 차이를 확인합니다. 뒤의 두 테스트가 통과했다고 MVC 필터 체인이나 실제로 동시에 들어온 HTTP 요청까지 확인한 것은 아닙니다.
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만 씁니다. 범위를 따로 정하지 않은 Spring 컨트롤러 빈은 컨테이너마다 하나뿐인 싱글톤입니다. 싱글톤을 함께 쓰는 것 자체는 괜찮지만, 요청마다 달라지는 회원이나 검색 조건, 임시 결과를 인스턴스 필드에 쓰면 여러 요청이 같은 메모리를 두고 다투게 됩니다.
실패 테스트는 Java 17의 플랫폼 스레드 두 개와 래치로, Alice가 currentMemberId에 7을 저장하고 기다리는 동안 Bob이 같은 필드를 9로 바꾸는 순서를 만듭니다. Alice가 다시 진행할 때 읽는 값은 자신이 넣었던 7이 아니라 지금의 공유 필드 값 9이므로, 두 호출이 모두 9로 조회됩니다. 스레드가 둘이라고 언제나 이 순서가 나오는 것은 아니며, 여기서는 래치가 그 잘못된 순서를 일부러 재현합니다. 고친 컨트롤러는 만들 때 받은 불변 협력 객체만 필드에 두고 현재 요청에서 얻은 값은 지역 변수로 다루므로, 같은 인스턴스를 동시에 호출해도 값이 섞이지 않습니다.
상태는 위치가 아니라 의미와 소유권으로 분류합니다
서버의 메모리나 데이터베이스에 있다고 모든 상태가 같은 종류가 되지는 않습니다.
| 분류 | 게시판의 예 | 요청 독립성과의 관계 |
|---|---|---|
| 리소스와 업무 상태 | 게시글, 회원, 버전, 고유 제약 조건 | 분명한 리소스와 업무의 사실이며 서버에 오래 남을 수 있음 |
| 정책과 인프라 상태 | 캐시, 토큰 폐기 목록, 요청률 카운터, 멱등성 기록 | 키와 원본, 수명, 일관성 정책을 밝히고 현재 요청으로 조회 |
| 요청 지역 상태 | 검증된 회원, 명령, 검증 결과 | 한 요청 안에서 만들고 응답 뒤 버림 |
| 서버 세션 문맥 | 불투명한 세션 ID가 가리키는 장바구니 단계나 선택한 회원 | 공유 저장소로 옮겨도 다음 요청이 서버 문맥에 의존하므로 상태가 있음 |
| 숨은 인스턴스·연결 상태 | 컨트롤러 필드의 회원 ID, "이 연결은 이미 로그인됨" | 경합, 인스턴스 이동, 연결 재사용에서 의미가 섞이므로 없앰 |
쿠키는 상태의 분류가 아니라 전달 수단입니다. 서명된 자기 완결형 자격 증명을 쿠키에 담으면 요청이 검증할 정보(claim)를 함께 실어 보낼 수 있습니다. 반대로 불투명한 세션 ID만 보내고 서버의 기록에서 다음 화면 단계나 선택한 회원을 되살린다면, 애플리케이션은 서버 세션 문맥을 유지하는 것입니다. 세션 저장소를 모든 인스턴스가 함께 쓰면 특정 인스턴스로만 보내는 고정 세션(sticky session)을 줄이고 장애 전환이 쉬워지지만, 그렇다고 REST의 무상태 제약을 지키게 되는 것은 아닙니다.
연결 재사용과 요청 독립성은 동시에 성립합니다
요청 메시지를 독립적으로 해석한다는 규칙이 요청마다 새 전송 연결을 만들라는 뜻은 아닙니다.
CONNECTION · REQUEST · AUTHORIZATION
연결은 재사용해도 요청의 인증·권한 판단은 독립적이다
전송 연결의 수명은 사용자 대화 상태가 아닙니다. 하나의 연결을 여러 교환이나 stream이 공유해도 각 요청은 자격 증명 또는 검증된 전송 보안 문맥에서 현재 principal을 복원하고 권한을 판정합니다.
HTTP/1.1 · PERSISTENT
지속 연결 안에서 여러 요청·응답 교환이 재사용된다
여러 교환이 연결을 이어 쓴다
HTTP/1.1은 지속 연결이 기본이며, 완결된 요청·응답 교환 뒤 같은 연결을 다음 교환에 재사용할 수 있습니다.close는 전송 재사용을 끝낸다
Connection: close는 현재 응답 뒤 연결을 더 쓰지 않겠다는 뜻이며 애플리케이션의 로그인·권한 상태를 결정하지 않습니다.교환 순서를 identity로 쓰지 않는다
같은 연결의 앞선 요청이 누구였는지 추론하지 않고, 현재 요청이 제공한 인증 정보로 principal을 확인합니다.
HTTP/2 · MULTIPLEXED
TCP/TLS 연결 하나가 동시에 열린 stream을 함께 운반한다
stream마다 요청·응답 의미를 유지한다
각 HTTP 요청·응답 교환은 자신의 stream에 속하며 다른 stream과 애플리케이션 의미가 섞이지 않습니다.
frame은 stream 사이에서 교차 전송된다
하나의
TCP/TLS연결이 여러 stream의 frame을 번갈아 운반하므로 여러 교환이 동시에 진행될 수 있습니다.연결 상태를 principal로 올리지 않는다
stream ID와 임의의 연결 기록만으로 사용자를 정하지 않습니다. 인증에 쓸 전송 보안 문맥도 검증한 뒤 현재 요청의 principal로 복원합니다.
HTTP/3 · QUIC STREAMS
QUIC 연결 안에서 요청마다 양방향 request stream을 사용한다
요청·응답 교환 하나가 stream 하나를 쓴다
각 요청·응답 교환은 클라이언트가 연 하나의 양방향 request stream을 사용하고, 교환이 끝나면 그 stream의 전송도 마칩니다.
연결 제어는 공유하고 진행은 분리한다
요청 stream은 같은
QUIC연결을 공유하지만, 한 stream의 손실 복구 때문에 다른 stream이 TCP의 바이트 순서를 기다리는 차단은 없습니다. 다만 연결의 혼잡·흐름 제어와 헤더 압축 의존성 같은 공유 조건은 다른 stream의 진행에도 영향을 줄 수 있습니다.재사용·종료와 권한을 분리한다
QUIC 연결을 이어 쓰거나 닫는 결정은 전송 자원 수명이며, 각 요청의 authorization은 현재 인증 정보와 리소스 정책으로 판정합니다.
포함 관계: 전송 연결 > 요청·응답 교환 또는 request stream. 연결 수명과 애플리케이션 principal·authorization의 수명은 서로 다릅니다.
그림은 HTTP 버전마다 전송 연결 하나 안에 서로 다른 요청이 담기는 모습을 보여 줍니다. HTTP/1.1은 지속 연결이 기본이어서 한 연결에서 여러 요청과 응답을 차례로 주고받습니다. HTTP/2는 TCP/TLS 연결 하나 안에 여러 스트림을 동시에 열고 프레임을 번갈아 보내며, HTTP/3는 QUIC 연결에서 요청과 응답 한 쌍마다 클라이언트가 연 양방향 요청 스트림 하나를 씁니다. 그림의 회원 7과 9는 서로 다른 요청이 각자 보낸 자격 증명의 결과이며, 연결에 저장된 회원이 아닙니다. HTTP/2와 HTTP/3 영역의 위아래 배치도 순서를 뜻하지 않습니다.
스트림들은 연결 단위의 흐름 제어나 압축, 보안 문맥 같은 전송 상태를 함께 쓸 수 있지만, 그렇다고 이전 요청에서 고른 회원까지 함께 써도 된다는 뜻은 아닙니다. 서버는 같은 연결의 두 요청이 같은 사용자의 것이라고 가정해서는 안 되며, Bearer 예제처럼 요청마다 자격 증명을 받거나, 검증된 전송 보안 문맥에서 현재 요청의 회원을 되살려야 합니다. "첫 요청이 로그인했으니 이 소켓의 다음 요청도 회원 7"이라는 규칙은 프록시의 연결 재사용이나 HTTP/2의 다중화, 연결 종료와 재연결에서 깨집니다.
기존 연결을 다시 쓰면 새 TCP/TLS나 QUIC 연결을 맺는 비용을 줄일 수 있습니다. 이름 해석 캐시, 유휴 연결 종료, 최대 동시 연결 수, 연결을 얻으려 기다리는 시간, 응답 제한 시간은 서로 다른 정책이므로 쓰는 HTTP 클라이언트의 API로 각각 설정합니다. 응답이 전송 중에 사라지는 경우는 DNS·IP·TCP·UDP와 연결 경계에서, 메서드별 재시도와 업무 결과 기록은 HTTP 메서드 속성에서 이어서 다룹니다. 연결을 다시 쓴다고 게시글 생성의 중복 효과를 막을 수는 없습니다.
수평 확장과 장애 복구에서 확인할 기준
| 설계 | 인스턴스를 늘리거나 장애를 넘길 때 | 개선 방법 |
|---|---|---|
| 컨트롤러 필드에 현재 회원 저장 | 요청끼리 섞이고, 인스턴스가 바뀌면 사라짐 | 현재 요청에서 검증한 회원을 지역 값으로 사용 |
| 로컬 메모리만 업무의 사실로 사용 | 인스턴스마다 결과가 다르고 재시작하면 사라짐 | 함께 쓰는 영속 저장소나 분명한 파티션 소유 |
| 로컬 캐시만 원본으로 사용 | 오래된 값과 빈 캐시가 업무 결과를 바꿈 | 원본 저장소, TTL, 무효화, 실패 정책을 명시 |
| 불투명한 세션 기록을 한 인스턴스에만 저장 | 특정 인스턴스로의 연결과 그 인스턴스의 생존에 의존 | 필요하면 공유 세션 저장소로 복구하되 상태가 있음을 밝히거나 요청 형식을 다시 설계 |
| 현재 요청에 리소스 ID, 회원, 조건 포함 | 어느 인스턴스든 같은 규칙을 적용 | 민감 정보 최소화, 서명과 만료와 권한 검증, 공유 상태의 일관성 관리 |
무상태 요청은 요청을 아무 인스턴스로나 보내는 동적 라우팅, 병렬 처리, 관측, 일부 장애의 복구를 단순하게 만듭니다. 그러나 데이터베이스 경합이나 캐시 일관성, 멱등성 기록, 인증 키와 폐기 정책까지 저절로 해결해 주지는 않습니다. "어느 인스턴스든 처리할 수 있다"는 것은 같은 입력과 일관된 정책 상태를 읽을 수 있다는 설계 목표이며, 시간이 흐르고 동시에 변경이 일어나도 응답이 언제나 똑같다는 보장은 아닙니다.
정리
- HTTP의 무상태성은 요청 하나의 의미를 다른 요청이나 연결과 상관없이 해석할 수 있다는 성질이며, 서버가 아무것도 저장하지 않는다는 뜻이 아닙니다.
X-Member-Id같은 헤더 값은 인증된 주체가 아니므로, 요청마다 자격 증명을 검증해 얻은 회원만 처리에 씁니다.- 401에는
WWW-Authenticate헤더를 함께 보내고, 인증 뒤의 권한 거부(403)와 존재를 감추는 404를 섞지 않습니다. - 싱글톤 컨트롤러의 필드에 요청마다 다른 값을 두면 동시 요청에서 섞이므로, 요청에서 얻은 값은 지역 변수로 다룹니다.
- 연결을 다시 쓰는 것과 요청을 독립적으로 해석하는 것은 함께 성립하며, 같은 연결의 다음 요청이 같은 사용자라고 가정하지 않습니다.
연습 문제
1. 인증 실패와 권한 거부 나누기
MemberAuthenticator에 만료된 자격 증명과 잘못된 발급자의 사례를 추가하고, 둘 다 401과 같은 Bearer 인증 요구를 돌려주면서 PostQuery는 호출하지 않는지 확인하세요. 그다음 게시글 하나를 조회하는 API를 따로 설계해, 인증 실패의 401과 인증 뒤 권한 거부의 403, 존재를 감추는 정책의 404를 섞지 않도록 테스트 이름과 전제 조건을 정하세요.
해설 보기
인증 실패 사례는 MemberAuthenticator가 OptionalLong.empty()를 돌려주게 추가하면 지금의 실패 경로를 그대로 쓸 수 있습니다. 여기서 확인하는 것은 "만료"나 "잘못된 발급자"로 정한 입력을 인증 협력 객체가 거절했을 때 컨트롤러가 어떻게 응답하는지이며, 실제 서명이나 시간, 발급자를 검증하는 알고리즘을 구현한 것은 아닙니다. 응답은 401, WWW-Authenticate: Bearer realm="board-api", 빈 본문, 조회 0회를 함께 확인해야 합니다.
게시글 하나를 조회할 때의 403과 404는 인증이 끝난 뒤 리소스 정책이 정합니다. 존재를 감추기로 했다면 같은 권한 조건에서 상태 코드와 응답 형태를 일관되게 유지해야 하며, 컨트롤러 필드나 이전 요청의 결과로 판단을 보충해서는 안 됩니다. 독립형 MockMvc 테스트는 이 컨트롤러의 MVC 동작만 다루므로, 실제 Spring Security 필터 체인은 별도의 통합 테스트로 확인합니다.
다음 글 HTTP 메시지 구조에서는 독립된 요청과 응답이 HTTP/1.1의 시작 줄, 헤더, 빈 줄, 본문으로 어떻게 나뉘는지 원시 메시지와 MVC 응답을 함께 읽어 봅니다.