Archive.sakamoto
ArchitectureConfig / Build

서버 모듈이 클라이언트 번들에 섞이는 이유와 해결

클라이언트 번들에 서버 전용 모듈이 섞이는 원인과 파일 분리로 해결하는 방법을 알아보자!

Sakamoto·

1. 핵심 개념

Next.js(Page Router)에서 한 파일에 클라이언트용 fetch 함수와 서버용 API Route 로직을 같이 선언하면,
클라이언트 번들링 과정에서 서버 전용 의존성(argon2 → fs) 이 함께 묶이면서 런타임 에러가 발생할 수 있다.

❗ 핵심 문제는 “코드 실행”이 아니라 “import 그래프” 다.

2. 왜 이렇게 설계하는가

번들러는 코드 실행 여부가 아니라 import 그래프 기준으로 파일을 묶는다.
즉, 실제로 argon2를 "사용"하지 않더라도 같은 파일에 import 경로가 연결되어 있으면 클라이언트 번들에 포함된다.

이 문제를 막으려면 "실행 경계"가 아니라 "파일 경계" 로 서버/클라이언트를 분리해야 한다.
서버 전용 모듈은 서버에서만 import되는 파일 안에만 존재해야 한다.

💡 면접 tip

"클라이언트 번들 오염이 왜 발생하나요?" 질문엔 "코드 실행 여부가 아니라 번들러의 import 그래프 분석 때문"이라고 답하면 된다. 파일 단위로 서버/클라이언트를 분리하지 않으면 실제로 사용하지 않는 서버 모듈도 번들에 포함될 수 있다.


2. 문제 상황 (실제 코드 구조)

❌ 문제의 구조 (한 파일에 섞여 있음)

// src/api/user/index.ts
 
import { auth_options } from '../auth/signin'; // ← argon2 사용
import { getServerSession } from 'next-auth';
 
export async function fetchUserList() {
  const res = await fetch('https://jsonplaceholder.typicode.com/users');
  return res.json();
}
 
export async function handler(req, res) {
  const session = await getServerSession(req, res, auth_options);
  ...
}

겉보기엔

  • fetchUserList는 외부 API만 호출
  • argon2랑 “직접적으로” 관계 없어 보임

👉 하지만 실제로는 같은 파일에 존재하기 때문에 문제가 됨


3. 왜 에러가 발생했나? (실행 흐름 기준)

🔥 실제로 벌어진 일

1. 클라이언트 컴포넌트에서 fetchUserList import
2. 번들러가 fetchUserList가 있는 "파일 전체"를 분석
3. 같은 파일에 handler 존재
4. handler → auth_options import
5. auth_options → argon2 import
6. argon2 → node-gyp-build → fs
7. 클라이언트 번들에 fs 포함 시도
8. 브라우저에는 fs 없음 → 💥 에러

에러 메시지의 의미

Module not found: Can't resolve 'fs'

👉 fs는 Node.js 전용 모듈

👉 브라우저 환경에는 존재하지 않음


4. 문제의 본질 (중요)

  • ❌ fetch 함수가 argon2를 “사용해서” 문제가 된 게 아님
  • ❌ Suspense / React Query 때문도 아님
  • 클라이언트에서 import한 파일에 서버 전용 코드가 “같이 들어 있었기 때문”

파일 단위 분리가 안 되어 있으면, 의도와 상관없이 서버 코드가 클라이언트 번들로 끌려온다.


5. 해결 방향 1: client / server 분리 (실습 기준)

✅ 실습 상황

  • 인증 중요 ❌
  • 외부 공개 API 사용
  • Suspense / Query 동작 확인 목적

👉 client/server 두 개만 분리하는 게 가장 단순하고 적절


📁 권장 디렉토리 (실습용)

src/api/user/
├── client.ts   // 외부 API 직접 fetch
└── server.ts   // API Route handler (auth, argon2)
pages/api/
└── user.ts     // server handler 호출 및 내보내기

client.ts (클라이언트에서 import 가능)

// src/api/user/client.ts
import type { User } from './types';
 
export async function fetchUserListClient(): Promise<User[]> {
  const res = await fetch('https://jsonplaceholder.typicode.com/users');
  if (!res.ok) throw new Error('FETCH_FAILED');
  return res.json();
}

✔ 브라우저 / SSR 모두 안전

✔ fs, argon2 전혀 연결 안 됨


server.ts (서버 전용)

// src/api/user/server.ts
import { auth_options } from '../auth/signin';
import { getServerSession } from 'next-auth';
import { fetchUserListClient } from './client';
 
export async function userHandler(req, res) {
  const session = await getServerSession(req, res, auth_options);
  if (!session) return res.status(401).json({ ok: false });
 
  const users = await fetchUserListClient();
  return res.status(200).json({ ok: true, list: users });
}

6. 해결 방향 2: 실무에서는 어떻게 해야 하나?

❗ 실무에서는 “클라에서 외부 API 직통 호출”을 거의 안 함

이유:

  • 인증/권한 통제 필요
  • API Key 노출 위험
  • CORS 이슈
  • 응답 스키마 통일 필요

7. 실무 기준 정석 구조

📁 실무 권장 디렉토리 구조

src/
├── services/
│   └── user/
│       ├── client.ts   // 브라우저 → /api/user 호출
│       └── server.ts   // DB / 외부 API / 비밀키
└── pages/
    └── api/
        └── user.ts     // 인증 + server 서비스 호출

client.ts (브라우저 전용)

// src/services/user/client.ts
export async function fetchUserList() {
  const res = await fetch('/api/user');
  if (!res.ok) throw new Error('FETCH_FAILED');
  const data = await res.json();
  return data.list;
}

server.ts (서버 전용, argon2 OK)

// src/services/user/server.ts
export async function fetchUserListFromUpstream() {
  return fetch('https://external-api.com/users').then(r => r.json());
}

API Route (경계선 역할)

// pages/api/user.ts
import { fetchUserListFromUpstream } from '@/services/user/server';
 
export default async function handler(req, res) {
  // 인증, 권한 체크
  const users = await fetchUserListFromUpstream();
  return res.status(200).json({ ok: true, list: users });
}

👉 클라이언트는 오직 **/api**만 호출

👉 서버 전용 모듈은 API Route 안에서만 실행


8. 실무에서 반드시 지켜야 할 규칙

  1. 한 파일에 client 코드 + server 코드 섞지 말 것

  2. 클라이언트에서 import 가능한 파일에는

    • auth_options

    • getServerSession

    • argon2

    • fs

      절대 포함 ❌

  3. 배럴 export(index.ts) 남용 ❌

  4. API Route를 클라/서버 경계선으로 사용

🔧 실무 point

index.ts로 배럴 export 할 때 서버 코드와 클라이언트 코드를 함께 re-export하면 똑같이 번들 오염이 생긴다. client.ts / server.ts로 파일 자체를 분리하고 배럴 파일은 용도별로 따로 두는 것이 실무 기준이다.


9. 요약 (한 줄)

이번 에러의 원인은 “argon2 자체”가 아니라,
클라이언트에서 import한 파일에 서버 코드가 함께 들어 있었던 구조 문제였다.
해결의 핵심은 client/server 파일 분리와 import 경계 설정이다.