[에러 해결] Python: 중첩 딕셔너리 및 리스트에서 특정 값 찾기 (ESPN API JSON 파싱)

안녕하세요, 개발자 여러분! 복잡한 데이터를 다루다 보면 중첩된 딕셔너리나 리스트 구조에서 원하는 값을 찾아야 할 때가 많습니다. 특히 외부 API를 통해 받아온 JSON 데이터는 이러한 구조를 띄는 경우가 흔하죠. 오늘은 Stack Overflow에서 제기된 실제 사례를 바탕으로, Python에서 중첩된 딕셔너리와 리스트를 효과적으로 탐색하여 특정 값을 찾아내는 방법에 대해 알아보겠습니다. ESPN API의 판타지 하키 데이터를 예시로, 여러분의 데이터 파싱 능력을 한 단계 업그레이드할 수 있는 실용적인 해결책을 제시합니다.

1. 에러 발생 상황

한 개발자 분이 판타지 하키 플레이어의 특정 스탯을 표시하는 도구를 만들고 있었습니다. 이를 위해 ESPN Public API를 사용하여 선수 통계 데이터를 가져왔죠. 사용하는 코드는 다음과 같습니다.

import requests
URL = 'https://sports.core.api.espn.com/v2/sports/hockey/leagues/nhl/athletes/1/statistics'
sJSON = requests.get(URL).json()

위 코드를 실행하면 sJSON 변수에 아래와 같은 JSON 데이터가 할당됩니다. 실제 출력은 훨씬 길지만, 문제 해결에 필요한 핵심 구조를 이해하기 위해 일부를 발췌했습니다.

{'$ref': 'http://sports.core.api.espn.com/v2/sports/hockey/leagues/nhl/athletes/1/statistics/0?lang=en&region=us', 'athlete': {'$ref': 'http://sports.core.api.espn.com/v2/sports/hockey/leagues/nhl/athletes/1?lang=en&region=us'}, 'splits': {'id': '0', 'name': 'All Splits', 'abbreviation': 'TOTAL', 'type': 'total', 'categories': [{'name': 'penalties', 'displayName': 'Penalties', 'shortDisplayName': 'Penalties', 'abbreviation': 'p', 'summary': '', 'stats': [{'name': 'penaltyMinutes', 'displayName': 'Penalty Minutes', 'shortDisplayName': 'Penalty Minutes', 'description': 'Total penalty minutes.', 'abbreviation': 'PIM', 'value': 52.0, 'displayValue': '52'}]}, {'name': 'rpi', 'displayName': 'RPI', 'shortDisplayName': 'RPI', 'abbreviation': 'rpi', 'summary': '', 'stats': [{'name': 'wins', 'displayName': 'Wins', 'shortDisplayName': 'W', 'description': 'Wins', 'abbreviation': 'W', 'value': 0.0, 'displayValue': '0'}, {'name': 'losses', 'displayName': 'Losses', 'shortDisplayName': 'L', 'description': 'Losses', 'abbreviation': 'L', 'value': 0.0, 'displayValue': '0'}, {'name': 'otLosses', 'displayName': 'Over Time Losses', 'shortDisplayName': 'OTL', 'description': 'Over Time Losses', 'abbreviation': 'OTL', 'displayValue': '0'}, {'name': 'points', 'displayName': 'Points', 'shortDisplayName': 'PTS', 'description': 'Points', 'abbreviation': 'PTS', 'value': 28.0, 'displayValue': '28.0'}, {'name': 'rpi', 'displayName': 'Relative Power Index', 'shortDisplayName': 'RPI', 'description': 'Relative Power Index', 'abbreviation': 'RPI', 'displayValue': '0'}, {'name': 'sos', 'displayName': 'Strength Of Schedule', 'shortDisplayName': 'SOS', 'description': 'Strength Of Schedule', 'abbreviation': 'SOS', 'displayValue': '0'}, {'name': 'powerRank', 'displayName': 'ESPN Power Ranking', 'shortDisplayName': 'PWR', 'description': 'ESPN Power Ranking', 'abbreviation': 'PWR', 'displayValue': '0'}, {'name': 'pointsFor', 'displayName': 'Goals For', 'shortDisplayName': 'GF', 'description': 'Goals For', 'abbreviation': 'GF', 'displayValue': '0'}, {'name': 'pointsAgainst', 'displayName': 'Goals Against', 'shortDisplayName': 'GA', 'description': 'Goals Against', 'abbreviation': 'GA', 'displayValue': '0'}]}]}}

개발자가 찾고 싶은 값은 'GA', 'RPI', 'PIM' 등과 같은 ‘abbreviation’ 키에 해당하는 값이며, 그와 연관된 ‘value’입니다. 문제는 이 값들이 중첩된 딕셔너리뿐만 아니라 리스트 안에 딕셔너리 형태로도 존재한다는 점입니다.

기존에 중첩 딕셔너리 파싱에 유용하게 사용되던 재귀 함수(아래 코드)가 있었지만, 이 경우에는 리스트 구조 때문에 제대로 동작하지 않았습니다.

# 기존 Stack Overflow에서 참조한 코드
def getpath(nested_dict, value, prepath=()):
    for k, v in nested_dict.items():
        path = prepath + (k,)
        if v == value: # 값을 찾음
            return path
        elif hasattr(v, 'items'): # v가 딕셔너리인 경우
            p = getpath(v, value, path) # 재귀 호출
            if p is not None:
                return p

2. 명확한 발생 원인

기존 getpath 함수가 문제를 해결하지 못하는 근본적인 원인은 바로 데이터 구조에 있습니다. 이 함수는 딕셔너리 내의 키와 값을 순회하면서, 값이 딕셔너리인 경우에만 재귀적으로 탐색합니다 (hasattr(v, 'items') 조건). 그러나 ESPN API 응답의 'splits' 아래 'categories' 키를 보면, 리스트([]) 안에 여러 개의 딕셔너리가 들어있는 것을 확인할 수 있습니다. 또한 각 카테고리 딕셔너리 안의 'stats' 키 역시 리스트 안에 딕셔너리들을 포함하고 있습니다.

예를 들어, 'categories'는 다음과 같은 형태입니다.

'categories': [
    {'name': 'penalties', ..., 'stats': [...]},
    {'name': 'rpi', ..., 'stats': [...]}
]

기존 getpath 함수는 리스트 자체를 재귀적으로 탐색하는 로직이 없기 때문에, 리스트 안에 중첩된 딕셔너리에 도달할 수 없습니다. 따라서 'abbreviation' 키와 'value' 키가 들어있는 딕셔너리들을 건너뛰게 되어 원하는 값을 찾지 못하게 됩니다.

3. 해결 방법 및 코드 예시

이 문제를 해결하기 위해서는 딕셔너리뿐만 아니라 리스트도 재귀적으로 탐색할 수 있는 범용적인 함수를 작성해야 합니다. 원하는 'abbreviation' 값을 찾으면, 해당 딕셔너리에 있는 'value' 키의 값을 반환하도록 구현해봅시다.

3.1. 문제 해결을 위한 재귀 함수

다음 find_stat_value 함수는 딕셔너리 또는 리스트를 입력받아 재귀적으로 탐색합니다. 목표는 'abbreviation' 키가 특정 값을 가지고 있는 딕셔너리를 찾고, 그 딕셔너리 내의 'value'를 반환하는 것입니다.

def find_stat_value(data, target_abbreviation):
    """
    중첩된 딕셔너리 및 리스트에서 특정 'abbreviation'에 해당하는 'value'를 찾습니다.

    Args:
        data (dict or list): 탐색할 JSON 데이터.
        target_abbreviation (str): 찾으려는 통계의 약어 (예: 'PIM', 'GA', 'RPI').

    Returns:
        any or None: 해당 'abbreviation'의 'value'를 찾으면 반환하고, 없으면 None 반환.
    """
    if isinstance(data, dict):
        # 현재 딕셔너리에 'abbreviation' 키가 있고, 그 값이 target_abbreviation과 일치하는지 확인
        if data.get('abbreviation') == target_abbreviation:
            return data.get('value') # 'value' 키의 값을 반환

        # 딕셔너리의 모든 값들을 순회하며 재귀적으로 탐색
        for key, value in data.items():
            result = find_stat_value(value, target_abbreviation)
            if result is not None:
                return result
    elif isinstance(data, list):
        # 리스트의 각 요소를 순회하며 재귀적으로 탐색
        for item in data:
            result = find_stat_value(item, target_abbreviation)
            if result is not None:
                return result
    return None # 찾지 못한 경우 None 반환

3.2. 코드 설명

  • isinstance(data, dict): 현재 탐색 중인 data가 딕셔너리인지 확인합니다.
    • 만약 딕셔너리이고, 'abbreviation' 키의 값이 target_abbreviation과 일치한다면, 해당 딕셔너리의 'value' 키 값을 즉시 반환하여 탐색을 종료합니다.
    • 그렇지 않다면, 딕셔너리의 모든 값(value)에 대해 find_stat_value 함수를 재귀적으로 호출하여 더 깊이 탐색합니다.
  • isinstance(data, list): 현재 탐색 중인 data가 리스트인지 확인합니다.
    • 리스트인 경우, 리스트의 각 요소(item)에 대해 find_stat_value 함수를 재귀적으로 호출하여 탐색을 이어갑니다.
  • return None: 어떤 경우에도 값을 찾지 못하면 None을 반환합니다.

3.3. 사용 예시

이제 위에서 정의한 find_stat_value 함수를 사용하여 ESPN API에서 가져온 sJSON 데이터로부터 원하는 통계 값을 쉽게 추출할 수 있습니다.

# 실제 ESPN API 호출 부분 (위에서 정의된 sJSON 사용)
# import requests
# URL = 'https://sports.core.api.espn.com/v2/sports/hockey/leagues/nhl/athletes/1/statistics'
# sJSON = requests.get(URL).json()

# 찾으려는 통계 값 추출
pim_value = find_stat_value(sJSON, 'PIM')
ga_value = find_stat_value(sJSON, 'GA')
rpi_value = find_stat_value(sJSON, 'RPI')
pts_value = find_stat_value(sJSON, 'PTS') # 포인트도 찾아봅시다.

print(f"PIM (Penalty Minutes): {pim_value}")
print(f"GA (Goals Against): {ga_value}")
print(f"RPI (Relative Power Index): {rpi_value}")
print(f"PTS (Points): {pts_value}")

이 코드를 실행하면 다음과 같은 출력을 얻을 수 있습니다.

PIM (Penalty Minutes): 52.0
GA (Goals Against): 0
RPI (Relative Power Index): 0
PTS (Points): 28.0

이제 복잡한 JSON 구조 속에서도 원하는 데이터를 정확히 찾아낼 수 있게 되었습니다!

4. 향후 예방을 위한 팁

복합적인 JSON 데이터를 다룰 때 발생할 수 있는 잠재적인 문제를 줄이고 효율적인 데이터 처리를 위한 몇 가지 팁을 드립니다.

  • JSON 구조 시각화 도구 활용: API 응답을 처음 받을 때는 JSON Formatter나 브라우저의 개발자 도구를 이용하여 JSON 구조를 시각적으로 파악하는 것이 중요합니다. 데이터를 눈으로 확인하면 어떤 방식으로 탐색해야 할지 쉽게 계획할 수 있습니다.
  • 에러 핸들링 강화: 실제 서비스에서는 키가 존재하지 않거나 데이터 형식이 예상과 다를 수 있습니다. dict.get() 메서드를 사용하여 기본값을 설정하거나, try-except 블록을 활용하여 KeyError 또는 TypeError 등을 처리하면 안정성을 높일 수 있습니다.
  • 범용적인 JSON 파싱 라이브러리 고려: 데이터 구조가 매우 복잡하거나 쿼리 패턴이 다양하다면 jsonpath-rw 또는 jq와 같은 JSONPath 기반의 라이브러리를 고려해볼 수 있습니다. 이들은 선언적인 방식으로 복잡한 JSON 쿼리를 가능하게 합니다.
  • API 문서 숙지: API 제공자가 제공하는 문서를 꼼꼼히 읽어 데이터 구조와 각 필드의 의미를 정확히 이해하는 것이 중요합니다. 이는 불필요한 시행착오를 줄이는 데 큰 도움이 됩니다.
  • 데이터 검증 및 테스트 코드 작성: API 응답 데이터의 유효성을 검증하고, 파싱 로직에 대한 단위 테스트를 작성하여 예기치 않은 데이터 변경에도 코드가 안정적으로 작동하는지 확인하는 것이 좋습니다.

이 문서를 통해 여러분이 Python으로 복잡한 JSON 데이터를 다루는 데 있어 자신감을 얻으셨기를 바랍니다. 행복한 코딩 되세요!

댓글 남기기