
백엔드를 이미지로 만들어 서버에 자동으로 올립니다. develop 브랜치에 push하면 WAR 빌드 → 이미지 생성 → Docker Hub 업로드 → API, Batch 서버 배포 → 헬스체크까지 자동으로 돌아갑니다.
1. 개발자가 Github에 push
2. Github Actions가 아래 작업을 진행
a. WAR 파일 빌드(Maven)
b. Docker 이미지 빌드
c. Docker Hub에 push
3. (빌드 성공 시) AWS SSM으로 각 API 서버와 Batch 서버에 배포 명령 전송
a. API 서버: pull 받은 후 docker compose up -d
b. Batch 서버: pull 받은 후 docker compose up -d
4. 로드 밸런서가 API 서버로 트래픽 전달
5. 헬스체크 통과
6. 배포 완료
API 서버와 Batch 서버는 완전히 같은 이미지를 사용합니다. 실행할 때 프로필(api / batch)만 다르게 줍니다. 그래서 이미지는 한 번만 만들면 되고, 두 서버의 버전이 어긋날 일이 없습니다.
가장 먼저 만든 건 Dockerfile입니다. 이게 있어야 이미지가 생기고, 이미지가 있어야 서버에 올릴 컨테이너가 뜹니다. 프로젝트 루트(pom.xml과 같은 위치)에 만들었습니다.
FROM tomcat:9-jdk17-temurin
# 기본 웹앱 제거 (manager, examples 등 불필요 + 보안)
RUN rm -rf /usr/local/tomcat/webapps/*
# WAR를 ROOT로 배포 → 컨텍스트 경로가 "/"
COPY target/*.war /usr/local/tomcat/webapps/ROOT.war
ENV TZ=Asia/Seoul
EXPOSE 8080
FROM tomcat:9-jdk17-temurin: 톰캣 9 버전과 JDK 17이 이미 세팅된 이미지에서 출발합니다. 톰캣을 직접 설치할 필요가 없습니다.
rm -rf .../webapps/*: 톰캣 기본 웹앱(manager, examples 등)을 통째로 지웁니다. 사용하지 않기도 하고, 이런 기본 앱들이 열려 있으면 보안 취약점이 되기 때문입니다.
COPY target/*.war ... /ROOT.war: 빌드된 WAR를 ROOT.war라는 이름으로 넣습니다. 톰캣은 ROOT.war를 컨텍스트 경로 /로 배포합니다. 즉 /api/...로 바로 접근됩니다.
ENV TZ=Asia/Seoul: 컨테이너 시간대를 한국으로 설정해서 로그, 스케줄, DB 시각이 어긋나지 않게 합니다.
EXPOSE 8080: 톰캣이 8080에서 듣는다는 표시입니다.
같은 위치에 .dockerignore를 만들어, 이미지에 들어갈 필요 없는 것들을 빌드 컨텍스트에서 제외했습니다.
.git
.github
.idea
*.iml
sql/
docs/
.env
README.md
특히 .env를 반드시 제외해야 합니다. 실수로 이미지 안에 비밀값이 구워지면 이미지를 받는 누구나 볼 수 있게 됩니다.
여기가 가장 중요한 지점이었습니다. 이미지가 로컬에서 뜨는 걸 확인하지 않고 바로 Actions에 올리면, 실패했을 때 원인 찾기가 훨씬 어렵습니다. 대부분의 문제(WAR 경로, 톰캣 버전, 컨텍스트 경로)가 여기서 드러나기 때문에, 이 단계를 통과하면 나머지가 수월해집니다.
# 1) WAR 빌드
./mvnw clean package -DskipTests
ls -lh target/*.war # WAR가 실제로 만들어졌는지 확인
# 2) 이미지 빌드
docker build -t nagasseum-backend:test .
# 3) 실행 (로컬 DB, Redis가 떠 있어야 함)
docker run --rm -p 8081:8080 \
-e JAVA_OPTS="-Dspring.profiles.active=local,api" \
-e DB_URL="jdbc:mysql://host.docker.internal:3306/independence?serverTimezone=Asia/Seoul" \
-e REDIS_HOST="host.docker.internal" \
nagasseum-backend:test
8081:8080으로 매핑한 이유: IntelliJ에서 띄우는 톰캣이 8080을 쓰기 때문에, 로컬 테스트 컨테이너는 8081로 빼서 충돌을 피했습니다.host.docker.internal: 컨테이너 안에서 호스트 PC의 DB/Redis에 접근할 때 쓰는 주소입니다.curl http://localhost:8081/api/v1/members/health
요청을 해보면 이미지가 만들어지고 WAR가 /로 배포되고 있는 것을 확인할 수 있습니다.
로컬에서 이미지가 검증됐으면, 이제 EC2에서 이 이미지를 어떻게 띄울지를 정의합니다. API 서버용과 Batch 서버용을 나눠 두 개를 만들었습니다.
services:
api-was:
image: ${DOCKER_USER}/nagasseum-backend:latest
container_name: api-was
restart: always
ports:
- "8080:8080" # ALB가 접근할 포트
environment:
JAVA_OPTS: "-Dspring.profiles.active=prod,api -Xmx512m"
DB_URL: ${DB_URL}
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: ${REDIS_HOST}
REDIS_PORT: 6379
CODEF_CLIENT_ID: ${CODEF_CLIENT_ID}
CODEF_CLIENT_SECRET: ${CODEF_CLIENT_SECRET}
CODEF_PUBLIC_KEY: ${CODEF_PUBLIC_KEY}
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
services:
batch-was:
image: ${DOCKER_USER}/nagasseum-backend:latest # API와 완전히 같은 이미지
container_name: batch-was
restart: always
# ports 없음 — 외부에서 접근할 일이 없음
environment:
JAVA_OPTS: "-Dspring.profiles.active=prod,batch -Xmx512m"
DB_URL: ${DB_URL}
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: ${REDIS_HOST}
REDIS_PORT: 6379
CODEF_CLIENT_ID: ${CODEF_CLIENT_ID}
CODEF_CLIENT_SECRET: ${CODEF_CLIENT_SECRET}
CODEF_PUBLIC_KEY: ${CODEF_PUBLIC_KEY}
SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL}
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
두 파일의 차이는 딱 세 가지입니다.
프로필: prod, api과 prod, batch입니다. 같은 코드가 프로필에 따라 API 서버로도, 배치 실행기로도 동작합니다.
포트 노출: API는 ALB가 붙어야 하니 8080을 열고, Batch는 외부에서 부를 일이 없어 포트를 안 엽니다.
Slack 웹훅: 배치 작업 결과 알림용이라 Batch에만 있습니다.
logging (json-file, max-size 10m, max-file 3): 이걸 안 걸면 도커 로그가 무한정 쌓여 몇 주 만에 디스크를 꽉 채웁니다. 실제로 아주 흔한 장애 원인이라, 최대 10MB * 3개로 순환하도록 제한했습니다.
Xmx512m (힙 제한): 서버가 t3.small(메모리 2GB)입니다. JVM은 힙을 명시하지 않으면 호스트 메모리의 약 1/4을 기본값으로 잡는데, 그러면 메모리 예측이 어긋납니다. 512MB로 못 박아 안정적으로 만들었습니다.
compose 파일의 ${DB_URL} 같은 변수들은 각 EC2의 ~/nagasseum/.env에서 채워집니다. 이 파일은 git에 절대 올리지 않습니다.
DOCKER_USER=<도커허브_아이디>
DB_URL=jdbc:mysql://<RDS_엔드포인트>:3306/independence?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
DB_USERNAME=admin
DB_PASSWORD=<RDS_마스터_비밀번호>
REDIS_HOST=<ElastiCache_Primary_엔드포인트>
REDIS_PORT=6379
CODEF_CLIENT_ID=...
CODEF_CLIENT_SECRET=...
CODEF_PUBLIC_KEY=...
SLACK_WEBHOOK_URL=...
위 값들은 예시로 마스킹했습니다. 실제 엔드포인트, 비밀번호는 팀 내부
.env(및 GitHub Secrets)에만 존재합니다.
여기서 주의할 부분이 있습니다.
REDIS_HOST는 반드시 Primary 엔드포인트로 해야 합니다. ElastiCache에는 읽기 전용 Reader 엔드포인트(ro가 붙음)가 따로 있는데, 이걸 넣으면 쓰기 작업이 전부 실패합니다.
DB_URL에 characterEncoding=UTF-8을 포함해야 합니다. 로컬에서 겪었던 한글 깨짐이 RDS에서도 재현될 수 있어서 미리 넣었습니다.
파일 권한도 소유자만 읽도록 제한했습니다.
chmod 600 ~/nagasseum/.env
이제 지금까지 만든 것들(이미지, compose, .env)을 하나로 잇습니다. .github/workflows/cd.yml을 만들어 빌드부터 배포, 검증까지 자동화했습니다.
name: CD - Build, Push & Deploy
on:
push:
branches: [ develop ]
workflow_dispatch: # 버튼으로 수동 실행도 가능
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: 소스 체크아웃
uses: actions/checkout@v4
- name: JDK 17 설정
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: maven
- name: WAR 빌드
run: ./mvnw clean package -DskipTests
- name: 빌드 결과 확인
run: ls -lh target/*.war
- name: Docker Hub 로그인
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Buildx 설정
uses: docker/setup-buildx-action@v3
- name: 이미지 빌드 & push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/nagasseum-backend:latest
${{ secrets.DOCKERHUB_USERNAME }}/nagasseum-backend:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: AWS 자격증명 설정
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: API 서버 배포
run: |
aws ssm send-command \
--targets "Key=tag:Role,Values=api" \
--document-name "AWS-RunShellScript" \
--comment "deploy ${{ github.sha }}" \
--parameters 'commands=["cd /home/ssm-user/nagasseum && docker compose pull && docker compose up -d && docker image prune -f"]'
- name: Batch 서버 배포
run: |
aws ssm send-command \
--targets "Key=tag:Role,Values=batch" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["cd /home/ssm-user/nagasseum && docker compose pull && docker compose up -d && docker image prune -f"]'
- name: 배포 결과 확인
run: |
sleep 60
curl -f https://api.nagasseum.com/api/v1/members/health
echo ""
echo "배포 성공"
setup-java & cache: maven: JDK 17을 깔고 Maven 의존성을 캐시합니다. 두 번째 실행부터 빌드가 빨라집니다.
ls -lh target/*.war: WAR가 실제로 나왔는지 로그에 남겨, 문제가 생겼을 때 빌드 단계인지 이미지 단계인지 바로 구분되게 했습니다.
platforms: linux/amd64: Actions 러너는 x86이라 대개 amd64가 나오지만, 이렇게 설정하면 ARM 이미지가 섞여 서버에서 실행이 안 되는 사고를 막습니다.
태그 2개 (latest + ${{ github.sha }}): latest는 배포 명령을 단순하게 해주고, 커밋 SHA 태그는 해당 이미지가 정확히 어느 커밋인지를 남겨 롤백 및 추적에 씁니다.
cache-from/to: gha: 이미지 레이어를 GitHub Actions 캐시에 저장해 빌드 시간을 줄입니다.
needs: build-and-push: 빌드/푸시가 성공해야만 배포가 시작됩니다. 이미지가 없거나 깨진 상태로 서버를 건드리는 일을 원천 차단하는 안전장치입니다.
SSH가 아니라 SSM으로 배포: 이 선택이 핵심인데, 이유는 아래에서 따로 설명합니다.
배포 명령 cd ... && docker compose pull && docker compose up -d && docker image prune -f
pull: 방금 올린 최신 이미지를 서버로 내려받고up -d: 새 이미지로 컨테이너를 교체하고image prune -f: 교체되고 떠도는 옛 이미지를 정리해 디스크를 아낍니다.
배포 방식으로 SSH 대신 AWS SSM(Systems Manager) send-command를 골랐습니다. 이유는 세 가지입니다.
접속 키가 필요 없습니다. SSH라면 GitHub Actions에 서버 개인키를 넣어둬야 합니다. SSM은 IAM 권한으로 명령을 보내므로 키를 보관 및 유출할 위험이 없고, 서버의 22번 포트를 열어둘 필요도 없습니다.
태그로 대상을 지정할 수 있습니다. Key=tag:Role,Values=api는 Role 태그가 api인 인스턴스 전부라는 뜻입니다. IP를 하드코딩하지 않으니, 서버를 교체하거나 늘려도 태그만 맞으면 워크플로 수정 없이 배포됩니다. 따라서 API 서버가 api-2a, api-2c 둘인데도 명령 한 번으로 둘 다 처리할 수 있습니다.
실행 기록이 남는다. 보낸 명령과 그 출력이 Systems Manager 콘솔의 실행 기록(Run Command)에 남아, 서버에 들어가지 않고도 배포 실패 원인을 확인할 수 있습니다.
여기서 또 주의할 점이 있었는데, SSM send-command의 commands 배열은 각 항목이 서로 독립된 셸에서 실행된다는 점이었습니다. 그래서 아래와 같이 나눠 쓰면 안 됩니다.
// 잘못된 예 - cd가 다음 줄에 적용되지 않는다
'commands=[
"cd /home/ssm-user/nagasseum",
"docker compose pull", // ← 홈 디렉터리에서 실행됨 → compose 파일 못 찾음
"docker compose up -d"
]'
cd를 따로 두면, 다음 명령은 이동이 사라진 새 셸에서 실행돼 compose 파일을 못 찾고 배포가 실패합니다. 그래서 &&로 한 줄에 이어 붙여 같은 셸에서 순서대로 돌게 했습니다.
// 올바른 예 — 하나의 셸에서 디렉터리가 유지된 채 순차 실행
'commands=["cd /home/ssm-user/nagasseum && docker compose pull && docker compose up -d && docker image prune -f"]'
덤으로 &&는 앞 명령이 성공했을 때만 다음으로 넘어가므로, pull이 실패하면 up -d까지 가지 않고 멈추는 안전장치도 됩니다.
- name: 배포 결과 확인
run: |
sleep 60
curl -f https://api.nagasseum.com/api/v1/members/health
echo ""
echo "배포 성공"
sleep 60: 애플리케이션이 완전히 기동할 시간을 줍니다. 뜨자마자 때리면 아직 준비 안 된 서버를 실패로 오판할 수 있습니다.
curl -f: f(fail)가 핵심입니다. HTTP 4xx/5xx가 오면 curl이 실패 코드로 끝나고, 그러면 이 스텝도 실패합니다. 즉 헬스체크가 통과하지 못하면 파이프라인이 초록불이 되지 않습니다. 배포는 됐는데 서버가 죽어 있는 상태를 놓치지 않게 해줍니다.
이 요청은 https://api.nagasseum.com(ALB) → API 서버 컨테이너로 흐르니, 헬스체크가 통과했다는 건 ALB, 서버, 컨테이너, DB 경로가 다 살아있다는 뜻이기도 합니다.