CI/CD를 붙인다고 끝이 아니었습니다. Git Flow와 GitHub Actions를 맞춰가는 과정
처음에는 그냥 수동으로 배포해도 된다고 생각했습니다.
서버에 들어가서 코드를 받고, 빌드하고, 컨테이너를 다시 띄우면 끝.
솔직히 작은 프로젝트에서는 이게 더 빠르게 느껴질 때도 있습니다.
그런데 goodtek-web을 계속 만들다 보니 생각이 바뀌었습니다.
수동 배포는 처음 몇 번은 괜찮습니다.
하지만 반복되기 시작하면 문제가 조금씩 드러납니다.
어떤 브랜치가 배포됐는지 헷갈리고,
빌드가 깨진 상태로 서버에 올라갈 수 있고,
컨테이너가 실제로 새로 뜬 건지 확인이 애매하고,
무엇보다 배포할 때마다 사람이 직접 기억해야 할 일이 늘어납니다.
작은 실수 하나가 dev 환경에서는 “아, 다시 하면 되지”로 끝날 수 있습니다.
하지만 나중에 고객이 돈을 내고 사용하는 production 환경에서는 이야기가 달라집니다.
서비스가 잠깐 멈추는 것도 경험이고, 그 경험이 쌓이면 신뢰가 무너집니다.
결국 언젠가는 무중단 배포, 이미지 기반 배포, 승인 절차, 롤백 가능한 구조까지 가야 합니다.
그래서 이번 작업은 단순히 자동 배포를 붙이는 일이 아니었습니다.
나중에 더 안전한 배포로 가기 위한 바닥을 까는 작업이었습니다.
이번에는 goodtek-web에 Git Flow 기반 CI/CD 구조를 붙였습니다.
대단한 기능을 만든 건 아니지만, 앞으로 계속 개발하고 배포하려면 꼭 필요한 작업이었습니다.
자동 배포는 편하려고만 하는 게 아니었습니다.
사람이 실수할 자리를 줄이고, 배포 과정을 기록 가능한 흐름으로 바꾸는 일이었습니다.
왜 이걸 해야 하는가
수동 배포의 가장 큰 문제는 “한 번은 된다”는 점입니다.
한 번 성공하면 괜찮아 보입니다.
두 번도 됩니다.
세 번쯤 되면 손에 익습니다.
문제는 그다음입니다.
누가, 언제, 어떤 커밋을, 어떤 순서로, 어떤 환경 변수와 함께 배포했는지 점점 흐려집니다.
작은 수정 하나를 올리는 데도 괜히 긴장하게 됩니다.
이번 작업 전에 제가 신경 쓰던 문제는 대략 이랬습니다.
|
문제 |
수동 배포에서 생기는 일 |
자동화로 바꾸려는 이유 |
|---|---|---|
|
브랜치 혼동 |
develop인지 task 브랜치인지 헷갈림 |
배포 대상을 고정하기 위해 |
|
빌드 누락 |
로컬에서는 됐는데 서버에서 깨짐 |
PR과 배포 전 검증을 분리하기 위해 |
|
컨테이너 재사용 |
새 코드인데 기존 컨테이너가 살아 있음 |
배포 후 상태를 명확히 확인하기 위해 |
|
추적 어려움 |
어떤 커밋이 올라갔는지 확인이 늦음 |
GitHub Actions 기록을 남기기 위해 |
여기서 핵심은 “자동으로 배포된다”가 아니었습니다.
배포 흐름을 사람이 기억하지 않아도 되게 만드는 것.
그리고 나중에 production 배포까지 확장할 수 있게 만드는 것.
그게 이번 작업의 진짜 목적이었습니다.

먼저 브랜치 전략부터
goodtek-web은 Git Flow를 기반으로 가져가기로 했습니다.
처음에는 단순하게 생각했습니다.
“develop에 머지되면 dev 서버에 자동 배포되게 하면 되지 않을까?”
그런데 막상 해보니 그 전에 정해야 할 게 많았습니다.
브랜치는 어떻게 나눌지, PR 검증은 어디서 할지, GitHub Actions는 언제 실행할지, dev 서버에서는 코드를 직접 빌드할지, 이미지를 빌드할지, 배포 후에는 정말 새 컨테이너가 뜬 게 맞는지까지 하나씩 확인해야 했습니다.
최종적으로 브랜치 흐름은 이렇게 잡았습니다.
task/*
→ PR
→ develop
→ dev.goodtek.xyz 자동 배포
→ 검증
→ main
→ production 배포
브랜치 역할은 단순하게 정리했습니다.
|
브랜치 |
역할 |
배포 대상 |
|---|---|---|
|
task/* |
실제 작업 브랜치 |
없음 |
|
develop |
개발 통합 브랜치 |
dev.goodtek.xyz |
|
main |
운영 배포용 브랜치 |
goodtek.xyz 예정 |
여기서 중요한 건 작업 브랜치와 배포 브랜치를 섞지 않는 것이었습니다.
task 브랜치는 작업을 위한 공간입니다.
develop은 dev 환경에 올라갈 통합 브랜치입니다.
main은 나중에 production으로 갈 운영 브랜치입니다.
이렇게 나누고 나니 GitHub Actions도 역할을 나눌 수 있었습니다.
goodtek-web
├─ task/*
│ └─ 실제 작업
├─ develop
│ └─ dev 서버 자동 배포
└─ main
└─ production 배포 예정처음에는 브랜치 전략이 조금 과한가 싶었습니다.
하지만 배포를 붙이기 시작하니 오히려 이 구분이 없으면 더 빨리 흔들릴 것 같았습니다.
브랜치 전략은 Git 규칙이 아니라 배포 사고를 줄이는 기준에 가까웠습니다.
CI는 PR을 막는 장치
처음에는 ci.yml이 pull_request와 push 둘 다에서 실행되고 있었습니다.
겉으로 보면 나쁘지 않아 보였습니다.
PR에서도 돌고, develop에 머지된 뒤에도 다시 도니까요.
그런데 역할이 애매했습니다.
CI가 PR 검증인지, 배포 전 검증인지, 아니면 그냥 모든 곳에서 도는 안전장치인지 흐려졌습니다.
그래서 CI의 역할을 하나로 줄였습니다.
CI는 PR을 머지하기 전에 확인하는 장치다.
최종적으로 ci.yml은 PR 검증 전용으로 정리했습니다.
name: CI
on:
pull_request:
branches:
- develop
- main
jobs:
validate:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11.5.0
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm build이제 CI는 이 흐름에서만 실행됩니다.
task/* → PR → develop
develop → PR → main
즉, 머지 전에 최소한 lint와 build가 통과해야 합니다.
아직 GitHub Free private organization에서는 branch protection 강제 적용에 제한이 있어서 완전한 강제는 못 걸었습니다.
이 부분은 나중에 GitHub Team으로 올리면 적용하기로 했습니다.
지금은 운영 규칙으로 가져갑니다.
- develop/main에 직접 push하지 않기
- 항상 task 브랜치에서 작업하기
- PR 만들기
- CI 확인하기
- develop 머지 후 Deploy Dev 확인하기
완벽한 자동 강제는 아니지만, 지금 단계에서는 흐름을 먼저 고정하는 것이 더 중요했습니다.
Deploy Dev는 develop push 이후에만
처음에는 workflow_run을 사용하려고 했습니다.
CI 성공
→ Deploy Dev 실행
겉으로는 자연스러운 구조였습니다.
저도 처음에는 이게 제일 깔끔해 보였습니다.
그런데 실제로 해보니 문제가 생겼습니다.
GitHub Actions의 workflow_run은 default branch 기준 제약이 있어서, default branch가 main인 상태에서 develop 중심 dev 배포를 구성하면 기대한 대로 동작하지 않는 경우가 있었습니다.
실제로 PR을 머지했는데 CI만 돌고 Deploy Dev가 실행되지 않았습니다.
여기서 잠깐 멈췄습니다.
“workflow끼리 억지로 연결하려고 해서 더 복잡해지는 거 아닌가?”
결국 구조를 바꿨습니다.
Deploy Dev workflow 안에서 validate와 deploy를 직접 연결하자.
최종 흐름은 이렇게 정리했습니다.
develop push
→ Deploy Dev workflow 실행
→ Validate job 실행
→ Validate 성공
→ Deploy to dev job 실행
→ health check즉, PR 단계의 CI와 develop push 이후의 Deploy Dev를 분리했습니다.

Deploy Dev workflow의 핵심은 needs였습니다.
jobs:
validate:
name: Validate
runs-on: ubuntu-24.04
steps:
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm build
deploy-dev:
name: Deploy to dev
needs: validate
runs-on: ubuntu-24.04
environment: dev
steps:
- name: Deploy on dev server
run: bash scripts/deploy-dev.sh이 구조가 훨씬 이해하기 쉬웠습니다.
PR 단계에서는 ci.yml
develop에 머지된 뒤에는 deploy-dev.ymldeploy-dev.yml 안에서는 validate → deploy-dev
역할이 분명해졌습니다.
비슷해 보이지만 둘은 다릅니다.
|
구분 |
실행 시점 |
역할 |
|---|---|---|
|
CI |
PR 머지 전 |
깨진 코드를 막는 장치 |
|
Deploy Dev Validate |
develop push 후 |
배포 직전 다시 확인하는 장치 |
|
Deploy Dev |
validate 성공 후 |
dev 서버에 실제 반영하는 장치 |
여기서 생각이 조금 바뀌었습니다.
처음에는 “검증을 왜 두 번 하지?”라고 생각했습니다.
하지만 지금은 다르게 봅니다.
PR 검증은 문 앞에서 막는 장치이고,
배포 전 검증은 서버에 올리기 전 마지막 확인입니다.
둘은 비슷하지만, 같은 역할은 아니었습니다.
dev 서버는 빌드 머신이 아니었다
dev 서버 배포 스크립트도 다시 봤습니다.
처음에는 dev 서버에서 이런 작업까지 하고 있었습니다.
- pnpm install
- pnpm build
- podman-compose up -d –build
그런데 생각해보면 GitHub Actions에서도 이미 install과 build를 하고 있었습니다.
Dockerfile 안에서도 이미지 빌드 과정에서 다시 install/build가 들어갑니다.
결과적으로 이런 구조였습니다.
GitHub Actions에서 build
→ dev 서버 host에서 build
→ Dockerfile 안에서 build여기서 현타가 왔습니다.
빌드를 꼼꼼히 하는 게 아니라, 그냥 여기저기서 반복하고 있던 겁니다.
그래서 dev 서버의 역할을 줄였습니다.
dev 서버는 소스 동기화, 컨테이너 재생성, health check에 집중한다.
최종적으로 scripts/deploy-dev.sh의 핵심 흐름은 이렇게 잡았습니다.
git fetch --prune origin
git checkout develop
git reset --hard "$DEPLOY_REF"
$COMPOSE_CMD --profile with-web down
$COMPOSE_CMD --profile with-web up -d --build
health_check "web" "$WEB_HEALTHCHECK_URL"
health_check "api" "$API_HEALTHCHECK_URL"여기서 중요한 포인트는 두 가지였습니다.
첫째, 서버 host에서 pnpm install, pnpm build를 하지 않습니다.
둘째, up -d --build만 하지 않고 먼저 down을 합니다.
처음에는 새 이미지가 빌드됐는데도 기존 컨테이너가 계속 살아 있어서 dev 사이트가 바뀌지 않는 문제가 있었습니다.
그때는 조금 당황했습니다.
“분명 배포는 성공했는데 왜 화면은 그대로지?”
결국 컨테이너 재생성 흐름을 명확히 해야 했습니다.
새 이미지를 만들었다고 해서 항상 새 컨테이너가 기대한 방식으로 뜨는 것은 아니었습니다.
down → up -d --build로 바꾸고 나서야 실제 컨테이너가 새로 생성되는 것을 확인했습니다.
잠깐의 502를 어떻게 볼 것인가
배포 중에 한 번 이런 상황이 있었습니다.
health check (web): https://dev.goodtek.xyz/ko
waiting for web... (1/30)
health check OK (web)처음에는 찜찜했습니다.
502가 한 번이라도 났다는 건 뭔가 잘못된 것처럼 보였기 때문입니다.
하지만 dev 환경에서 down → up 방식으로 컨테이너를 재생성하면, Caddy가 잠깐 web 컨테이너에 연결하지 못하는 순간이 생길 수 있습니다.
그래서 health check를 한 번만 하지 않고 retry로 구성했습니다.
일시적 502
→ retry
→ 정상 응답
→ 배포 성공dev 환경에서는 이 정도는 받아들일 수 있다고 봤습니다.
지금은 dev 서버이고, 목적은 빠르게 검증하는 것입니다.
하지만 production에서는 다르게 봐야 합니다.
고객이 돈을 내고 사용하는 환경에서는 잠깐의 502도 사용자 경험입니다.
그래서 나중에는 blue/green 배포, GHCR 이미지 pull 기반 배포, 무중단 전환, 롤백 가능한 구조로 가야 합니다.
이번 작업은 그 전 단계입니다.
무중단 배포를 바로 구현한 것은 아니지만,
무중단 배포로 갈 수 있는 흐름을 지금부터 정리한 것에 가깝습니다.

GitHub Secrets도 dev 기준으로 분리
GitHub Actions에서는 dev Environment Secrets를 사용했습니다.
필요한 값은 대략 이런 것들이었습니다.
- DEV_SSH_HOST
- DEV_SSH_PORT
- DEV_SSH_USER
- DEV_SSH_PRIVATE_KEY
- DEV_DEPLOY_PATH
- DEV_WEB_HEALTHCHECK_URL
- DEV_API_HEALTHCHECK_URL
dev 서버의 실제 배포 경로는 이렇게 잡았습니다.
/home/web/goodtek/goodtek-webhealth check URL도 dev 환경 기준으로 분리했습니다.
https://dev.goodtek.xyz/ko
https://dev.goodtek.xyz/api/health환경 값을 GitHub Secrets로 분리해두니 workflow 안에 민감한 값이 들어가지 않았습니다.
이것도 작은 차이처럼 보이지만, 나중에 production environment를 추가할 때 중요해질 것 같습니다.
dev와 production이 같은 방식으로 배포되더라도, 사용하는 secret과 승인 절차는 달라야 하니까요.
실제로 확인한 것
PR을 머지한 뒤 GitHub Actions에서 Deploy Dev가 실행됐습니다.
Deploy Dev 안에는 두 단계가 있었습니다.
- Validate
- Deploy to dev
dev 서버에서도 상태를 확인했습니다.
git status
git log --oneline --decorate -5
podman ps결과적으로 develop 브랜치는 origin/develop과 동기화됐고, working tree도 clean 상태였습니다.
컨테이너도 새로 뜬 것을 확인했습니다.
여기서 이번 구조가 실제로 동작한다고 판단했습니다.
정리하면 현재 dev 배포 흐름은 이렇습니다.
task/* 브랜치
→ PR 생성
→ CI 통과
→ develop 머지
→ Deploy Dev 실행
→ Validate 통과
→ dev 서버 배포
→ web/API health check이제 최소한 dev 환경에서는 “내가 서버에 들어가서 뭘 했더라?”를 덜 고민해도 됩니다.
물론 완성은 아닙니다.
하지만 바닥은 깔렸습니다.
아직 남은 것들
이번에 dev CI/CD는 정리됐습니다.
하지만 전체 배포 구조가 완성된 것은 아닙니다.
남은 작업은 꽤 명확합니다.
- develop/main 브랜치 보호 규칙
- main → production 배포 workflow
- production environment secrets
- 운영 배포 승인 절차
- GHCR 기반 이미지 배포 전환
- blue/green 또는 무중단 배포 구조
특히 branch protection은 바로 적용하려고 했지만, GitHub Free private organization에서는 강제 적용이 제한된다는 메시지를 확인했습니다.
그래서 이건 나중에 GitHub Team으로 업그레이드한 뒤 적용하기로 했습니다.
지금은 규칙을 문서화하고, 작업 습관으로 먼저 가져갑니다.
직접 push 하지 않기
task 브랜치에서 작업하기
PR 만들기
CI 확인하기
develop 머지 후 dev 배포 확인하기조금 수동적인 규칙이지만, 아예 기준이 없는 것보다는 훨씬 낫습니다.
그리고 기준이 생기면 나중에 자동화로 옮기기 쉽습니다.
정리하면서 남은 생각
CI/CD는 단순히 “자동 배포 버튼”을 만드는 일이 아니었습니다.
브랜치 전략, PR 검증, 배포 트리거, 서버 상태, 컨테이너 재생성, health check까지 흐름을 맞춰야 했습니다.
처음에는 develop에 머지되면 dev 서버에 자동 배포되게 하면 끝이라고 생각했습니다.
그런데 실제로는 그 사이에 꽤 많은 판단이 필요했습니다.
특히 기억에 남는 건 이 부분입니다.
CI는 머지 전에 막는 장치Deploy workflow의 validate는 배포 전에 다시 확인하는 장치
둘은 비슷해 보이지만 역할이 다릅니다.
이번 작업으로 goodtek-web은 최소한 dev 환경에서는 안정적인 흐름을 갖게 됐습니다.
작업
→ PR
→ CI
→ develop
→ Validate
→ dev 배포
→ health check아직 production 배포와 GHCR 기반 이미지 배포, 그리고 무중단 배포는 남아 있습니다.
하지만 이제는 다음 단계가 조금 더 선명해졌습니다.
dev에서 흐름을 고정하고,
production에서는 더 안전하게 만들고,
나중에는 고객이 사용 중이어도 끊기지 않는 배포까지 가는 것.
오늘도 화려한 기능은 아니었습니다.
하지만 goodtek을 계속 쌓아가기 위해서는 이런 바닥 작업이 필요했습니다.
눈에 잘 안 보이지만, 나중에 문제가 생겼을 때 제일 먼저 고마워하게 될 작업이 이런 것 아닐까 싶습니다.
개발 중인 goodtek website는 dev.goodtek.xyz에서 확인하실 수 있습니다.
여정을 함께 하실 분들은 언제나 환영입니다. 연락 주세요.