API 응답을 페이지별로 모은 리스트, 파일마다 읽은 줄 목록처럼 리스트의 리스트를 한 줄로 펴야 하는 경우는 흔하다. 방법은 list comprehension, itertools.chain, sum(xss, []) 등 여럿인데, 어느 것이 빠른지에 대한 통념과 실제 측정값이 다르고, 깊이가 섞인 데이터에서는 일부 방법이 아예 동작하지 않는다.
이 글에서는 한 단계 평탄화 방법 여섯 가지를 Python 3.12.3에서 직접 측정해 비교하고, 임의 깊이 평탄화와 실제로 재현한 함정까지 정리한다.
비교한 방법과 측정 코드
import functools
import itertools
import operator
import timeit
METHODS = {
"comprehension": lambda xss: [x for xs in xss for x in xs],
"extend loop": lambda xss: _extend(xss),
"chain.from_iter": lambda xss: list(itertools.chain.from_iterable(xss)),
"chain(*xss)": lambda xss: list(itertools.chain(*xss)),
"reduce(iconcat)": lambda xss: functools.reduce(operator.iconcat, xss, []),
"sum(xss, [])": lambda xss: sum(xss, []),
}
def _extend(xss):
out = []
for xs in xss:
out.extend(xs)
return out
def bench(xss, number):
for name, fn in METHODS.items():
t = min(timeit.repeat(lambda: fn(xss), number=number, repeat=5)) / number
print(f" {name:<16} {t * 1e3:8.3f} ms")
xss = [[1, 2, 3], [4, 5, 6], [7], [8, 9]]
assert all(fn(xss) == [1, 2, 3, 4, 5, 6, 7, 8, 9] for fn in METHODS.values())
print("짧은 리스트 10,000개 (각 10개)")
bench([list(range(10)) for _ in range(10_000)], number=20)
print("긴 리스트 10개 (각 100,000개)")
bench([list(range(100_000)) for _ in range(10)], number=5)측정값은 timeit.repeat 5회 중 최솟값을 1회 실행 시간으로 환산했다. 측정 환경은 Ubuntu 24.04 VirtualBox 게스트(6 vCPU)라 절대값보다 방법 간 비율을 보면 된다.
측정 결과
$ python3 flatten_bench.py
짧은 리스트 10,000개 (각 10개)
comprehension 0.724 ms
extend loop 0.207 ms
chain.from_iter 0.538 ms
chain(*xss) 0.556 ms
reduce(iconcat) 0.207 ms
sum(xss, []) 531.965 ms
긴 리스트 10개 (각 100,000개)
comprehension 7.515 ms
extend loop 2.896 ms
chain.from_iter 5.042 ms
chain(*xss) 4.378 ms
reduce(iconcat) 2.699 ms
sum(xss, []) 21.778 ms
흔히 가장 빠르다고 알려진 comprehension이 실제로는 extend보다 2.5~3.5배 느리다. extend와 iconcat은 원소를 하나씩 바이트코드로 옮기지 않고 C 레벨에서 리스트 단위로 복사하기 때문이다.
import timeit
for n in (1_000, 2_000, 4_000, 8_000):
xss = [list(range(10)) for _ in range(n)]
s = min(timeit.repeat(lambda: sum(xss, []), number=3, repeat=3)) / 3
c = min(timeit.repeat(lambda: [x for xs in xss for x in xs], number=3, repeat=3)) / 3
print(f"n={n:>5} sum: {s * 1e3:8.2f} ms comprehension: {c * 1e3:6.3f} ms")$ python3 sum_scale.py
n= 1000 sum: 5.29 ms comprehension: 0.107 ms
n= 2000 sum: 26.75 ms comprehension: 0.248 ms
n= 4000 sum: 116.11 ms comprehension: 0.277 ms
n= 8000 sum: 349.35 ms comprehension: 0.861 ms
sum은 매 단계 a + b로 새 리스트를 만들어 지금까지의 결과를 통째로 복사하므로, 리스트 개수가 두 배가 될 때마다 시간이 3~5배씩 늘어난다(O(n²)).
| 방법 | 짧은 리스트 다수 | 긴 리스트 소수 | 특징 |
|---|---|---|---|
[x for xs in xss for x in xs] | 0.72 ms | 7.5 ms | 가장 읽기 쉽고, 원소 변환·필터를 함께 넣을 수 있다 |
out.extend(xs) 루프 | 0.21 ms | 2.9 ms | 빠르고 명확하다 |
chain.from_iterable(xss) | 0.54 ms | 5.0 ms | 지연 평가, 제너레이터 입력에 적합 |
chain(*xss) | 0.56 ms | 4.4 ms | 인자를 전부 풀어야 시작한다 |
reduce(iconcat, xss, []) | 0.21 ms | 2.7 ms | 가장 빠르지만 초기값을 빼면 원본을 바꾼다 |
sum(xss, []) | 532 ms | 21.8 ms | O(n²), 쓰지 말 것 |
깊이가 섞인 중첩 평탄화
위 방법은 모두 한 단계만 편다. [1, [2, [3]]]처럼 깊이가 제각각이면 재귀 제너레이터를 쓴다.
from collections.abc import Iterable
def deep_flatten(items):
for x in items:
if isinstance(x, Iterable) and not isinstance(x, (str, bytes)):
yield from deep_flatten(x)
else:
yield x
print(list(deep_flatten([1, [2, [3, (4, 5)]], "abc", [[["xy"]]]])))$ python3 deep.py
[1, 2, 3, 4, 5, 'abc', 'xy']
튜플도 함께 펴진다. 리스트만 펴고 싶으면 조건을 isinstance(x, list)로 좁힌다.
주의사항
아래 출력은 모두 같은 환경에서 실제로 실행한 결과다.
import functools
import itertools
import operator
from collections.abc import Iterable
# 1) reduce에 초기값을 빼면 첫 번째 리스트가 바뀐다
xss = [[1, 2], [3], [4, 5]]
flat = functools.reduce(operator.iconcat, xss)
print("1)", flat, "xss[0] =", xss[0], "same object:", flat is xss[0])
# 2) 재귀 평탄화에서 str 예외 처리를 빼면 무한 재귀
def naive(items):
for x in items:
if isinstance(x, Iterable):
yield from naive(x)
else:
yield x
try:
list(naive(["ab"]))
except RecursionError as e:
print("2) RecursionError:", e)
# 3) 한 단계만 펴는 방법은 섞인 깊이에서 실패한다
try:
print([x for xs in [1, [2, 3]] for x in xs])
except TypeError as e:
print("3) TypeError:", e)
# 4) chain.from_iterable은 지연 평가, chain(*gen)은 전부 풀어야 시작
def rows():
n = 0
while True:
n += 1
yield [n, -n]
print("4)", list(itertools.islice(itertools.chain.from_iterable(rows()), 6)))$ python3 pitfalls.py
1) [1, 2, 3, 4, 5] xss[0] = [1, 2, 3, 4, 5] same object: True
2) RecursionError: maximum recursion depth exceeded
3) TypeError: 'int' object is not iterable
4) [1, -1, 2, -2, 3, -3]
- 1)
reduce(iconcat, xss)처럼 초기값[]을 빼면 첫 번째 리스트에 나머지를 이어 붙여 원본xss[0]이 바뀐다. 반드시 세 번째 인자로 새 리스트를 준다. - 2) 문자열은 한 글자짜리 문자열을 내놓는 iterable이라, 재귀 평탄화에서
str/bytes를 제외하지 않으면 끝없이 내려간다. - 3) 한 단계 평탄화 방법은 모든 원소가 iterable이라고 가정한다. 스칼라가 섞이면
TypeError가 나므로 재귀 버전을 쓴다. - 4) 무한·대용량 제너레이터에는
chain.from_iterable을 쓴다.chain(*gen)은 인자 언패킹 때문에 제너레이터를 끝까지 소비하려 해서 무한 제너레이터에서는 끝나지 않는다(같은 코드를chain(*rows())로 바꾸면timeout 5에 걸려 종료됨). - 재귀 버전은 중첩 깊이가 기본 재귀 한도(1000)에 가까우면
RecursionError가 난다. 그 정도로 깊은 데이터는 명시적 스택으로 바꾼다.
마무리
한 단계 평탄화는 가독성이면 comprehension, 속도면 extend 루프나 reduce(iconcat, xss, []), 제너레이터 입력이면 chain.from_iterable을 쓴다. sum(xss, [])은 짧아 보여도 O(n²)이라 쓰지 않는다.