[파이썬 100강] 68강. defaultdict로 누락 없는 그룹화와 집계 자동화하기

[파이썬 100강] 68강. defaultdict로 누락 없는 그룹화와 집계 자동화하기

리스트를 키별로 묶거나, 키가 처음 등장할 때 자동으로 그릇을 만들고 누적해야 하는 상황은 실무에서 정말 자주 나옵니다. 이번 강의에서는 collections.defaultdict를 써서 초기화 분기(if key not in dict) 를 줄이고, 데이터 집계를 더 짧고 안전하게 만드는 방법을 바로 예제로 익힙니다.


핵심 개념

  • defaultdict는 “없는 키를 조회했을 때 기본값을 자동 생성”해 주는 딕셔너리입니다.
  • 기본값 생성 규칙은 default_factory(예: list, int, set)로 지정합니다.
  • 그룹화/카운트/합계 누적 로직에서 초기화 분기를 제거해 코드 실수를 줄입니다.

일반 딕셔너리로 집계를 짤 때 초보가 가장 자주 하는 실수는 “첫 등장 키 처리”를 빼먹는 것입니다. 그래서 KeyError가 나거나, 분기문이 과도하게 늘어납니다. defaultdict는 이 문제를 구조적으로 없애 줍니다. 키가 없으면 자동으로 기본값을 생성하고, 있으면 그대로 가져옵니다. 예를 들어 defaultdict(list)는 없는 키에 접근하는 순간 빈 리스트를 만들어 주고, defaultdict(int)는 0을 만들어 줍니다. 덕분에 append, += 1 같은 누적 코드가 아주 자연스럽게 작성됩니다. 특히 로그 분석, 주문 데이터 집계, 사용자별 이벤트 묶기처럼 “키가 계속 새로 생기는” 데이터에서는 코드 길이뿐 아니라 안정성이 눈에 띄게 좋아집니다. 핵심은 단순 문법 암기가 아니라, “키 초기화 자체를 자료구조에 위임한다”는 설계 감각입니다.

기본 사용

예제 1) 키별 리스트 그룹화

>>> from collections import defaultdict
>>> rows = [
...     ("backend", "지민"),
...     ("frontend", "수아"),
...     ("backend", "민수"),
... ]
>>> grouped = defaultdict(list)
>>> for team, name in rows:
...     grouped[team].append(name)
...
>>> dict(grouped)
{'backend': ['지민', '민수'], 'frontend': ['수아']}

해설:

  • grouped[team]이 처음 등장해도 빈 리스트가 자동 생성됩니다.
  • 그래서 if team not in grouped: grouped[team] = []가 필요 없습니다.
  • 그룹화 문제에서 가장 자주 쓰는 패턴입니다.

예제 2) 카운트 누적(defaultdict(int))

>>> from collections import defaultdict
>>> events = ["login", "view", "login", "purchase", "view", "login"]
>>> counts = defaultdict(int)
>>> for e in events:
...     counts[e] += 1
...
>>> dict(counts)
{'login': 3, 'view': 2, 'purchase': 1}

해설:

  • int()의 기본값은 0이므로 카운터처럼 사용할 수 있습니다.
  • counts[e] = counts.get(e, 0) + 1보다 읽기 쉽고 실수 여지가 적습니다.
  • 로그/에러코드/상태값 빈도 집계에 바로 적용 가능합니다.

예제 3) 중복 제거 그룹화(defaultdict(set))

>>> from collections import defaultdict
>>> accesses = [
...     ("alice", "repo-a"),
...     ("alice", "repo-a"),
...     ("alice", "repo-b"),
...     ("bob", "repo-a"),
... ]
>>> by_user = defaultdict(set)
>>> for user, repo in accesses:
...     by_user[user].add(repo)
...
>>> {k: sorted(v) for k, v in by_user.items()}
{'alice': ['repo-a', 'repo-b'], 'bob': ['repo-a']}

해설:

  • set을 기본값으로 쓰면 같은 값이 여러 번 들어와도 중복이 자동 제거됩니다.
  • 권한/태그/카테고리 집계처럼 유니크 값이 필요한 시나리오에 적합합니다.

예제 4) 다단계 그룹화

>>> from collections import defaultdict
>>> sales = [
...     ("2026-02", "KR", 120),
...     ("2026-02", "US", 90),
...     ("2026-03", "KR", 150),
... ]
>>> table = defaultdict(lambda: defaultdict(int))
>>> for month, country, amount in sales:
...     table[month][country] += amount
...
>>> {m: dict(c) for m, c in table.items()}
{'2026-02': {'KR': 120, 'US': 90}, '2026-03': {'KR': 150}}

해설:

  • 월→국가처럼 키 계층이 있는 집계에 유용합니다.
  • 중첩 자료구조 초기화를 직접 하지 않아도 되어 코드가 단순해집니다.

자주 하는 실수

실수 1) 일반 dict처럼 바로 += 1 해서 KeyError 발생

>>> counts = {}
>>> for e in ["login", "view", "login"]:
...     counts[e] += 1
...
Traceback (most recent call last):
...
KeyError: 'login'

원인:

  • 첫 등장 키에는 기존 값이 없는데, 바로 증가 연산을 시도했습니다.

해결:

>>> from collections import defaultdict
>>> counts = defaultdict(int)
>>> for e in ["login", "view", "login"]:
...     counts[e] += 1
...
>>> dict(counts)
{'login': 2, 'view': 1}

실수 2) defaultdict(list())처럼 팩토리를 호출해서 전달

>>> from collections import defaultdict
>>> bad = defaultdict(list())
Traceback (most recent call last):
...
TypeError: first argument must be callable or None

원인:

  • default_factory에는 “호출 가능한 것”을 넘겨야 하는데, list()를 실행한 결과(빈 리스트 객체)를 넘겼습니다.

해결:

>>> from collections import defaultdict
>>> good = defaultdict(list)
>>> good['x'].append(1)
>>> dict(good)
{'x': [1]}

실수 3) 조회만 했는데 키가 생겨 버리는 부작용

  • 증상: 존재 확인만 하려 했는데 딕셔너리 길이가 늘어남
  • 원인: defaultdict에서 d['없는키'] 접근은 기본값 생성(삽입)을 유발함
  • 해결: 존재 확인은 if key in d 또는 d.get(key)로 처리합니다.

실수 4) JSON 직렬화 시 그대로 넘겨 타입 혼동

  • 증상: API 응답/로그 직렬화 시 소비자 코드가 타입을 다르게 해석
  • 원인: defaultdict도 dict처럼 보이지만 외부 경계에서는 명시적 변환이 안전함
  • 해결: 외부 출력 직전 dict(d) 또는 중첩 구조는 재귀 변환 후 내보냅니다.

실무 패턴

  • 입력 검증 규칙

    • 집계 전 키 정규화(공백 제거, 대소문자 통일)를 먼저 적용합니다.
    • None, 빈 문자열, 잘못된 타입은 초기에 제거해 쓰레기 키를 방지합니다.
  • 로그/예외 처리 규칙

    • 총 입력 건수와 유효 입력 건수를 함께 로깅해 누락을 추적합니다.
    • 예상 외 키 수가 급증하면 경고를 발생시켜 데이터 품질 문제를 조기 탐지합니다.
  • 재사용 함수/구조화 팁

    • group_by(records, key_fn, value_fn) 같은 유틸 함수를 만들어 팀 공통으로 씁니다.
    • 출력 계층(API/리포트)으로 넘어가기 전 defaultdict -> dict 변환을 표준화합니다.
  • 성능/메모리 체크 포인트

    • 고유 키가 지나치게 많으면 메모리 사용량이 커지므로 기간 샤딩 또는 상위 키만 유지하는 전략을 병행합니다.
    • 중첩 defaultdict는 편하지만 구조가 깊어질수록 디버깅이 어려워질 수 있으니 레벨을 제한합니다.
>>> from collections import defaultdict
>>> def normalize_key(s: str) -> str:
...     return s.strip().lower()
...
>>> def group_scores(rows):
...     result = defaultdict(list)
...     for user, score in rows:
...         if user is None:
...             continue
...         key = normalize_key(user)
...         result[key].append(score)
...     return {k: v for k, v in result.items()}
...
>>> group_scores([(" Alice ", 10), ("alice", 20), (None, 5), ("BOB", 7)])
{'alice': [10, 20], 'bob': [7]}

이 패턴에서 중요한 점은 “정규화 → 누적 → 경계 변환” 3단계를 명확히 분리하는 것입니다. 현업에서는 집계 코드 자체보다, 데이터가 들쭉날쭉해서 결과가 흔들리는 문제가 더 자주 발생합니다. defaultdict를 쓰면 누적 코드는 간결해지니, 남는 복잡도를 입력 정제와 출력 계약에 투자할 수 있습니다. 즉, 자료구조 선택 하나가 코드 스타일을 넘어 운영 안정성까지 연결됩니다.

오늘의 결론

한 줄 요약: defaultdict는 키 초기화를 자동화해 그룹화·집계 코드를 짧고 안전하게 만들어 주는 실전 도구입니다.

기억할 것:

  • 그룹화는 defaultdict(list), 카운트는 defaultdict(int), 중복 제거는 defaultdict(set)이 기본 조합입니다.
  • d['없는키'] 접근은 키를 실제로 생성하므로, 존재 확인에는 in/get을 우선 사용합니다.
  • 외부로 내보낼 때는 dict() 변환으로 자료구조 경계를 명확히 하세요.

연습문제

  1. 주문 데이터 (고객ID, 주문금액) 리스트를 받아 고객별 주문금액 리스트로 그룹화하세요.
  2. 로그 레벨 리스트(INFO, ERROR, WARN)를 카운트하되, 대소문자 섞인 입력을 정규화해 집계하세요.
  3. (월, 카테고리, 매출) 데이터로 월별-카테고리별 합계를 만드는 중첩 defaultdict 함수를 작성하세요.

이전 강의 정답

  1. 대소문자/공백 정규화 후 상위 2개 빈도 구하기
>>> from collections import Counter
>>> raw = ['A', 'b', 'a', 'B', 'a', ' c ']
>>> normalized = [x.strip().lower() for x in raw]
>>> c = Counter(normalized)
>>> c
Counter({'a': 3, 'b': 2, 'c': 1})
>>> c.most_common(2)
[('a', 3), ('b', 2)]
  1. 주간 집계 합산과 교집합
>>> from collections import Counter
>>> week1 = Counter({'error': 12, 'warn': 30})
>>> week2 = Counter({'error': 8, 'info': 50})
>>> week1 + week2
Counter({'info': 50, 'warn': 30, 'error': 20})
>>> week1 & week2
Counter({'error': 8})
  1. cancel을 차감해 순구매 횟수 계산(음수는 0 보정)
>>> from collections import Counter
>>> def net_purchase(events):
...     c = Counter(events)
...     net = c['purchase'] - c['cancel']
...     return max(net, 0)
...
>>> net_purchase(['purchase', 'purchase', 'cancel'])
1
>>> net_purchase(['cancel', 'cancel'])
0

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: collections.defaultdict (표준 라이브러리)
  • 실행 방법: 터미널 python REPL 또는 ipython
  • 재현 체크:
    • 그룹화 결과에서 키별 리스트가 기대와 일치하는지 확인
    • 카운트 집계에서 첫 등장 키 처리에 KeyError가 없는지 확인
    • 중첩 집계 출력을 dict로 변환해 구조를 점검