Next.js 렌더링, AI가 OK 해도 직접 재야 한다

Next.js 렌더링, AI가 OK 해도 직접 재야 한다

루트 레이아웃 한 줄이 76개 페이지를 동적 렌더링으로 끌어내린 사건—그리고 AI가 '확인했다'고 말할 때 그 말을 믿어선 안 되는 이유.

Next.js 렌더링 Static Generation Dynamic API Core Web Vitals AI 검증 App Router TTFB 최적화 캐시 전략
광고

next build가 끝나고 터미널을 닫을 때, 당신은 라우트 목록을 얼마나 꼼꼼히 읽는가? 솔직히 말하면, 대부분의 경우 빌드가 에러 없이 끝났다는 사실 자체에 안도하고 창을 닫는다. 거기서 문제가 시작된다.

최근 dev.to에 올라온 한 포스트가 이 맹점을 정확히 찌른다. 작성자는 Netlify에 배포된 Next.js App Router 프로젝트에서 모든 응답이 Cache-Control: private, no-cache, no-store로 내려오는 문제를 일주일 내내 추적했다. Netlify 설정을 뒤지고, netlify.toml을 들여다보고, 어댑터를 점검했다. 원인은 인프라가 아니었다. 루트 레이아웃 네 줄이었다.

원인은 단순했다. <html lang> 속성에 로케일을 넣으려고 루트 레이아웃에서 headers()를 호출했다. Next.js에서 headers()는 Dynamic API다. 이걸 호출하는 순간 해당 컴포넌트는 요청 시점에 렌더링해야 한다는 신호를 보낸다. 그리고 루트 레이아웃은 모든 라우트의 부모다. 결과는 76개 라우트 전체가 ƒ (Dynamic) 마커를 달고, Netlify는 정직하게 그 응답을 캐시하지 않았다. 인프라가 틀린 게 아니라, 앱이 스스로 '캐시하지 말라'고 요청한 것이다.

수정은 두 파일로 끝났다. 루트 레이아웃에서 headers() 호출을 제거하고, 이미 존재하던 클라이언트 컴포넌트의 usePathname()이 로케일을 직접 계산하도록 바꿨다. usePathname()은 정적 생성 라우트에서 빌드 타임에 라우트별로 해석된다. 이미 올바른 답이 거기 있었는데, headers()가 만들어낸 우회로를 따라가느라 그걸 쓰지 않고 있었던 것이다. 결과: 1개이던 정적 라우트가 77개가 됐고, TTFB는 0.34~0.44초에서 0.16~0.20초로 절반 가까이 줄었다.

이 이야기에서 내가 주목하는 건 성능 수치가 아니다. 문제가 한 주 동안 보이지 않았던 이유다. next build 출력에는 이미 모든 정보가 있었다. ƒ 마커가 76개, 마커가 1개. 하지만 우리는 76개의 라우트 이름을 훑으면서 모두가 같은 마커를 달고 있다는 사실을 눈치채지 못한다. 목록을 읽지, 범례를 읽지 않기 때문이다. Dynamic API의 파급 범위도 직관과 다르다. 리프 페이지에서 호출하면 그 하나만 동적이 되지만, 루트 레이아웃에서 호출하면 전체가 무너진다.

이 지점에서 두 번째 이야기가 겹친다. 같은 주, 역시 dev.to에 올라온 글에서 한 개발자는 AI에게 팟캐스트 파이프라인 코드를 복사해달라고 요청했다. 복사 후, 신선한 세션을 열어 AI에게 검토를 맡겼다. AI는 "모든 게 정상"이라고 답했다. 실제로 실행해보니 타이밍, 음악 페이드, 목소리 구성이 전부 틀렸다. 코드의 형태는 맞았지만, 파이프라인이 '좋다'는 게 무엇인지 AI는 알 방법이 없었다. 코드를 읽을 수 있어도, 그 코드가 만들어내는 경험을 들을 수는 없었기 때문이다.

작성자의 표현이 정확하다. "AI의 '검증'은 가설이지, 사실이 아니다. 아무런 지분도, 기억도 없는 것에서 나온 '확인'은 그렇다." 이건 AI를 비난하는 이야기가 아니다. AI가 볼 수 있는 것과 볼 수 없는 것 사이의 구조적 간극에 관한 이야기다.

세 번째 이야기는 그 간극을 더 정밀하게 해부한다. 혼자 작업하는 개발자가 한 주 동안 자신이 직접 만든 검증 스크립트 세 개가 모두 통과를 반환하는 동안 실제 작업물은 각기 다른 방식으로 깨져 있었다는 경험을 기록했다. 첫 번째 스크립트는 컬럼 개수를 세었지만 값의 의미는 보지 못했다. 두 번째는 올바른 위치를 찾으려 했지만 다른 행에 착지했다. 세 번째는 저장된 상태가 아니라 방금 타이핑한 탭의 상태를 측정했다.

그가 정리한 분류가 유용하다. 얕음(Shallow): 구조는 보지만 의미는 보지 못한다. 조준 이탈(Misaimed): 의도한 곳이 아닌 다른 곳을 측정한다. 시기 이름(Premature): 진짜 상태가 확정되기 전에 읽는다. AI가 렌더링 전략을 검토할 때 범하는 오류도 정확히 이 세 범주 안에 있다. AI는 코드가 유효한 Next.js 문법인지는 볼 수 있지만, 그 코드가 런타임에 어떤 캐싱 결정을 촉발하는지, 실제 응답 헤더가 무엇인지는 보지 못한다. 빌드 출력 범례를 읽지 않으면, 사람도 마찬가지다.

세 이야기를 합치면 하나의 원칙이 나온다. 프록시가 초록불을 켰다고 해서 실제가 정상인 건 아니다. 테스트 통과, PR 머지, 에이전트의 '완료', AI의 '검증 완료'—이것들은 모두 실제 상태의 대리인이다. 대리인이 완벽하게 초록색일 때도 그 아래가 깨져 있을 수 있다. AI가 코드를 생성하는 비중이 높아질수록, 이 간극은 더 넓어지고 더 보기 힘들어진다.

그렇다면 Next.js 렌더링 전략에서 '직접 재는' 것은 구체적으로 무엇인가? 체크리스트로 정리하면 이렇다.

next build 범례를 숫자로 읽어라. 라우트 이름 목록이 아니라 ƒ의 비율을 본다. 정적이어야 할 라우트가 동적이면, 트리를 거슬러 올라가 Dynamic API 호출을 찾는다.

Dynamic API 오염 경로를 파악한다. headers(), cookies(), searchParams, noStore(), force-dynamic, cache: 'no-store' fetch—이 중 하나가 루트 레이아웃이나 공유 레이아웃에 있으면 그 아래 전체가 동적이 된다.

실제 응답 헤더를 확인한다. 브라우저 DevTools나 curl -I로 프로덕션 배포의 Cache-ControlCache-Status를 직접 읽는다. 인프라가 캐시를 거부하고 있다면, 원인이 앱 코드 안에 있을 가능성을 먼저 의심한다.

AI에게 리뷰를 맡길 때 질문을 구체화한다. '이 코드 괜찮아?'가 아니라 '이 컴포넌트 트리에서 Dynamic API를 호출하는 지점을 모두 찾아줘. 그리고 각 호출이 어떤 라우트의 렌더링 전략에 영향을 주는지 설명해줘.'라고 묻는다. AI가 볼 수 있는 영역을 정확히 지정해야 유용한 답이 나온다.

루트 레이아웃 한 줄이 76개 페이지를 동적으로 만들었다. 수정하는 데 두 파일이 필요했다. 하지만 그 원인을 찾는 데 일주일이 걸린 건, 코드의 문제가 아니라 측정의 문제였다. AI가 코드를 잘 짜는 시대일수록, 그 코드가 런타임에 무엇을 하는지를 직접 측정하는 습관이 오히려 더 희소하고 더 가치 있는 역량이 된다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요