프론트엔드트러블슈팅

인트로 페이지 제거 → 루트가 메인 직행 + AuthGuard 스피너 게이트 제거로 SEO 색인 해결 — POP-SPOT v2.23 (+v2.23.1/2)

김동현
··6분 읽기

발견형 서비스인데 모든 방문자가 5 섭션 인트로→ENTER 거쳐야 메인 도달. 이탈을 키우는 구조라 제거. v2.23.1·2 은 SEO 색인 이시합 해결 — /popups/[slug] 이 크롤러를 /login 으로 튕긴게 수정하고, AuthGuard 의 스피너 게이트를 제거해 공개 페이지가 실제 HTML로 서버 렌더링 되도록.

출시 후 3 개월 Google Search Console 색인 1 건. "팝스팟"·"팝업스토어" 검색에 도메인이 안 뜨고, 유일한 클릭 한 건이 메인이 아닌 /popups/art 슬라이스 랜딩. 원인 추적 해보니 두 가지가 겹쳐 있었다. v2.23 에서 인트로 제거, v2.23.1·2 에서 크롤러 하이라이키 수정.

이 글에서 다루는 것

  • 인트로 페이지를 제거한 이유 — 이탈이 큰데 "모든 방문자 필수" 구조는 독
  • middleware 까지 함께 삭제 — 그 존재 자체가 이제 불필요
  • v2.23.1 — /popups/[slug] 가 AuthGuard 에 감싸여 있는데 PUBLIC_PATHS 에 없어 크롤러가 /login 으로 튕겨 SEO 회상 자체가 불가
  • v2.23.2 — AuthGuard 가 스피너를 먼저 렌더링해서 구글·네이버 Yeti 는 명확한 콘텐츠를 못 봄
  • 모르는 단어 한 줄로

    용어한 줄 설명
    SSGStatic Site Generation. 빌드 시점에 HTML 완성. 크롤러에게 가장 친화적
    AuthGuard로그인 필요 경로를 감싸 토큰을 검증하는 프론트 컴포넌트
    PUBLIC_PATHSAuthGuard 가 "로그인 안 되어도 통과" 하는 경로 목록
    Yeti네이버 크롤러. JavaScript 실행없이 HTML만 읽음
    SuspenseReact 의 지연 로딩 경계. useSearchParams 등이 필수로 요구함

    v2.23 — 인트로 제거

    의사결정

    발견형 서비스의 핵심 — "들어오자마자 명확한 가치 (팝업 목록·지도)" 를 보여주는 것. 그런데 인트로가 있는 한 새 방문자 전원이:

  • 인트로 첫 섹션 진입
  • 5 섹션 스크롤을 거쳐야 함
  • ENTER 를 눌러야 메인
  • 이 구조는 광고처럼 처음 몇 초 안에 닫는 방문자에게 아무것도 안 남긴다. AuthGuard 가 이미 "/"를 공개 로 명시되어 있으면 인트로 게이트만 넘기면 비로그인 사용자도 메인의 공개 콘텐츠 (팝업·지도·캘린더·랭킹) 을 바로 볼 수 있음. 찜·코스·스탬프·메이트 등 행동 시에만 로그인 유도 (기존 동작 그대로 유지).

    삭제 항목

    파일: 아래 경로들을 git rm 으로 삭제·커밋 (일반 코드 수정이 아닌 파일 삭제 명령)

    javascript
    삭제 대상:
    - app/intro/page.tsx
    - app/intro/layout.tsx
    - public/intro-background.mp4 (17 MB)
    - middleware.ts (인트로 리다이렉트가 유일한 역할이라 이제 필요 없음)

    참조 정리

    typescript
    // page.tsx — 탈퇴 후 이동
    // 갈아엎기 전
    await fetch('/api/v1/users/me', { method: 'DELETE' });
    router.replace('/intro');  // 인트로로
    
    // 갈아엎은 후
    await fetch('/api/v1/users/me', { method: 'DELETE' });
    router.replace('/login');

    파일: popspot-frontend/src/components/AuthGuard.tsx (v2.22 양식)

    typescript
    // v2.22 까지 — AuthGuard.tsx 의 PUBLIC_PATHS
    const PUBLIC_PATHS = ['/', '/login', '/signup', '/intro', '/popups/'];
    //                                              ^^^^^^^^               /intro 경로 존재 시절도 포함

    파일: 같은 AuthGuard.tsx (v2.23 수정 후)

    typescript
    // v2.23 — /intro 제거 (페이지 자체 삭제)
    const PUBLIC_PATHS = ['/', '/login', '/signup', '/popups/'];

    파일: app/sitemap.ts, app/feed.xml/route.ts, src/app/terms/page.tsx, 각종 admin 컴포넌트들

    typescript
    // sitemap.ts · feed.xml/route.ts · terms/page.tsx — 모두 /intro 링크 제거
    // admin 컴포넌트 — 비관리자가 접근 시 /intro 대신 / 로

    해석

  • git rm 으로 삭제. 필요하면 되돌릴 수 있음
  • git grep -n "/intro" 로 프로젝트 전체의 참조 다 찾아 정리. 최종 확인 결과 0 건

  • v2.23.1 — /popups/[slug] 이 크롤러를 /login 으로 튕김

    진단 과정

    파일: 코드 파일 아닌 Google Search Console 대시보드 고츠

    javascript
    Google Search Console:
    - "팝스팟" 검색 → 0 노출
    - "팝업스토어" 검색 → 0
    - 최근 3개월 노출 1 회, 클릭 1 회
      → 유일하게 클릭된 페이지는 /popups/art (메인 아님)

    /popups/art 는 v2.21-S3 에서 만든 랜딩 페이지. 서버 렌더링 (SSG) 이라 크롤러 친화적이어야 하는데 왜 색인이 이렇게 느릴까?

    근본 원인

    파일: popspot-frontend/src/app/layout.tsx (루트 레이아웃)

    typescript
    // app/layout.tsx — 루트 레이아웃
    export default function RootLayout({ children }) {
      return (
        <html>
          <body>
            <AuthGuard>  {/* 모든 경로에 적용 */}
              {children}
            </AuthGuard>
          </body>
        </html>
      );
    }

    AuthGuard 가 토큰 없이 접근한 건을 PUBLIC_PATHS 에 있는지 검사. 문제는 PUBLIC_PATHS 가 정확 매칭이었고 "/popups/" 프리픽스 안 들어있었음. 크롤러가 /popups/seongsu 접근 → PUBLIC_PATHS 에 없음 → /login 으로 리다이렉트. 결국 만든 SEO 랜딩을 아무도 볼 수 없었다.

    수정

    파일: popspot-frontend/src/components/AuthGuard.tsx (v2.23.1 수정 후)

    typescript
    // v2.23.1 — AuthGuard.tsx. 정확 매칭 vs 프리픽스 매칭 이원화
    const PUBLIC_PATHS = ['/', '/login', '/signup'];
    const PUBLIC_PREFIXES = ['/popups/'];  // 프리픽스 — /popups/seongsu, /popups/art 등 23개 하위 구간 일괄 커버
    
    function isPublicPath(pathname: string | null): boolean {
      if (!pathname) return false;
      if (PUBLIC_PATHS.includes(pathname)) return true;
      return PUBLIC_PREFIXES.some(prefix => pathname.startsWith(prefix));
    }

    해석

  • 정확 매칭 은 PUBLIC_PATHS, 프리픽스 매칭 은 PUBLIC_PREFIXES 이원화
  • pathname == null — SSR 프리렌더링 시점에 usePathname 이 null 을 반환하는 경우 대비. 이게 빠졌어서 빌드 검증이 한 번 터졌음
  • 23 개 슬라이스 페이지 (지역 11 + 시점 5 + 카테고리 7) 모두 /popups/ 프리픽스로 끝나 한 번에 해결

  • v2.23.2 — AuthGuard 의 스피너 게이트 제거

    더 심각한 문제

    v2.23.1 핫픽스 후도 네이버 서치어드바이저 에서는 여전히 색인 0. 구글 계속 느림.

    차이점 추적: 구글은 JavaScript 실행 후 DOM 읽을 수 있지만, 네이버 Yeti 는 JavaScript 안 돌린다. 서버가 돌려주는 HTML 만 읽는다.

    파일: 같은 AuthGuard.tsx (v2.23.1 까지의 상태)

    typescript
    // v2.23.1 까지 — AuthGuard. 서버가 돌려주는 HTML 이 스피너만 될 수 있음
    'use client';
    export function AuthGuard({ children }) {
      const [verified, setVerified] = useState(false);
      // 위: 초기 false. 클라이언트 JS 가 돌아야 verified 가 true 됨
      // …
      if (!verified) {
        return <Spinner />;
        // 위: 네이버 Yeti 는 JS 안 돌리므로 HTML 을 봐도 <Spinner /> 만 보임 → "본문 없음" 으로 판단
      }
      return <>{children}</>;
    }

    이 구조는 서버 렌더링 HTML 이 항상 스피너만. JavaScript 가 돌아야만 verified=true 되어 children 을 별도 렌더링. Yeti 는 스피너 이하를 볼 때까지 멈춰있으니 "명확한 본문 없음" 으로 판단.

    수정

    파일: 같은 AuthGuard.tsx (v2.23.2 수정 후. 현재 운영 장의 최종본)

    typescript
    // v2.23.2 — 항상 children 먼저 렌더링. 리다이렉트는 클라이언트에서 useEffect 로 처리
    export function AuthGuard({ children }) {
      return (
        <Suspense fallback={null}>
          {/* useSearchParams 는 Suspense 래퍼 필수 (Next.js 15 권장 구조) */}
          <AuthGuardInner>{children}</AuthGuardInner>
        </Suspense>
      );
    }
    
    function AuthGuardInner({ children }) {
      const pathname = usePathname();
    
      // 항상 children 먼저 렌더링
      return (
        <>
          {children}
          <PathBasedRedirector pathname={pathname} />
        </>
      );
    }
    
    function PathBasedRedirector({ pathname }) {
      useEffect(() => {
        if (isPublicPath(pathname)) return;
        // 토큰 검증 실패 시 리다이렉트
        verifyToken().catch(() => router.replace('/login'));
      }, [pathname]);
      return null;
    }

    해석

  • 항상 children 먼저 렌더링 — 서버가 돌려주는 HTML 에 페이지 본문이 와있음. Yeti 도 볼 수 있음
  • 보호 경로의 리다이렉트는 클라이언트 useEffect 에서. 실제 사용자는 JS 돌아서 바로 리다이렉트 됨 (속도는 거의 동일)
  • <Suspense> — useSearchParams 는 Suspense 래퍼 필수. 없으면 프로덕션 빌드 단계에서 "useSearchParams() should be wrapped in Suspense" 에러로 실패
  • fallback={null} — 더 이상 스피너 표시 안 함. 명시적으로 null
  • 결과

    생성된 정적 HTML 직접 확인 (next build 후 .next/server/app/popups/seongsu.html):

  • /popups/seongsu — "성수 팝업" 실제 본문 102 KB. 이전엔 1 KB (스피너만)
  • /about — "팝스팟·서울 팝업" 본문 31 KB
  • 네이버/구글이 SEO 랜딩·정보 페이지 색인 가능.

    남은 한계

    메인("/") 은 "use client" + useSearchParams 쓴다. 본문은 여전히 fallback (스피너) 으로 SSR 됨. 단 <head> 의 title·description·keywords·JSON-LD 는 서버 렌더되어 브랜드 "팝스팟" 검색에는 잡힌.

    메인 본문까지 완전 SSR 하려면 useSearchParamswindow.location 패턴 전환 필요. 하지만 app/page.tsx 가 1,592 줄이라 리팩터 그 자체가 별도 파일 서빘 (v2.22 감사의 클린코드 백로그 #4).

    빌드 이슈

    패치 프로덕션 빌드에서 두 번 실패:

  • usePathname() 이 prerender 경로에서 null 반환 → PUBLIC_PATHS.includes(null) 에서 크래시. isPublicPath 에 null 가드 추가.
  • 이제 useSearchParams 가 Suspense 래퍼 필수 — Suspense 추가해서 해결
  • 세 번째에 41/41 성공.


    배포 후 실수

  • Google Search Console / 네이버 서치어드바이저에 슬라이스 랜딩 23 개 + /about 색인 요청 권장
  • 의외 수확: 처음 설정한 키워드 이외에서도 long-tail 유입 증가

  • 관련 글

  • 이전 — v2.22, 전면 보안 감사
  • v2.0 이후 첨 번째로 큰 구조 변경: 인트로 서비스 판도에서 일한 출사
  • 공유

    댓글