Skip to content

[FEAT] SEO 최적화 — 메타 태그 + OG 이미지 + sitemap - #83

Merged
ochanhyeok merged 3 commits into
devfrom
feat/seo
Apr 21, 2026
Merged

[FEAT] SEO 최적화 — 메타 태그 + OG 이미지 + sitemap#83
ochanhyeok merged 3 commits into
devfrom
feat/seo

Conversation

@ochanhyeok

@ochanhyeok ochanhyeok commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

📌 PR 설명

SEO 최적화 + OG 태그 + sitemap/robots.txt 추가로 검색 엔진 노출 및 카카오톡/슬랙 링크 미리보기를 지원합니다.


✅ 완료한 기능 명세

  • react-helmet-async 기반 페이지별 동적 메타 태그 (title/description/OG)
  • OG 이미지 생성 (1200x630, 민트 그라데이션 + 차트 + WeFin 로고)
  • 뉴스 기사 공유 시 기사 제목/요약 동적 반영
  • robots.txt — 비공개 페이지(계좌/설정/어드민) 크롤링 차단
  • sitemap.xml — 주요 공개 페이지 6개 등록
  • index.html lang="ko" + 기본 OG 메타 태그

📸 스크린샷

OG 이미지 미리보기:

og-image


💭 고민과 해결과정

  • SPA에서의 SEO 한계: CSR 기반이라 크롤러가 JS 실행 후 콘텐츠를 읽어야 함. react-helmet-async로 <head> 메타 태그를 동적 주입하여 해결.
    Googlebot은 JS 렌더링을 지원하므로 인덱싱 가능
  • OG 이미지 생성: SVG로 디자인 → Puppeteer로 PNG 변환. ImageMagick은 한글 폰트/그라데이션 미지원으로 사용 불가
  • 카카오톡 미리보기: index.html에 정적 OG 태그를 fallback으로 넣어 크롤러 첫 방문 시에도 기본 정보 제공

Summary by CodeRabbit

릴리스 노트

  • New Features

    • SEO 메타데이터 및 소셜 미디어 태그가 전체 페이지에 추가되었습니다.
    • 검색 엔진 크롤러를 위한 사이트맵 및 로봇 규칙 파일이 추가되었습니다.
    • 한국어 언어 설정 및 WeFin 관련 메타데이터가 기본으로 적용됩니다.
  • Chores

    • 비동기 메타데이터 관리를 위한 라이브러리 의존성이 추가되었습니다.

ochanhyeok and others added 2 commits April 21, 2026 21:08
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@ochanhyeok has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 53 minutes and 33 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 53 minutes and 33 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2695ea3b-41a7-49ad-8537-f694f1e6e346

📥 Commits

Reviewing files that changed from the base of the PR and between c651659 and bebe5a0.

📒 Files selected for processing (4)
  • index.html
  • public/robots.txt
  • src/features/news-feed/ui/cluster-detail-content.tsx
  • src/shared/ui/seo-head.tsx
📝 Walkthrough

Walkthrough

애플리케이션에 SEO 메타데이터 관리를 위한 react-helmet-async 라이브러리를 통합했습니다. HelmetProvider를 추가하고 새로운 SeoHead 컴포넌트를 생성하여 여러 페이지에 적용했으며, robots.txt 및 sitemap.xml 파일을 추가했습니다.

Changes

Cohort / File(s) Summary
SEO 메타데이터 기반 구성
src/shared/ui/seo-head.tsx
페이지 제목, 설명, URL을 관리하는 새로운 SeoHead 컴포넌트 생성. Helmet을 사용하여 canonical 링크, Open Graph 태그, Twitter 카드 태그를 렌더링합니다.
제공자 및 의존성 설정
src/app/providers/app-providers.tsx, package.json
react-helmet-async 라이브러리 추가 및 HelmetProvider로 쿼리 클라이언트 감싸기.
페이지별 SEO 적용
src/pages/home/ui/home-page.tsx, src/pages/stocks/ui/stocks-page.tsx, src/features/news-feed/ui/cluster-detail-content.tsx
각 페이지에 SeoHead 컴포넌트 추가하여 메타데이터 설정.
정적 SEO 파일 및 HTML 메타데이터
public/robots.txt, public/sitemap.xml, index.html
크롤러 규칙, 사이트맵 선언 추가 및 HTML 언어를 한국어로 변경, Open Graph 및 설명 메타 태그 추가.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

FEAT, ready-for-review

Suggested reviewers

  • minseokim0113

Poem

🐰 메타데이터 춤을 춘다,
헬멧으로 감싸인 페이지들,
크롤러들이 우리를 찾아오고,
검색 엔진이 웃음짓네!
WeFin은 이제 더 빛나리~ ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 SEO 최적화라는 주요 변경사항을 명확하게 요약하고 있으며, 메타 태그, OG 이미지, sitemap 등 핵심 구현 내용을 간결하게 나열하고 있습니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/seo

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (1)
index.html (1)

5-14: ⚠️ Potential issue | 🟠 Major

정적 메타와 Helmet 메타의 중복 노출 가능성.

루트 코멘트(package.json)에서 설명한 대로, React 19 + react-helmet-async@3는 기존 v2처럼 동일 속성의 meta 태그를 dedupe하지 않고 React의 네이티브 호이스팅에 맡깁니다. 이 때문에 여기 선언된 description/og:*/twitter:card는 모든 라우트에서 SeoHead가 추가하는 동일 속성 태그와 <head>에 공존하게 됩니다.

크롤러는 보통 먼저 만나는 태그를 채택하므로, 정적 태그가 페이지별 동적 태그를 가리는 시나리오가 발생할 수 있습니다(예: /news/:id에서 OG가 사이트 기본값으로 노출). 최소 fallback만 남기고 페이지별 값은 SeoHead 단독으로 관리하는 것을 권장합니다.

또한 nit: <title>WEFIN</title>(L41)이 og:* 아래에 위치해 있어 읽기 흐름상 <meta charset><meta viewport> 근처로 옮겨두는 편이 일반적입니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.html` around lines 5 - 14, Remove the full set of static social/meta
tags from index.html so they don't clash with per-page tags created by the
SeoHead component: keep only a minimal fallback (e.g., site-wide canonical or
very generic description) and delete the duplicate description, og:* and
twitter:card meta entries that SeoHead will supply dynamically; also move the
<title> element closer to the top near the <meta charset> and <meta viewport>
for proper head ordering. Ensure SeoHead is the single source of truth for
page-specific meta (description, og:title, og:description, og:image, og:url,
twitter:card) and index.html only provides a minimal fallback value.
🧹 Nitpick comments (2)
public/sitemap.xml (1)

1-33: <lastmod> 추가 권장, 공개 뉴스 상세 경로는 의도적 제외 확인.

  • <url><lastmod>(ISO 8601)를 포함하면 크롤러가 변경 감지에 활용합니다. 현 항목들은 대부분 정적이므로 배포 시점 기준의 고정 날짜라도 넣어두는 것이 좋습니다.
  • robots.txt에서 /news는 차단되지 않았는데 sitemap에는 뉴스 상세(/news/:clusterId)가 없습니다. 동적 목록을 빌드 타임에 생성하지 않을 거라면 의도한 설계인지만 확인 부탁드립니다(색인을 원하면 동적 sitemap 생성이 필요).
  • <priority>는 구글이 공식적으로 무시한다고 밝힌 지 오래된 힌트라, 유지/제거 모두 상관없습니다(참고용).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@public/sitemap.xml` around lines 1 - 33, Add an ISO 8601 <lastmod> element to
each <url> entry in public/sitemap.xml (e.g., immediately after <loc>) — use a
build/deploy date constant or the current release date for static pages — and
ensure the format is YYYY-MM-DD or full timestamp. Also confirm whether news
detail pages (/news/:clusterId) should be indexed; if yes, implement a dynamic
sitemap generator to emit those <url> entries at build/runtime and include them
in the sitemap; if not, add a comment in sitemap.xml noting that news details
are intentionally omitted while robots.txt allows /news.
src/shared/ui/seo-head.tsx (1)

17-17: path 결합 시 슬래시 처리에 주의하세요.

path/stocks처럼 /로 시작하는 경우는 문제없지만, 호출부에서 실수로 stocks 혹은 /stocks/처럼 전달되면 canonical/og:urlhttps://www.wefin.ai.krstocks 또는 trailing slash 불일치로 정규화 문제가 생길 수 있습니다. 방어적으로 정규화하거나 JSDoc으로 규약을 명시해 두는 것을 권장합니다.

♻️ 제안 수정
-  const pageUrl = path ? `${SITE_URL}${path}` : SITE_URL
+  const normalizedPath = path ? (path.startsWith('/') ? path : `/${path}`) : ''
+  const pageUrl = `${SITE_URL}${normalizedPath}`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/seo-head.tsx` at line 17, The pageUrl construction using
pageUrl = path ? `${SITE_URL}${path}` : SITE_URL is fragile when callers pass
"stocks", "/stocks/", or other variants; update seo-head.tsx to normalize path
before concatenation (or use URL resolution): ensure path is either null/empty
or begins with a single leading slash and has no trailing slash (except "/"),
then build pageUrl from SITE_URL + normalized path (or use new
URL(normalizedPath, SITE_URL).toString()) so canonical and og:url are always
well-formed; update JSDoc for the path prop on the component to document the
accepted format.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@package.json`:
- Line 38: react-helmet-async v3.0.0 changes cause duplicate meta tags in React
19 because HelmetProvider becomes transparent and <Helmet> meta tags are hoisted
to native JSX, bypassing the library's duplicate-filtering; fix by choosing one
approach: either strip index.html down to only essential static meta tags (e.g.,
basic og:image and site name) and let your SeoHead component own per-page
description/og/twitter tags, or replace react-helmet-async with an alternative
that guarantees duplicate suppression (e.g., `@dr.pogodin/react-helmet`) and
update usages of HelmetProvider and <Helmet>/SeoHead accordingly so only one
source injects per-page OG/meta tags.

In `@public/robots.txt`:
- Around line 3-7: Update the Disallow rules so they block only directory
prefixes by appending a trailing slash: replace entries like "Disallow:
/account", "Disallow: /settings", "Disallow: /admin", "Disallow: /chat", and
"Disallow: /payment" with "Disallow: /account/", "Disallow: /settings/",
"Disallow: /admin/", "Disallow: /chat/", and "Disallow: /payment/" respectively
to avoid unintentionally blocking similarly prefixed routes (e.g., /accounts or
/account-overview); verify no public detail pages rely on the broader prefix
blocking.

In `@src/features/news-feed/ui/cluster-detail-content.tsx`:
- Line 60: Normalize and hard-truncate the description before passing to
SeoHead: take cluster.summary, collapse consecutive whitespace/newlines into
single spaces, strip problematic characters like unescaped quotes, then truncate
to ~155 characters and pass that as description to SeoHead (use cluster.title
unchanged); also validate clusterId by converting to a number (e.g.,
numericClusterId = Number(clusterId)) and only render a numeric canonical path
when Number.isFinite(numericClusterId) (e.g., `/news/${numericClusterId}`),
otherwise omit or fallback to a safe non-numeric path to avoid exposing
`/news/abc` as canonical.

In `@src/shared/ui/seo-head.tsx`:
- Around line 10-12: DEFAULT_DESCRIPTION currently differs from the static
og:description in index.html causing inconsistent crawler previews; pick the
canonical description (either the shorter "실시간 모의투자 트레이딩 시스템" or the longer
sentence) and update the other source to match—e.g., if you choose the longer
sentence, replace the static og:description in index.html with the value used by
DEFAULT_DESCRIPTION, or if you prefer the short phrase, set DEFAULT_DESCRIPTION
to that string; ensure both the DEFAULT_DESCRIPTION constant and the static meta
tag use the identical text so crawlers and client-side React render the same
description.
- Around line 14-35: SeoHead currently hardcodes og:type as "website" and omits
twitter:image; update the SeoHead component to accept an optional prop (e.g.,
type) defaulting to "website" and render <meta property="og:type" content={type}
/>, and when rendering image meta add <meta property="og:image"
content={`${SITE_URL}/og-image.png`} /> (already present) and also output <meta
name="twitter:image" content={`${SITE_URL}/og-image.png`} /> so Twitter has an
explicit image; ensure callers like cluster-detail-content can pass
type="article" to enable article semantics (and allow adding article-specific
metas such as article:published_time when type === "article").

---

Duplicate comments:
In `@index.html`:
- Around line 5-14: Remove the full set of static social/meta tags from
index.html so they don't clash with per-page tags created by the SeoHead
component: keep only a minimal fallback (e.g., site-wide canonical or very
generic description) and delete the duplicate description, og:* and twitter:card
meta entries that SeoHead will supply dynamically; also move the <title> element
closer to the top near the <meta charset> and <meta viewport> for proper head
ordering. Ensure SeoHead is the single source of truth for page-specific meta
(description, og:title, og:description, og:image, og:url, twitter:card) and
index.html only provides a minimal fallback value.

---

Nitpick comments:
In `@public/sitemap.xml`:
- Around line 1-33: Add an ISO 8601 <lastmod> element to each <url> entry in
public/sitemap.xml (e.g., immediately after <loc>) — use a build/deploy date
constant or the current release date for static pages — and ensure the format is
YYYY-MM-DD or full timestamp. Also confirm whether news detail pages
(/news/:clusterId) should be indexed; if yes, implement a dynamic sitemap
generator to emit those <url> entries at build/runtime and include them in the
sitemap; if not, add a comment in sitemap.xml noting that news details are
intentionally omitted while robots.txt allows /news.

In `@src/shared/ui/seo-head.tsx`:
- Line 17: The pageUrl construction using pageUrl = path ? `${SITE_URL}${path}`
: SITE_URL is fragile when callers pass "stocks", "/stocks/", or other variants;
update seo-head.tsx to normalize path before concatenation (or use URL
resolution): ensure path is either null/empty or begins with a single leading
slash and has no trailing slash (except "/"), then build pageUrl from SITE_URL +
normalized path (or use new URL(normalizedPath, SITE_URL).toString()) so
canonical and og:url are always well-formed; update JSDoc for the path prop on
the component to document the accepted format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bc7561f5-9caf-4551-878a-6c8acb7fa32d

📥 Commits

Reviewing files that changed from the base of the PR and between 699b28e and c651659.

⛔ Files ignored due to path filters (3)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • public/og-image.png is excluded by !**/*.png
  • public/og-image.svg is excluded by !**/*.svg
📒 Files selected for processing (9)
  • index.html
  • package.json
  • public/robots.txt
  • public/sitemap.xml
  • src/app/providers/app-providers.tsx
  • src/features/news-feed/ui/cluster-detail-content.tsx
  • src/pages/home/ui/home-page.tsx
  • src/pages/stocks/ui/stocks-page.tsx
  • src/shared/ui/seo-head.tsx

Comment thread package.json
"lucide-react": "^1.6.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-helmet-async": "^3.0.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

react-helmet-async 3.0.0 React 19 meta tag deduplication behavior

💡 Result:

In react-helmet-async version 3.0.0 (released March 3, 2026), when used with React 19, there is no meta tag deduplication. The library detects React version at runtime and switches behavior: for React 19+, Helmet renders native JSX elements (title, meta, link, etc.) that React automatically hoists to , acting as a transparent passthrough without the library's prior deduplication logic. For React 16-18, the original deduplication behavior (collecting instances across the component tree, removing duplicates by key attributes like name/property, and applying via manual DOM manipulation) is preserved. This change aligns with React 19's native metadata hoisting, which does not deduplicate tags—multiple renders result in duplicates in , with later ones potentially overriding earlier based on browser behavior (e.g., last title wins, but multiple metas may persist). Features like prioritizeSeoTags and SSR context serialization have no effect on React 19 path. If deduplication is needed with React 19, alternatives like @dr.pogodin/react-helmet maintain it, or manual logic is required.

Citations:


react-helmet-async v3.0.0 + React 19에서 메타 태그 중복 발생 위험

v3.0.0은 React 19에서 HelmetProvider를 투명한 통과층으로 만들고, <Helmet>의 메타 태그들을 네이티브 JSX로 렌더링하여 React 19의 헤드 호이스팅에 위임합니다. 이 과정에서 라이브러리의 메타 태그 중복 제거 로직이 작동하지 않아, index.html의 정적 description, og:*, twitter:card 태그와 SeoHead가 주입하는 동일 속성 태그가 모두 <head>에 렌더링됩니다.

SNS 크롤러가 어떤 태그를 우선으로 사용할지는 브라우저 동작에 따라 달라지므로, 페이지별 OG 메타데이터가 의도와 다르게 인식될 수 있습니다.

다음 중 하나를 선택하세요:

  • index.html의 메타 태그를 최소 필수(예: 기본 og:image, 사이트명)만 유지하고 나머지는 SeoHead에서 관리
  • 또는 React 19 네이티브 메타 호이스팅이나 @dr.pogodin/react-helmet 같은 중복 제거를 보장하는 대안 검토
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 38, react-helmet-async v3.0.0 changes cause duplicate
meta tags in React 19 because HelmetProvider becomes transparent and <Helmet>
meta tags are hoisted to native JSX, bypassing the library's
duplicate-filtering; fix by choosing one approach: either strip index.html down
to only essential static meta tags (e.g., basic og:image and site name) and let
your SeoHead component own per-page description/og/twitter tags, or replace
react-helmet-async with an alternative that guarantees duplicate suppression
(e.g., `@dr.pogodin/react-helmet`) and update usages of HelmetProvider and
<Helmet>/SeoHead accordingly so only one source injects per-page OG/meta tags.

Comment thread public/robots.txt Outdated

return (
<div className="overflow-hidden rounded-2xl bg-wefin-surface p-4 sm:rounded-3xl sm:p-8">
<SeoHead title={cluster.title} description={cluster.summary} path={`/news/${clusterId}`} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

description으로 전달되는 cluster.summary 길이 고려 필요.

검색/SNS 크롤러는 meta description을 대략 150~160자, og:description도 비슷한 범위로만 표시합니다. 기사 요약은 이를 초과할 가능성이 높아 잘린 채 노출될 수 있습니다. 또한 요약에 개행/따옴표가 포함되면 렌더된 메타 속성 값이 지저분해질 수 있으니 정규화(공백 압축) + 하드 트렁케이트를 SeoHead 또는 여기서 적용하는 것을 권장합니다.

💡 예시 구현
-      <SeoHead title={cluster.title} description={cluster.summary} path={`/news/${clusterId}`} />
+      <SeoHead
+        title={cluster.title}
+        description={truncate(cluster.summary.replace(/\s+/g, ' ').trim(), 155)}
+        path={`/news/${clusterId}`}
+      />

추가로 clusterId는 URL에서 온 문자열이며 numericClusterIdNaN일 수도 있습니다. path에는 원문 문자열을 넣고 있어 /news/abc 같은 경로가 canonical로 노출될 수 있으니, 유효성 체크(Number.isFinite(numericClusterId)) 후 렌더하는 것이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/news-feed/ui/cluster-detail-content.tsx` at line 60, Normalize
and hard-truncate the description before passing to SeoHead: take
cluster.summary, collapse consecutive whitespace/newlines into single spaces,
strip problematic characters like unescaped quotes, then truncate to ~155
characters and pass that as description to SeoHead (use cluster.title
unchanged); also validate clusterId by converting to a number (e.g.,
numericClusterId = Number(clusterId)) and only render a numeric canonical path
when Number.isFinite(numericClusterId) (e.g., `/news/${numericClusterId}`),
otherwise omit or fallback to a safe non-numeric path to avoid exposing
`/news/abc` as canonical.

Comment thread src/shared/ui/seo-head.tsx
Comment thread src/shared/ui/seo-head.tsx Outdated
… robots.txt 슬래시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ochanhyeok ochanhyeok added FEAT 새로운 기능 추가 또는 기존 기능 확장 ready-for-review PR 리뷰 요청 labels Apr 21, 2026

@kkhhmm3103 kkhhmm3103 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다~

@ochanhyeok
ochanhyeok merged commit 7ef1291 into dev Apr 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

FEAT 새로운 기능 추가 또는 기존 기능 확장 ready-for-review PR 리뷰 요청

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants