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

안동민 개발노트

본문 시작
4장 : HTTP 기본 원리

HTTP 무상태성과 연결

HTTP의 역할 분리와 요청 독립성을 게시판 API에 적용하고 TCP 연결 재사용과 서버 측 업무 상태를 무상태와 혼동하지 않습니다.

HTTP가 무상태라는 말을 “서버는 아무 상태도 저장하면 안 된다”로 외우면 게시글을 DB에 저장하는 API 자체가 모순처럼 보입니다.

무상태가 요구하는 것은 서버가 이전 요청의 숨은 대화 상태에 의존하지 않고 현재 요청만으로 처리 조건을 알 수 있어야 한다는 점입니다.

업무 데이터와 캐시는 얼마든지 서버에 남을 수 있습니다.


클라이언트·서버의 독립적 진화

클라이언트는 요청을 만들고 표현을 해석하며, 서버는 리소스와 업무 규칙을 소유합니다.

브라우저, 모바일 앱, 배치 클라이언트가 같은 게시판 HTTP 계약을 사용할 수 있습니다.

서버가 HTML을 반환하는 경우에도 화면 탐색과 입력 이벤트는 클라이언트 역할이고 리소스 처리와 권한 판정은 서버 역할입니다.

분리는 배포가 무조건 독립이라는 뜻이 아닙니다.

URI, 메서드, 미디어 타입, 스키마, 상태를 호환되게 유지해야 합니다.

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

다음 컨트롤러는 요청마다 회원 식별 정보를 받고 해당 회원의 게시글만 조회합니다.

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

import java.util.List;
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 class StatelessPostController {
    private final PostQuery query;

    public StatelessPostController(PostQuery query) {
        this.query = query;
    }

    @GetMapping
    ResponseEntity<List<PostSummary>> list(
            @RequestHeader(
                            name = "X-Member-Id",
                            required = false)
                    Long memberId) {
        if (memberId == null) {
            return ResponseEntity.status(401).build();
        }
        return ResponseEntity.ok(query.findAll(memberId));
    }
}

공개 서비스가 클라이언트가 보낸 X-Member-Id를 그대로 신뢰해서는 안 됩니다.

예제는 “현재 요청이 처리에 필요한 식별 정보를 운반한다”는 구조를 드러냅니다.

실제 운영에서는 검증된 전달자 토큰이나 신뢰 가능한 게이트웨이가 만든 인증 주체에서 회원 ID를 얻습니다.


요청의 자기 완결성

첫 요청이 회원 7이라고 알려 줬으니 다음 요청에서는 생략해도 된다는 서버 필드를 만들면 인스턴스와 연결에 순서 의존성이 생깁니다.

피해야 할 연결 대화 상태
@RestController
class StatefulMemberController {
    private Long currentMemberId;

    @PostMapping("/login/{memberId}")
    void select(@PathVariable long memberId) {
        currentMemberId = memberId;
    }

    @GetMapping("/api/posts")
    List<PostSummary> list() {
        return query.findAll(currentMemberId);
    }
}

Spring 컨트롤러는 기본 싱글톤입니다.

Alice가 필드에 7을 쓴 뒤 Bob이 9를 쓰면 Alice의 다음 조회도 9로 처리될 수 있습니다.

같은 사용자의 요청이 같은 애플리케이션 인스턴스나 같은 TCP 연결에 도착한다는 보장도 없습니다.

무상태 컨트롤러는 각 요청에서 검증된 동일성과 필터 조건을 얻고 불변 명령으로 애플리케이션 서비스에 넘깁니다.

어떤 인스턴스가 받아도 공유 데이터베이스를 기준으로 같은 권한과 결과를 계산할 수 있습니다.


MVC 요청 독립성 검증

RestTestClient로 두 회원의 헤더를 번갈아 보내도 이전 값이 남지 않는지 확인합니다.

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

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.client.RestTestClient;

class StatelessPostControllerTest {
    @Test
    void 매_요청의_member_id로_독립적으로_조회한다() {
        PostQuery query = memberId -> List.of(
                new PostSummary(
                        memberId * 100, memberId, "HTTP"));
        var client = RestTestClient
                .bindToController(
                        new StatelessPostController(query))
                .build();

        client.get()
                .uri("/api/posts")
                .header("X-Member-Id", "7")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$[0].memberId").isEqualTo(7)
                .jsonPath("$[0].id").isEqualTo(700);

        client.get()
                .uri("/api/posts")
                .header("X-Member-Id", "9")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$[0].memberId").isEqualTo(9)
                .jsonPath("$[0].id").isEqualTo(900);
    }

    @Test
    void 현재_요청에_identity가_없으면_거부한다() {
        PostQuery query = memberId -> List.of();
        var client = RestTestClient
                .bindToController(
                        new StatelessPostController(query))
                .build();

        client.get()
                .uri("/api/posts")
                .exchange()
                .expectStatus().isUnauthorized()
                .expectBody().isEmpty();
    }
}
실행 결과
StatelessPostControllerTest
  > 매_요청의_member_id로_독립적으로_조회한다() PASSED
StatelessPostControllerTest
  > 현재_요청에_identity가_없으면_거부한다() PASSED
BUILD SUCCESSFUL

동일성이 없을 때 401을 반환하고, 동일성은 있지만 리소스 권한이 없을 때 403 또는 존재 은닉을 위한 404를 선택할 수 있습니다.

중요한 것은 이전 요청에서 남은 값을 보완 입력으로 사용하지 않는 것입니다.


무상태성의 의미

게시판 서버에는 다음 상태가 존재합니다.

  • 데이터베이스의 게시글과 회원 정보
  • 동시 중복을 막는 트랜잭션과 고유 제약 조건
  • 자주 읽는 집계 캐시
  • 토큰의 폐기 목록이나 키 교체 정보
  • 요청률 제한 카운터와 관측 메트릭

이 상태는 리소스 또는 인프라 상태이며 특정 TCP 연결의 이전 요청 순서를 알아야만 해석되는 대화 상태와 다릅니다.

요청에 리소스 ID, 검증된 동일성, 버전 조건을 담으면 여러 인스턴스가 공유 저장소를 보고 처리할 수 있습니다.

HTTP 쿠키를 사용하는 로그인도 무조건 상태 저장 프로토콜이 되는 것은 아닙니다.

브라우저는 매 요청에 쿠키를 보내고 서버는 불투명 세션 ID로 공유 게시글 저장소를 조회할 수 있습니다.

확장할 때 세션 고정 없이도 모든 인스턴스가 같은 저장소를 볼 수 있어야 합니다.

쿠키는 뒤에서 보안 속성과 함께 다룹니다.


비연결성과 연결 재사용

HTTP 요청-응답 교환이 서로 독립적이라는 설명을 “요청마다 TCP를 끊는다”로 해석하면 안 됩니다.

  • HTTP/1.0 초기 사용은 요청마다 연결을 닫는 경우가 많았습니다.
  • HTTP/1.1은 지속 연결이 기본이며 여러 요청이 순차적으로 재사용할 수 있습니다.
  • HTTP/2는 하나의 연결 위에 여러 스트림을 동시에 다중화합니다.
  • HTTP/3는 QUIC 연결 위에서 독립 스트림을 사용합니다.

애플리케이션의 무상태 제약은 물리 연결 재사용 여부와 독립입니다.

서버가 “이 소켓에서 첫 요청이 로그인이었으니 이후는 인증됨”이라고 판단하면 로드 밸런서와 연결 풀, HTTP/2 다중화에서 깨집니다.

인증 정보는 각 요청의 헤더나 쿠키로 전달합니다.

연결 풀은 성능 자원입니다.

DNS·TCP·TLS 비용을 줄이지만 오래된 연결, 유휴 타임아웃, 최대 동시 연결 수를 관리해야 합니다.

풀 대기 타임아웃과 응답 타임아웃을 같은 값으로 보지 않습니다.


숨은 상태와 장애 복구

설계수평 확장 때 결과개선
컨트롤러 필드에 사용자 저장인스턴스가 바뀌면 유실·혼합요청 동일성
로컬 메모리에 업무 데이터만 저장인스턴스별 결과 불일치공유 DB 또는 명시적 분할
로컬 캐시만 진실로 사용오래된 데이터·콜드 스타트 문제원본 저장소와 무효화 정책
고정 세션에만 의존인스턴스 장애 시 세션 유실공유 세션 저장소 또는 재인증
요청에 모든 검증 조건 포함어느 인스턴스도 처리 가능민감 정보 최소화·서명 검증

무상태는 수평 확장을 쉽게 하지만 공짜로 만들어 주지는 않습니다.

데이터베이스 경합, 분산 캐시 일관성, 멱등성 키 저장은 여전히 설계해야 합니다.

다만 요청 순서와 특정 인스턴스 기억에 의존하지 않으므로 재시도와 장애 전환의 기준을 세울 수 있습니다.


연습 문제

현재 회원 ID를 컨트롤러 필드에 저장하는 실패 구현을 두 가상 스레드로 호출해 값이 섞이는 순서를 고정하세요.

그다음 회원 ID를 매 요청 헤더에서 받아 쿼리 명령에 넣고 회원 7과 9 요청을 번갈아 보내도 결과가 독립적인 MVC 테스트로 바꿉니다.

해설 보기

실패 재현은 이전 싱글톤 필드 예제처럼 래치를 사용합니다.

Alice가 7을 저장한 뒤 대기하고 Bob이 9를 저장한 다음 Alice를 재개하면 둘 다 9를 읽습니다.

수정 후에는 필드를 제거합니다.

@GetMapping
List<PostSummary> list(
        @RequestHeader("X-Member-Id") long memberId) {
    return query.findAll(memberId);
}

MVC 테스트에서 첫 요청의 응답이 7, 다음 응답이 9, 다시 보낸 첫 요청이 7인지 확인합니다.

운영 인증에서는 원시 헤더를 인증 주체로 바꾸되 “현재 요청이 처리에 필요한 동일성을 제공한다”는 구조는 같습니다.

다음 문서에서는 독립 요청이 실제 전송 형식에서 어떤 시작줄, 헤더, 빈 줄, 본문으로 경계 지어지는지 원시 HTTP 메시지와 MVC 응답을 함께 읽습니다.