-
AWS Lambda로 SPA SEO 설정하기Infra/AWS 2024. 6. 23. 17:05
현재 AWS S3와 CloudFront로 구성된 Serverless 인프라로 올라간 SPA에 SEO의 정보가 담긴 데이터를 가져와 적용하는 작업을 맡게 되었습니다. 람다 함수로 어떻게 SPA에 SEO를 적용했는지 팀 내부에 공유를 위해 문서 작성 겸 포스팅하게 되었습니다.
AWS Lambda?
AWS Lambda는 Serverless computing service로 별도의 서버 구성 및 관리의 필요성 없이 실행 시킬 코드를 람다 함수로 구성하여 필요할 때 함수를 실행시키는 서비스입니다. Lambda를 통해 따로 서버를 띄우지 않고 필요할 때만 함수를 실행시켜 사용한 컴퓨팅 시간만큼만 비용이 차징 되기 때문에 함수가 실행되지 않을 때는 차징 되지 않습니다.
작업 플로우
변경할 SEO의 정보를 API를 통해 가져온 다음 head의 meta tag를 교체하는 람다 함수를 작성하여 아래의 이미지와 같이 원본 응답(Origin response)에서 함수를 실행시키는 플로우로 진행했습니다.
1. 람다 함수 작성
CloudFront Edge Location에 적용을 위해 Lambda @Edge를 사용하며 리전은 us-east-1(버지니아 북부)에서 람다 함수를 생성합니다. 필요한 권한이 있다면 추가해서 생성합니다.
사용할 함수 이름을 설정하고 친숙한 런타임 환경을 선택합니다. 해당 작업은 Node 환경에서 진행하겠습니다. 함수 생성 이후 해당 함수에서 동작할 코드를 작성합니다.
API로부터 아래와 같은 타입의 meta data를 받아 meta tag를 작성하여 head에 주입시키는 코드를 작성해 보겠습니다.
export interface MetaData { favicon: string; gaScript: string; og: { title: string; description: string; imageUrl: string; }; seo: { title: string; description: string; registerScript: string; }; }
"use strict"; import * as https from "https"; import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; const s3 = new S3Client({ region: "ap-northeast-2", }); const BUCKET = "<S3 버킷 주소>"; const ENTRY_FILE = "index.html"; const API_SERVER_HOST = "<API HOST 주소>"; export const handler = async (event, context, callback) => { const { response } = event.Records[0].cf; // S3 return error, this case handle to success if (response.status === 403) { // set response status response.status = 200; response.statusDescription = "OK"; // set response header if (response.headers && response.headers["content-type"]) { response.headers["content-type"] = [ { key: "Content-Type", value: "text/html", }, ]; } // set response body from S3 /index.html const getObjectCommand = new GetObjectCommand({ Bucket: BUCKET, Key: ENTRY_FILE, }); const xrooEntryFile = await s3.send(getObjectCommand); let entryBody = await xrooEntryFile.Body.transformToString(); let metaData = { favicon: "", gaScript: "", og: { title: "", description: "", imageUrl: "", }, seo: { title: "", description: "", registerScript: "", }, }; try { const requestOptions = { hostname: API_SERVER_HOST, path: "<API PATH>", method: "GET", }; const response = await sendRequest(requestOptions); if (response.statusCode != 200) { return entryBody; } const responseMetaData = JSON.parse(response.body).data; metaData = Object.assign(metaData, responseMetaData); } catch (error) { console.log("request error: ", error); return entryBody; } // modify response html meta entryBody = await modifyHtmlMeta(entryBody, metaData); response.body = entryBody; } callback(null, response); }; const sendRequest = async (options) => { return new Promise((resolve, reject) => { const req = https.request(options, (res) => { let data = ""; res.on("data", (chunk) => { data += chunk; }); res.on("end", () => { resolve({ statusCode: res.statusCode, headers: res.headers, body: data, }); }); }); req.on("error", (error) => { reject(error); }); req.end(); }); }; const escapeHtmlCharacters = (originStr) => { return originStr .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }; const modifyHtmlMeta = async (originBody, metaData) => { const bodyChunks = originBody.split(`<head>`); // Favicon if (metaData.favicon && metaData.favicon.length > 0) { const modifiedSiteFavicon = `<link rel="icon" href="${metaData.favicon}"/>\n`; bodyChunks[1] = bodyChunks[1].replace( /<link\s+rel\s*=\s*"icon"\s*href\s*=\s*"[^"]*"\s*\/?>/im, modifiedSiteFavicon ); } // GA Script if (metaData.gaScript) { if ( metaData.gaScript && metaData.gaScript.length > 0 && metaData.gaScript.match(/(.*)<script(.*)<\/script>/i) ) { bodyChunks[1] = metaData.gaScript + bodyChunks[1]; } } // Opengraph if (metaData.og) { // OG title if (metaData.og.title && metaData.og.title.length > 0) { const esacpedOGTitle = escapeHtmlCharacters(metaData.og.title); if (esacpedTitle.length > 0) { const modifiedOGTitle = `<meta property="og:title" content="${esacpedOGTitle}"/>\n`; const modifiedTwitterTitle = `<meta name="twitter:title" content="${esacpedOGTitle}"/>\n`; bodyChunks[1] = bodyChunks[1] .replace( /<meta\s+property\s*=\s*"og:title"\s*content\s*=\s*"[^"]*"\s*\/?>/im, modifiedOGTitle ) .replace( /<meta\s+name\s*=\s*"twitter:title"\s*content\s*=\s*"[^"]*"\s*\/?>/im, modifiedTwitterTitle ); } } // OG desc if (metaData.og.description && metaData.og.description.length > 0) { const esacpedOGDesc = escapeHtmlCharacters(metaData.og.description); if (esacpedOGDesc.length > 0) { const modifiedOGDesc = `<meta property="og:description" content="${esacpedOGDesc}"/>\n`; const modifiedTwitterDesc = `<meta name="twitter:description" content="${esacpedOGDesc}"/>\n`; bodyChunks[1] = bodyChunks[1] .replace( /<meta\s+property\s*=\s*"og:description"\s*content\s*=\s*"[^"]*"\s*\/?>/im, modifiedOGDesc ) .replace( /<meta\s+name\s*=\s*"twitter:description"\s*content\s*=\s*"[^"]*"\s*\/?>/im, modifiedTwitterDesc ); } } // OG image if (metaData.og.imageUrl && metaData.og.imageUrl.length > 0) { const imgUrl = metaData.og.imageUrl; if (imgUrl.length > 0) { const modifiedOGImg = `<meta property="og:image" content="${imgUrl}"/>\n`; const modifiedTwitterImg = `<meta name="twitter:image" content="${imgUrl}"/>\n`; bodyChunks[1] = bodyChunks[1] .replace( /<meta\s+property\s*=\s*"og:image"\s*content\s*=\s*"[^"]*"\s*\/?>/im, modifiedOGImg ) .replace( /<meta\s+name\s*=\s*"twitter:image"\s*content\s*=\s*"[^"]*"\s*\/?>/im, modifiedTwitterImg ); } } } // SEO if (metaData.seo) { // Site title if (metaData.seo.title) { const esacpedSiteTitle = escapeHtmlCharacters(metaData.seo.title); if (esacpedSiteTitle.length > 0) { const metaOGSiteName = `<meta name="og:site_name" content="${esacpedSiteTitle}">\n`; const metaTwiiterSiteName = `<meta name="twitter:site" content="${esacpedSiteTitle}">\n`; bodyChunks[1] = metaOGSiteName + metaTwiiterSiteName + bodyChunks[1]; const modifiedSiteTitle = `<title>${esacpedSiteTitle}</title>\n`; bodyChunks[1] = bodyChunks[1].replace( /<title>(.*)<\/title>/im, modifiedSiteTitle ); } } // Site description if (metaData.seo.description) { const esacpedSiteDesc = escapeHtmlCharacters(metaData.seo.description); if (esacpedSiteDesc.length > 0) { const modifiedSiteDesc = `<meta name="description" content="${esacpedSiteDesc}"/>\n`; bodyChunks[1] = bodyChunks[1].replace( /<meta\s+name\s*=\s*"description"\s*content\s*=\s*"[^"]*"\s*\/?>/im, modifiedSiteDesc ); } } // SEO meta tag script if ( metaData.seo.registerScript && metaData.seo.registerScript.length > 0 && metaData.seo.registerScript.match(/<meta(.*)\/>/i) ) { bodyChunks[1] = metaData.seo.registerScript + bodyChunks[1]; } } return bodyChunks[0] + `<head>` + bodyChunks[1]; };
람다 함수에 적용할 해당 코드는 앞서 설명했던 교체할 meta data를 API를 통해 가져온 뒤 head에 교체할 meta data가 반영된 meta tag를 삽입하는 코드입니다. 코드 작성 후 Deploy 버튼을 눌러 반영합니다. 해당 코드는 참고 정도만 확인하면 되겠습니다.
2. 람다 함수 버전 발행
기존에 발행한 버전이 없다면 새로운 버전을 발행하고 추가로 여러 버전의 함수를 관리할 수 있습니다.
3. 람다 함수 적용
CloudFront - 동작 - 편집에서 원본 응답에 생성해둔 람다 함수 ARN을 복사해서 넣어줍니다. 사용할 람다 함수 버전에서 버전이 포함된 ARN을 복사하여 사용합니다.
그리고 작업된 내용이 반영되었는지 SPA에서 확인합니다.
테스트 이벤트
작업된 내용을 직접 확인하기 전에 람다에서 테스트를 돌려 확인할 수 있습니다. 테스트 템플릿은 상황에 따라 다르겠지만 원본 응답에 람다 함수를 적용시켰기때문에 cloudfront-response-generation 템플릿을 사용하여 테스트 진행합니다.
테스트할 이벤트 JSON을 작성하여 테스트를 진행하여 아래와 같이 결과를 확인합니다.
마무리
AWS Lambda를 통해 SPA에서 다이나믹하게 SEO를 적용하는 방식에 대해 살펴보았습니다. 실제 프로젝트에서 예시 코드를 바로 적용하기는 어렵겠지만 상황에 맞게 참고하시면 되겠습니다. 별도의 서버 구성 없이 AWS Lambda를 통해 다양한 기능들을 적용하며 Lambda를 활용하면 되겠습니다.
참고 자료
https://docs.aws.amazon.com/ko_kr/lambda/latest/dg/welcome.html
https://docs.aws.amazon.com/ko_kr/lambda/latest/dg/lambda-edge.html
'Infra > AWS' 카테고리의 다른 글
AWS Serverless 인프라 및 GitHub Actions CI/CD 구축 (1) 2024.05.11