Scale out을 해봅시다
개요
현재 프로젝트의 인프라 구성은 MongoDB Replica-set을 구성하여 장애 상황을 대비하여 failover 작업까지 해놨지만, 애플리케이션 서버는 여전히 하나의 인스턴스로 구성되어 단일 장애 지점(SPOF, Single point of failure)입니다.
이전 글의 부하 테스트의 목적이 DB 서버의 성능을 알아보기 위해 단일 인스턴스에서 진행했던 것이었기 때문에 실제 프로덕션 상황에 맞는 환경을 조성하려면 2대 이상의 서버를 구성하여 단일 장애 지점을 회피해야 합니다.
그런데 현재 코드 로직에서 서버를 1대에서 2대로 늘리는 작업은 인스턴스를 그대로 한 대 추가한다고 해결될 문제는 아니었습니다.
이번 글에서는 서버 Scale out을 진행하면서 겪은 문제와 의사 결정을 담아보았습니다.
문제점을 진단해보자
인프라 아키텍처의 문제
먼저 아키텍처를 보면서 문제점을 살펴봅시다. (단일 장애 지점 설명은 생략하겠습니다)

현재 하나의 인스턴스에 App 서버와 Mock OTA, Redis가 모두 작동하는 구조입니다.
프로젝트에 Redis를 도입한 이유는 세션을 공유하기 위함이었는데, 사용자가 A 서버와 B 서버를 오간다면 로그인 상태가 깨질 수 있습니다. 그래서 Redis를 별도로 분리하여 배치할 필요가 있어보입니다.
Ngnix도 2개가 생기면 각 서버의 진입점이 2개가 됩니다. Ngnix를 사용한 목적은 리버스 프록싱, 인증서 기능때문이었는데 외부에서 어느 Ngnix로 들어갈지 정리되지 않으면 Routing이 꼬이게 될 것입니다. 그래서 서버에 요청을 적절하게 꽂아줄 필요가 있겠네요.
또한 이전 부하 테스트에서 애플리케이션 서버가 위치한 인스턴스에 로깅 시스템을 같이 둬서 하드웨어 자원 문제가 생겼을 때 Grafana가 동작하지 않았던 경험이 있어서 별도의 인스턴스로 분리하여 App 서버가 다운되더라도 로깅 시스템이 작동해야합니다.
Mock OTA도 별도의 인스턴스로 분리하기로 결정했습니다. 이 때 외부 채널에서 제공하는 기능이라고 생각하고 mock-ota와 mongodb ota를 하나의 인스턴스에 배포하도록 하고 통신하도록 하겠습니다.
마지막으로 subnet 문제도 있습니다. DB는 private subnet에 위치시켜서 외부 인터넷에서 접근하지 못하도록 했지만 애플리케이션 서버는 여전히 public subnet에 위치하여 외부 인터넷에 노출되어 있습니다.
DB 서버와 마찬가지로 애플리케이션 서버도 외부 인터넷에 노출될 필요가 없기 때문에, private subnet에 위치시키고 그 안에서 DB와 Mock OTA 서버와 통신하도록 하겠습니다.
코드 베이스의 문제
인프라 뿐만 아니라 코드 상에 단일 서버를 상정한 애플리케이션 기능이 있었습니다.
현재 프로젝트가 제공하는 주요 기능 중 하나인 숙소 예약 시스템에는 다음과 같은 상황이 있는데요:

어드민이 추후 오픈할 숙소 달력에 날짜별로 객실 상태를 표현하려면 달력칸이 필요합니다.
이를 위해서 편의상 90일치 달력 칸을 매일 만들어두고 그 달력 칸의 총 객실 수를 맞춰주는 스케줄링 작업을 설정해놨습니다.
도메인 로직 설계 시 RoomInventory라는 날짜 별 재고를 담당하는 도메인을 만들어놓고, 이 재고를 스케줄러를 통해 날짜 별로 재고를 미리 만들어두고 보정하는 작업을 필요로 했는데:
@Scheduled(cron = "0 0 2 * * *")
fun syncAllInventories() {
val properties = propertyRepository.findAll().filter {
it.status == PropertyStatus.ACTIVE
}
log.info("재고 rolling 동기화 시작: 활성 숙소 ${properties.size}개")
for (property in properties) {
val roomTypes = roomTypeRepository.findByPropertyId(property.id)
for (roomType in roomTypes) {
inventoryApplication.syncInventoryForRoomType(property.id, roomType.id)
}
}
log.info("재고 rolling 동기화 완료")
}
...
여기서 문제되는 상황은 @Scheduled을 명시한 재고 동기화 유지 기능은 각 인스턴스에서 같은 시간에 별도로 실행되기 때문에 배포한 app-1, app-2 서버가 동시에 같은 작업을 수행하게 됩니다.
이렇게 되면 같은 재고를 가진 여러 인스턴스가 동시에 만들 수 있고, 이미 존재하는 재고를 보정하는 경우 optimistic locking을 해놓았기때문에 버전 충돌의 가능성이 생길 수도 있어 실행 주체는 하나가 제한해야 되어야합니다.
그래서 해결 후보 군이 몇 가지 있었는데:
- 별도로 배치 서버를 두면 되지 않을까?
실행 주체를 하나로 둔다는 점에서 생각한 포인트지만 비즈니스 상 그렇게 중요한 작업이 아니기때문에 서버 추가는 과하다고 생각했습니다.
- 외부 인프라 시스템에서 대신 실행하면 어떨까?
해결법을 찾아보다가 ECS Scheduled Task라는 서비스를 발견했는데 정해진 시간에 인프라 시스템이 대신하여 스케줄러를 실행할 수 있는 기능이 있었습니다.
실행 주체가 하나라는 점은 충족했지만 이 역시도 별로 중요하지 않은 기능에 너무 과한 투자이며 무엇보다 ECS 서비스로 관리되고 있는 Cluster만 사용할 수 있다보니 후보에서 배제되었습니다.
- 스케줄링 작업에 대해 lock을 걸면 어떨까?
기존 스케줄링 실행을 lock을 점유에 성공한 app 서버만 재고 보정을 실행하도록 하고, 실패했다면 아무것도 하지 않고 종료하도록 하는 방법입니다.
// InventoryRollingScheduler.kt
@Scheduled(cron = "0 0 2 * * *")
fun syncAllInventories() {
val lockName = "inventory-rolling"
val owner = "$lockName-$instanceId"
val now = clock.instant()
val lockedUntil = now.plus(Duration.ofHours(2))
if (!schedulerLock.tryAcquire(lockName, owner,
lockedUntil, now)) {
return
}
try {
syncAllInventoriesWithLock()
} finally {
schedulerLock.release(lockName, owner)
}
}
// MongoSchedulerLock.kt
@Document("scheduler_locks")
data class SchedulerLockDocument(
@Id val id: String,
val lockedBy: String,
val lockedUntil: Instant,
val updatedAt: Instant
)
결국 이 분산 lock을 채택했는데, 이유는 새로운 서버나 메시지 브로커를 추가하지 않으면서 기존 스케줄러 구조를 유지할 수 있었기 때문입니다.
기존에 사용하고 있는 MongoDB에 여유가 있다고 판단하고 DB에 lock을 저장하고 lock 획득에 성공한 인스턴스만 스케줄 작업을 실행하도록 했습니다.
이제 본격적인 인프라 설정을 한 번 해보도록 하겠습니다.
실험 로그
로드 밸런서 선정

기존 아키텍처를 보면 인터넷 게이트웨이를 통한 클라이언트 요청이 가장 먼저 Ngnix를 마주합니다.
기존 아키텍처에서 Ngnix의 역할은:
1) Let's Encrypt 인증 및 SSL/TLS를 처리하고
2) 사용자 요청을 애플리케이션 서버 컨테이너로 전달하며 (리버스 프록시)
3) timeout이나 forwarded header 전달 등 운영에 필요한 HTTPS 처리를 담당합니다.
여기서 이 Routing 역할을 할 수 있는 로드 밸런서로 무엇을 택할 수 있을지 고민하다가 Nginx 문서를 발견했습는데, Ngnix 로드 밸런서로 이용할 수 있다는 것입니다.
그럼에도 AWS가 제공하는 ALB(Appliaction Load Balancer)를 선택했는데:
일단 대부분의 인프라 구성이 AWS로 되어 있는 상태에서 ALB를 사용하지 않을 이유가 크게 없었으며, Ngnix의 번거로운 upstream 설정과 헬스체크 같은 작업을 EC2 인스턴스를 생성하지 않고 가능했기 때문에 App 서버의 Nginx는 제거하기로하고 관리형 서비스인 ALB를 사용하기로 결정했습니다.

개선된 구조에서는 클라이언트가 public DNS를 통해서 ALB에 접근을 하고, ALB가 HTTPS 요청을 받아 TLS를 종료한 뒤에 정상 상태의 애플리케이션 인스턴스 서버로만 트래픽을 전달하게 됩니다.
이로써 이전에 있었던 서버의 외부 노출 문제와 단일 장애 지점을 회피할 수 있게 되었습니다.
인프라 세팅을 좀 더 간편하게 할 수 없을까?
단일 서버를 상정한 기능 fix 및 로드 밸런서 설정까지 인프라 구성에 필요한 준비와 계획을 마쳤으니 이제 본격적인 인프라 세팅 차례입니다. (Redis나 모니터링 서버 분리는 생략했습니다.)
이전 부하 테스트에서 겪은 불편함 중 하나는 ‘AWS 콘솔에서 직접하는 인스턴스 생성 및 인스턴스 제거 작업’이었습니다.
현재 프로젝트 구성은 최소한의 작동을 유지할 수 있는 인프라 구성인 minimal과 scale out 및 로깅 시스템이 적용된 production으로 구성되어있습니다.
여기서 각 구성으로 전환(minimal <-> production)을 하려면 EC2 인스턴스를 중지를 해야하고 미사용 중이더라도 Elastic IP(탄력적 IP)나 EBS volume 같은 자원은 삭제하지 않는 이상 비용이 계속 나가기 때문에 작업이 끝나면 제거가 필요했습니다.
또한 인스턴스가 많으면 클라이언트 요청이 오가지 않아도 유지 비용이 꽤 드는 비용이 크기때문에 부하/기능 테스트 외에는 minimal 구성으로 배포된 상태를 유지할 필요가 있었죠.
저는 이를 좀 더 쉽게 관리할 수 있도록 Terraform을 선택하게 되었습니다.

Terraform은 수동으로 서버나 네트워크를 설정하는 대신에 코드를 통해(IaC, Infrastructure as Code) IT 인프라를 프로비저닝하는 데브옵스 도구입니다.
resource "aws_instance" "app" {
ami = "ami-..."
instance_type = "t3.micro"
}
Terraform은 ‘.tf’ 확장자를 가진 설정 파일로 인프라를 어떻게 구성할 지를 선언합니다.
인프라를 어떻게 구성할 지 코드로 구성했다면 다음 단계를 거치게됩니다.
- init
init은 Terraform 설정 파일이 포함된 작업 디렉터리를 초기화하는 단계입니다.
Terraform 자체는 AWS 같은 외부 벤더의 리소스(컴퓨팅 자원)를 직접 만드는 기능을 내장하고 있지 않아서 다른 외부 벤더의 시스템과 통신하려면 provider가 필요합니다.
init 단계에서 provider 플러그인을 다운로드하고 추가적으로 module을 다운로드합니다.
module은 Terraform에서 인프라 코드를 재사용하기 위한 단위로:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
}
위와 같이 네트워크, 데이터베이스, 로드밸런서 구성을 별도 module로 분리할 수 있습니다.
provider와 module까지 다운로드를 마쳤으면 state에 접근할 준비를 합니다.
- plan
plan은 이름 그대로 인프라 설정 반영 전에 수행할 작업을 미리 계획하는 단계입니다.
작성한 Terraform 코드를 확인하여 신규 혹은 변경될 인프라 상태를 확인하고 기존에 작성된 state에 기록된 리소스 상태와 실제 벤더에 생성된 리소스 상태를 비교하여 어떤 것을 반영할지 계획을 반영합니다.
- apply
apply는 plan에서 확인한 외부 벤더에 실제로 리소스를 생성 및 수정, 삭제를 진행합니다.
plan에서 잠시 state라는 개념을 설명했는데, 이는 Terraform에서 관리하는 현재 상태 기록과 실제 리소스와의 매핑 정보를 담는 데이터입니다.
적절한 예시인지는 모르겠지만 apply 시점에 일종의 스냅샷(?)을 만들어 현재 상태를 나타내는 변경점을 비교하고 반영하는 역할을 합니다.
이제 설정을 해보겠습니다.
Resource 생성/제거
먼저 가장 필요로 했던 작업인 resource 생성/제거 작업입니다.

현재 Github Actions에서 Terraform workflow를 실행하면 OIDC 토큰을 발급받습니다. 토큰의 역할은 이 workflow가 신뢰할 수 있는 Github 저장소에서 실행되었다는 것을 증명해주는 역할을 합니다.
이어서 IAM Provider가 Github OIDC 토큰을 검증하고 허용된 IAM Role을 기반으로 assume 합니다. 이렇게 임시 자격 증명을 얻게 됩니다.
인증이 되었으니 이제 S3 bucket에 보관한 state를 조회합니다. 저장된 state를 읽어 기존에 어떤 리소스를 관리하고 있었는지 확인하고 plan 단계에서 state와 실제 리소스 상태를 비교하여 계획을 세웁니다.
마지막으로 세웠던 계획을 검토했다면 apply로 실제 리소스를 생성하거나 변경하고 난 뒤에는 변경된 상태를 S3 state에 반영하여 다시 저장합니다.
번거로운 인스턴스 설정을 별도로 하지않고 아주 간편하게 변경할 수 있게 되었습니다.
Bootstraping이 필요한데..
여러 Resource(VPC, Subnet, EC2..)를 생성했는데 그 안의 실제 서비스를 실행하려면 추가적인 작업이 필요합니다. 이전에 수동으로 인스턴스를 구성하면서 가장 성가신 작업 중 하나였습니다.
현재 인프라 구성은 Docker compose로 구성되어 있기 때문에 그 안에서 Docker를 실행해야 하며 환경 변수 및 secret 값 주입, Docker image pull 같은 작업을을 수동으로 해줘야합니다. (사실 resource 생성보다 이게 더 귀찮더군요)
이왕 자동화를 도입하게 되었으니 이런 번거로움까지 해결해보겠습니다.
기존의 배포 방식
먼저 기존 인프라 세팅의 deploy 방식은 다음과 같습니다:
Github main push -> Github Actions -> SSH EC2 접속 -> deploy.sh 스크립트 실행
그런데 여기서 인프라를 개선하면서 몇 가지 문제가 생겼는데:
기존에는 app 서버가 public subnet으로 공개되어 있어서 SSH로 EC2를 접속하여 deploy를 실행했지만, 현재는 로드 밸런서를 제외한 모든 인스턴스가 private subnet에 배치되어 SSH에 직접 접속하여 배포를 할 수가 없는 상태가 됩니다.
또한 서버마다 환경 변수 세팅을 미리 맞춰둬야 하고, 배포 대상이 추가로 늘어나면 관리 포인트가 늘어나게되는 불편함이 추가로 발생하게 됩니다.
MongoDB replica-set을 구성하면서 3대의 인스턴스 서버를 두었는데 각각 DB 설정을 해주는 것이 가장 귀찮은 포인트였습니다..
그래서 문제점을 해결하기 위한 포인트를 크게:
- private subnet에 어떻게 접근하는가?
- 환경 변수를 쉽게 관리할 수 없을까?
위 두 포인트로 잡았습니다.
변경된 배포 방식
private subnet에 접근하는 방법은 여러 가지가 있는데 살펴보자면:
- Bastion Host (배스천 호스트)

Bastion host는 public subnet에 jump server를 두고, 그 서버를 거쳐서 private EC2에 SSH로 접근하는 방법입니다.
Bastion host 자체는 외부에서 접근 가능하여 SSH 접속이 가능하며 Security group을 설정하여 22번 포트(SSH)를 제한적으로 열어둡니다.
이어서 private 인스턴스는 Bastion host에서 오는 SSH만 허용하여 접속하게 되는 것입니다.
배스천 호스트를 이용한 방식은 public subnet에 호스팅한 서버를 하나 더 추가해야하고 보안 그룹(Security group) 설정, SSH 접속 인증을 위한 Key Pair가 필요합니다.
일단 후보로 남겨두겠습니다.
- EC2 Instacne Connect Endpoint

Instacne Connect Endpoint는 public IP없이 private subnet에 SSH 없이 접속하게 도와주는 AWS 기능입니다.
private subnet의 EC2 인스턴스는 public IP가 없어 SSH 접속이 불가능하기때문에 AWS 쪽에서 VPC 안에 접속용 endpoint를 만들어주고 사용자가 그 endpoint를 통해 SSH로 접근할 수 있습니다.
별도의 서버없이 private 인스턴스에 접근할 수 있고 IAM 정책으로 어느 사용자가 endpoint를 사용할 수 있는지 제어할수도 있습니다.
VPC가 직접 인터넷 연결을 하지않고 접근할 수 있어 딱히 단점이 없어보이는 후보입니다.
- SSM Session Manager

Session Manager는 SSH 통신없이 private EC2에 shell로 접속하는 방식입니다.
일단 Bastion host 및 key pair, security group도 필요가 없는데 SSH로 할 수 있는 작업은 모두 할 수 있습니다.
SSH 대신에 AWS CLI의 SSM 서비스의 start-session 명령으로 Systems Manager에 연결하여 HTTPS 프로토콜로 인스턴스에 접근합니다.
이 때 인증은 CLI 사용을 위해 등록한 Credentials를 사용하여 IAM user마다 접근할 수 있는 권한을 얻을 수 있습니다.
귀찮은 설정없이 보안 안전하고 쉽게 접근할 수 있어 Session Manager를 선택하게 되었습니다.
그렇다면 이제 남은 건 환경 변수 문제인데:
여태 진행했던 방식은 각 인스턴스마다 필요한 환경 변수를 수동으로 세팅하고 반영하는 작업을 진행했습니다.
애플리케이션 서버같은 경우 Spring boot 실행에 필요한 프로필 설정, MongoDB Driver 연결 정보를 DB 서버에는 MongoDB에 사용할 계정 설정 접속 URI, Replica set, keyfile을 수동으로 설정해줘야했습니다.
AWS는 System Manager에서 parameter store라는 기능을 제공하는데 애플리케이션 구성 데이터 및 secret 파일을 안전하게 저장하고 관리해주는 기능입니다.

일반 텍스트, 문자열 리스트, KMS로 암호화 된 문자열을 key-value 형태로 제공하며 값이 변경될 때마다 버전을 갱신하고 버전을 롤백시킬 수 있는 기능까지 제공합니다. 거기에 각 패러미터별로 IAM 권한을 설정하여 접근 제어가 가능합니다.
변수를 parameter store에 저장하고 bootstraping 시점에 동적으로 로드한다면 환경 변수도 쉽게 관리할 수 있겠네요.

그림으로 보면 위와 같습니다.
무중단 배포를 할 수 있지 않을까?
프로덕션 환경에서는 사용자 경험이 끊기지 않도록 애플리케이션이 새로운 버전으로 업데이트되더라도 정상적으로 요청을 처리해야합니다.
Scale out을 마쳤으니 무중단 배포를 고려를 할 수 있게 되었는데 여러 배포 방식 중에 현재 인프라 제약을 고려하여 택할 수 있는 방법을 고민했습니다.
현재 구조는 애플리케이션 서버 2대와 DB 3대 그리고 ALB 기반의 단순한 로드 밸런싱 구성으로 되어있는데:
기존 운영 환경 외에 새로운 버전을 적용하는 운영 환경을 하나 더 만들어 트래픽을 한 번에 새로운 버전으로 전환하는 Blue-Green 배포 방식은 같은 규모의 서버 세트를 하나 더 만들어야 하기때문에 비용면에서 부담이 되었습니다.
Canary 배포 방식은 새로운 버전을 일부 트래픽에 먼저 노출시키는 방식으로 이 배포의 방식은 risk를 최소화하는 방식입니다.
전체 사용자에게 바로 배포했다가 생길 수 있는 장애를 예방하고 문제가 발생하더라도 영향을 받는 범위만 빠른 롤백을 통해 복구가 가능한 방법이지만, 현재 2대의 서버로 트래픽을 조절하기에는 애플리케이션 인스턴스 하나에 성능이 몰릴 수 있는 가능성이 있습니다.
도입을 못할 이유는 없지만 관리 포인트가 많아질 수 있을 것 같습니다.

채택한 Rolling 배포 방식은 여러 서버를 한 대씩 순차적으로 업데이트하는 방식입니다.
가장 단순하면서 경제적(?)인 방법으로 서버 2대로 굴릴 수 있는 방식으로 간단한 대신에 신경쓸 포인트가 몇 가지 있습니다.
- 트래픽이 한 곳에 쏠리지 않을까
서버를 순차적으로 교체하는 작업은 나머지 서버를 교체하는 도중에는 가용 서버가 한 대로 줄어듭니다.
배포 시간에 트래픽이 몰리게된다면 해당 서버에 트래픽이 모두 쏟아져서 서비스가 느려지거나 멈출 수 있는 상황이 발생할 수 있어 이에 대한 대비가 필요합니다.
- 기존에 처리되던 요청은 어떻게..?
로드 밸런서에서 app-1과 app-2에 트래픽을 보내고 있는 상태라고 가정을 해봅시다.
사용자가 결제 승인 요청을 보내서 app-1에서 DB 저장 및 PG사의 승인, 예약 확정같은 작업을 처리 중에 배포 스크립트가 app-1을 로드 밸런서의 target에서 임시로 제거한다면 새로운 요청은 app-1으로 들어가지 않는 상태가 됩니다.
여기서 app-1에서 처리중인 결제 요청은 아직 끝나지 않은 상태에서 배포를 위해 컨테이너를 종료하면 처리 중인 요청이 끝나게 됩니다.
원활한 서비스를 위해서는 여기도 개선이 필요해보입니다.
- 구 버전과 새로운 버전의 서버가 동시에 있다면?
서버를 순차적으로 업데이트 하는 것이기 때문에 DB의 구조가 바뀌거나(흔한 일인지는 모르겠지만) 기존의 필드를 삭제 혹은 API의 응답구조가 바뀌었다면?
기존 필드:
status = CONFIRMED
신규 필드:
status = RESERVED
paymentStatus = PAID
신버전이 만든 데이터를 구버전이 읽지 못하면 클라이언트가 던진 요청을 이해할 수 없을 것입니다.
현재 코드 베이스를 분석하여 하나씩 해결해보겠습니다.
배포 개선
- 처리율 제한(Rate limit)
Rolling 배포 구조 상 일시적으로 가용 중인 서버에 트래픽이 쏠리는 것은 필연적입니다.
현재 배포 과정을 살펴보면 다음과 같습니다:
1) applicaiton 인스턴스가 2대 이상인지 확인
2) 로드밸런서에서 target 인스턴스가 health check
3) (helath check가 정상이라면) ALB Target Group에서 인스턴스 하나를 제거
4) 배포가 끝나면 다시 ALB에 등록 target 인스턴스가 정상될 때까지 대기
5) 이 때 target이 하나라도 unhealthy라면 deploy 중단
그리고 저희에게 주어진 조건은 다음과 같습니다.
1) 분산 환경이다.
2) 부하 테스트로 App 서버의 대략적인 내구성을 알고 있다.
3) 비즈니스 특성 상 대규모의 트래픽을 받지는 않는다. (상대적..)
또한 실제 운용 중인 서비스라면 트래픽이 몰리는 시간대와 정해진 트래픽이 있어 트래픽이 적은 시간대에 배포를 진행할 수 있겠지만 현재 프로젝트는 그런 기준이 없습니다.
근본적으로 ‘트래픽’을 어떻게 처리할지에 대한 문제인데 여러 방식을 모색해본 첫 번째 방법은 트래픽을 제한하는 것입니다.
처리율 제한(Rate limit)은 클라이언트나 서비스가 일정 시간동안 수행할 수 있는 요청을 제어하여 시스템의 안정성을 도모하는 방법 중 하나입니다.
현재는 scale out한 환경이어서 서버들이 같은 제한 기준을 공유해야하기 때문에 서버 측에서 처리율을 제한하도록 해보겠습니다.
이 처리율 제한에는 여러 방식이 있습니다. 그 중 Counter 방식을 소개하자면 이름 그대로 정해진 시간 동안 요청이 몇 번 들어왔는지 세는 방식입니다.
예를 들어 로그인 API에 다음과 같은 정책이 있다고 가정해봅시다.
같은 IP에서 로그인 요청은 1분에 10번까지만 허용
그러면 서버는 요청이 들어올 때마다 다음을 확인하고:
- 이 요청이 제한 대상 API인가?
- 이 요청을 보낸 사용자는 누구인가? 또는 IP는 무엇인가?
- 현재 1분 동안 몇 번 요청했는가?
- 허용 횟수를 넘었는가?
여기서 허용 횟수를 넘지 않았다면 요청을 통과시키고 넘었다면 429 Too Many Requests를 반환하도록 할 수 있습니다.
저는 이 Counter를 Redis에 저장하여 ‘특정 기준으로 묶은 요청 횟수’를 저장해보겠습니다.
로그인 API라면 다음과 같은 기준으로 카운터를 만들고:
어떤 API? - auth-login
누가 요청? - IP 또는 사용자 ID
어느 시간 구간? - 현재 1분 window
Redis에는 대략 다음과 같은 값이 저장됩니다:
key = rate:auth-login:{identityHash}:{windowStart}
value = 3
ttl = 60초
여기서 TTL을 함께 설정했는데 ‘10:00:00 ~ 10:00:59’ 동안 사용한 카운터는 ‘10:01:00’ 이후에는 더 이상 필요 없으니 TTL로 삭제해도 되겠죠.
추가로 window라는 필드를 확인할 수 있는데 처리율 제한에 Fixed Window Counter라는 알고리즘을 이용합니다.

Fixed Window Counter는 “시간을 고정된 칸으로 나누고, 그 칸 안에서 요청 수를 세는 방식”입니다.
로그인 API를 1분에 10번까지만 허용한다고 하면, 시간은 다음처럼 나뉩니다.
10:00:00 ~ 10:00:59 첫 번째 window
10:01:00 ~ 10:01:59 두 번째 window
10:02:00 ~ 10:02:59 세 번째 window
요청이 들어오면 서버는 현재 시간이 어느 window에 속하는지 계산합니다.
만약 현재 시간이 ‘10:00:30’이라면 현재 window는 ‘10:00:00 ~ 10:00:59’에 해당하여 window의 Redis counter를 1 증가시키는 것입니다.
Fixed window counter는 정해진 시간마다 카운터 하나만 두면 되기때문에 단순하며 scale out으로 늘어난 서버가 모두 Redis의 같은 counter를 증가시키기 때문에 서버 별로 카운터 불일치 문제가 생기지 않습니다.

다만 이 방식은 window 경계에 burst가 발생할 수 있습니다. 1분 단위로 잡은 각 window에 ‘12:59:59’, 10:01:00에 각각 10회 요청이 들어온다면 실제로는 1초 사이에 20회의 요청이 통과될 수 있다는 것이죠.
그치만 처리율 제한을 도입한 이유는 정밀한 트래픽 제어가 아니기 때문에 현재 알고리즘으로 burst 정도는 감수 가능해보입니다.
최종적으로 흐름은 다음과 같습니다:
HTTP 요청 -> RateLimiFilter -> RedisFixedWindowRateLimter -> Redis count++ -> 허용이라면 Controller로 통과하고 초과했다면 429를 반환!
- Graceful Shutdown(우아한 종료)
배포뿐만 아니라 scale up/out 같은 스케일링을 이유로 서비스를 중단시키고 다시 올려야하는 경우 진행 중이던 작업을 완료하고 리소스를 정리한 뒤에 종료할 필요가 있습니다.
현재는 숙소 결제 상태와 예악 상태를 별도의 상태로 관리하고 있는데 서버가 진행 중인 작업을 무시하고 서버를 내렸을 때 결제는 승인되었는데 예약은 PENDING 상태가 되는 여러 부작용이 나타날 수 있습니다.
Spring boot에서는 graceful shutdown을 위한 설정을 yml 파일에 명시할 수 있습니다.
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
server:
shutdown: graceful
종료 신호를 받으면 바로 종료하지 않고 처리 중인 요청을 최대 30초동안 기다립니다.
shutdown 시간은 정상 요청이 보통 몇 초안에 끝나는지를 고려하여 설정하는데, 저는 p99와 외부 연동 API의 timeout을 고려하여 적당하게 30초로 잡았습니다.
stop_grace_period: 40s
Docker 컨테이너도 마찬가지로 Docker compose 시스템이 컨테이너에 ‘SIGTERM’을 보낸 뒤에 바로 ‘SIGKILL’하지않고 최대 40초동안 기다리도록 설정했습니다.
Spring이 요청을 마무리하는 주체이고, 컨테이너는 Spring 서버가 작업을 마무리할 시간을 보장하는 환경이기 때문에 30초보다는 긴 40초를 보장하도록 했습니다.
- 구 버전과 새 버전의 공존
이건 당장 해결해야할 문제가 아니라고 판단했는데, DB의 스키마나 API 응답을 한 번의 배포에서 기존 계약을 바로깨지 않고, 일정 기간 동안 구 버전과 새 버전이 모두 이해할 수 있는 DB 구조와 API 응답을 유지하는 방식을 유지하는 것이 좋을 듯합니다.
필요한 작업은 아니니 넘어가고 설정한 작업이 의도대로 동작하는지 확인해봅시다.
의도한대로 동작할까?
부하 테스트 스크립트에서는 두 가지 시나리오를 동시에 실행하는 방식으로 진행했는데:
첫 번째는 일반 사용자 흐름으로 숙소 목록 조회, 객실 조회, offers 조회, 예약 생성, 결제 확인, 내 예약 조회를 섞어서 실행했습니다.
이는 rolling 배포 중 서버 한 대가 내려가더라도 기존 요청이 끊기지 않는지 확인하기 위함입니다.
두 번째는 처리율 제한 확인용 burst입니다. 5명의 VU가 2분 동안 잘못된 로그인 요청을 반복해서 보내도록 했으며, 로그인 API는 IP 기준 60초에 10회 제한이 걸려 있으므로 의도한대로라면 일정 횟수 이후에는 429 Too Many Requests가 발생해야 합니다.

먼저 첫 테스트의 결과 처리율 제한은 의도대로 동작한 것처럼 보이는데 로그에도 서버 장애성 실패나 500번대 서버 에러는 발생하지 않았습니다.
다만 전체 checks가 98.95%이고 심지어 예약은 거의 다 실패했는데, 처리율 제한이 너무 강하게 적용되어있는게 아닐까 추정됩니다. 처리율 제한을 완화하고 다시 진행을 해보겠습니다.

이번에도 처리율 제한이 동작하여 로그인 burst 요청 시 의도대로 429 에러를 날렸습니다. 이제 graceful shutdown이 동작했는지 확인해봅시다.

graceful shutdown이 동작한다면 서버의 응답 문제로 생기는 500번대 에러를 볼 수 있겠지만, 인스턴스를 내렸다가 다시 올리는 구간에도 서버 에러없이 배포가 된 것을 확인할 수 있었습니다.
다만 burst를 부하 테스트 warm up 이 후 2분동안 집중적으로 했기 때문에 이 부하 테스트에서는 배포 구간에 처리율 제한이 제대로 동작했는지는 확인할 수 없었네요.
다음 테스트에서는 burst 시간을 길게 늘리거나 배포를 빨리 수행하도록 해봐야겠습니다.
맺음
글을 다 쓰고보니 지극히 서버 관점에서만 서술한 것 같은데 클라이언트 관점에서 어떤 식으로 해결할 수 있을지도 공부해봐야겠습니다. (하지만 서버 처리가 안전한 걸)
이제 또 다른 개선점을 찾으러.. 총총