콘텐츠 보안 정책(Content Security Policy, CSP)은 교차 사이트 스크립팅(XSS), 클릭재킹, 그리고 기타 코드 삽입 공격과 같은 다양한 보안 위협으로부터 여러분의 Next.js 애플리케이션을 안전하게 지키기 위해 정말 중요한 역할을 합니다.
개발자들은 CSP를 사용해서 콘텐츠 출처, 스크립트, 스타일시트, 이미지, 폰트, 객체, 미디어(오디오, 비디오), iframe 등에 대해 어떤 출처(Origin)만 허용할 것인지 명시적으로 지정할 수 있어요.
예제 코드 확인하기💡 강사의 보충 설명 & 팁
"교차 사이트 스크립팅(XSS)"이 뭔지 궁금하시죠? 악의적인 해커가 우리 웹사이트 게시판 같은 곳에 몰래 자바스크립트 코드를 심어두고, 다른 사용자가 그 게시글을 읽을 때 해커의 코드가 실행되게 해서 로그인 토큰 같은 개인정보를 빼가는 무서운 해킹 기법이에요. CSP를 잘 설정해두면, 해커가 몰래 심어둔 출처 불명의 스크립트는 브라우저가 아예 실행을 차단해버리기 때문에 이런 공격을 원천적으로 막을 수 있답니다! 실무에 가면 보안팀에서 가장 먼저 요구하는 설정 중 하나예요.
논스(nonce)는 딱 한 번만 사용하기 위해 만들어진 고유하고 무작위의 문자열입니다. 엄격한 CSP 지시어를 우회하여 우리가 허용한 특정 인라인 스크립트나 스타일만 선택적으로 실행할 수 있도록 CSP와 함께 짝을 이루어 사용된답니다.
💡 강사의 보충 설명 & 팁
Nonce는 'Number used ONCE(한 번만 쓰이는 숫자)'의 줄임말이에요. 여러분 은행에서 송금할 때 쓰는 OTP(일회용 비밀번호) 아시죠? 그거랑 똑같은 개념이라고 생각하시면 됩니다. 매번 요청이 올 때마다 새로운 비밀번호(논스)를 만들어서 우리가 작성한 스크립트에 달아두고, 브라우저에게 "이 비밀번호가 적힌 스크립트만 실행해!"라고 알려주는 거예요.
CSP는 악의적인 공격을 막기 위해 인라인 스크립트(HTML 파일 안에 직접 작성한 스크립트)와 외부 스크립트 모두를 차단할 수 있습니다. 하지만 때로는 우리가 만든 스크립트는 실행시켜야 하잖아요? 이럴 때 논스를 사용하면, 정확히 일치하는 논스 값을 포함한 특정 스크립트만 안전하게 실행되도록 허용할 수 있어요.
만약 해커가 우리 페이지에 악성 스크립트를 로드하고 싶다면, 그들은 이번 요청에서 생성된 논스 값을 정확히 추측해야만 합니다. 그렇기 때문에 논스는 매 요청마다 절대 예측할 수 없고 고유해야 하는 거죠.
Proxy 파일을 활용하면 페이지가 렌더링되기 전에 헤더를 추가하고 논스를 생성할 수 있습니다.
페이지를 조회할 때마다 항상 새로운 논스가 생성되어야 합니다. 이 말은 즉, 논스를 추가하려면 반드시 동적 렌더링(dynamic rendering)을 사용해야 한다는 뜻입니다.
예시를 한번 볼까요:
알아두면 좋은 정보: 개발(Development) 환경에서는
'unsafe-eval'을 허용해 주어야 합니다. 왜냐하면 React가 브라우저에서 서버 측 오류 스택을 재구성하는 등 향상된 디버깅 정보를 제공하기 위해 내부적으로eval함수를 사용하기 때문이죠. 프로덕션(Production, 실제 서비스) 환경에서는unsafe-eval이 필요하지 않습니다. React나 Next.js 모두 기본적으로 프로덕션에서는eval을 사용하지 않으니까 안심하세요!
import { NextRequest, NextResponse } from 'next/server'
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const isDev = process.env.NODE_ENV === 'development'
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
// Replace newline characters and spaces
const contentSecurityPolicyHeaderValue = cspHeader
.replace(/\s{2,}/g, ' ')
.trim()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set(
'Content-Security-Policy',
contentSecurityPolicyHeaderValue
)
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
})
response.headers.set(
'Content-Security-Policy',
contentSecurityPolicyHeaderValue
)
return response
}
import { NextResponse } from 'next/server'
export function proxy(request) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const isDev = process.env.NODE_ENV === 'development'
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
// Replace newline characters and spaces
const contentSecurityPolicyHeaderValue = cspHeader
.replace(/\s{2,}/g, ' ')
.trim()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set(
'Content-Security-Policy',
contentSecurityPolicyHeaderValue
)
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
})
response.headers.set(
'Content-Security-Policy',
contentSecurityPolicyHeaderValue
)
return response
}
💡 강사의 보충 설명 & 팁
위 코드에서crypto.randomUUID()를 사용해서 논스 값을 만들고 있죠? 이건 웹 표준 암호화 API라서 아주 강력하고 무작위성이 뛰어납니다. 그리고 코드를 보시면 요청(request) 헤더와 응답(response) 헤더 양쪽 모두에 CSP 헤더를 세팅해주고 있는데, 이렇게 해야 Next.js가 렌더링하는 동안에도 값을 읽을 수 있고 최종적으로 클라이언트 브라우저에도 정책이 잘 전달됩니다.
기본적으로 Proxy는 모든 요청에 대해 실행됩니다. 하지만 matcher를 설정하면 특정 경로에서만 Proxy가 실행되도록 필터링할 수 있어요.
CSP 헤더가 굳이 필요 없는 정적 에셋(이미지, 폰트 등)이나 next/link에서 발생하는 프리패치(prefetch) 요청은 무시하도록 설정하는 것을 추천합니다.
export const config = {
matcher: [
/*
* 다음으로 시작하는 경로를 제외한 모든 요청 경로와 일치시킵니다:
* - api (API 라우트)
* - _next/static (정적 파일)
* - _next/image (이미지 최적화 파일)
* - favicon.ico (파비콘 파일)
*/
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}
export const config = {
matcher: [
/*
* 다음으로 시작하는 경로를 제외한 모든 요청 경로와 일치시킵니다:
* - api (API 라우트)
* - _next/static (정적 파일)
* - _next/image (이미지 최적화 파일)
* - favicon.ico (파비콘 파일)
*/
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}
💡 강사의 보충 설명 & 팁
Proxy(혹은 Middleware)는 사이트 트래픽이 많아질수록 성능에 직접적인 영향을 줍니다. 정적인 이미지나 CSS 파일 같은 것들까지 매번 검사해서 헤더를 달아줄 필요는 없겠죠? 그래서 저렇게 정규표현식을 써서 불필요한 연산을 줄여주는 최적화 작업은 실무에서 정말 칭찬받는 포인트입니다!
논스를 사용하려면, 해당 페이지는 반드시 동적 렌더링(dynamically rendered) 되어야 합니다. 왜냐하면 Next.js는 요청에 포함된 CSP 헤더를 기반으로 서버 사이드 렌더링(SSR) 과정 중에 논스를 적용하기 때문이에요. 빌드 타임에 미리 만들어두는 정적 페이지(Static pages)는 요청이나 응답 헤더 자체가 존재하지 않기 때문에 논스를 주입할 수가 없어요.
동적으로 렌더링되는 페이지에서 논스 지원이 어떻게 이루어지는지 순서대로 살펴볼까요?
Content-Security-Policy 헤더에 추가한 다음 사용자 정의 헤더인 x-nonce 에도 설정합니다.Content-Security-Policy 헤더를 분석해서 'nonce-{value}' 패턴을 찾아내 논스 값을 빼냅니다.nonce 속성(prop)을 사용하는 모든 <Script> 컴포넌트이렇게 프레임워크가 알아서 처리해주기 때문에, 개발자인 여러분이 일일이 모든 태그를 찾아다니며 수동으로 논스를 추가할 필요가 없습니다. 아주 편리하죠!
논스를 사용 중이라면, 페이지가 강제로 동적 렌더링되도록 명시적인 설정을 해줘야 할 수도 있습니다:
import { connection } from 'next/server'
export default async function Page() {
// 이 페이지를 렌더링하기 위해 들어오는 요청이 있을 때까지 기다립니다.
await connection()
// 페이지 콘텐츠 작성
}
import { connection } from 'next/server'
export default async function Page() {
// 이 페이지를 렌더링하기 위해 들어오는 요청이 있을 때까지 기다립니다.
await connection()
// 페이지 콘텐츠 작성
}
💡 강사의 보충 설명 & 팁
await connection()함수는 비교적 최신 버전에 도입된 API입니다. 예전에는headers()나cookies()같은 동적 함수를 사용하면 알아서 동적 렌더링으로 전환되었지만, 명시적으로 "이 페이지는 무조건 동적 렌더링 할거야!" 라고 프레임워크에 알려주기 위해 사용하는 아주 직관적인 방법이랍니다.
서버 컴포넌트(Server Component) 내부에서 headers 함수를 사용하면 논스 값을 직접 읽어올 수 있어요:
import { headers } from 'next/headers'
import Script from 'next/script'
export default async function Page() {
const nonce = (await headers()).get('x-nonce')
return (
<Script
src="[https://www.googletagmanager.com/gtag/js](https://www.googletagmanager.com/gtag/js)"
strategy="afterInteractive"
nonce={nonce}
/>
)
}
import { headers } from 'next/headers'
import Script from 'next/script'
export default async function Page() {
const nonce = (await headers()).get('x-nonce')
return (
<Script
src="[https://www.googletagmanager.com/gtag/js](https://www.googletagmanager.com/gtag/js)"
strategy="afterInteractive"
nonce={nonce}
/>
)
}
논스를 사용한다는 것은 Next.js 애플리케이션의 렌더링 방식에 아주 중요한 영향을 미칩니다:
CSP에 논스를 사용하게 되면, 반드시 모든 페이지가 동적으로 렌더링되어야 합니다. 이 말은 즉:
정적 렌더링에서 동적 렌더링으로의 전환은 성능에 다음과 같은 영향을 줍니다:
💡 강사의 보충 설명 & 팁
이 부분이 정말 중요합니다! 보안을 챙기려다가 웹사이트의 속도가 느려지고 서버 비용이 폭탄이 될 수도 있다는 이야기죠. 블로그나 회사 소개 페이지처럼 내용이 잘 안 바뀌는 사이트라면 굳이 Nonce를 쓰기보다는 밑에서 배울 다른 방식(Static CSP나 SRI)을 고려하는 게 현명한 개발자의 판단입니다.
다음과 같은 경우에 논스 사용을 고려해 보세요:
'unsafe-inline'(인라인 스크립트 허용)을 절대 금지하는 아주 엄격한 보안 요구사항이 있을 때.논스가 굳이 필요 없는 애플리케이션이라면, next.config.js 파일에 직접 CSP 헤더를 정적으로 설정할 수 있습니다. 이렇게 하면 정적 렌더링의 이점을 그대로 살릴 수 있죠:
const isDev = process.env.NODE_ENV === 'development'
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''};
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: cspHeader.replace(/\n/g, ''),
},
],
},
]
},
}
논스를 사용하는 방식의 대안으로, Next.js는 하위 자원 무결성(SRI)을 활용한 해시 기반 CSP에 대해 실험적인 지원을 제공하고 있습니다. 이 접근 방식을 사용하면, 엄격한 CSP 보안을 유지하면서도 정적 생성(Static Generation)의 이점을 포기하지 않을 수 있답니다!
알아두면 좋은 정보: 이 기능은 아직 실험적(Experimental) 단계이며, App Router 구조에서 웹팩(webpack) 번들러를 사용할 때만 사용 가능합니다.
논스를 매번 생성하는 대신, SRI는 애플리케이션을 빌드하는 단계에서 여러분의 자바스크립트 파일들에 대한 암호화 해시(hash) 값을 생성합니다. 이 해시 값들은 스크립트 태그의 integrity 속성으로 추가되며, 이를 통해 브라우저는 네트워크를 타고 파일이 전달되는 과정에서 해당 파일이 변조되지 않았음을 완벽하게 검증할 수 있습니다.
💡 강사의 보충 설명 & 팁
예를 들어app.js파일 내용으로QWERTY...라는 암호를 미리 만들어두고 브라우저에게 "이 파일은 해독하면 무조건 QWERTY... 가 나와야 해!"라고 알려주는 거예요. 만약 해커가 중간에 파일 내용을 조금이라도 바꾸면 암호가 달라지겠죠? 그럼 브라우저가 "어? 암호가 다르네? 악성 코드 섞였구나!" 하고 실행을 거부합니다. 정적 파일을 유지하면서도 보안을 챙길 수 있는 아주 멋진 기술이죠.
next.config.js 파일에 실험적 기능인 SRI 설정을 추가해 주세요:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
sri: {
algorithm: 'sha256', // 'sha384' 또는 'sha512' 도 사용 가능합니다
},
},
}
module.exports = nextConfig
SRI를 활성화했다면, 기존에 쓰던 CSP 정책을 그대로 계속 사용할 수 있습니다. SRI는 기존 자산에 integrity 속성을 덧붙이는 식으로 독립적으로 알아서 잘 작동하거든요:
알아두면 좋은 정보: 동적 렌더링이 필요한 상황이라면, 필요에 따라 Proxy를 통해 논스를 생성하는 방식을 함께 써서, SRI 무결성 검증과 논스 기반 CSP 방식을 적절히 섞어 쓸 수도 있습니다.
const isDev = process.env.NODE_ENV === 'development'
const cspHeader = `
default-src 'self';
script-src 'self'${isDev ? " 'unsafe-eval'" : ''};
style-src 'self';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
module.exports = {
experimental: {
sri: {
algorithm: 'sha256',
},
},
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: cspHeader.replace(/\n/g, ''),
},
],
},
]
},
}
개발 환경과 실제 운영(프로덕션) 환경에서의 CSP 구현은 차이가 있습니다:
개발 모드에서는 'unsafe-eval' 정책을 반드시 허용해야 합니다. 왜냐하면 React가 여러분에게 브라우저 화면상에서 서버 측 오류가 어디서 시작되었는지 에러 스택을 예쁘게 그려주기 위해(Enhanced debugging information) 내부적으로 eval 기능을 활용하기 때문이죠:
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const isDev = process.env.NODE_ENV === 'development'
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ''};
style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`};
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
// 나머지 proxy 구현 로직
}
export function proxy(request) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const isDev = process.env.NODE_ENV === 'development'
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ''};
style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`};
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
// 나머지 proxy 구현 로직
}
실제 서비스에 배포했을 때 흔히 마주치는 문제점들이에요:
마케팅 툴이나 애널리틱스 같은 외부 스크립트와 CSP를 함께 쓸 때는 이렇게 해보세요:
import { GoogleTagManager } from '@next/third-parties/google'
import { headers } from 'next/headers'
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const nonce = (await headers()).get('x-nonce')
return (
<html lang="en">
<body>
{children}
{/* 구글 태그 매니저에 nonce 속성을 전달해줍니다 */}
<GoogleTagManager gtmId="GTM-XYZ" nonce={nonce} />
</body>
</html>
)
}
import { GoogleTagManager } from '@next/third-parties/google'
import { headers } from 'next/headers'
export default async function RootLayout({ children }) {
const nonce = (await headers()).get('x-nonce')
return (
<html lang="en">
<body>
{children}
<GoogleTagManager gtmId="GTM-XYZ" nonce={nonce} />
</body>
</html>
)
}
그리고 스크립트가 로드되는 외부 도메인 자체를 CSP에서 허용해 주도록 헤더를 업데이트해야 합니다:
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' [https://www.googletagmanager.com](https://www.googletagmanager.com);
connect-src 'self' [https://www.google-analytics.com](https://www.google-analytics.com);
img-src 'self' data: [https://www.google-analytics.com](https://www.google-analytics.com);
`
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' [https://www.googletagmanager.com](https://www.googletagmanager.com);
connect-src 'self' [https://www.google-analytics.com](https://www.google-analytics.com);
img-src 'self' data: [https://www.google-analytics.com](https://www.google-analytics.com);
`
💡 강사의 보충 설명 & 팁
실무에서 프론트엔드 개발자들이 가장 고통받는(?) 순간이 바로 마케팅팀에서 "이거 스크립트 좀 추가해 주세요!" 라고 할 때예요. GTM(구글 태그 매니저)이나 페이스북 픽셀 같은 걸 달면 CSP 정책에 계속 위반되어서 빨간 에러를 뿜어내거든요. 위 예시처럼<GoogleTagManager>컴포넌트에도 꼭nonce를 넘겨주고, 해당 외부 스크립트 도메인을 Proxy 코드의script-src나connect-src에 반드시 허용 목록으로 추가해줘야 무사히 실행된답니다!
style-src 정책 위반이 뜬다면, 논스를 지원하는 CSS-in-JS 라이브러리로 교체하거나, 아예 인라인 스타일을 외부 CSS 파일로 분리해 보세요.import() 문법을 사용 중이라면 script-src 정책에서 동적으로 가져오는 코드가 허용되어 있는지 확인하세요.'wasm-unsafe-eval' 정책을 추가해야 합니다.| 버전(Version) | 변경 사항(Changes) |
|---|---|
v14.0.0 | 해시 기반 CSP를 위한 실험적인 하위 자원 무결성(SRI) 지원 기능이 추가되었습니다. |
v13.4.20 | 올바른 논스 처리 및 CSP 헤더 파싱을 위해 권장되는 버전입니다. |
모든 문서에 대한 의미론적(semantic) 개요를 보고 싶으시다면, https://nextjs.org/docs/sitemap.md 를 참고해 주세요.
사용 가능한 전체 문서의 색인(Index)을 보시려면, https://nextjs.org/docs/llms.txt 를 참고해 주세요.
자, 여기까지 Next.js에서 Content Security Policy(CSP)를 설정하는 방법을 쭉 살펴보았습니다. 보안과 성능 사이의 트레이드오프(동적 렌더링 전환 여부)를 잘 고려해서, 여러분의 프로젝트에 맞는 최적의 보안 정책을 설계하시길 바랍니다. 수고하셨습니다! 😊