JWT Guard 적용 — request.user undefined부터 jwt malformed까지

활동 로그 인터셉터에서 request.user가 undefined로 잡혔다. 원인을 파고드니 JwtAuthGuard 구현 누락, Auth Service와 Guard 간 JWT_SECRET 불일치, FE의 base64(JSON) Mock 토큰까지 3중 함정이 차례로 드러났다. 새벽 3시 임시 우회부터 다음 날 17시 정상화까지의 디버깅 흐름과 NestJS 글로벌 Guard·명시적 secret 검증·실제 BE API 시드 패턴을 정리한다.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • 활동 로그 인터셉터에서 request.user가 항상 undefined — 원인은 JwtAuthGuard가 설계만 되고 구현이 빠져 있었다
  • 함정 1: Guard 미구현request.user를 채우는 주체가 없으면 AuthGuard('jwt')를 어디서도 호출하지 못한다
  • 함정 2: JWT_SECRET 불일치 — Auth Service는 || 'default' 폴백, Guard는 process.env.JWT_SECRET! non-null 단언. 한쪽만 환경변수 누락 시 토큰 서명/검증 키가 달라 401
  • 함정 3: FE Mock 토큰 = btoa(JSON.stringify(payload)) — JWT 형식이 아니라서 jsonwebtoken이 즉시 jwt malformed 던진다
  • 해결: APP_GUARD로 글로벌 등록 + @Public() 데코레이터로 화이트리스트, secret은 생성자에서 명시적 검증, FE는 실제 POST /auth/login 호출로 전환
  • 새벽 3시 인터셉터 우회 → 다음 날 17시 정상화까지 14시간, 33개 파일·310줄로 인증 파이프라인 정리

🌱 왜 새벽에 JWT Guard가 끌려나왔나

활동 로그 기능을 막 끼우던 중이었다. 모든 인증된 요청에 대해 누가, 언제, 무엇을 했는지 activity_logs 테이블에 적재하는 NestJS Interceptor를 작성하고 있었다. ActivityLogInterceptor는 컨트롤러 실행 전에 ExecutionContext에서 요청을 꺼내, request.user.id와 액션 타입을 묶어 비동기로 로그 한 줄을 적는다.

// ❌ 처음 작성 — request.user가 undefined
@Injectable()
export class ActivityLogInterceptor implements NestInterceptor {
  constructor(private readonly activityLogService: ActivityLogService) {}

  intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    const actorId = request.user?.id; // ← 항상 undefined
    const action = this.resolveAction(context);

    return next.handle().pipe(
      tap(async () => {
        await this.activityLogService.log({ actorId, action });
      }),
    );
  }
}

테이블 마이그레이션·Service·Interceptor를 모두 붙이고 Authorization: Bearer ...가 들어간 요청을 보냈다. 서버 로그에 활동 한 줄이 남는지 확인했다. 한 줄이 남기는 했는데, actorIdnull이었다. request.user가 통째로 undefined였다.

📌 핵심: NestJS에서 request.user는 마법처럼 채워지는 값이 아니다. Passport 전략을 호출하는 Guard가 동작해야 그 결과가 request.user에 주입된다. Guard를 어디서도 적용하지 않으면, 토큰을 아무리 잘 보내도 request.user는 영원히 비어 있다.


🔥 증상 — Authorization 헤더는 도착하는데 user가 비어 있다

증상은 한 줄로 깔끔했다.

$ curl -X POST http://localhost:3000/admin/contents \
    -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR..." \
    -H "Content-Type: application/json" \
    -d '{"title":"콘텐츠 A"}'

{ "id": 42, "title": "콘텐츠 A" }  # ← 생성은 성공
-- DB
SELECT actor_id, action, created_at FROM activity_logs ORDER BY id DESC LIMIT 1;
--  actor_id |    action     |     created_at
-- ----------+---------------+---------------------
--  NULL     | content.create | 2026-01-16 02:50:01

요청은 통과했고 컨트롤러는 정상 실행됐다. 그런데 인터셉터가 보는 request.user는 빈 값이었다. 토큰 자체에는 sub·role이 분명히 들어 있었다(jwt.io로 디코드해 확인했다).

// 임시 디버그 로그
console.log('[ActivityLogInterceptor] request.user:', request.user);
console.log('[ActivityLogInterceptor] auth header:', request.headers.authorization?.slice(0, 20));

// 출력
// [ActivityLogInterceptor] request.user: undefined
// [ActivityLogInterceptor] auth header: Bearer eyJhbGciOiJIUzI1

토큰은 도착했다. request.user만 비었다. 헤더는 있는데 검증한 사람이 없다는 뜻이었다. 일단 서비스를 돌려야 했으므로, 인터셉터에서 Authorization 헤더를 직접 파싱해 actorId를 채우는 임시 우회로 새벽 3시 작업을 마감했다.

// ⚠️ 임시 우회 — 인터셉터에서 직접 JWT 파싱 (다음 날 걷어내기로 약속)
const authHeader = request.headers.authorization;
const token = authHeader?.replace(/^Bearer\s+/, '');
const payload = token ? jwt.decode(token) as { sub?: string } | null : null;
const actorId = payload?.sub ?? null;

decode는 서명을 검증하지 않는다. 위조된 토큰이어도 actorId만 멀쩡하면 그대로 기록된다. 운영에 그대로 둘 수 없는 코드였다. 그래서 다음 날 아침 첫 번째 작업으로 JWT Guard를 제대로 끼우는 항목이 잡혔다.

🔍 단서: request.user가 비어 있는데 Authorization 헤더는 정상이라면, 십중팔구는 인증 Guard가 라우트에 적용되지 않았거나, 글로벌 Guard 등록이 누락된 경우다. NestJS는 Passport 전략을 자동으로 돌리지 않는다. Guard가 명시적으로 super.canActivate(context)를 호출해야 비로소 validate()의 반환값이 request.user로 주입된다.


🔍 함정 1 — Guard는 설계만 되고 구현이 비어 있었다

다음 날 아침 코드베이스를 다시 살폈다. apps/api/src/auth/ 아래에 jwt.strategy.ts는 있었다. Passport JwtStrategy를 상속해 validate(payload){ id, role, tenantId }를 반환하는 구현까지 끝나 있었다.

// apps/api/src/auth/strategies/jwt.strategy.ts — 이건 있었다
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
    });
  }

  async validate(payload: { sub: string; role: string; tenantId?: string }) {
    return { id: payload.sub, role: payload.role, tenantId: payload.tenantId };
  }
}

하지만 이 전략을 호출하는 Guard 클래스가 없었다. AuthGuard('jwt')를 상속한 JwtAuthGuard도, 컨트롤러나 AppModule에 등록된 글로벌 Guard도 없었다. PR 단위로 보면 auth/ 폴더에 strategy만 들어가 있고, “Guard는 다음 단계에서”라는 주석이 한 줄 적혀 있었다. 다음 단계가 아직 안 온 상태에서 인증 의존 기능부터 먼저 들어간 것이 사고의 출발점이었다.

NestJS 공식 문서는 인증 가드 적용 절차를 분명히 명시한다.

“To use the JWT strategy, we need to configure a guard. We can also use the built-in AuthGuard provided by @nestjs/passport.” — NestJS Authentication 공식 가이드

NestJS — Authentication
Passport 전략을 NestJS Guard와 결합하는 표준 패턴. JwtStrategy → AuthGuard('jwt') → APP_GUARD 글로벌 등록 절차를 한 페이지로 정리한 공식 가이드.
docs.nestjs.com

PassportStrategy만 정의해 두면 NestJS가 자동으로 라우트마다 검증을 돌리지는 않는다. Guard로 명시적으로 끼워야 Passport가 호출되고, validate()가 반환한 값이 request.user에 주입된다.

🛠️ 해결 — Guard 인프라 + 글로벌 등록 + 데코레이터 3종

해결은 세 부분이었다. Guard 클래스 작성, 글로벌 적용, 화이트리스트 데코레이터.

1) JwtAuthGuard — @Public() 화이트리스트 지원

// apps/api/src/auth/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true; // 로그인·헬스체크 등은 통과
    return super.canActivate(context);
  }
}

핵심은 super.canActivate(context) 한 줄이다. 이걸 호출해야 Passport JwtStrategy가 돌아가고, 그 결과가 request.user에 들어간다. @Public()이 붙은 라우트는 검증을 건너뛰는 화이트리스트로 빠진다.

2) @Public() · @Roles() · @CurrentUser() 3종 데코레이터

// apps/api/src/auth/decorators/public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// apps/api/src/auth/decorators/roles.decorator.ts
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

// apps/api/src/auth/decorators/current-user.decorator.ts
export const CurrentUser = createParamDecorator(
  (_, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user,
);

이 3종이 있으면 컨트롤러는 다음과 같이 정리된다.

// apps/api/src/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
  @Public() // ← Guard 우회 (화이트리스트)
  @Post('login')
  login(@Body() dto: LoginDto) {
    return this.authService.login(dto);
  }
}

// apps/api/src/admin/contents.controller.ts
@Controller('admin/contents')
@Roles('SUPER_ADMIN', 'ADMIN') // ← RolesGuard에서 검사
export class AdminContentsController {
  @Post()
  create(@CurrentUser() user: { id: string }, @Body() dto: CreateContentDto) {
    return this.service.create(user.id, dto);
  }
}

3) APP_GUARD로 글로벌 등록 (RolesGuard도 함께)

// apps/api/src/app.module.ts
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard }, // 1순위: 인증
    { provide: APP_GUARD, useClass: RolesGuard },   // 2순위: 인가
  ],
})
export class AppModule {}

APP_GUARD로 글로벌 등록하면 모든 라우트가 기본적으로 인증을 거친다. 화이트리스트는 @Public()로 명시적으로 열어준다. 적용 범위는 컨트롤러 26개·엔드포인트 약 26개였다(관리자 9 / 고객사 10 / 회원 7).

commit cab521c
feat: JWT Guard 인프라 구축 + 글로벌 적용

33 files changed, 310 insertions(+), 3 deletions(-)

이걸로 새벽의 임시 우회 코드를 걷어냈다. 인터셉터는 다시 request.user.id를 그대로 읽도록 되돌렸고, actor_id에 정상적으로 값이 들어왔다.

📌 핵심: NestJS에서 인증·인가는 *전략(Strategy)*과 Guard가 한 쌍으로 동작한다. 전략은 토큰을 해석하고, Guard는 그 전략을 라우트에 끼운다. 둘 중 하나라도 빠지면 request.user는 영원히 빈다. APP_GUARD + @Public() 조합이 가장 사고가 적은 적용 패턴이다 — 기본은 막혀 있고, 열어줄 곳만 명시한다.


🧩 함정 2 — Auth Service와 Guard의 JWT_SECRET 불일치

오후 4시쯤, 운영자 한 명이 다른 증상을 보고했다.

“로그인은 분명히 200으로 떨어지는데, 바로 다음에 호출하는 Dashboard에서 401이 떨어집니다. 토큰을 새로 받자마자 401이 나는 게 말이 안 됩니다.”

cURL로 재현해 보니 정확히 같은 흐름이었다.

$ curl -X POST http://localhost:3000/auth/login \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"owner123"}'
{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5..." }

# 바로 그 토큰으로 dashboard 호출
$ curl http://localhost:3000/academy/dashboard \
    -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5..."
{ "statusCode": 401, "message": "Unauthorized" }

jwt.io로 디코드해 보면 페이로드는 멀쩡했다. sub·role·tenantId·exp 모두 정상. 그런데 검증에서 떨어졌다. 서명이 맞지 않다는 뜻이었다.

원인을 추적하니 Auth ServiceJwtStrategy 쪽 secret 처리 방식이 달랐다.

// apps/api/src/auth/auth.service.ts — 토큰 발급 (서명)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });

// apps/api/src/auth/strategies/jwt.strategy.ts — 토큰 검증 (서명 확인)
super({
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
  secretOrKey: process.env.JWT_SECRET!, // ← non-null 단언, 환경변수 없으면 undefined
});

차이가 한 줄에 숨어 있었다. Auth Service는 || 폴백으로 항상 어떤 문자열을 갖는다. 환경변수가 비어 있어도 'your-secret-key-change-in-production'로 떨어져서 토큰을 발급한다. Strategy는 ! non-null 단언이라서 환경변수가 비면 undefined가 그대로 secretOrKey에 들어간다. passport-jwtundefined secret을 받으면 어떤 토큰도 검증에 통과시키지 않는다.

문제 환경에서는 .env 파일이 한 단계 상위 폴더에 있어 NestJS가 로드하지 못하고 있었다. 발급 쪽은 폴백 문자열, 검증 쪽은 undefined — 같은 키여야 할 두 값이 서로 달랐고, 그래서 정상 발급된 토큰이 매번 401로 떨어졌다.

jsonwebtoken 공식 문서는 secret 누락에 명시적이다.

jwt.verify(token, secret) — If secret is undefined, all tokens will fail verification regardless of their signature.” — jsonwebtoken README

🛠️ 해결 — 폴백 폐기, 부팅 시 명시적 검증

해결은 단순했다. 두 군데 모두 폴백을 폐기하고, 부팅 시점에 환경변수를 명시적으로 검증하는 것.

// ❌ Before — 폴백·non-null 단언 혼재
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
// vs
secretOrKey: process.env.JWT_SECRET!,
// ✅ After — 단일 출처 + 부팅 시 검증
// apps/api/src/auth/jwt.config.ts
export function loadJwtSecret(): string {
  const secret = process.env.JWT_SECRET;
  if (!secret || secret.length < 32) {
    throw new Error(
      '[boot] JWT_SECRET 환경변수가 비어 있거나 32자 미만입니다. ' +
      '운영 환경은 최소 32자 이상의 랜덤 문자열을 사용하세요.',
    );
  }
  return secret;
}

// auth.service.ts
constructor() {
  this.jwtSecret = loadJwtSecret(); // 부팅 시 1회 호출
}

// jwt.strategy.ts
constructor() {
  super({
    jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    ignoreExpiration: false,
    secretOrKey: loadJwtSecret(), // 같은 함수 호출 → 같은 값 보장
  });
}

loadJwtSecret() 함수 하나로 통일하면 발급과 검증이 항상 동일한 값을 본다. 환경변수가 비어 있으면 부팅 자체가 실패하므로, 잘못된 토큰이 발급되어 운영에 흘러갈 일이 없어진다.

⚠️ 주의: “환경변수가 없으면 기본값 쓰면 되지 않나”는 운영에서 가장 위험한 패턴이다. 개발 환경에서는 통과하지만, 스테이징·운영에서 .env 누락 시 잘못된 키로 토큰이 발급된다. 한쪽이 폴백·다른 쪽이 non-null이면 토큰은 발급되는데 검증은 실패하는 조용한 버그가 된다. 부팅 시점에 fail-fast로 막는 게 옳다.

검증 후 commit:

fix: JWT_SECRET 단일 출처 + 부팅 시 명시적 검증
- jwt.config.ts 신설 (loadJwtSecret 함수)
- auth.service.ts·jwt.strategy.ts 모두 동일 함수 호출
- .env 누락 시 부팅 실패하도록 fail-fast 처리

🧩 함정 3 — FE Mock 토큰이 JWT가 아니었다

secret 통일을 끝내고 같은 cURL을 다시 돌렸다. 200·200으로 통과했다. cURL은 통과했다. 그런데 브라우저에서 직접 로그인하면 여전히 401이었고, 응답 본문에 다른 메시지가 박혀 있었다.

{
  "statusCode": 401,
  "message": "jwt malformed"
}

jwt malformedjsonwebtoken이 던지는 표준 에러 중 하나다. *“이 문자열은 JWT 형식 자체가 아니다”*라는 뜻이다. 정상 JWT는 header.payload.signature 세 부분이 점(.)으로 구분된 base64url 문자열이다. 하나만 있거나 점이 없으면 형식 검증에서 즉시 떨어진다.

브라우저 DevTools에서 요청 헤더를 까보니 의심스러웠다.

Authorization: Bearer eyJzdWIiOiJjbWQ4YnZ4eDAwMDA... (점이 1개)

점이 2개 있어야 정상인데, 1개도 없다시피 했다. base64 그 자체였다. FE 코드를 추적하니 원인이 나왔다.

// apps/learning-prototype/src/providers/auth-provider.ts (Mock 시절 잔재)
function generateMockToken(user: { id: string; role: string }): string {
  const payload = { sub: user.id, role: user.role, exp: Date.now() + 86400_000 };
  return btoa(JSON.stringify(payload)); // ← base64(JSON.stringify) — JWT 아님!
}

const authProvider: AuthProvider = {
  login: async ({ email, password }) => {
    // 실제 BE를 안 부르고 mock user 만들어서 토큰 발급
    const mockUser = { id: 'mock-user-1', role: 'STUDENT' };
    localStorage.setItem('token', generateMockToken(mockUser));
    return { success: true };
  },
};

FE 프로토타입 단계에서 BE 없이 화면을 먼저 만들기 위해 Mock auth-provider가 들어가 있었다. btoa(JSON.stringify(payload))는 base64로 인코딩된 JSON 문자열이지, JWT가 아니다. 점도 없고, 서명도 없다. BE는 그걸 받자마자 jwt malformed를 던졌다.

심지어 FE Mock은 페이로드의 exp밀리초로 넣고 있었다. JWT 표준은 exp초 단위 Unix timestamp로 정의한다(RFC 7519 §4.1.4). Mock 토큰을 BE가 운 좋게 통과시켰다 해도 만료 시각 계산이 틀어졌을 것이다.

🛠️ 해결 — Mock 폐기 + 실제 BE 호출 + 시드 4역할

수정은 두 부분이었다. Mock 폐기실제 로그인 가능한 시드 계정 준비.

1) FE auth-provider를 실제 POST /auth/login으로 전환

// ✅ apps/learning-prototype/src/providers/auth-provider.ts
import axios from 'axios';

const API_BASE = import.meta.env.VITE_API_BASE_URL;

const authProvider: AuthProvider = {
  login: async ({ email, password }) => {
    try {
      const { data } = await axios.post(`${API_BASE}/auth/login`, { email, password });
      localStorage.setItem('token', data.accessToken); // 진짜 JWT
      return { success: true, redirectTo: '/' };
    } catch (e) {
      return { success: false, error: { name: 'LoginError', message: '로그인 실패' } };
    }
  },
  check: async () => {
    const token = localStorage.getItem('token');
    if (!token) return { authenticated: false, redirectTo: '/login' };
    return { authenticated: true };
  },
  logout: async () => {
    localStorage.removeItem('token');
    return { success: true, redirectTo: '/login' };
  },
  getIdentity: async () => {
    const { data } = await axios.get(`${API_BASE}/auth/me`, {
      headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
    });
    return data;
  },
  getPermissions: async () => null,
  onError: async (error) => ({ error }),
};

2) BE seed에 4역할 계정 추가 — User not found 차단

Mock을 걷어내자 다음 에러가 따라왔다.

{ "statusCode": 401, "message": "User not found" }

테스트 DB에 계정이 없어서 발생한 에러였다. seed를 손봤다.

// apps/api/prisma/seed.ts — 4역할 계정 시드
const accounts = [
  { email: '[email protected]',          password: 'admin123',   role: 'PLATFORM_ADMIN' },
  { email: '[email protected]',     password: 'owner123',   role: 'ACADEMY_OWNER' },
  { email: '[email protected]',   password: 'teacher123', role: 'TEACHER' },
  { email: '[email protected]',   password: 'student123', role: 'STUDENT' },
];

for (const acc of accounts) {
  const passwordHash = await bcrypt.hash(acc.password, 10);
  await prisma.user.upsert({
    where: { email: acc.email },
    update: { passwordHash, role: acc.role },
    create: { email: acc.email, passwordHash, role: acc.role },
  });
}

upsert로 작성하면 멱등성이 유지된다. seed를 두 번 돌려도 계정이 중복되지 않고 갱신된다. 이 패턴은 이전 편의 seed FK 삭제 순서에서 정리한 “두 번 연속 성공해야 진짜 멱등” 원칙과 같은 흐름이다.

📌 핵심: 프로토타입 단계의 Mock 코드는 언젠가 반드시 폐기해야 할 부채다. 특히 인증·결제처럼 보안에 민감한 영역의 Mock은 BE와 통합하는 시점에 토큰 형식·페이로드 단위(초 vs 밀리초)·필드명까지 전부 어긋난다. Mock을 짤 때부터 실제 BE와 똑같은 응답 모양으로 짜야 통합 시 이런 충격이 줄어든다.


✅ 검증 — 4역할 × 24개 엔드포인트 권한 표

수정 후 검증은 4역할 계정으로 허용 1·차단 1씩 호출해 보는 비교표 형태로 정리했다.

# 1) PLATFORM_ADMIN — 관리자 라우트 OK
$ TOKEN=$(curl -s -X POST .../auth/login -d '{"email":"[email protected]","password":"admin123"}' | jq -r .accessToken)
$ curl -H "Authorization: Bearer $TOKEN" .../admin/contents
[ ... 200 ... ]

# 2) ACADEMY_OWNER — 관리자 라우트 차단
$ TOKEN=$(curl -s -X POST .../auth/login -d '{"email":"[email protected]","password":"owner123"}' | jq -r .accessToken)
$ curl -H "Authorization: Bearer $TOKEN" .../admin/contents
{ "statusCode": 403, "message": "Forbidden resource" }

# 3) STUDENT — 토큰 없이 호출 → 401
$ curl .../student/me
{ "statusCode": 401, "message": "Unauthorized" }

# 4) @Public 라우트 — 토큰 없어도 200
$ curl .../auth/login -X POST -d '...'
{ "accessToken": "..." }
역할자기 영역다른 역할 영역@Public
PLATFORM_ADMIN✅ 200✅ 200 (전체 접근)✅ 200
ACADEMY_OWNER✅ 200❌ 403✅ 200
TEACHER✅ 200❌ 403✅ 200
STUDENT✅ 200❌ 403✅ 200
(토큰 없음)❌ 401❌ 401✅ 200

브라우저에서도 동일한 흐름이 통과했다. 로그인 → 대시보드 → API 호출까지 401·403 없이 정상 동작했고, 활동 로그에 actor_id가 모두 채워졌다.

SELECT actor_id, action, created_at FROM activity_logs ORDER BY id DESC LIMIT 3;
--           actor_id           |     action      |     created_at
-- ----------------------------+-----------------+---------------------
--  cmd8bvxx0000080j5xq5p2k01  | content.create  | 2026-01-16 17:08:45
--  cmd8bvxx0000080j5xq5p2k01  | content.update  | 2026-01-16 17:09:12
--  cmd9azzz0000080j5xq5p2k02  | dashboard.view  | 2026-01-16 17:10:01

새벽의 NULL이 사라졌다.

직접 정리한 NestJS JWT 인증 3중 함정 — Guard 미구현 → Secret 불일치 → Mock 토큰 비교도
직접 정리한 NestJS JWT 인증 3중 함정 — Guard 미구현 → Secret 불일치 → Mock 토큰 비교도


🛡️ 예방 — JWT 적용 체크리스트

이 디버깅을 통과한 뒤로 NestJS + JWT 조합을 새로 시작할 때마다 아래 체크리스트를 돌린다.

  • JwtStrategy만 만들고 끝내지 않았는가? Guard로 명시적으로 끼웠는가?
  • APP_GUARD로 글로벌 등록했는가? 화이트리스트는 @Public()로 명시했는가?
  • JWT_SECRET 로드 함수 1개로 발급·검증을 통일했는가?
  • 환경변수 누락 시 부팅이 실패하는가? (fail-fast)
  • FE Mock 토큰이 진짜 JWT 형식(header.payload.signature)인가? btoa(JSON.stringify) 사용 금지
  • FE 페이로드의 exp초 단위 Unix timestamp인가? (밀리초 X — RFC 7519 §4.1.4)
  • seed에 4역할 계정이 모두 들어 있는가? upsert로 멱등성 보장하는가?
  • 토큰 검증 실패 응답이 Unauthorized / jwt malformed / jwt expired로 구분되는가? (디버깅용)

특히 첫 두 항목이 핵심이다. Strategy는 코드를 작성한 사람의 의도이고, Guard는 그 의도를 라우트에 강제하는 장치다. 의도만 있고 강제가 없으면 request.user는 영원히 비어 있다.

세 번째 항목 — secret 로드 함수 통일 — 은 단순해 보이지만 운영 사고를 가장 많이 막는다. || 폴백과 ! non-null 단언이 한 코드베이스에 섞여 있으면, 환경변수 누락 시점부터 조용히 망가진 토큰이 흘러다닌다.


📋 정리 — 핵심 요약

함정증상원인권장 패턴
Guard 미구현request.user === undefinedJwtStrategy만 있고 Guard 없음JwtAuthGuard + APP_GUARD 글로벌 등록 + @Public() 화이트리스트
Secret 불일치토큰 발급은 OK, 검증은 401`
FE Mock 토큰jwt malformedbtoa(JSON.stringify(payload))는 JWT 아님실제 POST /auth/login 호출 + seed로 계정 보장
User not found401 (cURL 통과 후 브라우저에서)테스트 DB에 계정 없음seed upsert로 4역할 계정 + 멱등성

숫자로 보는 디버깅

  • 임시 우회까지: 새벽 02:35 → 03:20 (약 45분 — 인터셉터에서 jwt.decode로 우회)
  • Guard 인프라 구축: 03:50 → 04:30 (약 40분, 33개 파일·310줄)
  • Secret 불일치 발견·수정: 16:10 → 16:25 (약 15분)
  • jwt malformed 추적·FE 전환·시드: 16:30 → 17:10 (약 40분)
  • 총 소요: 새벽 02:35 → 다음 날 17:10 (약 14시간 30분)

NestJS의 인증·인가는 StrategyGuard의 조합이 핵심이다. Strategy는 어떻게 토큰을 해석할지, Guard는 어디서 강제할지를 책임진다. 둘 중 하나가 빠지면 request.user라는 한 줄짜리 결과가 통째로 빈다. 그 빈 줄이 인터셉터부터 컨트롤러·서비스까지 줄줄이 무너뜨린다는 걸 새벽 한 시간으로 배웠다.

다음 편에서는 Guard 위에 얹은 디버깅용 운영 API 7개를 정리한다. 임베디드 게임 클라이언트 QA에서 30분짜리 만료 대기를 0초로 줄인 라우트 설계와, PLATFORM_ADMIN 단독 권한 + 환경 토글로 운영 노출을 막은 결정 흐름.

📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날