[NestJS] 리팩터링하기(1)

허창원·2024년 4월 4일
0
post-custom-banner

Money Note인 개인 재무 관리 애플리케이션의 기능을 개발한 후 리팩토링이 필요하다고 느꼈습니다.
어떻게 리팩토링 했는지에 대한 내용을 작성해보고자 합니다.
객체 지향 개발 프로그래밍의 SOLID 원칙에 따라 코드를 수정하고자 노력했습니다.
1. 한 메서드의 기능이 너무 커서 단일 책임의 원칙에 따라 더 세세하게 기능을 나누었습니다.
2. 공통된 패턴을 찾아서 공통 함수로 만들어 재사용성, 확장성을 향상할 것이다.
3. orm의 기능을 적극 활용해보고자 합니다.

TypeORM의 findOne을 findOneBy로 수정

// 개선 전
const categoryName = await this.categoryRepository.findOne({
        where: { name: category },
      })

// 개선 후
const categoryName = await this.categoryRepository.findOneBy({
        name: category,
      })
  • 공통점
    데이터베이스에서 단 하나의 엔티티를 조회할 때 사용됩니다.
  • 차이점
    findOne은 다양한 검색 조건을 지정할 수 있으며, where 절을 통해 복잡한 쿼리를 구현할 수 있습니다.
    findOneBy는 단일 조건에 기반하여 엔티티를 조회할 때 사용합니다.이 메서드는 코드의 간결성과 가독성을 높이는 데 유리하며, 단순한 쿼리에서 사용을 권장합니다.
  • 서비스 코드에서 메서드 사용의 명확성을 높이고 코드의 가독성을 높이기 위해 findOneBy로 수정했습니다.

TypeORM Repository API를 활용하자

TypeORM은 데이터베이스 작업을 추상화하고, 개발자 친화적인 API를 제공하는 ORM 라이브러리입니다. 특히, 복잡한 쿼리 작업을 간결하게 처리할 수 있는 createQueryBilder와 같은 강력한 기능을 제공합니다. 그러나 모든 상황에서 createQueryBilder를 사용하는 것이 최선의 선택은 아닙니다. 때로는 TypeORM의 Repository API를 활용하여 더 간단하고 가독성 높은 코드를 작성할 수 있습니다.

// 개선전
async findBudgetByYear(year: number, user: User): Promise<Budget[]> {
    const budgets = await this.budgetRepository
      .createQueryBuilder('budget')
      .where('budget.year = :year AND budget.user_id = :user', {
        year,
        user,
      })
      .getRawMany()

    if (!budgets.length) {
      throw new NotFoundException(
        `해당 연도(${year})의 예산 데이터를 찾을 수 없습니다.`,
      )
    }

    return budgets
  }

// 개선 후
async findBudgetByYear(year: number, user: User): Promise<Budget[]> {
    const budgets = await this.budgetRepository.find({
      where: {
        year,
        user,
      },
    })

    if (budgets.length === 0) {
      throw new NotFoundException(
        `해당 연도(${year})의 예산 데이터를 찾을 수 없습니다.`,
      )
    }

    return budgets
  }
  • 개선 전 코드에서는 createQueryBilder를 사용하여 특정 연도와 사용자 ID에 대항하는 예산 데이터를 조회합니다. 이 방식은 쿼리를 자유롭게 구성할 수 있으나 코드의 복잡성이 증가할 수 있습니다.
  • 개선 후 코드에서는 TypeORM의 Repository API인 find 메서드를 사용합니다. 이 방식은 쿼리 매개변수를 직접적으로 객체 형태로 전달하여, 보다 명확하고 간결한 코드로 동일한 결과를 얻을 수 있습니다.

Transaction 데코레이터와 TypeORM 활용

// 개선 전
async updateBudget(
    id: number,
    updateBudgetDto: UpdateBudgetDto,
    user: User,
  ): Promise<Budget> {
    const queryRunner = this.dataSource.createQueryRunner()
    await queryRunner.connect()
    await queryRunner.startTransaction()

    try {
      const category = await queryRunner.manager.findOne(Category, {
        where: { name: updateBudgetDto.category },
      })

      if (!category) {
        throw new NotFoundException('카테고리를 찾을 수 없습니다.')
      }

      const budget = await queryRunner.manager.findOne(Budget, {
        where: { id, user: { id: user.id } },
        relations: ['category', 'user'],
      })

      if (!budget) {
        throw new NotFoundException(`해당 ID(${id})의 예산을 찾을 수 없습니다.`)
      }

      budget.amount = updateBudgetDto.amount
      budget.category = category

      await queryRunner.manager.save(budget)

      await queryRunner.commitTransaction()

      return budget
    } catch (error) {
      await queryRunner.rollbackTransaction()
      throw new InternalServerErrorException('예산을 수정하는데 실패했습니다.')
    } finally {
      await queryRunner.release()
    }
  }
  
// 개선 후
@Transactional()
  async updateBudget(
    id: number,
    updateBudgetDto: UpdateBudgetDto,
    userId: string,
  ): Promise<Budget> {
    const category = await this.categoryRepository.findOneBy({
      name: updateBudgetDto.category,
    })

    if (!category) {
      throw new NotFoundException(
        `해당 카테고리${updateBudgetDto.category}를 찾을 수 없습니다.`,
      )
    }

    const updatedBudgetData = {
      ...updateBudgetDto,
      category,
      user: { id: userId },
    }

    const budget = await this.budgetRepository.findOne({
      where: { id, user: { id: userId } },
    })

    if (!budget) {
      throw new NotFoundException(`해당 ID(${id})의 예산을 찾을 수 없습니다.`)
    }

    return await this.budgetRepository.save({ ...budget, ...updatedBudgetData })
  }
  • 개선 전 코드에서는 queryRunner를 사용하여 데이터베이스 트랜잭션을 수동으로 관리합니다. 이 접근 방식은 트랜잭션의 시작과 커밋, 롤백을 명시적으로 제어해야하기 때문에 코드의 복잡성이 증가합니다.
  • 개선 후 코드에서는 Typeorm Transactional 라이브러리를 사용하여 트랜잭션 코드의 가독성과 생산성을 높였습니다. 한편, updateDTO를 사용하여 데이터를 업데이트함으로써 확장성도 보장합니다.

공통 인터페이스 사용

// 개선 전
export interface AverageAmount {
  categoryName: string
  averageAmount: string
}

export interface Ratio {
  categoryName: string
  ratio: string
}
  


// 개선 후
export interface BudgetDesign {
  categoryName: string
}

export interface AverageAmount extends BudgetDesign {
  averageAmount: string
}

export interface Ratio extends BudgetDesign {
  ratio: string
}
  • 개선 전 코드는 AverageAmountRatio 두 인터페이스가 categoryName이라는 공통 속성을 갖고 있음에도 불구하고, 이를 각각 독립적으로 정의하고 있습니다. 이는 중복 코드를 초래하고, 유지보수를 어렵게 만듭니다.
  • 개선 후 코드는 BudgetDesign이라는 공통 인터페이스를 정의하여 categoryName 속성을 이곳에 포함시켰습니다. AverageAmountRatio 인터페이스는 이제 BudgetDesign을 확장하므로, 구조적 타입 체크에서 더욱 명확하게 서로 구분됩니다.

하나의 기능으로 통합하기

// 개선 전
// budget.controller.ts
@Get('/year/:year')
  async findBudgetByYear(
    @Param('year', ParseIntPipe) year: number,
    @GetUser() userId: string,
  ): Promise<Budget[]> {
    return this.budgetService.findBudgetByYear(year, userId)
  }

  @Get('/year/:year/month/:month')
  async findBudgetByYearAndMonth(
    @Param('year', ParseIntPipe) year: number,
    @Param('month', ParseIntPipe) month: number,
    @GetUser() userId: string,
  ): Promise<Budget[]> {
    return this.budgetService.findBudgetByYearAndMonth(year, month, userId)
  }
  
// budget.service.ts
async findBudgetByYear(year: number, userId: User): Promise<Budget[]> {
    const budgets = await this.budgetRepository
      .createQueryBuilder('budget')
      .where('budget.year = :year AND budget.user_id = :userId', {
        year,
        userId,
      })
      .getRawMany()

    if (!budgets.length) {
      throw new NotFoundException(
        `해당 연도(${year})의 예산 데이터를 찾을 수 없습니다.`,
      )
    }

    return budgets
  }

  async findBudgetByYearAndMonth(
    year: number,
    month: number,
    userId: User,
  ): Promise<Budget[]> {
    const budgets = await this.budgetRepository
      .createQueryBuilder('budget')
      .where(
        'budget.user_id = :userId AND budget.year =:year AND budget.month = :month',
        { year, month, userId },
      )
      .getRawMany()

    if (!budgets.length) {
      throw new NotFoundException(
        `해당 연도(${year})와 월(${month})의 예산 데이터를 찾을 수 없습니다.`,
      )
    }
    return budgets
  }
 
// 개선 후
// budget.controller.ts
@Get('/:year/:month?')
  async findBudgets(
    @GetUser() userId: string,
    @Param('year', ParseIntPipe) year: number,
    @Param('month', ParseIntPipe) month?: number,
  ): Promise<Budget[]> {
    if (month) {
      return this.budgetService.findBudgets(userId, year, month)
    } else {
      return this.budgetService.findBudgets(userId, year)
    }
  }
// budget.service.ts
async findBudgets(
    userId: string,
    year?: number,
    month?: number,
  ): Promise<Budget[]> {
    const query = this.budgetRepository
      .createQueryBuilder('budget')
      .leftJoinAndSelect('budget.category', 'category')
      .where('budget.user_id = :userId', { userId })

    if (year) {
      query.andWhere('budget.year = :year', { year })
    }

    if (month) {
      query.andWhere('budget.month = :month', { month })
    }

    const budgets = await query.getMany()

    if (!budgets.length) {
      throw new NotFoundException(
        `No budgets found for the given criteria. User ID: '${userId}'${
          year ? `, Year: ${year}` : ''
        }${month ? `, Month: ${month}` : ''}.`,
      )
    }

    return budgets
  }
  • 개선 전 코드는 연도별 예산과 월별 예산 조회를 위해 두 개의 별도 API로 정의하였습니다. 이는 기능적으로 중복되어있어 API 경로의 일관성을 해칩니다.
  • 개선 후 코드는 단일 API 엔드포인트를 사용하여 연도별 예산 조회 및 월별 예산 조회기능을 통합하였습니다. 이는 선택적 month 파라미터를 사용하여 코드의 중복을 제거하고 가독성을 향상했습니다.
  • @GetUser() 데코레이터를 사용해 userId가 경로에 직접 표시되지 않도록 설계했습니다.
  • /budget/:year/:month 경로를 사용하여 간결하고 직관적으로 표현했습니다.
post-custom-banner

0개의 댓글