본문으로 건너뛰기
개발 머꼬
개발 노트Python
hohyeon.dev61

zip으로 리스트를 합쳤더니 사용자 한 명이 조용히 사라진 이유

  • #Common Pitfall
  • #Engineering Note
  • #Python

문제 발생

서로 다른 소스에서 받은 세 리스트를 합쳐 사용자 레코드를 만드는 배치였습니다.

ids    = fetch_ids()      # 5,000개
names  = fetch_names()    # 4,999개
scores = fetch_scores()   # 5,000개

rows = list(zip(ids, names, scores))
save_all(rows)

어느 날부터 사용자 한 명이 통째로 사라졌습니다. 에러도, 경고도, 빈 값도 없었습니다. rows의 길이가 4,999였는데, 저장 쪽에서는 "원래 그만큼 왔나 보다" 하고 넘어갔습니다. names를 주는 API가 한 건을 누락했는데, 하필 그 누락이 조용히 전파돼서 점검표를 맞추기 전까지 아무도 몰랐습니다. 더 나빴던 건 누락된 사람이 명단 맨 끝 사람이 아니라는 점이었습니다. 길이만 하나 밀렸을 뿐인데 그 지점부터 뒤의 id와 name이 한 칸씩 어긋나 엉뚱한 사람에게 붙었습니다.

원인 분석

zip은 가장 짧은 입력에서 멈춥니다. zip의 독스트링이 그대로 적어 둡니다. 튜플을 만드는 일이 "가장 짧은 인자가 소진될 때까지 계속된다"고요. names가 4,999개였으니 5,000번째 id와 score는 짝을 찾지 못하고 버려집니다. 길이가 다른 입력을 zip하면 긴 쪽의 꼬리가 말없이 잘립니다.

이 "조용함"이 함정입니다. strict 옵션을 제안한 PEP 618은 이걸 정면으로 짚습니다. zip 사용의 상당수는 길이가 같아야 하는 입력을 다루는데, 기본 동작 때문에 잘못된 리팩터링이나 로직 오류가 조용히 데이터를 잃는 결과로 이어질 수 있고, 이런 버그는 진단하기 어려울 뿐 아니라 애초에 발견하기조차 어렵다고요. 길이가 다르면 대개 버그인데, zip은 그걸 정상처럼 처리합니다.

dict(zip(...))에서 특히 잘 드러납니다. 키와 값 리스트로 딕셔너리를 만드는 흔한 관용구인데, 값이 하나 모자라면 그 키가 통째로 사라집니다.

keys   = ["id", "name", "email", "phone"]
values = ["101", "김철수", "[email protected]"]   # phone 깜빡

dict(zip(keys, values))
# {'id': '101', 'name': '김철수', 'email': '[email protected]'} — 'phone'이 없다

KeyError는 이 딕셔너리를 쓰는 한참 뒤에야, 전혀 다른 코드에서 터집니다. 원인과 증상이 멀리 떨어져 있어 추적이 어렵습니다.

해결 방안

  1. 길이가 같아야 하면 strict=True를 줍니다. Python 3.10부터 zip에 추가됐습니다. 독스트링대로 한 인자가 다른 것들보다 먼저 소진되면 ValueError를 냅니다. 조용한 데이터 손실이 그 자리에서 에러로 바뀝니다.
rows = list(zip(ids, names, scores, strict=True))
# ValueError: zip() argument 2 is shorter than argument 1

메시지가 몇 번째 인자가 짧은지(혹은 긴지)까지 알려 줘서, 어느 소스가 어긋났는지 바로 좁혀집니다. 길이가 같다고 믿고 쓰는 모든 zip에는 이걸 기본으로 붙이는 게 안전합니다.

  1. 길이가 다를 수 있고 그게 정상이면 zip_longest를 씁니다. itertools.zip_longest는 문서대로 길이가 안 맞으면 빠진 자리를 fillvalue로 채우고 가장 긴 입력이 소진될 때까지 돕니다. 기본 fillvalue는 None입니다. 짧은 쪽을 버리는 게 아니라 채워야 하는 상황이라면 이쪽이 맞습니다.
from itertools import zip_longest

list(zip_longest(ids, names, fillvalue="-"))
# 긴 쪽에 맞춰, 빠진 자리는 '-'
  1. zip 전에 길이를 명시적으로 확인합니다. strict를 쓸 수 없는 환경(3.9 이하)이거나, 에러 메시지에 맥락을 더 담고 싶으면 직접 검사합니다.
assert len(ids) == len(names) == len(scores), \
    f"길이 불일치: ids={len(ids)}, names={len(names)}, scores={len(scores)}"
  1. 제너레이터를 zip에 넣을 때는 소비된다는 걸 기억합니다. zip은 인자를 왼쪽부터 하나씩 당겨 씁니다. 그래서 짧은 입력보다 앞에 놓인 제너레이터는, 짧은 쪽이 끝난 걸 알아채기 전에 값을 하나 더 꺼내 버립니다. 아래에서 제너레이터는 0, 1, 4까지 당겨졌고, 그중 4는 짝을 못 만나 그냥 버려집니다.
g = (x * x for x in range(5))
list(zip(g, [1, 2]))   # [(0, 1), (1, 2)]
list(g)                # [9, 16] — 4(=2²)는 zip이 당겼다가 버렸다

리스트와 달리 제너레이터는 한 번 지나가면 되돌릴 수 없으니, zip 결과가 이상하면 입력이 이미 소비됐는지부터 봅니다. (제너레이터를 짧은 입력 뒤에 두면 이 추가 소비는 일어나지 않습니다.)

  1. 행렬 전치 관용구 zip(*rows)도 같은 규칙입니다. 한 행이라도 짧으면 그 길이에 맞춰 뒤쪽 열이 통째로 사라집니다. 들쭉날쭉한 데이터를 전치할 때는 전치 전에 행 길이부터 맞춥니다.
rows = [[1, 2, 3], [4, 5, 6], [7, 8]]   # 마지막 행이 짧다
list(zip(*rows))   # [(1, 4, 7), (2, 5, 8)] — 3번째 열이 사라짐

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.