GitOps 구조는 어떻게 진화해왔을까 – 멀티리전 배포를 가능하게 만든 선택들

안녕하세요! 강남언니 DevOps 팀에서 플랫폼 엔지니어링을 담당하고 있는 에일리와 아린입니다.
오늘은 저희가 "어떻게 하면 전 세계 유저들이 언제 어디서든 더 빠르고 안정적인 서비스를 경험할 수 있을까?" 라는 고민에서 시작된 여정을 공유해드리려고 해요. 강남언니가 글로벌 서비스로 확장하면서 마주한 다양한 도전들 중에서, 오늘은 특히 배포 관점에서의 이야기를 들려드릴게요.
💡 참고: 글로벌 서비스 확장 과정에서 겪은 유저 경험 관점의 도전과 해결책들은 별도 글에서 자세히 다룰 예정이니 기대해 주세요!
결론부터 말하면, 기가 막힌 해결책을 만들어냈습니다 🤗
왜 GitOps였을까?
3년 전, 저희가 처음 강남언니에 합류했을 때의 이야기부터 시작해볼게요.
그동안 고객에게 더 좋은 기능을 제공하는 데 집중하다 보니, 배포 시스템은 상대적으로 손이 덜 갔던 것 같아요. Jenkins와 Argo CD가 3년 전 설정 그대로 안정적으로 돌아가고 있었는데, 가장 큰 문제는 지금 운영 중인 서비스가 어떤 코드/설정 상태인지 아무도 정확히 알 수 없었다는 것이었어요. 이참에 현재 운영 중인 서비스의 상태를 더 명확하게 파악하고 관리할 수 있는 시스템으로 개선해보자는 생각이 들었어요.
Argo CD가 날아가면 “어? 지금 뭐가 배포되어 있지?” 하는 상황이 벌어졌고, Jenkins 설정은 EC2 안에서 수동으로 확인해야 했어요. 배포도 브랜치 기준으로 수동 진행되다 보니, 과거 버전으로 롤백하려면 이미지를 새로 빌드해야 하는 황당한 상황도 종종 있었습니다.
장애가 나면 원인을 파악하기보다,“지금 어떤 변경이 적용되어 있는 상태인지”부터 찾는 데 시간을 써야 했어요.
“이건 좀 아니지 않나?” 싶어서 바로 개선에 착수했습니다.
- 운영 안정성: 배포 상태 유실은 곧 대응 불가능으로 이어졌습니다
- 배포 프로세스 표준화: “누가, 언제, 무엇을 배포했는지”를 기록으로 남기고 싶었어요
- 신뢰할 수 있는 히스토리 관리: 장애 원인을 빠르게 추적하려면, 배포 이력이 명확해야 했습니다
그래서 Argo CD 업그레이드 + GitHub Actions 도입 + GitOps 체계 구축! 이 3단 콤보로 저희의 GitOps 여정이 시작됐습니다.
GitOps 체계, 이렇게 만들었어요
💡 GitOps란? Git 저장소를 "진실의 단일 소스(Single Source of Truth)"로 사용해서 인프라와 애플리케이션을 관리하는 방식이에요. 쉽게 말해 "Git에 원하는 상태를 적어두면, 시스템이 알아서 그 상태로 맞춰주는" 자동화된 방식입니다.
Argo CD + GitHub Actions
저희 GitOps 구조를 간단히 보여드릴게요:
📁 우리의 GitOps 저장소 ├── 📂 applications # 각 서비스의 배포 설정 │ ├── 📂 backend │ │ ├── 📂 member │ │ └── 📂 ... │ └── 📂 frontend ├── 📂 bootstrap # Argo CD 초기 설정 (처음 설치할 때 한 번만!) ├── 📂 charts # 공통 Helm Chart들 └── 📂 products # ApplicationSet 마법이 일어나는 곳
각 폴더의 역할을 소개할게요
bootstrap:
"Argo CD야, 너 자신부터 GitOps로 관리해!" 라고 선언하는 곳이에요. 그리고 쿠버네티스 운영에 필요한 온갖 도구들(Load Balancer Controller, Cert Manager, External-dns 등등)도 여기서 관리해요.
products:
ApplicationSet을 정의해서 "applications 폴더에 뭔가 추가되면 자동으로 Argo CD Application 만들어줘!" 하는 자동화 액션이 일어나는 곳입니다.
charts:
모든 서비스가 공통으로 사용하는 Helm Chart들을 관리하는 곳이에요.
Helm + Kustomize, 이 조합이 좋았던 이유
처음에는 이런 구조로 시작했어요:
# 각 서비스마다 이런 파일이 있었죠
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
helmGlobals:
chartHome: ../../../../charts # 공통 차트 가져와!
helmCharts:
- name: miniservice
namespace: **
releaseName: member
valuesFile: values.yaml
공통적인 건 Helm Chart로, 특별한 건 Kustomize로! 이 철학이 꽤 잘 먹혔어요.
예를 들어, 대부분의 서비스는 miniservice라는 공통 Helm Chart를 사용하지만, 가끔 특별한 커스터마이징이 필요한 경우가 있었어요.
NGINX가 필요한 프론트엔드 서비스의 경우:
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
helmGlobals:
chartHome: ../../../../charts # 공통 차트 가져와!
helmCharts:
- name: miniservice
namespace: **
releaseName: meteor
valuesFile: values.yaml
configMapGenerator:
- files:
- nginx.conf=nginx.conf
name: meteor
patches:
- path: nginx-deployment.yaml # NGINX 컨테이너 추가
target:
kind: Rollout
사실 이런 NGINX 설정도 Helm Chart에 포함시킬 수 있어요. 하지만 서비스에 대한 오너십이 각 개발팀에게 있기 때문에, nginx.conf의 캐시 정책이나 라우팅 설정들을 개발자들이 직접 확인하고 수정할 수 있길 바랐거든요.
그래서 meteor 서비스의 디렉토리 구조는 이렇게 되어 있어요:
meteor/
├── kustomization.yaml # 배포 설정
├── values.yaml # 공통 차트 설정
├── nginx.conf # 개발팀이 직접 관리하는 NGINX 설정
└── nginx-deployment.yaml # NGINX 컨테이너 추가 패치
이런 식으로 공통 차트 + 서비스별 커스터마이징이 가능했어요.
여기까지의 핵심!
- 문제: 오래된 레거시 시스템, 배포 상태 추적 불가
- 해결책: GitOps 체계 구축 (Argo CD + GitHub Actions)
- 결과: 안정적이고 추적 가능한 배포 시스템
글로벌 진출, 그리고 새로운 도전
1년간의 GitOps 정착기
GitOps 전환 과정에서 가장 기억에 남는 건 "1일 완성 CI/CD & GitOps 핸즈온 워크샵" 이었어요! 개발자분들과 함께 앉아서 "이제 이렇게 배포하는 거예요~" 하면서 같이 실습했던 기억이 나네요..😇
처음엔 "아, 이거 또 배워야 해?" 하시던 분들도 나중엔 "오, 이거 되게 편한데?" 하시더라고요.
멀티리전, 예상보다 복잡했던 이야기
그런데! 강남언니가 일본, 영어권으로 서비스를 확장하면서 새로운 과제가 생겼어요.
"전 세계 유저들이 빠른 서비스를 받으려면 각 지역에 인프라가 있어야 하는데... 이걸 어떻게 관리하지?"
처음엔 단순하게 생각했어요. "기존 구조에서 리전만 추가하면 되는 거 아닌가?"
슬프게도 막상 해보니 그렇지 않았어요:
- 리전별 트래픽에 따른 다른 스케일링 설정 필요
- 각 리전별 인증서와 보안 설정 차이
- 네트워크 구성의 차이
- 특정 리전에만 다른 버전을 배포하고 싶을땐 어떻게 하지?
단순한 복사-붙여넣기로는 해결되지 않는 구조적 한계를 직면하게 됐습니다.
기존 구조의 한계: 디렉토리 지옥의 시작
Helm + Kustomize 방식의 아쉬운 점들
기존 구조를 유지한 채 멀티리전을 적용하려다 보니.. 이런 문제들이 보였어요:
문제점들 ├── 📁 review/korea/dev/ # 한국용 설정 ├── 📁 review/japan/dev/ # 일본용 설정 (복사본) ├── 📁 review/usa/dev/ # 미국용 설정 (또 복사본) └── 📁 event/korea/dev/ # 이걸 또 3번 복사해야 함...
- 리전마다 디렉토리 복사 = 중복 코드 폭증
- 새 리전 추가 = 모든 서비스 수동 작업
- 공통 변경사항 = 모든 리전에 일일이 수정
3개 리전에 10개 서비스면 30개 디렉토리... 상상만 해도 끔찍하지 않나요^^?
구조적 전환: 더 똑똑한 방법을 찾다
"아하! Kustomize 없어도 되겠네?"
가만 보니 리전별로 정말 다른 건 values.yaml 설정뿐이었어요:
# jp.yaml - 일본 리전만의 특별한 설정들
ingress:
public:
certificateArn: arn:aws:acm:ap-northeast-1:123456781234:certificate/... # 일본 인증서
annotations:
external-dns.alpha.kubernetes.io/set-identifier: jp # 일본 식별자
external-dns.alpha.kubernetes.io/aws-geolocation-country-code: JP # 지역 라우팅
hosts:
- host: test.gangnamunni.com # 도메인, 리전별 도메인은 바뀌지 않아요! route53 geolocation 기반 트래픽 라우팅을 사용하기 때문이죠!
그런데 또 다른 문제들이 보였어요
문제 1: 서비스별로 리전 배포 정책이 달라요
- A 서비스: 한국, 일본, 미국 모두 배포
- B 서비스: 한국, 미국만 배포
- C 서비스: 한국만 배포
문제 2: 이 정책을 어디서 관리할까?
- ApplicationSet에 하드코딩? → 변경할 때마다 수정 필요
- 별도 설정 파일? → 관리 포인트 증가
문제 3: values.yaml 구조는 어떻게?
- 기본 설정은 공통으로 쓰고 싶음
- 리전별 다른 설정만 추가로 적용하고 싶음
그때 발견한 해결책: ArgoCD Matrix Generator!
💡 Matrix Generator란? 여러 개의 Generator를 조합해서 "서비스 x 리전" 같은 매트릭스를 만들어주는 ArgoCD의 기능이에요. 쉽게 말해 "이 서비스들을 이 리전들에 자동으로 배포해줘!"라고 선언하면 알아서 조합을 만들어주는 똑똑한 도구입니다.
"아, 이거다! 메타 정보로 서비스별 리전 배포 정책을 관리하고, values.yaml 기본 + 리전별 추가 설정 구조로 가면 되겠네!"
이 시점에서 중요한 의사결정을 했습니다. "서비스별 특이점들을 Kustomize가 아닌 Helm Chart 내에서 해결하는 방향으로 가자"
예전에는 NGINX나 특별한 설정이 필요하면 Kustomize patch를 써야 했는데, Helm Chart를 버전별로 관리하면서 이런 요구사항들을 차트 자체에 흡수시킬 수 있게 되었거든요. (이 부분은 다음 글에서 자세히 다룰 예정이에요!)
결국 저희가 원했던 건 이런 거였어요
- 중복 리소스 최소화: 같은 설정을 여러 곳에 복사하지 않기
- 실수 가능성 줄이기: 사람이 수동으로 할 일을 최대한 자동화
- 이해하기 쉬운 구조: 누가 봐도 명확한 디렉토리 구조
- 효율적인 확장: 새로운 리전이나 서비스 추가가 쉬워야 함
- 유연한 리전 배포: 서비스별로 다른 리전 배포 정책 지원
"그럼 이제 Kustomize 레이어를 없애고 Helm + Matrix Generator를 써보자!"
ApplicationSet의 마법: 구조가 이렇게 바뀌었어요
Before: 일반적인 디렉토리 기반 구조
# 이전: 디렉토리 구조를 기반으로 한 일반적인 방식
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
spec:
generators:
- git:
directories:
- path: applications/web/*/dev
template:
metadata:
name: "{{ index .path.segments 2 }}-kr" # 한국만 지원
spec:
destination:
name: kr-cluster # 한국 클러스터에만 배포
이 구조는 한국 서비스에는 잘 작동했어요. 하지만 멀티리전을 지원하려면 완전히 다른 접근이 필요했죠!
After: 메타 정보 기반의 똑똑한 구조
# 이후: Matrix Generator + 메타 파일로 멀티리전 자동 배포!
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
spec:
generators:
- matrix:
generators:
- git:
files:
- path: "applications/web/*/dev/meta.yaml" # 메타 파일 찾아줘
- list:
elementsYaml: '{{ .meta | toJson }}' # 메타 정보로 매트릭스 생성
template:
metadata:
name: '{{ index .path.segments 2 }}-{{ .region }}' # 서비스명-리전으로 자동 생성
spec:
sources:
- repoURL: 'https://github.com/healingpaper/argocd.git'
path: applications/web/{{ index .path.segments 2 }}/dev
ref: values
- repoURL: 'https://chart-museum.platform.gnsister.com'
chart: '{{ .chart.name }}'
targetRevision: '{{ .chart.version }}'
helm:
valueFiles:
- $values/.../values.yaml # 🏠 공통 설정
- $values/.../{{.region}}.yaml # 🌍 리전별 설정 자동 선택!
ignoreMissingValueFiles: true # 리전 파일 없어도 OK, 멀티리전 배포가 필요 없는 서비스를 위한 설정이에요.
destination:
name: '{{ .region }}-cluster' # kr-cluster, jp-cluster, us-cluster 자동 선택
핵심 포인트들:
- Matrix Generator: 메타 파일의 각 리전 정보와 서비스를 조합해서 자동으로 여러 Application 생성
- 동적 리전 설정: {{ .region }}.yaml 로 kr.yaml, jp.yaml, us.yaml을 자동으로 선택
- Chart Museum 연동: Helm Chart를 별도 저장소에서 버전별로 관리
- 다중 소스: values 파일은 Git에서, Chart는 Chart Museum에서 가져오는 하이브리드 구조
Matrix Generator가 실제로 어떻게 작동하는지 볼까요?
1단계: 메타 파일을 읽어옴
# cheetah/dev/meta.yaml
meta:
- region: kr
chart:
name: miniservice
version: 3.1.0
- region: jp
chart:
name: miniservice
version: 3.1.0
2단계: Matrix Generator가 조합을 생성
서비스: cheetah x 리전: [kr, jp] = 2개 조합
서비스명 | 리전 | Argo Application 이름 |
cheetah | kr | cheetah-kr |
cheetah | jp | cheetah-jp |
3단계: 각 조합별로 ArgoCD Application 자동 생성
# 생성되는 Application 1
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cheetah-kr
spec:
sources:
- repoURL: 'https://chart-museum.*.*.com'
chart: 'miniservice'
targetRevision: '3.1.0'
helm:
valueFiles:
- $values/applications/web/cheetah/dev/values.yaml # 공통 설정
- $values/applications/web/cheetah/dev/kr.yaml # 한국 설정
destination:
name: 'kr-cluster'
---
# 생성되는 Application 2
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cheetah-jp
spec:
sources:
- repoURL: 'https://chart-museum.*.*.com'
chart: 'miniservice'
targetRevision: '3.1.0'
helm:
valueFiles:
- $values/applications/web/cheetah/dev/values.yaml # 공통 설정
- $values/applications/web/cheetah/dev/jp.yaml # 일본 설정
destination:
name: 'jp-cluster'
결과: 하나의 ApplicationSet이 메타 파일을 보고 자동으로 여러 개의 Application을 생성해주는 거예요!
실제 디렉토리 구조도 깔끔해졌어요
새로운 구조 cheetah/ ├── dev/ │ ├── values.yaml # 모든 리전의 공통 설정 │ ├── kr.yaml # 한국만의 특별한 설정 │ ├── jp.yaml # 일본만의 특별한 설정 │ ├── us.yaml # 미국만의 특별한 설정 │ └── meta.yaml # "이 서비스는 어느 리전에 배포할까?" 정보 └── prod/
변화의 결과: 이제 이렇게 편해졌어요
새 리전 추가가 이렇게 쉬워졌어요!
Before (디렉토리 지옥):
- 모든 서비스마다 새 리전 디렉토리 생성
- ApplicationSet에 새 경로들 하나하나 추가
- 설정 파일들 복사하고 수정하고...
After (간결한 메타 사용):
- 각 서비스 meta.yaml에 새 리전 추가
- 새 리전용 values 파일 (예: sg.yaml) 생성
물론 이렇게 하기 위해선 싱가포르 리전에 K8s 클러스터가 준비되어 있어야 겠죠? 당연히 이런 영역은 IaC로 뚝딱 생성이 가능해요.
끝! 모든 서비스가 싱가포르에 자동 배포됩니다!
변경 관리도 훨씬 편해졌어요
공통 변경사항이 생겼을 때:
- 기존 구조를 멀티리전으로 확장했다면: 30개 디렉토리를 돌아다니며 일일이 수정해야 했을 거예요
- 현재 구조: values.yaml 하나만 고치면 모든 리전에 반영!
리전별 차이점 확인할 때:
- 기존 구조라면: 어디가 다른지 찾으려면 여러 디렉토리를 순회해야 했겠죠...
- 현재 구조: kr.yaml, jp.yaml, us.yaml 비교만 하면 끝!
우리가 이룬 것들
GitOps는 선언의 기술이라고 생각해요. "우리는 이런 상태를 원한다"고 선언하면, 시스템이 알아서 그 상태를 유지해주죠.
하지만 더 중요한 건 "구조가 확장성을 만든다" 는 것을 다시 한번 깨달았다는 점이에요.
처음 GitOps를 도입할 때는 단순히 "배포 좀 안정적으로 하자" 였어요. 하지만 멀티리전이라는 새로운 도전을 만나면서, 아예 구조를 다시 생각하게 되었고, 그 결과 훨씬 더 유연하고 확장 가능한 시스템을 만들 수 있었습니다.
구조적 개선 효과
- 디렉토리 뎁스: 서비스별 리전 디렉토리 → 통합 디렉토리 + 메타 관리
- 새 리전 추가: 전체 서비스 디렉토리 복사 → 메타 파일 몇 줄 추가
- 공통 변경: 리전 개수만큼 반복 수정 → 중앙 파일 한 번 수정
지금 저희 시스템은 새로운 리전 추가가 정말 쉬워요. 새로운 서비스 온보딩도 마찬가지고요. 무엇보다 개발자들이 배포를 두려워하지 않게 되었어요!
다음 이야기 예고편
"구조적 문제는 해결했는데... 뭔가 또 불안해?"
멀티리전 구조를 성공적으로 만들고 나서 뿌듯해하던 것도 잠시, 새로운 고민이 생겼어요.
"values.yaml 파일만 봐서는 실제로 뭐가 바뀔지 모르겠는데...?"
"helm chart를 변경하는데 전체 서비스에 영향이 없겠지..? 믿고가?"
그래서 저희가 도입한 두 가지의 솔루션:
- ChartMuseum + 차트 버저닝
- "Helm Chart도 코드니까 버전이 있어야 하지 않을까?"
- 이제 차트 변경이 명확하게 추적돼요!
- App CI + Diff 자동화
- "PR 올리면 자동으로 '이렇게 바뀔 거예요!' 보여주면 어떨까?"
- 더 이상 "이거 배포해도 될까?" 걱정 안 해도 돼요!
다음 글에서는 "변경이 안전하게 전달되는 방법" 이야기를 들려드릴 예정이에요.
변경이 많아질수록, 오히려 더 똑똑한 도구가 필요하다는 걸 깨달았거든요. "GitOps 배포를 두려워하지 않기 위한 작은 자동화" - 기대해 주세요!
함께 하실 분을 찾습니다
이런 재미있는 문제들을 함께 고민하고 해결해나갈 분을 찾고 있어요!
- "전 세계 유저들에게 더 빠른 서비스를 제공하려면?"
- "수백 개의 서비스를 효율적으로 관리하려면?"
- "개발자들이 더 편하게 배포할 수 있게 하려면?"
이런 고민들이 재미있게 느껴지신다면, 저희와 함께해요!