바이브코딩으로 하는 SEO 대응 실패와 교훈
블로그 재구성을 위한 정리 문서. 두 서비스의 SEO 전략, 실제 대응 방식, 3차에 걸친 장애 대응, 그리고 시사점을 시간순으로 정리했다.
0. 배경
- 지금여기(now) : 2026-03-26 시작. 성수·홍대 팝업스토어/공연/축제 정보를 다루는 hyperlocal 미디어. 네이버 검색 노출이 꾸준히 잘 됨.
- 네모네AIM(matmatch) : 2026-05-30 시작. 미식·문화·라이프·테크를 다루는 라이프스타일 매거진. 검색 유입이 트래픽의 거의 100%인데, 네이버 검색 노출이 계속 부진. 2026-05-31부터 급락 이후 여러 차례 원인을 찾아 수정했지만 좀처럼 회복되지 않았음.
두 서비스 모두 Next.js(App Router) 기반이고, 같은 인프라(서버, pm2, nginx)에서 운영 중. SEO 대응 방식과 시행착오를 비교하면 같은 실수를 반복하지 않을 수 있다고 판단해 전체 히스토리를 정리한다.
1. 공통 SEO 기본 전략
두 서비스 모두 다음 기본 요소는 처음부터 갖추고 시작:
| 항목 | 내용 |
|---|---|
| sitemap.xml | app/sitemap.ts로 동적 생성, 백엔드 API에서 게시물 목록을 가져와 URL 생성 |
| robots.txt | 네이버/구글 봇 허용, admin/auth 등 비공개 경로 차단 |
| 메타데이터 | Next.js generateMetadata/metadata export로 title, description, OG, canonical 설정 |
| 사이트 인증 | naver-site-verification, Google Search Console 인증 메타 태그 |
| 구조화 데이터(JSON-LD) | WebSite, Organization, Article/Event 등 schema.org 마크업 |
이 정도 "기본기"는 두 서비스 모두 처음부터 잘 갖춰져 있었다. 즉, 두 서비스의 노출 격차는 "기본 SEO 셋업의 차이"가 아니라 더 깊은 곳에 있었다.
2. 지금여기의 SEO 대응 타임라인
| 날짜 | 작업 |
|---|---|
| 2026-03-26 | 서비스 시작 |
| 2026-06-01 | Next.js 14.2.29 업그레이드, robots.ts 동적 전환, noindex 레이아웃 정리, Supabase SSR 인증 도입과 함께 SEO 메타데이터 정비 |
| 2026-06-10 | 상세 페이지(posts/[id]) title/description 중복 문제 수정 — 장소 전체 목록을 매번 불러오던 것을 GET /places/{id} 단건 조회로 교체, 폴백 메타데이터도 고유화 |
| 2026-06-17 | 랭킹/테마 UX 개선과 함께 홈 타이틀·OG 타이틀 SEO 강화, 만료된 장소(팝업스토어 등) 자동 DB 정리 로직 추가 |
| 2026-06-17 | 네이버 애널리틱스 + GA4 추적 추가 |
지금여기는 큰 장애 없이 점진적으로 메타데이터 품질을 개선하는 방식으로 작업해왔다. 핵심 콘텐츠(홈/상세 페이지)는 처음부터 클라이언트 렌더링(CSR) 구조였지만, 이것이 노출에 큰 문제가 되지 않은 것으로 보인다 (3장 참고).
3. 네모네AIM의 SEO 대응 타임라인 — 3차에 걸친 장애
네모네AIM은 5/31 급락 이후 세 번의 별개 원인을 찾아 수정했는데, 그중 세 번째가 가장 까다로웠고 오늘(6/21)에야 완전히 해결됐다.
1차 원인: 페이지네이션으로 인한 링크 소멸 (5/31 발생 → 6/14 수정)
- 5/31, 서버 부하 절감을 위해 홈페이지 포스트 목록을 전체 노출 →
useState(10)페이지네이션으로 변경. - 결과: 서버가 응답하는 홈 HTML에 포스트 링크가 10개만 포함됨. 나머지 90여 개는 "더보기" 버튼을 클릭해야 노출되는데, 크롤러는 버튼을 클릭하지 않으므로 그 포스트들을 발견할 경로가 사라짐.
- 검색 유입이 거의 전부인 서비스에서, 홈에서 끊긴 링크는 사이트맵만으로는 크롤 우선순위가 낮아짐.
6/14 수정 시도 (세 단계로 진행됨, 같은 날 저녁 1시간 사이):
645fdcf(20:27) —useState(10)→useState(initialPosts.length)로 변경해 SSR 시 전체 포스트가 HTML에 포함되게 하고, hydration 후useEffect로 다시 10개로 줄임 (사용자 UX는 유지).de61d6d(20:45) — 위 방식 대신,page.tsx(서버 컴포넌트)에<div className="h-0 overflow-hidden" aria-hidden="true">로 화면엔 안 보이지만 크롤러에게는 보이는 숨김 링크 목록을 추가하는 방식으로 교체.HomeContent.tsx는 다시useState(10)으로 원복.6d333c8(21:28) — 위 변경들과revalidate=60(ISR 캐싱) 시도가 충돌해 빌드 에러 발생. 급한 대로force-dynamic을 명시해 캐싱을 끄고 매 요청마다 새로 렌더링하도록 롤백.
2차 원인: 의도치 않은 봇 차단 설정 (6월 초 발견 → 긴급 제거)
- robots.txt 또는 관련 설정에 크롤러를 막는 규칙이 들어가 있던 것을 발견, 긴급 제거.
- 단, 발견 시점이 주말이라 "네이버가 주말에 크롤링을 덜 했을 수도 있다"는 가능성도 있어 실제 영향 범위는 불확실.
3차 원인: ISR 캐싱 + useSearchParams + Suspense의 충돌 (6/16 발생 → 6/21 완전 해결)
가장 교묘했던 원인. 발생 경위:
- 6/14 1차 원인 수정 직후, "force-dynamic이 메모리 부담이 크다"는 판단 아래 6/16
da20d53커밋에서 다시 ISR(revalidate=60)로 전환을 시도. 이번엔 빌드 에러를 피하려고 검색 기능에 쓰던useSearchParams()(HomeContent.tsx)를<Suspense fallback={null}>로 감쌈. - 커밋 메시지: "크롤러용 전체 포스트 링크 노출 효과는 그대로 유지" — 하지만 실제로는 정반대 결과가 났다.
실제로 벌어진 일: Next.js는 ISR(정적 캐시) 페이지에서 useSearchParams()를 쓰는 컴포넌트의 실제 렌더링 결과를 빌드/캐시 시점에 알 수 없다. 그래서 해당 Suspense 영역은 정적 HTML에는 fallback={null}만 굽고, 실제 콘텐츠는 브라우저에서 JS가 실행된 뒤에만 렌더링한다. 그런데 홈페이지의 메인 그리드(HomeContent.tsx) 전체가 이 useSearchParams() 호출 하나에 묶여 있었기 때문에, 홈페이지 전체 콘텐츠가 5일 동안(6/16~6/21) 크롤러에게 빈 화면으로 노출되고 있었다.
이건 로컬 코드만 봐서는 알아내기 어려웠다. page.tsx가 async function으로 서버에서 데이터를 fetch하고, 백엔드 호출도 정상이었기 때문에 코드상으로는 "SSR이 잘 되고 있다"고 판단하기 쉬운 구조였다. 실제로 운영 서버에 curl -A "Yeti" https://nemoneai.com/로 직접 요청을 보내 응답 HTML을 까봐야만 발견할 수 있었다:
포스트 링크: 0개, 이미지: 0개, 본문 텍스트: nav/footer 199자뿐
같은 방식으로 useSearchParams()를 쓰지 않는 /category/Taste 페이지를 검사하자 링크가 80개 정상 노출되어, 문제가 홈페이지 한 곳에 국한된 구조적 결함임이 확인됐다.
6/21 최종 수정:
- 검색 기능을
/search?q=...라는 별도 페이지로 완전히 분리 (서버 컴포넌트가searchParams를 prop으로 받아 처리,robots: {index:false}+ robots.txt에Disallow: /search추가로 인덱싱 대상에서도 제외). HomeContent.tsx에서useSearchParams()를 완전히 제거 — 홈은 검색 여부와 무관하게 항상 정적으로 전체 그리드를 렌더링.- 결과: 빌드 시
/라우트가○ (Static)으로 표시됨 (이전엔 보이지 않던 동적 우회 없이 정상 정적 생성). 배포 후 운영 서버에서 직접 확인한 결과 포스트 링크 10개, 이미지 12개, 제목 10개가 정상적으로 서버 HTML에 포함됨.
4. 진단 방법론 — 무엇이 달랐나
이번에 3차 원인을 찾아낸 방법은 이전 두 차례와 달랐다는 점이 중요하다.
- 1·2차 원인: 로컬 코드를 읽고 "이 부분이 문제겠다"를 추론한 뒤 수정 → 검증은 빌드 성공 여부로만 확인.
- 3차 원인: now와 matmatch의 코드를 나란히 비교 분석했지만, 코드 분석만으로는 결론이 나지 않았다 (오히려 코드상으로는 matmatch가 now보다 SSR/구조화 데이터 면에서 더 우수해 보였다). 실제로
curl -A "Yeti" <운영 URL>로 운영 서버가 크롤러에게 무엇을 실제로 응답하는지 직접 확인하면서, 코드의 "의도"와 배포된 "실제 동작"이 정반대였던 지점을 발견.
즉 "로컬 코드가 SSR처럼 보인다"는 것과 "운영 서버가 크롤러에게 실제로 콘텐츠를 내려준다"는 것은 다른 문제였다.
5. 지금여기는 왜 잘 되고 있었나 — 남은 질문
기술적으로만 보면 now의 홈/상세 페이지는 지금도 완전한 CSR이다 (상세 페이지는 초기 HTML에 "Loading..." 문구만 있고, 실제 본문은 클라이언트 렌더링 이후에만 나타남). 이는 일반적인 SEO 기준으로는 matmatch보다 불리한 구조다. 그런데도 now는 검색 노출이 잘 되고 있다. 가능한 설명:
- 서비스 시작 시점 차이: 네이버의 신뢰도/누적 지수(C-Rank 류) 알고리즘에서 사이트 연령과 누적 신호가 유리하게 작용했을 가능성.
- 콘텐츠 주제의 경쟁 구도 차이: 지금여기는 특정 팝업스토어명·축제명 등 고유명사 기반의 hyperlocal·시의성 콘텐츠를 다룬다. 경쟁 콘텐츠가 거의 없는 롱테일 키워드라 노출이 쉽다. matmatch는 "우롱차의 역사", "북경오리 과학" 같은 일반 교양/정보성 콘텐츠를 다루는데, 이는 위키·대형 미디어·블로그가 이미 점유한 초경쟑 카테고리라 같은 기술 수준이어도 노출이 훨씬 어렵다.
- 기술적 요인은 일부일 뿐: 이번에 고친 3차 원인(홈 콘텐츠 누락)은 명백한 결함이었지만, 그것만으로 now와의 격차 전체를 설명하긴 어렵다. 콘텐츠 전략(주제 선택)도 함께 검토할 필요가 있다.
이 부분은 아직 결론이 아니라 가설이며, 추후 검색 반영 추이를 보며 검증이 필요하다.
6. 시사점
- "의도"와 "실제 배포 결과"는 다를 수 있다. 6/16 커밋은 "크롤러용 링크 노출 효과를 유지"하려는 의도였지만 정반대로 작동했다. 코드 리뷰만으로는 이런 종류의 버그(프레임워크 차원의 렌더링 분기)를 잡기 어렵다.
- 운영 서버를 크롤러 시점에서 직접 검증해야 한다.
curl -A "Yeti" <URL>처럼 실제 크롤러 User-Agent로 운영 환경의 응답을 까보는 것이, 로컬 코드 분석보다 훨씬 빠르고 확실하게 진짜 원인을 드러냈다. - "서버 속도 최적화"와 "크롤러 노출"은 자주 충돌하는 목표다. 페이지네이션 축소(1차), ISR 캐싱 전환(3차) 모두 서버 부하를 줄이려는 시도였고, 그 과정에서 매번 크롤러 노출이 희생됐다. 두 목표를 동시에 만족시키려면 "검색 의존적 로직(useSearchParams 등)을 캐싱 가능한 정적 콘텐츠와 철저히 분리"하는 설계가 필요하다 (오늘 적용한
/search라우트 분리가 그 예). - 기술적 SEO가 전부는 아니다. matmatch는 now보다 기술적으로(SSR, JSON-LD, sitemap 세분화 등) 더 잘 갖춰져 있었는데도 검색 노출은 더 나빴다. 콘텐츠 주제의 경쟁 강도, 사이트 연령, 도메인 신뢰도 같은 비-기술적 요인이 동등하게, 혹은 더 크게 작용할 수 있다.
- 같은 인프라 위의 두 서비스를 비교하는 것 자체가 진단 도구가 된다. "왜 안 되지"를 한 서비스 안에서만 파다 보면 추측에 머물기 쉬운데, 잘 되고 있는 비교 대상(now)이 있었기에 "기술 vs 콘텐츠" 구분이 비로소 선명해졌다.

COMMENTS
의견을 남기려면 로그인이 필요합니다.