끄적끄적

Repository 의 추상화 본문

개발/js & ts & node.js

Repository 의 추상화

코리이 2023. 12. 23. 21:15

Repository Design Pattern —  https://codingsight.com/entity-framework-antipattern-repository/

 

이번 포스팅에서는 조금 오래된 개념인 Repository 패턴, 그 중에서 추상화에 대한 이야기를 해보고자 한다. 원래 이전부터 쓸까 했지만 이제는 많은 개발자들이 대부분 이 개념을 인지하고 있다고 생각해서 건너뛰었었다. 하지만 최근에 어떤 개발자와 이야기 할 때 아래와 같은 대화를 나눈적이 있었다.

A      : Nest에서 TypeOrm 0.2 에서 TypeOrm 0.3 으로 마이그레이션하기 어려워요.
필자  : 아 connection 이 datasource 로 바뀌어서 조금 달라지긴 했더라고요.
A      : 특히나 TypeOrm 0.2 @EntityRepository 가 삭제되어서 마이그레이션 할 때 고려할 부분이 많아요.
필자  : 혹시 TypeOrm 에서 제공하는 Repository 를 도메인에서 직접 사용해서 그런건가요?
A      : 네 일반적으로 사용하는 방식처럼 사용하죠.

 

실제로 여러 블로그들을 찾아봐도 Repository 패턴을 이야기 할때 "DB 를 추상화"해서 쓰기 위해 사용한다고 한다. 하지만 "Typeorm EntityRepository" 라고 구글에 검색만 해봐도 도메인이 TypeOrm 에 어마무시하게 종속적이도록 개발하고 있는걸 확인할 수 있다. 또한 이는 Nest.js 를 쓰는 개발자뿐만 아니라 Spring 으로 가면 더하면 더했지 덜하지는 않다. 만약 본인이 "spring-data-jpa" 를 사용해서 JpaRepository 를 도메인에 사용하고 있다면 동일한 문제를 발생시키고 있다고 생각하면 된다.

 

물론 Repository 를 추상화 시키지 않고 그대로 사용하는 것이 잘못된 개발이라는 뜻은 아니다. 실제로 여러 실력있는 개발자들은 추상화 시키지 않아도 잘 개발하고 오히려 KISS 법칙을 지켜야 한다고 한다. 과한 추상화는 독약이라고 말하는 것이다. 즉, 필자가 말하고자 하는 부분도 "잘못되었다"가 아니라 "Repository 패턴을 사용해서 DB 레이어를 추상화 시켰다" 라고 부르는 것이 애매하다는 것이다. (JPA, TypeORM ≈  DB 라고 생각하기 때문)  추가적으로 위의 대화처럼 라이브러리(프레임워크) 업데이트를 진행할때 어려움이 생길 수도 있다.

 

그러면 필자는 Repository 패턴을 어떻게 사용하고 있을까 하면 도메인의 Repository 자체는 interface 그 이상 그 이하도 아니다. 특별히 외부 라이브러리에 종속적이지도 않으며 이 Repository 의 구현은 Infra layer 에서 상속받아 구현한다.

종속적인 구현

우선 일반적으로 사용하는 TypeOrm 에 종속적인 Repository 부터 생각해보자. 이 포스팅에서는 0.3 을 사용하고 있으므로 참고 바란다.

import { DataSource, Repository } from 'typeorm';
import { ChatRoom } from '../entity/ChatRoom';

export class ChatRoomRepository extends Repository<ChatRoom>{
    constructor(dataSource: DataSource) {
        super(ChatRoom, dataSource.createEntityManager());
    }
}

 

그리고 이 Repository 를 Service 에서 inject 받아서 처리한다.

export class ChatRoomService {
    constructor(private readonly chatRoomRepository: ChatRoomRepository) {}

    async createRandomRoom(dto: { ownerId: string }): Promise<string> {
        const roomId = v4().toString();
        await this.chatRoomRepository.insert({
            id: roomId,
            title: faker.random.alpha(30),
            ownerId: dto.ownerId,
            description: faker.random.alpha(200),
            lastMessage: faker.random.alpha(200),
        });
        return roomId
    }
}

 

이때 위 코드는 TypeOrm Repository 에 지정되어 있는 insert 를 직접 사용하고 있지만 아래처럼 도메인 모델 패턴도 많이 활용한다.

export class ChatRoomService {
    constructor(private readonly chatRoomRepository: ChatRoomRepository) {}

    async createRandomRoom(dto: { ownerId: string }): Promise<string> {
        // room 을 생성하고 저장
        const room = createChatRoom({
            id: v4().toString(),
            title: faker.random.alpha(30),
            owner: createUser({
                id: dto.ownerId,
                name: faker.name.fullName(),
                profileUrl: faker.image.imageUrl(),
            }),
            description: faker.random.alpha(200),
            lastMessage: faker.random.alpha(200),
        });
        await this.chatRoomRepository.save(room);

        return room.id;
    }
}

 

위의 코드는 생각한대로 잘 수행된다. 하지만 이 때 만약 TypeOrm 이 0.4 로 마이그레이션 되면서 typeorm 의 Repository 가 deprecated 되고 datasource 에서 직접 사용해야 하도록 스펙이 변경되었다고 가정하자. 그렇게 된다면 Service 레이어에서 사용하는 모든 Repository 코드들을 찾아 다시 바꿔줘야 하는 작업을 수행해야 한다. 즉, 이번에 이야기 나눈 0.2->0.3 으로 업데이트 될 때 발생한 경우와 비슷하다는 이야기이다. 혹은 극단적으로 이번에는 TypeOrm 이 갑자기 개발 중단을 선언했다고 가정해보자. 그렇게 되면 ORM 자체를 Prisma 등으로 바꾸는 작업을 진행하면서 정말 많은 도메인 로직들을 하나하나 바꿔야 한다. 그렇기 때문에 Repository 를 다른 프레임워크에 독립적으로 작성할 필요가 있다.

Repository 추상화

Repository 를 추상화 하는 방법은 간단하다. 특히나 TypeOrm 같이 특정 프레임워크에 종속적이지 않도록 개발하도록 되어 있다면 더 쉽다. 설명하기 전에 코드부터 보자

// Domain Layer

export interface ChatRoomRepository {
    create(room: ChatRoom): Promise<void>;
    update(room: ChatRoom): Promise<void>;
}

export class ChatRoomService {
    constructor(
        private readonly chatRoomRepository: ChatRoomRepository
    ) {}

    async createRandomRoom(dto: { ownerId: string }): Promise<string> {
        const room = createChatRoom({
            id: v4().toString(),
            title: faker.random.alpha(30),
            owner: createUser({
                id: dto.ownerId,
                name: faker.name.fullName(),
                profileUrl: faker.image.imageUrl(),
            }),
            description: faker.random.alpha(200),
            lastMessage: faker.random.alpha(200),
        });
        await this.chatRoomRepository.create(room);

        return roomId;
    }
}

 

이제 위 코드는 TypeOrm 의 종속성에서 벗어났으면 Repository 는 단순 interface 로 infra layer 에서는 이를 상속받아 구현해주면 된다. 가장 간단하게 만든 infra 구현코드는 아래와 같다.

export class TypeOrmChatRoomRepository implements ChatRoomRepository {
    constructor(private readonly dataSource: DataSource) {}

    get repository(): Repository<ChatRoomDataEntity> {
        return this.dataSource.getRepository(ChatRoomDataEntity);
    }

    // 구현
    async create(room: ChatRoom): Promise<void> {
        await this.repository.insert(plainToInstance(ChatRoomDataEntity, room));
    }

    // 구현
    async update(room: ChatRoom): Promise<void> {
        await this.repository.save(plainToInstance(ChatRoomDataEntity, room));
    }
}

 

즉 domain layer 에서는 TypeOrm 의 Repsitory 는 사용하지 않았고 infra Layer 에서 TypeOrm 의 Repository 를 사용하는 코드로 바뀌었다. 만약 앞에서 가정한 같은 이슈(TypeOrm Repository 가 deprecated 됨) 가 발생하면 어떻게 변경하면 될까? 다들 알겠지만 TypeOrmChatRoomRepository 의 코드만 변경해주면 된다.

export class TypeOrmChatRoomRepository implements ChatRoomRepository {
    constructor(private readonly dataSource: DataSource) {}

    async create(room: ChatRoom): Promise<void> {
        await this.dataSource.manager.insert(ChatRoomDataEntity, plainToInstance(ChatRoomDataEntity, room));
    }

    async update(room: ChatRoom): Promise<void> {
        await this.dataSource.manager.save(ChatRoomDataEntity, plainToInstance(ChatRoomDataEntity, room));
    }
}

 

 

코드를 확인해보면 알겠지만 도메인 로직은 전혀 건드리지 않고 InfraLayer 에 있는 typeorm 의 repository 를 사용하지 않도록 변경했다. TypeOrm 대신 다른 Orm 을 쓰고 싶다면 어떨까? 이 또한 Repository 구현부만 변경해주면 된다. 즉, Repository 를 추상화 시킴으로써 도메인은 외부의 환경(DB, ORM 등) 에서 독립적으로 작동할 수 있도록 설계되었다는 점이다.

export class PrismaChatRoomRepository implements ChatRoomRepository {
    constructor(prismaClient: PrismaClient) {
    }

    async create(room: ChatRoom): Promise<void> {
        // 구현
    }

    async update(room: ChatRoom): Promise<void> {
        // 구현
    }
}

 

현재 예시를 필자가 가장 많이 사용하는 Typescript 예시로 들었지만 이것이 JAVA/Spring 으로 가더라도 동일한 설계 이론을 적용할 수 있다. "JPA Repository 를 직접 inject 받아 쓰지 말고 추상화" 하면 된다. 예를 들면 아래와 같은 형식이 될 것 같다.

// Domain Layer
public interface ChatRoomRepository {
    void create(ChatRoom room);
}

// Infra Layer
@Repository
public class JpaChatRoomRepository extends SimpleJpaRepository<ChatRoom, Long> implements ChatRoomRepository
    @Override
    public void create(ChatRoom room) {
        super.save(room);
    }
}

결론

Repository 추상화를 하는 것이 무조건적으로 옳다고 할 수는 없다. 특히나 빠르게 개발해야 하는데 Repository 를 추상화해서 개발하게 되면 개발 속도가 현저히 줄어들 가능성도 생긴다. 하지만 필자의 경우에도 나름 최근에 TypeOrm 0.2 에서 0.3 으로 마이그레이션 했을 때 매우 손쉽게 진행한 경험도 있고 해서 개인적으로는 더 선호하는 개발 방식이다. 이 블로그를 보고 나서 "아 Repository 는 무조건적으로 추상화해야 하는 구나" 라고 생각하는 것이 아니라 내가 개발하는 앱이 "Repository 를 추상화 했을 때 얻을 수 있는 이득이 얼마나 있을까" 를 한번쯤 고민해봤으면 좋겠다.