갑자기 프론트엔드 포스팅을 쓰게 되었다..
어느 날 문득 개발로 부수입을 벌어보고 싶다는 생각이 들었다.
마침 주변에 웹·앱에 애드센스를 붙여 매달 용돈을 벌고 있는 개발자가 적지 않게 보이더라.
문제는 프론트엔드 경험이 전무하다는 사실이었다.
하지만 최근 Cursor AI·GPT 같은 ‘바이브 코딩’ 도구들이 꽤 쓸 만해 보여 직접 React로 도전해 보기로 했다.
개인 일정상 오래 매달릴 수 없었기에, 주말 이틀동안 밤새서 ‘자체 1인 해커톤’으로 삼아 기획부터 배포까지 끝내기로 결심했다.
광고 수익을 노리려면 트래픽이 필수이니, 공유 기능이 있는 단순 모바일 웹을 목표로 삼았다.
LuckStargram
그래서 LuckStargram이라는 AI 운세 서비스를 만들기로 했다.
- 이름·생년월일·운세 날짜를 입력하면 GPT AI가 오늘의 운세를 생성한다.
- 결과는 카드형 UI로 표시되며, 메시지와 행동 팁을 함께 제공한다.
또한, 인스타그램이나 카카오톡에 쉽게 공유를 유도하기 위해 '티켓'이라는 게이미피케이션 요소도 넣었다.
- 운세 결과를 공유하면 티켓 1장 추가 획득
- 공유 링크로 들어온 사용자도 1회 티켓을 받을 수 있음
주말 동안 기획 → 개발 → 배포까지 일사천리로 진행했다.
- 백엔드 : NestJS → AWS API Gateway + AWS Lambda
- 프론트엔드 : React → Vercel
하지만 카카오톡으로 공유해 보니 메타태그가 항상 같은 이미지·텍스트로 노출되었다.
그래서 React 공유페이지에서 메타태그를 아래처럼 넣고 공유해봤다.
const dateObj = new Date(json.fortune_date);
const mm = dateObj.getMonth() + 1;
const dd = dateObj.getDate();
const nameOnly = json.name.length > 1 ? json.name.slice(1) : json.name;
const title = `${nameOnly}님의 ${mm}월 ${dd}일 운세 🍀`;
const firstSentence = json.message.split('. ')[0] + '.';
const description = firstSentence;
document.title = title;
const setMeta = (sel: string, attr: string, val: string) => {
const el = document.querySelector(sel);
if (el) el.setAttribute(attr, val);
};
setMeta('meta[property="og:title"]', 'content', title);
setMeta('meta[name="twitter:title"]', 'content', title);
setMeta('meta[property="og:description"]', 'content', description);
setMeta('meta[name="description"]', 'content', description);
그러나 결과는 변하지 않았다.
찾아보니 React는 SPA라서 클라이언트 사이드에서 동적으로 메타태그를 변경해도
카카오톡, 페이스북 같은 소셜 크롤러들이 이를 제대로 인식하지 못한다고 하더라.
SPA는 처음에 비어있는 HTML을 받고, JavaScript가 실행되면서 동적으로 내용을 채우는 방식이다.
하지만 소셜 크롤러들은 초기 HTML만 읽고 JavaScript 실행을 기다리지 않기 때문에 빈 메타태그만 보게 된다.
그래서 아무리 JavaScript로 메타태그를 수정해도 소용없는 것이다.
그래서 다른 방법을 찾아보았다.
다이나믹 링크, Prerender.io 시도
이전에 다른 프로젝트에서 공유 기능을 넣을 때 Firebase의 다이나믹 링크를
쉽게 적용했었던 기억이 있어서 바로 적용하려고 홈페이지에 들어가보니
... 그렇다고 한다.
그래서 어쩔 수 없이 다른 대안을 찾아보았다.
프리렌더링 Prerender.io를 알게 되었는데, 이것도 물론 편하고 좋지만
초반 일부만 무료고 유료라길래 다른 방법을 알아보기로 했다.
https://www.linode.com/ko/blog/devops/improve-seo-with-prerender-io/
Prerender.io로 SEO 개선
이 글에서는 Prerender.io의 프리렌더링 서비스가 SEO 장애물을 극복하는 데 어떻게 도움이 되는지 살펴보겠습니다.
www.linode.com
React-Helmet-Async + Puppeteer 시도
- react-helmet-async : 렌더 시점에 <head>를 동적으로 조작해 주는 라이브러리
- Puppeteer : Headless Chrome을 제어해 실제 브라우저에서 페이지를 렌더링, 정적 HTML을 생성할 수 있음
두 라이브러리 조합이 내 상황에 적절해 보였기에 바로 도입했다.
1. <Helmet>으로 SPA 내부에서 동적 OG 메타태그를 설정했다.
2. Puppeteer로 크롤러 User-Agent일 때만 HTML 스냅샷을 생성하도록 프리렌더링 플로우를 만들었다.
하지만…
// vite.config.ts
function generatePerformanceRoutes() {
const staticRoutes = ['/', '/main']; // 정적 경로
const dynamicRoutes = []; // 동적 경로 저장 배열
dynamicRoutes.push('/share/123');
return staticRoutes.concat(dynamicRoutes);
}
dynamicRoutes에 직접 경로를 써야만 HTML이 생성됐다.
운세 페이지는 /share/:uuid 형태로 동적 생성되는데, SSG 시점에는 UUID를 알 수 없다.
빌드 후에 메시지 큐로 vite 설정을 다시 만지려 하니, 구조가 지나치게 복잡해져 다른 방법을 모색했다.
Next.js SSR 도입
결국 하이브리드 구조로 결정했다:
- 메인 앱: React SPA로 유지 (빠른 사용자 경험)
- 공유 페이지: Next.js SSR로 분리 (완벽한 OG 메타태그)
- share. 서브도메인을 만들어 도메인 레벨로 기능을 구분
여기에 Short Link까지 도입해서 share/luckstargram.com/abc123 같은 깔끔한 공유 주소를 만들기로 했다.
// pages/share/[uuid].tsx
// pages/share/[uuid].tsx
import { GetStaticProps } from 'next';
import Head from 'next/head';
export const getStaticProps: GetStaticProps = async ({ params }) => {
const uuid = params?.uuid as string;
// 백엔드에서 운세 데이터 가져오기
const res = await fetch(`${process.env.API_BASE_URL}/share/${uuid}`);
const data = await res.json();
// 동적 OG 메타 생성
const nameOnly = data.name.length > 1 ? data.name.slice(1) : data.name;
const title = `${nameOnly}님의 ${mm}월 ${dd}일 운세 🥠`;
const description = data.message.split('. ')[0] + '.';
return {
props: { meta: { title, description, image: '/logo.webp' } },
revalidate: 60, // ISR로 60초마다 캐시 갱신
};
};
export default function SharePage({ meta }) {
return (
<>
<Head>
<title>{meta.title}</title>
<meta property="og:title" content={meta.title} />
<meta property="og:description" content={meta.description} />
<meta property="og:image" content={meta.image} />
{/* 일반 사용자는 메인 앱으로 리다이렉트 */}
<meta httpEquiv="refresh" content="0; URL=https://luckstargram.com" />
</Head>
<div />
</>
);
}
// middleware.ts
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const SOCIAL_BOTS = ['facebookexternalhit', 'twitterbot', 'kakaotalk'];
export async function middleware(req: NextRequest) {
const userAgent = req.headers.get('user-agent')?.toLowerCase() || '';
const isSocialBot = SOCIAL_BOTS.some(bot => userAgent.includes(bot));
if (isSocialBot) {
// 소셜 크롤러 → SSR 페이지로 내부 라우팅
return NextResponse.rewrite(new URL(`/share/${uuid}`, req.url));
} else {
// 일반 사용자 → 메인 앱으로 리다이렉트
return NextResponse.redirect('https://luckstargram.com/share/...', 302);
}
}
/share/[uuid] 페이지에서 ISR + Edge Runtime으로 OG 메타를 동적으로 공급하도록 구성했다.
여기서 ISR(Incremental Static Regeneration)이란?
Next.js의 하이브리드 렌더링 방식으로, 빌드 시에는 정적 페이지를 만들고,
런타임에는 필요에 따라 페이지를 재생성한다.
revalidate: 60로 설정하면 60초마다 백그라운드에서 페이지를 새로 만들어서 최신 데이터를 유지할 수 있다.
이렇게 SPA의 빠른 로딩 성능을 유지하면서도,
공유 페이지는 SSR로 완벽히 OG 메타를 제공하는 하이브리드 구조를 완성했다.
최종적으로 아키텍쳐는 아래처럼 간단하게 구성을 하였다.
설명이 길었다.
결국 서비스가 제대로 작동하는지 궁금하다면, 직접 한 번 체험해 보는 편이 가장 빠르다.
https://share.luckstargram.com/mCrr1R
승모님의 5월 25일 운세 🥠
오늘은 명확함이 길을 열어요.
share.luckstargram.com
이름이랑 생년월일 입력해 AI가 오늘의 운세를 만들어준 결과를
카카오톡에 공유해보면 이 글에서 설명한 동적 OG 메타태그가 어떻게 작동하는지 직접 확인할 수 있을 것이다!
참조 :
https://techblog.woowahan.com/15469/
https://team-beat.tistory.com/32
https://firebase.google.com/support/dynamic-links-faq?hl=ko
https://www.npmjs.com/package/react-helmet-async
https://developer.chrome.com/docs/puppeteer?hl=ko
https://nextjs.org/docs/app/getting-started/metadata-and-og-images
++ 해당 포스팅은 vibeaz에도 함께 게재되었습니다.