DRF에서의 의존성 주입(DI)에 대하여

imasimdi·2024년 1월 3일

Django Rest Framework

목록 보기
5/5
post-thumbnail

🤩 들어가면서

훕치치 프로젝트를 시작한지 어언 3개월치, 이제 곧 3차 릴리즈를 향해 달려가고 있다.
그동안 나는 DRF를 주로 사용하면서, DRF를 사용할 때 Django에서 기본으로 지원하는 MVC 패턴에서 벗어나고자 많이 노력했다.
이유는 Rest api만을 개발하는데 MVC 패턴은 하나의 큰 덩어리처럼 스파게티 코드를 만들게 된다.

주로 Django에서는 views.py에 모든 로직이 담기게 된다. 즉, 클라이언트와 상호작용하는 presention 로직, 실제 비지니스 로직, 그리고 데이터베이스와 상호작용하는 persistance 로직까지 하나의 클래스나 함수에 담기게 되는 것이다. 이렇게 되면 유지보수가 대단히 어려워지게 된다. 너무 절차식으로 되어있다보니 어디가 잘못된 부분인지 잘 모르게 되고, 각 view의 함수와 클래스의 책임이 무엇인지도 모르며, 로직들이 강하게 결합이 되어있기 때문이다.

그래서 기존에 한 view에 모든 로직을 때려박는 안티패턴에서 탈피해, 최대한 아키텍처를 세분화하고 싶었다.
전에 'QRTaxi' 라는 서비스를 개발할 때, 의존성 주입은 아니더라도 layered architecture를 함수로 구현한 적이 있었다.

예를 들어서, 택시가 호출 성공하고 나서 배정정보를 불러오는 API가 있었을 때 이렇게 service로직만을 나눴었다.

call/views/call_success_view.py

class CallSuccessView(APIView):
    """
    택시 호출 성공 후 assign 정보를 보여주는 view
    """
    def get(self, request, hashed_assign_id: str):
        try:
            response = get_success_info(hashed_assign_id)
            return Response(response, status=status.HTTP_200_OK)
        except exceptions.ValidationError as e:
            return Response({"detail": "잘못된 요청입니다.", "error": e.detail}, status=status.HTTP_400_BAD_REQUEST)

그리고 나서 get_success_info와 같은 service 로직들은 따로 분리해서 service라는 디렉토리에 모아놨었다.

call/service/call_success_service.py

def get_success_info(hashed_assign_id: str):
    """
    기사가 배정된 assign 정보를 불러오는 service
    """
    try:
        assign_id = Hashing.decode(hashed_assign_id)
        get_assign_info = Assign.objects.select_related('qr_id', 'driver_id').get(id=assign_id)
        if get_assign_info.status in ('success', 'riding'):
            get_assign_serializer = CallSuccessGetSerializer(get_assign_info)
            result = get_assign_serializer.data
            result['estimated_time'] = calculate_estimated_distance(get_assign_info)
            return result
        else:
            raise exceptions.ValidationError("잘못된 운행 상태입니다.")
    except Assign.DoesNotExist:
        raise exceptions.NotFound("해당 콜 데이터를 찾을 수 없습니다.")

비록 이때도 persistance 로직을 분리하지는 않았지만, 그래도 하나의 view에 모든 로직이 담겨있는 것 보다 훨씬 유지보수가 쉬웠다. 어떤 api를 수정해야할 때 하나의 책임을 갖는presentation(위에서는 view) 파일을 찾아서 service 로직으로 들어가면 됐으니깐 말이다.

그럼에도 불구하고 이는 계층이나 클래스가 분리가 된 것이 아니다. 보다시피 서비스 로직도 결국 view 클래스 객체의 한 메소드이기 때문이다. 따라서, 이번에 훕치치 프로젝트를 시작하기 전에, DRF에서 의존성 주입(DI)을 어떻게 하면 좋을까? 생각하여서 본격적으로 연구해보았다.

🤔 의존성 주입은 무엇이고 왜 하는가?

그전에 '의존성 주입' 이 무엇인지 대충 정리하고 가는 것이 좋겠다. 한줄로 요약하면 이렇다.

'의존성 주입'이란 디자인 패턴 중 하나로, 한 객체가 다른 객체에 의존할 때, 이 의존성을 '외부'에서 제공하는 방식이다.

무슨 말이냐하면, DI를 사용하지 않으면 객체는 자신이 필요로하는 의존성을 직접 생성한다. 이렇게 되면 의존성이 변경될 때 해당 객체의 코드도 변경을 해야하는 상황이 벌어진다. 예시를 보여주겠다.

DI를 사용하지 않을 때

class Service:
    def __init__(self):
        self.client = HttpClient()   # Service 클래스가 HttpClient 클래스에 직접 의존

    def do_something(self):
        response = self.client.get("http://some.api/something")
        return response

service = Service()
result = service.do_something()

이 코드는 하나의 HttpClient에서 response를 받아오는 예시 코드이다. 위 코드에서는 Service 클래스가 HttpClient를 직접 생성하는 식으로 앞서 말한 의존성을 직접 생성하는 모습을 보여준다.

이 상황에서 HttpClient가 수정되거나, 다른 클래스를 사용해야한다고 하면 Service도 수정해야하며, 이는 객체 간의 결합도를 높이게 되고, 유지보수도 좋지 않게 된다.
하지만 DI를 사용하면 어떨까?

DI를 사용할 때

Containers.py

from dependency_injector import providers, containers

class Container(containers.DeclarativeContainer):
    client = providers.Factory(HttpClient)
    service = providers.Factory(Service, client=client_provider)

우선 이렇게 DI 컨테이너를 만들어줘서 service에 필요한 의존성을 이 컨테이너 객체에 정의한다. 그리고 이 컨테이너에 정의된 의존성을 바탕으로 실제 Service 로직은 이렇게 된다.
Service.py

class HttpClient:
    def get(self, url):
        pass  # 실제 HTTP 요청을 수행하는 코드

class Service:
    def __init__(self, client):
        self.client = client  # 의존성이 외부에서 주입됨

    def do_something(self):
        response = self.client.get("http://some.api/something")
        return response
        
container = Container()
service = container.service()
result = service.do_something()

Service class는 HttpClient를 직접 의존하지 않고, 외부에서 주입된 client 객체를 주입 받는다. 이렇게 되면 Service 객체는 HttpClient 내부 로직을 전혀 몰라도 되며, 또한 client가 실제 어떤 객체인지도 몰라도 된다.
즉, client가 HttpClient인지, HttpsClient인지 몰라도 된다는 것이다.

정리해보면 DI를 사용하면 얻을 수 있는 장점들은 다음과 같다.

  1. 객체 간의 결합도를 낮출 수 있다.
  2. 코드의 유연성을 높여 유지보수를 쉽게 해준다.
  3. 테스트가 용이해진다.

🤔 그럼 DIP(의존성 역전 법칙)은 뭐야?

의존성 주입과 연결 되는 것이 의존성 역전 법칙이다. 이는 SOLID 5대 원칙 중 하나이고, 한 줄로 말하자면 다음과 같다.

'고수준의 모듈은 저수준 모듈이 구현에 의존하면 안된다' 즉, '추상화에 의존하고, 구현체에 의존하지 마라'

예시 코드를 보여주겠다. 만약 유저의 정보와 관려해서 데이터베이스와 상호작용하는 'UserManager'가 존재한다고 치자. 만약에 의존성 역전의 법칙을 위반한 코드는 어떨까?

# 저수준 모듈
class MySQLDatabase:
    def connect(self):
        # DB를 연결하는 로직

    def disconnect(self):
        # DB를 연결을 끊는 로직

# 고수준 모듈
class UserManager:
    def __init__(self):
        self.database = MySQLDatabase()

    def get_users(self):
        self.database.connect()
        # 사용자 정보를 가져오는 로직
        self.database.disconnect()

여기서 UserManager는 MySQLDatabase라는 저수준의 모듈, 즉 구현체에 직접 의존을 하고 있다. 이 상태에서 만약에 MongoDB로 변경되어야 한다면 UserManager를 직접 변경해야한다.
이와 같은 문제는 DIP를 통해서 해결할 수 있다. 다음은 DIP를 적용한 코드이다.

# 추상클래스
class Database(ABC):
	@abstractmethod
    def connect(self):
        pass
        
	@abstractmethod
    def disconnect(self):
        pass
        
# 저수준 모듈(구현체)
class MySQLDatabase(Database):
    def connect(self):
        # DB를 연결하는 로직

    def disconnect(self):
        # DB를 연결을 끊는 로직
        
# 저수준 모듈(구현체)
class MongoDatabase(Database):
    def connect(self):
        # DB를 연결하는 로직

    def disconnect(self):
        # DB를 연결을 끊는 로직
        
# 고수준 모듈
class UserManager:
    def __init__(self, database: Database):
        self.database = database

    def get_users(self):
        self.database.connect()
        # 사용자 정보를 가져오는 로직
        self.database.disconnect()

UserManager가 구현체에 의존하는 것이 아니라 추상화에 의존을 하게 되면서 UserManager는 이제 어떤 데이터베이스와 연결되는지 알 필요가 없어졌다.
물론 이는 DI 컨테이너에서 의존성을 결정하고, 이를 통해서 구체적인 database 구현체는 생성자를 통해서 주입된다.
다형성의 원칙에 따라서 database의 타입이 추상 클래스인 Database여도 Database를 상속받은 MySQLDatabase 인스턴스를 참조할 수 있다.

이렇게 DIP를 통해서 DI의 장점과 유사하게 모듈간 결합도를 낮추고, 유지보수에 용이해진다는 장점이 있다.

🚢 dependency_injector를 이용한 DRF의 DI

그렇다면 내가 선택한 DRF에서의 DI 방법은 무엇일까? 스프링에서는 프레임워크 자체에서 클래스를 탐색하고, 빈을 생성하여 객체들의 관계를 맺어준다. 하지만 DRF에서는 DI 컨테이너 같은 것은 존재하지 않고, 이런 식으로 의존성 주입을 하고 있었다.

class PostRetrieveUpdateDestroyView(RetrieveUpdateDestroyAPIView):
    serializer_class = PostSerializer
    authentication_classes = [JWTAuthentication]
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        return Post.objects.filter(user=self.request.user)

여기서 RetrieveUpdateDestroyAPIView 클래스는 Response객체를 만들 때 PostSerializer를 사용하며, JWT 인증과 권한에 대해서 class 밑쪽에 명시를 하고 있다.
하지만 APIView의 이러한 의존성 관리는 serializer나 authentication_classes와 같은 한정적인 class만 가능했고, 외부에서 주입 받는 것이 아닌 class 안에서 직접적으로 의존하고 있어 class간 높은 결합도를 보이고 있다.

즉, 기본적으로 django와 DRF는 제대로 된 DI 패턴을 지원하지 않는다. 애초에 그렇게 설계된 프레임워크가 아니기 때문..

하지만 나는 포기를 모른체 다른 외부 라이브러리를 탐색했고, django를 비롯해 Fastapi, flask에도 적용할 수 있는 범용적인 DI 툴인 "dependency_injector" 를 선택했다.
공식 문서도 잘 정리되어 있어서 선택에 한몫했다.
https://python-dependency-injector.ets-labs.org

이번 훕치치 프로제트에 적용한 나의 DI 적용 코드를 예시로 보여주겠다. 해당 코드는 타임라인(DB상으로는 Record)를 기록하는 API이다. 이해를 돕기 위한 API 명세를 보여주겠다.

{
	"gameTeamId": 1,
	"gameTeamPlayerId": 6,
	"score": 1,
	"quarterId": 2
}

해당 데이터를 request 받으면, 타임라인을 생성하고 저장하는 로직이다. 우선 밑의 코드는 Record 앱에 있는 컨테이너이다.

🖥️ record/containers.py

from dependency_injector import containers, providers
from record.domain import RecordRepository
from record.application import RecordService
from game.domain import GameRepository

class RecordContainer(containers.DeclarativeContainer):
    record_repository = providers.Factory(RecordRepository)
    game_repository = providers.Factory(GameRepository)
    record_service = providers.Factory(
        RecordService,
        record_repository=record_repository,
        game_repository=game_repository,
    )

record service에 필요한 record repo와 game repo를 공급자를 통해서 Factory로 생성하고, record_service의 의존성을 지정해준다.
만약에 record_repository가 다른 구현체의 repository를 의존하는 것으로 바뀌어도 이 컨테이너만 변경하면 된다.

🖥️ record/record_view.py

class RecordView(APIView):
    authentication_classes = [JWTAuthentication]
    permission_classes = [IsAdminUser]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._record_service = RecordContainer.record_service()

    def post(self, request, game_id: int):
        self._record_service.create_record(game_id, request.data)
        return Response(status=status.HTTP_201_CREATED)

presentation 계층의 record_view이다. 여기서 생성자를 통해서 레코드 서비스를 주입 받고, 레코드 서비스의 create_record를 부른다.

🖥️ record/record_service.py

class RecordService:
    def __init__(self, record_repository: RecordRepository, game_repository: GameRepository):
        self._record_repository = record_repository
        self._game_repository = game_repository

    def create_record(self, game_id: int, request_data):
        record_request_serializer = RecordRequestSerializer(data=request_data)
        record_request_serializer.is_valid(raise_exception=True)
        record_data: dict = record_request_serializer.validated_data

        game_team_id: int = record_data.get('game_team_id')
        game_team: GameTeam = self._game_repository.find_game_team_by_id(game_team_id)
        game_team.score += record_data.get('score')
        self._game_repository.save_game_team(game_team)

        new_record = Record(
            game_id=game_id,
            game_team_id=record_data.get('game_team_id'),
            game_team_player_id=record_data.get('game_team_player_id'),
            score=record_data.get('score'),
            scored_quarter_id=record_data.get('quarter_id')
        )
        self._record_repository.save_record(new_record)

해당 서비스는 score을 game_team의 스코어에 더한 뒤 저장을 하고, 새로운 Record 객체를 만들고 저장해준다.
레코드 서비스에서도 DI 컨테이너를 통해서 주입받은 record_repository와 game_repository를 생성자를 통해서 만들어준다.
생성자를 통해서 의존성 주입을 함으로써 주입 받은 객체가 변하지 않는다는 것을 보장받을 수 있었다.

😆 후기

이렇게 dependency_injector를 통해서 DRF에서도 훌륭한 의존성 주입이 가능했다. 스프링의 빈처럼 프레임워크 자체에서 의존성 주입을 지원하지 않는 다는 것이 아쉬웠지만, DRF에서도 충분히 DI 구현이 가능했다.
물론 아직 추상 클래스를 잘 활용하지 못해서 의존성 역전 법칙을 잘 따르지 못하는 것 같지만 조금은 더 큰 서비스에서의 DRF 활용 방법을 찾았고, 나의 개발 스타일이 만들어져서 공부하길 잘했다는 생각이 들었다. 👍

📚 참고 자료

https://mangkyu.tistory.com/125
https://python-dependency-injector.ets-labs.org

profile
터키어 하는 개발자(호소인)이에요

0개의 댓글