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

  • Spring App을 수평 확장하려면 어떤 로컬 상태를 제거하거나 외부화해야 하는가?
  • Pod 수를 늘릴 때 DB, Redis, scheduler, file storage에는 어떤 영향이 생기는가?
  • Stateless App 확장은 성능과 장애 격리를 어떻게 개선하고 어떤 운영 복잡도를 만드는가?

개요

Stateless App 수평 확장은 같은 app instance를 여러 개 띄워 요청을 나누는 방식이다. 이 방식은 app CPU 병목과 일부 instance 장애를 잘 흡수한다. 하지만 app이 정말 stateless여야 한다. session, upload file, in-memory cache, local lock, scheduled job, temporary file이 instance 안에 있으면 요청이 다른 instance로 갈 때 깨진다. App을 늘리는 일은 상태를 외부화하고 shared dependency budget을 다시 계산하는 일이다.

이 문서가 바꾸는 설계 축

바뀌는 내용
성능app CPU와 thread 병목을 여러 instance로 분산한다.
정합성로컬 상태를 외부 owner로 옮겨 요청 간 일관성을 맞춘다.
장애 격리한 instance 장애가 전체 장애가 되지 않게 한다.
운영 복잡도load balancing, autoscaling, 배포, observability, downstream budget을 운영한다.

확장 전 점검할 로컬 상태

항목위험대안
HttpSession 메모리 저장다른 instance에서 로그인 상태 유실Redis session, token 정책
로컬 파일 저장다음 요청에서 파일을 못 찾음object storage
static Map cacheinstance별 값 불일치Redis/cache with TTL
local lock다중 instance 중복 실행 방어 실패DB constraint, 분산 lock, queue
scheduler모든 instance가 같은 job 실행leader election, external scheduler
temporary file배포/재시작 시 유실persistent store, 재처리
이 표를 확인하지 않고 HPA만 붙이면 scale out은 장애를 분산하지 않고 버그를 복제한다.

대표 시나리오

상품 이미지 업로드 API를 수평 확장한다고 하자. 기존 구현은 업로드 파일을 app local disk에 저장하고, 비동기 리사이즈 job이 같은 서버에서 파일을 읽는다. 서버를 4대로 늘리면 업로드 요청은 A 서버로 가고 조회 요청은 B 서버로 갈 수 있다. B 서버에는 파일이 없다. 해결은 sticky session이 아니라 object storage와 job queue로 상태 owner를 바꾸는 것이다.

upload API -> object storage
resize request -> queue
worker -> object storage read/write
metadata -> DB

HPA 계산

App scale out은 instance 용량에서 시작한다.

single_pod_capacity = 400 QPS at p95 150ms
target_peak_qps = 2,400
headroom = 1.5
required_pods = 2,400 * 1.5 / 400 = 9

9개 Pod가 필요하다는 결론은 app만 본 것이다. DB pool, Redis QPS, log volume, external API quota도 9개 Pod 기준으로 계산해야 한다.

HPA 설정 예시

CPU만으로는 충분하지 않을 수 있다.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: product-api
spec:
  minReplicas: 6
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60

CPU가 낮아도 DB wait 때문에 latency가 높을 수 있다. queue consumer는 CPU보다 queue oldest age가 더 좋은 scale 지표일 수 있다. autoscaling 지표는 workload 성격에 맞게 고른다.

DB connection 총합

Pod 수가 늘면 DB connection도 늘어난다.

pods = 30
pool_per_pod = 12
max_connections_from_app = 360
db_safe_connection_budget = 240

이 경우 pool을 줄이거나 Pod 상한을 낮추거나 read path를 cache/replica로 나눠야 한다. DB connection은 무한 자원이 아니다. connection이 너무 많으면 context switching과 lock 경합이 늘어 DB가 더 느려질 수 있다.

Scheduler 중복 실행

Spring @Scheduled job은 instance마다 실행된다.

@Scheduled(cron = "0 * * * * *")
public void closeExpiredCoupons() {
    couponService.closeExpiredCoupons();
}

Pod가 10개면 같은 job이 10번 실행될 수 있다. 해결은 다음 중 하나다.

선택비용
외부 scheduler운영 도구 의존성이 생긴다.
DB locklock wait와 timeout 설계 필요
leader electionsplit brain과 fencing 검토 필요
queue 기반 job중복 소비와 retry 설계 필요

Load balancing과 배포

수평 확장은 rolling deploy와 함께 봐야 한다. readiness probe가 준비되기 전에 트래픽을 받으면 오류가 난다.

readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5

graceful shutdown도 필요하다. 요청 처리 중 Pod가 내려가면 사용자는 timeout을 본다. shutdown timeout, connection drain, idempotency가 배포 안정성을 만든다.

관측 지표

지표이유
request_per_pod부하가 고르게 분산되는지 본다.
pod_restart_count불안정 instance를 찾는다.
p95_latency_by_pod특정 Pod 문제를 격리한다.
db_pool_pending_by_poddownstream 병목을 본다.
local_cache_hit_by_podinstance별 cache 편차를 본다.
job_execution_countscheduler 중복 실행을 잡는다.

로컬 상태 제거 테스트

Stateless 주장은 문서가 아니라 테스트로 확인한다. 로컬에서 app을 두 개 띄우고 같은 사용자의 요청을 번갈아 보낸다.

request 1 -> app-a: login
request 2 -> app-b: get profile
request 3 -> app-a: upload image
request 4 -> app-b: read image metadata
request 5 -> app-b: run idempotent command twice

이 흐름에서 session, file, cache, 중복 command가 깨지지 않아야 한다. 테스트가 실패하면 load balancer sticky 옵션으로 덮지 않는다. 먼저 상태 owner를 Redis, DB, object storage, queue 중 어디로 옮길지 정한다. Sticky routing은 임시 완화일 수 있지만 배포, scale-in, 장애 조치 때 다시 같은 문제가 나타난다.

Instance별 차이를 줄이는 방법

수평 확장은 모든 instance가 같은 요청을 처리할 수 있다는 전제를 가진다. 다음 차이는 요청 결과를 흔들 수 있다.

차이예시처리
설정 차이일부 Pod만 다른 feature flag중앙 설정과 rollout 검증
cache 차이특정 Pod local cache만 오래됨TTL 단축, shared cache, version key
clock 차이만료 판단이 instance마다 다름clock sync, DB time 기준
warmup 차이새 Pod만 cold cachereadiness에서 warmup 확인
library statestatic singleton에 누적 상태외부 store 또는 요청 단위 상태
수평 확장된 app은 “같은 코드”만으로 충분하지 않다.
같은 설정, 같은 시간 기준, 같은 외부 상태 owner가 필요하다.

개인 프로젝트 기준

  • session, file, static map, scheduler 사용 여부를 체크리스트로 남긴다.
  • Docker 두 개를 띄워 요청이 어느 instance로 가도 동작하는지 테스트한다.
  • DB pool 총합과 Redis 의존성을 계산한다.
  • 파일 업로드는 처음부터 local disk 대신 object storage를 고려한다.
  • scheduled job은 다중 instance에서 한 번만 실행되는지 검증한다.

기업 운영 기준

  • autoscaling 기준은 workload별로 CPU, latency, queue age, custom metric을 분리한다.
  • rollout, rollback, readiness, graceful shutdown을 배포 표준에 포함한다.
  • Pod 수 상한은 DB/Redis/external API budget과 연결한다.
  • instance별 metric cardinality와 log volume 비용을 계산한다.
  • 다중 instance에서 중복 job과 중복 message 처리를 정기적으로 점검한다.

위험 신호

  • app이 stateless라고 말하지만 session과 file을 local에 저장한다.
  • Pod 수만 늘리고 DB pool 총합을 계산하지 않는다.
  • @Scheduled job이 모든 Pod에서 실행된다.
  • CPU 기반 HPA만 보고 DB wait나 queue age를 보지 않는다.
  • readiness probe 없이 배포 중 트래픽을 받는다.
  • scale out 뒤 log, metric, tracing 비용이 급증하는 것을 예상하지 않는다.

확인 질문

확인 질문

  • 확인 질문: Stateless App scale out 전 가장 먼저 찾을 것은 무엇인가?
    • 답변: 요청 사이에 유지되는 로컬 상태와 다중 instance에서 중복 실행될 작업이다.
  • 확인 질문: Pod 수를 늘리면 왜 DB가 더 위험해질 수 있는가?
    • 답변: Pod마다 connection pool과 동시 query가 늘어 DB connection budget과 lock 경합을 압박하기 때문이다.
  • 확인 질문: HPA 지표를 CPU로만 잡으면 부족한 이유는 무엇인가?
    • 답변: latency는 DB wait, external timeout, queue lag 때문에 늘 수 있고 CPU는 낮게 보일 수 있기 때문이다.