ArgoCD App of AppSets로 PR Preview 환경 완전 자동화하기

(수정: 2026년 3월 27일) · 11분 읽기 Threads Disquiet
목차

ArgoCD App of Apps에서 ApplicationSet PR Generator로 전환하며 PR Preview 환경을 완전 자동화한 기록.
CSI Secret 부트스트래핑, PreDelete Hook 우회, Race Condition — 며칠간 막혔던 문제들을 하나씩 풀어간 과정.

ArgoCD ApplicationSet으로 PR Preview 환경을 자동화하는 아이소메트릭 일러스트

전제 환경: ArgoCD v2.x, Kubernetes 1.28+, Azure AKS, Secrets Store CSI Driver, GitHub Actions. EKS/GKE에서도 CSI Driver 부분만 각 클라우드의 Secret Manager로 대체하면 동일 패턴 적용이 가능하다.

기존 환경에서는 CI/CD가 안정적으로 돌아가고 있었다. 와일드카드 도메인으로 PR마다 독립된 서브도메인이 자동 할당됐고, SealedSecrets로 Secret 관리가 단순했으며, 네임스페이스도 자유롭게 만들 수 있었다. 이 환경을 새로운 Azure AKS 클러스터로 전환해야 했는데, 새 환경에는 제약이 많았다. 와일드카드 기반의 도메인 라우팅이 Context Path 기반으로 바뀌고, SealedSecrets가 Azure KeyVault + CSI Driver로 바뀐 데다가, 네임스페이스까지 고정 2개로 제한됐다.

App of Apps 패턴으로 PR Preview를 구현하려 했지만, git commit 기반 생명주기 관리가 구조적으로 맞지 않았다. ApplicationSet PR Generator로 전환하면서 새로운 문제 3개를 만났고, 각각을 기존 기술의 조합으로 해결했다. 이 글의 초점은 두 패턴을 조합해 PR Preview를 구축하면서 실전에서 부딪힌 문제들을 어떻게 해결했는지에 있다.

왜 App of Apps가 아닌가

PR Preview에서 App of Apps의 문제는 생명주기 관리였다. PR이 열릴 때 CI가 Application YAML을 생성해서 git commit하고, 새 커밋이 올라오면 이미지 태그를 업데이트하는 커밋이 추가되고, PR이 닫히면 cleanup workflow가 삭제 커밋을 만든다. 모든 단계에서 git commit이 발생한다.

이 방식의 단점은 세 가지였다.

  • Git history 오염: PR 하나의 생명주기에 Application 생성/업데이트/삭제 커밋이 3~10개씩 쌓인다
  • Cleanup 실패 리스크: GitHub Actions가 실패하거나 타이밍이 어긋나면 orphan Application이 남는다
  • 관심사 혼재: CI가 이미지 빌드뿐 아니라 ArgoCD Application 관리까지 담당한다

이 세 가지 단점은 모두 “CI가 ArgoCD의 영역을 침범한다”는 구조적 문제에서 비롯된다. ApplicationSet은 이 관심사를 ArgoCD 쪽으로 돌려놓는다.

App of Apps와 ApplicationSet 방식의 CI/CD 흐름 비교

App of AppsApplicationSet
PR 환경 생성CI가 Application YAML git commitArgoCD가 GitHub API polling으로 자동 감지
PR 환경 삭제CI cleanup workflow 필요PR close 시 자동 cascade 삭제
Git commitPR당 3~10개 관리 커밋0개 (git 무관)
실패 시 복구orphan 수동 정리ArgoCD가 desired state로 자동 수렴
CI 책임이미지 빌드 + Application 관리이미지 빌드만

ApplicationSet PR Generator

핵심 변화는 CI가 Application 관리에서 완전히 빠진다는 것이다. ApplicationSet PR Generator가 GitHub API를 polling하여 열린 PR을 감지하고, PR마다 독립된 Application을 자동으로 생성/삭제한다.

기존 (App of Apps):
  PR open  → CI: 이미지 빌드 + Application YAML git commit
  PR push  → CI: 이미지 빌드 + 태그 업데이트 git commit
  PR close → CI: Application YAML 삭제 git commit + cleanup

변경 (ApplicationSet):
  PR open  → CI: 이미지 빌드만
  PR push  → CI: 이미지 빌드만
  PR close → (CI 불필요. ApplicationSet이 자동 삭제)

다만 App of Apps에도 장점이 있다. 모든 변경이 git commit으로 남기 때문에 변경 추적성과 감사(audit)가 뛰어나고, git revert로 즉시 롤백할 수 있다. 금융·공공 등 감사 추적이 중요한 환경이나, PR 수가 월 수십 개 이하로 적은 환경에서는 App of Apps가 여전히 현실적인 선택이다.

판단 기준App of Apps 유리ApplicationSet 유리
PR 빈도월 수십 개 이하수시 생성/삭제
감사 요구git 기반 변경 추적 필수운영 편의 우선
GitHub API 접근불안정하거나 제한적안정적
팀 ArgoCD 숙련도낮음높음
실패 복구git revert로 즉시 롤백ArgoCD 자동 수렴

이번 사례에서 ApplicationSet을 선택한 이유는 PR이 수시로 열리고 닫히는 동적 환경이었고, git commit 오염과 cleanup 실패 리스크가 실제로 반복되고 있었기 때문이다.

당연히 PR Preview 같은 동적 환경이 아니라면 이런 구조가 필요 없다. DEV/STG/PRD 같은 고정 환경은 Git에 매니페스트를 커밋하고 ArgoCD가 동기화하는 일반적인 GitOps로 충분하다. ApplicationSet이 빛나는 건 PR처럼 수시로 생성되고 삭제되는 동적 환경이다.

CI는 이미지를 빌드하고 레지스트리에 push하는 것만 책임진다. ArgoCD의 ApplicationSet이 GitHub API를 180초 간격으로 polling하면서 PR의 생성/업데이트/삭제를 감지하고, child Application을 자동으로 관리한다.

ApplicationSet의 Go Template으로 PR 번호와 커밋 SHA를 변수로 사용할 수 있다.

# ApplicationSet 핵심 설정
spec:
  generators:
    - pullRequest:
        github:
          owner: "my-org"
          repo: "my-app"
          tokenRef:
            secretName: repo-appset-secret
            key: password
        requeueAfterSeconds: 180
  template:
    metadata:
      name: "pr-{{.number}}-my-app"
    spec:
      source:
        path: manifests/apps/pr
        targetRevision: "{{.head_sha}}"
        kustomize:
          namePrefix: "pr-{{.number}}-"
          images:
            - "my-registry/web:pr-{{.number}}-{{.head_short_sha_7}}"
            - "my-registry/api:pr-{{.number}}-{{.head_short_sha_7}}"

targetRevision: {{.head_sha}}가 핵심이다. PR 브랜치의 매니페스트를 직접 참조하므로, PR에서 Kustomize overlay를 수정하면 Preview 환경에 즉시 반영된다.

App of AppSets: Root App 구조

ApplicationSet만으로 PR Preview를 만들 수 있지 않을까? PR Generator로 child Application을 생성하는 것까지는 된다. 하지만 Preview 환경이 동작하려면 인프라 리소스가 먼저 존재해야 한다. ServiceAccount, SecretProviderClass, RBAC — 이것들이 없으면 Pod는 기동조차 못 한다. ApplicationSet 단독으로는 이런 배포 순서를 보장할 수 없다.

그래서 Root App이 Application과 ApplicationSet을 혼합 관리하는 App of AppSets 패턴을 적용했다.

Root App (in-cluster/argocd)
├── [Wave 0] infra-ns01   (Application)  → 인프라: SA, SecretProviderClass
├── [Wave 0] infra-ns02   (Application)  → 인프라: SA, SPC, RBAC, CronJob
├── [Wave 1] dev          (Application)  → DEV 워크로드
└── [Wave 1] pr-appset    (ApplicationSet) → PR Preview (자동)

Sync Wave로 배포 순서를 보장한다. Wave 0의 인프라(ServiceAccount, SecretProviderClass)가 먼저 생성되어야 Wave 1의 워크로드 Pod가 정상 기동할 수 있다.

PR Preview는 하나의 네임스페이스를 여러 PR이 공유한다. 리소스 격리는 Kustomize의 namePrefix로 처리한다. PR-4가 열리면 pr-4-web, pr-4-api, pr-4-ingress 같은 이름으로 리소스가 생성되고, PR이 닫히면 ApplicationSet이 cascade 삭제한다.

라우팅도 namePrefix가 해결한다. 기존 환경이 와일드카드 도메인(pr-4.dev.example.com)에서 Context Path 기반(dev.example.com/pr-4/)으로 바뀌었는데, Ingress의 path prefix에 PR 번호가 포함되므로 PR 간 충돌 없이 독립된 엔드포인트를 갖는다. Web과 API는 같은 PR의 namePrefix를 공유하기 때문에, Web에서 API를 호출할 때도 PR 번호 기반의 내부 Service 이름(pr-4-api)으로 자연스럽게 연결된다.

동시에 열리는 PR 수가 늘어나면 클러스터 리소스가 문제될 수 있다. PR 환경의 Deployment에는 DEV 대비 낮은 resource request/limit을 적용하고, ApplicationSet 템플릿에서 replica를 1로 고정하여 리소스 사용을 제한했다.

문제 1: 닭과 달걀 — CSI Secret 부트스트래핑

Azure KeyVault의 Secret을 Pod에서 사용하려면 Secrets Store CSI Driver를 쓴다. Pod가 CSI 볼륨을 mount할 때 KeyVault에서 Secret을 가져와 K8s Secret을 생성하는 방식이다.

여기서 닭과 달걀 문제가 발생했다.

  • Pod는 Secret이 있어야 envFrom: secretRef로 환경변수를 로드할 수 있다
  • 하지만 Secret은 Pod가 CSI 볼륨을 mount해야 생성된다
  • Pod가 기동되려면 Secret이 필요하고, Secret이 생성되려면 Pod가 기동되어야 한다

해결책은 migration Job을 부트스트래퍼로 활용하는 것이었다. ArgoCD의 PreSync Hook으로 실행되는 DB migration Job에 CSI 볼륨 mount를 추가했다. PR Preview 환경에서는 PR별 전용 스키마(pr_4 등)를 생성해야 하므로, migration Job이 Secret 부트스트래핑과 스키마 구성을 동시에 처리하는 것이 자연스러웠다.

1. ArgoCD Sync 시작
2. [PreSync] migration Job 생성 → CSI mount → K8s Secret 자동 생성 (첫 owner)
3. migration Job이 DB 스키마 적용
4. [Sync] Deployment Pod 생성 → 동일 CSI mount → Secret에 추가 owner 등록
5. migration Job Pod 삭제 → Deployment Pod가 owner로 남아 Secret 유지
sequenceDiagram
    participant ArgoCD
    participant Job as Migration Job<br>(PreSync Hook)
    participant CSI as CSI Driver
    participant KV as KeyVault
    participant Secret as K8s Secret
    participant Deploy as Deployment Pod

    ArgoCD->>Job: PreSync: Job 생성
    Job->>CSI: CSI 볼륨 mount
    CSI->>KV: Secret 요청
    KV-->>CSI: Secret 값 반환
    CSI->>Secret: K8s Secret 생성 (owner: Job)
    Job->>Job: DB migration 실행
    ArgoCD->>Deploy: Sync: Pod 생성
    Deploy->>CSI: 동일 CSI 볼륨 mount
    CSI->>Secret: owner 추가 (owner: Job + Deploy)
    ArgoCD->>Job: Job 완료 → Pod 삭제
    Note over Secret: Deploy가 owner로 남아<br>Secret 유지

핵심은 Ownership Handoff 메커니즘이다. PreSync 단계의 Pod가 CSI mount로 Secret을 먼저 생성하고, Sync 단계의 Deployment Pod가 동일한 CSI mount로 ownerReference를 이어받는다. 첫 번째 Pod가 삭제되더라도 두 번째 Pod가 owner로 남아 Secret이 유지된다. migration Job이 없는 환경이라면 별도의 init Job을 PreSync Hook으로 추가해도 같은 효과를 얻을 수 있고, 아예 CSI 방식을 쓰지 않는다면 External Secrets Operator 같은 대안을 검토할 수 있다.

문제 2: PreDelete Hook 미동작

CSI Secret 부트스트래핑을 해결하고 PR Preview가 정상 기동하자, 다음 문제가 드러났다. PR을 닫을 때 DB에 남은 스키마 정리였다.

PR이 닫히면 ApplicationSet이 child Application을 삭제한다. 이때 DB에 생성된 PR 전용 스키마(pr_4 같은)도 정리해야 한다.

ArgoCD의 PreDelete Hook을 사용하면 Application 삭제 전에 cleanup Job을 실행할 수 있다. 문제는 ApplicationSet이 child App을 삭제할 때 PreDelete Hook이 실행되지 않는다는 것이었다. 이는 당시(v2.x) ArgoCD의 알려진 한계였다. (이 이슈는 v3.0에서 수정되었다. v3.0 이상을 사용한다면 PreDelete Hook이 정상 동작할 수 있다.)

당시 환경에서는 대안으로 CronJob을 만들었다. 6시간마다 실행되면서 orphan DB 스키마를 정리한다. v3.0 이전 환경이거나 PreDelete Hook 설정이 여의치 않은 경우 여전히 유효한 우회책이다.

# CronJob: 6시간마다 orphan PR 스키마 정리
spec:
  schedule: "0 */6 * * *"
  jobTemplate:
    spec:
      containers:
        - name: cleanup
          command:
            - /bin/sh
            - -c
            - |
              # 현재 활성 PR Deployment 목록 조회
              ACTIVE=$(kubectl get deploy -l app=api \
                -o jsonpath='{.items[*].metadata.name}' | tr ' ' '\n' | \
                grep -oP 'pr-\K\d+' | sort -u)

              # DB에서 pr_ 스키마 목록 조회
              SCHEMAS=$(psql -t -c "SELECT schema_name FROM information_schema.schemata \
                WHERE schema_name LIKE 'pr_%'" | tr -d ' ')

              # 활성 PR에 없는 스키마 삭제
              for schema in $SCHEMAS; do
                pr_num=${schema#pr_}
                if ! echo "$ACTIVE" | grep -qx "$pr_num"; then
                  psql -c "DROP SCHEMA $schema CASCADE"
                fi
              done

실시간 정리는 못 하지만, 최대 6시간 내에 orphan 스키마가 정리된다. PR Preview 환경에서 이 정도 지연은 허용 가능했다.

문제 3: Race Condition — 이미지가 없는데 Pod가 먼저

생성과 삭제를 모두 해결하고 나니, 마지막으로 타이밍 문제가 남았다.

ApplicationSet PR Generator는 GitHub API를 180초 간격으로 polling한다. PR이 열리자마자 child Application이 생성되고, ArgoCD가 즉시 동기화를 시작한다.

문제는 CI가 이미지를 빌드하는 시간이다. PR이 열리면 GitHub Actions가 트리거되어 이미지를 빌드하는데, 첫 빌드는 3~5분 걸린다. 하지만 ApplicationSet은 PR이 열린 것을 감지하자마자(최대 180초 내) 배포를 시작한다. 레지스트리에 이미지가 아직 없으니 Pod는 ImagePullBackOff에 빠진다.

ArgoCD가 재시도하면서 결국 해결되긴 하지만, 첫 배포에서 불필요한 에러 로그가 쌓이고 안정화까지 시간이 걸렸다.

해결책은 GitHub PR Label을 게이트로 활용하는 것이었다. ApplicationSet의 PR Generator에 label 필터를 추가했다.

generators:
  - pullRequest:
      github:
        labels:
          - preview-ready    # 이 라벨이 있는 PR만 감지

ApplicationSet의 labels 필터는 GitHub PR에 특정 라벨이 붙은 경우에만 해당 PR을 Generator 대상으로 인식한다. GitHub PR 화면에서 preview-ready 라벨이 부착된 PR만 ArgoCD가 감지하고, 라벨이 없는 PR은 완전히 무시한다.

GitHub PR 목록에서 preview-ready 라벨이 부착된 PR과 그렇지 않은 PR의 차이

CI workflow의 마지막 단계에서 이미지 빌드와 push가 완료된 후에 preview-ready 라벨을 자동으로 부착한다.

# CI workflow 마지막 step
- name: Add preview-ready label
  if: success()
  uses: actions/github-script@v7
  with:
    script: |
      await github.rest.issues.addLabels({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: context.issue.number,
        labels: ['preview-ready']
      });

이제 흐름이 정리됐다.

PR open → CI 시작 (이미지 빌드)
         → ApplicationSet polling (라벨 없으므로 무시)
         → CI 완료 → 'preview-ready' 라벨 부착
         → ApplicationSet polling → 라벨 감지 → child App 생성 → 배포
         → 이미지가 이미 레지스트리에 있으므로 즉시 Pull 성공

Race condition이 구조적으로 해결됐다. CI가 완료되지 않은 PR에 대해서는 ApplicationSet이 아예 Application을 생성하지 않는다.

PR Preview 생명주기

최종적으로 PR Preview의 전체 생명주기가 자동화됐다.

flowchart TD
    A[PR Open] --> B[CI: 이미지 빌드]
    B --> C[CI: preview-ready 라벨 부착]
    C --> D[ApplicationSet: PR 감지]
    D --> E[child App 생성]
    E --> F[PreSync: migration Job<br>CSI → Secret 생성<br>DB 스키마 생성]
    F --> G[Deployment 배포<br>Web + API]
    G --> H{PR 상태}
    H -->|push| B
    H -->|close| I[ApplicationSet: child App 삭제]
    I --> J[리소스 cascade 삭제]
    J --> K[CronJob: orphan<br>DB 스키마 정리]

CI가 담당하는 영역과 ArgoCD가 담당하는 영역이 깔끔하게 분리됐다. cleanup workflow도 삭제했다. ApplicationSet이 PR의 전체 생명주기를 자동으로 관리한다.

ApplicationSet이 가져오는 트레이드오프

CI는 간결해졌지만, 복잡도가 사라진 게 아니라 ArgoCD 쪽으로 이동했다. App of AppSets 패턴, PreSync Hook, CronJob을 조합한 구조는 ArgoCD에 대한 깊은 이해를 요구하며, 문제가 발생했을 때 ArgoCD · Kubernetes · GitHub Actions 세 레이어에 걸쳐 디버깅해야 한다.

ApplicationSet 고유의 실패 모드도 있다.

  • GitHub API 의존성: polling 방식이라 GitHub 장애나 API Rate Limit에 영향을 받는다. 대규모 조직이라면 Webhook 기반 트리거(ApplicationSet Notification)를 검토할 필요가 있다
  • 토큰 관리: PR Generator용 GitHub 토큰이 만료되거나 권한이 변경되면 모든 PR Preview가 동시에 멈춘다
  • Label 게이팅의 양면: CI가 preview-ready 라벨을 부착하는 구조라, CI 실패 시 라벨이 안 붙어 배포가 안 되거나, 반대로 수동 라벨링 실수로 의도하지 않은 배포가 발생할 수 있다. CI↔CD 책임 분리가 깔끔해 보이지만, 라벨이라는 새로운 coupling 포인트가 생긴 셈이다
  • polling 지연: 180초 간격이므로 PR 환경 생성까지 최대 3분의 지연이 있다. 즉각적인 피드백이 필요한 환경에서는 Webhook 설정이 필수다

운영 팁: ApplicationSet 관련 장애 감지를 위해 ImagePullBackOff 발생 횟수, CronJob 실패율, orphan 스키마 수를 모니터링 지표로 설정해 두면 문제를 빠르게 잡을 수 있다.

정리

며칠간 App of Apps로 씨름하면서 ApplicationSet은 제약사항 때문에 안 된다고 생각했다. scoped repo 버그(#21016), PreDelete Hook 미동작, UI 미지원 — 하나하나가 블로커처럼 보였다. 그날 아침 샤워하던 중에 갑자기 각 블로커의 우회책이 연쇄적으로 떠올랐다. non-scoped secret, CronJob, CLI 기반 관리. 이미 알고 있던 것들이 문제와 연결되지 않았을 뿐이었다. 머리가 채 마르기도 전에 집을 나서서, 교통카드도 사원증도 두고 온 채로 회사에 도착했다.

세 가지 문제는 각각 다른 영역의 지식을 요구했지만, 해결책은 모두 이미 존재하는 기술의 조합이었다. migration Job을 부트스트래퍼로 재활용하고, CronJob으로 Hook의 한계를 우회하고, PR Label로 타이밍을 동기화했다. 가장 큰 변화는 유지보수성이다. CI workflow(이미지 빌드)와 Kustomize overlay(매니페스트) 두 곳만 관리하면 되고, 새로운 PR 환경 요구사항이 생기면 ApplicationSet 템플릿 하나만 수정하면 모든 PR에 일괄 적용된다.

대안 경로: CSI Secret 부트스트래핑이 부담스럽다면 External Secrets Operator로 닭과 달걀 문제 자체를 회피할 수 있다. CronJob cleanup은 ArgoCD v3.0의 PreDelete Hook 정식 지원으로 대체 가능하다. 현재 구조는 과도기적 해결책이며, 장기적으로는 이런 우회를 하나씩 제거하는 방향이 맞다.

이 패턴이 적합하지 않은 경우도 있다. 네임스페이스를 공유하고 namePrefix로만 격리하는 구조라, 동시 PR이 많아지면 리소스 경합이나 보안 경계 문제가 생길 수 있다. 같은 ServiceAccount와 Config를 공유하므로 PR 간 lateral movement 리스크도 있다. 보안 요구사항이 높은 조직이라면 네임스페이스 per PR, NetworkPolicy 적용, ResourceQuota 설정을 검토해야 한다. 소규모 팀(동시 PR 3~5개 이하)에서 보안보다 운영 단순성이 우선인 경우에 이 패턴이 가장 잘 맞는다.

참고자료

이어서 읽기