파이썬 zip 함수 중급 가이드 동일 이터레이터 중복 전달 함정 완벽 이해
🐍 한 줄 실수로 데이터가 두 칸씩 건너뛰는 이유와 안전한 패턴을 정리합니다
코드를 깔끔하게 줄이는 상황에서 zip을 쓰다 보면 의도치 않게 값이 사라지거나 짝이 어긋나는 경험이 생깁니다.
특히 같은 이터레이터를 두 번 전달했을 뿐인데 결과가 반씩 줄어드는 장면은 꽤 낯설죠.
이 글은 그런 당황스러운 순간을 깔끔하게 해소하도록 구성했습니다.
파이썬의 이터레이터가 값을 어떻게 소비하는지, zip이 요소를 언제 읽어 가는지, 그리고 왜 동일한 이터레이터를 중복 전달하면 원소가 두 칸씩 소모되는지를 실제 동작 관점에서 풀어봅니다.
현업 데이터 처리나 로그 파싱, 토큰 스트리밍처럼 한 번만 순회 가능한 입력을 다룰 때 특히 중요한 포인트라서, 사소한 습관 하나가 디버깅 시간을 얼마나 줄여 주는지도 함께 느낄 수 있을 겁니다.
핵심은 간단합니다.
동일 이터레이터를 zip에 두 번 넘기면 zip 호출마다 각 위치에서 next가 한 번씩 일어나므로 한 루프에 두 번 소비가 발생합니다.
예를 들어 it=iter(seq) 뒤에 zip(it,it)을 적용하면 (s0,s1),(s2,s3)처럼 인접한 쌍이 만들어지고, 전체 순회 길이도 절반으로 줄어듭니다.
이 현상은 버그가 아니라 이터레이터의 정의와 zip의 평가 타이밍이 맞물린 필연적인 결과입니다.
중요한 것은 언제 이 패턴을 의도적으로 활용하고, 언제는 명시적 복제나 독립 이터레이터를 사용해야 안전한지 구분하는 일입니다.
아래 목차를 따라가며 원리, 예제, 대안, 체크 포인트까지 차근차근 정리해 드립니다.
📋 목차
🔗 파이썬 zip 기본과 이터레이터 동작
zip은 여러 이터러블을 병렬로 순회하며 같은 인덱스의 요소를 튜플로 묶어 줍니다.
리스트나 튜플처럼 재사용 가능한 자료형도 받을 수 있고, 한 번만 순회 가능한 이터레이터도 받을 수 있습니다.
핵심은 zip이 각 입력에서 동일한 타이밍에 요소를 하나씩 꺼낸다는 점입니다.
따라서 입력 중 하나가 먼저 소진되면 zip 전체 순회도 그 지점에서 종료됩니다.
이 기본 규칙을 이해하면 길이가 다른 시퀀스 처리와 메모리 사용, 스트리밍 데이터 결합에서 예측 가능한 동작을 얻을 수 있습니다.
파이썬에서는 이터러블(iterable)과 이터레이터(iterator)를 구분하는 것이 중요합니다.
이터러블은 iter(x)로 이터레이터를 생성할 수 있는 객체를 말하며, 이터레이터는 next로 값을 순차적으로 내어놓는 상태 보유 객체입니다.
리스트는 이터러블이고, iter(리스트)는 이터레이터입니다.
zip은 전달받은 각 인자에 대해 내부적으로 이터레이터를 만들어 두고 루프마다 각 이터레이터에서 하나씩 값을 소비합니다.
# 기본 동작: 같은 위치끼리 묶기
names = ["Ann", "Bob", "Cyd"]
ages = [28, 34, 26]
for pair in zip(names, ages):
print(pair) # ('Ann', 28), ('Bob', 34), ('Cyd', 26)
# 길이가 다르면 짧은 쪽에서 종료
for pair in zip(["a","b","c"], [1,2]):
print(pair) # ('a',1), ('b',2) -> 'c'는 출력되지 않음
zip은 지연 평가(lazy) 방식으로 동작합니다.
묶인 튜플들을 미리 만들지 않고, 반복할 때마다 다음 요소를 필요할 때 계산합니다.
이 덕분에 매우 긴 입력도 메모리 부담 없이 처리할 수 있지만, 동시에 이터레이터의 소모에 민감해집니다.
한 번 소비된 요소는 되돌릴 수 없기 때문입니다.
| 구분 | 특징 |
|---|---|
| 이터러블 | iter(x)로 이터레이터 생성 가능. 여러 번 순회해도 동일 결과. 예: list, tuple, str, dict, range. |
| 이터레이터 | 상태를 가진 소비형 객체. next로 한 번 소비하면 되돌릴 수 없음. 예: iter(list), 파일 객체, 제너레이터. |
💬 zip은 입력 인자의 각 이터레이터에서 동시에 한 스텝씩 next를 호출해 튜플을 구성합니다.
이때 입력 중 하나라도 StopIteration을 일으키면 전체 루프가 종료됩니다.
- 🧩입력은 이터러블과 이터레이터를 혼용할 수 있으나 소비 타이밍을 고려합니다.
- 📏길이가 다르면 짧은 쪽 기준으로 결과 길이가 결정됩니다.
- ⚡지연 평가이므로 메모리 친화적이며 스트리밍 데이터 결합에 적합합니다.
💡 TIP: zip으로 길이가 다른 입력을 안전하게 다루려면 Python 3.10+에서 제공되는 itertools.zip_longest를 고려하세요.
채움값(fillvalue)로 누락을 표시해 정보 손실을 방지할 수 있습니다.
⚠️ 주의: 동일한 이터레이터 객체를 zip에 중복 전달하면 반복마다 두 번 소비되어 원소가 두 칸씩 진행됩니다.
예: it=iter(seq); zip(it, it) → (s0, s1), (s2, s3), …
🛠️ 동일 이터레이터 중복 전달의 원리
이제 문제의 핵심으로 들어가 보겠습니다.
zip에 동일한 이터레이터를 두 번 전달하면 왜 결과가 반으로 줄고, (s0, s1), (s2, s3)처럼 짝이 어긋나게 나올까요?
이 현상은 단순한 버그가 아니라 이터레이터 소비의 타이밍 때문입니다.
zip은 각 인자에서 순서대로 next()를 호출하므로, 같은 이터레이터를 두 번 전달하면 한 루프 내에서 두 번의 next() 호출이 일어나게 됩니다.
즉 한 번의 반복에서 요소 두 개가 소모되는 것입니다.
이 동작을 이해하려면 zip 내부의 흐름을 살펴볼 필요가 있습니다.
zip은 반복될 때마다 다음과 같은 단계를 수행합니다.
- ➊모든 입력 인자에 대해 내부적으로 iter()를 호출합니다.
- ➋루프가 한 번 돌 때마다 각 이터레이터에서 next()를 호출하여 값을 가져옵니다.
- ➌각 next()의 결과를 튜플로 묶어 한 번의 출력으로 반환합니다.
- ➍하나라도 StopIteration이 발생하면 zip의 반복이 종료됩니다.
따라서 it=iter(seq); zip(it,it)은 서로 다른 두 입력이 아니라 동일한 이터레이터 객체를 가리킵니다.
결과적으로 zip은 매 루프마다 같은 it에서 두 번 연속 next()를 호출하게 되며, 시퀀스가 두 칸씩 건너뛰게 됩니다.
seq = [0, 1, 2, 3, 4, 5]
it = iter(seq)
result = list(zip(it, it))
print(result) # [(0, 1), (2, 3), (4, 5)]
위 결과에서 볼 수 있듯, zip은 (0,1), (2,3), (4,5)로 쌍을 만들어 냅니다.
이는 zip이 한 번의 반복에서 같은 이터레이터 it을 두 번 next() 하기 때문입니다.
즉 (next(it), next(it))의 결과가 튜플이 되는 구조입니다.
이 과정을 시각화하면 아래와 같습니다.
| 루프 단계 | next() 호출 순서 | 결과 튜플 |
|---|---|---|
| 1회차 | (s0), (s1) | (s0, s1) |
| 2회차 | (s2), (s3) | (s2, s3) |
| 3회차 | (s4), (s5) | (s4, s5) |
💬 결과가 절반으로 줄어드는 이유는 zip이 동일한 이터레이터를 두 번 호출하면서 한 루프마다 두 요소를 소비하기 때문입니다.
이터레이터는 한 번 소비된 뒤에는 복구되지 않습니다.
💡 TIP: 이 구조를 역이용하면 인접 요소를 묶는 pairwise 패턴을 쉽게 구현할 수 있습니다.
Python 3.10에서는 itertools.pairwise()가 이 방식을 공식적으로 제공합니다.
⚠️ 주의: zip(it, it)는 짝짓기(pairwise)를 만들 때는 유용하지만, 데이터 정렬이나 동기화 작업에는 적합하지 않습니다.
반복 가능한 객체가 아니라 한 번만 소비되는 이터레이터라는 점을 잊지 마세요.
⚙️ zip에 동일 이터레이터 전달 예제
이제 실제 예제를 통해 zip 함수에 동일한 이터레이터를 넘겼을 때 어떤 결과가 나타나는지 단계별로 살펴보겠습니다.
이 과정은 단순히 동작 확인이 아니라, 이터레이터가 상태를 가진 일회용 객체임을 체감할 수 있는 좋은 학습 포인트입니다.
특히, 반복 가능한 객체(리스트 등)과 이터레이터 객체(iter(리스트))의 차이를 비교해 보면 명확히 이해할 수 있습니다.
seq = [10, 20, 30, 40, 50, 60]
# 1️⃣ 리스트 자체를 전달
print(list(zip(seq, seq)))
# [(10, 10), (20, 20), (30, 30), (40, 40), (50, 50), (60, 60)]
# 2️⃣ 동일 이터레이터를 전달
it = iter(seq)
print(list(zip(it, it)))
# [(10, 20), (30, 40), (50, 60)]
두 결과의 차이는 바로 ‘이터레이터의 소비 방식’에서 비롯됩니다.
첫 번째는 각 인자로 전달된 리스트가 독립적으로 iter() 처리되기 때문에 순회가 서로 간섭하지 않습니다.
하지만 두 번째는 두 인자가 모두 동일한 이터레이터 it을 참조하므로 zip 내부에서 두 번의 next() 호출이 일어나, 한 루프에 두 항목씩 진행되는 것입니다.
💬 zip(seq, seq)은 독립적인 두 이터레이터를 생성하므로 정상적으로 병렬 매칭되지만, zip(it, it)은 동일 이터레이터를 공유해 한 번의 루프에 두 칸씩 이동합니다.
- 🐍zip(seq, seq)은 각 인자에 대해 새로운 이터레이터를 만들어 서로 간섭하지 않습니다.
- ⚡zip(it, it)은 같은 이터레이터를 공유하므로 매 루프마다 next()가 두 번 호출됩니다.
- 📉결과적으로 전체 길이가 절반으로 줄어들며, (s0,s1),(s2,s3) 구조가 만들어집니다.
이러한 방식은 의도적으로 인접 쌍을 만들고 싶을 때 유용합니다.
예를 들어 센서 데이터, 로그 이벤트, 시계열 값을 두 개씩 묶어 처리하는 상황에서 자주 쓰입니다.
하지만 동일한 데이터 순회가 여러 곳에서 필요할 때는 반드시 새 이터레이터를 생성해야 합니다.
💡 TIP: Python 3.10 이상에서는 itertools.pairwise()를 활용하면 zip(it,it) 패턴을 더 명확하고 안전하게 구현할 수 있습니다.
이 함수는 내부적으로 동일 원리를 사용하지만, 코드 의도가 훨씬 명확합니다.
⚠️ 주의: 동일한 이터레이터를 여러 zip, map, filter 등에 동시에 전달하는 것은 의도치 않은 데이터 손실을 초래할 수 있습니다.
반복이 한쪽에서 먼저 끝나면 나머지도 중단됩니다.
| 전달 형태 | 동작 결과 |
|---|---|
| zip(seq, seq) | 각 인자가 별도 이터레이터로 평가되어 (s0,s0),(s1,s1)… |
| zip(it, it) | 같은 이터레이터를 두 번 소비 → (s0,s1),(s2,s3)… |
🔍 한 칸씩이 아닌 두 칸씩 소비되는 이유
zip(it, it)이 결과를 반으로 줄이는 이유는, zip의 내부 메커니즘이 한 루프에 두 번의 next() 호출을 수행하기 때문입니다.
이 동작을 코드 수준에서 직접 확인해 보면 훨씬 명확해집니다.
zip은 반복할 때마다 두 인자 모두에서 next()를 호출하고, 이를 한 쌍의 튜플로 묶어 반환합니다.
즉 동일한 이터레이터를 두 번 넘기면 zip은 같은 객체를 두 번 next()하게 되므로 한 스텝마다 원소가 두 칸씩 소모되는 것입니다.
# zip 내부 동작을 간단히 시뮬레이션
def my_zip(a, b):
a_iter, b_iter = iter(a), iter(b)
while True:
try:
yield (next(a_iter), next(b_iter))
except StopIteration:
return
it = iter([1, 2, 3, 4, 5, 6])
print(list(my_zip(it, it)))
# [(1, 2), (3, 4), (5, 6)]
이 예제는 실제 zip 함수의 내부 구조와 거의 동일합니다.
결국 zip(it, it)은 내부적으로 다음과 같은 호출을 반복합니다.
💬 (next(it), next(it)) → (1, 2) → (3, 4) → (5, 6)
이터레이터 it이 같은 객체이므로 zip이 한 번 돌 때마다 두 개의 값을 순차적으로 소모합니다.
반대로, zip(seq, seq)는 두 인자로부터 각각 다른 이터레이터를 생성하므로 순회가 독립적으로 이루어집니다.
즉 zip(seq, seq)는 (next(iter1), next(iter2)) 구조이며, iter1과 iter2가 서로 다른 객체이기 때문에 한쪽의 next()가 다른 쪽에 영향을 미치지 않습니다.
| 전달 방식 | next() 호출 구조 | 소비 결과 |
|---|---|---|
| zip(seq, seq) | (next(iter1), next(iter2)) | 각각 별도 소비 → (s0, s0), (s1, s1)… |
| zip(it, it) | (next(it), next(it)) | 한 루프에 두 칸 소비 → (s0, s1), (s2, s3)… |
이 동작을 이해하면, 왜 zip(it, it)이 데이터 일부만 처리하고 종료되는지 명확하게 파악할 수 있습니다.
이는 zip이 ‘병렬 결합’이 아니라 ‘동시 next() 호출’로 구현되어 있기 때문입니다.
결과적으로 동일 이터레이터를 중복 전달하면 다음 두 가지 현상이 발생합니다.
- ⚡이터레이터가 두 번씩 next() 호출되어 절반 길이의 결과가 만들어집니다.
- 🚫이미 소비된 원소는 복원되지 않으므로, 이후 다른 연산에서는 데이터가 누락됩니다.
💡 TIP: zip(it, it)은 pairwise 형태를 구현할 때는 유용하지만, 원본 데이터 전체를 보존하려면 tee()로 이터레이터를 복제하거나, 리스트로 변환 후 zip(seq, seq[1:]) 형태로 사용하는 것이 안전합니다.
💡 안전한 대안과 베스트 프랙티스
동일 이터레이터를 zip에 중복 전달하는 패턴은 유용할 때도 있지만, 대부분의 경우 의도치 않은 데이터 손실로 이어집니다.
따라서 명시적으로 이터레이터를 복제하거나, 별도의 순회 구조를 사용하는 것이 좋습니다.
다음은 실제 코드에서 자주 쓰이는 대안 패턴입니다.
- 🧩itertools.tee()를 사용해 독립된 이터레이터 복제본을 만듭니다.
- 🔁pairwise 처리를 원할 때는 Python 3.10+의 itertools.pairwise()를 사용합니다.
- 🧮리스트나 튜플처럼 재사용 가능한 객체는 zip(seq, seq) 형태로 안전하게 활용할 수 있습니다.
- 📦이터레이터가 소모되기 전에 필요하면 list()로 한 번 저장한 뒤 여러 번 사용할 수 있습니다.
from itertools import tee, pairwise
# 1️⃣ tee로 이터레이터 복제
it1, it2 = tee(iter([1, 2, 3, 4, 5, 6]))
print(list(zip(it1, it2))) # (1,1), (2,2), ...
# 2️⃣ pairwise로 인접쌍 묶기
print(list(pairwise([1, 2, 3, 4, 5, 6])))
# [(1,2), (2,3), (3,4), (4,5), (5,6)]
# 3️⃣ 리스트 슬라이싱으로 안전하게 구현
seq = [1, 2, 3, 4, 5, 6]
print(list(zip(seq, seq[1:])))
# [(1,2), (2,3), (3,4), (4,5), (5,6)]
tee()는 내부 버퍼를 사용하여 이터레이터를 여러 개의 복제본처럼 동작하게 해 주며, 각 복제본은 독립적으로 next()를 호출할 수 있습니다.
pairwise()는 바로 zip(it, it) 패턴을 명시적으로 구현한 함수로, 코드 의도를 훨씬 명확하게 표현할 수 있습니다.
💬 pairwise는 zip(it, it) 패턴의 안전하고 가독성 높은 대체제입니다.
Python 3.10 이상에서 기본 제공되며, 구버전에서는 itertools 레시피로 직접 구현할 수 있습니다.
💡 TIP: zip의 입력으로 동일 이터레이터를 전달하기 전, 꼭 이터레이터의 재사용 가능 여부를 확인하세요.
디버깅 중 결과 길이가 예상보다 짧게 나온다면, 동일한 이터레이터가 중복 전달된 것은 아닌지 의심해 볼 필요가 있습니다.
⚠️ 주의: tee()는 내부적으로 메모리를 사용해 각 복제본의 상태를 유지합니다.
따라서 대용량 데이터 스트림에서는 불필요한 복제를 피하고, 한 번에 필요한 만큼만 처리하는 것이 좋습니다.
결국 zip(it, it) 패턴은 “이터레이터의 소비”라는 파이썬의 핵심 개념을 드러내는 대표적인 예시입니다.
코드를 짧게 줄이는 대신 데이터 소모 시점을 통제해야 한다는 점을 명심하면, 더 안전하고 예측 가능한 반복문을 작성할 수 있습니다.
❓ 자주 묻는 질문 (FAQ)
zip(it, it)을 사용할 때 항상 결과가 절반으로 줄어드나요?
zip(seq, seq)과 zip(it, it)의 차이는 무엇인가요?
반면 zip(it, it)은 동일한 이터레이터를 공유하므로 한 번의 루프에 두 번 소비가 일어납니다.
이 현상을 버그로 봐야 할까요?
파이썬의 이터레이터가 한 번 소비되면 복원되지 않는다는 특성을 그대로 따른 결과입니다.
이터레이터를 복제해서 안전하게 zip을 쓰려면 어떻게 해야 하나요?
tee(it, 2)는 원본 이터레이터를 두 개의 독립된 복제본처럼 사용할 수 있게 만들어 줍니다.
pairwise()는 zip(it, it)과 완전히 동일한가요?
Python 3.10 이상에서 기본 제공됩니다.
zip_longest()를 사용하면 이런 문제를 피할 수 있나요?
따라서 원인 해결에는 도움이 되지 않습니다.
이터레이터 대신 리스트를 사용하면 안전한가요?
이 동작을 의도적으로 활용하는 경우가 있을까요?
예를 들어 좌표쌍, 구간, 인접 비교 등에 사용하면 효율적입니다.
📘 zip(it, it) 패턴을 이해하면 보이는 파이썬의 진짜 힘
파이썬의 zip 함수는 단순히 여러 시퀀스를 묶는 기능을 넘어서, 이터레이터의 작동 원리를 가장 직관적으로 보여주는 도구입니다.
특히 동일한 이터레이터를 중복 전달했을 때 발생하는 ‘두 칸씩 소비되는 현상’은 초보자에게 혼란스럽지만, 이터레이터의 소비 특성을 완벽히 이해하는 데 큰 도움이 됩니다.
이 패턴을 올바르게 이해하면 스트리밍 데이터, 로그 파이프라인, 제너레이터 기반 코드에서도 안정적인 처리가 가능해집니다.
핵심은 zip이 인자를 병렬로 순회하면서 각 이터레이터에서 next()를 호출한다는 점입니다.
따라서 동일한 이터레이터를 중복 전달하면 루프당 두 번의 next() 호출이 일어나고, 결과가 반으로 줄어듭니다.
이 특성을 알고 나면, zip(it, it)은 버그가 아니라 파이썬 내부의 합리적인 설계임을 이해하게 됩니다.
결국 이 현상은 이터레이터는 상태를 가진 일회용 객체라는 파이썬의 철학을 보여 줍니다.
이를 바탕으로 안전하게 zip을 활용하려면 다음과 같은 원칙을 기억해 두면 좋습니다.
- 🐍zip에 동일한 이터레이터를 넘기면 루프마다 두 칸씩 소비됩니다.
- 🔁독립 이터레이터가 필요하면 tee()로 복제하거나, 리스트로 변환하세요.
- 💡인접 요소를 묶고 싶다면 itertools.pairwise()를 활용하세요.
- 🚀이터레이터 기반 로직을 다룰 때는 ‘소비’ 타이밍을 항상 염두에 두세요.
이처럼 zip(it, it) 함정은 단순한 문법 오류가 아니라, 이터레이터라는 구조의 본질을 드러내는 좋은 사례입니다.
이를 완전히 이해하면, 제너레이터와 비동기 처리, 데이터 스트림 파이프라인 설계 등에서도 훨씬 예측 가능한 코드를 작성할 수 있습니다.
🏷️ 관련 태그 : 파이썬, zip함수, 이터레이터, 제너레이터, pairwise, itertools, tee함수, 파이썬중급, 데이터순회, 코딩팁