GitHub Actions로 Spring Boot 배포 파이프라인 만들기

배포를 손으로 하면 두 가지가 반드시 따라옵니다. 사람마다 절차가 조금씩 다르고, 급할 때 테스트를 건너뜁니다. GitHub Actions는 이 둘을 구조적으로 막아 줍니다. 여기서는 Spring Boot 프로젝트를 기준으로 테스트-빌드-배포 파이프라인을 만드는 순서, 빌드를 빠르게 만드는 캐시 설정, 그리고 시크릿을 안전하게 다루는 방법을 정리합니다.

워크플로의 뼈대

워크플로는 저장소의 .github/workflows/ 아래 YAML 파일로 둡니다. 가장 단순한 형태부터 시작합니다.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: JDK 설정
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: gradle            # ← 의존성 캐시. 이 한 줄로 빌드가 크게 빨라진다

      - name: 테스트 실행
        run: ./gradlew test --no-daemon

세 가지만 짚으면 됩니다.

항목 의미
on 언제 돌릴 것인가. PR에도 걸어야 머지 전에 깨진 코드를 잡는다
cache: gradle 의존성을 캐시. 없으면 매번 전부 내려받아 몇 분씩 더 걸린다
--no-daemon CI는 일회성 환경이라 데몬이 의미 없다. 메모리만 더 쓴다

액션 버전은 @v4처럼 고정하십시오. @main으로 두면 어느 날 갑자기 동작이 바뀝니다.

테스트 결과를 읽히게 만들기

기본 설정은 실패하면 빨간불만 뜨고, 무엇이 왜 실패했는지 보려면 로그를 뒤져야 합니다. 결과를 PR에 표시되게 하면 훨씬 빠릅니다.

      - name: 테스트 실행
        run: ./gradlew test --no-daemon

      - name: 테스트 리포트 게시
        uses: mikepenz/action-junit-report@v4
        if: always()                        # ★ 실패해도 실행되어야 의미가 있다
        with:
          report_paths: '**/build/test-results/test/TEST-*.xml'

if: always()가 핵심입니다. 기본값은 앞 단계가 실패하면 이후를 건너뛰는데, 정작 실패했을 때 리포트가 가장 필요합니다.

통합테스트에 도커가 필요하다면 별도 설정이 필요 없습니다. ubuntu-latest 러너에는 도커가 이미 올라가 있어 Testcontainers가 그대로 돕니다.

빌드와 배포를 나눈다

테스트가 안정되면 배포를 붙입니다. 이때 job을 나누는 것이 중요합니다.

jobs:
  test:
    runs-on: ubuntu-latest
    steps: [ ... 위와 동일 ... ]

  deploy:
    needs: test                              # ★ 테스트가 통과해야만 실행
    if: github.ref == 'refs/heads/main'      # ★ main 브랜치에서만 배포
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: gradle

      - name: 빌드
        run: ./gradlew bootJar --no-daemon -x test    # 테스트는 앞 job 에서 이미 했다

      - name: 서버로 전송
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          source: "build/libs/*.jar"
          target: "/opt/app/releases"
          strip_components: 2

      - name: 배포 스크립트 실행
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          script: /opt/app/deploy.sh

needs: test와 if: github.ref == ... 두 줄이 “테스트를 통과한 main 브랜치만 배포된다”는 규칙을 강제합니다. 사람이 지키는 규칙이 아니라 파이프라인이 지키는 규칙이 됩니다.

실제 전환은 서버의 deploy.sh가 담당합니다. 파일만 올려놓고 기동과 트래픽 전환은 서버 쪽 스크립트에 맡기는 구조가 단순하고 안전합니다. 그 스크립트가 어떻게 무중단으로 전환하는지는 무중단 배포 Nginx vs HAProxy에서 다뤘습니다.

설정 파일을 jar 밖에 두고 있다면 우선순위를 함께 확인하십시오. CI가 만든 jar와 서버의 외부 설정이 어떤 순서로 합쳐지는지는 Spring Boot 설정 우선순위에 정리해 두었습니다.

시크릿 다루기

SSH 키나 DB 비밀번호를 YAML에 그대로 적으면 안 됩니다. 저장소 설정의 Settings ▸ Secrets and variables ▸ Actions에 등록하고 ${{ secrets.NAME }}으로 참조합니다.

지킬 규칙이 몇 가지 있습니다.

규칙 이유
로그에 echo 하지 않는다 GitHub이 마스킹하지만 가공하면 뚫린다 (base64 등)
pull_request 트리거에서 쓰지 않는다 외부 기여자의 PR에서 시크릿이 노출될 수 있다
배포 키는 전용 계정으로 만든다 권한을 배포에 필요한 만큼으로 제한
Environment로 한 번 더 감싼다 승인 절차와 브랜치 제한을 걸 수 있다

특히 두 번째가 중요합니다. 포크된 저장소에서 올라온 PR은 시크릿에 접근할 수 없도록 설계돼 있지만, 워크플로를 잘못 구성하면(pull_request_target 사용 등) 우회가 가능해집니다. 배포 관련 job에는 반드시 브랜치 조건을 겁니다.

운영 배포라면 Environment를 쓰는 편이 낫습니다.

  deploy:
    needs: test
    environment: production       # 승인자를 지정하면 수동 승인 후에만 진행된다
    runs-on: ubuntu-latest

빌드를 빠르게 만드는 것들

파이프라인이 느리면 사람들이 우회하기 시작합니다. 몇 가지만 챙겨도 체감이 달라집니다.

중복 실행 취소

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

같은 브랜치에 연달아 푸시하면 이전 실행을 자동으로 취소합니다. 어차피 최신 커밋만 의미가 있으므로 낭비를 줄여 줍니다.

Gradle 병렬 실행

# gradle.properties
org.gradle.parallel=true
org.gradle.caching=true

테스트를 나누기

통합테스트가 오래 걸린다면 단위테스트와 분리해 PR에서는 단위테스트만 돌리는 구성도 가능합니다.

  unit-test:
    runs-on: ubuntu-latest
    steps:
      - run: ./gradlew test --no-daemon

  integration-test:
    if: github.ref == 'refs/heads/main'      # main 에서만
    runs-on: ubuntu-latest
    steps:
      - run: ./gradlew integrationTest --no-daemon

다만 이 구성은 머지 후에야 통합테스트 실패를 알게 되는 위험이 있습니다. 통합테스트가 5분 안에 끝난다면 PR에서 같이 돌리는 편이 안전합니다. 어디까지 나눌지의 기준은 Spring Boot 테스트 전략을 참고하십시오.

자주 묻는 질문

Q1. Jenkins를 쓰고 있는데 옮길 이유가 있나요?

이미 잘 돌고 있다면 굳이 옮길 필요는 없습니다. GitHub Actions의 이점은 별도 서버를 운영하지 않아도 되고, 워크플로가 코드와 같은 저장소에 있다는 점입니다. 반대로 Jenkins는 빌드 머신을 직접 통제할 수 있고 플러그인 생태계가 넓습니다. 빌드 서버 관리에 드는 시간이 아깝다면 옮길 만합니다.

Q2. 프라이빗 저장소는 사용 시간에 제한이 있지 않나요?

무료 플랜에 월별 무료 사용 시간이 있고 초과분은 과금됩니다. 다만 일반적인 Spring Boot 프로젝트의 CI는 회당 몇 분 수준이라, 캐시만 제대로 걸어 두면 대개 무료 범위에서 해결됩니다. 사용량은 저장소 설정의 Billing에서 확인할 수 있습니다. 그래도 부족하면 self-hosted 러너를 붙이는 선택지가 있습니다.

Q3. 배포가 실패하면 자동으로 롤백되나요?

기본으로는 되지 않습니다. 롤백은 서버 쪽 배포 스크립트가 담당해야 합니다. 이전 jar를 releases 디렉터리에 남겨 두고, 헬스체크가 실패하면 직전 버전으로 되돌리는 로직을 스크립트에 넣는 구성이 일반적입니다. Actions는 “언제 무엇을 올릴지”를 정할 뿐, 전환의 안전성은 배포 스크립트의 몫입니다.

Q4. 워크플로가 자꾸 깨집니다.

가장 흔한 원인 셋입니다. 액션 버전을 고정하지 않아 업스트림 변경에 영향을 받는 경우, 로컬과 JDK 버전이 달라 컴파일이 실패하는 경우, 그리고 테스트가 실행 순서에 의존해 CI의 다른 순서에서 깨지는 경우입니다. 앞의 둘은 버전을 명시하면 해결되고, 마지막은 테스트 격리 문제라 근본적으로 고쳐야 합니다.

마무리

파이프라인의 목적은 자동화 자체가 아니라 규칙을 사람 손에서 떼어 내는 것입니다. “테스트를 통과한 main 브랜치만 배포된다”는 문장이 문서가 아니라 needs: test와 if: github.ref 두 줄로 강제되면, 급한 날에도 절차가 무너지지 않습니다.

처음부터 완성된 파이프라인을 만들려 하지 마십시오. PR에서 테스트만 돌리는 단계부터 시작해, 안정되면 빌드를 붙이고, 그다음 배포를 붙이는 순서가 실패가 적습니다.

함께 보면 좋은 글