이전 글에서 CI에 대해 살펴봤으니, 이번 글에서는 CD에 대해 다뤄보겠습니다.
| 구분 | 설명 |
|---|---|
| Continuous Delivery | 코드가 항상 배포 가능한 상태로 유지되며, 실제 배포는 사람이 승인 후 진행 |
| Continuous Deployment | 코드가 테스트를 통과하면 사람의 개입 없이 자동으로 프로덕션까지 배포 |
CD는 크게 위와 같이 구분 할 수 있는데, 프로젝트에서는 Continuous Deployment 방식을 택했습니다. main 브랜치에 머지되면, 그 이후로는 별도의 작업 없이 사용자에게 새 버전이 자동으로 전달됩니다.
CI가 "코드를 자동으로 합치고 검증하는 것"이라면, CD는 "그 검증된 코드를 자동으로 사용자에게 전달하는 것"입니다. 보통 이 둘은 하나의 파이프라인 안에서 이어서 동작합니다.
CI는 GitHub Actions를 사용했지만, CD는 AWS CodePipeline를 사용했습니다. GitHub Actions로 CD까지 구성하는 것이 설정과 통합 측면에서 더 간편하다는 장점이 있지만, 당시에는 해당 방식을 사용할 수 없는 환경이어서 CodePipeline을 선택하게 되었습니다.
이 프로젝트는 외부에서 관리되는 AWS 계정 환경에서 진행됐습니다. GitHub Actions에서 S3 업로드나 CloudFront 무효화를 하려면 GitHub이 AWS에 접근할 수 있는 자격증명이 필요합니다. IAM 사용자를 만들어 Access Key를 발급하거나, OIDC로 신뢰 관계를 설정하는 방식입니다. 하지만 이 계정에서는 IAM 관련 작업 자체가 제한되어 있어서 GitHub 쪽에 AWS 접근 권한을 줄 수 없었습니다.
CodePipeline은 AWS 안에서 시작해서 AWS 안에서 끝나는 구조라, 자격증명을 외부로 꺼낼 필요가 없습니다. IAM Role로 서비스끼리 권한을 주고받으면 되고, 자격증명이 외부에 노출될 위험도 없습니다.
| 툴 | 특징 |
|---|---|
| GitHub Actions | GitHub에 내장, YAML로 간단하게 구성, 무료 티어 존재 |
| Jenkins | 오픈소스, 자체 서버 운영 필요, 플러그인 생태계가 풍부 |
| AWS CodePipeline | AWS 서비스와 네이티브 통합, IAM 기반 권한 관리 |
| CircleCI / GitLab CI | SaaS 기반, 빠른 설정 가능 |

main 브랜치에 코드가 머지되면, CodePipeline의 Source와 Build 스테이지가 순서대로 진행되고, CodeBuild가 프론트엔드를 빌드한 뒤 S3에 파일을 동기화합니다.
이후 Lambda가 CloudFront의 index.html 캐시를 무효화하면서 사용자에게 최신 버전이 전달됩니다.

Source 스테이지는 파이프라인의 시작점입니다. 지정한 깃허브 리포지토리의 main 브랜치에 커밋이 푸시되면 CodePipeline이 감지해 파이프라인을 실행합니다.
현재는 GitHub OAuth 앱 방식으로 연결되어 있는데, AWS 콘솔에서 이 방식은 더 이상 권장하지 않는다는 경고를 띄웁니다.
권장 방식은 GitHub App 연결로, 리포지토리별로 세밀하게 권한을 제어할 수 있고 계정 전체 권한을 넘기지 않아도 됩니다. 현재는 동작에 문제가 없어 유지하고 있지만, 보안 측면에서 개선이 필요한 부분입니다.

Build 스테이지는 두 작업으로 작업 그룹을 구성했습니다.
CodeBuild는 빌드 과정을 buildspec.yml 파일에 정의된 명령어 순서대로 실행합니다. 이 파일은 리포지토리 루트에 위치해야 하며, 파일 이름도 buildspec.yml이어야 합니다. CodeBuild가 실행될 때 자동으로 해당 경로에서 파일을 찾아 읽기 때문에 별도로 경로를 지정할 필요가 없습니다.
version: 0.2
phases:
install:
runtime-versions:
nodejs: 22
commands:
- cd frontend
- npm install -g pnpm
- if [ -d node_modules ]; then echo "Using cached node_modules"; else mkdir -p node_modules; fi
- pnpm install --frozen-lockfile --prefer-offline
build:
commands:
- pnpm run build
- aws s3 sync ./dist s3://moaon/fe-build/ --delete --only-show-errors --exact-timestamps
cache:
paths:
- frontend/node_modules/**/*
install 단계에서 의존성을 설치하고, build 단계에서 pnpm run build로 프론트엔드를 빌드합니다. 빌드 결과물인 dist/ 폴더는 aws s3 sync 명령으로 S3에 업로드됩니다.
--delete 옵션은 S3에는 있지만 현재 빌드 결과물에는 없는 파일을 자동으로 삭제합니다. 이전 배포에서 사용하던 파일이 그대로 남아 불필요하게 서빙되는 상황을 방지합니다.
--exact-timestamps는 파일 내용이 같더라도 타임스탬프가 다르면 다시 업로드해, 변경된 파일이 빠짐없이 반영되도록 합니다.
AWS CodePipeline을 처음 접하면 Source → Build → Deploy 구조를 기대하게 됩니다. 하지만 이 파이프라인에는 Deploy 스테이지를 구성하지 않았습니다.
buildspec.yml 파일의 aws s3 sync 명령 자체가 Deploy 역할을 겸하기 때문입니다.
Deploy 스테이지가 필요한 경우는 EC2 서버에 파일을 전송하거나, ECS 컨테이너를 교체하거나, Lambda 함수를 새 버전으로 바꾸는 것처럼 실행 환경에 결과물을 따로 밀어 넣는 과정이 있을 때입니다.
정적 사이트는 다릅니다. 빌드 결과물을 S3에 올리는 것이 곧 배포의 전부이기 때문에, Deploy 스테이지를 추가하면 파이프라인만 복잡해지고 실행 시간만 늘어납니다.
S3에 파일을 올렸는데도 사용자에게 이전 버전이 보이는 경우가 있습니다. CloudFront가 엣지 서버에 이전 파일을 캐시하고 있기 때문입니다. CD의 목적은 자동으로 배포하는 것이지만, 진짜 목표는 사용자가 항상 최신 버전을 받아보는 것입니다.
파일 종류에 따라 캐시 전략을 다르게 가져갑니다.
| 우선순위 | 경로 패턴 | 응답 헤더 정책 | 전략 |
|---|---|---|---|
| 0 | /*.js | — | 1년 캐싱 |
| 1 | index.html | html-policy (no-cache) | 캐시 없음 |
| 2 | 기본값 (*) | js-policy (1년) | 1년 캐싱 |
js-policy: Cache-Control: max-age=31536000 → 1년(31,536,000초) 캐싱
html-policy: Cache-Control: no-cache → 매 요청마다 원본 확인

JS, CSS, 이미지 파일들은 빌드 시 파일명에 콘텐츠 해시가 포함됩니다 (예: main.a3f9c1.js). 파일 내용이 바뀌면 파일명 자체가 달라지므로 브라우저가 자동으로 새 파일을 요청합니다. 이런 파일들은 1년 동안 캐시해도 안전하고, 오히려 적극적으로 캐시해야 CDN이 제 역할을 합니다.
반면 index.html은 해시 없는 고정 파일명입니다. 여기에 캐시를 걸어두면, 새 배포 후에도 브라우저가 이전 버전의 index.html을 들고 있다가 이미 사라진 JS 파일을 요청하는 문제가 생깁니다. 그래서 index.html만 no-cache로 설정합니다.
no-cache는 브라우저에게 "매번 서버에 확인하고 써라"라고 지시하는 설정입니다. 그런데 이 지시는 브라우저에게만 해당됩니다.
CloudFront 엣지 서버는 브라우저와 S3 사이에 있는 별개의 레이어로, 자체적으로 index.html을 캐시하고 있을 수 있습니다.
이 상태에서 배포가 되면 브라우저는 매번 CloudFront에 요청을 보내지만, CloudFront가 이전 버전의 index.html을 그대로 내려주는 상황이 생깁니다. 그래서 배포 직후 Lambda로 CloudFront의 캐시를 직접 날려주는 것입니다.
// moaon-cloudfront-invalidation-after-deploy
import { CloudFrontClient, CreateInvalidationCommand } from "@aws-sdk/client-cloudfront";
import { CodePipelineClient, PutJobSuccessResultCommand, PutJobFailureResultCommand } from "@aws-sdk/client-codepipeline";
const cp = new CodePipelineClient({ region: "ap-northeast-2" });
const cf = new CloudFrontClient({ region: "us-east-1" });
export const handler = async (event) => {
const jobId = event["CodePipeline.job"].id;
const userParameters = JSON.parse(
event["CodePipeline.job"].data.actionConfiguration.configuration.UserParameters
);
const distributionId = userParameters.distributionId;
try {
const result = await cf.send(new CreateInvalidationCommand({
DistributionId: distributionId,
InvalidationBatch: {
Paths: { Quantity: 1, Items: ["/index.html"] },
CallerReference: `invalidation-${Date.now()}`
}
}));
await cp.send(new PutJobSuccessResultCommand({ jobId }));
} catch (err) {
await cp.send(new PutJobFailureResultCommand({
jobId,
failureDetails: { message: err.message, type: "JobFailed" },
}));
}
};
/index.html만 무효화하는 이유는, JS와 CSS는 해시 파일명 덕분에 무효화할 필요 자체가 없기 때문입니다. 불필요한 무효화 요청은 비용과 처리 시간 모두 낭비입니다.
CallerReference: invalidation-${Date.now()}는 중복 요청 방지용입니다. CloudFront는 동일한 CallerReference로 들어오는 중복 무효화 요청을 거부하기 때문에, 매번 고유한 타임스탬프를 붙여줍니다.
PutJobSuccessResult와 PutJobFailureResult는 빠뜨리면 파이프라인이 무한 대기 상태에 빠집니다. CodePipeline 안에서 Lambda를 작업으로 등록할 경우, Lambda가 직접 파이프라인에 결과를 알려줘야 다음 단계로 넘어갑니다.
CD 도입 이전에는 수동 배포 과정에서 환경 변수 오적용 등 반복적인 휴먼 에러가 발생했습니다. 잘못된 브랜치에서 빌드를 돌리거나, S3 업로드를 빠뜨리거나, 캐시 무효화를 빠뜨리는 상황도 마찬가지입니다. 파이프라인이 이 순서를 고정해두면 그런 실수가 끼어들 자리가 없어집니다.
PR을 머지하는 것으로 배포가 완료됩니다. 터미널을 열거나 AWS 콘솔에 들어갈 필요가 없습니다.
초기 설정이 적지 않습니다. CodePipeline, CodeBuild, IAM 역할, buildspec.yml, Lambda 함수까지 처음에 구성해야 할 것들이 많습니다.
모든 머지가 즉시 배포됩니다. 실수로 머지된 코드도 바로 나갑니다. 스테이징 없이 프로덕션에 직결되는 구조라면 특히 주의가 필요합니다.
이 프로젝트의 CD 파이프라인을 정리하면 다음과 같습니다.
main 브랜치에 머지pnpm build 후 S3에 동기화index.html 캐시 무효화S3와 CloudFront 기반의 정적 배포에서 핵심은 무엇을 오래 캐시하고 무엇을 즉시 무효화할 것인지를 명확히 정의하는 것입니다. 해시 파일명을 활용한 장기 캐싱과, index.html만 선택적으로 무효화하는 전략을 함께 쓰면 CDN 효율과 배포 즉시성을 동시에 확보할 수 있습니다.