PUBLISHED
07. Middleware와 토큰 재발급
작성일: 2024.08.01

미들웨어에 대해 알아보기 전에, 한 가지 짚고 넘어가야 할 개념이 있다. Nest.js 서버로 들어온 요청은 단순히 컨트롤러로 바로 전달되지 않는다. 앞서 살펴본 가드나 파이프뿐만 아니라, 미들웨어와 인터셉터 등을 거쳐 단계적으로 처리된 뒤 컨트롤러에 도달한다. 그리고 컨트롤러에서 처리된 결과는 다시 인터셉터와 예외 필터를 거쳐 클라이언트로 전달된다.
이처럼 요청이 서버에 들어와 응답으로 나가기까지 거치는 일련의 흐름을 요청 생명주기(Request Lifecycle) 라고 부른다. Nest.js에서 HTTP 요청은 다음과 같은 순서로 처리된다. 이때 컨트롤러 이전 단계에서 동작하는 컴포넌트들은 전역 → 컨트롤러 → 라우터 순으로 실행되고, 컨트롤러 이후 단계에서 동작하는 컴포넌트들은 그 반대 순서로 실행된다. 이 실행 순서의 특성은 미들웨어의 역할과 위치를 이해하는 데 매우 중요하다.

Middleware
토큰이 만료되었을 때, 클라이언트가 직접 refresh 엔드포인트를 호출하도록 만들 수도 있다. 하지만 나는 이 과정을 클라이언트에 맡기지 않고, Nest.js가 요청을 처리하는 과정에서 알아서 토큰을 재발급하도록 구성하기로 했다.
이미 가드를 사용해 유저 인증을 구현한 상태였기 때문에, 토큰 재발급 로직은 가드보다 앞 단계에서 실행되어야 했다. 요청 생명주기를 기준으로 보면, 이 조건을 만족하는 컴포넌트는 미들웨어뿐이다. 그래서 토큰 재발급 로직을 미들웨어로 구현하는 것이 가장 자연스러운 선택이었다.
Nest.js에서 미들웨어는 보통 NestMiddleware 인터페이스를 구현하는 Injectable한 프로바이더 클래스로 작성된다. 다만 전역 미들웨어로 적용하는 경우에 한해 함수형 미들웨어를 사용할 수도 있다.
이 차이에 대한 설명은 글의 후반부에서 다시 다루고, 특별한 언급이 없는 한 이 글에서 말하는 “미들웨어”는 NestMiddleware를 구현한 클래스형 미들웨어를 의미한다.
토큰 재발급 로직에서 AuthService를 사용해야 했기 때문에, 미들웨어 모듈에 AuthModule을 import했다.
import { Module } from '@nestjs/common';
import { RefreshMiddleware } from './refresh.middleware';
import { AuthModule } from 'src/auth/auth.module';
@Module({
providers: [RefreshMiddleware],
imports: [AuthModule],
})
export class MiddlewareModule {}NestMiddleware를 구현하는 클래스라는 점을 제외하면, 작성 방식은 Express.js의 미들웨어와 크게 다르지 않다. 덕분에 코드 자체는 비교적 빠르게 작성할 수 있었다.
다만 Nest.js든 Express.js든 미들웨어를 작성할 때 절대 잊지 말아야 할 부분이 하나 있다. 바로 next()를 호출해 다음 단계로 제어권을 넘겨주어야 한다는 점이다. 이걸 깜빡하면 요청은 그대로 멈춰버린다.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { AuthService } from '../auth/auth.service';
@Injectable()
export class RefreshMiddleware implements NestMiddleware {
constructor(private authService: AuthService) {}
use(req: Request, res: Response, next: NextFunction) {
// 토큰 재발급 로직 처리
next();
}
}미들웨어 적용하기
이렇게 만든 미들웨어를 실제로 적용하려면, 해당 모듈이 NestModule 인터페이스를 구현해야 한다. 그리고 configure 메서드 안에서 MiddlewareConsumer를 사용해 미들웨어를 설정한다.
@Module({
imports: [...],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(RefreshMiddleware)
.forRoutes('*');
}
}apply에는 적용할 미들웨어를, forRoutes에는 미들웨어를 적용할 경로를 지정한다. 위처럼 '*'를 지정하면 전역 미들웨어로 동작한다.
함수형 미들웨어
함수형 미들웨어의 가장 큰 장점은 app.use()를 사용해 아주 적은 코드로 전역 미들웨어를 적용할 수 있다는 점이다. 하지만 이 장점은 직접 미들웨어를 구현할 때보다는, 이미 만들어진 외부 미들웨어를 사용할 때 훨씬 크게 체감된다.
대표적인 예가 Express 커뮤니티에서 오랫동안 검증된 미들웨어 라이브러리들이다. 예를 들어 cookie-parser 같은 라이브러리는 Nest.js에서도 그대로 사용할 수 있다.
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
await app.listen(3000);
}
bootstrap();반면, 애플리케이션 내부 로직과 밀접하게 연관된 미들웨어를 작성해야 한다면 클래스형 미들웨어가 압도적으로 유리하다. 모듈–프로바이더 구조를 그대로 활용할 수 있기 때문에, 다른 서비스나 레포지토리를 주입받아 중복 없이 로직을 구성할 수 있다.
결과적으로 전역 설정이나 외부 라이브러리에는 함수형 미들웨어가 적합하고, 비즈니스 로직이 개입되는 경우에는 클래스형 미들웨어가 훨씬 좋은 선택이라고 생각한다.