Deallo

인프라 · 배포

릴리스·배포 파이프라인 자동화 - 9단계를 2단계로

매주 1~2회, 릴리스마다 9단계를 손으로 반복했다. 이걸 GitHub Actions 체인으로 묶어 2단계로 줄이기까지 20일이 걸렸다. 코드를 짠 시간보다 "왜 안 되지"를 파고든 시간이 훨씬 길었다. 그리고 이 파이프라인 위에, 배포가 끝나면 변경 내역을 AI가 정리해 슬랙에 발행하는 배포 노트 자동화(Phase 4)까지 얹었다.

릴리스 한 번에 9단계, 12~17분

릴리스 한 번에 이걸 손으로 했다.

# 릴리스 PR 만들기 (5단계)
git worktree add ../release-temp develop
git checkout -b release/v26.04.30 origin/develop
git merge origin/main -s ours      # ← 빼먹으면 phantom conflict
git push -u origin release/v26.04.30
# GitHub 웹에서 PR 생성
# 머지 후 (4단계)
6. PR 머지 → CodeBuild 자동 빌드 (몇 분 대기)
7. Slack에서 새 이미지 태그 복사
8. service-config 리포 yaml의 image 태그 손으로 교체 → main 직접 push
9. ArgoCD UI에서 SYNC 클릭

그중에서도 사고가 잦던 지점들:

  • -s ours를 빼먹으면 develop과 main이 충돌도 없는데 git이 충돌이라 우기는 _phantom conflict_가 처음부터 다시 시작됐다
  • 같은 날 두 번째 배포면 v26.04.30.1 같은 suffix를 머릿속으로 카운트했다
  • yaml 태그를 손으로 갈아끼우다 다른 라인을 건드릴 위험
  • service-config를 PR 없이 main에 직접 push - 리뷰 우회
  • 브랜치명 오타·옵션 누락·태그 오타 - 휴먼 에러 지점이 5곳 이상

무엇이 좋아졌나 (정량)

지표BeforeAfter
사람 액션 단계92 (78%↓)
1회 소요12~17분~30초 + 버튼 클릭 (95%↓)
외울 명령어5개 + -s ours0개
휴먼 에러 지점5곳+0

연간 약 16시간(개인), 팀 5인 확산 시 약 80시간. 덤으로 service-config를 main에 직접 push하던 패턴이 사라져 감사 추적성도 올랐다.

9단계를 어떻게 2단계로 - 체인을 3개 Phase로 잘랐다

한 번에 다 자동화하려 들지 않았다. 9단계가 지나는 세 시스템(앱 레포 → 배포 설정 레포 → 클러스터)을 각각 한 Phase씩 자동화해 하나의 체인으로 이었다. 사람이 누르는 건 이제 둘뿐 - 나머지는 체인이 받는다.

[① Run workflow 클릭]                      ← 사람
        ↓  Phase 1
   Release PR 자동 생성 (develop → main)
[② PR 리뷰 + 머지]                          ← 사람
        ↓  (이하 전부 자동)
   CodeBuild: Docker 빌드 → ECR push         ← 기존 AWS
        ↓  Phase 2
   CodeBuild가 GitHub로 dispatch 발사
        ↓  Phase 2
   bump-image: yaml image 태그 교체 → main 자동 commit
        ↓  Phase 3
   ArgoCD: web 자원만 한정 sync

   k8s rolling update → 새 pods Healthy

각 Phase가 9단계 중 무엇을 없앴는지가 정확히 갈린다.

Phase자동화한 것없앤 수동 단계
Phase 1release-pr 워크플로 (deallo-web)워크트리·브랜치·-s ours merge·push·PR 생성 (①~⑤)
Phase 2CodeBuild dispatch + bump-image listener (service-config)Slack 태그 복사·yaml 손편집·main 직접 push (⑦~⑧)
Phase 3bump-image에 ArgoCD sync step 추가ArgoCD UI sync 클릭 (⑨)

남은 사람 단계는 Run workflow 클릭PR 머지(⑥) 둘뿐. 그리고 처음부터 100%를 노리지 않았다 - 반자동(PR 링크만 출력) → 90%(마지막 sync만 수동) → 100%로 단계를 올리며 Phase를 따로 검증했다. 그래서 막혀도 _어느 구간_이 깨졌는지 바로 좁혀졌다.

결정의 흔적 - 막힐 때마다 갈아엎었다

자동화는 한 번에 안 됐다. 막힐 때마다 원인을 파고 결정을 바꿨다.

① "PR은 만들어지는데 CI가 안 도네" - GITHUB_TOKEN의 함정

차수시도결과
1차GITHUB_TOKEN으로 PR 자동 생성PR은 생성되나 CI가 트리거 안 됨
2차PR 자동 생성 포기, 생성 링크만 출력 → 사람이 클릭CI 정상 (반자동)
3차RELEASE_PR_PAT(user-context) 발급CI 트리거 + 완전 자동

원인: GitHub는 GITHUB_TOKEN으로 만든 PR이 다른 워크플로를 트리거하지 않게 막는다(자동화 무한 루프 방지). 이걸 모르고 한참 헤맸다. 토큰 3종(GITHUB_TOKEN / PAT / GitHub App)의 트레이드오프를 따져 - 개인·소규모엔 PAT, 조직 단위엔 App - PAT로 갔다.

② "같은 버전이 두 번 만들어지네" - N차 카운트를 두 번 갈아엎다

차수방식문제
1차release 브랜치가 있으면 .1 붙이기머지 후 브랜치 자동삭제 룰 때문에 이미 머지됐어도 브랜치가 없어 같은 버전 재생성
2차브랜치가 아니라 PR 제목으로 카운트 (OPEN + MERGED 합산)해결
OPEN=$(gh pr list --state open --search "[RELEASE] v26.04.30 in:title" --jq length)
MERGED=$(gh pr list --state merged --search "[RELEASE] v26.04.30 in:title" --jq length)
COUNT=$((OPEN + MERGED))   # closed-without-merge는 제외 - 취소된 시도 이름 재사용 OK

상태(브랜치 존재)가 아니라 PR 이력(기록)으로 카운트해야 삭제 룰에 안 깨진다는 걸 배웠다.

③ "둘이 동시에 누르면?" - concurrency

영희와 철수가 같은 순간 "Run workflow"를 누르면, 둘 다 "오늘 버전 없네" 카운트 → 같은 브랜치명으로 push 충돌. concurrency 그룹으로 한 번에 하나만 돌게 큐잉했다.

concurrency: { group: release-pr, cancel-in-progress: false }

④ "전체를 자동 sync하면 위험" - 자원 한정 배포

ArgoCD의 deallo-prod는 web/api/ws가 한 묶음이라, 전체 자동 sync를 켜면 web 배포가 api·ws의 수동 변경(drift)까지 건드린다. 그래서 자동 sync 토글을 켜지 않고 web Deployment만 자원 한정 sync로 좁혔다.

argocd app sync deallo-prod --resource apps:Deployment:web-deallo

절반은 레포 밖이었다 - 권한을 하나씩 얻어내며

이 자동화는 deallo 웹 레포 안에서 끝나는 일이 아니었다. 체인이 지나가는 시스템이 넷 - 앱 레포, 배포 설정 레포, AWS(CodeBuild·Secrets Manager·IAM), 그리고 ArgoCD/k8s 클러스터 - 인데, FE에겐 그 어디에도 기본 권한이 없었다. 그래서 코드만큼이나 권한도 하나씩 마련해야 했다.

  • 자동 PR 생성용 fine-grained PAT는 배포 설정 레포 한정 권한으로 조직 owner의 승인을 받아 발급
  • 그 토큰을 AWS Secrets Manager에 등록하고, CodeBuild 서비스 롤에는 그 시크릿만 읽을 수 있는 IAM 인라인 정책을 추가
  • 마지막 자동 sync는 백엔드 인프라 담당과 협의해 ArgoCD 계정·토큰을 새로 만들고, 빌드 환경에서 ArgoCD 서버로 네트워크가 열리는지부터 확인
  • 검증하려고 프로덕션 클러스터를 kubectl로 직접 조회할 IAM 접근도 이때 얻었다

클러스터가 나를 모른다 - RBAC를 직접 뚫다

넷 중 가장 오래 막힌 건 클러스터 RBAC였다. sync를 자동화하려면 클러스터 설정(ConfigMap)을 고쳐야 하는데, FE 계정은 클러스터가 모르는 상태였다.

kubectl get configmap argocd-cm -n argocd
→ You must be logged in to the server      # 클러스터가 나를 모름

권한을 단계적으로 맞춰가며 에러가 You must be logged in(모름) → Forbidden(알지만 권한 X)으로 바뀌는 걸 확인했다. 이건 내가 직접 맡아 끝까지 가보기로 한 작업이라, 막힌 김에 내 권한부터 확인했다.

aws iam list-attached-user-policies --user-name seeun_park
# → IAMFullAccess   ← 내가 나한테 권한을 줄 수 있다

IAMFullAccess가 있었다 - 내가 나한테 줄 수 있는 권한이었다. EKS Access Entry(aws-auth ConfigMap을 대체하는 2023년 방식)로 내 계정에 cluster admin을 직접 붙였다.

aws eks associate-access-policy --cluster-name DEALLO-PROD \
  --principal-arn ...user/seeun_park \
  --policy-arn .../AmazonEKSClusterAdminPolicy --access-scope type=cluster
kubectl get configmap argocd-cm -n argocd      # → 정상 출력 🎉

권한을 뚫으니 이번엔 도구가 막았다. kubectl edit의 nano는 저장만 하면 Edit cancelled, no changes made. 편집기를 우회해 kubectl patch --type merge로 바꿀 부분만 명시했다(편집기를 안 거치니 더 안전하기도). RBAC ConfigMap은 명령이 더 길어 zsh가 paste 중 줄바꿈을 끼워넣어 깨뜨렸고 - heredoc도 깨져서 결국 짧은 변수 여러 개로 쪼개 조립했다.

코드 한 줄보다 승인 하나가 오래 걸리는 날이 많았다. 대신 권한을 빌려 쓰지 않고 직접 얻어둔 덕에 - 몇 주 뒤 상용 OOM 사건이 터졌을 때, 이때 뚫어둔 kubectl·관측 도구 접근이 그대로 원인 추적에 쓰였다.

검증이 진짜 사투였다

코드를 다 짜고도 실제 릴리스에서 세 번 깨졌다. 자동화의 비용은 "짜기"가 아니라 "정말 도나"였다.

  1. argocd-server를 재시작 안 해 설정이 안 먹은 걸 첫 릴리스에서 발견 → 재시작
  2. RBAC 권한 객체 형식 오류 - deallo-prod/*로 줬는데 실제 포맷은 default/deallo-prod였다
  3. 권한 시뮬레이션 argocd admin settings rbac can ... → Yes사전 검증을 끝낸 뒤에야, 실제 release(2026-05-19)에서 자동 sync가 도는 걸 확인하고 종료했다

20일, 어떻게 흘렀나

시점한 일결과
Day 1Phase 1·2 핵심 구현 (release-pr + dispatch/bump-image)end-to-end 1차 통과
Day 3백엔드와 Phase 3 협의전체 auto-sync 거절 → web 자원 한정 결정
Day 9~12Phase 3 - cluster admin 셀프 획득·RBAC 설정·sync step 머지코드 완성 (숨은 버그 2개 잠복)
Day 13첫 검증 release❌ argocd-server 재시작이 안 됐었음
Day 15두 번째 검증 release❌ RBAC object 형식 오류
Day 16RBAC 수정 + 권한 시뮬레이션rbac can … → Yes 사전 검증 통과
Day 20실제 release✅ 자동 sync 동작 - 사람 개입 0

코드는 Day 12에 다 짰다. 그런데 "정말 도는가"를 확인하는 데 8일이 더 걸렸다 - 자동화의 무게중심이 "짜기"가 아니라 "검증"에 있다는 게 이 8일에 다 들어 있다.

Phase 4 - 배포가 끝나면, 무엇이 바뀌었는지도 자동으로

파이프라인이 자리 잡은 뒤 그 위에 얹은 마지막 조각이다. 배포마다 슬랙에 배포 노트를 남기는 건 이미 팀 문화였다 - 무엇이 바뀌었는지 팀과 이해관계자가 한눈에 보도록.

실제 발행된 배포 노트 - 배포 ID·날짜·구분(FE)과 [신규기능]/[상시]/[기타개선] 3개 섹션에 걸친 대규모 릴리스 항목들이 정리된 슬랙 메시지

문제는 정리 리소스였다. 스프린트 일정상 상시 배포는 범위가 작아 금방이지만, 위처럼 큰 릴리스는 그 항목들이 PR 수십 개에 흩어져 있다 - 전부 읽고 사용자 관점의 한 줄로 추려 [신규기능]/[상시]/[기타개선]으로 분류하는 게 매번 시간이 드는 일이었다. 이 정리를 줄이는 게 목표였다.

아이디어는 한 줄이다: 릴리스 PR 본문을 배포 노트의 원고로 쓴다. PR이 생성될 때 AI가 이번 릴리스의 PR을 전부 읽고 초안을 1회 써서 PR 본문에 넣으면, 사람은 PR을 리뷰하면서 코드와 함께 노트도 다듬는다. 배포가 끝나면(ArgoCD health 통과 후) 그 본문을 그대로 슬랙에 발행한다. 사람이 누르는 단계는 여전히 둘뿐 - 노트를 위해 새 단계를 만들지 않았다.

[① Run workflow]  release-pr
     ├─ (기존) 버전 결정 → release 브랜치 → PR 생성
     └─ ⭐ main..release diff → 변경 목록 → AI 초안 → PR 본문 마커 섹션에 삽입
[② PR 리뷰 + 머지]                    ← 사람이 코드와 함께 노트도 다듬음
     ↓  (이하 전부 자동)
   CodeBuild → bump-image → ArgoCD 자원 한정 sync → health 통과
     └─ ⭐ deallo-web으로 dispatch (deploy-complete)
   ⭐ deploy-note: 최신 릴리스 PR 본문의 마커 섹션 추출 → Slack 발행

손으로 쓰던 노트의 진짜 문제는 "시간 기준"이었다

수동으로 노트를 쓸 땐 "지난 배포 이후 머지된 것"을 골랐다. 그런데 릴리스 PR을 리뷰하는 사이에 develop에 다른 PR이 머지되면 - 이번 배포엔 안 실리는데 노트엔 적히는 일이 생겼다. 시간 기준의 함정이다.

그래서 변경 목록을 시간이 아니라 git diff에서 뽑았다. 이번 릴리스에 실제로 실리는 건 "main에 없는 release 브랜치 커밋" - 릴리스 PR의 커밋 집합 그 자체다.

# 날짜 근사가 아니라 git이 계산한 "이번 배포에 실리는 변경"
git log origin/main..HEAD --first-parent --pretty='%s'

이렇게 하면 "노트엔 있는데 배포엔 없음 / 배포됐는데 노트엔 없음"이 구조적으로 불가능해진다. 리뷰 중에 머지된 건 diff에 없으니 자동으로 다음 노트로 이월된다. Phase 1에서 버전 카운트를 "브랜치 존재"가 아니라 "PR 기록"으로 옮겼던 것과 같은 교훈 - 상태가 아니라 사실에서 뽑는다.

AI는 초안까지만, 최종 문구는 사람이 검수한다

AI가 관여하는 건 PR 생성 시 초안 1회뿐이다. 코드리뷰에 쓰던 것과 같은 claude-code-action을 재사용해(새 AI 시크릿 0개) PR 목록을 [신규기능]/[상시]/[기타개선]으로 분류한 초안을 만들고, 사람이 PR 리뷰에서 최종 문구를 확정한다. 발행 순간엔 AI가 없다 - 사람이 검수한 텍스트를 그대로 슬랙에 전달할 뿐이다. 확률로 답하는 AI를 초안 자리에만 두고 통제권은 사람에게 남기는 구조다.

한 번 조용히 당한 함정: claude-code-action기본 권한이 파일 쓰기를 막는다. --allowedTools "Read,Write"를 안 주면 초안 생성이 permission denial로 실패하는데, 파이프라인은 폴백으로 넘어가 에러 없이 PR 목록 원문이 초안이 된다 - 조용해서 더 위험했다. 테스트에서 permission_denials_count: 1을 보고서야 찾았다.

노트는 체인의 잎(leaf) - 어떤 실패도 배포를 막지 않는다

배포 알림이 배포 자체를 위협하면 안 된다. 그래서 노트 발행은 체인의 으로 격리했다.

  • AI 초안 실패 → PR 목록 원문으로 폴백 (릴리스 PR 생성이 막히지 않음)
  • 마커 섹션 없음/삭제 → PR 링크로 폴백 발행
  • 배포 완료 신호(dispatch) 실패 → soft-fail + 경고. 이 시점엔 배포가 이미 성공이라, dispatch 실패로 파이프라인이 빨간불이 되면 "배포 실패"로 오독된다
  • 노트 누락deploy-note에 수동 트리거를 달아 태그 입력 + 클릭 1번으로 재발행 (몇 번이고 안전)

dispatch는 fire-and-forget이라 204는 "GitHub이 접수했다"일 뿐 워크플로가 실제로 돌았다는 보장이 아니다 - 그래서 검증도 "수동 발동으로 발행부터 확인" 순서로 밟았다.

발행 결과 (테스트 채널 검증)

테스트 채널에 발행된 배포 노트 - 배포 ID·날짜·구분(FE)·[신규기능]/[상시] 섹션이 템플릿 형식으로 자동 조립된 슬랙 메시지

배포 ID(=이미지 태그) · 날짜(태그 앞 8자리 파싱) · 구분(FE) · 내용(PR 본문 마커 섹션)이 템플릿에 맞춰 조립돼 발행된다. 불릿()은 발행 순간에만 입히고 PR 본문 초안은 불릿 없는 평문으로 둔다 - 편집기에서 다듬기 쉽게.

현재 상태: 구현을 마치고 테스트 채널에서 풀 형식을 검증했다(빈 섹션 생략·PR 번호 제거까지 정확). 위 스크린샷의 9999-12-31은 수동 dispatch로 넣은 검증용 태그다. 남은 건 웹훅을 실채널로 교체하는 운영 토글 한 번 - 그 뒤엔 "PR 생성부터 배포 후 슬랙 노트까지" 자동화가 문자 그대로 완성된다.

배운 점

  • 자동화의 본체는 "짜기"가 아니라 "프로덕션에서 검증"이다. 권한·재시작·환경 같은 보이지 않는 전제가 실제 릴리스에서야 드러난다 → 이제는 권한 시뮬레이션으로 사전에 검증한다
  • 막힐 때마다 결정을 갈아엎은 게 자산이 됐다. GITHUB_TOKEN이 왜 CI를 안 깨우는지, 카운트를 왜 "기록" 기준으로 해야 하는지는 막혀봐야 안다
  • "전체 자동화"가 늘 정답은 아니다. web·api·ws가 묶인 구조에선 자원 한정이 더 안전했다
  • 배포 알림도 "파이프라인 부품"으로 설계했다. 노트를 시간이 아니라 git diff에서 뽑아 배포-노트 불일치를 구조적으로 없앴고, AI는 초안·최종 검수는 사람으로 통제권을 남겼다. 알림은 체인의 _잎_이라 어떤 실패도 배포를 막지 않는다