ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Python] 제어 반전 (Inversion of Control; IoC)
    언어/파이썬 & 장고 2023. 7. 3. 13:29

    Inversion of Control (IoC)은 프로그램 흐름에서 제어를 역전시키는 프로그래밍 원칙입니다. 애플리케이션이 직접 함수와 메서드를 호출하는 대신, 제어는 의존성을 관리하고 애플리케이션에 주입하는 컨테이너로 전달됩니다.

    IoC는 라이브러리가 통제를 애플리케이션으로 반환함으로써 애플리케이션이 라이브러리의 동작에 영향을 미칠 때 발생합니다.

    정렬을 예시로 살펴봅니다.

    sorted([1, 2, 3, 4, 5, 6])

    내장된 sorted() 함수는 아이템 이터러블을 받아 정렬된 리스트를 반환합니다. 통제는 호출자(즉, 애플리케이션)에서 sorted() 함수로 전달되고 함수에서 정렬한 다음, 결과를 반환하여 호출자에게 통제를 되돌려 줍니다.

    다음 예시를 살펴봅니다.

    def distance_from(item):
        return abs(item - 3)
    
    sorted([1, 2, 3, 4, 5, 6], key=distance_from)

    sorted() 함수는 이터러블 인수의 모든 요소에 대해 key 함수를 호출합니다. 아이템의 값들을 비교하는 것이 아니라 key 함수의 반환값과 비교합니다. 바로 이 지점에서 IoC가 발생합니다. sorted() 함수는 애플리케이션에서 인수로 제공한 distance_from() 함수를 upcall 합니다. 라이브러리가 애플리케이션의 함수를 호출하므로 통제의 흐름이 반전됩니다.

    IoC는 디자인의 한 속성일 뿐 디자인 패턴은 아닙니다. 위의 예시는 단순한 예시이며 다음과 같이 다양한 형태를 가지고 있습니다.

    • 다형성: 커스텀 클래스가 베이스 클래스를 상속하고 베이스 메소드가 커스텀 메소드를 호출
    • 인수 전달: 받는 함수가 공급한 함수의 메소드를 호출할 때
    • 데코레이터: 데코레이터 함수가 데코레이트된 함수를 호출할 때
    • 클로저: 중첩된 함수가 해당 스코프 밖에 있는 함수를 호출할 때

    애플리케이션의 IoC 예시

    여기서 Flask를 사용하여 애플리케이션은 전통적인 통제의 흐름에서 시작해 특정한 위치에서 IoC를 이용해 이점을 얻을 수 있다는 것을 확인할 것입니다.

    from collections import Counter
    from http import HTTPStatus
    
    from flask import Flask, request, Response
    
    app = Flask(__name__)
    storage = Counter()
    
    PIXEL = ''
    
    @app.route('/track')
    def track():
        referer = request.header['Referer']
    
        storage['referer'] += 1
    
        return Response(
            PIXEL, headers = {}
        )

    Flask 핸들러는 전형적으로 @app.route(route) 데코레이터로 시작하며 주어진 HTTP 경로에 이어지는 핸들러 함수를 등록합니다. 요청 핸들러는 뷰라고 불리기도 하며 여기서 track() 뷰를 /track 경로의 엔드포인트에 대한 핸들러로 등록합니다. 여기서 IoC가 등장하며 Flask 프레임워크 안에서 우리가 구현한 핸들러를 등록한 것입니다.

    좋은 디자인 패턴은 IoC를 책임지는 객체의 인터페이스에 대한 정의의 종류를 어느 정도 지시합니다. Count 클래스의 인터페이스는 좋은 시작점으로 보이므로 추상 베이스 클래스로 정의하도록 합니다.

    from abc import ABC, abstractmethod
    
    class ViewStorageBackend(ABC):
        @abstractmethod
        def increment(self, key):
            pass
    
        @abstractmethod
        def most_common(self, n):
            pass
    
    -----------------------------------
    
    from collections import Counter
    from redis import Redis
    
    class CounterBackend(ViewStorageBackend):
    
        def __init__(self):
            self._counter = Counter()
    
        def increment(self, key):
            self._counter[key] += 1
    
    
        def most_common(self, n):
            return dict(self._counter.most_common(n))
    
    --------------------------------------
    
    from redis import Redis
    
    class RedisBackend(ViewStorageBackend):
    
        def __init__(self, redis_client, set_name):
            self._client = redis_client
            self._set_name = set_name
    
        def increment(self, key):
            self._client.zincrby(self._set_name, 1, key)
    
        def most_common(self, n):
            return {
                ~~
            }

    위 두 백엔드 클래스는 모두 같은 추상 클래스를 가지고 있으며 느슨하게 추상적 베이스 클래스 사용을 강요합니다. 즉, 두 클래스의 인스턴스들은 바꿔 사용할 수 있습니다. 이제 문제는 어떻게 track() 함수의 통제를 반전시켜서 서로 다른 뷰 스토리지 구현을 넣을 수 있을까 하는 점입니다.

    Flask 프레임워크에서 app.route() 데코레이터는 함수를 특정 경로에 대한 핸들러로 등록합니다. HTTP 요청 경로에 대한 콜백으로 볼 수 있지만 해당 함수는 직접 호출할 수 없고 Flask가 이 함수에 전달되는 인수를 완전히 통제합니다. 하지만 위 스토리지 클래스 구현을 쉽게 대체할 수 있는데 한 가지 방법으로 핸들러 등록을 지연하고 해당 함수가 스토리지 클래스를 인수로 받도록 하는 것입니다.

    def track(storage: ViewStorageBackend):
        referer = request.header['Referer']
    
        storage.increment(referer)
    
        return Response(
            PIXEL, headers = {}
        )

    위에서 추가한 인수는 ViewStorageBackend 타입으로 되어 있으므로 IDE나 다른 도구를 통해 쉽게 검증 할 수 있습니다. 덕분에 이 함수들에 대한 반전된 통제를 얻음과 동시에 더 나은 모듈성을 얻을 수 있습니다. 또한 스토리지 구현을 호환된 인터페이스를 가진 다른 클래스로 쉽게 교체할 수 있습니다. IoC의 추가적인 장점은 스토리지 구현으로부터 track() 메소드를 격리해 쉽게 단위 테스트를 할 수 있다는 것입니다.

    여기서는 실제 경로 등록 부분이 빠져 있는데 더 이상 app.route() 데코레이터를 함수에 사용할 수 없습니다. Flask가 스토리지 클래스 인수를 직접 결정하지 않기 때문입니다. 이 문제는 필요한 스토리지 구현을 핸들러 함수에 미리 주입해서 극복할 수 있으며 새로운 함수를 만들어 app.route() 호출과 함께 등록할 수 있습니다.

    from functools import partial
    
    if __name__ == '__main__':
        views_storage = RedisBackend(Redis(host='redis'), 'my-stats')
        app.route('track', endpoint='track')(partial(track, storage=views_storage))
    
        app.run(host='0.0.0.0', port=8000)

    이러한 방식은 엔드포인트가 많지 않은 작은 규모의 서비스에서는 잘 작동하지만 큰 규모의 애플리케이션에서는 확장이 어렵습니다. 또한 구현은 쉽지만 읽기가 어렵습니다.

    궁극적으로는 직접 의존 객체를 주입할 필요 없이 뷰 함수를 작성하고 등록할 수 있도록 하는 것입니다.

    @app.route('/track')
    def track(storage: ViewStorageBackend):
        referer = request.header['Referer']
    
        storage.increment(referer)
    
        return Response(
            PIXEL, headers = {}
        )

    이와 같이 하기 위해서는 Flask 프레임워크가 다음을 지원해야 합니다.

    • 추가적인 인수를 뷰의 디펜던시로 인식
    • 이 디펜던시들을 위한 기본 구현을 정의할 수 있어야함
    • 런타임에 자동으로 디펜던시를 해결하고 이들을 뷰에 주입

    이런 메커니즘을 디펜던시 주입(Dependency Injection)이라 부릅니다.

    댓글