이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 숨은 로컬 상태는 어떤 장애와 배포 문제를 만드는가?
  • 중복 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 counterwindow 크기에 맞춰 만료한다.
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 mapRedis session으로 이동token + session 관리 정책
local idempotency mapDB unique index 추가idempotency service
TTL 없는 key상태별 TTL 추가lifecycle policy와 cleanup job
중복 ownerwrite path 하나로 합침service boundary와 event 계약
local lockDB 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, 수명, 복구 경로를 정하지 않고 구현 편의에 맞춰 저장 위치를 고르는 것이다.