팀 프로젝트의 Spring Boot 애플리케이션을 AWS에 배포했다. 목표는 단순히 EC2에서 애플리케이션을 실행하는 것이 아니었다. main 브랜치에 반영된 코드가 테스트를 거쳐 Docker 이미지로 만들어지고, HTTPS 도메인으로 접근 가능한 상태까지 이어지는 배포 경로를 만드는 것이 목표였다.
이번 환경은 기능 검증 후 비용 관리를 위해 삭제한 임시 배포 환경이다. 따라서 아래 내용은 운영 환경을 완성했다는 기록이 아니라, 배포에 필요한 구성 요소가 어떻게 연결되는지 직접 확인한 기록이다.
코드에 꺾쇠로 표시한 계정 ID, 저장소, 프로젝트명, 커밋 SHA 같은 식별자는 실제 구조를 유지하면서 일반화한 예시다.
배포 경로
전체 흐름은 아래와 같다.
GitHub Actions (main push)
├─ 테스트 실행
├─ OIDC로 AWS 역할 임시 권한 획득
├─ GHCR 미러와 ECR에 ARM64 커밋 SHA 태그 이미지 push
└─ SSM Run Command로 EC2 배포 스크립트 실행
├─ ECR에서 같은 SHA 태그 이미지 pull
├─ EC2 Docker 컨테이너 실행 :8080
└─ EC2 애플리케이션에서 RDS MySQL 연결 :3306
브라우저
→ Route 53
→ ALB :443 (ACM 인증서로 TLS 종료)
→ 대상 그룹 HTTP :8080
→ EC2 Docker 컨테이너
| 구간 | 역할 | 구성 |
| GitHub Actions → AWS | 배포 권한 획득 | OIDC와 IAM 역할 |
| GitHub Actions → GHCR·ECR | 이미지 저장 | ARM64 커밋 SHA 태그, 실제 EC2 배포 원본은 ECR |
| GitHub Actions → EC2 | 원격 배포 실행 | SSM Run Command |
| 외부 사용자 → ALB | HTTPS 진입점 | ACM 인증서, 443 리스너 |
| ALB → EC2 | 애플리케이션 전달 | 대상 그룹, 8080, 상태 검사 |
| EC2 → RDS | 데이터베이스 연결 | 보안 그룹 참조, 3306 |
1. GitHub Secrets 대신 OIDC로 AWS 권한 받기
GitHub Actions가 실행 시점에 OIDC 토큰을 받고, AWS IAM 역할이 그 토큰을 검증한 뒤 짧은 시간의 임시 권한을 발급하는 방식을 사용했다. 그래서 GitHub에 장기 AWS Access Key와 Secret Key를 저장하지 않았다.
워크플로에는 OIDC 토큰 발급 권한이 필요하다.
permissions:
contents: read
id-token: write
IAM 역할의 신뢰 정책은 어떤 저장소와 브랜치의 워크플로만 역할을 맡을 수 있는지 제한한다. 중요한 것은 sub 조건이 실제 실행 주체와 정확히 일치해야 한다는 점이다. 아래 정책은 이번 과정에서 사용한 구조에서 계정과 저장소 식별자만 일반화한 예시다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<AWS_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<ORGANIZATION>/<REPOSITORY>:ref:refs/heads/main"
}
}
}
]
}
이번 저장소에서는 예시와 같은 기존 형식의 sub를 사용했다. GitHub 공식 문서에 따르면 2026년 7월 15일 이후 생성됐거나 immutable subject claim을 사용하도록 전환한 저장소는 sub에 변경 불가능한 소유자·저장소 ID가 포함될 수 있으므로, 예시를 그대로 복사하지 말고 실제 토큰 형식과 신뢰 정책을 맞춰야 한다.
IAM 역할에는 ECR 이미지 push와 SSM 명령 실행·결과 조회에 필요한 권한을 부여했다. 신뢰 정책은 누가 역할을 맡을 수 있는지를 제한하고, 권한 정책은 역할을 맡은 뒤 어떤 AWS 작업을 할 수 있는지를 정의한다.
2. 이미지는 SHA로 식별하고, 배포 명령은 SSM으로 전달하기
main push에서는 같은 커밋을 linux/arm64 이미지로 각각 빌드해 GHCR과 ECR에 push했다. GHCR은 같은 SHA의 미러로 사용했고, 실제 EC2 배포에는 ECR 이미지만 사용했다. 이후 GitHub Actions가 SSM Run Command로 EC2의 배포 스크립트를 실행하면, EC2가 ECR에서 해당 SHA의 이미지를 받아 컨테이너를 교체하도록 구성했다.
커밋 SHA를 이미지 태그로 사용하기
이미지 태그를 latest 하나로 두면 현재 배포된 코드가 무엇인지 확인하기 어렵다. 그래서 GitHub 커밋 SHA를 이미지 태그로 사용했다.
<AWS_ACCOUNT_ID>.dkr.ecr.ap-northeast-2.amazonaws.com/<프로젝트명>:<GITHUB_SHA>
이렇게 하면 현재 EC2에서 실행 중인 이미지가 어떤 커밋으로 만들어졌는지 확인할 수 있다. 배포에 문제가 생겼을 때 이전 SHA를 지정해 다시 배포할 수 있는 기준도 남는다.
SSM으로 EC2 배포 스크립트 실행하기
EC2에는 Docker와 SSM Agent를 실행하고 /opt/<배포 디렉터리>/deploy.sh를 미리 배치했다. GitHub Actions는 이미지를 직접 EC2에 전달하지 않고, SSM Run Command를 통해 해당 스크립트를 실행했다.
DEPLOY_DIR="/opt/<배포 디렉터리>"
sudo "${DEPLOY_DIR}/deploy.sh" "<GITHUB_SHA>" normal
GitHub Actions는 SSM 명령을 보낸 뒤 aws ssm wait command-executed로 명령이 성공할 때까지 기다리고, 실행 결과 JSON을 조회했다. 실제 deploy.sh는 다음 작업을 담당한다.
- bootstrap 또는 normal 모드에 맞는 DB 초기화 환경 변수 결정
- ECR 로그인
- 전달받은 SHA 태그의 이미지 pull
- 기존 컨테이너 제거
- prod 프로필과 모드별 환경 변수를 전달해 새 컨테이너 실행
- 로컬 health check
아래 코드는 이 가운데 이미지를 받고 컨테이너를 교체하는 부분만 줄인 예시다. 꺾쇠로 표시한 값은 실제 계정 ID, 프로젝트명, 배포할 커밋 SHA로 바꿔야 한다.
PROJECT_NAME="<프로젝트명>"
IMAGE_TAG="<GITHUB_SHA>"
IMAGE_URI="<AWS_ACCOUNT_ID>.dkr.ecr.ap-northeast-2.amazonaws.com/${PROJECT_NAME}:${IMAGE_TAG}"
CONTAINER_NAME="${PROJECT_NAME}-app"
docker pull "$IMAGE_URI"
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p 8080:8080 \
"$IMAGE_URI"
이 방식에서는 SSH 키를 GitHub에 보관하거나 EC2의 22번 포트를 배포용으로 열 필요가 없다. GitHub Actions는 SSM에 명령을 전달하고, 실제 이미지 pull과 컨테이너 실행은 EC2 내부에서 수행한다.
GitHub Actions 역할과 EC2 역할 분리하기
GitHub Actions가 맡는 IAM 역할에는 ECR 이미지 push와 SSM 명령 실행·결과 조회에 필요한 권한을 부여했다.
EC2의 런타임 역할에는 SSM 관리형 노드로 동작하기 위한 AmazonSSMManagedInstanceCore와 다음 실행 권한을 부여했다.
- ECR 이미지 읽기
- Parameter Store 설정 읽기
- CloudWatch Logs 쓰기
DB 비밀번호와 JWT 같은 값은 GitHub Secrets나 Docker 이미지에 포함하지 않았다. deploy.sh는 컨테이너에 prod 프로필과 DB 초기화 모드만 전달했고, Spring 애플리케이션이 시작될 때 spring.config.import를 통해 Parameter Store의 설정을 직접 읽었다.
3. HTTPS는 ALB에서 끝내고, 애플리케이션은 8080으로 유지하기
Spring Boot 컨테이너는 8080 포트로 실행했다. TLS 인증서와 HTTPS 처리는 애플리케이션이 아니라 ALB가 맡도록 했다.
사용자 HTTPS 요청
→ ALB 443 리스너
→ ACM 인증서로 TLS 종료
→ 대상 그룹 HTTP 8080
→ Spring Boot 컨테이너
ALB에는 두 리스너를 구성했다.
- HTTP:80은 HTTPS:443으로 301 리다이렉트
- HTTPS:443은 대상 그룹으로 전달
대상 그룹의 상태 검사 경로는 Spring Boot Actuator의 /actuator/health로 지정했다. ALB는 이 경로의 응답을 기준으로 등록된 대상을 healthy 또는 unhealthy로 판단한다.
{
"groups": ["liveness", "readiness"],
"status": "UP"
}
등록된 EC2 대상이 정상적으로 healthy 상태가 되려면 컨테이너가 실행 중이어야 하고, ALB에서 EC2의 8080 포트로 접근할 수 있어야 하며, health check 경로가 정상 응답해야 한다.
4. 보안 그룹으로 서비스 간 접근 경로 제한하기
애플리케이션이 RDS에 접근하는 주체는 개발자 PC가 아니라 EC2이므로, 서비스 간 연결을 보안 그룹 참조로 제한했다.
대상 인바운드 허용 이유
| ALB 보안 그룹 | 0.0.0.0/0의 80, 443 | 공개 HTTP·HTTPS 진입점 |
| EC2 보안 그룹 | ALB 보안 그룹의 8080 | ALB만 애플리케이션에 전달 |
| RDS 보안 그룹 | EC2 보안 그룹의 3306 | 애플리케이션만 DB 접근 |
이 구조에서는 외부 사용자가 EC2의 8080이나 RDS의 3306에 직접 접근할 수 없다.
이번 작업에서는 단일 EC2를 퍼블릭 서브넷에 두었다.
5. 최초 DB 초기화와 일반 배포 모드 분리하기
빈 RDS에 스키마와 초기 데이터를 넣는 최초 배포와 기존 DB를 유지하는 이후 배포를 bootstrap, normal 모드로 분리했다. data.sql은 bootstrap에서만 실행하도록 했다.
구분 용도 초기화 정책
| bootstrap | 빈 초기 DB를 처음 만들 때만 사용 | 스키마 생성 후 초기 데이터 실행 |
| normal | 이후 일반 배포 | 스키마 검증, 초기 데이터 미실행 |
bootstrap: ddl-auto=update, sql.init.mode=always, defer-datasource-initialization=true
normal: ddl-auto=validate, sql.init.mode=never, defer-datasource-initialization=false
첫 배포 순서도 일반 배포와 분리했다.
- DEPLOY_TO_EC2=false 상태에서 첫 main 배포를 실행해 GHCR과 ECR에 SHA 태그 이미지를 올리고, EC2 배포는 건너뛴다.
- 그 SHA로 bootstrap을 한 번 실행하고 초기 데이터가 들어갔는지 확인한다.
- 같은 SHA를 normal로 다시 실행해 초기화 설정을 끈다.
- DEPLOY_TO_EC2=true로 바꾼 뒤부터 main 배포가 SSM을 통해 normal 모드만 실행하게 한다
6. Route 53과 ACM으로 서비스 도메인 연결하기
commerce.trex1004.click은 *.trex1004.click 와일드카드 인증서가 포함하는 한 단계 하위 도메인이다. ACM에서 Issued 상태인 이 인증서를 선택해 ALB의 HTTPS:443 리스너에 연결했다.
도메인은 다음 순서로 연결했다.
- 등록 도메인의 상태가 Active인지 확인한다.
- 기존 와일드카드 인증서가 사용할 서비스 도메인을 포함하는지 확인한 뒤 ALB의 443 리스너에 연결한다.
- 서비스 도메인의 Route 53 A 별칭 레코드를 ALB로 연결한다.
7. 상태 확인 경로와 사용자 진입 경로를 분리했다
ALB의 /actuator/health는 애플리케이션 상태를 확인하는 경로다. 사용자가 처음 접속하는 루트(/)는 별도의 진입점으로 준비했다. 처음에는 루트 요청에 아래 파일을 제공해 /demo/products.html로 이동시켰다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Commerce Payment System</title>
<script>
window.location.replace("/demo/products.html");
</script>
</head>
<body>
<a href="/demo/products.html">상품 목록으로 이동</a>
</body>
</html>
배포 완료 후 동작 확인하기
배포 후에는 브라우저에서 실제 서비스 도메인으로 접속해 /과 프론트 화면이 정상적으로 열리는지 확인했다.
아래는 배포과정 중 이상이 있을때 확인 할 수 있는 명령어들이다.
CONTAINER_NAME="<프로젝트명>-app"
APP_DOMAIN="<서비스 도메인>"
# 1. EC2가 실제로 어떤 이미지를 실행하는지 확인
sudo docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME"
# 2. 컨테이너 로그 확인
sudo docker logs --tail 200 "$CONTAINER_NAME"
# 3. EC2 내부의 애플리케이션 health 확인
curl -fsS http://localhost:8080/actuator/health
# 4. 외부 HTTPS 경로 확인
curl -i "https://${APP_DOMAIN}/actuator/health"
대상 그룹 health와 Route 53 DNS 조회도 함께 확인하면 CI, 이미지, 컨테이너, 네트워크, 도메인 중 어느 구간에서 문제가 발생했는지 구분하는 데 도움이 된다.
회고
Auto Scaling Group과 다중 인스턴스, 프라이빗 애플리케이션 서버와 NAT Gateway, WAF까지 적용하는 방법도 생각했다. 하지만 이전 프로젝트에서 구성을 확장하면서 과금이 빠르게 늘어나는 것을 경험했기 때문에, 이번에는 구성을 단순화하고 배포 자동화에 집중했다.
다만 범위를 줄였음에도 실제 배포 과정에서는 예상보다 손이 많이 가는 지점들이 꽤 있었다.
애플리케이션 배포와 DB 초기화 책임이 섞였다
RDS를 처음 연결했을 때 테이블은 생성됐지만 상품 데이터는 들어가지 않았다. 당시 bootstrap 모드가 ddl-auto=update, sql.init.mode=never, defer-datasource-initialization=false로 실행돼 data.sql이 동작하지 않은 것이 원인이었다.
설정을 나눠 초기 데이터를 넣을 수는 있었지만 DEPLOY_TO_EC2 값 변경, bootstrap 실행, 데이터 확인, normal 재실행 순서를 사람이 관리해야 했다. bootstrap을 다시 실행하면 기존 데이터를 지우는 현재 data.sql이 다시 동작할 수도 있었고, 애플리케이션 배포와 DB 초기화 책임도 하나의 deploy.sh에 섞였다.
Flyway는 스키마나 데이터 변경 SQL에 버전을 붙이고, 아직 적용되지 않은 변경을 순서대로 실행한 뒤 이력을 기록하는 도구다. 아직 이 프로젝트에는 적용하지 않았지만, 다음에는 이런 방식으로 DB 변경을 관리해 수동 초기화 단계를 없애 보고 싶다.
SSM 배포 경로의 초기 설정과 수동 검증이 번거로웠다
이미지 빌드와는 별개로, SSM Run Command를 통해 EC2의 배포 스크립트를 실행하는 경로를 처음 구성하는 과정도 번거로웠다. 처음에는 EC2가 SSM 관리형 노드로 잡히지 않아 대상 인스턴스를 선택할 수 없었고, IAM 역할과 SSM Agent 상태를 바로잡아야 했다. 이후에도 Run Command 화면에서 대상 인스턴스를 선택하고 Git SHA가 포함된 명령을 직접 실행하며 동작을 확인했다.
간단한 배포환경을 위한 작업치고는 설정과 수동 확인이 매우 비생산적으로 느껴졌다.
이후 GitHub Actions가 SSM에 normal 모드의 배포 명령을 보내도록 구성하면서 반복적인 콘솔 실행은 없어졌다.
인증서 문제가 아니라 도메인 등록 상태 문제였다
이미 Issued 상태인 *.trex1004.click 인증서가 있었는데도 commerce.trex1004.click용 인증서를 다시 요청했다. 이 때문에 ACM 검증용 CNAME이 하나 더 생겼고 새 인증서는 Pending validation 상태로 남았지만, 실제 원인은 인증서가 아니었다.
당시 받은 이메일은 ACM 인증서 검증이 아니라 Route 53 도메인 등록자 연락처 검증을 위한 것이었다. 안내된 15일 안에 이메일을 확인하지 않아 도메인이 clientHold 상태가 됐고, 이 배포에서는 commerce.trex1004.click 조회가 NXDOMAIN으로 확인됐다. 이메일 검증 후 도메인이 Active로 바뀌자 DNS 조회도 정상화됐다.
기존 와일드카드 인증서를 ALB에 연결하면 됐으므로 별도 인증서 발급은 불필요했다. NXDOMAIN은 HTTPS 인증서를 확인하기 전 단계에서 도메인 이름을 해석하지 못한 상태이므로, 도메인 등록 상태와 DNS 조회를 먼저 확인한 뒤 ALB와 인증서를 확인했어야 했다.
GHCR과 ECR을 함께 사용했지만 복구 전략은 부족했다
한쪽 이미지 저장소에 문제가 생겼을 때를 대비해 GHCR과 ECR에 같은 이미지를 저장했다.
하지만 배포 경로는 ECR을 기준으로만 구성했기 때문에 GHCR 이미지를 자동으로 사용하는 복구 절차까지는 만들지 않았다.
돌이켜보면 이미지 저장소를 이중화하는 것과 배포 경로 전체를 이중화하는 것은 다른 문제였다.
참고 자료
'AWS' 카테고리의 다른 글
| [AWS S3] IAM Role로 만든 Presigned URL이 7일을 못 버티는 이유 (0) | 2026.08.06 |
|---|