https://github.com/wanted-wecode-subjects/redbrick-subject
요구사항
delete와 softDelete는 TypeORM의 메소드이며, 데이터를 삭제할 때 사용합니다.
다만, delete는 테이블의 데이터를 실제로 삭제하는 반면, softDelete는 엔티티에 @DeleteDateColumn() 데코레이터가 적용된 컬럼의 값만 수정하고 실제로 데이터를 삭제하지 않습니다.
async delete(id: number): Promise<Project> {
const project = await this.findOne(id);
await this.projectRepository.softDelete({ id });
return project;
}
테이블 간의 1:1, 1:N, N:M 관계에 대응하기 위해, typeOrm에서는 @OneToOne, @OneToMany, @ManyToMany 데코레이터를 통해 relations 기능을 제공하고 있습니다.
1:1 관계에 있는 테이블 간의 매핑에 사용되며, 이번 프로젝트에서는 프로젝트와 게임에 대해 1:1 매핑을 시도하였습니다.
이는 프로젝트마다 하나의 게임만 퍼블리싱 할 수 있기 때문입니다.
game.entity.ts
@OneToOne(() => Project, { eager: true })
@JoinColumn()
project: Project;
참고: https://typeorm.io/#/one-to-one-relations/
1:N, N:1의 관계를 지정하기 위해 @OneToMany와 @ManyToOne을 사용할 수 있습니다. 이번 프로젝트에서는 하나의 유저가 다수의 프로젝트 또는 게임을 소유할 수 있기 때문에 user-project, user-game의 관계에 사용하였습니다.
user.entity.ts
@OneToMany((_type) => Game, (game) => game.user, {
eager: false,
cascade: true,
})
games: Game[];
game.entity.ts
@ManyToOne((_type) => User, (user) => user.games, {
onDelete: 'CASCADE',
})
user: User;
참고: https://typeorm.io/#/many-to-one-one-to-many-relations/
@ManyToMany를 사용하면 두 테이블을 조인하여 각 PK를 담은 형태의 테이블을 생성하여 N:M 관계를 명시할 수 있습니다.
이번 프로젝트에서는 게임의 좋아요 기능에 대해 유저가 좋아요를 클릭하였는지 여부를 나타내기 위해 @ManyToMany 기능을 사용하였습니다.
game.entity.ts
@ManyToMany((_type) => User, (users) => users.likes, { eager: true })
likes: User[];
user.entity.ts
@ManyToMany((_type) => Game, (game) => game.likes, {
cascade: true,
})
@JoinTable({ name: 'users_likes' })
likes: Game[];
참고: https://typeorm.io/#/many-to-many-relations/
where 절의 or
검색을 사용하기 위해 다음과 같이 find()의 옵션을 줄 수 있습니다.
db.getRepository(MyModel).find({
where:[
{name:"john"},
{lastName: "doe"}
]
})
출처: https://stackoverflow.com/questions/53407236/typeorm-or-operator
또는 queryBuilder의 orWhere() 메소드를 사용하여 or 검색을 수행할 수 있습니다. 이번 프로젝트에서는 게임을 검색할 때 유저와 게임 테이블을 조인하고 검색할 수 있도록 queryBuilder의 orWhere()를 사용하였습니다.
game.service.ts
const data = await this.gameRepository
.createQueryBuilder('game')
.innerJoin('game.user', 'user')
.where(`game.title like :keyword`, { keyword: `%${keyword}%` })
.orWhere(`user.nickname like :keyword`, { keyword: `%${keyword}%` })
.limit(limit)
.offset(offset)
.getMany();
이번 프로젝트에서 project와 game이 컨트롤러에서 서로의 서비스 객체를 사용하는 코드가 있어 모듈에서 export와 import를 하던 중, 순환참조 현상이 발생하였습니다.
이를 해결하기 위해 forwardRef() 함수를 사용하였습니다.
forwardRef()
는 '@nestjs/common'에 존재하며, 대상이 되는 두 모듈과 이를 사용하는 클래스의 생성자에서 forwardRef()를 적용해야 합니다.
game.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([GameRepository]),
forwardRef(() => ProjectsModule),
],
controllers: [GameController],
providers: [GameService],
exports: [GameService],
})
export class GameModule {}
projects.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([Project]),
forwardRef(() => GameModule),
MemoryCacheModule,
],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}
game.controller.ts
export class GameController {
constructor(
private gameService: GameService,
@Inject(forwardRef(() => ProjectsService))
private projectsService: ProjectsService,
) {}
projects.controller.ts
export class ProjectsController {
constructor(
private readonly projectsService: ProjectsService,
@Inject(forwardRef(() => GameService))
private readonly gameService: GameService,
private connection: Connection,
) {}
하나의 기능 또는 작업 단위에서 DB의 데이터에 영향을 줄 수 있는 쿼리(insert, update, delete)가 두 번 이상 나오는 경우, 쿼리에 문제가 발생하면 이전의 쿼리도 롤백해야 하는 경우가 있습니다.
이를 위해 트랜잭션을 적용하여 작업이 성공할 경우 commit, 실패할 경우 rollback을 하는데 typeOrm에서는 이를 위해 queryRunner를 지원합니다.
queryRunner는 connection에서 가져올 수 있습니다.
typeOrm의 DB connection을 전역으로 사용하려면 app.module에서 다음과 같은 코드로 적용합니다.
app.module.ts
export class AppModule {
constructor(private connection: Connection) {}
}
transaction을 적용하기 위해 먼저 대상이 되는 클래스에 connection을 가져옵니다.
projects.controller.ts
export class ProjectsController {
constructor(
...
private connection: Connection,
) {}
이후 적용할 메소드에서 connection.createQueryRunner()
코드를 사용하여 queryRunner를 생성합니다.
그리고 queryRunner.connect()
를 통해 연결한 다음, queryRunner.startTransaction()
으로 트랜잭션을 시작합니다.
projects.controller.ts
const queryRunner = this.connection.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
트랜잭션이 시작된 후 기능에 필요한 여러 쿼리(insert, update, delete)를 각각 수행합니다. 이때 exception이 발생하면 catch 하여 롤백(queryRunner.rollbackTransaction()
) 할 수 있도록 try-catch 문을 사용합니다.
모든 작업이 정상적으로 수행되었다면 커밋(queryRunner.commitTransaction()
)을 수행합니다.
이후 finally에서 queryRunner.release()
를 통해 queryRunner의 연결을 해제합니다.
projects.controller.ts
try {
...
await queryRunner.commitTransaction();
return { message: 'publish complete' };
} catch (error) {
await queryRunner.rollbackTransaction();
return { message: 'publish fail' };
} finally {
await queryRunner.release();
}
transaction에 필요한 queryRunner에 대한 mock을 생성하기 위해 다음과 같은 코드로 mocking을 수행할 수 있습니다.
projects.controller.spec.ts
let connection: Connection;
const qr = {
manager: {},
} as QueryRunner;
class ConnectionMock {
createQueryRunner(mode?: 'master' | 'slave'): QueryRunner {
return qr;
}
}
beforeEach(async () => {
Object.assign(qr.manager, {
save: jest.fn(),
});
qr.connect = jest.fn();
qr.release = jest.fn();
qr.startTransaction = jest.fn();
qr.commitTransaction = jest.fn();
qr.rollbackTransaction = jest.fn();
const module: TestingModule = await Test.createTestingModule({
controllers: [ProjectsController],
providers: [
ProjectsService,
GameService,
{ provide: Connection, useClass: ConnectionMock },
],
}).compile();
src/projects/projects.service.ts
async update({
id,
updateProjectDto,
user,
}: {
id: number;
updateProjectDto: UpdateProjectDto;
user: User;
}): Promise<any> {
if (Object.keys(updateProjectDto).length === 0) {
throw new BadRequestException(PROJECT_ERROR_MSG.NO_VALUE_FOR_UPDATE);
}
const project = await this.findOne(id);
this.checkAuthor(project, user);
const timeoutList = this.schedulerRegistry.getTimeouts();
const timeoutKey = PROJECT_CONSTANTS.TIMEOUT_KEY_PREFIX + id;
if (timeoutList.includes(timeoutKey)) {
this.schedulerRegistry.deleteTimeout(timeoutKey);
}
const cacheKey = PROJECT_CONSTANTS.CACHE_KEY_PREFIX + id;
await this.cacheService.setCacheData(cacheKey, updateProjectDto);
const cacheData = await this.cacheService.getCacheData(cacheKey);
this.schedulerRegistry.addTimeout(
timeoutKey,
setTimeout(async () => {
project.title = cacheData.title;
project.code = cacheData.code;
await this.projectRepository.save(project);
}, PROJECT_CONSTANTS.MILLISECONDS_FOR_TIMEOUT),
);
return cacheData;
}