본문으로 건너뛰기
버튼 하나 뒤에서

7장

계산대 하나 더

손님이 몰려 서버 한 대가 숨이 찰 때, 같은 서버를 하나 더 켜면 무엇이 빨라지고 무엇이 꼬일까요?

  1. 1계산대가 하나뿐이면 손님이 아무리 몰려도 한 명씩만 계산해요. 줄만 길어져요.

  2. 2알고 보니 옆에 계산대가 하나 더 있는데 불이 꺼져 있었어요. 서버 칸(CPU 코어)은 둘인데 하나만 쓰고 있던 거예요.

  3. 3계산대를 하나 더 열고, 입구 직원이 줄이 짧은 쪽으로 손님을 보내요.

  4. 4그런데 마감 정산은 한 번이면 돼요. 두 계산대가 둘 다 하지 않게, 열쇠를 먼저 잡은 쪽만 해요.

계산대는 둘인데 하나만 쓰고 있었어요

운영 서버에 손님을 800명까지 몰아 봤더니, 초당 620건 근처에서 처리량이 멈추고 기다리는 시간만 길어졌어요. 서버 CPU는 코어가 2개인데, API는 Node.js 프로세스 하나라 코어 하나만 꽉 채우고 있었어요. 메모리와 데이터베이스는 여유가 있었어요.

그래서 서버를 바꾸지 않고, 같은 API를 하나 더 켜서 문지기(Caddy)가 손님을 둘로 나눠 보내게 했어요.

지금 누가 계산하고 있을까요?

버튼을 누르면 운영 서버에 "괜찮아?"(GET /health)를 10번 동시에 물어요. 응답마다 붙어 오는 이름표(x-instance-id)로 어느 계산대가 받았는지 세요.

  1. 1?
  2. 2?
  3. 3?
  4. 4?
  5. 5?
  6. 6?
  7. 7?
  8. 8?
  9. 9?
  10. 10?

둘로 늘리면 꼬이는 것

같은 프로그램을 두 개 켜면, "나 혼자 돈다"고 믿고 짠 코드가 문제가 돼요. 처음엔 세 군데인 줄 알았는데, 다시 보니 네 군데였어요.

"하나만"을 서버마다 셌어요

처음엔 무거운 실험은 "서버 전체에서 한 번에 하나"만 돌게 API 메모리에 표시했어요. 실제로는 메모리가 계산대마다 따로라, 두 계산대에서 실험 두 개가 같은 데모 데이터로 동시에 돌 수 있었어요.

그래서 그 표시를 Redis로 옮겼어요. 어느 계산대에서 오든 같은 열쇠 하나를 다퉈요. 초당 요청 수 같은 넉넉한 한도는 계산대마다 따로 세는 채로 뒀어요(한도가 사실상 2배).

정해진 시간 작업이 두 번 돌아요

처음엔 5분마다 도는 집계 작업(3장)을 그대로 뒀어요. 실제로는 계산대마다 시계가 있어서 같은 집계를 두 번 했어요. 결과는 같지만 DB 일만 두 배였어요.

그래서 5분 칸마다 Redis 열쇠(SET NX PX)를 먼저 잡은 쪽만 집계해요. 결제 정리 작업(5장)은 "아직 처리 중인 것만 실패로" 조건을 건 UPDATE라 두 번 돌아도 한 번만 바뀌어서 그대로 뒀어요.

DB 구조 바꾸기는 한 번만

처음엔 켜질 때마다 DB 구조 변경(마이그레이션)을 하던 그대로였어요. 실제로는 두 계산대가 동시에 켜지면서 같은 변경을 서로 하려고 줄을 섰어요.

그래서 1번 계산대만 변경하고 2번은 건너뛰어요(RUN_MIGRATIONS=false). 컬럼 추가만 먼저 하는 규칙(expand → contract) 덕분에 2번이 먼저 떠도 괜찮아요.

"방금 들어옴" 알림이 가끔 빠져요

처음엔 세 군데만 고치면 되는 줄 알았어요. 실제로는 3장의 실시간 알림도 계산대 안에서만 돌았어요. 화면은 1번 계산대에 연결돼 있는데 이벤트 묶음이 2번으로 가면, 2번만 알고 화면에는 알림이 안 왔어요.

그래서 알림을 Redis 채널(bv:events:ingested)로 보내고, 계산대 둘 다 그 채널을 듣다가 자기에게 붙은 화면으로 흘려줘요. 듣는 연결은 다른 명령을 못 써서 Redis 연결을 하나 더 열었어요.

이 서버는 코어가 2개라 API를 더 켜도 나눌 CPU가 없어요. 다음은 Caddy와 데이터베이스를 다른 서버로 빼거나, 서버 자체를 여러 대로 늘리는 쪽이에요 (로드 밸런서 + EC2 여러 대).

숫자로 본 결과

늘린 뒤 같은 조건으로 다시 재 봤어요. 처리량은 거의 그대로였고, 대신 줄 서서 기다리는 시간이 줄고 시간 초과가 사라졌어요. 이번엔 API 하나가 아니라 서버 CPU 전체가 꽉 찼어요. API 둘이 각각 반 코어 남짓을 쓰고, 나머지는 문지기(Caddy)와 데이터베이스가 나눠 썼거든요.

1.9초
손님 800명일 때 느린 쪽 5%가 기다린 시간 (p95)
API 1개일 땐 7~10초였어요. 12분 전체 p95도 2.64초 → 1.4초.
0%
시간 초과로 실패한 요청
1개일 땐 0.89%였어요. 이번엔 395,841건이 전부 200.
초당 약 640건
처리량 (손님 100명부터 더 안 늘어난 지점)
1개일 때 약 620건과 거의 같아요. 나눠 쓸 CPU가 이미 바닥이었어요.
약 180%
서버 CPU 전체 (컨테이너 합, 코어 2개 = 200%)
API 둘이 각각 50~65%, Caddy 35~50%, MySQL 25~40%. API 1개일 때는 API 혼자 100~120%였어요.
운영 서버 실측 — API 1개는 2026-10-21, 2개는 2026-10-22. 같은 k6 스크립트로 손님(가상 사용자)을 10명에서 800명까지 12분 동안 늘리며 GET /health, /events/stats 호출. CPU는 그동안 서버에서 약 36초마다 찍은 docker stats의 정점 구간이에요. 500 오류는 두 번 다 0번이었어요.
개발자용 자세히수평 확장, 로드 밸런싱(least_conn), 헬스 체크, 분산 cron 잠금(SET NX PX), 마이그레이션 한 번만

흐름 따라가기

비유 ↔ 용어

계산대
API 컨테이너. 지금 서비스 중인 색의 api_blue_1 · api_blue_2 (다음 배포 땐 api_green_1 · api_green_2)
계산대 하나 더 열기
수평 확장(scale-out). 서버를 키우는(scale-up) 대신 같은 프로세스를 하나 더
입구 직원
Caddy reverse_proxy의 로드 밸런싱, lb_policy least_conn
이름표
응답 헤더 x-instance-id (컨테이너 환경 변수 INSTANCE_NAME)
마감 정산 열쇠
분산 cron 잠금 SET bv:cron:aggregation:<5분 칸> 1 NX PX 240000
실험은 한 번에 하나
Redis 잠금 bv:simulation:<이름> + 쿨다운 키 (예전엔 프로세스 메모리)

요청 흐름

  1. 브라우저 → Caddy GET /health가 443으로 들어와요. Caddy가 HTTPS를 풀고 upstream.caddy에 적힌 두 곳 중 진행 중인 요청이 적은 쪽을 골라요.
    • 30초마다 두 곳의 /health를 3초 안에 확인하고, 실패한 쪽엔 안 보내요
  2. Caddy → API api_blue_1:4000 또는 api_blue_2:4000이 받아서 200과 함께 x-instance-id: api-blue-1을 붙여 돌려줘요. CORS exposedHeaders에 넣어서 브라우저 JS에서도 읽혀요.
  3. API → Redis (cron) 5분마다 두 API의 @Cron이 동시에 깨어나 같은 키에 SET NX를 보내요.
    • OK를 받은 한 쪽만 어제~오늘 집계를 다시 해요
    • nil을 받은 쪽은 그냥 끝내요. 키는 4분 뒤 저절로 사라지고, 다음 5분 칸은 새 키라서 일부러 지우지 않아요
  4. 배포 (deploy.sh) 쉬던 색의 _1 · _2를 함께 켜고, 둘 다 건강해야 upstream.caddy를 바꿔요. 마이그레이션은 _1만 하고 _2는 RUN_MIGRATIONS=false예요.

Redis에는 이렇게 남아요

cron 잠금 키 (예)
keyvaluePTTL언제
bv:cron:aggregation:2026102214051약 24000014:05에 한 쪽이 잡은 직후
bv:cron:aggregation:202610221405-24분 뒤 저절로 만료

5분 칸마다 키 이름이 달라져요. PTTL은 남은 밀리초, -2는 키가 없다는 뜻이에요.

한계·운영에선

  • 코어 2개짜리 t4g.small이라 API 2개가 끝이에요. 배포 중엔 새 색 2개까지 잠깐 4개가 같이 떠요. 더 필요하면 서버를 여러 대로(로드 밸런서 + EC2 여러 대) 늘려야 해요.
  • 초당 요청 수 같은 넉넉한 한도(SlidingWindowLimit)는 계산대마다 따로 세서 사실상 2배예요. 엄격하게 하려면 Redis INCR + EXPIRE로 옮겨야 하는데, 요청마다 Redis 왕복이 하나 늘어요.
  • Node cluster로 한 컨테이너 안에서 코어 2개를 쓰는 방법도 있지만, 컨테이너를 둘로 나누면 한쪽이 죽어도 다른 쪽이 받고, Caddy 헬스 체크로 빼낼 수 있어서 이쪽을 골랐어요.

개발자 도구로 보면

  • Network 탭에서 /health 응답 헤더의 x-instance-id를 보세요. 로컬에선 local이에요.

코드·문서