Next.jsSEOApp Router미들웨어next/og

Next.js로 SEO 기본기 세팅하기 — robots, sitemap, OG 이미지까지 코드로

2026년 7월 10일5 분 읽기

왜 SEO 세팅이 필요했나

매치다(MatchDa) 서비스를 점검하다가 SEO가 거의 손도 안 대져 있다는 걸 알게 됐다. robots.txtsitemap.xml도 없었고, 루트 레이아웃의 메타데이터는 titledescription 딱 두 줄이 전부였다. metadataBase도 없고 OG(Open Graph)·Twitter 카드 설정도 없어서, 카카오톡이나 슬랙에 링크를 붙여넣어도 미리보기가 안 떴다. 전체 22개 페이지 중에서 자체 메타데이터를 가진 건 이용약관·개인정보처리방침 같은 법적 페이지 4개뿐이었고, 정작 검색에 잡혀야 할 랜딩·요금제·소개 페이지엔 아무것도 없었다.

이번 글은 이걸 처음부터 세팅한 기록이다. 그 과정에서 겉보기엔 멀쩡해 보였지만 실제로는 SEO 파일들을 전부 무력화하고 있던 버그도 하나 잡았는데, 그 얘기가 사실 이 글의 핵심이다.

Step 1. 작업 범위부터 좁히기

22개 페이지를 전부 손볼 필요는 없었다. 매치다는 로그인해야 쓸 수 있는 서비스라, 로그인 안 한 사람(그리고 검색엔진 크롤러)이 실제로 콘텐츠를 볼 수 있는 페이지는 몇 개 안 된다. 이걸 확인하는 가장 빠른 방법은 인증 미들웨어의 공개 경로 화이트리스트를 보는 것이었다.

// src/middleware.ts
const PUBLIC_PATHS = ['/', '/about', '/pricing', '/terms', '/privacy', '/refund', '/support', ...]

여기 있는 7개가 SEO 작업의 전부다. 나머지(대시보드, 워크스페이스, 설정 등)는 로그인 안 하면 어차피 /login으로 튕겨나가니, 검색엔진이 크롤링해봤자 인덱싱할 콘텐츠가 없다. "로그인 후 사용" 위주 서비스에서 SEO 작업의 첫 단계는 전체 페이지 목록이 아니라 미들웨어의 공개 경로부터 확인하는 것이라는 걸 이번에 배웠다.

Step 2. robots.txt — 파일 하나로 끝

Next.js App Router는 src/app/robots.ts 파일을 만들면 /robots.txt를 자동으로 생성해준다. 별도 라우트 핸들러를 짤 필요가 없다.

// src/app/robots.ts
import type { MetadataRoute } from 'next'

const SITE_URL = 'https://matchda.com'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: ['/', '/about', '/pricing', '/terms', '/privacy', '/refund', '/support'],
      disallow: [
        '/dashboard', '/applications', '/discover', '/profile', '/workspace',
        '/settings', '/onboarding', '/login', '/auth', '/api', '/matchda', '/r/',
      ],
    },
    sitemap: `${SITE_URL}/sitemap.xml`,
  }
}

/r/<slug>(유저가 만든 공개 이력서 공유 링크)를 disallow에 넣은 게 포인트다. 이 페이지는 "링크를 아는 사람만 보라"는 의도로 만든 기능이라, 로그인 없이 접근은 되지만 검색 노출은 원치 않는다. 접근 가능 = 검색 노출 허용이 아니라는 걸 명시적으로 갈라줘야 한다.

Step 3. sitemap.xml — 같은 방식으로

src/app/sitemap.ts도 마찬가지로 파일 컨벤션이다. 공개 페이지 7개를 등록하고, 중요도에 따라 priority를 다르게 줬다.

// src/app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
  const now = new Date()
  return [
    { url: `${SITE_URL}/`, lastModified: now, changeFrequency: 'weekly', priority: 1 },
    { url: `${SITE_URL}/about`, lastModified: now, changeFrequency: 'monthly', priority: 0.8 },
    { url: `${SITE_URL}/pricing`, lastModified: now, changeFrequency: 'monthly', priority: 0.8 },
    { url: `${SITE_URL}/support`, lastModified: now, changeFrequency: 'monthly', priority: 0.5 },
    { url: `${SITE_URL}/terms`, lastModified: now, changeFrequency: 'yearly', priority: 0.2 },
    // ...
  ]
}

랜딩 페이지가 1.0, 소개·요금제가 0.8, 법적 페이지들은 0.2 — 자주 안 바뀌고 검색 우선순위도 낮은 페이지는 낮게 잡았다.

Step 4. OG 이미지 — 디자인 툴 없이 코드로

링크 공유 미리보기 이미지를 만들려면 보통 디자인 툴이 필요하다고 생각하기 쉬운데, Next.js는 next/ogImageResponse이미지를 서버에서 요청 시점에 렌더링할 수 있게 해준다. src/app/opengraph-image.tsx 파일 하나면 끝이다.

// src/app/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

export const runtime = 'nodejs'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function OpengraphImage() {
  // public 폴더의 로고 파일을 읽어서 base64로 임베드
  const logoData = await readFile(join(process.cwd(), 'public', 'matchda-mark.png'))
  const logoSrc = `data:image/png;base64,${logoData.toString('base64')}`

  return new ImageResponse(
    (
      <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
                    alignItems: 'center', justifyContent: 'center', background: '#F7FBF9' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
          <img src={logoSrc} alt="" width={84} height={84} style={{ borderRadius: 20 }} />
          <span style={{ fontSize: 64, fontWeight: 700, color: '#0C1A14' }}>MatchDa</span>
        </div>
        <div style={{ marginTop: 32, fontSize: 34, fontWeight: 600, color: '#0B1A12' }}>
          한국어 이력서를 전문가 수준 영어로,
        </div>
        <div style={{ marginTop: 8, fontSize: 34, fontWeight: 600, color: '#046C4E' }}>
          해외 채용 공고에 맞춰 자동으로.
        </div>
      </div>
    ),
    { ...size }
  )
}

ImageResponse가 받는 JSX는 일반 React가 아니라 제한된 서브셋(flexbox 기반 스타일만 지원)인데, 로컬 이미지 파일을 쓰려면 <Image> 컴포넌트가 아니라 fs.readFile로 직접 읽어서 base64 data URI로 변환한 뒤 평범한 <img> 태그에 넣어야 한다. 이 패턴 하나만 알면 로고가 들어간 브랜드 OG 이미지를 코드만으로 만들 수 있다.

만들고 나서 바로 결과물을 PNG로 받아서 눈으로 확인했다. 코드가 문법적으로 맞다고 결과물이 예쁘게 나온다는 보장은 없으니, 실제로 열어보는 게 중요하다.

Step 5. 메타데이터 상속 구조 잡기

루트 레이아웃에 metadataBasetitle 템플릿을 추가했다.

// src/app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://matchda.com'),
  title: {
    default: 'MatchDa — 한국 인재를 위한 글로벌 커리어 플랫폼',
    template: '%s — MatchDa',
  },
  description: '...',
  openGraph: {
    // ...
    images: ['/opengraph-image'],  // 하위 페이지가 따로 안 정하면 이걸 상속
  },
}

title.template이 핵심이다. 이렇게 해두면 하위 페이지는 title: '요금제'처럼 짧게만 써도 실제로는 "요금제 — MatchDa"로 자동 완성된다. openGraph.images도 마찬가지로, 하위 페이지가 이미지를 따로 안 정하면 방금 만든 OG 이미지를 그대로 물려받는다.

트러블슈팅 — 코드 리뷰만으로는 못 잡는 버그 두 개

버그 1. title이 두 번 겹침

title 템플릿을 추가하고 나서, 기존에 이미 메타데이터가 있던 4개 페이지(이용약관·개인정보처리방침·환불정책·고객센터)를 다시 봤다.

// Before — 이미 "— MatchDa"까지 박아둔 상태
export const metadata = { title: '환불 정책 — MatchDa' }

템플릿(%s — MatchDa)과 이 값이 합쳐지면 "환불 정책 — MatchDa — MatchDa"가 되어버린다. 템플릿을 도입하는 순간, 기존에 하위 페이지들이 스스로 접미사를 붙이고 있던 건 아닌지 전부 재점검해야 한다. 4개 페이지 전부 짧은 제목으로 바꾸고, 없던 description도 새로 추가했다.

// After
export const metadata = {
  title: '환불 정책',
  description: 'MatchDa 프리미엄 구독의 환불 가능 조건, 제한 사유, 요청 방법을 안내합니다.',
}

버그 2. robots.txt조차 로그인 페이지로 리다이렉트되고 있었다

여기가 진짜였다. 다 만들어놓고 브라우저로 직접 접속해봤다.

curl -s http://localhost:3999/robots.txt
# → /login  (?!)

robots.txt/login으로 리다이렉트되고 있었다. sitemap.xml도, 방금 만든 opengraph-image도, 심지어 파비콘(icon.svg, apple-icon.png)까지 전부 마찬가지였다.

원인은 인증 미들웨어의 matcher 설정이었다.

// Before
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api|auth/callback).*)'],
}

이 정규식은 "이 목록에 없는 모든 경로는 미들웨어를 통과시켜라(=로그인 체크를 해라)"는 뜻이다. _next/static, api 같은 건 제외돼 있었지만, robots.txt·sitemap.xml·opengraph-image처럼 새로 생긴 Next.js 파일 컨벤션 경로들은 이 목록에 없었다. 그래서 이것들도 평범한 페이지 취급을 받아 "로그인 안 했으니 /login으로 보내" 로직을 그대로 탄 것이다.

즉 지금까지 검색엔진이 robots.txt조차 제대로 못 읽고 있었을 가능성이 있었다는 뜻이다. 아무리 robots.ts를 잘 짜도, 그 앞단에서 미들웨어가 리다이렉트를 걸어버리면 크롤러 입장에선 robots.txt가 아니라 로그인 페이지 HTML을 받게 된다.

// After
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|api|auth/callback|robots.txt|sitemap.xml|opengraph-image|icon.svg|apple-icon.png).*)',
  ],
}

이 버그의 교훈은 명확하다. SEO 관련 파일은 코드가 문법적으로 맞다고 끝난 게 아니라, 실제로 HTTP 요청을 날려서 응답을 확인해야 한다. robots.ts 코드만 리뷰했다면 "잘 만들었네"로 끝났을 텐데, 미들웨어라는 전혀 다른 레이어가 조용히 이걸 다 막고 있었다. 코드 리뷰로는 절대 못 잡는 종류의 문제였다.

검증

수정하고 나서 다시 curl로 확인했다.

curl -s http://localhost:3999/robots.txt
# User-Agent: *
# Allow: /
# Allow: /about
# ...
# Sitemap: https://matchda.com/sitemap.xml

curl -s -o /dev/null -w "%{content_type} %{size_download}\n" http://localhost:3999/opengraph-image
# image/png 37825

그리고 미들웨어를 건드렸으니, 보호돼야 할 페이지가 실수로 뚫리지 않았는지도 꼭 재확인했다.

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3999/dashboard
# 307  ← 여전히 로그인으로 리다이렉트됨(정상)

SEO 파일들이 열리게 하려고 matcher를 넓혔는데, 그 과정에서 정작 지켜야 할 보호 경로까지 같이 뚫려버리면 그게 더 큰 사고다. 바뀐 설정이 "의도한 것만" 풀어줬는지 확인하는 이 단계를 빼먹지 않는 게 중요하다.

자주 쓰는 패턴 요약

목적방법
robots.txt 생성src/app/robots.ts (파일 컨벤션, 코드 반환)
sitemap.xml 생성src/app/sitemap.ts (파일 컨벤션)
동적 OG 이미지src/app/opengraph-image.tsx + next/ogImageResponse
로컬 이미지를 OG 이미지에 임베드fs.readFilebase64 data URI → <img src="data:...">
하위 페이지 title 자동 완성루트 metadata.title = { default, template: "%s — 브랜드명" }
미들웨어가 특수 라우트를 막고 있는지 확인curl -s -o /dev/null -w "%{http_code}"로 직접 요청

정리

공개 페이지 범위 확정(미들웨어 화이트리스트 확인, 22개 중 7개만 해당)
  → robots.ts / sitemap.ts (파일 컨벤션으로 자동 생성)
  → opengraph-image.tsx (next/og로 코드에서 PNG 렌더링, 로고는 fs+base64로 임베드)
  → layout.tsx에 metadataBase + title 템플릿 + OG/Twitter 기본값
  → 기존 4개 페이지의 title 중복 수정
  → curl로 실제 응답 확인 → 미들웨어가 SEO 라우트를 로그인 페이지로 막고 있던 버그 발견
  → matcher 수정 → 재검증(SEO 라우트는 열림, 보호 페이지는 여전히 막힘)

이번 작업에서 가장 크게 남은 인상은, SEO 세팅은 "그럴듯한 코드를 작성했는가"가 아니라 "실제로 그 경로에 요청을 날렸을 때 뭐가 오는가"로 검증해야 한다는 것이다. robots.ts·sitemap.ts·OG 이미지 코드는 전부 문법적으로 완벽했다. 문제는 그 앞단의 미들웨어였고, 이건 curl 한 번 안 날려봤다면 배포하고 몇 주가 지나도 몰랐을 종류의 버그다.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.