React로 구현했더니 카톡에 링크 미리보기가 다 똑같이 떴다
React SPA라 카톡 미리보기와 검색 결과가 전부 똑같이 떴다. SSR 없이 Express가 글마다 메타태그, JSON-LD, noscript 본문을 끼워 넣도록 만든 과정과 나중에 canonical에서 데인 이야기다.
#SEO #프론트엔드 #SPA #OG #React
블로그 글 하나를 카톡으로 공유했다. 미리보기 카드가 뜨긴 뜨는데, 제목도 썸네일도 설명도 다른 글이랑 똑같았다. 다른 글 링크를 보내봐도 마찬가지였다. 무슨 글을 보내든 전부 블로그 메인 미리보기로 떴다. 검색도 비슷했다. 구글에 글 제목을 쳐도 잘 안 잡히고, 잡혀도 설명이 엉뚱했다. PHP로 서버에서 HTML을 그려줄 땐 이런 적이 없었는데, React로 옮기고 나서 생긴 문제였다. 이 글은 Next.js 같은 SSR 프레임워크 없이, 서버에서 글별 메타태그만 끼워넣어서 이걸 해결한 기록이다. 그리고 그 구조 때문에 나중에 한 번 크게 데인 이야기까지 같이 적어둔다. 왜 전부 똑같이 떴을까 내 Express는 마지막에 이 한 줄로 끝난다. // app.js // 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 말고 서버에서 끼워넣자 방법은 둘이었다. Next.js 같은 SSR 프레임워크로 갈아타거나, 크롤러가 보는 그 첫 HTML에 서버가 글별 메타를 미리 박아서 내려주거나. 앞쪽은 블로그 하나 SEO 때문에 프론트를 통째로 다시 짜는 거라 과했다. 그래서 뒤쪽으로 갔다. /blog/:bid로 요청이 오면 index.html을 그냥 내려주지 말고, 그 글의 제목과 설명, 썸네일을 DB에서 읽어서 메타태그를 끼워넣은 다음 내려주기로 했다. React를 서버에서 돌리는 게 아니라 HTML 문자열을 고치는 거라, 프론트 코드는 한 줄도 안 건드린다. 순서가 제일 중요했다 이거 하면서 제일 먼저 깨달은 건 라우트 등록 순서다. 위에서 본 app.get('*')가 모든 걸 잡아채기 때문에, 글 전용 핸들러를 그 앞에 둬야 한다. 뒤에 두면 영원히 안 불린다. // app.js 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과 '*' 사이에 끼어 있는 게 핵심이다. static보다 앞에 두면 /blog/ 아래에 정적 파일이 있을 때 문제가 되고, '*' 뒤에 두면 아예 안 불린다. 이 순서가 어긋나면 아무리 코드를 잘 짜도 핸들러가 안 탄다. 메타태그 갈아끼우기 핸들러가 하는 일은 단순하다. DB에서 글을 읽고, index.html의 기본 메타를 걷어낸 뒤, 글별 태그로 갈아끼운다. // util/seo.js // 기존 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()로 감쌌다. 글 제목에 따옴표나 부등호가 들어가면 태그가 통째로 깨지니까, 이건 보안이기도 하고 안정성 문제이기도 하다. 지금 이 글을 curl로 받아보면 이렇게 나온다. 글마다 og:title, og:description, og:image, og:url이 다르게 박혀서 나간다. 브라우저 없이 curl로 보이는 게 중요하다. 크롤러가 보는 게 딱 이 상태니까. 썸네일은 글 첫 이미지, 없으면 로고 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 태그 문자열이 그대로 들어 있으면 브라우저가 거기서 스크립트가 끝난 줄 알고 태그를 닫아버린다. 그 뒤의 본문이 스크립트 바깥으로 튀어나오면서 페이지가 깨진다. 그래서 JSON 문자열 안의 여는 부등호를 전부 유니코드 이스케이프로 바꿨다. // util/seo.js function buildJsonLd(meta) { const data = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: (meta.h1 || meta.title).slice(0, 110), description: meta.description, ...(meta.bodyText ? { articleBody: meta.bodyText } : {}), image: meta.image, datePublished: meta.published, author: { '@type': 'Person', name: '최재용', url: meta.base }, mainEntityOfPage: { '@type': 'WebPage', '@id': meta.url }, }; // /script 같은 시퀀스로 스크립트가 깨지지 않도록 를 이스케이프 return JSON.stringify(data).replace(/ /g, '\\u003c'); } JSON 안에서 \u003c는 그냥 부등호와 같은 문자로 읽히니까 데이터는 안 바뀌고, HTML 파서 눈에는 닫는 태그가 안 보인다. 이거 안 하면 평소엔 멀쩡하다가, 본문에 우연히 그 문자열이 들어간 글 하나가 페이지 전체를 깨뜨린다. 딱 내가 제일 싫어하는 종류의 함정이다. 평소엔 안 보이다가 특정 데이터에서만 터진다. 크롤러가 본문을 아예 못 읽는 문제 메타는 해결됐는데, 본문이 또 문제였다. 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자에서 잘랐다. 브라우저에서는 noscript라 안 보이고, 자바스크립트를 안 돌리는 크롤러한테만 보인다. sitemap도 DB에서 만든다 크롤러한테 "내 글 목록 여기 있다"고 알려주는 sitemap.xml도 정적 파일로 두면 글 올릴 때마다 손으로 고쳐야 한다. 그냥 요청이 올 때 DB에서 글 목록을 읽어서 그 자리에서 XML을 만들어 내려주게 했다. 글을 새로 올리면 sitemap에 자동으로 들어간다. 위 라우트 순서에서 sitemap 핸들러를 static보다 앞에 둔 것도 그래서다. dist 안에 옛날 sitemap.xml이 남아 있으면 static이 그걸 먼저 내보낸다. 한 발 더, 코인 사이트는 썸네일을 실시간으로 그린다 블로그는 글마다 다른 게 "내용"이라, 첨부된 이미지를 썸네일로 박으면 됐다. 근데 코인 사이트는 사정이 달랐다. 거기서 매번 다른 건 시세다. 비트코인 가격이랑 김치프리미엄은 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초 안이면 다시 안 그림 } 결국 블로그랑 코인은 같은 문제를 정반대로 풀었다. 블로그는 내용이 변수라 메타를 갈아끼웠고, 코인은 데이터가 변수라 이미지를 그렸다. 보여줘야 할 게 뭐가 변하느냐에 따라 방법이 갈린 거다. 이 코드 때문에 나중에 크게 데였다 여기서 만든 구조에 함정이 있었다. 메타태그를 서버가 끼워넣게 해놓고, canonical은 빌드된 index.html에 하드코딩된 값을 그대로 쓰고 있었다. link rel="canonical" href="https://blog.내도메인/" 블로그만 쓸 때는 문제가 없었다. 그런데 나중에 루트 도메인에서도 같은 서버를 서빙하게 되면서, 루트로 들어온 크롤러가 이 태그를 보게 됐다. "이 페이지의 정본은 다른 주소다"라고 사이트가 스스로 선언한 셈이다. sitemap은 루트 주소를 제출하는데 canonical은 서브도메인을 가리키니까, 구글 입장에서는 루트에 평가할 내용이 없는 페이지가 됐다. 애드센스에서 "게시자 콘텐츠가 없는 화면"이라는 경고를 받고 나서야 원인을 찾았다. 메타태그를 서버가 그린다고 했으면 전부 서버가 그렸어야 했다. 몇 개만 서버가 그리고 나머지는 빌드 시점 값을 쓰는 어중간한 상태가 제일 위험했다. 지금은 접속한 호스트를 보고 canonical을 만들도록 고쳤고, 위의 curl 결과에 og:url이 접속한 도메인으로 나오는 것도 그때 고친 결과다. SPA에 카톡 미리보기나 검색 노출이 필요한데 프레임워크를 바꾸기는 부담스럽다면, 이 방식을 한 번 써볼 만하다. 다만 시작할 때 "head 안의 태그는 전부 서버가 책임진다"고 정해두길 권한다. 반만 하면 나처럼 나중에 찾기 힘든 데서 터진다.