[wanted-pre-onboarding] Assignment3

·2021년 11월 10일
0

Github Repo

https://github.com/wanted-wecode-subjects/redbrick-subject

개요

  • 회원, 프로젝트, 게임 API 만들기

기간

  • 2021.11.08 - 2021.11.10

개발 환경

  • TypeScript
  • NestJS
  • Sqlite3

사용 라이브러리

  • bcrypt
  • passport
  • typeorm
  • class-validator
  • passport-jwt
  • cache-manager
  • schedule

구현 사항

요구사항

  • 회원 기능
  • 게임
    • 게임 또는 사용자로 게임 검색
    • 조회수, 좋아요 기능 구현
  • 프로젝트(제작 중인 게임을 의미)
    • 프로젝트의 실시간 반영
      (예를 들어, 프로젝트 수정 중 비정상적으로 사이트가 종료되어도 작업 내용이 보존되어야 함)
    • 프로젝트 당 퍼블리싱 할 수 있는 게임은 하나
    • 퍼블리싱 이후 프로젝트를 수정하여 재출시하면 기존에 퍼블리싱된 게임도 수정됨

DB 모델링

delete vs softDelete

delete와 softDelete는 TypeORM의 메소드이며, 데이터를 삭제할 때 사용합니다.
다만, delete는 테이블의 데이터를 실제로 삭제하는 반면, softDelete는 엔티티에 @DeleteDateColumn() 데코레이터가 적용된 컬럼의 값만 수정하고 실제로 데이터를 삭제하지 않습니다.

  • softDelete() 코드
    projects.service.ts
  async delete(id: number): Promise<Project> {
    const project = await this.findOne(id);
    await this.projectRepository.softDelete({ id });
    return project;
  }
  • softDelete()를 적용한 테이블
    데이터가 DB에서 지워지지 않고 deletedAt 컬럼이 갱신된 것을 확인할 수 있습니다.

typeOrm relations

테이블 간의 1:1, 1:N, N:M 관계에 대응하기 위해, typeOrm에서는 @OneToOne, @OneToMany, @ManyToMany 데코레이터를 통해 relations 기능을 제공하고 있습니다.

@OneToOne

1:1 관계에 있는 테이블 간의 매핑에 사용되며, 이번 프로젝트에서는 프로젝트와 게임에 대해 1:1 매핑을 시도하였습니다.
이는 프로젝트마다 하나의 게임만 퍼블리싱 할 수 있기 때문입니다.

  • 단방향 @OneToOne을 적용하기 위해 game entity에 project를 매핑합니다.
    또한 게임을 생성할 때 Project 객체를 game에 넣어 생성해야 합니다.

game.entity.ts

  @OneToOne(() => Project, { eager: true })
  @JoinColumn()
  project: Project;

참고: https://typeorm.io/#/one-to-one-relations/

@OneToMany, @ManyToOne

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

@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/

typeOrm or 검색

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();

forwardRef()

이번 프로젝트에서 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,
  ) {}

typeOrm transaction

하나의 기능 또는 작업 단위에서 DB의 데이터에 영향을 줄 수 있는 쿼리(insert, update, delete)가 두 번 이상 나오는 경우, 쿼리에 문제가 발생하면 이전의 쿼리도 롤백해야 하는 경우가 있습니다.
이를 위해 트랜잭션을 적용하여 작업이 성공할 경우 commit, 실패할 경우 rollback을 하는데 typeOrm에서는 이를 위해 queryRunner를 지원합니다.

connection

queryRunner는 connection에서 가져올 수 있습니다.
typeOrm의 DB connection을 전역으로 사용하려면 app.module에서 다음과 같은 코드로 적용합니다.

app.module.ts

export class AppModule {
  constructor(private connection: Connection) {}
}

transaction 적용

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 mock

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();

출처: https://stackoverflow.com/questions/63664322/how-to-use-jest-spyon-with-nestjs-transaction-code-on-unit-test

cache manager, schdulerRegistry timeout

  • 요구사항 중 프로젝트의 내용이 실시간으로 반영되어야 하는 사항이 있습니다.
    실시간 반영을 위해서는 클라이언트에서 매 초마다 DB에 update 요청을 날리거나,
    내용이 입력될 때마다 DB에 update 요청을 날린다면 서버에 부담이 커집니다.
  • 따라서 부담을 줄이기 위해 cache manager와 schedulerRegistry의 timeout을 사용하여 가장 최근의 update 요청으로부터 특정 시간이 지난 후에 update가 이뤄질 수 있도록 처리하였습니다.
  • 즉, 첫 번째 update 요청이 서버로 들어오면 캐시에 해당 데이터를 저장합니다.
    그리고 특정 시간이 지나면 DB에 update를 합니다.
  • 반면 첫 번째 update 요청이 들어오고 특정 시간이 지나기 전에 두 번째 요청이 들어오면 캐시에 두 번째 요청의 데이터를 저장하고 특정 시간이 다시 지난 후에 DB에 update를 하게 됩니다.

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;
  }

회고

Fact(사실)

  • PR 컨벤션을 안 지키는 경우가 종종 있다.
  • github의 이슈에 대해 open/close가 제때 되지 않는다.

Feeling(느낀점)

  • 위의 이슈에 대해 말하기가 불편하다.

Finding(교훈)

  • 컨벤션이나 이슈 처리 같은 것은 팀에서 정한 룰이므로 지켜야 하는게 맞고, 이에 대해 팀원에게 말해줘야 하는 것이 맞다고 생각한다.

Future action(향후 계획)

  • PR 컨벤션 정도는 github에 PR 템플릿을 설정하여 양식에 맞춰 작성할 수 있도록 한다.

Feedback(피드백)

0개의 댓글