FastAPI에 적용하는 Interface & Implements 와 DI Container
목차
지난글
- 2024.11.08 - [Frame Work/FastAPI] - FastAPI에 적용하는 Interface & Implements
개요
지난 글에서는 생각과 설명의 나열이 주를 이뤘다. 또한 코드 예제까지 포함하면 긴 호흡의 글이 작성될 것 같아 코드 예제를 포함하지 않았다.
이 글은 지난 글의 “Enhancement”의 하위 주제인 “interface & implement”와 “DI Conatiner”에 대한 설명과 개인 경험과 그에 대한 생각을 추가하면서 실제 적용한 코드 예시까지 기록해보고자한다.
Python에 Interface가 필요한가?
만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥 거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다.
- 덕 타이핑(위키백과)
파이썬은 동적 타입 언어다. 객체의 타입을 동적(런타임)으로 다루기 때문에 interface를 사용하여 타입을 강제할 필요가 없다. 또한 실용적인 면도 한 몫한다. 간결한 문법으로 생산성을 비교적 높일 수 있는 Python에서 Interface를 만들어내는 것은 어떻게 보면 오히려 생산성을 떨어뜨리는 원인일 수도 있다.
그러나 나는 점을 바꿔서 보기로 했다. “동적타입언어” 와 “생산성” 보다 상위에 존재하는 장점이라고 한다면 유지보수성이라고 생각한다. 여기서 “좋은 코드, 나쁜 코드”라는 책에서 나온 다음 문구를 인용한다.
답은 단기적으로는 고품질 코드를 작성하는 데 시간이 더 걸릴 수 있다는 것이다. 코드 품질을 고려하지 않고 먼저 떠오르는 대로 코딩하면 처음에는 시간을 절약할 수 있다.
그러나 이런 코드는 머지않아 취약하고 복잡한 코드베이스로 귀결될 것이며, 점점 더 이해하기 어렵고 추론할 수 없는 코드가 된다. 새로운 기능을 추가하거나 버그를 수정하는 것이 점점 더 어려워지고 시간도 더 많이 걸리는데, 작동하지 않는 코드를 처리하고 재설계해야 하기 때문이다.
지금 당장 돌아가는 코드보다 내일도 돌아가는 코드를 만드는게 중요한게 아닐까싶다. 물론 이럴려면 돌아가는 코드를 만들어내는 건 당연하게 할 줄 알아야하지만..
그렇다면 코드를 재사용하는데 현실적으로 얼마나 걸릴 것인지 생각해 보자.
지난 프로젝트 경험동안 깨달은 바는 코드는 예상외로 하루가 달리 사용된다는 점이다. 엄밀히 표현해 “사용”보다는 “기억”해 낼 수 있는 코드인지에 좀 더 가깝다. 많은 작업량이 들이닥치면 오늘 작성한 코드가 내일은 스스로조차 못 알아볼 수 있는 코드가 만들어지곤 한다. 물론 어떻게든 읽고 파악할 순 있겠지만 그러려면 처음부터 시간 쫌 들여서 고품질 코드를 작성하는 노력을 하는 게 낫지 않겠는가.
결론적으로 Python을 다룸으로써 따라오는 특징을 취하기보다 프로젝트를 진행함에 있어 일이 되게 만드는 코드의 구조를 신경 쓰는것이 중요하고 그렇기에 Python에도 적극적으로 interface를 사용해야 된다는 코멘트를 남기고 싶다.
Abstract vs Protocol
Python에서 Interface를 표현하는 방법에는 두 가지 방식이 존재한다. ABCMeta를 이용한 방법과 typing 모듈이 제공하는 “Protocol”를 이용하는 것이다.
from abc import ABCMeta
class Interface01(metaclass=ABCMeta): ...
from typing import Protocol
class Interface02(Protocol): ...
Python의 Interface가 덕타이핑이기 때문에 둘 중 어떤 걸 사용해도 Interface를 표현할 수 있지만 나 개인은 ABCMeta를 이용해서 Interface라는 것을 명시한다.
from abc import ABCMeta, abstractmethod
class Interface01(metaclass=ABCMeta):
@abstractmethod
def method(self):
raise NotImplementedError("Must be Implements")
공식 문서에 따르자면 ABC는 “PEP 3119(2007-04-18)”를 Protocol은 “PEP 544(2017-03-05)”에서 다뤘다.
Protocol이 좀 더 최신에 가깝고 서브타이핑을 다루기 위해 나타났다.
쭉 ABCMeta를 사용했지만 글을 쓰는 지금 순간에 생각해 보건대 Protocol로 interface를 표현하는 것이 조금 더 명시적이지 않을까 싶다.
Java에서의 abstract와 interface를 Python에 대입해 보자면 abstract→ABCMeta, interface→Protocol이라고 봐도 괜찮지 않을까 싶다.
FastAPI에 Interface & Implemenets 사용하기
이제 서론과 개인 생각은 접어두고 FastAPI에서 Python Interface를 적용한 예시 코드에 대해 다뤄보겠다. Interface를 적용할 수 있는 부분은 꽤나 다양하지만 Service 계층이 예시다.
FastAPI APIRouter에 Interface && Implements를 적용하기
간단히 Service 계층에서 어떤 Resource를 등록 및 등록해제를 표현하는 Interface는 단순히 다음과 같이 선언할 수 있을 것이다.
class IResourceService(metaclass=ABCMeta):
reader: ResourceReader
writer: ResourceWriter
@abstractmethod
def register(self, resource):
...
@abstractmethod
def unregister(self, resource):
...
그리고 이에 대한 구현체는 다음과 같이 만들어낼 수 있다.
class ResourceService(IResourceService):
def register(self, resource):
self.writer.create(resource)
def unregister(self, resource):
self.writer.delete(resource)
Python에는 interface라는 개념이 없어 java처럼 “implemenets” 같은 키워드는 없다. 그래서 class 상속을 이용했다. FastAPI에서 위와 같은 코드를 가져다 사용하려면 APIRouter가 다음과 같은 모습이 나온다.
@test_router.get(
path=""
)
@inject
async def test_endpoint(
service: IResourceService = Depends(ResourceService()),
):
return JSONResponse(content={})
Depands를 이용해 구현체 클래스의 의존성을 주입한 것인데 위와 같은 코드 형태는 TypeError를 유발한다.
TypeError: <src.application.interfaces.xxx.ResourceService object at 0x1075b4a50> is not a callable object
해결하기 위해선 다음과 같이 구현체 클래스에 “call”을 추가해줘야 했다.
class ResourceService(IResourceService):
...
def __call__(self, *args, **kwargs):
return self
그러나 구현체 클래스에 매직(call) 메서드가 들어가 있는 형태가 마음에 들지 않았는데 이는 dependency-injector로 Service를 관리하는 형태로 개선시켰다.
dependency-injector를 사용해 Container로 만들기
이 아이디어는 “필요한 의존성 설정을 미리 설정하고 이를 xxxContainer라 명명한 클래스를 만든 뒤 이를 통해 필요한 Service 로직을 DI해 보자”라는 아이디어에서 출발했다.
이 아이디어를 실현 가능해주게 도와준 라이브러리가 있었는데 dependency-injector가 바로 그것이다. https://python-dependency-injector.ets-labs.org/
앞선 방법의 한계점은 구현체(ResourceService)가 필요한 경우 APIRouter의 파라미터에서 매번 필요한 의존성을 주입해서 사용해야한다는 점이다. 이는 같은 코드의 반복이며 코드의 라인 수가 늘어나기 때문에 인지적인 부담이 증가할 수 있는 요소로 생각한다.
그래서 미리 Container라 불리는 class 안에서 구현체 클래스에서 필요한 의존성들을 미리 주입한 다음 APIRouter가 Container 클래스를 사용하게 만들어 일관적인 코드 스타일을 유지할 수 있게한다.
앞선 예시의 ResourceService로 적용하자면 다음과 같다.
class ResourceService(IResourceService):
...
def __init__(self, reader, writer):
self.reader = reader
self.writer = writer
class ResourceServiceConatiner(containers.DeclarativeContainer):
wiring_config = containers.WiringConfiguration()
impl = providers.Singleton(
ResourceService,
reader = ResourceReader(),
writer= ResourceWriter()
)
만약 reader, writer 외에도 기타 다른 의존성이 필요한 경우 이 Container 단위로 수정을 가하자. 그리고 이를 FastAPI의 APIRouter에 적용하면 다음과 같은 코드를 뽑아낼 수 있었다.
@test_router.get(
path=""
)
@inject
async def test_endpoint(
service: IResourceService = Depends(Provide[ResourceService.impl]),
):
return JSONResponse(content={})
앞서 장황한 APIRouter가 만들어진 것에 비해 꽤나 깔끔한 스타일인듯 하다.