도입: Next.js CSP nonce 적용 시 정적 렌더링과 캐시가 바뀌는 이유 한 줄 정의
Next.js에서 CSP nonce를 적용하면 요청마다 고유한 nonce 값을 생성해야 하므로 빌드 시점에 HTML을 미리 만드는 정적 렌더링(Static Rendering)이 불가능해지고, 동적 렌더링(Dynamic Rendering)으로 전환되면서 캐시 전략도 함께 변경된다.
Next.js CSP nonce 적용 시 정적 렌더링과 캐시가 바뀌는 이유 핵심 항목
1. CSP nonce의 정의와 작동 원리
정의
CSP(Content Security Policy) nonce는 인라인 스크립트와 스타일을 안전하게 실행하기 위해 사용하는 일회성 난수 토큰이다. 서버가 응답마다 새로운 nonce 값을 생성하고, 이 값을 HTTP 헤더와 HTML 태그 속성에 동시에 포함시켜 브라우저가 검증할 수 있도록 한다.
원리
CSP 헤더에 script-src 'nonce-abc123' 형태로 허용할 nonce 값을 선언하면, 브라우저는 <script nonce="abc123">처럼 동일한 nonce 속성을 가진 스크립트만 실행한다. 공격자가 악의적인 스크립트를 삽입하더라도 올바른 nonce 값을 알 수 없으므로 실행이 차단된다.
예시
Next.js App Router에서 middleware.ts 파일에 다음과 같이 nonce 생성 로직을 추가할 수 있다.
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const cspHeader = `script-src 'self' 'nonce-${nonce}' 'strict-dynamic';`;
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', cspHeader);
response.headers.set('x-nonce', nonce);
return response;
}
레이아웃 컴포넌트에서는 헤더에서 nonce를 읽어 Script 컴포넌트에 전달한다.
import { headers } from 'next/headers';
import Script from 'next/script';
export default function RootLayout({ children }) {
const nonce = headers().get('x-nonce');
return (
<html>
<body>
{children}
<Script src="/analytics.js" nonce={nonce} />
</body>
</html>
);
}
오해
nonce를 사용하면 모든 XSS 공격을 막을 수 있다고 생각하는 경우가 있다. nonce는 인라인 스크립트 실행을 제어할 뿐, DOM 기반 XSS나 외부 리소스 로딩 취약점은 별도로 관리해야 한다.
2. 정적 렌더링에서 동적 렌더링으로 전환되는 이유
정의
정적 렌더링은 빌드 시점에 HTML을 생성해 모든 사용자에게 동일한 파일을 제공하는 방식이다. 동적 렌더링은 요청 시점에 서버에서 HTML을 생성해 각 요청마다 다른 내용을 반환할 수 있다.
원리
CSP nonce는 요청마다 고유한 값이어야 보안 효과가 있다. 만약 빌드 시점에 생성한 nonce를 모든 사용자에게 재사용하면, 공격자가 한 번 nonce 값을 확인한 후 같은 값으로 악의적 스크립트를 삽입할 수 있다. 따라서 Next.js는 headers() 같은 동적 API를 감지하면 해당 페이지를 자동으로 동적 렌더링으로 전환한다.
예시
다음 페이지 컴포넌트는 빌드 시 정적으로 생성된다.
export default function Page() {
return <div>정적 콘텐츠</div>;
}
하지만 headers()를 호출하면 동적 렌더링으로 바뀐다.
import { headers } from 'next/headers';
export default function Page() {
const nonce = headers().get('x-nonce');
return <div nonce={nonce}>동적 콘텐츠</div>;
}
Next.js 빌드 로그에서 페이지 옆에 표시되는 아이콘이 ○(정적)에서 λ(동적)로 변경된 것을 확인할 수 있다.
오해
동적 렌더링으로 전환되면 성능이 무조건 나빠진다고 생각할 수 있다. 실제로는 CDN 엣지에서 동적 렌더링을 수행하거나, Streaming SSR로 초기 응답 속도를 개선할 수 있으며, 보안 요구사항에 따라 필요한 선택일 수 있다.
3. 캐시 전략 변경의 원리
정의
정적 렌더링된 페이지는 CDN이나 브라우저에 오래 캐시할 수 있지만, 동적 렌더링된 페이지는 요청마다 새로운 nonce를 포함해야 하므로 캐시 수명이 짧아지거나 캐시를 사용하지 않게 된다.
원리
Next.js는 정적 페이지에 대해 Cache-Control: public, max-age=31536000, immutable 같은 헤더를 설정해 장기 캐시를 활성화한다. 동적 페이지로 전환되면 Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate 형태로 변경되어 매 요청마다 서버에서 새로 생성한다.
Next.js 13 이후 App Router에서는 fetch 요청 단위로 캐시를 제어할 수 있지만, nonce를 사용하는 페이지 자체는 요청마다 달라지므로 전체 페이지 캐시는 비활성화된다.
예시
정적 페이지의 응답 헤더:
Cache-Control: public, max-age=31536000, immutable
nonce를 사용하는 동적 페이지의 응답 헤더:
Cache-Control: private, no-cache, no-store, must-revalidate
Content-Security-Policy: script-src 'self' 'nonce-xyz789'
x-nonce: xyz789
Data Cache는 여전히 활성화할 수 있다.
export default async function Page() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }
});
const nonce = headers().get('x-nonce');
return <div nonce={nonce}>{data}</div>;
}
이 경우 페이지 자체는 동적으로 렌더링되지만, API 응답은 1시간 동안 캐시된다.
오해
동적 렌더링으로 전환되면 모든 데이터 요청도 캐시할 수 없다고 생각하는 경우가 있다. Next.js App Router의 Data Cache와 Full Route Cache는 별도로 작동하므로, 페이지가 동적이어도 개별 fetch 요청은 revalidate 옵션으로 캐시할 수 있다.
4. 부분 사전 렌더링(PPR)과의 관계
정의
부분 사전 렌더링(Partial Prerendering, PPR)은 Next.js 14에서 도입된 기능으로, 페이지의 정적 부분은 빌드 시점에 생성하고 동적 부분만 요청 시점에 렌더링하는 방식이다.
원리
PPR을 사용하면 nonce가 필요한 부분을 Suspense 경계로 감싸서 동적으로 처리하고, 나머지 콘텐츠는 정적 셸로 제공할 수 있다. 이렇게 하면 초기 HTML은 즉시 전송되고, nonce가 포함된 부분만 스트리밍으로 추가된다.
예시
import { Suspense } from 'react';
import { headers } from 'next/headers';
function DynamicScript() {
const nonce = headers().get('x-nonce');
return <script nonce={nonce} src="/app.js" />;
}
export default function Page() {
return (
<div>
<h1>정적 헤더</h1>
<Suspense fallback={<div>로딩 중...</div>}>
<DynamicScript />
</Suspense>
<footer>정적 푸터</footer>
</div>
);
}
next.config.js에서 PPR을 활성화한다.
module.exports = {
experimental: {
ppr: true,
},
};
오해
PPR을 사용하면 nonce를 적용해도 완전히 정적 렌더링을 유지할 수 있다고 생각할 수 있다. 실제로는 nonce가 필요한 부분은 여전히 동적으로 렌더링되며, PPR은 정적 부분과 동적 부분을 효율적으로 조합하는 방식일 뿐이다.
핵심 원리 정리
CSP nonce는 요청마다 고유해야 하므로 빌드 시점에 값을 확정할 수 없다. Next.js는 headers() 같은 동적 API 호출을 감지하면 해당 라우트를 자동으로 동적 렌더링으로 전환한다. 동적 렌더링된 페이지는 요청마다 새로운 HTML을 생성하므로 Full Route Cache를 사용할 수 없으며, 응답 헤더의 Cache-Control 값도 캐시를 제한하는 방향으로 변경된다.
이러한 변경은 Next.js의 자동 최적화 메커니즘에 의해 발생하며, 개발자가 명시적으로 설정을 변경하지 않아도 빌드 시스템이 코드를 분석해 적절한 렌더링 전략을 선택한다. Data Cache는 별도로 작동하므로 개별 fetch 요청은 여전히 캐시할 수 있으며, PPR을 사용하면 정적 부분과 동적 부분을 분리해 성능을 개선할 수 있다.
흔한 오해
nonce를 환경 변수로 설정하면 정적 렌더링을 유지할 수 있다
환경 변수는 빌드 시점이나 서버 시작 시점에 한 번 설정되므로, 모든 요청에 동일한 nonce가 사용되어 보안 효과가 사라진다. nonce는 반드시 요청마다 새로 생성해야 한다.
동적 렌더링으로 전환되면 빌드 시간이 단축된다
동적 렌더링은 빌드 시 HTML을 생성하지 않으므로 빌드 시간에는 영향을 주지 않는다. 대신 런타임에 각 요청을 처리하는 시간이 추가되므로 서버 부하가 증가할 수 있다.
CSP nonce를 사용하면 외부 스크립트를 로드할 수 없다
CSP 정책에서 'strict-dynamic'을 함께 사용하면 nonce가 설정된 스크립트가 동적으로 로드하는 하위 스크립트도 신뢰할 수 있다. 또한 script-src 지시어에 특정 도메인을 명시적으로 추가할 수도 있다.
모든 페이지에 nonce를 적용하면 전체 사이트가 동적 렌더링된다
Next.js는 라우트 단위로 렌더링 전략을 결정한다. nonce가 필요한 레이아웃이나 페이지만 동적으로 전환되며, nonce를 사용하지 않는 다른 라우트는 여전히 정적으로 생성될 수 있다.
이해 확인 요약
Next.js에서 CSP nonce를 적용하면 요청마다 고유한 값을 생성해야 하므로 정적 렌더링이 불가능해진다. headers() 같은 동적 API를 호출하면 Next.js는 자동으로 해당 라우트를 동적 렌더링으로 전환하고, Full Route Cache를 비활성화하며, 응답 헤더의 Cache-Control 값을 변경한다.
동적 렌더링으로 전환되어도 Data Cache는 별도로 작동하므로 개별 fetch 요청은 revalidate 옵션으로 캐시할 수 있다. PPR을 사용하면 정적 부분과 동적 부분을 분리해 초기 응답 속도를 개선할 수 있다.
nonce는 반드시 요청마다 새로 생성해야 보안 효과가 있으며, 환경 변수나 빌드 시점 값을 재사용하면 안 된다. Next.js의 자동 최적화 메커니즘은 코드를 분석해 적절한 렌더링 전략을 선택하므로, nonce를 사용하는 라우트만 동적으로 전환되고 다른 라우트는 영향을 받지 않는다.
참고 자료
- Next.js Docs - Content Security Policy https://nextjs.org/docs/app/guides/content-security-policy
- MDN - Content Security Policy https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP
