NestJS Guard와 Interceptor를 선택하는 기준
NestJS Guard와 Interceptor를 선택하는 기준
Guard는 현재 Handler를 실행해도 되는지 결정하고, Interceptor는 Handler 실행 전후의 흐름을 감싸 측정·응답 변환·캐시 등을 적용한다. 입력 변환은 Pipe, 예외 응답은 Filter, Handler를 모르는 공통 전처리는 Middleware에 둔다. 기능 이름보다 요청 생명주기에서 필요한 시점을 기준으로 선택한다.
목차
- #인증 로깅 변환을 한 곳에 넣을 때 생기는 문제
- #NestJS 요청 생명주기에서 위치를 먼저 본다
- #Guard는 실행 허용 여부를 결정한다
- #Interceptor는 Handler 전후를 감싼다
- #Middleware Pipe Filter와 구분한다
- #인증과 인가를 Guard에 나누어 적용한다
- #리소스 소유권 검사의 위치와 비용
- #Interceptor로 구조화 로그와 시간을 측정한다
- #응답 변환과 캐시를 사용할 때의 경계
- #Timeout Interceptor가 실제 작업을 취소하는지 확인한다
- #전역 등록과 실행 순서를 관리한다
- #HTTP GraphQL 메시지 환경을 함께 지원할 때
- #테스트와 운영 점검
- #마무리
- #참고 자료
- #관련 노트
인증 로깅 변환을 한 곳에 넣을 때 생기는 문제
Express에 익숙하면 모든 공통 기능을 Middleware로 만들기 쉽다.
app.use(async (req, res, next) => {
// 토큰 검증
// 역할 검사
// 요청 body 변환
// 실행 시간 측정
// 응답 body 감싸기
// 오류 응답 변환
next();
});
하지만 Middleware가 실행될 때는 어떤 Controller Handler가 선택될지에 대한 NestJS 메타데이터를 활용하기 어렵다. @Roles(), @Public(), @CacheTtl()처럼 Handler별 선언을 읽어야 하는 기능에는 Guard나 Interceptor가 더 맞다.
반대로 모든 것을 Guard로 옮기면 Guard 안에서 응답을 변환하거나 실행 시간을 재려고 하게 된다.
@Injectable()
export class EverythingGuard
implements CanActivate {
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
// 인증
// 권한
// 로깅 시작
// 캐시 조회
// 응답 변환 시도
return true;
}
}
Guard는 Handler 실행 이후의 반환값을 받지 않는다. 이 역할에는 Interceptor의 next.handle() Observable이 필요하다.
“공통 로직인가?”만으로는 부족하다. Handler 선택 전인지, 실행 허용 판단인지, 인자 변환인지, 실행 전후인지, 예외 이후인지를 먼저 확인한다.
NestJS 요청 생명주기에서 위치를 먼저 본다
NestJS 공식 요청 생명주기를 단순화하면 다음과 같다.
flowchart LR
A[Request] --> M[Middleware]
M --> G[Guards]
G --> I1[Interceptors before]
I1 --> P[Pipes]
P --> H[Controller Handler]
H --> I2[Interceptors after]
I2 --> R[Response]
G -. exception .-> F[Exception Filter]
I1 -. exception .-> F
P -. exception .-> F
H -. exception .-> F
I2 -. exception .-> F
F --> R세부 실행 순서에는 global, controller, route scope와 등록 순서가 영향을 준다. 특히 Interceptor는 요청 방향으로 들어갔다가 응답 방향에서 역순으로 나온다.
Global Interceptor before
Controller Interceptor before
Route Interceptor before
Handler
Route Interceptor after
Controller Interceptor after
Global Interceptor after
이 구조 때문에 Interceptor는 Handler 전체 시간을 측정하거나 응답과 오류를 공통 처리하기 좋다.
Guard는 실행 허용 여부를 결정한다
Guard는 CanActivate를 구현하고 boolean, Promise<boolean>, Observable<boolean>을 반환한다.
@Injectable()
export class PermissionGuard
implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly permissions:
PermissionService,
) {}
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
const required =
this.reflector.getAllAndOverride(
RequiredPermissions,
[
context.getHandler(),
context.getClass(),
],
);
if (!required?.length) {
return true;
}
const request =
context.switchToHttp().getRequest<
AuthenticatedRequest
>();
return this.permissions.allows(
request.user,
required,
);
}
}
Handler에 메타데이터를 선언한다.
export const RequiredPermissions =
Reflector.createDecorator<
readonly Permission[]
>();
@Delete(":orderId")
@RequiredPermissions([
"orders.cancel",
])
async cancel(
@Param("orderId") orderId: string,
) {
return this.cancelOrder.execute({
orderId,
});
}
Guard가 false를 반환하면 Nest는 요청을 거절한다. 인증되지 않은 경우처럼 다른 HTTP 의미가 필요하면 구체적인 예외를 던진다.
if (!request.user) {
throw new UnauthorizedException();
}
if (!hasPermission) {
throw new ForbiddenException();
}
Guard가 적합한 질문
이 Handler는 공개 경로인가?
인증된 사용자인가?
필요한 역할·권한이 있는가?
현재 테넌트에 접근할 수 있는가?
기능 플래그상 이 Handler를 실행해도 되는가?
Guard는 결과를 변경하는 기능이 아니라 실행 전 허용·거절 결정에 적합하다.
Interceptor는 Handler 전후를 감싼다
Interceptor는 NestInterceptor를 구현하고 next.handle()로 Handler를 호출한다. 반환값은 RxJS Observable이다.
@Injectable()
export class RequestTimingInterceptor
implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
const startedAt = performance.now();
return next.handle().pipe(
finalize(() => {
const durationMs =
performance.now() - startedAt;
recordRequestDuration(
context,
durationMs,
);
}),
);
}
}
next.handle()을 호출하지 않으면 Handler가 실행되지 않는다. 캐시 적중처럼 의도적으로 다른 Observable을 반환할 때 사용할 수 있다.
@Injectable()
export class ExampleCacheInterceptor
implements NestInterceptor {
constructor(
private readonly cache: ResponseCache,
) {}
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
const key = buildCacheKey(context);
const cached = this.cache.get(key);
if (cached !== undefined) {
return of(cached);
}
return next.handle().pipe(
tap((value) => {
this.cache.set(key, value);
}),
);
}
}
Interceptor가 적합한 기능
- Handler 전체 실행 시간 측정
- 성공·실패 공통 로깅
- 응답 객체 변환
- 예외 매핑
- 캐시 hit에서 Handler 우회
- Handler Observable에 timeout 적용
- trace span 시작과 종료
tap, catchError, finalize 차이
return next.handle().pipe(
tap({
next: (value) => {
// 성공 값 관찰
},
error: (error) => {
// 오류 관찰
},
}),
catchError((error) => {
// 오류를 다른 오류나 값으로 변환
return throwError(() => error);
}),
finalize(() => {
// 성공·오류·취소 모두 정리
}),
);
단순 측정 정리는 finalize, 결과 변환은 map, 오류 변환은 catchError처럼 의도를 드러낸다.
Middleware Pipe Filter와 구분한다
| 구성 요소 | 핵심 질문 | Handler 메타데이터 | 반환값 접근 | 대표 용도 |
|---|---|---|---|---|
| Middleware | 라우트 선택 전 공통 전처리인가 | 제한적 | 없음 | Request ID, 원시 헤더, 쿠키 |
| Guard | Handler 실행을 허용할 것인가 | 있음 | 없음 | 인증·인가 |
| Interceptor | Handler 전후 흐름을 감쌀 것인가 | 있음 | 있음 | 로깅·측정·캐시·응답 변환 |
| Pipe | Handler 인자를 변환·검증할 것인가 | 인자 메타데이터 | 인자 | DTO 검증, ID 파싱 |
| Filter | 발생한 예외를 응답으로 표현할 것인가 | 있음 | 예외 | 오류 코드·HTTP 응답 |
Request ID
라우트와 무관하게 가장 앞에서 생성해야 하므로 Middleware가 자연스럽다. 이후 Interceptor가 문맥의 Request ID를 구조화 로그에 사용한다.
UUID 검증
@Get(":orderId")
findOne(
@Param("orderId", ParseUUIDPipe)
orderId: string,
) {}
Handler 인자 변환·검증이므로 Pipe다.
도메인 오류 응답
@Catch(OrderNotFoundError)
export class OrderNotFoundFilter
implements ExceptionFilter {
catch(
error: OrderNotFoundError,
host: ArgumentsHost,
): void {
const response =
host.switchToHttp().getResponse<Response>();
response.status(404).json({
code: "ORDER_NOT_FOUND",
message: "주문을 찾을 수 없습니다.",
});
}
}
예외를 HTTP 표현으로 바꾸므로 Filter가 맞다.
인증 Middleware가 토큰을 파싱하고 Guard가 Handler 메타데이터에 따라 접근을 결정할 수 있다. 중요한 것은 같은 로직을 중복 실행하지 않고 각 단계의 계약을 분명히 하는 것이다.
인증과 인가를 Guard에 나누어 적용한다
인증과 인가는 다른 질문이다.
Authentication: 누구인가?
Authorization: 이 작업을 해도 되는가?
전역 인증 Guard가 기본적으로 모든 경로를 보호하고 @Public() 메타데이터만 예외로 둘 수 있다.
export const Public =
Reflector.createDecorator<boolean>();
@Injectable()
export class AuthenticationGuard
implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly authenticator:
TokenAuthenticator,
) {}
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
const isPublic =
this.reflector.getAllAndOverride(
Public,
[
context.getHandler(),
context.getClass(),
],
);
if (isPublic) {
return true;
}
const request =
context.switchToHttp().getRequest<
AuthenticatedRequest
>();
request.user =
await this.authenticator.authenticate(
request.headers.authorization,
);
return true;
}
}
권한 Guard는 인증된 사용자를 전제로 한다.
@UseGuards(
AuthenticationGuard,
PermissionGuard,
)
순서에 암묵적으로 의존하면 테스트가 필요하다. 전역 Guard 등록 순서와 route Guard 조합을 공식 생명주기 기준으로 확인한다.
기본 거절 정책이 안전하다
권한 메타데이터가 없으면 허용할지 거절할지 선택해야 한다.
if (!requiredPermissions) {
return false; // deny by default
}
모든 일반 사용자 경로에 메타데이터를 붙이는 비용이 크다면 인증만 요구하고 세부 권한이 필요한 Handler에만 Permission을 붙일 수도 있다. 조직의 정책 누락 위험에 따라 정한다.
리소스 소유권 검사의 위치와 비용
orders.cancel 권한이 있어도 자신의 주문만 취소할 수 있다는 규칙이 있을 수 있다.
역할 기반 권한: 주문 취소 기능 사용 가능
리소스 기반 권한: 이 주문을 취소할 수 있음
Guard에서 주문을 조회할 수 있다.
@Injectable()
export class OrderOwnershipGuard
implements CanActivate {
constructor(
private readonly orders:
OrderAccessReader,
) {}
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
const request =
context.switchToHttp().getRequest<
AuthenticatedRequest
>();
const orderId =
request.params.orderId;
return this.orders.isOwnedBy(
orderId,
request.user.id,
);
}
}
하지만 Handler의 유스케이스도 같은 주문을 다시 조회하면 DB 호출이 중복된다. Guard가 읽은 엔티티를 Request에 붙이면 계층이 강하게 결합되고 오래된 상태를 사용할 수 있다.
더 중요한 문제는 TOCTOU다.
Guard: 사용자가 소유자임을 확인
다른 트랜잭션: 소유자 변경
Handler: 취소 실행
최종 업무 불변식은 유스케이스의 트랜잭션 안에서도 확인해야 한다.
UPDATE orders
SET
status = 'cancelled',
cancelled_at = now()
WHERE id = $1
AND customer_id = $2
AND status = 'pending';
영향받은 행이 0개면 소유권 또는 상태 조건이 맞지 않는다.
Guard는 불필요한 실행을 일찍 거절하고 선언적 접근 정책을 제공한다. 데이터 변경의 최종 권한·상태 조건은 원자적 쓰기 경계에서 다시 보장한다.
Guard DB 조회가 비싸면 다음을 검토한다.
- 토큰 claim으로 판정 가능한 권한과 DB가 필요한 권한 분리
- 필요한 컬럼만 조회
- 동일 요청 안의 안전한 access decision 캐시
- 목록 endpoint의 행별 Guard 조회 방지
- 최종 UPDATE 조건에 권한 포함
Interceptor로 구조화 로그와 시간을 측정한다
Handler 전후를 감싸는 Interceptor는 요청 완료 로그에 적합하다.
@Injectable()
export class AccessLogInterceptor
implements NestInterceptor {
constructor(
private readonly logger:
ContextLogger,
) {}
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
const request =
context.switchToHttp().getRequest<Request>();
const response =
context.switchToHttp().getResponse<Response>();
const startedAt = performance.now();
const handler =
context.getHandler().name;
const controller =
context.getClass().name;
this.logger.info(
{
method: request.method,
controller,
handler,
},
"request handler started",
);
return next.handle().pipe(
tap({
error: (error) => {
this.logger.warn(
{
controller,
handler,
errorCode:
classifyError(error),
},
"request handler failed",
);
},
}),
finalize(() => {
this.logger.info(
{
method: request.method,
controller,
handler,
statusCode: response.statusCode,
durationMs:
performance.now() - startedAt,
},
"request handler finished",
);
}),
);
}
}
finalize가 보는 시간
Interceptor Observable이 종료되는 시점은 Handler 반환 흐름의 완료다. 실제 클라이언트 소켓에 마지막 바이트가 전송된 시점과 완전히 같지 않을 수 있다. 네트워크 전송 완료 시간이 필요하면 HTTP adapter의 response finish·close 이벤트를 별도로 관측한다.
민감 정보와 카디널리티
원본 URL에는 사용자 ID, 검색어, 토큰이 포함될 수 있다. route template과 Handler 이름을 사용하고 body 전체를 기록하지 않는다.
좋음: route=/orders/:orderId
주의: url=/orders/order-real-id?token=...
Request ID 문맥은 구조화 로그에 Request ID 넣기와 연결된다.
응답 변환과 캐시를 사용할 때의 경계
응답 Envelope
type ApiResponse<T> = Readonly<{
data: T;
meta: {
requestId: string;
};
}>;
@Injectable()
export class ResponseEnvelopeInterceptor<T>
implements NestInterceptor<
T,
ApiResponse<T>
> {
intercept(
_context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
data,
meta: {
requestId:
currentRequestId(),
},
})),
);
}
}
Nest 공식 문서가 경고하듯 @Res()로 라이브러리별 응답 객체를 직접 사용하는 Handler에는 일반적인 response mapping이 기대대로 동작하지 않을 수 있다.
@Get()
findAll(@Res() response: Response) {
response.json([]);
}
가능하면 framework-standard 반환 방식을 유지한다.
캐시
Interceptor가 캐시 적중 시 Handler를 우회할 수 있다. 하지만 키와 권한 경계를 조심해야 한다.
function buildCacheKey(
context: ExecutionContext,
): string {
const request =
context.switchToHttp().getRequest<
AuthenticatedRequest
>();
return [
context.getClass().name,
context.getHandler().name,
request.user.tenantId,
canonicalizeQuery(request.query),
].join(":");
}
사용자·테넌트별 응답을 공용 키로 저장하면 데이터가 노출된다. 캐시 전에 Guard가 실행되므로 권한 검사는 거치지만, 잘못된 캐시 키는 다른 인가된 사용자의 응답을 돌려줄 수 있다.
쓰기 Handler나 사용자별 민감 응답에는 단순 global 캐시를 적용하지 않는다.
Timeout Interceptor가 실제 작업을 취소하는지 확인한다
RxJS timeout()으로 Handler Observable을 실패시킬 수 있다.
@Injectable()
export class HandlerTimeoutInterceptor
implements NestInterceptor {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next.handle().pipe(
timeout(3000),
catchError((error) => {
if (error instanceof TimeoutError) {
return throwError(
() =>
new RequestTimeoutException(),
);
}
return throwError(() => error);
}),
);
}
}
그러나 Promise 기반 Handler가 이미 외부 API와 DB 쿼리를 시작했다면 Observable 구독이 취소되어도 실제 작업이 자동 중단된다고 가정해서는 안 된다.
async function handler() {
await externalApi.call(); // AbortSignal 미전달
}
클라이언트에는 408이 반환돼도 externalApi.call()이 계속 실행될 수 있다. Timeout 없는 외부 API 호출이 위험한 이유에서 다룬 것처럼 AbortSignal을 실제 하위 I/O에 전달해야 한다.
async function handler(
context: RequestContext,
) {
await externalApi.call({
signal: context.signal,
});
}
Timeout Interceptor는 상위 deadline과 AbortController를 요청 문맥에 넣고 timeout 시 abort할 수 있다. 다만 DB 드라이버와 외부 SDK가 취소를 지원하는지 확인한다.
응답을 포기했더라도 결제·주문 같은 쓰기 작업은 완료됐을 수 있다. 멱등성 키와 결과 조회가 필요하다.
전역 등록과 실행 순서를 관리한다
애플리케이션 밖에서 인스턴스를 직접 만들면 DI를 사용하기 어렵다.
app.useGlobalGuards(
new AuthenticationGuard(),
);
Module Provider로 등록한다.
@Module({
providers: [
{
provide: APP_GUARD,
useClass: AuthenticationGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: AccessLogInterceptor,
},
],
})
export class RequestPipelineModule {}
등록 Module이 어디든 APP_GUARD, APP_INTERCEPTOR는 전역으로 작동한다. 관련 구현이 정의된 Module에 두면 출처를 찾기 쉽다.
여러 전역 Guard와 Interceptor의 순서가 중요하다.
providers: [
{
provide: APP_GUARD,
useClass: AuthenticationGuard,
},
{
provide: APP_GUARD,
useClass: PermissionGuard,
},
]
인증이 먼저 사용자 문맥을 만들고 권한 Guard가 사용한다. 순서를 문서화하고 E2E로 검증한다.
적용 범위 선택
| 범위 | 사용 시점 | 주의점 |
|---|---|---|
| Global | 모든 Handler에 같은 정책 | public 예외와 비용 |
| Controller | 한 기능의 공통 정책 | Controller가 커질 수 있음 |
| Route | 특정 Handler만 | 선언 반복 |
인증은 global + @Public() 예외, 특정 권한은 route metadata, 공통 access log는 global 같은 조합이 가능하다.
HTTP GraphQL 메시지 환경을 함께 지원할 때
ExecutionContext는 HTTP뿐 아니라 GraphQL, WebSocket, RPC에서도 사용된다. switchToHttp()만 호출하는 Guard는 다른 transport에서 동작하지 않는다.
function getPrincipal(
context: ExecutionContext,
): Principal | undefined {
switch (context.getType<string>()) {
case "http":
return context
.switchToHttp()
.getRequest<
AuthenticatedRequest
>()
.user;
case "rpc":
return context
.switchToRpc()
.getContext<RpcContext>()
.principal;
default:
return undefined;
}
}
GraphQL은 전용 execution context 변환이 필요하다. 모든 transport를 하나의 거대한 Guard에서 지원하기보다 공통 권한 정책과 transport adapter를 분리할 수 있다.
HTTP Principal Adapter ─┐
GraphQL Principal Adapter ─→ Permission Policy
RPC Principal Adapter ──┘
Hybrid app에서 useGlobalGuards()나 global filter 적용 범위가 transport별로 다를 수 있으므로 공식 문서와 실제 bootstrap 설정을 확인한다.
테스트와 운영 점검
Guard 단위 테스트
it("denies a user without the required permission", async () => {
const context = createHttpExecutionContext({
user: {
id: "user-example",
permissions: ["orders.read"],
},
handlerMetadata: {
permissions: ["orders.cancel"],
},
});
const allowed =
await guard.canActivate(context);
expect(allowed).toBe(false);
});
Public route, 인증 누락, 메타데이터 병합, Controller·Handler 우선순위를 함께 테스트한다.
Interceptor marble보다 행위 테스트
간단한 Interceptor는 Observable을 구독해 성공·오류·finalize를 확인한다.
it("records duration when the handler fails", async () => {
const next: CallHandler = {
handle: () =>
throwError(
() => new Error("example failure"),
),
};
await expect(
lastValueFrom(
interceptor.intercept(context, next),
),
).rejects.toThrow("example failure");
expect(metrics.observeDuration)
.toHaveBeenCalledTimes(1);
});
E2E 생명주기 테스트
Given 인증 토큰이 없고 body도 잘못됨
When 보호된 endpoint 호출
Then Guard의 401이 먼저 반환
And Handler와 업무 Provider는 호출되지 않음
Given 인증·권한은 유효하고 body가 잘못됨
Then Pipe의 400이 반환
And Handler는 호출되지 않음
Given Handler가 도메인 오류 발생
Then Interceptor가 실패 메트릭 기록
And Filter가 안정적인 오류 응답 생성
실행 순서를 가정만 하지 않고 통합 테스트한다.
운영 메트릭
- Guard별 허용·거절 횟수와 안정적인 이유
- 인증 실패와 권한 실패 분리
- Controller·Handler별 latency와 오류율
- Interceptor fallback·cache hit 횟수
- timeout 이후 남은 in-flight 작업
- Filter가 처리한 도메인 오류 코드
사용자 ID, 토큰, 원본 URL을 메트릭 라벨로 넣지 않는다.
선택 체크리스트
마무리
Guard와 Interceptor는 둘 다 Controller 앞에 붙일 수 있지만 책임과 실행 시점이 다르다.
Guard는 Handler와 메타데이터를 보고 요청 실행을 허용하거나 거절한다. Interceptor는 next.handle()을 중심으로 Handler 전후 Observable을 감싸 로깅, 측정, 변환, 캐시를 적용한다.
입력은 Pipe, 예외 응답은 Filter, Handler를 모르는 전처리는 Middleware에 둔다. 특히 리소스 권한은 Guard의 사전 확인만 믿지 않고 데이터 변경 트랜잭션에서 다시 보장하며, Timeout Interceptor는 실제 하위 I/O 취소와 연결한다. 생명주기의 올바른 위치에 책임을 놓으면 선언은 짧아지고 실행 순서와 테스트 경계는 더 명확해진다.
참고 자료
- NestJS Guards
- NestJS Interceptors
- NestJS Request Lifecycle
- NestJS Execution Context
- NestJS Pipes
- NestJS Exception Filters