안녕하세요. 인프랩 데브옵스 엔지니어 선비입니다.

올해 저희 팀은 2023년 Pulumi IaC 시스템 정착, 2024년 AWS ECS 환경의 고도화를 지나 Kubernetes로의 인프라 마이그레이션이라는 큰 산을 넘었습니다.
단순히 운영 플랫폼을 옮긴 것이 아니라, 많은 시행착오를 바탕으로 GitOps와 Service Mesh 등 현대적인 인프라 스택을 도입하며 그동안 해결하기 어려웠던 팀 내 비효율을 기술로 개선할 수 있는 단단한 기반을 다진 한 해였습니다.

왜 ECS를 떠나 Kubernetes로의 전환을 결심했는지, 그리고 그 과정에서 어떻게 서비스 중단 없이 마이그레이션을 진행했는지 등 나누고 싶은 이야기가 많습니다. 하지만 이에 대해서는 추후 별도의 글을 통해 깊이 있게 다루기로 하고, 오늘은 이 변화가 가져온 긍정적인 결과 중 하나에 집중해보려 합니다.

이번 글에서는 이러한 인프라 변화를 발판 삼아, 개발팀의 고질적인 병목이었던 QA 환경 부족 문제를 기술적으로 해결한 PR Preview 환경 구축기를 공유하고자 합니다.

배경: “개발 서버, 지금 써도 되나요?”

병목 다이어그램

저희 팀은 많은 개발자가 하나의 레포지토리에서 동시에 여러 기능을 개발하고 있습니다. 인프라 전환 이전에는 위 다이어그램에 표현된 것처럼 공통적으로 다음과 같은 문제에 시달려야 했습니다.

  1. 단일 QA 환경: QA를 위한 환경은 develop 브랜치가 배포되는 개발 환경 단 하나뿐이었습니다.
  2. 병목: 기능 A를 테스트 중일 때 기능 B를 배포하려면 충돌 해결을 하며 병합을 하거나, 기능 A 테스트가 끝날 때까지 기다렸다가 덮어씌워야 했습니다.
  3. 높은 커뮤니케이션 비용: 결국 팀원들은 누가 언제 개발 환경을 사용할지 이야기하며 순차적으로 QA를 진행해야 했고, 긴급한 테스트를 위해 기존 배포를 덮어씌우려면 일일이 양해를 구해야 했습니다.

이러한 일이 너무 자주 일어나다보니 아래와 같이 “리베이스 배포”, “테섭 밀게요” 같은 슬랙 이모티콘이 만들어져 여러 채널에서 일상적으로 사용되고 있었습니다.

개발서버 사용 슬랙 채널

이러한 문제를 완화하기 위한 시도가 개발팀에서 먼저 있었습니다. 바로 기능 플래그를 기반으로 단일 환경에 여러 기능을 배포하고 사용 중에 플래그를 변경하여 원하는 기능을 확인하는 방식이었습니다.

기능플래그

그러나 기능 플래그로는 문제를 완전히 해소할 수 없는 다음과 같은 상황이 있었습니다.

  1. 기능이 다양한 영역에 걸쳐 있어서 플래그로 감싸기가 난감한 경우
  2. 종속 패키지 버전의 업데이트 등 서버 수준의 교체가 필요한 경우

위와 같은 상황에서도 QA 병목을 없애려면 인프라 수준에서의 지원이 불가피했습니다.

이러한 배경에서, 저희는 Kubernetes 도입 논의를 시작하던 2024년 말부터 Kubernetes의 유연함과 GitOps 도구들을 활용해 “PR 별로 독립된 테스트 환경을 자동으로 만들자” 는 목표를 세웠습니다.

아키텍처 및 해결 과정

저희가 목표로 한 PR Preview 환경의 핵심 요구사항은 다음과 같습니다.

  1. PR에 preview 라벨이 붙으면 자동으로 격리된 환경이 생성되어야 한다.
  2. 많은 PR 환경이 생성되더라도 비용 부담이 적어야 한다.
  3. 기존 개발 환경과 동일한 방식으로 접근할 수 있으면서 특정 서비스를 향하는 내 트래픽만 분기되어야 한다.

이를 해결하기 위해 Argo CD ApplicationSet의 동적 환경 생성 능력과 Linkerd의 트래픽 라우팅 기능을 조합했습니다.

1. Argo CD ApplicationSet: 동적 환경 생성

PR마다 자동으로 환경을 만들려면 두 가지 조건이 필요했습니다. 첫째는 필요할 때마다 찍어낼 수 있는 ‘틀(Template)‘이고, 둘째는 PR 생명주기에 맞춰 틀을 찍고 부수는 ‘기계(Generator)‘입니다.

저희는 Helm 차트로 각 서비스의 환경 템플릿을 준비하고, Argo CD(쿠버네티스를 위한 GitOps 기반의 지속적 배포 도구)의 ApplicationSet Pull Request Generator 를 도입했습니다.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: pr
spec:
  goTemplate: true
  generators:
    - pullRequest:
        github:
          owner: # org name
          repo: # project repo name
          appSecretName: argocd-github-app
          labels: # PR Labels Filter
            - preview
  requeueAfterSeconds: 1800
  template:
    metadata:
      name: "inflearn-courses-fe-pr-{{ .number }}"
      labels:
        type: pr
    spec:
      project: default
      source:
        repoURL: "https://github.com/org/gitops.git" # gitops repo
        path: "apps/inflearn/courses-fe/chart"
      destination:
        namespace: "inflearn"
      syncPolicy:
        automated: {}
  templatePatch: |
    {{ $env := eq .target_branch "main" | ternary "prod" "dev" }}
    metadata:
      annotations:
        # Argo CD 알림 수신할 슬랙 채널
        notifications.argoproj.io/subscribe.on-sync-status-unknown.slack: "alarm-course-fe-{{ $env }}-deploy"
        notifications.argoproj.io/subscribe.on-sync-failed.slack: "alarm-course-fe-{{ $env }}-deploy"
        notifications.argoproj.io/subscribe.on-health-degraded.slack: "alarm-course-fe-{{ $env }}-deploy"
    spec:
      source:
        helm:
          releaseName: "inflearn-courses-fe-pr-{{ .number }}"
          # 글로벌, 환경, 앱 순서로 로드하여 중복 존재 시 후행 우선
          valueFiles:
            - "/values/values.yaml"
            - "/values/{{ $env }}.yaml"
            - "/apps/inflearn/courses-fe/{{ $env }}/values.yaml"
          valuesObject:
            prNumber: "{{ .number }}"
            imageTag: "{{ $env }}-{{ .head_short_sha_7 }}"
      destination:
        # 환경 이름으로 클러스터 선택
        name: "{{ $env }}"
  # 수동 Auto-Sync 구성 유지
  ignoreApplicationDifferences:
    - jsonPointers:
        - /spec/syncPolicy

Argo CD는 Webhook을 통해 GitHub의 PR 이벤트를 감지합니다. 무분별한 리소스 생성을 막기 위해 preview 라벨이 붙은 PR에 대해서만 Application이 생성되도록 구성했습니다.

이제 개발자가 PR에 라벨만 붙이면, Argo CD가 알아서 inflearn-courses-fe-pr-<번호> 와 같은 이름의 Application을 생성하고 배포를 시작합니다.

GitHub PR preview 태그 설정

Argo CD PR Application

Preview 환경은 기능 테스트가 목적이므로 개발/운영 환경과 동일한 스펙일 필요가 없습니다. Helm 템플릿 로직을 활용해 Ingress 등 불필요한 리소스는 제외하고, Pod 리소스와 Replicas를 최소한으로 줄여 비용을 최적화했습니다.

{{- if not .Values.prNumber }}
apiVersion: networking.k8s.io/v1
kind: Ingress
...
{{- define "inflearn-courses-fe.deployment" -}}
{{- $replicas = eq .type "pr" | ternary 1 (int .helm.Values.replicas) -}}
apiVersion: apps/v1
kind: Deployment
...
spec:
  replicas: {{ $replicas }}
  template:
    spec:
      containers:
        - name: {{ include "inflearn-courses-fe.name" .helm }}
          {{- if eq .type "pr" }}
          resources:
            requests:
              cpu: "100m"
              memory: "500Mi"
            limits:
              memory: "500Mi"
...

2. Linkerd HTTPRoute: 쿠키 기반 트래픽 라우팅

환경은 격리되어 생성되었지만, 사용자가 이 환경에 어떻게 접속하게 할지 고민이 필요했습니다. 저희는 다음 세 가지 방안을 검토했습니다.

  1. 서브 도메인 분리: project-pr-123.inflearn.com
  2. URL 경로 분리: www.inflearn.com/project-pr-123/
  3. 쿠키 기반 라우팅: 기존 도메인 유지 + 특정 헤더(쿠키) 값에 따라 분기

1번과 2번 방식의 결정적인 문제는 ‘서비스 간 이동 시의 맥락 단절’ 이었습니다. 예를 들어 PR 환경의 서비스 A(a-pr-1.inflearn.com)에서 운영 환경의 서비스 B(www.inflearn.com)로 이동했다가 다시 A로 돌아올 때, 사용자는 PR 환경이 아닌 운영 환경의 A로 돌아오게 됩니다. 이를 해결하려면 애플리케이션 코드 수정 작업이 필요했습니다.

결국 저희는 애플리케이션 수정 없이 인프라 레벨에서 처리할 수 있는 3번 쿠키 기반 라우팅을 선택했습니다.

이 구현의 핵심은 Linkerd(사이드카 컨테이너 기반의 경량 서비스 메시)였습니다.

처음에는 ingress-nginx를 활용하려 했는데, 동일한 Host와 Path를 가진 Ingress 리소스를 중복해서 생성하면 ingress-nginx 컨트롤러에서 오류를 내므로 PR 환경을 동적으로 라우팅하려면 하나의 Ingress 리소스를 수정해 복잡한 configuration-snippet을 구성하는 곡예에 가까운 설정이 필요했습니다.
반면, Gateway API의 HTTPRoute와 같은 리소스는 라우팅 규칙을 여러 리소스로 분산하여 정의하는 것을 기본적으로 지원하므로 기존 설정을 건드리지 않고도 PR마다 라우팅 리소스를 개별적으로 생성하고 삭제하는, 훨씬 안전한 방식의 구현이 가능했습니다.

이미 mTLS 및 모니터링을 위해 Linkerd를 도입한 상태였기 때문에, ingress-nginx는 그대로 두고 Linkerd의 Traffic Split 기능을 활용하여 다음과 같이 라우팅 규칙을 구성했습니다.

{{- if .Values.prNumber }}
{{- $fullname := include "inflearn-courses-fe.fullname" . }}
{{- $originalServiceName := mustRegexReplaceAll "^[^-]+-" $fullname "" }}
{{- $cookieName := printf "<redacted>-%s-pr" $fullname }}
apiVersion: policy.linkerd.io/v1beta3
kind: HTTPRoute
metadata:
  name: {{ include "inflearn-courses-fe.name" . }}
spec:
  parentRefs:
    - name: {{ $originalServiceName }}
      kind: Service
      group: core
      port: 80
  rules:
    - matches:
      - headers:
        - type: RegularExpression
          name: cookie
          value: "(^|.*; ){{ $cookieName }}={{ .Values.prNumber }}(;.*|$)"
      backendRefs:
        - name: {{ include "inflearn-courses-fe.name" . }}
          port: 80
    - backendRefs:
      - name: {{ $originalServiceName }}
        port: 80
{{- end }}

위와 같이 HTTPRoute 리소스를 통해 특정 헤더(쿠키)를 가진 요청은 해당하는 PR Preview Service를 향하도록 라우팅하는 규칙을 정의하여 PR Preview 환경이 여러 개 실행 중인 경우에도 pr=101, pr=102 등 쿠키 값에 따라 다른 HTTPRoute를 따르게 되어 정상적으로 라우팅되도록 하였습니다.

이러한 구성 덕분에 팀원들은 브라우저 확장 프로그램(Cookie-Editor)이나 사내 유틸리티를 통해 PR 번호 쿠키만 설정하면, 평소 쓰던 URL 그대로 테스트하려는 PR 환경에 접속할 수 있게 되었습니다.

쿠키 설정

쿠키로 라우팅되는 것 확인

결과: 병목 해소

이러한 작업을 통해 QA 병목 현상을 다음과 같이 해소할 수 있었습니다.

QA 병목 해결 다이어그램

  1. 병렬 QA 가능: 동시에 10개의 기능이 개발되어도, 10개의 독립된 테스트 환경이 돌아갑니다.
  2. 커뮤니케이션 비용 절감: “지금 배포해도 되나요?”라고 물어볼 필요가 없어졌습니다. 그냥 라벨만 붙이면 됩니다.

마치며

GitOps와 Service Mesh를 활용하여 PR Preview 환경을 구축함으로써 팀 내 비효율을 개선한 방법에 대해 간략하게 이야기해보았습니다. 팀원들이 보다 빠르게 결과를 확인하고 피드백을 주고받는데 도움이 되어 결국 더 나은 제품으로 이어질 것이라고 생각합니다.

올해부터는 지난 2년 간의 준비를 발판으로 삼아 IaC와 같은 기반이 없었다면 불가능했을 다양한 작업을 진행해볼 계획입니다.

2024년 2월에 작성했던 인프랩 IaC 구축기 (Part 1)의 마지막 문단에서 말씀드렸던 내용입니다.
그로부터 1년 10개월 정도가 지났는데, 정말로 IaC를 기반으로 많은 시도와 작업을 진행할 수 있었고 덕분에 점점 더 유연하고 자동화된 시스템을 만들어올 수 있었던 것 같습니다.

저희는 여기서 멈추지 않고, 관측 가능성과 데이터 기반 운영 등 오래 전부터 계획해온 다음 단계의 개선을 준비하고 있습니다. 언제나 그랬듯 기술 자체보다는 팀이 더 좋은 제품에 집중할 수 있는 환경을 만드는 것을 최우선 목표로 삼아 계속 나아가겠습니다.

감사합니다.