시작하기에 앞서

이전 포스팅까지 (2개의 포스팅을 통해) 우린 로그인 구현을 통해 JWT 토큰을 생성해보고 해당 JWT 토큰이 사용가능한 토크인지 "검증"해보는 단계를 밟아보았다.
이번 포스팅부턴 "권한(Role)관리"를 구현해볼 것이다. 예를 들어, 로그인을 한 유저들 중에서도 해당 사이트에서 본인의 권한이 다를 경우가 존재할 것이다. 만약, 회원관리 메뉴에 "관리자"가 아닌 일반 사용자가 접근을 할 경우 큰 문제가 생길 것이다. 이렇게 어떠한 "역할(Role)"에 따른 유저의 접근을 가능케 하는 기능을 구현해 볼것이다.

이것은 "인증(Authentication)"과 "인가(Authorization)"중에서도 "인가"에 해당되는 내용이다. 그럼 지금부터 이러한 "인가"를 가능케 하는 "권한 관리(RoleGuard)"를 구현해보도록 하자.

물론 이번 포스팅에선 해당 내용의 전부를 진행하기엔 너무 길어지므로 본격적 권한관리(인가)에 앞선 권한 테이블 생성 및 테이블 JOIN에 관해 알아볼 것이다.


권한 관리(RoleGuard)


권한 관리를 위해서는 다음 사항들이 수행되어야 한다. 코드로 진입하기 전 먼저 흐름 파악을 위해 알아보자.

  • 권한 구분: admin, user등으로 권한을 설정한다.

  • 사용자 별로 어떤 권한이 있는지를 db에 저장하고 있어야 한다.

  • 메뉴 접근시 사용자의 권한을 체크하여 사용 가능 여부에 따라 접근을 허가 또는 거부한다.


테이블 생성 (user_authority)


현재 db는 MySQL을 사용하고 있다. 앞전의 내용들을 토대로 user엔티티를 통해 매핑해준 user란 테이블이 생성되어 있고, 이번에는 같은 위치에 user_authority란 테이블을 생성해 준다.

db에 사용자의 권한을 관리하기 위한 테이블 user_authority의 구조는 다음과 같다.

  • user_id는 user 테이블의 id 값에 매핑되는 값이다.

  • authority_name은 다음 두 가지 type을 갖도록 ENUM으로 만든다.
    - authority_name: USER, ADMIN

  • 초기 데이터는 아래와 같이 입력한다.

    CREATE TABLE IF NOT EXISTS `test`.`user_authority` (
      `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
      `user_id` BIGINT NOT NULL,
      `authority_name` ENUM('ROLE_USER', 'ROLE_ADMIN') NOT NULL,
      PRIMARY KEY (`id`))
    ENGINE = InnoDB

    그 후 값을 입력해주도록 하자.

    insert into `test`.`user_authority` (user_id, authority_name) values (1,'ROLE_USER');
    insert into `test`.`user_authority` (user_id, authority_name) values (1,'ROLE_ADMIN');
    insert into `test`.`user_authority` (user_id, authority_name) values (2,'ROLE_USER');

그 후 조회를 해보면

위와 같은 테이블이 생성된 것을 확인할 수 있을 것이다.

그럼 잠깐 지난번에 만들어주었던 user 테이블로 가보자.

위에서도 언급했다시피 user_authority테이블의 user_iduser테이블의 id값과 매핑된다. 즉, 이에 따라서 id값이 1인 Jake는 ROLE_USERROLE_ADMIN값을 모두 가질 수 있고, Daegyu는 ROLE_USER의 값만 가질 수 있다.

다시 말해서 Jake는 관리자와 유저의 권한 모두를 부여 받을 수 있고, Daegyu는 유저의 권한만 부여받을 수 있도록 설정한 것이다.

자 이렇게 데이터를 모두 설정해 주었다.


엔티티 생성 ( 테이블 JOIN하기 )


<UserAuthority 엔티티 생성>

user-authority entity를 생성한다.

한 사용자가 여러 개의 authority를 가질 수 있으므로 user_authority 테이블에서는 ManyToOne으로 Join을 한다.

// user-authority.entity.ts

import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { User } from "./user.entity";

@Entity('user_authority')
export class UserAuthority {
  @PrimaryGeneratedColumn()
  id: number;

  @Column('int', {name: 'user_id'})
  userId: number;

  @Column('varchar', {name: 'authority_name'})
  authorityName: string;
	
  // ManyToOne
  @ManyToOne(type => User, user => user.authorities)
  @JoinColumn({name: 'user_id'})
  user: User
}

위의 코드를 설명하기에 앞서 해당 엔티티와 매핑되는 우리가 기존에 생성해주었던 User엔티티를 살펴보자.


<User엔티티 수정>

기존 User 엔티티에 @OneToMany 데코레이터를 가지는 authorities란 속성을 optional property로 추가해준다.

// user.entity.ts

import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { UserAuthority } from "./user-authority.entity";


@Entity('user')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  password: string;
	
  // @OndToMany  
  @OneToMany(type => UserAuthority, userAuthority => userAuthority.user, {eager: true})
  authorities?: any[];
}

자, 그럼 UserAuthority엔티티를 살펴보자. 추가해 준 JOIN문 위주로 보자.

// 생략	

  @ManyToOne(type => User, user => user.authorities)
  @JoinColumn({name: 'user_id'})
  user: User

한 사용자(user)가 여러 개의 authority(권한)을 갖을 수 있으므로 user_authority테이블에서는 ManyToOne으로 Join한다.

@ManyToOne의 첫 번째 인자로 들어가는 type => User는 어떠한 엔티티(테이블)와 JOIN 할 것인가이다. 우린 User엔티티와 JOIN 할 것이므로 위와 같이 설정해주었고, 두 번째는 JOIN 할 Column이다. 위에서 확인할 수 있듯이 User엔티티에 authorities를 속성으로 추가해 준 것을 확인할 수 있다. 우린 해당 컬럼과 JOIN 할 것이다.

@JoinColumn은 이름에서 알 수 있듯이, 외래 키를 매핑할 때 사용한다. name속성에는 매핑할 외래 키 이름을 지정한다. 우리는

@Column('int', {name: 'user_id'})
  userId: number;

user_id 이름의 컬럼과 연관관계를 맺기로 하였으므로 @JoinColumnname속성에도 같게 이름을 지정해 줄 수 있다.

하지만 @JoinColumn생략 가능하다.

@JoinColumn은 매핑할 외래 키의 이름을 지정해 주는 것 이외의 역할을 해주진 않는다. 이미 @ManyToOne을 통해서 Join 하였고, 즉 생략이 가능하다. 만약 생략을 할 경우 알아서 @ManyToOne의 대상이 되는 엔티티의 user_id를 대상으로 삼게 된다.


UserAuthority엔티티에서 @ManyToOne을 통해 user 속성을 User 엔티티의 authorities라는 속성과 Join을 구현하였고, 자연스래 User 엔티티의 authorities@OneToMany를 통해 정의해 줄 수 있다.

// @OndToMany  
  @OneToMany(type => UserAuthority, userAuthority => userAuthority.user, {eager: true})
  authorities?: any[];

눈여겨봐야할 부분은 @OneToMany의 인자로 담은 {eager: true}이다.

해당 속성은 "ORM(Object Relational Mapping _객체와 관계형 DB의 매핑 )""N + 1 문제"라는 키워드와 직접적 연관이 있다.

"N + 1 문제"는 주로 JPA에서 많이 등장하는 내용인데, 우리가 하고 있는 nest의 TypeORM에서도 연관 관계에서 충분히 발생하게 되는 이슈이다.


해당 이슈에 대해 깊게 알고 싶다면
N + 1의 문제란? <-- 링크 참조


간단하게 해당 이슈에 대해서 말하자면 N + 1 문제란, 어떤 테이블의 참조된 데이터를 가져오기 위해 해당 (테이블 조회(1) + 참조된 데이터 조회(N)) 회의 쿼리를 날리는 문제를 이야기한다.

ORM을 잘못 사용한다면 어디서든 발생할 수 있는 문제이다.

그럼 우리 코드를 통해 알아보자.

우린 2개의 테이블을 구현해 주었다. 각 테이블에서 매핑을 수행하는 두 속성(Userauthorities , UserAuthorityuser)을 통해 각 user마다의 authorities를 쿼리로써 불러오는 작업을 수행해본다 하자.

그리고, user의 row가 즉, 유저가 5개가 있다고 가정해보자. 이런 상황에선 우린 처음 user를 갖오는 쿼리(1)와 각각의 user에 대한 authorities를 가져오는 쿼리(5) 해서 총 6번의 쿼리를 날린다.
이런 상황이 바로 "N + 1 문제" 이다.

사실, 위의 상황은 쿼리 문이 적어서 크게 와닿지 않지만 극단적으로 user의 row가 1억 건이라고 하면, 1억 1번의 쿼리를 날리는 상황에 이를 것이다. 이것은 확실히 바람직하지 못하다. 굳이 불필요하고 깔끔해 보이지 못한 작업임에 틀림없다.


해결법 1 -- find시 relations 조건 주기

불필요한 "N + 1 문제"를 없애기 위해선 "JOIN"을 시켜줘야한다.
데이터를 조회하는 find (혹은 findOne등...) 메서드 이용시 relations옵션을 지정해주는 것이 그 방법 중 하나이다.

처음부터 이번 포스팅을 보면 이해가 가지 않을 수 있겠지만 우리는 커스텀 레포지터리를 생성하고 해당 레포지터리에서 데이터를 조회할때, UserService에서 findOne()메서드를 이용해서 수행해 주었다.

// user.service.ts

async findByFields(options: FindOneOptions<UserDto | User>): Promise<User | undefined> {
  return await this.userRepository.findOne(options);
}

잠깐 findOne()의 파라미터로 받은 options가 타입으로 가지는 FindOneOptions를 열어보자.

export interface FindOneOptions<Entity = any> {
  // ~~~ 생략
  
  // Indicates what relations of entity should be loaded (simplified left join form).
    relations?: FindOptionsRelations<Entity> | FindOptionsRelationByString;  
}

FindOneOptions인터페이스는 relations라는 속성을 가지고 해당 속성은 JOIN할 엔티티의 속성을 타입으로 받는다.

즉, 우리 코드에서 해당 옵션을 적용시켜보면

async findByFields(options: FindOneOptions<UserDto | User>): Promise<User | undefined> {
  return await this.userRepository.findOne({relation: ["authorities"]});
}

위와 같이 나타낼 수 있을 것이다.

이러한 속성 추가로 user의 쿼리값을 불러올때 각 user마다의 authorities까지 한 번의 쿼리로 가져올 수 있다.


해결법 2 -- Eager Relations

TypeORM에서 Eager Relations 관계를 설정해 두는 것 또한, "N + 1 문제"를 해결하는 또 하나의 방법이다. 해당 관계를 설정해 두면, 상위 엔티티를 로드했을 때, 그 하위 엔티티까지 모두 로드되게 된다.
이는 Entity 클래스에서 eager 옵션을 true로 설정해 구현할 수 있다.

우리 코드에서 알아보자.

// user.entity.ts

@Entity('user')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  password: string;

  @OneToMany(type => UserAuthority, userAuthority => userAuthority.user, {eager: true})
  authorities?: any[];
}

아래의 @OneToMany로 설정한 부분이다.

@OneToMany(type => UserAuthority, userAuthority => userAuthority.user, {eager: true})
  authorities?: any[];

Module 수정

AppModule 수정

잠깐 AppModule을 수정해보자. 우리가 생성한 엔티티를 추가시키고 몇가지 설정을 변경할 것이다.

// app.module.ts

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'Janelevy0318@',
      database: 'test',
      entities: [Cat, User, UserAuthority], // UserAuthority 추가
      synchronize: false,   //  true --> false
      logging: true,  // logging 추가, 로그에 쿼리문이 보이게 한다.
    }),
    CatsModule,
    AuthModule],
  controllers: [AppController],
  providers: [AppService],
})

Repository 생성

일전에 User 엔티티를 생성하고 해당 엔티티를 통해 만들어진 테이블의 데이터를 조회하는 과정해서 우린 "Repository"를 이용하였다.

이번에도 우린 UserAuthority를 생성하였고 서비스에 적용시키기 전 "Repository"를 생성한다.

// user-authority.repository.ts

import { Repository } from "typeorm";
import { UserAuthority } from "../entity/user-authority.entity";
import { CustomRepository } from "./typeorm-ex.decorator";

@CustomRepository(UserAuthority)
export class UserAuthorityRepository extends Repository<UserAuthority> {}

생성한 CustomRepository를 데코레이터로써 불러오는 것을 잊지말자.
(@EntityRepository는 TypeORM 0.3.x 이후 deprecated 되었다...) => 자세한 내용은 해당 포스팅 클릭 !!

레포지터리 생성 후 AuthModule에 방금 생성한 UserAuthorityRepositoryimport 시켜준다.

// auth.module.ts

@Module({
  imports: [
    // UserAuthority 엔티티 추가
    TypeOrmModule.forFeature([User, UserAuthority]),
    // UserAutorityRepository 레포지터리 추가
    TypeOrmExModule.forCustomRepository([UserRepository, UserAuthorityRepository]),
    JwtModule.register({
      secret: 'SECRET_KEY',
      signOptions: {expiresIn: '300s'},
    }),
    PassportModule,
  ],
  
  // ~~~ 생략
})

다음 포스팅 예고

이번 포스팅에서 전부 다루고 싶었지만 내용이 너무 길어지는 것을 대비해 가장 처음에 언급했다시피 권한과 관련된 테이블 생성 및 엔티티의 옵션을 통해 테이블의 컬럼을 JOIN 해주는 작업 위주로 진행하였고, 그 후 레포지터리 생성 및 모듈에 적용시키는 과정까지 수행해보았다.

다음 포스팅에선 본격적으로 RoleGuard를 생성해볼 것이다. 해당 가드를 통해 우린 "인가(Autorization)"를 가능케 할 수 있다. 그럼 곧 다음 포스팅에서 진행해보도록 하자.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글

Powered by GraphCDN, the GraphQL CDN