
저번 포스트에서 Next.js Page Router에서 서버/클라이언트 사이드 모두에서 Axios interceptor를 활용해 Access token을 재발급하는 방법을 정리했었다.
그러나 App Router에서는 이 방법을 그대로 적용할 수 없어, 대신 Middleware를 사용해 토큰을 재발급하는 방식이 주로 사용된다.
혹시 App Router에서 Middleware 없이 서버 컴포넌트에서 발생한 401 에러를 처리하는 좋은 방법은 없을까? 🤔
이 방법은 PoC 단계로만 검증되었으며, 실제 사례가 부족해 프로덕션 환경에
적용 시 주의가 필요합니다. 참고 부탁드립니다! 🙏
App Router나 Page Router 모두 클라이언트 사이드에서는 동일하게 Axios interceptor로 토큰 재발급을 처리할 수 있다. 자세한 내용은 이전 포스트에 자세하게 정리되어 있으므로 생략하겠다.
항상 문제는 서버 사이드(서버 컴포넌트)에서 발생한다. 그러나 App Router에서는 cookies() 함수가 제공되므로 오히려 수월하지 않을까?
서버 컴포넌트에서 사용할 Axios instance를 대강 작성해보자.
"use server";
import axios from "axios";
import { cookies } from "next/headers";
// Axios instance 생성
export const APIServer = axios.create({
baseURL: "http://localhost:4000",
timeout: 5 * 1000,
});
// 요청 전, cookies 함수를 통해 Access token을 가져와 헤더에 삽입
APIServer.interceptors.request.use(async (config) => {
const cookieStore = await cookies();
const accessToken = cookieStore.get("accessToken")?.value;
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// 401 에러 발생 시 cookies 함수를 통해 새 토큰을 저장하거나 삭제
APIServer.interceptors.response.use(undefined, async (error) => {
const requestConfig = error.config;
const cookieStore = await cookies();
const refreshToken = cookieStore.get("refreshToken")?.value;
try {
const newTokens = await getNewToken(refreshToken);
cookieStore.set("accessToken", newTokens.accessToken);
cookieStore.set("refreshToken", newTokens.refreshToken);
requestConfig.headers.Authorization = `Bearer ${newTokens.accessToken}`;
return axios(requestConfig);
} catch (err) {
cookieStore.delete("accessToken");
cookieStore.delete("refreshToken");
}
});
Page Router와의 차이는 쿠키를 읽고 쓰고 삭제할 때 cookies() 함수가 반환한 쿠키 객체를 사용한다는 것 뿐이다. 한 번 테스트해보자.
// app/private/page.tsx
import { APIServer } from "@/axios/server/instance";
export default async function PrivatePage() {
const { data } = await APIServer.get("/api/users/me");
return <div>{data.username}</div>;
}

어라? interceptor에서 새 토큰을 발급하고 쿠키에 저장하려 하자, Server Action 또는 Router Handler에서만 쿠키를 수정할 수 있다는 에러가 발생하는 것을 알 수 있다.
HTTP does not allow setting cookies after streaming starts, so you must use
.setin a Server Action or Route Handler.
App Router에서는 왜 서버 컴포넌트 렌더링 도중에 쿠키를 수정할 수 없는 것일까? Page Router와 비교해 원인을 정리해보자.
Page Router는 응답을 스트리밍 하지 않는다.
getServerSideProps에서 Set-Cookie 헤더를 추가한 뒤, HTTP 헤더를 전송하는 것이 가능하다.반면 App Router는 응답을 스트리밍한다.
Set-Cookie를 추가할 수 없으므로 쿠키 수정이 불가능하다.여러 시도(Server Action, Route Handler에서 쿠키 업데이트 등..)를 해보았지만, 근본적으로 HTTP 헤더가 먼저 전송되기 때문에 새 토큰이 브라우저 쿠키에 저장될 수 없다는 점을 깨닫고 포기하려고 했다.
그때 한 Reddit의 댓글에서 아이디어를 얻게 되었다. "토큰 만료 책임은 클라이언트가 가져야 한다"는 내용이었다. RSC payload도 API의 응답으로 생각하면, 서버 컴포넌트에서 발생한 401 에러는 "브라우저의 책임" 아닐까?
서버 컴포넌트에서 401 에러가 발생했다는 사실을 브라우저가 인지하고, 토큰을 재발급한 뒤, 다시 서버 컴포넌트를 요청할 수는 없을까?
이번 포스트에서는 서버 컴포넌트에서 발생하는 401 에러를 브라우저의 책임으로 돌려 토큰을 재발급하는 방법에 대해 다뤄보려고 한다.
크게 두 가지 기능이 필요하다.
error.tsx의 reset() 함수로 에러 바운더리를 리셋하고, 렌더링을 재시도한다.이를 조합하면 다음과 같은 흐름으로 구현할 수 있다.
router.refresh()로 서버 컴포넌트 재요청 (새 토큰 활용)reset()으로 에러 바운더리 리셋 → 새 컴포넌트 렌더링구현은 복잡하지 않다. 하나씩 살펴보자!
먼저 Access token을 헤더에 주입하는 request interceptor를 구현한다.
// axios/server/interceptors/request.ts
import type { InternalAxiosRequestConfig } from "axios";
import { cookies } from "next/headers";
export const serverRequestInterceptor = async (
config: InternalAxiosRequestConfig
) => {
const cookieStore = await cookies();
const accessToken = cookieStore.get("accessToken")?.value;
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
};
다음으로 401 에러를 감지해 커스텀 에러로 변환하는 response interceptor를 구현한다.
// axios/server/interceptors/response.ts
import type { AxiosError } from "axios";
export const serverResponseErrorInterceptor = (error: AxiosError) => {
if (error.response?.status === 401) {
// 에러 바운더리에서 감지할 수 있도록 커스텀 에러 생성
const customError = new Error("UNAUTHORIZED");
throw customError;
}
throw error;
};
마지막으로 두 interceptor를 Axios instance에 등록한다.
// axios/server/instance.ts
import axios from "axios";
import { serverRequestInterceptor } from "./interceptors/request";
import { serverResponseErrorInterceptor } from "./interceptors/response";
export const APIServer = axios.create({
baseURL: "http://localhost:4000",
timeout: 5 * 1000,
});
APIServer.interceptors.request.use(serverRequestInterceptor, undefined);
APIServer.interceptors.response.use(undefined, serverResponseErrorInterceptor);
에러 바운더리에서 서버에서 발생한 401 에러를 식별해 토큰을 재발급한 뒤, 쿠키를 업데이트하도록 구현해보자.
// app/error.tsx
"use client";
import { getNewToken } from "@/axios/api/getNewToken";
import { isAxiosError } from "axios";
import { getCookie, setCookie } from "cookies-next";
import { useRouter } from "next/navigation";
import { startTransition, useEffect } from "react";
type Props = {
error: Error & { digest?: string };
reset: () => void;
};
export default function Error({ error, reset }: Props) {
const router = useRouter();
useEffect(() => {
(async () => {
// 서버 컴포넌트에서 발생한 401 에러인지 확인
if (error.message === "UNAUTHORIZED") {
// 토큰 재발급 시도
try {
const refreshToken = getCookie("refreshToken");
if (refreshToken) {
const newTokens = await getNewToken(refreshToken);
// 새 토큰을 쿠키에 저장
setCookie("accessToken", newTokens.accessToken);
setCookie("refreshToken", newTokens.refreshToken);
router.refresh(); // 먼저 refresh로 서버 컴포넌트 재요청
startTransition(() => {
reset(); // 그 다음 에러 바운더리를 리셋
});
}
} catch (refreshError) {
if (isAxiosError(refreshError)) {
// 재발급 실패 시 로그인 페이지로 이동
if (refreshError.response?.status === 401) {
router.replace("/");
}
}
}
}
})();
}, [error.message, router, reset]);
return null; // 토큰 재발급 중에는 아무것도 렌더링하지 않음
}
reset()을 startTransition()으로 감싼 이유는router.refresh()가 먼저 완료된 후 에러 바운더리가 리셋되도록 실행 순서를 보장하기 위함이다. (Github Issue)
테스트를 위해 로그인 → 인가 데이터 렌더링 페이지를 생성하고, Axios instance와 Error boundary 동작을 확인해보자.

페이지 500 에러 → 토큰 재발급 → 서버 컴포넌트 요청 → 재렌더링 흐름이 정상적으로 작동하는 것을 확인할 수 있다!
화면에 발생하는 깜박임은 로딩 스피너나 스켈레톤으로 처리해줄 수 있다.
하지만 위 코드는 프로덕션 환경(build → start)에서 정상적으로 동작하지 않는다.
Next.js는 서버 컴포넌트에서 발생한 에러로 인해 민감 정보가 노출되지 않도록 production 환경에서 의도적으로 에러 메시지를 마스킹 처리하기 때문이다.

This is a security precaution to avoid leaking potentially sensitive details included in the error to the client.
The
messageproperty contains a generic message about the error and thedigestproperty contains an automatically generated hash of the error that can be used to match the corresponding error in server-side logs.
이로 인해 error.message === "UNAUTHORIZED"가 false로 평가되어 토큰이 재발급되지 않는 것이다.
하지만 digest 필드는 마스킹되지 않으므로, 이를 활용해 서버에서 발생한 401 에러를 식별할 수 있다. 에러를 던질 때 digest 필드에 커스텀 에러 메시지를 추가하면 된다.
// axios/server/interceptors/response.ts
import type { AxiosError } from "axios";
type NextError = Error & { digest?: string };
export const serverResponseErrorInterceptor = (error: AxiosError) => {
if (error.response?.status === 401) {
const customError: NextError = new Error("UNAUTHORIZED");
// digest 필드를 에러 식별에 활용
customError.digest = "UNAUTHORIZED";
throw customError;
}
throw error;
};
에러 바운더리에서 401 에러 검증 시에도 digest 값을 활용하도록 변경한다.
// app/error.tsx
"use client";
...
export default function Error({ error, reset }: Props) {
const router = useRouter();
useEffect(() => {
(async () => {
// digest를 활용해 서버 컴포넌트에서 발생한 401 에러인지 확인
if (error.digest === "UNAUTHORIZED") {
try {
...
} catch (refreshError) {
...
}
}
})();
}, [error.digest, router, reset]);
return null;
}

이제 프로덕션 환경에서도 digest를 통해 서버 컴포넌트의 401 에러를 감지하고 토큰을 재발급할 수 있다.
App Router에서 Middleware 없이 토큰을 재발급하는 방법을 고민하는 과정에서 App Router의 스트리밍 렌더링과 에러 처리 메커니즘에 대해 이해할 수 있었다.
물론 Middleware를 사용하는 방법이 더 일반적이고 안정적이겠지만, 클라이언트 측에서 토큰을 관리하는 구조가 필요하다면 시도해볼 가치가 있다고 생각한다.
글 읽어주셔서 감사합니다.
App Router에서 토큰 재발급 구현 방법을 고민하고 계시다면, 이 방법이 하나의 해결책이 되길 바랍니다. 여러분의 의견이나 더 나은 개선안이 있다면 댓글로 공유해주세요.
이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다!