목차
· 배경
· SPA에서 어떻게 동적 오픈 그래프 태그를 적용해요?
· SPA의 동작원리
· Vercel 배포 환경에서 동적 오픈그래프 태그 적용해보기
배경
처음 레이어 서비스를 구상했을 때, 아이디어에 발맞춰 개발자들끼리 개발에 대한 설계를 진행하던 중이었어요. 레이어 서비스는 사용자들이 자유롭게 만들어가는 회고 서비스로 초기 기술 스택 선정과 설계에 대한 아래와 같은 고민들을 했어요.
· 과연 레이어 서비스에서 SSR이 필요한가?
· 서비스를 개발할 때 알고 있는 기술 스택을 기반으로 빠른 개발을 진행해야하는가?
· 비록 러닝 커브가 있더라도 신기술에 대한 도전을 해야하는가?
결과적으로 팀 레이어의 프론트엔드 개발자들은 문제를 해결하기 위한 기술에 초점을 맞추었고, 서비스가 필요로하지 않는 오버 엔지니어링을 지양하고자 SPA를 기반으로 서비스에 꼭 필요한 기술스택으로 프로젝트를 진행하기로 했어요. 서로에게 익숙한 기술스택으로 진행을 하다보니 실제로 개발은 순조롭게 진행이 되었고, 정말 많은 커밋 수와 빠른 MVP 개발을 진행할 수 있었어요. 하지만 최종 프로덕트를 출시하기 전 문제가 발생합니다.
SPA 동작 원리
해당 본문에 들어가기 전 SPA의 동작원리에 대해 가볍게 알아보려고 해요. 동적 오픈 그래프가 기본적으로 적용되지 않아요. SSR 프레임워크인 Next에서는 워낙 쉽게 접근을 할 수 있지만, 지원을 하지 않기 때문에 워낙 SPA에서 동적 오픈 그래프를 적용하는 것은 기술적 챌린지 중 하나였습니다.
먼저, 가장 많이 비교하는 SSR와 CSR을 비교해볼게요. SSR의 경우 매번 새로운 HTML 파일을 응답하여 생성된 HTML을 바탕으로 매번 새로운 페이지를 그려주는 방식을 채택해요. 그리고 이 각각의 페이지는 서로 다른 HTML을 의미합니다. 반면 CSR의 경우 여러 개의 페이지로 존재하는 것처럼 보이더라도, 내부적으로는 1개의 HTML 파일에서 컴포넌트들이 교체되는 형식으로 구성이 되어있어요. 클라이언트 측에서 응답 데이터를 기반으로 새로운 페이지가 아닌 그 부분에 대해서만 업데이트를 해요.
이를 위해 SEO 관련하여 크롤러 입장에서 살펴보았을 때, 크롤러는 SSR 방식의 각 페이지에 접근하면서 각 페이지의 html 정보를 수집하게 되어요. 반면 CSR 방식의 각 페이지에 접근을 하게 된다면, 각 페이지에 대한 정보는 1개의 루트.html 정보만을 수집하게 됩니다. 즉, 모든 페이지들은 크롤러의 입장에서 하나의 루트 페이지로 인식합니다.
SPA에서 어떻게 동적 오픈 그래프 태그를 적용해요?
SPA에서 동적 오픈 그래프 태그를 적용하기 위해서 가장 많이 사용을 하고 있는 react-helmet과 react-snap의 조합을 통해 빠르고 쉽게 구현을 하고자 했어요. 그런데.. 오픈 그래프 태그를 동적으로 변경해주는 react-helmet까지는 원활하게 적용이 잘 되고 있지만, 이를 브라우저 환경에서 잘 인식시켜주기 위한 react-snap의 경우 워낙 오래된 라이브러리이다보니 현재 사용하고 있는 Vite 프로젝트에는 맞지 않는 라이브러리였어요. 이러한 이유들은 Next와 같은 SSR 관련 프레임워크가 출시되면서 업데이트가 되지 않는 SPA 동적 태그 관련 라이브러리들이 방치되고 있었고, 관련해 문제에 대한 새로운 방도를 찾아야했어요. 그렇게 첫번째로 찾은 방도는 puppeteer였습니다.
puppeteer는 구글에서 만든 노드 라이브러리로 백그라운드에서 작동하는 브라우저인 Headless Chrome 또는 Chrominum을 제어할 수 있어요. puppeteer을 사용하면 화면을 스크린샷하거나 PDF를 생성할 수 있으며, 크롤링을 하여 사전에 페이지를 렌더링할 수 있어요. 아래 보이는 vite.config.ts 파일을 설정하여 build 시에 사전에 react-helemet으로 설정해놓은 동적 메타태그를 크롤러봇이 routes에 설정한 경로대로 미리 렌더링을 진행하면서 build 결과물에 .html 파일을 생성해요.
import prerender from "@prerenderer/rollup-plugin";
export default defineConfig({
plugins: [
react(),
prerender({
routes: ["/", "/join"],
renderer: "@prerenderer/renderer-puppeteer",
server: {
port: 5173,
host: "localhost",
},
rendererOptions: {
maxConcurrentRoutes: 1,
renderAfterTime: 500,
},
postProcess(renderedRoute) {
renderedRoute.html = renderedRoute.html
.replace(/http:/i, "https:")
.replace(
/(https:\/\/)?(localhost|127\.0\.0\.1):\d*/i,
"https://layerapp.io/"
);
},
}),
],
});
이제 로컬 환경에서 테스트는 마쳤고, 스테이징 환경으로 배포를 진행해서 반영 후 개발자 테스트를 진행하려고 했어요. 그런데 여기서 또 발생한 문제점은 서비스에서 배포환경인 Vercel에서 puppeteer 용량이 워낙 크다보니 설치가 정상적으로 되지 않는다는 사실을 알게 되었고, 또 다시 Next를 사용하지 않는 과거의 나를 후회하며 리서칭을 진행합니다. 그렇다고 배포 환경을 변경하려고 해도, 이미 스테이징과 프로덕션 환경을 구축해놓았기 때문에 서비스의 중요 요소인 초대 기능을 위해서는 불가피하게 진행을 해야했어요.
Vercel 배포 환경에서 동적 오픈그래프 태그 적용해보기
Vercel 배포 환경에서 동적 오픈 그래프 태그를 적용하기 위해, express 서버 측에서 부분 렌더링을 구현했어요. 전체 HTML파일을 다시 렌더링 하지 않고, cheerio 라이브러리를 통해 특정 요소들만 동적으로 변경하여 클라이언트에 전달할 수 있도록 했어요.
* cheerio : 서버측에서 HTML과 XML 문서를 처리할 때 자주 사용하는 자바스크립트 라이브러리
먼저 fs.readFileSync(filePath,"utf8")로 기본적인 뼈대만 갖춘 HTML 파일을 읽어온 후, cheerio.load로 DOM을 로드해 <title>과 <meta> 태그와 같은 부분만 선택적으로 업데이트 했어요. 이 방식은 통해 서버에서 받은 데이터를 바탕으로 필요한 부분만 동적으로 수정할 수 있었어요. 이 과정에서, 변경된 HTML은 res.send($.html())을 통해 클라이언트에게 전달돼요. 즉, HTML 파일의 구조는 유지하면서, 특정 부분만 변경해 효율적으로 페이지를 업데이트하는 방식을 적용했어요. 아래의 코드는 /space/join/:id로 넘어오는 라우팅에 대해 동적으로 cheerio를 응용하여 변경을 해주는 예제예요.
require('dotenv').config();
const express = require("express");
const fs = require("fs");
const path = require("path");
const cheerio = require("cheerio");
const axios = require("axios");
const CryptoJS = require("crypto-js");
const app = express();
// 프로젝트 루트 경로에서 dist 폴더 제공
const distPath = path.resolve(__dirname, "../dist");
app.use(express.static(distPath));
const CRYPTO_KEY = process.env.VITE_CRYPTO_KEY;
const VECTOR_KEY = process.env.VITE_VECTOR_KEY;
const key = CryptoJS.enc.Utf8.parse(CRYPTO_KEY);
const iv = CryptoJS.enc.Utf8.parse(VECTOR_KEY);
// 서비스에서 ID 값을 암호화하여 이를 복호화하기 위한 함수 구현
function decryptId(encryptedId) {
const word_array = CryptoJS.enc.Base64.parse(encryptedId);
const decoding = word_array.toString(CryptoJS.enc.Utf8);
const decrypted = CryptoJS.AES.decrypt(decoding, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return decrypted.toString(CryptoJS.enc.Utf8);
}
app.get("/space/join/:id", async (req, res) => {
const encryptedId = req.params.id;
const filePath = path.join(distPath, "index.html"); // dist 경로에 있는 index.html 사용
let html;
// 1. HTML 파일 읽기
try {
console.log(`Reading HTML file from ${filePath}`);
html = fs.readFileSync(filePath, "utf8");
} catch (err) {
console.error("Failed to read index.html:", err);
return res.status(500).send("Error loading the page.");
}
let leaderName, teamName;
console.log("VITE_API_URL:", process.env.VITE_API_URL);
console.log(`Decoded ID: ${encryptedId}`);
try {
const decryptedId = decryptId(encryptedId);
const apiResponse = await axios.get(`${process.env.VITE_API_URL}/api/space/public/${decryptedId}`);
const spaceData = apiResponse.data;
console.log("Space Data:", spaceData);
leaderName = spaceData?.leader?.name;
teamName = spaceData?.name;
if (!leaderName || !teamName) {
throw new Error("Missing leaderName or teamName from API response.");
}
} catch (err) {
console.error("Error fetching space data:", err.message);
return res.status(500).send("Failed to fetch space data.");
}
try {
const $ = cheerio.load(html);
$('title').text(`${leaderName}님의 회고 초대장`);
$('meta[name="description"]').attr('content', `함께 회고해요! ${leaderName}님이 ${teamName} 스페이스에 초대했어요.`);
$('meta[property="og:title"]').attr('content', `${leaderName}님의 회고 초대장`);
$('meta[property="og:description"]').attr('content', `함께 회고해요! ${leaderName}님이 ${teamName} 스페이스에 초대했어요.`);
$('meta[property="og:image"]').attr('content', 'https://kr.object.ncloudstorage.com/layer-bucket/retrospectOG.png');
res.send($.html());
} catch (err) {
console.error("Error manipulating HTML:", err);
return res.status(500).send("Error processing HTML.");
}
});
app.get("*", (req, res) => {
const filePath = path.join(distPath, "index.html");
res.sendFile(filePath);
});
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
결론적으로 앞서 많은 고난이 있었지만 ‘서비스가 필요로 하지 않는 오버 엔지니어링을 지양하자’라는 초기 목표에 맞게 SPA기반에 부분 SSR을 적용하여 필요한 기능들을 구현할 수 있었어요
처음 SPA에 동적 오픈 그래프 태그를 적용하면서 express를 사용한 예제에 대해서 많은 레퍼런스가 존재하지 않았어요. 하지만 이를 구현하기 위한 다양한 방법을 시도하면서 Next를 통한 SSR을 굳이 사용하지 않고도, SPA에서 부분 서버 사이드를 사용하고 싶다면 이런 방식으로도 구현이 가능하다는 것을 새롭게 알게 되었습니다 ⚡️
댓글