Next.js

Next.js Dynamic server usage 오류 해결: cookies headers 사용 위치 확인하기

2026.05.19·수정 2026.05.20·약 16분

이 글에서 정리하는 내용

Next.js App Router에서 Dynamic server usage 오류가 발생했을 때, cookies()headers() 호출 위치를 추적하고 정적 렌더링을 유지할지, 동적 렌더링으로 명확히 전환할지 판단하는 과정을 정리합니다.

Dynamic server usage 오류가 의미하는 것

Next.js App Router에서 정적 렌더링과 요청 시점 API 사용이 충돌하는 흐름

Dynamic server usage 오류는 cookies()headers() 사용법 하나만 잘못되어서 생기는 문제가 아닙니다. 더 자주 부딪히는 원인은 렌더링 방식의 충돌입니다. Next.js가 어떤 라우트를 정적으로 만들 수 있다고 판단했는데, 그 안에서 요청마다 달라지는 값을 읽으면 같은 HTML로 고정하기 어렵습니다.

블로그 상세 페이지나 상품 상세 페이지처럼 빌드 시점에 미리 만들어도 되는 화면이 있습니다. 반대로 로그인한 사용자 이름, 인증 토큰, 브라우저의 요청 헤더처럼 접속할 때마다 달라지는 값도 있습니다. 이 둘이 한 라우트 안에서 섞이면 Next.js는 해당 라우트를 정적으로 둘 수 있는지 다시 판단해야 합니다.

개발 서버에서는 화면이 일단 열리기 때문에 문제가 늦게 보이기도 합니다. 하지만 next build나 Vercel 배포 과정에서는 정적 생성 가능 여부가 더 분명하게 드러납니다. 로컬에서는 지나간 것처럼 보였던 코드가 배포 빌드에서 Dynamic server usage로 걸리는 이유가 여기에 있습니다.

자주 보는 오류 흐름

Dynamic server usage: Route /account couldn't be rendered statically because it used cookies
Dynamic server usage: Route /dashboard couldn't be rendered statically because it used headers

이 메시지를 보면 먼저 API 이름보다 라우트 위치를 봐야 합니다. 같은 cookies() 호출이라도 마이페이지처럼 사용자별 화면이 필요한 곳에서는 자연스럽습니다. 반대로 정적 콘텐츠 페이지에서 같은 코드를 호출했다면, 쿠키를 읽는 위치를 분리하거나 해당 라우트의 렌더링 방식을 다시 정해야 합니다.

cookies와 headers가 정적 렌더링을 흔드는 이유

App Router에서 cookies()headers()는 요청 시점의 값을 읽는 API입니다. Next.js 15 기준으로 둘 다 비동기 함수이므로 새 코드에서는 await cookies(), await headers() 형태를 기준으로 잡는 것이 좋습니다. 이전 방식의 동기 접근이 남아 있는 코드라도, 새 프로젝트에서는 비동기 호출을 전제로 구조를 보는 쪽이 덜 헷갈립니다.

cookies()는 브라우저가 요청과 함께 보낸 쿠키를 서버 컴포넌트, 서버 함수, Route Handler에서 읽을 때 사용합니다. 다만 쿠키를 설정하거나 삭제하는 작업은 응답 헤더를 조정해야 하므로 렌더링 중인 서버 컴포넌트보다 서버 함수나 Route Handler 쪽에서 다루는 것이 맞습니다.

headers()도 같은 성격을 가집니다. authorization, user-agent, 커스텀 헤더 같은 값은 빌드 시점에 미리 알 수 없습니다. 그래서 페이지나 레이아웃에서 headers()를 호출하면 해당 라우트는 요청별 렌더링에 가까워집니다.

정적 페이지 안에서 요청 값을 바로 읽는 코드

import { cookies } from 'next/headers';

export default async function ProductPage() {
    const cookieStore = await cookies();
    const token = cookieStore.get('access_token')?.value;

    return (
        <main>
            <h1>상품 상세</h1>
            <p>토큰 값: {token}</p>
        </main>
    );
}

이 코드에서 확인할 부분은 cookies() 한 줄이 아니라 페이지의 성격입니다. 상품 상세 페이지가 빌드 시점에 미리 만들어져야 한다면, 그 안에서 사용자마다 다른 토큰을 읽는 구조가 맞지 않습니다. 반대로 이 화면이 로그인한 사용자 전용 상품 관리 페이지라면, 정적 페이지로 유지하려는 판단이 어긋난 것입니다.

오류가 자주 생기는 사용 위치 확인하기

오류를 잡을 때는 빌드 로그에 나온 라우트부터 확인합니다. 그다음 해당 라우트의 page.tsx, 상위 layout.tsx, 공통 서버 유틸, Route Handler 순서로 따라갑니다. 특히 공통 유틸 안에서 headers()cookies()를 호출하면, 페이지 파일에 직접 코드가 없어도 라우트 전체가 영향을 받을 수 있습니다.

page.tsx에서 바로 쿠키를 읽는 경우는 비교적 찾기 쉽습니다. 실제로 시간이 걸리는 부분은 getCurrentUser(), getAuthToken(), createServerClient()처럼 이름만 보면 단순 유틸처럼 보이는 함수 내부입니다. 이런 함수가 정적 페이지에서도 재사용되면 오류 위치를 빌드 로그만으로 바로 찾기 어렵습니다.

공통 유틸 내부에서 요청 API를 호출하는 경우

import { headers } from 'next/headers';

export async function getRequestSource() {
    const headersList = await headers();
    return headersList.get('user-agent') ?? 'unknown';
}

이 함수 자체는 짧습니다. 하지만 정적으로 생성하고 싶은 페이지에서 이 함수를 부르면 해당 페이지도 요청 헤더에 의존하게 됩니다. 함수 이름이 getRequestSource처럼 명확하면 추적이 쉽지만, getPageData 같은 이름 안에 숨어 있으면 한참 뒤에야 원인을 찾게 됩니다.

layout에서 전역으로 인증 상태를 판단하는 경우

import { cookies } from 'next/headers';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
    const cookieStore = await cookies();
    const token = cookieStore.get('access_token')?.value;

    return (
        <html lang="ko">
            <body>
                <header>{token ? '로그인 상태' : '비로그인 상태'}</header>
                {children}
            </body>
        </html>
    );
}

루트 레이아웃에서 쿠키를 읽으면 영향 범위가 큽니다. 그 아래에 있는 많은 페이지가 요청별 렌더링 성격을 갖게 됩니다. 로그인 표시가 사이트 전체에 필요한지, 일부 메뉴에만 필요한지, 마이페이지 그룹에서만 필요한지 먼저 나눠야 합니다. 전역 레이아웃에 넣기 전에 라우트 그룹을 분리할 수 있는지도 같이 확인해야 합니다.

비동기 컨텍스트를 벗어난 호출도 확인하기

import { cookies } from 'next/headers';

export default async function Page() {
    setTimeout(async () => {
        const cookieStore = await cookies();
        console.log(cookieStore.get('access_token'));
    }, 1000);

    return <main>페이지 본문</main>;
}

요청 API는 렌더링과 같은 요청 문맥 안에서 읽어야 합니다. 위처럼 setTimeout 내부나 분리된 비동기 작업 안으로 밀어 넣으면, Next.js가 동적 사용을 정상적으로 추적하기 어려운 형태가 됩니다. 오류가 단순히 “쿠키를 읽었다”가 아니라 “어디에서, 어떤 타이밍에 읽었는가”와 연결되는 이유입니다.

해결 방향을 고르는 기준

해결책은 하나로 고정되지 않습니다. 먼저 이 페이지가 모든 사용자에게 같은 결과를 보여줘도 되는지, 아니면 요청마다 결과가 달라져야 하는지 나눠야 합니다. 이 질문에 답하지 않고 force-dynamic만 추가하면 오류는 사라질 수 있지만 캐싱과 성능 판단이 흐려집니다.

마이페이지, 결제 내역, 관리자 대시보드처럼 인증 상태가 화면 전체를 결정한다면 동적 렌더링을 명확히 선택할 수 있습니다. 이 경우에는 라우트 자체가 사용자 요청에 의존하므로 쿠키를 읽는 위치도 설명 가능합니다.

페이지 자체가 요청별로 달라져야 하는 경우

import { cookies } from 'next/headers';

export const dynamic = 'force-dynamic';

export default async function AccountPage() {
    const cookieStore = await cookies();
    const token = cookieStore.get('access_token')?.value;

    if (!token) {
        return <p>로그인이 필요합니다.</p>;
    }

    return <main>내 계정 화면</main>;
}

force-dynamic은 해당 라우트를 요청 시점 렌더링으로 다루겠다는 의도를 코드에 드러냅니다. 다만 프로젝트의 Next.js 버전과 캐싱 설정에 따라 적용 범위가 달라질 수 있습니다. 새 캐싱 모델을 적용한 프로젝트라면 기존 설정 하나로 끝내기보다 현재 프로젝트의 캐시 구성을 같이 확인해야 합니다.

반대로 상품 상세, 공지사항, 기술 블로그 글처럼 본문 자체는 모든 사용자에게 같고 상단의 로그인 버튼 정도만 달라지는 화면이라면 페이지 전체를 동적으로 바꾸는 것이 과할 수 있습니다. 이때는 요청 의존 부분을 작게 분리하는 구조가 더 낫습니다.

정적 페이지를 유지하고 사용자 영역만 분리하는 경우

export default async function ProductPage() {
    const product = await getProductBySlug('sample-product');

    return (
        <main>
            <h1>{product.name}</h1>
            <p>{product.description}</p>
            <UserMenu />
        </main>
    );
}

이 예시에서는 상품 정보와 사용자 메뉴의 성격을 분리합니다. 상품 본문은 정적으로 유지하고, 로그인 여부처럼 요청마다 달라지는 부분만 별도 경계로 뺍니다. 실제 프로젝트에서는 UserMenu를 클라이언트 컴포넌트로 두고 API를 호출하거나, 인증이 필요한 영역을 별도 라우트로 나누는 식으로 정리할 수 있습니다.

Route Handler에서 쿠키를 읽는 경우

import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function GET() {
    const cookieStore = await cookies();
    const token = cookieStore.get('access_token')?.value;

    if (!token) {
        return NextResponse.json({ user: null }, { status: 401 });
    }

    return NextResponse.json({ user: { name: '사용자' } });
}

Route Handler는 요청과 응답을 직접 다루는 경계입니다. 쿠키를 읽고 JSON 응답을 내려주는 역할이 분명하므로, 서버 컴포넌트 렌더링 중에 모든 인증 판단을 섞는 것보다 책임을 나누기 쉽습니다. 단, 인증이 필요한 데이터라면 캐싱이 의도치 않게 적용되지 않도록 응답 정책도 같이 봐야 합니다.

배포 전에 다시 볼 체크포인트

Next.js 배포 빌드 전 cookies headers 사용 위치를 점검하는 체크리스트

배포 실패를 줄이려면 오류 메시지를 보고 바로 코드를 고치기보다 확인 순서를 고정해두는 것이 좋습니다. 먼저 빌드 로그에서 문제가 된 라우트를 찾습니다. 그다음 해당 라우트의 파일만 보는 것이 아니라 상위 레이아웃과 공통 서버 유틸까지 같이 검색합니다.

검색할 키워드는 cookies(, headers(, next/headers, force-dynamic, revalidate, fetch 정도면 충분합니다. 특히 next/headers import가 직접 들어간 파일은 요청 시점 API를 읽을 가능성이 높습니다.

확인 위치봐야 할 내용
page.tsx정적 페이지에서 쿠키나 헤더를 직접 읽고 있는지 확인
layout.tsx상위 레이아웃에서 인증 상태를 전역으로 판단하는지 확인
서버 유틸 함수공통 함수 내부에 cookies(), headers() 호출이 숨어 있는지 확인
Route Handler쿠키를 읽는 책임이 API 경계로 분리되어 있는지 확인
라우트 설정정적 유지가 목표인지, 동적 렌더링이 목표인지 확인

확인 과정에서 흔한 실수는 “오류가 난 페이지 파일에 코드가 없으니 원인이 아니다”라고 판단하는 것입니다. App Router는 레이아웃과 페이지가 함께 라우트 트리를 구성합니다. 상위 레이아웃에서 쿠키를 읽으면 하위 페이지에서도 영향을 받을 수 있고, 공통 유틸에서 헤더를 읽으면 호출한 쪽이 동적 API 사용 위치가 됩니다.

또 하나는 force-dynamic을 먼저 넣고 끝내는 방식입니다. 인증 페이지라면 맞는 선택일 수 있습니다. 하지만 정적 콘텐츠 페이지라면 페이지 전체를 동적으로 바꾸는 대신 요청 의존 영역만 작게 분리하는 구조가 더 적절할 수 있습니다. 오류 해결보다 라우트의 성격을 먼저 정해야 이후 캐싱 문제도 줄어듭니다.

정리

Dynamic server usage 오류는 cookies()headers()를 쓰지 말라는 뜻이 아닙니다. 정적으로 만들 수 있는 라우트 안에서 요청 시점 값을 읽었거나, 요청 API가 원래 문맥을 벗어난 위치에서 호출되었을 가능성을 알려주는 신호에 가깝습니다.

수정할 때는 세 가지를 순서대로 보면 됩니다. 먼저 빌드 로그에서 라우트 위치를 찾습니다. 다음으로 page.tsx, layout.tsx, 공통 서버 유틸에서 cookies()headers() 호출을 찾습니다. 마지막으로 해당 페이지가 정적으로 남아야 하는지 요청별로 달라져야 하는지 결정합니다.

마이페이지나 관리자 화면처럼 사용자 요청에 따라 화면 전체가 달라진다면 동적 렌더링을 명확히 선택하면 됩니다. 반대로 상품 상세나 블로그 본문처럼 대부분의 내용이 고정된 페이지라면 쿠키나 헤더를 읽는 부분만 분리하는 구조가 더 안정적입니다. 같은 오류를 다시 만나면 API 이름만 보지 말고, 그 API가 라우트 트리의 어디에서 호출되는지 먼저 추적해야 합니다.

참고 자료는 Next.js 공식 문서의 cookies, headers, DynamicServerError, Route Segment Config 설명을 기준으로 확인했습니다. 버전과 캐싱 설정에 따라 세부 옵션은 달라질 수 있으므로, 실제 프로젝트에서는 사용 중인 Next.js 버전의 문서를 함께 확인해야 합니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기