🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

GitHub Actions로 CI/CD 파이프라인 구축 - 슬라이드 1/9
A

AI Generated

2025. 11. 30. · 22 Views

GitHub Actions로 CI/CD 파이프라인 구축

GitHub Actions를 활용하여 코드 푸시부터 배포까지 자동화하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 워크플로우 작성법부터 실전 배포 전략까지 단계별로 설명합니다.


목차

  1. GitHub Actions 시작하기
  2. 워크플로우 트리거 이해하기
  3. Job과 Step 구조 이해하기
  4. 환경 변수와 시크릿 관리
  5. Node.js 프로젝트 CI 파이프라인
  6. Docker 이미지 빌드 및 푸시
  7. 프로덕션 자동 배포 구현
  8. 재사용 가능한 워크플로우 만들기

1. GitHub Actions 시작하기

김개발 씨는 매일 같은 일을 반복하고 있었습니다. 코드를 작성하고, 테스트를 돌리고, 빌드하고, 서버에 배포하고.

어느 날 선배가 물었습니다. "아직도 수동으로 배포해요?

GitHub Actions 써보세요."

GitHub Actions는 GitHub 저장소에서 직접 워크플로우를 자동화할 수 있는 CI/CD 플랫폼입니다. 마치 충직한 비서가 정해진 시간에 정해진 일을 알아서 처리해주는 것과 같습니다.

코드를 푸시하면 테스트, 빌드, 배포까지 자동으로 진행되어 개발자는 코드 작성에만 집중할 수 있습니다.

다음 코드를 살펴봅시다.

# .github/workflows/hello.yml
name: Hello GitHub Actions

# main 브랜치에 푸시될 때 실행됩니다
on:
  push:
    branches: [main]

jobs:
  greeting:
    # Ubuntu 최신 버전에서 실행합니다
    runs-on: ubuntu-latest
    steps:
      # 저장소 코드를 가져옵니다
      - uses: actions/checkout@v4
      # 간단한 인사 메시지를 출력합니다
      - name: Say Hello
        run: echo "Hello, GitHub Actions!"

김개발 씨는 입사 6개월 차 주니어 개발자입니다. 그에게는 한 가지 고민이 있었습니다.

바로 배포 작업이었습니다. 코드를 수정할 때마다 로컬에서 테스트를 돌리고, 빌드가 성공하면 서버에 접속해서 파일을 올리고, 서비스를 재시작해야 했습니다.

어느 금요일 저녁, 급하게 핫픽스를 배포하다가 실수로 테스트를 건너뛰었습니다. 결과는 참담했습니다.

주말 내내 장애 대응을 해야 했습니다. 월요일 아침, 선배 개발자 박시니어 씨가 다가왔습니다.

"김개발 씨, 이제 수동 배포는 그만하고 GitHub Actions를 써보는 게 어때요?" 그렇다면 GitHub Actions란 정확히 무엇일까요? 쉽게 비유하자면, GitHub Actions는 마치 회사의 충직한 비서와 같습니다.

사장님이 "내가 도장 찍으면 계약서 복사해서 관련 부서에 전달해줘"라고 말해두면, 비서는 도장이 찍힐 때마다 알아서 그 일을 처리합니다. GitHub Actions도 마찬가지입니다.

"코드가 푸시되면 테스트하고 배포해줘"라고 설정해두면, 그 이후로는 자동으로 처리됩니다. GitHub Actions가 없던 시절에는 어땠을까요?

개발자들은 Jenkins, Travis CI 같은 별도의 CI/CD 서버를 구축해야 했습니다. 서버를 관리하고, 빌드 스크립트를 작성하고, GitHub과 연동하는 것만 해도 하루가 꼬박 걸렸습니다.

더 큰 문제는 이 서버도 관리 대상이라는 점이었습니다. 서버가 다운되면 배포도 멈췄습니다.

바로 이런 문제를 해결하기 위해 GitHub Actions가 등장했습니다. GitHub Actions를 사용하면 별도의 서버 없이 GitHub 내에서 모든 자동화가 가능합니다.

설정 파일 하나만 저장소에 추가하면 됩니다. 무엇보다 GitHub과 완벽하게 통합되어 있어 Pull Request, Issue, Release 등 GitHub의 모든 이벤트에 반응할 수 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 name은 워크플로우의 이름입니다.

GitHub Actions 탭에서 이 이름으로 표시됩니다. on 섹션은 언제 이 워크플로우가 실행될지를 정의합니다.

여기서는 main 브랜치에 푸시될 때 실행되도록 설정했습니다. jobs 섹션이 핵심입니다.

하나의 워크플로우는 여러 개의 job을 가질 수 있습니다. 각 job은 runs-on으로 지정된 환경에서 실행됩니다.

steps는 job 내에서 순차적으로 실행되는 단계들입니다. actions/checkout@v4는 GitHub에서 제공하는 공식 액션으로, 저장소의 코드를 가져옵니다.

이것이 없으면 우리 코드에 접근할 수 없습니다. 실제 현업에서는 어떻게 활용할까요?

대부분의 회사에서는 코드 푸시 시 자동으로 테스트를 실행하고, PR이 머지되면 스테이징 환경에 배포하고, 릴리스 태그가 생성되면 프로덕션에 배포하는 방식으로 사용합니다. 이렇게 하면 휴먼 에러를 줄이고, 배포 속도도 빨라집니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언을 듣고 GitHub Actions를 도입한 김개발 씨는 이제 금요일 저녁에도 마음 편히 배포할 수 있게 되었습니다.

테스트가 통과하지 않으면 배포 자체가 진행되지 않으니까요.

실전 팁

💡 - 워크플로우 파일은 반드시 .github/workflows 폴더에 있어야 합니다

  • YAML 문법에서 들여쓰기는 스페이스 2칸을 사용하세요

2. 워크플로우 트리거 이해하기

김개발 씨가 GitHub Actions를 처음 설정했을 때, 이상한 일이 벌어졌습니다. 브랜치를 만들기만 했는데 워크플로우가 실행된 것입니다.

"왜 이렇게 동작하는 거죠?" 박시니어 씨가 웃으며 말했습니다. "트리거 설정을 잘못했네요."

트리거는 워크플로우가 언제 실행될지를 결정하는 조건입니다. 마치 알람 시계의 설정과 같습니다.

아침 7시에 울리도록 설정하면 7시에만 울리듯이, push 이벤트에 반응하도록 설정하면 코드가 푸시될 때만 워크플로우가 실행됩니다. 상황에 맞는 트리거를 설정해야 불필요한 실행을 막을 수 있습니다.

다음 코드를 살펴봅시다.

name: Multiple Triggers Example

on:
  # 코드가 푸시될 때
  push:
    branches: [main, develop]
    paths:
      - 'src/**'  # src 폴더 변경 시에만 실행
  # PR이 생성되거나 업데이트될 때
  pull_request:
    branches: [main]
  # 매일 자정에 실행 (UTC 기준)
  schedule:
    - cron: '0 0 * * *'
  # 수동으로 실행할 때
  workflow_dispatch:

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

김개발 씨는 GitHub Actions의 기본 설정을 마쳤습니다. 그런데 이상한 일이 벌어졌습니다.

README 파일 하나만 수정했는데도 전체 빌드와 테스트가 실행되는 것이었습니다. 빌드에 10분이나 걸리는데, 문서 수정 때문에 10분을 기다려야 한다니요.

박시니어 씨가 설명을 시작했습니다. "트리거 설정을 제대로 안 해서 그래요.

언제 워크플로우가 실행될지를 정확히 지정해줘야 합니다." 트리거란 정확히 무엇일까요? 집의 현관 센서등을 생각해보세요.

누군가 지나가면 불이 켜지도록 설정해둡니다. 이 센서가 바로 트리거입니다.

GitHub Actions에서 트리거는 어떤 이벤트가 발생했을 때 워크플로우를 시작할지를 결정합니다. 가장 많이 사용하는 트리거는 push입니다.

코드가 저장소에 푸시되면 실행됩니다. branches 옵션으로 특정 브랜치만 지정할 수 있고, paths 옵션으로 특정 폴더나 파일이 변경되었을 때만 실행되도록 할 수 있습니다.

pull_request 트리거는 PR이 생성되거나 업데이트될 때 실행됩니다. 코드 리뷰 전에 자동으로 테스트를 돌리고 싶을 때 유용합니다.

PR이 머지되기 전에 문제를 발견할 수 있으니까요. schedule 트리거는 정해진 시간에 워크플로우를 실행합니다.

cron 문법을 사용합니다. 매일 밤 보안 점검을 하거나, 주간 리포트를 생성하는 데 활용할 수 있습니다.

workflow_dispatch 트리거는 수동 실행을 가능하게 합니다. GitHub 웹 인터페이스에서 버튼 하나로 워크플로우를 시작할 수 있습니다.

긴급 배포나 특수한 상황에서 유용합니다. paths 옵션이 특히 중요합니다.

김개발 씨의 문제는 바로 이 paths 설정이 없었기 때문입니다. src 폴더만 지정해두면 README를 수정해도 워크플로우가 실행되지 않습니다.

반대로 docs 폴더만 변경되었을 때 문서 빌드 워크플로우를 실행하도록 설정할 수도 있습니다. 실제 프로젝트에서는 여러 트리거를 조합해서 사용합니다.

예를 들어 개발 중에는 push로 빠른 피드백을 받고, PR 단계에서는 더 철저한 테스트를 실행하고, 프로덕션 배포는 수동으로 트리거하는 식입니다. 이렇게 하면 각 상황에 맞는 최적의 워크플로우를 구성할 수 있습니다.

하지만 주의할 점도 있습니다. schedule 트리거는 UTC 시간을 기준으로 합니다.

한국 시간으로 아침 9시에 실행하고 싶다면 UTC 0시를 설정해야 합니다. 또한 GitHub Actions는 무료 계정의 경우 월별 실행 시간에 제한이 있으므로, 불필요한 트리거는 피하는 것이 좋습니다.

김개발 씨는 paths 옵션을 추가한 후 만족스러워했습니다. "이제 README 수정할 때 10분씩 기다리지 않아도 되겠네요!"

실전 팁

💡 - push와 pull_request 트리거에는 항상 branches를 지정하세요

  • paths-ignore 옵션으로 특정 파일 변경을 무시할 수도 있습니다
  • cron 문법이 어렵다면 crontab.guru 사이트를 활용하세요

3. Job과 Step 구조 이해하기

김개발 씨가 복잡한 워크플로우를 작성하다가 막혔습니다. "테스트랑 빌드를 동시에 돌리고 싶은데, 배포는 둘 다 끝난 후에 해야 하거든요." 박시니어 씨가 화이트보드에 그림을 그리기 시작했습니다.

"Job과 Step의 차이를 알면 쉬워요."

Job은 독립적인 실행 단위이고, Step은 Job 내에서 순차적으로 실행되는 단계입니다. 마치 요리에서 Job은 각각의 요리사이고, Step은 한 요리사가 수행하는 조리 단계와 같습니다.

여러 Job은 병렬로 실행될 수 있지만, Step은 항상 순서대로 실행됩니다.

다음 코드를 살펴봅시다.

name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: npm test

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build application
        run: npm run build

  deploy:
    # test와 build가 모두 성공해야 실행됩니다
    needs: [test, build]
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to server
        run: echo "Deploying..."

김개발 씨는 워크플로우가 점점 복잡해지자 고민에 빠졌습니다. 테스트를 돌리고, 빌드를 하고, 린트를 체크하고, 보안 검사를 하고...

모든 것을 순서대로 실행하니 시간이 너무 오래 걸렸습니다. "테스트랑 빌드는 서로 상관없으니까 동시에 돌리면 안 될까요?" 박시니어 씨가 설명을 시작했습니다.

"물론 가능해요. Job을 분리하면 됩니다." Job과 Step의 관계를 이해하려면 주방을 떠올려보세요.

큰 식당의 주방에는 여러 명의 요리사가 있습니다. 한 요리사는 스테이크를 굽고, 다른 요리사는 파스타를 만들고, 또 다른 요리사는 샐러드를 준비합니다.

이렇게 동시에 일하면 손님에게 음식을 빨리 내어줄 수 있습니다. 각 요리사가 바로 Job입니다.

하지만 한 요리사 입장에서 보면 순서가 있습니다. 스테이크를 굽는 요리사는 먼저 고기에 간을 하고, 그 다음 팬을 달구고, 그 다음 고기를 굽습니다.

이 단계들을 뒤죽박죽 실행할 수는 없습니다. 이 순서대로 실행되는 단계가 바로 Step입니다.

위의 코드에서 test와 build Job은 동시에 실행됩니다. 서로 다른 러너(가상 머신)에서 독립적으로 실행되기 때문입니다.

테스트에 5분, 빌드에 5분이 걸린다면, 순차 실행 시 10분이 걸리지만 병렬 실행 시 5분이면 됩니다. needs 키워드가 핵심입니다.

deploy Job은 needs: [test, build]로 설정되어 있습니다. 이 설정이 없으면 deploy도 test, build와 동시에 시작됩니다.

하지만 테스트도 안 끝났는데 배포가 되면 큰일이겠죠. needs를 사용하면 지정된 Job들이 모두 성공한 후에만 실행됩니다.

하나의 Job 내에서 Step은 항상 순서대로 실행됩니다. checkout을 먼저 하지 않으면 코드가 없으니 테스트를 돌릴 수 없습니다.

따라서 Step의 순서는 논리적인 흐름에 맞게 배치해야 합니다. 실제 프로젝트에서는 이런 식으로 구성합니다.

첫 번째 단계로 lint, test, security-check 같은 검증 Job들을 병렬로 실행합니다. 이들이 모두 통과하면 build Job이 실행됩니다.

빌드가 성공하면 deploy-staging이 실행되고, 스테이징 테스트 후 deploy-production이 실행됩니다. 이런 파이프라인 구조를 만들 수 있습니다.

주의할 점이 있습니다. 병렬 Job이 많아질수록 동시에 사용하는 러너 수가 늘어납니다.

GitHub Actions 무료 플랜에서는 동시 실행 수에 제한이 있으므로, 무작정 Job을 분리하는 것보다는 적절한 균형을 찾아야 합니다. 김개발 씨는 워크플로우를 재구성한 후 감탄했습니다.

"와, 전체 실행 시간이 반으로 줄었어요!"

실전 팁

💡 - 서로 의존성이 없는 작업은 별도 Job으로 분리하여 병렬 실행하세요

  • needs에 여러 Job을 지정할 수 있으며, 모두 성공해야 다음 Job이 실행됩니다
  • 각 Job은 독립된 환경에서 실행되므로 파일 공유가 필요하면 artifacts를 사용하세요

4. 환경 변수와 시크릿 관리

김개발 씨가 배포 스크립트를 작성하다가 멈칫했습니다. "서버 비밀번호를 어디에 써야 하지?

코드에 직접 넣으면... 안 되겠죠?" 박시니어 씨가 고개를 끄덕였습니다.

"당연하죠. 시크릿을 쓰세요."

환경 변수는 워크플로우 실행 중에 사용되는 값을 저장하고, 시크릿은 비밀번호나 API 키 같은 민감한 정보를 안전하게 관리합니다. 마치 집 현관의 비밀번호는 가족끼리만 공유하고, 집 주소는 배달 기사에게 알려줘도 되는 것과 같습니다.

시크릿은 로그에도 노출되지 않아 안전합니다.

다음 코드를 살펴봅시다.

name: Deploy with Secrets

on:
  push:
    branches: [main]

env:
  NODE_ENV: production
  APP_NAME: my-awesome-app

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

      - name: Show environment variable
        run: echo "Deploying ${{ env.APP_NAME }}"

      # 시크릿은 절대 로그에 출력하지 마세요
      - name: Deploy to server
        env:
          SERVER_HOST: ${{ secrets.SERVER_HOST }}
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
        run: |
          echo "Deploying to server..."
          # ssh 배포 스크립트 실행

김개발 씨는 자동 배포 스크립트를 완성하기 직전이었습니다. 모든 것이 완벽했지만, 한 가지 문제가 있었습니다.

서버에 접속하려면 비밀번호가 필요한데, 이걸 어디에 적어야 할지 모르겠습니다. "코드에 직접 적으면 되지 않을까?" 이 생각을 하자마자 박시니어 씨가 옆에서 소리쳤습니다.

"절대 안 됩니다!" 코드에 비밀번호를 적으면 어떤 일이 벌어질까요? GitHub 저장소가 public이라면 전 세계 누구나 볼 수 있습니다.

private 저장소라도 팀원 전체가 볼 수 있습니다. 더 큰 문제는 Git 히스토리에 남는다는 것입니다.

나중에 삭제해도 과거 커밋을 뒤지면 찾을 수 있습니다. 바로 이런 문제를 해결하기 위해 시크릿이 존재합니다.

시크릿은 금고와 같습니다. GitHub 저장소의 Settings에서 Secrets and variables 메뉴를 통해 등록합니다.

등록된 값은 암호화되어 저장되고, 워크플로우 실행 시에만 복호화되어 사용됩니다. 심지어 실수로 echo로 출력하려 해도 GitHub이 자동으로 마스킹해서 보여줍니다.

환경 변수는 조금 다릅니다. 민감하지 않은 설정 값을 저장하는 데 사용합니다.

NODE_ENV, APP_NAME 같은 것들입니다. 워크플로우 파일에 직접 적어도 되고, 로그에 출력해도 문제없습니다.

환경 변수는 세 가지 레벨에서 정의할 수 있습니다. 최상위 env는 전체 워크플로우에서 사용됩니다.

Job 레벨의 env는 해당 Job 내에서만, Step 레벨의 env는 해당 Step에서만 사용됩니다. 하위 레벨에서 같은 이름을 정의하면 상위를 덮어씁니다.

시크릿 사용 시 주의사항이 있습니다. 시크릿 값을 다른 변수에 할당하면 마스킹이 적용되지 않을 수 있습니다.

예를 들어 시크릿을 base64로 인코딩해서 출력하면 원본이 노출됩니다. 따라서 시크릿은 최소한의 범위에서만 사용하고, 절대로 가공해서 출력하지 마세요.

실제 프로젝트에서는 이런 식으로 관리합니다. 서버 주소, SSH 키, 데이터베이스 비밀번호, API 키 등은 모두 시크릿으로 등록합니다.

앱 이름, 환경 구분, 리전 같은 설정은 환경 변수로 관리합니다. 환경별로 다른 시크릿이 필요하다면 GitHub Environments를 활용할 수 있습니다.

김개발 씨는 시크릿을 설정한 후 안심했습니다. "이제 비밀번호 걱정 없이 배포할 수 있겠네요."

실전 팁

💡 - 시크릿 이름은 대문자와 언더스코어를 사용하는 것이 관례입니다

  • 팀 전체에서 사용하는 시크릿은 Organization Secrets를 활용하세요
  • 시크릿을 업데이트해도 진행 중인 워크플로우에는 적용되지 않습니다

5. Node.js 프로젝트 CI 파이프라인

김개발 씨의 팀은 Node.js로 백엔드 서비스를 개발하고 있습니다. PR마다 테스트를 수동으로 돌리다 보니, 가끔 테스트 실패를 놓치고 머지하는 일이 생겼습니다.

"이제 CI를 제대로 구축해봅시다." 박시니어 씨의 한마디에 팀이 움직이기 시작했습니다.

**CI(Continuous Integration)**는 코드 변경 시 자동으로 빌드와 테스트를 수행하는 것입니다. 마치 공장의 품질 검사 라인처럼, 불량품이 다음 공정으로 넘어가지 않도록 막아줍니다.

Node.js 프로젝트에서는 의존성 설치, 린트 검사, 테스트 실행, 빌드 확인이 기본 CI 파이프라인입니다.

다음 코드를 살펴봅시다.

name: Node.js CI

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

jobs:
  ci:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18, 20]

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

김개발 씨의 팀에 어느 날 큰 사고가 터졌습니다. 운영 서버에 배포된 코드에서 심각한 버그가 발견된 것입니다.

알고 보니 테스트가 실패하는 코드가 PR 리뷰를 통과해서 머지된 것이었습니다. "분명히 테스트 돌려보라고 했는데..." 팀장님의 한숨이 깊어졌습니다.

문제는 수동 프로세스였습니다. 사람은 실수합니다.

바쁘면 건너뛰고, 급하면 확인을 생략합니다. 하지만 기계는 설정된 대로 정확하게 동작합니다.

CI란 바로 이런 검증 과정을 자동화하는 것입니다. 자동차 공장의 품질 검사 라인을 생각해보세요.

차가 조립되면 자동으로 검사 장비를 통과합니다. 브레이크가 제대로 작동하는지, 엔진은 정상인지, 도장은 잘 되었는지.

불량이 발견되면 라인이 멈추고, 통과해야만 다음 공정으로 넘어갑니다. 코드도 마찬가지입니다.

PR이 생성되면 자동으로 테스트가 돌아갑니다. 실패하면 머지 버튼이 비활성화됩니다.

통과해야만 코드 리뷰를 진행할 수 있습니다. 위 코드의 핵심 기능들을 살펴보겠습니다.

matrix 전략이 눈에 띕니다. Node.js 18과 20에서 동시에 테스트를 실행합니다.

이렇게 하면 버전 호환성 문제를 미리 발견할 수 있습니다. 두 버전에서 별도의 Job이 병렬로 실행됩니다.

actions/setup-node@v4는 Node.js를 설치하는 공식 액션입니다. cache 옵션을 npm으로 설정하면 node_modules를 캐싱해서 다음 실행 시 의존성 설치 시간을 단축합니다.

npm ci는 npm install보다 CI에 적합합니다. package-lock.json을 기준으로 정확한 버전을 설치하고, 기존 node_modules를 삭제한 후 깨끗하게 설치합니다.

재현 가능한 빌드를 보장합니다. 린트, 테스트, 빌드 순서가 중요합니다.

린트 검사가 가장 빠르므로 먼저 실행합니다. 문법 오류가 있다면 굳이 테스트를 돌릴 필요가 없습니다.

테스트가 통과하면 빌드를 수행합니다. 빌드는 시간이 오래 걸리므로 마지막에 배치합니다.

실제 프로젝트에서는 여기에 더 많은 것을 추가합니다. 타입 체크(tsc --noEmit), 보안 취약점 검사(npm audit), 코드 커버리지 측정, E2E 테스트 등을 파이프라인에 포함시킵니다.

단, 파이프라인이 너무 오래 걸리면 개발 속도가 느려지므로 균형이 필요합니다. 주의할 점이 있습니다.

테스트가 가끔 실패하는 flaky test가 있다면 CI의 신뢰도가 떨어집니다. "어차피 가끔 실패하니까"라며 무시하게 되죠.

flaky test는 반드시 수정하거나 제거해야 합니다. 김개발 씨 팀은 CI를 도입한 후 더 이상 테스트 실패 코드가 머지되는 일이 없어졌습니다.

"이제 안심하고 휴가 갈 수 있겠어요!"

실전 팁

💡 - PR에 대해 status check를 필수로 설정하면 테스트 실패 시 머지가 불가능합니다

  • 캐시를 활용하면 CI 시간을 크게 단축할 수 있습니다
  • npm audit을 CI에 포함시켜 보안 취약점을 조기에 발견하세요

6. Docker 이미지 빌드 및 푸시

김개발 씨는 애플리케이션을 Docker 이미지로 만들어 배포하라는 미션을 받았습니다. 로컬에서 docker build를 실행하고, docker push를 하고...

"이것도 자동화할 수 있을 텐데." 김개발 씨는 이미 GitHub Actions의 맛을 알고 있었습니다.

Docker 이미지 빌드 자동화는 코드 변경 시 자동으로 이미지를 빌드하고 레지스트리에 푸시하는 것입니다. 마치 제빵사가 반죽을 만들면 자동으로 오븐에 들어가고, 완성되면 진열대에 올라가는 것과 같습니다.

GitHub Container Registry나 Docker Hub에 이미지를 자동으로 배포할 수 있습니다.

다음 코드를 살펴봅시다.

name: Docker Build and Push

on:
  push:
    branches: [main]
    tags: ['v*']

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

김개발 씨는 새로운 과제를 받았습니다. 팀에서 개발하는 서비스를 Kubernetes에 배포하기로 했는데, 그러려면 Docker 이미지가 필요합니다.

처음에는 로컬에서 docker build를 실행하고, docker push로 레지스트리에 올렸습니다. 문제는 이 과정이 매번 반복된다는 것이었습니다.

게다가 로컬 환경마다 결과가 조금씩 다를 수도 있었습니다. "이것도 자동화해야겠어요." 김개발 씨는 GitHub Actions 워크플로우를 작성하기 시작했습니다.

Docker 이미지 빌드 자동화의 장점은 무엇일까요? 첫째, 일관성입니다.

항상 같은 환경에서 빌드되므로 "내 컴퓨터에서는 되는데"라는 문제가 사라집니다. 둘째, 속도입니다.

캐시를 활용하면 변경된 레이어만 다시 빌드합니다. 셋째, 추적성입니다.

어떤 커밋에서 만들어진 이미지인지 태그로 추적할 수 있습니다. 위 코드의 핵심 요소를 살펴보겠습니다.

docker/setup-buildx-action@v3는 Docker Buildx를 설정합니다. Buildx는 멀티 플랫폼 빌드, 캐싱 등 고급 기능을 제공합니다.

docker/login-action@v3는 컨테이너 레지스트리에 로그인합니다. GitHub Container Registry(ghcr.io)를 사용하면 GITHUB_TOKEN으로 바로 인증할 수 있어 편리합니다.

Docker Hub를 사용한다면 별도의 시크릿이 필요합니다. docker/build-push-action@v5가 핵심입니다.

context는 빌드 컨텍스트 경로이고, push를 true로 설정하면 빌드 후 자동으로 푸시합니다. tags로 여러 태그를 동시에 지정할 수 있습니다.

태그 전략이 중요합니다. 예제에서는 latest와 커밋 SHA를 태그로 사용합니다.

latest는 항상 최신 버전을 가리키고, SHA 태그는 특정 커밋의 이미지를 정확히 참조할 수 있게 해줍니다. 실제 프로덕션에서는 시맨틱 버전(v1.0.0)을 사용하는 것이 좋습니다.

캐싱이 빌드 속도를 크게 높여줍니다. cache-from: type=gha는 GitHub Actions의 캐시를 사용하겠다는 의미입니다.

이전 빌드의 레이어를 재사용해서 변경된 부분만 다시 빌드합니다. 의존성 설치 같은 시간이 오래 걸리는 레이어를 캐싱하면 빌드 시간이 수 분에서 수십 초로 줄어들 수 있습니다.

실제 프로젝트에서는 여러 레지스트리에 동시에 푸시하기도 합니다. GitHub Container Registry와 Docker Hub, AWS ECR 등에 같은 이미지를 푸시합니다.

이렇게 하면 어느 한 레지스트리에 장애가 생겨도 다른 곳에서 이미지를 가져올 수 있습니다. 김개발 씨는 워크플로우를 완성한 후 뿌듯했습니다.

"이제 코드만 푸시하면 이미지가 자동으로 만들어지네요!"

실전 팁

💡 - 멀티 스테이지 빌드를 사용하면 최종 이미지 크기를 줄일 수 있습니다

  • Dockerfile의 레이어 순서를 최적화하면 캐시 효율이 높아집니다
  • 이미지에 라벨을 추가해서 빌드 정보를 기록하세요

7. 프로덕션 자동 배포 구현

드디어 대망의 순간이 왔습니다. CI도 구축했고, Docker 이미지도 자동으로 만들어집니다.

이제 남은 것은 실제 서버에 배포하는 것입니다. 김개발 씨는 설레는 마음으로 배포 워크플로우를 작성하기 시작했습니다.

**CD(Continuous Deployment)**는 코드 변경이 자동으로 프로덕션까지 배포되는 것입니다. 마치 공장에서 제품이 만들어지면 자동으로 포장되어 배송 트럭에 실리는 것과 같습니다.

SSH를 통한 직접 배포, Docker 컨테이너 교체, Kubernetes 배포 등 다양한 방식이 있습니다.

다음 코드를 살펴봅시다.

name: Deploy to Production

on:
  push:
    branches: [main]

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

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy via SSH
        run: |
          ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} << 'EOF'
            cd /app
            git pull origin main
            npm ci --production
            pm2 reload all
          EOF

김개발 씨는 드디어 CI/CD 파이프라인의 마지막 단계에 도달했습니다. 코드를 푸시하면 테스트가 돌아가고, 이미지가 빌드되고, 이제 실제 서버에 배포만 하면 됩니다.

"수동 배포는 이제 안녕이에요!" 김개발 씨는 기대에 부풀었습니다. 하지만 박시니어 씨가 진지하게 말했습니다.

"자동 배포는 양날의 검이에요. 잘못하면 버그가 있는 코드가 바로 프로덕션에 나갈 수도 있어요." 그래서 안전장치가 필요합니다.

위 코드에서 environment: production이 그 역할을 합니다. GitHub의 Environment 기능을 사용하면 배포 전에 수동 승인을 요구하거나, 특정 브랜치에서만 배포를 허용하는 등의 보호 규칙을 설정할 수 있습니다.

또한 needs: test로 테스트가 반드시 통과해야만 배포가 진행되도록 했습니다. 테스트가 실패하면 배포는 시작조차 되지 않습니다.

SSH 배포 방식을 살펴보겠습니다. 먼저 SSH 키를 설정합니다.

시크릿에 저장된 개인 키를 파일로 저장하고, 권한을 600으로 설정합니다. ssh-keyscan으로 서버의 호스트 키를 등록해서 최초 접속 시 확인 프롬프트를 피합니다.

그 다음 SSH로 서버에 접속해서 배포 명령을 실행합니다. git pull로 최신 코드를 가져오고, npm ci로 의존성을 설치하고, pm2 reload로 무중단 재시작합니다.

실제 프로덕션 환경에서는 더 정교한 배포 전략을 사용합니다. Blue-Green 배포는 새 버전을 별도 환경에 배포한 후 트래픽을 한 번에 전환합니다.

문제가 생기면 즉시 롤백할 수 있습니다. Canary 배포는 새 버전을 일부 사용자에게만 먼저 배포해서 문제가 없는지 확인한 후 전체에 배포합니다.

주의해야 할 점이 있습니다. SSH 개인 키는 절대로 유출되면 안 됩니다.

배포 전용 계정을 만들고, 최소한의 권한만 부여하세요. 또한 배포 스크립트에 rollback 기능을 포함시키는 것이 좋습니다.

문제가 생겼을 때 빠르게 이전 버전으로 돌아갈 수 있어야 합니다. 데이터베이스 마이그레이션도 고려해야 합니다.

코드 배포와 DB 스키마 변경이 동시에 필요한 경우, 순서가 중요합니다. 보통 backward-compatible한 마이그레이션을 먼저 실행하고, 코드를 배포한 후, 정리 마이그레이션을 실행하는 식으로 진행합니다.

김개발 씨는 배포 워크플로우를 완성한 후 첫 자동 배포를 지켜봤습니다. 코드를 푸시하고 2분 후, 새 버전이 프로덕션에 반영되었습니다.

"와, 진짜 자동이네요!"

실전 팁

💡 - 프로덕션 배포에는 반드시 환경 보호 규칙을 설정하세요

  • 배포 후 헬스체크를 수행해서 서비스가 정상인지 확인하세요
  • 롤백 전략을 미리 준비해두세요

8. 재사용 가능한 워크플로우 만들기

김개발 씨의 팀에는 이제 5개의 서비스가 있습니다. 그런데 모든 서비스의 CI/CD 워크플로우가 거의 비슷합니다.

하나를 수정하면 나머지 4개도 수정해야 합니다. "복붙 지옥이에요..." 김개발 씨가 한숨을 쉬었습니다.

재사용 가능한 워크플로우는 공통 로직을 하나의 파일로 만들어 여러 저장소에서 호출하는 방식입니다. 마치 함수를 한 번 정의해두고 여러 곳에서 호출하는 것과 같습니다.

workflow_call 트리거를 사용하면 워크플로우를 모듈처럼 재사용할 수 있습니다.

다음 코드를 살펴봅시다.

# .github/workflows/reusable-ci.yml (공통 워크플로우)
name: Reusable CI

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '20'
    secrets:
      NPM_TOKEN:
        required: false

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
      - run: npm test
      - run: npm run build

# 다른 저장소에서 호출
# .github/workflows/main.yml
name: Main CI
on: [push]
jobs:
  call-ci:
    uses: my-org/shared-workflows/.github/workflows/reusable-ci.yml@main
    with:
      node-version: '18'

김개발 씨의 팀은 급속도로 성장했습니다. 마이크로서비스 아키텍처를 채택하면서 저장소가 5개, 10개, 15개로 늘어났습니다.

문제는 각 저장소마다 비슷한 워크플로우가 있다는 것이었습니다. 어느 날 보안 팀에서 연락이 왔습니다.

"npm audit을 CI에 추가해주세요." 김개발 씨는 15개 저장소를 하나씩 수정해야 했습니다. "함수처럼 한 번만 수정하면 전체에 적용되게 할 수는 없을까요?" 물론 있습니다.

재사용 가능한 워크플로우가 바로 그것입니다. 프로그래밍에서 함수를 정의하면 여러 곳에서 호출할 수 있듯이, 워크플로우도 한 번 정의해두면 여러 저장소에서 호출할 수 있습니다.

공통 로직을 하나의 파일에 담아두고, 각 저장소에서는 그 파일을 참조만 하면 됩니다. workflow_call 트리거가 핵심입니다.

일반적인 push나 pull_request 트리거 대신 workflow_call을 사용하면, 이 워크플로우는 직접 실행되지 않고 다른 워크플로우에서 호출될 때만 실행됩니다. 마치 라이브러리 함수처럼요.

inputs로 매개변수를 받을 수 있습니다. 위 예제에서는 node-version을 매개변수로 받습니다.

호출하는 쪽에서 '18'이나 '20'을 전달할 수 있고, 전달하지 않으면 기본값 '20'이 사용됩니다. 이렇게 하면 하나의 워크플로우로 다양한 상황에 대응할 수 있습니다.

secrets도 전달할 수 있습니다. private npm 패키지를 사용하려면 NPM_TOKEN이 필요합니다.

호출하는 쪽에서 시크릿을 전달하면 재사용 워크플로우 내에서 사용할 수 있습니다. 조직 전체에서 공유하려면 별도의 저장소를 만드세요.

예를 들어 my-org/shared-workflows 저장소를 만들고, 여기에 공통 워크플로우들을 모아둡니다. 각 서비스 저장소에서는 uses로 이 워크플로우를 참조합니다.

이제 공통 로직을 수정하면 모든 서비스에 자동으로 적용됩니다. 버전 관리가 중요합니다.

@main 대신 @v1 같은 태그를 사용하면, 공통 워크플로우가 변경되어도 각 서비스는 안정적인 버전을 유지할 수 있습니다. 새 버전이 검증되면 태그를 업데이트하는 식으로 안전하게 적용할 수 있습니다.

김개발 씨는 공통 워크플로우를 만든 후 뿌듯했습니다. 다음에 보안 점검을 추가하라는 요청이 와도, 한 파일만 수정하면 됩니다.

"이제 복붙 지옥에서 벗어났어요!"

실전 팁

💡 - 재사용 워크플로우 저장소는 가급적 private으로 설정하세요

  • 버전 태그를 사용해서 breaking change로부터 보호하세요
  • 필수 입력과 선택 입력을 구분해서 유연성을 높이세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#GitHub Actions#CI/CD#DevOps#Automation#Workflow#Data Science

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.