파이썬에서 JSON 다루는 방법

파이썬에서 JSON을 사용하는 방법

 

JSON은 JavaScript Object Notation의 줄임말로, 기본적으로 키-값쌍의 포맷으로 구조화된 정보를 인코딩하는 규격이다. 예전에는 XML이 유연성을 근거로 많이 사용되었으나, XML 파서는 기본적으로 무겁고 비싸게 돌아가기 때문에, JSON이 등장하면서 API 관련한 쪽에서 급격히 JSON을 쓰는 쪽이 늘어나기 시작했다.

JSON 포맷은 JSON의 객체 리터럴 및 배열 리터럴을 그대로 사용하는 문법을 쓰는데, 이 포맷은 문자열을 키로 하는 파이썬의 사전(dictionary) 포맷과도 일치한다.  이에 따라 json API는 marshal이나 pickle과 유사하게 되어 있다. 기본적으로 엄격한 JSON 파일은 단일 루트 객체가 존재하며 그 내부에 여러 프로퍼티들을 갖는다. 단일 루트 객체는 사전이나 리스트 중 하나와 유사하며 json 모듈은 결국 사전/리스트를 문자열로 인코딩하거나 그 역의 처리를 하는 일을 한다.

<주의> : 흔히 하는 오해 중 하나가 JSON 파일이 그냥 텍스트 파일이라고 생각하는 점이다. 물론 HTML이 그러하듯 JSON 규격은 텍스트 데이터의 규격을 따르는 그 하위 타입이기는 하다. 실제로 텍스트 편집기에서도 열 수 있고. 하지만 유니코드가 광범위하게 사용되는 요즘에, 여러분은 가급적이면 “일반 텍스트”라는 표현을 애써 부정해야 마땅하다. JSON 파일은 “유니코드 텍스트”를 사용해서 표현되며, 이것이 파일로 고정되거나, 네트워크 소켓을 통해 전송될 때에는 UTF8등의 유니코드 인코딩을 통해서 바이너리 데이터로 변환된다. 따라서 JSON 파일은 UTF8 등의 인코딩을 통해 저장되는 바이너리 데이터로 이해하고 있는 쪽이 올바르다.

이 모듈에서는 (몇몇 다른 유틸리티 함수들도 있다만) 기본적으로 dump/dumps, load/loads를 이용해서 JSON 파일(문자열)에서 파이썬 타입의 데이터 객체를 뽑아내거나 그 반대의 일을 한다고 생각하면 된다. 파이썬에서의 JSON 호환 객체는 다음과 같은 것들이 있다. 즉 어떤 데이터를 JSON으로 만들고 싶다면, 여기서 언급하는 타입의 값들로 구성되어야 한다.

  • 정수 혹은 실수의 number 타입 (int / float)
  • 문자열
  • 모든 키가 문자열 타입이며, 모든 값은 JSON 호환 객체인 사전
  • 모든 원소가 JSON 호환 객체인 리스트

JSON은 이러한 호환 타입 객체를 인코딩한 바이트(bytes) 객체이며, 파이썬의 사전처럼 조작하거나 특정 키를 통해서 액세스할 수 없는 상태가 된다. JSON 호환 타입 객체를 JSON 데이터로 인코딩하는 함수가 dump 이고, load는 이런 데이터를 Python 객체로 환원한다. 각각의 함수에 s 가 붙은 버전은 “문자열 기반”으로 말 그대로 JSON을 표현하는 문자열로 변환하거나, 그 반대의 변환을 가리킨다.

다음 코드는 데이터를 JSON으로 저장하고 다시 읽어들일 때 사용할 수 있는 코드이다. 파일을 열고 JSON 데이터를 읽고 쓰는 것은 open() 으로 얻은 파일 디스크립터를 사용하면 되므로 다음과 같은 식으로 처리한다.

import json

## read json file
with open('myobj.json', 'rb') as f:
  root = json.load(f)
  ...

## write json file
with open('myobj2.json', 'wb') as f:
  json.dump(root, f)
  ...

값 인코딩

JSON 자체가 포맷팅된 텍스트라 할 때 이를 파일에 저장하거나, 읽어들일 때 입출력 인코딩을 사용한다. 보통 이것은 JSON 문자열 자체를 바이너리로 만들 때 사용하는 것이다. 그런데 파이썬 객체를 JSON으로 변환할 때, 값을 별도로 인코딩하게 된다. 예를 들어서 문자열 값이 비라틴 계열의 문자를 쓰고 있다면 그대로 표현되는 것이 아니라 "\\uc548" 과 같이 유니코드 코드 포인트 값으로 바껴서 출력된다. 기본적으로 json 모듈은 비라틴 문자열에 대해서 ascii 인코딩을 적용해서 유니코드 코드 포인트 표현으로 변환해 버린다. 만약 JSON 파일 내에 “읽을 수 있는” 형태로 문자열을 그대로 저장하고 싶다면, dump(), dumps() 함수의 ensuer_ascii= 파라미터를 False로 주어야 한다.

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


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

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 함수가 아스키 이스케이프 인코딩을 적용한다는 점이다. Flask처럼 웹서버로 쓰이는 환경에서 이런식으로 데이터를 내려보내면 프론트엔드에서는 다시 이 코드포인트들을 문자열로 변환하는 작업을 한 번 더 거쳐야 한다. 어차피 UTF8 등으로 인코딩해서 전송할 것이기 때문에 굳이 두 번 변환할 이유가 없는 것이다.

어차피 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. 이게 은근 문제라서, 결국 Flask를 버리고 aioHTTP로 갈아타는 결정적 계기가 됐다. aioHTTP에서는 json 버전으로 만들 때 dump 함수를 지정할 수 있게 되어 있다.