왜 서버는 이전 요청을 기억하지 않는가
Stateless 설계 — 상태는 앱이 아닌 데이터 소스가 들고 있다
난이도: ★★☆☆☆
핵심 요약
- 각 발송 실행이 이전 실행 결과를 기억하지 않고, 매번 BQ에서 현재 상태를 새로 읽어 대상자를 재도출한다.
- 상태를 애플리케이션(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)]
HttpOnlyCookie에 JWT를 담아도 동일한 Stateless 구조- 차이는 XSS 노출 여부 (Cookie는 JS 접근 불가, localStorage는 노출)
HTTP와의 1:1 대응
| HTTP | CRM 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)에서 끝낸다는 같은 지향
참고
- HTTP Stateless Protocol — MDN — HTTP stateless의 공식 정의와 세션과의 차이