본문으로 바로가기
728x90
반응형


개요

Python에서 Type Hint를 적용하면 TypedDict를 사용해 Key를 자동완성 시킨다거나 특정 클래스에 어떤 속성이 들어 있는지 파악할 수 있어 많이 편리합니다. 하지만 Typing 자체를 적용하려고 공수를 들여 작업하는  케이스도 존재합니다.

이번에 마주한 경우는 외부 API에서 받은 응답을 프로젝트에서도 사용할 수 있게 구성해야 했습니다. 단순히 API의 요청과 응답을 제공하는 메서드를 만드는 것이 아닌 응답받은 데이터에 대해 TypeHint를 적용해 코드 자동 완성을 달성하기 위함이었죠.

문제는 외부 API에서 응답으로 주는 데이터가 중첩된 구조의 자료형일 때 나타났습니다. 여기서 중첩된 구조라 하면 다음과 같은 형태를 말합니다.

{
    "a": {
        "b": {
            "c": 1,
            "e": 1
        }
    }
}

이와 같이 중첩된 구조의 데이터인 경우 어떻게 타이핑을 적용할 수 있을지 고민해 봤습니다.

Sample Data

이 포스팅에서 사용할 데이터 예시는 Sample APIs의 https://sampleapis.com/api-list/beers를 사용했으며 이 API의 응답 데이터는 다음과 같습니다

[
  {
    "price": "$16.99",
    "name": "Founders All Day IPA",
    "rating": {
      "average": 4.411243509154233,
      "reviews": 453
    },
    "image": "https://www.totalwine.com/media/sys_master/twmmedia/h00/h94/11891416367134.png",
    "id": 1
  },
	...
]


DataClass vs TypedDict

먼저 타이핑을 적용하는 방법에 대해서 언급할 필요가 있습니다.

저는 주로 DataClass나 TypedDict를 이용한 형태의 Typing을 적용하는 편입니다.

  • DataClass의 경우 Data Transfer Object를 만들 때 이용하고 더 복잡한 개념을 다룰 때는 직접 class를 정의해서 만듭니다.
  • TypedDict의 경우 Dictionary를 생성할 때 Dictionary Key를 제한하는 용도로 사용 중입니다.


그렇다면 외부 API를 타이핑하기 위해서 어떤 방법을 사용해야 할까요?

사실 어느 방법을 선택해도 타이핑 적용을 class로 적용할 것이냐 dictionary로 적용할 것이냐의 문제이기 때문에 상황에 맞는 방법을 선택해서 사용하면 됩니다

이제 외부 API 요청에 대한 타이핑을 적용하는 방법을 알아봅시다.

01: SimpleNameSpace!

python 3.3에 추가된 네임스페이스 속성에 대한 접근을 표기할 수 있는 클래스입니다(문서). 외부 API에서 응답정보에 대한 네임스페이스를 추출할 때 이용할 수 있습니다.

from types import SimpleNamespace

import requests

r = requests.get('https://api.sampleapis.com/beers/ale')

response_data_first = r.json(object_hook=lambda x: SimpleNamespace(**x))[0]

결과는 다음과 같습니다.

namespace(
    price='$16.99',
    name='Founders All Day IPA',
    rating=namespace(average=4.411243509154233, reviews=453),
    image='https://www.totalwine.com/media/sys_master/twmmedia/h00/h94/11891416367134.png',
    id=1
)

입력된 데이터에서 namespace를 추출해 namespace를 통해 데이터에 접근할 수 있는 방식을 제공합니다. 즉 response_data_first 변수는 다음과 같이 사용할 수 있게 됩니다.

print(response_data_first.price) # $16.99
print(response_data_first.rating.average) # 4.411243509154233

외부 API에서 응답받은 데이터에 쉽게 접근 가능한 형태로 만들어진 것 같습니다. 하지만 사용하다 보니 타이핑이 적용되지 않아 외부 API구조를 파악하고 접근한다는 측면에서 문제가 해결되지 않았습니다.

마치 Dictionary에 존재하는 모든 Key를 알고 있어야 사용 가능하다 정도로 요약할 수 있을 것 같습니다. 이렇게 될 경우 타이핑을 적용하기 위해서는 TypedDict로 모든 Key를 일일이 타이핑해서 적용해줘야 하기 때문에 많이 번거롭습니다.

02: DataClass 이용하기

다음은 DataClass를 이용한 방식입니다.

import requests

r = requests.get('https://api.sampleapis.com/beers/ale')
response_data_first = r.json()[0]

DataClass를 이용한 방식은 SimpleNameSpace와는 다르게 응답 데이터에 존재하는 데이터를 표현하는 클래스를 먼저 생성해둬야 합니다.

from dataclasses import dataclass, field

@dataclass
class Rating:
    average: float
    reviews: int

@dataclass
class ResponseData:
    price: int
    name: int
    image: str
    id: int
    rating: Rating = field(init=True, default=None)

그리고 이에 맞춰 응답 데이터의 정보를 초기화해 주는 코드가 필요하게 됩니다.

response_data_first_dto = ResponseData(**response_data_first)
if rating := response_data_first.get('rating', None):
    response_data_first_dto.rating = Rating(**rating)

dataclass를 이용하면 타이핑을 적용할 순 있게 되지만 사전 준비 작업으로 API 응답 데이터 맞는 클래스를 생성해둬야 한다는 번거로움이 생깁니다.

즉, 타이핑 관점에서는 이득이 되지만 작업 편의 관점에서는 사전 준비 작업이 커집니다.

비교하기

  SimplaNameSpace DataClass
장점 Dictionary 자료형의 namespace를 간단히 추출 가능 사전에 필요한 데이터를 클래스를 만들어둠으로써 타이핑이 적용 가능함
단점 Dictionary 자료형의 namespace만 추출할 뿐 타이핑은 되지 않음
API 응답 데이터 구조를 파악하고 있어야만 어떤 key에 어떤 value로 접근하는지 파악이 가능함
사전에 필요한 데이터가 몇 개나 될 지 예측할 수 없는 상황이 있을 수 있으며 상황에 따라 준비할 클래스가 많다면 번거로움이 증대됨

저는 위 두 가지 방식에서 타이핑을 적용하기 위해 DataClass를 이용하는 방식을 선택했습니다. 이 방식을 이용하는 단점만 해결된다면 목표했던 타이핑 적용이 가능해집니다. 이 단점을 어떻게 해결할 수 있을까 도중 아이디어 하나를 떠올리게 됩니다

DataClass 번거로움 해결하기

어떤 환경 설정에서는 생산성을 높이기 위해서 json을 yaml로 변환하는 작업을 도와주는 도구나 기타 다른 형식의 포맷에 맞춰 변환해 주는 도구들이 존재합니다. 그렇다면 json을 입력하면 이를 python object로 추출해 주는 도구도 존재하지 않을까 싶어 아래 링크를 발견하게 되었습니다.

https://json2csharp.com/code-converters/json-to-python

 

JSON to Python Classes Online Converter - Json2CSharp Toolkit

 

json2csharp.com


위 사이트는 다음과 같은 방식으로 이용했습니다

import requests

r = requests.get('https://api.sampleapis.com/beers/ale')

response_data = r.json()

먼저 API 응답의 결과로 발생한 데이터가 들어있는 response_data에 들어있는 값을 복사합니다. 그리고 첨부한 링크에 복사 붙여 넣기 합니다. 이러면 위 사이트는 다음과 같은 코드를 반환해 줍니다.

from typing import Any
from dataclasses import dataclass
import json
@dataclass
class Rating:
    average: float
    reviews: int

    @staticmethod
    def from_dict(obj: Any) -> 'Rating':
        _average = float(obj.get("average"))
        _reviews = int(obj.get("reviews"))
        return Rating(_average, _reviews)

@dataclass
class Root:
    price: str
    name: str
    rating: Rating
    image: str
    id: int

    @staticmethod
    def from_dict(obj: Any) -> 'Root':
        _price = str(obj.get("price"))
        _name = str(obj.get("name"))
        _rating = Rating.from_dict(obj.get("rating"))
        _image = str(obj.get("image"))
        _id = int(obj.get("id"))
        return Root(_price, _name, _rating, _image, _id)

dataclass를 얻었으니 다음과 같이 시도해 봅시다.

import requests

r = requests.get('https://api.sampleapis.com/beers/ale')
response_data_first = r.json()[0]

response_data_first = Root(**response_data_first)

결과는 다음과 같습니다.

{'price': '$16.99', 'name': 'Founders All Day IPA', 'rating': {'average': 4.411243509154233, 'reviews': 453}, 'image': 'https://www.totalwine.com/media/sys_master/twmmedia/h00/h94/11891416367134.png', 'id': 1}

 

Notes

json2csharp.com을 이용해서 dataclass를 만들어내는 게 완벽하게 적용되지는 않습니다. 중간중간 비어있는 속성들이 존재하는가 보면 어떤 경우에는 변환이 되지 않는 케이스들이 존재합니다. json으로 만들어낸 데이터를 잘 보고 집어넣어야 올바른 class를 만들어줍니다.

하지만 dataclass를 빨리 만드는데 한 번은 유틸리티 성으로 이용해도 괜찮습니다. 혹시 다른 방식을 통해 문제를 처리하고 있다면 댓글에 남겨주시면 감사합니다.


(본 포스팅이 도움이 되셨다면 공감 버튼 클릭 부탁드립니다)


728x90
반응형