문서 읽는 데 52분 · F2

F-2: 제너레이터와 데코레이터 — 값을 하나씩, 함수를 포장하기

목차 29
전체 22강 중 18강 · 파이썬 기초
난이도 · 입문

ℹ️프로그래밍 경험이 없어도 시작할 수 있는 파이썬 입문 — ‘개발 입문’을 먼저 들으면 더 수월해요. AI·데이터 분석으로 이어지는 출발점이에요.

안녕하세요, 홍순구 튜터입니다. 지난 시간엔 우리 도메인에 타입 라벨을 또박또박 붙였죠. Member에도, save_members에도 "이건 글자, 이건 회원 목록"이라고 적어 Pyright가 실수를 미리 잡아주게 만들었어요.

그런데 지난 시간 끝에 제가 슬쩍 던진 질문이 하나 있었어요. "우리 피드가 게시물 백만 개라면?" [m for m in members]처럼 전부 리스트에 담으면, 백만 개를 한꺼번에 메모리에 쌓아야 해요. 첫 게시물 한 개만 보고 싶을 뿐인데도요.

오늘은 그 부담을 더는 도구, 제너레이터를 배워요. 값을 한 번에 다 만들지 않고 필요할 때 하나씩 꺼내 쓰는 방식이에요. 김밥집에 비유하면, 리스트는 김밥 백 줄을 미리 다 싸서 쟁반에 올려두는 가게이고, 제너레이터는 주문이 들어올 때마다 한 줄씩 즉석에서 마는 가게예요. 더불어 함수를 살짝 포장해 기능을 더하는 데코레이터, 그리고 with의 진짜 정체인 컨텍스트 매니저까지 만나요. 모던 파이썬이 점점 깊어지는 날이에요.

텍스트
오늘의 여정
  yield            값을 한 번에 다 만들지 않고 하나씩 흘려보내기
  이터레이터        for 가 속으로 도는 원리 (__iter__ / __next__)
  데코레이터        함수를 선물 포장하듯 감싸 기능 더하기 (@)
  컨텍스트 매니저    with 의 정체 — 열고 반드시 닫기 (@contextmanager)

💡 오늘 수업의 핵심 — "값은 하나씩, 함수는 포장으로, 자원은 with 로"

제너레이터는 값을 미리 다 만들지 않고 하나씩 내놓아 메모리를 아껴요. 데코레이터는 원래 함수를 건드리지 않고 겉을 감싸 기능을 더하고요. 컨텍스트 매니저는 "열었으면 반드시 닫는다"를 with 한 줄로 보장해요.

🎯 학습 목표

  • yield로 제너레이터를 만들고, 게으른 평가와 "한 번 소진"의 성질을 이해해요.
  • for가 내부에서 도는 원리(__iter__/__next__)를 들여다봐요.
  • 데코레이터로 함수를 감싸 기능을 더하고, @functools.wraps로 정체를 지켜요.
  • with의 정체인 컨텍스트 매니저를 클래스와 @contextmanager 두 방식으로 만들어요.

Step 1: "값을 하나씩 내놓는 함수 — yield"

지금까지 함수는 return으로 값을 돌려줬어요. return은 값을 한 번 건네주고 그 자리에서 함수가 끝나죠. 그런데 값을 한 번에 다 주지 말고, 하나 주고 잠깐 멈췄다가, 또 달라고 하면 그다음 걸 주는 함수가 있다면 어떨까요?

그게 바로 yield(이일드, "내놓다")를 쓰는 제너레이터(generator) 함수예요. 일단 코드부터 볼게요.

Python
# src/modern/f2_yield.py
def count_up_to(limit: int):
    n = 1
    while n <= limit:
        yield n
        n += 1

return 자리에 yield가 들어갔어요. 이 함수를 불러볼게요.

Python
gen = count_up_to(3)
print(gen)            # <generator object count_up_to at 0x...>
print(next(gen))      # 1  (여기서 첫 yield 까지만 실행)
print(next(gen))      # 2
print(next(gen))      # 3

신기한 점이 두 개예요. 첫째, count_up_to(3)을 불렀는데 숫자가 안 나오고 <generator object ...>라는 낯선 게 나와요. yield가 든 함수는 호출해도 코드가 곧장 돌지 않고, "제너레이터 객체"라는 리모컨만 하나 만들어줘요. 둘째, next(gen)을 부를 때마다 값이 하나씩 나와요. next는 "다음 걸 내놔"라는 뜻이에요.

텍스트
count_up_to(3)  호출
   └─ 제너레이터 객체 생성 (아직 코드 한 줄도 안 돈다)

next(gen)    yield n 까지 실행   1 내놓고 멈춤
next(gen)    멈춘 자리부터 다시   2 내놓고 멈춤
next(gen)    멈춘 자리부터 다시   3 내놓고 멈춤

핵심은 멈춤이에요. yield n을 만나면 n을 내놓고 그 줄에서 일시정지해요. 다음에 next가 또 불리면 멈췄던 바로 그 자리부터 이어서 달려요. while 루프가 통째로 한 번에 도는 게 아니라, yield마다 끊겼다 이어지는 거예요.

for로 돌리면 next를 일일이 부르지 않아도 돼요. for가 알아서 하나씩 꺼내거든요.

Python
def greet_each(names: list[str]):
    for name in names:
        yield f"안녕하세요 @{name}"
Python
for greeting in greet_each(["minji", "jaehoon"]):
    print(greeting)
# 안녕하세요 @minji
# 안녕하세요 @jaehoon

💡 한 줄 정리

yield가 하나라도 든 함수는 제너레이터예요. 호출하면 곧장 실행되지 않고 객체만 생기고, next(또는 for)로 값을 하나씩 꺼낼 때마다 멈췄던 자리부터 이어 달려요.

🙋 학생 질문 — "튜터님, 값을 다 꺼낸 뒤에 next 를 또 부르면 어떻게 되나요?"

좋은 질문이에요. count_up_to(3)에서 1, 2, 3을 다 꺼낸 뒤에 next를 한 번 더 부르면, 파이썬은 "더 내놓을 게 없다"는 신호로 StopIteration이라는 걸 띄워요. 사실 for는 이 신호를 받으면 조용히 반복을 멈추는 거예요. 이 StopIteration의 정체는 Step 3에서 for의 속을 들여다볼 때 제대로 만나요. 지금은 "다 꺼내면 끝난다" 정도만 기억해두세요.


Step 2: "필요할 때 하나씩 — 게으름과 소진"

제너레이터의 가장 큰 장점은 게으르다(lazy)는 거예요. 여기서 게으름은 칭찬이에요. 시키기 전엔 값을 만들지 않고, 달라고 할 때 그제서야 딱 하나 만들거든요. 백만 개짜리 피드라도 첫 줄만 보고 싶으면 첫 줄 하나만 만들어요. 우리 피드 스트림을 제너레이터로 만들어볼게요.

Python
# src/instagram/feed.py
def feed_stream(usernames: list[str]):
    for username in usernames:
        yield f"@{username} 님의 새 게시물이 올라왔어요"
Python
stream = feed_stream(["minji", "jaehoon", "seungwoo"])
print(next(stream))     # @minji 님의 새 게시물이 올라왔어요

세 명을 넘겼지만 next를 한 번만 불렀으니 minji의 한 줄만 만들어졌어요. jaehoon과 seungwoo의 줄은 아직 세상에 없어요. 이게 메모리를 아끼는 비결이에요.

그런데 제너레이터엔 조심할 성질이 하나 있어요. B-3에서 () 제너레이터 표현식을 잠깐 봤을 때 "한 번 쓰면 소진된다"고 했던 것, 기억나시죠? 제너레이터 함수도 똑같아요.

Python
one_shot = feed_stream(["minji", "jaehoon"])
print(list(one_shot))   # ['@minji ...', '@jaehoon ...']
print(list(one_shot))   # []  ← 이미 소진

list(one_shot)로 한 번 쭉 훑으면 두 줄이 다 나와요. 그런데 같은 one_shot을 한 번 더 list로 훑으면 텅 빈 []가 나와요. 이미 다 꺼내 써버려서 남은 게 없거든요. 한 번 따라버린 그릇 같은 거예요. 다시 흐르게 하려면 feed_stream(...)을 새로 불러 새 제너레이터를 만들어야 해요.

게으름의 진짜 위력은 무한도 다룰 수 있다는 데 있어요. 끝없이 게시물 번호를 매기는 제너레이터를 볼게요.

Python
def post_numbers(start: int):
    number = start
    while True:
        yield number
        number += 1

while True라 영원히 도는데도 멈추지 않고 동작해요. 게으르니까요. 달라고 할 때만 하나 만들거든요.

Python
counter = post_numbers(1)
print([next(counter), next(counter), next(counter)])   # [1, 2, 3]

앞의 세 개만 next로 콕콕 집어 꺼냈어요.

[!WARNING] 무한 제너레이터에 list(post_numbers(1))for n in post_numbers(1): 처럼 "전체를 훑는" 명령을 내리면 영원히 멈추지 않아요. 무한 제너레이터는 반드시 next로 조금씩, 또는 멈출 조건과 함께 다뤄야 해요.

💡 한 줄 정리

제너레이터는 게을러서 달라고 할 때만 값을 하나씩 만들어 메모리를 아껴요. 대신 한 번 훑으면 소진되고, 무한 스트림도 다룰 수 있어요.

🙋 학생 질문 — "튜터님, 그럼 리스트는 이제 안 쓰고 제너레이터만 쓰면 되나요?"

아니에요, 둘 다 자리가 있어요. 같은 데이터를 두 번 이상 훑어야 하거나, 길이(len)를 알아야 하거나, 인덱스로 [2]처럼 콕 집어야 한다면 리스트가 맞아요. 제너레이터는 그게 다 안 되거든요(한 번 소진되고, 길이도 인덱스도 없어요). 반대로 데이터가 아주 크거나, 한 번 쭉 훑어 sum·max로 값 하나만 낼 거거나, 무한히 흘려보낼 거라면 제너레이터가 빛나요. "두 번 볼 거냐, 한 번 흘릴 거냐"로 나눠 생각하면 편해요.


Step 3: "for 는 어떻게 도는가 — 이터레이터"

여기서 잠깐 궁금증 하나. 우리는 리스트도 for로 돌고, 제너레이터도 for로 돌고, rangefor로 돌아요. 생긴 게 다 다른데 어떻게 for 하나로 전부 도는 걸까요? 비밀은 이들이 모두 같은 약속, 이터레이터 프로토콜을 따르기 때문이에요.

for x in 무언가:를 쓰면 파이썬은 속으로 이렇게 해요. 먼저 그 "무언가"에게 __iter__를 불러 하나씩 꺼내줄 담당(이터레이터)을 받고, 그다음 __next__를 반복해서 부르다가, 더 줄 게 없다는 StopIteration 신호가 오면 멈춰요. 말로만 들으면 막연하니, yield 없이 손으로 직접 만들어 속을 들여다볼게요.

Python
# src/modern/f2_iterator.py
class CountUp:
    def __init__(self, limit: int):
        self.limit = limit
        self.current = 0

    def __iter__(self):
        return self

    def __next__(self) -> int:
        if self.current >= self.limit:
            raise StopIteration
        self.current += 1
        return self.current

__iter__는 "하나씩 꺼내줄 담당"으로 자기 자신(self)을 돌려줘요. __next__는 부를 때마다 다음 숫자를 돌려주다가, 한도에 닿으면 raise StopIteration으로 "이제 끝"이라고 알려요. 직접 굴려볼게요.

Python
counter = CountUp(3)
print(next(counter))   # 1
print(next(counter))   # 2
print(next(counter))   # 3

for n in CountUp(3):
    print(n)           # 1, 2, 3

next로 하나씩 꺼낼 수도 있고, for에 넣으면 알아서 돌아요. for가 속으로 하는 일을 그려보면 이래요.

텍스트
for n in CountUp(3):
   │
   ├─ __iter__()    하나씩 꺼내줄 담당을 받는다
   ├─ __next__()    1
   ├─ __next__()    2
   ├─ __next__()    3
   └─ __next__()    StopIteration    for 가 조용히 멈춘다

Step 1에서 만났던 StopIteration이 여기서 정체를 드러냈죠. 이게 핵심이에요. 제너레이터(yield)는 이 __iter__·__next__·StopIteration을 우리 대신 자동으로 만들어줘요. 그래서 CountUp 클래스 열 줄을, Step 1의 count_up_to는 단 네 줄로 끝냈던 거예요. 같은 일을 훨씬 짧게요.

💡 한 줄 정리

for__iter__로 담당을 얻고 __next__를 반복하다 StopIteration에서 멈춰요. 제너레이터는 이 프로토콜을 자동으로 구현해 주는 간편한 도구예요.

🙋 학생 질문 — "튜터님, 이렇게 클래스로도 되는데 왜 제너레이터를 따로 배우나요?"

방금 두 코드를 견줘 봤듯, 대부분의 경우 제너레이터(yield)가 훨씬 짧고 읽기 쉬워요. CountUp이 열 줄이라면 count_up_to는 네 줄이었죠. 그래서 실무에선 거의 제너레이터를 먼저 떠올려요. 클래스로 직접 __iter__/__next__를 짜는 건, 상태가 아주 복잡하거나 다른 메서드도 함께 필요할 때 정도예요. 오늘 이걸 손으로 만들어 본 건 "for의 속이 이렇게 생겼구나"를 눈으로 보기 위해서예요. 정체를 알고 나면 제너레이터가 왜 편한지 더 와닿거든요.


Step 4: "함수를 선물 포장하기 — 데코레이터"

이제 방향을 바꿔 데코레이터(decorator)로 가볼게요. C-2에서 "함수도 값이라 변수에 담고 다른 함수에 넘길 수 있다", 그리고 "함수가 다른 함수를 돌려줄 수도 있다(클로저)"고 했던 것, 기억나시죠? 데코레이터는 바로 그 토대 위에 서 있어요.

데코레이터는 함수를 받아서, 그 함수를 감싼 새 함수를 돌려줘요. 선물 포장에 비유하면 딱이에요. 알맹이(원래 함수)는 그대로 두고, 겉에 포장지(추가 기능)를 덧입히는 거죠. 예를 들어 어떤 함수든 실행 앞뒤에 알림을 한 줄씩 찍고 싶다고 해봐요. 함수마다 print를 넣으면 중복투성이가 되겠죠. 데코레이터로 해결해요.

Python
# src/modern/f2_decorator.py
def announce(func):
    def wrapper():
        print("=== 함수를 시작합니다 ===")
        func()
        print("=== 함수가 끝났습니다 ===")
    return wrapper


@announce
def say_hello():
    print("안녕하세요 @minji")

announce는 함수(func)를 받아, 그 앞뒤에 알림을 두른 wrapper를 돌려줘요. 그리고 say_hello 위에 붙은 @announce가 마법의 한 줄이에요. 실행해볼게요.

Python
say_hello()
# === 함수를 시작합니다 ===
# 안녕하세요 @minji
# === 함수가 끝났습니다 ===

분명 say_hello는 인사말 한 줄만 찍는 함수였는데, 앞뒤로 알림이 둘러졌어요. @announce가 무슨 일을 한 걸까요? 사실 이거예요.

텍스트
@announce
def say_hello(): ...

   ≡  say_hello = announce(say_hello)

  announce(원래 say_hello)    wrapper 를 돌려준다
  이제 say_hello 라는 이름은 wrapper 를 가리킨다

@announce는 "say_hello = announce(say_hello)를 함수 정의 위에 예쁘게 적은 것"일 뿐이에요. announcewrapper를 돌려주니까, 이제 say_hello라는 이름은 알맹이를 감싼 wrapper를 가리키게 돼요. 그래서 say_hello()를 부르면 포장지까지 함께 실행되는 거예요.

💡 한 줄 정리

데코레이터는 함수를 받아 감싼 새 함수를 돌려주는 도구예요. @데코레이터함수 = 데코레이터(함수)를 정의 위에 적는 문법이고요. 원래 함수를 건드리지 않고 기능을 더해요.

🙋 학생 질문 — "튜터님, @ 를 안 쓰고 announce(say_hello) 라고 직접 써도 되나요?"

네, 완전히 똑같아요. @announce를 지우고 함수 정의 아래에 say_hello = announce(say_hello)라고 적으면 결과가 동일해요. @는 그저 그 작업을 함수 정의 바로 위에 한 줄로 깔끔하게 적게 해주는 편의 문법이에요(이런 걸 문법 설탕, syntactic sugar라고 불러요). 읽는 사람이 "아, 이 함수는 announce로 포장됐구나"를 정의를 보자마자 알 수 있으니 더 친절하죠.


Step 5: "얼마나 걸렸나 재보기 — 실행시간 데코레이터"

Step 4의 wrapper()는 괄호가 비어 있었어요. 그래서 인자를 받는 함수는 감쌀 수 없죠. 그런데 우리가 감싸고 싶은 함수는 인자를 받을 때가 많잖아요. 이때 C-1에서 배운 *args**kwargs가 빛을 발해요. "인자가 몇 개든 전부 받아 그대로 넘기는" 그 도구요.

실무에서 정말 자주 쓰는 데코레이터, 실행시간 측정기를 만들어볼게요. 어떤 함수가 느린지 잴 때 쓰는 거예요.

Python
# src/modern/f2_timing.py
import time


def timed(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} 실행: {elapsed:.6f}초")
        return result
    return wrapper


@timed
def build_feed(usernames: list[str]) -> list[str]:
    return [f"@{name} 님의 게시물" for name in usernames]

wrapper(*args, **kwargs)는 어떤 인자가 와도 전부 받아서, func(*args, **kwargs)로 원래 함수에 그대로 넘겨요. 포장지가 안에 뭐가 들었든 상관없이 감싸는 것처럼요. 실행해볼게요.

Python
feed = build_feed(["minji", "jaehoon", "seungwoo"])
print(feed)
# build_feed 실행: 0.000002초
# ['@minji 님의 게시물', '@jaehoon 님의 게시물', '@seungwoo 님의 게시물']

실행 시간이 먼저 찍히고, 그다음 원래 결과가 나와요. 시간 숫자(0.000002초)는 컴퓨터마다, 또 부를 때마다 달라요. 여러분 화면엔 다른 숫자가 나올 거예요.

여기서 놓치기 쉬운 한 줄이 result = func(...)return result예요. 원래 함수가 돌려준 값을 result에 받아뒀다가 그대로 돌려줘야 해요. 이걸 빠뜨리면 포장하면서 알맹이를 떨어뜨린 셈이 돼서, build_feed가 게시물 목록 대신 None을 돌려주거든요.

💡 한 줄 정리

wrapper(*args, **kwargs)로 인자를 전부 받아 원래 함수에 넘기면 어떤 함수든 감쌀 수 있어요. 원래 함수의 결과는 result에 받아 꼭 return해 돌려줘요.

🙋 학생 질문 — "튜터님, result 를 return 하지 않으면 어떤 일이 벌어지나요?"

build_feed(...)를 부르면 게시물 목록 대신 None이 돌아와요. wrapper가 원래 함수를 실행은 했지만, 그 결과를 밖으로 돌려주지 않으니까요. 함수가 아무것도 return하지 않으면 파이썬은 자동으로 None을 돌려준다고 했죠(C-1). 데코레이터를 만들 때 가장 흔한 실수가 바로 이 return result를 빠뜨리는 거예요. "포장은 했는데 알맹이를 안 돌려줬다"고 기억하면 안 잊어버려요.


Step 6: "포장해도 정체는 남기기 — functools.wraps"

데코레이터엔 작지만 성가신 부작용이 하나 있어요. 함수를 wrapper로 감싸면, 그 함수의 "이름표"가 wrapper로 바뀌어 버려요. 알맹이의 정체가 포장지에 가려지는 거예요. 직접 확인해볼게요.

Python
# src/modern/f2_wraps.py
import functools


def announce_without_wraps(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


def announce_with_wraps(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

위쪽은 그냥 감싸기만 했고, 아래쪽은 wrapper@functools.wraps(func)를 붙였어요. 각각으로 함수를 포장한 뒤 이름표(__name__)를 들여다볼게요.

Python
@announce_without_wraps
def post_photo():
    """사진을 게시한다."""


@announce_with_wraps
def post_video():
    """영상을 게시한다."""
Python
print(post_photo.__name__)   # wrapper      ← 정체가 가려짐
print(post_video.__name__)   # post_video   ← @wraps 가 지켜줌
print(post_video.__doc__)    # 영상을 게시한다.

@wraps 없이 감싼 post_photo는 이름표가 wrapper로 보여요. 진짜 이름인 post_photo가 사라진 거죠. 반면 @functools.wraps(func)를 붙인 post_video는 원래 이름과 설명(__doc__)을 그대로 간직하고 있어요. wraps가 원래 함수의 이름표를 wrapper에 옮겨 붙여주거든요.

이게 왜 중요하냐면, 나중에 코드가 잘못됐을 때 어디서 문제가 났는지 추적하는데 모든 함수가 죄다 wrapper로 보이면 누가 누군지 알 수가 없어요. 그래서 데코레이터를 만들 때는 안쪽 wrapper@functools.wraps(func)를 붙이는 걸 거의 습관처럼 해요. 우리 [응용] 과제에서도 이걸 써볼 거예요.

💡 한 줄 정리

데코레이터로 감싸면 함수의 이름표가 wrapper로 가려져요. 안쪽 wrapper@functools.wraps(func)를 붙이면 원래 이름과 설명이 보존돼요. 데코레이터의 기본 예절이에요.

🙋 학생 질문 — "튜터님, @wraps 를 안 붙이면 코드가 고장 나나요?"

실행 자체는 멀쩡히 돼요. post_photo()를 부르면 잘 동작해요. @wraps가 없어서 달라지는 건 "이름표"뿐이에요. 하지만 프로그램이 커지고 함수가 수십 개씩 데코레이터로 감싸지면, 오류를 추적할 때 화면에 전부 wrapper라고만 떠서 어느 함수인지 가늠하기 어려워져요. 당장은 티가 안 나도 나중에 크게 고마운 한 줄이라, 처음부터 습관으로 붙여두면 좋아요. 🌟 데코레이터를 만들 땐 일단 @functools.wraps(func)부터 적는다고 기억해두세요.


Step 7: "with 의 진짜 정체 — 컨텍스트 매니저"

이제 마지막 퍼즐, with로 가볼게요. E-2에서 파일을 다룰 때 with open(...) as f:를 썼죠. 블록을 벗어나면 파일이 자동으로 닫혔어요. 그때 "지난 시간 finally로 '무슨 일이 있어도 닫는다'를 보장했던 걸 with가 한 줄에 담았다"고 했어요. 그 with의 정체가 바로 컨텍스트 매니저(context manager)예요.

자동문에 비유하면 딱이에요. 들어갈 때 문이 열리고(__enter__), 나올 때 문이 닫혀요(__exit__). 심지어 안에서 에러가 나 뛰쳐나가도 문은 닫혀요. 우리 피드 세션을 with로 감싸는 컨텍스트 매니저를 직접 만들어볼게요.

Python
# src/modern/f2_context.py
class FeedSession:
    def __init__(self, username: str):
        self.username = username

    def __enter__(self):
        print(f"@{self.username} 피드 세션 시작")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print(f"@{self.username} 피드 세션 종료")
        return False

    def show(self, message: str):
        print(f"  {message}")

__enter__with 블록에 들어갈 때 호출돼요. 여기서 돌려준 값(return self)이 as 뒤 변수에 담겨요. __exit__는 블록을 벗어날 때 호출되는 정리 담당이고요. 써볼게요.

Python
with FeedSession("minji") as session:
    session.show("게시물 1")
    session.show("게시물 2")
# @minji 피드 세션 시작
#   게시물 1
#   게시물 2
# @minji 피드 세션 종료

"시작"이 먼저 찍히고, 블록 안 두 줄이 실행되고, 블록을 벗어나는 순간 "종료"가 자동으로 찍혔어요. close()를 직접 부르지 않았는데도요. with open이 파일을 알아서 닫아준 게 바로 이 __exit__ 덕분이었던 거예요.

[!WARNING] __exit__가 받는 exc_type, exc_value, traceback 세 인자는 "블록 안에서 에러가 났다면 그 정보"가 담기는 자리예요. 끝의 return False는 "에러를 몰래 삼키지 말고 원래대로 띄워라"라는 뜻이고요. 지금은 이 정도만 알아도 충분해요. 핵심은 에러가 나도 __exit__는 반드시 호출된다는 거예요. 그래서 with가 정리를 보장하는 거죠.

💡 한 줄 정리

with의 정체는 컨텍스트 매니저예요. __enter__로 들어가고(반환값이 as 변수), __exit__로 나와요. 에러가 나도 __exit__는 반드시 불려서 정리를 보장해요.

🙋 학생 질문 — "튜터님, exit 의 인자 세 개를 안 쓸 거면 빼도 되나요?"

빼면 안 돼요. with가 블록을 벗어날 때 그 세 자리에 에러 정보(없으면 None 세 개)를 넣어 __exit__를 부르거든요. 받을 자리가 없으면 "인자 개수가 안 맞는다"며 에러가 나요. 그래서 당장 쓰지 않더라도 exc_type, exc_value, traceback 세 자리는 받아둬야 해요. 마치 누가 택배를 세 개 건네주는데 손이 세 개 있어야 받을 수 있는 것과 같아요. 다음 Step에서 볼 @contextmanager를 쓰면 이 세 인자를 직접 적지 않아도 돼서 한결 가벼워져요.


Step 8: "제너레이터로 컨텍스트 만들기 — @contextmanager"

Step 7의 FeedSession은 클래스에 메서드를 두 개나 만들어야 했어요. 짧은 정리 작업 하나 하려고 클래스를 통째로 쓰는 건 좀 무겁죠. 더 간단한 길이 있어요. 오늘 배운 yield(Step 1)와 데코레이터(Step 4)가 만나는 자리예요.

contextlib@contextmanager를 제너레이터 함수에 붙이면, 그 함수 하나가 컨텍스트 매니저가 돼요. 실행시간을 재는 컨텍스트 매니저를 만들어볼게요.

Python
# src/modern/f2_contextmanager.py
import time
from contextlib import contextmanager


@contextmanager
def timer(label: str):
    start = time.perf_counter()
    yield
    elapsed = time.perf_counter() - start
    print(f"{label}: {elapsed:.6f}초")

규칙은 단순해요. yield를 기준으로 위는 진입(__enter__), 아래는 정리(__exit__)예요. yield가 내놓는 값이 as 변수에 담기고요.

텍스트
@contextmanager
def feed_session(username):
    print("시작")          ──  yield 앞 = 진입 (__enter__)
    try:
        yield username     ──  내놓는 값 = as 변수, with 블록이 여기서 실행
    finally:
        print("종료")      ──  yield 뒤 = 정리 (__exit__), 에러나도

정리를 try/finally로 감싸면, 블록 안에서 에러가 나도 마무리가 보장돼요. E-1에서 배운 finally의 정신이 여기서 다시 쓰여요. 두 컨텍스트 매니저를 함께 써볼게요.

Python
# src/modern/f2_contextmanager.py
@contextmanager
def feed_session(username: str):
    print(f"@{username} 피드 세션 시작")
    try:
        yield username
    finally:
        print(f"@{username} 피드 세션 종료")
Python
with timer("피드 만들기"):
    feed = [f"@{name} 님의 게시물" for name in ["minji", "jaehoon"]]
    print(feed)
# ['@minji 님의 게시물', '@jaehoon 님의 게시물']
# 피드 만들기: 0.000005초

with feed_session("minji") as who:
    print(f"  {who} 의 게시물을 봅니다")
# @minji 피드 세션 시작
#   minji 의 게시물을 봅니다
# @minji 피드 세션 종료

Step 7에서 클래스로 열 줄 넘게 짰던 걸, @contextmanager + 제너레이터로는 대여섯 줄에 끝냈어요. 게다가 __exit__의 인자 세 개도 적을 필요가 없죠. 제너레이터의 "멈춤"과 데코레이터의 "감싸기"가 만나, with를 가장 간결하게 만드는 한 수예요.

💡 한 줄 정리

@contextmanager를 제너레이터 함수에 붙이면, yield 앞이 진입, 뒤가 정리가 돼요. try/finally로 감싸면 에러가 나도 정리가 보장되고요. 클래스보다 훨씬 간결해요.

🙋 학생 질문 — "튜터님, 컨텍스트 매니저를 클래스랑 @contextmanager 중 뭘로 만들어야 하나요?"

대부분은 @contextmanager가 짧고 읽기 편해서 먼저 손이 가요. 🌟 짧은 "열고 닫기" 작업이면 @contextmanager를 기본으로 떠올리세요. 클래스(__enter__/__exit__)는 컨텍스트 매니저가 상태를 여럿 들고 있어야 하거나, show처럼 블록 안에서 부를 메서드를 여러 개 제공해야 할 때 어울려요. 우리 Step 7의 FeedSessionsession.show(...)를 제공했던 것처럼요. "메서드가 더 필요하면 클래스, 그냥 열고 닫기만이면 @contextmanager"로 나누면 편해요.


마무리

오늘은 모던 파이썬의 세 기둥을 한 번에 세웠어요. 값을 하나씩 흘려보내는 제너레이터, 함수를 감싸 기능을 더하는 데코레이터, 그리고 자원을 안전하게 여닫는 컨텍스트 매니저까지요. 셋 다 처음엔 낯설어도, 한 번 정체를 알고 나면 "아, with가 이거였구나" 하고 그동안 써온 도구들이 새로 보일 거예요.

오늘 배운 핵심 세 가지

  • 💡 하나제너레이터(yield)는 값을 한 번에 다 만들지 않고 하나씩 내놓아요. 게을러서 메모리를 아끼고, 한 번 훑으면 소진되며, 무한 스트림도 다뤄요. for의 속엔 __iter__/__next__/StopIteration이 돌고 있고, 제너레이터는 그걸 자동으로 만들어줘요.
  • 💡 데코레이터(@)는 원래 함수를 건드리지 않고 감싸 기능을 더해요. wrapper(*args, **kwargs)로 어떤 함수든 감싸고, 결과는 return result로 돌려주며, @functools.wraps(func)로 함수의 정체를 지켜요.
  • 💡 컨텍스트 매니저(with)는 "열었으면 반드시 닫는다"를 보장해요. 클래스(__enter__/__exit__)로도, @contextmanager + 제너레이터로도 만들 수 있고, 후자가 더 간결해요. with open의 정체가 바로 이거였어요.

다음 시간 예고

오늘 if/elif로 갈래를 나누는 대신 함수를 감싸고, 값을 하나씩 흘려보내는 새 도구들을 익혔어요. 다음 시간(F-3)엔 갈래를 나누는 일 자체를 더 우아하게 푸는 match/case를 배워요. if/elif 사슬이 길어질 때, 값의 모양에 따라 깔끔하게 가지를 치는 도구예요. 더불어 조건을 검사하면서 값을 담는 walrus(:=)라는 깜찍한 기호도 만나고요. 그리고 그 너머 F-4에선, 오늘 만든 피드 제너레이터가 비동기(async)로 변신해 여러 게시물을 동시에 불러오는 모습까지 보게 돼요. 모던 파이썬의 마지막 봉우리들이 남았어요.


과제

오늘 배운 세 도구를 직접 손으로 만들어볼 차례예요. 막히면 교안의 해당 Step을 다시 펼쳐 보세요. 작은 함수부터 차근차근 굴려보면 금방 익숙해져요.

[기초] 인기 게시물만 흘려보내는 제너레이터

좋아요 수 목록을 받아, 그중 100 이상인 값만 하나씩 내놓는 제너레이터 popular_posts를 만들어 보세요.

Python
def popular_posts(like_counts):
    # 여기를 채워 보세요
    ...
  • like_counts는 정수 목록이에요(예: [50, 100, 150, 99, 240]).
  • for로 목록을 돌면서, 100 이상인 값만 yield하세요.
  • list(popular_posts([50, 100, 150, 99, 240]))를 하면 [100, 150, 240]이 나오게 하세요.

힌트: 리스트 컴프리헨션처럼 if로 거르되, []에 담는 대신 yield로 하나씩 내보내면 돼요. Step 1·2를 참고하세요.

[응용] 호출을 기록하는 데코레이터

함수를 부르기 전에 [로그] 함수이름 호출을 찍어주는 데코레이터 log_call을 만들어 보세요. 단, @functools.wraps로 원래 함수의 정체도 지켜야 해요.

  • log_call(func)은 안에 wrapper(*args, **kwargs)를 두고, 그 안에서 먼저 [로그] ... 호출print한 뒤 원래 함수를 실행해 결과를 돌려줘요.
  • wrapper@functools.wraps(func)를 붙이세요.
  • 어떤 함수(post_photo(caption)처럼 인자가 있는 것)에 @log_call을 붙여, 호출하면 로그가 찍히고 결과도 제대로 돌아오는지 확인하세요.

힌트: 함수 이름은 func.__name__으로 꺼낼 수 있어요. 결과를 빠뜨리지 않으려면 return func(*args, **kwargs)를 잊지 마세요. Step 5·6을 참고하세요.

[심화] 구역을 감싸는 컨텍스트 매니저

블록에 들어갈 때 제목 배너를, 나올 때 마감선을 찍어주는 컨텍스트 매니저 section@contextmanager로 만들어 보세요. 블록 안에서 에러가 나도 마감선은 반드시 찍혀야 해요.

  • @contextmanager를 붙인 제너레이터 함수 section(title)을 만드세요.
  • yield 앞에서 ===== {title} 시작 =====을, yield 뒤에서 ===== {title} 끝 =====print하세요.
  • 에러가 나도 마감선이 찍히도록 yieldtry/finally로 감싸세요.
  • with section("오늘의 피드"): 안에 게시물 두어 줄을 출력해 동작을 확인하세요.

힌트: yield만 단독으로 써도(yield username처럼 값을 안 내놔도) 컨텍스트 매니저로 동작해요. finally에 마감선을 두는 게 핵심이에요. Step 8을 참고하세요.


생각해볼 주제

1. 리스트로 담을까, 제너레이터로 흘릴까

같은 데이터를 다루는데 리스트에 다 담는 것과 제너레이터로 하나씩 흘리는 것, 두 길이 있어요. 게시물이 백만 개일 때 메모리 관점에선 제너레이터가 유리하죠. 그런데 만약 그 데이터를 "두 번 훑어야" 하거나, "전체 개수를 알아야" 한다면 어떨까요? 제너레이터의 "한 번 소진"과 "길이를 모름"이라는 성질을 떠올리며, 어떤 상황에서 무엇을 고를지 생각해 보세요.

2. 데코레이터는 왜 원본을 안 건드릴까

실행시간을 재고 싶으면, 함수 안에 직접 time.perf_counter() 코드를 넣어도 되긴 해요. 그런데 우리는 데코레이터로 함수를 "감쌌"죠. 시간을 재고 싶은 함수가 열 개라면 두 방식의 차이가 어떻게 벌어질까요? 또 나중에 시간 재기를 그만두고 싶을 때는요? "원본을 건드리지 않고 겉을 감싼다"는 선택이 주는 이점을, 함수가 여러 개일 때와 기능을 켰다 끌 때로 나눠 생각해 보세요.

3. with 가 보장하는 "반드시 닫기"의 가치

파일을 열고 닫는 걸 with 없이 한다면, 다 쓴 뒤 close()를 직접 불러야 해요. 그런데 그 사이에 에러가 나서 코드가 뛰쳐나가면 close()까지 못 가죠. with(컨텍스트 매니저)는 에러가 나도 __exit__를 반드시 불러 정리를 보장해요. 이 "반드시"가 왜 그렇게 중요한지, 파일이나 네트워크 연결처럼 "닫지 않으면 새는" 자원의 입장에서 생각해 보세요. try/finally를 매번 손으로 적는 것과 with 한 줄의 차이도 함께요.

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안이에요. 먼저 스스로 손으로 짠 뒤에 펼쳐 보세요. 코드를 직접 굴려 출력을 확인해보면 훨씬 오래 남아요.


🎯 [과제 1 예시답안] 인기 게시물만 흘려보내는 제너레이터

채점 포인트

항목 배점 확인
for로 목록을 돌며 100 이상만 거르는가 ★★★
return이 아니라 yield로 하나씩 내놓는가 ★★★
list(...)로 감쌌을 때 [100, 150, 240]이 나오는가 ★★☆

풀이 예시

Python
def popular_posts(like_counts):
    for likes in like_counts:
        if likes >= 100:
            yield likes
Python
for likes in popular_posts([50, 100, 150, 99, 240]):
    print(f"인기 게시물 — 좋아요 {likes}")
# 인기 게시물 — 좋아요 100
# 인기 게시물 — 좋아요 150
# 인기 게시물 — 좋아요 240

핵심은 []에 담는 대신 yield로 하나씩 내보낸다는 거예요. 리스트 컴프리헨션 [likes for likes in like_counts if likes >= 100]과 거의 똑같이 생겼지만, 대괄호와 append 없이 yield로 흘려보내니 값이 미리 다 만들어지지 않아요. 50과 99는 if likes >= 100 조건에서 걸러져 yield에 닿지 못하고 건너뛰어져요.

💡 튜터의 한마디: 제너레이터의 진가는 큰 데이터에서 드러나요. 좋아요 수가 백만 개라도 popular_posts는 100 이상인 값을 만날 때마다 하나씩 흘려보낼 뿐, 결과를 통째로 메모리에 쌓지 않거든요. sum(popular_posts(...))처럼 한 번 쭉 훑어 합을 낼 때 특히 깔끔해요.

🎯 [과제 2 예시답안] 호출을 기록하는 데코레이터

채점 포인트

항목 배점 확인
wrapper(*args, **kwargs)로 어떤 함수든 감싸는가 ★★★
원래 함수 결과를 return func(...)로 돌려주는가 ★★★
@functools.wraps(func)로 정체를 보존하는가 ★★★
func.__name__으로 함수 이름을 꺼내 로그를 찍는가 ★★☆

풀이 예시

Python
import functools


def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[로그] {func.__name__} 호출")
        return func(*args, **kwargs)
    return wrapper


@log_call
def post_photo(caption: str) -> str:
    return f"사진 게시: {caption}"
Python
print(post_photo("오늘의 노을"))
print("함수 정체:", post_photo.__name__)
# [로그] post_photo 호출
# 사진 게시: 오늘의 노을
# 함수 정체: post_photo

wrapper*args, **kwargs로 인자를 전부 받아 원래 함수에 넘기니, post_photo가 받는 caption 인자도 문제없이 전달돼요. 로그를 찍은 뒤 return func(*args, **kwargs)로 원래 결과("사진 게시: 오늘의 노을")를 그대로 돌려주는 게 중요해요. 그리고 @functools.wraps(func) 덕분에 post_photo.__name__wrapper가 아니라 post_photo로 제대로 보여요.

return을 빠뜨리지 않았는가 — 가장 흔한 실수예요. return이 없으면 post_photo(...)None을 돌려줘요. ❌ @functools.wraps(func)를 빠뜨리면 — 실행은 되지만 __name__wrapper로 보여요.

💡 튜터의 한마디: 로그 데코레이터는 실무에서 정말 자주 쓰여요. 어떤 함수가 언제 불렸는지 기록을 남기면, 문제가 생겼을 때 추적이 쉬워지거든요. 함수 본문을 한 줄도 건드리지 않고 @log_call 한 줄만 얹어 기능을 더한다는 점이 데코레이터의 매력이에요.

🎯 [과제 3 예시답안] 구역을 감싸는 컨텍스트 매니저

채점 포인트

항목 배점 확인
@contextmanager를 제너레이터 함수에 붙였는가 ★★★
yield 앞에 제목 배너, 뒤에 마감선을 두었는가 ★★★
try/finally로 감싸 에러가 나도 마감선이 찍히는가 ★★★
with section("..."):로 동작을 확인했는가 ★★☆

풀이 예시

Python
from contextlib import contextmanager


@contextmanager
def section(title: str):
    print(f"===== {title} 시작 =====")
    try:
        yield
    finally:
        print(f"===== {title} 끝 =====")
Python
with section("오늘의 피드"):
    print("게시물 1")
    print("게시물 2")
# ===== 오늘의 피드 시작 =====
# 게시물 1
# 게시물 2
# ===== 오늘의 피드 끝 =====

yield 앞은 진입이라 제목 배너가, 뒤는 정리라 마감선이 찍혀요. 여기서 try/finally가 핵심이에요. 만약 블록 안에서 에러가 나도, finally 덕분에 마감선이 반드시 찍히거든요. 직접 확인해볼게요.

Python
with section("오늘의 피드"):
    print("게시물 1")
    raise RuntimeError("문제 발생")
# ===== 오늘의 피드 시작 =====
# 게시물 1
# ===== 오늘의 피드 끝 =====   ← 에러가 나도 마감선은 찍힌다
# (그 뒤 RuntimeError 가 그대로 띄워진다)

try/finallyyield를 감쌌는가 — 이게 빠지면 에러가 났을 때 마감선이 찍히지 않아요. ❌ try 없이 yield 뒤에 마감선만 두면 — 정상 흐름에선 찍히지만, 에러가 나면 건너뛰어져요.

💡 튜터의 한마디: with를 직접 만들 수 있다는 건 생각보다 강력해요. "이 블록을 실행하는 동안 무언가를 켜뒀다가, 끝나면(에러가 나도) 반드시 끈다"는 패턴은 파일·연결·잠금 등 곳곳에서 쓰여요. @contextmanager + try/finally는 그 패턴을 가장 짧게 적는 길이에요.


🤔 [생각해볼 주제 1] 리스트로 담을까, 제너레이터로 흘릴까

문제 상황 요약

같은 데이터를 리스트에 다 담을지, 제너레이터로 하나씩 흘릴지 고르는 상황이에요. 메모리만 보면 제너레이터가 유리하지만, 데이터를 두 번 훑거나 전체 개수를 알아야 한다면 이야기가 달라져요.

튜터의 가이드 및 해설

판단은 딱 두 가지 질문으로 갈려요. "데이터를 두 번 이상 봐야 하나?"와 "길이나 인덱스가 필요한가?"예요.

제너레이터가 맞는 경우는 데이터가 아주 크거나 무한할 때, 그리고 한 번 쭉 훑어 sum·max·any처럼 값 하나만 뽑을 때예요. 백만 개짜리 좋아요의 합을 구한다면, 리스트로 백만 개를 다 쌓을 필요 없이 제너레이터로 하나씩 더하면 메모리가 거의 안 들어요.

리스트가 맞는 경우는 같은 데이터를 두 번 이상 훑어야 하거나(제너레이터는 첫 훑기에 소진돼 두 번째엔 비어 있어요), len()으로 개수를 세야 하거나, data[2]처럼 인덱스로 콕 집어야 할 때예요. 제너레이터는 이 셋을 모두 못 하거든요.

실무 감각으론, "흘려보내고 끝"이면 제너레이터, "쥐고 여러 번 만질" 거면 리스트예요. 애매하면 일단 리스트로 시작하고, 데이터가 커져 메모리가 부담될 때 제너레이터로 바꾸는 것도 좋은 순서예요.

💡 핵심을 한마디로

"한 번 흘려보내고 끝이면 제너레이터, 두 번 보거나 개수·인덱스가 필요하면 리스트예요. 제너레이터는 게으르고 메모리를 아끼지만 한 번 소진된다는 대가를 치르거든요."

🤔 [생각해볼 주제 2] 데코레이터는 왜 원본을 안 건드릴까

문제 상황 요약

실행시간을 재는 일을 함수 안에 직접 코드로 넣을 수도, 데코레이터로 함수를 감쌀 수도 있어요. 재고 싶은 함수가 여럿일 때, 그리고 나중에 그만두고 싶을 때 두 방식의 차이가 드러나요.

튜터의 가이드 및 해설

함수 열 개의 실행시간을 재고 싶다고 해봐요. 직접 코드를 넣는 방식이면, 열 개 함수 안에 똑같은 time.perf_counter() 코드를 열 번 복사해 넣어야 해요. 한 군데를 고치려면 열 군데를 다 고쳐야 하고요. 반면 데코레이터는 @timed 한 줄씩만 얹으면 끝이에요. 시간 재는 로직은 timed 한 곳에만 있고요.

기능을 켰다 끄는 상황에선 차이가 더 커져요. 시간 재기를 그만두고 싶으면, 데코레이터 방식은 @timed 줄만 지우면 원래 함수가 고스란히 남아요. 직접 넣은 방식이면 열 개 함수에서 측정 코드를 일일이 찾아 도려내야 하죠. 그 과정에서 원래 로직을 실수로 건드릴 위험도 있고요.

이게 "원본을 건드리지 않는다"의 진짜 가치예요. 핵심 로직(알맹이)과 부가 기능(포장지)을 분리해두면, 둘을 따로따로 켜고 끄고 고칠 수 있어요. 로그·실행시간·권한 검사처럼 "여러 함수에 공통으로 얹고 싶은 기능"은 데코레이터로 빼는 게 정석이에요.

💡 핵심을 한마디로

"데코레이터는 핵심 로직과 부가 기능을 분리해요. 여러 함수에 같은 기능을 한 줄로 얹고, 필요 없어지면 그 한 줄만 떼면 되니까요. 원본을 안 건드리는 덕분에 켜고 끄기가 자유로워요."

🤔 [생각해볼 주제 3] with 가 보장하는 "반드시 닫기"의 가치

문제 상황 요약

파일을 with 없이 다루면 다 쓴 뒤 close()를 직접 불러야 해요. 그런데 그 사이에 에러가 나면 close()까지 도달하지 못하죠. with는 에러가 나도 정리를 보장해요.

튜터의 가이드 및 해설

파일이나 네트워크 연결 같은 자원은 "닫지 않으면 새는" 성질이 있어요. 운영체제가 내준 자원을 돌려주지 않으면, 프로그램이 오래 돌수록 안 닫힌 파일·연결이 쌓여 결국 "더 못 연다"는 한계에 부딪혀요.

with 없이 close()를 직접 부르는 코드를 떠올려보세요. 파일을 열고, 뭔가 처리하고, close()를 부르는 순서인데, 처리 도중에 에러가 나면 코드가 거기서 뛰쳐나가 close() 줄에 도달하지 못해요. 파일이 열린 채로 남는 거죠.

이걸 손으로 막으려면 try/finally로 감싸 finallyclose()를 넣어야 해요. 그런데 파일을 열 때마다 이 try/finally를 적는 건 번거롭고, 빠뜨리기도 쉬워요. with(컨텍스트 매니저)는 바로 그 try/finally를 한 줄에 담아둔 거예요. 블록을 벗어나는 순간, 에러가 났든 안 났든 __exit__가 반드시 불려 정리를 끝내거든요.

그래서 파이썬에서 파일·연결·잠금처럼 "열었으면 닫아야 하는" 자원은 거의 항상 with로 다뤄요. "반드시"라는 보장이, 사람이 깜빡할 여지를 아예 없애주니까요.

💡 핵심을 한마디로

"withtry/finally를 한 줄에 담아, 에러가 나도 정리를 반드시 보장해요. 파일이나 연결처럼 안 닫으면 새는 자원에선, 이 '반드시'가 사람의 깜빡임을 막아주는 안전장치예요."

전체 목록 파이썬 기초