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
경로를 사용하여 간결하고 직관적으로 표현했습니다.