"하나만"을 서버마다 셌어요
처음엔 무거운 실험은 "서버 전체에서 한 번에 하나"만 돌게 API 메모리에 표시했어요. 실제로는 메모리가 계산대마다 따로라, 두 계산대에서 실험 두 개가 같은 데모 데이터로 동시에 돌 수 있었어요.
그래서 그 표시를 Redis로 옮겼어요. 어느 계산대에서 오든 같은 열쇠 하나를 다퉈요. 초당 요청 수 같은 넉넉한 한도는 계산대마다 따로 세는 채로 뒀어요(한도가 사실상 2배).
7장
손님이 몰려 서버 한 대가 숨이 찰 때, 같은 서버를 하나 더 켜면 무엇이 빨라지고 무엇이 꼬일까요?
1계산대가 하나뿐이면 손님이 아무리 몰려도 한 명씩만 계산해요. 줄만 길어져요.
2알고 보니 옆에 계산대가 하나 더 있는데 불이 꺼져 있었어요. 서버 칸(CPU 코어)은 둘인데 하나만 쓰고 있던 거예요.
3계산대를 하나 더 열고, 입구 직원이 줄이 짧은 쪽으로 손님을 보내요.
4그런데 마감 정산은 한 번이면 돼요. 두 계산대가 둘 다 하지 않게, 열쇠를 먼저 잡은 쪽만 해요.
운영 서버에 손님을 800명까지 몰아 봤더니, 초당 620건 근처에서 처리량이 멈추고 기다리는 시간만 길어졌어요. 서버 CPU는 코어가 2개인데, API는 Node.js 프로세스 하나라 코어 하나만 꽉 채우고 있었어요. 메모리와 데이터베이스는 여유가 있었어요.
그래서 서버를 바꾸지 않고, 같은 API를 하나 더 켜서 문지기(Caddy)가 손님을 둘로 나눠 보내게 했어요.
버튼을 누르면 운영 서버에 "괜찮아?"(GET /health)를 10번 동시에 물어요. 응답마다 붙어 오는 이름표(x-instance-id)로 어느 계산대가 받았는지 세요.
??????????같은 프로그램을 두 개 켜면, "나 혼자 돈다"고 믿고 짠 코드가 문제가 돼요. 처음엔 세 군데인 줄 알았는데, 다시 보니 네 군데였어요.
처음엔 무거운 실험은 "서버 전체에서 한 번에 하나"만 돌게 API 메모리에 표시했어요. 실제로는 메모리가 계산대마다 따로라, 두 계산대에서 실험 두 개가 같은 데모 데이터로 동시에 돌 수 있었어요.
그래서 그 표시를 Redis로 옮겼어요. 어느 계산대에서 오든 같은 열쇠 하나를 다퉈요. 초당 요청 수 같은 넉넉한 한도는 계산대마다 따로 세는 채로 뒀어요(한도가 사실상 2배).
처음엔 5분마다 도는 집계 작업(3장)을 그대로 뒀어요. 실제로는 계산대마다 시계가 있어서 같은 집계를 두 번 했어요. 결과는 같지만 DB 일만 두 배였어요.
그래서 5분 칸마다 Redis 열쇠(SET NX PX)를 먼저 잡은 쪽만 집계해요. 결제 정리 작업(5장)은 "아직 처리 중인 것만 실패로" 조건을 건 UPDATE라 두 번 돌아도 한 번만 바뀌어서 그대로 뒀어요.
처음엔 켜질 때마다 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)와 데이터베이스가 나눠 썼거든요.
api_blue_1 · api_blue_2 (다음 배포 땐 api_green_1 · api_green_2)reverse_proxy의 로드 밸런싱, lb_policy least_connx-instance-id (컨테이너 환경 변수 INSTANCE_NAME)SET bv:cron:aggregation:<5분 칸> 1 NX PX 240000bv:simulation:<이름> + 쿨다운 키 (예전엔 프로세스 메모리)GET /health가 443으로 들어와요. Caddy가 HTTPS를 풀고 upstream.caddy에 적힌 두 곳 중 진행 중인 요청이 적은 쪽을 골라요./health를 3초 안에 확인하고, 실패한 쪽엔 안 보내요api_blue_1:4000 또는 api_blue_2:4000이 받아서 200과 함께 x-instance-id: api-blue-1을 붙여 돌려줘요. CORS exposedHeaders에 넣어서 브라우저 JS에서도 읽혀요.@Cron이 동시에 깨어나 같은 키에 SET NX를 보내요.OK를 받은 한 쪽만 어제~오늘 집계를 다시 해요nil을 받은 쪽은 그냥 끝내요. 키는 4분 뒤 저절로 사라지고, 다음 5분 칸은 새 키라서 일부러 지우지 않아요_1 · _2를 함께 켜고, 둘 다 건강해야 upstream.caddy를 바꿔요. 마이그레이션은 _1만 하고 _2는 RUN_MIGRATIONS=false예요.| key | value | PTTL | 언제 |
|---|---|---|---|
| bv:cron:aggregation:202610221405 | 1 | 약 240000 | 14:05에 한 쪽이 잡은 직후 |
| bv:cron:aggregation:202610221405 | -2 | 4분 뒤 저절로 만료 |
5분 칸마다 키 이름이 달라져요. PTTL은 남은 밀리초, -2는 키가 없다는 뜻이에요.
t4g.small이라 API 2개가 끝이에요. 배포 중엔 새 색 2개까지 잠깐 4개가 같이 떠요. 더 필요하면 서버를 여러 대로(로드 밸런서 + EC2 여러 대) 늘려야 해요.SlidingWindowLimit)는 계산대마다 따로 세서 사실상 2배예요. 엄격하게 하려면 Redis INCR + EXPIRE로 옮겨야 하는데, 요청마다 Redis 왕복이 하나 늘어요.cluster로 한 컨테이너 안에서 코어 2개를 쓰는 방법도 있지만, 컨테이너를 둘로 나누면 한쪽이 죽어도 다른 쪽이 받고, Caddy 헬스 체크로 빼낼 수 있어서 이쪽을 골랐어요./health 응답 헤더의 x-instance-id를 보세요. 로컬에선 local이에요.