AWS Lambda@Edge를 사용한 유연한 A/B 테스트

게시 됨: 2022-03-11

Amazon CloudFront와 같은 콘텐츠 전송 네트워크(CDN)는 최근까지 웹 인프라의 비교적 단순한 부분이었습니다. 전통적으로 웹 애플리케이션은 이러한 애플리케이션을 중심으로 설계되었으며 대부분 활성 구성 요소가 아닌 수동 캐시로 처리되었습니다.

Lambda@Edge 및 이와 유사한 기술은 웹 애플리케이션과 해당 사용자 사이에 새로운 논리 계층을 도입함으로써 이 모든 것을 변화시켰고 모든 가능성의 세계를 열었습니다. 2017년 중반부터 사용 가능한 Lambda@Edge는 CloudFront의 엣지 서버에서 직접 Lambda 형식으로 코드를 실행하는 개념을 도입한 AWS의 새로운 기능입니다.

Lambda@Edge가 제공하는 새로운 가능성 중 하나는 서버 측 A/B 테스트에 대한 깨끗한 솔루션입니다. A/B 테스트는 웹사이트 청중의 다른 세그먼트에 동시에 보여줌으로써 웹사이트의 여러 변형의 성능을 테스트하는 일반적인 방법입니다.

A/B 테스트에서 Lambda@Edge가 의미하는 것

A/B 테스트의 주요 기술 과제는 실험 데이터의 품질이나 웹사이트 자체에 어떤 식으로든 영향을 미치지 않으면서 들어오는 트래픽을 적절하게 분류하는 것입니다.

이를 구현하기 위한 두 가지 주요 경로가 있습니다: 클라이언트 측서버 측 .

  • 클라이언트 측에는 표시할 변형을 선택하는 최종 사용자의 브라우저에서 약간의 JavaScript 코드 실행이 포함됩니다. 이 접근 방식에는 몇 가지 중요한 단점이 있습니다. 특히 렌더링 속도가 느려지고 깜박임 또는 기타 렌더링 문제가 발생할 수 있습니다. 즉, 로딩 시간을 최적화하려고 하거나 UX에 대한 높은 표준을 갖고 있는 웹사이트는 이 접근 방식을 피하는 경향이 있습니다.
  • 서버 측 A/B 테스트는 반환할 변형에 대한 결정이 전적으로 호스트 측에서 이루어지기 때문에 이러한 문제의 대부분을 제거합니다. 브라우저는 웹사이트의 표준 버전인 것처럼 각 변형을 정상적으로 렌더링합니다.

이를 염두에 두고 왜 모든 사람이 단순히 서버 측 A/B 테스트를 사용하지 않는지 궁금할 것입니다. 불행히도 서버 측 접근 방식은 클라이언트 측 접근 방식만큼 구현하기 쉽지 않으며 실험을 설정하려면 서버 측 코드 또는 서버 구성에 대한 개입이 필요한 경우가 많습니다.

상황을 더욱 복잡하게 만드는 것은 SPA와 같은 최신 웹 애플리케이션이 웹 서버를 포함하지 않고도 S3 버킷에서 직접 정적 코드 번들로 제공된다는 점입니다. 웹 서버가 관련된 경우에도 A/B 테스트를 설정하기 위해 서버 측 로직을 변경하는 것이 종종 실현 가능하지 않습니다. CDN의 존재는 캐싱이 세그먼트 크기에 영향을 미치거나 반대로 이러한 종류의 트래픽 세분화가 CDN의 성능을 저하시킬 수 있기 때문에 또 다른 장애물이 됩니다.

Lambda@Edge가 제공하는 것은 실험의 변형이 서버에 도달하기 전에 사용자 요청을 라우팅하는 방법입니다. 이 사용 사례의 기본 예는 AWS 설명서에서 직접 찾을 수 있습니다. 개념 증명으로 유용하지만 여러 동시 실험이 있는 프로덕션 환경에는 더 유연하고 강력한 것이 필요할 수 있습니다.

또한, Lambda@Edge로 작업한 후에 아키텍처를 구축할 때 알아야 할 몇 가지 뉘앙스가 있음을 깨닫게 될 것입니다.

예를 들어 엣지 Lambda 배포에는 시간이 걸리고 해당 로그는 AWS 리전 전체에 배포됩니다. 502 오류를 피하기 위해 구성을 디버그해야 하는 경우 이 점에 유의하십시오.

이 자습서에서는 AWS 개발자에게 Edge Lambda를 수정 및 재배포하지 않고도 여러 실험에서 재사용할 수 있는 방식으로 Lambda@Edge를 사용하여 서버 측 A/B 테스트를 구현하는 방법을 소개합니다. AWS 설명서 및 기타 유사한 자습서의 예제 접근 방식을 기반으로 하지만 Lambda 자체에서 트래픽 할당 규칙을 하드코딩하는 대신 언제든지 변경할 수 있는 S3의 구성 파일에서 규칙을 주기적으로 검색합니다.

Lambda@Edge A/B 테스트 접근 방식 개요

이 접근 방식의 기본 아이디어는 CDN이 각 사용자를 세그먼트에 할당하도록 한 다음 사용자를 연결된 원본 구성으로 라우팅하는 것입니다. CloudFront를 사용하면 배포가 S3 또는 사용자 지정 오리진을 가리킬 수 있으며 이 접근 방식에서는 둘 다 지원합니다.

실험 변형에 대한 세그먼트 매핑은 S3의 JSON 파일에 저장됩니다. 여기서는 단순성을 위해 S3를 선택했지만, 이는 데이터베이스 또는 엣지 Lambda가 액세스할 수 있는 다른 형태의 스토리지에서 검색할 수도 있습니다.

참고: 몇 가지 제한 사항이 있습니다. 자세한 내용은 AWS 블로그의 Lambda@Edge에서 외부 데이터 활용 문서를 참조하십시오.

구현

Lambda@Edge는 4가지 유형의 CloudFront 이벤트에 의해 트리거될 수 있습니다.

Lambda@Edge는 4가지 유형의 CloudFront 이벤트에 의해 트리거될 수 있습니다.

이 경우 다음 세 가지 이벤트 각각에 대해 Lambda를 실행합니다.

  • 뷰어 요청
  • 원산지 요청
  • 시청자 반응

각 이벤트는 다음 프로세스의 단계를 구현합니다.

  • abtesting-lambda-vreq : 대부분의 논리가 이 람다에 포함되어 있습니다. 먼저 들어오는 요청에 대해 고유 ID 쿠키를 읽거나 생성한 다음 [0, 1] 범위로 해시합니다. 그런 다음 트래픽 할당 맵을 S3에서 가져와서 여러 실행에 걸쳐 캐시합니다. 마지막으로 해시된 값을 사용하여 다음 Lambda에 JSON 인코딩 헤더로 전달되는 원본 구성을 선택합니다.
  • abtesting-lambda-oreq : 이전 Lambda에서 원본 구성을 읽고 그에 따라 요청을 라우팅합니다.
  • abtesting-lambda-vres : Set-Cookie 헤더를 추가하여 사용자의 브라우저에 고유 ID 쿠키를 저장합니다.

3개의 S3 버킷도 설정해 보겠습니다. 이 중 2개에는 각 실험 변형의 콘텐츠가 포함되고 세 번째 버킷에는 트래픽 할당 맵이 포함된 JSON 파일이 포함됩니다.

이 자습서의 버킷은 다음과 같습니다.

  • abtesting-ttblog - 공개
    • index.html
  • abtesting-ttblog-b 공개
    • index.html
  • abtesting-ttblog-map 비공개
    • 지도.json

소스 코드

먼저 트래픽 할당 맵부터 시작하겠습니다.

지도.json

 { "segments": [ { "weight": 0.7, "host": "abtesting-ttblog-a.s3.amazonaws.com", "origin": { "s3": { "authMethod": "none", "domainName": "abtesting-ttblog-a.s3.amazonaws.com", "path": "", "region": "eu-west-1" } } }, { "weight": 0.3, "host": "abtesting-ttblog-b.s3.amazonaws.com", "origin": { "s3": { "authMethod": "none", "domainName": "abtesting-ttblog-b.s3.amazonaws.com", "path": "", "region": "eu-west-1" } } } ] }

각 세그먼트에는 해당 트래픽 양을 할당하는 데 사용되는 트래픽 가중치가 있습니다. 또한 원본 구성 및 호스트도 포함됩니다. 오리진 구성 형식은 AWS 설명서에 설명되어 있습니다.

abtesting-lambda-vreq

 'use strict'; const aws = require('aws-sdk'); const COOKIE_KEY = 'abtesting-unique-id'; const s3 = new aws.S3({ region: 'eu-west-1' }); const s3Params = { Bucket: 'abtesting-ttblog-map', Key: 'map.json', }; const SEGMENT_MAP_TTL = 3600000; // TTL of 1 hour const fetchSegmentMapFromS3 = async () => { const response = await s3.getObject(s3Params).promise(); return JSON.parse(response.Body.toString('utf-8')); } // Cache the segment map across Lambda invocations let _segmentMap; let _lastFetchedSegmentMap = 0; const fetchSegmentMap = async () => { if (!_segmentMap || (Date.now() - _lastFetchedSegmentMap) > SEGMENT_MAP_TTL) { _segmentMap = await fetchSegmentMapFromS3(); _lastFetchedSegmentMap = Date.now(); } return _segmentMap; } // Just generate a random UUID const getRandomId = () => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); }; // This function will hash any string (our random UUID in this case) // to a [0, 1) range const hashToInterval = (s) => { let hash = 0, i = 0; while (i < s.length) { hash = ((hash << 5) - hash + s.charCodeAt(i++)) << 0; } return (hash + 2147483647) % 100 / 100; } const getCookie = (headers, cookieKey) => { if (headers.cookie) { for (let cookieHeader of headers.cookie) { const cookies = cookieHeader.value.split(';'); for (let cookie of cookies) { const [key, val] = cookie.split('='); if (key === cookieKey) { return val; } } } } return null; } const getSegment = async (p) => { const segmentMap = await fetchSegmentMap(); let weight = 0; for (const segment of segmentMap.segments) { weight += segment.weight; if (p < weight) { return segment; } } console.error(`No segment for value ${p}. Check the segment map.`); } exports.handler = async (event, context, callback) => { const request = event.Records[0].cf.request; const headers = request.headers; let uniqueId = getCookie(headers, COOKIE_KEY); if (uniqueId === null) { // This is what happens on the first visit: we'll generate a new // unique ID, then leave it the cookie header for the // viewer response lambda to set permanently later uniqueId = getRandomId(); const cookie = `${COOKIE_KEY}=${uniqueId}`; headers.cookie = headers.cookie || []; headers.cookie.push({ key: 'Cookie', value: cookie }); } // Get a value between 0 and 1 and use it to // resolve the traffic segment const p = hashToInterval(uniqueId); const segment = await getSegment(p); // Pass the origin data as a header to the origin request lambda // The header key below is whitelisted in Cloudfront const headerValue = JSON.stringify({ host: segment.host, origin: segment.origin }); headers['x-abtesting-segment-origin'] = [{ key: 'X-ABTesting-Segment-Origin', value: headerValue }]; callback(null, request); };

여기에서 이 튜토리얼에 대한 고유 ID를 명시적으로 생성하지만, 대부분의 웹사이트에는 대신 사용할 수 있는 다른 클라이언트 ID가 있는 것이 일반적입니다. 이렇게 하면 뷰어 응답 Lambda가 필요하지 않습니다.

성능 고려 사항을 위해 트래픽 할당 규칙은 모든 요청에 ​​대해 S3에서 가져오는 대신 Lambda 호출 전반에 걸쳐 캐시됩니다. 이 예에서는 캐시 TTL을 1시간으로 설정했습니다.

X-ABTesting-Segment-Origin 헤더는 CloudFront에서 허용 목록에 추가되어야 합니다. 그렇지 않으면 원본 요청 Lambda에 도달하기 전에 요청에서 지워집니다.

abtesting-람다-오렉

 'use strict'; const HEADER_KEY = 'x-abtesting-segment-origin'; // Origin Request handler exports.handler = (event, context, callback) => { const request = event.Records[0].cf.request; const headers = request.headers; const headerValue = headers[HEADER_KEY] && headers[HEADER_KEY][0] && headers[HEADER_KEY][0].value; if (headerValue) { const segment = JSON.parse(headerValue); headers['host'] = [{ key: 'host', value: segment.host }]; request.origin = segment.origin; } callback(null, request); };

원본 요청 Lambda는 매우 간단합니다. 원본 구성 및 호스트는 이전 단계에서 생성되어 요청에 주입된 X-ABTesting-Origin 헤더에서 읽습니다. 그러면 캐시 누락이 발생한 경우 해당 오리진으로 요청을 라우팅하도록 CloudFront에 지시합니다.

abtesting-람다-vres

 'use strict'; const COOKIE_KEY = 'abtesting-unique-id'; const getCookie = (headers, cookieKey) => { if (headers.cookie) { for (let cookieHeader of headers.cookie) { const cookies = cookieHeader.value.split(';'); for (let cookie of cookies) { const [key, val] = cookie.split('='); if (key === cookieKey) { return val; } } } } return null; } const setCookie = function (response, cookie) { console.log(`Setting cookie ${cookie}`); response.headers['set-cookie'] = response.headers['set-cookie'] || []; response.headers['set-cookie'] = [{ key: "Set-Cookie", value: cookie }]; } exports.handler = (event, context, callback) => { const request = event.Records[0].cf.request; const headers = request.headers; const response = event.Records[0].cf.response; const cookieVal = getCookie(headers, COOKIE_KEY); if (cookieVal != null) { setCookie(response, `${COOKIE_KEY}=${cookieVal}`); callback(null, response); return; } console.log(`no ${COOKIE_KEY} cookie`); callback(null, response); }

마지막으로 최종 사용자 응답 Lambda는 Set-Cookie 헤더에서 생성된 고유 ID 쿠키를 반환하는 역할을 합니다. 위에서 언급했듯이 고유한 클라이언트 ID가 이미 사용 중인 경우 이 Lambda는 완전히 생략될 수 있습니다.

실제로 이 경우에도 뷰어 요청 Lambda에 의해 리디렉션으로 쿠키를 설정할 수 있습니다. 그러나 이것은 약간의 대기 시간을 추가할 수 있으므로 이 경우 단일 요청-응답 주기에서 수행하는 것을 선호합니다.

코드는 GitHub에서도 찾을 수 있습니다.

Lambda 권한

다른 엣지 Lambda와 마찬가지로 Lambda를 생성할 때 CloudFront 청사진을 사용할 수 있습니다. 그렇지 않으면 사용자 지정 역할을 생성하고 "기본 Lambda@Edge 권한" 정책 템플릿을 연결해야 합니다.

최종 사용자 요청 Lambda의 경우 트래픽 할당 파일이 포함된 S3 버킷에 대한 액세스도 허용해야 합니다.

Lambda 배포

Edge Lambda를 설정하는 것은 표준 Lambda 워크플로와 약간 다릅니다. Lamba의 구성 페이지에서 "트리거 추가"를 클릭하고 CloudFront를 선택합니다. 그러면 이 Lambda를 CloudFront 배포와 연결할 수 있는 작은 대화 상자가 열립니다.

세 가지 Lambda 각각에 대해 적절한 이벤트를 선택하고 "배포"를 누릅니다. 그러면 CloudFront의 엣지 서버에 함수 코드를 배포하는 프로세스가 시작됩니다.

참고: Edge Lambda를 수정하고 재배포해야 하는 경우 먼저 수동으로 새 버전을 게시해야 합니다.

CloudFront 설정

CloudFront 배포가 트래픽을 오리진으로 라우팅할 수 있으려면 오리진 패널에서 각각을 별도로 설정해야 합니다.

변경해야 할 유일한 구성 설정은 X-ABTesting-Segment-Origin 헤더를 허용 목록에 추가하는 것입니다. CloudFront 콘솔 에서 배포를 선택한 다음 편집을 눌러 배포 설정을 변경합니다.

동작 편집 페이지 에서 선택한 요청 헤더 기반 캐시 옵션의 드롭다운 메뉴에서 화이트리스트 를 선택하고 사용자 지정 X-ABTesting-Segment-Origin 헤더를 목록에 추가합니다.

CloudFront 설정

이전 섹션에서 설명한 대로 Edge Lambda를 배포했다면 이미 배포와 연결되어 있고 Edit Behavior 페이지의 마지막 섹션에 나열되어 있어야 합니다.

사소한 경고가 있는 좋은 솔루션

서버 측 A/B 테스트는 CloudFront와 같은 CDN 서비스 뒤에 배포된 트래픽이 많은 웹 사이트에 대해 적절하게 구현하기 어려울 수 있습니다. 이 기사에서 우리는 A/B 실험을 실행하기 위한 깨끗하고 안정적인 솔루션을 제공하면서 구현 세부 정보를 CDN 자체에 숨겨서 Lambda@Edge를 이 문제에 대한 새로운 솔루션으로 사용할 수 있는 방법을 보여주었습니다.

그러나 Lambda@Edge에는 몇 가지 단점이 있습니다. 가장 중요한 것은 CloudFront 이벤트 간의 이러한 추가 Lambda 호출은 지연 시간과 비용 측면에서 모두 합산될 수 있으므로 CloudFront 배포에 미치는 영향을 먼저 신중하게 측정해야 합니다.

또한 Lambda@Edge는 AWS의 비교적 최근에 개발된 기능이므로 여전히 가장자리가 약간 거칠게 느껴집니다. 좀 더 보수적인 사용자는 인프라의 중요한 지점에 배치하기 전에 시간을 기다려야 할 수도 있습니다.

즉, 제공하는 고유한 솔루션으로 인해 CDN의 필수 기능이 되므로 앞으로 훨씬 더 널리 채택될 것으로 기대하는 것이 무리가 아닙니다.


AWS 고급 컨설팅 파트너 배지