이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 숨은 로컬 상태는 어떤 장애와 배포 문제를 만드는가?
- 중복 owner, TTL 누락, 로컬 lock은 왜 작은 서비스에서도 위험한가?
- 상태 관리 실수를 설계 리뷰에서 어떻게 조기에 발견하는가?
개요
상태 관리 실수는 대개 코드 한 줄에서 시작한다.
static Map, in-memory cart, local lock, ThreadLocal, TTL 없는 Redis key는 처음에는 빠르고 단순해 보인다.
하지만 서버가 2대가 되는 순간 같은 사용자의 상태가 둘로 갈라진다.
장애 후에는 어떤 값이 진실인지 알 수 없고, 배포 중에는 새 코드와 옛 상태가 충돌한다.
이 문서는 상태 저장 기술을 나열하는 문서가 아니라, “어떤 상태가 어디에 숨어 있는지”를 찾는 감사 문서다.
이 문서가 바꾸는 설계 축
| 축 | 바뀌는 내용 |
|---|---|
| 성능 | 로컬 상태는 빠르지만 scale out 후 cache miss, 부하 불균형, 중복 계산을 만든다. |
| 정합성 | 중복 owner와 로컬 lock은 서로 다른 인스턴스에서 다른 결론을 낸다. |
| 장애 격리 | 숨은 상태는 인스턴스 장애 때 조용히 사라져 복구 원인을 찾기 어렵다. |
| 운영 복잡도 | TTL, owner, rebuild, migration 기준을 뒤늦게 추가해야 한다. |
자주 보이는 실수
| 실수 | 처음에는 좋아 보이는 이유 | 실제 위험 |
|---|---|---|
| static Map에 세션 저장 | 구현이 빠르고 DB가 필요 없다 | 인스턴스별 상태 분리, 장애 시 유실 |
| local cache 무제한 사용 | latency가 낮다 | stale data, memory leak, 배포 후 불일치 |
| ThreadLocal 정리 누락 | 요청 context 전달이 쉽다 | thread 재사용 시 사용자 정보 오염 |
| TTL 없는 Redis key | 값이 계속 남아 편하다 | memory 증가, 오래된 상태 사용 |
| 여러 서비스가 같은 데이터 write | 구현이 빠르다 | owner 충돌, 복구 책임 불명확 |
| local lock 사용 | 단일 서버에서는 동시성 해결 | 다중 인스턴스에서는 lock 효과 없음 |
대표 시나리오
장바구니와 주문 생성 기능을 예로 든다. 처음에는 비로그인 장바구니를 서버 메모리에 저장했다.
private static final Map<String, Cart> carts = new ConcurrentHashMap<>();단일 서버에서는 동작한다. 하지만 서버가 3대가 되면 같은 사용자가 어떤 서버로 가느냐에 따라 장바구니가 다르게 보인다. 결제 직전 서버가 죽으면 장바구니가 사라진다. 장바구니가 주문 생성의 입력이라면 이것은 단순 UX 문제가 아니라 주문 정합성 문제가 된다.
로컬 lock의 착각
다음 코드는 한 JVM 안에서는 중복 주문을 줄일 수 있다.
synchronized (("order:" + userId).intern()) {
orderService.createOrder(userId, request);
}하지만 Pod가 여러 대면 각 JVM이 자기 lock만 가진다. 같은 사용자의 요청이 다른 Pod로 가면 동시에 실행된다. 중복 방지는 DB unique constraint, idempotency key, 분산 lock, owner service 중 하나로 설계해야 한다. 분산 lock을 쓰더라도 lock 유실과 lease 만료를 고려해 최종 안전장치는 DB 불변식으로 둔다.
TTL 누락
Redis key에 TTL을 두지 않으면 상태가 계속 쌓인다.
idempotency:order:abc = SUCCESS
checkout:draft:user-10 = ...
rate-limit:user-10 = 42모든 key가 같은 보존 기간을 가져서는 안 된다.
| 상태 | TTL 기준 |
|---|---|
| idempotency key | 외부 retry 가능 기간보다 길게 둔다. |
| checkout draft | 사용자가 재개할 수 있는 기간만 유지한다. |
| rate limit counter | window 크기에 맞춰 만료한다. |
| password reset token | 보안 기준에 맞춰 짧게 둔다. |
| TTL은 메모리 절약만이 아니다. | |
| 오래된 상태가 새 요청에 잘못 적용되지 않게 하는 정합성 장치다. |
중복 owner
가장 위험한 실수는 같은 상태를 여러 곳에서 독립적으로 수정하는 것이다. 예를 들어 장바구니 수량을 cart service, order service, promotion service가 모두 수정한다고 하자. 어느 값이 최종 진실인지 알 수 없다.
| 데이터 | owner가 흐릴 때 생기는 문제 |
|---|---|
| 장바구니 | 할인 적용 전후 수량이 엇갈린다. |
| 포인트 | 적립과 차감이 중복되거나 누락된다. |
| 주문 상태 | 결제 실패인데 배송 준비로 넘어갈 수 있다. |
| 사용자 권한 | 권한 변경 반영 시점이 서비스마다 다르다. |
| owner는 코드 주석이 아니라 write API, DB 제약, 이벤트 계약으로 드러나야 한다. |
ThreadLocal과 요청 context
ThreadLocal은 인증 사용자, request id, tenant id 전달에 자주 쓰인다. 문제는 thread pool이 thread를 재사용한다는 점이다. 요청 종료 시 정리하지 않으면 다음 요청이 이전 사용자의 context를 볼 수 있다.
try {
TenantContext.set(tenantId);
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}multi-tenant 서비스에서는 이 실수가 데이터 유출로 이어진다. ThreadLocal은 편의 도구이지 영속 상태 저장소가 아니다.
Local Cache의 경계
local cache는 모든 상태 관리 실수가 아니다. 읽기 전용 설정값, 짧은 TTL의 reference data, 재생성 가능한 값에는 유용하다. 문제는 invalidation과 owner를 숨기는 경우다.
| local cache 가능 | local cache 위험 |
|---|---|
| 1분 TTL feature flag snapshot | 즉시 반영되어야 하는 권한 |
| 국가 코드 목록 | 사용자 잔액 |
| 상품 카테고리 이름 | 주문 상태 |
| 실패해도 DB fallback 가능한 값 | 결제 중복 방지 key |
| local cache를 쓰려면 TTL, 최대 크기, fallback, 강제 갱신 방법을 정한다. |
설계 리뷰 질문
상태 관리 실수는 다음 질문으로 빨리 찾을 수 있다.
- 이 값은 요청이 끝난 뒤에도 필요합니까?
- 서버가 한 대 더 늘어나도 같은 값이 보입니까?
- 인스턴스가 죽으면 이 값은 어디서 복구합니까?
- 이 값을 수정할 수 있는 코드는 몇 군데입니까?
- TTL이 없으면 언제까지 유효합니까?
- 배포 후 old/new code가 같은 상태를 읽을 수 있습니까?
- 사용자가 재시도하면 같은 결과가 보장됩니까?
수정 방향
실수를 찾았다고 무조건 큰 구조로 바꿀 필요는 없다.
| 발견 | 작은 수정 | 구조적 수정 |
|---|---|---|
| static session map | Redis session으로 이동 | token + session 관리 정책 |
| local idempotency map | DB unique index 추가 | idempotency service |
| TTL 없는 key | 상태별 TTL 추가 | lifecycle policy와 cleanup job |
| 중복 owner | write path 하나로 합침 | service boundary와 event 계약 |
| local lock | DB constraint 보강 | reservation 또는 queue |
개인 프로젝트 기준
- static map, local cache, ThreadLocal, Redis key 목록을 설계 문서에 적는다.
- 각 상태가 요청 내 상태인지 영속 상태인지 구분한다.
- TTL 없는 key가 있다면 이유를 남긴다.
- 서버 2대 실행 시 같은 동작을 하는지 간단히 검증한다.
- 중복 주문, 중복 쿠폰, 중복 포인트처럼 돈과 연결된 상태는 DB 제약으로 막는다.
기업 운영 기준
- 상태 owner와 write path는 architecture decision record로 관리한다.
- Redis keyspace, TTL 분포, memory 증가율을 dashboard로 본다.
- local cache는 cache policy와 invalidation owner를 명시한다.
- ThreadLocal이나 request context는 framework filter에서 반드시 정리한다.
- 배포 전 old/new state compatibility를 확인한다.
위험 신호
- “일단 메모리에 두고 나중에 Redis로 옮긴다”는 전환 조건이 없다.
- static map이 인증, 주문, 결제, 쿠폰 상태를 가진다.
- Redis key에 TTL이 없고 owner도 없다.
- local lock으로 다중 인스턴스 중복 실행을 막는다고 설명한다.
- 여러 서비스가 같은 상태를 직접 수정한다.
- 장애 후 사용자가 다시 입력해야 하는 상태와 자동 복구 가능한 상태를 구분하지 않는다.
확인 질문
확인 질문
- 확인 질문: 숨은 로컬 상태를 찾는 가장 빠른 질문은 무엇인가?
- 답변: 서버가 한 대 더 늘어나거나 현재 인스턴스가 죽어도 같은 값이 유지되는지 묻는 것이다.
- 확인 질문: TTL은 왜 성능 설정이 아니라 설계 판단인가?
- 답변: 상태가 언제까지 유효한지 정해 오래된 값이 새 요청에 잘못 적용되지 않게 하기 때문이다.
- 확인 질문: 상태 관리 실수의 공통 원인은 무엇인가?
- 답변: 상태의 owner, 수명, 복구 경로를 정하지 않고 구현 편의에 맞춰 저장 위치를 고르는 것이다.