Devin.KR
로그인

파이썬 표준 라이브러리 - collections itertools datetime pathlib (파이썬 중급 13단원)

개발자 조회 1

이 단원에서 배우는 것

11단원에서 주문 로그를 제너레이터로 한 줄씩 흘려보냈고, 12단원에서 그 처리 함수에 시간 측정과 재시도를 붙였다. 남은 것은 실제 집계다. SKU별 합계, 시간대별 주문 건수, 날짜 범위 필터, 로그 파일 찾기. 이 네 가지는 전부 손으로 짤 수 있지만, 표준 라이브러리에 이미 정답이 있고 대개 더 빠르고 덜 틀린다. 이번 단원은 그중 실무에서 손이 가장 많이 가는 네 모듈을 고른다.

  • collectionsitertools로 집계 코드를 짧고 빠르게 쓸 수 있다
  • 시간대를 아는 datetime과 모르는 datetime의 차이를 알고, 저장과 표시를 분리할 수 있다
  • pathlib로 문자열 경로 조작을 대체할 수 있다

왜 필요한가

SKU별 합계를 내는 코드를 직접 짜면 대개 이렇게 된다.

totals = {}
for line in lines:
    if line.sku not in totals:
        totals[line.sku] = 0
    totals[line.sku] += line.amount

돌아간다. 그런데 이 네 줄에 세 가지가 숨어 있다. 매 항목마다 in 검사로 해시를 두 번 계산하고, "없으면 0으로 시작한다"는 규칙이 코드 사이에 묻혀 있고, 옆 파일에서 같은 것을 totals.get(sku, 0) 방식으로 또 짠다. 표준 라이브러리를 아는 사람은 이 자리에서 Counter 한 줄을 쓴다.

더 중요한 이유는 틀리기 쉬운 문제를 이미 풀어 놓았다는 것이다. 날짜 계산과 시간대는 직접 짜면 거의 확실히 틀린다. 서머타임, 윤년, 자정 경계, 서버와 사용자의 시간대 차이. 이런 것들은 표준 모듈이 이미 겪은 문제다. 이번 단원의 목표는 "이 상황에서는 저 도구가 있다"는 목록을 머리에 넣는 것이다.

문법과 예제

네 모듈 모두 함수가 많아서 전부 외울 필요는 없다. itertoolspathlib 문서는 목차만 한 번 훑어 두고, 필요할 때 찾아 쓰는 방식이 현실적이다.

아래 예제들은 이 데이터를 공통으로 쓴다.

from dataclasses import dataclass
from datetime import datetime, timezone


@dataclass(frozen=True)
class OrderLine:
    order_id: str
    sku: str
    qty: int
    unit_price: int
    ordered_at: datetime

    @property
    def amount(self) -> int:
        return self.qty * self.unit_price


def sample():
    def dt(h):
        return datetime(2026, 8, 25, h, 0, tzinfo=timezone.utc)
    return [
        OrderLine("ORD-1", "BOOK-01", 2, 12000, dt(9)),
        OrderLine("ORD-1", "PEN-07", 5, 3000, dt(9)),
        OrderLine("ORD-2", "BOOK-01", 1, 12000, dt(14)),
        OrderLine("ORD-3", "BOOK-02", 3, 25000, dt(14)),
        OrderLine("ORD-4", "PEN-07", 1, 3000, dt(21)),
    ]

collections.Counter: 세는 일 전용

from collections import Counter

lines = sample()

# 항목 등장 횟수
sku_count = Counter(line.sku for line in lines)
print(sku_count)                 # Counter({'BOOK-01': 2, 'PEN-07': 2, 'BOOK-02': 1})
print(sku_count.most_common(2))  # [('BOOK-01', 2), ('PEN-07', 2)]

# 세는 것뿐 아니라 더하는 데도 쓴다
amount_by_sku = Counter()
for line in lines:
    amount_by_sku[line.sku] += line.amount
print(amount_by_sku.most_common())
# [('BOOK-02', 75000), ('BOOK-01', 36000), ('PEN-07', 18000)]

Counter는 없는 키를 0으로 읽어 주므로 초기화 코드가 필요 없다. 다만 KeyError가 절대 안 난다는 뜻이기도 하다. 오타 난 키를 조회해도 0이 나오므로, 존재 여부를 확인해야 하는 자리에서는 오히려 위험하다. 두 카운터를 +로 합칠 수 있고, -로 뺄 수 있는데 뺄셈은 0 이하를 결과에서 제거한다. 재고 차감 같은 데 쓰면 음수 재고를 놓친다. 음수까지 남기려면 subtract()를 쓴다.

collections.defaultdict: 값이 리스트나 집합일 때

from collections import defaultdict

lines_by_order = defaultdict(list)
for line in sample():
    lines_by_order[line.order_id].append(line.sku)

print(dict(lines_by_order))
# {'ORD-1': ['BOOK-01', 'PEN-07'], 'ORD-2': ['BOOK-01'], 'ORD-3': ['BOOK-02'], 'ORD-4': ['PEN-07']}

defaultdict(list)의 인자는 호출 가능한 것이다. listlist()가 아니다. 없는 키에 접근할 때마다 그것을 호출해 기본값을 만든다. 11단원에서 딕셔너리 컴프리헨션이 같은 키를 덮어쓴다고 했는데, 그때 필요한 것이 바로 이 도구다.

collections.deque: 양쪽 끝에서 넣고 빼기

from collections import deque

recent = deque(maxlen=3)        # 최근 3건만 유지
for line in sample():
    recent.append(line.order_id)
print(list(recent))             # ['ORD-2', 'ORD-3', 'ORD-4']

리스트의 pop(0)insert(0, x)는 뒤의 원소를 전부 한 칸씩 옮기므로 원소 수에 비례해 느려진다. deque는 양쪽 끝 연산이 원소 수와 무관하게 일정하다. 큐나 슬라이딩 윈도가 필요하면 리스트 대신 이걸 쓴다. maxlen을 주면 넘치는 쪽이 자동으로 밀려 나간다.

itertools: 반복을 조합하는 도구

import itertools

lines = sample()

# groupby: 연속된 같은 값끼리 묶는다. 반드시 먼저 정렬해야 한다
by_sku = sorted(lines, key=lambda x: x.sku)
for sku, group in itertools.groupby(by_sku, key=lambda x: x.sku):
    group = list(group)
    print(sku, sum(g.amount for g in group))
# BOOK-01 36000
# BOOK-02 75000
# PEN-07 18000

# islice: 제너레이터에서 앞의 N개만
first_two = list(itertools.islice((l.sku for l in lines), 2))
print(first_two)          # ['BOOK-01', 'PEN-07']

# chain.from_iterable: 중첩 리스트 펼치기
nested = [["BOOK-01", "PEN-07"], ["BOOK-02"]]
print(list(itertools.chain.from_iterable(nested)))
# ['BOOK-01', 'PEN-07', 'BOOK-02']

# accumulate: 누적합
print(list(itertools.accumulate([12000, 3000, 25000])))
# [12000, 15000, 40000]

groupby가 이 목록에서 사고를 가장 많이 낸다. SQL의 GROUP BY와 이름이 같지만 동작이 다르다. 연속된 구간만 묶는다. 정렬하지 않고 넣으면 같은 SKU가 여러 그룹으로 쪼개지고, 에러 없이 틀린 집계가 나온다. 게다가 groupby가 주는 그룹은 제너레이터라서, 다음 그룹으로 넘어가는 순간 이전 그룹은 소비 불가가 된다. 위에서 list(group)으로 바로 받은 이유가 그것이다. 11단원의 "제너레이터는 한 번만"이 여기서 다시 나온다.

모든 항목을 정확히 N개씩 잘라야 한다면 itertools.batched가 편한데, 이 함수는 파이썬 3.12에 추가됐다. 3.11 환경이라면 islice로 직접 만든다.

import itertools

def batched(iterable, n):
    """3.12 미만에서 itertools.batched 대용."""
    it = iter(iterable)
    while batch := tuple(itertools.islice(it, n)):
        yield batch


print(list(batched(range(7), 3)))   # [(0, 1, 2), (3, 4, 5), (6,)]

datetime: aware 와 naive

파이썬의 datetime에는 두 종류가 있다. 시간대 정보를 가진 것(aware)과 없는 것(naive)이다. 섞으면 예외가 난다.

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo          # 파이썬 3.9+

naive = datetime(2026, 8, 25, 9, 0)                      # 어디의 9시인지 모른다
aware = datetime(2026, 8, 25, 9, 0, tzinfo=timezone.utc)  # UTC 9시

print(aware - aware)      # 0:00:00
# print(aware - naive)    # TypeError: can't subtract offset-naive and offset-aware datetimes

# 지금 시각은 반드시 시간대를 붙여서 얻는다
now = datetime.now(timezone.utc)

# 저장은 UTC, 표시는 사용자 시간대
seoul = aware.astimezone(ZoneInfo("Asia/Seoul"))
print(seoul.isoformat())   # 2026-08-25T18:00:00+09:00

# 문자열 변환
print(aware.strftime("%Y-%m-%d %H:%M"))                     # 2026-08-25 09:00
print(datetime.fromisoformat("2026-08-25T09:00:00+00:00"))  # 2026-08-25 09:00:00+00:00

# 날짜 계산은 timedelta 로
print((aware + timedelta(days=7)).date())   # 2026-09-01

실무 규칙은 단순하다. 저장과 계산은 UTC aware로, 사람에게 보여줄 때만 지역 시간대로 바꾼다. naive를 DB에 넣으면 서버 시간대가 바뀌는 순간 과거 데이터의 의미가 통째로 흔들린다. datetime.utcnow()는 UTC 시각을 주면서 tzinfo는 안 붙이는 naive를 돌려주므로 특히 위험하다. 파이썬 3.12부터 공식적으로 사용이 권장되지 않는다. 3.11에서도 쓰지 않는 편이 낫다. datetime.now(timezone.utc)를 쓴다.

pathlib: 경로를 문자열로 다루지 않기

from pathlib import Path

base = Path("logs")
base.mkdir(parents=True, exist_ok=True)     # 이미 있어도 에러 안 남

path = base / "2026-08" / "orders.csv"      # / 연산자로 결합. 구분자 걱정이 없다
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("ORD-1,BOOK-01,2,12000\n", encoding="utf-8")

print(path.exists())      # True
print(path.name)          # orders.csv
print(path.stem)          # orders
print(path.suffix)        # .csv
print(path.parent)        # logs/2026-08
print(path.with_suffix(".json"))   # logs/2026-08/orders.json

print(path.read_text(encoding="utf-8").strip())   # ORD-1,BOOK-01,2,12000

# 하위 폴더까지 훑기
for p in sorted(base.rglob("*.csv")):
    print(p, p.stat().st_size)

os.path.join(a, b)와 문자열 +를 섞어 쓰다 보면 구분자가 두 번 들어가거나 빠지는 버그가 생긴다. Path는 그 문제를 없앤다. rglob은 제너레이터를 돌려주므로 파일이 수만 개여도 메모리를 잡아먹지 않는다. 여기서도 11단원의 성질이 그대로 적용된다.

모아서: 시간대별 매출 집계

from collections import Counter
from zoneinfo import ZoneInfo

def revenue_by_hour(lines, tz="Asia/Seoul"):
    zone = ZoneInfo(tz)
    result = Counter()
    for line in lines:
        hour = line.ordered_at.astimezone(zone).hour
        result[hour] += line.amount
    return dict(sorted(result.items()))


print(revenue_by_hour(sample()))
# {6: 3000, 18: 39000, 23: 87000}

UTC 9시가 한국 시간 18시로 바뀌었다. 집계 기준 시간대를 인자로 뺀 것이 핵심이다. 이 값을 코드 안에 박아 두면 나중에 다른 지역 매출을 볼 때 함수를 복사하게 된다.

실무에서 자주 틀리는 것

1. groupby 전에 정렬하지 않는다

import itertools

lines = sample()   # SKU 순으로 정렬돼 있지 않다
for sku, group in itertools.groupby(lines, key=lambda x: x.sku):
    print(sku, len(list(group)))
# BOOK-01 1
# PEN-07 1
# BOOK-01 1     <- 같은 SKU 가 또 나온다
# BOOK-02 1
# PEN-07 1

예외가 안 나고 그럴듯한 결과가 나오는 것이 문제다. 딕셔너리에 담으면 나중 그룹이 앞 그룹을 덮어써서 매출이 줄어든 채 보고된다. groupby를 쓰기 전에는 같은 key 함수로 정렬한다. 단순 집계라면 Counterdefaultdict가 정렬 없이 되므로 더 안전하다.

2. defaultdict 를 조회에 쓴다

from collections import defaultdict

prices = defaultdict(int)
prices["BOOK-01"] = 12000

print(prices["BOOK-99"])   # 0    <- 존재하지 않는 SKU 인데 에러가 안 난다
print(len(prices))         # 2    <- 조회만 했는데 키가 생겼다

단순히 0이 나오는 게 아니라 조회하는 행위가 키를 만든다. 반복 도중에 조회하면 딕셔너리 크기가 바뀌어 RuntimeError가 나기도 한다. 없는 키를 안전하게 읽고 싶을 뿐이라면 prices.get("BOOK-99", 0)을 쓴다. defaultdict쌓는 용도일 때만 쓴다.

3. naive 와 aware 를 섞어 저장한다

from datetime import datetime, timezone

created = datetime.now()                    # naive. 서버 시간대에 따라 값이 달라진다
expires = datetime.now(timezone.utc)        # aware

# print(expires - created)   # TypeError

더 나쁜 경우는 두 값을 각각 다른 코드 경로에서 만들어 DB에 넣고, 나중에 정렬했을 때 9시간 차이로 순서가 뒤집히는 것이다. 이때는 에러조차 안 난다. 프로젝트 전체에서 naive 를 아예 만들지 않는다는 규칙을 세우는 편이 개별 주의보다 확실하다.

4. 반복문 안에서 Path 를 문자열로 되돌린다

from pathlib import Path

base = Path("logs")
for name in ["a.csv", "b.csv"]:
    path = str(base) + "/" + name        # 윈도에서 구분자가 섞인다

base / name이면 끝난다. Path는 대부분의 표준 라이브러리 함수에 그대로 넘길 수 있으므로 str()로 되돌릴 일이 거의 없다. 문자열로 만드는 순간 .parent, .suffix 같은 기능도 같이 잃는다.

스스로 확인하기

  1. 주문 항목 리스트에서 SKU별 매출 합계를 구하는 코드를 Counter로 세 줄 안에 써라.
  2. itertools.groupby로 SKU별 집계를 냈는데 어떤 SKU의 매출이 실제보다 적게 나왔다. 예외는 나지 않았다. 원인은 무엇인가?
  3. 주문 시각을 datetime.now()로 저장해 온 서비스가 서버를 UTC 시간대 장비로 옮겼다. 어떤 문제가 생기며, 앞으로 어떻게 저장해야 하는가?

정답

  1. totals = Counter()
    for line in lines:
        totals[line.sku] += line.amount
    
    없는 키가 자동으로 0으로 시작하므로 초기화 분기가 필요 없다.
  2. groupby에 넣기 전에 같은 key로 정렬하지 않았다. groupby는 연속 구간만 묶으므로 같은 SKU가 여러 그룹으로 쪼개지고, 결과를 딕셔너리에 담는 과정에서 앞 그룹이 뒤 그룹에 덮여 사라진다. sorted(lines, key=...)를 먼저 하거나 Counter로 바꾼다.
  3. 이전 데이터는 서버의 옛 지역 시간(한국이면 KST)으로 저장돼 있고 새 데이터는 UTC로 저장되므로, 같은 컬럼에 기준이 다른 값이 섞인다. 시간대 정보가 없어 나중에 어느 것이 어느 기준인지 구분할 방법도 없다. 앞으로는 datetime.now(timezone.utc)로 aware 값을 만들어 UTC로 저장하고, 표시할 때만 astimezone(ZoneInfo("Asia/Seoul"))로 변환한다.