Clean Architecture를 보고 오시면 더 좋습니다.
Service에서의 repository 의존 역전하기
기존 service와 repository 코드는 아래와 같습니다.
// user.service.ts
@Injectable()
export class UserService {
constructor(private userRepository: UserRepository) { }
async findUserByEmail(email: string): Promise<User | undefined> {
return await this.userRepository.findOneBy({ email });
}
async findUserById(id: number): Promise<User | undefined> {
return await this.userRepository.findOneBy({ id });
}
async save(user: User): Promise<User> {
return await this.userRepository.save(user);
}
async update(id: number, data: Partial<User>): Promise<UpdateResult> {
return await this.userRepository.update(id, data);
}
async findUserByIds(userIds: number[]): Promise<User[]> {
return await this.userRepository.findBy({ id: In(userIds) });
}
async findUserWithRelationshipById(userId: number): Promise<User | undefined> {
return await this.userRepository.findUserWithRelationshipById(userId);
}
}
// user.repository.ts
@Injectable()
export class UserRepository extends Repository<User> {
constructor(private dataSource: DataSource) {
super(User, dataSource.createEntityManager())
}
async findUserByIds(userIds: number[]): Promise<User[]> {
return await this.createQueryBuilder('u')
.where('u.id IN (:...userIds)', { userIds })
.getMany();
}
async findUserWithRelationshipById(userId: number): Promise<User | undefined> {
return await this.createQueryBuilder('u')
.leftJoinAndSelect('u.relationshipFromMe', 'relationFromMe')
.leftJoinAndSelect('relationFromMe.toUser', 'follow')
.leftJoinAndSelect('u.relationshipToMe', 'relationToMe')
.leftJoinAndSelect('relationToMe.fromUser', 'follower')
.where('u.id = :userId', { userId })
.getOne();
}
}
위 코드는 "Use Cases" 레이어에 속하는 UserService가 TypeORM을 사용하여 데이터베이스와 상호 작용하는 "Frameworks & Drivers" 레이어에 속하는 UserRepository를 직접 의존하고 있습니다. 이는 Clean Architecture의 의도와는 다릅니다. Clean Architecture에서는 내부 레이어는 외부 레이어에 의존해서는 안 되며, 외부 레이어는 내부 레이어의 추상화된 인터페이스에 의존해야 합니다. 그렇게 작성하지 않은, 위와 같은 경우 외부 라이브러리인 typeorm의 코드가 변경되거나 아예 다른 ORM 라이브러리를 사용할 때 UserRepository 뿐만 아니라 UserService도 함께 변경해야 합니다. 이는 하나의 변경 사항이 전체 애플리케이션에 영향을 미칠 수 있음을 뜻합니다. 의존성 역전 패턴을 사용하여 UserService 클래스가 갖고 있는 UserRepository "클래스"에 대한 의존을 UserRepository "인터페이스"에 대한 의존으로 바꾸어 해당 문제를 해결해 봅시다.
// user.service.ts
@Injectable()
export class UserService {
constructor(private userRepository: UserRepository) { }
async findUserByEmail(email: string): Promise<User | null> {
return await this.userRepository.findOneByEmail(email);
}
async findUserById(id: number): Promise<User | null> {
return await this.userRepository.findOneById(id);
}
async save(user: User): Promise<User> {
return await this.userRepository.save(user);
}
async update(id: number, data: Partial<User>): Promise<void> {
return await this.userRepository.update(id, data);
}
async findUserByIds(ids: number[]): Promise<User[]> {
return await this.userRepository.findByIds(ids);
}
async findUserWithRelationshipById(userId: number): Promise<User | null> {
return await this.userRepository.findWithRelationshipById(userId);
}
}
// user.repository.ts
export abstract class UserRepository {
abstract findOneByEmail(email: string): Promise<User | null>;
abstract findOneById(id: number): Promise<User | null>;
abstract findByIds(ids: number[]): Promise<User[]>;
abstract findWithRelationshipById(id: number): Promise<User | null>;
abstract save(entity: User): Promise<User>;
abstract update(id: number, entity: Partial<User>): Promise<void>;
}
// user.repository.impl.ts
@Injectable()
export class UserRepositoryImpl implements UserRepository {
constructor(
@InjectRepository(User)
private readonly repository: Repository<User>
) { }
findOneByEmail(email: string): Promise<User | null> {
return this.repository.findOneBy({ email });
}
findOneById(id: number): Promise<User | null> {
return this.repository.findOneBy({ id });
}
findByIds(ids: number[]): Promise<User[]> {
return this.repository.findBy({ id: In(ids) });
}
findWithRelationshipById(id: number): Promise<User | null> {
return this.repository.createQueryBuilder('u')
.leftJoinAndSelect('u.relationshipFromMe', 'relationFromMe')
.leftJoinAndSelect('relationFromMe.toUser', 'follow')
.leftJoinAndSelect('u.relationshipToMe', 'relationToMe')
.leftJoinAndSelect('relationToMe.fromUser', 'follower')
.where('u.id = :id', { id })
.getOne();
}
save(entity: User): Promise<User> {
return this.repository.save(entity);
}
async update(id: number, entity: Partial<User>): Promise<void> {
await this.repository.update(id, entity);
return;
}
}
위 코드는 더 이상 UserService가 DB와 직접 상호작용하는 typeORM코드가 있는 UserRepositoryImpl에 직접 의존하지 않습니다. 따라서 typeORM이 변경되거나 아예 다른 ORM을 사용하더라도 UserRepository와 UserService를 수정할 필요 없이 단 하나의 클래스(UserRepositoryImpl)만 수정하면 됩니다. 따라서 하나의 변경 사항이 전체 애플리케이션에 영향을 미치지 않습니다. 이는 SOLID 규칙 중 SRP를 준수하는 것이며 Clean Architecture의 종속성 규칙도 준수하는 것입니다.
단, 두 개의 코드블록을 보면 알 수 있듯 아래의 코드블록이 위 코드블록보다 더 길고 복잡합니다. 다시 말해 작성하는 데 조금 더 많은 시간이 필요하고 어떻게 보면 가독성도 떨어집니다. Clean Architecture는 소프트웨어의 구조를 일관성 있게 유지하고, 의존성 관리를 통해 소프트웨어를 격리하는 데에 중점을 두고 있습니다. 이러한 구조는 대규모 프로젝트나 장기적인 유지보수가 필요한 프로젝트에서 가장 큰 장점을 제공합니다. 하지만 작은 규모의 프로젝트나 프로토타입 같은 경우 Clean Architecture를 적용하기 어려울 수 있습니다. 이 경우 새로운 기능 추가나 수정 작업이 필요한 경우 Clean Architecture를 적용해 가면서 리팩터링 하는 것이 가능합니다. 따라서 Clean Architecture는 소프트웨어를 설계하는 과정에서 고려해야 할 중요한 요소 중 하나이지만, 항상 엄격하게 준수해야 하는 것은 아닙니다. 프로젝트 크기와 복잡도, 유지보수 필요성 등을 고려하여 적절하게 적용하는 것이 좋습니다.
https://betterprogramming.pub/clean-node-js-architecture-with-nestjs-and-typescript-34b9398d790f
https://medium.com/@jonathan.pretre91/clean-architecture-with-nestjs-e089cef65045
'NestJs' 카테고리의 다른 글
Entity -> DTO 변환 (0) | 2023.03.23 |
---|---|
NestJS - @Module(), @Injectable(), @InjectRepository() (0) | 2023.03.17 |
NestJS - passport (0) | 2023.03.13 |
NestJs - Guard (0) | 2023.03.09 |
NestJS 소개 (0) | 2022.10.23 |