왜 서버는 이전 요청을 기억하지 않는가

Stateless 설계 — 상태는 앱이 아닌 데이터 소스가 들고 있다

httpstatelessnetworkdesign-pattern

난이도: ★★☆☆☆


핵심 요약

  1. 각 발송 실행이 이전 실행 결과를 기억하지 않고, 매번 BQ에서 현재 상태를 새로 읽어 대상자를 재도출한다.
  2. 상태를 애플리케이션(cron)이 들고 있지 않고, 데이터 소스(BigQuery)가 들고 있다.

왜 이 선택을 했나

발송 시퀀스를 구현할 때 두 가지 선택지가 있었다:

  • Stateful: Supabase에 “고객 A는 2회차까지 받았다” 같은 발송 이력을 쌓아두고 관리
  • Stateless: BQ의 event_date, user_status 값을 매 실행마다 fresh하게 조회해서 대상자 산출

하루 수십 명 규모 + 빠른 구현이 필요 → Stateless 선택.


메커니즘

[Stateful — HTTP Session 방식]

Client ──req──► Server (세션 저장: "A는 로그인 상태")

                     └─ 상태를 서버가 기억

[Stateless — HTTP Token 방식]

Client ──req + JWT──► Server (토큰 검증만)

                           └─ 상태는 클라이언트 토큰이 들고 있음
[Stateful CRM]

Supabase ─ "고객 A: 1회차 수신 2026-06-19" 저장
cron ──► DB 조회 → "A는 이미 받았으니 제외"

          └─ 상태를 우리 시스템이 기억

[Stateless CRM — 현재 구조]

BQ: event_date=2026-06-18, status="미처리"
cron ──► BQ 조회 → "18일에 기록된 미처리 대상자" 산출

          └─ 상태는 BQ가 들고 있음. cron은 기억 없음

핵심 패턴:

애플리케이션 레이어 = 상태 없음 (stateless)
데이터 소스(BQ/토큰) = 상태 보유

HTTP 핵심 특성 — Stateless & Connectionless

두 개념은 독립적이다. 혼동하면 안 된다.

Stateless (무상태) — 애플리케이션 레이어

서버는 이전 요청의 컨텍스트를 기억하지 않는다. 요청 A를 처리한 뒤 요청 B가 와도, 서버는 B를 보낸 사람이 A를 보낸 사람인지 모른다.

Connectionless (비연결) — 전송 레이어

응답 후 TCP 연결을 닫는다.

HTTP/1.0:
Client ──TCP connect──► Server
       ──HTTP req──►
       ◄──HTTP res──
       ──TCP close──►   ← 매 요청마다 연결 생성/해제

HTTP/1.1 Keep-Alive:
Client ──TCP connect──► Server
       ──req 1──►  ◄──res 1──
       ──req 2──►  ◄──res 2──   ← TCP는 유지, 그러나 상태는 여전히 없음
       ──TCP close──►

HTTP/2 Multiplexing:
하나의 TCP에 여러 스트림 → 연결 효율↑, 여전히 stateless

연결 지속 ≠ 상태 보유. 둘은 독립적인 개념. HTTP/1.1부터 연결은 지속되지만, 서버는 여전히 이전 요청을 기억하지 않는다.


상태가 필요하면? → 레이어를 추가한다

HTTP 위에 상태 관리 레이어를 얹는 방식으로 해결한다.

[방법 1: Cookie + Server Session — Stateful]

         req 1 (로그인)
Client ─────────────────► Server → session 생성 → Redis: {sid:"abc", userId:1}
       ◄── Set-Cookie: sid=abc ──

         req 2 (장바구니)
Client ─── Cookie: sid=abc ─────► Server → Redis 조회 → "userId:1이구나"
  • 상태를 서버(Redis/DB)가 들고 있음
  • 수평 확장 시 세션 공유 문제 (서버 A에 저장된 세션을 서버 B가 모름)

[방법 2: JWT — Stateless Auth]

         req 1 (로그인)
Client ─────────────────► Server → JWT 발급: {userId:1, role:admin, exp:1h}
       ◄── token: eyJhbGci... ──

         req 2
Client ─── Authorization: Bearer eyJhbGci... ──► Server → 서명 검증만
                                                          → DB/Redis 조회 없음
  • 상태를 “토큰(클라이언트)“이 들고 있음 → Stateless
  • 단점: exp 전 강제 무효화 불가 (revocation 문제 → refresh token + 블랙리스트로 보완)

[방법 3: Cookie (Stateless)]

  • HttpOnly Cookie에 JWT를 담아도 동일한 Stateless 구조
  • 차이는 XSS 노출 여부 (Cookie는 JS 접근 불가, localStorage는 노출)

HTTP와의 1:1 대응

HTTPCRM cron
서버가 이전 요청 기억 안 함cron이 이전 실행 기억 안 함
상태는 클라이언트 토큰이 들고 있음상태는 BQ event_date, user_status가 들고 있음
매 요청이 필요한 정보를 직접 담아옴매 실행마다 BQ를 fresh하게 조회해서 상태 재도출
Stateful 하려면 서버에 세션 저장소 필요Stateful 하려면 Supabase에 발송 이력 테이블 필요

Trade-off

Stateless (BQ 기반)Stateful (DB 추적)
구현 복잡도낮음높음
중복 발송 방지부분적 (시간 윈도우 의존)완전
시퀀스 정확도~95% (엣지 케이스 존재)100%
운영 투명성낮음 (누가 뭘 받았는지 추적 어려움)높음
상태 변경 대응자동 (미처리→처리완료 전환 시 새 시퀀스 자연스럽게 시작)별도 리셋 로직 필요

Stateless의 숨겨진 장점: 고객 상태가 변경(미처리→처리완료)되면 event_date가 갱신되고, 다음 cron이 자동으로 새 시퀀스를 잡는다. Stateful이면 이 케이스를 별도로 처리해야 한다.


다음에 이 선택을 마주치면

트리거: 반복 실행 작업에서 “이전에 처리한 것을 추적해야 하나?” 고민이 생길 때

→ 이렇게 접근:

  • 볼륨이 하루 수십~수백 명 이하인가? → Stateless 고려
  • 중복 발송 시 고객 민원 리스크가 큰가? → Stateful 필요
  • 상태가 외부 데이터(DB/BQ)에 이미 있는가? → 그걸 그대로 쓰면 Stateless 가능
  • 로직이 자주 바뀌는가? → Stateless는 BQ 쿼리만 수정, Stateful은 상태 마이그레이션도 필요

커넥팅 닷

← 선행 개념 (이걸 알아야 이해된다)

  • HTTP 프로토콜 기초 (Stateless/Connectionless) — TCP 연결 지속과 HTTP 상태는 독립적인 개념. “연결이 지속된다 ≠ 상태를 기억한다”의 뿌리
  • sessionStorage 대신 URL에 상태를 담는 이유 — 같은 HTTP Stateless 원리가 프론트엔드 라우팅 레이어에 적용된 것

→ 확장 개념 (여기서 더 나아가면)

  • 광고 픽셀은 UTM이 사라져도 어떻게 전환을 잡는가 — 쿠키가 상태를 들고 있는 구체적 사례. 클릭 ID를 쿠키에 저장하고 전환 시 매체 서버로 전송하는 구조는 HTTP Stateless 위에 쿠키로 상태 레이어를 얹는 패턴의 실물

  • Idempotency (멱등성) — Stateless API 설계의 핵심. 같은 요청을 여러 번 보내도 결과가 같아야 함. cron 중복 실행 방어와 같은 원리

  • Event Sourcing — Stateless의 극단. 상태를 저장하지 않고 이벤트 로그에서 매번 재계산

  • SWR / React Query — HTTP stateless 위에서 서버 상태를 다루는 프론트 레이어

    HTTP가 stateless이기 때문에, “서버 상태를 언제 다시 가져올 것인가”를 클라이언트가 직접 판단해야 한다.

    핵심 옵션 3개:

    staleTime: 이 시간 동안 캐시를 "신선함"으로 취급 → 서버 재조회 안 함
               Cookie maxAge와 동일 원리 ("아직 유효한 상태")
    
    gcTime:    stale 이후 메모리에서 얼마나 더 보관할지 (세션 TTL과 동일)
               gcTime 지나면 queryKey 자체를 메모리에서 제거
    
    refetchOnWindowFocus: 탭 복귀 시 재조회
               connectionless 때문 — 탭 숨김 동안 "연결이 없었으므로"
               그 사이 서버 상태가 바뀌었을 수 있음
    // Stateful — useState
    const [user, setUser] = useState(null);
    useEffect(() => { fetch('/api/user').then(r => r.json()).then(setUser); }, []);
    // 한번 가져와서 로컬 상태에 박제 → 서버 변경 감지 불가
    
    // Stateless — useQuery
    const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
    // staleTime 지나면 → 다음 접근 시 서버에서 fresh하게 재조회
    // "지금 서버가 뭐라고 하는지"가 기준

    로컬 상태 ≠ 진실의 원천(Source of Truth).
    HTTP가 stateless이므로, 서버가 유일한 진실의 원천이다.

    React Query는 그 원칙을 강제하는 도구.

↔ 같은 원리가 적용되는 곳

  • JWT 인증 — 서버가 세션 저장 없이 토큰만으로 상태 검증
  • SWR stale-while-revalidate — 캐시를 들고 있되, 매번 백그라운드에서 재조회
  • React Server Components — 서버가 상태 없이 매 요청마다 fresh하게 렌더링
  • 크롤러가 CSR 페이지를 못 읽는 이유 — HTTP GET 요청이 공통 기반. stateless HTTP가 “서버가 응답을 어떻게 내려주느냐”로 이어지는 연결
  • DB에 JOIN을 맡기면 ‘일을 떠넘기는’ 걸까? — 계산을 데이터 있는 곳(BQ)에서 끝낸다는 같은 지향

참고