[파이썬 100강] 71강. OrderedDict로 순서가 중요한 매핑 로직 안정화하기

[파이썬 100강] 71강. OrderedDict로 순서가 중요한 매핑 로직 안정화하기

딕셔너리는 Python 3.7+부터 입력 순서를 유지하지만, 순서를 "조작"하는 기능까지 자동으로 주지는 않습니다. 이번 강의에서는 collections.OrderedDict를 이용해 LRU 같은 순서 기반 로직을 안전하게 구현하는 방법을 바로 실습합니다.


핵심 개념

  • OrderedDict는 키-값 저장 + 순서 유지 + 순서 조작(move_to_end, popitem)을 함께 제공하는 매핑 타입입니다.
  • 일반 dict도 순서를 기억하지만, "가장 최근 사용" 같은 순서 정책을 구현할 때는 OrderedDict API가 훨씬 명확합니다.
  • 캐시, 최근 기록 목록, 우선 처리 큐처럼 순서 자체가 비즈니스 규칙인 경우에 특히 유용합니다.

실무에서 순서가 중요한 데이터는 꽤 많습니다. 예를 들어 "최근 조회한 상품 20개", "가장 오래된 작업부터 제거", "최근 접근 순으로 캐시 정리" 같은 정책은 값 자체보다 "순서 변화"가 핵심입니다. 이걸 일반 dict로 억지 구현하면 삭제 후 재삽입 같은 암묵적 코드가 늘어나고, 팀원이 읽을 때 의도가 잘 드러나지 않습니다. 반대로 OrderedDict는 "맨 앞으로", "맨 뒤로", "앞/뒤에서 제거"가 메서드 이름으로 직접 표현됩니다. 코드가 길어지는 대신이 아니라, 규칙이 코드에 문장처럼 남는 장점이 생깁니다. 운영 중 장애가 났을 때도 "어떤 항목이 왜 밀려났는지"를 추적하기 쉬워집니다.

기본 사용

예제 1) 생성과 기본 순서 확인

>>> from collections import OrderedDict
>>> od = OrderedDict()
>>> od["user"] = "kim"
>>> od["role"] = "admin"
>>> od["active"] = True
>>> list(od.items())
[("user", "kim"), ("role", "admin"), ("active", True)]

해설:

  • 입력한 순서대로 항목이 유지됩니다.
  • 일반 dict와 비슷하게 쓰되, 이후에 순서를 조작하는 메서드를 바로 사용할 수 있습니다.

예제 2) move_to_end로 최근 사용 항목 갱신하기

>>> from collections import OrderedDict
>>> cache = OrderedDict([("A", 100), ("B", 200), ("C", 300)])
>>> cache.move_to_end("A")  # A를 최근 사용으로 간주해서 맨 뒤로 이동
>>> list(cache.keys())
['B', 'C', 'A']
>>> cache.move_to_end("C", last=False)  # C를 맨 앞으로 이동
>>> list(cache.keys())
['C', 'B', 'A']

해설:

  • last=True(기본값)이면 뒤로, last=False면 앞으로 이동합니다.
  • "최근 사용" / "최우선 처리" 규칙을 매우 직관적으로 표현할 수 있습니다.

예제 3) popitem으로 앞/뒤 제거 정책 구현

>>> from collections import OrderedDict
>>> q = OrderedDict([("job1", "pending"), ("job2", "pending"), ("job3", "pending")])
>>> q.popitem(last=False)  # 가장 앞(오래된 항목) 제거
('job1', 'pending')
>>> q.popitem()  # 가장 뒤(최근 항목) 제거
('job3', 'pending')
>>> list(q.items())
[('job2', 'pending')]

해설:

  • FIFO, LIFO 정책을 코드 한 줄로 분명하게 드러낼 수 있습니다.
  • 운영 스크립트에서 "어떤 기준으로 버렸는지"가 명확해 리뷰와 디버깅이 쉬워집니다.

예제 4) 간단한 LRU 캐시 만들기

>>> from collections import OrderedDict
>>>
>>> class LRUCache:
...     def __init__(self, capacity: int):
...         self.capacity = capacity
...         self.data = OrderedDict()
...
...     def get(self, key):
...         if key not in self.data:
...             return None
...         self.data.move_to_end(key)  # 접근한 키를 최신화
...         return self.data[key]
...
...     def put(self, key, value):
...         if key in self.data:
...             self.data[key] = value
...             self.data.move_to_end(key)
...             return
...         self.data[key] = value
...         if len(self.data) > self.capacity:
...             self.data.popitem(last=False)  # 가장 오래된 항목 제거
...
>>> c = LRUCache(2)
>>> c.put("u1", "Kim")
>>> c.put("u2", "Lee")
>>> c.get("u1")
'Kim'
>>> c.put("u3", "Park")
>>> list(c.data.items())
[('u1', 'Kim'), ('u3', 'Park')]

해설:

  • u1get했기 때문에 최신 항목이 되었고, u2가 먼저 제거됩니다.
  • 캐시 축출(eviction) 규칙을 간단하고 예측 가능하게 구현할 수 있습니다.

자주 하는 실수

실수 1) dictOrderedDict를 완전히 동일하다고 가정

>>> from collections import OrderedDict
>>> d = {"a": 1, "b": 2}
>>> od = OrderedDict([("a", 1), ("b", 2)])
>>> d == od
True
>>> od == OrderedDict([("b", 2), ("a", 1)])
False

원인:

  • 일반 dict 비교는 키-값 집합 중심으로 보지만, OrderedDict끼리 비교는 순서까지 고려합니다.
  • 테스트 코드에서 타입 혼합 비교를 무심코 쓰면 의도와 다른 결과를 놓치기 쉽습니다.

해결:

>>> left = OrderedDict([("a", 1), ("b", 2)])
>>> right = OrderedDict([("b", 2), ("a", 1)])
>>> list(left.items()) == list(right.items())
False
  • 순서가 중요하면 비교 기준을 list(items)로 명시하세요.
  • "값만 같으면 된다"와 "순서까지 같아야 한다"를 테스트에서 분리하는 게 안전합니다.

실수 2) 접근(get) 시 순서 갱신을 빼먹어 LRU가 깨짐

>>> from collections import OrderedDict
>>> data = OrderedDict([("A", 1), ("B", 2)])
>>> _ = data["A"]  # 조회만 했다고 최신화되지 않음
>>> data["C"] = 3
>>> data.popitem(last=False)
('A', 1)

원인:

  • "조회하면 최근 사용으로 바뀐다"고 착각하지만, OrderedDict는 자동 갱신하지 않습니다.
  • move_to_end를 직접 호출해야 정책이 반영됩니다.

해결:

>>> from collections import OrderedDict
>>> data = OrderedDict([("A", 1), ("B", 2)])
>>> _ = data["A"]
>>> data.move_to_end("A")
>>> data["C"] = 3
>>> data.popitem(last=False)
('B', 2)
  • 조회/갱신/삽입 흐름마다 순서 갱신 규칙을 코드로 명시하세요.

실수 3) 용량 초과 시 제거 기준을 뒤집어 버림

  • 증상: 새로 들어온 데이터가 바로 제거되거나, 오래된 데이터가 남아 캐시 효율이 급락함
  • 원인: popitem(last=False)(앞 제거)와 popitem()(뒤 제거) 의미를 혼동
  • 해결: "오래된 것 제거"라면 항상 last=False를 쓰고, 테스트 케이스에 축출 대상 키를 명시적으로 검증

실수 4) 순서 불변 요구사항인데 중간에 일반 dict로 변환

  • 증상: 직렬화/역직렬화 이후 순서 검증이 흔들리고 재현이 어려움
  • 원인: 파이프라인 중간에서 dict(ordered) 변환 후 다시 매핑 사용
  • 해결: 경계(I/O) 전까지는 OrderedDict 유지, 필요 시 list(items)를 저장해 순서를 명시적으로 보존

실무 패턴

  • 입력 검증 규칙

    • 키 존재 여부 + 키 순서 요구사항을 함께 검증합니다. (예: 헤더 순서가 계약인 CSV)
    • 순서가 계약인 API 응답은 list(od.keys()) 기준으로 스키마 검증을 추가합니다.
  • 로그/예외 처리 규칙

    • 축출 이벤트 발생 시 evicted_key, size_before, size_after를 구조 로그로 남깁니다.
    • 순서 관련 버그는 값 비교만으로는 안 잡히므로, 디버그 로그에 키 순서를 같이 기록합니다.
  • 재사용 함수/구조화 팁

    • touch(key)(조회 후 최신화), evict_oldest() 같은 래퍼 함수를 만들면 팀 코드가 일관됩니다.
    • 비즈니스 로직에서 OrderedDict 메서드를 직접 흩뿌리기보다, 작은 클래스로 캡슐화하면 정책 변경이 쉬워집니다.
  • 성능/메모리 체크 포인트

    • 단순 저장/조회만 한다면 일반 dict가 더 자연스러울 수 있습니다.
    • 하지만 순서 조작이 빈번하면 OrderedDict가 의도 표현과 유지보수 비용 측면에서 이득입니다.
>>> from collections import OrderedDict
>>>
>>> class RecentViews:
...     def __init__(self, limit=5):
...         self.limit = limit
...         self._data = OrderedDict()
...
...     def add(self, user_id: str, item_id: str):
...         key = (user_id, item_id)
...         if key in self._data:
...             self._data.move_to_end(key)
...         else:
...             self._data[key] = {"user_id": user_id, "item_id": item_id}
...         if len(self._data) > self.limit:
...             evicted = self._data.popitem(last=False)
...             return evicted[0]
...         return None
...
...     def list_for_user(self, user_id: str):
...         return [v["item_id"] for (u, _), v in self._data.items() if u == user_id]
...
>>> rv = RecentViews(limit=3)
>>> rv.add("u1", "p10")
>>> rv.add("u1", "p11")
>>> rv.add("u2", "p20")
>>> rv.add("u1", "p10")  # 재조회 -> 최신화
>>> rv.add("u3", "p30")  # 용량 초과 -> 가장 오래된 항목 축출
('u1', 'p11')
>>> rv.list_for_user("u1")
['p10']

이 패턴의 핵심은 "정책 이름"을 코드에 드러내는 것입니다. 예를 들어 단순 dict로 같은 기능을 구현하면 del data[key]; data[key]=...처럼 의도를 추론해야 하는 코드가 생깁니다. 반면 move_to_endpopitem(last=False)를 쓰면 팀원이 처음 봐도 "최근화"와 "오래된 항목 제거"를 즉시 이해합니다. 장기 운영에서는 이런 명확함이 장애 대응 속도를 크게 올려 줍니다.

오늘의 결론

한 줄 요약: 순서가 단순 표시가 아니라 규칙이라면, OrderedDict는 유지보수 비용을 줄이는 가장 현실적인 선택입니다.

기억할 것:

  • 순서 조작이 필요하면 move_to_end, popitem을 적극 활용하세요.
  • LRU/FIFO 같은 정책은 "암묵적 관례"가 아니라 메서드 호출로 명시해야 안전합니다.
  • 테스트에서 값 비교와 순서 비교를 분리하면 순서 버그를 훨씬 빨리 잡을 수 있습니다.

연습문제

  1. OrderedDict로 크기 3인 LRU 캐시를 구현하고, 키 접근 시 move_to_end가 반드시 호출되도록 작성하세요.
  2. 작업 큐를 OrderedDict로 만들고, 오래된 작업부터 꺼내 처리하는 dequeue_oldest() 함수를 구현하세요.
  3. 사용자별 최근 검색어를 최대 5개만 유지하는 구조를 만들고, 동일 검색어 재입력 시 최신 순서로 갱신되게 구현하세요.

이전 강의 정답

  1. Product(sku, name, price, stock)에서 재고 있는 항목 평균 가격 구하기
>>> from collections import namedtuple
>>> Product = namedtuple("Product", ["sku", "name", "price", "stock"])
>>> items = [
...     Product("A1", "Keyboard", 50000, 3),
...     Product("A2", "Mouse", 25000, 0),
...     Product("A3", "Monitor", 220000, 2),
... ]
>>> in_stock = [p.price for p in items if p.stock > 0]
>>> round(sum(in_stock) / len(in_stock), 2)
135000.0
  1. paid 주문만 사용자별 총액 집계하기
>>> from collections import namedtuple, defaultdict
>>> Order = namedtuple("Order", ["order_id", "user_id", "amount", "status"])
>>> orders = [
...     Order("o1", "u1", 12000, "paid"),
...     Order("o2", "u2", 8000, "pending"),
...     Order("o3", "u1", 5000, "paid"),
... ]
>>> totals = defaultdict(int)
>>> for o in orders:
...     if o.status == "paid":
...         totals[o.user_id] += o.amount
...
>>> dict(totals)
{'u1': 17000}
  1. Log(ts, level, message) 기본 레벨 적용 + _asdict() 직렬화
>>> from collections import namedtuple
>>> Log = namedtuple("Log", ["ts", "level", "message"], defaults=["INFO", ""])
>>> logs = [
...     Log("2026-02-17T10:00:00"),
...     Log("2026-02-17T10:01:00", "ERROR", "connection timeout"),
... ]
>>> [x._asdict() for x in logs]
[{'ts': '2026-02-17T10:00:00', 'level': 'INFO', 'message': ''}, {'ts': '2026-02-17T10:01:00', 'level': 'ERROR', 'message': 'connection timeout'}]

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: collections.OrderedDict (표준 라이브러리)
  • 실행 방법: 터미널에서 python 또는 ipython 실행 후 예제를 순서대로 입력
  • 재현 체크:
    • move_to_end(key) 호출 전/후 키 순서가 예상대로 바뀌는지 확인
    • 용량 초과 시 popitem(last=False)로 가장 오래된 키가 제거되는지 확인
    • 순서 비교 테스트에서 list(od.items()) 기준 검증이 통과하는지 확인