Iterator·Generator: 큰 데이터를 메모리 없이 한 번에 하나씩만 다루는 법
리스트처럼 전체를 메모리에 올리지 않고도 데이터를 순서대로 처리할 수 있는 generator를 배운다. 예외 처리로 프로그램이 죽지 않게 만들고, 파일 입출력과 with 문으로 안전하게 파일을 열고 닫는 법도 함께 다룬다. 오늘 배운 것들은 나중에 대용량 학습 데이터를 다룰 때 그대로 쓰이는 기본기다.
오늘의 목표
LLM을 학습시킬 때 쓰는 데이터셋은 보통 수십 GB~TB 단위다. 이걸 리스트에 다 담으면 컴퓨터가 죽는다. 오늘은 "데이터 전체를 한꺼번에 메모리에 올리지 않고, 필요할 때 하나씩만 꺼내 쓰는" 방법을 익힌다. 그리고 파일을 다루다 보면 형식이 이상한 줄, 숫자가 아닌 값 같은 게 꼭 섞여 있는데, 프로그램이 이런 것 때문에 통째로 죽지 않게 예외 처리도 같이 배운다.
개념과 직관
iterable과 iterator
iterable(이터러블, "순회 가능한 것")은 for문으로 하나씩 꺼낼 수 있는 대상이다. 리스트, 문자열, 딕셔너리 모두 iterable이다.
iterator(이터레이터, "실제로 하나씩 꺼내주는 도구")는 iterable에서 iter()를 호출해서 얻는 객체로, next()를 호출할 때마다 다음 값을 하나씩 내놓는다. 더 이상 꺼낼 게 없으면 StopIteration이라는 예외를 던진다.
비유: 책(iterable)은 그냥 존재하는 정보 덩어리다. 책갈피를 꽂고 한 장씩 넘기는 사람(iterator)이 있어야 실제로 "다음 페이지"를 볼 수 있다. for문은 뒤에서 자동으로 책갈피 사람을 만들어서 페이지를 넘겨준다.
numbers = [1, 2, 3] # iterable
it = iter(numbers) # iterator 생성
print(next(it)) # 1
print(next(it)) # 2
print(next(it)) # 3
print(next(it)) # StopIteration 발생generator: yield로 만드는 게으른 데이터 공장
리스트는 만드는 순간 모든 값을 메모리에 다 채워 넣는다. generator(제너레이터, "값을 미리 다 만들지 않고 요청할 때마다 하나씩 만드는 함수")는 yield를 쓰면 함수가 값을 하나 내놓고 "그 자리에서 멈춘다". 다음 값이 필요할 때 다시 그 자리부터 실행을 이어간다.
비유: 리스트는 "미리 100만 개의 도시락을 다 만들어서 창고에 쌓아두는 것"이고, generator는 "주문이 들어올 때마다 그때그때 도시락 하나씩 만드는 것"이다.
def count_up_to(n):
i = 1
while i <= n:
yield i # 여기서 멈추고 값을 내보냄
i += 1
gen = count_up_to(3)
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 3yield가 하나라도 있으면 그 함수는 호출해도 바로 실행되지 않고 generator 객체를 반환한다. 실제 코드가 실행되는 건 next()를 부를 때다.
try·except·finally: 예외 처리
예외(exception, "실행 중 발생하는 오류 상황")가 나면 프로그램은 기본적으로 그 자리에서 멈춘다. try 블록에서 위험한 코드를 실행하고, except로 특정 오류를 잡아서 처리하고, finally는 오류가 나든 안 나든 항상 실행된다(파일 닫기 같은 마무리 작업에 씀).
def parse_int(text):
try:
return int(text)
except ValueError:
print(f"'{text}'는 숫자가 아니라서 건너뜁니다")
return None
finally:
pass # 항상 실행되는 부분 (여기선 딱히 할 일 없음)open · read · write · with
파일을 열 때는 open(경로, 모드)를 쓴다. "r"은 읽기, "w"는 쓰기(기존 내용 덮어씀), "a"는 추가. 파일은 다 쓰면 반드시 close()로 닫아야 하는데, 중간에 에러가 나면 닫는 코드를 놓치기 쉽다. with 문을 쓰면 블록이 끝나는 순간(에러가 나도!) 자동으로 파일이 닫힌다.
with open("data.txt", "r") as f:
for line in f: # 파일 객체 자체가 iterator다!
print(line.strip())
# 이 블록을 벗어나면 f는 자동으로 닫힌다여기서 중요한 포인트: open()으로 얻은 파일 객체는 그 자체로 iterator처럼 동작해서, 파일 전체를 메모리에 안 올리고 한 줄씩만 읽어온다. 이게 오늘의 핵심과 바로 연결된다.
코드로 직접 해보기
1) 파일을 한 줄씩 읽는 generator 만들기
def read_lines(path):
with open(path, "r") as f:
for line in f:
yield line.strip() # 줄바꿈 제거하고 하나씩 내보냄
for line in read_lines("data.txt"):
print(line)read_lines는 파일 전체를 읽어서 리스트로 반환하지 않는다. 호출한 쪽에서 한 줄을 요청할 때마다 딱 한 줄만 메모리에 올라온다.
2) 숫자가 아닌 입력을 예외로 처리하는 파서
def parse_numbers(lines):
for line in lines:
try:
yield int(line)
except ValueError:
print(f"건너뜀: '{line}'은 숫자가 아님")
continue
raw = ["10", "abc", "20", "", "30"]
for n in parse_numbers(raw):
print(n)generator 안에서도 yield와 try/except를 같이 쓸 수 있다. 잘못된 줄은 그냥 건너뛰고 나머지는 계속 처리한다.
3) CSV를 읽어 합계 내기
sample.csv 파일이 아래처럼 있다고 하자.
name,score
alice,90
bob,85
carol,notanumber
dave,70def read_scores(path):
with open(path, "r") as f:
header = f.readline() # 첫 줄(헤더)은 그냥 건너뜀
for line in f:
name, score_text = line.strip().split(",")
try:
yield name, int(score_text)
except ValueError:
print(f"'{name}'의 점수 '{score_text}'는 숫자가 아니라서 제외")
total = 0
count = 0
for name, score in read_scores("sample.csv"):
total += score
count += 1
print(f"합계: {total}, 인원: {count}, 평균: {total / count:.2f}")파일을 통째로 읽는 대신 한 줄씩 읽으면서 바로 합산하기 때문에, 파일이 아무리 커도 메모리 사용량은 거의 늘지 않는다.
오늘의 실험
리스트로 100만 개를 만드는 것과 generator로 같은 걸 다루는 것의 메모리 차이를 직접 눈으로 확인한다.
import sys
# 방법 1: 리스트로 100만 개
list_version = [i * i for i in range(1_000_000)]
print("리스트 크기(바이트):", sys.getsizeof(list_version))
# 방법 2: generator로 같은 것
gen_version = (i * i for i in range(1_000_000))
print("generator 크기(바이트):", sys.getsizeof(gen_version))관찰할 것:
- 리스트 버전의 크기(수 MB 단위)와 generator 버전의 크기(몇십~백 바이트 수준)를 비교하고 숫자를 그대로 기록한다.
range(1_000_000)을range(10_000_000),range(100_000_000)으로 늘려가며 리스트 버전의 크기가 어떻게 커지는지, generator 버전은 거의 안 변하는지 기록한다.- (선택)
sum(list_version)과sum(gen_version)둘 다 계산해서 결과값이 같은지, 시간이 얼마나 차이 나는지time모듈로 재본다.
기록 형식 예: "range 크기 / 리스트 sys.getsizeof / generator sys.getsizeof / 체감 속도" 표로 정리해두면 나중에 비교하기 좋다.
왜 이걸 배우나 (LLM / Post-training 연결)
LLM 학습 데이터는 보통 파일 하나에 다 안 들어가고, 전체를 메모리에 올릴 수도 없다. 실제 학습 파이프라인에서는 데이터를 파일에서 한 줄(또는 한 샘플)씩 읽어서 배치를 만드는데, 이게 오늘 만든 read_lines, parse_numbers 패턴과 원리가 완전히 같다. 나중에 PyTorch의 Dataset/DataLoader를 배울 때도 "필요한 만큼만 그때그때 꺼내온다"는 이 generator 사고방식이 그대로 이어지고, 데이터에 이상한 줄이 섞여 있을 때 전체 학습을 멈추지 않고 건너뛰는 예외 처리 습관도 여기서부터 시작된다.
초보자가 막히는 지점
- generator를 한 번 다 쓰면 끝:
for문으로 한 번 순회하고 나면 그 generator는 비어버린다. 다시 쓰려면 함수를 다시 호출해서 새 generator를 만들어야 한다. "왜 두 번째 for문에서는 아무것도 안 나오지?"라는 흔한 의문의 원인이다. - `yield`가 있으면 함수 호출이 즉시 실행 안 됨:
read_lines("data.txt")를 호출해도 파일이 바로 열리지 않는다(사실은for로 첫next()가 호출되는 순간 실행이 시작된다). 디버깅할 때 "왜 print가 안 찍히지" 하고 헷갈리기 쉽다. - except에서 너무 넓게 잡기:
except:만 쓰면 모든 종류의 에러를 다 삼켜버려서 진짜 버그도 조용히 숨어버린다.except ValueError:처럼 구체적으로 잡는 습관을 들이자. - with 문 밖에서 파일 객체 쓰기:
with블록이 끝나면 파일은 이미 닫혀 있다. 블록 밖에서f.read()를 시도하면 에러가 난다. - CSV를 직접 `split(",")`로 파싱할 때: 값 안에 쉼표가 들어있는 경우(예:
"seoul, korea") 이 방식이 깨진다. 오늘은 원리 학습이 목적이라 넘어가지만, 실제로는csv모듈을 쓴다는 것 정도만 기억해두자.
알고리즘 (병행 1시간)
- LeetCode Easy 1문제 + Medium 1문제(문자열 관련)를 푼다.
- 문제를 고를 때는 오늘 배운 개념과 억지로 연결하려 하지 말고, 순수하게 문제 해결 감각을 기르는 시간으로 쓴다.
- 풀 때마다 아래 세 가지를 짧게 기록해둔다:
- 접근: 어떤 방식으로 풀려고 했는지 (예: 투 포인터, 해시맵, 슬라이딩 윈도우 등) - 시간복잡도: 대략 O(n), O(n^2) 등으로 스스로 판단해보기 - 틀린 이유: 처음에 틀렸다면 왜 틀렸는지 (엣지 케이스를 놓쳤는지, 인덱스 실수인지, 문제를 잘못 이해했는지) — 이 기록이 쌓이면 나중에 자기가 자주 하는 실수 패턴이 보인다.