GitHubGitHub
← 홈으로 돌아가기
backend·

JwtStrategy, JwtAuthGuard, RolesGuard로 이해한 인증과 권한 검사 흐름

NestJS에서 JWT 토큰을 검증하고, 로그인 여부와 사용자 권한을 검사하는 흐름을 정리한 글

JwtStrategy, JwtAuthGuard, RolesGuard로 이해한 인증과 권한 검사 흐름

이전 글에서는 AuthModule, AuthController, AuthService를 기준으로 로그인 요청이 어떻게 처리되고 JWT access token이 발급되는지 정리했다.

이번 글에서는 로그인 이후의 흐름을 정리해보려고 한다.

로그인에 성공하면 프론트는 서버로부터 access_token을 받는다.

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
}

그렇다면 이후 보호된 API를 호출할 때 백엔드는 이 토큰을 어떻게 검사할까?

이번 글의 핵심은 다음과 같다.

JwtStrategy + JwtAuthGuard
→ 로그인한 사용자인지 검사한다.

RolesDecorator + RolesGuard
→ 로그인한 사용자 중 특정 권한을 가진 사용자인지 검사한다.

쉽게 말하면 두 단계 방어막이다.

1차 방어막: 너 로그인했어?
2차 방어막: 너 이 API를 사용할 권한이 있어?

전체 흐름

관리자만 접근할 수 있는 API가 있다고 가정해보자.

예를 들어 관리자만 공지사항을 생성할 수 있는 API를 만든다면 다음과 같은 구조가 될 수 있다.

@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
async createNotice(@Body() dto: CreateNoticeDto) {
  return this.noticesService.create(dto);
}

이 API는 다음 순서로 동작한다.

POST /notices 요청
JwtAuthGuard 실행
JWT 토큰이 유효한지 검사
유효하면 req.user 생성
RolesGuard 실행
@Roles(Role.ADMIN)에 적힌 권한 확인
req.user.role이 ADMIN인지 확인
ADMIN이면 Controller 실행
아니면 요청 차단

즉 역할을 나누면 다음과 같다.

JwtAuthGuard
→ 인증 검사
→ 이 사용자가 로그인한 사용자인지 확인

RolesGuard
→ 권한 검사
→ 로그인한 사용자가 필요한 role을 가지고 있는지 확인

1. JwtStrategy는 JWT 토큰 검증 전략이다

먼저 JwtStrategy를 보자.

구조를 단순화하면 다음과 같다.

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
    });
  }

  async validate(payload: any) {
    return {
      userId: payload.sub,
      email: payload.email,
      role: payload.role,
    };
  }
}

JwtStrategy는 JWT 토큰을 어떻게 꺼내고, 어떻게 검증할지 정하는 클래스다.

프론트가 보호된 API를 호출할 때 보통 다음과 같은 헤더를 보낸다.

Authorization: Bearer access_token

여기서 JwtStrategy는 다음 일을 한다.

1. Authorization 헤더에서 Bearer 토큰을 꺼낸다.
2. JWT_SECRET으로 토큰이 유효한지 검증한다.
3. 토큰 만료 시간이 지나지 않았는지 확인한다.
4. 검증에 성공하면 payload를 validate()로 넘긴다.
5. validate()가 반환한 값을 req.user에 넣는다.

즉, JwtStrategy는 보호된 API 요청에 들어온 토큰을 해석하고 검증하는 전략이다.


2. Authorization 헤더에서 토큰 꺼내기

JwtStrategy에서 가장 먼저 볼 부분은 이 코드다.

jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken()

이 코드는 JWT 토큰을 어디서 꺼낼지 정한다.

현재 설정은 요청 헤더에서 토큰을 꺼낸다.

정확히는 다음 형태를 기대한다.

Authorization: Bearer <토큰>

예를 들어 프론트에서는 보호 API를 호출할 때 다음처럼 보낼 수 있다.

fetch('/api/protected', {
  headers: {
    Authorization: `Bearer ${accessToken}`,
  },
});

여기서 Bearer 뒤에 있는 긴 문자열이 JWT 토큰이다.

즉 이 설정은 다음과 같이 볼 수 있다.

요청 헤더의 Authorization 값에서 Bearer 토큰을 꺼내라.

3. 토큰 만료 시간을 검사하기

다음 설정은 ignoreExpiration이다.

ignoreExpiration: false

이 값은 토큰 만료 시간을 무시할지 여부를 정한다.

현재 값은 false다.

즉 다음 뜻이다.

토큰 만료 시간을 무시하지 않는다.
만료된 토큰은 거부한다.

예를 들어 환경변수에 토큰 만료 시간이 이렇게 설정되어 있다고 해보자.

JWT_EXPIRES_IN=1h

그러면 로그인 후 1시간이 지나면 해당 토큰은 만료된다.

테스트를 위해 짧게 설정할 수도 있다.

JWT_EXPIRES_IN=5s

이 경우 로그인 후 5초가 지나면 보호된 API 요청이 실패해야 한다.

토큰 만료를 무시하면 오래된 토큰도 계속 사용할 수 있기 때문에 보안상 위험하다.
그래서 일반적으로는 ignoreExpiration: false로 두고 만료된 토큰을 거부하는 것이 자연스럽다.


4. JWT_SECRET으로 토큰 검증하기

다음 설정은 secretOrKey다.

secretOrKey: configService.getOrThrow<string>('JWT_SECRET')

JWT는 발급할 때 비밀키로 서명된다.

이전 글에서 로그인 성공 시 AuthService.login()에서 다음 코드를 봤다.

this.jwtService.sign(payload)

이때 JwtModule에 설정된 JWT_SECRET으로 토큰이 서명된다.

그리고 토큰을 검증할 때도 같은 비밀키가 필요하다.

토큰 발급
→ JWT_SECRET으로 서명

토큰 검증
→ 같은 JWT_SECRET으로 검증

만약 발급할 때 사용한 비밀키와 검증할 때 사용하는 비밀키가 다르면, 서버는 해당 토큰을 신뢰할 수 없다.

그래서 JwtStrategy에서도 ConfigService를 통해 JWT_SECRET을 가져와 사용한다.

configService.getOrThrow<string>('JWT_SECRET')

getOrThrow()를 사용하면 값이 없을 때 서버 시작 또는 설정 과정에서 바로 문제를 발견할 수 있다.


5. validate()는 req.user를 만든다

JwtStrategy에서 가장 중요한 메서드는 validate()다.

async validate(payload: any) {
  return {
    userId: payload.sub,
    email: payload.email,
    role: payload.role,
  };
}

이 메서드는 JWT 검증이 성공했을 때 실행된다.

로그인할 때 JWT payload를 이렇게 만들었다고 해보자.

const payload = {
  email: user.email,
  sub: user.id,
  role: user.role,
};

이 payload가 토큰 안에 들어가고, 보호된 API 요청 시 토큰 검증에 성공하면 다시 꺼내진다.

그리고 validate()는 이 payload를 받아 다음 객체로 바꿔 반환한다.

{
  userId: payload.sub,
  email: payload.email,
  role: payload.role,
}

이 반환값이 나중에 요청 객체의 req.user가 된다.

즉 Controller에서는 다음처럼 사용할 수 있다.

@Get('me')
@UseGuards(JwtAuthGuard)
getMe(@Request() req) {
  return req.user;
}

이때 req.user에는 대략 이런 값이 들어 있다.

{
  userId: 'user-id',
  email: 'test@example.com',
  role: 'USER',
}

정리하면 다음과 같다.

JWT payload
JwtStrategy.validate()
req.user

6. JwtAuthGuard는 로그인 여부를 검사한다

이제 JwtAuthGuard를 보자.

구조를 단순화하면 다음과 같다.

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  handleRequest(err: unknown, user: any, info: any) {
    if (info?.name === 'TokenExpiredError') {
      throw new UnauthorizedException({
        code: 'TOKEN_EXPIRED',
        message: '로그인이 만료되었습니다. 다시 로그인해 주세요.',
      });
    }

    if (err || !user) {
      throw err || new UnauthorizedException({
        code: 'UNAUTHORIZED',
        message: '인증이 필요합니다.',
      });
    }

    return user;
  }
}

Guard는 Controller 메서드가 실행되기 전에 요청을 먼저 검사하는 문지기다.

요청 들어옴
Guard 검사
통과하면 Controller 실행
실패하면 Controller 실행 안 됨

JwtAuthGuard는 다음처럼 사용할 수 있다.

@Get('me')
@UseGuards(JwtAuthGuard)
getMe(@Request() req) {
  return req.user;
}

이 API는 로그인한 사용자만 접근할 수 있다.


7. AuthGuard('jwt')와 JwtStrategy의 연결

JwtAuthGuardAuthGuard('jwt')를 상속한다.

export class JwtAuthGuard extends AuthGuard('jwt') {}

여기서 'jwt'는 JWT 전략을 의미한다.

JwtAuthGuard가 실행되면 내부적으로 JwtStrategy가 동작한다.

흐름은 다음과 같다.

JwtAuthGuard 실행
AuthGuard('jwt') 실행
JwtStrategy 실행
Authorization 헤더에서 토큰 추출
JWT_SECRET으로 토큰 검증
validate(payload) 실행
req.user 생성

즉 한 문장으로 정리하면 다음과 같다.

JwtAuthGuard는 JwtStrategy를 실행해서 요청에 포함된 JWT가 유효한지 검사한다.

8. handleRequest로 인증 실패 응답 다루기

JwtAuthGuard에는 handleRequest()가 있다.

handleRequest(err: unknown, user: any, info: any) {

이 메서드는 Passport가 토큰 검사를 마친 뒤 결과를 처리한다.

여기에는 세 가지 값이 들어온다.

err
→ 인증 과정에서 발생한 에러

user
→ JwtStrategy.validate()가 반환한 사용자 정보

info
→ 토큰 만료 같은 추가 정보

handleRequest()는 토큰 검증 결과를 보고 요청을 통과시킬지, 에러를 던질지 결정한다.


9. 토큰 만료 처리

먼저 토큰이 만료된 경우를 따로 처리한다.

if (info?.name === 'TokenExpiredError') {
  throw new UnauthorizedException({
    code: 'TOKEN_EXPIRED',
    message: '로그인이 만료되었습니다. 다시 로그인해 주세요.',
  });
}

토큰이 만료되면 info.nameTokenExpiredError가 될 수 있다.

이 경우 서버는 TOKEN_EXPIRED 코드를 포함한 401 에러를 던진다.

응답은 대략 이런 형태가 될 수 있다.

{
  "statusCode": 401,
  "message": {
    "code": "TOKEN_EXPIRED",
    "message": "로그인이 만료되었습니다. 다시 로그인해 주세요."
  },
  "error": "Unauthorized"
}

이렇게 에러 코드를 명확히 내려주면 프론트에서 처리하기 쉽다.

예를 들어 프론트에서는 TOKEN_EXPIRED를 보고 다음 처리를 할 수 있다.

저장된 access_token 제거
로그인 페이지로 이동
만료 안내 메시지 표시

즉 백엔드의 에러 코드 설계가 프론트의 자동 로그아웃 흐름과 연결된다.


10. 일반 인증 실패 처리

토큰 만료가 아닌 일반 인증 실패도 처리한다.

if (err || !user) {
  throw err || new UnauthorizedException({
    code: 'UNAUTHORIZED',
    message: '인증이 필요합니다.',
  });
}

이 경우는 예를 들면 다음과 같다.

Authorization 헤더가 없음
Bearer 토큰이 없음
토큰 형식이 잘못됨
JWT_SECRET이 달라서 검증 실패
토큰이 변조됨

이런 상황에서는 user가 없거나 err가 있을 수 있다.

그러면 UNAUTHORIZED 코드를 포함한 401 에러를 던진다.

{
  "code": "UNAUTHORIZED",
  "message": "인증이 필요합니다."
}

정리하면 JwtAuthGuard는 인증 실패 상황을 다음처럼 나누어 처리한다.

토큰 만료
→ TOKEN_EXPIRED

그 외 인증 실패
→ UNAUTHORIZED

이렇게 구분하면 프론트에서도 상황별 처리가 쉬워진다.


11. 성공하면 user를 반환한다

검증에 성공하면 마지막에 user를 반환한다.

return user;

이 user는 JwtStrategy.validate()에서 반환한 값이다.

{
  userId: payload.sub,
  email: payload.email,
  role: payload.role,
}

이 값이 Controller의 req.user에 들어간다.

즉 보호된 API에서는 다음처럼 인증된 사용자 정보를 사용할 수 있다.

@Get('me')
@UseGuards(JwtAuthGuard)
getMe(@Request() req) {
  return {
    userId: req.user.userId,
    email: req.user.email,
    role: req.user.role,
  };
}

이 예시는 실제 도메인 로직이 아니라, req.user가 어떻게 만들어지고 사용되는지 보여주기 위한 예시다.


12. RolesDecorator는 필요한 권한 정보를 붙인다

이제 권한 검사로 넘어가보자.

RolesDecorator의 구조는 다음과 같다.

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

이 코드는 @Roles() 데코레이터를 만든다.

예를 들어 다음처럼 사용할 수 있다.

@Roles(Role.ADMIN)

이렇게 붙이면 해당 API에는 다음 의미가 생긴다.

이 API는 ADMIN 권한이 필요하다.

중요한 점은 @Roles() 자체가 요청을 막지는 않는다는 것이다.

@Roles()는 단지 메타데이터를 붙이는 역할을 한다.

@Roles(Role.ADMIN)
→ 이 API에는 ADMIN 권한이 필요하다는 정보를 붙임

RolesGuard
→ 그 정보를 읽고 실제로 통과 또는 거부를 판단함

13. SetMetadata와 ROLES_KEY

RolesDecorator는 내부적으로 SetMetadata를 사용한다.

SetMetadata(ROLES_KEY, roles)

SetMetadata는 코드에 추가 정보를 붙이는 역할을 한다.

여기서는 roles라는 이름으로 필요한 권한 정보를 저장한다.

export const ROLES_KEY = 'roles';

예를 들어 다음처럼 작성하면:

@Roles(Role.ADMIN)

내부적으로는 이런 메타데이터가 붙는다고 이해할 수 있다.

key: 'roles'
value: [Role.ADMIN]

이후 RolesGuard가 이 정보를 읽어서 현재 사용자의 role과 비교한다.

ROLES_KEYRolesDecoratorRolesGuard를 이어주는 공통 이름표다.

RolesDecorator가 'roles'라는 이름으로 저장
RolesGuard가 'roles'라는 이름으로 읽음

14. 여러 role을 받을 수 있는 구조

Roles 함수는 rest parameter를 사용한다.

export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

여기서 ...roles는 여러 권한을 받을 수 있다는 뜻이다.

하나만 받을 수도 있다.

@Roles(Role.ADMIN)

여러 개를 받을 수도 있다.

@Roles(Role.ADMIN, Role.USER)

이 경우 roles는 배열이 된다.

[Role.ADMIN, Role.USER]

즉 이 API는 ADMIN 또는 USER 권한 중 하나라도 있으면 허용할 수 있다.


15. RolesGuard는 실제 권한을 검사한다

이제 RolesGuard를 보자.

구조를 단순화하면 다음과 같다.

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles) {
      return true;
    }

    const { user } = context.switchToHttp().getRequest();

    return requiredRoles.some((role) => user.role === role);
  }
}

RolesGuard@Roles()에 적힌 권한과 현재 로그인한 사용자의 권한을 비교한다.

예를 들어 API에 다음처럼 붙어 있다고 해보자.

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
@Post()
async createAdminNotice() {
  return '관리자 전용 공지 생성';
}

그리고 로그인한 사용자의 req.user가 다음과 같다면:

{
  userId: 'user-id',
  email: 'admin@example.com',
  role: 'ADMIN',
}

이 요청은 통과한다.

반대로 role이 USER라면:

{
  userId: 'user-id',
  email: 'user@example.com',
  role: 'USER',
}

@Roles(Role.ADMIN) 조건에 맞지 않기 때문에 요청이 차단된다.


16. CanActivate와 canActivate()

Guard는 보통 CanActivate 인터페이스를 구현한다.

export class RolesGuard implements CanActivate

CanActivatecanActivate() 메서드를 요구한다.

canActivate(context: ExecutionContext): boolean

이 메서드의 반환값이 중요하다.

true
→ 요청 통과, Controller 실행

false
→ 요청 차단

인증과 권한을 구분하면 다음과 같다.

401 Unauthorized
→ 로그인 자체가 안 됨
→ 토큰 없음, 토큰 잘못됨, 토큰 만료 등

403 Forbidden
→ 로그인은 했지만 권한이 부족함

JwtAuthGuard는 주로 401과 관련 있고, RolesGuard는 403과 관련된 권한 검사라고 볼 수 있다.


17. Reflector로 @Roles() 메타데이터 읽기

RolesGuard에서 가장 중요한 부분은 이 코드다.

const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
  context.getHandler(),
  context.getClass(),
]);

이 코드는 @Roles(Role.ADMIN)처럼 붙여둔 메타데이터를 읽는다.

여기서 Reflector는 메타데이터를 읽는 도구다.

SetMetadata로 저장한 정보
Reflector로 읽음

context.getHandler()는 현재 실행하려는 Controller 메서드를 의미한다.

@Post()
@Roles(Role.ADMIN)
async create() {}

여기서 create() 메서드가 handler다.

context.getClass()는 현재 Controller 클래스를 의미한다.

@Controller('admin')
export class AdminController {}

클래스 자체에 @Roles()를 붙일 수도 있기 때문에, Guard는 메서드와 클래스 양쪽의 메타데이터를 확인한다.


18. @Roles()가 없으면 통과시킨다

다음 코드도 중요하다.

if (!requiredRoles) {
  return true;
}

이 말은 @Roles()가 붙어 있지 않은 API라면 role 검사를 하지 않고 통과시킨다는 뜻이다.

예를 들어 다음 API는 로그인만 필요하고 특정 role은 필요하지 않을 수 있다.

@Get('me')
@UseGuards(JwtAuthGuard)
getMe(@Request() req) {
  return req.user;
}

이 API는 JwtAuthGuard만 통과하면 된다.

반면 관리자 권한까지 필요한 API는 다음처럼 작성한다.

@Post('admin-only')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
createAdminOnlyData() {
  return '관리자만 가능';
}

즉 역할은 다음처럼 나뉜다.

JwtAuthGuard만 사용
→ 로그인 여부만 검사

JwtAuthGuard + RolesGuard + @Roles()
→ 로그인 여부와 권한까지 검사

19. req.user.role과 requiredRoles 비교하기

권한 비교는 이 코드에서 이루어진다.

return requiredRoles.some((role) => user.role === role);

requiredRoles@Roles()에 적힌 권한 배열이다.

예를 들어:

@Roles(Role.ADMIN)

이면 다음과 같다.

requiredRoles = [Role.ADMIN]

그리고 user.roleJwtStrategy.validate()를 거쳐 req.user에 들어간 현재 사용자의 권한이다.

user.role = 'ADMIN'

이 코드의 의미는 다음과 같다.

requiredRoles 중 하나라도 user.role과 같으면 통과한다.

some()은 배열 안에 조건을 만족하는 값이 하나라도 있으면 true를 반환한다.

예를 들어 다음과 같은 조건이 있다고 해보자.

@Roles(Role.ADMIN, Role.USER)

그리고 현재 사용자의 role이 USER라면:

ADMIN === USER → false
USER === USER  → true

하나라도 true가 있으므로 요청은 통과한다.


20. Guard 순서가 중요한 이유

관리자 API에는 보통 다음처럼 Guard를 함께 사용한다.

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)

이 순서가 중요하다.

먼저 JwtAuthGuard가 실행되어야 한다.

왜냐하면 RolesGuardreq.user.role을 검사하는데, req.userJwtAuthGuard가 성공해야 만들어지기 때문이다.

흐름은 다음과 같다.

JwtAuthGuard 실행
JwtStrategy.validate() 실행
req.user 생성
RolesGuard 실행
req.user.role 검사

만약 JWT 검사를 하지 않고 RolesGuard만 실행하면 req.user가 없어서 권한 검사를 할 수 없다.

그래서 권한이 필요한 API는 보통 다음처럼 작성한다.

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)

21. 예시로 보는 보호 API

실제 도메인 로직 대신, 인증과 권한 흐름만 보여주기 위한 예시 API를 만들어보면 다음과 같다.

@Controller('notices')
export class NoticesController {
  @Get('me')
  @UseGuards(JwtAuthGuard)
  getMyAuthInfo(@Request() req) {
    return req.user;
  }

  @Post()
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.ADMIN)
  createNotice(@Body() dto: CreateNoticeDto) {
    return {
      message: '관리자만 공지를 생성할 수 있습니다.',
      data: dto,
    };
  }
}

이 예시에서 첫 번째 API는 로그인만 필요하다.

@Get('me')
@UseGuards(JwtAuthGuard)

흐름은 다음과 같다.

access_token 있음
토큰 유효함
→ req.user 반환

두 번째 API는 로그인뿐 아니라 관리자 권한이 필요하다.

@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)

흐름은 다음과 같다.

access_token 있음
토큰 유효함
req.user.role이 ADMIN
→ 공지 생성 가능

이 예시는 실제 프로젝트 도메인 로직이 아니라, JwtAuthGuardRolesGuard가 어떻게 조합되는지 설명하기 위한 예시다.


22. role 흐름 연결하기

로그인할 때 AuthService.login()에서 JWT payload에 role을 넣었다.

const payload = {
  email: user.email,
  sub: user.id,
  role: user.role,
};

그리고 JwtStrategy.validate()에서 payload의 role을 꺼내 req.user에 넣었다.

return {
  userId: payload.sub,
  email: payload.email,
  role: payload.role,
};

마지막으로 RolesGuardreq.user.role을 확인한다.

return requiredRoles.some((role) => user.role === role);

즉 role 흐름은 이렇게 연결된다.

DB의 user.role
로그인 성공 시 JWT payload에 role 저장
프론트가 access_token 저장
보호 API 요청 시 Authorization 헤더로 토큰 전달
JwtStrategy가 payload.role 추출
req.user.role 생성
RolesGuard가 req.user.role 검사

이 흐름을 이해하면 인증과 인가의 큰 구조가 잡힌다.

인증
→ 너 누구야?
→ access_token이 유효한지 확인

인가
→ 너 이 기능 써도 돼?
→ role이 필요한 권한과 일치하는지 확인

23. JWT 안의 role을 사용할 때 주의할 점

현재 구조는 role을 JWT payload 안에 넣고 있다.

이 방식은 많이 사용된다.

장점은 빠르다는 것이다.

토큰 안에 role이 있음
→ 권한 검사 때 DB 조회를 하지 않아도 됨
→ 빠름

하지만 주의할 점도 있다.

예를 들어 사용자가 로그인할 당시 role이 USER였다고 해보자.

토큰 payload.role = USER

그런데 이후 DB에서 그 사용자의 role을 ADMIN으로 변경했다.

이미 발급된 토큰 안에는 여전히 USER가 들어 있다.

반대로 로그인 당시 ADMIN이었던 사용자를 DB에서 USER로 낮췄더라도, 기존 토큰이 만료되기 전까지는 토큰 안 role이 ADMIN일 수 있다.

즉 JWT payload의 role은 토큰 발급 시점의 권한이다.

그래서 실무에서는 보안 요구사항에 따라 선택이 필요하다.

1. JWT payload의 role을 그대로 신뢰한다.
   → 빠르다.
   → 매 요청마다 DB 조회가 없다.
   → 단, role 변경이 즉시 반영되지 않을 수 있다.

2. 토큰의 userId로 매번 DB에서 최신 role을 조회한다.
   → 더 정확하다.
   → role 변경이 즉시 반영된다.
   → 단, 요청마다 DB 조회 비용이 생긴다.

현재 구조는 1번에 가깝다.

초기 프로젝트나 일반적인 관리자 기능에서는 자연스러운 선택일 수 있지만, 권한 변경이 즉시 반영되어야 하는 서비스라면 2번 방식도 고려해야 한다.


24. 인증과 권한 흐름 최종 정리

로그인 이후 보호 API 요청 흐름을 한 번에 정리하면 다음과 같다.

1. 사용자가 로그인한다.

2. 서버가 access_token을 발급한다.
   payload = {
     email,
     sub: user.id,
     role
   }

3. 프론트가 access_token을 저장한다.

4. 보호 API 요청 시 Authorization 헤더에 토큰을 담는다.
   Authorization: Bearer access_token

5. JwtAuthGuard가 실행된다.

6. JwtStrategy가 토큰을 꺼내고 검증한다.

7. 토큰이 유효하면 validate(payload)가 실행된다.

8. validate() 반환값이 req.user가 된다.
   req.user = {
     userId,
     email,
     role
   }

9. RolesGuard가 실행된다.

10. RolesGuard가 @Roles() 메타데이터를 읽는다.

11. req.user.role과 필요한 role을 비교한다.

12. 권한이 맞으면 Controller 실행,
    권한이 부족하면 요청 차단

정리

이번 글에서는 JwtStrategy, JwtAuthGuard, RolesDecorator, RolesGuard를 기준으로 인증과 권한 검사 흐름을 정리했다.

각각의 역할은 다음과 같다.

JwtStrategy
→ Authorization 헤더에서 JWT를 꺼내고 검증한다.
→ 성공하면 payload를 req.user로 바꿔준다.

JwtAuthGuard
→ JwtStrategy를 실행해서 인증 여부를 검사한다.
→ 토큰 만료와 일반 인증 실패 응답을 구분한다.

RolesDecorator
→ @Roles(Role.ADMIN)처럼 API에 필요한 권한 정보를 붙인다.

RolesGuard
→ @Roles()에 적힌 권한과 req.user.role을 비교해서 통과 또는 차단한다.

가장 중요한 차이는 인증과 인가의 구분이었다.

인증
→ 이 사용자가 로그인한 사용자인가?
→ JwtAuthGuard가 담당

인가
→ 이 사용자가 이 기능을 사용할 권한이 있는가?
→ RolesGuard가 담당

지금까지 main.ts에서 시작해서 AppModule, PrismaModule, UsersModule, UsersService, DTO, Auth 흐름, JWT 인증과 권한 검사까지 정리했다.

처음에는 각각의 파일이 따로 떨어져 있는 것처럼 보였지만, 전체 흐름으로 보면 다음처럼 연결된다.

main.ts
AppModule
기능 모듈 등록
Controller
Service
PrismaService
DB

로그인 이후에는

AuthService
JWT 발급
JwtAuthGuard
JwtStrategy
req.user 생성
RolesGuard
권한 검사

이 흐름을 정리하면서 NestJS 백엔드의 큰 구조를 조금 더 명확하게 이해할 수 있다.

JwtStrategy, JwtAuthGuard, RolesGuard로 이해한 인증과 권한 검사 흐름 | OnlyMinkk Blog