파이썬 함수: 스코프·기본 인수 함정·클로저를 실행 결과로

*args·**kwargs, / 와 *, 가변 기본 인수 함정, LEGB 와 UnboundLocalError, 루프 속 lambda, 3.11 의 match, 함수 호출 비용까지 실제 실행 결과로 정리.

시리즈: 파이썬 강좌 2/6 (초급). def 는 쓸 줄 알지만, 함수가 지난 데이터를 "기억" 하거나 변수가 갑자기 "referenced before assignment" 로 죽어서 당황한 분을 위한 글입니다. 다 읽고 나면 기본값이 언제 평가되는지, 이름을 어디서 찾는지, 함수 호출 한 번이 얼마나 드는지 정확히 알 수 있습니다.

크롤러나 텔레그램 봇을 처음 만들 때 생기는 함수 버그는 대부분 로직 문제가 아닙니다. 아무도 크게 말해 주지 않는 세 가지 규칙에서 나옵니다. 기본값은 한 번만 만들어진다, 이름에 대입하면 그 함수 전체에서 지역 변수가 된다, 클로저는 변수를 만들 때가 아니라 실행할 때 읽는다. 아래에서 규칙마다 파일 하나에 붙여 넣어 돌릴 수 있는 코드와, 파이썬 3.9·파이썬 3.11(Apple Silicon Mac, M1 Pro)에서 실제로 찍힌 출력을 보여 드립니다. 트레이스백 경로는 ~/demo/ 로 줄였습니다.

1강을 아직 안 읽었다면 첫 절 "이름은 객체를 가리킨다" 만이라도 보고 오세요. 이 글의 함정은 모두 그 이야기에서 나옵니다.

매개변수: 위치·키워드·args·*kwargs

def describe(name, version, *tags, debug=False, **extra):
    print(f"name={name} version={version} tags={tags} debug={debug} extra={extra}")

describe("app", "1.0")
describe("app", "1.0", "ios", "android", debug=True, store="jp")

args = ("app", "2.0", "web")
opts = {"debug": True, "region": "eu"}
describe(*args, **opts)
name=app version=1.0 tags=() debug=False extra={}
name=app version=1.0 tags=('ios', 'android') debug=True extra={'store': 'jp'}
name=app version=2.0 tags=('web',) debug=True extra={'region': 'eu'}
  • *tags 는 남는 위치 인수를 튜플로 모읍니다.
  • *tags 뒤에 오는 것은 키워드로만 넘길 수 있어서 debug 는 키워드 전용입니다.
  • **extra 는 모르는 키워드 인수를 딕셔너리로 모읍니다.
  • 호출하는 쪽의 *args, **opts 는 반대로 시퀀스와 딕셔너리를 풀어서 인수로 넘깁니다.

/ 와 * 표시

파이썬 3.8 에서 / 가 생겼습니다. 그 앞의 매개변수는 위치로만 넘길 수 있습니다. 이름 없는 * 뒤는 키워드로만 넘길 수 있습니다.

def clamp(value, /, low=0, high=100, *, strict=False):
    if strict and not (low <= value <= high):
        raise ValueError(f"{value} out of range")
    return max(low, min(high, value))

print(clamp(150), clamp(-5, low=-10), clamp(50, 0, 40))
100 -5 40

잘못 호출하면 나오는 TypeError 네 가지입니다. 메시지는 3.9 와 3.11 에서 똑같았습니다.

clamp(value=5)                  -> TypeError: clamp() got some positional-only arguments passed as keyword arguments: 'value'
clamp(5, 0, 100, True)          -> TypeError: clamp() takes from 1 to 3 positional arguments but 4 were given
describe('a')                   -> TypeError: describe() missing 1 required positional argument: 'version'
describe('a', '1', version='2') -> TypeError: describe() got multiple values for argument 'version'

두 번째가 * 를 쓰는 이유입니다. clamp(5, 0, 100, True) 는 읽는 사람이 "True 가 뭐지?" 하게 되니 함수가 아예 거절합니다. 언제 무엇을 쓸지는 이렇게 정리됩니다.

원하는 것 쓰는 법 이유
매개변수 이름이 의미 없고, 나중에 바꿀 수도 있음 def f(x, /) 이름을 바꿔도 호출하는 쪽이 안 깨진다
플래그·옵션 def f(x, *, verbose=False) 호출부에 verbose=True 가 보여서 읽기 좋다
다른 함수를 감싸서 인수를 그대로 넘김 def f(*args, **kwargs) 데코레이터·래퍼(4강)
보통의 매개변수 def f(a, b=1) 기본 선택

흔한 실수: 가변 기본 인수

파이썬에서 가장 유명한 함정이고, 1강의 별칭 문제가 모양만 바꾼 것입니다.

def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add_item("a"))
print(add_item("b"))
print(add_item("c", []))
print(add_item("d"))
print("defaults:", add_item.__defaults__)
['a']
['a', 'b']
['c']
['a', 'b', 'd']
defaults: (['a', 'b', 'd'],)

실제로 일어나는 일: [] 라는 식은 def 문이 실행될 때 한 번만 평가되고, 그 리스트는 함수 객체의 __defaults__ 에 저장됩니다. bucket 을 생략한 호출은 모두 그 리스트를 받습니다. 직접 리스트를 넘기면("c") 정상이라서 단위 테스트에서는 숨어 있다가, 서버가 켜진 뒤 들어온 모든 요청의 데이터가 한 리스트에 쌓이는 식으로 운영 중에 터집니다. 봇이 다른 사용자의 장바구니를 보여 주는 사고가 이렇게 납니다.

두 줄짜리 그림. 윗줄: def 문은 한 번만 실행되어 기본 리스트 하나를 만들고 add_item.__defaults__ 에 저장한다. 첫 호출 add_item('a') 는 거기에 추가해 ['a'] 를 돌려주고, 두 번째 add_item('b') 도 같은 리스트에 추가해 ['a', 'b'] 를 돌려준다. 아랫줄은 해결책: 기본값을 bucket=None 으로 두고 None 일 때만 함수 안에서 새 리스트를 만들면 호출마다 새 리스트가 나온다.
bucket=[] 가 호출 사이에 남는 이유. 기본값은 def 가 실행될 때 한 번만 만들어지고, 인수를 생략한 호출은 모두 같은 리스트를 받는다.

해결: None 을 표식으로 쓰고 함수 안에서 객체를 만듭니다.

def add_item_fixed(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

print(add_item_fixed("a"), add_item_fixed("b"))
['a'] ['b']

계산된 기본값이면 무엇이든 같은 규칙에 걸립니다. def log(msg, when=time.time()) 는 모듈을 import 한 시각을 기록합니다. 한 번 호출하고 1.1초 쉰 뒤 다시 호출했더니 두 호출의 타임스탬프가 같았습니다. when=None 으로 두고 안에서 time.time() 을 계산하세요.

스코프: LEGB 와 UnboundLocalError

함수에서 이름을 쓰면 파이썬은 네 곳을 차례로 찾고 처음 찾은 곳에서 멈춥니다. Local(지역), Enclosing(바깥 함수), Global(모듈), Builtins(내장).

이름 찾기 흐름도. 함수 안에서 쓴 이름은 먼저 Local(그 함수 자신의 변수)에서 찾는다. 없으면 Enclosing(바깥 함수의 변수, nonlocal 로 재대입 가능), 그다음 Global(모듈 최상위 이름, global 로 재대입 가능), 마지막으로 Builtins(len, print 등)에서 찾는다. 어디에도 없으면 NameError 가 난다.
LEGB: 이름은 Local → Enclosing → Global → Builtins 순서로 찾고, 처음 찾은 곳에서 멈춘다. 함수 안 어디에서든 대입한 이름은 그 함수 전체에서 지역 변수가 된다.
x = "global"
def outer():
    x = "enclosing"
    def inner():
        print("inner sees:", x)
    inner()
outer()
inner sees: enclosing

바깥 이름을 읽는 것은 자유롭습니다. 문제는 대입입니다. 크롤링한 페이지 수를 세려고 쓴 이 카운터는 멀쩡해 보입니다.

count = 0

def hit():
    count += 1
    return count

hit()

파이썬 3.9:

Traceback (most recent call last):
  File "~/demo/unbound.py", line 7, in <module>
    hit()
  File "~/demo/unbound.py", line 4, in hit
    count += 1
UnboundLocalError: local variable 'count' referenced before assignment

파이썬 3.11 은 문구를 바꿨고, 새 문구가 실제 원인에 더 가깝습니다.

    count += 1
    ^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

"count 가 지역 변수인가" 는 함수를 컴파일할 때 본문 전체에서 대입을 찾아 결정합니다. count += 1 은 대입이므로 hit 안의 count 는 같은 줄의 오른쪽까지 포함해 모두 지역 변수입니다. 더 헷갈리는 경우는 대입이 뒤에 있을 때입니다.

total = 100

def report():
    print("before:", total)   # 여기서 실패
    total = 0

report()
    print("before:", total)
UnboundLocalError: local variable 'total' referenced before assignment

아래 줄에 total = 0 한 줄을 추가했을 뿐인데 위 줄이 깨집니다. 코드 위아래를 오가며 고치다 보면 자주 만나는 상황입니다.

global 과 nonlocal

count = 0
def hit():
    global count
    count += 1
    return count
hit(); hit()
print("count =", count)

def make_counter():
    n = 0
    def step():
        nonlocal n
        n += 1
        return n
    return step

c1, c2 = make_counter(), make_counter()
print(c1(), c1(), c1(), "|", c2())
count = 2
1 2 3 | 1

global 은 모듈 수준 이름을, nonlocal 은 가장 가까운 바깥 함수의 이름을 다시 묶습니다. make_counter 를 부를 때마다 새 n 이 생기므로 c1 과 c2 는 따로 셉니다.

상황 추천
함수가 값을 필요로 함 매개변수로 받는다
함수가 새 값을 만듦 return 한다
호출 사이에 유지할 작은 상태 클로저 + nonlocal (또는 클래스)
시작할 때 한 번 정하는 모듈 전체 설정 global, 최소한으로
공유 리스트·딕셔너리 수정 키워드 불필요(.append 는 대입이 아님). 다만 한 번 더 생각

클로저와 늦은 바인딩 함정

클로저는 자신을 만든 함수의 변수에 계속 접근할 수 있는 안쪽 함수입니다.

def make_multiplier(k):
    return lambda v: v * k

double, triple = make_multiplier(2), make_multiplier(3)
print(double(5), triple(5))
print("cell:", c1.__closure__[0].cell_contents)
10 15
cell: 3

붙잡힌 변수는 셀(cell) 에 들어 있고, 앞 절의 c1 은 3 까지 셌습니다. 함정은 클로저가 만들 때의 값이 아니라 변수 자체를 저장한다는 점입니다.

fns = [lambda: i for i in range(3)]
print("late binding:", [f() for f in fns])

fns = [lambda i=i: i for i in range(3)]
print("default fix :", [f() for f in fns])

from functools import partial
def show(i): return i
fns = [partial(show, i) for i in range(3)]
print("partial fix :", [f() for f in fns])
late binding: [2, 2, 2]
default fix : [0, 1, 2]
partial fix : [0, 1, 2]

세 lambda 모두 호출될 때 i 를 읽는데, 그때는 루프가 끝나 i == 2 입니다. i=i 가 통하는 이유는 앞의 기본 인수 규칙 덕분입니다. 기본값은 정의할 때 한 번 평가되니, 여기서는 그게 바로 원하는 동작입니다. tkinter 같은 GUI 코드에서는 "모든 버튼이 같은 일을 한다" 로 나타납니다.

buttons = {}
for name in ("save", "load"):
    buttons[name] = lambda: f"clicked {name}"
print(buttons["save"]())
clicked load

타입 힌트는 강제되지 않는다

def area(w: float, h: float) -> float:
    return w * h

print(area("ab", 3))
print(area.__annotations__)
ababab
{'w': <class 'float'>, 'h': <class 'float'>, 'return': <class 'float'>}

에러가 나지 않습니다. CPython 은 힌트를 __annotations__ 에 저장만 하고 실행에는 쓰지 않습니다. "ab" * 3 은 문자열 반복이라 조용히 ababab 이 나옵니다. 힌트는 mypy 같은 도구와 에디터 자동완성, 그리고 문서로서 값을 합니다. 실행 중에 꼭 확인해야 하는 값은 직접 검사하세요(if not isinstance(w, (int, float)): raise TypeError(...)). 자바·C# 에서 넘어온 분이 가장 많이 오해하는 부분입니다.

match (파이썬 3.10+)

구조적 패턴 매칭은 switch 가 아닙니다. 데이터의 모양 을 맞춰 봅니다. 파이썬 3.11 에서 실행했습니다.

def handle(cmd):
    match cmd.split():
        case ["quit" | "exit"]:
            return "bye"
        case ["go", direction] if direction in ("north", "south"):
            return f"moving {direction}"
        case ["go", _]:
            return "unknown direction"
        case ["drop", *items]:
            return f"dropping {len(items)} item(s): {items}"
        case _:
            return f"??? {cmd!r}"

def status(resp):
    match resp:
        case {"status": 200, "data": data}:
            return f"ok: {data}"
        case {"status": 404}:
            return "not found"
        case {"status": int(code)} if code >= 500:
            return f"server error {code}"
        case _:
            return "unexpected"
quit           -> bye
go north       -> moving north
go up          -> unknown direction
drop key lamp  -> dropping 2 item(s): ['key', 'lamp']
dance          -> ??? 'dance'
{'status': 200, 'data': [1, 2]}  -> ok: [1, 2]
{'status': 404, 'msg': 'x'}      -> not found
{'status': 503}                  -> server error 503
{'code': 1}                      -> unexpected

딕셔너리 패턴은 남는 키(위의 'msg')를 무시합니다. API 응답 JSON 을 분기할 때 편합니다. 같은 파일을 파이썬 3.9 에서 돌리면 시작조차 안 됩니다.

  File "~/demo/l2_match.py", line 2
    match cmd.split():
          ^
SyntaxError: invalid syntax

웹호스팅이나 오래된 서버, macOS 기본 파이썬(3.9)에서도 돌아야 하는 코드라면 if/elif 를 쓰세요.

match 에서 초보자가 걸리는 함정: case 에 쓴 맨 이름은 비교가 아니라 캡처입니다.

NOT_FOUND = 404
def check(code):
    match code:
        case NOT_FOUND:
            return "not found"
        case 200:
            return "ok"
    case NOT_FOUND:
         ^^^^^^^^^
SyntaxError: name capture 'NOT_FOUND' makes remaining patterns unreachable

이 에러가 나면 차라리 다행입니다. case 가 하나뿐이면 컴파일되고, 모든 값에 매치되며, 값을 NOT_FOUND 에 대입해 버립니다. 200 으로 호출했더니 matched; NOT_FOUND inside = 200 이 나왔습니다. 점이 들어간 이름(IntEnum 으로 만든 case HTTP.NOT_FOUND: 는 404 만 매치하고 200 은 지나쳤습니다)이나 리터럴을 쓰세요.

실측: 함수 호출 한 번의 비용

100만 개의 i 에 대해 i * i 를 더하는 일을 여섯 가지로 썼습니다(timeit, 7회 중 최솟값, 파이썬 3.9, Apple M1 Pro. 여섯 결과의 합계는 모두 같음).

import timeit
N = 1_000_000
def square(x): return x * x

def run_inline():
    t = 0
    for i in range(N): t += i * i
    return t
def run_call():
    t = 0
    for i in range(N): t += square(i)
    return t
# run_lambda: sq = lambda x: x * x 후 t += sq(i)
# run_kw:     t += square(x=i)
def run_comp(): return sum([i * i for i in range(N)])
def run_map():  return sum(map(square, range(N)))

for f in (run_inline, run_call, run_comp, run_map):
    print(f.__name__, min(timeit.repeat(f, number=1, repeat=7)))
방식 1회차 2회차 반복 1회당
리스트 컴프리헨션 + sum 42.3 ms 41.0 ms 약 42 ns
인라인 루프 44.2 ms 44.8 ms 약 44 ns
sum(map(square, …)) 50.2 ms 50.6 ms 약 50 ns
루프 안 lambda 호출 80.4 ms 79.2 ms 약 80 ns
루프 안 def 호출 82.9 ms 84.0 ms 약 83 ns
키워드 호출 square(x=i) 92.2 ms 91.1 ms 약 92 ns
1회당 나노초 막대그래프. 리스트 컴프리헨션+sum: 42 ns. 인라인 루프: 44 ns. sum(map(square, …)): 50 ns. 루프 안 lambda 호출: 80 ns. def 함수 호출: 83 ns. 키워드 인수 호출 square(x=i): 92 ns.
100만 번 루프에서 t += i*i 를 작성 방식별로 잰 1회당 시간. timeit 7회 중 최솟값, Python 3.9, Apple M1 Pro. 본문이 가벼운 루프에서는 함수 호출이 비용을 거의 두 배로 만든다.

파이썬 3.9 에서 함수 호출은 일 자체 외에 약 40 ns 가 더 들고, 키워드 인수는 약 9 ns 를 더 씁니다. lambda 라고 def 보다 빠르지 않습니다. 둘은 같은 종류의 객체입니다. 본문이 이렇게 가벼운 루프에서는 호출이 시간을 두 배로 만들지만, DB 조회·JSON 파싱·정규식처럼 실제 일을 하는 함수라면 40 ns 는 잡음입니다. 함수는 읽기 좋게 나누고, 측정해서 뜨겁다고 확인된 루프 안에서만 인라인하세요(프로파일링은 6강). 파이썬 3.11 은 호출 비용이 눈에 띄게 줄었지만, 이 Mac 의 3.11 은 에뮬레이션으로 돌아가서 시간을 재지 않았습니다.

한계와 버전 메모

  • 모든 코드는 파이썬 3.9.6 과 파이썬 3.11.4 에서 실행했습니다. 시간 측정은 3.9 에서만 했습니다.
  • / 위치 전용 매개변수는 파이썬 3.8(PEP 570)에서 추가됐습니다. 3.9 와 3.11 에서 돌렸고 3.8 에서는 돌리지 않았습니다.
  • match 는 파이썬 3.10(PEP 634)에서 추가됐습니다. 3.11 에서 실행하고 3.9 의 SyntaxError 를 확인했으며, 3.10 자체로는 돌리지 않았습니다.
  • UnboundLocalError 메시지는 위에서 본 것처럼 3.11 에서 바뀌었습니다. 메시지 문자열로 분기하는 코드는 버전이 바뀌면 깨지니 예외 타입으로 잡으세요.
  • 파이썬 3.12 의 type 문과 PEP 695 제네릭 문법(def first[T](xs: list[T]) -> T)은 설명만 합니다. 3.11 은 이 문법을 거부하므로 실행하지 않았습니다.

다음 강좌

자주 묻는 질문

파이썬 함수가 이전 호출의 리스트를 기억하는 이유는?

기본값은 호출할 때마다가 아니라 def 문이 실행될 때 한 번만 평가됩니다. def add_item(item, bucket=[]) 는 호출 사이에 리스트 하나를 공유해서, 두 번째 호출이 ['a', 'b'] 를 돌려줬습니다. bucket=None 으로 두고 함수 안에서 리스트를 만드세요.

UnboundLocalError: local variable referenced before assignment 는 왜 나나요?

함수 안 어디에서든 이름에 대입하면(count += 1 포함) 그 이름은 함수 전체에서 지역 변수가 되므로, 대입 전에 읽으면 실패합니다. 정말 바깥 변수를 바꾸려면 global 이나 nonlocal 을 선언하고, 가능하면 값을 인수로 받아 새 값을 return 하세요.

루프에서 만든 lambda 가 모두 마지막 값을 돌려줍니다.

클로저는 만들 때가 아니라 호출할 때 변수를 읽습니다. [lambda: i for i in range(3)] 은 [2, 2, 2] 를 돌려줬습니다. lambda i=i: i 처럼 기본 인수로 지금 값을 묶거나 functools.partial 을 쓰면 [0, 1, 2] 가 나왔습니다.

  • #파이썬
  • #초급
  • #함수
  • #스코프
  • #클로저
  • #match

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑