22 Oct 2025
|
memo
nextjs
authentication
middleware
app-router
1편에서는 Next.js App Router 환경에서 SSR 렌더링이 시작되기 전에 토큰을 리프레시하는 미들웨어 방식을 다뤘다.
이번 글에서는 그 구조가 가진 race condition 문제와, 이를 해결하기 위한 클라이언트 주도형 리프레시 접근을 정리한다.
미들웨어 사전 리프레시의 한계
1편에서 사용한 방식은 렌더링 전에 토큰을 갱신해 SSR을 안정적으로 보장하는 구조였다.
하지만 이 방식은 여러 요청이 동시에 들어오는 상황에서 심각한 부작용을 일으킬 수 있다.
팀장님께선 이 문제를 아래와같이 설명해주셨다.
“간단한 서비스는 이게 큰 문제가 되지는 않아서 사용자 경험이나 SEO를 생각해 사전리프레시를 하기도 하는 것 같은데, 우리가 개발하려는 사이트는 분산 시스템으로서 동시에 여러 서버 액션을 호출한다던가 하는 일이 잦을 수 있어 이 문제가 걱정스러운 수준까지 커질 수 있다고 생각합니다.”
문제 상황: race condition
race condition = 여러 요청이 동시에 동일한 자원(여기서는 refreshToken)에 접근해, 실행 순서에 따라 결과가 달라지는 불안정한 상태
예시
/dashboard, /me, /notifications 페이지를 동시에 요청
- accessToken이 만료되어 있음
- 각 요청의 미들웨어가
각자 동시에 /auth/restore-token 호출
- 첫 번째 요청만 성공하고, 나머지는 “이미 사용된 refreshToken”으로 실패
- 실패한 요청들이
/login으로 리다이렉트
- 브라우저는 리다이렉트 루프에 빠짐 🔁
- 심한 경우 요청이 O(n²) 수준으로 증식 → 서버 과부하
단일 서버에서는 눈에 잘 안 띄지만, 분산 시스템(예: 아몬드영 구조)에서는 쉽게 재현되는 문제다.
클라이언트 주도형 리프레시 (벨로그의 어떤분이 제안한 방식)
이 문제를 해결하기 위해 리프레시 주도권을 클라이언트로 옮긴 구조가 현재 시간을 아끼는 방법중 가장 베스트로 채택되었다.
즉, 401을 받은 후 클라이언트가 재요청하는 구조다.
흐름 요약
- 서버에서 401 발생
- 클라이언트의 axios interceptor가 이를 감지
/auth/restore-token 요청
- 새 accessToken 발급 후 원래 요청을 재시도
- 성공 시 쿠키 동기화 완료
22 Oct 2025
|
memo
nextjs
authentication
middleware
app-router
Next.js App Router 환경에서 401 에러를 어떻게 처리할지 정리한 메모.
서버사이드에서 쿠키가 갱신되지 않는 문제를 여러 방식으로 실험하면서, 결국 미들웨어가 유일하게 일관된 해법이라는 결론에 도달했다.
아래는 그 과정에서 시도해봄직한 여러 방법들과 정리이다.
401 에러 처리 흐름
App Router 기반의 SSR 환경에서는 accessToken이 만료되었을 때
백엔드(NestJS)에서 refreshToken으로 재발급하더라도
Next 서버가 그걸 브라우저 쿠키로 전달하지 못함.
즉, Nest에서는 새 쿠키를 Set-Cookie로 내려보냈는데
Next.js는 이미 스트리밍이 시작되어 cookies()가 고정된 상태라
새 쿠키를 반영할 수 없음.
결과적으로 백엔드에서는 재발급 성공 → Next 서버는 이전 쿠키로 요청 → 또 401.
원인 요약
- App Router는
서버컴포넌트 렌더링과 동시에 스트리밍이 시작됨
cookies()는 read-only라 한 번 읽으면 수정 불가능
- SSR 렌더링 도중 쿠키 갱신이 일어나도 반영 불가
결국 렌더링 전에(스트리밍 시작 전) 토큰을 갱신해야 함.
이걸 할 수 있는 위치는 middleware밖에 없음.
미들웨어 방식 (결론)
렌더링 시작 전에 accessToken이 만료되어 있으면 refreshToken으로 새로 발급받고
그걸 response.cookies에 세팅한 다음 redirect로 새 요청 사이클 시작시킴.
즉, SSR 이전에 쿠키 교체 완료 후 렌더링이 시작되도록 하는 구조.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
import { USER_SERVICE_BASE_URL } from './const';
const JWT_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET);
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (
pathname.startsWith('/_next/') ||
pathname.startsWith('/api/') ||
pathname.startsWith('/favicon.ico') ||
pathname.includes('.')
) {
return NextResponse.next();
}
const accessToken = request.cookies.get('accessToken')?.value;
const refreshToken = request.cookies.get('refreshToken')?.value;
if (!accessToken && !refreshToken) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
if (accessToken) await jwtVerify(accessToken, JWT_SECRET);
} catch {
if (!refreshToken) {
return NextResponse.redirect(new URL('/login', request.url));
}
const res = await fetch(`${USER_SERVICE_BASE_URL}/auth/restore-token`, {
method: 'POST',
credentials: 'include',
headers: { Cookie: request.cookies.toString() },
});
if (!res.ok) return NextResponse.redirect(new URL('/login', request.url));
const data = await res.json();
const newResponse = NextResponse.redirect(request.url);
newResponse.cookies.set('accessToken', data.accessToken);
// 새 요청 사이클로 교체
return newResponse;
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
특징
단점
-
401 발생 시 요청 2번 (성능 이슈)
-
무한 리다이렉트 위험 있음
→ 해결책
-
401 자체가 자주 안 나오는 구조로 설계
-
재시도 횟수를 쿠키나 헤더에 기록해서 제한
server-api 쪽에서 해볼만한 시도들
1. axios 내부에서 1회성 캐싱 토큰으로 해결 시도
렌더링 사이클 안에서만 유효한 newAccessToken 변수를 둠.
401 → refresh → newAccessToken 저장 → 이후 요청부터 이 값 사용.
import axios from 'axios';
import { cookies } from 'next/headers';
export const serverApi = () => {
let newAccessToken: string | null = null;
const instance = axios.create({
baseURL: process.env.NEXT_PUBLIC_USER_SERVICE_URL,
});
instance.interceptors.request.use(async (config) => {
const store = await cookies();
const oldAccessToken = store.get('accessToken')?.value;
const token = newAccessToken ?? oldAccessToken;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
instance.interceptors.response.use(
(res) => res,
async (error) => {
if (error.response?.status === 401) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_USER_SERVICE_URL}/auth/restore-token`,
{ method: 'POST', credentials: 'include' }
);
if (res.ok) {
const data = await res.json();
newAccessToken = data.accessToken;
return instance(error.config);
}
}
throw error;
}
);
return instance;
};
2. 에러 바운더리에서 401 처리 (클라이언트 위임)
이건 NARARIA03 님이 제안한 실험적인 방식.
서버 컴포넌트에서 401이 발생하면 ErrorBoundary로 던지고
클라이언트에서 refresh → router.refresh()로 SSR 재요청.
// app/error.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, startTransition } from 'react';
export default function Error({ error, reset }) {
const router = useRouter();
useEffect(() => {
const tokenRefresh = async () => {
if (error.digest === 'UNAUTHORIZED' || error.message === 'UNAUTHORIZED') {
const res = await fetch('/auth/refresh', { method: 'POST' });
if (res.ok) {
startTransition(() => {
router.refresh();
reset();
});
}
}
};
tokenRefresh();
}, [error, reset, router]);
return null;
}
-
서버는 단순히 401 던짐
-
클라이언트가 토큰 갱신 후 페이지 새로 렌더
-
자연스러운 UI 흐름 가능
-
단, 공식적인 방법은 아니고 트릭에 가까움
결론
결론적으로 App Router 기반 SSR에서는
미들웨어에서 리프레시 처리 → redirect로 새 요청 시작
이게 현재까지 가장 깔끔한 구조.
참고자료
15 Oct 2025
|
memo
내가 작성한 코드였는데도, 쿠키 관련 개념을 자꾸 헷갈려서 정리함.
회사 프로젝트를 진행하면서 fetch를 server 환경과 client 환경에 맞게 분리해줬었다.
이렇게 나눈 가장 큰 이유는 바로 쿠키(Cookie) 때문이었다.
핵심 개념
Client API (브라우저 환경)
브라우저에서는 쿠키가 자동으로 포함된다.
즉, axios나 fetch 요청 시 withCredentials: true 옵션만 주면 브라우저가 알아서 쿠키를 첨부한다.
헤더에 직접 쿠키를 넣을 필요가 없다.
Server API (Node.js 환경)
반면, 서버에서 실행되는 코드(Next.js server actions, getServerSideProps, route handlers 등)는
브라우저 환경이 아니기 때문에 쿠키를 자동으로 포함하지 않는다.
따라서 직접 쿠키를 읽어서 헤더에 실어줘야 한다.
Server API 예시
import { cookies } from 'next/headers';
import axios from 'axios';
export async function serverApi(endpoint) {
const cookieStore = cookies();
const res = await axios.get(
`${process.env.NEXT_PUBLIC_BACKEND_URL}${endpoint}`,
{
headers: {
Cookie: cookieStore.toString(), // 🍪 직접 쿠키 전달
},
}
);
return res.data;
}
Client API 예시
import axios from 'axios';
axios.get('/api/current-user', {
withCredentials: true, // ✅ 브라우저가 자동으로 쿠키를 첨부
});
01 Sep 2025
|
nestjs
배경
지금까지 컨트롤러는 API 엔드포인트를 제공하고 서비스와 연결하는 통로 역할만 한다고 생각했고, 에러 처리는 주로 서비스 레이어에서 해왔다.
그런데 팀장님의 코드를 보니 서비스에서는 null 반환이나 일부 에러만 처리하고, 컨트롤러에서 HTTP 상태 코드와 함께 상세한 에러 처리를 하는 패턴을 발견했다.
질문
이런 설계 철학의 배경과 장점이 무엇인지 궁금해서 여쭤보게 되었다.
답변 요약
핵심 원칙: 각 레이어의 책임 분리
- 컨트롤러: 통신 수단에 종속된 것들에 대한 책임 (HTTP, GraphQL, WebSocket 등)
- 서비스: 통신 수단과 무관한 비즈니스 로직에 대한 책임
- 레포지토리: DB 접근 등 외부 인프라와 데이터를 주고받는 책임
왜 이렇게 분리하는가?
- 재사용성: 동일한 서비스를 HTTP API, GraphQL, WebSocket 등 다양한 방식의 요청에 동일한 서비스레이어 제공 가능
- 테스트 용이성: 서비스 레이어는 HTTP와 무관하게 순수 비즈니스 로직만 테스트할 수 있고, 컨트롤러는 e2e 테스트시에 오히려 서비스 클래스를 뽑아버리고, 컨트롤러가 적절하게 에러를 응답으로 매핑하는지만 확인해볼 수 있게된다.
- 관심사의 분리: “서비스는 HTTP를 몰라야 함” - 각 레이어가 자신의 책임에만 집중
실제 코드 예시
회사 코드라 많은 부분을 생략했지만, 핵심은 service에서 master를 찾지 못했을 때 return null을 하고, 컨트롤러에서 HTTP 에러 처리를 하는 패턴에 주목하면 된다.
// controller.ts
@Get(':id')
async getMasterDetail(@Param('id') id: string): Promise<MasterDetailDto> {
try {
const master = await this.productMastersService.getMasterDetail(id);
if (!master) {
throw new HttpException('Master not found', HttpStatus.NOT_FOUND);
}
return master;
} catch (error) {
if (error.message === 'Master not found' || error.status === HttpStatus.NOT_FOUND) {
throw new HttpException('Master not found', HttpStatus.NOT_FOUND);
}
throw new HttpException('Failed to get master detail', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
// service.ts
async getMasterDetail(masterId: string, tx?: DbTransaction): Promise<MasterDetailDto | null> {
const client = this.getClient(tx);
const master = await this.getMasterById(masterId, tx);
if (!master) {
return null;
}
const optionGroups = await client...
const optionGroupsWithValues: any[] = [];
for (const group of optionGroups) {
....
}
const variants = await client
.select()
.from(productVariants)
.where(eq(productVariants.masterId, masterId))
.orderBy(productVariants.displayOrder);
const channelProducts = [];
return {
...master,
optionGroups: optionGroupsWithValues,
variants: variants.map(v => ({ ...v, optionValues: [] })),
channelProducts
};
}
깨달음
"서비스는 HTTP를 몰라야 한다"는 원칙이 핵심이었다. 서비스 레이어를 순수하게 유지하면 테스트도 쉬워지고, 다양한 전송 방식으로 확장하기도 용이하다.
앞으로의 계획
- 새로 작성하는 코드는 이 원칙을 적용
- 기존 코드는 점진적으로 리팩토링
참고 자료
NestJS 공식 문서를 다시 찾아보니, 컨트롤러에서 HTTP 상태 코드와 함께 에러를 처리하는 예제들이 있었다.
처음 문서를 읽을 때는 단순히 예제 코드라서 간단하게 컨트롤러에서 처리한 거라고 생각하고 넘어갔었다. 지금 생각해보니 공식 문서도 레이어별 책임 분리 원칙을 따르고 있었던 것 같다. 내가 그때는 이 설계 원칙의 중요성을 제대로 이해하지 못해서 놓쳤던 부분인듯 싶다.
29 Aug 2025
|
wil
이번 주는 특별히 큰 진전을 이룬 건 없는 것 같다.
계획했던 CS50 강의는 손도 못 댔고, 타입스크립트 문제 풀이를 한두 개 정도 진행했을 뿐이다.
그나마 『패턴으로 익히고 설계로 완성하는 리액트』 책의 마지막 파트만 남겨두었고, 추가 공부를 위해 책 세 권을 새로 구매한 정도가 이번 주의 성과였다.
그 외에는 메인 개발 작업보다는 각종 에러 처리가 더 기억에 남는다.
회사에서 NestJS로 개발을 진행하면서 class-validator가 Body pipe 단계에서 길이 검증을 제대로 수행하지 못하는 문제를 발견했다.
원래라면 “길이가 올바르지 않다”는 식의 에러 메시지를 띄워야 했는데, 실제로는 DB의 길이 제약 오류가 발생하면서 민감한 DB 정보까지 노출되는 상황이었다. 이는 보안상 큰 문제가 될 수 있기 때문에 원인을 추적했는데, 범인은 TypeScript의 Omit이었다.
Omit은 타입스크립트의 타입 유틸리티일 뿐, 컴파일 시점에서만 작동하여 타입을 변경할 뿐이다.
런타임 시 실제 클래스에서 프로퍼티를 제거하지 않기 때문에 class-validator에서는 해당 필드가 여전히 존재하는 것으로 간주되고, 결국 유효성 검증이 동작하지 않은 것이다. 덕분에 OmitType과 같은 NestJS 전용 유틸리티를 사용해야 한다는 교훈을 얻었다.
또한 일렉트론으로 배포한 앱에서 사용자 CS 문의가 들어왔다. 코드를 살펴보니, 과거에 비공개 레포에서 일렉트론 자동 업데이트 및 배포가 정상적으로 되지 않는 문제가 있어 공개 레포로 전환했던 적이 있었다.
이 과정에서 깃허브 검색에 최대한 노출되지 않게 하려고 레포 이름을 다르게 설정해 두었는데, 그 부분이 원인이 되어 버그가 발생한 듯했다. 이렇게 작은 결정이 예상치 못한 방식으로 스노우볼처럼 굴러올 줄은 전혀 상상하지 못했다.
이번 주는 계획대로 흘러가진 않았지만, 에러와 문제 해결 과정에서 더 큰 배움을 얻었다.
결국 개발은 문제 없는 길을 찾는 게 아니라, 문제를 마주하고 풀어가는 과정임을 다시금 깨달았다.