바로 Custom Repository를 들어가면 개연성이 없으므로 어떠한 과정에서 어떠한 문제가 있었고, 우리가 직접 Custom Repository(사용자 생성 레포지토리)를 생성해야 하는 이유를 미리 말하고자 한다. 또한 Custom Repository를 생성하는 것에만 그치지 않고, “회원가입 인증”이라는 하나의 서비스를 구현하면서 해당 Custom Repository가 어떻게 서비스 모듈에 주입되고 적용되는지에 대한 전체적인 흐름또한 알아보고자 한다.
굉장히 긴 글이 될 것입니다. 그래도 파트별로 나눠져있으니 꼭 한번 읽어주시면 감사하겠습니다. 만약 본인이 저처럼 Nest 프레임워크의 모듈간의 소통에 익숙치 않다면 읽으면 도움이 되실 겁니다!!!
nest에서 데이터베이스를 받아오는 과정에서 직접 쿼리문을 작성하지않고, “TypeORM”을 통해서 받아오는 과정을 진행중이였다. 모두 알다시피 TypeORM을 사용하면서 데이터베이스의 테이블 값을 매핑하기 위해 “entity”를 따로 만들 것이다. 또한, 우리가 생성한 entity를 사용하기 위해 “repository”를 생성하는 것 또한 알 것이다.
repository는 어렵게 생각할 것 없다. Nest에서 TypeORM을 사용할 때, Nest는 이러한 “Repository Pattern”을 제공해주는데 서비스 모듈과 entity를 연결시켜주는 매개체라고 생각하면 편할 것이다.
우리가 entity를 통해 만들어준 테이블의 값들이 어떠한 일련의 과정을 통해 서비스에서 접근 가능하게끔 해야할 것이다. 그때, repository가 매개체로서 그러한 과정을 수행해준다. 즉, repository에서 entity에서 정의해준 DB의 값을 받고, service에 주입시켜준다고 생각하면 된다.
간단히 코드를 통해 알아보자.
먼저 아래와 같은 entity 파일이 있다고 가정하자.
@Entity('user')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
}
아래에 두 가지 케이스를 통해 repository를 생성하는 방법에 대해 비교 설명하고자 한다.
Case 1) - 일반적 repository 생성
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserRepository)
private userRepository: Repository<User> // <-- 주목!
){}
// ~~ implementation
}
typeorm에서 제공하는 Repository<entity에서 정의한 클래스명>
키워드를 통해 service에서 바로 entity에 접근하는 방법이 있다. 이것은 바로 entity에 접근할 수 있게 하지만, 사용자의 입맛에 맞는 DB연산을 가능케 해주지 못한다.
그에 따라 우리는 아래에서 사용자 정의(Custom)를 통한 repository를 구현할 것이다.
Case 2) - Custom repository 생성
import { EntityRepository, Repository } from "typeorm";
import { User } from "./entity/user.entity";
@EntityRepository(User)
export class UserRepository extends Repository<User>{}
typeorm에서 제공하는 @EntityRepository()
를 통해 우린 Custom repository를 구현할 수 있다. 사실 이제는 위와 같은 방법이 불가! 하다.
typeorm 0.3.x 버전 이후 해당 @EntityRepository()
를 사용하게되면 deprecated!라는 문구를 보게 될 것이고 즉, 사용 불가하게 된 것이다.
하지만, 우리는 Custom Repository를 사용해야하고, 어떻게 만들 수 있는지 이번 포스팅을 통해 말하고자 한다.
import { SetMetadata } from "@nestjs/common";
export const TYPEORM_EX_CUSTOM_REPOSITORY = "TYPEORM_EX_CUSTOM_REPOSITORY";
export function CustomRepository(entity: Function): ClassDecorator {
return SetMetadata(TYPEORM_EX_CUSTOM_REPOSITORY, entity);
}
위와 같이 데코레이터를 먼저 생성해준다. 위 아이가 기존의 @EntityRepository()
가 될 아이이다.
즉, 우리는 후에 메인 레포지터리 파일에 (UserRepository
클래스에 ) 기존엔 @EntityRepository()
를 주입하였다면 @CustomRepository
를 주입시켜 줄 것이다.
여기서 SetMetadata()
는 vsCode의 정의 기능을 통해 확인해보면
export declare const SetMetadata: <K = string, V = any>(metadataKey: K, metadataValue: V) => CustomDecorator<K>;
다음과 같이 key: value 형태를 인자로 받는 것을 알 수 있다.
조금 어렵게 들릴수도 있지만, 위와 같이 우리가 직접 생성해준 커스텀 데코레이터와 같은 경우는 조금은 특별하면서도 강력한 기능을 제공한다. SetMetadata()
를 이용한 메타데이터 세팅을 통해 빌드타임에 선언해둔 메타데이터를 활용하여 런타임에 동작을 제어할 수 있다. 타입스크립트 환경을 계속 접하다보면 함수 호출이나 클래스 호출을 통한 “런타임” 환경에서 동작을 제어할 수 있다는 것이 얼마나 강력한 것이지 알게 될 것이다. (물론 본인도 아직 잘 모릅니다…)
즉, 다시 코드로 돌아가서 보면 "TYPEORM_EX_CUSTOM_REPOSITORY" 문자열을 받은 TYPEORM_EX_CUSTOM_REPOSITORY
가 SetMetadata()
의 key값이 되는 것이고, entity
가 value값이 되는 것이다.
해당 동적 모듈(dynamic module)은 앞서 작성한 @CustomRepository
데코레이터가 적용된 Repository를 받아줄 모듈이다.
import { DynamicModule, Provider } from "@nestjs/common";
import { getDataSourceToken } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { TYPEORM_EX_CUSTOM_REPOSITORY } from "./typeorm-ex.decorator";
export class TypeOrmExModule {
public static forCustomRepository<T extends new (...args: any[]) => any>(repositories: T[]): DynamicModule {
const providers: Provider[] = [];
for (const repository of repositories) {
const entity = Reflect.getMetadata(TYPEORM_EX_CUSTOM_REPOSITORY, repository);
if (!entity) {
continue;
}
providers.push({
inject: [getDataSourceToken()],
provide: repository,
useFactory: (dataSource: DataSource): typeof repository => {
const baseRepository = dataSource.getRepository<any>(entity);
return new repository(baseRepository.target, baseRepository.manager, baseRepository.queryRunner);
},
});
}
return {
exports: providers,
module: TypeOrmExModule,
providers,
};
}
}
아마 위의 코드를 처음 보고 도데체 뭔지 숨이 턱 막힐 수도 있을 것이다. 사실 본인도 아직 완벽한 이해를 한 건 아닐지도 모른다. 그렇지만 차근차근 코드를 해석해보자.
먼저 해당 코드를 보자.
public static forCustomRepository<T extends new (...args: any[]) => any>(repositories: T[]): DynamicModule {~~~}
타입스크립트의 데코레이터에 대해 일가견이 있는 분이라면 아마 위의 형식을 보고 무언가가 떠오를 것이다. 바로 “클래스 데코레이터(Class Decorator)”이다.
“클래스 데코레이터”에 대해 먼저 알고자 하면 아래 포스팅을 참조 바란다. ⬇⬇
@데코레이터 뽀개기 !!! (feat _IoC, TS)
위에 걸어둔 포스팅에서도 언급하였지만 위 코드와 연관지어 핵심을 간단히 말하겠다.
“클래스 데코레이터”는 클래스 생성자에 적용되고, 클래스 데코레이터의 표현식은 데코레이팅된 클래스의 생성자를 유일한 인수로 런타임에 함수로 호출된다.
다음으로 아래 코드를 보자.
for (const repository of repositories) {
const entity = Reflect.getMetadata(TYPEORM_EX_CUSTOM_REPOSITORY, repository);
if (!entity) {
continue;
}
//~~~~
}
앞서 우린 클래스 데코레이터 forCustomRepository
정의를 통해 매개변수 repositories
를 동적으로 받아온다는 것을 알게 되었다. 이제 위의 코드를 동적으로 받아온 repositories
배열을 for문을 돌면서 repository
요소를 추출한다. 이 때, if문을 통해 메타데이터 key값에 해당하는 entity가 존재하는 경우 아래 Factory를 이용하여 provider
를 동적으로 생성하여 providers
에 추가한다.
(아래에서 자세히 설명)
그후, getMetadata
설정을 한다. 앞서 우린 CustomDecorator
커스텀 데코레이터에서 setMetadata
설정을 한 사실을 기억할 것이다. 이젠 getMetadata()
를 통해 앞전 파일에서 생성해준 TYPEORM_EX_CUSTOM_REPOSITORY
를 key로, 동적으로 받아오는 repository
를 target으로 설정해준다.
function getMetadata(metadataKey: any, target: Object): any; // Nest에서 정의
아마 아직 이해가 잘 가지 않을 것이다. 당연하다. 해당 내용은 가장 아래서 한번 더 언급할 것이다. 일단 기억하고 있자.
그리고 가장 중요한 다음 부분이다.
providers.push({
inject: [getDataSourceToken()],
provide: repository,
useFactory: (dataSource: DataSource): typeof repository => {
const baseRepository = dataSource.getRepository<any>(entity);
return new repository(baseRepository.target, baseRepository.manager, baseRepository.queryRunner);
},
});
앞서 지정해준 providers
배열에
const providers: Provider[] = [];
push
메서드를 사용하여 위의 속성들을 넣어주는 것이다.
갑자기 inject, provide, useFactory와 같은 속성들이 나와서 뭔말인가 싶을 것이다. 이것은 nest의 “Custom Provider”중 “Factory Provider”에서 등장하게 되는 구문이다.
“Factory Provider”를 사용하게 되면 프로바이더 인스턴스를 동적으로 구성할 수 있다. (더 자세한 것을 알고 싶다면 꼭 “Factory Provider”에 관해 찾아보길 바란다.)
간단히 코드를 설명하자면 팩토리 프로바이더는 속성으로 inject
, provide
, useFactory
를 받는다.
기존에는 useFactory
의 인자로 받는 DataSource
를 그대로 inject
배열에 주입하는 것이 원칙이지만 우린 DB의 데이터를 받아야 하므로 getDataSourceToken()
을 통해 토큰을 전달한다.
useFactory
구문은 일일이 까보기 보단 단순히 공급자(provider)인 repository
를 동적으로 결정할 거라는 것! 정도로 알아두자.
조금 더 쉽게 말하자면, 데이터베이스를 활용하여 동적으로 값을 받아온다!! 정도로 알아둬도 될 것이다.
위의 내용에 대해 잘 이해가가지 않는다면 “동적 모듈 바인딩”에 관해 작성한 포스팅을 먼저 보고오면 좋을 것이다. ⬇⬇⬇
우린 위에서 “다이나믹 모듈”을 직접 생성하므로써 “커스텀 레포지터리”를 구현할 수 있었다. 여기서 끝이 아니다. 해당 레포지터리를 이제 “서비스”와 연결 시키고 최종적으로 모듈에 적용까지 시켜야 한다. 커스텀 레포지터리를 생성하는 것 보단 이렇게 다른 계층에 전달 및 주입하는 과정에서 우린 더 많은 에러를 마주하고 한다.
글의 서두에 언급하였다시피 "회원 가입 인증" 이라는 간단한 서비스 구현을 통해 CustomRepository를 어떻게 모듈에 적용시키고 서비스에 사용하는지를 알아보도록 하자.
그럼 직접 과정을 통해 최종 실행까지 확인해보자.
우린 앞서 @EntityRepository
의 역할을 하는 함수인 CustomRepository
를 하나의 파일에서 생성해 주었다. 해당 함수를 @EntityRepository
를 주입시킬 때와 마친가지로 메인 레포지터리 파일(UserRepository
)파일에 적용시켜야한다.
CustomRepository() 함수
// typeorm-ex.decorator.ts
// After typeOrm version 0.3.x, @
import { SetMetadata } from "@nestjs/common";
export const TYPEORM_EX_CUSTOM_REPOSITORY = "TYPEORM_EX_CUSTOM_REPOSITORY";
export function CustomRepository(entity: Function): ClassDecorator {
return SetMetadata(TYPEORM_EX_CUSTOM_REPOSITORY, entity);
}
UserRepository 클래스에 적용
// user.repository.ts
import { Repository } from "typeorm";
import { CustomRepository } from "./repository/typeorm-ex.decorator";
import { User } from "./entity/user.entity";
@CustomRepository(User)
export class UserRepository extends Repository<User>{}
데코레이터의 인자로는 CustomRepository()
함수에서 정의한대로 entity
를 받게 된다. 즉, 우리가 앞서 설정한 User
엔티티를 받게되고 생성하게 될 UserRepository
클래스는 typeorm에서 제공하는 Repository<>
모듈을 확장받는다.
서비스에 적용시키는 부분에서도 생각해보아야 할 포인트들이 몇 가지 있다. 설명하기에 앞서 우리는 “회원가입 로직”을 구현하고 있다고 가정하에 코드를 진행할 것이다.
서비스 파일에서는 회원가입 기능을 구현하는데 있어 데이터베이스로부터 받아온 데이터들을 (즉, 엔티티 값을 넘겨받은 레포지터리로 부터 받아온 데이터) 찾거나(혹은, 검색하거나) 저장하는 작업들을 수행한다. 물론 지금 말하고 있는 서비스는 “유저 서비스(UserService)” 측면이다. user.service 파일에서 데이터들을 찾거나(find), 저장하는(save) 메서드들을 담아주고, 실제 회원가입을 구현하는 인증로직은 또 다른 서비스 파일인 “AuthService(auth.service)”파일에 넘겨 줄 것이다.
Part 1 _ UserService
위의 흐름을 머릿 속으로 그려보고, 아래 UserService (user.service.ts) 파일을 만들어보자.
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { FindOneOptions } from "typeorm";
import { UserDto } from "./dto/user.dto";
import { UserRepository } from "./user.repository";
@Injectable()
export class UserService {
constructor(
//@InjectRepository(UserRepository)
private userRepository: UserRepository
){}
async findByFields(options: FindOneOptions<UserDto>): Promise<UserDto | undefined> {
return await this.userRepository.findOne(options);
}
async save(userDto: UserDto): Promise<UserDto | undefined> {
return await this.userRepository.save(userDto);
}
}
UserService
클래스의 생성자로써 앞서 만들어준 UserRepository
클래스를 의존관계로 주입시킨다. UserRepository
클래스는 만들어 준 코드에서 확인할 수 있듯이 typeorm에서 제공하는 Repository
모듈을 확장받는다. 즉, typeorm에서 제공하는 findOne()
, save()
메서드 등을 사용할 수 있게 된다.
findOne() : 특정 유저 조회
save() : 유저 저장
UserRepository
클래스를 통해 조회 및 저장을 하는 작업을 리턴 값으로 가지는 두 메서드 findByFields()
, save()
는 레포지터리로 부터 결과를 받아오는 수행 시간을 고려해 async await
구문을 사용해 비동기 처리 해준다.
위에 코드에서 한 가지 특이점이 보일 것이다. 일부러 주석 처리 함으로써 실행시키지 않아도 된다는 것을 나타내었다.
constructor(
//@InjectRepository(UserRepository) --> 제거해준다.
private userRepository: UserRepository
){}
기존엔 서비스에 레포지터리를 주입하는 과정에서 nestJS에 제공하는 typeorm 모듈인 @InjectRepository
를 사용하였다. 하지만 “커스텀 레포지터리”는 @InjectRepository
데코레이터를 사용하지 않는다 !!
Part 2 _ AuthService
위에서 생성한 UserService
클래스를 회원 가입 인증 구현을 위해 AuthService
클래스에 주입시켜주어야 한다. AuthService
클래스의 생성자(constructor
)로써 UserService
클래스를 의존 관계 형성시켜준다.
// auth.service.ts
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { UserDto } from './dto/user.dto';
import { UserService } from './user.service';
@Injectable()
export class AuthService {
constructor(
private userService: UserService // UserService와 의존 관계 형성
){}
async registerUser(newUser: UserDto): Promise<UserDto> {
let userFind: UserDto = await this.userService.findByFields({
where: { username: newUser.username }
})
if(userFind) {
throw new HttpException("Username already used!", HttpStatus.BAD_REQUEST);
}
return await this.userService.save(newUser);
}
}
앞서 서비스 모듈의 시작 부분에서도 언급했듯이, UserService
클래스가 레포지터리를 통해 데이터를 받아오는 로직이였다면 AuthService
는 UserService
로부터 받아온 데이터들을 회원가입 등록가능한지 판별하고, 등록하는 로직을 처리한다.
유저 서비스 부분에서 언급을 생략하였는데, 아마 UserDto
모듈이 메서드의 매개변수로써 여러번 등장한 것을 확인할 수 있을 것이다. 앞서 유저 서비스의 두 메서드(findByFields()
, save()
) 또한, 매개변수로써 UserDto
를 받아 접근하였고, 이번에 유저 서비스로부터 조회한 데이터를 받아 등록을 처리하는 registerUser()
메서드 또한 매개변수로써 UserDto
를 받아오게 된다.
“””
그냥 넘어가긴 섭섭하니까! UserDto
클래스를 간단히 확인해보자.
// user.dto.ts
export class UserDto {
username: string;
password: string;
}
“Dto”에 관해 자세히 설명하면 복잡해지므로 아주 간단히 알아보자. Dto는 각 계층끼리 데이터를 주고받기 위해 만들어진 객체(여기선 클래스)이다. 뭔가 보기엔 “Entity”파일과 굉장히 흡사해 보인다.
하지만 형식은 비슷할지 몰라도 사용되는 이유는 확실히 다르다.
엔티티는 데이터베이스의 테이블과 직접적으로 매핑이되는 객체이다. 즉, “영속성”을 띄어야한다. 엔티티와 같은 객체를 그대로 컨트롤러와 같은 View와 실질적으로 소통하는 로직에 담게 되면, 변경의 위험등이 존재하므로 “영속성”이 아닌 “일회성”이 될 위험이 존재한다.
즉, 컨트롤러와 같은 클라이언트(View)단과 직접 마주하는 계층에서는 엔티티대신 DTO를 사용해서 데이터를 교환하는 것이 안전하고 효율적이다.
“””
다시 코드로 넘어와 AuthService
클래스의 registerUser()
메서드를 살펴보자.
async registerUser(newUser: UserDto): Promise<UserDto> {
let userFind: UserDto = await this.userService.findByFields({
where: { username: newUser.username }
})
if(userFind) {
throw new HttpException("Username already used!", HttpStatus.BAD_REQUEST);
}
return await this.userService.save(newUser);
}
userService
로 부터 데이터를 조회하는 작업이 먼저 수행이 된 후, 회원가입 인증 로직을 수행하는 것이 바람직하므로 해당 메서드 구문은 async await
을 통해 “비동기 처리”해 준다.
먼저, UserService
객체에서 만든 findByFields()
메서드를 활용하여 데이터를 불러온 뒤 userFind
변수에 담아준다.
findByFields()
의 인자로써 where
과 { username: newUser.username }
을 key와 value로 가지는 객체를 받는 것을 확인할 수 있다. 갑자기 “where”이라는 key값이 나왔는데 당황할 필요없다.
해당 where절의 의미를 알기 위해선 우리가 앞서 만들어 주었던 UserService
의 findByFields()
메서드로 잠깐 이동해 볼 필요가 있다.
// user.service.ts
async findByFields(options: FindOneOptions<UserDto>): Promise<UserDto | undefined> {
return await this.userRepository.findOne(options);
}
매개변수로써 options
를 담고 있고, 해당 options
는 FindOneOptions
라는 인터페이스를 타입으로 가진다는 것을 알 수 있다. 그럼 해당 FindOneOptions
인터페이스의 정의를 확인해보자.
export interface FindOneOptions<Entity = any> {
// ~~~ 생략
//Simple condition that should be applied to match entities.
where?: FindOptionsWhere<Entity>[] | FindOptionsWhere<Entity>;
// ~~~ 생략
}
FindOneOptions
인터페이스는 where
라는 key를 가지는 타입을 가지고 있고 엔티티를 통해 데이터를 조회한다는 것 또한 확인해볼 수 있다.
위의 주석 내용을 잠깐 해석해보면
“엔티티를 일치시키기위해 적용해야하는 간단한 조건이다.” 정도로 의미를 둘 수 있다.
자 , 다시 진행 중인 코드( AuthService )로 돌아와 확인해보자.
where 조건문을 통해 username을 찾을 수 있도록 한뒤, userFind
변수에 담아주는 작업을 마치면, 해당 userFind
가 이미 존재하는 경우, 유저 이름이 이미 존재한다는 문구를 내보낼 것이고 새로운 유저 이름을 조회했을 경우엔 UserService
클래스에서 정의한 save()
메서드를 이용해 newUser
객체를 저장해 주는 과정을 진행한다.
회원가입 인증을 위한 서비스 로직 처리를 나름 자세히 파헤쳐보았다. 간단히 진행을 요약하자면 먼저 생성한 “Custom Repository”를 “UserService”에 보내주었고 해당 “UserService”에서 정의한 메서드를 토대로 “AuthService”에서 회원가입 인증 로직(유효성 검사)을 구현하는 과정이었다.
길게 작성된만큼, 이해하기 껄끄러울 수 있지만 이러한 Nest의 계층별 전달 단계에 있어서 처음이라면 꼭 한번 생각해봐야할 과정이다.
컨트롤러는 모두 알다시피 클라이언트단의 요청(Request)을 받아 CRUD(데이터의 조회, 생성, 수정, 제거 등의 과정)을 처리하는 로직을 수행한다.
생성자(constructor)로 서비스 모듈을 의존관계로 두고, 클라이언트의 요청에 따른 본문(Body)의 데이터를 서비스 모듈에서 제공하는 비즈니스 로직(메서드)들에 따라 CRUD를 수행한다.
// auth.controller.ts
import { Body, Controller, Post, Req } from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { UserDto } from './dto/user.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService){}
@Post('/register')
async registerAccount(@Req() req: Request, @Body() userDto: UserDto): Promise<any>{
return await this.authService.registerUser(userDto);
}
}
클라이언트에서 우리가 작성한 UserDto
의 형식에 맞게 보내준 body의 데이터들을 AuthService
클래스에서 정의한 registerUser()
메서드의 인자로 넣어준다. 해당 구문또한 비동기처리 해 준다.
최상위 루트 모듈(AppModule
)을 수정하기 전, 먼저 AuthModule
에 우리가 만든 서비스모듈과 리퍼지토리를 정의시켜줘야한다.
AuthModule
// auth.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User } from './entity/user.entity';
import { TypeOrmExModule } from './repository/typeorm-ex.module';
import { UserRepository } from './user.repository';
import { UserService } from './user.service';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
TypeOrmExModule.forCustomRepository([UserRepository])
],
exports: [TypeOrmModule],
controllers: [AuthController],
providers: [AuthService, UserService],
})
export class AuthModule {}
여기서 주목해야 할 부분은 데코레이터 @Module
의 “imports” 부분이다.
imports: [
TypeOrmModule.forFeature([User]),
TypeOrmExModule.forCustomRepository([UserRepository])
],
기존에 즉, Typeorm 0.3.x 이전의 0.2대 버전에서는 @EntityRepository
가 제공되었기 때문에 따로 커스텀 리포지터리를 생성할 필요가 없었고 데코레이터 모듈에서 불러올 때도,
imports: [
TypeOrmModule.forFeature([UserRepository]),
],
그냥 위와 같이 처리해주면 되었다.
(참고로, TypeOrmModule.forFeature([])
즉, forFeature()
메서드를 사용하면 배열형태로 리포지터리나 엔티티 객체를 인자로 받음으로써, 데코레이터 모듈내에서 우리가 작성한 리포지터리와 엔티티를 불러올 수 있다.)
하지만 커스텀 리포지터리를 직접 생성해 준 우리와 같은 경우는 엔티티를 사용할 때는 “TypeOrmModule”로, 리포지터리를 사용할 때는 우리가 만들어 준 “TypeOrmExModule”로 구분하여 사용한다. 즉, 모듈 내에서 imports 해줄때도 위와 같이 불러와주는 것이 바람직하다.
이렇게 하면, 커스텀 리포지터리의 경우 해당 metadata token을 이용해서 값들을 찾은 다음, 그 값들중 일치하는 값을 추후 삽입하는 방식을 가능케한다.
아마 이게 무슨 말인지 이해가 가지 않을 것이다. 사실 위의 설명은 커스텀 리포지터리를 생성하는 과정에서 (위에 생성부분 참조) 한 번 언급했었다. 커스텀 리포지터리 클래스 TypeOrmExModule
를 다시 한 번 불러와보자.
// typeorm-ex.module.ts
// import 부분 생략
export class TypeOrmExModule {
public static forCustomRepository<T extends new (...args: any[]) => any>(repositories: T[]): DynamicModule {
const providers: Provider[] = [];
for (const repository of repositories) {
const entity = Reflect.getMetadata(TYPEORM_EX_CUSTOM_REPOSITORY, repository);
if (!entity) {
continue;
}
providers.push({
inject: [getDataSourceToken()],
provide: repository,
useFactory: (dataSource: DataSource): typeof repository => {
const baseRepository = dataSource.getRepository<any>(entity);
return new repository(baseRepository.target, baseRepository.manager, baseRepository.queryRunner);
},
});
}
return {
exports: providers,
module: TypeOrmExModule,
providers,
}
}
}
auth.module.ts(모듈 부)의 모듈 데코레이터 안에서 작성한 imports 부를 다시 확인해보자.
imports: [
TypeOrmModule.forFeature([User]),
TypeOrmExModule.forCustomRepository([UserRepository])
],
forCustomRepository()
의 인자로써 [UserRepository]
를 받은 것을 확인할 수 있다.
즉, 위의 생성 부에서 확인해보면 forCustomRepository()
의 매개변수 repositories
가 UserRepository
인 것이다.
그리고 해당 repositories
배열을 돌게 되는 요소인 repository
를 getMetadata()
에 인자로써 넣어준다. 그리고 팩토리 프로바이더 구문을 수행하며 최종 커스텀 리포지터리의 주입을 가능케한다.
앞전에 우리가 UserService
클래스의 생성부에서 @InjectRepository(UserRepository)
를 제거한 것을 떠올려보자. 바로 우리가 커스텀 리포지터리 내에서 작성해 준 getMetadata()
가 그 역할을 하는 것이다 !!!
getMetadata()
는 @InjectRepositoy
가 반환하는 것과 동일한 주입 토큰을 얻을 수 있는 도우미 메서드이다.
이것에 대한 자세한 원리를 설명하기엔 너무 길어질 것을 고려해 따로 찾아보길 바란다. Reflect의 get, setMetadata를 검색해보면 될 것이다. 나중에 따로 포스팅에 올려보도록 해야겠다.
다시 돌아와 module부의 @Module
데코레이터를 보자.
@Module({
imports: [
TypeOrmModule.forFeature([User]),
TypeOrmExModule.forCustomRepository([UserRepository])
],
exports: [TypeOrmModule, TypeOrmExModule],
controllers: [AuthController],
providers: [AuthService, UserService],
})
imports 부를 통해 엔티티와 레포지터리를 불러왔고, 바탕이 되는 두 모듈 TypeOrmModule
과 TypeOrmExModule
은 exports 시켜준다. 마지막으로 controller와 providers에 우리가 작성한 모듈들을 정의해주면 AuthModule 부는 완료된다.
AppModule
최종 루트 모듈인 AppModule이다. 여긴 특별히 건드릴 것은 없다. 엔티티에 우리가 작성한 엔티티 객체만 추가시켜주면 된다.
imports: [
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: '비밀이지롱 ~~~',
database: 'test',
entities: [User], // entities에 User 추가시켜주기
synchronize: true,
}),
CatsModule,
AuthModule],
코드만 작성하면 섭섭하다. 하지만 클라이언트단을 직접 작성해서 (프론트부) 회원가입 기능을 구현하긴 시간이 걸리므로 Postman을 통해 우리 서버에 요청을 보내 보도록 하자.
먼저 우리가 controller에서 설정한 경로 /auth/register 로 POST 요청을 보낸다. 클라이언트에서 요청을 보낼 땐 데이터를 Body(본문)에 넣어 보내는 것을 알 것이다. 또한, JSON 형태로 보낼 것이다. 참고로 nest는 서버에서 JSON을 받는 것에 대한 처리는 자동으로 처리 해 주기때문에 res.json()
과 같은 처리는 해주지 않아도 된다.
여하튼, JSON 형태로 DTO에서 정의한 형식에 맞게 보내면 “201 Created” 문구와 함께 데이터 생성에 성공한 것을 확인할 수 있다.
(참고로 왜 id가 3이냐고 할 수 있는데 일전에 두 번의 작업을 수행했었습니다….)
클라이언트의 요청에 응답한 (즉, 생성된) 객체는 entity에서 설정해 준, Primary Key인 id
값과 함께 생성된다. 위에 과정은 본인이 세 번째로 생성한 객체이다. 즉, id
값이 3을 가지는 것이다.
그러면 이번엔 동일한 “username”을 가지는 데이터를 한 번더 POST 요청 해보자.
동일한 “username”을 가지는 데이터 객체를 또 한번 POST 요청 하였더니 HTTP 상태가 “400 Bad Request”라고 뜨며 데이터가 한번 더 생성되는 것이 아닌 “Username already used!”라는 메시지를 띄운다.
이미 생성된 유저의 이름이므로 중복 생성이 불가하다는 뜻이다. 이것은 우리가 회원가입 인증 로직인 AuthService에서 직접 구현시킨 것이다.
// auth.service.ts
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { UserDto } from './dto/user.dto';
import { UserService } from './user.service';
@Injectable()
export class AuthService {
constructor(
private userService: UserService
){}
// 회원가입 인증 로직
async registerUser(newUser: UserDto): Promise<UserDto> {
let userFind: UserDto = await this.userService.findByFields({
where: { username: newUser.username }
})
if(userFind) {
throw new HttpException("Username already used!", HttpStatus.BAD_REQUEST); // 여기 !
}
return await this.userService.save(newUser);
}
}
Postman을 통해 생성시킨 데이터들이 우리가 Entity 객체를 통해 생성시킨 user 테이블의 필드에 직접 주입되었는지 확인해보자.
보다 시피 user 테이블의 우리가 3번째로 생성시킨 “Jake”라는 username와 12345라는 password를 가지는 컬럼이 잘 주입된 것을 확인할 수 있다.
굉장히 긴 포스팅이다. 본인도 “타입스크립트 데코레이터”와 관련된 포스팅 작성 이후 가장 길게 작성한 글이 아닌가 싶다.
사실, @EntityRepository
의 사용불가에 따른 Custom Repository의 작성법에 대해서만 포스팅 작성을 하려 했었다. 하지만, 해당 Custom Repository가 어떻게 작성되는지만 작성하기엔 어떠한 로직 및 애플리케이션에서 어떠한 역할을 하는지와 왜 필요한지에 대한 원천적인 접근을 담지 못한다고 생각하였다. Custom Repository 작성법은 여러 블로그나 공식 문서를 찾아보면 바로 알 수 있다.
하지만, nest라는 프레임워크를 처음 접해보거나 익숙치 않은 분들은 Custom Repository가 서비스, 컨트롤러와 같은 모듈에서 어떻게 의존관계를 지닐 수 있고, 어떤 방식으로 서로 관계를 주고받는지에 관해 이해하는 것이 상당히 어려울 수 있다고 생각한다.
본인도 Nest를 최근에 접하였지만 Nest라는 프레임워크에서는 여러 모듈들간에 의존관계를 가지며 소통하는 것을 이해하는 것이 상당히 중요하다 생각한다. 그런 의미에서 “회원가입 인증”이라는 간단한 하나의 서비스를 통해 “Custom Repository”를 생성 부터 적용까지 설명해 보았다.
Custom Repository를 생성하는 부분과 코드에 적용시키고 어떻게 회원가입 인증 기능이 작동하는 가에 대한 부분을 나눠서 두 개의 포스팅으로 작성할 생각이었다. 너무 글이 길면 읽는데 지루할 수도 있기 때문이다. 하지만 해당 내용은 꼭 이어져서 읽어야 더 의미가 있다고 생각했기에 굉장히 길게 작성하였다.
아직 본인도 Nest에 대한 상식이 부족합니다. 이 글을 읽는 분들께서는 꼭 댓글을 통해서 잘못된 부분이나 개선사항들을 거리낌없이 알려주시면 감사하겠습니다.