Python의 List, Dict, Set Comprehension: 간결하면서도 읽기 쉽게
1. 개요
Comprehension은 Python의 특징적인 문법입니다. 이를 통해 하나의 줄 안에서 iterable로부터 list, dict 또는 set을 생성할 수 있습니다. 올바르게 작성하면 코드가 for 루프보다 짧고 명확합니다. 남용하면 코드가 긴 한 줄이 되어 이해하기 어렵습니다.
이 글에서는 list, dict, set comprehension과 generator expression을 구체적인 예제로 살펴봅니다. 이 글을 다 읽고 나면, comprehension을 언제 사용해야 하는지, 언제 for 루프로 돌아가야 하는지, 그리고 데이터 변환 로직을 어떻게 테스트할지 알게 될 것입니다. 모든 예제는 Python 3.12에서 실행됩니다.
2. List comprehension
2.1. 기본 문법
주문 목록이 있고 각 주문의 총액을 가져와야 한다고 가정해봅시다. 전통적인 for 루프를 사용하는 방법은 다음과 같습니다:
from dataclasses import dataclass
@dataclass
class Order:
id: int
customer: str
total: float
status: str
# 주문 목록
orders = [
Order(1, "An", 250_000, "PAID"),
Order(2, "Bình", 90_000, "CANCELLED"),
Order(3, "An", 1_200_000, "PAID"),
Order(4, "Chi", 450_000, "PENDING"),
]
# 모든 주문의 총액
totals = []
for order in orders:
totals.append(order.total)
print(totals)
List comprehension으로 다시 작성하면:
totals = [order.total for order in orders]
print(totals)
[250000, 90000, 1200000, 450000]
구조는 [표현식 for 요소 in iterable]입니다. 왼쪽에서 오른쪽으로 읽으면: “orders의 각 order에 대해 order.total을 가져온다”입니다. 빈 list를 선언할 필요도 없고 append를 호출할 필요도 없습니다. “새로운 list를 만들고 있다”는 의도가 [ 기호에서 바로 드러납니다.
2.2. if를 사용한 요소 필터링
이미 결제된 주문만 가져오려면 끝에 if 절을 추가합니다:
paid_ids = [order.id for order in orders if order.status == "PAID"]
print(paid_ids)
[1, 3]
끝의 if는 필터 역할을 합니다. 조건을 만족하지 않는 요소는 제외되므로, 결과 list가 원본 list보다 짧을 수 있습니다.
2.3. 앞에 있는 if-else 표현식
필터링하는 대신 조건에 따라 모든 요소를 변환하려면, 표현식 부분에 if-else를 배치합니다:
labels = ["lớn" if order.total >= 500_000 else "nhỏ" for order in orders]
print(labels)
['nhỏ', 'nhỏ', 'lớn', 'nhỏ']
여기가 많은 사람들이 헷갈리는 부분입니다. 두 가지 규칙만 기억하면 됩니다:
for뒤의if는 필터링에 사용됩니다. 이 절에는else가 없습니다.for앞의a if cond else b는 조건부 표현식입니다. 이 절은 반드시else가 있어야 하며 요소의 개수를 유지합니다.
[x for x in data if cond else y]를 작성하면 SyntaxError가 발생합니다.
3. Dict comprehension
Dict comprehension은 {} 기호와 key: value 쌍을 사용합니다. 예를 들어, id로 주문을 빠르게 조회해야 합니다:
orders_by_id = {order.id: order for order in orders}
print(orders_by_id[3])
Order(id=3, customer='An', total=1200000, status='PAID')
이미 있는 dict를 변환할 수도 있습니다. 예를 들어, 가격표에 10% 세금을 적용합니다:
prices = {"cafe": 30_000, "tra_sua": 45_000, "banh_mi": 20_000}
prices_with_vat = {name: round(price * 1.1) for name, price in prices.items()}
print(prices_with_vat)
{'cafe': 33000, 'tra_sua': 49500, 'banh_mi': 22000}
prices.items()는 (key, value) 쌍을 반환하고, for 루프 안에서 바로 unpack합니다.
유용한 팁은 dict를 뒤집는 것입니다:
status_codes = {"PAID": 1, "PENDING": 2, "CANCELLED": 3}
code_to_status = {code: status for status, code in status_codes.items()}
print(code_to_status[2])
PENDING
주의: 두 요소가 같은 key를 생성하면, 나중 요소가 무음으로 이전 요소를 덮어씁니다. 예를 들어
{order.customer: order.total for order in orders}는 “An”의 마지막 주문(1,200,000)만 유지하고, 250,000 주문은 경고 없이 사라집니다. 그룹화가 필요하면 vòngfor와 함께defaultdict를 사용하세요 (섹션 7 참조).
4. Set comprehension
Set comprehension도 {}를 사용하지만 한 개의 표현식만 있고 :가 없습니다. 결과는 자동으로 중복을 제거합니다. 예를 들어, 어떤 고객이 주문했는지 알고 싶다면:
customers = {order.customer for order in orders}
print(sorted(customers))
['An', 'Bình', 'Chi']
각 고객은 “An”이 두 개의 주문을 가지고 있어도 한 번만 나타납니다. set 내의 순서는 보장되지 않으므로, 위 예제는 출력할 때 sorted를 사용합니다.
Set comprehension은 빠른 멤버십 확인이 필요할 때 매우 유용합니다:
banned_emails = {"spam@x.com", "Fake@Y.com"}
normalized = {email.strip().lower() for email in banned_emails}
print("fake@y.com" in normalized)
True
주의: 빈
{}는 dict이지 set이 아닙니다. 빈 set을 만들려면set()을 작성해야 합니다.
5. 여러 for 루프와 중첩된 comprehension
Comprehension 안에 여러 for를 작성할 수 있습니다. 읽는 순서는 위에서 아래로 중첩된 for 루프를 작성할 때와 정확히 같습니다:
carts = {
"An": ["cafe", "banh_mi"],
"Chi": ["tra_sua"],
}
items = [(customer, item) for customer, products in carts.items() for item in products]
print(items)
[('An', 'cafe'), ('An', 'banh_mi'), ('Chi', 'tra_sua')]
위의 comprehension은 다음 코드와 동치입니다:
items = []
for customer, products in carts.items():
for item in products:
items.append((customer, item))
가장 일반적인 경우는 중첩된 list를 평탄화(flatten)하는 것입니다:
matrix = [[1, 2, 3], [4, 5, 6]]
flat = [value for row in matrix for value in row]
print(flat)
[1, 2, 3, 4, 5, 6]
기억하는 방법: 가장 바깥쪽 for 루프가 먼저 나옵니다. 순서를 이해하기 위해 두 번째로 읽어야 한다면, 일반 for 루프로 나누는 것이 좋다는 신호입니다.
6. Generator expression: list가 필요 없을 때
[]를 ()로 바꾸면 generator expression을 얻습니다. Python은 메모리에 list를 생성하지 않습니다. 값은 코드가 소비할 때 하나씩 생성됩니다.
revenue = sum(order.total for order in orders if order.status == "PAID")
print(revenue)
1450000
Generator가 함수의 유일한 인수일 때, 괄호 한 쌍을 생략할 수 있습니다. 이 방식은 sum, any, all, max, min과 잘 맞습니다:
has_big_order = any(order.total > 1_000_000 for order in orders)
all_positive = all(order.total > 0 for order in orders)
print(has_big_order, all_positive)
True True
any는 조건을 만족하는 첫 번째 요소를 만나면 즉시 중지합니다. 따라서 대용량 데이터의 경우, generator는 메모리를 절약하면서도 any([...])보다 빠릅니다.
주의: Generator는 한 번만 순회할 수 있습니다. 두 번째 순회는 아무것도 반환하지 않습니다:
paid = (o for o in orders if o.status == "PAID")
print(len(list(paid)))
print(len(list(paid)))
2
0
결과를 여러 번 사용해야 한다면 list comprehension을 사용하세요.
7. Comprehension을 읽기 쉽게 유지하기
7.1. 복잡한 로직에 대해 함수의 이름 지정
긴 조건은 comprehension을 읽기 어렵게 만듭니다:
vip = [o.customer for o in orders if o.status == "PAID" and o.total >= 1_000_000 and not o.customer.startswith("test")]
조건을 명확한 이름을 가진 함수로 분리할 수 있습니다:
def is_vip_order(order: Order) -> bool:
return (
order.status == "PAID"
and order.total >= 1_000_000
and not order.customer.startswith("test")
)
vip = [order.customer for order in orders if is_vip_order(order)]
print(vip)
['An']
이제 comprehension은 문장처럼 읽히고, 비즈니스 로직은 테스트할 별도의 위치가 있습니다.
7.2. 합리적인 줄바꿈
Comprehension은 한 줄에 있을 필요가 없습니다. 괄호 안에서 자유롭게 줄을 나눌 수 있습니다:
summary = {
order.id: order.total
for order in orders
if order.status != "CANCELLED"
}
print(summary)
{1: 250000, 3: 1200000, 4: 450000}
각 줄은 한 역할을 합니다: 표현식, 소스, 필터.
7.3. 두 번 계산을 피하기 위한 Walrus 연산자
때로는 필터링과 동일한 계산의 결과를 모두 사용해야 합니다. := 연산자(Python 3.8+)는 한 번만 계산하도록 도와줍니다:
def discount_for(order: Order) -> float:
return order.total * 0.05 if order.total >= 400_000 else 0
discounts = {
order.id: d
for order in orders
if (d := discount_for(order)) > 0
}
print(discounts)
{3: 60000.0, 4: 22500.0}
walrus를 절제 있게 사용하세요. 변수 d는 comprehension 외부 범위로 “누출”됩니다. 또한 많은 독자가 이 구문에 익숙하지 않을 것입니다.
8. 데이터 변환 로직 테스트하기
Comprehension은 종종 데이터 변환 함수에 있으므로 다른 로직처럼 테스트해야 합니다. 먼저 pytest를 설치하세요:
pip install pytest
reports.py 모듈이 있다고 가정해봅시다:
# reports.py
from dataclasses import dataclass
@dataclass
class Order:
id: int
customer: str
total: float
status: str
def revenue_by_customer(orders: list[Order]) -> dict[str, float]:
customers = {o.customer for o in orders if o.status == "PAID"}
return {
c: sum(o.total for o in orders if o.customer == c and o.status == "PAID")
for c in customers
}
그리고 해당 테스트 파일:
# test_reports.py
from reports import Order, revenue_by_customer
def test_revenue_only_counts_paid_orders():
orders = [
Order(1, "An", 250_000, "PAID"),
Order(2, "An", 1_200_000, "PAID"),
Order(3, "Bình", 90_000, "CANCELLED"),
Order(4, "Chi", 450_000, "PAID"),
]
result = revenue_by_customer(orders)
assert result == {"An": 1_450_000, "Chi": 450_000}
assert "Bình" not in result
def test_empty_orders_returns_empty_dict():
assert revenue_by_customer([]) == {}
pytest -q
.. [100%]
2 passed in 0.01s
첫 번째 테스트는 두 가지를 주장합니다: 취소된 주문은 계산되지 않고, 취소된 주문만 있는 고객은 결과에 나타나지 않습니다. 두 번째 테스트는 빈 list라는 경계 케이스를 확인합니다.
위의 함수는 맞지만 O(n × 고객 수)의 복잡도를 가집니다. 각 고객이 전체 orders를 다시 순회하기 때문입니다. 대용량 데이터의 경우, for 루프와 defaultdict를 함께 사용하는 것이 더 빠르고 읽기 쉽습니다:
from collections import defaultdict
def revenue_by_customer(orders: list[Order]) -> dict[str, float]:
totals: dict[str, float] = defaultdict(float)
for order in orders:
if order.status == "PAID":
totals[order.customer] += order.total
return dict(totals)
기존 두 테스트는 수정할 필요 없이 여전히 통과합니다. 이것이 테스트가 있는 것의 장점입니다: comprehension에서 for 루프로, 또는 그 반대로 자신 있게 리팩토링할 수 있습니다.
9. Comprehension을 사용하면 안 될 때
- Side effect가 필요할 때.
[print(o) for o in orders]또는[db.save(o) for o in orders]를 작성하지 마세요. 이렇게 하면 의미 없는Nonelist가 생성되고 독자를 혼동시킵니다.for루프를 사용하세요. - 두 개 이상의 수준으로 중첩되거나 여러
if가 섞여 있을 때. 한 줄에 세 개의for루프는 거의 항상 줄을 나눈 버전보다 읽기 어렵습니다. - 누계 또는 그룹화가 필요할 때, 예를 들어 합계 또는 개수 세기.
defaultdict,Counter또는for루프를 사용하세요. - 각 요소에 대해 예외 처리가 필요할 때. Comprehension은
try/except를 포함할 수 없습니다. 별도의 함수로 분리하여 comprehension에서 호출하거나for루프를 사용해야 합니다. - 한 번만 순회하는 매우 큰 데이터일 때. 이 경우 list 대신 generator expression을 사용하세요.
주의: Python 3에서는 comprehension의 루프 변수가 외부로 누출되지 않습니다.
[x for x in range(3)]이후, 이름x는 외부 범위에 존재하지 않으며, 이는 일반for루프와 다릅니다. 유일한 예외는 섹션 7.3에서 언급했듯이:=로 할당된 변수입니다.
10. 결론
Comprehension은 “기존 collection에서 새 collection을 만든다”는 의도를 간결하게 표현하는 데 도움이 됩니다:
- list는
[...], dict는{k: v ...}, set은{...}, generator는(...). for뒤의if는 필터링에 사용되고,for앞의a if cond else b는 변환에 사용됩니다.- 복잡한 조건을 명확한 이름의 함수로 분리하고, 표현식이 길 때는 줄을 나누세요.
- 메모리를 절약하기 위해
sum,any,all과 함께 generator expression을 사용하세요. - Side effect가 있을 때, 그룹화가 필요할 때, 예외 처리할 때, 또는 comprehension이 읽기 어려워질 때
for루프로 돌아가세요.
가장 간단한 규칙: 동료가 당신의 comprehension을 두 번 읽어야 이해한다면 for 루프로 다시 작성하세요. 간결한 코드는 읽기 쉬울 때만 가치가 있습니다.