서브도메인 SEO 설정, robots.txt 와 sitemap 한 벌로 관리하기

서비스를 서브도메인으로 늘릴 때 robots.txt, sitemap, 대표 주소, 구조화 데이터를 앱마다 복사하지 않고 한 벌로 관리하는 법이에요. 바꾸기 전후 비교로 확인하는 방법까지 담았어요.

셀루모2026.10.17 · 15분 읽기
목차

서비스가 하나일 때는 검색 노출 설정을 한 번 해 두면 끝이에요. 그런데 서브도메인으로 서비스가 둘, 셋 늘어나기 시작하면 이야기가 달라져요. 처음 만든 설정을 복사해서 붙이고, 이름만 바꾸고, 어느 날 한 곳만 고쳐진 채로 몇 달이 지나요. 이 글은 그 복사본이 생기기 전에 검색 설정을 한 벌로 묶는 방법을 순서대로 정리해요.

여기서 말하는 검색 설정은 네 가지예요. 검색 로봇에게 "여기는 들어오지 마세요" 하고 알려 주는 파일(robots.txt), 색인해 달라고 내미는 페이지 목록(sitemap), 같은 내용이 여러 주소로 열릴 때 "대표는 이 주소예요" 하고 지정하는 표시(canonical), 그리고 페이지가 무엇인지 기계가 읽기 좋게 적어 두는 데이터(JSON-LD)예요. 검색 엔진이 이 네 가지를 읽고 사이트를 이해해요. 가게로 치면 간판, 영업 시간표, 대표 전화번호, 메뉴판에 해당해요.

혹시 이런 고민 있으신가요?

  • 서비스마다 서브도메인이 있는데, robots.txt 와 sitemap 을 앱마다 따로 복사해서 쓰고 있어요.
  • 한 서비스의 설정을 고쳤는데 다른 서비스는 안 고쳐져서, 어느 쪽이 맞는 건지 모르겠어요.
  • 내부용 관리 화면에는 로봇에게 아무 말도 안 하고 있는데, 이대로 괜찮은지 모르겠어요.

이 글을 읽으면

  • 앱마다 달라야 하는 값과 모든 앱이 같아야 하는 규칙을 갈라서, 설정을 한 곳에서 관리할 수 있어요.
  • robots.txt 와 sitemap 을 값만 넣으면 나오는 함수로 만들고, 늘어나는 페이지까지 담을 수 있어요.
  • 설정을 옮긴 뒤에 이미 색인된 페이지가 조용히 망가지지 않았는지 전후 비교로 확인할 수 있어요.

1단계. 앱마다 다른 값과 모두 같은 규칙 가르기

공통으로 만들기 전에 먼저 해야 할 일은 복사해 둔 파일을 나란히 놓고 "무엇이 다르고 무엇이 같은지" 줄을 긋는 거예요. 이 구분이 틀리면 공통 코드가 앱 이름을 알게 되고, 새 앱을 만들 때마다 공통 코드를 고쳐야 해요.

저희가 처음 서비스 하나에 만들어 둔 검색 설정을 나눠 보니 이렇게 갈렸어요.

항목모든 앱이 같은가정리
서비스 이름다름앱이 가져요
색인해 줄 공개 페이지 목록다름앱이 가져요
막을 경로다름앱이 가져요
사이트 소유 확인값다름호스트마다 검색 콘솔에서 받아요
robots.txt 의 모양같음공통으로 옮겨요
sitemap 을 절대 주소로 바꾸는 규칙같음공통으로 옮겨요
공유 미리보기(Open Graph)의 기본값같음공통으로 옮겨요
JSON-LD 를 화면에 심는 부품같음공통으로 옮겨요
JSON-LD 를 만드는 함수모양은 같고 값은 다름함수는 공통, 값은 앱이 넣어요

마지막 줄처럼 한 파일 안에 두 가지가 섞여 있는 경우가 제일 많아요. 모양은 같은데 값이 다르면, 모양을 함수로 만들고 값은 부르는 쪽이 넘기게 해요. 이 방식을 뒤에서 robots.txt 와 sitemap 에 그대로 써요.

앱마다 값만 넘기고 규칙은 공통 한 벌을 쓰는 구조앱은 값만 넘기고, 모양을 만드는 규칙은 한 곳에 둬요

TIP

공통으로 옮기는 시점은 두 번째 앱이 생길 때가 좋아요. 앱이 하나일 때 만든 공통 모듈은 미래에 대한 추측이고, 앱이 셋일 때 만들면 복사본이 이미 갈라져 있어서 어느 쪽이 정답인지부터 다퉈야 해요. 저희는 두 번째 앱을 만들기 직전에 옮겼어요.

내 경우에 맞게 바꿀 점

표의 줄은 여러분의 파일에서 직접 뽑아야 해요. 파일을 열어 놓고 줄마다 "이걸 다른 앱에서 그대로 쓸 수 있나?"를 물어보세요. 서비스 이름이 들어가 있거나 경로 목록이 박혀 있으면 앱의 몫이에요. 어느 쪽인지 애매하면 앱의 몫으로 두세요. 공통에서 앱 쪽으로 옮기는 것보다 반대가 훨씬 쉬워요.

2단계. robots.txt 를 값만 넣는 함수로 만들기

robots.txt 는 검색 로봇이 사이트에 처음 들어와서 제일 먼저 읽는 파일이에요. 모양은 거의 정해져 있어요. 어떤 로봇에게(user agent), 어느 경로를 막고(disallow), 목록 파일은 어디에 있는지(sitemap) 적어요.

공통 함수는 이렇게 막을 경로만 받게 만들었어요.

TS
export function createRobots({  disallow,  appUrl = requireAppUrl(),}: {  disallow: string[]  appUrl?: string}): MetadataRoute.Robots {  return {    rules: { userAgent: "*", disallow },    sitemap: new URL("/sitemap.xml", appUrl).toString(),  }}

각 앱의 robots 파일은 이제 이 정도로 줄어요.

TS
export const dynamic = "force-dynamic" export default function robots() {  return createRobots({ disallow: ["/api/", "/auth/", "/home"] })}

결과로 나오는 robots.txt 는 아래처럼 단순해요.

TEXT
User-Agent: *Disallow: /api/Disallow: /auth/Disallow: /home Sitemap: https://example.com/sitemap.xml

여기서 정할 것이 세 가지 있어요.

로봇마다 규칙을 나누지 않기

저희는 모든 로봇에게 같은 규칙 하나만 줘요. AI 답변 엔진의 로봇도 따로 막지 않아요. 이유는 단순해요. 서비스가 낯선 사람에게 발견되어야 하는데, "도장 이미지를 PDF 에 넣는 방법"을 물어본 사람에게 답으로 나오는 것이 이득이라고 판단했어요. 막는 이유로 흔한 것은 두 가지인데, 학습에 쓰이기 싫은 독자적인 콘텐츠가 있거나 로봇 트래픽이 비용이 되는 경우예요. 공개 화면이 도구 설명과 사용법뿐이라 둘 다 해당하지 않았어요.

반대 판단도 충분히 방어할 수 있어요. 공개 화면에 다른 사람이 쓴 글이 실리는 날이 오면 그때는 다시 생각해야 해요. robots.txt 에는 소급 효과가 없어서, 막기 전에 이미 가져간 내용은 돌아오지 않기 때문이에요. 정했다면 이유를 한 줄이라도 기록해 두세요. 나중에 누군가 "왜 AI 로봇을 안 막았지?" 하고 물을 때 답이 있어야 해요.

robots.txt 는 자물쇠가 아니에요

robots.txt 의 차단은 예의예요. 점잖은 로봇은 지키지만, 지키지 않는 쪽을 막아 주지는 못해요. 그래서 로그인 뒤 화면과 저장한 파일은 파일에 적어 두는 것으로 끝내지 않고, 세션이 없으면 내용을 내주지 않도록 따로 잠가야 해요. 저희는 막는 경로가 모두 "공개 화면이 아닌 곳"이라서 이미 다른 자물쇠가 있었고, robots.txt 는 로봇이 헛걸음하지 않게 하는 안내판 역할만 해요.

내부용 화면에는 전부 막는 파일을 두기

내부 운영 화면에는 robots.txt 를 아예 두지 않은 앱이 있었어요. 화면 안에는 "로그인 뒤 내부 도구라서 검색 설정이 필요 없다"고 적혀 있었는데, 메타데이터가 필요 없다는 말은 맞아도 robots.txt 는 달라요. 파일이 없으면 로봇에게 아무 말도 하지 않는 거예요. 그래서 모든 경로를 막는 한 줄짜리 파일을 두고, sitemap 은 내지 않았어요. 공통 함수에 disallow: ["/"] 를 넘기면 끝나요.

참고

주소를 요청이 들어온 때에 읽게 한 데는 이유가 있어요. 컨테이너 이미지는 한 번 만들어서 여러 환경에서 쓰는데, 실제 주소는 실행 중인 컨테이너만 알아요. 빌드할 때 주소를 구워 넣으면 시험 환경 주소가 운영 파일에 들어가는 사고가 나요. 그래서 위 예시의 force-dynamic 이 붙어 있어요. 빌드 때 주소를 받는 구조라면 이 줄은 필요 없어요.

3단계. sitemap 에 고정 페이지와 늘어나는 페이지 함께 담기

sitemap 은 "색인해 줬으면 하는 페이지 목록"이에요. 로봇은 링크를 따라다니며 페이지를 찾지만, 링크가 얕게 닿는 페이지나 새로 생긴 페이지는 목록이 있을 때 훨씬 빨리 발견돼요.

앱의 페이지는 두 종류예요. 처음부터 있는 고정 페이지(소개, 요금, 도움말 같은 것)와 시간이 지나며 늘어나는 페이지(글, 템플릿 소개, 상품 같은 것)예요. 고정 페이지는 경로만 있으면 되고, 늘어나는 페이지는 수정한 날짜 같은 값이 딸려 와요. 그래서 함수가 두 가지를 따로 받게 만들었어요.

TS
export function createSitemap({  paths,  entries = [],  appUrl = requireAppUrl(),}: {  paths: string[]  entries?: MetadataRoute.Sitemap  appUrl?: string}): MetadataRoute.Sitemap {  return [    ...paths.map((path) => ({ url: new URL(path, appUrl).toString() })),    ...entries.map((entry) => ({ ...entry, url: new URL(entry.url, appUrl).toString() })),  ]}

핵심은 마지막 줄의 new URL(...) 이에요. sitemap 의 주소는 반드시 https:// 로 시작하는 절대 주소여야 하는데, 앱 코드는 /templates/abc 같은 상대 경로로 다루는 게 편해요. 절대 주소로 바꾸는 일을 공통 함수에 맡기면 앱에서는 경로만 신경 쓰면 돼요.

앱에서는 이렇게 불러요.

TS
export default async function sitemap() {  const templates = await fetchPublicTemplates()  return createSitemap({    paths: ["/", "/templates", "/guide"],    entries: templates.map((t) => ({      url: `/templates/${t.slug}`,      lastModified: t.updatedAt,    })),  })}

목록에 넣으면 안 되는 것

늘어나는 페이지를 목록에 넣을 때는 "넣을 것"보다 "빼야 할 것"을 먼저 정해요. 로그인해야 보이는 페이지, 유료 회원 전용 페이지, 비공개 상태의 항목이 목록에 들어가면 로봇은 열어 보고 빈 화면이나 로그인 창만 만나요. 그런 페이지가 많아지면 사이트 전체의 품질을 낮게 보는 근거가 될 수 있어요. 가설이지만 피해서 손해 볼 일은 없어요.

저희는 늘어나는 항목에 "공개 주소 이름(slug)이 있고, 공개 상태인 것"이라는 조건을 걸어서 목록을 만들어요. 주소 이름이 없는 항목은 소개 페이지 자체가 없으니 목록에도 올라가지 않아요.

TIP

목록을 만드는 코드가 있으면 시험 하나를 같이 두세요. "비공개 항목이 목록에 없다", "고정 경로와 늘어나는 경로가 함께 나온다" 두 줄이면 충분해요. 목록에서 빠지는 건 눈에 띄지 않는 사고라서, 사람 눈 대신 시험이 지켜야 해요.

4단계. 대표 주소, 구조화 데이터, 소유 확인 정리하기

앞의 두 파일을 정리했다면 나머지는 페이지 안쪽에 들어가는 것들이에요.

대표 주소는 호스트마다 자기 것으로

서브도메인이 여러 개이면 "서비스끼리 대표 주소를 한쪽으로 통일해야 하나?" 하는 고민이 생겨요. 저희 답은 아니었어요. 각 호스트는 자기 페이지의 대표 주소를 자기 주소로 지정해요(self-referencing canonical). 서로 다른 내용을 가진 페이지를 한쪽으로 몰아줄 이유가 없기 때문이에요.

글만은 달랐어요. 서비스마다 블로그를 두면 글이 여러 도메인에 흩어져서 어느 곳에도 주제의 힘이 쌓이지 않을 거라고 생각했어요. 한 도메인에 모으는 쪽이 검색에 유리하다는 것은 업계에서 흔히 하는 말이고, 저희 수치로 확인한 것은 아니에요. 그래서 글은 대표 도메인의 /blog 한 곳에 두고, 서비스 쪽에는 제목과 요약 카드만 둔 뒤 본문 링크로 보내요. 같은 본문을 두 곳에 두고 canonical 로 한쪽을 가리키면, 가리킨 쪽만 힘을 받고 다른 쪽은 둘 이유가 사라져요.

주소를 blog.example.com 같은 서브도메인으로 할지, example.com/blog 같은 하위 경로로 할지도 이때 정해야 해요. 색인이 쌓인 뒤에 바꾸면 모든 글에 이전 안내(301 리다이렉트)를 걸어야 하고, 그래도 순위가 그대로 따라오지 않아요. 글이 쌓이기 전에 정하세요.

구조화 데이터는 화면에 있는 것만

JSON-LD 는 페이지 안에 <script type="application/ld+json"> 로 심는 데이터예요. 만드는 함수를 공통으로 두면 모든 앱이 같은 모양을 써요. 저희는 서비스 소개용(SoftwareApplication), 글용(Article), 경로 표시용(BreadcrumbList) 세 가지를 주로 써요.

TS
export function breadcrumbListJsonLd(items: { name: string; url: string }[]) {  return {    "@context": "https://schema.org",    "@type": "BreadcrumbList",    itemListElement: items.map((item, index) => ({      "@type": "ListItem",      position: index + 1,      name: item.name,      item: item.url,    })),  }}

지킬 규칙은 하나예요. 화면에 보이지 않는 내용을 데이터에만 적지 않아요. 평점(aggregateRating)처럼 실제로 모으지 않은 값을 만들어 넣는 것은 구조화 데이터 정책 위반이 될 수 있어서, 소개 데이터에는 아예 넣지 않았어요. 질문과 답을 담는 FAQ 데이터는 화면에 그 질문과 답이 그대로 보일 때만 쓰고, 함수가 받는 목록도 화면에 그리는 목록과 같은 것을 넘기게 했어요.

소유 확인값은 앱 설정으로

구글 서치 콘솔과 네이버 서치어드바이저에 사이트를 등록하면 "이 사이트가 당신 것인지" 확인하는 값을 줘요. 이 값은 호스트마다 달라요. 레이아웃 파일에 문자열로 박아 두면 앱을 복사할 때 남의 값이 따라와요. 그래서 설정에서 받아서 넣는 자리를 공통 코드에 두고, 값은 앱이 채우게 했어요. 콘솔에 등록하고 sitemap 을 제출하는 일은 사람이 콘솔에서 하는 일이라 코드로 해결되지 않아요. 절차만 기록으로 남겨 두세요.

5단계. 바꾸기 전후 결과를 비교해 확인하기

이 작업에서 제일 어려운 점은 새 기능이 없다는 거예요. 겉으로 보이는 변화는 아무것도 없어야 성공이에요. 그런데 틀리면 이미 색인된 페이지가 조용히 사라지거나 대표 주소가 바뀌어도 아무도 모르고, 몇 주 뒤에 방문자가 줄어드는 것으로 드러나요.

그래서 완료 기준을 "전후 결과가 같다"로 잡았어요. 옮기기 전에 먼저 현재 출력을 파일로 받아 둬요.

BASH
mkdir before aftercurl -s https://sign.example.com/robots.txt  > before/robots.txtcurl -s https://sign.example.com/sitemap.xml > before/sitemap.xmlfor p in / /templates /guide; do  curl -s "https://sign.example.com$p" \    | grep -oE '<link rel="canonical"[^>]*>|<meta property="og:[^>]*>|<script type="application/ld\+json">[^<]*' \    > "before/page$(echo $p | tr / _).txt"done

설정을 갈아 끼운 뒤에 같은 명령을 after 폴더에 받아서 비교해요.

BASH
diff -r before after && echo "같아요"

차이가 없어야 해요. 차이가 있다면 의도한 것인지 하나씩 따져요. 흔한 차이는 두 가지예요. 하나는 sitemap 목록의 순서가 바뀐 것인데, 순서는 의미가 없으니 괜찮아요. 다른 하나는 주소 끝의 슬래시 유무나 http 와 https 가 바뀐 것인데, 이건 대표 주소가 바뀌는 일이라 반드시 고쳐야 해요.

공통 부품이 늘어난 뒤에도 이 확인은 계속 쓸 수 있어요. 새 앱을 추가할 때마다 "처음 출력"을 받아 두고, 공통 코드를 고칠 때마다 모든 앱의 출력을 비교하면 한 곳을 고쳤는데 다른 곳이 어긋나는 일을 막아요.

TIP

비교할 대상을 공개 페이지 한두 개로 줄여도 돼요. 전부 비교하려다 귀찮아서 안 하는 것보다, 가장 방문이 많은 페이지 두세 개를 매번 비교하는 편이 훨씬 나아요.

자주 묻는 질문

Q1. llms.txt 도 공통으로 만들어 두는 게 좋을까요?

저희는 만들지 않았어요. 2026년 1분기에 확인했을 때, 주요 검색과 AI 서비스 가운데 이 파일을 읽는다고 공개 문서에 밝힌 곳을 찾지 못했어요. 파일은 사이트 구조를 요약하는 문서라서 경로가 바뀔 때마다 같이 고쳐야 하는데, 안 고치면 틀린 안내가 돼요. 읽는다고 밝히는 곳이 나오면 그때 다시 보기로 했어요. 나중에 파일 하나를 쓰면 되니 지금 급하게 정할 일도 아니에요.

Q2. 앱이 두 개뿐인데 벌써 공통으로 빼야 하나요?

두 번째 앱이 생기는 순간이 가장 좋은 때예요. 하나일 때는 무엇이 공통인지 알 수 없고, 셋 이상이 되면 복사본이 이미 제각각이어서 합치는 일이 훨씬 커져요. 다만 앱이 둘이고 앞으로도 늘 일이 없다면 복사본 하나를 유지하는 쪽이 더 가벼울 수 있어요.

Q3. 검색 콘솔에는 서브도메인마다 따로 등록해야 하나요?

콘솔의 등록 방식에 따라 달라요. 호스트 단위로 등록하면 서브도메인마다 소유 확인과 sitemap 제출을 따로 해야 해요. 도메인 전체를 한 번에 등록하는 방식을 지원하는 콘솔도 있으니, 쓰는 콘솔의 안내를 먼저 확인하세요. 저희는 호스트마다 값을 받아서 설정으로 넣는 방식을 택했어요.

정리

  • 복사해 둔 검색 설정을 나란히 놓고, 앱마다 다른 값과 모두 같은 규칙을 먼저 갈라요.
  • robots.txt 와 sitemap 은 앱의 값을 받아서 같은 모양으로 만드는 함수 하나로 묶어요.
  • robots.txt 는 안내판이지 자물쇠가 아니므로, 숨겨야 할 화면은 따로 잠가요.
  • 늘어나는 페이지는 공개된 것만 sitemap 에 담고, 목록에서 빠지는 것은 시험이 지키게 해요.
  • 옮기기 전에 현재 출력을 파일로 받아 두고, 옮긴 뒤 비교해서 같을 때만 끝내요.

검색 설정은 만들 때보다 서비스가 늘어난 뒤에 어긋나는 일이 훨씬 많아요. 오늘 복사본 파일 하나를 열어서 앱마다 다른 줄과 같은 줄을 나눠 보는 것부터 시작해 보세요.

이어서 읽기 좋은 글