Money Note인 개인 재무 관리 애플리케이션의 기능을 개발한 후 리팩토링이 필요하다고 느꼈습니다.
어떻게 리팩토링 했는지에 대한 내용을 작성해보고자 합니다.
객체 지향 개발 프로그래밍의 SOLID 원칙에 따라 코드를 수정하고자 노력했습니다.
1. 한 메서드의 기능이 너무 커서 단일 책임의 원칙에 따라 더 세세하게 기능을 나누었습니다.
2. 공통된 패턴을 찾아서 공통 함수로 만들어 재사용성, 확장성을 향상할 것이다.
3. orm의 기능을 적극 활용해보고자 합니다.
// 개선 전
const categoryName = await this.categoryRepository.findOne({
where: { name: category },
})
// 개선 후
const categoryName = await this.categoryRepository.findOneBy({
name: category,
})
findOne은 다양한 검색 조건을 지정할 수 있으며, where 절을 통해 복잡한 쿼리를 구현할 수 있습니다.findOneBy는 단일 조건에 기반하여 엔티티를 조회할 때 사용합니다.이 메서드는 코드의 간결성과 가독성을 높이는 데 유리하며, 단순한 쿼리에서 사용을 권장합니다.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에 대항하는 예산 데이터를 조회합니다. 이 방식은 쿼리를 자유롭게 구성할 수 있으나 코드의 복잡성이 증가할 수 있습니다.// 개선 전
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 })
}
// 개선 전
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
}
AverageAmount와 Ratio 두 인터페이스가 categoryName이라는 공통 속성을 갖고 있음에도 불구하고, 이를 각각 독립적으로 정의하고 있습니다. 이는 중복 코드를 초래하고, 유지보수를 어렵게 만듭니다.BudgetDesign이라는 공통 인터페이스를 정의하여 categoryName 속성을 이곳에 포함시켰습니다. AverageAmount와 Ratio 인터페이스는 이제 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
}
@GetUser() 데코레이터를 사용해 userId가 경로에 직접 표시되지 않도록 설계했습니다./budget/:year/:month 경로를 사용하여 간결하고 직관적으로 표현했습니다.