Next.js App Router RSC 유형별 Vitest 테스트 환경 분리 전략
저도 처음에는 기존 Jest + jsdom 방식으로 RSC를 테스트하려다 "왜 렌더링이 안 되지?" 하고 한참 헤맸던 기억이 납니다. 알고 보면 당연한 이유가 있었습니다. RSC는 브라우저가 아닌 서버에서만 실행되도록 설계되었기 때문에, jsdom이 흉내 내는 브라우저 환경과 근본적으로 맞지 않습니다.
이 글을 읽고 나면 Next.js App Router로 개발하는 분들이 RSC 유형별로 어떤 Vitest 환경을 선택해야 하는지 판단할 수 있습니다. 동기 RSC, 비동기 RSC, 그리고 실험적인 vitest-plugin-rsc까지 실제로 동작하는 코드와 함께 살펴봅니다. 핵심은 환경 분리입니다 — 서버 컴포넌트와 클라이언트 컴포넌트를 같은 환경으로 테스트하려는 시도 자체가 대부분 문제의 출발점입니다.
Next.js App Router를 이미 사용 중이고, RTL이나 Playwright로 테스트를 작성해본 경험이 있다면 이 글이 바로 연결됩니다. RSC 자체가 무엇인지는 다루지 않고, 테스트 환경 설정에 집중합니다.
핵심 개념
RSC가 기존 테스트 방식을 깨뜨리는 이유
RSC는 Node.js 서버 런타임에서만 실행됩니다. async/await로 DB를 직접 조회하거나 API를 호출할 수 있고, 번들에 포함되지 않아 클라이언트로 코드가 전송되지 않습니다. 이 설계 덕분에 성능 이점이 있지만, 테스트 관점에서는 골치가 아픕니다.
environment: 'jsdom'으로 RSC를 render()하려 하면 React가 서버 컴포넌트를 클라이언트 환경에서 실행하려 시도하면서 예상치 못한 오류가 연속으로 납니다. 해결책은 환경을 올바르게 분리하는 것이고, 컴포넌트 유형별로 전략이 달라집니다.
컴포넌트 유형별 전략 — 먼저 큰 그림부터
실전에서 가장 자주 맞닥뜨리는 실수는 이 표를 무시하고 모든 컴포넌트를 같은 환경으로 테스트하려는 것입니다.
| 컴포넌트 유형 | 권장 도구 | 실행 환경 |
|---|---|---|
| 동기 RSC (순수 UI) | Vitest + renderToStaticMarkup |
node |
| 비동기 RSC (data fetch) | Vitest + MSW + await 직접 호출 |
node / jsdom |
클라이언트 컴포넌트 ("use client") |
Vitest + RTL | jsdom 또는 브라우저 |
| 핵심 유저 플로우 | Playwright E2E | 실제 브라우저 |
"use client" 경계가 테스트에 미치는 영향
// ServerComponent.tsx — RSC (서버에서만 실행, "use client" 없음)
export default async function ServerComponent() {
const data = await db.query('SELECT * FROM posts')
return <PostList posts={data} />
}
// PostList.tsx — 클라이언트 컴포넌트
'use client'
export default function PostList({ posts }) {
const [selected, setSelected] = useState(null)
// ...
}"use client" 경계란 RSC 트리에서 서버와 클라이언트 실행의 경계선입니다. 이 경계를 기준으로 테스트 환경도 나누어 생각하는 것이 핵심입니다.
실전 적용
동기 RSC: renderToStaticMarkup으로 node 환경에서 검증
데이터 페칭 없이 props만 받아 렌더링하는 순수 서버 컴포넌트가 가장 간단한 케이스입니다. 여기서 한 가지 주의할 점이 있습니다. environment: 'node'를 설정하면 DOM이 없기 때문에 @testing-library/react의 render()를 그대로 쓸 수 없습니다. RTL은 내부적으로 react-dom을 사용하고, react-dom은 document 객체가 필요합니다. 저도 처음에 이걸 몰라서 render()를 그대로 썼다가 "document is not defined" 오류를 보고 당황했습니다.
대신 react-dom/server의 renderToStaticMarkup을 쓰면 DOM 없이 HTML 문자열로 검증할 수 있습니다.
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'node', // DOM 없는 순수 Node.js 환경
},
})// UserCard.tsx — 동기 RSC ('use client' 없음, 서버에서만 실행)
export default function UserCard({ name, role }: { name: string; role: string }) {
return (
<div>
<h2>{name}</h2>
<span>{role}</span>
</div>
)
}// UserCard.test.tsx
import { renderToStaticMarkup } from 'react-dom/server'
import UserCard from './UserCard'
test('사용자 이름과 역할을 렌더링한다', () => {
const html = renderToStaticMarkup(<UserCard name="Alice" role="Engineer" />)
expect(html).toContain('Alice')
expect(html).toContain('Engineer')
})| 포인트 | 설명 |
|---|---|
environment: 'node' |
jsdom 없는 순수 Node.js 환경 — DOM API 사용 불가 |
renderToStaticMarkup |
서버 렌더링으로 HTML 문자열 반환, DOM 불필요 |
| HTML 문자열 검증 | toContain()으로 텍스트 포함 여부 확인 |
파일별로 환경을 다르게 설정하고 싶다면 테스트 파일 상단에 // @vitest-environment node 주석을 추가하는 방식도 있습니다. 이렇게 하면 전역 설정은 jsdom으로 유지하면서 특정 파일만 node로 돌릴 수 있습니다.
비동기 RSC: 컴포넌트 함수를 직접 await + MSW
솔직히 처음 이 패턴을 봤을 때 "이게 맞나?" 싶었습니다. render(<PostList />) 대신 컴포넌트 함수를 직접 호출하고 결과를 렌더링하는 방식인데, Vitest가 비동기 RSC를 공식 지원하지 않아서 나온 현실적인 우회책입니다. MSW가 Node.js 환경의 fetch를 가로채주기 때문에 외부 API 없이 동작합니다.
// PostList.tsx (비동기 RSC)
export default async function PostList() {
const posts = await fetch('/api/posts').then(r => r.json())
return (
<ul>
{posts.length === 0 ? (
<li data-testid="empty">포스트가 없습니다</li>
) : (
posts.map((p: { id: number; title: string }) => (
<li key={p.id}>{p.title}</li>
))
)}
</ul>
)
}// PostList.test.tsx
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { render, screen } from '@testing-library/react'
import PostList from './PostList'
const server = setupServer(
http.get('/api/posts', () =>
HttpResponse.json([
{ id: 1, title: '첫 번째 포스트' },
{ id: 2, title: '두 번째 포스트' },
])
)
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('포스트 목록을 렌더링한다', async () => {
const jsx = await PostList() // render() 대신 직접 await
render(jsx)
expect(await screen.findByText('첫 번째 포스트')).toBeInTheDocument()
expect(await screen.findByText('두 번째 포스트')).toBeInTheDocument()
})
test('빈 목록일 때 안내 메시지를 보여준다', async () => {
server.use(
http.get('/api/posts', () => HttpResponse.json([]))
)
const jsx = await PostList()
render(jsx)
expect(screen.getByTestId('empty')).toBeInTheDocument()
})MSW(Mock Service Worker)란? 서비스 워커를 이용해 네트워크 요청을 가로채는 라이브러리입니다. v2부터 Node.js 환경에서도
fetch인터셉션을 안정적으로 지원하여 서버 컴포넌트 테스트에 활용할 수 있게 되었습니다.
| 포인트 | 설명 |
|---|---|
await PostList() |
컴포넌트 함수를 직접 실행해 JSX 결과를 얻음 |
msw/node |
Node.js 환경에서 fetch 인터셉션 |
server.resetHandlers() |
각 테스트마다 핸들러 초기화로 격리 보장 |
findByText |
비동기 쿼리로 렌더링 완료 후 확인 |
await PostList()로 얻은 JSX를 render()로 넘기는 순간 DOM이 필요하므로, 이 패턴에서는 environment: 'jsdom'(기본값)을 유지하는 것이 자연스럽습니다.
vitest-plugin-rsc: RSC 실행 모델을 재현하는 실험적 접근
Storybook 팀이 공개한 서드파티 플러그인으로, Vitest에서 RSC를 직접 단위 테스트할 수 있게 해줍니다. 내부적으로 react-server 환경과 react_client 환경을 분리 실행하여 RSC의 실제 실행 모델에 더 가깝게 재현합니다. 아직 실험적 단계여서 프로덕션 적용에는 신중하게 접근하는 것을 권장합니다.
// ServerComponent.tsx — 플러그인 테스트 대상
export default async function ServerComponent({ title }: { title: string }) {
return (
<section>
<h1>{title}</h1>
<p>서버에서 렌더링된 콘텐츠입니다.</p>
</section>
)
}// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { vitestPluginRSC } from 'vitest-plugin-rsc'
export default defineConfig({
plugins: [vitestPluginRSC()],
test: {
browser: {
enabled: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }],
},
},
})// ServerComponent.test.tsx
import { renderServer } from 'vitest-plugin-rsc/testing-library'
import { screen } from '@testing-library/react'
import ServerComponent from './ServerComponent'
test('서버 컴포넌트가 올바른 헤딩을 렌더링한다', async () => {
await renderServer(<ServerComponent title="Hello RSC" />)
expect(screen.getByRole('heading')).toHaveTextContent('Hello RSC')
})처음 CI에 이 플러그인을 붙였을 때 Playwright 프로세스 스핀업 때문에 빌드 시간이 눈에 띄게 늘어납니다. 중요한 케이스는 E2E로 보완하면서 병행하는 접근이 현실적입니다.
Playwright E2E: 핵심 플로우를 실제 환경으로 보호
비동기 RSC에서 데이터 페칭이 핵심 역할을 담당하거나, cookies(), headers() 같은 Next.js 서버 API가 얽힐 때는 E2E 테스트가 가장 현실적입니다. 실제 서버를 띄우고 브라우저로 접근하기 때문에 복잡한 모킹 없이도 동작을 검증할 수 있습니다.
// e2e/posts.spec.ts
import { test, expect } from '@playwright/test'
test('포스트 목록 페이지가 정상 렌더링된다', async ({ page }) => {
await page.goto('/posts')
await expect(page.getByRole('list')).toBeVisible()
await expect(page.getByRole('listitem').first()).toContainText(/포스트/)
})
test('로그인하지 않으면 로그인 페이지로 리다이렉트된다', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveURL('/login')
})장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 빠른 피드백 | Vitest는 Vite 네이티브로 Jest 대비 콜드 스타트가 확연히 빠름 |
| 격리된 테스트 | MSW 모킹으로 외부 API 의존 없이 서버 컴포넌트 로직 검증 가능 |
| 타입 안전성 | TypeScript strict mode와 완벽하게 통합됨 |
| 브라우저 모드 안정화 | Vitest v4에서 Browser Mode가 정식 안정화, CSS·레이아웃 포함 신뢰도 높은 테스트 가능 |
| 공식 가이드 존재 | Next.js 공식 문서가 Vitest 설정을 다루기 시작하면서 진입 장벽이 낮아짐 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 비동기 RSC 공식 미지원 | Vitest 자체는 비동기 RSC를 아직 공식 지원하지 않음 | 직접 await 패턴 또는 플러그인 사용 |
| 플러그인 실험적 상태 | vitest-plugin-rsc는 Storybook 팀의 서드파티 플러그인, 안정성 미보장 |
중요 케이스는 E2E로 보완 |
| browser mode 초기화 비용 | Playwright 프로세스 스핀업으로 처음 CI에 붙였을 때 빌드 시간이 2배 가까이 늘어서 당황했는데, 캐싱 전략이 필수 | CI 캐싱 전략 병행 |
| Next.js 서버 API 모킹 복잡성 | cookies(), headers(), redirect() 등을 별도 모킹해야 함 |
vi.mock()으로 모듈 단위 모킹 |
| URL 상태 테스트 한계 | browser mode에서 주소창 접근 불가 | URL 동기화 로직은 Playwright로 커버 |
| 설정 복잡도 | 환경별로 별도 vitest 설정 파일이 필요할 수 있음 | workspace 설정으로 분리 관리 |
실무에서 가장 흔한 실수
environment: 'node'에서 RTLrender()를 그대로 쓰는 것 — 순수 node 환경에는 DOM이 없어서react-dom이 동작하지 않습니다. "서버 컴포넌트니까 node 환경이 맞겠지" 싶어서 설정을 바꿨다가 "document is not defined" 오류를 보게 됩니다.renderToStaticMarkup을 쓰거나, jsdom 환경을 유지하면서await Component()패턴을 사용하는 것이 맞습니다.- 비동기 RSC에
render(<AsyncComponent />)를 그대로 사용하는 것 —async컴포넌트는 이 형태로는 제대로 동작하지 않습니다.await AsyncComponent()로 직접 호출하거나 플러그인을 사용하는 방식이 현실적입니다. - Next.js 서버 API를 모킹하지 않고 단위 테스트를 실행하는 것 —
cookies()나headers()를 컴포넌트 내에서 호출하면 테스트 환경에서 오류가 납니다. Next.js 공식 예제가 테스트 환경 설정을 생략하고 있어서 이 함정을 피하기가 어렵습니다.vi.mock('next/headers', () => ({ cookies: () => ({ get: vi.fn() }) }))형태의 모킹이 필요합니다.
마치며
RSC 테스트에서 가장 비직관적인 지점은 "환경을 분리한다"는 개념 자체가 아니라, node 환경에서 RTL을 그대로 쓸 수 없다는 사실입니다. 서버 컴포넌트니까 node 환경이 맞겠지 싶어서 설정을 바꾼 뒤 render()를 쓰면 DOM 오류가 납니다. 이 관문을 넘고 나면 나머지는 생각보다 자연스럽게 흘러갑니다.
지금 시작해볼 수 있는 3단계:
- 가장 단순한 동기 RSC 하나를 골라
renderToStaticMarkup으로 테스트를 작성해볼 수 있습니다.pnpm add -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom으로 설치하고,vitest.config.ts에서environment: 'node'를 설정하면 됩니다. - 비동기 RSC가 있다면
pnpm add -D msw로 설치하고,setupServer로 fetch를 가로챈 뒤await ComponentFunction()패턴을 적용해보시면 됩니다. kettanaito/nextjs-rsc-testing 예제가 시작점으로 유용합니다. - 로그인이나 결제처럼 가장 중요한 경로 하나를 Playwright E2E로 작성해두면, Vitest 단위 테스트와 함께 견고한 안전망이 완성됩니다.
cookies(),headers()모킹이 복잡하게 느껴진다면 E2E부터 시작하는 것도 좋은 선택입니다.
참고 자료
- Component testing RSCs | Storybook Blog
- storybookjs/vitest-plugin-rsc | GitHub
- Testing with Vitest | Next.js 공식 문서
- kettanaito/nextjs-rsc-testing | GitHub
- Running Tests with RTL and Vitest on Async RSCs | Aurora Scharff
- Browser Mode | Vitest 공식 문서
- Component Testing | Vitest 공식 문서
- Testing Async RSCs with Vitest | pronextjs.dev
- nickserv/rsc-testing | GitHub
- Vitest Introduces Browser Mode as Alternative to JSDOM | InfoQ
- Storybook + Vitest + Next.js RSC 컴포넌트 테스트 | Mamezou Developer Portal