컨테이너란?

파이썬에서 컨테이너(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)

파이썬의 주요 컨테이너

컨테이너순서 유지중복 허용변경 가능용도
listOOO순서 있는 데이터 모음
tupleOOX변경 불가능한 데이터
setXXO중복 없는 데이터
dictO (3.7+)X (키)O키-값 쌍
strOOX문자 시퀀스

이 중에서 가장 많이 쓰이는 건 리스트(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

컨테이너 순회의 원리를 제대로 이해하려면 IteratorIterable을 알아야 한다.

Iterable이란?

Iterablefor 루프에 넣을 수 있는 모든 것이다.

# 이것들은 모두 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()
       ▼
     값 반환

핵심은 이거다. Iterableiter()를 호출하면 Iterator가 나온다. Iteratornext()를 호출하면 다음 값이 나온다.

직접 만들어보기

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 키워드

yieldreturn과 비슷하지만, 함수의 상태를 기억한다.

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를 쉽게 만드는 방법이다. 이게 전체 계층 구조다.