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

안동민 개발노트

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

쿠키와 상태 관리

게시판 로그인 쿠키의 Set-Cookie와 Cookie 왕복을 구현하고 HttpOnly, Secure, SameSite, Path, 만료, 서버 게시글 저장소의 책임을 검증합니다.

HTTP 요청은 독립적이지만 브라우저는 서버가 준 쿠키를 조건에 맞는 다음 요청에 자동 첨부할 수 있습니다. 쿠키는 작은 이름-값과 전송 범위·수명 메타데이터입니다. 게시글 전체를 쿠키에 넣기보다 추측하기 어려운 불투명 세션 ID만 두고 실제 로그인 상태는 서버 측 저장에 보관합니다.


로그인 성공 응답은 일반 응답 헤더와 문법이 다른 Set-Cookie를 보냅니다.

로그인 응답
HTTP/1.1 204 No Content
Set-Cookie: __Host-board-session=J4u8Zx2Qp9Lm7Nc4;
 Path=/; Max-Age=1800; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store

브라우저는 다음 HTTPS 요청에서 조건이 맞으면 다음처럼 쿠키를 보냅니다.

인증된 조회 요청
GET /api/posts HTTP/1.1
Host: board.example
Cookie: __Host-board-session=J4u8Zx2Qp9Lm7Nc4
Accept: application/json

Set-Cookie는 여러 쿠키를 설정할 때 하나의 쉼표 목록으로 단순 결합하지 않고 헤더 필드를 각각 보냅니다. Expires 날짜에 쉼표가 들어가고 쿠키 문법이 일반 목록 헤더와 다르기 때문입니다. 프레임워크의 타입이 지정된 ResponseCookie와 응답 API를 사용합니다.

불투명 식별자와 서버 상태

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

import java.time.Duration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class LoginController {
    static final String COOKIE_NAME =
            "__Host-board-session";
    private final LoginService loginService;
    private final LoginSessionStore sessions;

    LoginController(
            LoginService loginService,
            LoginSessionStore sessions) {
        this.loginService = loginService;
        this.sessions = sessions;
    }

    @PostMapping("/api/login")
    ResponseEntity<Void> login(
            @RequestBody LoginRequest request) {
        var member = loginService.authenticate(request);
        var token = sessions.create(
                member.id(), Duration.ofMinutes(30));
        var cookie = ResponseCookie
                .from(COOKIE_NAME, token)
                .httpOnly(true)
                .secure(true)
                .sameSite("Lax")
                .path("/")
                .maxAge(Duration.ofMinutes(30))
                .build();

        return ResponseEntity.noContent()
                .header(
                        HttpHeaders.SET_COOKIE,
                        cookie.toString())
                .header(
                        HttpHeaders.CACHE_CONTROL,
                        "no-store")
                .build();
    }

    @GetMapping("/api/me")
    ResponseEntity<MemberResponse> me(
            @CookieValue(
                            name = COOKIE_NAME,
                            required = false)
                    String token) {
        return sessions.findMember(token)
                .map(MemberResponse::from)
                .map(ResponseEntity::ok)
                .orElseGet(() ->
                        ResponseEntity.status(401).build());
    }
}

쿠키 값에 회원 ID 7만 넣으면 다른 ID로 바꿔 위조할 수 있습니다. 엔트로피가 충분한 임의 토큰을 만들고 해시 형태로 저장소에 저장하면 데이터베이스 유출 시 원시 토큰 재사용 위험을 줄일 수 있습니다. 로그인 성공·로그아웃·권한 변경·비밀번호 변경 때 세션 교체와 폐기 정책을 둡니다.

쿠키 보안 속성

속성효과빠뜨렸을 때
SecureHTTPS 요청에만 전송평문 연결로 인증 정보 노출
HttpOnly브라우저 스크립트에서 읽기 제한XSS가 쿠키 값을 직접 탈취
SameSite교차 사이트 전송 범위 제한CSRF 표면 확대
Path=/전송 경로 범위예상 밖 경로 포함·제외
Domain 없음발급 호스트로 제한하위 도메인과 불필요 공유
Max-Age클라이언트 저장 수명지나치게 긴 로그인 유지

__Host- 접두사는 Secure, Path=/, Domain 없음 조건을 요구해 쿠키 스코프를 강하게 제한하는 브라우저 규칙입니다. 운영 환경 HTTPS에는 유용하지만 로컬 HTTP 테스트에서 실제 브라우저가 Secure 쿠키를 저장하지 않을 수 있습니다. 테스트 프로필에서 보안을 조용히 끄기보다 HTTPS 종단 간 테스트와 헤더 계약 테스트를 분리합니다.

HttpOnly는 XSS 자체를 막지 않습니다. 스크립트가 쿠키를 읽지 못해도 사용자의 브라우저에서 인증된 요청을 보낼 수 있으므로 출력 인코딩, 콘텐츠 보안 정책, 입력 처리도 필요합니다.

SameSite=Lax는 일반 최상위 탐색과 많은 동일 사이트 요청에 실용적인 기본입니다. 교차 사이트 삽입이나 연합 인증 때문에 SameSite=None이 필요하면 Secure도 함께 요구되고 CSRF 토큰과 출처 검증을 별도로 설계합니다.

쿠키 왕복 검증

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;

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

class LoginCookieContractTest {
    @Test
    void 로그인_cookie의_보안_속성을_반환한다() {
        var sessions = new InMemoryLoginSessionStore(
                "opaque-token-001");
        var client = RestTestClient
                .bindToController(new LoginController(
                        request -> member(7L), sessions))
                .build();

        var result = client.post()
                .uri("/api/login")
                .contentType(APPLICATION_JSON)
                .body("""
                        {
                          "email": "member@example.com",
                          "password": "not-a-real-secret"
                        }
                        """)
                .exchange()
                .expectStatus().isNoContent()
                .expectHeader()
                .value("Set-Cookie", value -> {
                    assertThat(value)
                            .contains("__Host-board-session="
                                    + "opaque-token-001")
                            .contains("Path=/")
                            .contains("Max-Age=1800")
                            .contains("Secure")
                            .contains("HttpOnly")
                            .contains("SameSite=Lax")
                            .doesNotContain("Domain=");
                })
                .expectHeader()
                .valueEquals("Cache-Control", "no-store");
    }

    @Test
    void 유효_cookie만_현재_사용자를_조회한다() {
        var sessions = new InMemoryLoginSessionStore(
                "opaque-token-001", member(7L));
        var client = RestTestClient
                .bindToController(new LoginController(
                        request -> member(7L), sessions))
                .build();

        client.get()
                .uri("/api/me")
                .cookie("__Host-board-session",
                        "opaque-token-001")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.id").isEqualTo(7);

        client.get()
                .uri("/api/me")
                .cookie("__Host-board-session", "forged")
                .exchange()
                .expectStatus().isUnauthorized();
    }
}
실행 결과
LoginCookieContractTest
  > 로그인_cookie의_보안_속성을_반환한다() PASSED
LoginCookieContractTest
  > 유효_cookie만_현재_사용자를_조회한다() PASSED
BUILD SUCCESSFUL

테스트 로그와 실패 출력에 비밀번호와 원시 세션 토큰을 남기지 않습니다. 예제 토큰은 고정된 가짜 값이며 운영 토큰 형식을 복사하지 않습니다.

로그아웃과 상태 폐기

쿠키를 Max-Age=0으로 지워도 탈취된 토큰이 서버 저장에서 유효하면 공격자가 직접 보낼 수 있습니다. 먼저 현재 토큰을 폐기하고 같은 이름·경로의 만료 쿠키를 반환합니다.

src/main/java/board/web/LogoutController.java
@PostMapping("/api/logout")
ResponseEntity<Void> logout(
        @CookieValue(
                name = LoginController.COOKIE_NAME,
                required = false)
        String token) {
    sessions.revoke(token);
    var expired = ResponseCookie
            .from(LoginController.COOKIE_NAME, "")
            .httpOnly(true)
            .secure(true)
            .sameSite("Lax")
            .path("/")
            .maxAge(Duration.ZERO)
            .build();
    return ResponseEntity.noContent()
            .header(HttpHeaders.SET_COOKIE,
                    expired.toString())
            .build();
}

로그아웃 POST도 CSRF 대상입니다. GET 링크로 상태를 바꾸지 않고 CSRF 방어와 동일 출처 정책을 적용합니다. 모든 기기에서 로그아웃하는 기능은 회원의 세션을 전부 폐기하는 별도 사용 사례입니다.

쿠키·토큰 저장 선택

서버 측 세션은 즉시 폐기, 기기 목록, 활동 기반 만료를 구현하기 쉽지만 공유 저장이 필요합니다. 자체 완결형 서명 토큰은 매 요청 데이터베이스 조회를 줄일 수 있지만 만료 전 즉시 폐기와 권한 변경 반영이 어렵고 페이로드가 클라이언트에 보입니다. 서명은 암호화와 같지 않습니다.

브라우저에서는 토큰을 JavaScript가 읽을 수 있는 로컬 저장소에 두면 XSS 탈취 표면이 커집니다. HttpOnly 쿠키는 CSRF 방어가 필요합니다. 어느 한 저장 방식이 모든 공격을 없애지 않으므로 클라이언트 종류, 폐기 요구, 교차 출처 구조를 기준으로 고릅니다.

쿠키는 요청마다 전송되어 크기가 성능에 영향을 줍니다. 브라우저별 개수·크기 제한도 있으므로 게시글이나 큰 권한 목록을 넣지 않습니다.

연습 문제

로그인 토큰을 교체하는 갱신 엔드포인트를 만드세요. 기존 토큰으로 요청하면 서버 저장에서 즉시 폐기하고 새 쿠키를 발급해야 합니다. 두 요청이 동시에 같은 이전 토큰을 갱신할 때 새 세션 하나만 유효해야 하며, 로그아웃 후 이전·새 토큰 모두의 기대 상태를 검증합니다.

해설 보기

저장의 rotate(oldToken)을 하나의 트랜잭션이나 원자적 비교 후 교체 연산으로 구현합니다. 이전 토큰이 활성일 때만 폐기 상태로 바꾸고 새 임의 토큰을 삽입합니다. 두 번째 동시 요청은 이미 폐기된 상태를 보고 401 또는 명시적 충돌을 반환합니다.

var rotated = sessions.rotate(requiredToken);
var cookie = sessionCookie(rotated.rawToken());

return ResponseEntity.noContent()
        .header(HttpHeaders.SET_COOKIE,
                cookie.toString())
        .header(HttpHeaders.CACHE_CONTROL, "no-store")
        .build();

테스트는 이전 토큰으로 /api/me가 401, 새 토큰이 200인지 확인합니다. 원시 토큰을 데이터베이스와 로그에 그대로 저장하지 않고 해시 조회와 일회성 응답 전달 경계를 유지합니다.

다음 문서에서는 개인 응답은 저장하지 않으면서 공개 집계는 재사용하고, ETag와 조건부 요청으로 본문 전송을 줄이며 변경 뒤 캐시를 안전하게 무효화합니다.