03 배포 자동화 - woowacourse-teams/2026-jachwi-sunbae GitHub Wiki

개요

"배포가 안 되는데, 담당자가 없으니 아무도 못 고쳐요."한 팀원이 배포 설정을 해뒀는데 빌드가 실패하고 서비스에 반영이 안 된다. 어떤 명령어를 썼는지, 어떤 설정을 봐야 하는지 문서가 없어 나머지 팀원은 손 놓고 기다릴 수밖에 없다. 머지할 때마다 누군가 수동으로 배포하고, 배포했는데 예전 화면이 보인다는 제보에도 원인을 짚지 못한다.

완료조건

  • 빌드 결과물이 AWS에 배포되어, 팀 외부의 사람이 서비스 URL로 접속할 수 있다.
  • main 브랜치에 병합하면 검증(빌드·린트·테스트)과 배포가 자동으로 이어진다.
  • 배포한 뒤 서비스에 접속했을 때 가장 최신 버전이 바로 적용된다.

기술 역량 목표

  • 정적 리소스를 AWS에 배포하는 구조와 과정을 팀원 모두가 이해하고 동일하게 적용할 수 있다.
  • 병합에서 배포까지 이어지는 자동화 흐름을 직접 구성하고, 실패했을 때 원인을 찾을 수 있다.
  • 배포한 리소스가 최신으로 반영되지 않는 원인을 찾아 해결할 수 있다.

(참고 키워드) GitHub Actions 등의 CI/CD 도구, Cache Busting, CDN Cache Invalidation, contenthash — 검색 출발점으로 던지는 키워드다. 이 중 무엇을 쓸지, 다른 것을 쓸지는 팀이 정한다.

문제 상황

처음으로 배포 파이프라인을 구성해야 됐기 떄문에 아래의 문제들을 해결해야 됐습니다.

  1. 계정 제약이 심하다.

우테코가 제공한 AWS 계정에서 팀은 IAM Role과 액세스 키를 만들 수 없다었습니다. VPC, 서브넷, 보안 그룹, S3 버킷은 이미 만들어진 것을 여러 팀이 공유해야 됐고 흔히 쓰는 방법(GitHub Actions가 액세스 키로 AWS에 배포)이 통하지 않았습니다.

  1. 삭제 권한이 없다.

잘못 만든 리소스를 되돌릴 수 없었습니다. 대상 그룹은 만든 뒤 포트를 바꿀 수 없고, EC2는 서브넷과 키 페어를 바꿀 수 없었습니다.

  1. 예산 한도가 있다. 8월 $50, 9월 $60, 10월 이후 $70이며 초과하면 리소스를 종료,삭제해야 됐으므로 구성마다 비용을 계산해야 했습니다.

  2. 클라이언트에 캐시 무효화 장치를 뒀어야 됐습니다. webpack.config.js의 output에 파일명 해시가 없어 배포해도 브라우저가 옛 번들을 계속 쓸 수 있는 상태였습니다.

목표 설정

그래서 아래와 같은 목표를 설정했습니다.

  • 팀원이 배포 명령을 직접 실행하지 않아도 main 병합만으로 서비스에 반영되는가
  • 배포가 실패했을 때 어느 단계에서 왜 실패했는지 로그로 특정할 수 있는가
  • 배포 후 접속했을 때 항상 최신 상태로 오는가

대안 검토

배포 자동화 도구

대안 장점 우려 판단
GitHub Actions에서 AWS로 직접 배포 저장소 안에 워크플로가 있어 이력이 남는다 액세스 키가 필요한데 만들 수 없다. OIDC는 IAM 초기 설정 권한이 필요하다 거부
CodePipeline + CodeBuild + CodeDeploy 제공된 service role만으로 동작한다 codebuild-project role이 파이프라인 SourceArtifact를 읽지 못한다 부분 거부
CodePipeline Commands 와 CodeDeploy 파이프라인 서비스 role을 그대로 쓴다 빌드 정의가 저장소가 아니라 콘솔에 있다 채택

CodeBuild 경로는 같은 계정의 다른 팀(SETTY)이 먼저 막힌 기록을 확인해 시도 순서를 바꿨다.

세티팀에게 물어보지 않았으면 해당 작업에서 시간이 더욱 많이 날렸을 것이다.

빌드 정의가 콘솔로 간 것은 어쩔수 없었으며 아주 아쉽다. 그래서 빌드 명령이 하는 일과 그 이유를 backend/docs/operations/deployment.md에 적고, 파이프라인 설정을 바꾸면 문서를 함께 고치기로 정했다.

운영 비밀 관리

대안 우려 판단
SSM Parameter Store SecureString 인스턴스 role로는 동작하지만 계정 전체가 한 범위다. describe-parameters를 실행하면 다른 팀 파라미터가 그대로 보인다 거부
AWS Secrets Manager 시크릿당 월 요금이 붙어 예산에 부담이 된다 거부
EC2 로컬 설정 파일 값을 바꾸려면 사람이 서버에 접속해야 하고 이력이 남지 않는다 채택

처음에는 Parameter Store로 설계했다. 그런데 EC2에서 인스턴스 role로 시험하니 다른 팀 파라미터가 목록에 나왔다.

/apollo/dev/discord/client-id  (LastModifiedUser: user/brie)

우리 비밀을 넣으면 다른 팀 인스턴스에서도 읽을 수 있다는 뜻이다. 채택하지 않은 이유가 "쓸 수 없어서"에서 "격리되지 않아서"로 바뀌었고, 그 근거를 문서에 그대로 남겼다.

캐시 무효화

대안 장점 우려 판단
매 배포 CloudFront 전체 무효화(/*) 지금 당장 간단하다 요청을 빠뜨렸을 때 그 사실이 드러나지 않는다. 사용자는 옛 파일을 계속 쓰는데 아무도 모른다 거부
contenthash 도입 후 index.html만 무효화 내용이 바뀌면 파일명이 바뀌어 브라우저가 옛 파일을 쓸 수 없다 빌드 설정을 고쳐야 한다 채택

이 프로젝트는 React.lazy로 코드 분할을 쓰고 있어 청크 파일이 여럿이다. 무효화 대상이 많을수록 파일명 기반이 유리하다.

EC2 운영체제

대안 판단
Ubuntu 팀에 익숙하고 참고 자료가 많아 먼저 택했으나, 빠른 시작 목록에 26.04만 있었다. CodeDeploy 에이전트가 지원 목록에 올린 배포판에서만 검증되는데 26.04는 확인되지 않았다
Amazon Linux 2023 (arm64) 채택. 파이프라인 전체를 막을 수 있는 위험이라 검증된 조합을 골랐다

2️⃣ 적용

최종 구성

배포 최종 구성 상세

GitHub main 병합
  → CodePipeline (웹훅 자동 트리거)
    → Commands: Corretto 21 설치 → clean bootJar -x test → BuildArtifact
    → CodeDeploy → EC2
        ApplicationStop  → 서비스 중지 요청
        BeforeInstall    → /opt/jachwi-sunbae 비움
        AfterInstall     → app.env·실행 사용자 확인, systemd 유닛 설치
        ApplicationStart → systemctl restart
        ValidateService  → localhost:80/actuator/health = UP
  → 실패하면 자동 롤백

사용자 → api.jachwi-sunbae.kr → WAF → ALB(443) → EC2(80) → RDS
                                                      └→ S3(사진)

사용자 → www.jachwi-sunbae.kr → CloudFront(OAC) → S3(정적 SPA)

만든 리소스

리소스 값
EC2 i-0f602d10ed2ace6c7 Amazon Linux 2023 arm64, t4g.small, project-app-a(사설)
RDS jachwi-sunbae-db MySQL 8.4.9, db.t4g.micro, gp3 20GB, 단일 AZ
ALB jachwi-sunbae-alb, project-lb-a/b, WAF techcourse-project-waf 연결
대상 그룹 HTTP 80, 헬스체크 /actuator/health
CloudFront E3LI41UZ24V9WD, Pay as you go, OAC, 원본 경로 /jachwi-sunbae/web
ACM 서울 api.jachwi-sunbae.kr, 버지니아 북부 www.jachwi-sunbae.kr
도메인 가비아 jachwi-sunbae.kr
파이프라인 jachwi-sunbae-line(백엔드), jachwi-sunbae-web-line(프론트)

모든 리소스에 태그 3종(Service, Role, ProjectTeam)을 붙였다. 비용이 $0인 ACM 인증서도 예외로 두지 않았다. 태그 없는 리소스는 종료·삭제 대상이기 때문이다.

검증은 어디서 하는가

배포 빌드는 테스트를 실행하지 않는다. clean bootJar -x test만 실행한다.

GitHub Actions(backend-ci.yml, frontend-ci.yml)가 PR과 main push 양쪽에서 빌드,타입,린트,포맷, 테스트를 실행하므로, 배포되는 커밋은 이미 검증을 통과한 상태다. 같은 테스트를 두 곳에서 돌릴 이유가 없고, 통합 테스트가 Testcontainers로 Docker를 요구해 관리형 빌드 환경에 특권 모드가 필요해진다.

경계를 이렇게 나누고 그 이유를 문서에 적었다.

단계 담당
PR 검증 (빌드,린트, 테스트) GitHub Actions
배포 빌드 (실행 가능한 산출물 생성) CodePipeline Commands
기동 확인 CodeDeploy ValidateService

최신 버전이 바로 반영되게 한 방법

운영 빌드의 파일명에 contenthash를 붙였다. 개발 빌드에는 붙이지 않는다. 파일명이 매번 바뀌면 dev-server의 HMR이 불편하다.

filename: isProduction ? '[name].[contenthash].js' : '[name].js',
chunkFilename: isProduction ? '[name].[contenthash].js' : '[name].js',
assetModuleFilename: isProduction ? 'assets/[name].[contenthash][ext]' : 'assets/[name][ext]',
clean: true,

내용이 바뀌면 파일명이 바뀌므로 브라우저가 캐시된 옛 파일을 쓸 수 없다. 이름이 고정인 것은 index.html 하나뿐이고, 그 안에 해시가 붙은 파일명이 들어 있다. 따라서 배포마다 index.html 하나만 무효화하면 나머지는 자동으로 새 파일을 가리킨다.

aws s3 sync frontend/dist/ s3://techcourse-project-2026/jachwi-sunbae/web/ --delete
aws cloudfront create-invalidation --distribution-id E3LI41UZ24V9WD --paths "/index.html"

--delete는 옛 해시 파일을 정리한다. clean: true는 빌드 산출물 디렉터리에 옛 파일이 쌓이지 않게 한다.

문서로 남긴 것

문서 내용
docs/operations/deployment-architecture.md 전체 구성, 제약, 검토한 대안, 비용 추정
backend/docs/operations/deployment.md 배포 경로, 훅별 역할, 서버에 있어야 하는 것, 로그 확인
backend/docs/operations/rollback.md 자동 롤백 동작과 읽는 법
frontend/docs/deployment.md 빌드 환경 함정, 캐시 무효화, SPA 폴백, origin path
backend/docs/guides/environment-variables.md 운영 프로필에서 달라지는 변수
GitHub Issue #13~#21 콘솔 작업 체크리스트와 확인 결과

저장소에 diff가 남는 작업은 PR로, 콘솔 작업은 이슈 체크리스트로 나눴다. 콘솔 작업을 PR로 만들면 빈 PR이 되고, 파이프라인 설정처럼 저장소에 파일이 들어가는 작업은 리뷰가 필요하기 때문이다.


3️⃣ 관찰

적용 전후

항목 적용 전 적용 후
배포 환경 없음 www.jachwi-sunbae.kr, api.jachwi-sunbae.kr
배포 시작 사람이 수동 main 병합으로 자동
빌드 환경 개인 로컬 관리형 환경에 버전 명시
기동 확인 사람이 로그 확인 ValidateService가 health 자동 확인
실패 시 서비스가 내려간 채로 남음 자동 롤백으로 직전 리비전 복구
비밀 관리 미정 EC2 0600 파일, systemd가 root로 읽고 앱 계정으로 내려감
캐시 무효화 장치 없음 contenthash 와 index.html 무효화
프로세스 관리 수동 java -jar systemd (Restart=on-failure, 부팅 시 자동 시작)
배포 지식 담당자 머릿속 문서 6종 + 이슈 기록

외부에서 확인한 결과

$ curl https://api.jachwi-sunbae.kr/actuator/health
{"status":"UP"}

$ curl -I http://api.jachwi-sunbae.kr/actuator/health
301 → https://api.jachwi-sunbae.kr:443/actuator/health

$ curl -I https://www.jachwi-sunbae.kr/
200

$ curl -I https://www.jachwi-sunbae.kr/properties
200          ← SPA 폴백

$ curl -s -H "Origin: https://www.jachwi-sunbae.kr" -D - https://api.jachwi-sunbae.kr/actuator/health | grep -i access-control
access-control-allow-origin: https://www.jachwi-sunbae.kr

사용자 흐름도 직접 확인했다. 구글 로그인 → 매물 등록 → 사진 업로드가 동작하고, 사진은 팀 접두사 아래에 쌓였다.

$ aws s3 ls s3://techcourse-project-2026/jachwi-sunbae/members/ --recursive
2026-08-15 03:41:16  31029  jachwi-sunbae/members/1/properties/1/2c2650f9-...
2026-08-15 03:41:16  37988  jachwi-sunbae/members/1/properties/1/69850efe-...

Flyway 마이그레이션도 첫 기동에서 자동 적용됐다.

Schema history table `jachwi_sunbae`.`flyway_schema_history` does not exist yet
Successfully validated 4 migrations
Migrating schema `jachwi_sunbae` to version "1 - create v1 0 schema"
...
Successfully applied 4 migrations to schema `jachwi_sunbae`, now at version v4

비용

항목 사양 월 환산
EC2 t4g.small 1대 ~$15
RDS db.t4g.micro, gp3 20GB ~$15
ALB 기동 시간요금 + 소량 LCU ~$17
WAF 공용 techcourse-project-waf $0 (팀 예산 제외)
ACM 퍼블릭 인증서 2장 $0
CloudFront Pay as you go, 상시 무료 티어 안 ~$0
S3·전송량 소량 ~$2
합계 ~$49

CloudFront 요금제를 고를 때 Pro($15)를 검토했으나 9월 한도 $60을 넘겨서 채택하지 않았다. Pay as you go는 상시 무료 티어(월 1TB 전송, 1천만 요청) 안에 들어가고, 우리가 필요한 기능(외부 DNS, 기존 ACM 인증서, OAC, origin path, WAF 끄기)이 모두 가능하다.


겪은 실패와 원인

1. 출력 아티팩트를 정의하지 않아 배포가 저장소 원본을 받았다

빌드는 성공했는데 Deploy가 실패했다. 빌드 액션에 출력 아티팩트를 정의하지 않아 Deploy의 입력이 SourceArtifact로 잡혀 있었다. CodeDeploy가 빌드 결과가 아니라 저장소 원본을 받았고, 거기서는 appspec.yml이 최상단이 아니라 backend/deploy/에 있어 배포가 시작되지 않았다.

appspec.yml 은 아티팩트 최상단에 있어야 한다.

2. 앱은 8080, 대상 그룹은 80이었다

ALB가 502를 반환했다. project-app 보안 그룹의 인바운드를 확인했다.

유형 포트 소스
SSH 22 project-public
HTTPS 443 project-lb
HTTP 80 project-lb

8080이 없다. 이 그룹은 여러 팀이 공유하므로 규칙을 추가하면 다른 팀 인스턴스에도 열린다. 규칙을 바꾸는 대신 애플리케이션을 80으로 옮겼고, 비루트 계정이 1024 미만 포트에 바인딩하도록 systemd에 권한을 주었다.

AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

같은 문제를 겪은 다른 팀은 EC2 안에서 iptables로 80을 8080에 포워딩했다. 동작하지만 그 규칙은 저장소에 남지 않고 재부팅 시 사라져 따로 영속화해야 한다. systemd 권한 방식은 설정이 전부 저장소에 남는다.

3. 배포가 성공했는데 아무것도 바뀌지 않았다

가장 오래 붙잡힌 문제다. 배포 로그를 보자.

10:03:12  ApplicationStop  → jachwi-sunbae.service 가 없어 중지할 것이 없다.
10:03:17  ValidateService  → health 확인됨 (1번째 시도).

5초 만에 health가 통과했다. 새 프로세스가 뜰 시간이 아니다. 옛 프로세스가 응답한 것이다.

원인은 두 겹이었다.

  • stop.sh가 systemctl list-unit-files 출력을 grep해 서비스 존재를 검사했는데, 유닛이 설치돼 있는데도 매치되지 않아 중지를 건너뛰었다.
  • systemctl start 는 이미 active 인 서비스에 아무 일도 하지 않는다. 옛 프로세스가 남고 새 jar는 실행되지 않았다.

ApplicationStop에 프로세스 교체를 기댄 설계 자체가 옳지 않았다. 이 훅은 직전 리비전의 스크립트로 실행되므로 첫 배포에는 실행되지 않고, 직전 리비전이 잘못돼 있으면 동작하지도 않는다. ApplicationStart를 systemctl restart로 바꿔 해결했다.

4. 자동 롤백이 실패를 성공처럼 보이게 했다

배포가 실패하면 직전 리비전으로 되돌아가고 그 롤백 실행에 초록불이 뜬다. 실행 목록만 보면 배포가 성공한 것으로 읽힌다.

✅ 성공   Source: Merge pull request #25
        AutomatedRollback FailedPipelineExecutionId - c22f5be9

서비스를 살려두는 장치라 끄지 않는다. 대신 실패한 실행을 따로 열어야 원인이 보인다는 사실을 롤백 문서에 적었다.

5. 빌드 환경의 Node 18이 PATH에서 앞섰다

프론트 빌드가 실패했다.

Error [ERR_REQUIRE_ESM]: require() of ES Module @babel/core/lib/index.js not supported

yum install -y nodejs가 Node 22.23.2를 설치했는데도 실행되는 것은 18이었다. 이미지에 Node 18이 이미 있고 PATH에서 앞섰다. @babel/core 8은 ESM 전용이라 require(esm)을 지원하지 않는 Node 18에서는 실패한다.

.nvmrc의 버전을 공식 tarball로 받아 절대 경로로 실행하는 방식으로 해결했다.

NODE_VERSION="$(tr -d '[:space:]' < frontend/.nvmrc | sed 's/^v//')"
NODE_ARCH="$(uname -m | sed -e 's/^x86_64$/x64/' -e 's/^aarch64$/arm64/')"
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" -o /tmp/node.tar.gz
mkdir -p /opt/node
tar -xzf /tmp/node.tar.gz -C /opt/node --strip-components=1
/opt/node/bin/node -v
PATH="/opt/node/bin:$PATH" /opt/node/bin/npm --prefix frontend ci
PATH="/opt/node/bin:$PATH" /opt/node/bin/npm --prefix frontend run build

export PATH도 쓰지 않는다. 빌드 명령이 줄 단위로 실행되므로 앞 줄의 export가 다음 줄까지 살아 있다고 가정하지 않는다. 같은 이유로 여러 줄짜리 case·if·for도 쓸 수 없다. 줄마다 쪼개져 깨진다.

버전은 콘솔에 박지 않고 .nvmrc에서 읽는다. Node를 올릴 때 저장소 파일 하나만 고치면 파이프라인이 따라온다.

6. 빌드, 테스트가 모두 통과했는데 브라우저에서만 터졌다

배포된 사이트를 열었더니 화면이 그려지지 않았다.

Uncaught TypeError: (0 , u.jsxDEV) is not a function

@babel/preset-react가 개발 모드로 동작해 jsxDEV를 내보냈다. React 19의 운영 JSX 런타임에는 그 함수가 없다. webpack의 --mode production은 번들 안의 NODE_ENV만 바꾸고 빌드 프로세스의 NODE_ENV는 건드리지 않기 때문이다.

['@babel/preset-react', { runtime: 'automatic', development: !isProduction }],

CodeBuild 문제가 아니었다. 로컬에서 빌드해도 같은 번들이 나왔다.

$ npm run build && grep -c jsxDEV dist/main.*.js
1        ← 고치기 전
0        ← 고친 뒤

빌드·타입·린트·테스트 168개가 전부 통과한다. CI도 파이프라인도 초록이다. 운영 번들을 실제 브라우저에서 열어봐야만 드러난다.

7. 도메인 포워딩이 사이트 전체를 끊을 뻔했다

apex(jachwi-sunbae.kr)를 www로 리다이렉트하려고 가비아 웹 포워딩을 설정했다. 저장 직전 미리보기에서 발견했다.

A   @     121.254.178.229   포워딩
A   www   121.254.178.229   포워딩     ← 이것

이 기능은 @ 뿐 아니라 www 에도 A 레코드를 만든다. 한 호스트에 CNAME과 A는 공존할 수 없으므로 www의 CloudFront CNAME이 밀려나 사이트 전체가 뜨지 않게 된다.

AWS로 리다이렉트를 만들려면 전용 S3 버킷과 CloudFront 배포, apex를 포함한 인증서가 더 필요하다. 지울 수 없는 리소스가 둘 늘어난다. 사용자는 안내받은 주소로 들어오므로 얻는 것에 비해 대가가 크다고 판단해 apex는 서비스하지 않기로 했다.


4️⃣ 학습

1. 실패보다 거짓 성공이 찾기 어렵다

이번에 가장 오래 붙잡힌 문제 셋(3번, 4번, 6번)은 모두 모든 지표가 초록인 상태였다. 파이프라인 성공, CI 통과, 테스트 168개 통과. 그런데 서비스는 바뀌지 않았거나 화면이 뜨지 않았다.

배포가 실패하면 알 수 있다. 성공했다고 나오는데 바뀐 게 없으면 한참 뒤에 발견한다. 그래서 성공 판정을 무엇으로 하는지가 중요하다는 것을 배웠다.

ValidateService가 옛 프로세스의 health를 확인해 성공으로 기록한 것이 대표적이다. "프로세스가 살아 있다"와 "이번 배포의 프로세스가 살아 있다"는 다르다.

2. 훅의 실행 주체를 알아야 한다

CodeDeploy의 ApplicationStop은 직전 리비전의 스크립트로 실행된다. 이 사실을 모르면 두 가지를 오해한다.

  • 첫 배포에서 이 훅이 실행되지 않는 이유
  • stop.sh를 고쳐도 다음 배포에서 여전히 옛 스크립트가 도는 이유

프로세스 교체를 이 훅에 기대면 안 된다는 결론에 도달하는 데 이 이해가 필요했다.

3. 제약이 설계를 정했다

우리가 내린 결정 대부분은 기술적 우열로 고른 것이 아니다.

결정 실제 이유
AWS 네이티브 파이프라인 액세스 키를 만들 수 없다
CodePipeline Commands codebuild-project role이 SourceArtifact를 못 읽는다
비밀을 EC2 로컬 파일에 Parameter Store가 팀 간 격리를 못 한다
애플리케이션 포트 80 공용 보안 그룹이 8080을 안 연다
Amazon Linux 2023 Ubuntu 26.04의 CodeDeploy 지원이 확인되지 않았다
apex 미서비스 지울 수 없는 리소스를 늘리고 싶지 않다

그 경계를 함께 적지 않으면 나중에 보는 사람에게 이상한 선택으로 읽힌다. "왜 SSM 안 썼지", "왜 8080이 아니고 80이지" 같은 질문이 반드시 나온다. 그래서 결정마다 근거를 문서에 남겼다.

4. 다른 팀의 실패 기록이 시간을 아꼈다

CodeBuild 경로는 같은 계정의 다른 팀이 먼저 막힌 기록을 보고 시도 순서를 바꿨다. 우리가 처음부터 뚫으려 했으면 같은 자리에서 시간을 썼을 것이다.

다만 다른 팀의 기록을 결론이 아니라 먼저 확인할 지점으로 다뤘다. 권한 구성이 그 사이 바뀌었을 수 있어, 우리 계정에서 직접 확인하기 전까지는 단정하지 않았다. 실제로 CloudFront 무료 플랜 제한처럼 우리 상황에서 다르게 판단해야 하는 것도 있었다.

5. 되돌릴 수 없는 선택은 미리 확인해야 한다

삭제 권한이 없는 환경에서는 순서가 곧 안전장치다. 다음 항목들은 만든 뒤 바꿀 수 없어 만들기 전에 확인했다.

항목 확인한 것
EC2 서브넷 라우팅 테이블에 NAT 경로가 있는지
EC2 아키텍처 t4g는 ARM이므로 arm64 AMI
대상 그룹 포트 보안 그룹이 어떤 포트를 여는지
ACM 인증서 리전 CloudFront용은 us-east-1
CloudFront origin path 비워두면 다른 팀 파일을 찾는다

ALB는 앱을 배포한 뒤에 만들었다. 앱이 없는 상태로 대상 그룹을 만들면 헬스체크가 실패하는데, 설정 문제인지 앱이 없어서인지 구분할 수 없기 때문이다.


기술역량 목표 대조

목표 달성 근거
정적 리소스를 AWS에 배포하는 구조와 과정을 팀원 모두가 이해하고 동일하게 적용할 수 있다 frontend/docs/deployment.md에 배포 경로, 빌드 환경 함정, 캐시 무효화, SPA 폴백, origin path를 근거와 함께 적었다. 콘솔 설정은 이슈 #20에 값과 이유를 기록했다
병합에서 배포까지 이어지는 자동화 흐름을 직접 구성하고, 실패했을 때 원인을 찾을 수 있다 두 파이프라인을 직접 구성했고 7가지 실패를 각각 로그로 원인을 특정해 해결했다. 원인과 해결을 이슈·문서에 남겼다
배포한 리소스가 최신으로 반영되지 않는 원인을 찾아 해결할 수 있다 contenthash로 파일명 기반 무효화를 도입하고 index.html만 CDN 무효화한다. 왜 전체 무효화를 택하지 않았는지도 기록했다

이후 확장 — dev 환경 분리

배포 자동화를 마친 뒤에도 부쉬도 되는 곳이 없다는 문제가 남았다. main 병합이 곳바로 운영으로 나가므로 실제 인프라에서만 드러나는 문제를 안전하게 확인할 수 없었다.

develop 브랜치와 dev 환경을 만들어 해소했다.

환경 브랜치 프론트엔드 백엔드
prod main www.jachwi-sunbae.kr api.jachwi-sunbae.kr
dev develop dev.jachwi-sunbae.kr dev-api.jachwi-sunbae.kr

작업 브랜치는 develop에서 분기하고, 운영 배포는 develop에서 main으로 PR로 승격한다. 무엇이 운영으로 나가는지 diff로 보이게 하기 위해서다.

새로 만든 것은 EC2 한 대와 CloudFront 하나뿐이다

RDS, ALB, WAF, S3는 두 환경이 공유한다. 따로 만들었으면 RDS 월 15달러, ALB 월 17달러가 더 들었다.

공유 리소스 나누는 방법
RDS 같은 인스턴스의 다른 스키마와 계정
ALB 443 리스너에 호스트 기반 규칙 추가. 인증서는 SNI로 두 장
WAF ALB에 이미 붙어 있어 dev도 자동 적용
S3 같은 버킷의 다른 접두사

dev EC2는 t4g.micro다. 예산 여유가 적어 t4g.small을 쓸 수 없었다. 실측 여유는 296MB이며 보험으로 스왑20을 붙였다. 월 비용은 49달러에서 57달러가 됐다.

무엇이 두 환경을 가르는가

"나누어져 있다"는 말로는 부족하다. 무엇이 나누는지 적어야 새 리소스를 만들 때 빠뜨리지 않는다.

계층 prod dev
배포 대상 EC2 태그 DeployTarget=jachwi-sunbae-prod ...-dev
요청 라우팅 ALB 기본 동작 호스트 헤더 규칙
데이터 스키마 jachwi_sunbae jachwi_sunbae_dev
사진 접두사 jachwi-sunbae/ jachwi-sunbae/dev/
프론트 정적 파일 origin path web web-dev
토큰 JWT_SECRET_BASE64 다름 다름

위험한 것은 둘이다. EC2 태그를 틀리면 배포가 두 환경으로 나가고, s3 sync 대상 경로를 틀리면 --delete 때문에 dev 빌드가 prod 사이트를 통째로 덮는다.

격리가 실제로 됐다는 증거는 번들 해시다. 같은 S3 버킷을 쓰면서도 서로 다른 파일을 서빙한다.

dev  -> /main.246e7100080c6647405f.js
prod -> /main.8a49163cbe52bf996d07.js

다시 걸린 것

Deploy 스테이지의 입력 아티팩트가 또 SourceArtifact로 잡혔다. 마법사 기본값이라 파이프라인을 만들 때마다 확인해야 한다.

더 어려웠던 것은 콘솔 오류 메시지가 실제 원인을 가렸다는 점이다. "CodeDeploy agent was not able to receive the lifecycle event"라고 떴지만 에이전트는 멀쥰했다. 에이전트 로그를 직접 보니 이벤트를 정상 수신했고 appspec.yml을 찾지 못해 실패했다.

기록 위치는 Issue #33~#37, PR #38~#39다.


남은 것

  • WAF 연결을 확인할 수 없다. IAM 사용자에게 wafv2:ListResourcesForWebACL·wafv2:GetWebACLForResource 권한이 없어 콘솔에서 연결 여부를 볼 수 없다. 연결 작업 자체는 오류 없이 끝났고 요청도 통과하지만, 확인했다고 적을 수는 없다.
  • 공용 S3 버킷이 공개 읽기다. 버킷 정책에 Principal: *로 s3:GetObject가 허용되어 있고 퍼블릭 액세스 차단이 꺼져 있다. 우리 사진도 URL만 알면 인증 없이 읽을 수 있다. 객체 키가 UUID라 추측은 사실상 불가능하고 목록 조회도 막혀 있지만, 접근 제어가 아니라 추측 난이도에 기대는 상태다. ADR-0006의 비공개 전제와 어긋난다. 공용 버킷이라 팀이 바꿀 수 없고 서비스 동작에는 영향이 없어 현재 구성을 그대로 둔다. 다만 그 사실 자체는 설계 문서와 ADR-0008의 트레이드오프에 기록했다.
  • 무중단 배포가 아니다. systemctl restart 중 짧은 다운타임이 있다. 환경마다 EC2가 한 대라 단순함을 우선한 결정이다.
  • RDS를 두 환경이 공유한다. dev 부하가 prod 성능에 영향을 줄 수 있다. 월 15달러를 아끼는 대가다. 문제가 되면 분리한다.
  • 자동 롤백이 파이프라인 계층에만 있다. CodeDeploy 배포 그룹 자체의 롤백은 꺼져 있어, 파이프라인 밖에서 수동 배포하면 동작하지 않는다.
  • 모니터링이 없다. 배포 성공 여부는 확인하지만 운영 중 지표는 아직 수집하지 않는다.
  • 빌드 정의가 저장소 밖에 있다. CodePipeline Commands의 빌드 명령은 콘솔에 있다. 저장소에 두려면 CodeBuild가 필요하고 그건 role 권한으로 막혀 있다. 대신 명령이 하는 일과 이유를 문서에 적고, 파이프라인 설정을 바꾸면 문서를 함께 고치기로 정했다. 파이프라인이 넷이라 어긋날 여지도 넷이다.

참고

  • 저장소: woowacourse-teams/2026-jachwi-sunbae
  • 이슈: #13(에픽), #14~#21
  • PR: #22~#31
  • 문서: docs/operations/deployment-architecture.md, backend/docs/operations/deployment.md, backend/docs/operations/rollback.md, frontend/docs/deployment.md