(41)

typeORM을 사용하면서 왜 N+1 문제를 마주하지 못했을까?

ORM을 사용하다보면 N + 1 문제를 마주하곤 하는데, 특히 ORM의 Default Fetch Type설정이 Lazy일 경우 더 그렇다. 이제 막 typeORM을 사용해보고 있다고 하시는 분과 커피챗을 할 기회가 생겼는데 typeORM에서는 N + 1을 어떻게 해결하냐는 얘기가 나왔었다. N + 1이 어떤 것인지는 알고 있었으나 나는 typeORM을 사용하면서 실제적으로 N + 1을 마주한 경험이 없다. 실제로 실무에서도 페이징을 위한 paginate 라이브러리 사용 시 Distinct로 클러스터 인덱스를 가져와서 리스트, 페이징에 총 세 번의 쿼리를 사용하는 경우를 제외하고는 본 적이 없다. 왜 그럴까 곰곰이 생각을 해봤다. 최근에 nest에서 graphQL을 사용하고자 했을 때에도 N + 1 문제를..

graphQL의 N + 1문제와 DataLoader

graphQL에 대해 알아보자 - 1 (with NestJS, typeORM)학습을 위해 생성한 예제 코드는 깃헙에 있습니다.(링크)  graphQLgraphQL은 기존 데이터로 쿼리를 실행하기 위한 API를 위한 쿼리 언어이자 런타임이다. 클라이언트가 필요한 것만 정확히 요청할mag1c.tistory.com  N + 1이전 글의 예제에서 Post를 가져오는데에 Post와 Comments는 1:N 관계를 가진다.이 관계에서 comments를 조회할 때 comment가 lazy loding되어 N + 1 문제가 발생할 수 있다. //lazy loadingasync findAll(authorId?: number): Promise {    if (authorId) return await this.postRep..

graphQL에 대해 알아보자 (with NestJS, typeORM)

GitHub - mag123c/nest-graphQLContribute to mag123c/nest-graphQL development by creating an account on GitHub.github.com graphQLgraphQL은 기존 데이터로 쿼리를 실행하기 위한 API를 위한 쿼리 언어이자 런타임이다. 클라이언트가 필요한 것만 정확히 요청할 수 있게 해준다. 공식 문서의 설명을 읽어보면 자세한 특징을 서술해두었고, 읽어보면 공통적으로 나오는 키워드들은 빠르다, 단일 요청, 쿼리와 타입 정도가 있다. 또한 공식 문서 내의 포스팅 중 REST와 비교한 글이 있었는데, "graphQL은 REST와 크게 다르지 않지만, API를 구축하고 소비하는 개발자 경험에 큰 차이를 만드는 작은 몇가지의 변..

[NestJS] enum과 literal type 중 어떤걸 사용할까? (feat. Tree-shaking, Template Literal Ty

정확한 정보 전달이 아닌, 여러 좋은 포스팅들을 보며 적용해보고개인의 관점에서의 의견 서술입니다. 여러 피드백들을 적극 환영합니다.    요약트리 쉐이킹(Tree Shaking)은 번들링 시 사용하지 않는 불필요한 코드를 제거하는 최적화 작업을 말한다.프론트에서의 트리쉐이킹은 번들의 크기를 최소화해서 UX의 향상에 목적이 있다고 하지만 백엔드 관점에서의 최적화는 코드의 안정성, 유지보수 등에 초점이 맞춰지고, 프로젝트의 특성과 요구사항과 등을 고려하는 것이 좋다고 생각한다.TypeScript4.1에 추가된 Template Literal Type처럼, 명시된 타입들을 조합하는 복잡한 타입 조합이 필요하지 않을 경우, 이넘을 사용하는 것이 어떠한 이넘 값으로 강제되기 때문에 오히려 더 명확한 의도를 전달할 ..

[NestJS] JWT Token 인증 - (3) Refresh Token 사용

이전 글에서 이어집니다. [NestJS] JWT Token 인증(Access Token) 구현하기(with passport) - (2) 서론 지난글에 이어, Nest에서 JWT를 사용한 인증을 진행해보려한다. [NestJS] JWT Token 인증 - (1) JWT 토큰이란? [NestJS] JWT Token 인증 - (1) JWT 토큰이란? 쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Sessio mag1c.tistory.com Access Token의 한계 Access Token을 통해 웹사이트를 이용하다, 토큰이 만료되면 다시 재로그인을 해야만 하는 불편함을 겪게 된다. 또한 Access Token 만료 전 토큰을 탈취당하게 된다면 토큰이 만료될 때까지 속수무책이라는 보안상의 문제도 있다..

[NestJS] JWT Token 인증(Access Token) 구현하기(with passport) - (2)

서론 지난글에 이어, Nest에서 JWT를 사용한 인증을 진행해보려한다. [NestJS] JWT Token 인증 - (1) JWT 토큰이란? [NestJS] JWT Token 인증 - (1) JWT 토큰이란? 쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Session) HTTP 프로토콜의 특징 비연결성 ( Connectionless ) 클라이언트가 서버에 요청(Request)할 때, 그에 대한 응답(Response)을 한 후, 연결을 끊는다. 비상태 mag1c.tistory.com NestJS에서 제공하는 공식 문서의 인증 관련 페이지를 참조했다. Documentation | NestJS - A progressive Node.js framework Nest is a framework for b..

[NestJS] JWT Token 인증 - (1) JWT 토큰이란?

쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Session) HTTP 프로토콜의 특징 비연결성 ( Connectionless ) 클라이언트가 서버에 요청(Request)할 때, 그에 대한 응답(Response)을 한 후, 연결을 끊는다. 비상태성 ( Stateless ) 클라이언트의 상태 정보를 가지지 않는 mag1c.tistory.com 쿠키는 인증이 필요한 요청을 할 때마다 쿠키를 던져 요청하는 동작 구조를 가진다. 쿠키를 통한 인증 시 다음과 같은 단점들이 존재한다. 쿠키의 단점 1. 민감 정보들을 노출당하기 쉽고 조작당하기도 쉽다. 2. 웹 브라우저마다 지원 형태가 다르기 때문에 공유가 불가능하다. 3. 쿠키의 사이즈 제한(4KB)이 있어 충분한 데이터를 담을 수 없다. 4. 서버에 매..

[NestJS] typeorm의 transaction

서론 JWT를 구현하는 도중, 아래와 같은 에러가 발생했다. //회원가입 및 JWT TOKEN 발급 코드 async signup(signInDto: UserSigninDto) { const user = await this.userService.findById(signInDto.id); const exist = user != null; if (exist) { throw new BadRequestException('중복된 아이디 존재'); } else { const valiUser = new UserEntity(); const hashedPassword = this.hash(signInDto.pw); valiUser.id = signInDto.id; valiUser.pw = hashedPassword; co..

[NestJS] 예외처리, Exception Filter (HttpException, Error Handling]

서론 노드로 전향한지 만 1개월이 되었다. 현재 근무하고 있는 곳의 애플리케이션 코드를 보면, 따로 예외처리를 해주는 부분이 없어 에러 핸들링과 에러 로깅 작업을 커스텀으로 진행하려고 한다. 이를 위해 공식 문서를 활용해가며 학습할 필요를 느껴서 공식문서에 해당 내용을 확인하고 학습해보자. NestJS의 예외 처리 Nest에는 애플리케이션 내에서 처리되지 않은 모든 예외를 처리하는 내장 예외 레이어가 존재한다. 애플리케이션 코드에서 예외 처리가 되지 않으면 예외 레이어에서 예외를 처리한다. 기본적으로 이 작업은 HttpException유형과 하위 클래스의 예외를 처리하는 내장 전역 예외 필터에 의해 수행된다. 예외를 인식할 수 없는 경우 다음과 같은 500에러를 내보낸다. { "statusCode": 5..

typeORM을 사용하면서 왜 N+1 문제를 마주하지 못했을까?

Tech/JavaScript & TypeScript 2024. 8. 27. 17:37
728x90

 
ORM을 사용하다보면 N + 1 문제를 마주하곤 하는데, 특히 ORM의 Default Fetch Type설정이 Lazy일 경우 더 그렇다.
 
이제 막 typeORM을 사용해보고 있다고 하시는 분과 커피챗을 할 기회가 생겼는데 typeORM에서는 N + 1을 어떻게 해결하냐는 얘기가 나왔었다.
N + 1이 어떤 것인지는 알고 있었으나 나는 typeORM을 사용하면서 실제적으로 N + 1을 마주한 경험이 없다.
 
실제로 실무에서도 페이징을 위한 paginate 라이브러리 사용 시 Distinct로 클러스터 인덱스를 가져와서 리스트, 페이징에 총 세 번의 쿼리를 사용하는 경우를 제외하고는 본 적이 없다.
 
왜 그럴까 곰곰이 생각을 해봤다. 최근에 nest에서 graphQL을 사용하고자 했을 때에도 N + 1 문제를 마주했으며
그 때는 DataLoader로 해결했었다.
 

graphQL의 N + 1문제와 DataLoader

graphQL에 대해 알아보자 - 1 (with NestJS, typeORM)학습을 위해 생성한 예제 코드는 깃헙에 있습니다.(링크)  graphQLgraphQL은 기존 데이터로 쿼리를 실행하기 위한 API를 위한 쿼리 언어이자 런타임이다.

mag1c.tistory.com

 
 
마주하지 않는 문제더라도, 궁금증이 생겨서 직접 실험해보고 포스팅을 남기기로 했다.
 
 


 
 
 

import { UpdatableEntity } from 'src/abstract/base.entity';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Enrollment } from './enrollment.entity';
import { Teacher } from './teacher.entity';

@Entity('lecture', { database: 'test' })
export class Lecture extends UpdatableEntity {
  @Column('int', { name: 'teacher_id', nullable: false })
  teacherId!: number;
  
  @Column('varchar', { length: 100, unique: true, nullable: false })
  title!: string;
  
  @Column('text', { nullable: false })
  content!: string;
  
  @Column('int', { nullable: false })
  price!: number;
  
  @OneToMany(() => Enrollment, (enrollments) => enrollments.lecture)
  @JoinColumn({ referencedColumnName: 'enrollmentId' })
  enrollments?: Enrollment[];
  
  @ManyToOne(() => Teacher, (teacher) => teacher.lectures, { eager: true })
  @JoinColumn({ name: 'teacher_id', referencedColumnName: 'id' })
  teacher?: Teacher;
}

 
나는 엔터티의 관계 매핑을 할 때 RelationOptions에 loding (eager, lazy) 옵션을 따로 두지 않는다.
예를 들어 위와 같은 강의 엔터티가 있을 때 연관 관계가 있는 테이블이 있을 때, 따로 옵션 설정을 하지 않는다.
 
왜?
필요하면 relations을 명시해주거나, QueryBuilder로 JoinSelect을 하여 사용하기 때문이다.
알고보니 자연스러운 이 코드 작성 습관이 알아서 N + 1 문제를 직면하지 않도록 만들어 주었다. (좋은건지는 모르겠다.)

 
 
만약 위처럼 강의 도메인에서, 강사 명이 필요하거나 수강생 정보가 필요하다면 아래처럼 작성 할 것이다.

type TLectureList = Pick<Lecture, 'title' | 'content' | 'price' | 'teacher'>[];

//강의 리스트 반환.
async findLecturesWithTeachers(): Promise<TLectureList> {
    return await this.createQueryBuilder('l')
      .innerJoinAndSelect('l.teacher', 't')
      .select(['l.title, l.content, l.price', 't.name'])
      .getMany();
}

 
 
물론 아래처럼 Repository API의 find를 사용하여 relations을 명시해 줄 수 있다.
하지만 관계를 명시한 하위 테이블의 필드는 선택할 수 없다. 그렇기 때문에 개인적으로는 OverFetching 때문에 사용하지 않는 편이다.

//강의 리스트 반환.
async findLecturesWithTeachers(): Promise<TLectureList> {
    return await this.find({
      select: ['title', 'content', 'price'],
      relations: ['teacher'],
    });
}

 
 
 
정리해보면
 
1. RelationOptions의 Loding 설정을 따로 명시하지 않는다.
2. 쿼리 빌더를 주로 사용한다. (스칼라 서브쿼리나 인라인 뷰가 다수 들어가는 복잡한 쿼리는 raw query를 사용한다.)
 
이런 습관들 때문에 N + 1 문제를 인지하지 못하고 있었다.
 
 
typeORM의 Default Fetch Type을 알아보고자 공식문서를 뒤져봤지만 디폴트 타입에 대한 명시는 없었다.
Lazy Loding을 사용하고 싶으면 반드시 비동기처리를 하라는 주의사항 밖에 없었다.
 

/**
 * Describes all relation's options.
 */
export interface RelationOptions {
    //...생략...
    
    lazy?: boolean;
    eager?: boolean;
    
    //...생략...
}

 
코드를 열어보면,  둘 다 옵셔널인 것을 보아하니 default는 없는 것 같다. 실제로 확인해보기 위해 아래 코드를 돌려보았다.
 
 

async findLectures(): Promise<void> {
    const lecture = await this.lectureRepo.findOneBy({ id: 1 });
    console.log(lecture);
    console.log('Teacher: ', lecture?.teacher);
}

 
옵션이 없을 때는 1개의 쿼리를 실행했고
Lazy Loding이 적용되었을 때는 Teacher을 찾기 위한 N + 1 쿼리가 발생했다.
마지막으로 Eager Loding에서는 Lecture를 조회할 때 이미 Teacher가 같이 등장한다.
 
 
 


 
 
 

정리

1. typeORM에서 Lazy Loding은 프로미스 객체로 반환된다.

Note: if you came from other languages (Java, PHP, etc.) and are used to use lazy relations everywhere - be careful. Those languages aren't asynchronous and lazy loading is achieved different way, that's why you don't work with promises there. In JavaScript and Node.JS you have to use promises if you want to have lazy-loaded relations. This is non-standard technique and considered experimental in TypeORM.

 
공식문서에서 언급했듯이, 아래처럼 변경해주면 되겠다.

async findLectures(): Promise<void> {
    const lecture = await this.lectureRepo.findOneBy({ id: 1 });
    console.log(lecture);
    console.log('Teacher: ', await lecture?.teacher);
}

 
 
 

2. 현재의 습관

현재의 코드 습관들이 자연스레 OverFetching과 N + 1 문제를 피하고 있었다.
사내에서는 혼자 백엔드 개발을 하다보니 코드 레벨에서의 관점을 나눌 사람이 없었다.
외부로 눈을 돌려 typeORM을 사용하는 다른 분들과 커피챗을 통해 관련된 코드 습관들이 올바른 방향인지 점검할 필요는 있을 것 같다.

300x250
mag1c

mag1c

2년차 주니어 개발자.

graphQL의 N + 1문제와 DataLoader

Tech/JavaScript & TypeScript 2024. 6. 29. 22:34
728x90
 

graphQL에 대해 알아보자 - 1 (with NestJS, typeORM)

학습을 위해 생성한 예제 코드는 깃헙에 있습니다.(링크)  graphQLgraphQL은 기존 데이터로 쿼리를 실행하기 위한 API를 위한 쿼리 언어이자 런타임이다. 클라이언트가 필요한 것만 정확히 요청할

mag1c.tistory.com

 
 

N + 1

이전 글의 예제에서 Post를 가져오는데에 Post와 Comments는 1:N 관계를 가진다.
이 관계에서 comments를 조회할 때 comment가 lazy loding되어 N + 1 문제가 발생할 수 있다.

 

//lazy loading
async findAll(authorId?: number): Promise<Post[]> {
    if (authorId) return await this.postRepo.find({ where: { authorId: authorId } });
    return await this.postRepo.find();
}

//eager loading
async findAll(authorId?: number): Promise<Post[]> {
    const options: FindManyOptions<Post> = {
        relations: ['author', 'comments', 'comments.author']
    };

    if (authorId) {
        options.where = { authorId: authorId };
    }

    return await this.postRepo.find(options);
}

 
 
 

graphQL의 N + 1

미리 join연산을 통해 fetch하더라도 N + 1문제가 발생할 수 있다.
이전 글과 동일하게 데이터를 post 10개, user 10명, comment는 post당 10개로 설정했다.

//Post Resolver
@ResolveField(() => User)
async author(@Parent() post: Post): Promise<User> {
    return await this.userService.findOne(post.authorId);
}

@ResolveField(() => [Comment])
async comments(@Parent() post: Post): Promise<Comment[]> {
    return await this.postService.getCommentsByPostId(post.id);
}


//Comment Resolver
@ResolveField(() => User)
async author(@Parent() comment: Comment): Promise<User> {
    return await this.userService.findOne(comment.authorId);
}

 
 
기존의 코드는 각 리졸버에서 개별적으로 가져오는 방식으로 동작하기때문에 아래와 같이 동작한다.

  1. 10개의 post를 가져오는 쿼리
  2. 각 post에 대해 author를 가져오는 10개의 쿼리
  3. 각 post에 대해 comments을 가져오는 10개의 쿼리
  4. comments 각각의 author을 가져오는 쿼리 (게시물 당 10개의 댓글 - 10 x 10)

이러한 방식으로 총 121개의 쿼리가 실행된다;;;;;;

 
 
만약 리졸버에서 join해서 fetch하더라도, 데이터를 실제로 가져오는 백단에서는 여전히 OverFetching이 발생한다.
 
OverFetching을 방지하고자 select를 커스터마이징할 수도 없다. 일관성을 해치고 다른 쿼리를 위해 추가로 코드를 작성해야한다. 다른 잠재적인 문제들도 발생할 것이다.
 
엔터티 간의 관계를 명시할때, TypeORM에서 eager을 제공해주긴 하지만, 위의 경우는 이런 속성들로 해결할 수 없다.
 
 
 

DataLoader

dataloader은 페이스북에서 만든 라이브러리로 여러 요청을 하나의 배치로 묶어서 한 번에 데이터베이스에 요청을 보낼 수 있다.
 

@Injectable({ scope: Scope.REQUEST })
export class UserLoader {

    constructor(
        private readonly userRepo: UserRepository,
    ) { }

    findById = new DataLoader<number, User>(
        async (authorIds: number[]) => {
            const user: User[] = await this.userRepo.findByIds(authorIds);
            return authorIds.map((id: number) => user.find((user: User) => user.id === id));
        }
    );
}
@Injectable({ scope: Scope.REQUEST })
export class CommentLoader {
    constructor(
        private readonly commentRepo: CommentRepository,
    ) { }
    findByPostId = new DataLoader<number, Comment[]>(
        async (postIds: number[]) => {
            const comments: Comment[] = await this.commentRepo.findByPostIds(postIds);
            return postIds.map((id: number) => comments.filter((comment: Comment) => comment.postId === id));
        }
    )
}

 
 
DataLoader를 사용하면 각 요청이 배치로 처리되어 쿼리 수가 대폭 줄어든다.

@Resolver(of => Post)
export class PostResolver {
    constructor(
        private postService: PostService,
        private userLoader: UserLoader,
        private commentLoader: CommentLoader,
    ) {  }

    @Query(() => [Post])
    posts(): Promise<Post[]> {
        return this.postService.findAll();
    }

    @ResolveField(() => User)
    author(@Parent() post: Post): Promise<User> {
        console.log(this.userLoader.findById)
        return this.userLoader.findById.load(post.authorId);
    }

    @ResolveField(() => [Comment])
    comments(@Parent() post: Post): Promise<Comment[]> {
        return this.commentLoader.findByPostId.load(post.id);
    }

}

 
 
반면 DataLoader은 각 요청이 배치처리되어 필요할 때 한번에 데이터를 요청하기 때문에 쿼리 수가 대폭 줄어들게 된다.

  1. post 10개를 가져오는 쿼리
  2. post 각각의 author을 한 번에 가져오는 쿼리
  3. comments를 한 번에 가져오는 쿼리

 
 
DataLoader 인스턴스가 생성되고 내부적으로 요청을 수집한다. 아래의 로그는 posts에 대한 각 author들의 정보를 받아오는 DataLoader의 인스턴스 로그이다. load(1), load(2)와 같은 함수 호출에서 DataLoader의 배치된 요청들은 tick에서 한 번에 처리하여 데이터베이스에 fetch요청을 보낸다.

DataLoader {
  _batchLoadFn: [AsyncFunction (anonymous)],
  _maxBatchSize: Infinity,
  _batchScheduleFn: [Function (anonymous)],
  _cacheKeyFn: [Function (anonymous)],
  _cacheMap: Map(0) {},
  _batch: null,
  name: null
}
DataLoader {
  _batchLoadFn: [AsyncFunction (anonymous)],
  _maxBatchSize: Infinity,
  _batchScheduleFn: [Function (anonymous)],
  _cacheKeyFn: [Function (anonymous)],
  _cacheMap: Map(1) { 1 => Promise { <pending> } },
  _batch: { hasDispatched: false, keys: [ 1 ], callbacks: [ [Object] ] },
  name: null
}
DataLoader {
  _batchLoadFn: [AsyncFunction (anonymous)],
  _maxBatchSize: Infinity,
  _batchScheduleFn: [Function (anonymous)],
  _cacheKeyFn: [Function (anonymous)],
  _cacheMap: Map(2) { 1 => Promise { <pending> }, 2 => Promise { <pending> } },
  _batch: {
    hasDispatched: false,
    keys: [ 1, 2 ],
    callbacks: [ [Object], [Object] ]
  },
  name: null
}

(...생략...)

DataLoader {
  _batchLoadFn: [AsyncFunction (anonymous)],
  _maxBatchSize: Infinity,
  _batchScheduleFn: [Function (anonymous)],
  _cacheKeyFn: [Function (anonymous)],
  _cacheMap: Map(10) {
    1 => Promise { <pending> },
    2 => Promise { <pending> },
    3 => Promise { <pending> },
    4 => Promise { <pending> },
    5 => Promise { <pending> },
    6 => Promise { <pending> },
    7 => Promise { <pending> },
    8 => Promise { <pending> },
    9 => Promise { <pending> },
    10 => Promise { <pending> }
  },
  _batch: {
    hasDispatched: false,
    keys: [
      1, 2, 3, 4,  5,
      6, 7, 8, 9, 10
    ],
    callbacks: [
      [Object], [Object],
      [Object], [Object],
      [Object], [Object],
      [Object], [Object],
      [Object], [Object]
    ]
  },
  name: null
}

 
 
 
아래는 DataLoader의 load 함수다. 요청을 배치로 묶어 처리하고, 캐싱을 통해 중복을 방지하는 역할 또한 수행한다.
코드를 간략히 살펴보면, 배치를 가져와 캐시 키를 생성하고 캐시의 활성 여부에 따라 promise를 재활용할지 생성할지를 결정해 캐시를 업데이트 후 promise를 반환한다. 그래서 위 예제에서, 1부터 10번까지의 key에 대해 배치를 쌓아나갈 수 있던 것이다.

_proto.load = function load(key) {
    if (key === null || key === undefined) {
      throw new TypeError('The loader.load() function must be called with a value, ' + ("but got: " + String(key) + "."));
    }

    var batch = getCurrentBatch(this);
    var cacheMap = this._cacheMap;

    var cacheKey = this._cacheKeyFn(key); // If caching and there is a cache-hit, return cached Promise.


    if (cacheMap) {
      var cachedPromise = cacheMap.get(cacheKey);

      if (cachedPromise) {
        var cacheHits = batch.cacheHits || (batch.cacheHits = []);
        return new Promise(function (resolve) {
          cacheHits.push(function () {
            resolve(cachedPromise);
          });
        });
      }
    } // Otherwise, produce a new Promise for this key, and enqueue it to be
    // dispatched along with the current batch.


    batch.keys.push(key);
    var promise = new Promise(function (resolve, reject) {
      batch.callbacks.push({
        resolve: resolve,
        reject: reject
      });
    }); // If caching, cache this promise.

    if (cacheMap) {
      cacheMap.set(cacheKey, promise);
    }

    return promise;
}

 
 

이벤트 루프와 태스크 큐에서의 DataLoader 처리
DataLoader은 Node의 이벤트 루프와 태스크 큐를 활용하여 요청을 배치로 처리한다.
Promise는 다음 tick에서 실행되므로 배치 처리는 여러 요청을 수집한 후 다음 tick에서 한 번에 처리할 수 있게 된다.

 
 
 
 
 
 
 

참조

 

GitHub - graphql/dataloader: DataLoader is a generic utility to be used as part of your application's data fetching layer to pro

DataLoader is a generic utility to be used as part of your application's data fetching layer to provide a consistent API over various backends and reduce requests to those backends via batching...

github.com

 

 

GraphQL DataLoader를 이용한 성능 최적화

이번 포스팅에서는 GraphQL 에서 N+1 문제를 해결하기 위한 솔루션인 DataLoader에 대한 소개와 GraphQL 에 DataLoader를 어떤식으로 적용해야되는지를 정리해보려고 한다. N+1 문제N+1 문제는 ORM을 사용할때

y0c.github.io

 
 

 

typeorm/docs/eager-and-lazy-relations.md at master · typeorm/typeorm

ORM for TypeScript and JavaScript. Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, SAP Hana, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms. -...

github.com

 

 

nestjs-dataloader

A NestJS decorator for dataloader. Latest version: 9.0.0, last published: 2 years ago. Start using nestjs-dataloader in your project by running `npm i nestjs-dataloader`. There are 5 other projects in the npm registry using nestjs-dataloader.

www.npmjs.com

 

 

Node.js — The Node.js Event Loop

Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

nodejs.org

 
 

300x250
mag1c

mag1c

2년차 주니어 개발자.

graphQL에 대해 알아보자 (with NestJS, typeORM)

Tech/JavaScript & TypeScript 2024. 6. 26. 18:05
728x90

 

GitHub - mag123c/nest-graphQL

Contribute to mag123c/nest-graphQL development by creating an account on GitHub.

github.com

 


 
 

graphQL

graphQL은 기존 데이터로 쿼리를 실행하기 위한 API를 위한 쿼리 언어이자 런타임이다. 클라이언트가 필요한 것만 정확히 요청할 수 있게 해준다.
 
공식 문서의 설명을 읽어보면 자세한 특징을 서술해두었고, 읽어보면 공통적으로 나오는 키워드들은 빠르다, 단일 요청, 쿼리와 타입 정도가 있다.
 
또한 공식 문서 내의 포스팅 중 REST와 비교한 글이 있었는데, "graphQL은 REST와 크게 다르지 않지만, API를 구축하고 소비하는 개발자 경험에 큰 차이를 만드는 작은 몇가지의 변화를 가지고 있다"고 한다.

 
 
 

REST API의 문제점

OverFetching

아래의 페이지는, 현재 근무하는 회사의 서비스인 아이웨딩의 페이지 일부이다.
 

 
 
이 페이지를 구성하기 위해 적어도 해당 카테고리에 맞는 상단 배너와 베스트 상품, 리스트 API로 데이터를 받아와서 화면을 구성해야 할 것이다.
 

GET api/v1/banner?category=${category}
GET api/v1/products/best?category=${category}
GET api/v1/products?category=${category}

 

 
 
상품의 리스트 부분에서, 클라이언트에게 필요한 것은 브랜드명, 상품명, 가격 정보다.
하지만 product 내에 해당 정보를 포함한 다른 정보들도 들어 있는 경우도 많다. 수 년 동안 같은 리스트의 정보에 대한 요구사항이 변했을 수도 있고, 그런 이유가 아니더라도 API의 규격에서 확장하거나 축소해서 커스텀하는 경우도 있을 것이다. (이런걸 REST라고 해야하나..?)
 
우리의 Product List API 또한 그런 형태가 되어있었다.
 

 
 
 
굳이 클라이언트에게 필요 없는 데이터들이 REST 에서는 같이 나가게 되는 경우가 빈번하고 이럴 경우 네트워크의 대역폭이 낭비되게 된다. 데이터가 필요 이상으로 커지기 때문이다. 이런 현상을 OverFetching이라고 한다
 
 

UnderFetching

이번엔 외부로 시선을 돌려보았다.

출처: MrBeast Youtube

 
영상의 댓글을 가져오기 위해 나는 GET videos/1/comments API를 호출했다. 하지만 comments API의 응답에는 댓글을 쓴 사람의 nickname, thumbnail 정보가 포함되어 있지 않다.
 
따라서 댓글 작성자의 정보를 얻기 위해 추가적으로 GET user/{userId} API를 호출해서 유저 정보를 받아와야 한다.
(만약 클라이언트 사이드의 모든 상황에서 comments에 유저 정보를 필요로 한다면 comments API 자체를 커스터마이징하면 된다.)
 
이 때 두가지 문제가 발생하는데, 첫 번째는 해당 API에서 원하는 정보를 모두 가져오지 못해 추가적인 API 호출이 이루어졌다는 점이고, 두 번째는 user API 역시 nickname, thumbnail을 포함한 유저 정보가 들어있기 때문에 OverFetching이 일어났다는 점이다.
 
이렇게 하나의 API에서 모든 데이터를 처리하지 못해 추가로 요청하여 처리해야하는 현상을 UnderFetching이라 하며, UnderFetching은 추가 데이터 요청 과정에서 OverFetching까지 초래할 수 있다.
 
 
 

graphQL로 전환

 
 
다시 위 사진에서 graphQL API인 POST /graphql 로 요청을 보낸다면 아래처럼 응답을 받을 수 있다.

 
딱 원하는 데이터에 대한 응답만 받을 수 있는 것이다.
 
 
 

graphQL의 단점

캐싱

개발을 하면서, 304(Not Modified)라는 상태코드를 본 적이 있을 것이다. 요약하자면 클라이언트가 요청한 리소스가 변경되지 않았음을 나타내는 이 상태 코드는 클라이언트가 캐시된 데이터를 사용할 수 있게 한다. 이는 서버의 응답을 줄이고 네트워크 트래픽을 절약하는 데 유용하다.
 
HTTP에서 제공하는 캐싱 정책은 단순히 헤더에 명시하는 것만으로 쉽게 적용할 수 있다. 또한 HTTP 캐싱 전략은 각 URL에 고유한 정책을 설정하는 형태로 이루어진다. REST API는 각 엔드포인트마다 다른 캐싱 정책을 설정할 수 있어 HTTP의 캐싱 전략을 그대로 사용할 수 있다. 그러나 graphQL은 단일 엔드포인트인 /graphql로 들어오기 때문에 기존의 HTTP 캐싱 전략을 적용하기 어렵다.
 
이로 인해 서버와 클라이언트 측 모두 적절한 캐싱 처리를 구현해야 하며, 캐싱 전략도 별도로 사용해야 한다.
 
 

기타

이 외에도, 파일 업로드를 위한 추가처리, REST와 달리 버전 관리의 어려움의 단점이 있으며
graphQL 스키마 설계와 리졸버, 쿼리 최적화 등의 높은 러닝커브 또한 단점으로 꼽힌다.
 


 
 
 
 

Nest + TypeORM + graphQL

apollo, mercurius을 기본적으로 지원하고, 커스텀 드라이버도 사용할 수 있다고 한다. 나는 apollo를 사용했고, 접근성이 좋은 블로그나 SNS 등의 일반적인 포맷인 게시물 + 유저 형태로 코드를 작성해가며 학습했다.
 
리졸버의 사용에 익숙해지고, graphQL의 데코레이터들에만 익숙해진다면 금방 따라갈 수 있다.
 
내가 학습하면서 기록하고 싶은 부분들을 서술해보고자 한다.
 

독립적으로 구현 가능

posts API에서 우리는 정책상 comments, author을 모두 들고오기로 했다고 해보자.

@Injectable()
export class PostService {

    constructor(
        private postRepo: PostRepository,
    ) {}

    async getPostsAuthorAndComments(authorId?: number) {
        return await this.postRepo.findPostsAuthorAndComments(authorId);
    }
}
@Injectable()
export class PostRepository implements BaseRepository<Post> {

    constructor(
        @InjectRepository(Post) private postRepo: Repository<Post>,
    ) { }

    async findPostsAuthorAndComments(authorId?: number): Promise<Post[]> {
        const query = this.postRepo.createQueryBuilder('post')
            .leftJoinAndSelect('post.author', 'user')
            .leftJoinAndSelect('post.comments', 'comment')
            .leftJoinAndSelect('comment.author', 'commentAuthor')
            
        if (authorId) {
            query.where('post.authorId = :authorId', { authorId });
        }
        
        return await query.getMany();
    }
}

 
 
위와 같이 작성한다면 findPostsAuthorAndComments 함수는 authorId에 따라 포스트와 관련된 모든 데이터를 한번에 가져온다. 아래처럼 완전 분리해서 구현도 가능하다.(커넥션이 많아져서 선호하진 않지만)
 

@Injectable()
export class PostService {

    constructor(
        private postRepo: PostRepository,
        private commentRepo: CommentRepository,
        private userRepo: UserRepository,
    ) {}

    async getPostsAuthorAndComments() {
        const posts = await this.postRepo.findAll();
        await Promise.all(posts.map(async (post) => {
            post.author = await this.userRepo.findOne(post.authorId);
            post.comments = await this.getCommentsByPostId(post.id);
        }));
        return posts;
    }
}

 
 
리졸버에서도, 똑같이 ResolveField 데커레이터를 사용해서 쿼리의 데이터 내부의 관계에 따라 추가적인 동작을 할 수 있다.

@Resolver(of => Post)
export class PostResolver {
    constructor(
        private postRepo: PostRepository,
        private userRepo: UserRepository,
        private commentRepo: CommentRepository,
    ) {  }

    @Query(() => [Post])
    async posts(
        @Args('authorId', { type: () => Number, nullable: true }) authorId?: number,
    ): Promise<Post[]> {
        console.log('전체 글 목록을 가져오는 리졸버')
        return this.postRepo.findAll(authorId);
    }

    @ResolveField(() => User)
    async author(@Parent() post: Post): Promise<User> {
        console.log('글의 작성자를 가져오는 리졸버')
        return await this.userRepo.findOne(post.authorId);
    }

    @ResolveField(() => [Comment])
    async comments(@Parent() post: Post): Promise<Comment[]> {
        console.log('코멘트를 가져오는 리졸버')
        return await this.commentRepo.findByPostId(post.id);
    }
    
}

 

@Resolver(of => Comment)
export class CommentResolver {
    constructor(private userRepo: UserRepository) {}

    @ResolveField(() => User)
    async author(@Parent() comment: Comment): Promise<User> {
        console.log('코멘트의 작성자를 가져오는 리졸버')
        return await this.userRepo.findOne(comment.authorId);
    }
}

 
@ResolvedField는 특정 엔터티의 필드가 다른 엔터티와 연관이 되어 있을 때, 해당 연관된 데이터를 가져오는 로직을 정의하는 데 사용된다. 위처럼 각 로직들을 개별 메서드로 분리하여 코드의 가독성과 유지보수를 높일 수 있다.
 

 
이런 요청을 보내면, 순차적으로 posts를 찾고 posts에 대한 author, posts에 대한 comments를 찾은 뒤 각 코멘트의 author을 찾는 순서로 이루어진다. ResolvedField로 관계 매핑을 확인하여 해당 로직으로 데이터를 가져와 넣어주게 된다.
 

(글을 2개써서 2번의 글 작성자를 가져왔다)

 
 
 
 
 
 

InputType, ObjectType, ArgsType

이 데커레이터들은 데이터를 주고 받는 구조를 명확히 정의할 수 있는 데커레이터들로, 데이터 교환이 일관되고 예측 가능하게 한다. nest에서는 이 위에 Validation, Trasnforming등을 할 수 있어 강력한 검증이 가능하다.
기존의 Dto를 graphQL을 사용하기 위해 데커레이터를 덧씌워준다고 생각하면 되겠다. gql 타입 정의를 통해 자동 문서화도 된다.
 
@InuptType()은 주로 뮤테이션의 인자로 사용되는 입력 객체를 정의할 때 사용한다.
@ObjectType()은 객체 타입을 정의할 때 사용하고, 데이터를 반환할 때 사용되는 타입을 정의한다.

@InputType()
export class WritePostReq {
    @Field(() => String, { nullable: false })
    title: string;
    @Field(() => String, { nullable: false })
    content: string;
    @Field(() => Int, { nullable: false })
    authorId: number;
}

@ObjectType()
export class WritePostRes {        
    @Field(() => String, { nullable: false })
    title: string;
    @Field(() => String, { nullable: false })
    nickname: string;
    @Field(() => Date, { nullable: false })
    createdAt: Date;
}
@Mutation(() => WritePostRes)
async writePost(
    @Args('postInput') postInput: WritePostReq,
): Promise<WritePostRes> {
    const post = await this.postRepo.savePost(postInput);
    const author = await this.userRepo.findOne(post.authorId);

    return {
        title: post.title,
        nickname: author.nickname,
        createdAt: post.createdAt,
    };
}

 
 
@ArgsType()은 쿼리나 뮤테이션의 파라미터를 정의할 때 사용되며, @Args()와 함께 사용하여 특정 필드를 파라미터로 받을 수 있다. 위의 posts Query를 아래와 같이 변경할 수 있다.

@ArgsType()
export class PostArgs {
  @Field(() => Int, { nullable: true })
  authorId?: number;

  @Field(() => Int, { nullable: true })
  limit?: number;

  @Field(() => Int, { nullable: true })
  offset?: number;
}
@Query(() => [Post])
async posts(@Args() postArgs: PostArgs): Promise<Post[]> {
    return this.postService.findAll(postArgs);
}

//@Args('authorId', { type: () => Number, nullable: true }) authorId?: number,
//@Args('limit', { type: () => Number, nullable: true }) authorId?: number,
//@Args('offset', { type: () => Number, nullable: true }) authorId?: number,

 
 
 
 
 
 

REST vs graphQL

기술 블로그들에 잘 설명 되어있는 정보 전달성 versus를 제외하고, 실무에서 써본적이 없는 주니어 개발자인 내가, 현재 업무와의 연관성을 지어보면서 단순 학습만으로 어떻게든 둘의 차이를 느껴보려고 했다.
 

OverFetching + UnderFetching

실제 프로덕션을 보면서 생각보다 이런 문제가 심각할 수도 있는 경우가 아마 클라이언트 측 사양이 낮은 경우가 있지 않을까 싶다. 항상 최신식에 가까운 사양의 장비들로 개발을 하다보니 이런 부분을 놓칠 수 있다. 이런 측면에서 봤을때 graphQL은 괜찮은 대안이 될 수 있을 것 같다.
 
 

문서화(Swagger는 어려워요)

Swagger은 API 문서화를 위한 강력한 도구지만, 아무리 서술을 잘 해두어도 프론트 개발자 분들이 Swagger만 보고 완벽하게 개발을 할 수는 없다. API를 개발한 당사자의 의도도 100% 파악하기 어렵다. graphQL은 보다 직관적이게 쿼리나 뮤테이트의 타입들만 보고 확인할 수 있으니 데이터의 구조와 관계를 더 명확히 이해할 수 있을 것이고 협업 시에 더 이점을 제공할 것 같다.
 
 


 
 
 
 
 
 

참조

GraphQL | A query language for your API

Evolve your API without versions Add new fields and types to your GraphQL API without impacting existing queries. Aging fields can be deprecated and hidden from tools. By using a single evolving version, GraphQL APIs give apps continuous access to new feat

graphql.org

 
 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

Streamlining APIs, Databases, & Microservices | Apollo GraphQL

Unlock microservices potential with Apollo GraphQL. Seamlessly integrate APIs, manage data, and enhance performance. Explore Apollo's innovative solutions.

www.apollographql.com

 

300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] enum과 literal type 중 어떤걸 사용할까? (feat. Tree-shaking, Template Literal Ty

Tech/JavaScript & TypeScript 2024. 3. 29. 15:16
728x90

 
 
 

 
정확한 정보 전달이 아닌, 여러 좋은 포스팅들을 보며 적용해보고
개인의 관점에서의 의견 서술입니다. 여러 피드백들을 적극 환영합니다.

 
 
 
 

요약


트리 쉐이킹(Tree Shaking)은 번들링 시 사용하지 않는 불필요한 코드를 제거하는 최적화 작업을 말한다.

프론트에서의 트리쉐이킹은 번들의 크기를 최소화해서 UX의 향상에 목적이 있다고 하지만 백엔드 관점에서의 최적화는 코드의 안정성, 유지보수 등에 초점이 맞춰지고, 프로젝트의 특성과 요구사항과 등을 고려하는 것이 좋다고 생각한다.
TypeScript4.1에 추가된 Template Literal Type처럼, 명시된 타입들을 조합하는 복잡한 타입 조합이 필요하지 않을 경우, 이넘을 사용하는 것이 어떠한 이넘 값으로 강제되기 때문에 오히려 더 명확한 의도를 전달할 수도 있다.

 
 
 
 

Nest에서의 요청

@ApiTags("product")
@Controller("/api/v1/product")
export class ProductDetailController implements DetailController {

    constructor (private productService: ProductService) {}
    
    @ApiOperation({ summary: "상품 상세" })
    @ApiQuery({ name: "no", description: "상품 번호 PK", required: true })
    @UseGuards(VisitorGuard)
    @Get()
    async getProductDetail(@Query("no") no: number) {
    	return await this.productService.getProductDetail(no);
    }
    
}

 
위와 같은 컨트롤러에, 상품번호로 요청을 보낼 때, no는 number이 아니여도, NaN으로 비즈니스 로직까지 타고 넘어가서 동작하게 된다.


TypeScript에서 타입을 명시해도, 이는 컴파일 단계에서만 적용되는 타입 어노테이션이다.
실제 런타임에서는 데이터 타입을 변환하거나, 검증해주지 않는다는 소리다.
따라서 API 호출 시 쿼리 파라미터로 전달된 값이 숫자가 아닌 문자여서 서버 측에서 이를 숫자로 변환하려할 때,
런타임은 JavaScript환경이기 때문에 NaN으로 흘러가게 되는 것이다.

 
 
그렇기 때문에, 보통은 불필요한 자원 낭비를 피하기위해 Validation Pipe를 사용하거나, 객체로 변환해서 Class-Validator을 사용하곤 한다. 앞 단에서 걸러주지 않으면, 결국 비즈니스 로직에서 DB Connection까지 흘러가서 쿼리 파라미터 에러가 발생할 것이다.
 
어떤 요청이 컨트롤러에 도달하기 전에, Pipes에서 걸러지냐, NaN으로 비즈니스 끝까지 들어가서 쿼리에서 에러를 뱉어내냐는 꽤나 중요한 문제다. 불필요한 리소스를 낭비할 수 있다.

 


 
 


실제로는 number pk값과 같은 것들은 +를 붙여 number로 캐스팅하여 넘기기도 한다.
나는 예시를 들기위해 아래처럼 커스텀파이프를 만들었다.
실제로 간단한 Number검증은 기본적으로 제공하는 ParseIntPipe를 사용해도 된다.
@ApiTags("product")
@Controller("/api/v1/product")
export class ProductDetailController implements DetailController {

    constructor (private productService: ProductService) {}
    
    @ApiOperation({ summary: "상품 상세" })
    @ApiQuery({ name: "no", description: "상품 번호 PK", required: true })
    @UseGuards(VisitorGuard)
    @Get()
    async getProductDetail(@Query("no", ValidationNumberPipe) no: number) {
    	return await this.productService.getProductDetail(no);
    }
    
}
//의존성 주입을 통해 모듈 전역에서도 사용이 가능하다.
export class ValidateNumberPipe implements PipeTransform<string> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

 
종종 위처럼 Pipe의 구현체인 ValidationPipe를 사용하지 않고, 직접 구현해서 사용했었다.
 
 
여러 데이터들이 들어와 DTO를 구현해서 요청을 받을 때는, Class-Validator을 사용하기도 한다.

export class UserInfo {
    @IsString()
    @IsNotEmpty()
    @Length(4, 20)
    id: string;
    @IsString()
    @IsNotEmpty()
    @Length(4, 20)
    pw: string;
    @IsString()
    @IsNotEmpty()
    @Length(10)
    name: string;
}

 
 
 

이넘과 리터럴 타입

하지만, 조금 더 특정 데이터만 들어올 수 있게 강제하기위해 이넘타입을 많이 사용했었다.
이 이유에는, 타입스크립트 전에 스프링을 사용했기 때문도 있는 것 같다.

export enum RsvCategory {
  DEFAULT = '전체',
  WEDDINGHALL = '웨딩홀',
  HANBOK = '한복',  
  DRESS = '예복',
  GIFT = '예물',
  APPLIANCES = '혼수가전'  
}


export enum RsvSort {
  DEFAULT = '전체',
  COMPLETE = '답변완료',
  WAITING = '답변대기'
}

 
 
위의 코드들은 비교적 최근에 작업한 프로젝트 중 일부로, 데이터 조회를 위한 쿼리들에 이넘타입을 선언한 모습이다.
 
 
라인 기술블로그의 타입스크립트에서 이넘을 사용하지 않는게 좋은 이유 라는 포스팅을 보고나서 확인차 실제로 코드를 작성해보았다.
 
아래는 각각 이넘과 리터럴타입의 트랜스파일 코드들이다.

enum COIN {
    BITCOIN, ALTCOIN
}

enum ALTCOIN {
    ETHEREUM, RIPPLE, LITECOIN, DASH, MONERO, ZCASH, NEM, BITCOIN_CASH, DOGECOIN
}
var COIN;
(function (COIN) {
    COIN[COIN["BITCOIN"] = 0] = "BITCOIN";
    COIN[COIN["ALTCOIN"] = 1] = "ALTCOIN";
})(COIN || (COIN = {}));
var ALTCOIN;
(function (ALTCOIN) {
    ALTCOIN[ALTCOIN["ETHEREUM"] = 0] = "ETHEREUM";
    ALTCOIN[ALTCOIN["RIPPLE"] = 1] = "RIPPLE";
    ALTCOIN[ALTCOIN["LITECOIN"] = 2] = "LITECOIN";
    ALTCOIN[ALTCOIN["DASH"] = 3] = "DASH";
    ALTCOIN[ALTCOIN["MONERO"] = 4] = "MONERO";
    ALTCOIN[ALTCOIN["ZCASH"] = 5] = "ZCASH";
    ALTCOIN[ALTCOIN["NEM"] = 6] = "NEM";
    ALTCOIN[ALTCOIN["BITCOIN_CASH"] = 7] = "BITCOIN_CASH";
    ALTCOIN[ALTCOIN["DOGECOIN"] = 8] = "DOGECOIN";
})(ALTCOIN || (ALTCOIN = {}));
//# sourceMappingURL=coin.enum.js.map

 

type Coin = 'BITCOIN' | Altcoin;
type Altcoin = 'ETHEREUM' | 'RIPPLE' | 'LITECOIN' | 'DASH' | 'MONERO' | 'ZCASH' | 'NEM' | 'BITCOIN_CASH' | 'DOGECOIN';

let myCoin: Coin = 'BITCOIN';
let myAltcoin: Altcoin = 'ETHEREUM';
let myCoin = 'BITCOIN';
let myAltcoin = 'ETHEREUM';
//# sourceMappingURL=coin.type.js.map

 
 
라인의 포스팅에서는, 과도한 이넘 타입의 설정은 번들링 시 코드 최적화에 실패하여 유저에게 최적의 UX를 제공하지 못한다. 라고 해석되었다.
 
이를 해소하기위해 위처럼 유니온 타입을 사용하길 권장했고, 더 나아가서 유니온타입을 유연하게 사용하기 위해 템플릿 리터럴 타입도 생각해봄직 한 것 같았다.
 
 
 
 

사용해보기

 
우선 기존 프로젝트 중 분석에 대한 기능을 담당하는 코드들을 조금 손보기로 했다.

type ReadonlyRecord<K extends string, V> = Readonly<Record<K, V>>;
//step1
export type TStudioConcept = '인물 중심' | '다양한 배경' | '인물 + 배경';
export type TDressMood = '심플함' | '화려함';
export type TDressMaterial = '실크' | '레이스' | '비즈';
export type TMakeupStyle = '과즙/생기' | '깨끗/청초/화사' | '윤곽/음영';

//step2
export type TBudget = '100만원대' | '200만원대' | '300만원대' | '400만원대 이상';

//step3
export type TBodyType = '슬림' | '평균' | '통통';
export type TPeriod = '44사이즈' | '55사이즈' | '66사이즈' | '77사이즈 이상';


//step1
export const StudioConcept: ReadonlyRecord<TStudioConcept, TStudioConcept> = {
    '인물 중심': '인물 중심',
    '다양한 배경': '다양한 배경',
    '인물 + 배경': '인물 + 배경'
};
export const DressMood: ReadonlyRecord<TDressMood, TDressMood> = {
    '심플함': '심플함',
    '화려함': '화려함'
};
export const DressMaterial: ReadonlyRecord<TDressMaterial, TDressMaterial> = {
    '실크': '실크',
    '레이스': '레이스',
    '비즈': '비즈'
};
export const MakeupStyle: ReadonlyRecord<TMakeupStyle, TMakeupStyle> = {
    '과즙/생기': '과즙/생기',
    '깨끗/청초/화사': '깨끗/청초/화사',
    '윤곽/음영': '윤곽/음영'
};

//step2
export const Budget: ReadonlyRecord<TBudget, TBudget> = {
    '100만원대': '100만원대',
    '200만원대': '200만원대',
    '300만원대': '300만원대',
    '400만원대 이상': '400만원대 이상'
};

//step3
export const BodyType: ReadonlyRecord<TBodyType, TBodyType> = {
    '슬림': '슬림',
    '평균': '평균',
    '통통': '통통'
};
export const Period: ReadonlyRecord<TPeriod, TPeriod> = {
    '44사이즈': '44사이즈',
    '55사이즈': '55사이즈',
    '66사이즈': '66사이즈',
    '77사이즈 이상': '77사이즈 이상'
};

 
여기서 ReadonlyRecord타입은, as const처럼 변경 불가능한 읽기전용 객체임을 편하게 명시하기 위함이다. 이넘을 타입으로 변경하려다보니, 프로퍼티 값들을 고정시켜 이넘처럼 사용할 수 있게 하여 일관성과 안정성을 유지하고자 했다.
 
 
이제 이를, Reuqest DTO에 각 섹션마다 뿌려주면 되었고, IsEnum을 사용하여 이넘 타입과 똑같이 벨리데이션이 가능했다.
 

export class AnaylsisStep1 {
    @IsNotEmpty()
    @IsEnum(StudioConcept)
    @ApiProperty({ enum: StudioConceptProperty, description: '스튜디오 컨셉', required: true })
    sConcept: TStudioConcept

    @IsNotEmpty()
    @IsEnum(DressMood)
    @ApiProperty({ enum: DressMoodProperty, description: '드레스 분위기', required: true })
    dMood: TDressMood;

    @IsNotEmpty()
    @IsEnum(DressMaterial)
    @ApiProperty({ enum: DressMaterialProperty, description: '드레스 소재', required: true })
    dMaterial: TDressMaterial;

    @IsNotEmpty()
    @IsEnum(MakeupStyle)
    @ApiProperty({ enum: MakeupStyleProperty, description: '메이크업 스타일', required: true })
    mStyle: TMakeupStyle;
}

export class AnaylsisStep2 {
    @IsNotEmpty()
    @IsEnum(Budget)
    @ApiProperty({ enum: BudgetProperty, description: '예산', required: true })
    budget: TBudget;
}

export class AnaylsisStep3 {
    @IsNotEmpty()
    @IsEnum(BodyType)
    @ApiProperty({ enum: BodyTypeProperty, description: '몸매 타입', required: true })
    bodyType: TBodyType;

    @IsNotEmpty()
    @IsEnum(Period)
    @ApiProperty({ enum: PeriodProperty, description: '체형', required: true })
    period: TPeriod;

    @IsNotEmpty()
    @IsBoolean()
    @ApiProperty({ description: '브랜드 인지도', required: true })
    bAwareness: boolean;
}

export class StyleAnalysisRequestDto {
    @ValidateNested()
    @Type(() => AnaylsisStep1)
    @IsNotEmpty()
    @ApiProperty({ type: AnaylsisStep1 })
    step1: AnaylsisStep1;

    @ValidateNested()
    @Type(() => AnaylsisStep2)
    @IsNotEmpty()
    @ApiProperty({ type: AnaylsisStep2 })
    step2: AnaylsisStep2;

    @ValidateNested()
    @Type(() => AnaylsisStep3)
    @IsNotEmpty()
    @ApiProperty({ type: AnaylsisStep3 })
    step3: AnaylsisStep3;
}

 
 
 
 
 

마무리하며

백엔드 관점에서, 빌드된 서버 코드들이 모두 클라이언트에게 넘어가는 것이 아니기 때문에, 반드시 이넘타입을 변경해야 할까? 라는 생각이 들었다. 프론트 개발에서 중요한 번들 크기 최적화 같은 이슈가, 백엔드에서는 크게 중요하지 않을 수 있다.
 
Class-Validator이나, Swagger Response등 객체를 반드시 집어넣어야 동작하는 프레임워크의 내장 모듈이나 외부 라이브러리 등을 사용할 때, 단순 이넘을 선언하는 것이 편리했다. 리터럴 타입은 반드시 객체를 한번 더 선언해야만 동작했기 때문이다. 분명 개발 편의성과 효율성에서 차이가 있다.
 
마지막으로 선호도 차이도 있다. 자바 진영에서 타입스크립트로 넘어왔는데,  Class사용이 가능하고, Enum도 마찬가지로 사용이 가능하기 때문에 둘을 선호해서 사용했던 것 같다.
 
종합해보면, 코드의 안정성과 기타 필수로 갖춰야 할 코드베이스들을 해치지 않으면서, 프로젝트의 성격 / 팀원들의 성향 또는 개인의 성향에 맞게 작성하면 될 것 같다.는 결론이 나왔다.
 
2주일 정도 구현도 직접 해보고, 포스팅을 위한 고민을 했다. 좋은 포스팅을 여러개 모아서 스토리북처럼 읽어가면서 직접 구현해보면서 생각의 풀을 넓힐 수 있는 좋은 시간이였던 것 같다.
 
 
 
 

참조

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

TypeScript enum을 사용하지 않는 게 좋은 이유를 Tree-shaking 관점에서 소개합니다.

들어가며 안녕하세요. LINE Growth Technology UIT 팀의 Keishima(@pittanko_pta)입니다. 이번 글에서는 TypeScript의 enum을 사용하지 않는 편이 좋은 이유를 Tree-shaking 관점에서 소개하겠습니...

engineering.linecorp.com

 

Template Literal Types로 타입 안전하게 코딩하기

TypeScript 코드베이스의 타입 안전성을 한 단계 올려줄 수 있는 Template Literal Type의 뜻과 응용에 대해 알아봅니다.

toss.tech

 

300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] JWT Token 인증 - (3) Refresh Token 사용

Tech/JavaScript & TypeScript 2023. 11. 25. 19:15
728x90

 

 

이전 글에서 이어집니다.

 

[NestJS] JWT Token 인증(Access Token) 구현하기(with passport) - (2)

서론 지난글에 이어, Nest에서 JWT를 사용한 인증을 진행해보려한다. [NestJS] JWT Token 인증 - (1) JWT 토큰이란? [NestJS] JWT Token 인증 - (1) JWT 토큰이란? 쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Sessio

mag1c.tistory.com

 

 

Access Token의 한계

Access Token을 통해 웹사이트를 이용하다, 토큰이 만료되면 다시 재로그인을 해야만 하는 불편함을 겪게 된다.

또한 Access Token 만료 전 토큰을 탈취당하게 된다면 토큰이 만료될 때까지 속수무책이라는 보안상의 문제도 있다.

 

이러한 문제들 때문에, 보통 Acces Token의 유효 시간을 짧게 해두고, Refresh Token의 유효기간을 길게 설정하여

Refresh Token이 만료되기 전에는, Access Token을 갱신하여 사용할 수 있다.

 

출처 : 인파(inpa)

 

정리
1. Access Token의 유효 기간을 짧게 설정, Refresh Token의 유효 기간은 길게 설정
2. 두 토큰 모두 서버에 전송하여 Access Token으로 인증하고, 만료 시 Refresh Token으로 재발급한다.
3. Access Token을 탈취당해도, 짧은 유효 기간이 지나면 사용할 수 없다.

 

 

하지만 만약 Refresh Token이 탈취당한다면..?

 

Is a Refresh Token really necessary when using JWT token authentication?

I'm referencing another SO post that discusses using refresh tokens with JWT. JWT (JSON Web Token) automatic prolongation of expiration I have an application with a very common architecture where my

stackoverflow.com

위 스택오버플로우의 글에서 제안하는 방법은 다음과 같다.

1. 데이터베이스에 Access Token, Refresh Token 쌍을 저장(1:1 매핑)

2. 공격자는 탈취한 Refresh Token으로 새 Access Token을 생성하여 서버에 전송하게 된다. 이 때, 서버에서 데이터베이스에 저장된 Access Token과 공격자에게 받은 Access Token이 다른 것을 확인하게 된다. 이 때, 저장된 토큰이 만료되지 않았을 경우 탈취당한 것으로 간주하고 두 토큰을 모두 만료시킨다.

3. 재로그인을 통해 인증을 다시 수행해야하지만, 공격자의 토큰 역시 만료됐기 때문에 공격자는 접근이 불가능해진다.

 


토큰은 클라이언트나 서버 전역에서 만료시킬 수 있는 개체가 아니기 때문에, 토큰의 유효 기간이 지나기 전까지는 데이터베이스에 저장하여 관리할 필요가 있다.

또한 Refresh Token도 Access Token과 같은 유효기간을 가지게 해서, 사용자가 한 번 Refresh Token으로 Access Token을 재발급을 받았을 때, Refresh Token도 재발급 할 수 있도록 권장하고 있다고 한다. (ietf문서 링크)

 

 

 

 

Refresh Token 이용하기(Refresh Strategy)

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { UserService } from 'src/user/user.service';

@Injectable()
export class RefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
  constructor(
    private readonly configService: ConfigService,
    private readonly userService: UserService
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
        return request?.cookies?.refreshToken
      }]),
      ignoreExpiration: false,
      passReqToCallback: true,
      algorithms: ['HS256'],
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(request: Request, payload: any) {    
    const refreshToken = request.cookies['refreshToken'];
    return this.userService.refreshTokenMatches(refreshToken, payload.no);
  }
}
//JWT의 추출방법을 지정한다.
//fromAuthHeaderAsBearerToken()을 사용하여 헤더에서
//Bearer Token을 Authorization 헤더에서 추출한다.
jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
    return request?.cookies?.refreshToken
}]),

//토큰 만료 기간을 무시할지? false는 만료된 토큰 자체를 거부한다.
ignoreExpiration: false,

//validate함수의 첫 번째 인자로 request 객체 전달 여부를 결정.
passReqToCallback: true,

//서명에 사용할 알고리즘을 지정한다.
algorithms: ['HS256'],

//서명에 사용되는 비밀키로 환경변수에서 가져왔다.
secretOrKey: configService.get('JWT_SECRET'),

 

 

 

validate의 refreshTokenMatches를 통해, 해당 user의 row에 있는 refresh token이 request의 refresh token과 일치한지 여부를 확인한다.

//JWT 관련 로직
async refreshTokenMatches(refreshToken: string, no: number): Promise<UserEntity> {
    const user = await this.findByNo(no);

    const isMatches = this.isMatch(refreshToken, user.refresh_token);
    if (isMatches) return user;
}

 

 

검증에 성공하면, 다시 accessToken을 반환하여, 클라이언트에서 이를 보관처리하고 다시 인증에 유효하게 사용할 수 있을 것이다.

@Post('/refresh')
@UseGuards(RefreshAuthGuard)
@ApiOperation({ description: '토큰 재발급' })
async refreshToken(@Request() req) {        
    const accessToken = await this.authService.createAccessToken(req.user);
    const user = new UserEntityBuilder()
        .withNo(req.user.no)
        .withId(req.user.id)        
    return { accessToken, user };
}

 

 

 

 

 

참조

https://docs.nestjs.com/security/authentication

https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps-00#section-8

https://stackoverflow.com/questions/32060478/is-a-refresh-token-really-necessary-when-using-jwt-token-authentication

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Access-Token-Refresh-Token-%EC%9B%90%EB%A6%AC-feat-JWT

https://velog.io/@park2348190/JWT%EC%97%90%EC%84%9C-Refresh-Token%EC%9D%80-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%9C%EA%B0%80

300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] JWT Token 인증(Access Token) 구현하기(with passport) - (2)

Tech/JavaScript & TypeScript 2023. 11. 18. 18:55
728x90

 

 

 

서론

지난글에 이어, Nest에서 JWT를 사용한 인증을 진행해보려한다.

 

[NestJS] JWT Token 인증 - (1) JWT 토큰이란?

 

[NestJS] JWT Token 인증 - (1) JWT 토큰이란?

쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Session) HTTP 프로토콜의 특징 비연결성 ( Connectionless ) 클라이언트가 서버에 요청(Request)할 때, 그에 대한 응답(Response)을 한 후, 연결을 끊는다. 비상태

mag1c.tistory.com

 

 

 

NestJS에서 제공하는 공식 문서의 인증 관련 페이지를 참조했다.

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

설치한 모듈
passport (@nestjs/passport, passport, passport-local, @types/passport-local)
jwt (@nestjs/jwt, passport-jwt, @types/passport-jwt)

 

 

 

Passport

Node에서 인증을 적용할 때 사용할 수 있는 미들웨어로, 클라이언트가 서버에 권한을 요청할 자격이 있는지 인증할 때 passport 미들웨어를 사용한다. Nest에서는 토큰 인증에 있어서, passport의 사용을 권장하고 있다.

 

간단히 살펴보면, 유저의 자격 증명을 통해 사용자를 인증하며, 인증된 상태관리를 JWT와 같은 토큰을 통해 수행하며, Request에서 추가로 사용할 수 있게 사용자의 추가 정보를 첨부할 수 있다고 되어있다.

 

패키지 설치 시 @nestjs/passport 패키지를 반드시 요한다고 공식문서에 나타나 있다.

 

Nest에서는, PassportStrategy 클래스를 확장하여 구성하며, 하위 클래스에서 super() 메서드를 통해 파라미터를 전달하며 하위 클래스에서 validate() 메서드를 구현하여 인증에 대한 콜백을 수행한다고 명시되어 있다.

 

즉, 사용자가 존재하는지, 존재한다면 validate되는지를 따져 원하는 리턴값을 반환시킬 수 있고, 에러도 만들어 낼 수 있다.

 

Passport Strategy

이제 이 미들웨어의 전략을 구상(??)해야하는데, 기본적으로 공식 문서나 기타 개발 블로그 등 자료들에서 볼 수 있듯 기본 전략에서 내가 사용하는 서비스에 맞게 변경해주었다.

 

또한 들어가기에 앞서, @UseGuards 데코레이터를 사용하여 어떤 전략을 사용할 지 명시해주면 @nestjs/passport가 자동으로 명시된 전략을 호출한다.

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

 

예를들어, 위 AuthGuard는 local이라는 파라미터를 Passport에게 전달하여, 해당 Guard사용 시 LocalStrategy를 사용하게 한다.

 

 

 

 

그리하여 세가지 strategy를 만들 수 있었다.

Strategy

1. 로그인 시 사용하는 LocalStrategy
2. 로그인 후 인증 전반을 담당하는 JwtStrategy
3. Access Token 만료 시 Refresh Token을 검증하는 RefreshStrategy

 

 

로그인 (LocalStrategy)

로그인 시 수행하는 전략은 id와 pw를 가지고, 흔히 구현된 로그인 로직으로 보내어 유저 정보가 일치하는지 validate하는 전략이다.

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { UserService } from 'src/user/user.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService, private userService: UserService) {
    super({ usernameField: 'id', passwordField: 'pw' });
  }

  async validate(id: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(id, password);
    if (!user) {
      throw new UnauthorizedException('아이디 혹은 비밀번호를 확인해주세요.');
    }
    return user;
  }
}

 

 

유저 검증을 위한 서비스의 validateUser 코드이며, 유저의 비밀번호는 암호화되어 있기 때문에 복호화 메서드를 통해 검증을 추가로 진행하고, 유저 정보를 리턴할 것이기 때문에 유저의 비밀번호는 삭제해주었다.

 

유저 객체 자체를 클라이언트로 반환하는 것은 아닌데, 굳이 비밀번호 삭제가 필요한 지는 의문이긴 하다. 토큰 생성 시에도 user의 PK와 id만 담긴다. 확실한 지식이 없어 혹여 불필요한 비밀번호가 유출 될까 삭제하긴 했는데.. 이 부분을 좀 더 알아봐야겠다. (조언좀 부탁드립니다!!)

 

//validate for stragety
async validateUser(id: string, password: string): Promise<any> {
    const user = await this.userService.findById(id);
    if (!user) {
        throw new UnauthorizedException('가입된 유저가 아닙니다.');
    }
    if (user && (await this.isMatch(password, user.pw))) {
        delete user.pw;
        return user;
    } else {
        throw new BadRequestException('패스워드를 확인해주세요');
    }
}

 

 

 

LocalStrategy에서, 올바르게 유저를 검증하는데 성공했다면, user객체를 반환하게 코드를 만들어놓았기 때문에, 로그인 유저 검증에 성공한다면 Request객체에 user가 담기게되고, 우리는 이를 이용해서 로그인 처리(토큰 생성)를 수행할 수 있게 된다.

@Post('/signin')
@UseGuards(LocalAuthGuard)
@ApiOperation({ description: '로그인' })
@ApiResponse({ status: 200, description: 'JWT_TOKEN을 발급합니다.', type: JwtTokenDto })
signin(@Request() req) {
    return this.authService.signin(req.user);
}

 

async signin(user: UserEntity) {
    //1. user validate with hashed pw        
    if (this.validateUser) {
        user.last_join_date = new Date();
        //1-1. 유저의 마지막 로그인 시점 업데이트
        const signinDateUpdate = this.userService.updateSignin(user);

        //1-2. when validated >> token created
        if (signinDateUpdate) return this.createToken(user);
    }        
}

//access token 발급
createToken(user: UserEntity) {
    const no = user.no;
    const tokenDto = new JwtTokenDto(this.createAccessToken(user), this.createRefreshToken(no));
    this.setCurrentRefreshToken(tokenDto.refresh_token, no);
    return tokenDto;
}

createAccessToken(user: UserEntity) {
    const cu = { no: user.no, id: user.id };
    return this.jwtService.sign(cu, {
        secret: this.configService.get('JWT_SECRET'),
        expiresIn: this.configService.get('expiresIn')
    });
}

createRefreshToken(no: number) {
    return this.jwtService.sign({ no }, {
        secret: this.configService.get('JWT_SECERT'),
        expiresIn: this.configService.get('expiresInRefresh')
    });
}

 

 

토큰 생성시, payload에는 민감정보를 전달하면 안되기 때문에, pk와 id만 담았다. (pk는 좀 애매할 수 있을것 같다.)

생성 시 서명(signature)으로 설정해둔 시크릿 키와, 유효기간을 담았다.

createAccessToken(user: UserEntity) {
    const cu = { no: user.no, id: user.id };
    return this.jwtService.sign(cu, {
        secret: this.configService.get('JWT_SECRET'),
        expiresIn: this.configService.get('expiresIn')
    });
}

createRefreshToken(no: number) {
    return this.jwtService.sign({ no }, {
        secret: this.configService.get('JWT_SECERT'),
        expiresIn: this.configService.get('expiresInRefresh')
    });
}

 

 

정상적으로 로그인이 되면 아래처럼, access_token과 refresh_token이 생성되고 리턴된다.

 

 

 

토큰 검증(JwtStrategy)

토큰 검증을 수행하기로 한 Strategy로, PassportStrategy에 JWT 전략에 필요한 옵션들을 보낸다.

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      algorithms: ['HS256'],
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(payload: any) {
    return { ...payload };
  }
}

 

//JWT의 추출방법을 지정한다.
//fromAuthHeaderAsBearerToken()을 사용하여 헤더에서
//Bearer Token을 Authorization 헤더에서 추출한다.
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),

//토큰 만료 기간을 무시할지? false는 만료된 토큰 자체를 거부한다.
ignoreExpiration: false,

//서명에 사용할 알고리즘을 지정한다.
algorithms: ['HS256'],

//서명에 사용되는 비밀키로 환경변수에서 가져왔다.
secretOrKey: configService.get('JWT_SECRET'),

 

 

이제, 헤더에서 토큰을 추출하여 토큰이 있을 경우, 디코딩된 payload를 validate로 가져올 수 있다.

여기서는, 유저의 PK와 ID가 담겨있고, iat와 exp가 추가로 담겨있다 (iat : 발급시간, exp : 만료시간)

 

 

마찬가지로, @UseGuard를 이용해서 사용할 수 있으며, 컨트롤러 전역에 토큰 인증이 필요할 경우, 전역으로도 사용할 수 있다. 아래는 토이 프로젝트의 유저 컨트롤러 일부이다.

import { Controller, Get, Post, Body, Patch, Param, Delete, Query, Req, UseGuards, Request, Put } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { UserService } from './user.service';
import { Jwt } from 'src/decorator/CurrentUserDecorator';
import CurrentUser from 'src/auth/dto/currentUser.dto';
import { JwtAuthGuard } from 'src/auth/guard/jwt-auth.guard';
import UserInvestmentDataPutDto from './dto/user-investmentData.dto';

@ApiTags('user')
@Controller('/api/v1/user')
@UseGuards(JwtAuthGuard)
export class UserController {
  constructor(private userService: UserService) {}

  @Post('/check')
  @ApiOperation({ description: '토큰 검증' })
  async checkToken(@Request() req) {
    return req.user;
  }

  @Get('/myInvestmentData')
  @ApiOperation({ description: '투자내역 조회' })
  async getInvestmentDataByYyyyMm(@Jwt() cu: CurrentUser, @Query('yyyymm') yyyymm: string) {
     return await this.userService.getInvestmentDataByYyyyMm(cu.no, yyyymm);
  }

  @Put('/myInvestmentData')
  @ApiOperation({ description: '투자내역 입력(by day) '})
  async putInvestmentData(@Jwt() cu: CurrentUser, @Body() userInvDataPutDto: UserInvestmentDataPutDto) {
    return await this.userService.putInvestmentData(cu.no, userInvDataPutDto);
  }

  @Delete('/myInvestmentData/:yyyymm/:day')
  @ApiOperation({ description: '투자내역 초기화(day)' })
  async deleteInvestmentData(@Jwt() cu: CurrentUser, @Param() params) {
    const { yyyymm, day } = params;
    return await this.userService.deleteInvestmentData(cu.no, yyyymm, day)
  }
}

 

 

 

마무리

Refresh Token관련 내용은 다음 포스팅에서..

300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] JWT Token 인증 - (1) JWT 토큰이란?

Tech/JavaScript & TypeScript 2023. 11. 15. 14:54
728x90

 

 

 

쿠키와 세션을 통한 인증

 

쿠키와 세션 (Cookie & Session)

HTTP 프로토콜의 특징 비연결성 ( Connectionless ) 클라이언트가 서버에 요청(Request)할 때, 그에 대한 응답(Response)을 한 후, 연결을 끊는다. 비상태성 ( Stateless ) 클라이언트의 상태 정보를 가지지 않는

mag1c.tistory.com

 

쿠키는 인증이 필요한 요청을 할 때마다 쿠키를 던져 요청하는 동작 구조를 가진다.

 

쿠키를 통한 인증 시 다음과 같은 단점들이 존재한다.

쿠키의 단점

1. 민감 정보들을 노출당하기 쉽고 조작당하기도 쉽다.
2. 웹 브라우저마다 지원 형태가 다르기 때문에 공유가 불가능하다.
3. 쿠키의 사이즈 제한(4KB)이 있어 충분한 데이터를 담을 수 없다.
4. 서버에 매번 쿠키 값을 넘겨 인증해야하며, 조작된 쿠키가 넘어오더라도 방지할 수 없다.

 

 

 

세션은 개인 민감 정보를 노출하는 쿠키의 단점을 막을 수 있다.

세션은 인증 정보 자체를 특정 세션에 저장하고, 쿠키에 담아 클라이언트가 쿠키를 요청할 때 마다 세션에 저장되어 있는 정보와 동일한 지 인증한다.

 

아래의 코드는 개발자 첫 입문 프로젝트 시 사용했던 스프링 로그인 관련 코드이다.

로그인 시 인증 객체를 통해 세션 ID를 만들어 user_email, nickname세션에 저장하고, 세션 ID를 쿠키에 담아 반환할 것이다.

@RequestMapping("signin_check")
public ModelAndView signin_check(@RequestParam String email, String password, UserDto userDto,
                    HttpSession session, ModelAndView mv) {
    String str = userService.login(userDto);
    if(str != null) {
        session.setAttribute("user_email", userDto.getEmail());
        session.setAttribute("nickname", str);
        session.setMaxInactiveInterval(60*30);
        mv.setViewName("redirect:/");
    }else {
        mv.setViewName("user/signin");
    }
    return mv;
}

//로그아웃
@RequestMapping("signout")
public String logout(HttpSession session) {
    session.invalidate();		
    return "redirect:/";
}

 

 

세션에 식별할 수 있는 인증 값을 넣어두고, 비교할 수 있기 때문에 보완이 쿠키에 비해서 좋다. 만약 보안 문제가 발생할 경우, 서버의 세션을 지워버리면 된다. 하지만 반대로 이야기하면, 세션의 장애가 발생할 경우, 인증 전반에 문제가 생겨 사용자의 인증이 불가능한 상황을 야기시킨다.

 

추가적으로 세션의 단점을 정리해보자면 다음과 같다.

세션의 단점

1. 위에서 언급했듯이, 세션 자체에 문제가 발생하면, 인증 체계가 무너진다.
2. stateful하기 때문에 http의 stateless에 위배된다.
   > (scale out 시 걸림돌이 될 수 있다. 세션 ID를 또 다른 서버에 저장해야함)
3. 인증 요청 시 매번 세션 저장소를 조회해야한다.
4. 세션 저장소를 사용하는 비용. 사용자가 증가할수록 메모리 사용량도 증가.

 

 

 

JWT

JWT(Json Web Token)은 인터넷 표준 인증 방식으로, Json 객체에 인증이 필요한 정보들을 담은 후 비밀키로 서명한 토큰이다. 말 그대로 인증에 필요한 정보들을 Token에 담아 암호화시켜 사용하는 토큰이다.

 

인증 구조 자체는 쿠키와 크게 다르지 않지만, 서명된 토큰이라는 점이 주요 차이점이다. public/private key를 쌍으로 사용하여 토큰에 서명할 경우, 서명된 토큰은 개인 키를 보유한 서버가 이 서명된 토큰이 정상적인 토큰인지 인증할 수 있다.

 

 

JWT의 동작 과정

 

동작 과정은 위와 같다.

사용자가 서버에 로그인 요청을 보내면, 서버는 secret key를 사용해 암호화한 JWT 토큰을 발급하여 내보낸다.

이렇게 받은 토큰을 클라이언트의 로컬에 저장해두고, 인증이 필요한 요청 시 토큰을 헤더에 실어서 보내 토큰 검증 과정을 거친다.

 

 

 

JWT의 구조

JWT는 Header, Payload, Signature의 세 구조로 되어있다.

 

 

 

Header

Header는 토큰의 타입, 서명 생성에 어떤 알고리즘이 사용되었는지를 저장한다.

 

 

 

 

Payload

Payload에는 Claim을 Key-Value 형태로 저장한다.

 

여기서 Claim이란 사용자 혹은 토큰에 대한 프로퍼티이며, 토큰에서 사용할 정보의 조각을 의미한다.

여기에는, 커스텀하여 값들을 넣을 수 있지만,  민감한 정보를 payload에 담지 않는 것이 중요하다. header와 payload는 json이 디코딩되어 있을 뿐, 특별한 암호화가 걸려있지 않기 때문에 누구나 값을 알 수 있기 때문이다.

JWT 표준 Claim

1. iss(issuer) - 토큰 발급자
2. sub(subject) - 토큰 제목(사용자에 대한 식별 값)
3. aud(audience) - 토큰 대상자
4. exp(expiration time) - 토큰 만료 시간
5. nbf(not before) - 토큰 활성 날짜
6. iat(issued at) - 토큰 발급 시간
7. jti(jwt id) - 토큰 식별자 (issuer가 여러 명일 때를 위한 구분 값)

 

 

 

 

Signature

헤더와 페이로드의 문자열을 합친 후, 헤더에서 선언한 알고리즘과 key를 통해 암호화한 값이다.

 

위에서 언급했듯, Header와 Payload는 단순히 Base64로 인코딩되어있어 누구나 쉽게 복호화할 수 있지만, Signature는 암호화 시 사용된 key가 없으면 복호화 할 수 없다. 이를 통해 JWT는 보안상 안전하다는 특성을 가질 수 있게 된 것이다.

 

 

 

정리

장점

1. 로컬에 저장하기 때문에 서버 스펙에 영향을 받지 않고, 네트워크 부하가 적다 (http 헤더를 통한 전송)
2. 공통 스펙으로 사용 가능한 확장성이 뛰어난 토큰
3. 여러 플랫폼 및 기기에서 동작 가능하며 서로 다른 도메인에서도 통신이 가능
4. 별도의 세션 저장소를 강제하지 않아 stateless하다.

 

단점

1. 토큰 만료 처리를 구현해야한다.
2. 토큰의 크기를 신경써야한다. (트래픽에 영향)

 

 

JWT Token을 활용해서, 개발하는 서비스의 성격에 맞게 보안과 사용자의 편의를 고려하여 조율하면서 좋은 인증 정책을 결정할 수 있도록 해야겠다.

 

 

 

 

참조

https://jwt.io/introduction

https://brunch.co.kr/@jinyoungchoi95/1

300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] typeorm의 transaction

Tech/JavaScript & TypeScript 2023. 11. 9. 09:50
728x90

 

 

서론

JWT를 구현하는 도중, 아래와 같은 에러가 발생했다.

//회원가입 및 JWT TOKEN 발급 코드
async signup(signInDto: UserSigninDto) {
    const user = await this.userService.findById(signInDto.id);
    const exist = user != null;
    if (exist) {
        throw new BadRequestException('중복된 아이디 존재');
    }
    else {
        const valiUser = new UserEntity();
        const hashedPassword = this.hash(signInDto.pw);
        valiUser.id = signInDto.id;
        valiUser.pw = hashedPassword;
        const savedUser = await this.userService.saveUser(valiUser);
        return this.removePasswordFromUserData(savedUser);
    }
}

 

회원가입과 동시에 JWT Token을 발급하는 그런 로직을 짜고 있었는데, DB에는 들어왔지만, 에러는 발생한 그런 상황이다. 토큰은 발급되지 않았다.

 

 

 

 

@Transactional

자바를, 스프링을 사용했을 당시 AOP를 활용한 선언적 트랜잭션인 @Transactional 애너테이션을 사용하곤 했었다.

//게시판의 게시글을 삭제하는 메서드 
@Transactional
public void removeBoard(Long id)throws Exception{
    replyDAO.removeAll(id); //삭제할 게시글의 답글 삭제
    boardDAO.deleteBoard(id); //게시글 삭제 
}

 

 

 

typeorm에서는?

데커레이터는 권장하지 않는다고 하여 getManager().transaction()과 queryRunner중 queryRunner을 사용했다.

async signup(signInDto: UserSigninDto) {
    //typeorm transaction start
    const queryRunner = this.datasource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
        const user = await this.userService.findById(signInDto.id);
        const exist = user != null;
        if (exist) {
            throw new BadRequestException('중복된 아이디 존재');
        }
        else {
            const hashedPassword = this.hash(signInDto.pw);

            const valiUser = new UserEntity();                
            valiUser.id = signInDto.id;
            valiUser.pw = hashedPassword;

            const savedUser = await this.userService.saveUser(valiUser);                
            const delPwUserData = this.removePasswordFromUserData(savedUser);               

            const token = this.createToken(delPwUserData)                
            await queryRunner.commitTransaction();

            return token;
        }
    } catch (error) {
        await queryRunner.rollbackTransaction();
        console.error(error);
    } finally {
        await queryRunner.release();
    }

}

 

 

 

 

 

 

트랜잭션 다루기 (feat. NestJS, TypeORM)

Nest JS에서 트랜잭션을 올바르게 다루기 위해 겪었던 고민과 문제에 대한 해결방법을 정리한 글입니다

www.timegambit.com

 

300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] 예외처리, Exception Filter (HttpException, Error Handling]

Tech/JavaScript & TypeScript 2023. 10. 9. 16:46
728x90

 

 

 

서론

노드로 전향한지 만 1개월이 되었다.

현재 근무하고 있는 곳의 애플리케이션 코드를 보면, 따로 예외처리를 해주는 부분이 없어 에러 핸들링과 에러 로깅 작업을 커스텀으로 진행하려고 한다. 이를 위해 공식 문서를 활용해가며 학습할 필요를 느껴서 공식문서에 해당 내용을 확인하고 학습해보자.

 

 

 

NestJS의 예외 처리

Nest에는 애플리케이션 내에서 처리되지 않은 모든 예외를 처리하는 내장 예외 레이어가 존재한다. 애플리케이션 코드에서 예외 처리가 되지 않으면 예외 레이어에서 예외를 처리한다.

 

기본적으로 이 작업은 HttpException유형과 하위 클래스의 예외를 처리하는 내장 전역 예외 필터에 의해 수행된다. 예외를 인식할 수 없는 경우 다음과 같은 500에러를 내보낸다.

{
  "statusCode": 500,
  "message": "Internal server error"
}

 

 

 

HttpException

Nest는 @nestjs/common의 HttpException을 제공하며 이를 통해 다음과 같이 403에러에 대한 예외처리를 할 수 있다.

@Get()
async findAll() {
  throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}
{
  "statusCode": 403,
  "message": "Forbidden"
}

 

기본 형태의 error response JSON을 재정의할 수 있다.

단 statusCode는 유효한 상태 코드여야한다.

@Get()
async findAll() {
  try {
    await this.service.findAll()
  } catch (error) { 
    throw new HttpException({
      status: HttpStatus.FORBIDDEN,
      error: 'This is a custom message',
    }, HttpStatus.FORBIDDEN, {
      cause: error
    });
  }
}
{
  "status": 403,
  "error": "This is a custom message"
}

 

@nestjs/common의 HttpStatus Enum을 사용하면 간편하게 상태코드를 정의할 수 있다.

 

 

Custom Exception

커스텀 Exception을 생성해야 하는 경우, HttpException에서 상속되는 예외 계층을 만드는 것이 좋다. 아래의 예시처럼 사용한다면, Nest가 사용자의 커스텀 Exception을 인식하고 오류 응답을 자동으로 처리한다.

export class ForbiddenException extends HttpException {
  constructor() {
    super('Forbidden', HttpStatus.FORBIDDEN);
  }
}
@Get()
async findAll() {
  throw new ForbiddenException();
}

 

또한 아래와 같은 내장 HttpException을 제공한다.

 

 

Exception Filter

Nest는 앞에서 언급한 것 처럼, 예외 필터 레이어가 있어서 원하는 대로 예외를 다룰 수 있다.

로그를 남기거나, 에러 메세지를 커스터마이징 할 수 있다.

 

아래의 예시는, 상태 코드 뿐 아니라, 에러 발생 시각과 에러가 발생된 요청 URL을 반환한다.

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

 

@Catch(HttpExceptioin) 데코레이터는 필요한 메타데이터를 Exception Filter에 바인딩하여 Nest에게 이 특정 필터가 위의 내장 HttpException 유형의 예외를 찾고 있으며, 다른 예외는 없음을 알려준다. 또한 쉼표로 구분지어 여러 유형의 예외에 대한 필터를 한 번에 설정할 수 있다.

모든 Custom Exception Filter는 일반 ExceptionFilter interface를 구현해야 한다.
위 Custom Filter에 구현된 catch 메서드를 제공해야 한다는 뜻이다.

또한 @nestjs/platform-fastfy를 사용하는 경우, response.json() 대신 response.send()를 사용할 수 있다.

 

필터는, @UseFilters 데코레이터를 이용하여 적용시켜주면 되는데, 이 때 원하는 엔드포인트에 사용할 수 있다.

@Post()
@UseFilters(new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

@Controller('user')
@UseFilters(HttpExceptionFilter)
export class UserController

 

애플리케이션 전역에서 작용하는 Filter로 설정하기 위해서는 main.ts 파일 안에서 다음과 같이 useGlobalFilters() 메서드를 사용한다.

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

 

전역 범위의 필터는 애플리케이션 전역에 대해 동작하기 때문에, 아래와 같이 모든 모듈에서 직접 전역 범위 필터를 등록할 수 있게 설정해주어 종속성 주입 문제를 해결해주어야 한다.

import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
  ],
})
export class AppModule {}

 

 

 

 

사용해보기

사내 백엔드 환경과 동일하게 셋팅한 후, 가볍게 복리계산 로직을 만들고, React로 간단하게 View를 만들어서 확인해보았다.

 

전역에서 사용할 것이기 때문에 위 포스팅의 전역 설정을 모두 마친 뒤, 아래처럼 코드를 작성했다.

async calculate(price: number, percent: number, time: number, type: string) {
    if (time <= 0 || price <= 0) return null;

    let priceArr = [price];
    let data = new Array<CPResponse>();
    let totalRevenue = 0;
    for (let i = 1; i <= time; i++) {
      const beforePrice = +priceArr[i - 1];
      const interest = beforePrice * (+percent / 100);
      const currentPrice = Math.round(beforePrice + interest);
      const revenue = currentPrice - beforePrice;
      const totalPercent = ((currentPrice - price) / (price / 100)).toFixed(2);
      totalRevenue += revenue;

      if (revenue === Infinity || !Number(revenue) || currentPrice === Infinity) {
        throw new BadRequestException();
      }

      priceArr.push(currentPrice);
      const cpResponse = new CPResponse(i + type, revenue, currentPrice, totalPercent + '%');
      data.push(cpResponse);
    }

    return { data, totalRevenue };

 

아래 부분에서, 숫자가 기하급수적으로 커질 때 Infinity 값이 출력될 수 있으므로, 수의 범위를 벗어나면(?) BadRequest를 반환하도록 했다.

if (revenue === Infinity || !Number(revenue) || currentPrice === Infinity) {
	throw new BadRequestException();
}

300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록