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)

컴프리헨션 요약

  1. 리스트 컴프리헨션: [표현식 for 변수 in 반복가능객체 if 조건] - 간결하고 빠른 리스트 생성
  2. 딕셔너리 컴프리헨션: {키: 값 for ...} - 키-값 쌍 변환
  3. 세트 컴프리헨션: {표현식 for ...} - 중복 제거 자동
  4. 제너레이터 표현식: (표현식 for ...) - 메모리 효율적
  5. 가독성 우선: 복잡하면 일반 반복문 사용

기억할 기준

  • 결과가 컬렉션 하나이고 조건이 한두 개라면 컴프리헨션, 부작용이나 여러 단계 분기가 있으면 일반 루프
  • 한 번만 소비할 큰 데이터는 제너레이터 표현식, 여러 번 순회하거나 인덱싱하면 리스트
  • 속도 차이는 반복당 호출 비용 수준이므로, 성능이 중요하면 추측하지 말고 timeit으로 측정

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 컴프리헨션 안에서 일부 데이터 때문에 ValueError가 나면 어떻게 처리하나요?

A. 컴프리헨션 표현식 안에는 try-except를 직접 쓸 수 없기 때문에, [int(x) for x in data]는 잘못된 값 하나만 있어도 전체가 실패합니다. 간단한 경우라면 if x.isdigit() 같은 조건으로 먼저 걸러 내고, 판단이 복잡하다면 예외를 처리하는 safe_int 같은 함수를 만들어 컴프리헨션에서 호출합니다. 실패한 값을 None으로 반환하게 했다면 if x is not None으로 한 번 더 걸러 내면 됩니다.