파이썬 @memoize 고립된 환경에서 사용하기

😢 이 페이지는 다음 주소로 변경될 예정입니다.

파이썬에서 데코레이터를 정말 자주 사용하고 있지만 다양한 용례는 접해보지 못했었다. Ned Batchelder의 글 Isolated @memoize은 @memoize 데코레이터에 대한 이야기인데 같이 곁들여진 설명과 각 링크가 유익해서 번역했다. 파이썬 데코레이터 모음 위키 페이지도 살펴보면 좋겠다.


파이썬 @memoize 고립된 환경에서 사용하기

실행 비용이 비싼 함수를 호출한다고 생각해보자. 동일한 입력을 했을 때 동일한 결과를 반환하는 함수인 경우에는 사람들 대부분은 @memoize 데코레이터를 사용하는 것을 선호한다. 이 데코레이터는 이전에 실행한 적이 있는 경우에는 동일한 결과를 빠르게 내놓을 수 있도록 캐시해둔다. 다음은 @memoize 구현 모음을 차용해서 만든 간단한 코드다.

def memoize(func):
    cache = {}

    def memoizer(*args, **kwargs):
        key = str(args) + str(kwargs)
        if key not in cache:
            cache[key] = func(*args, **kwargs)
        return cache[key]

    return memoizer

@memoize
def expensive_fn(a, b):
    return a + b        # 물론 이 함수는 그렇게 비싼 연산이 아니다!

이 코드는 원하는 동작을 제대로 수행하는 좋은 코드다. expresive_fn 함수를 동일한 인자로 반복해서 호출하면 실제로 함수를 호출하지 않고 캐시된 값을 사용할 수 있다.

하지만 여기에는 잠재적인 위험이 있다. 캐시 딕셔너리가 전역이라는 점이다. 물론 이 말을 문자 그대로 전역이라고 생각하는 잘못을 범하지 말자. global 키워드를 사용하지 않았고 모듈 단위의 변수인 것도 아니다. 하지만 이 딕셔너리는 프로세스 전체에서 expensive_fn를 대상으로 오직 하나의 캐시 딕셔너리를 갖고 있기 때문에 이 관점에서는 전역 변수라고 말할 수 있을 것이다.

전역은 잘 짜여진 테스트를 방해할 수 있다. 자동화된 테스트에서 가장 이상적인 동작 방식은 각 테스트가 서로 영향을 미치지 않도록 고립된 형태로 테스트를 수행하는 것이다. test1에서 어떤 일이 일어나든지 test99에는 영향이 없어야 한다. 하지만 여기서 test1부터 test99까지 expensive_fn을 (1, 2) 인자를 사용해서 호출했다면 test1은 함수를 호출하지만 test99는 캐시에 저장된 값을 사용한다. 더 나쁜 부분은 전체 테스트를 호출하면 test99는 캐시에 저장된 값을 사용하게 될 것인 반면 test99만 실행하면 함수를 실제로 실행하게 된다는 점이다.

만약 expensive_fn이 정말로 부작용 없는 순수 함수라면 이런 특징이 문제가 되지 않을 것이다. 하지만 때로는 문제가 되는 경우도 있다.

내가 관리하게 된 프로젝트 중에 고정된 데이터를 가져오기 위해 @memoize를 사용하는 웹사이트가 있었다. 자료를 가져올 때 단 한 번만 가져왔기 때문에 @memoize는 적절했고 프로그램을 사용하는데 전혀 문제가 되질 않았다. 테스트는 Betamax를 사용해서 네트워크 접근을 모조로 만들었다.

Betamax는 대단한 라이브러리다. 각 테스트 케이스를 구동할 때 각 테스트에서의 네트워크 접근을 확인한 후, “카세트”에 요청과 반환을 JSON 양식으로 저장한다. 다시 테스트를 수행하면 카세트에 저장되어 있는 정보를 사용해서 네트워크 접근을 모조해서 처리해준다.

문제는 test1의 카세트에서 캐시로 저장될 자원을 네트워크로 요청하게 되고 test99는 @memoize로 인해 네트워크를 통해 데이터를 요청할 필요가 없어졌기 때문에 test99의 카세트가 제대로 생성이 되지 않는다. 이제 테스트를 test99만 구동했을 때 카세트에 저장된 정보가 없기 때문에 테스트가 실패하게 된다. test1과 test99는 각각 고립되서 실행된다고 볼 수 없다. 저장된 캐시를 통해서 전역적으로 값을 공유하기 때문이다.

내 해결책은 @memoize를 사용했을 때 테스트 사이에 캐시 내용을 지우는 방식이다. 이 코드를 직접 작성하기 보다는 functools에 포함되어 있는 lru_cache 데코레이터를 사용할 수 있다. (여전히 2.7 버전의 파이썬을 사용하고 있다면 functools32을 찾아보자.) 이 데코레이터는 전역 캐시의 모든 값을 지울 수 있는 .cache_clear 함수를 제공한다. 이 함수는 각 데코레이터를 사용한 함수에 있기 때문에 사용한 함수를 목록으로 갖고 있어야 한다.

import functools

# memoize를 사용한 함수 목록을 저장. 그런 후
# `clear_memoized_values`로 일괄 비우기를 수행.
_memoized_functions = []

def memoize(func):
    """함수를 호출해서 반환한 값을 캐시로 저장함"""
    func = functools.lru_cache()(func)
    _memoized_functions.append(func)
    return func

def clear_memoized_values():
    """@memoize에 저장된 모든 값을 비워서 각 테스트가 고립된 환경으로 동작할 수 있도록 함"""
    for func in _memoized_functions:
        func.cache_clear()

이제 각 테스트 전에 캐시를 비우기 위해 py.test의 픽스쳐에서, 또는 테스트 케이스의 setUp() 메서드에서 clear_memoized_values() 함수를 사용할 수 있다.

# py.test를 사용하는 경우

@pytest.fixture(autouse=True)
def reset_all_memoized_functions():
    """@memoize에 캐시로 저장된 값을 매 테스트 전에 비움"""
    clear_memoized_values()

# unittest를 사용하는 경우

class MyTestCaseBase(unittest.TestCase):
    def setUp(self):
        super().setUp()
        clear_memoized_values()

사실 @memoize를 사용하는 다양한 이유를 보여주는 것이 더 나을지도 모른다. 순수 함수는 모든 테스트에서 캐시를 사용해서 같은 값을 반환해도 문제가 없을 것이다. 연산이 필요한 경우라면 누가 이런 문제를 신경 쓸까? 하지만 다른 경우에서는 확실히 고립된 환경을 만들어서 사용해야 한다. @memoize는 마술이 아니다. 이 코드가 어떤 일을 하는지, 어떤 상황에서 더 제어가 필요한지 잘 알아야 한다.


오현석(enshahar)님 피드백을 받아 번역을 개선했습니다. 감사 말씀 드립니다.

파이썬 리스트 vs. 튜플

😢 이 페이지는 다음 주소로 변경될 예정입니다.

파이썬을 처음 공부할 때 리스트와 튜플에 대해 비슷한 의문을 가진 적이 있었다. 이 둘을 비교하고 설명하는 Ned Batchelder의 Lists vs. Tuples 글을 번역했다. 특별한 내용은 아니지만 기술적인 차이와 문화적 차이로 구분해서 접근하는 방식이 독특하게 느껴진다.


Python에 입문하는 사람들이 흔하게 하는 질문이 있다. 리스트(list)와 튜플(tuple)의 차이는 무엇인가?

이 질문의 답변은 이렇다. 이 두 타입은 각각 상호작용에 있어 두 가지 다른 차이점이 존재한다. 바로 기술적인 차이와 문화적인 차이다.

먼저 두 타입의 공통점을 확인하자. 리스트와 튜플은 둘 다 컨테이너로 일련의 객체를 저장하는데 사용한다.

>>> my_list = [1, 2, 3]
>>> type(my_list)
<class 'list'>
>>> my_tuple = (1, 2, 3)
>>> type(my_tuple)
<class 'tuple'>

둘 다 타입과 상관 없이 일련의 요소(element)를 갖을 수 있다. 두 타입 모두 요소의 순서를 관리한다. (세트(set)나 딕셔너리(dict)와 다르게 말이다.)

이제 차이점을 보자. 리스트와 튜플의 기술적 차이점은 불변성에 있다. 리스트는 가변적(mutable, 변경 가능)이며 튜플은 불변적(immutable, 변경 불가)이다. 이 특징이 파이썬 언어에서 둘을 구분하는 유일한 차이점이다.

>>> my_list[1] = "two"
>>> my_list
[1, 'two', 3]
>>> my_tuple[1] = "two"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

이 특징은 리스트와 튜플을 구분하는 유일한 기술적 차이점이지만 이 특징이 나타나는 부분은 여럿 존재한다. 예를 들면 리스트에는 .append() 메소드를 사용해서 새로운 요소를 추가할 수 있지만 튜플은 불가능하다.

>>> my_list.append("four")
>>> my_list
[1, 'two', 3, 'four']
>>> my_tuple.append("four")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'tuple' object has no attribute 'append'

튜플은 .append() 메소드가 필요하지 않다. 튜플은 수정할 수 없기 때문이다.

문화적인 차이점을 살펴보자. 리스트와 튜플을 어떻게 사용하는지에 따른 차이점이 있다. 리스트는 단일 종류의 요소를 갖고 있고 그 일련의 요소가 몇 개나 들어 있는지 명확하지 않은 경우에 주로 사용한다. 튜플은 들어 있는 요소의 수를 사전에 정확히 알고 있을 경우에 사용한다. 동일한 요소가 들어있는 리스트와 달리 튜플에서는 각 요소의 위치가 큰 의미를 갖고 있기 때문이다.

디렉토리 내에 있는 파일 중 *.py로 끝나는 파일을 찾는 함수를 작성한다고 가정해보자. 이 함수를 사용했을 때는 파일을 몇 개나 찾게 될 지 알 수 없다. 그리고 동일한 규칙으로 찾은 파일이기 때문에 항목 하나 하나가 의미상 동일하다. 그러므로 이 함수는 리스트를 반환할 것이다.

>>> find_files("*.py")
["control.py", "config.py", "cmdline.py", "backward.py"]

다른 예를 확인한다. 기상 관측소의 5가지 정보, 식별번호, 도시, 주, 경도와 위도를 저장한다고 생각해보자. 이런 상황에서는 리스트보다 튜플을 사용하는 것이 적합하다.

>>> denver = (44, "Denver", "CO", 40, 105)
>>> denver[1]
'Denver'

(지금은 클래스를 사용하는 것에 대해서 이야기하지 않을 것이다.) 이 튜플에서 첫 요소는 식별번호, 두 번째는 도시… 순으로 작성했다. 튜플에서의 위치가 담긴 내용이 어떤 정보인지를 나타낸다.

C 언어에서 이 문화적 차이를 대입해보면 목록은 배열(array) 같고 튜플은 구조체(struct)와 비슷할 것이다.

파이썬은 네임드튜플(namedtuple)을 제공하는데 이 네임드튜플을 사용하면 튜플에서 각 위치의 의미를 명시적으로 작성할 수 있다.

>>> from collections import namedtuple
>>> Station = namedtuple("Station", "id, city, state, lat, long")
>>> denver = Station(44, "Denver", "CO", 40, 105)
>>> denver
Station(id=44, city='Denver', state='CO', lat=40, long=105)
>>> denver.city
'Denver'
>>> denver[1]
'Denver'

튜플과 리스트의 문화적 차이를 영악하게 정리한다면 튜플은 네임드튜플에서 이름이 없는 것이라고 할 수 있다.

기술적 차이점과 문화적 차이점을 연계해서 생각하기란 쉽지 않은데 종종 이 차이점이 이상할 때가 있기 때문이다. 왜 단일 종류의 일련 데이터는 가변적이고 여러 종류의 일련 데이터는 불변이어야 하는 것일까? 예를 들면 앞에서 저장했던 기상관측소의 데이터는 수정할 수 없다. 네임드 튜플은 튜플이고 튜플은 불변이기 때문이다.

>>> denver.lat = 39.7392
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

때때로 기술적인 고려가 문화적 고려를 덮어쓰는 경우가 있다. 리스트를 딕셔너리에서 키로 사용할 수 없다. 불변 값만 해시를 만들 수 있기 때문에 키에 불변 값만 사용 가능하다. 대신 리스트를 키로 사용하고 싶다면 다음 예처럼 리스트를 튜플로 변경했을 때 사용할 수 있다.

>>> d = {}
>>> nums = [1, 2, 3]
>>> d[nums] = "hello"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>> d[tuple(nums)] = "hello"
>>> d
{(1, 2, 3): 'hello'}

기술과 문화가 충돌하는 또 다른 예가 있다. 파이썬에서도 리스트가 더 적합한 상황에서 튜플을 사용하는 경우가 있다. *args를 함수에서 정의했을 때, args로 전달되는 인자는 튜플을 사용한다. 함수를 호출할 때 사용한 인자의 순서가 크게 중요하지 않더라도 말이다. 튜플은 불변이고 전달된 값은 변경할 수 없기 때문에 이렇게 구현되었다고 말할 수 있겠지만 그건 문화적 차이보다 기술적 차이에 더 가치를 두고 설명하는 방식이라 볼 수 있다.

물론 *args에서 위치는 매우 큰 의미를 갖는다. 매개변수는 그 위치에 따라 의미가 크게 달라지기 때문이다. 하지만 함수는 *args를 전달 받고 다른 함수에 전달해준다고만 봤을 때 *args는 단순히 인자 목록이고 각 인자는 별 다른 의미적 차이가 없다고 할 수 있다. 그리고 각 함수에서 함수로 이동할 때마다 그 목록의 길이는 가변적인 것으로 볼 수 있다.

파이썬이 여기서 튜플을 사용하는 이유는 리스트에 비해서 조금 더 공간 효율적이기 때문이다. 리스트는 요소를 추가하는 동작을 빠르게 수행할 수 있도록 더 많은 공간을 저장해둔다. 이 특징은 파이썬의 실용주의적 측면을 나타낸다. 이런 상황처럼 *args를 두고 리스트인지 튜플인지 언급하기 어려운 애매할 때는 그냥 상황을 쉽게 설명할 수 있도록 자료 구조(data structure)라는 표현을 쓰면 될 것이다.

대부분의 경우에 리스트를 사용할지, 튜플을 사용할지는 문화적 차이에 기반해서 선택하게 될 것이다. 어떤 의미의 데이터인지 생각해보자. 만약 프로그램이 실제로 다루는 자료가 다른 길이의 데이터를 갖는다면 분명 리스트를 써야 할 것이다. 작성한 코드에서 세 번째 요소에 의미가 있는 경우라면 분명 튜플을 사용해야 할 상황이다.

반면 함수형 프로그래밍에서는 코드를 어렵게 만들 수 있는 부작용을 피하기 위해서 불변 데이터 구조를 사용하라고 강조한다. 만약 함수형 프로그래밍의 팬이라면 튜플이 제공하는 불변성 때문에라도 분명 튜플을 좋아하게 될 것이다.

자, 다시 질문해보자. 튜플을 써야 할까, 리스트를 사용해야 할까? 이 질문의 답변은 항상 간단하지 않다.

스마트 소켓 Orvibo S20을 라즈베리 파이로 텔레그램과 연동하기

😢 이 페이지는 다음 주소로 변경될 예정입니다.

반 년 전에 Orvibo S20이라는 스마트 소켓을 구입했다. 스마트 소켓은 스위치를 제어할 수 있도록 Wifi 모듈이 내장되어 있다. 스마트폰 앱을 사용해서 이 소켓의 전원을 올렸다 내렸다 할 수 있는데 집에 있는 거실 스탠드와 안방 스탠드에 연결해서 사용하고 있었다. Orvibo에서는 WiMo라는 앱을 제공하는데 앱이 상당히 부실하고 연결이 오락가락하는 문제점이 있었다. 또한 같은 wifi 네트워크 내에서만 동작하기 때문에 집을 나서면 불을 켜고 나왔는지 확인할 방법이 없었다. 이렇게 생각보다 활용도가 많이 떨어져서 제대로 쓰지 않고 방치하고 있었다.

_DSC0580
_DSC0590

어느 날 거실에서 방을 들어가는데 불을 끄고 스마트폰을 손전등처럼 쓰는 내 모습을 보고는 왜 사놓고 제대로 못쓰고 있나 생각이 확 들었다. 그래서 간단하게라도 만들어보기로 했다.

Orvibo에서는 딱히 개발을 위한 API를 공개하지 않았지만 S20 인터페이스를 리버싱해서 어떤 방식으로 제어할 수 있는지 분석한 사람들이 오픈소스 라이브러리로 내놓기 시작했다. 마침 집에서 방치하고 있던 라즈베리 파이가 있었고 최근 부지런히 사용하고 있는 텔레그램과 연동해서 아주 간단하게 외부에서 제어 가능한 환경을 만들 수 있었다.

사용한 라이브러리

텔레그램 봇을 만들 수 있는 파이썬 패키지는 엄청 많다. python-telegram-bot같은 멋진 라이브러리도 있지만 간단하게 데코레이터 문법으로 작성할 수 있는 pyTelegramBotAPI을 골랐다. 이 패키지는 간단한 기능만 제공하고 있어서 텔레그램의 고급 기능을 사용하려면 다른 패키지를 고르는 것이 낫다.

S20을 제어하는 패키지는 happyleavesaoc/python-orvibo를 사용했다. S20 자체에도 타이머나 추가적인 설정이 가능하지만 이 패키지는 그 기능을 지원하지 않는다. 그래도 자체 기능보다는 프로그램 쪽에서 제어하는게 편해서 제대로 켜고 끌 수만 있기만 하면 되서 이 패키지를 사용하게 되었다.

라우터 설정

라우터에서 설정할 부분은 크게 없었다. 텔레그램 봇은 네트워크 내에서 polling 해오는 방식이라서 별도로 포트를 포워딩해 외부 접근을 허용하거나 할 필요가 전혀 없었다. 대신 편의를 위해서 S20의 IP를 수동으로 할당해 내부에서 고정 IP로 접근할 수 있도록 변경했다.

텔레그램 봇 설정

봇은 예전에 만든 봇을 그대로 사용했다. 만드는 방법은 MS PowerShell에서 텔레그램 메시지 전송하기에서 확인할 수 있다.

코드 작성

멋지진 않지만 그래도 동작하는 코드를 아래처럼 작성했다.

from orvibo.s20 import S20
from telebot import Telebot


bot = TeleBot("<API KEY>")

living_room = ("Living", S20("192.168.3.1"))
bed_room = ("Bed", S20("192.168.3.2"))

def change_switch(s20, mode):
  s20[1].on = mode
  return status_message(s20)

def status_message(s20):
  flag = "on" if s20[1].on else "off"
  return "%s room light is %s" % (s20[0], flag)

@bot.message_handler(commands=["living_room_on"])
def living_room_on(message):
  msg = change_switch(living_room, True)
  bot.send_message(message.chat.id, msg)

@bot.message_handler(commands=["living_room_off"])
def living_room_off(message):
  msg = change_switch(living_room, False)
  bot.send_message(message.chat.id, msg)

@bot.message_handler(commands=["bed_room_on"])
def bed_room_on(message):
  msg = change_switch(bed_room, True)
  bot.send_message(message.chat.id, msg)

@bot.message_handler(commands=["bed_room_off"])
def bed_room_off(message):
  msg = change_switch(bed_room, False)
  bot.send_message(message.chat.id, msg)

@bot.message_handler(commands=["status"])
def all_statuses(message):
  living_msg = status_message(living_room)
  bed_msg = status_message(bed_room)
  bot.send_message(message.chat.id, living_msg)
  bot.send_message(message.chat.id, bed_msg)


if __name__ == '__main__':
  bot.polling()

라즈베리파이 설정

봇에 문제가 생겨도 다시 실행되도록 Supervisor를 사용했다. pip을 사용해서 설치하려 했는데 python3 환경에선 사용하지 못한다고 나왔다. 대신 systemd를 추천 받아서 설치하려는데 라즈비안엔 없어서 고민했는데 apt-get에 Supervisor가 올라와 있어서 쉽게 해결할 수 있었다.

$ sudo apt-get install supervisor

supervisord.conf를 간단하게 작성하고 실행했다. 전체 경로로 지정하고 싶지 않다면 PATH를 추가해도 된다.

[program:bot]
command=/home/pi/bot/.venv/bin/python /home/pi/bot/bot.py
$ supervisord -c /home/pi/bot/supervisord.conf

이제 봇에 말을 걸어보면 동작하는 것을 확인할 수 있다.

telegram s20 bot

문제 해결

S20이 종종 연결이 끊어지는 문제가 있는데 S20으로부터 응답이 없으면 코드에 별도의 에러 핸들링이 없어서 봇이 재시작되었다. 이 경우에는 시간이 지나면 알아서 S20이 다시 붙거나 아니면 S20을 뽑았다 다시 끼워야 한다. 연결에 실패한 것이라도 확인할 수 있도록 코드를 조금 수정했다.

def wrong_message(s20):
  return "%s room light is something wrong" % s20[0]

def change_switch(s20, mode):
  try:
    s20[1].on = mode
  except:
    return wrong_message(s20)
  return status_message(s20)

def status_message(s20):
  try:
    flag = "on" if s20[1].on else "off"
  except:
    return wrong_message(s20)
  return "%s room light is %s" % (s20[0], flag)

실제로 사용해보니 생각보다 조작을 많이 해야해서 타이머를 이용해 일종의 오토메이션 명령어를 추가했다.

import time

@bot.message_handler(commands=["go_to_bed"])
def go_to_bed(message):
  bed_room_on(message)
  bot.send_message(message.chat.id, "Living room light will turn off 15 secs after.")
  time.sleep(15)
  living_room_off(message)


얼마 전 iOS 10 업데이트를 하면서 Home 앱이 기본적으로 들어왔는데 애플의 HomeKit과 연동이 가능하면 사용할 수 있다고 한다. 이 프로토콜을 사용할 수 있도록 구현한 패키지가 nodejs에 존재하는데 HAP-NodeJShomebridge다. 이 도구와 연동하면 Siri를 이용해서 켜고 끌 수 있다는 장점이 있어서 여기서 작성한 코드를 조만간 연동 가능하게 만들어볼 생각이다.

각 장비에서 직접 API를 제공하거나 API를 사용해서 제어할 수 있는 방식에서 마이크로 서비스 아키텍처의 향기가 난다. 대부분의 홈 오토메이션 장비가 게이트웨이를 필요로 한다. 이 글에서는 라즈베리 파이를 썼고 애플의 HomeKit에서는 애플TV가 그런 역할을 한다. 마치 MSA의 API Gateway와 같은 방식으로 말이다. 앞으로 어떤 도구들이 재미있는 API를 들고 나올지 기대된다.