Lambda@Edge로 특정 유저에게 다른 S3 Origin을 제공하는 AB Test 구현기

메디스트림·2026년 3월 6일
post-thumbnail

안녕하세요, 메디스트림 PM 스쿼드의 백엔드 개발자 김현수입니다.

PM 스쿼드는 한의사 밀착관리 서비스 린다이어트를 개발하고 있습니다. 우리 스쿼드는 환자가 진료를 신청하고 다이어트 과정을 기록하는 린다이어트 앱과, 한의사가 환자의 데이터를 기반으로 밀착관리를 돕는 린다이어트 차트, 두 개의 제품을 관리하고 있습니다.

최근 린다이어트 차트에서 특정 유저에게만 다른 S3 Origin을 제공하는 AB Test를 구현했습니다. 이 글에서는 S3, CloudFront 환경에서 어떻게 사용자에 따라 Origin을 분기하는지 소개하고, 구현 과정에서 마주했던 문제와 해결 과정을 정리합니다.

들어가며: 왜 이런 구조가 필요했는가

서비스를 운영하다 보면, 새로운 기능을 전체 유저에게 한꺼번에 배포하고 싶지 않은 경우가 있습니다. UI 시안에 따라 사용자 행동을 구분해서 분석하고 싶거나, 비용을 지출해야 하는 기능을 특정 유저 세그먼트에게 먼저 검증하고 싶을 때가 그렇습니다.

우리 스쿼드에는 다음과 같은 요구사항이 있었습니다:

  • 특정 유저에게만 새로운 기능을 먼저 제공하고 싶다
  • 프론트엔드 코드에 분기 로직을 넣지 않고, 인프라 레벨에서 분기하고 싶다
  • 실험이 끝나면 인프라 설정만 변경하여 쉽게 롤백하고 싶다

사내에는 이미 QA를 위한 S3 Bucket이 별도로 생성되어 있었고, 다른 작업을 위해 Lambda@Edge를 활용하고 있었습니다. 이미 있는 인프라를 최대한 활용하고 싶었기에, Lambda@Edge로 AB Test를 구현하는 것으로 결정하였습니다.


CloudFront와 Lambda@Edge의 기본 개념

CloudFront의 요청 처리 흐름

CloudFront는 AWS의 CDN 서비스입니다. 유저의 요청이 들어오면 다음과 같은 라이프사이클을 거칩니다:

유저 → [Viewer Request] → CloudFront 캐시 확인
                              ↓ (캐시 미스)
                        [Origin Request] → S3/Origin 서버
                              ↓
                        [Origin Response] → CloudFront 캐시 저장
                              ↓
                        [Viewer Response] → 유저

여기서 주목할 부분은 Origin입니다. Origin이란 CloudFront가 콘텐츠를 가져오는 원본 서버를 의미하며, S3 버킷, EC2 인스턴스, ALB 등이 Origin이 될 수 있습니다. 린다이어트 차트는 Vue 3 기반의 SPA(Single Page Application)로, 빌드된 정적 파일을 S3 버킷에 업로드하고 CloudFront를 통해 서빙하고 있습니다. 즉 이 구조에서 Origin을 바꾼다는 것은, CloudFront가 정적 파일을 가져오는 S3 버킷 자체를 바꾸는 것을 의미합니다.

Lambda@Edge란?

Lambda@Edge는 CloudFront의 각 라이프사이클 이벤트에 Lambda 함수를 연결할 수 있는 기능입니다. 4개의 이벤트 각각에 함수를 붙일 수 있으며, 각 이벤트는 서로 다른 역할을 합니다.

이벤트시점주요 용도
Viewer Request유저 요청이 CloudFront에 도달했을 때인증, 리다이렉트, 헤더 조작
Origin RequestCloudFront가 Origin으로 요청을 보내기 직전Origin 변경, URL 재작성
Origin ResponseOrigin으로부터 응답을 받았을 때응답 헤더 추가, 에러 처리
Viewer Response유저에게 응답을 보내기 직전보안 헤더 추가

제가 주목한 것은 Origin Request 이벤트입니다. 이 시점에서 request.origin을 변경하면, CloudFront가 다른 S3 버킷에서 파일을 가져오도록 할 수 있습니다. 이 때에만 Origin을 동적으로 변경할 수 있기 때문에 기능 구현에는 Origin Request 작업이 반드시 필요했습니다.


아키텍처 설계 및 구현

핵심 구조는 단순합니다. 유저의 id를 추출해 실험 대상인지 확인하고, 대상이 아니라면 기존 배포된 S3 버킷으로, 대상이라면 신규 기능이 포함된 S3 버킷으로 Origin을 바꿔주는 설계입니다.

아키텍처

CloudFront Origin 추가 설정

코드에서 request.origin을 동적으로 변경하더라도, CloudFront 배포에 해당 S3 버킷이 Origin으로 등록되어 있어야 정상적으로 동작합니다. 이 설정을 빠뜨리면 CloudFront가 요청을 거부하거나 예기치 않은 에러가 발생할 수 있습니다.

따라서 Lambda@Edge 배포 이전에 AWS Console에서 다음 단계를 수행해야 합니다.

  1. CloudFront Console → 설정하고자 하는 배포 선택 → Origins(원본) 탭 이동
  2. Create Origin(원본 생성) 클릭
  3. 실험용 S3 버킷 정보 입력
  4. Origin 생성 후, Behavior(동작) 설정의 Lambda@Edge 함수 구성

또한 신규 기능이 배포된 S3 버킷의 버킷 정책(Bucket Policy)에서 CloudFront로부터의 접근을 허용해야 합니다. 이때 버킷이 CloudFront OAI/OAC를 통한 접근이 설정되어 있어야 합니다.

전체 코드

export const handler = (event, context, callback) => {
  const { request } = event.Records[0].cf;
  const { headers } = request;
  const host = headers.host?.[0]?.value || "";

  const userId = getUserIdFromCookie(headers.cookie);

  // userId가 cookie에 없다면 비로그인 유저
  if (!userId) {
    callback(null, request);
    return;
  }

  const isExperimentTarget = checkIsExperimentTarget(userId);

  if (isExperimentTarget) {
    const experimentBucketName = EXPERIMENT_BUCKET_NAME_MAP[env];
    const experimentBucketPath = EXPERIMENT_BUCKET_PATH_MAP[env] || "";

    request.origin = {
      s3: {
        authMethod: "none",
        customHeaders: {},
        domainName: experimentBucketName,
        path: experimentBucketPath,
        readTimeout: 30,
      },
    };

    headers.host = [{ key: "Host", value: experimentBucketName }];
  }

  callback(null, request);
};

코드 흐름 상세 분석

Step 1: 유저 식별

const userId = getUserIdFromCookie(headers.cookie);

쿠키에서 userId 식별자를 추출합니다. 위의 코드에는 하나의 userId만 추출하는 것으로 되어있지만, 실제로는 안정성을 위해 두개 이상의 식별자를 추출해 사용했습니다.

만일 userId가 없으면 비로그인 유저로 간주하고, 기존 Origin을 그대로 사용합니다.

Step 2: 실험 대상 확인

const isExperimentTarget = checkIsExperimentTarget(userId);

checkIsExperimentTarget 함수는 화이트리스트에 해당 userId가 존재하는지 확인합니다:

export const BETA_ASSIGNMENTS_MAP = {
  TARGET_USER_ID: true,
};

export const checkIsExperimentTarget = (id) => BETA_ASSIGNMENTS_MAP[id];

처음 구현하는 기능이었고, Lambda@Edge의 실행 시간 제약 등을 고려해 코드 내에 object를 만들어 화이트리스트를 관리하는 방식을 선택하였습니다.

Step 3: Origin 교체 (핵심)

if (isExperimentTarget) {
  request.origin = {
    s3: {
      authMethod: "none",
      customHeaders: {},
      domainName: experimentBucketName,
      path: experimentBucketPath,
      readTimeout: 30,
    },
  };

  headers.host = [{ key: "Host", value: experimentBucketName }];
}

이것이 전체 구현의 가장 핵심적인 부분입니다.

request.origin을 새로운 S3 설정 객체로 교체하면, CloudFront는 원래 설정된 Origin 대신 신규 기능이 배포된 S3 버킷에서 파일을 가져옵니다. 유저 입장에서는 같은 URL에 접속했지만, 다른 빌드 결과물을 받게 됩니다.


구현 시 마주했던 문제와 유의사항

Origin 변경 후 403 Access Denied

Origin을 신규 기능이 배포된 S3 버킷으로 변경하는 코드를 작성한 뒤, 가장 먼저 마주친 것은 403 Access Denied 에러였습니다.

Origin Path와 루트 파일 매핑을 수정해보고, OAC/OAI 설정을 여러 번 변경하고, 캐시를 무효화하고, 아예 새 버킷을 만들어 테스트해 봐도 동일한 에러가 발생했습니다. 기존 버킷과 모든 설정이 동일한데 왜 안 되는 건지 한참을 헤맸습니다.

결국 원인은 CloudFront Behavior의 캐시 정책 및 헤더 관련 설정에 있었습니다. 기존 Behavior에 설정된 헤더 관련 정책이 Lambda@Edge에서 변경한 Host 헤더의 전달에 영향을 주고 있었던 것입니다. 해당 설정을 조정한 뒤에야 정상적으로 동작했습니다.

Lambda@Edge로 Origin을 동적으로 변경할 때는, 코드뿐 아니라 CloudFront Behavior에 설정된 캐시 정책과 헤더 포워딩 설정도 함께 확인해야 합니다. 특히 Origin 변경 시 Host 헤더를 함께 변경하는 것은 필수인데, S3는 Host 헤더를 기반으로 요청을 검증하기 때문입니다.

// Origin 변경 시 Host 헤더도 반드시 함께 변경
headers.host = [{ key: "Host", value: experimentBucketName }];

Lambda@Edge의 환경 변수 미지원

일반 Lambda에서는 process.env로 환경변수를 전달할 수 있지만, Lambda@Edge는 환경 변수를 지원하지 않습니다. 환경 변수를 사용해야 한다면 코드 레벨에서 관리하거나, 외부 저장소를 사용해야 합니다.

유저 식별을 위한 쿠키

이 구현은 쿠키에서 userId를 추출하는 것을 전제로 합니다. 린다이어트 차트의 경우 기존에 유저 식별용 쿠키가 이미 존재했기 때문에 별도의 프론트엔드 수정 없이 구현할 수 있었습니다. 만약 쿠키에 유저를 식별할 수 있는 값이 없다면, 프론트엔드에서 쿠키를 심어주는 작업이 선행되어야 합니다. "인프라 레벨에서의 분기"라는 장점이 프론트엔드 변경 없이 가능했던 것은, 이러한 사전 조건이 갖춰져 있었기 때문입니다.


이 방식의 장점과 한계

장점

항목설명
프론트엔드 코드 무변경분기 로직이 인프라에만 존재하므로, 프론트엔드에 if/else가 필요 없음
캐시 효율Origin Request는 캐시 미스 시에만 실행되므로, 불필요한 Lambda 호출을 최소화
비용 효율Lambda@Edge는 실행 횟수 기반 과금이며, 캐시 히트 시 과금되지 않음
완전한 격리실험 빌드와 메인 빌드가 물리적으로 다른 경로에 존재하여 간섭 없음

한계 및 고려사항

항목설명
정적 화이트리스트대상 유저가 코드에 하드코딩되어 있어, 변경 시 재배포 필요
Lambda@Edge 제약실행 시간 30초 제한, 패키지 크기 1MB 제한, 환경 변수 미지원
배포/삭제 지연Lambda@Edge는 전 세계 엣지 로케이션에 배포되므로, 배포 및 삭제 후 반영까지 수 분 소요
디버깅 어려움CloudWatch 로그가 실행된 엣지 로케이션의 리전에 분산 저장됨

만약 특정 유저를 지정하지 않고 랜덤하게 비교군(Control)과 대조군(Experiment)을 나누어야 하는 경우, Origin Request 단독으로는 구현이 어렵습니다. 유저를 처음 방문할 때 그룹에 배정하고, 이후 동일한 그룹을 유지해야 하기 때문입니다. 이를 위해서는 다음과 같은 추가 작업이 필요합니다:

  1. Viewer Request: 쿠키가 없는 신규 유저에게 랜덤으로 그룹을 배정하고, 해당 정보를 헤더에 추가
  2. Origin Request: 헤더에 담긴 그룹 정보를 기반으로 Origin을 분기
  3. Origin Response: 배정된 그룹 정보를 Set-Cookie로 응답에 포함하여, 다음 요청부터 동일한 그룹이 유지되도록 처리

이 패턴에 대한 상세한 구현 방법은 A/B Testing on AWS CloudFront with Lambda@Edge에서 잘 설명하고 있으니 참고하시기 바랍니다.


마무리

Lambda@Edge의 Origin Request 이벤트를 활용하면, 클라이언트 코드 변경 없이 인프라 레벨에서 AB Test를 구현할 수 있습니다.

이 글에서 소개한 접근법의 핵심을 요약하면:

  1. 쿠키 기반으로 유저를 식별하여 실험 대상 여부 판별
  2. Origin Request에서 request.origin을 변경하여 S3 버킷을 동적으로 교체

Lambda@Edge는 몇 가지 제약사항이 있지만, CDN 레벨에서 요청을 세밀하게 제어할 수 있다는 점에서 강력한 도구입니다. 특히 "같은 URL인데 다른 결과물을 서빙해야 하는" 상황에서 빛을 발합니다.


참고했던 글

profile
메디스트림 기술 블로그

0개의 댓글