- Published on
Python Generator close·throw로 누수 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/배치 코드에서 제너레이터는 메모리 효율과 지연 평가 덕분에 자주 쓰입니다. 문제는 제너레이터가 “끝까지 소비된다”는 가정이 깨질 때입니다. 예를 들어 break로 루프를 탈출하거나, 예외가 중간에 터지거나, 호출자가 더 이상 값을 원하지 않아 이터레이션을 멈추면, 제너레이터 내부에서 열어둔 파일/커넥션/락이 예상대로 닫히지 않을 수 있습니다.
이 글은 close()와 throw()를 중심으로, **제너레이터 기반 코드에서 발생하는 리소스 누수(파일 디스크립터, DB 커넥션, 락 미해제 등)**를 어떻게 확실하게 잡을지 다룹니다. 특히 “정상 종료”만 고려한 제너레이터가 실무에서 어떻게 누수로 이어지는지, 그리고 어떤 형태로 설계를 바꾸면 안전해지는지에 집중합니다.
관련해서 인프라 레벨의 누수도 비슷한 양상으로 나타납니다. 예를 들어 네트워크 리소스가 회수되지 않아 고갈되는 케이스는 EKS VPC CNI IP 누수로 Pod IP 고갈 해결하기 같은 글에서 다루는 문제와 결이 같습니다. “정리 경로를 강제하지 않으면 언젠가 고갈된다”는 점이 동일합니다.
제너레이터 종료 시그널: GeneratorExit와 close()
파이썬 제너레이터는 종료를 통보받을 수 있는 메커니즘이 있습니다.
- 호출자가
gen.close()를 호출하면, 제너레이터 내부로GeneratorExit예외가 주입됩니다. - for-loop가 제너레이터를 끝까지 소비하면
StopIteration으로 종료됩니다. - 호출자가 더 이상 참조하지 않으면 가비지 컬렉션 시점에 정리될 수도 있지만, 시점이 비결정적이므로 리소스 정리에 의존하면 위험합니다.
핵심은 다음입니다.
- 제너레이터 내부에서
try/finally로 정리 코드를 두면,close()로 종료되더라도finally가 실행됩니다. GeneratorExit를 잡아서 무언가를 할 수는 있지만, 일반적으로는finally에서 정리하는 것이 가장 안전합니다.
누수가 나는 전형적인 패턴
다음 코드는 얼핏 정상적으로 보이지만, 소비자가 중간에 탈출하면 파일이 열려 있을 수 있습니다.
def read_lines(path):
f = open(path, "r", encoding="utf-8")
for line in f:
yield line.rstrip("\n")
f.close()
문제점은 f.close()가 루프가 끝까지 돌았을 때만 실행된다는 것입니다. 호출자가 break로 빠져나가면 f.close()까지 도달하지 못합니다.
try/finally로 close() 보장하기
def read_lines(path):
f = open(path, "r", encoding="utf-8")
try:
for line in f:
yield line.rstrip("\n")
finally:
f.close()
이제 호출자가 다음처럼 중간에 빠져도 안전합니다.
gen = read_lines("app.log")
for line in gen:
if "ERROR" in line:
break
# for-loop가 gen을 더 이상 소비하지 않으면, 구현/상황에 따라 close가 호출될 수 있으나
# 가장 확실한 건 호출자가 명시적으로 닫는 것
gen.close()
여기서 중요한 포인트는 “for-loop가 알아서 닫아주겠지”가 아니라, 리소스가 걸린 제너레이터라면 호출자가 생명주기를 책임질 방법을 제공해야 한다는 것입니다.
throw()로 예외를 주입해 정리/롤백 경로 강제하기
close()는 “그만할게”라는 신호라면, throw()는 “이 예외 상황이 발생했으니 그에 맞게 처리해”라는 신호입니다.
gen.throw(SomeError)는 제너레이터의 현재yield지점에 예외를 발생시킵니다.- 이를 통해 제너레이터 내부에서 트랜잭션 롤백, 락 해제, 버퍼 flush 중단 같은 정책을 강제할 수 있습니다.
예: 락을 잡는 제너레이터에서 throw()로 중단 처리
import threading
class Cancelled(Exception):
pass
lock = threading.Lock()
def locked_items(items):
lock.acquire()
try:
for x in items:
yield x
except Cancelled:
# 호출자가 "취소"를 명시적으로 주입한 경우
# 여기서 상태 기록/메트릭 등을 남길 수 있음
raise
finally:
lock.release()
호출자는 다음처럼 취소를 주입할 수 있습니다.
g = locked_items(range(10))
print(next(g)) # 0
print(next(g)) # 1
try:
g.throw(Cancelled("stop now"))
except Cancelled:
pass
throw()를 쓰면 “그냥 close”와 달리 취소/오류라는 의미를 제너레이터 내부로 전달할 수 있어, 내부에서 적절한 분기 처리가 가능합니다.
이 패턴은 분산 트랜잭션에서 보상 트랜잭션을 트리거하는 것과도 사고방식이 비슷합니다. 실패 시 경로를 명시적으로 태워야 안전해집니다. 관련 주제를 더 넓게 보려면 실무 MSA에서 SAGA vs Outbox 선택 가이드도 참고할 만합니다.
yield from와 close/throw 전파 규칙
제너레이터를 합성할 때 yield from를 쓰면, close/throw가 하위 제너레이터로 전파됩니다. 이 성질을 이해하면 “파이프라인 제너레이터”에서 정리 누수를 줄일 수 있습니다.
def source(path):
f = open(path, "r", encoding="utf-8")
try:
for line in f:
yield line
finally:
f.close()
def strip_newline(lines):
for line in lines:
yield line.rstrip("\n")
def pipeline(path):
# yield from을 쓰면 close/throw가 source까지 전파되기 쉬움
yield from strip_newline(source(path))
여기서 pipeline()을 닫으면 source()의 finally가 실행될 가능성이 높아집니다. 다만, 합성 단계가 복잡해질수록 “어느 레이어가 리소스를 소유하는지”가 흐려지므로, 리소스 오너는 한 곳으로 모으는 설계가 중요합니다.
실무에서 많이 터지는 누수 시나리오 4가지
1) 부분 소비: break, islice, 조건 필터
제너레이터를 일부만 소비하는 코드는 매우 흔합니다.
from itertools import islice
first_100 = list(islice(read_lines("big.log"), 100))
이때 islice는 100개만 가져오고 멈추므로, read_lines가 finally로 닫히지 않는 구현이면 파일 디스크립터가 남습니다. 따라서 **리소스가 있는 제너레이터는 무조건 try/finally**가 기본값이어야 합니다.
2) 소비자 예외로 인한 중단
for line in read_lines("app.log"):
obj = parse(line) # 여기서 예외
handle(obj)
parse에서 예외가 터지면 루프가 중단됩니다. 제너레이터가 finally로 정리하지 않으면 누수로 이어집니다.
3) 제너레이터를 반환만 하고 닫지 않는 API
함수가 제너레이터를 반환하는 순간, 리소스 생명주기 관리 책임이 호출자에게 넘어갑니다. 호출자가 이를 인지하지 못하면 누수가 됩니다.
이럴 때는 “제너레이터를 반환”하기보다, 아예 컨텍스트 매니저를 제공하는 것이 안전합니다.
4) async와 혼용하면서 취소가 전파되지 않는 구조
동기 제너레이터와 비동기 코드가 섞이면, 취소/타임아웃이 발생했을 때 정리 경로가 끊기는 경우가 있습니다. 비슷한 맥락의 실수로는 await 누락처럼 “호출은 했지만 실제로 실행/전파가 안 되는” 버그가 있습니다. 참고로 Python async 데코레이터에서 await 누락 버그 잡기도 같은 계열의 사고를 다룹니다.
권장 설계: 리소스는 컨텍스트 매니저로, 제너레이터는 순수 변환으로
가장 안전한 방향은 다음 분리입니다.
- 파일/소켓/DB 같은 리소스는
with블록(컨텍스트 매니저)에서 열고 닫는다. - 제너레이터는 “입력을 받아 변환해서 흘려보내는” 순수한 역할로 유지한다.
contextlib.contextmanager로 안전한 API 만들기
from contextlib import contextmanager
@contextmanager
def open_lines(path):
f = open(path, "r", encoding="utf-8")
try:
def gen():
for line in f:
yield line.rstrip("\n")
yield gen()
finally:
f.close()
사용 측은 이렇게 됩니다.
with open_lines("app.log") as lines:
for line in lines:
if "ERROR" in line:
break
이 구조의 장점은 호출자가 break를 하든 예외가 나든, with가 끝나는 순간 항상 닫힌다는 점입니다. 즉, close() 호출 책임을 API 사용자가 실수로 놓칠 여지를 줄입니다.
close()와 throw()를 언제 쓰나: 선택 가이드
close()- “더 이상 필요 없음”을 알리는 정상 종료 신호
- 소비자가 부분 소비를 하고 명시적으로 정리하고 싶을 때
- 제너레이터 내부는
try/finally로 정리만 잘 해도 충분한 경우가 많음
throw()- “오류/취소/중단 사유”를 제너레이터 내부로 전달해야 할 때
- 내부에서 롤백/보상/상태 기록 같은 분기 처리가 필요할 때
- 파이프라인에서 특정 스테이지에 예외를 주입해 중단시키고 싶은 경우
둘 다 공통적으로 중요한 점은, 정리 로직이 finally에 있어야 한다는 것입니다. close()든 throw()든 결국 제너레이터를 빠져나오게 만드는 트리거일 뿐, 정리를 보장하는 것은 finally입니다.
디버깅 팁: 누수는 "증상"으로 먼저 나타난다
제너레이터 누수는 보통 다음 증상으로 먼저 드러납니다.
- 파일 디스크립터 증가:
Too many open files - DB 커넥션 풀 고갈: 대기/타임아웃 증가
- 락 미해제: 스레드/프로세스가 교착 상태처럼 멈춤
- 네트워크 리소스 고갈: 포트/커넥션이 TIME_WAIT에 쌓임
이때 제너레이터가 의심된다면 다음을 체크하세요.
- 리소스를 여는 코드가 제너레이터 함수 안에 있는가
try/finally로 닫힘이 보장되는가- 호출자가 부분 소비할 가능성이 있는가
- API가 컨텍스트 매니저를 제공하는가
마무리
제너레이터는 “메모리를 아끼는 반복자”로만 이해하면 위험합니다. 실무에서는 제너레이터가 리소스를 품는 순간, 생명주기 관리 객체가 됩니다.
close()는 소비 중단 시 정리 경로를 열어주는 최소한의 안전장치throw()는 취소/오류 의미를 제너레이터 내부로 전달해 롤백/보상 같은 정책을 실행시키는 도구- 그러나 최종적으로 누수를 잡는 핵심은
try/finally와, 가능하다면 컨텍스트 매니저 기반 API 설계입니다.
이 원칙만 지켜도 “부분 소비/예외/취소”가 일상인 서비스 코드에서 제너레이터가 만드는 누수의 상당수를 예방할 수 있습니다.