본문 바로가기
won2dev-log
HomeArchiveTagsCategoriesAboutProjects
HomeArchiveTagsCategoriesAboutProjects
won2dev-logwon2dev-logwon2dev-log

비전공 개발자의 로그 | won2dev-log

Navigation
  • Home
  • Archive
  • About
  • Projects
Categories
  • Docs
  • TIL
  • Project
  • Automation
  • Git · GitHub
더보기
Tags
  • TIL
  • Java
  • Spring
  • Backend
  • n8n
더보기
About

기록을 거름 삼아 공유는 성장을 만든다.

LicensePrivacy
© won2dev 2026. All rights reserved.
Home›TIL›머지는 늦게, 배포는 먼저 — stage 브랜치 CI/CD 설계
TIL

머지는 늦게, 배포는 먼저 — stage 브랜치 CI/CD 설계

won2dev·2026년 08월 07일
#TIL

자세한 프로젝트 내용은 포트폴리오 — 모니 - 초보 투자자를 위한 AI 모의투자 플랫폼 에서 확인할 수 있습니다.

배포 환경에서 문제가 생기는 건 머지 이후가 아니라 머지 이전에 알아야 한다. 그 생각 하나로 CI/CD 구조가 결정됐다.

흐름

stage 브랜치에 PR을 올리면 머지 없이 바로 CI → CD가 순서대로 돌아간다.

머지 전에 실제 배포 환경에서 동작을 확인할 수 있다. 문제가 있으면 롤백되고 PR만 닫으면 된다. 핫픽스가 필요할 때 특히 유용한 구조다.


CI와 CD를 같은 워크플로우 안에서 분리한 이유

워크플로우 파일은 ci.yml과 cd-service.yml 두 개다. CI가 끝난 뒤 CD를 workflow_call로 호출하는 방식으로 연결했다.

yaml
deploy:
  needs: build
  if: >
    github.event_name == 'pull_request' &&
    github.base_ref == 'stage' &&
    needs.build.outputs.has_changes == 'true'
  uses: ./.github/workflows/cd-service.yml
  with:
    changed_services: ${{ needs.build.outputs.changed_services }}
deploy:
  needs: build
  if: >
    github.event_name == 'pull_request' &&
    github.base_ref == 'stage' &&
    needs.build.outputs.has_changes == 'true'
  uses: ./.github/workflows/cd-service.yml
  with:
    changed_services: ${{ needs.build.outputs.changed_services }}

(changed_services)를 CD로 넘겨서 변경된 서비스만 배포한다. 11개 서비스 전체를 매번 올리지 않아도 된다.


왜 이 구조를 선택했나

이유는 단순하다. 초기 개발 단계에서 배포까지 걸리는 시간을 줄이고, 문제가 생겼을 때 핫픽스를 빠르게 치고 빠질 수 있어야 했다.

머지 후에 문제를 발견하면 되돌리는 비용이 크다. PR 단계에서 실제 배포 환경에 올려보고, 문제가 있으면 롤백되고 PR만 닫으면 끝나는 구조가 초기 개발 속도에 맞았다.

이 패턴은 Jenkins나 다른 CI/CD 도구에도 그대로 적용할 수 있다고 본다. 특히 초기 오픈 서비스처럼 빠른 반복이 필요한 환경에서 유용한 구조라고 생각한다.


Artifacts로 빌드 결과 넘기기

CI에서 빌드한 JAR를 CD가 다시 빌드하지 않도록 Artifacts로 전달한다.

GitHub Artifacts는 워크플로우 실행 중 생성된 파일을 저장해서, 같은 실행 내 다른 job이나 이후 워크플로우에서 다시 가져다 쓸 수 있게 하는 기능이다. job이든 workflow_call로 이어지는 워크플로우든 서로 격리된 환경에서 돌기 때문에, build 단계에서 만든 파일을 deploy 단계에서 쓰려면 파일시스템을 공유할 방법이 없는데 Artifacts가 이 역할을 한다. 업로드된 Artifact는 retention-days로 지정한 기간 동안 보관되는데, 기본은 90일이지만 CI→CD 사이에서만 잠깐 쓰고 버릴 파일이라 1일로 짧게 잡았다.

CI 단계에서 빌드 결과를 업로드하고:

yaml
- name: Upload JAR artifacts
  uses: actions/upload-artifact@v7
  with:
    name: service-jars
  path: |
    */build/libs/*.jar
    !*/build/libs/*-plain.jar
  retention-days: 1
- name: Upload JAR artifacts
  uses: actions/upload-artifact@v7
  with:
    name: service-jars
  path: |
    */build/libs/*.jar
    !*/build/libs/*-plain.jar
  retention-days: 1

CD 단계에서 먼저 다운로드를 시도하고, 실패하면 직접 빌드한다:

yaml
- name: Download JAR artifacts
  id: download-artifact
  uses: actions/download-artifact@v8
  with:
    name: service-jars
  continue-on-error: true

- name: Build
  if: steps.download-artifact.outcome == 'failure'
  run: ./gradlew bootJar -x test
- name: Download JAR artifacts
  id: download-artifact
  uses: actions/download-artifact@v8
  with:
    name: service-jars
  continue-on-error: true

- name: Build
  if: steps.download-artifact.outcome == 'failure'
  run: ./gradlew bootJar -x test

CI → CD가 같은 워크플로우 실행 안에서 이어지는 경우 JAR를 그대로 쓰고, CD를 단독으로 수동 실행할 때는 직접 빌드한다. 중복 빌드를 없애면서 단독 실행도 가능한 구조다.


동시 PR 제한

stage 브랜치에 PR이 동시에 여러 개 열리면 배포가 꼬인다. 그래서 PR이 열릴 때 열린 PR이 이미 1개 이상이면 CI를 막는다.

yaml
- name: Check stage PR limit
  if: github.event_name == 'pull_request' && github.base_ref == 'stage'
  run: |
    COUNT=$(gh pr list --base stage --state open --json number --jq length)
    if [ "$COUNT" -gt 1 ]; then
      echo "stage 브랜치 동시 PR은 1개만 허용됩니다."
      exit 1
    fi
- name: Check stage PR limit
  if: github.event_name == 'pull_request' && github.base_ref == 'stage'
  run: |
    COUNT=$(gh pr list --base stage --state open --json number --jq length)
    if [ "$COUNT" -gt 1 ]; then
      echo "stage 브랜치 동시 PR은 1개만 허용됩니다."
      exit 1
    fi

초과 시 Discord로 알림도 간다. 배포 순서가 꼬이지 않도록 하는 최소한의 안전장치다.


헬스체크와 롤백

배포 후 각 서비스의 /actuator/health를 15초 간격으로 최대 15번 확인한다. 하나라도 실패하면 이전 SHA의 이미지로 자동 롤백된다.

yaml
- name: Save pre-deploy state
  run: |
    PREV=$(ssh service "cat ~/moni/.deployed-sha 2>/dev/null || true")
    echo "PREV_SHA=${PREV}" >> $GITHUB_ENV
- name: Save pre-deploy state
  run: |
    PREV=$(ssh service "cat ~/moni/.deployed-sha 2>/dev/null || true")
    echo "PREV_SHA=${PREV}" >> $GITHUB_ENV

배포 전에 현재 SHA를 저장해두고, 롤백이 필요하면 ECR에서 해당 SHA 이미지를 다시 pull해서 올린다. 머지 전에 배포하는 구조라 롤백이 부담 없다. 문제가 있으면 그냥 PR 닫으면 된다.


SSH 터널링으로 프라이빗 EC2 배포

서비스 EC2는 프라이빗 서브넷에 있어서 직접 SSH가 안 된다. Bastion Host를 경유해서 들어간다.

yaml
- name: Setup SSH
  run: |
    printf '%s\n' \
      'Host bastion' \
      '  HostName ${{ secrets.BASTION_HOST }}' \
      '  User ubuntu' \
      '  IdentityFile ~/.ssh/id_rsa' \
      'Host service' \
      '  HostName ${{ secrets.SERVICE_HOST }}' \
      '  User ec2-user' \
      '  IdentityFile ~/.ssh/id_rsa' \
      '  ProxyJump bastion' \
      >> ~/.ssh/config
- name: Setup SSH
  run: |
    printf '%s\n' \
      'Host bastion' \
      '  HostName ${{ secrets.BASTION_HOST }}' \
      '  User ubuntu' \
      '  IdentityFile ~/.ssh/id_rsa' \
      'Host service' \
      '  HostName ${{ secrets.SERVICE_HOST }}' \
      '  User ec2-user' \
      '  IdentityFile ~/.ssh/id_rsa' \
      '  ProxyJump bastion' \
      >> ~/.ssh/config

ProxyJump bastion 한 줄로 Bastion을 경유하는 SSH 터널이 설정된다. 이후 ssh service 명령이 자동으로 Bastion을 거쳐서 프라이빗 EC2로 들어간다.

공유하기
이전 글Terraform을 처음 써보고 인프라가 달라 보이기 시작했다다음 글 Refresh Token 재발급에서 Race Condition을 마주쳤다

목차

  • 흐름
  • CI와 CD를 같은 워크플로우 안에서 분리한 이유
  • 왜 이 구조를 선택했나
  • Artifacts로 빌드 결과 넘기기
  • 동시 PR 제한
  • 헬스체크와 롤백
  • SSH 터널링으로 프라이빗 EC2 배포

카테고리

TIL

태그

#TIL

최근 글

어드민은 왜 따로 분리했나 — Okta OIDC와 Thymeleaf로 어드민 서비스 구성한 이유레이어드와 헥사고날, 하나를 고르지 않아도 된다Terraform을 처음 써보고 인프라가 달라 보이기 시작했다Refresh Token 재발급에서 Race Condition을 마주쳤다GraalVM Native Image가 MSA에서 주목받는 이유