파이썬 함수: 스코프·기본 인수 함정·클로저를 실행 결과로
*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") 정상이라서 단위 테스트에서는 숨어 있다가, 서버가 켜진 뒤 들어온 모든 요청의 데이터가 한 리스트에 쌓이는 식으로 운영 중에 터집니다. 봇이 다른 사용자의 장바구니를 보여 주는 사고가 이렇게 납니다.
해결: 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(내장).
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 |
파이썬 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 은 이 문법을 거부하므로 실행하지 않았습니다.
다음 강좌
- 이전: 파이썬 강좌 1/6: 자료형·가변성·is 와 ==
- 다음: 파이썬 강좌 3/6: 이터레이터·제너레이터·메모리
- 클로저와
*args, **kwargs는 4강 데코레이터와 컨텍스트 매니저에서, 호출 비용은 6강 프로파일링과 최적화에서 다시 나옵니다. - 다른 언어의 같은 주제: VB.NET 강좌 2: ByVal·ByRef 와 제어 흐름, C# 람다식, C# 메서드
자주 묻는 질문
파이썬 함수가 이전 호출의 리스트를 기억하는 이유는?
기본값은 호출할 때마다가 아니라 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] 가 나왔습니다.