블로그 서비스를 운영하며 가장 빈번하게 발생하는 이슈는 '인증 만료'입니다.
사용자 흐름을 방해하지 않으면서 보안을 유지하기 위해, Fetch API 기반의 토큰 재발급 로직과 NestJS의 가드(Guard)를 활용한 선택적 권한 제어를 어떻게 구현했는지 공유합니다.
1. Frontend: Axios 없이 Fetch로 구현한 재시도 로직
많은 개발자가 axios interceptor를 사용하지만,
저는 의존성을 줄이기 위해 nextjs에서 확장되어 캐시 기능도 포함된 fetch를 사용하여, 인증 기능도 확장한 fetchServerWithAuth를 커스텀 구현했습니다.
핵심 매커니즘: 401 에러 감지 및 재귀적 호출
사용자가 모르게 토큰을 갱신하는 과정은 다음과 같습니다.
fetchServer를 통해 일차적으로 요청을 보냅니다.만약 결과가 401(Unauthorized)이라면, 즉시 쿠키에서
refreshToken을 꺼내 갱신 API를 호출합니다.갱신에 성공하면 새 토큰을 쿠키에 저장하고, 원래 보내려던 요청을 다시 호출(Retry)하여 결과를 반환합니다.
// fetchServerWithAuth의 핵심: 401 발생 시 재발급 후 재요청
if (result.statusCode === 401) {
const refreshResult = await fetchServer<RefreshResponseData>(...);
if (refreshResult.ok) {
setCookie(...); // 새 토큰 저장
return fetchServer(input, { ...newHeaders }); // 원래 요청 재실행
}
}2. Backend: 미들웨어와 가드의 역할 분담
NestJS 백엔드에서는 인증(Authentication)과 인가(Authorization)를 명확히 분리했습니다.
BearerTokenMiddleware: "일단 누구인지 확인만 할게"
미들웨어는 모든 요청을 검사하지만, 토큰이 없다고 해서 요청을 차단하지 않습니다.
역할: 헤더에 토큰이 있다면 디코딩하여
req.user에 정보를 담아줍니다.유연성: 토큰이 없으면
next()를 호출해 그냥 통과시킵니다. 덕분에 로그인하지 않은 사용자도 공개된 게시글은 볼 수 있습니다.
AuthGuard: "여기는 허가된 사람만 들어와"
실제 차단은 가드에서 담당합니다. @Public() 데코레이터가 붙은 핸들러는 무조건 통과시키고, 그렇지 않은 경우에만 req.user 존재 여부를 확인합니다.
// AuthGuard 로직: Public이면 통과, 아니면 user 객체 검증
const isPublic = this.reflector.get(Public, context.getHandler());
if (isPublic) return true;
if (!request.user || request.user.type !== TokenCategory.accessToken) {
return false; // 403 Forbidden!
}3. BearerTokenMiddleware: JWT의 한계를 보완하는 DB 검증 전략
일반적인 JWT 인증은 토큰의 유효 기간과 서명만 확인하고 통과시킵니다. 하지만 제 블로그 시스템에서는 한 걸음 더 나아가 DB에 저장된 토큰과 실제 요청된 토큰을 대조하는 과정을 추가했습니다.
🛠️ 왜 DB 검증이 필요한가? (Stateful JWT)
JWT는 한 번 발급되면 서버에서 강제로 만료시키기 어렵다는 단점이 있습니다. 하지만 DB에 현재 유효한 토큰을 기록해두면 다음과 같은 상황에 대응할 수 있습니다.
중복 로그인 방지: 새로운 기기에서 로그인하여 새 토큰이 발급되면, 기존 토큰은 DB와 불일치하게 되어 즉시 무효화됩니다.
강제 로그아웃: 보안상 이유로 사용자의 세션을 끊어야 할 때, DB의 토큰 정보만 삭제하면 해당 토큰은 더 이상 사용할 수 없게 됩니다.
🛠️ 미들웨어에서의 검증 로직
미들웨어는 단순히 토큰을 디코딩하는 것에 그치지 않고, Prisma를 통해 DB에 저장된 해당 유저의 토큰과 일치하는지 확인합니다.
// BearerTokenMiddleware 내부의 DB 대조 로직const db = await this.prisma.token.findUnique({
where: {
user_category_unique: {
userId: decodePayload.userId,
category: decodePayload.type, // accessToken 또는 refreshToken
},
},
});
// 발급된 토큰과 DB에 기록된 최신 토큰이 다르면 침입이나 만료로 간주if (!db || db.token !== token) {
throw new UnauthorizedException('유효하지 않은 토큰입니다!');
}4. 실무 적용: 비공개 포스트 필터링
인증 시스템의 백미는 "권한에 따른 동적 데이터 반환"입니다. 게시글 목록 조회 시, 미들웨어가 추출해준 유저 정보 유무에 따라 쿼리 조건을 다르게 가져갑니다.
async findAll({ page, pageSize }: GetPostQueryDto, user?: TokenPayload) {
// 인증된 사용자는 전체, 비인증 사용자는 published: true인 글만!
const where = user ? {} : { published: true };
const posts = await this.prisma.post.findMany({ where, ... });
// ...
}이 방식을 통해 하나의 API 핸들러에서 관리자와 일반 사용자의 요구사항을 모두 충족할 수 있었습니다.
5. 성능 최적화: Payload의 활용
매 요청마다 유저의 등급이나 상태를 확인하기 위해 DB를 조회하는 것은 비효율적입니다.
전략: Access Token(1시간)의 Payload에 필수 유저 정보를 담았습니다.
이점: 1시간 동안은 DB 조회 없이 토큰만 디코딩하여 즉시 권한을 판단합니다. 정보 최신화가 필요할 때쯤(1시간 뒤) 토큰이 만료되어 재발급 로직이 돌면서 자연스럽게 최신 DB 정보를 다시 토큰에 담게 됩니다.
마치며
단순히 "로그인을 시킨다"는 개념을 넘어, 사용자의 불편함을 최소화(Silent Refresh)하고 서버 리소스를 아끼는(DB Query 최소화) 구조를 고민해 보았습니다. fetch와 NestJS의 조합은 생각보다 더 강력하고 유연한 인증 시스템을 선사해주었습니다.