Overview

브랜디 개발팀에서는 현재 운영 중인 여러 파이썬 프로젝트에 도입하게 될 사내 프레임워크의 개발을 진행하고 있습니다. 그리고 그 개선 업무를 저도 함께 담당하게 되었습니다. 프레임워크 개발이라니, 개발자로서 이보다 더 흥미로운 일이 있을까요! 여기서는 그 과정 중 일부인 ‘AOP를 제 입맛대로 도입하게 된’ 이야기를 공유하고자 합니다.

프레임워크는 AWS Lambda의 사용을 전제로 개발되었습니다. 그 외 자세한 설명은 이번 글에서는 다루지 않겠습니다.

계기

AOP 도입을 검토하게 된 계기는 단순합니다. ‘인증’의 구현이 필요했습니다. 인증은 거의 대다수 프로젝트에 필요한 기능이죠. 당연히 프레임워크 단에서 어느 정도 편의를 제공하면 좋겠다 생각했습니다.

Helper

기존의 구조로는 인증 기능을 helper로 구현할 수 밖에 없었습니다. 하지만 helper는 특정 로직을 재사용 가능하도록 독립적인 함수로 제공하는 역할이죠. String이나 Datetime, bcrypt처럼 인증보다는 조금 더 일반적인 기능을 제공하는게 알맞습니다.

Library

Library로 구현하면 될까요? helper보다는 적절해보이지만 여전히 마음에 들지 않네요. 현재의 프레임워크는 아주 가볍게 최소한의 기능만 제공하는지라 라이브러리의 개념이 없습니다. 이후에 개발될 가능성이 매우 높지만 당장은 인증 하나 때문에 라이브러리를 구현하고 싶지 않았습니다.

Auth

차라리 인증이라는 별도의 기능을 제공하는건 어떨까요. 대부분의 프로젝트에서 사용하는 기능이니 문제가 없어 보였죠. 실제로 Django, Laravel 등 인증 기능을 별도 미들웨어로 제공하는 프레임워크는 많습니다. 하지만 프레임워크 기능의 추가는 결국 개발자들이 기억해야 할 ‘약속’이 하나 더 늘어남을 의미하죠. 인증이라는 단순 기능 하나로 인해 약속을 늘리다니, 지금 단계에선 조심스러운 부분이었습니다.

계속된 고민으로, 인증은 helper나 library처럼 단순히 기능을 제공하는 모듈의 개념이 아닌 서비스 가장 앞단에서 사용자를 검증하는 하나의 필터의 역할로 보는 게 맞다는 데에까지 생각이 이어졌습니다. 어떠한 동작(인증)이, 적절한 시점(URI mapping 이후)에 특정한 기능(구매… 등)에게 주입되어야 합니다. 이러한 요구 사양들을 가장 우아하게 구현할 수 있는 개념이 뭘까요? 바로 AOP입니다.

AOP란?

AOP, Aspect-Oriented Programming이란 관점 지향적 프로그래밍을 의미합니다. 특정한 개념을 누구의 관점에서 관리할 것인가 하는 것이 핵심이죠. 예를 들어 보겠습니다. 서비스를 한참 운영하던 중 문제가 발생했습니다. ‘댓글 작성’ 기능이 ‘로그인한 사용자만 나한테 접근하게 해줘! 제발!!!’ 이라고 외치기 시작한거죠.

def comment():
    if not auth():
        Exception!
    ...

서둘러 기능을 구현해 붙였습니다 (이후 다른 곳에서 필요할지도 모르니 인증 모듈로 별도 구현 했습니다. 뿌듯)
그런데 갑자기 상품 구매 기능도 인증이 필요하다네요? 마이페이지도, 환불 페이지도!
요구 사양이 몰려옵니다.

def buy():
    if not auth():
        Exception!
    ...

def mypage():
    if not auth():
        Exception!
    ...

def refund():
    if not auth():
        Exception!
    ...

…… 무언가 잘못 흘러가고 있음을 느끼지만 일단 다 붙여줬습니다.
“Winter is coming” - 왕좌의 게임

시간이 흘러 새로운 기능이 등장했습니다. 바로 그 이름도 어마무시한 ‘비회원 구매’입니다 (두둥)
구매 기능도, 환불 기능도 로그인이 필요 없네요? 서둘러 걷어내야 합니다!

def buy():
    # if not auth():
    # 		Exception!
    ...

def refund():
    # if not auth():
    # 		Exception!
    ...


문제점을 찾으셨나요? 우리는 인증이라는 개념을 각각의 기능들이 자신의 관점에서 관리하고 있습니다. 인증과 관련된 변경이 필요할 경우, 영향을 받는 모든 위치에 수정이 이루어져야 하죠. 너무 비효율적입니다. 기준을 바꿔봅시다. 인증 기능은 인증의 관점에서 관리한다면 어떨까요?

# 기존 (회원만 구매 가능)
def auth():
    ...
    return True or False

# 수정 (비회원도 구매 가능)
def auth():
    if buy or refund:
        return True
    ...
    return True or False

어떤가요? 한결 관리가 편해지지 않았나요? ‘인증이 필요한 기능인가’를 인증 메소드가 직접 ‘자신의 관점에서’ 관리하고 있습니다. 인증에 관련된 수정이 이루어질 경우 인증 모듈만 수정이 이루어지죠. 이게 바로 관점 지향 프로그래밍의 강점입니다.

도입

위의 내용은 AOP의 핵심 개념일 뿐, 사실 관점 지향 프로그래밍에 관련된 이야기는 이보다 훨씬 방대합니다. 하지만 언제나 정확한 요구 사양의 파악이 중요하죠. 우리에게 지금 필요한건 ‘최대한 AOP의 개념에 부합하는 구현’이 아닌 ‘프레임워크의 효율적인 개선’입니다. 부분적으로만 개념을 차용해 우리 입맛에 맞게 도입하면 됩니다.

요구사양 정의

일단 다시 한 번 요구사양을 정리 해봅시다. 추후의 확장성 고려와 오버 엔지니어링 사이의 적절한 줄다리기가 중요합니다.

  • 동작 시점의 정의가 가능해야 합니다
    일단 고정된 세 가지 시점을 제공해 사용하도록 했습니다
    1. 라우터 단의 URI 맵핑 직후의 필터링 로직
    2. 컨트롤러 동작 전 구동되어야 하는 로직
    3. 컨트롤러 동작 후 응답 직전에 구동되어야 하는 로직
  • 어떤 기능들에 동작시킬 건지도 명시할 수 있어야 합니다
    단, 지금 단계에서는 URI 단위의 서비스 엔드포인트 기준으로만 명시 가능하면 될 것 같습니다
    더 세부적으로(특정 메소드 동작 전…) 명시하는 기능은 구현하지 않겠습니다
  • 이 모든 사항은 해당 기능의 관점에서 관리되어야 합니다

구현

드디어 구현입니다. Filter와 Interceptor의 개념을 사용했습니다. 개선된 프레임워크 생명주기는 이러합니다.

  • 기존 : 요청 → 라우터 URI 맵핑 → 컨트롤러 동작 → 응답
  • 개선 : 요청 → 라우터 URI 맵핑 → Filter::do_filter() → Interceptor::pre_handle() → 컨트롤러 동작
    → Interceptor::post_handle() → 응답

각각의 필터들이 상속받을 추상 클래스입니다

# framework/filter.py
from abc import ABCMeta, abstractmethod

class Filter(metaclass=ABCMeta):
    def __init__(self, event):
        self._event = event
        self._except_paths = []

    @property
    @abstractmethod
    def except_paths(self):
        return self._except_paths

    @abstractmethod
    def do_filter(self):
        pass

필터의 기본적인 동작은 ‘모든 요청에 구동되는 방화벽’의 개념이라 생각해 동작이 필요한 URI를 명시하는게 아닌 동작이 필요없는 URI를 명시하도록 구현했습니다. 추가로, AWS Lambda handler의 event 객체는 멤버 변수로 넣어주면 쓸모가 많은 것 같아 초기화 메소드에 구현했습니다.

사실 당장의 필요로 인해 구현하긴 했지만 event 객체는 조금 더 다듬을 필요가 있습니다. event 객체를 filter의 멤버로 정의 하는 것이 올바른가의 검토부터 ‘filter에서 event 객체 접근이 가능하다’를 개발자가 쉽게 알아차리도록 할 우아한 방법에 대한 고민도 필요합니다.

다음은 인터셉터입니다

# framework/interceptor.py
from abc import ABCMeta, abstractmethod

class Interceptor(metaclass=ABCMeta):
    def __init__(self, event):
        self._event = event
        self._pre_handle_paths = []
        self._post_handle_paths = []

    @property
    @abstractmethod
    def pre_handle_paths(self):
        return self._pre_handle_paths

    @property
    @abstractmethod
    def post_handle_paths(self):
        return self._post_handle_paths

    @abstractmethod
    def pre_handle(self):
        pass

    @abstractmethod
    def post_handle(self):
        pass

기본적인 구조는 필터와 동일하지만 몇 가지 차이가 있습니다. 우선 필터와 다르게, 동작시키지 않을 URI를 명시하지 않고 동작시킬 URI를 명시하도록 했습니다. 또 다른 점이라면, pre_handle_paths와 post_handle_paths를 빈 배열로 남겨두면 모든 URI에서 동작하도록 했습니다. 모든 URI에서 동작시키고 싶지 않다면, 컨트롤러에 구현된 약속과 동일하게 파일명 앞에 언더바 두 개(‘__‘)를 붙이도록 했습니다 (‘__auth.filter.py’, ‘__log_interceptor.py’ …)

‘모든 URI에서 동작하려면 빈 배열, 모든 URI에서 동작시키지 않으려면 언더바 두 개’의 동작은 조금 더 고민이 필요합니다. 실제 서비스의 시범 도입으로 더 나은 방법을 검토해 개선시키려 합니다.

실제로 사용할 필터와 인터셉터를 구현합시다

# app/filters/auth_filter.py
from brandi.framework.filter import Filter

class AuthFilter(Filter):
    except_paths = ['/buy/{id:[0-9]+}']

    def do_filter(self):
        print('### AuthFilter::do_filter() ###')

# app/interceptors/log_interceptor.py
from brandi.framework.interceptor import Interceptor

class LogInterceptor(Interceptor):
    pre_handle_paths = ['/products/{id:[0-9]+}']
    post_handle_paths = ['/events/list', '/mypage/point']

    def pre_handle(self):
        print('### LogInterceptor::pre_handle() ###')

    def post_handle(self):
        print('### LogInterceptor::post_handle() ###')

URI는 정규표현식으로 명시할 수 있도록 구현했습니다.

마지막으로 한 가지가 더 남았습니다. 필터들 사이와 인터셉터들 사이의 동작 순서를 정의할 수 있어야 합니다.
yaml파일을 사용했습니다.

# app/filters/filter_order.yml
# -> AuthFilter::do_filter() -> MaintenanceFilter::do_filter() ->
filter:
  - auth_filter
  - maintenance_filter

# app/interceptors/interceptor_order.yml
# -> BatchInterceptor::pre_handle() -> LogInterceptor::pre_handle()
# -> ProductsController
# -> BatchInterceptor::post_handle() -> LogInterceptor::post_handle() ->
interceptor:
  - batch_interceptor
  - log_interceptor

여러 예제 소스를 살펴본 결과 필터, 인터셉터의 정의 순서대로 동작시키는 경우도 있었고 Java Annotation을 사용해 @Order(2)와 같이 구현하기도 했습니다. 넘버링으로 명시하는 방식은 관리가 쉽지 않다고 판단해 결국 별도의 파일에 명확하게 직접 명시해주기로 했습니다. 추가적인 설정 파일이 늘어나는 것은 기피하고 싶었지만 빠른 도입을 위해 이 또한 이후의 개선 필요 사항으로 남겨두었습니다.

Run!

### Request : mypage/point ###
### AuthFilter::do_filter() ###
### MaintenanceFilter::do_filter() ###
### BatchInterceptor::pre_handle() ###
### PointController ###
### BatchInterceptor::post_handle() ###
### LogInterceptor::post_handle() ###
### Response : 200 ###

단순히 로그만 찍어보았습니다. 잘 동작하는 군요!

개선

몇 가지 개선해야 할 사항들이 보입니다

  • 위에서 말씀드린 event 객체의 정의, 모든 URI에서 동작하거나 동작안함의 명시, 동작 순서의 명시법에 대해 조금 더 매끄러운 방식의 검토가 필요합니다.
  • do_filter, pre_handle, post_handle의 반환 데이터 타입에 대한 강제성은 지금 단계에선 부여하지 않았습니다. 지금처럼 자유롭게 사용 가능하도록 두는 것이 나을지는 좀 더 지켜봐야 할 부분입니다.
  • Spring 프레임워크의 경우 이보다 훨씬 더 강력한 기능을 다수 제공하고 있습니다. 그 중 아주 부분적으로 도입한 것은 의도한 일이지만, 추가적인 도입은 지속적으로 고민되어야 합니다.

Conclusion

실제로 충분히 검증된, 유용한 개념과 개발 방법론은 정말 많습니다. 덕분에 이게 맞다 저게 맞다 제각기 의견도 분분하죠. 자칫 잘못해 중심을 지키지 않으면 코드가 산으로 가버릴 수 있습니다. 제가 생각하는 불변의 원칙은 ‘정확한 요구사양’ 입니다. 욕심내지 않고, 정말 우리 프로젝트에 필요한 기능인가를 항상 염두에 두고 결정 내린다면 그게 바로 좋은 개발자가 아닐까요. 개발자는 결국 사람들이 ‘필요로 하는’ 그 무언가를 만들어내는 사람이니까요.


양정훈 | 풀필먼트 개발팀
yangjh@brandi.co.kr
브랜디, 오직 예쁜 옷만