목차
- 문제 시나리오: 에러 응답이 유실되는 순간
- 왜 커스텀 에러 필드가 사라지는가
- Next.js 공식 권장: Expected Error vs Uncaught Exception
- 해결 방법: Result Pattern
- 클라이언트 httpClient(axios) 중앙화
- 전체 흐름 정리
- 요약
1. 문제 시나리오: 에러 응답이 유실되는 순간
흔히 마주치는 상황을 먼저 살펴보겠습니다.
백엔드 API 서버는 인증이 만료됐을 때 다음과 같은 응답을 내려줍니다.
HTTP 401
{
"message": "인증이 만료되었습니다",
"errorCode": "AUTH_EXPIRED"
}
Next.js Server Action에서 이 응답을 받고 클라이언트에 에러를 전달하기 위해 throw를 사용합니다.
// Server Action (서버)
export async function fetchUserAction() {
const res = await fetch('/api/user');
if (!res.ok) {
throw new HttpError(401, '인증이 만료되었습니다', 'AUTH_EXPIRED');
}
return res.json();
}
클라이언트에서 이 에러를 catch해 처리하려 합니다.
// 클라이언트
try {
await fetchUserAction();
} catch (error) {
console.log(error.status); // ❌ undefined
console.log(error.errorCode); // ❌ undefined
console.log(error.message); // ⚠️ 개발 환경에서만 원본 유지, 프로덕션에서는 제네릭 메시지
}
서버에서 분명히 담아 보낸 status, errorCode 정보가 클라이언트에 도착했을 때는 흔적도 없이 사라져 있습니다. 게다가 프로덕션 빌드에서는 message조차 원본이 전달되지 않습니다.
이 현상의 원인과 올바른 해결 방법을 단계적으로 살펴보겠습니다.
2. 왜 커스텀 에러 필드가 사라지는가
React Flight Protocol의 에러 직렬화
Server Action의 결과는 React Flight Protocol (RSC 프로토콜) 을 통해 서버에서 클라이언트로 전달됩니다. 이 프로토콜은 에러를 직렬화할 때 다음과 같이 동작합니다.
- 프로덕션 환경: 보안을 위해
Error.message조차 클라이언트에 전달하지 않습니다. 대신"An error occurred in the Server Components render. The specific message is omitted in production builds..."같은 일반적인 메시지로 대체됩니다. - 개발 환경:
Error.message,Error.name,Error.stack만 전달됩니다. - 어떤 환경에서도:
status,errorCode,data등 커스텀 프로퍼티는 모두 소실됩니다.
이는 의도된 동작입니다. 서버의 내부 에러 정보(스택 트레이스, DB 에러 메시지 등)가 클라이언트에 노출되면 보안 취약점이 될 수 있기 때문입니다.
Next.js 공식 문서: error.js Reference
Next.js error.js API Reference에 따르면, Error Boundary에 전달되는 error 객체는 다음과 같이 동작합니다.
error.message
- Client Component에서 발생한 에러: 원본
Error.message가 그대로 전달됩니다.- Server Component에서 발생한 에러: 식별자가 포함된 제네릭 메시지로 대체됩니다. 민감한 정보 노출을 방지하기 위한 의도적 설계입니다.
error.digest에러의 자동 생성 해시값입니다. 서버 사이드 로그에서 동일한 에러를 매칭하는 데 사용할 수 있습니다.
즉, 프로덕션 환경에서 서버 발생 에러는 message조차 원본이 전달되지 않으며, digest를 통해 서버 로그에서만 원인을 추적할 수 있습니다. 개발 환경에서는 디버깅 편의를 위해 원본 메시지가 직렬화되어 전달되지만, 프로덕션과는 다른 동작이라는 점에 유의해야 합니다.
// error.tsx — Error Boundary가 받는 props 타입
export default function Error({
error,
}: {
error: Error & { digest?: string };
}) {
// ❌ error.status → 존재하지 않음 (커스텀 프로퍼티 소실)
// ❌ error.errorCode → 존재하지 않음
// ⚠️ error.message → 프로덕션에서 제네릭 메시지로 대체됨
// ✅ error.digest → 서버 로그 매칭용 해시
}
환경별 전달되는 프로퍼티 요약
| 프로퍼티 | 개발 모드 | 프로덕션 |
|---|---|---|
message |
원본 유지 | 제네릭 메시지로 교체 |
name |
원본 유지 | "Error" |
stack |
복원 | 제네릭 |
digest |
있음 | 있음 |
status (커스텀) |
소실 | 소실 |
errorCode (커스텀) |
소실 | 소실 |
결론: Server Action에서 throw한 에러로는 클라이언트가 status, errorCode 등 비즈니스 로직에 필요한 정보를 얻을 수 없습니다. 이것이 Next.js가 Expected Error에 대해 return value 패턴을 권장하는 이유입니다.
3. Next.js 공식 권장: Expected Error vs Uncaught Exception
Next.js 공식 문서에서는 에러를 두 종류로 구분합니다.
Expected Error (예상되는 에러)
API 응답 실패, 유효성 검증 오류 등 정상 운영 중 발생할 수 있는 에러를 의미합니다.
권장 패턴은 return value로 처리하는 것입니다.
'use server';
export async function createPost(formData: FormData) {
const res = await fetch('https://api.example.com/posts', {
method: 'POST',
body: formData,
});
if (!res.ok) {
// ✅ throw 대신 return
return { error: { message: 'Failed to create post', status: res.status } };
}
return { data: await res.json() };
}
throw대신 반환값으로 에러 정보를 전달합니다.- 반환값은 Flight Protocol이 정상적으로 직렬화하므로 커스텀 필드가 소실되지 않습니다.
Uncaught Exception (예상치 못한 에러)
프로그래밍 버그, 인프라 장애 등 비정상적인 상황을 의미합니다.
throw된 에러는error.tsx(Error Boundary)에서 잡히게 됩니다.- 사용자에게는 "문제가 발생했습니다" 같은 일반 메시지를 표시합니다.
- 상세 에러 정보는 서버 로그에서만 확인할 수 있습니다.
구분 요약
| 구분 | Expected Error | Uncaught Exception |
|---|---|---|
| 발생 원인 | 인증 실패, 유효성 오류, API 에러 | 버그, 인프라 장애 |
| 전달 방식 | return value | throw → Error Boundary |
| 커스텀 정보 | 완전히 보존 | 소실 (보안상 의도적) |
| 클라이언트 처리 | 상태 기반 조건부 UI | error.tsx fallback UI |
4. 해결 방법: Result Pattern
Next.js의 공식 권장에 따라, 예상 가능한 에러를 throw하지 않고 반환값으로 전달하는 Result Pattern을 구성합니다.
핵심 타입 정의
// shared/api/httpError.ts
export interface IHttpError {
status: number;
message: string;
errorCode?: string;
}
/** Server Action 반환 타입 */
export type ActionResult<T> =
| { data: T; error?: never }
| { data?: never; error: IHttpError };
/** 클라이언트에서 사용하는 HTTP 에러 클래스 */
export class HttpError extends Error implements IHttpError {
constructor(
public readonly status: number,
message: string,
public readonly errorCode?: string,
) {
super(message);
this.name = 'HttpError';
}
}
unwrapAction — Server Action을 감싸는 고차함수
Server Action은 에러를 값(plain object) 으로 반환하기 때문에, 클라이언트에서 이를 다시 에러 객체로 복원하는 단계가 필요합니다. unwrapAction이 이 역할을 담당합니다.
unwrapAction은 Server Action을 인자로 받아, ActionResult를 자동으로 unwrap하는 새 함수를 반환하는 고차함수입니다. 내부적으로 에러가 있으면 HttpError로 throw하고, 없으면 data를 반환합니다.
function unwrapResult<T>(result: ActionResult<T>): T {
if (result.error) {
throw new HttpError(result.error.status, result.error.message, result.error.errorCode);
}
return result.data;
}
export function unwrapAction<TArgs extends unknown[], T>(
action: (...args: TArgs) => Promise<ActionResult<T>>,
): (...args: TArgs) => Promise<T> {
return (...args) => action(...args).then(unwrapResult);
}
핵심은 서버-클라이언트 경계에서는 안전한 값(plain object)으로 전달하되, 클라이언트 진입 직후 고차함수가 에러 객체로 복원하는 것입니다. 클라이언트 사이드의 throw이므로 Flight Protocol 직렬화 제한의 영향을 받지 않습니다.
흐름 요약
Server Action
→ ActionResult<T>: { data } | { error: IHttpError }
→ 에러 정보가 Flight Protocol을 값(plain object)으로 통과 → 100% 보존
unwrapAction (클라이언트)
→ ActionResult → data 반환 또는 HttpError throw
→ 클라이언트 사이드 throw이므로 커스텀 필드 완전 보존
5. 클라이언트 httpClient(axios) 중앙화
클라이언트에서 모든 네트워크 요청을 중앙화하면, 인증 만료 처리나 공통 에러 핸들링 같은 정책을 한 곳에서 일관되게 관리할 수 있습니다. 여기서는 axios를 사용해 httpClient를 구성하는 예시를 살펴봅니다.
axios 인터셉터 기반 httpClient
// shared/api/httpClient.ts
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { HttpError } from './httpError';
const instance: AxiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
withCredentials: true,
});
// 요청 인터셉터: 공통 헤더 설정
instance.interceptors.request.use((config) => {
const token = getAccessToken(); // 토큰 조회 로직
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 응답 인터셉터: 에러를 HttpError로 변환
instance.interceptors.response.use(
(response: AxiosResponse) => response,
(error) => {
const status = error.response?.status ?? 500;
const message = error.response?.data?.message ?? 'Unknown error';
const errorCode = error.response?.data?.errorCode;
// ✅ axios 에러를 HttpError로 변환하여 throw
return Promise.reject(new HttpError(status, message, errorCode));
},
);
export const httpClient = {
get: <T>(url: string, params?: Record<string, unknown>) =>
instance.get<T>(url, { params }).then((res) => res.data),
post: <T>(url: string, data?: unknown) =>
instance.post<T>(url, data).then((res) => res.data),
put: <T>(url: string, data?: unknown) =>
instance.put<T>(url, data).then((res) => res.data),
delete: <T>(url: string) =>
instance.delete<T>(url).then((res) => res.data),
};
이제 클라이언트에서 발생하는 모든 HTTP 에러는 HttpError 인스턴스로 통일됩니다.
클라이언트에서 직접 호출하는 경우
Server Action을 거치지 않고 클라이언트에서 직접 API를 호출할 때도 동일한 HttpError가 throw되므로, 에러 처리 방식이 일관됩니다.
// 클라이언트 컴포넌트에서 직접 사용
try {
const user = await httpClient.get<User>('/user');
} catch (error) {
if (error instanceof HttpError) {
console.log(error.status); // ✅ 401
console.log(error.errorCode); // ✅ "AUTH_EXPIRED"
}
}
Server Action과 함께 사용하는 경우
Server Action 내부에서 httpClient를 사용하되, 에러를 throw하지 않고 ActionResult로 반환합니다.
// Server Action
'use server';
import { httpClient } from '@/shared/api/httpClient';
import type { ActionResult } from '@/shared/api/httpError';
export async function getUserAction(): Promise<ActionResult<User>> {
try {
const data = await httpClient.get<User>('/user');
return { data };
} catch (error) {
if (error instanceof HttpError) {
// ✅ throw 대신 return — 에러 정보를 값으로 반환
return { error: { status: error.status, message: error.message, errorCode: error.errorCode } };
}
// 예상치 못한 에러는 throw → Error Boundary에서 처리
throw error;
}
}
클라이언트에서는 unwrapAction으로 감싸서 사용합니다.
// 클라이언트
const getUser = unwrapAction(getUserAction);
// TanStack Query와 함께 사용
const { data, error } = useQuery({
queryKey: ['user'],
queryFn: getUser, // 성공: data 반환, 실패: HttpError throw
});
QueryProvider로 전역 에러 핸들링 중앙화
모든 클라이언트 네트워크 요청이 동일한 HttpError를 throw하는 구조에서, TanStack Query의 QueryCache/MutationCache를 활용해 공통 에러 정책을 한 곳에서 관리할 수 있습니다. 이것이 SSOT(Single Source Of Truth) 원칙에 맞는 접근입니다.
// app/_providers/QueryProvider.tsx
'use client';
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import { HttpError } from '@/shared/api/httpError';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => {
const client = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
// HttpError instanceof 체크로 타입 안전하게 처리
if (error instanceof HttpError && error.status === 401) {
// 인증 만료 시 캐시 초기화
client.clear();
// 필요하다면 로그인 페이지로 리다이렉트
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
if (error instanceof HttpError && error.status === 401) {
client.clear();
}
},
}),
});
return client;
});
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
QueryCache/MutationCache의 onError는 모든 query/mutation의 에러를 한 곳에서 수신할 수 있어, 글로벌 에러 핸들링의 자연스러운 위치가 됩니다. 각 컴포넌트에 에러 처리 로직을 분산시키면 일관성이 깨지기 쉽기 때문에, 공통 정책은 반드시 이곳에서 관리하는 것이 좋습니다.
6. 전체 흐름 정리
케이스 A: 클라이언트 → (httpClient) → API 직접 호출
httpClient(axios) 요청
↓
API 에러 발생 (예: 401)
↓
axios 응답 인터셉터 → HttpError로 변환하여 throw
↓
TanStack Query → error 상태로 전환
↓
QueryCache.onError 수신 → HttpError { status: 401 }
↓
client.clear() (캐시 초기화)
케이스 B: 클라이언트 → Server Action → httpClient → API 호출
httpClient(axios)에서 HTTP 에러 발생
↓
Server Action이 catch → ActionResult { error: { status: 401, ... } }로 반환
↓
React Flight Protocol이 반환값을 정상 직렬화 (에러 정보 100% 보존)
↓
unwrapAction이 HttpError로 throw (클라이언트 사이드)
↓
TanStack Query → error 상태로 전환
↓
QueryCache.onError 수신 → HttpError { status: 401 }
↓
client.clear() (캐시 초기화) — 프로덕션에서도 정상 동작
두 케이스 모두 QueryProvider의 onError에서 동일하게 처리되므로, 에러 처리 정책의 일관성이 보장됩니다.
7. 요약
핵심 원칙
1. Server Action에서 예상 가능한 에러는 throw 대신 return으로 전달한다.
React Flight Protocol은 에러 객체의 커스텀 프로퍼티를 직렬화하지 않습니다. 반환값(plain object)으로 전달하면 이 제약을 우회할 수 있습니다.
2. 클라이언트 httpClient(axios)에서 에러를 HttpError로 통일한다.
axios 응답 인터셉터에서 모든 에러를 HttpError 인스턴스로 변환하면, 직접 API 호출과 Server Action 경유 호출 모두 동일한 에러 타입을 갖게 됩니다.
3. unwrapAction()으로 Server Action의 Result를 unwrap한다.
서버-클라이언트 경계에서 plain object로 전달된 에러 정보를 클라이언트 진입 직후 HttpError로 복원합니다. 클라이언트 사이드의 throw이므로 Flight Protocol 직렬화 제한의 영향을 받지 않습니다.
4. QueryProvider에서 공통 에러 정책을 중앙 관리한다.
TanStack Query의 QueryCache/MutationCache onError를 활용해 인증 만료 등 공통 정책을 한 곳에서 처리합니다. 개별 컴포넌트에 로직을 분산시키지 않아 SSOT를 유지할 수 있습니다.
5. 예상치 못한 에러(버그, 인프라 장애)는 그대로 throw한다.
이러한 에러는 Error Boundary(error.tsx)에서 처리하게 됩니다.
의사결정 플로우차트
에러 발생
│
├─ 예상 가능한 에러? (인증 실패, 유효성 오류, API 에러)
│ │
│ ├─ 클라이언트 직접 호출: axios 인터셉터 → HttpError throw
│ │ → TanStack Query error 상태 → QueryProvider 글로벌 처리
│ │
│ └─ Server Action 경유: ActionResult로 return
│ → unwrapAction이 HttpError throw (클라이언트)
│ → TanStack Query error 상태 → QueryProvider 글로벌 처리
│
└─ 예상치 못한 에러? (버그, 인프라 장애)
│
└─ throw → Error Boundary (error.tsx)에서 처리
참고 자료
'react' 카테고리의 다른 글
| 리액트의 동시성에 따른 tearing이슈 (0) | 2026.01.12 |
|---|---|
| 컴포넌트에서 서버 데이터 가져오기 (0) | 2026.01.05 |
| 왜 내 코드의 Effect가 실행됐을까? (0) | 2025.12.29 |
| 함수 컴포넌트 시대에 돌아보는 class 문법 (0) | 2025.12.24 |
| 함수 컴포넌트와 클로저 (0) | 2025.12.23 |