태그 보관물: python

(Python) asyncio를 통한 비동기 코루틴 완전 정복

asyncio에 의한 단일 스레드 병렬 작업

지난 글에서 concurrent.futures 모듈을 통해서 멀티스레딩/멀티프로세싱에 대한 고수준 API를 파이썬에서 사용할 수 있게 됨을 확인했다. 이 새로운 API는 멀티 스레드 디스패치를 사용하기 쉽게 만들 뿐 아니라, 직접 스레드를 제어하는 것이 아닌 Future 객체를 사용함으로써 Promise 개념을 도입한 것과 유사한 결과를 보여준다고 했다.

이 기능이 도입된 것이 파이썬 3.2였고, 뒤이은 업데이트인 3.4 버전에서는 단일 스레드 기반의 비동기 처리를 할 수 있는 asyncio가 도입되었다. asyncio는 파이썬의 코루틴을 사용하여 I/O 등의 레이턴시가 큰 작업에 대해 non-blocking으로 동작하는 코드를 작성할 수 있게 한다.

Continue reading “(Python) asyncio를 통한 비동기 코루틴 완전 정복” »

JSON in Python

json – Python’s JSON encoder / decoder

https://docs.python.org/3/library/json.html

JSON 포맷의 형식은 다행스럽게도(?) 파이썬의 사전과 닮았고 이에 따라 json API는 marshal이나 pickle과 유사하게 되어 있다. 기본적으로 엄격한 JSON 파일은 단일 루트 객체가 존재하며 그 내부에 여러 프로퍼티들을 갖는다. 단일 루트 객체는 사전이나 리스트 중 하나와 유사하며 json 모듈은 결국 사전/리스트를 문자열로 인코딩하거나 그 역의 처리를 하는 일을 한다.

이 모듈에서는 (몇몇 다른 유틸리티 함수들도 있다만) 기본적으로 dump/dumps, load/loads를 이용해서 JSON 파일(문자열)에서 파이썬 타입의 데이터 객체를 뽑아내거나 그 반대의 일을 한다고 생각하면 된다.

뭐 여기까지는 아주 쉬운데, 다음과 같은 문제가 있다. 왜인지는 모르겠는데 ensuer_ascii 옵션값이 True가 디폴트이다.

>>> json.dumps('{"name": "안녕이다"}')                       
'"{\\"name\\": \\"\\uc548\\ub155\\uc774\\ub2e4\\"}"' 


>>> json.dumps('{"name": "안녕이다"}', ensure_ascii=False)   
'"{\\"name\\": \\"안녕이다\\"}"'                         

이 부분은 상당히 이상한데, 출력쪽의 인코딩이 UTF8이어도 이 옵션때문에 인코딩된 값이 나간다. 문제는 이 출력을 받아서 사용해야하는 부분이다.

Flask에서 JSON

Flask에서는 jsonify라는 유틸리티 함수를 만들어서 사전이나 배열을 던져서 쉽게 JSON 타입의[^1]의 HTTP Response를 생성해준다.

from flask import jsonify

@app.route('/api/test')
def test_api():
    data = dict(zip(('code', 0), ('msg', 'ok')))
    return jsonify(data)

이 함수는 JSON을 payload로 하는 API를 만들 때 편리하게 쓸 수 있다. 하지만 여기에는 함정이 있다… 보통 테스트를 해볼 때 그냥 아무 영단어나 넣어서 해보면 큰 문제가 없는 것 같지만…

flask.jsonify의 한글 문제

문제는 이 jsonify 함수가 아스키문자가 아닌 경우에는 이스케이프 인코딩을 적용한다는 점이다. 따라서 JSON 데이터에 한글이 포함되는 경우에 브라우저에서 쓸데없이 이 값을 다시 디코딩해야 할텐데…

어차피 UTF8로 전송될거라면 그냥 그대로 보내도 상관없지 않겠나. 브라우저는 UTF8로 인코딩된 바이너리 데이터를 받아서 그걸 디코딩해서 유니코드 문자를 복구할텐데. 그래서 jsonify는 한글로 데이터를 주고 받는 API라면 사용을 포기하는 수 밖에 없다. (심지어 저 옵션을 제어할 방법도 딱히 없는 것 같다.) 1

그래서 데이터를 이런식으로 내려보내주면 되지 않을까하고 테스트를 아래와 같이 해봤다.

import json
@app.route('/api/test')
def test_api():
    data = dict(zip(('code', 0), ('msg', 'ok')))
    json_data = json.dumps(data, ensuer_ascii=False)
    res = make_response(json_data)
    res.headers['Content-Type'] = 'application/json'
    return res

다행히 브라우저에서 정상적으로 동작한다. 그런데 모든 API 쪽 라우팅 함수마다 이 과정을 거치는 건 무척이나 지리한 작업이 될 거다.  따라서 우리는 @app.route 처럼 데코레이터를 하나 작성해서 수고를 덜어줄 필요가 있다.

def json_api(f):
    def inner(*args, **kwds):
        r = f(*args, **kwds)
        result = make_response(json.dumps(r, ensure_ascii=False))
        return result
    return inner

그러나 불행히도 이 데코레이터는 제대로 작동할 수 없다. 아니 데코레이터 자체는 잘 작동하겠지만, URL 패턴과 URL핸들러 함수의 관계를 함수의 이름, 즉 __name__ 속성으로 맵핑하는 플라스크의 특성상 이렇게 데코레이터를 씌운 함수는 이름이 모두 inner가 될테니 말이다.

AssertionError: View function mapping is overwriting an existing endpoint function: inner

적용한 함수가 2개 이상이 되면 같은 이름의 함수가 2개 이상 있어서 이름 충돌이 발생하기 때문이다.

이를 해결하기 위해서는

  1. JSON 응답을 만들어주는 jsonify 함수를 따로 작성해서 매번 호출하거나
  2. 데코레이팅 받는 함수가 이름을 유지하게끔 작성하는

방법이 있다.

기왕 두 번재 방법으로 가기로 한 거 좀 더 파보겠다. 그러니까 수정해야 할 부분은 데코레이터가 리턴하는 함수가 아닌 원래 함수의 이름을 갖는 함수를 리턴하도록 하는 것인데, 파이썬 커뮤니티의 선지자들은 이런 상황에 대해서  update_wrapper()라는 함수를 만들어 놓으셨고,  게다가 이걸 쉽게 만들어주는 wraps()라는 데코레이터 함수가 있다.

from functools import wraps
def json_api(f):
    @wraps
    def inner(*args, **kwds):
        r = f(*args, **kwds)
        result = make_response(json.dumps(r, ensure_ascii=False))
        return result
    return inner

이제 코드가 정상적으로 작동하게 된다.

요약

  1. json으로 덤프하는 함수는 기본적으로 아스키문자가 아닌 문자들을 모두 이스케이핑한다.
  2. 웹용 API를 만드는 시점에 이 부분은 문제가 되는데, Flask는 이 부분을 따로 변경할 방법을 제공하지 않는다. (왜 이걸 생각을 안했지?)
  3. 그래서 별도의 래핑함수를 만들고 이걸 아예 데코레이터로 만들면 좀 더 편하게 쓸 수 있다.
  4. 그냥 aioHTTP 쓰는게 더 빠른 거 같다.

  1. 이게 은근 문제라서, 결국 Flask를 버리고 aioHTTP로 갈아타는 결정적 계기가 됐다.