React로 구현했더니 카톡에 링크 미리보기가 다 똑같이 떴다
SSR 없이 SPA 글에 OG 썸네일·메타 붙이기
#SEO #프론트엔드 #SPA #OG #React
블로그 글 하나를 카톡으로 공유했다. 미리보기 카드가 뜨는데 — 제목도, 썸네일도, 설명도 다른 글이랑 똑같았다. 다른 글 링크를 보내봐도 마찬가지. 무슨 글을 보내든 전부 블로그 메인 미리보기로 떴다. 검색도 비슷했다. 구글에 글 제목을 쳐도 잘 안 잡히고, 잡혀도 설명이 엉뚱했다. PHP로 서버에서 HTML을 그려줄 땐 이런 적이 없었는데, React(SPA)로 옮기고 나서 생긴 문제였다. 이 글은 SSR(Next.js 같은 거) 없이 , 서버에서 글별 메타태그만 끼워넣어서 이걸 해결한 기록이다. 왜 전부 똑같이 떴을까 내 Express는 마지막에 이 한 줄로 끝난다. // React SPA 라우팅 처리 app.get('*', (req, res) = { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); /blog/56이든 /blog/60이든 메인이든, 모든 경로에 똑같은 index.html 하나를 내려준다. 그게 SPA다. 실제 글 내용은 그 다음에 자바스크립트가 돌면서 화면에 그려진다. 문제는 여기다 — 카톡 봇이랑 검색 크롤러는 자바스크립트를 안 기다린다. 걔네는 서버가 처음 내려준 HTML만 쓱 보고 간다. 근데 그 HTML엔 글마다 다른 정보가 한 글자도 없다. title도 og 태그도 전부 index.html에 박힌 기본값 그대로니까, 크롤러 입장에선 내 블로그 글이 전부 똑같은 페이지인 거다. JS가 나중에 제목을 바꿔봐야 소용없다. 카톡 봇은 이미 떠난 뒤니까. 그럼 JS 말고 서버에서 끼워넣자 방법은 둘이었다. 1. Next.js 같은 SSR 프레임워크로 갈아탄다 2. 크롤러가 보는 그 첫 HTML에, 서버가 글별 메타를 미리 박아서 내려준다 1번은 블로그 하나 SEO 때문에 프론트를 통째로 다시 짜는 거다. 과했다. 그래서 2번 — /blog/:bid로 요청이 오면, index.html을 그냥 내려주지 말고 그 글의 제목·설명·썸네일을 읽어서 메타태그를 끼워넣은 다음 내려주기로 했다. 순서가 제일 중요했다 이거 하면서 제일 먼저 깨달은 건 라우트 등록 순서 다. 위에서 본 app.get('*')가 모든 걸 잡아채기 때문에, 글 전용 핸들러를 그 앞에 둬야 한다. 뒤에 두면 영원히 안 불린다. const { blogMetaHandler, sitemapHandler } = require('./util/seo'); // sitemap은 정적 dist/sitemap.xml보다 먼저 app.get('/sitemap.xml', sitemapHandler); app.use(express.static(path.join(__dirname, 'dist'))); // 글 상세만 가로채서 글별 메타를 주입 app.get('/blog/:bid', blogMetaHandler); // 그 외 전부 → SPA app.get('*', (req, res) = { /* index.html */ }); /blog/:bid가 static과 '*' 사이에 끼어 있는 게 핵심이다. 이 순서가 어긋나면 아무리 코드를 잘 짜도 핸들러가 안 탄다. 메타태그 갈아끼우기 핸들러가 하는 일은 단순하다. DB에서 글을 읽고, index.html의 기본 메타를 걷어낸 뒤, 글별 태그로 갈아끼운다. // 기존 title/og/twitter 태그를 다 걷어내고 html = html .replace(/ title [\s\S]*? \/title /i, '') .replace(/ meta\s+property="og:[^"]*"[^ ]* /gi, '') .replace(/ meta\s+name="twitter:[^"]*"[^ ]* /gi, ''); // 글별 태그를 /head 앞에 박는다 const tags = ` title ${esc(meta.title)} /title meta property="og:title" content="${esc(meta.title)}" / meta property="og:description" content="${esc(meta.description)}" / meta property="og:image" content="${esc(meta.image)}" / meta name="twitter:card" content="summary_large_image" / meta name="twitter:image" content="${esc(meta.image)}" / `; html = html.replace(' /head ', tags + ' /head '); 카톡 미리보기 썸네일이 바로 이 og:image 다. twitter:card를 summary_large_image로 주면 큰 이미지 카드로 뜨고. 기존 태그를 안 지우고 추가만 하면 태그가 두 개씩 생겨서 어떤 게 먹을지 알 수 없으니, 걷어내고 → 새로 박는 순서로 했다. 그리고 사용자 입력이 들어가는 모든 값은 esc()로 감쌌다. 글 제목에 따옴표나 부등호가 들어가면 태그가 통째로 깨지니까 — 이건 보안이자 안정성 문제다. 썸네일은 글 첫 이미지, 없으면 로고 og:image에 넣을 썸네일은 그 글에 첨부된 첫 번째 이미지를 쓴다. 없으면 블로그 로고로 떨어지게 했다. const fileRows = await q( `SELECT f.path FROM BoardFile bf JOIN Files f ON f.id = bf.file_id WHERE bf.board_id = ? ORDER BY bf.seq ASC LIMIT 1`, [bid] ); const image = fileRows.length ? `${BASE}/${fileRows[0].path}` : `${BASE}/logo.png`; 여기서 이미지 주소는 무조건 절대경로 여야 한다. 카톡 봇은 내 사이트 밖에서 이 URL을 따로 받아오기 때문에, /files/... 같은 상대경로를 주면 썸네일이 안 뜬다. 그래서 앞에 도메인(BASE)을 꼭 붙였다. 구글한테는 한 술 더 — JSON-LD 카톡은 og 태그면 되는데, 구글은 구조화 데이터(JSON-LD) 를 넣어주면 검색결과에 작성자·발행일 같은 걸 더 풍부하게 보여준다. BlogPosting 타입으로 글 정보를 JSON으로 박았다. 여기서 한 번 데인 게 있다. 이 JSON을 script 태그 안에 넣는데, 글 본문에 닫는 script 태그 문자열이 들어 있으면 스크립트 태그가 거기서 끊겨버린다. 그래서 부등호( // /script 시퀀스로 스크립트가 깨지지 않도록 를 이스케이프 return JSON.stringify(data).replace(/ /g, '\\u003c'); 이거 안 하면 평소엔 멀쩡하다가, 본문에 우연히 그 문자열이 들어간 글 하나가 페이지 전체를 깨뜨린다. 딱 내가 좋아하는 종류의 함정이다 — 평소엔 안 보이다가 특정 데이터에서만 터지는. 크롤러가 본문을 아예 못 읽는 문제 메타는 해결됐는데, 본문이 또 문제였다. SPA라 처음 HTML의 root div는 텅 비어 있다. 크롤러가 본문 키워드를 읽을 게 없는 거다. 그래서 실제 글 텍스트를 noscript에 넣어서 같이 내려줬다. const body = ` noscript article h1 ${esc(meta.h1)} /h1 p ${esc(meta.description)} /p div ${esc(meta.bodyText)} /div /article /noscript `; HTML에서 본문을 그냥 긁으면 p, strong 태그가 딸려 오니까, 정규식으로 태그를 다 벗겨 순수 텍스트만 넣었다. 너무 길면 페이지가 비대해지니까 12,000자에서 잘랐고. 마지막으로 sitemap도 DB로 크롤러한테 "내 글 목록 여기 있다"고 알려주는 sitemap.xml도 정적 파일로 두면 글 올릴 때마다 손으로 고쳐야 한다. 그냥 요청 올 때 DB에서 글 목록을 읽어서 그 자리에서 XML을 만들어 내려주게 했다. 글을 새로 올리면 sitemap에 자동으로 들어간다. 한 발 더 — 코인 사이트는 썸네일을 실시간으로 그린다 블로그는 글마다 다른 게 "내용"이라, 첨부된 이미지를 썸네일로 박으면 됐다. 근데 코인 사이트는 사정이 달랐다. 거기서 매번 다른 건 시세 다. 비트코인 가격이랑 김치프리미엄은 1초마다 변하는데, 정적 이미지 한 장으로 이걸 보여줄 방법이 없다. 그래서 코인 쪽은 아예 썸네일을 그때그때 그려서 내려준다. /og-image가 현재 시세를 받아서 SVG로 카드 한 장을 만들고, 그걸 PNG로 변환해 응답한다. app.get('/og-image', async (req, res) = { if (!btcPrice) btcPrice = await fetchLiveBtc(); // 시세 가져와서 const svg = buildOgSvg({ btcDisplay, kimpDisplay, kimpColor }); // SVG 카드로 그리고 const png = await sharp(Buffer.from(svg)).png().toBuffer(); // PNG로 변환 res.setHeader('Content-Type', 'image/png'); res.send(png); }); SVG로 그리니까 텍스트 위치, 폰트 크기, 색깔을 코드로 자유롭게 박을 수 있다. 김프가 플러스면 초록, 마이너스면 빨강으로 칠해서 — 썸네일만 봐도 지금 분위기가 보이게 했다. 그리고 공유용 HTML(/share)은 크롤러만을 위한 페이지다. og:image가 위의 동적 이미지를 가리키고, 진짜 사용자가 들어오면 JS로 본 사이트로 리다이렉트시킨다. 근데 매 공유마다 이미지를 새로 그리는 건 비싸다. SNS 봇은 같은 링크를 여러 번 긁어가기도 한다. 그래서 같은 파라미터면 60초 동안 만들어둔 PNG를 재사용 하게 캐시를 뒀다. if (ogCache.key === cacheKey ogCache.png (now - ogCache.at) OG_CACHE_TTL) { return res.send(ogCache.png); // 60초 안이면 다시 안 그림 } 결국 블로그랑 코인은 같은 문제(SPA 공유 미리보기)를 정반대로 풀었다. 블로그는 내용이 변수라 메타를 갈아끼웠고, 코인은 데이터가 변수라 이미지를 그렸다. 보여줘야 할 게 뭐가 변하느냐에 따라 방법이 갈린 거다. 정리하면 SPA의 SEO 문제는 결국 한 문장이다 — 크롤러는 자바스크립트가 그려주기 전의 HTML만 본다. 그래서 글마다 달라야 하는 것(제목, 썸네일, 본문)을 JS한테 맡기지 말고, 서버가 내려주는 그 첫 HTML에 미리 박아둬야 한다. Next.js로 갈아탈 필요도 없었다. 글 상세 경로 하나만 서버에서 가로채서 메타를 끼워넣고, 본문은 noscript로 보태고, sitemap은 DB로 만들고. 이게 전부다. 거창한 SSR 대신, "카톡 봇이랑 구글이 지금 내 페이지에서 뭘 보고 있나" 를 한 번 들여다본 게 시작이었다. 혹시 SPA로 블로그나 서비스를 만들었는데 공유 미리보기가 영 허전하다면, 프레임워크를 바꾸기 전에 이 방법부터 해볼 만하다. 보여줘야 할 게 내용이냐 데이터냐만 정하면, 나머지는 그 첫 HTML에 뭘 박을지의 문제였다.