개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← YouTube Shorts 자동화

보안 강화 — 채널 소유권 검증과 IAM 최소권한, S3 SSE

2026년 8월 10일
보안IAMS3Zod채널

보안 점검하듯 하루 잡고 훑었다. 큰 건 하나, 작은 건 여러 개.

channels — 소유권 검증 구멍

멀티유저 구조를 붙인 지는 꽤 됐는데, PATCH /channels/:id/schedule와 DELETE /channels/:id에는 소유권 체크가 빠져 있었다. x-user-id 헤더로 로그인한 사람은 알 수 있는데, 그 사람이 진짜 이 채널 주인인지는 안 봤다는 뜻이다.

async updateSchedule(id: string, dto: UpdateScheduleDto, userId?: string) {
  const exists = await this.repo.findSchedulerConfig(id);
  if (!exists) throw new NotFoundException('채널을 찾을 수 없습니다.');
  if (userId && exists.userId !== userId) {
    throw new ForbiddenException('채널 소유자가 아닙니다.');
  }
  ...
}

userId가 없을 때는 검증을 생략한다. scheduler-worker 같은 내부 서비스가 x-user-id 없이 API를 호출하는 경로가 있어서, 이걸 강제하면 내부 호출까지 막힌다.

DELETE /channels/:id — x-user-id 헤더가 있으면 채널 소유자(Channel.userId) 일치 여부 검증 → 불일치 시 403
PATCH /channels/:id/schedule — 동일

읽기 API(GET /channels)는 원래도 userId 기준으로 필터링돼서 남의 채널이 보이진 않았다. 근데 PATCH/DELETE 같은 쓰기 API는 URL의 :id만 맞으면 통과됐다. 조회는 막혀 있는데 변경은 뚫려 있는 전형적인 패턴이다.

workers — SQS 메시지 런타임 검증

JSON.parse(record.body) as SQSMessage처럼 타입 단언만 하던 걸 Zod 스키마 검증으로 바꿨다.

const SQSMessageSchema = z.object({
  jobId: z.string().min(1),
  channelId: z.string().min(1),
  topic: z.string().min(1),
});

const { jobId, channelId, topic } = SQSMessageSchema.parse(JSON.parse(record.body));

as SQSMessage는 컴파일 타임에만 존재하는 가짜 보장이다. 실제 메시지에 필드가 비어있거나 타입이 다르면 그대로 통과해서 나중에 엉뚱한 곳에서 터진다. script, tts, upload 워커에 전부 적용했다.

upload-worker — 중복 업로드 방지

SQS는 최소 한 번 전달을 보장하지, 정확히 한 번은 아니다. 같은 메시지가 재처리되면 같은 영상이 YouTube에 두 번 올라갈 수 있었다.

const job = await prisma.job.findUnique({
  where: { id: jobId },
  select: { youtubeVideoId: true, scriptContent: true },
});

if (job.youtubeVideoId) {
  log.info({ youtubeVideoId: job.youtubeVideoId }, '이미 업로드됨 — 재처리 스킵');
  continue;
}

youtubeVideoId가 이미 채워져 있으면 이미 업로드된 job이라는 뜻이니 그냥 스킵한다. 기존엔 이 조회와 scriptContent 조회가 각각 따로 나갔는데, 이번에 하나로 합쳤다.

infra — IAM 와일드카드 제거

SQS, SSM, EventBridge 권한이 Resource: "*"로 열려 있었다. 실제 리소스 ARN 패턴으로 좁혔다.

# SQS: arn:aws:sqs:...:prod-* 로 범위 한정
# SSM: arn:aws:ssm:...:parameter/shorts.prod.* 로 범위 한정
# EventBridge: rule/* 로 한정 (채널별 동적 규칙 생성 필요성 주석 명시)

EventBridge만 rule/*로 남겨뒀다. API가 채널마다 EventBridge 규칙을 동적으로 만드는 구조라 규칙 이름을 미리 다 알 수 없다. 대신 왜 와일드카드가 필요한지 주석으로 명시해뒀다 — 나중에 "이거 왜 좁혀놨지" 하고 다시 벌리는 실수를 막으려고.

infra — S3 서버사이드 암호화

버킷에 SSE-AES256 암호화를 붙였다.

resource "aws_s3_bucket_server_side_encryption_configuration" "bucket" {
  bucket = aws_s3_bucket.bucket.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

영상 원본과 렌더 결과물이 저장되는 버킷이라 기본 암호화 정도는 당연히 켜져 있었어야 했는데, 그동안 놓치고 있었다.

이 다섯 개를 묶어서 .claude/rules/security.md에 SQS 검증·IAM 패턴·S3 암호화·업로드 멱등성 규칙으로 정리해뒀다. 다음에 새 워커나 엔드포인트 만들 때 이 체크리스트를 다시 안 놓치기 위해서다.

← 이전 글알람 정비 — SNS 파싱과 KST 변환 수정