보통 하루 안에 끝나던 분석 배치가 하루를 넘기기 시작했다. 코드가 느려진 게 아니다. 분석해야 할 작업이 늘었는데, 그 작업을 처리하는 태스크는 여전히 하나였다.
태스크를 10개로 고정하면 당장은 풀리지만, 작업 목록이 비어 있는 시간에도 10개가 돈다. 필요한 건 더 많은 태스크가 아니라, 일이 있을 때만 늘어나는 태스크였다. 이 글에서는 대기열에 남은 작업 수를 CloudWatch 커스텀 메트릭으로 보내고, 그 메트릭에 건 Alarm으로 ECS Fargate 태스크를 1개와 10개 사이에서 움직이게 만든 과정을 정리한다. 처음에 세웠던 “SDK로 직접 조절한다"는 계획을 버린 이유도 함께 적는다.
태스크 하나가 대기열 전체를 받아내던 구조
시스템은 Spring 기반 MSA이고, 배치 전용 모듈이 두 개 있다. 모든 모듈은 ECS Fargate로 운영한다.
모듈 A: 작업 목록을 가진 Queue 역할. Redshift 테이블을 조회해 작업 목록을 Queue에 적재한다.
모듈 B: A의 Queue에서 작업 목록을 읽어 분석한다. 데이터가 있으면 Queue에서 pull하고, 분석이 끝나면 Redshift 특정 테이블의 해당 row를 delete한다.
분석 속도는 B의 태스크 수에 비례한다. 그런데 B는 1개로만 운영되고 있었고, 필요한 분석 작업이 늘면서 배치가 하루를 넘기기 시작했다.
SDK로 직접 조절하려던 계획을 버린 이유
처음 계획은 CloudWatch Alarm 없이, A 모듈이 AWS SDK로 B 서비스의 컴퓨팅 구성을 직접 바꾸는 것이었다. 대기열 상태를 가장 먼저 아는 쪽이 A이니 자연스러운 선택처럼 보였다.
사전 테스트에서 문제가 나왔다. 조정 정책을 직접 컨트롤하면 ECS가 필요한 태스크를 모두 새로 만든 다음 기존 태스크를 전부 지웠다. 태스크 1개로 운용하다가 작업이 늘어 4개를 추가하면, 4개만 붙는 게 아니라 5개가 새로 생기고 기존 1개가 삭제됐다. 기존 태스크가 멀쩡히 분석을 돌리고 있었다면 그 작업은 뜬금없이 중단된다.
5개를 만들고 1개를 지우는 동작은 서비스 배포 설정(최소 실행 작업 비율 100%, 최대 200%)으로 새 배포를 굴릴 때의 모습과 닮았다. 직접 호출이 태스크 수 조정이 아니라 새 배포로 처리됐을 가능성이 있는데, 당시 어떤 API를 어떻게 호출했는지는 기록에 남아 있지 않다.
그래서 방향을 바꿨다. A는 숫자만 보내고, 태스크 수는 ECS Service Auto Scaling이 CloudWatch Alarm을 받아 조정하게 했다. A가 스케일링을 결정하지 않고 메트릭만 내보내면, 태스크를 늘리고 줄이는 일은 ECS가 정상 경로로 처리한다.
flowchart LR
A["모듈 A<br/>@Scheduled 5분"] -->|PutMetricData<br/>RecommendationTargetCount| M["CloudWatch<br/>ECS/CustomMetrics"]
M --> H["Alarm: High-RecommendationTargetCount<br/>최소값 >= 10, 2/2"]
M --> E["Alarm: Empty-RecommendationTargetCount<br/>최대값 <= 0, 2/2"]
H -->|Set to 10 tasks| S["ECS Service B<br/>cbp-inf-opti-aws-recm"]
E -->|Set to 1 task| SA가 5분마다 보내는 숫자 하나
먼저 A에 CloudWatch SDK를 추가했다.
dependencies {
implementation 'software.amazon.awssdk:cloudwatch:2.22.9'
}
CloudWatchClient를 빈으로 등록한다. 자격 증명은 DefaultCredentialsProvider에 맡겨 Fargate 태스크 역할을 그대로 쓴다.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
import java.util.Objects;
@Configuration
@EnableAutoConfiguration
public class AwsConfiguration {
private final String defaultRegion;
public AwsConfiguration(@Value("${cloud.aws.region.static}") String defaultRegion) {
this.defaultRegion = defaultRegion;
}
@Bean
public CloudWatchClient cloudWatchClient() {
return CloudWatchClient.builder()
.region(Region.of(defaultRegion))
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
}
5분 주기로 남은 작업 수를 세어 ECS/CustomMetrics 네임스페이스의 RecommendationTargetCount 메트릭으로 보낸다.
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
import software.amazon.awssdk.services.cloudwatch.model.Dimension;
import software.amazon.awssdk.services.cloudwatch.model.MetricDatum;
import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataRequest;
import software.amazon.awssdk.services.cloudwatch.model.StandardUnit;
@Profile({"prd", "dev"})
@Configuration
@Slf4j
@RequiredArgsConstructor
public class CmmtOptiMetricJob {
private final CmmtRecmQueueRepository cmmtRecmQueueRepository;
private final CmmtRecmDao cmmtRecmDao;
private final CloudWatchClient cloudWatchClient;
private final String NAMESPACE = "ECS/CustomMetrics";
private final String METRIC_NAME = "RecommendationTargetCount";
private final String DIMENSION_NAME = "ServiceName";
private final String DIMENSION_VALUE = "cbp-inf-opti-aws";
@Scheduled(fixedDelay = 300000) // 매 5분
public void putRecommendationTargetCountMetricToCloudWatch() {
int queueTargetCount = cmmtRecmQueueRepository.getRecommendationTargetQueue().size();
int tableTargetCount = cmmtRecmDao.selectRecommendationTargetCount(); // 대기열 테이블 레코드 개수 조회
// Queue에 남아 있으면 Queue 크기를, Queue가 비었으면 Redshift 대기열 테이블 건수를 보낸다
int recommendationTargetCount = queueTargetCount > 0 ? queueTargetCount : tableTargetCount;
Dimension dimension = Dimension.builder()
.name(DIMENSION_NAME)
.value(DIMENSION_VALUE)
.build();
MetricDatum datum = MetricDatum.builder()
.metricName(METRIC_NAME)
.unit(StandardUnit.COUNT)
.value((double) recommendationTargetCount)
.dimensions(dimension)
.build();
PutMetricDataRequest request = PutMetricDataRequest.builder()
.namespace(NAMESPACE)
.metricData(datum)
.build();
cloudWatchClient.putMetricData(request);
}
}
눈여겨볼 곳은 삼항 연산자 한 줄이다. Queue가 비어도 바로 0을 보내지 않고 Redshift 대기열 테이블 건수로 넘어간다. B는 분석을 마친 뒤에 테이블 row를 지우므로, 테이블 건수에는 아직 처리 중인 작업이 포함된다. Queue가 비었다는 것과 일이 끝났다는 것은 다르다. 메트릭이 0이 되는 시점을 “Queue가 빈 때"가 아니라 “마지막 분석까지 끝난 때"로 미룬 셈이고, 이게 뒤에서 볼 Scale In이 작업을 끊지 않는 이유다.

늘릴 때는 최소값, 줄일 때는 최대값을 본다
메트릭 하나에 Alarm 두 개를 걸었다. 둘 다 기간 5분, 데이터 포인트 2/2(10분 내 2개), 누락된 데이터는 무시(경보 상태 유지)다. 다른 건 통계와 조건이다.
| Alarm | 통계 | 조건 | 연결한 조정 정책 |
|---|---|---|---|
| High-RecommendationTargetCount | 최소 | >= 10 | 정확히 10 작업 |
| Empty-RecommendationTargetCount | 최대 | <= 0 | 정확히 1 작업 |
Scale Out Alarm은 최소값을 보므로 5분 구간 안에서 가장 작은 값조차 10 이상이어야 한다. 잠깐 튄 값으로는 늘리지 않는다. Scale In Alarm은 반대로 최대값을 본다. 구간 안에서 가장 큰 값조차 0이어야 하니, 작업이 하나라도 남아 있던 흔적이 있으면 줄이지 않는다. 여기에 2/2 데이터 포인트가 겹쳐 두 조건 모두 10분 동안 유지돼야 움직인다. 늘릴 때도 줄일 때도, 확실할 때만 움직이도록 통계를 반대로 골랐다.
원래 목표는 “대기열이 0이 아니면 Scale Out"이었지만 실제 임계값은 10이다. 작업이 몇 건 안 남았을 때 태스크 10개를 띄우는 건 낭비라서 이렇게 둔 것으로 보인다.
- 작업목록 발생시 Scale Out 수행할 Alarm


- 작업목록 발생시 Scale In 수행할 Alarm


기본 1개는 Fargate, 늘어나는 9개는 Fargate Spot
B 서비스 쪽에서는 서비스 자동 크기 조정을 켜고 최소 태스크 1개, 최대 10개로 범위를 잡았다. 조정 정책은 둘 다 단계 조정(Step Scaling)이고, 앞의 Alarm을 “기존 경보 사용"으로 연결했다.
High-RecommendationTargetCount: 경보 조건 하한 10 ~ +infinity일 때 Set to 10 tasksEmpty-RecommendationTargetCount: 경보 조건 -infinity ~ 상한 0일 때 Set to 1 tasks휴지 기간은 둘 다 300초
“몇 개 추가"가 아니라 “정확히 몇 개로"를 쓴 건 상태가 두 개뿐이기 때문이다. 일이 쌓여 있으면 10개, 없으면 1개다.
비용은 용량 공급자 전략으로 줄였다. 원하는 태스크 1, 그리고 공급자 두 개를 다음처럼 뒀다.
| 용량 공급자 | 기본(base) | 가중치(weight) |
|---|---|---|
| FARGATE | 1 | 0 |
| FARGATE_SPOT | 0 | 1 |
base는 해당 공급자에 먼저 채울 최소 태스크 수이고, weight는 base를 채운 뒤 나머지를 나누는 비율이다. FARGATE의 weight가 0이니 첫 태스크 1개만 일반 Fargate에 뜨고, Scale Out으로 늘어나는 태스크는 전부 Fargate Spot으로 간다. 상시 도는 1개는 중단되지 않는 쪽에, 몰릴 때만 잠깐 쓰는 9개는 싼 쪽에 둔 것이다.


10개까지 늘었다가 1개만 남았다
메트릭 송수신은 정상이었고, 대기열에 작업이 쌓이자 B 태스크가 Fargate Spot으로 늘었다. 서비스 이벤트에는 1개, 3개, 5개 순서로 모두 9개의 태스크가 시작됐다고 찍혔다. 기본 1개와 합쳐 최대치인 10개다.
태스크 start

작업 목록을 모두 처리해 대기열이 비자, 서비스는 실행 중이던 태스크 9개를 멈추고 Fargate 태스크 1개만 남겼다.
태스크 stop

Scale In Alarm 그래프를 보면 흐름이 한눈에 보인다. RecommendationTargetCount는 03시 무렵 8.59k에서 시작해 08시 무렵 거의 바닥에 닿았다. 경보 상태는 In alarm에서 시작해 작업이 들어오자 OK로 바뀌었고, 대기열이 완전히 빈 뒤 다시 In alarm으로 돌아왔다.
Alarm 모니터링

아직 남은 문제
Spot 회수에 대한 대비가 글에 없다. 늘어난 9개는 전부 Fargate Spot이라 AWS가 회수하면 분석 도중 태스크가 멈출 수 있다. B가 Queue에서 pull한 작업을 처리 도중 잃었을 때 다시 대기열로 돌아가는지는 이 글의 범위에서 확인하지 않았다.
상태가 두 개뿐이다. 10건 이상이면 10개, 0건이면 1개다. 1~9건이 남은 구간에서는 이미 떠 있는 태스크 수가 그대로 유지된다. 작업량에 비례해 늘리려면 단계를 더 쪼개거나 다른 조정 방식을 써야 한다.
직접 조절이 왜 전체 교체를 일으켰는지 원인을 확정하지 못했다. 배포 설정 때문이라는 건 추정이다.
정리
배치가 느려진 원인은 코드가 아니라 태스크 수였다. 작업은 늘었는데 B는 여전히 1개였다.
A는 스케일링을 결정하지 않고 숫자만 보낸다. SDK로 직접 조절하면 기존 태스크까지 교체되는 문제가 있어서, 판단과 실행을 CloudWatch Alarm과 ECS Service Auto Scaling에 넘겼다.
Queue가 비었다고 일이 끝난 건 아니다. Queue가 비면 처리 중인 작업까지 포함된 테이블 건수를 보내 Scale In 시점을 늦췄다.
Scale Out은 최소값
>= 10, Scale In은 최대값<= 0. 두 Alarm 모두 10분 동안 조건이 유지될 때만 움직인다.기본 1개는 FARGATE(base 1), 늘어나는 태스크는 FARGATE_SPOT(weight 1)으로 비용을 눌렀다.
대기열을 가장 잘 아는 쪽이 결정하는 게 맞다고 생각해서, 처음 떠올린 방법은 A가 직접 태스크 수를 바꾸는 것이었다. 결과적으로 태스크가 제대로 늘고 준 건 A가 결정을 내려놓은 뒤였다.
참고자료
Publish custom metrics —
PutMetricData로 커스텀 메트릭을 보내는 방법Using Amazon CloudWatch alarms — 기간, 데이터 포인트, 누락 데이터 처리 등 Alarm 평가 방식
Automatically scale your Amazon ECS service — ECS Service Auto Scaling 개요와 조정 정책 종류
Amazon ECS capacity providers for Fargate workloads — FARGATE / FARGATE_SPOT 용량 공급자와 base, weight