
개발자의 SEO: 아무도 안 오는 서비스를 만들고 싶지 않다면
서비스를 만들었는데 아무도 오지 않았다. 마케팅 예산 없이 검색 유입을 만드는 기술적 SEO를 Next.js에서 직접 구현한 경험.

서비스를 만들었는데 아무도 오지 않았다. 마케팅 예산 없이 검색 유입을 만드는 기술적 SEO를 Next.js에서 직접 구현한 경험.
게시판에 달린 댓글 하나 때문에 관리자 계정이 탈취당했습니다. XSS(Cross-Site Scripting)의 3가지 유형(Stored, Reflected, DOM)과 React/Next.js 환경에서의 구체적인 방어법(HTML 이스케이프, CSP, 쿠키 보안)을 예제와 함께 깊이 있게 다룹니다.

Next.js 빌드 로그에 나오는 동그라미(○)와 람다(λ) 기호의 의미를 아시나요? 실수로 모든 페이지를 동적으로 만들어버리지 않는 방법을 확인하세요.

Vercel 대신 AWS S3에 정적 배포(Static Export)를 시도했다가 겪은 세 가지 악몽(이미지 최적화, API 라우트, 동적 라우팅)과 그 해결책을 공유합니다. '서버 없는 Next.js'가 어떤 제약이 있는지 확실히 이해하게 될 것입니다.

Vercel에서는 잘 되던 ISR이 AWS나 Docker 환경에서는 왜 작동하지 않을까요? 파일 시스템 캐시의 함정과 해결책을 파헤쳐봤습니다.

서비스를 만들었다. 3개월을 쏟았다. 인증, 대시보드, 결제 연동, 다크 모드까지. 기술 스택도 나름 현대적이었다. Next.js, TypeScript, Supabase. 코드도 깔끔했다. 테스트 커버리지도 나쁘지 않았다.
런치했다. 트위터에 공유했다. ProductHunt에도 올렸다. 첫 날 방문자가 좀 됐다. 그리고 다음 날부터 조용해졌다.
Google Search Console을 열었다. 클릭 수: 0. 노출 수: 0. 색인된 페이지: 0.
구글이 내 서비스의 존재를 모르고 있었다.
당연하다면 당연한 일이다. 나는 서비스를 "만드는" 데만 집중했고, 구글이 내 서비스를 "찾을 수 있게" 만드는 일은 하지 않았다. 수백만 개의 웹사이트가 구글에 색인되기를 기다리고 있는데, 내가 먼저 손을 들고 "여기 있어요"라고 알려야 한다. 그 방법이 SEO다.
흔히 SEO를 마케터의 영역으로 생각한다. 키워드 리서치, 백링크 전략, 콘텐츠 달력. 맞다, 그것도 SEO다. 하지만 그 전에 기술적 SEO(Technical SEO)라는 기반이 있어야 한다. 개발자가 직접 다뤄야 하는 부분이다. 아무리 좋은 콘텐츠를 써도, 기술적 기반이 없으면 구글이 읽지 못한다.
이 글은 내가 그 삽질을 겪고 나서 정리한 노트다.
비유 하나로 시작하자.
골목 안에 식당을 차렸다. 음식이 맛있다. 인테리어도 괜찮다. 그런데 간판이 없다. 메뉴판도 없다. 지도 앱에도 등록이 안 됐다.
지나가는 사람이 우연히 들어오지 않는 이상, 아무도 그 식당의 존재를 모른다. 음식이 얼마나 맛있든 관계없다.
SEO는 이 간판과 메뉴판과 지도 등록을 합친 개념이다. 정확히는 검색 엔진이 내 사이트를 발견하고, 이해하고, 적절한 검색 결과에 보여줄 수 있도록 돕는 일이다.
기술적 SEO는 그 중에서도 "발견"과 "이해" 부분을 담당한다. 검색 엔진 크롤러가 내 사이트에 접근할 수 있는가, 페이지의 내용을 올바르게 파악할 수 있는가, 사이트 구조가 논리적인가. 이런 질문들에 대한 답을 코드로 만드는 것이다.
HTML의 <head> 태그 안에 들어가는 정보들이다. 제목, 설명, 이미지. 검색 결과 목록에 표시되는 바로 그것들이다.
메타데이터를 명함에 비유한다면, <title>은 이름이고, <meta name="description">은 자기소개다. 카카오톡이나 슬랙에 링크를 붙여넣을 때 미리보기로 뜨는 이미지와 제목은 Open Graph 태그가 담당한다.
Next.js App Router에서는 generateMetadata() 함수로 이걸 처리한다.
// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: { slug: string; locale: string };
}): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
if (!post) return {};
const title = params.locale === 'ko' ? post.title_ko : post.title_en;
const description =
params.locale === 'ko' ? post.description_ko : post.description_en;
const ogImage = post.cover_image ?? '/images/og-default.png';
return {
title,
description,
openGraph: {
title,
description,
images: [{ url: ogImage, width: 1200, height: 630 }],
type: 'article',
publishedTime: post.date,
tags: post.tags ?? [],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
alternates: {
canonical: `https://example.com/${params.locale}/blog/${params.slug}`,
},
};
}
alternates.canonical은 중복 콘텐츠 문제를 방지한다. 한국어 버전과 영어 버전이 별도 URL로 존재할 때, 구글이 어느 페이지가 기준인지 알 수 있도록 알려주는 것이다. 다국어 사이트라면 반드시 챙겨야 한다.
사이트맵은 내 사이트에 어떤 페이지들이 있는지 구글에게 알려주는 파일이다. 골목 안 식당을 지도 앱에 등록하는 것과 같다. 크롤러가 직접 모든 링크를 따라다니며 발견하길 기다릴 수도 있지만, 사이트맵을 제출하면 훨씬 빠르고 확실하게 색인된다.
Next.js 13+에서는 app/sitemap.ts 파일을 만들면 자동으로 /sitemap.xml을 생성해준다.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/queries';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const locales = ['ko', 'en'] as const;
const baseUrl = 'https://example.com';
// 정적 페이지
const staticRoutes = ['', '/blog', '/projects', '/about'].flatMap((route) =>
locales.map((locale) => ({
url: `${baseUrl}/${locale}${route}`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: route === '' ? 1 : 0.8,
}))
);
// 블로그 포스트
const postRoutes = posts.flatMap((post) =>
locales.map((locale) => ({
url: `${baseUrl}/${locale}/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: 'weekly' as const,
priority: 0.6,
}))
);
return [...staticRoutes, ...postRoutes];
}
사이트맵을 만들었으면 Google Search Console에서 제출해야 한다. https://search.google.com/search-console에서 사이트를 등록하고, 사이트맵 URL(/sitemap.xml)을 제출하면 구글이 크롤링 스케줄을 잡아준다.
크롤러에게 "어디는 들어와도 되고, 어디는 들어오지 마라"를 알려주는 파일이다. 건물 출입 안내판과 같다.
관리자 페이지, API 라우트, 로그인 이후에만 볼 수 있는 페이지는 크롤링할 필요가 없다. 오히려 크롤링 예산(crawl budget)을 낭비한다.
Next.js에서는 app/robots.ts로 처리한다.
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/_next/'],
},
],
sitemap: 'https://example.com/sitemap.xml',
};
}
JSON-LD(구조화된 데이터)는 검색 결과에 별점, 이미지, 날짜 같은 추가 정보(Rich Result)가 표시되게 해주는 마크업이다. 식당이 지도에 등록될 때 영업시간, 별점, 사진까지 함께 표시되는 것과 같다.
블로그라면 BlogPosting 스키마를 쓴다.
// components/blog/JsonLd.tsx
export function BlogPostJsonLd({
title,
description,
date,
author,
url,
image,
}: BlogPostJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: title,
description,
datePublished: date,
dateModified: date,
author: {
'@type': 'Person',
name: author,
},
image,
url,
publisher: {
'@type': 'Organization',
name: 'Codemapo',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/images/logo.png',
},
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
페이지 컴포넌트에서 이 컴포넌트를 렌더링하면 <head> 안에 JSON-LD가 들어간다. Google의 Rich Results Test에서 유효성을 검사할 수 있다.
슬랙이나 트위터에 링크를 붙여넣으면 미리보기 카드가 뜬다. 그 카드의 이미지가 OG 이미지다. 매력적인 OG 이미지가 있으면 클릭률이 올라간다. 클릭률이 올라가면 간접적으로 SEO에도 좋다.
Next.js에서는 opengraph-image.tsx 파일을 라우트 폴더에 넣으면 자동으로 OG 이미지를 생성한다. 동적 라우트라면 게시글 제목과 날짜로 이미지를 만들 수 있다.
// app/[locale]/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getPostBySlug } from '@/lib/queries';
export const runtime = 'edge';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function OgImage({
params,
}: {
params: { slug: string; locale: string };
}) {
const post = await getPostBySlug(params.slug);
const title =
params.locale === 'ko'
? (post?.title_ko ?? 'Codemapo')
: (post?.title_en ?? 'Codemapo');
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '60px',
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
color: 'white',
}}
>
<div
style={{
fontSize: 56,
fontWeight: 700,
lineHeight: 1.2,
marginBottom: 24,
maxWidth: 900,
}}
>
{title}
</div>
<div style={{ fontSize: 28, opacity: 0.7 }}>codemapo.com</div>
</div>
),
size
);
}
Edge Runtime에서 React 컴포넌트를 PNG로 렌더링한다. 별도 이미지 편집 없이 모든 게시글에 일관된 스타일의 OG 이미지가 자동으로 생성된다.
구글은 2021년부터 Core Web Vitals를 검색 순위 신호로 반영하고 있다. 사이트가 느리면 아무리 콘텐츠가 좋아도 검색 순위에서 불이익을 받는다.
세 가지 지표를 이해하면 충분하다.
| 지표 | 의미 | 목표 |
|---|---|---|
| LCP (Largest Contentful Paint) | 가장 큰 콘텐츠가 렌더링되는 시간 | 2.5초 이하 |
| CLS (Cumulative Layout Shift) | 페이지 로드 중 레이아웃 이동 정도 | 0.1 이하 |
| INP (Interaction to Next Paint) | 사용자 입력에 대한 응답 속도 | 200ms 이하 |
개발자 관점에서 가장 흔한 실수들을 정리하면 이렇다.
LCP 개선: 히어로 이미지에 priority 속성 추가. Next.js의 <Image> 컴포넌트를 쓰면 자동으로 loading="lazy"가 붙는데, 화면 상단의 중요한 이미지에는 priority를 넣어서 지연 로딩을 막아야 한다.
// 화면 상단 히어로 이미지
<Image
src="/images/hero.webp"
alt="Hero"
width={1200}
height={600}
priority // lazy loading 비활성화
/>
CLS 개선: 이미지에 width와 height를 명시한다. 치수가 없으면 브라우저가 이미지 크기를 미리 예측하지 못해서, 이미지가 로드될 때 주변 텍스트가 밀린다. 폰트 로딩 중 레이아웃이 틀어지는 것도 CLS의 주요 원인이다. font-display: swap과 <link rel="preload">로 폰트 로딩을 최적화할 수 있다.
INP 개선: 무거운 이벤트 핸들러를 최적화한다. 클릭 이벤트에 너무 많은 동기 연산이 있으면 응답이 느려진다. useTransition으로 우선순위가 낮은 상태 업데이트를 지연시키는 방법이 효과적이다.
Chrome DevTools의 Lighthouse 탭에서 Core Web Vitals 점수를 측정할 수 있다. 실제 사용자 데이터는 Google Search Console의 "코어 웹 바이탈" 섹션에서 확인한다.
React로 SPA(Single Page Application)를 만들면 SEO가 망가진다. 이게 내가 처음에 몰랐던 함정이다.
SPA는 브라우저가 빈 HTML 파일을 받고, JavaScript가 실행되면서 콘텐츠를 그린다. 구글 크롤러도 JavaScript를 실행할 수 있지만, 문제가 있다. 크롤러는 JavaScript 렌더링 결과를 즉시 색인하지 않는다. 크롤링과 렌더링이 분리된 파이프라인에서 처리되기 때문에, 색인되기까지 며칠이 걸릴 수 있다. 그리고 렌더링에 실패하면 빈 페이지가 색인된다.
서버 사이드 렌더링(SSR)이나 정적 생성(SSG)을 쓰면 서버에서 완성된 HTML을 내려준다. 크롤러가 JavaScript를 실행할 필요 없이 바로 콘텐츠를 읽을 수 있다. Next.js App Router의 기본 동작이 Server Component 기반 SSR이라 이 문제를 자연스럽게 피할 수 있다.
CRA(Create React App)나 Vite로 만든 순수 SPA에서 Next.js로 마이그레이션하는 가장 큰 이유 중 하나가 이것이다. 아무리 좋은 프론트엔드 기술을 써도, 크롤러가 읽을 수 있는 HTML이 없으면 검색 결과에 나오지 않는다.
서비스를 런치하면 가장 먼저 해야 하는 일이 Google Search Console 설정이다. 비유하자면, 식당을 열고 나서 구글 지도에 등록하는 것이다.
절차는 이렇다.
/sitemap.xml)등록 후 며칠이 지나면 검색 노출 데이터가 들어오기 시작한다. 어떤 검색어로 내 사이트가 노출되는지, 클릭률은 어떤지 파악할 수 있다. 이 데이터가 콘텐츠 전략의 출발점이 된다.
정리해본다. SEO를 처음 챙길 때 흔히 놓치는 실수들이다.
메타 설명 없음: <meta name="description">이 없으면 구글이 페이지 본문에서 임의로 텍스트를 잘라서 검색 결과에 표시한다. 어떤 문장이 잘릴지 알 수 없다.
모든 페이지에 같은 제목: <title>Codemapo</title>을 모든 페이지에 쓰면 구글이 각 페이지를 구분하지 못한다. 페이지별로 고유한 제목이 있어야 한다.
이미지 alt 텍스트 없음: 이미지 검색에 노출되지 않는다. 스크린 리더 사용자 접근성도 떨어진다. 일석이조로 챙길 수 있는 부분이다.
canonical URL 미설정: 다국어 사이트에서 /ko/blog/post-1과 /en/blog/post-1이 비슷한 내용이면 구글이 중복 콘텐츠로 판단할 수 있다. hreflang 태그와 canonical 태그를 함께 설정해야 한다.
sitemap 미제출: 사이트맵을 만들어놓고 Search Console에 제출하지 않으면 의미가 반쪽이다. 파일이 있다고 구글이 자동으로 가져가지는 않는다.
기술적 SEO는 화려하지 않다. 특별한 마법도 없다. 하지만 이 기반이 없으면 아무리 좋은 콘텐츠도, 아무리 멋진 서비스도 구글 검색 결과의 저 깊은 곳에 묻힌다.
아무도 오지 않는 서비스를 3개월 동안 운영하고 나서 와닿은 것들을 정리했다.
/sitemap.xml): 구글에게 보내는 약도. 없으면 크롤러가 헤맨다.SEO는 거창한 게 아니다. 내 서비스가 존재한다는 걸 구글에게 제대로 알려주는 일이다. 간판을 달고, 메뉴판을 써붙이고, 지도에 등록하는 일. 그걸 코드로 하는 것이다.