배너

프론트엔드 배포 시스템의 진화 (1) - 결제 SDK에 카나리 배포 적용하기

#Frontend
라웅배 · 토스페이먼츠 Frontend Developer
2024년 3월 5일

토스페이먼츠의 결제는 JavaScript SDK에서 시작돼요. 그래서 안정적인 SDK 배포는 무척 중요하죠. 이 중요도 만큼 SDK 기능 및 제품 출시에 대한 팀원들의 부담과 피로도도 높았는데요. SDK의 변경 사항이 모든 결제 가맹점에 동시에 전파되는 배포 방식 때문이었어요. 그래서 SDK 배포 방식을 카나리 배포(Canary Release) 방식으로 바꿔보기로 했어요. 백엔드 엔지니어링에서 주로 사용되는 ‘카나리 배포’ 기법을 SDK 같은 정적 리소스 환경에 알맞게 변형시켜 안전한 배포 시스템을 만든 거죠.

결제 SDK에 카나리 배포가 필요한 이유

전통적인 프론트엔드 서비스에서는 변경 사항을 모든 사용자에게 한 번에 적용해요. 하지만 실제 사용자 환경은 테스트 환경과 달라서 다양한 이유로 문제가 발생할 수 있어요. 이런 잠재적 리스크 때문에 개발자들은 작은 변경 사항도 쉽게 배포하지 못했고, 배포 후에도 혹시 장애가 발생하지 않을까 걱정하고 지속적으로 모니터링 하는 비효율이 발생했어요. 게다가 결제 제품은 아주 사소한 버그나 오류도 큰 장애와 손실로 전파될 수 있어요. 이런 이유로 변경 사항을 적용할 때 사용자 경험에 미치는 영향을 최소화하면서도, 실제 사용 환경에서의 안정성과 호환성을 보장할 수 있는 접근 방식이 필요하다고 생각했어요.

이 문제를 해결하기 위해 토스페이먼츠 클라이언트 플랫폼 팀에서는 결제 SDK 제품에 카나리 배포를 도입하기로 결정했어요. 카나리 배포는 변경 사항을 전체 사용자에 바로 적용하지 않고, 먼저 소수의 사용자에게만 적용해보고 문제가 없다는 것을 확인한 후에 점진적으로 전체 사용자에게 확대 적용하는 방식이에요. 그럼 지금부터 점진적인 출시로 리스크를 관리하면서 제품의 초기 문제 발견이 가능한 시스템을 구축한 과정을 소개할게요.

요구사항 정의

‘점진적인 출시로 리스크를 관리하면서 제품의 초기 문제 발견하기’라는 목표를 달성하기 위해 필요한 SDK 카나리 배포 요구사항은 다음과 같아요.

  1. 모든 환경에서 균일한 비율로 사용자에게 카나리 버전을 제공할 수 있어야 한다.
    • 브라우저, 기기, 언어, 지역 등으로 구분해서 카나리 배포를 하면 특정 프로덕션 환경이나 유즈케이스가 카나리 대상에서 누락될 수 있어요. 이는 초기 문제 발견을 어렵게 해요.
  2. 요청에 대한 응답이 CDN 캐시로 이루어져야 한다. (적중률 99% 이상)
    • 초기 로딩과 함께 불러오는 JavaScript 파일이기 때문에 빠른 응답을 위한 캐싱 전략이 필수에요.
  3. 실시간으로 롤백이 가능하고, stable/canary 버전의 비율을 자유롭게 조정할 수 있어야 한다.
    • 카나리 버전에 초기 문제가 발견되었을 때, 이를 신속하게 복구할 수 있어야 해요.

우선 단계별로, 카나리 버전을 균일한 비율로 제공하기 위해 다음 3가지 요소를 기반으로 초기 인프라를 설계했어요.

1) SDK 파일을 정적으로 공급하는 S3 bucket

2) 정적리소스를 카나리 배포 전략에 따라 분기가능한 Lambda@Edge

3) 대규모 분산 네트워크 캐싱을 지원하는 CloudFront

초기 설계 및 문제점

표1. CloudFront와 Lambda@Edge를 활용한 간단한 카나리 배포 아키텍처

처음에는 Lambda@Edge를 사용해서 요청마다 생성된 난수를 기준으로 stable 버전과 canary 버전을 weight 비율에 따라 분기하고, 이를 랜덤하게 사용자에게 공급하는 구조를 생각했어요. 하지만 이 설계는 곧 몇 가지 한계에 부딪혔어요.

1. 캐시를 활성화했을 때

예를 들어, /v1/payment 요청에 대해 빠른 응답과 비용 절감을 위해 캐싱 응답이 가능하도록 정책을 설정한다고 가정해 볼게요. 이때 캐시가 아직 없는 초기 응답에서는 Lambda@Edge를 거쳐 임의의 버전(stable 혹은 canary)을 응답하게 되고, 그 이후로 캐싱 시간 동안 다른 기기들은 모두 같은 응답을 받게 됩니다. 이런 상황에서는 기존의 URL 기반 캐싱이 카나리 배포의 목적인 '제한된 클라이언트에 변경 사항을 적용하는 것'과 맞지 않죠.

2. 캐시를 비활성화 했을 때

캐시를 비활성화하면, 모든 요청이 매번 Lambda@Edge를 거쳐 처리되어야 해요. 이렇게 되면 /v1/payment 경로에 대한 모든 요청이 실시간으로 처리되고, 각 요청은 weight에 따라 독립적으로 stable 버전과 canary 버전 중 하나를 받게 됩니다. 이 설정은 카나리 배포의 비율을 정확히 제어할 수 있고, 실시간으로 반영된다는 장점이 있어요. 하지만 no-cache 정책을 사용하면 상당한 Lambda@Edge 컴퓨팅 비용이 발생하고, SDK를 사용하는 서비스들의 콘텐츠 로딩이 지연될 수 있어요. 이는 JavaScript 같은 정적 리소스를 공급하기에 매우 비효율적인 방법이에요.

이를 시뮬레이션 해보면 다음과 같아요:

Canary weight 50%, 캐시를 활성화 했을 때(s-max-age=600)

시간
stable 버전 이용자
canary 버전 이용자
~10분
0%
100%
10~20분
100%
0%
20~30분
0%
100%
30~40분
0%
100%
40~50분
100%
0%

Canary weight 50% , 캐시를 비활성화 했을 때(no-cache)

시간
stable 버전 이용자
canary 버전 이용자
~10분
49.4%
50.6%
10~20분
50.1%
49.9%
20~30분
49.7%
50.3%
30~40분
50.6%
49.4%
40~50분
50.3%
49.7%

User-Based Canary

앞서 언급한 한계들로 인해, 카나리 버전을 균일한 비율로 일정하게 공급하면서 동시에 캐싱 정책도 사용할 수 있는 새로운 아키텍처를 고민하게 됐어요. 꽤 긴 시간 고민하던 중 Kubernetes의 카나리 배포 방법론에서 다음 두 방식의 장점이 눈에 띄었어요:

  • weight-based canary: canary weight를 조절해서 실시간으로 트래픽 공급을 조절하고 언제든지 변경 사항을 롤백할 수 있어요.
    그림 1. 설정된 weight에 따라 랜덤하게 canary/stable 을 공급하는 Weight-Based Canary
  • user-based canary: 사용자마다 일정한 버전의 서비스를 제공할 수 있고, 헤더/쿠키 기반 캐시를 활용할 수 있어요.
    그림 2. 헤더/쿠키 기반으로 canary/stable를 결정하여 공급하는 User-Based Canary

이 두 방식을 합치면, 캐시를 효율적으로 활용하면서도 카나리 배포의 유연성을 유지할 수 있을 것 같다는 영감을 받았습니다.

두 마리 토끼를 잡는 카나리 배포 설계하기

이제 앞서 요구사항 정의에서 언급했던 SDK 카나리 배포의 요구사항을 하나씩 체크하면서, 새로운 구조를 설계해 볼게요.

첫 번째 요구사항인 ‘모든 환경에서 균일한 비율로 사용자에게 카나리 버전을 제공할 수 있어야 한다.’를 충족하기 위해 우선 언어, 지역, 기기, 브라우저 등으로 특정되지 않는 사용자를 구분할 수 있는 값이 필요했어요. 이를 위해 네트워크 패킷 정보를 기반으로 한 32글자의 임의의 문자열인 JA3를 사용했습니다. 이 기능은 Cloudfront Request Header에서 제공되고, 엣지 함수 내에서는 event.request.header['Cloudfront-viewer-ja3-fingerprint']로 접근할 수 있습니다.

그림 3. 위와 같이 Origin Request 정책에 Cloudfront-viewer-ja3-fingerprint' 를 추가하면 CloudFront 함수에서 접근이 가능해진다.

이제 두 번째 요구사항 ‘요청에 대한 응답이 CDN 캐시로 이루어져야 한다.’를 충족하기 위한 작업을 해 볼게요. 일단 복잡한 값(예: user-agent)을 캐시 키로 사용하면 캐시 적중률이 낮아지는 문제를 해결해야 했어요. 그래서 CloudFront의 viewer request 함수를 활용해서 JA3 헤더를 단순한 형태로 변환하는 방법을 사용했어요. 예를 들어 key: x-toss-cohort, value:0~9와 같은 형태로 변환하고, 이를 커스텀 헤더로 삽입해서 origin request의 캐시 키로 사용합니다. 이렇게 하면 캐시 적중률을 높이면서도 필요한 정보를 효과적으로 관리할 수 있죠.

function handler(event) {
  // ...

  if (fingerprintCookie) {
    // 저장된 쿠키가 있으면 이를 헤더로 변환하여 전달합니다 
    // ...
  } else {
    // 저장된 쿠키가 없으면 JA3 헤더를 0~9 사이의 정수로 단순화하여 전달합니다 
    const fingerprintHeader = request.headers['Cloudfront-viewer-ja3-fingerprint'];
    requestHeaders['X-Toss-Cohort'] = { value: getCohort(fingerprintHeader) };
  }

  return request;
}

우리가 사용한 X-Toss-Cohort라는 헤더는 0부터 9 사이의 값 중 하나를 가지고 있고, 이 값은 origin request에서 캐시 키로 사용됩니다. Lambda@Edge에서는 이렇게 손실 없이 전달된 X-Toss-Cohort의 값을 canary weight와 비교하여 canary와 stable 중 적절한 버전을 제공할 수 있어요. 이 방법으로 우리는 사용자에게 가장 적합한 서비스 버전을 효율적으로 제공하면서도, 캐싱을 효과적으로 관리할 수 있게 됩니다.

그림 4. X-Toss-Cohort 값에 따라 응답 결과가 달라지도록 캐시 키 설정

또한 이러한 방식은 Lambda@Edge 실행마다 외부 API를 통해 canary weight를 불러오도록 함으로써, 사용자마다 부여된 헤더/쿠키와 상관없이 동적으로 어떤 비율로 카나리 버전을 공급할지 결정할 수 있어요. 세 번째 요구사항인 ‘실시간으로 롤백이 가능하고, stable/canary 버전의 비율을 자유롭게 조정할 수 있어야 한다.’를 충족시키죠.

다음 표는 canary weight를 20%에서 30%로 변경했을 때 키별 응답 결과의 변화를 보여줘요.

Canary weight가 변화하면서 lambda@edge가 Canary/Stable 버전을 결정하는 과정

마지막으로, 카나리 배포 과정에서 사용자가 일관된 버전을 경험할 수 있도록 요청 과정에서 생성된 JA3 헤더와 X-Toss-Cohort 커스텀 헤더를 쿠키로 저장합니다. 다만 쿠키는 항상 정확성을 보장하지 않기 때문에 핵심 기능이 아닌 사용자마다 일정한 버전 제공을 위해서만 사용했어요. 쿠키가 저장되지 않더라도 서비스에 문제가 없도록 주의해야 해요.

function handler(event) {
  // ...

  function setCookie(name, value) {
    response.cookies[name] = { value, attributes: ... };
  }

  if (fingerprintCookie && fingerprintCookie.length == 32 && cohortHeader) {
    // 이미 쿠키가 존재하면, 쿠키의 유효기간을 갱신합니다. 
    setCookie('x_toss_fingerprint', fingerprintCookie);
    setCookie('x_toss_cohort', cohortHeader);
  } else if (fingerprintHeader && cohortHeader) {
    // 쿠키가 존재하지 않으면, 헤더를 쿠키로 저장합니다.
    setCookie('x_toss_fingerprint', fingerprintHeader);
    setCookie('x_toss_cohort', cohortHeader);
  }

  return response;
}

전체적인 과정을 도식화하면 다음과 같아요:

언뜻 보면 Cloudfront Function이 하나의 요청마다 두 번씩 실행되어 무거울 것 같지만, 실제로는 그렇지 않아요. Cloudfront Function의 실행 시간이 1밀리초 미만으로 매우 짧아서 요청 처리에 지연이 거의 발생하지 않아요. 또 비용 측면에서도 효율적인데요. 예를 들어 1000만 건의 SDK 요청이 발생해도, Cloudfront Function의 추가 비용은 단 $0.6입니다. (10000000(요청 건수) * 2(실행 수) * 0.03 / 1000000(비용) = 0.6)

다음 단계로 나아가기

지금까지 JavaScript SDK와 같이 상대적으로 정적인 프론트엔드 제품에 카나리 배포를 적용하는 과정을 살펴봤어요. 카나리 배포라는 백엔드 기술을 프론트엔드 제품에 접목시키는 작업을 하면서, 다양한 배포 전략과 이를 클라우드 아키텍쳐에 효과적으로 구현하기 위한 요소들을 깊이 이해할 수 있었어요.

하지만 카나리 배포를 100% 활용하기 위해서는 이런 기술적인 구현 뿐만 아니라 실제 개발자의 ‘배포 경험’도 중요하게 고려해야 해요. 변경 사항을 배포하는 개발자가 안전하다고 느끼며 자신감을 가지고 서비스를 출시할 수 있도록 개발자 경험을 개선하는 것이죠. 그래서 다음 포스트에서는 카나리 배포를 이용해 동료들이 더 자신감 있고 안전하게 배포하는 경험을 만들어나간 과정도 다뤄볼게요.

감사합니다.

참고 자료

Write 라웅배 Review 나재은 Edit 한주연 Graphic 이나눔, 이은호

댓글 0댓글 관련 문의: toss-tech@toss.im
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.