Python에서는 변수 범위 해석이 실행 중이 아닌 컴파일 단계에서 정적으로 수행됩니다. CPython 컴파일러가 함수 정의를 만날 때, 추상 구문 트리를 탐색하여 모든 이름을 지역, 전역 또는 셀 변수로 분류하는 심볼 테이블을 만듭니다. 함수 본문 내의 어디에서든 할당 작업(예: 대입, 증분 대입 또는 임포트)을 감지하면 해당 이름을 전체 범위에 대해 지역 변수로 표시합니다. 이러한 설계는 가상 머신이 고정 크기 배열에서 작동하는 최적화된 LOAD_FAST 바이트코드를 사용할 수 있게 하여 느린 해시 테이블 조회를 수행하는 것을 피합니다. 이 최적화는 Python의 함수 호출 성능에 필수적이지만 엄격한 바인딩 요구 사항을 도입합니다.
이름이 지역으로 분류되면 컴파일러는 해당 이름의 모든 읽기 작업에 대해 LOAD_FAST 바이트코드 명령을 생성합니다. 런타임 중에 LOAD_FAST는 프레임의 지역 변수 배열에서 해당 인덱스의 객체 참조를 검색하려고 시도합니다. 슬롯에 아직 값이 할당되지 않았음을 나타내는 널 포인터가 포함되어 있으면 런타임은 UnboundLocalError를 발생시킵니다. 이는 동일한 이름의 전역 변수가 존재하더라도 발생하며, 이는 컴파일러가 의도적으로 LOAD_GLOBAL을 생성하는 것을 피했기 때문입니다. 이 오류는 이 정적 범위 결정에 대해 명시적으로 나타내며, NameError와 구별됩니다.
이를 해결하려면 해당 이름이 전역 네임스페이스를 참조함을 컴파일러에 명시적으로 알려주어야 하며, global <variable_name>을 선언해야 합니다. 이 선언은 컴파일러가 LOAD_GLOBAL 및 STORE_GLOBAL 명령을 사용하도록 하여 모듈의 전역 사전에서 이름을 동적으로 조회하게 만듭니다. 또는 조건 논리가 해당 변수를 읽기 전에 함수의 맨 위에서 모든 지역 변수를 초기화하도록 코드를 구조 조정할 수 있습니다. 중첩된 범위의 경우, nonlocal 키워드는 컴파일러가 클로저 셀에 접근하기 위해 LOAD_DEREF를 사용하도록 강제합니다. 이러한 선언은 컴파일 시간에 컴파일러의 바인딩 결정을 변경하여 미지정 지역 변수를 방지합니다.
threshold = 100 def analyze(data): # 컴파일러는 'threshold = ...'를 다음에 보아 지역으로 표시 if data > threshold: # UnboundLocalError 발생 return "high" threshold = 50 # 할당으로 지역 변수로 만듦 # 'global'을 사용하는 솔루션 def analyze_fixed(data): global threshold if data > threshold: # LOAD_GLOBAL 성공 return "high" threshold = 50 # 전역 변수를 업데이트
데이터 엔지니어링 팀이 Apache Airflow를 사용하여 ETL 파이프라인을 구축하고 있었습니다. 그들은 처리 매개변수를 쉽게 조정할 수 있도록 모듈 수준에서 기본 구성 사전을 CONFIG = {"batch_size": 1000}으로 정의했습니다. 주요 변환 함수 process_batch()는 처음에 if len(records) > CONFIG["batch_size"]:를 확인하여 분할이 필요한지를 판별했습니다. 나중에 함수 내에서 특정 조건 하에 코드는 CONFIG = {"batch_size": 500}으로 배치 크기를 줄이려 하였습니다. 이러한 패턴은 의도치 않게 범위 충돌을 유발했습니다.
파이프라인이 실행될 때 함수의 첫 번째 줄에서 UnboundLocalError로 크래시되었습니다: local variable 'CONFIG' referenced before assignment. 함수의 마지막에 있는 할당 문장이 Python 컴파일러가 CONFIG를 전체 함수 본체에 대한 지역 변수로 취급하게 했습니다. 그 결과, 시작 부분의 비교 작업은 초기화되지 않은 지역 변수 슬롯을 접근하기 위해 LOAD_FAST를 사용했습니다. 이 실패는 함수가 실행을 시작할 수 없었기 때문에 데이터 파이프라인을 중단했습니다.
팀은 처음에 지역 재할당을 local_config로 이름을 바꾸려 했는데, 이렇게 하면 축소된 배치 처리를 위한 새로운 사전을 생성하여 그림자 문제를 완전히 피할 수 있었습니다. 그러나 이 접근법은 현재 한계를 반영하기 위해 CONFIG라는 이름을 기대하는 하위 코드의 리팩토링이 필요했습니다. 개발자가 이후의 논리에서 새 변수 이름을 사용하는 것을 잊을 경우 잠재적인 불일치를 초래할 수 있었습니다. 동일한 개념에 대해 두 개의 변수 이름을 추적하는 인지적 부담도 이 솔루션을 덜 매력적으로 만들었습니다.
다른 옵션은 함수 시작 부분에 global CONFIG를 추가하여 컴파일러가 모든 참조를 전역 조회로 취급하도록 강제하는 것이었습니다. 이렇게 하면 오류를 방지할 수 있지만, 팀은 배치 처리 중에 전역 상태를 수정하는 것은 위험한 안티 패턴이라고 거부했습니다. 이는 함수 재진입성을 방해하고 단위 테스트를 상당히 복잡하게 만듭니다. 또한 코드가 스레드 간에 병렬화될 경우 경쟁 상태를 생성할 수 있습니다. 모듈 수준 상태에 대한 부작용은 생산 데이터 파이프라인에서는 용납할 수 없는 것으로 간주되었습니다.
세 번째 솔루션은 변수 이름 자체를 재할당하는 대신 기존 사전을 제자리에서 변경하는 것이었습니다. CONFIG["batch_size"] = 500을 사용하는 방법으로 이름 CONFIG에 대한 새로운 바인딩을 생성하지 않으므로 컴파일러는 이를 계속 전역 참조로 취급합니다. 이렇게 하면 UnboundLocalError를 피하면서 구성 업데이트가 후속 호출에 지속될 수 있습니다. 이는 즉각적인 수정으로 가장 좋은 것으로 간주되었지만, 팀은 나중에 구성을 클래스 인스턴스로 리팩토링할 계획을 세웠습니다. 변경 방법은 기존의 API를 유지하면서 즉각적인 크래시 문제를 해결했습니다.
그들은 세 번째 솔루션을 구현하여 재할당을 변경하여 CONFIG["batch_size"] = 500로 수정했습니다. 파이프라인은 오류 없이 실행을 재개하였고 구성 변경이 후속 배치에 제대로 적용되었습니다. 나중에 그들은 코드를 리팩토링하여 Pydantic 설정 객체를 함수에 주입하였습니다. 이렇게 하면 모듈 수준 전역 변수에 대한 의존성이 완전히 제거되고 함수가 순수하고 테스트 가능하게 되었습니다. 이 사건은 모든 Airflow 연산자의 코드 리뷰를 촉발하여 유사한 그림자 패턴을 없애는 계기가 되었습니다.
왜 함수 내에서 변수를 del 한 후 이를 읽으려 할 때 UnboundLocalError가 발생하고, 전역 범위로 돌아가지 않나요?
지역 변수에서 del x를 실행하면 프레임의 f_locals에서 참조가 제거되지만 x의 정적 분류가 지역으로 변경되지는 않습니다. 컴파일러는 후속 읽기를 위해 여전히 LOAD_FAST를 생성합니다. 인터프리터가 LOAD_FAST를 실행할 때 해당 슬롯이 비어 있음을 발견하고 UnboundLocalError를 발생시킵니다. 이는 런타임에서 범위 결정이 불변임을 확인합니다. 삭제 후 전역 x에 접근하려면 컴파일 시간에 global x를 선언해야 합니다.
기본 인수 표현식이 UnboundLocalError 함정을 어떻게 피하며, 이것이 평가 타이밍에 대해 무엇을 보여주나요?
기본 인수는 함수 정의가 주변 범위 내에서 실행될 때 한 번 평가되며, 함수의 지역 범위 내에서가 아닙니다. def f(val=CONFIG["key"]):라고 작성하면 Python은 정의 시간에 LOAD_GLOBAL을 사용하여 CONFIG를 해결합니다. 이후 함수 본문에서 CONFIG에 할당을 수행하더라도 지역으로 만들게 되지만, 기본값은 안전하게 이미 캡처되었습니다. 이는 기본값이 정의 시간에 전역 범위를 사용함을 보여주며, 함수 본문의 지역 실행과는 별개입니다. 따라서 기본값은 할당 전에 함수 본문에서 동일한 접근이 발생했을 때 발생할 UnboundLocalError를 피합니다.
왜 클래스 본문에서는 UnboundLocalError가 결코 발생하지 않으며, 이를 가능하게 하는 바이트코드 차이는 무엇인가요?
클래스 본문은 변수 접근을 위해 LOAD_NAME을 사용하며, 이는 클래스 사전, 다음으로 전역 사전, 마지막으로 내장 사전에서 동적 조회를 수행합니다. 이는 미리 할당된 고정 슬롯을 사용하지 않으므로 "미지정 지역" 상태를 만나지 않습니다. 클래스 본문에서 할당 전에 이름이 참조되면 LOAD_NAME이 단순히 전역 범위에서 찾을 수 있도록 진행합니다. 이러한 사전 기반 접근은 함수 로컬의 속도와 교환하여 클래스 생성 중에 필요한 유연성을 제공합니다.