모던 파이썬 기초 #4 컬렉션과 컴프리헨션

6 분 소요

#3 제어 흐름에서 마지막에 살짝 본 컴프리헨션을 본격적으로 다룰 차례입니다. 그 전에 파이썬의 네 가지 핵심 컬렉션list, tuple, dict, set의 쓰임새부터 정리합니다.

한 표로 보는 비교 #

변경 가능순서중복키-값표기
listOO허용X[1, 2, 3]
tupleXO허용X(1, 2, 3)
setOX불허X{1, 2, 3}
dictO키 uniqueO{"a": 1}

¹ Python 3.7+ 부터 dict는 삽입 순서를 보장합니다.

list — 가장 많이 쓰는 컬렉션 #

list 기본
nums: list[int] = [1, 2, 3]

nums.append(4)         # [1, 2, 3, 4]
nums.insert(0, 0)      # [0, 1, 2, 3, 4]
nums.remove(2)         # [0, 1, 3, 4]
last = nums.pop()      # 4 반환, 리스트는 [0, 1, 3]

print(len(nums))       # 3
print(nums[0])         # 0
print(3 in nums)       # True

슬라이스 — [start:stop:step] #

리스트의 진가는 슬라이스에 있습니다.

슬라이스
items = [10, 20, 30, 40, 50]

items[1:3]    # [20, 30]    1 이상 3 미만
items[:2]     # [10, 20]    처음부터 2 미만
items[3:]     # [40, 50]    3 이상부터 끝까지
items[-2:]    # [40, 50]    끝에서 2개
items[::2]    # [10, 30, 50]  step 2
items[::-1]   # [50, 40, 30, 20, 10]   뒤집기

step = -1로 리스트를 뒤집는 패턴은 자주 등장합니다. JavaScript의 arr.toReversed()보다 짧고 빠릅니다.

슬라이스는 새 리스트를 만들어 반환 합니다. 원본은 안 바뀝니다.

+* — 결합과 반복 #

결합과 반복
[1, 2] + [3, 4]   # [1, 2, 3, 4]
[0] * 5           # [0, 0, 0, 0, 0]

[0] * 5는 0으로 채운 리스트를 만드는 가장 짧은 방법입니다. 다만 참조 타입을 곱하면 모두 같은 객체가 됩니다.

함정 — 가변 객체 곱하기
matrix = [[]] * 3
matrix[0].append(1)
print(matrix)
# [[1], [1], [1]]   ← 모두 같은 리스트를 가리킴!

이런 경우는 컴프리헨션을 써야 합니다 (아래에서 다룸).

tuple — 모양이 정해진 묶음 #

list와 거의 같은데 변경 불가입니다. 그 대신 쓰임새가 다릅니다.

tuple 기본
point: tuple[float, float] = (1.0, 2.0)
person: tuple[str, int] = ("커티스", 30)

# tuple은 보통 unpack으로 풀어서 씀
name, age = person
print(name, age)   # 커티스 30

# 한 원소 tuple은 콤마가 필수 — 괄호만으론 부족
single = (42,)     # tuple
not_tuple = (42)   # 그냥 정수

tuple이 어울리는 경우 #

  • 여러 값을 함께 반환할 때: return name, age (사실은 tuple)
  • 딕셔너리 키: list는 키로 못 쓰지만 tuple은 됨 (불변이라)
  • 모양이 정해진 좌표/날짜 같은 데이터 — list보다 의도가 분명

더 명시적인 tuple — NamedTuple #

위치가 정해진 tuple은 시간이 지나면 헷갈립니다 (person[0]이 이름이었던가?). 이름을 붙인 tuple을 쓰면 깔끔합니다.

NamedTuple
from typing import NamedTuple

class Person(NamedTuple):
    name: str
    age: int

p = Person("커티스", 30)
print(p.name, p.age)   # 커티스 30

# tuple처럼 unpack도 됨
name, age = p

dict — 키-값 매핑 #

dict 기본
user: dict[str, int] = {"id": 1, "age": 30}

print(user["id"])              # 1
print(user.get("nope"))        # None  (없는 키 → 안전)
print(user.get("nope", -1))    # -1    (기본값)

user["name"] = "curtis"        # 추가/수정
del user["age"]                # 삭제
print("name" in user)          # True

user["없는키"]KeyError 예외를 던집니다. 안전하게 꺼내려면 .get()을 쓰세요.

순회 #

dict 순회
for key in user:                # 키만
    print(key)

for key, value in user.items(): # 둘 다
    print(key, value)

for value in user.values():     # 값만
    print(value)

.items()는 거의 매일 씁니다.

합치기 — | 연산자 (3.9+) #

dict 합치기
defaults = {"a": 1, "b": 2}
overrides = {"b": 20, "c": 30}

merged = defaults | overrides
print(merged)    # {"a": 1, "b": 20, "c": 30}

|는 오른쪽이 이김 — 같은 키면 뒤쪽 값이 살아남습니다. JavaScript의 {...defaults, ...overrides}와 같은 의미.

set — 중복 없는 묶음 #

set 기본
unique: set[int] = {1, 2, 3, 2, 1}
print(unique)   # {1, 2, 3}

unique.add(4)      # {1, 2, 3, 4}
unique.discard(2)  # {1, 3, 4}
print(3 in unique) # True

# 빈 set은 set() — {} 는 빈 dict
empty = set()

집합 연산 #

집합 연산
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}

a | b   # 합집합  {1, 2, 3, 4, 5, 6}
a & b   # 교집합  {3, 4}
a - b   # 차집합  {1, 2}
a ^ b   # 대칭차  {1, 2, 5, 6}

리스트 중복 제거의 가장 짧은 방법: list(set(items)). 다만 순서가 깨질 수 있습니다. 순서를 유지하면서 중복 제거하려면:

순서 보존 중복 제거
items = [1, 2, 1, 3, 2, 4]
unique = list(dict.fromkeys(items))
print(unique)   # [1, 2, 3, 4]

dict.fromkeys()가 dict의 순서 보존 + 키 unique 성질을 동시에 활용합니다.

컴프리헨션 — 한 줄로 컬렉션 만들기 #

for 루프와 조건을 한 줄에 합치는 문법입니다. 파이썬에서 가장 자주 쓰는 패턴 중 하나 입니다.

리스트 컴프리헨션 #

기본 형태
# [표현식 for 변수 in이터러블]

squares = [x ** 2 for x in range(5)]
# [0, 1, 4, 9, 16]

for 루프로 풀어 쓰면:

같은 코드, 풀어서
squares = []
for x in range(5):
    squares.append(x ** 2)

다섯 줄짜리가 한 줄로 줄어들고, 한 번에 의도가 보여 읽기도 더 쉬워집니다.

조건 — 필터 #

if 절
evens = [x for x in range(10) if x % 2 == 0]
# [0, 2, 4, 6, 8]

if는 필터입니다. 조건에 맞는 원소만 들어갑니다.

변환 + 필터 동시 #

둘 다 동시에
# 짝수만 골라서 제곱
result = [x ** 2 for x in range(10) if x % 2 == 0]
# [0, 4, 16, 36, 64]

if-else 표현식 (위치에 주의) #

if-else표현식 위치에 들어갑니다. 필터의 if와 위치가 다릅니다.

조건 표현식
labels = ["even" if x % 2 == 0 else "odd" for x in range(5)]
# ['even', 'odd', 'even', 'odd', 'even']

순서는 [표현식 if cond else 다른표현식 for x in iter]. 헷갈리는 부분이니 천천히 읽으세요.

중첩 — 2D 만들기 #

중첩 컴프리헨션 — 2D 매트릭스
matrix = [[0 for _ in range(3)] for _ in range(3)]
# [[0, 0, 0], [0, 0, 0], [0, 0, 0]]

앞에서 본 [[0]] * 3의 함정을 컴프리헨션이 해결합니다. 매번 새 리스트가 만들어지니 서로 독립입니다.

딕셔너리 컴프리헨션 #

dict comprehension
square_map = {x: x ** 2 for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

# 키-값 swap
user = {"id": 1, "name": "curtis"}
swapped = {v: k for k, v in user.items()}
# {1: "id", "curtis": "name"}

세트 컴프리헨션 #

set comprehension
unique_lengths = {len(w) for w in ["a", "bb", "cc", "ddd"]}
# {1, 2, 3}

함정 — 중첩 for의 순서 #

중첩 for
pairs = [(x, y) for x in [1, 2] for y in ['a', 'b']]
# [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]

여러 for가 있을 때 왼쪽이 바깥 루프입니다. 풀어 쓰면:

같은 동작 풀어쓰기
pairs = []
for x in [1, 2]:
    for y in ['a', 'b']:
        pairs.append((x, y))

제너레이터 표현식 — 메모리를 아낀다 #

대괄호 대신 소괄호를 쓰면 컴프리헨션이 아니라 제너레이터가 됩니다.

제너레이터 식
gen = (x ** 2 for x in range(1_000_000))
print(gen)   # <generator object ...>

리스트 컴프리헨션은 즉시 모든 원소를 만듭니다. 100만 개면 100만 개분의 메모리를 잡습니다. 제너레이터는 요청할 때만 다음 값을 만들기 때문에 메모리가 거의 안 듭니다.

합계 — 제너레이터로 충분
total = sum(x ** 2 for x in range(1_000_000))
# 함수에 바로 넣으면 ()를 생략할 수 있음

sum, max, any, all같이 한 번 순회만 하면 되는 함수에 넘길 때가 제너레이터의 적합한 용도입니다.

리스트 컴프리헨션제너레이터 식
표기[ ... ]( ... )
메모리모든 원소 즉시 생성필요할 때만
재사용여러 번 순회 가능한 번만 순회 가능
인덱스 접근O result[3]X

언제 컴프리헨션, 언제 풀어쓰기? #

컴프리헨션이 항상 좋은 건 아닙니다. 로직이 복잡해지면 풀어쓰는 게 더 읽기 좋습니다.

이건 풀어쓰자
# 한 줄로 가능은 한데, 읽기 어려움
result = [transform(x) for x in items if validate(x) and is_active(x) and not is_deleted(x)]

# 풀어 쓰면 디버그하기 좋고 읽기도 좋음
result = []
for x in items:
    if not validate(x):
        continue
    if not is_active(x) or is_deleted(x):
        continue
    result.append(transform(x))

판단 기준은: 한 줄에 한 표현식만, if는 한 개까지 정도가 컴프리헨션이 빛나는 범위입니다. 그 이상은 풀어쓰는 게 보통 낫습니다.

정리 #

이번 글에서 정리한 것:

  • list (가변, 순서, 중복) — 슬라이스 [start:stop:step]가 강력
  • tuple (불변, 모양 고정) — 다중 반환,dict 키,NamedTuple
  • dict (키-값, 순서 보장) — .get(), .items(), | 합치기
  • set (중복 없음, 순서 없음) — 집합 연산 | & - ^
  • 리스트 / 딕셔너리 / 세트 컴프리헨션 — [표현 for x in iter if cond]
  • if-else는 표현식 위치, if는 필터 위치
  • 제너레이터 식 (x for x in iter) — 메모리 효율, 한 번 순회
  • 너무 복잡하면 컴프리헨션을 풀어쓰는 쪽이 낫습니다

다음 글(#5 함수 — 인자 패턴)에서는 함수 정의의 다양한 인자 패턴을 다룹니다. positional-only, keyword-only, *args / **kwargs 같은 표기까지 짚습니다.

X