어쩌다 undecoration이 필요하게 되었을까
테스트코드를 작성하다보면 프로덕트 코드를 짜는 것보다 휠씬 더 많은 시간을 할애하곤 합니다. 단위테스트를 작성할 때는 One Assertion Per Test(단위테스트는 오직 한 가지의 이유만으로 성공 또는 실패 되어야 한다)를 만족하도록 해야 하기 때문입니다. 단위테스트가 여러가지 이유에 의해 실패하게 된다면 테스트의 의도가 모호해지고 실패의 원인을 파악하는데 많은 노력이 소모됩니다.
한 가지 예시를 들어보겠습니다. 요기요에서 3월 2일부터 3월 14일까지 화이트데이 이벤트를 한다고 가정하겠습니다. 사용자가 이벤트에 참여하기 위해서는 아래 2가지 조건을 만족해야 합니다.
화이트데이 이벤트 기간(3/2 ~ 3/14) 안에 참여를 해야한다.
경품 수령을 위해 참여 시 핸드폰 번호를 입력해야 한다.
apply_for_event()라는 이름의 함수가 이벤트 기간(3/2 ~ 3/14) 내에 들어오는 요청을 처리한다고 가정하겠습니다. apply_for_event()를 검사하는 어떤 단위테스트가 2번 조건에 대해 테스트한다면 그 단위테스트의 결과는 1번 조건에 의해 결정되어선 안됩니다. 단위테스트가 실패하는 원인이 핸드폰 번호의 유효성 뿐만 아니라 참여 요청의 시각에도 있다면 실패 원인이 2가지가 되므로 One Assertion Per Test를 만족하지 않기 때문입니다.
2번 조건만을 검증하는 단위테스트를 알아보기 전에 단위테스트의 대상이 되는 기능이 어떻게 구현되어 있는지를 먼저 살펴보는 게 좋겠습니다.
이벤트 참여를 요청한 시각이 3/2 ~ 3/14 사이에 있는지 검사하는 부분은 decorator로 구현했습니다. 이벤트 기간 외에 들어오는 요청일 경우에는 ‘이벤트 기간이 아닙니다.’ 라는 메세지와 함께 BadRequest 로 응답하고 이벤트 기간 내에 들어오는 경우에는 apply_for_event()를 리턴하도록 말이죠. (apply_for_event() 의 호출 결과를 리턴하는게 아니라 함수 자체를 리턴합니다.)
White-Day라는 프로젝트에서 event 라는 앱을 만들고 decorators.py 와 views.py 에서 각각 아래와 같이 작성했습니다.
event/decorators.py
event/views.py
event 앱 안에 tests.py 를 만들고 아래와 같이 단위테스트를 작성했습니다.
event/tests.py
3월 2일 이전에 배포되어야 하므로 저는 늦어도 2월에 위 코드들을 작성했을 것입니다. (2월에 apply_for_event() 를 호출하면 ‘이벤트 기간이 아닙니다.’ 라는 메세지를 받게 됩니다.)
apply_for_event()를 decorate하는 current_time_in_period()를 모킹했기 때문에 요청시각이 3월 2일과 3월 14일 사이에 있는지 여부를 검사하지 않을거라 예상했습니다. (앞서 설명한 1번 조건이 단위테스트 결과에 영향을 미치지 않도록 했습니다.) 그리고 잘못된 핸드폰번호로 참여 요청을 했으므로 ‘잘못된 핸드폰번호입니다.’ 메세지를 받을 것으로 예상했습니다.(2번 조건만을 테스트하도록 했습니다.) 하지만 결과는 아래와 같았습니다. 왜 그럴까요?
==================================================================== FAIL: test_apply_for_event (event.tests.TestWhiteDayEvent) -------------------------------------------------------------------- Traceback (most recent call last): File "/Users/DaEun/White-Day/event/tests.py", line 33, in test_apply_for_event self.assertEqual(response.content.decode('utf-8'), '잘못된 핸드폰번호입니다.') AssertionError: '이벤트 기간이 아닙니다.' != '잘못된 핸드폰번호입니다.' - 이벤트 기간이 아닙니다. + 잘못된 핸드폰번호입니다.
-------------------------------------------------------------------- Ran 1 test in 0.009s
FAILED (failures=1)
decorator 의 실행 시점
원인은 current_time_in_period()가 실행되는 시점에 있습니다. current_time_in_period() decorator가 실행되는 것은 런타임이 아니라 모듈을 로딩하는 시점, 즉 apply_for_event()를 임포트하는 시점에서 이뤄지기 때문입니다.
데커레이터의 실행시점 알아보기
current_time_in_period() 는 위 스크립트가 실행되기 전에 이미 apply_for_event() 를 decorate 합니다. 즉 check_decorating_time.py이 로딩되는 시점에 current_time_in_period() 가 실행 되었으므로 Decorator called.라는 메세지가 The main is running. 메세지 이전에 출력되는 것입니다.
위 단위테스트의 경우도 tests.py 에서 apply_for_event() 를 임포트하는 시점에서 apply_for_event() 에 붙은 current_time_in_period()가 실행됩니다. 따라서 런타임에서 current_time_in_period()를 모킹했다 하더라도 current_time_in_period() 를 실행한 결과인 BadRequest(‘이벤트 기간이 아닙니다.’)를 응답으로 받게 되는 것입니다.
난감한 상황에 처하게 된 저는 여러방법을 고민하다가 “뷰를 decorator에서 벗겨(undecorate)낼 수 없을까?” 라는 질문을 떠올리게 됩니다.
클로저 (closure)
위에서 작성된 test_apply_for_event() 는 3월 2일부터 3월 14일 23시 59분 59초까지는 잘 통과 하겠지만, 그 이후에는 2월에 그랬던 것처럼 언제나 AssertionError를 발생시킬 것입니다. 시간에 의존적인 current_time_in_period()에 의해 이미 decorate 되어버린 apply_for_event()를 undecorate 하려면 어떻게 해야 할까요?
답은 apply_for_event() 의__closure__ 속성에 있었습니다. __closure__ 속성을 이용하여 undecoration을 구현하기 전에 먼저 ‘클로저'란 무엇인지 알아보겠습니다.
데커레이터가 리턴한 함수를 호출하기
위 코드를 언뜻 훑어보고 예상을 해본다면 get_total() 함수의 numbers 리스트는 지역변수이므로 get_total() 이 종료되고나면 사라지는 게 맞습니다. 그리고 매번 get_total()를 호출할 때마다 numbers 리스트는 새로 만들어져서 아래와 같은 결과가 출력되는게 옳을것입니다.
numbers: [10] 10 numbers: [20] 20 numbers: [30] 30
하지만 자세히 보면 get_total() 은 함수를 리턴하고 있습니다. 리턴 되는 함수는 total 이라는 변수에 저장되어 10, 20, 30을 인자로 받아서 호출되고 있고 매 호출마다 들어온 숫자들이 numbers 리스트에 쌓이게 됩니다. 이 현상이 가능한 이유는 numbers 리스트가 자유변수(free variable)이기 때문입니다. 자유변수란 어떤 함수의 지역범위에 종속되어 있지 않은 변수를 뜻합니다.
get_total()의 내부에 정의되어 있는 wrapper()의 value는 wrapper()가 호출될 때마다 새로 만들어지고 종료될 때 마다 사라지는 지역변수입니다. 반면에 numbers는 wrapper()의 지역이 아니라 get_total()의 지역에 한번 정의된 이후로 wrapper()의 호출과 종료가 반복되더라도 사라지지 않고 계속 남아있습니다. wrapper()의 입장에서 numbers 는 wrapper() 의 지역범위에 종속되지 않은 자유변수인 것입니다.
‘closure'란 무엇인지 설명하기에 앞서 자유변수를 언급한 이유는 클로저가 자유변수와 연관이 있기 때문입니다. closure란 wrapper()와 같은 nested function이 접근하는 외부 환경을 뜻합니다. wrapper()에서 numbers 에 접근했던 것처럼 클로저는 nested function이 외부 함수에서 정의된 변수나 함수에 접근할 때 만들어지고 nested function의 자유변수를 포함하고 있습니다.
위의 예제에서 만들어진 자유변수는 nested function의__closure__ 속성을 통해 접근할 수 있습니다.
wrapper 함수(nested function)의 __closure__ 속성
nested function의 __closure__ 속성은 튜플형태로 이뤄져 있습니다. 튜플 안의 각 엘리먼트는 cell 객체로 구성되어 있습니다.
“cell 객체는 다수의 범위에서 참조되는 변수의 값을 저장하기 위해 사용됩니다. 각 스택프레임의 지역변수들은 외부범위의 변수들을 참조할 때 이 cell 객체를 참조하게 됩니다. cell 객체에 접근할 때는 cell 객체 자체가 아닌 cell 객체가 가지고 있는 값에 접근합니다.”
- 출처 : https://docs.python.org/3/c-api/cell.html#cell-objects
위의 설명에 의하면 numbers는 외부범위의 변수에 해당할 것 입니다. cell 객체가 가지고 있는 값은 cell 객체의 cell_contents 속성으로 접근할 수 있습니다. 따라서 위와 같이 total 의 __closure__ 속성에 있는 cell 객체의 cell_contents 를 통해 자유변수인 numbers 의 실제 값에 접근할 수 있는 것입니다.
decorated function 을 undecorate 하기
클로저를 설명하기 위해 꽤 긴 내용이 소개되었습니다. 그럼 이제부터 위에서 소개한 __closure__속성을 이용하여 decorate된 함수를 undecorate 해보겠습니다.
__closure__를 이용한 undecoration
위 스크립트를 실행한 결과는 아래와 같습니다.
cells in closure:
두 숫자의 합을 리턴하는 add_num() 함수가 current_time_in_period() 에 의해 decorate 되었습니다. (엄밀히 말하면 factory() 함수가 리턴하는 current_time_in_period()에 의해 decorate 되었습니다.)
decorate 된 함수의 환경은 decorator가 리턴하는 함수의 환경 의해 대체됩니다. add_num() 의 환경은 wrapper() 의 것으로 대체되면서 add_num() 의 __closure__ 속성은 wrapper() 의 __closure__ 속성과 동일한 값을 갖게 됩니다.
add_num() 함수의 클로저는 current_time_in_period() 의 지역범위와 클로저를 포함합니다. 따라서 add_num()의__closure__ 속성에는 current_time_in_period() 의 파라미터인 func 와 factory() 의 파라미터인 start , end 가 cell 객체로 포함되어 있습니다.
undecorate() 는 함수의 __closure__를 순회하면서 각 cell 객체의 cell_contents 가 함수일 때 cell_contents를 리턴합니다. 위의 경우 current_time_in_period()의 파라미터로 들어온 func 를 리턴할 것입니다.
current_time_in_period() 의 파라미터로 전달된 func 은add_num() 자기자신과 동일하므로 리턴된 cell_contents 는 곧 데커레이트 되지 않은 add_num() 일 것입니다.
event/tests.py
test_apply_for_event() 는 current_time_in_period() 에 의해 decorate 되지 않은 apply_for_event() 를 호출합니다. 따라서 요청 시각이 3/2 ~ 3/14 사이에 있는지 검증하는 과정 없이 잘못된 핸드폰 번호로 이벤트 참여를 요청했을 경우 상태코드 400번과 함께 ‘잘못된 핸드폰번호입니다.’ 라는 메세지를 받는지 여부를 테스트할 수 있게 되었습니다.
한계 & 마무리
앞서 소개한 이벤트 사례는 decorator와 decorate 된 함수의 로직에 서로 연관성이 없으므로 단위테스트에서 undecoration을 적용해도 무방합니다. 하지만 decorate 된 함수가 decorator에서 구현한 로직에 의존적이라면 undecoration은 쓰지 않는 것이 좋습니다. 예를 들어 django의 login_requireddecorator에서 세션의 값을 변경하고 login_required 로 decorate 된 함수가 변경된 세션 값으로 무언가를 해야한다면 함수를 undecorate 하기보다는 임의의 테스팅용 계정을 만들어서 로그인 처리를 하는 게 적절한 방법입니다.
뷰를 테스트 할 때 unittest 의 TestCase 클래스가 제공하는 HTTP client 를 쓰지 않고 HttpRequest 를 직접 만들어야 하는 번거로움이 있지만 decorator를 제거해야만 하는 상황에서는 유용하게 쓸 수 있을 듯 합니다.
김다은, Backend Developer @Delivery Hero Korea