클로저, 코드 객체, 프레임: Hook 체인의 Python 내부

클로저, 코드 객체, 프레임: Hook 체인의 Python 내부

이 글은 throttled-py Hook 시스템 파헤치기 시리즈의 부록입니다.

들어가며

throttled-py는 Python용 rate limiting 라이브러리입니다. 저는 이 프로젝트에 hook(미들웨어) 시스템을 기여했는데, 핵심은 rate limit 체크를 감싸는 hook 체인을 빌드하는 것이었습니다. hooks = [A, B]를 넘기면 A.on_limit(B.on_limit(do_limit)) 형태로 양파 껍질처럼 감싸서, A_before -> B_before -> do_limit -> B_after -> A_after 순서로 실행되는 구조입니다.

이 글은 hook 체인을 구현하면서 마주친 Python 내부 동작에 대한 내용입니다. 클로저(closure)가 어떻게 상태를 기억하는지, code object와 frame object가 무엇인지, 그리고 이것들이 hook 체인에서 어떤 역할을 하는지 파고들었습니다. Python을 어느 정도 쓰고 있지만 내부 구조까지는 들여다보지 않은 개발자를 대상으로 작성했습니다.

1. 함수만 리턴을 하네?

throttled-py 에서 구현한 build_hook_chain 함수의 핵심 구조는 이렇습니다:

def build_hook_chain(hooks, do_limit, context):
    if not hooks:
        return do_limit

    chain = do_limit
    for hook in reversed(hooks):
        post_chain = chain

        def make_chain(h, next_fn):
            def chain_fn():
                # ... h와 next_fn을 사용하는 로직 ...
                return h.on_limit(tracked_next, context)
            return chain_fn

        chain = make_chain(hook, post_chain)

    return chain

처음 이 코드를 마주하시면 (저도 마찬가지지만) 혼란스러울 것입니다. make_chainchain_fn이라는 함수를 만들어서 리턴만 합니다. 아무것도 실행하지 않죠. h.on_limit도 호출하지 않고, do_limit도 호출하지 않습니다. 그런데 나중에 chain()을 호출하면 모든 hook이 순서대로 실행됩니다.

"이 함수 객체가 대체 어떻게 hnext_fn을 알고 있는 거지?", "빌드할 때 아무것도 안 했는데, 호출할 때 어떻게 작동하는 거지?" 하는 질문들이 드실텐데요. 이는 Python의 closure, code object, frame object를 파고드는 좋은 출발점이 될 것입니다. 같이 보시죠!

2. 네임스페이스와 스코프: 클로저를 이해하기 위한 기반

클로저를 이해하려면 먼저 이것부터 알아야 했습니다. Python이 변수 이름을 어떻게 관리하고, 어떤 순서로 찾는지를 모르면, 클로저가 "바깥 변수를 기억한다"는 말 자체가 공중에 뜹니다. 이 절에서는 클로저의 전제가 되는 세 가지, 즉 namespace, scope, LEGB 규칙을 짚고 넘어가겠습니다.

네임스페이스란

Python에서 namespace는 이름(name)을 객체(object)로 매핑하는 것입니다. 내부적으로 딕셔너리로 구현되어 있고, Python에는 네 종류의 namespace가 있습니다:

각 namespace는 서로 다른 생명주기를 가집니다. 간단하게 확인해볼 수 있습니다:

def show_namespace():
    x = 42
    print(dir())   # ['x'] -- local scope에 x가 보인다
    return x

show_namespace()
# 함수가 끝나면 x는 사라진다. local namespace의 생명주기가 함수 호출과 같기 때문이다.

LEGB 규칙

Python이 이름을 찾을 때는 정해진 순서가 있습니다. Local → Enclosing → Global → Built-in 순서로 찾는데, 이것을 LEGB 규칙이라 부릅니다. 안쪽 스코프에서 먼저 찾고, 없으면 바깥으로 나가면서 찾습니다.

name = "global"

def outer():
    name = "enclosing"

    def inner():
        # name = "local"  <-- 이 줄을 주석 해제하면 "local"이 출력된다
        print(name)        # "enclosing" -- Local에 없으니 Enclosing에서 찾음

    inner()

outer()

inner()에서 name을 쓰면, Python은 먼저 inner의 local scope를 봅니다. 없으면 enclosing scope(outer)를 보고, 거기서 "enclosing"을 찾습니다. 이 LEGB 규칙을 알아야 build_hook_chain에서 context가 왜 chain_fn 안에서 보이는지 이해할 수 있습니다. chain_fnmake_chain 안에 있고, make_chainbuild_hook_chain 안에 있으니, context는 Enclosing scope를 따라 두 단계 바깥에서 찾아지는 것입니다.

nonlocal 키워드

LEGB 규칙 덕분에 inner function에서 outer function의 변수를 읽는 것은 자연스럽습니다. 하지만 수정하려면 nonlocal 키워드가 필요합니다. 이것 없이 할당하면 Python은 새로운 local 변수를 만들어 버립니다.

def counter():
    count = 0

    def increment():
        nonlocal count   # 이것이 없으면 UnboundLocalError 발생
        count += 1
        return count

    return increment

c = counter()
print(c())  # 1
print(c())  # 2

이것이 중요한 이유는 실제 build_hook_chaintracked_next 함수 때문입니다. tracked_nextnonlocal next_called, next_result를 선언해서, make_chain 스코프의 변수를 수정합니다. LEGB 규칙으로는 읽기만 가능하고, 쓰기를 위해서는 nonlocal로 명시적으로 선언해야 합니다.

이 세 가지, namespace, LEGB, nonlocal이 클로저의 기반입니다. 다음 절에서 클로저 자체를 다루겠습니다.

3. 클로저의 본질

클로저 = 함수 + 그 함수가 만들어진 환경

가장 단순한 예제부터 보겠습니다:

def make_greeter(name):
    def greet():
        print(f"안녕, {name}!")
    return greet

greet_alice = make_greeter("Alice")
greet_bob = make_greeter("Bob")

greet_alice()  # 안녕, Alice!
greet_bob()    # 안녕, Bob!

make_greeter("Alice")는 이미 실행이 끝났습니다. 함수가 리턴되었으니 그 지역 변수 name은 사라져야 할 것 같습니다. 하지만 greet_alice()를 호출하면 "Alice"가 출력됩니다. greet 함수가 자신이 만들어진 환경의 변수 name기억하고 있기 때문입니다.

이것이 closure입니다. 함수 객체 + 그 함수가 정의된 시점의 바깥 변수 참조를 합친 것입니다.

__closure__cell_contents로 확인하기

Python에서는 closure가 캡처한 변수를 실제로 들여다볼 수 있습니다:

print(greet_alice.__closure__[0].cell_contents)  # "Alice"
print(greet_bob.__closure__[0].cell_contents)    # "Bob"

__closure__는 cell 객체의 tuple이고, 각 cell의 cell_contents가 캡처된 실제 값입니다. greet_alicegreet_bob은 같은 코드(greet 함수 본문)를 가지지만, 각각 다른 환경(다른 name 값)을 캡처하고 있습니다.

hook 체인에 적용

build_hook_chainmake_chain도 정확히 같은 원리입니다:

def make_chain(h, next_fn):
    def chain_fn():
        return h.on_limit(lambda: next_fn(), context)
    return chain_fn

make_chain(HookB, do_limit)을 호출하면 chain_fn이 리턴됩니다. 이 chain_fn은 클로저로서 h=HookBnext_fn=do_limit을 기억합니다. 다시 make_chain(HookA, chain_fn_B)를 호출하면 h=HookAnext_fn=chain_fn_B를 기억하는 또 다른 클로저가 만들어집니다.

make_chainchain_fn을 리턴할 때, chain_fn"나중에 호출되면 hnext_fn을 쓸 거야"라는 약속을 품고 있는 함수 객체입니다. 이것이 "나중을 위한 상태 저장"의 정체입니다.

4. 스코프 체인과 자유 변수

chain_fn 안에서 쓰이는 변수들은 여러 스코프 레벨에서 옵니다. Python의 LEGB(Local, Enclosing, Global, Built-in) 규칙에 따라 바깥 스코프의 변수를 참조할 수 있습니다.

build_hook_chain의 4단계 스코프를 시각화하면 이렇습니다:

build_hook_chain(hooks, do_limit, context)    <-- context는 여기 있음
|
+-- chain = do_limit
+-- for hook in reversed(hooks):
|   |
|   +-- make_chain(h, next_fn)                <-- h, next_fn은 여기 있음
|       |
|       +-- chain_fn()                        <-- context? -> 바깥 스코프에서 찾음!
|           |
|           +-- tracked_next()                <-- next_fn? -> make_chain에서 찾음!
|
+-- return chain

chain_fncontext를 쓸 수 있는 이유는 간단합니다. chain_fnbuild_hook_chain 안에서 정의되었으므로, Python 스코프 규칙에 따라 바깥 함수의 context자연스럽게 참조할 수 있습니다. "기억한다"기보다는 "원래 보이는 범위에 있다"가 더 정확한 표현입니다.

중첩 스코프가 실제로 동작하는지 확인해보겠습니다:

def outer(x):
    def middle(y):
        def inner():
            print(f"inner에서: x={x}, y={y}")
        return inner
    return middle

fn = outer("from_outer")("from_middle")
fn()  # inner에서: x=from_outer, y=from_middle

print(fn.__code__.co_freevars)  # ('x', 'y')

inner는 한 단계 바깥의 y뿐 아니라, 두 단계 바깥의 x까지 참조할 수 있습니다. co_freevars를 보면 ('x', 'y')로, inner가 바깥 스코프에서 가져다 쓰는 변수(자유 변수, free variable)가 정확히 두 개임을 알 수 있습니다.

build_hook_chainchain_fn에 대응시키면 다음과 같습니다:

변수 출처 스코프 레벨
h make_chain 파라미터 바로 바깥
next_fn make_chain 파라미터 바로 바깥
context build_hook_chain 파라미터 두 단계 바깥

세 변수 모두 chain_fn자유 변수이고, 클로저를 통해 캡처됩니다.

5. 코드 객체 (__code__)

함수 객체 vs 코드 객체

Python 함수는 내부적으로 두 부분으로 나뉩니다:

  1. 함수 객체 (function object): 이름, 기본값, 클로저 등 "포장"
  2. 코드 객체 (code object): 바이트코드, 변수 목록 등 "내용물"

__code__는 함수의 코드 객체에 접근하는 속성입니다:

def add(x, y):
    total = x + y
    return total

code = add.__code__
print(code.co_name)       # 'add'
print(code.co_varnames)   # ('x', 'y', 'total') -- 지역 변수 (파라미터 포함)
print(code.co_argcount)   # 2 -- 파라미터 개수
print(code.co_freevars)   # () -- 자유 변수 (바깥에서 캡처한 변수)

co_freevars, 클로저의 증거

co_freevars는 해당 함수가 바깥 스코프에서 캡처하는 변수의 이름 목록입니다. 이름만 있고 실제 값은 __closure__에 들어있습니다:

def outer(x):
    def inner(y):
        return x + y
    return inner

fn = outer(10)
print(fn.__code__.co_varnames)   # ('y',) -- 자기 변수
print(fn.__code__.co_freevars)   # ('x',) -- 바깥에서 캡처
print(fn.__closure__[0].cell_contents)  # 10 -- 실제 값

co_freevars는 이름 목록이고, __closure__는 그에 대응하는 cell 객체 tuple입니다. 둘을 zip하면 "어떤 이름이 어떤 값으로 캡처되었는지" 완전히 알 수 있습니다.

hook 체인의 chain_fn에서 확인

def build_hook_chain(hooks, do_limit, context):
    def make_chain(h, next_fn):
        def chain_fn():
            return h.on_limit(lambda: next_fn(), context)
        return chain_fn
    return make_chain("HookA", do_limit)

chain_fn = build_hook_chain([], lambda: None, {"key": "user:123"})
print(chain_fn.__code__.co_freevars)
# ('context', 'h', 'next_fn')

chain_fn이 바깥 스코프에서 context, h, next_fn 세 변수를 정말로 캡처하고 있음이 코드 객체 레벨에서 증명됩니다. "추측"이 아니라 Python 인터프리터가 컴파일 시점에 기록해둔 사실입니다.

정리하면 다음과 같습니다:

속성 의미
__code__ 함수의 "설계도" (바이트코드 + 메타데이터)
co_varnames 함수 안에서 선언된 지역 변수
co_freevars 함수가 바깥 스코프에서 가져다 쓰는 변수 이름
__closure__ co_freevars에 대응하는 실제 값 (cell 객체)

6. 프레임 객체

Code object(정적 설계도)와 Frame object(동적 실행 상태)

code object와 frame object의 관계를 비유하면 다음과 같습니다:

code object는 함수 정의 시 한 번 만들어지고, 불변(immutable)입니다. frame object는 함수가 호출될 때마다 새로 생성되고, 리턴하면 소멸합니다. 하나의 code object로 여러 frame이 만들어질 수 있으며, 재귀 호출이 대표적인 예입니다.

Frame이 가진 것

import sys

def example(x):
    y = x + 1
    frame = sys._getframe()  # 현재 실행 중인 프레임
    print(f"함수 이름:     {frame.f_code.co_name}")    # 'example'
    print(f"지역 변수:     {frame.f_locals}")           # {'x': 10, 'y': 11, ...}
    print(f"현재 줄 번호:  {frame.f_lineno}")
    print(f"호출한 함수:   {frame.f_back.f_code.co_name}")

example(10)
속성 의미
f_code 이 프레임이 실행 중인 code object
f_locals 지역 변수의 현재 값
f_globals 전역 변수
f_back 호출한 쪽의 frame (콜 스택 링크)
f_lineno 현재 실행 중인 줄 번호

같은 Code object, 다른 Frame

재귀 호출로 이를 확인할 수 있습니다:

def factorial(n):
    frame = sys._getframe()
    print(f"factorial({n}): frame id={id(frame)}, code id={id(frame.f_code)}")
    if n <= 1:
        return 1
    return n * factorial(n - 1)

factorial(3)
# factorial(3): frame id=..., code id=140234567  <-- code id 동일
# factorial(2): frame id=..., code id=140234567  <-- code id 동일
# factorial(1): frame id=..., code id=140234567  <-- code id 동일

code id는 모두 동일합니다. 같은 함수의 설계도이기 때문입니다. frame id는 모두 다릅니다. 각 호출마다 별도의 실행 상태이기 때문입니다.

Frame이 실제로 쓰이는 곳

frame object는 Python 인터프리터 내부에서 핵심적인 역할을 합니다:

traceback: 에러 발생 시 Frame 체인을 따라가며 콜 스택을 출력합니다. 우리가 매일 보는 에러 메시지의 정체가 frame 체인 순회입니다.

logging: sys._getframe(1)로 호출자의 파일명, 줄 번호, 함수명을 얻습니다:

def my_log(msg):
    caller = sys._getframe(1)
    print(f"[{caller.f_code.co_name}:{caller.f_lineno}] {msg}")

sys.settrace: 프로파일러와 디버거의 핵심입니다. 모든 함수 호출/리턴을 감시합니다:

call_log = []

def tracer(frame, event, arg):
    if event == "call":
        call_log.append(frame.f_code.co_name)
    return tracer

sys.settrace(tracer)
# ... 함수 호출 ...
sys.settrace(None)
print(call_log)  # ['foo', 'bar', ...]

pytest: assert 실패 시 지역 변수를 출력하는 것도 frame의 f_locals를 통해서입니다.

hook 체인에서의 Frame 생명주기

build_hook_chain과 frame의 관계를 정리하면 다음과 같습니다:

빌드 시점:

  1. build_hook_chain의 frame 1개 생성
  2. make_chain이 호출될 때마다 frame 추가 생성/소멸
  3. 빌드가 끝나면 모든 frame 소멸
  4. 하지만 클로저(chain_fn)는 살아남습니다

호출 시점 (chain()):

chain_fn_A의 Frame 생성
  -> HookA.on_limit의 Frame 생성
    -> tracked_next_A의 Frame 생성
      -> chain_fn_B의 Frame 생성
        -> HookB.on_limit의 Frame 생성
          -> tracked_next_B의 Frame 생성
            -> do_limit의 Frame 생성
            <- do_limit Frame 소멸
          <- tracked_next_B Frame 소멸
        <- HookB.on_limit Frame 소멸
      <- chain_fn_B Frame 소멸
    <- tracked_next_A Frame 소멸
  <- HookA.on_limit Frame 소멸
<- chain_fn_A Frame 소멸

Frame은 호출할 때 생겼다가, 리턴하면 사라지는 임시 실행 상태입니다. Closure는 함수가 리턴된 후에도 살아남는 캡처된 변수입니다. 이 차이가 핵심입니다.

7. 빌드 vs 호출 전체 추적

이제 hooks = [A, B]일 때 빌드부터 호출까지 전체를 추적해보겠습니다. 이 분리가 혼란의 원인이었으니 한 단계씩 짚어보겠습니다.

빌드 단계: 함수 체인 조립

빌드 단계에서는 아무 hook의 on_limit도 실행되지 않습니다. 클로저 객체만 만들어집니다.

chain = do_limit
# reversed(hooks) = [B, A]  <-- 뒤에서부터 감싼다

# 반복 1: hook = B
make_chain(h=B, next_fn=do_limit)
# -> chain_fn_B 리턴 (B와 do_limit을 기억하는 클로저)
chain = chain_fn_B

# 반복 2: hook = A
make_chain(h=A, next_fn=chain_fn_B)
# -> chain_fn_A 리턴 (A와 chain_fn_B를 기억하는 클로저)
chain = chain_fn_A

빌드가 끝난 후 메모리 상태입니다:

chain_fn_A  ->  클로저: { h: HookA, next_fn: chain_fn_B }
chain_fn_B  ->  클로저: { h: HookB, next_fn: do_limit }
(context는 둘 다 build_hook_chain 스코프에서 참조)

build_hook_chain"설계도"를 만듭니다. chain_fn들의 연결 구조일 뿐입니다.

호출 단계: 설계도대로 실행

chain()을 호출하면 그제서야 모든 것이 실행됩니다:

chain()
= chain_fn_A()
  | h=HookA, next_fn=chain_fn_B, context=ctx
  |
  | tracked_next_A 정의됨
  |
  | HookA.on_limit(call_next=tracked_next_A, context=ctx)
  |   |
  |   | [A] before
  |   | call_next()  =  tracked_next_A()
  |   |   | next_result = next_fn() = chain_fn_B()
  |   |   |   |
  |   |   |   | h=HookB, next_fn=do_limit, context=ctx
  |   |   |   | tracked_next_B 정의됨
  |   |   |   |
  |   |   |   | HookB.on_limit(call_next=tracked_next_B, ctx)
  |   |   |   |   |
  |   |   |   |   | [B] before
  |   |   |   |   | call_next() = tracked_next_B()
  |   |   |   |   |   | next_result = do_limit()
  |   |   |   |   |   |   -> "결과"
  |   |   |   |   |   | return "결과"
  |   |   |   |   | result = "결과"
  |   |   |   |   | [B] after
  |   |   |   |   | return "결과"
  |   |   |   |
  |   |   |   | return "결과"
  |   |   |
  |   |   | return "결과"
  |   |
  |   | result = "결과"
  |   | [A] after
  |   | return "결과"
  |
  | return "결과"

실행 순서: A_before -> B_before -> do_limit -> B_after -> A_after

혼란이 생기는 이유는 빌드 시점에 아무 일도 일어나지 않기 때문입니다. make_chainchain_fn을 리턴할 때 "이게 뭐가 되는 거지?"라는 의문이 드는 것이 자연스럽습니다. chain_fn"나중에 불리면 이렇게 하겠다"는 약속일 뿐이기 때문입니다.

Hook이 없을 때

Hook이 없으면 do_limit을 그대로 리턴합니다:

chain = build_hook_chain([], do_limit, ctx)
chain is do_limit  # True

감쌀 것이 없으면 감싸지 않습니다. 가장 단순한 케이스입니다.

8. 클로저 late-binding 함정

이 절은 make_chain 팩토리 함수가 반드시 필요한가에 대한 이야기입니다. make_chain 없이 루프 안에서 직접 클로저를 만들면 어떻게 되는지 보겠습니다.

버그 버전: make_chain 없이 직접 정의

def build_hook_chain_BROKEN(hooks, do_limit, context):
    if not hooks:
        return do_limit

    chain = do_limit
    for hook in reversed(hooks):
        next_fn = chain

        # 문제: chain_fn 안의 hook과 next_fn은 루프 변수를 직접 참조
        def chain_fn():
            return hook.on_limit(next_fn, context)

        chain = chain_fn

    return chain

hooks = [A, B]로 호출하면 RecursionError가 발생합니다.

왜 무한 재귀가 되는가

Python 클로저의 late binding 특성 때문입니다. 클로저는 변수의 이 아니라 변수의 **이름(참조)**을 캡처합니다. 루프 안에서 정의된 chain_fnhooknext_fn이라는 이름을 붙잡고 있을 뿐입니다.

루프가 끝나면:

결과적으로 chain_fn()을 호출하면 hook.on_limit(next_fn, ...)에서 next_fn자기 자신을 가리키므로 무한 재귀에 빠집니다.

정상 버전: make_chain 팩토리로 값을 고정

def make_chain(h, next_fn):
    def chain_fn():
        return h.on_limit(tracked_next, context)
    return chain_fn

chain = make_chain(hook, post_chain)

make_chain을 호출하면 그 시점의 hookpost_chain hnext_fn 파라미터로 복사됩니다. chain_fn은 루프 변수가 아닌 make_chain의 파라미터를 캡처하므로, 루프가 계속 돌아도 이미 캡처된 값은 변하지 않습니다.

이것이 Python에서 "루프 안에서 클로저를 만들 때는 팩토리 함수를 써라"라고 하는 이유입니다. make_chain은 단순한 코드 정리가 아니라, 정확한 동작을 위한 필수 장치입니다.

9. 정리: 세 가지 개념의 관계

개념 생성 시점 생명주기 hook 체인에서의 역할
Code object 함수 정의 시 (컴파일 타임) 프로그램 종료까지 (불변) chain_fn의 바이트코드와 co_freevars 목록 보관
Frame object 함수 호출 시 (런타임) 함수 리턴 시 소멸 (임시) chain() 호출 시 콜 스택 형성, 리턴 시 모두 소멸
Closure 함수 객체 생성 시 (런타임) 함수 객체가 참조되는 한 지속 h, next_fn, context 값을 빌드 이후에도 유지

세 가지는 서로 다른 레이어에서 동작합니다:

build_hook_chain에서 빌드 단계가 끝나면 frame은 모두 소멸하지만, closure 안에 캡처된 hnext_fn은 살아남습니다. 나중에 chain()이 호출되면 새로운 frame들이 생성되고, closure에 저장된 값을 꺼내서 사용하고, 또 소멸합니다.

이 세 가지를 이해하고 나니, make_chainchain_fn을 "그냥 리턴만" 하는 것이 왜 작동하는지가 명쾌해졌습니다. 함수를 리턴하는 것이 아니라, 함수 + 그 함수가 살아가는 데 필요한 환경을 통째로 리턴하는 것이었습니다.

마무리

hook 체인을 구현하면서 "이게 어떻게 되는 거지?"라는 질문 하나가 Python 내부 깊은 곳까지 이어졌습니다. closure가 상태를 기억하는 원리, code object가 정적 메타데이터를 보관하는 방식, frame object가 호출 시 만들어지고 사라지는 생명주기, 이 세 가지가 맞물려서 build_hook_chain이 동작합니다.

특히 late-binding 문제로 인한 RecursionError를 직접 겪어보면서, make_chain 팩토리가 "깔끔한 코드 정리"가 아니라 "정확한 동작을 위한 필수 장치"임을 체감했습니다. 라이브러리 코드를 읽을 때 "왜 이렇게 작성했을까?"를 파고드는 것이 가장 확실한 학습 방법이라는 것을 다시 한 번 느꼈습니다.

다음 Part 4에서는 hook 시스템의 타입 검증과 로깅에 대해 다룰 예정입니다.