GINGORANG문서
홈랩 WiFi 장애 자가복구 와치독26.10.19DEV
자격시험 일정 알림 서비스 (큐넷)26.09.28DEV
청약 공고 알림 서비스 (LH·SH·청약홈)26.09.24DEV
안드로이드 앱 (gingorang-app)26.09.22DEV
도서관 서비스 (책 찾기·개관·실시간 좌석)26.09.21DEV
DB 스키마 규약 — 시각 컬럼·네이밍·주석26.09.20DEV
지도 정적 POI 통합 서비스 (map_poi)26.09.20DEV
따릉이 대여소 지도 레이어26.09.14DEV
네이티브 헬퍼 — media-helper 구조·설치·ffmpeg/ONNX 자동 준비·보안·AI 필터·저장26.09.10DEV
게임 실행 서비스 — 브라우저 에뮬레이션 구조·권한·세이브·모바일 조작26.08.30DEV

slug 기반 동적 라우팅 운영 정리

2026년 2월 20일

목차

1) 배경

  • 요구사항
    • 문서 URL을 /doc/<slug> 형태로 유지
    • slug가 자주 추가되어도 재배포 없이 반영
    • 404 우회가 아닌 정식 라우트/SEO 처리

2) 기존 방식의 한계 (output: "export")

  • 정적 export + nginx 정적 파일 서빙 구조
  • 빌드 시점에 없는 slug 경로는 정식 페이지로 존재 불가
  • /doc/*를 not-found 우회 렌더링으로 처리하면 라우팅/SEO 신호 불안정

요약: /doc/<slug>를 안정적으로 운영하려면 export 방식은 구조적으로 한계 존재

3) 적용한 해결

3.1 라우팅/렌더링

  • output: "export" 제거
  • App Router 동적 라우트 사용: /doc/[...slug]
  • /doc/*를 정식 라우트로 처리

3.2 배포/서빙 구조

  • 정적 단일 nginx 컨테이너 방식 -> nginx + Next web 2서비스 분리
  • 요청 흐름
    • browser -> nginx(80/443) -> web:3000(Next)
  • /api/*는 기존 백엔드 프록시 유지

4) 왜 서버 런타임이 필요한가

  • slug는 데이터(문서) 추가로 계속 늘어나는 동적 요구사항
  • export는 빌드 산출물 중심이라 "미리 모르는 slug" 대응 불가
  • 서버 런타임은 요청 시점에 slug를 받아 조회/렌더 가능

결과:

  • 새 slug 즉시 반영 가능
  • /doc/<slug> 정식 URL 유지 가능
  • 정상/404 분기와 메타 처리 일관성 확보 가능

5) 이미지 용량 이슈와 최적화

5.1 원인

  • 초기 서버 런타임 전환 시 node_modules + .next를 그대로 런너 이미지에 포함
  • 이미지가 대폭 증가

5.2 조치

  • next.config.mjs에 output: "standalone" 적용
  • 런너 이미지에 아래만 복사
    • .next/standalone
    • .next/static
    • public
  • 실행: node server.js

5.3 결과

  • 수동 배포 이미지 기준 ~2.59GB -> ~117MB 수준으로 축소

6) 현재 운영 기준 파일 역할

  • docker-compose.yml
    • web: Next 앱 컨테이너 (내부 3000)
    • nginx: TLS 종료/리버스 프록시 (80/443)
  • nginx.conf
    • / -> proxy_pass http://web:3000
    • /api/ -> 백엔드로 프록시
  • Dockerfile, Dockerfile.manual
    • Next standalone 런타임 이미지 빌드

7) 수동 배포 절차 (요약)

  1. 로컬에서 Dockerfile.manual로 이미지 빌드
  2. docker save로 tar 생성
  3. 서버로 전송 후 docker load
  4. 서버 docker-compose.yml의 web 이미지 태그 갱신
  5. docker compose up -d web
  6. 필요 시 docker compose up -d nginx (nginx.conf 변경 시)

8) 남은 체크포인트

  • .env.production/런타임 env 관리 정책 통일
  • health check/readiness 정책 확정
  • 배포 파이프라인에서 image tag 정책(version, latest, 임시태그) 명확화

9) SEO 메타데이터 동작 (현재 구현)

  • /와 /doc

    • SSR metadata를 공통 변수 homeDocSeo로 관리
    • src/lib/metadata.ts의 homeDocSeo.title, homeDocSeo.keywords 수정 시 두 페이지에 동시에 반영
  • /doc/[...slug]

    • generateMetadata에서 slug 기준으로 문서 조회
    • 문서가 있으면 title = MdDoc.title, keywords = MdDoc.tags
    • 조회 실패/미존재 시 title = "Docs" fallback
  • /, /doc, /doc/[...slug] 초기 데이터 조회

    • 홈 문서 초기 조회는 서버에서 먼저 수행(SSR) 후 클라이언트에 주입
    • 클라이언트는 초기 렌더 시 주입값을 우선 사용하고, 이후 상호작용 시에만 추가 조회
    • 관련 유틸: src/lib/mdDocApi.ts, src/lib/mdDocHome.ts, src/lib/mdDocSlug.ts
  • URL 고정 상태에서 사이드바로 문서 전환

    • 서버 metadata는 URL 기준이므로 재생성되지 않음
    • 현재는 클라이언트에서 document.title을 추가로 바꾸지 않음
    • 즉 URL이 안 바뀌면 title/keywords도 고정 유지

SEO 핵심 태그 정리

태그/필드역할현재 적용 위치
<title>검색 결과 제목, 브라우저 탭 제목, 클릭률(CTR)에 가장 큰 영향buildPageMetadata, /doc/[...slug]/generateMetadata
<meta name="description">검색 결과 설명문으로 노출될 수 있음, 페이지 요약 신호buildPageMetadata
<link rel="canonical">중복 URL 정규화, 대표 URL 지정buildPageMetadata (alternates.canonical)
<meta name="robots">인덱싱/링크 추적 허용 여부 제어 (index, follow)buildPageMetadata
<meta name="keywords">키워드 보조 신호, 현대 SEO 영향은 낮음/, /doc는 homeDocSeo.keywords, /doc/[...slug]는 MdDoc.tags
Open Graph (og:title, og:description, og:url, og:site_name)SNS 공유 미리보기 품질, 외부 유입 품질에 영향buildPageMetadata.openGraph

10) Sitemap / Robots 적용 상태

  • src/app/sitemap.ts

    • /sitemap.xml 동적 생성
    • 기본 URL: /, /doc
    • 문서 URL: 백엔드 GET /md-doc/slugs 응답(items: string[]) 기반 추가
    • slug 원문(test1)을 /doc/${slug} URL로 조합
    • slug에 /가 포함된 비정상 값은 제외
    • URL 기준 dedupe 적용
  • src/app/robots.ts

    • /robots.txt 생성
    • sitemap 경로를 ${appUrl}/sitemap.xml로 노출
    • private/admin 성격 경로는 disallow 처리

11) 백엔드 권장 쿼리(슬러그 전용 API)

  • 목적: sitemap 생성용 경량 데이터 제공
  • 권장 조건:
    • status = 1
    • slug IS NOT NULL
    • slug는 prefix 없는 값(test1, guide/start)이 아니라 단일 slug 토큰(test1) 기준으로 관리
    • / 포함값 배제(프론트 sitemap 규칙과 일치)
    • 필요 시 distinct

예시(QueryDSL):

java
@Transactional(readOnly = true)
public List<String> getActiveSlugs() {
    return jpaQueryFactory
        .selectDistinct(mdDocEntity.slug)
        .from(mdDocEntity)
        .where(
            mdDocEntity.status.eq(1),
            mdDocEntity.slug.isNotNull(),
            mdDocEntity.slug.notLike("%/%")
        )
        .fetch();
}