[파이썬 100강] 71강. OrderedDict로 순서가 중요한 매핑 로직 안정화하기
딕셔너리는 Python 3.7+부터 입력 순서를 유지하지만, 순서를 "조작"하는 기능까지 자동으로 주지는 않습니다. 이번 강의에서는 collections.OrderedDict를 이용해 LRU 같은 순서 기반 로직을 안전하게 구현하는 방법을 바로 실습합니다.
핵심 개념
OrderedDict는 키-값 저장 + 순서 유지 + 순서 조작(move_to_end,popitem)을 함께 제공하는 매핑 타입입니다.- 일반
dict도 순서를 기억하지만, "가장 최근 사용" 같은 순서 정책을 구현할 때는OrderedDictAPI가 훨씬 명확합니다. - 캐시, 최근 기록 목록, 우선 처리 큐처럼 순서 자체가 비즈니스 규칙인 경우에 특히 유용합니다.
실무에서 순서가 중요한 데이터는 꽤 많습니다. 예를 들어 "최근 조회한 상품 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')]
해설:
u1을get했기 때문에 최신 항목이 되었고,u2가 먼저 제거됩니다.- 캐시 축출(eviction) 규칙을 간단하고 예측 가능하게 구현할 수 있습니다.
자주 하는 실수
실수 1) dict와 OrderedDict를 완전히 동일하다고 가정
>>> 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_end와 popitem(last=False)를 쓰면 팀원이 처음 봐도 "최근화"와 "오래된 항목 제거"를 즉시 이해합니다. 장기 운영에서는 이런 명확함이 장애 대응 속도를 크게 올려 줍니다.
오늘의 결론
한 줄 요약: 순서가 단순 표시가 아니라 규칙이라면, OrderedDict는 유지보수 비용을 줄이는 가장 현실적인 선택입니다.
기억할 것:
- 순서 조작이 필요하면
move_to_end,popitem을 적극 활용하세요. - LRU/FIFO 같은 정책은 "암묵적 관례"가 아니라 메서드 호출로 명시해야 안전합니다.
- 테스트에서 값 비교와 순서 비교를 분리하면 순서 버그를 훨씬 빨리 잡을 수 있습니다.
연습문제
OrderedDict로 크기 3인 LRU 캐시를 구현하고, 키 접근 시move_to_end가 반드시 호출되도록 작성하세요.- 작업 큐를
OrderedDict로 만들고, 오래된 작업부터 꺼내 처리하는dequeue_oldest()함수를 구현하세요. - 사용자별 최근 검색어를 최대 5개만 유지하는 구조를 만들고, 동일 검색어 재입력 시 최신 순서로 갱신되게 구현하세요.
이전 강의 정답
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
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}
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'}]
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 사용 모듈:
collections.OrderedDict(표준 라이브러리) - 실행 방법: 터미널에서
python또는ipython실행 후 예제를 순서대로 입력 - 재현 체크:
move_to_end(key)호출 전/후 키 순서가 예상대로 바뀌는지 확인- 용량 초과 시
popitem(last=False)로 가장 오래된 키가 제거되는지 확인 - 순서 비교 테스트에서
list(od.items())기준 검증이 통과하는지 확인