파이썬으로 살펴보는 Repository Pattern

Matthew Woo·2022년 9월 25일
0

Architecture

목록 보기
2/2

현재 회사에서 DDD, Hexagonal architecture 을 중요시 하는 편이라 업무에서 많이 사용하는 Repository 패턴을 정리해보고자 합니다.


이미지출처(https://engineering.spendesk.com/posts/repository-pattern-at-spendesk/)

Repository는..

  • a simplifying abstraction over data storage
  • allow us to decouple our model layer from the data layer

즉, 서비스의 메인 로직, 서비스 요소들을 저장소(DB)와 분리시킨다.

왜 분리시켜야할까?

우선 분리가 안된 그림을 보자. MVC 패턴의 경우 레이어별로 구분되어 있지 않아 Database 관련하여 수정사항이 생겼을 때 해당 DB를 사용하는 서비스, 메인로직도 수정을 해야할 것 이다. 위 상황에서 Database를 변경한다고 가정해보자. 그럼 메인 로직이 직접적으로 Database에 함수들을 call하고 있기에 DB의 변경, 혹은 DB쪽 관련 코드들이 변경된다면 메인 로직의 코드들도 수정이 필요할 것 이다.

이러한 상황으로부터 벗어나고자 Repository 패턴을 사용한다. 헥사고날 아키텍처를 접했다면 port와 adapter의 개념이 나오는데,
Repository 패턴은 위의 문제시 되는 상황을 port와 adapter의 개념으로 해결한다.

도메인, 서비스 레이어에서 DB를 사용할 때는 Port를 만들어 두고, 해당 Port를 통해 DB를 전달한다. Port에 연결된 adapter는 서비스, 도메인으로부터 분리되어 있기에 쉽게 갈아끼울 수 있다. 특정 A라는 DB를 사용하다가 어느 날 특정 의사결정으로 인해 B라는 다른 DB로 변경하더라도 Port 규격에 맞춰놨던 데이터를 전달하기만 하면 되기 때문이다. 또한 DB에서 발생한 문제, 변경사항이 있더라도 저장소 layer에서 변경, 처리 후 Port 규격에 맞춰 기존처럼 전달하면 된다. 이로 인해 특정 layer의 변경사항이 다른 layer에 영향을 주는 것을 방지할 수 있다. 또한 레이어별로 분리를 해두었기에 각 레이어별로 테스트할 수 있는 장점이 있다.

추상화의 개념에서 보자면, 도메인(서비스 레이어)에서는 DB는 추상화하여 Port를 이용한다. 내부 디테일한 부분들은 DB layer내에서 이루어지기에 이런 부분들을 도메인에서는 알 필요가 없다. 포트는 두 레이어가 만나는 Interface이자 추상화의 대상이며, adapter는 이러한 추상화 뒤에서 구체적인 구현 이 이뤄지는 부분이다.

또 다른 장점은, Business Logic 이 담긴 레이어에서 외부 IO를 요청하는 것이 DB 하나만 있진 않을텐데 이런 외부 IO들을 Repository 패턴으로 구성하면 메인 서비스 Logic 코드가 깔끔하고 가독성이 좋아진다.


User 를 관리하는 서비스라는 가정으로 간단한 예시를 만들어보자면, User를 관리하는 서비스이기에 User를 DB에 조회, 생성, 수정 하는 작업이 필요할 것이다. 그럼 아래와 같이 Interface 즉 Port를 만들어둔다.

# Port
class AbstractUserRepository(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def get_user(self, user_id: str) -> User:
        """User를 조회합니다."""
        raise NotImplementedError

    @abc.abstractmethod
    def create_user(self, entity: User) -> None:
        """User를 생성합니다."""
        raise NotImplementedError

    @abc.abstractmethod
    def update_user(self, entity: User) -> None:
        """User를 수정합니다."""
        raise NotImplementedError

Port에서 정의한 규격에 따라 adapter는 어떻게 구성할 수 있을까?

from db.models import User

# Adapter
class DjangoUserRepository(AbstractUserRepository): # 위에서 포트로 설정한 Class를 상속
	def get_user(self, config_id: str) -> User:
        try:
            user_model: User = User.objects.get(id=user_id)
        except User.DoesNotExist:
            raise NotExistsIdError()
        return user_model.to_entity()

    def create_user(self, entity: Config) -> None:
        try:
            User(
                id=entity.id,
                name=entity.name,
                ...
                
            ).save()

        except Exception as error:
            logger.error() # logging
            raise error

    def update_user(self, entity: User) -> None:
        try:
            user = User.objects.get(pk=entity.id)
            user.name = entity.name
            ...
            user.save()
        except User.DoesNotExist:
            raise NotExistsIdError()

위 adapter는 Django ORM을 이용하는 Repository다. 어느 날 Django ORM 이 아닌 query를 직접 작성하여 사용하는 Mysql 이나, 다른 DB로 변경이 되더라도 이 adapter반 변경해주면, Port나 비지니스 로직까지 변경 및 영향을 받는 일이 발생하지 않는다.

class UseCase:
    def __init__(
        self,
        repo: AbstractUserRepository,
    ):
        self._repo: AbstractUserRepository = repo

    def get_user(self, user_id: str) -> User:
        return self._repo.get_user(user_id)

그럼 서비스(도메인) 레이어에서 이렇게 DB에서 user를 가져오는 작업은 abstraction 된 get_user라는 함수를 이용해서 직접 디비관련 코드는 encapsulate 되었기에 해당 레이어의 변경으로부터 보호받게 된다.

또 다른 장점은, 해당 레이어별로 테스트를 작성하고 레이어별 테스트를 진행할 수 있다는 점 이다. 테스트 코드들이 레이어별로 잘 짜여져 있다면 추후 코드를 변경하더라도 자신있게 변경할 수 있고, 어느 부분 어느 레이어에서 문제가 발생하는지 또한 빠르게 캐치할 수 있다.


그럼 레포지토리 패턴은 무조건 좋을까?
MVC 패턴의 장고를 예로 들자면, 빠르고 쉽게 서비스를 만들 수 있는데에 비해 Repository 패턴의 경우 장고의 serializer로 구현하는 것에 비해 처음에는 많은 공수가 들어가기 마련이다.

그럼에도 불구하고 serializer를 피하라. 라는게 개인적인 의견이다. 위 이미지는 처음에 Repo Pattern을 사용하는 것이 비용을 지불하는 것이지만 어느 시점 이후부터는 Repo로 코드를 짜두는 것이 훨씬 절약하는 방식임을 보여준다.
간단한 테스트 프로젝트라던가, 앞으로의 해당 코드의 변경사항이 없으리라는 확신이 있을 때 사용해야한다. 추후 변경이 있을 것 같고 활발한 서비스가 진행중인데 기한이 급할 경우라면..? 이런 부분은 작업 기한을 조금은 늘어나더라도 이 부분은 엔지니어로서 타협해서는 안될 지점이다. 초기 들어가는 기한 대비 추후에 가져올 비용을 크게 줄일 수 있기 때문이다. 처음 토대를 잘 갖추어 놓으면 추후에 보다 유연하고 빠르게 변화에 대처할 수 있다.다른 팀의 개발자가 참고할 부분이 있어 처음 보는 코드를 보는데 serializer로 몇백줄 씩 짜여진 코드를 보게 된다면.. 음.. 응원해주고 싶다. 파이팅. (나의 경험.. 또륵..)

또한 장고의 serializer는 마이크로서비스 환경에서 적합하지 않다. 이유는 나중에 한번 다뤄보도록 하겠다. 잘 쓰다가 뒷 부분에 와서 얘기가 엉뚱하게 흘렀다.

참고

https://www.cosmicpython.com/ (참고 & 추천)

https://www.getoutsidedoor.com/2018/09/03/ports-adapters-architecture/ (중간에 이미지 가져옴)

profile
Code Everyday

2개의 댓글

comment-user-thumbnail
2023년 4월 10일

Django에서의 Repository 패턴에 대한 유익한 글 잘 읽었습니다. 더 완벽한 글을 위해 오타 제보합니다.

class DjangoUserRepository(AbstractUserRepository): # 위에서 포트로 설정한 Class를 상속
	def get_user(self, config_id: str) -> User:

config_id -> user_id로 수정해야 할 것 같습니다.

답글 달기
comment-user-thumbnail
2023년 10월 2일

좋은 글 감사합니다.
Interface 클래스를 정의 시 이름을 AbstractUserRepository가 아닌 UserRepository로 짓고
구현체를 UserRepositoryImplement(혹은 UserRepositoryImpl)로 짓는게 좀 더 좋은 코드 같습니다.

답글 달기