Python 강좌 3편 - 컨테이너와 제너레이터
컨테이너(Container)란?
파이썬에서 컨테이너(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" # 문자열도 컨테이너!
이들은 모두 여러 개의 데이터를 담고 있다는 공통점이 있습니다.
컨테이너의 특징
컨테이너는 두 가지 중요한 특징을 가지고 있습니다:
1. 멤버십 테스트 (Membership Test)
numbers = [1, 2, 3, 4, 5]
# "이 값이 컨테이너 안에 있나?" 확인
print(3 in numbers) # True
print(10 in numbers) # False
print(10 not in numbers) # True
in 연산자를 사용할 수 있는 것이 컨테이너의 첫 번째 특징입니다.
2. 반복 가능 (Iterable)
# 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
리스트 복사의 비밀
리스트를 복사할 때 주의해야 할 점이 있습니다.
# 얕은 복사 (Shallow Copy)
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]] - 내부 리스트는 공유됨!
# 깊은 복사 (Deep Copy)
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만 개의 숫자를 처리하고 싶습니다.
# 리스트 (Container)로 만들기
numbers = [i for i in range(1000000)] # 100만 개를 모두 메모리에!
for num in numbers:
print(num)
문제점:
- 100만 개의 숫자를 모두 메모리에 저장
- 실제로는 한 번에 하나씩만 필요한데 비효율적
제너레이터의 등장
제너레이터(Generator)는 값을 필요할 때마다 하나씩 만들어내는 똑똑한 Iterator입니다.
# 제너레이터 버전
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를 만나면:
1. 값을 반환
2. 함수를 일시정지
3. 다음 호출 때 이어서 실행
제너레이터의 장점
# 무한 수열도 만들 수 있습니다!
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
# 계속 가능...
리스트로는 불가능한 일입니다. 무한한 메모리가 필요하니까요!
for 루프와 제너레이터
for 루프의 원리
for 루프는 실제로 제너레이터를 사용합니다.
# 우리가 쓰는 코드
for num in [1, 2, 3]:
print(num)
# 실제로 일어나는 일
iterator = iter([1, 2, 3])
while True:
try:
num = next(iterator)
print(num)
except StopIteration:
break
for 루프로 제너레이터 만들기
def count_up_to(max):
count = 1
while count <= max:
yield count
count += 1
for num in count_up_to(5):
print(num)
# 1
# 2
# 3
# 4
# 5
while 루프 안에서 yield를 사용하면 값을 계속 생성할 수 있습니다.
실전 예제 1: 파일 읽기
큰 파일을 읽을 때 제너레이터가 유용합니다.
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)
실전 예제 2: 피보나치 수열
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
실전 예제 3: 데이터 필터링
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
while 루프와 제너레이터
while 루프로 더 복잡한 제너레이터를 만들 수 있습니다.
조건부 생성
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]
컨테이너 vs 제너레이터
언제 무엇을 사용해야 할까요?
컨테이너(리스트)를 사용할 때
# 1. 여러 번 접근이 필요할 때
data = [1, 2, 3, 4, 5]
print(data[2]) # 인덱스 접근
print(len(data)) # 길이 확인
print(data[:3]) # 슬라이싱
# 2. 데이터 크기가 작을 때
small_list = [x for x in range(100)] # OK
# 3. 전체 데이터가 필요할 때
sorted_data = sorted([3, 1, 2]) # 정렬은 전체 데이터 필요
# 4. 여러 번 순회가 필요할 때
for x in data:
print(x)
for x in data: # 다시 순회 가능!
print(x * 2)
제너레이터(Iterator)를 사용할 때
# 1. 데이터가 클 때
huge_data = (x for x in range(10000000)) # 메모리 절약
# 2. 한 번만 순회할 때
for item in huge_data:
process(item)
# 3. 무한 수열일 때
def infinite():
while True:
yield get_next_value()
# 4. 파이프라인 처리 (체인)
data = (x for x in range(100))
filtered = (x for x in data if x % 2 == 0)
squared = (x**2 for x in filtered)
# 5. 지연 평가(Lazy Evaluation)가 필요할 때
# 필요한 순간까지 계산을 미룸
정리
배운 개념의 계층 구조
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)
- 정의: 여러 객체를 담을 수 있는 자료구조
- 특징:
- 멤버십 테스트 (
in연산자) - 반복 가능 (Iterable)
- 종류: list, tuple, set, dict, str
리스트 (List)
- 내부 구조: 동적 배열로 구현
- capacity: 미리 공간을 확보해서 효율성 향상
- 시간 복잡도:
- 빠른 것: 인덱스 접근, 끝에 추가/제거
- 느린 것: 중간 삽입/제거, 검색
- 복사 주의: 얕은 복사 vs 깊은 복사
Iterator와 Iterable
- Iterable:
iter()를 호출하면 Iterator 반환 - Iterator:
next()를 호출하면 다음 값 반환 - for 루프: 내부적으로
iter()와next()사용 - 차이점: Iterable은 여러 번 순회 가능, Iterator는 한 번만
제너레이터 (Generator)
- 정의: Iterator를 쉽게 만드는 방법
- yield: 값을 하나씩 반환하고 상태를 유지
- 장점: 메모리 효율적, 무한 수열 가능
- 사용법:
for루프와yieldwhile루프와yield(조건부 생성)- 제너레이터 표현식
(x for x in ...)
언제 무엇을 사용할까?
| 상황 | 사용할 것 | 이유 |
|---|---|---|
| 작은 데이터 | Container (List) | 빠르고 편리 |
| 여러 번 순회 | Container (List) | 재사용 가능 |
| 인덱스 접근 | Container (List) | 랜덤 액세스 |
| 큰 데이터 | Generator | 메모리 절약 |
| 한 번만 순회 | Generator | 효율적 |
| 무한 수열 | Generator | 필수 |
| 파이프라인 | Generator | 체인 구성 |