이전 포스팅까지 (2개의 포스팅을 통해) 우린 로그인 구현을 통해 JWT 토큰을 생성해보고 해당 JWT 토큰이 사용가능한 토크인지 "검증"해보는 단계를 밟아보았다.
이번 포스팅부턴 "권한(Role)관리"를 구현해볼 것이다. 예를 들어, 로그인을 한 유저들 중에서도 해당 사이트에서 본인의 권한이 다를 경우가 존재할 것이다. 만약, 회원관리 메뉴에 "관리자"가 아닌 일반 사용자가 접근을 할 경우 큰 문제가 생길 것이다. 이렇게 어떠한 "역할(Role)"에 따른 유저의 접근을 가능케 하는 기능을 구현해 볼것이다.
이것은 "인증(Authentication)"과 "인가(Authorization)"중에서도 "인가"에 해당되는 내용이다. 그럼 지금부터 이러한 "인가"를 가능케 하는 "권한 관리(RoleGuard)"를 구현해보도록 하자.
물론 이번 포스팅에선 해당 내용의 전부를 진행하기엔 너무 길어지므로 본격적 권한관리(인가)에 앞선 권한 테이블 생성 및 테이블 JOIN에 관해 알아볼 것이다.
권한 관리를 위해서는 다음 사항들이 수행되어야 한다. 코드로 진입하기 전 먼저 흐름 파악을 위해 알아보자.
권한 구분: admin, user등으로 권한을 설정한다.
사용자 별로 어떤 권한이 있는지를 db에 저장하고 있어야 한다.
메뉴 접근시 사용자의 권한을 체크하여 사용 가능 여부에 따라 접근을 허가 또는 거부한다.
현재 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_id
는 user
테이블의 id
값과 매핑된다. 즉, 이에 따라서 id
값이 1인 Jake는 ROLE_USER
와 ROLE_ADMIN
값을 모두 가질 수 있고, Daegyu는 ROLE_USER
의 값만 가질 수 있다.
다시 말해서 Jake는 관리자와 유저의 권한 모두를 부여 받을 수 있고, Daegyu는 유저의 권한만 부여받을 수 있도록 설정한 것이다.
자 이렇게 데이터를 모두 설정해 주었다.
<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
이름의 컬럼과 연관관계를 맺기로 하였으므로 @JoinColumn
의 name
속성에도 같게 이름을 지정해 줄 수 있다.
하지만 @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개의 테이블을 구현해 주었다. 각 테이블에서 매핑을 수행하는 두 속성(User
의 authorities
, UserAuthority
의 user
)을 통해 각 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[];
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],
})
일전에 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
에 방금 생성한 UserAuthorityRepository
를 import
시켜준다.
// 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)"를 가능케 할 수 있다. 그럼 곧 다음 포스팅에서 진행해보도록 하자.