[파이썬 100강] 70강. namedtuple로 가볍고 읽기 좋은 데이터 모델 만들기

[파이썬 100강] 70강. namedtuple로 가볍고 읽기 좋은 데이터 모델 만들기

딕셔너리는 빠르게 만들기 좋지만, 키 오타가 나도 실행 전까지 잘 안 드러나고 구조가 커질수록 코드 가독성이 급격히 떨어집니다. 이번 강의에서는 collections.namedtuple튜플의 가벼움은 유지하면서, 필드 이름으로 의미를 드러내는 데이터 모델을 만드는 방법을 바로 실습합니다.


핵심 개념

  • namedtuple은 튜플 기반의 불변(immutable) 레코드 타입을 만들어 줍니다.
  • 인덱스 접근(row[0]) 대신 속성 접근(row.user_id)이 가능해 코드 의도가 선명해집니다.
  • 메모리 사용량은 일반 클래스보다 가벼운 편이라 대량 레코드 처리에 유리합니다.

실무에서는 CSV/로그/API 응답을 처리하다 보면 “구조는 고정인데 객체까지 만들기엔 과하다”는 상황이 정말 자주 나옵니다. 이때 그냥 dict로 밀어붙이면 처음에는 편한데, 몇 주 뒤에는 "usid", "userId", "user_id" 같은 키 혼선이 생기고 리팩터링 비용이 커집니다. namedtuple은 이런 구간에서 딱 좋은 절충안입니다. 생성은 간단하고 읽기는 쉬우며, 튜플이라 해시 가능(필드가 해시 가능할 때)해서 집합/딕셔너리 키로도 활용됩니다. 또 불변이라 의도치 않은 값 변경을 막을 수 있어, 데이터 파이프라인에서 “중간에 누가 값을 바꿨는지” 추적하느라 시간을 버리는 문제를 줄여 줍니다. 즉, namedtuple은 거대한 아키텍처 도구가 아니라, 일상적인 데이터 흐름을 단단하게 만드는 실전 도구입니다.

기본 사용

예제 1) 가장 기본 패턴: 필드 이름이 있는 튜플 만들기

>>> from collections import namedtuple
>>> User = namedtuple("User", ["id", "name", "email"])
>>> u = User(101, "Kim", "[email protected]")
>>> u
User(id=101, name='Kim', email='[email protected]')
>>> u.id, u.name
(101, 'Kim')
>>> u[0], u[1]
(101, 'Kim')

해설:

  • namedtuple("User", [...])User라는 새 타입을 만듭니다.
  • 같은 데이터라도 u[0]보다 u.id가 훨씬 읽기 쉽고 오해가 적습니다.
  • 튜플 기반이라 인덱스 접근도 되지만, 실무 코드에서는 속성 접근 중심으로 쓰는 것이 유지보수에 유리합니다.

예제 2) 리스트/딕셔너리 원천 데이터를 안전하게 레코드화하기

>>> from collections import namedtuple
>>> Order = namedtuple("Order", ["order_id", "user_id", "amount", "status"])
>>> raw_rows = [
...     ("o-100", "u-1", 12000, "paid"),
...     ("o-101", "u-2", 8000, "pending"),
...     ("o-102", "u-1", 5000, "paid"),
... ]
>>> orders = [Order(*row) for row in raw_rows]
>>> [o.user_id for o in orders if o.status == "paid"]
['u-1', 'u-1']
>>> sum(o.amount for o in orders if o.user_id == "u-1")
17000

해설:

  • 언패킹(*row)을 쓰면 입력 순서만 맞으면 빠르게 레코드를 만들 수 있습니다.
  • 이후 로직은 o[2] 같은 매직 인덱스가 아니라 의미 있는 필드명으로 읽히기 때문에 협업 시 리뷰 효율이 올라갑니다.
  • 데이터 분석/집계 로직에서도 의도 전달이 좋아져 버그가 줄어듭니다.

예제 3) _replace, _asdict로 불변 모델을 실무에 맞게 다루기

>>> from collections import namedtuple
>>> Event = namedtuple("Event", ["ts", "level", "message"], defaults=["INFO", ""])
>>> e1 = Event("2026-02-17T11:30:00")
>>> e1
Event(ts='2026-02-17T11:30:00', level='INFO', message='')
>>> e2 = e1._replace(message="worker started")
>>> e2
Event(ts='2026-02-17T11:30:00', level='INFO', message='worker started')
>>> e2._asdict()
{'ts': '2026-02-17T11:30:00', 'level': 'INFO', 'message': 'worker started'}

해설:

  • namedtuple은 불변이라 직접 수정이 불가하지만, _replace로 “수정된 새 객체”를 만들 수 있습니다.
  • _asdict()로 직렬화 가능한 dict 형태를 쉽게 얻어 API 응답/로그 출력으로 연결하기 좋습니다.
  • defaults를 활용하면 선택 필드가 있는 레코드를 더 간단히 생성할 수 있습니다.

예제 4) 대량 데이터 처리에서 정렬/중복 제거 키로 사용하기

>>> from collections import namedtuple
>>> Point = namedtuple("Point", ["x", "y"])
>>> points = [Point(1, 2), Point(0, 0), Point(1, 2), Point(2, 3)]
>>> sorted(points)
[Point(x=0, y=0), Point(x=1, y=2), Point(x=1, y=2), Point(x=2, y=3)]
>>> set(points)
{Point(x=2, y=3), Point(x=1, y=2), Point(x=0, y=0)}

해설:

  • 튜플 성질 덕분에 정렬, 집합화, 딕셔너리 키 활용이 자연스럽습니다.
  • 단, 필드에 리스트/딕셔너리 같은 비해시 타입이 있으면 set/키 사용 시 실패할 수 있으니 주의가 필요합니다.

자주 하는 실수

실수 1) 필드 이름 오타를 런타임까지 끌고 가는 문제

>>> from collections import namedtuple
>>> User = namedtuple("User", ["id", "name"])
>>> u = User(1, "Kwon")
>>> u.nmae
Traceback (most recent call last):
...
AttributeError: 'User' object has no attribute 'nmae'

원인:

  • dict에서는 키 오타를 None 처리 등으로 놓치기 쉽지만, namedtuple 속성 오타는 즉시 예외가 발생합니다.
  • 예외 자체는 좋은 신호지만, 필드명을 통일하지 않으면 코드 전반에서 반복됩니다.

해결:

>>> # 필드명 규칙을 먼저 고정하고 IDE 자동완성/정적분석을 활용
>>> User = namedtuple("User", ["user_id", "display_name"])
>>> u = User("u-1", "Kwon")
>>> u.display_name
'Kwon'

실수 2) 가변 객체를 필드로 넣고 "불변이라 안전"하다고 착각

>>> from collections import namedtuple
>>> Bag = namedtuple("Bag", ["owner", "items"])
>>> b = Bag("kim", ["pen"])
>>> b.items.append("notebook")
>>> b
Bag(owner='kim', items=['pen', 'notebook'])

원인:

  • namedtuple 객체 자체는 불변이지만, 내부 필드가 리스트면 리스트는 여전히 변경 가능합니다.
  • 그래서 "절대 안 바뀐다"고 믿고 공유하면 사이드이펙트가 발생합니다.

해결:

>>> from collections import namedtuple
>>> SafeBag = namedtuple("SafeBag", ["owner", "items"])
>>> b = SafeBag("kim", tuple(["pen"]))
>>> b.items
('pen',)

실수 3) 스키마 변경 시 생성자 인자 순서를 깨뜨림

  • 증상: 값이 밀려 들어가는데 타입 에러 없이 조용히 잘못 저장됨
  • 원인: 위치 인자 기반 생성만 고집해서 필드 추가/순서 변경 리스크를 키움
  • 해결: 가급적 키워드 인자로 생성 (User(id=..., name=...))하거나 변환 팩토리 함수를 둬서 순서를 한 곳에서 관리

실수 4) 외부 경계에서 namedtuple을 그대로 직렬화하려다 포맷 혼선

  • 증상: JSON 직렬화 시 리스트처럼 보이거나 도구마다 표현이 달라짐
  • 원인: 소비자(API/프런트/로그 파서)가 기대하는 포맷(dict)과 내부 표현(tuple)을 구분하지 않음
  • 해결: 경계 지점에서 _asdict() 또는 명시적 DTO 변환 후 전달

실무 패턴

  • 입력 검증 규칙

    • 원천 데이터(CSV/JSON)를 바로 namedtuple로 바꾸기 전에 길이/필수 필드/타입을 먼저 검증합니다.
    • parse_row -> validate -> to_namedtuple 3단계를 함수로 분리하면 장애 분석이 빨라집니다.
  • 로그/예외 처리 규칙

    • 파싱 실패 시 원본 행 전체를 그대로 로그에 남기지 말고, 민감값 마스킹 후 핵심 필드만 남깁니다.
    • 어떤 필드에서 실패했는지(field=amount, value='N/A')를 구조적으로 기록합니다.
  • 재사용 함수/구조화 팁

    • from_dict, to_public_dict 같은 변환 함수를 타입 옆에 두면 데이터 경계가 명확해집니다.
    • namedtuple은 저장/전달 모델로 쓰고, 비즈니스 규칙이 커지면 dataclass로 승격하는 기준을 팀 규칙으로 정해 둡니다.
  • 성능/메모리 체크 포인트

    • 대량 레코드를 다룰 때 일반 클래스보다 가볍지만, 변환이 잦으면 _asdict() 비용이 누적됩니다.
    • 따라서 내부 계산 구간에서는 namedtuple 상태를 유지하고, 외부 I/O 직전에만 dict 변환하는 전략이 효율적입니다.
>>> from collections import namedtuple
>>>
>>> Trade = namedtuple("Trade", ["symbol", "qty", "price", "side"])
>>>
>>> def parse_trade(row: dict) -> Trade:
...     symbol = row["symbol"].strip().upper()
...     qty = int(row["qty"])
...     price = float(row["price"])
...     side = row["side"].strip().lower()
...     if side not in {"buy", "sell"}:
...         raise ValueError(f"invalid side: {side}")
...     return Trade(symbol=symbol, qty=qty, price=price, side=side)
...
>>> def pnl(trades: list[Trade]) -> float:
...     # 매우 단순화된 예시: sell은 +, buy는 -
...     acc = 0.0
...     for t in trades:
...         sign = 1 if t.side == "sell" else -1
...         acc += sign * t.qty * t.price
...     return round(acc, 2)
...
>>> rows = [
...   {"symbol": " aapl ", "qty": "10", "price": "180.5", "side": "buy"},
...   {"symbol": "AAPL", "qty": "4", "price": "182.0", "side": "sell"},
... ]
>>> trades = [parse_trade(r) for r in rows]
>>> trades
[Trade(symbol='AAPL', qty=10, price=180.5, side='buy'), Trade(symbol='AAPL', qty=4, price=182.0, side='sell')]
>>> pnl(trades)
-1077.0

위 패턴에서 핵심은 “입력 정규화”와 “도메인 계산”을 분리한 점입니다. Trade 타입이 생기면 이후 함수 시그니처가 분명해지고, 리뷰어는 dict 키를 머릿속으로 추적하지 않아도 됩니다. 작은 구조화 하나가 장기 유지보수 비용을 크게 낮춥니다. 특히 자동화 스크립트가 6개월 이상 운영되는 팀에서는 이런 차이가 장애 빈도로 바로 드러납니다.

오늘의 결론

한 줄 요약: namedtuple은 딕셔너리의 자유로움과 클래스의 가독성 사이를 메우는, 가볍고 실용적인 데이터 모델입니다.

기억할 것:

  • 고정 구조 레코드에는 namedtuple이 가장 빠른 개선책이 될 때가 많습니다.
  • 불변 객체여도 내부 가변 필드는 변할 수 있으니 설계 시 타입 선택이 중요합니다.
  • 외부 경계에서는 _asdict()로 포맷 계약을 명시해 혼선을 줄이세요.

연습문제

  1. Product(sku, name, price, stock) namedtuple을 만들고, 제품 목록에서 stock > 0인 항목의 평균 가격을 구하세요.
  2. 주문 레코드 Order(order_id, user_id, amount, status) 리스트에서 status == "paid"만 골라 사용자별 총액 dict를 만드세요.
  3. 로그 레코드 Log(ts, level, message)를 만들고, level이 없는 입력은 기본값 "INFO"를 사용하도록 생성한 뒤 _asdict()로 직렬화 리스트를 출력하세요.

이전 강의 정답

  1. defaults, env, cli를 우선순위 cli > env > defaults로 적용하는 make_config
>>> from collections import ChainMap
>>> def make_config(defaults, env, cli):
...     return ChainMap(cli, env, defaults)
...
>>> cfg = make_config(
...     {"host": "127.0.0.1", "port": 8000},
...     {"port": 9000},
...     {"host": "0.0.0.0"},
... )
>>> cfg["host"], cfg["port"]
('0.0.0.0', 9000)
  1. new_child로 테스트 환경에서 timeout만 임시 1로 오버라이드
>>> from collections import ChainMap
>>> base = ChainMap({}, {"timeout": 5, "retries": 2})
>>> test_cfg = base.new_child({"timeout": 1})
>>> base["timeout"], test_cfg["timeout"], test_cfg["retries"]
(5, 1, 2)
  1. 레이어 순서 오류 비교 (port 값 확인)
>>> from collections import ChainMap
>>> defaults = {"port": 8000}
>>> env = {"port": 9000}
>>> cli = {"port": 7000}
>>> wrong = ChainMap(defaults, env, cli)
>>> right = ChainMap(cli, env, defaults)
>>> wrong["port"], right["port"]
(8000, 7000)

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: collections.namedtuple (표준 라이브러리)
  • 실행 방법: 터미널에서 python 또는 ipython 실행 후 예제 코드를 순서대로 입력
  • 재현 체크:
    • 속성 접근(obj.field)과 인덱스 접근(obj[i])이 동일 데이터를 가리키는지 확인
    • _replace가 원본을 바꾸지 않고 새 객체를 반환하는지 확인
    • _asdict() 결과가 직렬화 파이프라인(JSON 변환 등)에서 기대 포맷과 일치하는지 확인