이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 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 cache | instance별 값 불일치 | 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 -> DBHPA 계산
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 = 99개 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: 60CPU가 낮아도 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 lock | lock wait와 timeout 설계 필요 |
| leader election | split brain과 fencing 검토 필요 |
| queue 기반 job | 중복 소비와 retry 설계 필요 |
Load balancing과 배포
수평 확장은 rolling deploy와 함께 봐야 한다. readiness probe가 준비되기 전에 트래픽을 받으면 오류가 난다.
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5graceful shutdown도 필요하다. 요청 처리 중 Pod가 내려가면 사용자는 timeout을 본다. shutdown timeout, connection drain, idempotency가 배포 안정성을 만든다.
관측 지표
| 지표 | 이유 |
|---|---|
| request_per_pod | 부하가 고르게 분산되는지 본다. |
| pod_restart_count | 불안정 instance를 찾는다. |
| p95_latency_by_pod | 특정 Pod 문제를 격리한다. |
| db_pool_pending_by_pod | downstream 병목을 본다. |
| local_cache_hit_by_pod | instance별 cache 편차를 본다. |
| job_execution_count | scheduler 중복 실행을 잡는다. |
로컬 상태 제거 테스트
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 cache | readiness에서 warmup 확인 |
| library state | static 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 총합을 계산하지 않는다.
@Scheduledjob이 모든 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는 낮게 보일 수 있기 때문이다.