본문으로 건너뛰기
Advertisement

5.4 클로저와 스코프 — LEGB 규칙, nonlocal, global

변수의 범위(스코프)를 이해하면 코드의 동작을 정확히 예측할 수 있습니다. Python의 LEGB 규칙과 클로저를 마스터하면 데이터 은닉, 상태 유지, 데코레이터 구현 등 고급 패턴을 작성할 수 있습니다.


LEGB 규칙

Python은 변수를 찾을 때 다음 순서로 검색합니다:

L(Local) → E(Enclosing) → G(Global) → B(Built-in)

# Built-in: Python 내장 이름 공간
print(len([1, 2, 3])) # len은 Built-in

# Global: 모듈 수준 변수
global_var = "전역"

def outer():
# Enclosing: outer 함수의 스코프
enclosing_var = "외부 함수"

def inner():
# Local: inner 함수의 스코프
local_var = "지역"
print(local_var) # L: 지역 변수
print(enclosing_var) # E: 외부 함수 변수
print(global_var) # G: 전역 변수
print(len) # B: 내장 함수

inner()

outer()

스코프 검색 예시

x = "전역"

def outer():
x = "외부"
def inner():
x = "내부"
print(x) # "내부" — 가장 가까운 스코프
inner()
print(x) # "외부" — inner의 x와 무관

outer()
print(x) # "전역" — outer의 x와 무관


# 변수를 찾지 못하면 NameError
def no_local():
print(undefined_var) # NameError (없는 변수)

# try:
# no_local()
# except NameError as e:
# print(f"오류: {e}")

global 키워드

# 전역 변수를 함수 내에서 수정하려면 global 선언 필요
count = 0

def increment():
global count # 전역 count를 수정하겠다 선언
count += 1

increment()
increment()
increment()
print(count) # 3


# global 없이 수정 시도 → UnboundLocalError
total = 100

def bad_modify():
# total += 1 # UnboundLocalError: 지역 변수로 보이지만 값이 없음
pass

# 올바른 방법
def good_modify():
global total
total += 1

good_modify()
print(total) # 101


# global 사용은 테스트/디버깅을 어렵게 만듦 — 가급적 피하기
# 대안: 클래스, 함수 인수/반환값 활용
class Counter:
def __init__(self):
self.count = 0

def increment(self) -> int:
self.count += 1
return self.count

counter = Counter()
print(counter.increment()) # 1
print(counter.increment()) # 2

nonlocal 키워드

# 외부 함수(Enclosing) 변수를 내부 함수에서 수정
def make_counter(start: int = 0):
count = start # Enclosing 변수

def increment(step: int = 1) -> int:
nonlocal count # 외부 count를 수정
count += step
return count

def decrement(step: int = 1) -> int:
nonlocal count
count -= step
return count

def reset() -> int:
nonlocal count
count = start
return count

def get() -> int:
return count # nonlocal 없이도 읽기 가능

return increment, decrement, reset, get


inc, dec, rst, get = make_counter(10)
print(get()) # 10
print(inc()) # 11
print(inc(5)) # 16
print(dec(3)) # 13
print(rst()) # 10 (start로 리셋)


# nonlocal 없이 수정 시도
def broken_counter():
count = 0
def inc():
# count += 1 # UnboundLocalError!
pass
return inc

클로저(Closure)

클로저는 외부 스코프의 변수(자유 변수)를 기억하는 내부 함수입니다.

# 기본 클로저
def make_multiplier(factor: int):
# factor는 make_multiplier의 지역 변수
def multiply(x: int) -> int:
return x * factor # factor를 "기억"
return multiply # 내부 함수 반환

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5)) # 10
print(triple(5)) # 15

# double과 triple은 서로 독립된 factor를 기억
print(double.__closure__) # (<cell at 0x...>,)
print(double.__closure__[0].cell_contents) # 2
print(triple.__closure__[0].cell_contents) # 3


# 클로저로 데이터 은닉
def make_bank_account(initial_balance: float = 0):
"""클로저로 잔액을 은닉"""
balance = initial_balance

def deposit(amount: float) -> float:
nonlocal balance
if amount <= 0:
raise ValueError("입금액은 양수여야 합니다")
balance += amount
return balance

def withdraw(amount: float) -> float:
nonlocal balance
if amount <= 0:
raise ValueError("출금액은 양수여야 합니다")
if amount > balance:
raise ValueError(f"잔액 부족: {balance}원")
balance -= amount
return balance

def get_balance() -> float:
return balance # 읽기만 가능 (외부에서 직접 수정 불가)

return deposit, withdraw, get_balance


deposit, withdraw, get_balance = make_bank_account(10000)
print(get_balance()) # 10000
deposit(5000)
print(get_balance()) # 15000
withdraw(3000)
print(get_balance()) # 12000

try:
withdraw(20000)
except ValueError as e:
print(f"오류: {e}")

클로저 활용 패턴

1. 설정 기억 (Configuration Factory)

def make_logger(prefix: str, level: str = "INFO"):
"""특정 접두사와 레벨을 기억하는 로거 함수 생성"""
import time

def log(message: str) -> None:
timestamp = time.strftime("%H:%M:%S")
print(f"[{timestamp}][{level}][{prefix}] {message}")

return log


api_logger = make_logger("API")
db_logger = make_logger("DB", "DEBUG")
error_logger = make_logger("ERROR", "ERROR")

api_logger("요청 수신: GET /users")
db_logger("쿼리 실행: SELECT * FROM users")
error_logger("연결 실패: timeout")

2. 메모이제이션 (수동 구현)

def make_memoized(func):
"""클로저로 메모이제이션 구현"""
cache = {}

def memoized(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]

def get_cache_info() -> dict:
return {"size": len(cache), "keys": list(cache.keys())}

memoized.cache = cache
memoized.cache_info = get_cache_info
return memoized


@make_memoized
def fibonacci(n: int) -> int:
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)


print(fibonacci(10)) # 55
print(fibonacci(30)) # 832040
print(fibonacci.cache_info())

3. 부분 적용 (Partial Application)

def partial_apply(func, *partial_args, **partial_kwargs):
"""함수의 일부 인수를 미리 고정"""
def wrapper(*args, **kwargs):
all_args = partial_args + args
all_kwargs = {**partial_kwargs, **kwargs}
return func(*all_args, **all_kwargs)
return wrapper


def send_request(method: str, url: str, headers: dict = None, body: dict = None):
headers = headers or {}
return f"{method} {url} headers={headers} body={body}"


# GET 요청 함수 생성
get = partial_apply(send_request, "GET")
post = partial_apply(send_request, "POST")

print(get("https://api.example.com/users"))
print(post("https://api.example.com/users", body={"name": "Alice"}))

클로저 vs 클래스

# 같은 기능을 클로저와 클래스로 구현
# 클로저 버전
def make_accumulator(initial: float = 0):
total = initial

def add(n: float) -> float:
nonlocal total
total += n
return total

def reset() -> float:
nonlocal total
total = initial
return total

def value() -> float:
return total

return add, reset, value


# 클래스 버전
class Accumulator:
def __init__(self, initial: float = 0):
self._initial = initial
self._total = initial

def add(self, n: float) -> float:
self._total += n
return self._total

def reset(self) -> float:
self._total = self._initial
return self._total

@property
def value(self) -> float:
return self._total


# 클로저 사용
add, reset, value = make_accumulator(0)
print(add(10)) # 10
print(add(5)) # 15
print(reset()) # 0

# 클래스 사용
acc = Accumulator(0)
print(acc.add(10)) # 10
print(acc.add(5)) # 15
print(acc.reset()) # 0

# 선택 기준:
# - 상태가 단순하고 메서드가 몇 개: 클로저가 간결
# - 상태가 복잡하고 메서드가 많음: 클래스가 유지보수 용이
# - 상속이 필요: 클래스
# - 직렬화(pickle) 필요: 클래스 (클로저는 직렬화 어려움)

클로저로 데코레이터 이해

import time
import functools

# 데코레이터의 핵심은 클로저!
def timer(func):
"""함수 실행 시간을 측정하는 데코레이터"""
@functools.wraps(func) # func의 메타데이터 보존
def wrapper(*args, **kwargs):
# wrapper는 func을 "기억"하는 클로저
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.4f}초")
return result
return wrapper


@timer
def slow_function(n: int) -> int:
"""n까지 합계 계산"""
return sum(range(n))


result = slow_function(1_000_000)
print(f"결과: {result:,}")


# 인수 있는 데코레이터 = 클로저 3중 중첩
def repeat(n: int):
"""n번 반복 실행하는 데코레이터"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
results = []
for _ in range(n):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator


@repeat(3)
def greet(name: str) -> str:
return f"안녕하세요, {name}님!"


print(greet("Alice"))
# ['안녕하세요, Alice님!', '안녕하세요, Alice님!', '안녕하세요, Alice님!']

실전 예제: 이벤트 핸들러 시스템

from typing import Callable, Any
from collections import defaultdict


def make_event_emitter():
"""클로저 기반 이벤트 에미터"""
handlers: dict[str, list[Callable]] = defaultdict(list)

def on(event: str, handler: Callable) -> Callable:
"""이벤트 핸들러 등록. 제거 함수 반환."""
handlers[event].append(handler)

def off():
if handler in handlers[event]:
handlers[event].remove(handler)

return off

def emit(event: str, *args: Any, **kwargs: Any) -> int:
"""이벤트 발생. 호출된 핸들러 수 반환."""
called = 0
for handler in list(handlers.get(event, [])):
handler(*args, **kwargs)
called += 1
return called

def once(event: str, handler: Callable) -> None:
"""이벤트를 한 번만 처리"""
def one_time(*args, **kwargs):
handler(*args, **kwargs)
off() # 자동 제거

off = on(event, one_time)

return on, emit, once


on, emit, once = make_event_emitter()

# 핸들러 등록
off1 = on("data", lambda d: print(f"핸들러1: {d}"))
off2 = on("data", lambda d: print(f"핸들러2: {d * 2}"))
once("connect", lambda: print("최초 연결됨"))

emit("connect") # "최초 연결됨"
emit("connect") # 아무것도 없음 (once이므로 제거됨)

count = emit("data", 42)
print(f"호출된 핸들러: {count}개")

off1() # 핸들러1 제거
emit("data", 100) # 핸들러2만 호출됨

고수 팁

1. 클로저에서 루프 변수 캡처 주의

# 흔한 버그: 루프 변수가 클로저에 캡처될 때
funcs_bad = []
for i in range(5):
funcs_bad.append(lambda: i) # i를 참조 (값 아님)

print([f() for f in funcs_bad]) # [4, 4, 4, 4, 4] — 모두 마지막 i=4!

# 해결책 1: 기본값으로 현재 값 캡처
funcs_good = []
for i in range(5):
funcs_good.append(lambda i=i: i) # 기본값으로 현재 i 복사

print([f() for f in funcs_good]) # [0, 1, 2, 3, 4]

# 해결책 2: 팩토리 함수 사용
def make_func(i):
return lambda: i

funcs_factory = [make_func(i) for i in range(5)]
print([f() for f in funcs_factory]) # [0, 1, 2, 3, 4]

2. __closure__ 로 클로저 내부 검사

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

f = outer(42)
print(f.__closure__) # (<cell at 0x...>,)
print(f.__closure__[0].cell_contents) # 42
print(f.__code__.co_freevars) # ('x',) — 자유 변수 목록
Advertisement