Python 강좌 3편 - 컨테이너와 제너레이터
파이썬의 컨테이너 개념부터 리스트의 내부 구조, 그리고 제너레이터를 이용한 효율적인 반복 처리까지 배워봅니다
컨테이너란?
파이썬에서 컨테이너(Container)는 다른 객체들을 담을 수 있는 자료구조다.
# 여러 가지 컨테이너들
my_list = [1, 2, 3, 4, 5] # 리스트
my_tuple = (1, 2, 3) # 튜플
my_set = {1, 2, 3} # 세트
my_dict = {"name": "Python"} # 딕셔너리
my_string = "hello" # 문자열도 컨테이너!
이들의 공통점은 여러 개의 데이터를 담고 있다는 것이다.
컨테이너의 특징
컨테이너에는 두 가지 중요한 특징이 있다.
첫째, 멤버십 테스트가 가능하다.
numbers = [1, 2, 3, 4, 5]
# "이 값이 컨테이너 안에 있나?"
print(3 in numbers) # True
print(10 in numbers) # False
print(10 not in numbers) # True
in 연산자를 쓸 수 있으면 컨테이너다.
둘째, 반복이 가능하다.
# for 루프로 하나씩 꺼낼 수 있다
for num in numbers:
print(num)
파이썬의 주요 컨테이너
| 컨테이너 | 순서 유지 | 중복 허용 | 변경 가능 | 용도 |
|---|---|---|---|---|
| list | O | O | O | 순서 있는 데이터 모음 |
| tuple | O | O | X | 변경 불가능한 데이터 |
| set | X | X | O | 중복 없는 데이터 |
| dict | O (3.7+) | X (키) | O | 키-값 쌍 |
| str | O | O | X | 문자 시퀀스 |
이 중에서 가장 많이 쓰이는 건 리스트(List)다.
리스트 - 가장 많이 쓰는 컨테이너
리스트는 파이썬에서 제일 자주 쓰는 컨테이너다.
numbers = [1, 2, 3, 4, 5]
fruits = ["apple", "banana", "orange"]
mixed = [1, "hello", 3.14, True] # 다양한 타입 가능!
메모리에서 리스트는 어떻게 생겼을까?
리스트를 만들면 파이썬은 메모리에 어떻게 저장할까?
my_list = [10, 20, 30]
리스트는 실제로 배열(Array)로 구현된다. 하지만 일반적인 배열과는 조금 다르다.
메모리 구조:
리스트 객체:
┌─────────────┐
│ size: 3 │ ← 현재 원소 개수
│ capacity: 4 │ ← 할당된 공간 크기
│ items: ───┐ │ ← 실제 원소들을 가리키는 포인터
└───────────┼─┘
│
▼
원소 배열:
┌────┬────┬────┬────┐
│ • │ • │ • │ │
└─┼──┴─┼──┴─┼──┴────┘
│ │ │
▼ ▼ ▼
10 20 30
리스트는 size(실제 원소 개수)와 capacity(확보된 공간 크기) 두 가지 정보를 갖고 있다.
왜 capacity가 size보다 클까?
원소를 추가할 때마다 메모리를 다시 할당하면 너무 느리다.
그래서 파이썬은 미리 여유 공간을 확보해둔다.
my_list = [] # capacity: 0
my_list.append(1) # capacity: 4 (한 번에 4칸 확보)
my_list.append(2) # capacity: 4 (아직 여유 있음)
my_list.append(3) # capacity: 4
my_list.append(4) # capacity: 4
my_list.append(5) # capacity: 8 (공간 부족, 2배로 증가)
이런 방식을 동적 배열(Dynamic Array)이라 한다.
리스트 연산의 속도
어떤 연산이 빠르고 어떤 연산이 느린지 알아두면 좋다.
빠른 연산 (O(1) -- 상수 시간):
# 인덱스로 접근
value = my_list[2] # 매우 빠름
# 끝에 추가
my_list.append(10) # 평균적으로 빠름
# 끝에서 제거
my_list.pop() # 빠름
느린 연산 (O(n) -- 리스트 크기에 비례):
# 중간에 삽입
my_list.insert(0, 5) # 느림 (모든 원소를 한 칸씩 이동)
# 중간에서 제거
my_list.pop(0) # 느림 (모든 원소를 한 칸씩 당김)
# 원소 검색
if 10 in my_list: # 느림 (처음부터 끝까지 검색)
pass
리스트 복사 -- 주의할 점
이건 정말 많이들 헷갈려한다.
# 이건 복사가 아니다
original = [1, 2, 3]
copy1 = original # 같은 리스트를 가리킴!
copy1.append(4)
print(original) # [1, 2, 3, 4] - 원본도 변경됨!
# 진짜 복사
copy2 = original.copy() # 또는 original[:]
copy2.append(5)
print(original) # [1, 2, 3, 4] - 원본은 그대로
중첩 리스트는 더 까다롭다.
# 2차원 리스트
matrix = [[1, 2], [3, 4]]
shallow = matrix.copy()
shallow[0].append(99)
print(matrix) # [[1, 2, 99], [3, 4]] - 내부 리스트는 공유됨!
# 깊은 복사
import copy
deep = copy.deepcopy(matrix)
deep[0].append(100)
print(matrix) # [[1, 2, 99], [3, 4]] - 원본은 안전
Iterator와 Iterable
컨테이너 순회의 원리를 제대로 이해하려면 Iterator와 Iterable을 알아야 한다.
Iterable이란?
Iterable은 for 루프에 넣을 수 있는 모든 것이다.
# 이것들은 모두 Iterable
for x in [1, 2, 3]: # 리스트
print(x)
for x in (1, 2, 3): # 튜플
print(x)
for x in "hello": # 문자열
print(x)
for x in {1, 2, 3}: # 세트
print(x)
Iterator란?
Iterator는 실제로 값을 하나씩 꺼내주는 객체다.
numbers = [1, 2, 3]
# iter()로 Iterator 만들기
iterator = iter(numbers)
# next()로 값을 하나씩 꺼내기
print(next(iterator)) # 1
print(next(iterator)) # 2
print(next(iterator)) # 3
print(next(iterator)) # StopIteration 에러!
for 루프의 비밀
매일 쓰는 for 루프가 내부적으로는 이렇게 작동한다.
# 우리가 쓰는 코드
for num in [1, 2, 3]:
print(num)
# 실제로 파이썬이 하는 일
iterator = iter([1, 2, 3]) # Iterable을 Iterator로 변환
while True:
try:
num = next(iterator) # 다음 값 꺼내기
print(num)
except StopIteration: # 더 이상 값이 없으면 종료
break
Iterable vs Iterator
┌──────────────┐
│ Iterable │ "반복 가능한 객체" (list, tuple, str 등)
└──────┬───────┘
│ iter()
▼
┌──────────────┐
│ Iterator │ "값을 하나씩 꺼내주는 객체"
└──────┬───────┘
│ next()
▼
값 반환
핵심은 이거다. Iterable에 iter()를 호출하면 Iterator가 나온다. Iterator에 next()를 호출하면 다음 값이 나온다.
직접 만들어보기
class CountUp:
"""1부터 n까지 세는 Iterable"""
def __init__(self, max):
self.max = max
def __iter__(self):
"""Iterator를 반환"""
return CountUpIterator(self.max)
class CountUpIterator:
"""실제로 값을 반환하는 Iterator"""
def __init__(self, max):
self.max = max
self.current = 0
def __next__(self):
"""다음 값 반환"""
if self.current >= self.max:
raise StopIteration
self.current += 1
return self.current
# 사용
counter = CountUp(5)
for num in counter:
print(num)
# 1, 2, 3, 4, 5
코드가 좀 길다. 훨씬 간단한 방법이 있다. 바로 제너레이터다.
제너레이터 - 똑똑한 Iterator
문제: 메모리 낭비
100만 개의 숫자를 처리하고 싶다.
# 리스트로 만들기
numbers = [i for i in range(1000000)] # 100만 개를 모두 메모리에!
for num in numbers:
print(num)
100만 개를 전부 메모리에 올려놓는다. 실제로는 한 번에 하나씩만 필요한데. 비효율적이다.
제너레이터의 등장
제너레이터는 값을 필요할 때마다 하나씩 만들어낸다.
# 제너레이터 버전
def number_generator():
for i in range(1000000):
yield i # 값을 하나씩 반환
for num in number_generator():
print(num)
차이가 극적이다.
| 리스트 (Container) | 제너레이터 (Iterator) | |
|---|---|---|
| 메모리 | 모든 값을 저장 (100만 개) | 하나씩만 생성 (1개) |
| 속도 | 초기 생성 느림 | 즉시 시작 |
| 재사용 | 여러 번 순회 가능 | 한 번만 순회 가능 |
제너레이터는 Iterator를 쉽게 만드는 방법이다.
yield 키워드
yield는 return과 비슷하지만, 함수의 상태를 기억한다.
def simple_generator():
print("첫 번째 값 생성")
yield 1
print("두 번째 값 생성")
yield 2
print("세 번째 값 생성")
yield 3
gen = simple_generator()
print(next(gen)) # "첫 번째 값 생성" → 1
print(next(gen)) # "두 번째 값 생성" → 2
print(next(gen)) # "세 번째 값 생성" → 3
yield를 만나면 값을 반환하고, 함수를 일시정지하고, 다음 호출 때 그 자리에서 이어서 실행한다.
무한 수열도 가능하다
def infinite_numbers():
num = 0
while True:
yield num
num += 1
gen = infinite_numbers()
print(next(gen)) # 0
print(next(gen)) # 1
print(next(gen)) # 2
# 계속 가능...
리스트로는 불가능하다. 무한한 메모리가 필요하니까.
실전 제너레이터 예제
큰 파일 읽기
제너레이터는 큰 파일을 읽을 때 빛난다.
def read_large_file(file_path):
"""파일을 한 줄씩 읽는 제너레이터"""
with open(file_path) as file:
for line in file:
yield line.strip()
# 100GB 파일도 메모리 부담 없이 처리
for line in read_large_file("huge_file.txt"):
process(line)
피보나치 수열
def fibonacci():
"""무한 피보나치 수열 생성기"""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# 처음 10개만 가져오기
fib = fibonacci()
for _ in range(10):
print(next(fib))
# 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
데이터 필터링
def even_numbers(numbers):
"""짝수만 반환하는 제너레이터"""
for num in numbers:
if num % 2 == 0:
yield num
for num in even_numbers(range(10)):
print(num)
# 0, 2, 4, 6, 8
제너레이터 표현식
리스트 컴프리헨션과 비슷한데 [] 대신 ()를 쓴다.
# 리스트 컴프리헨션 (메모리 많이 사용)
squares_list = [x**2 for x in range(1000000)]
# 제너레이터 표현식 (메모리 절약)
squares_gen = (x**2 for x in range(1000000))
# 필요할 때만 계산
print(next(squares_gen)) # 0
print(next(squares_gen)) # 1
print(next(squares_gen)) # 4
더 복잡한 제너레이터 패턴
조건부 생성
def numbers_until_condition(limit):
"""합이 limit를 넘을 때까지 숫자 생성"""
total = 0
num = 1
while total < limit:
yield num
total += num
num += 1
for n in numbers_until_condition(20):
print(n)
# 1, 2, 3, 4, 5 (1+2+3+4+5=15, 6을 더하면 21이므로 중단)
상태를 가진 제너레이터
def countdown(start):
"""카운트다운 제너레이터"""
current = start
while current > 0:
yield current
current -= 1
yield "발사!"
for count in countdown(5):
print(count)
# 5, 4, 3, 2, 1, 발사!
제너레이터 체이닝
def first_n(generator, n):
"""제너레이터에서 처음 n개만 가져오기"""
count = 0
while count < n:
yield next(generator)
count += 1
# 무한 제너레이터 + 제한
def all_numbers():
num = 0
while True:
yield num
num += 1
limited = first_n(all_numbers(), 5)
print(list(limited)) # [0, 1, 2, 3, 4]
언제 뭘 써야 할까?
리스트는 여러 번 접근이 필요하거나, 데이터가 작거나, 정렬이 필요하거나, 여러 번 순회해야 할 때 쓴다.
data = [1, 2, 3, 4, 5]
print(data[2]) # 인덱스 접근
print(len(data)) # 길이 확인
print(data[:3]) # 슬라이싱
for x in data:
print(x)
for x in data: # 다시 순회 가능!
print(x * 2)
제너레이터는 데이터가 크거나, 한 번만 순회하거나, 무한 수열이거나, 파이프라인을 구성할 때 쓴다.
# 큰 데이터
huge_data = (x for x in range(10000000)) # 메모리 절약
# 파이프라인 처리
data = (x for x in range(100))
filtered = (x for x in data if x % 2 == 0)
squared = (x**2 for x in filtered)
전체 그림
이 개념들이 어떻게 연결되는지 보자.
graph TD
A["Container
가장 일반적인 개념
데이터를 담는 자료구조
list, tuple, set, dict, str"]
B["Iterable
반복 가능한 객체
for 루프에 넣을 수 있음
iter()를 호출하면 Iterator 반환"]
C["Iterator
값을 하나씩 꺼냄
next()로 다음 값 반환
한 번만 순회 가능"]
D["Generator
Iterator의 간편 버전
yield로 값을 생성
메모리 효율적, 무한 수열 가능"]
A -->|"모든 Container는..."| B
B -->|"iter()"| C
C -->|"쉽게 만드는 방법"| D
style A fill:#e3f2fd
style B fill:#fff3e0
style C fill:#f3e5f5
style D fill:#e8f5e9
모든 Container는 Iterable이다. Iterable에 iter()를 호출하면 Iterator가 나온다. 그리고 Generator는 Iterator를 쉽게 만드는 방법이다. 이게 전체 계층 구조다.