최종 프로젝트 인프라 2차 구축

박병욱·2026년 8월 24일

KB IT's Your Life

목록 보기
11/11
post-thumbnail

🔄 Dockerfile부터 CD 자동 배포까지

백엔드를 이미지로 만들어 서버에 자동으로 올립니다. 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 작성

가장 먼저 만든 건 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 파일 작성

같은 위치에 .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가 /로 배포되고 있는 것을 확인할 수 있습니다.

 

📂 서버용 compose 파일 2개

로컬에서 이미지가 검증됐으면, 이제 EC2에서 이 이미지를 어떻게 띄울지를 정의합니다. API 서버용과 Batch 서버용을 나눠 두 개를 만들었습니다.

📃 docker-compose.yml (api-2a, api-2c에 배치)

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"

 

📃 docker-compose.yml (batch-2a에 배치)

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"

두 파일의 차이는 딱 세 가지입니다.

  1. 프로필: prod, apiprod, batch입니다. 같은 코드가 프로필에 따라 API 서버로도, 배치 실행기로도 동작합니다.

  2. 포트 노출: API는 ALB가 붙어야 하니 8080을 열고, Batch는 외부에서 부를 일이 없어 포트를 안 엽니다.

  3. Slack 웹훅: 배치 작업 결과 알림용이라 Batch에만 있습니다.

 

2️⃣ 꼭 챙긴 두 가지 설정

  • logging (json-file, max-size 10m, max-file 3): 이걸 안 걸면 도커 로그가 무한정 쌓여 몇 주 만에 디스크를 꽉 채웁니다. 실제로 아주 흔한 장애 원인이라, 최대 10MB * 3개로 순환하도록 제한했습니다.

  • Xmx512m (힙 제한): 서버가 t3.small(메모리 2GB)입니다. JVM은 힙을 명시하지 않으면 호스트 메모리의 약 1/4을 기본값으로 잡는데, 그러면 메모리 예측이 어긋납니다. 512MB로 못 박아 안정적으로 만들었습니다.

 

👤 서버의 .env

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_URLcharacterEncoding=UTF-8을 포함해야 합니다. 로컬에서 겪었던 한글 깨짐이 RDS에서도 재현될 수 있어서 미리 넣었습니다.

파일 권한도 소유자만 읽도록 제한했습니다.

chmod 600 ~/nagasseum/.env

 

⛓️ CD 파이프라인

이제 지금까지 만든 것들(이미지, 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 "배포 성공"

 

🔨 build-and-push job: 이미지 만들어서 올리기

  • 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 캐시에 저장해 빌드 시간을 줄입니다.

 

🛫 deploy job: 실제 서버에 반영

  • 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가 아니라 SSM인가

배포 방식으로 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-commandcommands 배열은 각 항목이 서로 독립된 셸에서 실행된다는 점이었습니다. 그래서 아래와 같이 나눠 쓰면 안 됩니다.

// 잘못된 예 - 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 경로가 다 살아있다는 뜻이기도 합니다.

0개의 댓글