(읽고오면 좀더 이해하기 쉽습니다. 좋은 기술로그가 있어 공유드립니다.)https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Stateful-Stateless-%EC%A0%95%EB%A6%AC
웹 애플리케이션에서 Stateless(무상태) 란,
서버가 이전 요청의 상태나 컨텍스트를 저장하지 않는 설계 방식을 말한다.
즉, 각 요청(Request) 은 서로 완전히 독립적이며,
서버는 매번 “처음 보는 요청”처럼 처리한다.
💡 REST, HTTP, MSA 구조 모두 이 ‘무상태성’을 기반으로 한다.
| 구분 | Stateful | Stateless |
|---|---|---|
| 요청 간 맥락 | 유지됨 (세션/쿠키 등) | 없음 (독립 요청) |
| 서버 부하 | 높음 (메모리 관리 필요) | 낮음 (단순 처리) |
| 확장성 | 낮음 (세션 공유 필요) | 높음 (로드밸런싱 용이) |
| 장애 복구 | 어려움 (세션 일관성 문제) | 쉬움 (무상태 서버 교체 가능) |
요청 하나로 서버가 필요한 모든 정보를 알아야 한다.
예
GET /api/user/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
💬 즉, 서버는 오직 “입력 → 처리 → 응답”만 담당하며,
상태관리는 외부로 위임한다.
sequenceDiagram
participant Client as 클라이언트
participant LB as 로드 밸런서
participant ServerA as 서버 A
participant ServerB as 서버 B
Client->>LB: 로그인 요청 (/login)
LB->>ServerA: 요청 전달
ServerA->>ServerA: 사용자 인증 후 세션 생성 (sessionId=abc123)
ServerA-->>Client: 세션 쿠키 발급 (Set-Cookie: JSESSIONID=abc123)
Note over Client,ServerA: 이후 요청에서 쿠키(JSESSIONID=abc123)를 포함
Client->>LB: 프로필 요청 (/profile, 쿠키 포함)
LB->>ServerA: 세션이 존재하는 서버로 라우팅됨
ServerA->>ServerA: 세션 정보 조회
ServerA-->>Client: 사용자 프로필 응답
Note over LB,ServerB: 만약 LB가 ServerB로 보낸다면?
Client->>LB: 프로필 요청 (/profile, 쿠키 포함)
LB->>ServerB: 요청 전달
ServerB->>ServerB: ❌ 세션 정보 없음
ServerB-->>Client: 401 Unauthorized (로그인 만료)
요약
서버가 세션을 메모리에 저장하므로, 요청이 항상 같은 서버로 가야 정상 처리됨.
서버 A가 죽거나 LB가 Server B로 라우팅하면 세션 일관성 깨짐.
확장성 낮고, Sticky Session 필요.
sequenceDiagram
participant Client as 클라이언트
participant LB as 로드 밸런서
participant ServerA as 서버 A
participant ServerB as 서버 B
Client->>LB: 로그인 요청 (/login)
LB->>ServerA: 요청 전달
ServerA->>ServerA: 사용자 인증 후 JWT 생성 (서명 포함)
ServerA-->>Client: JWT 반환 (Authorization: Bearer eyJhbGciOiJIUzI1Ni...)
Note over Client: 이후 요청마다 JWT를 헤더에 포함시킴
Client->>LB: 프로필 요청 (/profile, Authorization 헤더 포함)
LB->>ServerB: 요청 전달 (무작위 라우팅 가능)
ServerB->>ServerB: JWT 서명 검증 (서버 상태 필요 없음)
ServerB-->>Client: 사용자 프로필 응답 (정상 처리)
Note over Client,ServerB: 모든 서버가 같은 JWT 서명 키를 공유하므로,<br>서버 간 상태 공유 불필요
요약
서버는 세션을 전혀 저장하지 않음.
클라이언트가 JWT로 상태를 직접 유지하므로,
요청이 어느 서버로 가든 인증 가능하고 수평 확장(Scale-out)에 유리하다.
✅ 요청 간 맥락이 없어도, 토큰 기반으로 어디서나 처리 가능
✅ 서버 간 세션 공유나 Sticky Session이 필요 없음
| 항목 | Stateful (세션 기반) | Stateless (JWT 기반) |
|---|---|---|
| 상태 저장 위치 | 서버 메모리 | 클라이언트(JWT) |
| 요청 독립성 | 낮음 | 높음 |
| 확장성 | 세션 공유 필요 → 낮음 | 서버 간 독립 → 높음 |
| 장애 복구 | 세션 유실 위험 | 즉시 처리 가능 |
| REST 철학 부합 | ❌ | ✅ |
| 적용 예시 | 전통적인 JSP/Servlet, Spring Session | REST API, MSA, Serverless, Gateway |
🌐 핵심 정리
Stateless 구조는 요청 간 독립성을 유지하고,
서버를 ‘기억하지 않아도 되는 노드’로 만들어
확장성과 복원력을 극대화한다.
| 항목 | 설명 |
|---|---|
| 🔁 확장성(Scalability) | 서버 간 세션 공유 필요가 없어 수평 확장(Scale-out)에 유리 |
| 💥 복원력(Fault Tolerance) | 서버 다운 시에도 다른 인스턴스가 즉시 요청 처리 가능 |
| 🚀 성능(Performance) | 세션 조회나 복제 비용이 없음 |
| 🧩 단순성(Simplicity) | 요청-응답 구조 단순, 유지보수 용이 |
| 🧱 표준성(RESTful) | REST API의 근간이자 HTTP의 본래 철학에 부합 |
| 상황 | 권장 설계 |
|---|---|
| 인증 유지 | JWT + Refresh Token |
| 로그인 상태 표시 | 클라이언트에서 상태 관리 (e.g. localStorage) |
| 장바구니, 임시 데이터 | Redis, DB 등 외부 저장소 활용 |
| 서버 확장 | 무상태 서버로 구성, Sticky Session 피하기 |
| MSA 간 인증 | Access Token 공유 or API Gateway 단에서 검증 |
| 오해 | 올바른 이해 |
|---|---|
| “무상태면 로그인 못한다” | ❌ → 상태를 서버가 아닌 클라이언트/외부 저장소에 둔다 |
| “모든 상태는 제거해야 한다” | ❌ → 단, 서버가 상태를 ‘기억하지 않는다’가 핵심 |
| “Stateful이 나쁘다” | ❌ → 일부 실시간 서비스(게임, 트레이딩)는 상태 필요 |
| 주제 | 설명 |
|---|---|
| 🧩 Session Clustering | 상태를 공유하기 위해 여러 서버가 세션 복제하는 구조 (비추천) |
| 🪶 Sticky Session | 같은 클라이언트를 항상 같은 서버로 라우팅하는 방식 |
| 🧱 Token-based Auth (JWT) | 클라이언트가 상태 정보를 포함해 요청 (무상태 핵심 구현체) |
| 🧭 Idempotency (멱등성) | 무상태 API의 중요한 속성, 같은 요청은 항상 같은 결과 |
| 🧰 MSA 설계 원칙 중 Stateless | 마이크로서비스 간 독립 배포·확장을 위한 핵심 원칙 |
| 질문 | 핵심 답변 |
|---|---|
| “왜 Stateless가 중요한가요?” | 서버 확장성과 복원력을 높이고, RESTful 원칙을 유지하기 위해 |
| “로그인 상태를 Stateless하게 어떻게 유지하나요?” | JWT를 통해 클라이언트가 상태를 직접 전달 |
| “Stateful과 Stateless의 중간 설계가 가능한가요?” | 가능함. 외부 세션 스토리지(Redis)를 통한 하이브리드 구조 |
| “Sticky Session은 왜 피해야 하나요?” | 서버 간 부하 불균형 및 확장성 저하 유발 |
핵심 답변
Stateless는 서버 확장성과 복원력을 높이고, RESTful 원칙을 유지하기 위해 필요하다.
설명
핵심 답변
서버가 세션을 저장하지 않고, JWT(Json Web Token) 을 이용해 클라이언트가 인증 상태를 직접 유지한다.
설명
Authorization: Bearer <JWT> 형태로 토큰을 함께 보낸다. 💡 이 구조 덕분에 서버는 로그인 상태를 기억할 필요가 없으며,
수평 확장 시에도 인증 정보가 서버 간에 공유될 필요가 없다.
핵심 답변
가능하다. 대표적인 방법은 외부 세션 스토리지(Redis 등) 를 사용하는 하이브리드 구조다.
설명
[Client] → [LoadBalancer]
├─▶ [Server A] → Redis에서 세션 조회
└─▶ [Server B] → 동일 세션 Redis에서 공유
⚙️ 실무에서는 “Stateless 서버 + Stateful 외부 저장소”로 혼합 설계하는 경우가 많다.
핵심 답변
Sticky Session은 확장성 저하와 장애 복원성 저하를 유발하기 때문에 지양해야 한다.
설명
💬 Sticky Session은 “임시방편”으로만 쓰며,
장기적으로는 세션 스토리지나 JWT로 전환하는 것이 이상적이다.
핵심 답변
있다. 특히 토큰 탈취, 재사용 공격 등에 대비해야 한다.
설명
🔒 Stateless 구조는 보안 로직을 토큰 단에서 강화해야 한다.
핵심 답변
아니다. 무상태는 대부분의 웹 서비스에 유리하지만, 실시간 연결이나 상태 기반 로직에는 Stateful이 필요하다.
설명
🌐 Stateless = 요청 간 독립성 보장 + 서버의 단순화 + 확장성 극대화
같은 요청을 여러 번 보내도 최종 결과가 동일하도록 만드는 속성.
네트워크 재시도·중복 전송·타임아웃 재요청 상황에서도 중복 부작용(이중 결제, 이중 발송 등)을 막는다.
| 메서드 | 표준상 멱등성 | 비고 |
|---|---|---|
| GET/HEAD | ✅ | 조회만 수행해야 함 |
| PUT | ✅ | “리소스를 이 값으로 만들어라(Upsert)” 의미일 때 |
| DELETE | ✅ | 여러 번 호출해도 최종 상태는 “없음” |
| OPTIONS | ✅ | 프리플라이트 조회 |
| POST | ❌(기본) | Idempotency-Key로 멱등화 가능 |
| PATCH | ❌(기본) | 조건부 갱신(ETag/If-Match)로 멱등화 가능 |
리소스 키 기반(자연/비즈니스 키) Upsert
PUT /users/42 : “항상 42번 사용자를 이 본문으로 만든다”POST + Idempotency-Key 헤더
Idempotency-Key: 7f2c-...조건부 갱신(Optimistic Locking)
If-Match: <ETag>를 이용한 버전 충돌 방지멱등 컨슈머(메시지 처리)
1) 스키마(예시)
CREATE TABLE idempotency (
endpoint VARCHAR(120) NOT NULL,
idem_key VARCHAR(64) NOT NULL,
request_hash CHAR(64) NOT NULL,
response_status SMALLINT NOT NULL,
response_body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (endpoint, idem_key)
);
2) 처리 알고리즘
(endpoint, idem_key) 조회 request_hash 같으면 캐시된 응답 반환, 다르면 409 Conflict3) 동시성/경쟁 해결
iss/aud/exp/nbf 체크 + JWKS 캐시kid 기반으로 JWKS 자동 갱신, 실패 시 백오프 + 로컬 캐시 유지scope, roles, tenant, sub 등으로 RBAC/ABACX-User-Id, X-Scopes)sequenceDiagram
participant Client as 클라이언트
participant IdP as 인증 서버(IdP)
participant GW as API 게이트웨이
participant Svc as 내부 서비스
Client->>IdP: 로그인 (OIDC/OAuth 2.1)
IdP-->>Client: Access Token(JWT) 발급 + (선택)Refresh Token
Client->>GW: 보호 API 호출 (Authorization: Bearer <JWT>)
GW->>GW: JWKS 캐시에서 kid로 공개키 가져와 JWT 서명 검증
GW->>GW: iss/aud/exp/nbf/Scope 등 클레임 검증 + 정책 평가(RBAC/ABAC)
alt 검증 성공
GW->>Svc: 요청 전달 (원본 JWT 또는 주입 헤더 포함)
Svc-->>GW: 비즈니스 응답
GW-->>Client: 200 OK (응답)
else 검증 실패
GW-->>Client: 401/403 에러
end
spring:
cloud:
gateway:
default-filters:
- RemoveRequestHeader=Cookie
- DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials
routes:
- id: user-api
uri: http://user-service
predicates:
- Path=/api/users/**
filters:
- name: JwtAuth
args:
jwk-set-uri: https://idp.example.com/.well-known/jwks.json
expected-issuer: https://idp.example.com
expected-audience: user-api
clock-skew: 120
inject-headers:
X-User-Id: "#{claims['sub']}"
X-Scopes: "#{claims['scope']}"
iss/aud 테넌트별 매핑 access_by_lua*에서 Authorization 파싱 → JWKS 캐시로 서명 검증 X-User-* 헤더 주입 후 프록시 패스좀더 상세히 들어간다면 ?
JWT 기반 인증을 NGINX 레이어에서 직접 수행해,
내부 서비스에는 검증된 요청만 전달하고,
서버 자체는 완전 무상태(Stateless) 로 유지한다.
sequenceDiagram
participant Client as 클라이언트
participant NGINX as NGINX(OpenResty + Lua)
participant JWKS as JWKS 서버 (Auth Provider)
participant Service as 내부 서비스
Client->>NGINX: Authorization: Bearer <JWT>
NGINX->>NGINX: access_by_lua*에서 JWT 파싱
NGINX->>JWKS: kid 기반 JWKS 키 요청 (캐시 확인)
JWKS-->>NGINX: JWK 공개키 응답
NGINX->>NGINX: 서명 검증 + iss/aud/exp/nbf 검증
alt 성공
NGINX->>NGINX: X-User-Id, X-Scopes 등 헤더 주입
NGINX->>Service: 프록시 패스 (내부 API 전달)
Service-->>NGINX: 응답
NGINX-->>Client: 200 OK
else 실패
NGINX-->>Client: 401 Unauthorized
end
local jwt_token = ngx.var.http_authorization
if not jwt_token or not jwt_token:find("Bearer") then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
local token = jwt_token:gsub("Bearer ", "")
local cjson = require "cjson"
local jwt = require "resty.jwt"
local jwt_obj = jwt:load_jwt(token)
local kid = jwt_obj and jwt_obj.header and jwt_obj.header.kid
if not kid then
ngx.log(ngx.ERR, "JWT kid not found")
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
local jwks_cache = ngx.shared.jwks_cache
local jwk_key = jwks_cache:get(kid)
if not jwk_key then
local http = require "resty.http"
local httpc = http.new()
local res, err = httpc:request_uri("https://idp.example.com/.well-known/jwks.json")
if not res or res.status ~= 200 then
ngx.log(ngx.ERR, "JWKS fetch failed: ", err)
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
local jwks = cjson.decode(res.body)
for _, key in ipairs(jwks.keys) do
jwks_cache:set(key.kid, cjson.encode(key), 3600)
end
jwk_key = jwks_cache:get(kid)
if not jwk_key then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
end
local key_obj = cjson.decode(jwk_key)
local jwt = require "resty.jwt"
local validators = require "resty.jwt-validators"
local jwt_obj = jwt:verify_jwk_obj(key_obj, token, {
lifetime_grace_period = 120, -- exp 오차 허용
iss = "https://idp.example.com",
aud = "my-api"
})
if not jwt_obj.verified then
ngx.log(ngx.ERR, "JWT invalid: ", jwt_obj.reason)
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
ngx.req.set_header("X-User-Id", jwt_obj.payload.sub)
ngx.req.set_header("X-Scopes", table.concat(jwt_obj.payload.scope or {}, " "))
ngx.req.set_header("X-Tenant", jwt_obj.payload.tenant or "default")
| 항목 | 설명 |
|---|---|
| JWKS 캐시 | ngx.shared.DICT에 kid별 JWK JSON 저장 (TTL: 1h~6h) |
| 백오프 로직 | JWKS 서버 장애 시 기존 캐시 유지 (ngx.timer.at 비동기 갱신) |
| 로컬 캐시 백업 | 초기 부팅 시 디스크 캐시 복원 (lua-resty-lock + 파일캐시) |
| 서명 알고리즘 제한 | RS256만 허용 (HS256 금지) |
| exp/nbf/iat 오차 허용 | ±120초 leeway |
| 항목 | 설명 |
|---|---|
| 공개키 검증 | JWKS는 TLS 위에서만 요청, HTTP 비허용 |
| JWT 크기 제한 | client_max_body_size, large_client_header_buffers 설정 |
| PII 최소화 | 민감정보(email, name)는 X-User-* 헤더로 전달하지 않음 |
| CORS 정책 통합 | 게이트웨이에서 Origin/Headers/Methods 허용 제어 |
| Rate Limiting / Circuit Breaker | Lua 모듈(lua-resty-limit-traffic, lua-resty-circuit-breaker)로 처리 |
💡 정리
- OpenResty는 “NGINX + Lua” 조합으로 무상태 인증 로직을 네트워크 계층에서 수행할 수 있는 강력한 도구다.
- 서버 애플리케이션이 인증을 직접 처리하지 않아도, 게이트웨이 단에서 JWT 검증, 클레임 주입, 접근 정책 결정을 전부 수행할 수 있다.
- 이로써 전체 아키텍처가 Stateless + Secure + Scalable 해진다.