Python 컴프리헨션: 리스트·딕셔너리·세트 컴프리헨션과 가독성 기준
이 글의 핵심
컴프리헨션은 for 루프보다 짧고 대체로 빠르지만, 조건과 중첩이 겹치면 오히려 읽기 힘든 코드가 됩니다. 중첩 for의 읽는 순서, 리스트 대신 제너레이터를 써야 하는 데이터 크기, 한 줄로 줄이지 말고 일반 루프로 풀어야 하는 경우를 가독성 기준과 함께 정리합니다.
들어가며
컴프리헨션은 “기존 반복 가능한 객체에서 새 컬렉션을 만든다”는 한 가지 목적에 특화된 문법입니다. 빈 리스트를 만들고, 루프를 돌고, append를 호출하는 세 단계를 하나의 표현식으로 합치기 때문에, 코드를 읽는 사람은 “이 줄의 결과가 리스트 하나”라는 사실을 첫눈에 알 수 있습니다. 반대로 같은 문법으로 부작용이 있는 작업이나 여러 단계의 분기를 표현하려고 하면 오히려 읽기 어려워집니다. 이 글은 문법 자체보다 “어디까지 컴프리헨션으로 쓰고 어디서 멈출지”에 초점을 맞춥니다.
리스트 컴프리헨션: 필터링, 변환, 중첩
리스트 컴프리헨션이란?
리스트 컴프리헨션은 한 줄로 리스트를 생성하는 Python의 강력한 기능입니다. 일반 for 루프보다 간결하고 빠릅니다.
문법: [표현식 for 변수 in 반복가능객체 if 조건]
기본 문법과 동작 원리
아래 왼쪽은 빈 장바구니에 for 루프로 하나씩 담는 방식이고, 오른쪽 한 줄은 같은 일을 컴프리헨션으로 압축한 것입니다. 읽을 때는 for i in range(10)을 먼저 읽으며, 앞쪽 i ** 2가 각 반복에서 리스트에 들어간다고 보면 됩니다.
# 일반 for 루프 (전통적 방식)
squares = []
for i in range(10):
squares.append(i ** 2)
print(squares) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# 리스트 컴프리헨션 (Python 스타일)
squares = [i ** 2 for i in range(10)]
print(squares) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# 동작 과정:
# 1. range(10)에서 i를 하나씩 가져옴 (0, 1, 2, ..., 9)
# 2. 각 i에 대해 i ** 2 계산
# 3. 결과를 리스트에 추가
# 4. 최종 리스트 반환
성능 비교:
import time
# 방법 1: for 루프
start = time.time()
result1 = []
for i in range(1000000):
result1.append(i ** 2)
print(f"for 루프: {time.time() - start:.4f}초") # 약 0.15초
# 방법 2: 리스트 컴프리헨션
start = time.time()
result2 = [i ** 2 for i in range(1000000)]
print(f"컴프리헨션: {time.time() - start:.4f}초") # 약 0.10초
# 차이는 환경과 Python 버전에 따라 다르며, 보통 컴프리헨션 쪽이 조금 빠름
컴프리헨션이 빠른 이유는 마법이 아니라 바이트코드 차이입니다. for 루프 버전은 매 반복마다 result1.append라는 속성을 찾아 메서드를 호출하지만, 컴프리헨션은 전용 명령(LIST_APPEND)으로 요소를 바로 추가합니다. 그래서 차이는 “반복 한 번당 메서드 호출 비용” 정도이고, 루프 본문에서 무거운 연산(정규식, 파일 I/O, 네트워크 호출)을 한다면 그 비용에 묻혀 거의 사라집니다. 속도 때문에 컴프리헨션을 고르기보다는, 결과가 컬렉션 하나로 떨어지는 단순 변환일 때 의도가 잘 드러나서 고른다고 생각하는 편이 맞습니다. 직접 측정하려면 time.time()보다 timeit 모듈을 쓰는 것이 좋습니다. 반복 실행과 고해상도 타이머로 측정 잡음을 줄여 주기 때문입니다.
조건문 추가 (필터링)
if 조건으로 특정 요소만 선택할 수 있습니다.
# 짝수만 선택
evens = [i for i in range(10) if i % 2 == 0]
print(evens) # [0, 2, 4, 6, 8]
# 동작 과정:
# i=0: 0 % 2 == 0 → True → 0 추가
# i=1: 1 % 2 == 0 → False → 무시
# i=2: 2 % 2 == 0 → True → 2 추가
# ...
# 3의 배수이면서 10보다 큰 수
multiples = [i for i in range(30) if i % 3 == 0 and i > 10]
print(multiples) # [12, 15, 18, 21, 24, 27]
# 문자열 필터링
words = ['apple', 'banana', 'cherry', 'date', 'elderberry']
long_words = [word for word in words if len(word) > 5]
print(long_words) # ['banana', 'cherry', 'elderberry']
if-else 표현식 (변환)
if-else를 사용하여 요소를 변환할 수 있습니다.
# 짝수/홀수 라벨링
labels = ['짝수' if i % 2 == 0 else '홀수' for i in range(5)]
print(labels) # ['짝수', '홀수', '짝수', '홀수', '짝수']
# 문법 주의: if-else는 for 앞에
# [표현식 if 조건 else 다른표현식 for 변수 in 반복가능객체]
# 양수/음수/0 분류
numbers = [-2, -1, 0, 1, 2]
signs = [
'양수' if n > 0 else ('음수' if n < 0 else '0')
for n in numbers
]
print(signs) # ['음수', '음수', '0', '양수', '양수']
# 점수를 등급으로 변환
scores = [95, 85, 75, 65, 55]
grades = [
'A' if s >= 90 else 'B' if s >= 80 else 'C' if s >= 70 else 'D' if s >= 60 else 'F'
for s in scores
]
print(grades) # ['A', 'B', 'C', 'D', 'F']
여러 단계의 A if ... else B if ... else C는 문법적으로는 가능하지만, 등급 구간이 하나 더 늘어나는 순간 한 줄로 읽기 어려워집니다. 이런 매핑은 def to_grade(s): ... 같은 함수로 빼고 컴프리헨션에서는 [to_grade(s) for s in scores]만 남기는 편이 테스트하기도 쉽습니다. 구간이 많다면 bisect.bisect로 경계 리스트를 탐색하는 방법도 있습니다.
if vs if-else 위치 차이:
# 필터링 (if만): for 뒤에
evens = [i for i in range(10) if i % 2 == 0]
# 변환 (if-else): for 앞에
labels = ['짝수' if i % 2 == 0 else '홀수' for i in range(10)]
# 필터링 + 변환: 둘 다 사용
# [변환표현식 for 변수 in 반복 if 필터조건]
positive_squares = [i ** 2 if i > 0 else 0 for i in range(-5, 6) if i != 0]
print(positive_squares)
중첩 반복문 (Nested Loops)
중첩 for 루프를 한 줄로 표현할 수 있습니다.
# 2차원 리스트 평탄화 (Flatten)
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
# 일반 for 루프
flat = []
for row in matrix:
for num in row:
flat.append(num)
print(flat) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
# 리스트 컴프리헨션 (왼쪽 for가 외부, 오른쪽 for가 내부)
flat = [num for row in matrix for num in row]
print(flat) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
# 읽는 순서:
# for row in matrix: # 외부 루프
# for num in row: # 내부 루프
# num # 표현식
# 구구단 생성
multiplication_table = [
f"{i} x {j} = {i*j}"
for i in range(2, 10) # 2단부터 9단
for j in range(1, 10) # 1부터 9까지
]
print(multiplication_table[:5])
# ['2 x 1 = 2', '2 x 2 = 4', '2 x 3 = 6', '2 x 4 = 8', '2 x 5 = 10']
# 좌표 생성
coordinates = [(x, y) for x in range(3) for y in range(3)]
print(coordinates)
# [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (2, 0), (2, 1), (2, 2)]
# 조건 추가: 대각선만
diagonal = [(x, y) for x in range(5) for y in range(5) if x == y]
print(diagonal) # [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)]
처음 중첩 컴프리헨션을 쓸 때 가장 흔히 헷갈리는 부분은 for 절의 순서입니다. 저도 [num for num in row for row in matrix]처럼 순서를 거꾸로 써서 NameError: name 'row' is not defined를 본 적이 있는데, 규칙은 단순합니다. for 절은 일반 중첩 루프를 위에서 아래로 쓴 순서 그대로 왼쪽에서 오른쪽으로 나열하고, 표현식만 맨 앞으로 끌어올린 것입니다. 헷갈리면 먼저 일반 루프로 쓴 뒤 줄을 그대로 옮기면 틀리지 않습니다.
중첩 컴프리헨션 vs 중첩 리스트:
# 2차원 리스트 생성 (행렬)
# 방법 1: 중첩 컴프리헨션
matrix = [[i * j for j in range(5)] for i in range(5)]
print(matrix)
# [[0, 0, 0, 0, 0],
# [0, 1, 2, 3, 4],
# [0, 2, 4, 6, 8],
# [0, 3, 6, 9, 12],
# [0, 4, 8, 12, 16]]
# 방법 2: 일반 for 루프
matrix = []
for i in range(5):
row = []
for j in range(5):
row.append(i * j)
matrix.append(row)
딕셔너리 컴프리헨션
딕셔너리 컴프리헨션이란?
딕셔너리 컴프리헨션은 키-값 쌍을 한 줄로 생성하는 기능입니다.
문법: {키표현식: 값표현식 for 변수 in 반복가능객체 if 조건}
기본 문법과 동작 원리
# 일반 for 루프
squares_dict = {}
for i in range(5):
squares_dict[i] = i ** 2
print(squares_dict) # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
# 딕셔너리 컴프리헨션
squares_dict = {i: i ** 2 for i in range(5)}
print(squares_dict) # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
# 동작 과정:
# i=0: 키=0, 값=0**2=0 → {0: 0}
# i=1: 키=1, 값=1**2=1 → {0: 0, 1: 1}
# i=2: 키=2, 값=2**2=4 → {0: 0, 1: 1, 2: 4}
# ...
# 리스트를 딕셔너리로 변환
names = ['철수', '영희', '민수']
name_dict = {i: name for i, name in enumerate(names)}
print(name_dict) # {0: '철수', 1: '영희', 2: '민수'}
# enumerate() 설명:
# enumerate(names) = [(0, '철수'), (1, '영희'), (2, '민수')]
# 각 (i, name) 튜플에서 i는 키, name은 값
딕셔너리 컴프리헨션에서 같은 키가 두 번 나오면 예외 없이 뒤의 값이 앞의 값을 덮어씁니다. 일반 루프의 d[k] = v와 똑같은 동작이라 버그가 조용히 숨어 있기 쉽습니다. 키 중복 가능성이 있는 데이터라면 아래 “키-값 변환” 절처럼 defaultdict(list)로 모으거나, 변환 전후의 len()을 비교해 중복을 검사해야 합니다.
조건부 딕셔너리 (필터링)
# 짝수만 딕셔너리에 추가
even_squares = {i: i ** 2 for i in range(10) if i % 2 == 0}
print(even_squares) # {0: 0, 2: 4, 4: 16, 6: 36, 8: 64}
# 특정 값만 필터링
scores = {'철수': 85, '영희': 92, '민수': 78, '지영': 95}
high_scores = {name: score for name, score in scores.items() if score >= 90}
print(high_scores) # {'영희': 92, '지영': 95}
# 키와 값 모두 조건 적용
data = {'apple': 5, 'banana': 3, 'cherry': 8, 'date': 2}
filtered = {
k.upper(): v * 2
for k, v in data.items()
if len(k) > 4 and v > 3
}
print(filtered) # {'APPLE': 10, 'CHERRY': 16}
키-값 변환 (Swap)
# 키와 값 교환
original = {'a': 1, 'b': 2, 'c': 3}
swapped = {v: k for k, v in original.items()}
print(swapped) # {1: 'a', 2: 'b', 3: 'c'}
# 주의: 값이 중복되면 마지막 값만 남음
original = {'a': 1, 'b': 2, 'c': 1}
swapped = {v: k for k, v in original.items()}
print(swapped) # {1: 'c', 2: 'b'} ('a'는 'c'에 덮어씌워짐)
# 해결: 값을 리스트로 수집
from collections import defaultdict
swapped_multi = defaultdict(list)
for k, v in original.items():
swapped_multi[v].append(k)
print(dict(swapped_multi)) # {1: ['a', 'c'], 2: ['b']}
데이터 변환 예제
# 예제 1: 문자열 길이 매핑
words = ['apple', 'banana', 'cherry', 'date']
word_lengths = {word: len(word) for word in words}
print(word_lengths)
# {'apple': 5, 'banana': 6, 'cherry': 6, 'date': 4}
# 예제 2: 환경 변수 파싱
env_str = "DEBUG=True,PORT=8000,HOST=localhost"
env_dict = {
pair.split('=')[0]: pair.split('=')[1]
for pair in env_str.split(',')
}
print(env_dict)
# {'DEBUG': 'True', 'PORT': '8000', 'HOST': 'localhost'}
# 개선: 한 번만 분리하고, 값에 '='가 있어도 안전하게
env_dict = dict(pair.split('=', 1) for pair in env_str.split(','))
# 예제 3: 두 리스트를 딕셔너리로 결합
keys = ['name', 'age', 'city']
values = ['Alice', 25, 'Seoul']
person = {k: v for k, v in zip(keys, values)}
print(person) # {'name': 'Alice', 'age': 25, 'city': 'Seoul'}
# 예제 4: 딕셔너리 필터링 및 변환
products = {
'apple': 1000,
'banana': 500,
'cherry': 2000,
'date': 800
}
# 1000원 이상 제품에 10% 할인
discounted = {
name: price * 0.9
for name, price in products.items()
if price >= 1000
}
print(discounted) # {'apple': 900.0, 'cherry': 1800.0}
세트 컴프리헨션
세트 컴프리헨션이란?
세트 컴프리헨션은 중복 없는 집합을 한 줄로 생성합니다. 리스트 컴프리헨션과 비슷하지만 [] 대신 {}를 사용합니다.
문법: {표현식 for 변수 in 반복가능객체 if 조건}
기본 문법과 중복 제거
# 중복이 있는 리스트
numbers = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
# 일반 방법: set() 사용
unique = set(numbers)
print(unique) # {1, 2, 3, 4}
# 세트 컴프리헨션
unique = {n for n in numbers}
print(unique) # {1, 2, 3, 4}
# 변환 + 중복 제거
numbers = [1, -2, 2, -3, 3, -4, 4]
abs_unique = {abs(n) for n in numbers}
print(abs_unique) # {1, 2, 3, 4} (음수가 양수로 변환되며 중복 제거)
조건부 세트
# 짝수만
even_set = {i for i in range(10) if i % 2 == 0}
print(even_set) # {0, 2, 4, 6, 8}
# 문자열에서 모음만 추출
text = "Hello World"
vowels = {char.lower() for char in text if char.lower() in 'aeiou'}
print(vowels) # {'e', 'o'} (중복 'o' 자동 제거)
# 리스트에서 길이 3 이상 단어만
words = ['hi', 'hello', 'hey', 'hello', 'world', 'hi']
long_words = {word for word in words if len(word) >= 3}
print(long_words) # {'hello', 'hey', 'world'} (중복 제거됨)
세트 컴프리헨션 활용 예
# 예제 1: 이메일 도메인 추출
emails = [
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]'
]
domains = {email.split('@')[1] for email in emails}
print(domains) # {'gmail.com', 'yahoo.com', 'outlook.com'}
# 예제 2: 파일 확장자 수집
files = ['image.jpg', 'doc.pdf', 'photo.jpg', 'video.mp4', 'report.pdf']
extensions = {file.split('.')[-1] for file in files}
print(extensions) # {'jpg', 'pdf', 'mp4'}
# 예제 3: 숫자의 마지막 자리 수
numbers = [123, 456, 789, 111, 222, 333]
last_digits = {n % 10 for n in numbers}
print(last_digits) # {1, 2, 3, 6, 9}
세트 컴프리헨션의 결과는 순서가 보장되지 않습니다. 출력 예시의 순서는 실행 환경에 따라 다를 수 있고, 문자열은 해시 무작위화(PYTHONHASHSEED) 때문에 프로세스마다 순서가 바뀔 수 있으므로, 테스트에서 print 결과를 문자열로 비교하면 불안정해집니다. 순서가 필요하면 sorted({...})로 정렬하고, 등장 순서를 유지하면서 중복만 없애고 싶다면 list(dict.fromkeys(items))가 관용적인 방법입니다. 또 하나 흔한 실수는 빈 중괄호입니다. {}는 빈 세트가 아니라 빈 딕셔너리이므로, 빈 세트는 set()으로 만들어야 합니다.
세트 연산과 결합
# 두 리스트의 공통 요소
list1 = [1, 2, 3, 4, 5]
list2 = [4, 5, 6, 7, 8]
common = {x for x in list1} & {x for x in list2}
print(common) # {4, 5}
# 또는 더 간단하게
common = set(list1) & set(list2)
print(common) # {4, 5}
# 차집합: list1에만 있는 요소
diff = {x for x in list1} - {x for x in list2}
print(diff) # {1, 2, 3}
# 합집합
union = {x for x in list1} | {x for x in list2}
print(union) # {1, 2, 3, 4, 5, 6, 7, 8}
제너레이터 표현식
제너레이터 표현식이란?
제너레이터 표현식은 필요할 때만 값을 생성하는 메모리 효율적인 방법입니다. 리스트 컴프리헨션과 문법이 같지만 [] 대신 ()를 사용합니다.
문법: (표현식 for 변수 in 반복가능객체 if 조건)
기본 문법과 동작 원리
# 리스트 컴프리헨션: 모든 값을 메모리에 저장
squares_list = [i ** 2 for i in range(1000000)]
print(type(squares_list)) # <class 'list'>
print(len(squares_list)) # 1000000 (모든 요소가 메모리에 존재)
# 제너레이터 표현식: 필요할 때만 생성
squares_gen = (i ** 2 for i in range(1000000))
print(type(squares_gen)) # <class 'generator'>
# print(len(squares_gen)) # TypeError: object of type 'generator' has no len()
# next()로 하나씩 가져오기
print(next(squares_gen)) # 0 (첫 번째 값 생성)
print(next(squares_gen)) # 1 (두 번째 값 생성)
print(next(squares_gen)) # 4 (세 번째 값 생성)
# for 루프에서 사용 (자동으로 next() 호출)
for square in (i ** 2 for i in range(5)):
print(square, end=' ') # 0 1 4 9 16
print()
# 제너레이터는 한 번만 순회 가능
gen = (i for i in range(3))
print(list(gen)) # [0, 1, 2]
print(list(gen)) # [] (이미 소진됨)
메모리 효율성 비교
import sys
# 리스트: 모든 요소를 메모리에 저장
list_comp = [i for i in range(100000)]
print(f"리스트 크기: {sys.getsizeof(list_comp):,} bytes") # ~800,000 bytes
# 제너레이터: 객체만 저장
gen_expr = (i for i in range(100000))
print(f"제너레이터 크기: {sys.getsizeof(gen_expr):,} bytes") # ~120 bytes
여기서 주의할 점은 sys.getsizeof가 컨테이너 자체의 크기만 잰다는 것입니다. 리스트 쪽 숫자는 요소를 가리키는 포인터 배열의 크기이고, 각 정수 객체의 메모리는 포함되지 않으므로 실제 차이는 이 숫자보다 더 큽니다. 반대로 제너레이터가 가볍다고 해서 공짜는 아닙니다. 값을 꺼낼 때마다 멈춰 둔 제너레이터 프레임을 재개하는 비용이 있어서, 작은 데이터를 여러 번 쓰는 경우에는 리스트가 더 빠르고 단순합니다.
언제 제너레이터를 사용할까?
# 1. sum, max, min 등 집계 함수와 함께
total = sum(i ** 2 for i in range(1000000)) # 메모리 효율적
maximum = max(i ** 2 for i in range(1000))
# 2. any, all 함수 (조기 종료 가능)
# 10000보다 큰 제곱수가 있는지 확인
has_large = any(i ** 2 > 10000 for i in range(1000000))
print(has_large) # True (i=101에서 조기 종료)
# 3. 파일 처리
with open('large_file.txt') as f:
# 빈 줄이 아닌 줄 수 계산
non_empty_lines = sum(1 for line in f if line.strip())
# 4. 체이닝
# 제너레이터를 여러 단계로 체이닝
numbers = range(1000000)
evens = (x for x in numbers if x % 2 == 0)
squares = (x ** 2 for x in evens)
large = (x for x in squares if x > 100)
result = sum(large) # 메모리 효율적으로 처리
제너레이터 표현식을 함수의 유일한 인자로 넘길 때는 sum(x for x in data)처럼 괄호를 한 번만 써도 됩니다. 인자가 둘 이상이면 sorted((x for x in data), reverse=True)처럼 괄호를 따로 감싸야 하며, 그렇지 않으면 SyntaxError: Generator expression must be parenthesized가 납니다. 파일 처리 예제는 with 블록 안에서 제너레이터를 끝까지 소비해야 한다는 점도 중요합니다. 제너레이터를 반환해서 블록 밖에서 순회하면 이미 닫힌 파일을 읽게 되어 ValueError: I/O operation on closed file이 발생합니다.
제너레이터 vs 리스트 선택 가이드
# ✅ 제너레이터 사용
# - 한 번만 순회
# - 메모리가 제한적
# - 무한 시퀀스
total = sum(i ** 2 for i in range(1000000))
# ✅ 리스트 사용
# - 여러 번 순회
# - 인덱싱 필요
# - 길이 확인 필요
squares = [i ** 2 for i in range(10)]
print(squares[5]) # 인덱싱
print(len(squares)) # 길이
print(sum(squares)) # 첫 번째 순회
print(max(squares)) # 두 번째 순회
CSV 파싱, 성적 처리, 문자열·경로·API 응답 예제
CSV 데이터 파싱
CSV 문자열을 딕셔너리 리스트로 변환하는 실용적인 예제입니다.
# CSV 데이터 (헤더 + 데이터 행)
csv_data = "name,age,city\n철수,25,서울\n영희,30,부산\n민수,28,대전"
# 1단계: 줄 단위로 분리
lines = csv_data.strip().split('\n')
print(lines)
# ['name,age,city', '철수,25,서울', '영희,30,부산', '민수,28,대전']
# 2단계: 헤더 추출
header = lines[0].split(',')
print(header) # ['name', 'age', 'city']
# 3단계: 데이터 행을 딕셔너리로 변환
data = [
dict(zip(header, line.split(',')))
for line in lines[1:]
]
print(data)
# [{'name': '철수', 'age': '25', 'city': '서울'},
# {'name': '영희', 'age': '30', 'city': '부산'},
# {'name': '민수', 'age': '28', 'city': '대전'}]
# zip() 설명:
# header = ['name', 'age', 'city']
# line.split(',') = ['철수', '25', '서울']
# zip(header, line.split(',')) = [('name', '철수'), ('age', '25'), ('city', '서울')]
# dict(...) = {'name': '철수', 'age': '25', 'city': '서울'}
타입 변환 추가:
# age를 정수로 변환
data_typed = [
{
'name': parts[0],
'age': int(parts[1]), # 문자열 → 정수
'city': parts[2]
}
for line in lines[1:]
for parts in [line.split(',')] # 변수 재사용 트릭
]
print(data_typed)
# [{'name': '철수', 'age': 25, 'city': '서울'}, ...]
for parts in [line.split(',')]는 요소가 하나뿐인 리스트를 순회해서 중간 결과에 이름을 붙이는 오래된 트릭입니다. Python 3.8 이상이라면 대입 표현식(바다코끼리 연산자) :=로 같은 일을 할 수 있지만, 컴프리헨션 안의 :=는 바깥 스코프에 변수를 남긴다는 점이 일반 반복 변수와 다릅니다. 실무 CSV라면 이렇게 직접 쪼개기보다 csv.DictReader를 쓰는 편이 안전합니다. 필드 안에 쉼표가 들어 있거나 따옴표로 감싼 값이 있으면 split(',')은 열 개수를 잘못 셉니다.
학생 성적 처리
필터링과 변환을 동시에 수행하는 예제입니다.
# 학생 데이터
students = [
{'name': '철수', 'score': 85},
{'name': '영희', 'score': 92},
{'name': '민수', 'score': 78},
{'name': '지영', 'score': 95},
{'name': '수진', 'score': 88}
]
# 필터링: 90점 이상 학생 이름만
high_scores = [
s['name'] for s in students if s['score'] >= 90
]
print(high_scores) # ['영희', '지영']
# 변환: 등급 추가
graded = [
{**s, 'grade': 'A' if s['score'] >= 90 else 'B' if s['score'] >= 80 else 'C'}
for s in students
]
print(graded)
# [{'name': '철수', 'score': 85, 'grade': 'B'},
# {'name': '영희', 'score': 92, 'grade': 'A'},
# {'name': '민수', 'score': 78, 'grade': 'C'},
# {'name': '지영', 'score': 95, 'grade': 'A'},
# {'name': '수진', 'score': 88, 'grade': 'B'}]
# {**s, 'grade': ...} 설명:
# **s: 딕셔너리 언패킹 (기존 키-값 복사)
# 'grade': ...: 새 키 추가
# 결과: 기존 딕셔너리 + 새 키
# 필터링 + 변환: 80점 이상만 등급 부여
passed = [
{**s, 'grade': 'A' if s['score'] >= 90 else 'B'}
for s in students
if s['score'] >= 80
]
print(passed)
# [{'name': '철수', 'score': 85, 'grade': 'B'},
# {'name': '영희', 'score': 92, 'grade': 'A'},
# {'name': '지영', 'score': 95, 'grade': 'A'},
# {'name': '수진', 'score': 88, 'grade': 'B'}]
문자열 처리
# 문자열 리스트 정리
names = [' alice ', 'BOB', ' Charlie', 'david ']
# 공백 제거 + 소문자 변환
cleaned = [name.strip().lower() for name in names]
print(cleaned) # ['alice', 'bob', 'charlie', 'david']
# 첫 글자 대문자
capitalized = [name.strip().capitalize() for name in names]
print(capitalized) # ['Alice', 'Bob', 'Charlie', 'David']
# 길이 3 이상인 이름만
filtered = [name.strip() for name in names if len(name.strip()) >= 3]
print(filtered) # ['alice', 'BOB', 'Charlie', 'david']
파일 경로 처리
import os
# 파일 목록에서 .txt 파일만 추출
files = ['data.txt', 'image.png', 'report.txt', 'video.mp4', 'notes.txt']
txt_files = [f for f in files if f.endswith('.txt')]
print(txt_files) # ['data.txt', 'report.txt', 'notes.txt']
# 확장자 제거
names_only = [os.path.splitext(f)[0] for f in txt_files]
print(names_only) # ['data', 'report', 'notes']
# 전체 경로 생성
base_path = '/home/user/documents'
full_paths = [os.path.join(base_path, f) for f in txt_files]
print(full_paths)
# ['/home/user/documents/data.txt',
# '/home/user/documents/report.txt',
# '/home/user/documents/notes.txt']
API 응답 처리
# API 응답 (JSON 형태)
api_response = {
'users': [
{'id': 1, 'name': 'Alice', 'active': True, 'age': 25},
{'id': 2, 'name': 'Bob', 'active': False, 'age': 30},
{'id': 3, 'name': 'Charlie', 'active': True, 'age': 35},
{'id': 4, 'name': 'David', 'active': True, 'age': 28}
]
}
# 활성 사용자 ID만 추출
active_ids = [
user['id']
for user in api_response['users']
if user['active']
]
print(active_ids) # [1, 3, 4]
# 활성 사용자 이름과 나이
active_users = [
{'name': user['name'], 'age': user['age']}
for user in api_response['users']
if user['active']
]
print(active_users)
# [{'name': 'Alice', 'age': 25},
# {'name': 'Charlie', 'age': 35},
# {'name': 'David', 'age': 28}]
# 30세 이상 활성 사용자
senior_active = [
user['name']
for user in api_response['users']
if user['active'] and user['age'] >= 30
]
print(senior_active) # ['Charlie']
메모리와 속도: 리스트 vs 제너레이터
메모리 효율성: 리스트 vs 제너레이터
리스트 컴프리헨션은 모든 요소를 메모리에 저장하지만, 제너레이터 표현식은 필요할 때만 생성합니다.
import sys
# 리스트 컴프리헨션: 전체 리스트를 메모리에 생성
squares_list = [i ** 2 for i in range(1000000)]
# 제너레이터 표현식: 필요할 때만 생성 (괄호 사용)
squares_gen = (i ** 2 for i in range(1000000))
# 메모리 사용량 비교
print(f"리스트: {sys.getsizeof(squares_list):,} bytes") # ~8,000,000 bytes (8MB)
print(f"제너레이터: {sys.getsizeof(squares_gen):,} bytes") # ~120 bytes
# 제너레이터는 한 번에 하나씩만 생성
for i, square in enumerate(squares_gen):
if i >= 5:
break
print(square) # 0, 1, 4, 9, 16
언제 제너레이터를 사용할까?
# 1. 큰 데이터를 한 번만 순회할 때
total = sum(i ** 2 for i in range(1000000)) # 제너레이터 사용
# 2. 파일 처리
with open('large_file.txt') as f:
# 각 줄의 단어 수 계산 (메모리 효율적)
word_counts = (len(line.split()) for line in f)
total_words = sum(word_counts)
# 3. 무한 시퀀스
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# 처음 10개만 (islice로 끊어야 함)
from itertools import islice
first_10 = list(islice(fibonacci(), 10))
print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
# 주의: [fib for i, fib in enumerate(fibonacci()) if i < 10]는
# if가 필터일 뿐 루프를 멈추지 않으므로 영원히 끝나지 않음
리스트가 필요한 경우:
# 1. 여러 번 순회해야 할 때
numbers = [i for i in range(10)]
print(sum(numbers)) # 첫 번째 순회
print(max(numbers)) # 두 번째 순회 (제너레이터는 소진되어 불가)
# 2. 인덱싱이 필요할 때
squares = [i ** 2 for i in range(10)]
print(squares[5]) # 25 (제너레이터는 인덱싱 불가)
# 3. 길이를 알아야 할 때
print(len(squares)) # 10 (제너레이터는 len() 불가)
성능 벤치마크
import time
# 테스트 데이터
data = list(range(1000000))
# 1. 리스트 컴프리헨션
start = time.time()
result1 = [x * 2 for x in data if x % 2 == 0]
time1 = time.time() - start
print(f"컴프리헨션: {time1:.4f}초")
# 2. 일반 for 루프 + append
start = time.time()
result2 = []
for x in data:
if x % 2 == 0:
result2.append(x * 2)
time2 = time.time() - start
print(f"for 루프: {time2:.4f}초")
# 3. map + filter
start = time.time()
result3 = list(map(lambda x: x * 2, filter(lambda x: x % 2 == 0, data)))
time3 = time.time() - start
print(f"map+filter: {time3:.4f}초")
# 대체로 컴프리헨션이 가장 빠르지만, 차이의 크기는 환경마다 다름
map과 filter에 lambda를 넘기면 요소마다 Python 함수 호출이 한 번씩 일어나서 컴프리헨션보다 느려지는 경우가 많습니다. 반대로 map(str, data)나 map(int, tokens)처럼 C로 구현된 내장 함수를 바로 넘길 때는 map이 비슷하거나 더 빠를 수 있습니다. 즉 “map은 느리다”가 아니라 “lambda를 끼우면 호출 비용이 붙는다”가 정확한 설명입니다.
컴프리헨션의 스코프도 알아 둘 만합니다. Python 3에서는 반복 변수가 바깥으로 새지 않아서, 컴프리헨션이 끝난 뒤 반복 변수를 참조하면 이전 값이 그대로 있거나 NameError가 납니다(Python 2와 다른 점). 그런데 클래스 본문에서는 이 별도 스코프 때문에 함정이 생깁니다. class C: 안에서 base = 10 다음에 vals = [base + i for i in range(3)]을 쓰면 NameError: name 'base' is not defined로 실패합니다. 컴프리헨션 내부는 클래스 스코프의 이름을 볼 수 없기 때문입니다(맨 왼쪽 반복 대상인 range(3)만 클래스 스코프에서 평가됩니다). 클래스 변수로 컬렉션을 만들어야 한다면 메서드나 모듈 레벨 함수에서 만드는 것이 안전합니다.
중첩 컴프리헨션 주의사항
# ❌ 너무 복잡: 3중 중첩
result = [
z
for x in range(10)
for y in range(10)
for z in range(10)
if x < y < z
]
# ✅ 개선: 함수로 분리
def is_ascending(x, y, z):
return x < y < z
result = [
z
for x in range(10)
for y in range(10)
for z in range(10)
if is_ascending(x, y, z)
]
# ✅ 더 나은 방법: itertools 사용
from itertools import combinations
result = [c[2] for c in combinations(range(10), 3)]
컴프리헨션과 for 루프, 언제 무엇을 쓸까
리스트 컴프리헨션은 장바구니에 담을 물건을 한 줄로 고르는 진열처럼 읽기 쉽습니다. 분기가 많아지거나 부작용(파일 쓰기 등)이 섞이면 일반 for 루프가 의도를 드러내기 쉽습니다. 한 줄에 조건이 너무 많이 붙으면 디버깅이 어려워지므로, 그때는 루프로 쪼개는 편이 낫습니다.
# ✅ 컴프리헨션 사용 (간단한 변환)
squares = [x ** 2 for x in range(10)]
# ✅ 반복문 사용 (복잡한 로직)
result = []
for x in range(10):
if x % 2 == 0:
temp = x ** 2
if temp > 20:
result.append(temp)
else:
result.append(temp * 2)
# ❌ 너무 복잡한 컴프리헨션 (가독성 나쁨)
result = [
x ** 2 if x % 2 == 0 and x ** 2 > 20 else x ** 2 * 2
for x in range(10) if x % 2 == 0
]
리스트 복사 등 흔한 실수
실수 1: 리스트 복사 문제
# ❌ 잘못된 방법: 같은 리스트를 여러 번 참조
matrix = [[0] * 3] * 3
matrix[0][0] = 1
print(matrix) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]] (모든 행이 변경됨!)
# ✅ 올바른 방법: 리스트 컴프리헨션으로 각 행을 독립적으로 생성
matrix = [[0] * 3 for _ in range(3)]
matrix[0][0] = 1
print(matrix) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]] (첫 행만 변경)
실수 2: 불필요한 리스트 생성
# ❌ 비효율: 리스트를 만들고 다시 합산
total = sum([i ** 2 for i in range(1000000)]) # 8MB 메모리 사용
# ✅ 효율적: 제너레이터로 바로 합산
total = sum(i ** 2 for i in range(1000000)) # 120 bytes만 사용
실수 3: 늦은 바인딩(late binding)되는 lambda
# ❌ 세 함수 모두 2를 반환
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2]
# ✅ 기본 인자로 현재 값을 고정
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]
lambda는 만들어질 때 i의 값을 복사하지 않고, 호출될 때 i라는 이름을 찾습니다. 컴프리헨션이 끝난 시점의 i는 마지막 값인 2이므로 세 함수가 모두 같은 결과를 냅니다. 콜백 목록이나 버튼 핸들러를 컴프리헨션으로 만들 때 자주 겪는 문제이며, 기본 인자나 functools.partial로 값을 고정해야 합니다.
실수 4: 부작용(Side Effect) 있는 함수 사용
# ❌ 나쁜 예: 컴프리헨션에서 부작용 발생
results = []
[results.append(x * 2) for x in range(10)] # append의 반환값(None)으로 리스트 생성
# ✅ 좋은 예: 일반 for 루프 사용
results = []
for x in range(10):
results.append(x * 2)
# ✅ 또는 컴프리헨션으로 직접 생성
results = [x * 2 for x in range(10)]
디버깅 팁
# 복잡한 컴프리헨션 디버깅
data = [1, 2, 3, 4, 5]
# 한 줄로 작성 (디버깅 어려움)
result = [x ** 2 for x in data if x % 2 == 0]
# 단계별로 분리 (디버깅 쉬움)
filtered = [x for x in data if x % 2 == 0]
print(f"필터링 결과: {filtered}") # [2, 4]
result = [x ** 2 for x in filtered]
print(f"최종 결과: {result}") # [4, 16]
데이터 정규화 등 활용 패턴
패턴 1: 데이터 정규화
# 이름 정규화
raw_names = [' ALICE ', 'bob', ' Charlie ', 'DAVID']
normalized = [name.strip().title() for name in raw_names]
print(normalized) # ['Alice', 'Bob', 'Charlie', 'David']
패턴 2: 조건부 집계
# 짝수의 합, 홀수의 합
numbers = range(1, 11)
even_sum = sum(x for x in numbers if x % 2 == 0)
odd_sum = sum(x for x in numbers if x % 2 == 1)
print(f"짝수 합: {even_sum}, 홀수 합: {odd_sum}") # 짝수 합: 30, 홀수 합: 25
패턴 3: 데이터 그룹화
# 점수별 학생 그룹화
students = [
{'name': '철수', 'score': 85},
{'name': '영희', 'score': 92},
{'name': '민수', 'score': 85},
{'name': '지영', 'score': 92}
]
# 점수별로 이름 그룹화
from collections import defaultdict
grouped = defaultdict(list)
for s in students: # 부작용이 목적이면 일반 루프
grouped[s['score']].append(s['name'])
print(dict(grouped)) # {85: ['철수', '민수'], 92: ['영희', '지영']}
# 대안: itertools.groupby (반드시 같은 키로 먼저 정렬해야 함)
from itertools import groupby
students_sorted = sorted(students, key=lambda s: s['score'])
grouped_better = {
score: [s['name'] for s in group]
for score, group in groupby(students_sorted, key=lambda s: s['score'])
}
print(grouped_better) # {85: ['철수', '민수'], 92: ['영희', '지영']}
index out of range, 키 중복, 컴프리헨션 안의 예외
”list index out of range”
# 문제 상황
data = [[1, 2], [3, 4, 5], [6]]
# result = [row[2] for row in data] # IndexError!
# 해결 1: 조건 추가
result = [row[2] for row in data if len(row) > 2]
print(result) # [5]
# 해결 2: 기본값 사용
result = [row[2] if len(row) > 2 else None for row in data]
print(result) # [None, 5, None]
딕셔너리 키 중복
# 문제: 마지막 값만 남음
items = [('a', 1), ('b', 2), ('a', 3)]
d = {k: v for k, v in items}
print(d) # {'a': 3, 'b': 2} (첫 번째 'a': 1이 덮어씌워짐)
# 해결: 값을 리스트로 수집
from collections import defaultdict
d = defaultdict(list)
for k, v in items:
d[k].append(v)
print(dict(d)) # {'a': [1, 3], 'b': [2]}
groupby는 연속된 같은 키만 묶기 때문에 정렬하지 않은 데이터에 쓰면 같은 점수가 여러 그룹으로 쪼개지고, 딕셔너리 컴프리헨션에서는 뒤 그룹이 앞 그룹을 덮어써 데이터가 사라집니다. 정렬 비용(O(n log n))까지 고려하면, 단순히 모으기만 할 때는 defaultdict 루프가 더 단순하고 빠른 경우가 많습니다.
컴프리헨션 내부에서 예외 처리
# 문제: 일부 데이터가 잘못된 형식
data = ['1', '2', 'three', '4', 'five']
# ❌ 에러 발생
# result = [int(x) for x in data] # ValueError: invalid literal for int()
# ✅ 해결 1: 조건으로 필터링
result = [int(x) for x in data if x.isdigit()]
print(result) # [1, 2, 4]
# 주의: '-3'처럼 부호가 붙은 값은 isdigit()이 False라 함께 걸러짐
# ✅ 해결 2: 함수로 예외 처리
def safe_int(x):
try:
return int(x)
except ValueError:
return None
result = [safe_int(x) for x in data]
print(result) # [1, 2, None, 4, None]
# None 제거
result_filtered = [x for x in result if x is not None]
print(result_filtered) # [1, 2, 4]
상황별 요약과 코드 리뷰 체크리스트
아래 표는 앞의 “컴프리헨션과 for 루프” 절에서 말한 “간단하면 컴프리헨션, 복잡하면 for”를 상황별로 정리한 것입니다. 예제 코드는 그 절과 동일하므로, 필요하면 그 절의 설명과 함께 참고하시면 됩니다.
| 상황 | 추천 방법 | 이유 |
|---|---|---|
| 간단한 변환/필터링 | 컴프리헨션 | 간결하고 빠름 |
| 복잡한 조건 로직 | 일반 for 루프 | 가독성, 디버깅 |
| 부작용(파일 쓰기, DB 저장) | 일반 for 루프 | 명확한 의도 표현 |
| 대용량 데이터 한 번 순회 | 제너레이터 | 메모리 효율 |
| 여러 번 순회 필요 | 리스트 컴프리헨션 | 재사용 가능 |
코드 리뷰 체크리스트
- 한 줄이 80자를 넘지 않는가? (PEP 8)
- 중첩이 2단계를 넘지 않는가?
- 다른 개발자가 5초 안에 이해할 수 있는가?
- 제너레이터로 대체 가능한가? (메모리 최적화)
- 예외 처리가 필요한가?
연습 문제
문제 1: 기본 변환
# 1부터 20까지 중 3의 배수의 제곱을 리스트로 만드세요
# 정답: [9, 36, 81, 144, 225, 324]
문제 2: 딕셔너리 변환
# 다음 리스트를 {이름: 나이} 딕셔너리로 변환하세요
people = [('Alice', 25), ('Bob', 30), ('Charlie', 35)]
# 정답: {'Alice': 25, 'Bob': 30, 'Charlie': 35}
문제 3: 중첩 리스트 처리
# 2차원 리스트에서 짝수만 추출하여 평탄화하세요
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
# 정답: [2, 4, 6, 8]
문제 4: 조건부 변환
# 온도 리스트를 "추움"(10 미만), "적당"(10-25), "더움"(25 초과)으로 분류하세요
temps = [5, 15, 30, 8, 22, 28]
# 정답: ['추움', '적당', '더움', '추움', '적당', '더움']
정답 보기
# 문제 1
multiples_of_3 = [x ** 2 for x in range(1, 21) if x % 3 == 0]
print(multiples_of_3)
# 문제 2
people_dict = {name: age for name, age in people}
print(people_dict)
# 문제 3
evens_flat = [num for row in matrix for num in row if num % 2 == 0]
print(evens_flat)
# 문제 4
labels = [
'추움' if t < 10 else '적당' if t <= 25 else '더움'
for t in temps
]
print(labels)
컴프리헨션 요약
- 리스트 컴프리헨션:
[표현식 for 변수 in 반복가능객체 if 조건]- 간결하고 빠른 리스트 생성 - 딕셔너리 컴프리헨션:
{키: 값 for ...}- 키-값 쌍 변환 - 세트 컴프리헨션:
{표현식 for ...}- 중복 제거 자동 - 제너레이터 표현식:
(표현식 for ...)- 메모리 효율적 - 가독성 우선: 복잡하면 일반 반복문 사용
기억할 기준
- 결과가 컬렉션 하나이고 조건이 한두 개라면 컴프리헨션, 부작용이나 여러 단계 분기가 있으면 일반 루프
- 한 번만 소비할 큰 데이터는 제너레이터 표현식, 여러 번 순회하거나 인덱싱하면 리스트
- 속도 차이는 반복당 호출 비용 수준이므로, 성능이 중요하면 추측하지 말고
timeit으로 측정
다음 단계
같이 보면 좋은 글
- Python 환경 설정 | Windows/Mac에서 Python 설치하고 시작하기
- Python 예외 처리
- Python 데코레이터
- Python 실전 시리즈 전체 목차 | #01~#23 학습 경로·영문 글·연관 글
- Python 자료형
- Python 함수
자주 묻는 질문 (FAQ)
Q. 컴프리헨션 안에서 일부 데이터 때문에 ValueError가 나면 어떻게 처리하나요?
A. 컴프리헨션 표현식 안에는 try-except를 직접 쓸 수 없기 때문에, [int(x) for x in data]는 잘못된 값 하나만 있어도 전체가 실패합니다. 간단한 경우라면 if x.isdigit() 같은 조건으로 먼저 걸러 내고, 판단이 복잡하다면 예외를 처리하는 safe_int 같은 함수를 만들어 컴프리헨션에서 호출합니다. 실패한 값을 None으로 반환하게 했다면 if x is not None으로 한 번 더 걸러 내면 됩니다.