지금까지 스케줄러는 API 서버(NestJS) 안에서 @Cron('* * * * *')로 1분마다 돌고 있었다. Phase 5에서 이걸 통째로 갈아엎었다.
API 서버가 죽거나 재배포되면 스케줄러도 같이 멈춘다. Auto Scaling도 안 걸린다. 이건 별도 Lambda로 빼야 했다.
// 이전: apps/api/src/scheduler/scheduler.service.ts
@Injectable()
export class SchedulerService {
@Cron('* * * * *')
async tick() {
const channels = await this.channelsRepo.getEnabledSchedules();
for (const ch of channels) {
if (!ch.uploadSchedule || !shouldRunNow(ch.uploadSchedule)) continue;
// ... Job 생성 ...
}
}
}
이 로직을 그대로 뽑아서 apps/workers/scheduler라는 독립 Lambda로 옮겼다. 트리거는 EventBridge rate(1 minute).
# apps/workers/scheduler/serverless.yml
functions:
handler:
handler: src/handler.handler
timeout: 60
events:
- schedule:
rate: rate(1 minute)
enabled: true
핸들러 안에서는 Prisma로 직접 채널을 조회하고, cron 표현식이 지금 시각과 맞는 채널만 골라서 SQS로 Job 생성 메시지를 보낸다. API 서버 쪽 SchedulerService는 빈 껍데기로 남기고 SchedulerModule도 app.module.ts에서 뺐다.
SQS 메시지가 3번 재시도 후 실패하면 DLQ(Dead Letter Queue)로 빠지는데, 지금까지는 이걸 CloudWatch 콘솔 들어가서 눈으로 확인해야 했다. 그래서 DLQ 5개(script/tts/subtitle/render/upload)를 트리거로 문 Lambda 하나를 새로 만들었다.
// apps/workers/dlq-notifier/src/handler.ts
export const handler: SQSHandler = async (event) => {
for (const record of event.Records) {
const queueName = record.eventSourceARN.split(':').pop() ?? record.eventSourceARN;
const label = QUEUE_LABELS[queueName] ?? queueName;
const receiveCount = record.attributes.ApproximateReceiveCount;
let jobId = '알 수 없음';
let channelId = '알 수 없음';
try {
const parsed = JSON.parse(record.body) as Record<string, unknown>;
jobId = String(parsed['jobId'] ?? '알 수 없음');
channelId = String(parsed['channelId'] ?? '알 수 없음');
} catch {
// 파싱 실패 시 원본 body 유지
}
await postWebhook(WEBHOOK_URL, `🚨 DLQ 알림 — ${label}\n큐: ${queueName}\nJob ID: ${jobId}...`);
}
};
메시지 body가 항상 정상 JSON이라는 보장이 없어서 파싱 실패 시에도 원본 body를 그대로 보여주도록 방어했다.
여기까지 하고 나니 "그래서 애초에 왜 DLQ까지 갔는지"를 알람으로도 받고 싶어졌다. Terraform으로 두 종류를 추가했다.
# Lambda 에러율 > 5% (5분 윈도우)
resource "aws_cloudwatch_metric_alarm" "lambda_error_rate" {
for_each = local.lambda_workers
metric_query {
id = "error_rate"
expression = "IF(invocations > 0, errors / invocations * 100, 0)"
return_data = true
}
threshold = 5
comparison_operator = "GreaterThanThreshold"
}
# DLQ 메시지 1개 이상 쌓이면 즉시 알림
resource "aws_cloudwatch_metric_alarm" "dlq_depth" {
for_each = toset(["prod-script-queue-dlq", "prod-tts-queue-dlq", ...])
metric_name = "ApproximateNumberOfMessagesVisible"
threshold = 0
}
두 알람 모두 SNS 토픽 하나로 묶고 이메일 구독을 연결했다. 이 시점부터는 워커가 죽으면 콘솔을 안 봐도 알 수 있게 됐다.
스케줄러까지 Lambda로 옮기고 나니 ECS/Fargate 관련 Terraform 모듈(ecs-cluster)이랑 API의 scheduler 스텁 코드가 완전히 죽은 코드가 됐다. 이번 기회에 같이 걷어냈다.