본문 바로가기

[Nest]

[Nest] [클린코드 2편] Guard와 Decorator로 요청 처리 깔끔하게 구현하기

728x90

[Nest] [클린코드 1편] JWT 발급 및 인증 구조 깔끔하게 구현하기 (Postman API 테스트)

 

[Nest] [클린코드 1편] JWT 발급 및 인증 구조 깔끔하게 구현하기 (Postman API 테스트)

👋 소개안녕하세요! 대학생 개발자 주이어입니다.오늘은 Nest라는 새로운 카테고리로 찾아뵙게 되었습니다.Nest는 현재 제가 프로젝트를 만들 때 가장 많이 사용하고 있는 백엔드 프레임워크 인

blog.juyear.dev

이전 글 읽으러 가기!

 

👋 소개

안녕하세요! 대학생 개발자 주이어입니다.

오늘은 Nest 클린코드 두 번째 글이고, GuardDecorator에 대해서 정리해보려고 합니다!

 

1편에서 작성했던 코드에 이어서 진행할 예정이니, 혹시 "JWT 발급 및 인증" 부분에 대해서 잘 모르신다면 1편을 보고 오시는 것을 추천드립니다.

 

그럼 바로 본론으로 들어가도록 하겠습니다.


💡 Guard 란?

저번 1편에서 "verify" 라는 API를 만들어서 Token을 검증하는 작업을 테스트 했었는데요.

이 부분에서 Token을 확인하기 위한 API만 따로 만드는 경우는 실제 서비스에서 거의 없다고 했었습니다.

 

왜냐하면 Token을 검증하는 API를 따로 만들어서 관리하게 될 경우,

보호되어야 하는 모든 서버 자원에 대해서 "verify"를 호출해야하는 중복 & 비효율이 발생하기 때문입니다.

또한 이러한 방식은 Nest의 미들웨어-Guard 구조의 역할 분리에도 어긋나게 됩니다.
(Token 검증은 Nest에서 미들웨어보다 Guard 단계에서 수행하는 것이 바람직합니다.)

 

 

여기까지 읽으셨다면, Guard가 대충 어떤 역할을 하는지 감이 잡히셨을 것 같은데요.

Guard는 위에서 말한 컨트롤러에 도달하기 전에 요청을 검증하는 역할을 합니다.


⚙️ Guard 구현하기

Nest에서 Guard를 구현하는 방법은 크게 어렵지 않습니다.

Nest에서 제공하는 CanActivate 인터페이스를 구현해주기만 하면 되는데요.

코드를 조금씩 나눠서 확인해보도록 하겠습니다.

@Injectable()
export class JwtAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {

canActivate 함수를 선언하는 부분입니다.

 

기본적으로 canActivate 함수는 boolean 자료형을 반환하는 함수인데요. 

이 반환 값이 참이라면 controller로 요청이 전송되고, 거짓이거나 오류가 나온다면 controller로 전송되지 않습니다.

 

또한 canActivate는 ExecutionContext를 context로 입력받게 되는데, 여기엔 요청과 관련된 정보가 들어오게 됩니다.

(Guard 실행시 현재 요청 정보를 담은 ExecutionContext 객체를 생성하여 context에 저장합니다.)

const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers['authorization'];

if (!authHeader) throw new UnauthorizedException('No token provided');

const [, token] = authHeader.split(' ');

그 다음은 요청을 가져오고 Token을 추출하는 부분입니다.

ExecutionContext는 HTTP, GraphQL, WebSocket 등 다양한 요청 정보를 담을 수 있는 추상화 객체이기 때문에,

현재 요청이 어느 요청 객체인지 변환을 시켜줘야합니다.

이 때 사용되는 것이 switchToHttp() 입니다. 이 코드는 현재 요청을 HTTP 요청 객체로 변환시켜주는 역할을 합니다.

 

이렇게 변환시켜준 요청은 getRequest로 요청 정보에 접근할 수 있게 됩니다. 

요청 정보는 API에서 전달받는 요청 정보와 똑같은 구조를 가지고 있습니다.

따라서 headers, body 등에 접근할 수 있게 되는 것이죠.

 

headers에 접근할 수 있으니 Token 정보를 읽어오는 것은 저번 글에서 했던 것과 똑같이 해주시면 됩니다.

try {
  const decoded = jwt.verify(token, 'SECRET_KEY') as {
    id: number;
    username: string;
  };
  request.user = decoded;
  return true;
} catch (err) {
  throw new UnauthorizedException(`Token expired or invalid - ${err}`);
}

마지막으로 가져온 Token을 검증하고 값을 반환하는 부분입니다.

Guard를 사용하는 가장 핵심적인 이유는 결국 Token을 검증하고 controller에 요청을 전송시킬지 정하는 것이죠.

 

Token은 위에서 가져왔으니 검증만 진행해주면 됩니다.

이 부분 또한 저번 글에서 진행했던 것과 똑같이 진행됩니다.

 

조금 집중해서 볼 만한 부분이 있다면, request.user = decoded 부분 입니다.

코드 그대로 요청 정보에 user 데이터를 넣어주는 부분인데요.

이렇게 수정된 요청 정보는 controller에서도 수정된 채로 유지됩니다.

요청 정보를 수정하는 이유라면, Token을 그대로 controller에 보내면 결국 또 Token을 해석하는 작업을 거쳐야 하니 해석된 데이터를 사전에 미리 넣어주는 겁니다.

 

조금 복잡할 수 있지만, 크게 어려운 부분은 없었던 Guard 구현이었습니다.


🚀 Guard 사용하기

Guard를 만들었으니 이제 API에 적용을 시켜야겠죠.

하지만 그 전에 제대로 된 사용 예제를 확인하기 위해서 user API를 제작해 주었습니다.

// user controller
@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get('profile')
  getProfile(@Req() request: { user: { id: number; username: string } }) {
    const user = this.userService.findById(request.user.id);
    return {
      message: '유저 프로필',
      user,
    };
  }
}
// user service
@Injectable()
export class UserService {
  private users = [{ id: 1, username: 'juyear', password: '1234' }];

  findById(id: number) {
    const user = this.users.find((u) => u.id === id);
    return user;
  }
}

제가 제작한 예제는 유저 프로필을 가져오는 API 입니다.

유저 프로필 정보는 당연히 보호되어야 하는 서버 자원이라고 볼 수 있겠죠.

여기서 Guard를 사용하는 방법은 정말 간단한데요.

 

API 메서드 위에 UseGuards(적용시킬 Guard)를 적어주기만 하면 됩니다.

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Req() request: { user: { id: number; username: string } }) {
    const user = this.userService.findById(request.user.id);
    return {
      message: '유저 프로필',
      user,
	};
}

위에가 그 예시입니다.

UseGuards에 아까 만들었던 JwtAuthGuard를 넣어주었습니다.

 

여기까지 해주면 Guard를 제작하고 적용시키는 것 까지 성공적으로 마무리 되었습니다.


Guard 테스트 (Postman)

Postman login API 테스트

먼저 저번에 만들었던 login API를 사용하여 JWT를 발급 받습니다.

Postman profile 테스트

그 후 Guard를 적용시켰던 profile API에 Token을 넣고 요청을 진행합니다.

Postman profile 응답 - 1

그럼 위 사진처럼 유저 정보가 반환되는 것을 확인할 수 있습니다.

하지만 Guard가 제대로 작동하는지 확인해보기 위해서는 Token이 없거나, 값이 이상할 때를 확인해 봐야겠죠.

Postman profile 응답 - 2

저는 토큰이 없는 경우를 테스트 해봤습니다.

그랬더니 위와 같이 Guard 구현 때 작성해주었던 오류 메세지가 반환되었습니다.

이로써 Guard가 잘 작동하는 것을 확인할 수 있었습니다.


🎯 Decorator로 요청 정보 추출하기

request 출력 사진

Decorator를 왜 사용하는지는 위 사진을 보시면 이해할 수 있습니다.

위 사진은 Guard가 넘겨준 request 값을 콘솔에 출력한 값인데요.

밑에 부분에 자세히 보면, 저희가 사용하는 user 데이터도 보이긴 하지만, 나머지 값은 현재 controller에서 사용하지 않는 값입니다.

Decorator는 이러한 요청 데이터에서 필요한 데이터만 추출해 controller 메서드에 전달하는 역할을 합니다.

 

Decorator는 정말 간단하게 구현하고 사용할 수 있습니다.

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';

export const UserDecorator = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const request = context.switchToHttp().getRequest<Request>();
    return request.user;
  },
);

위 코드는 아까 사진에서 봤던 수많은 데이터 중에서 필요한 user 데이터만 가져와 반환하는 UserDecorator 입니다.

Decorator을 구현하기 위해서는 nest에서 제공하는 createParamDecorator 함수를 사용하시면 됩니다.

 

createParamDecorator는 data와 context를 매개변수로 입력받는데,

context는 Guard 구현할 때와 마찬가지로 현재 요청 정보 객체가 자동으로 들어가게 됩니다.

data 매개변수는 현재 데이터 처리에 필요한 값을 개발자 직접 넣어줄 때 사용되는 매개변수 입니다.

 

이렇게 해주면 요청 정보를 가져와 user 데이터만 추출 후 반환하는 UserDecorator가 완성되었습니다.

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@UserDecorator() request: { id: number; username: string }) {
    console.log(request);
    const user = this.userService.findById(request.id);
    return {
      message: '유저 프로필',
      user,
    };
}

만든 Decorator는 controller에서 @Req 대신 사용할 수 있습니다.

이제 Guard가 전달한 request를 UserDecorator가 필요한 데이터만 추출해서 controller에 반환하게 됩니다.

Decorator 이후 request 출력

이제 다시 request를 출력해보면 아까와 달리 필요한 데이터만 출력되는 것을 확인할 수 있습니다.

이를 통해 데이터를 더 효율적으로 관리할 수 있게 되었으며, 다음 글에서 다룰 DTO하고도 함께 활용할 수 있게 되었습니다.

 

이렇게 하면 Decorator를 구현하고 사용하는 것 까지도 완성되었습니다.


🤔 클린코드랑 무슨 관련이 있을까?

혹시 이게 왜 클린코드랑 관련이 있는지 잘 이해가 안되시는 분들을 위해 간단하게 설명을 준비했습니다.

  • Guard
    클린코드는 관심사의 분리, 가독성 및 유지보수 향상, 중복 제거 및 일관성 등과 관련이 있습니다.
    Guard가 없었다면, 위에서 설명했듯이 모든 API마다 토큰 검증 로직을 중복으로 작성해야 합니다.
    또한 controller가 인증 로직까지 처리해야 한다는 점에서 관심사의 분리 원칙도 만족하지 못 한다고 볼 수 있습니다.
    하지만 Guard를 사용함으로써 중복 코드를 제거하고, 유지보수 또한 쉽게 할 수 있으며, controller의 관심사도 분리할 수 있게 됩니다.
  • Decorator
    Decorator 또한 클린코드와 밀접한 관련이 있습니다.
    Decorator를 사용하지 않을 경우, controller는 기존 책임에 더해 "사용자 정보 추출" 이라는 책임도 가지게 됩니다.
    또한 user 데이터를 가져와야 하는 모든 API에 대해서 중복으로 코드를 작성해야 하고, 이는 유지보수에도 어려움을 줍니다.
    (@Req() request보다 @User() user가 가독성 면에서도 더 명확함)
    이러한 부분을 해결해주는 것이 Decorator라고 볼 수 있습니다.

결과적으로 Guard와 Decorator 모두 책임을 분리하고, 유지보수성을 올려주는 클린코드의 일부분입니다.


😊 마무리

이렇게 오늘 Guard와 Decorator를 사용하여 요청을 깔끔하게 처리하는 방법에 대해서 정리해보았습니다.

Guard를 사용한 미들웨어 구조 (JWT 검증), Decorator를 사용한 데이터 추출 모두 클린 코드에 필요한 중요한 부분들이니

Nest를 사용하신다면 꼭 한 번 직접 구현해보시는 것을 추천드립니다.

 

 

이번 글에서 진행한 것을 저번 글에서 적었던 것과 이어서 보면,

로그인 구현(JWT 발급) -> API 요청 -> Guard로 JWT 검증 -> Decorator로 데이터 추출

 

까지 진행이 되었습니다.

 

다음 글에서는 아까 살짝 말했던 DTO 개념에 대해서 정리하고, 구현 및 사용 방법에 대해서 정리해볼 예정입니다.

 

그럼 지금까지 읽어주셔서 감사드리며, 다음에 더 유익한 글로 찾아오도록 하겠습니다.

https://discord.gg/8Hh8WgM4zp

 

KYT CODING COMMUNITY Discord 서버에 가입하세요!

Discord에서 KYT CODING COMMUNITY 커뮤니티를 확인하세요. 25명과 어울리며 무료 음성 및 텍스트 채팅을 즐기세요.

discord.com

KYT CODING COMMUNITY 가입하기!

728x90