본문 바로가기

IT

[python] module level __getattr__ (__getattr__ 조금 더 파보기)

Intro

기존 코드의 하위 호환을 위해서, module level의 __getattr__을 쓰게 되었다.

오늘은 module level의 __getattr__에 대해서 정리해보려고 한다.

 

Contents

# constant.py
class Settings:
    def __init__(self):
        self.ENV_1 = "test1"
        self.ENV_2 = "test2"

setting = None

def get_setting():
    global setting

    if setting is None:
        setting = Settings()

    return setting
    
def __getattr__(name: str):
    return getattr(get_setting(), name)
# main.py
from constant import ENV_1

if __name__ == "__main__":
    print(ENV_1)

 

 

python에서는 놀랍게도 위와 같은 코드가 정상적으로 동작한다.

왜 정상적으로 동작하는지에 대해서는 python의 import 순서 부터 이해해야한다.

 

python import 동작

1. module load

기본적으로는 python은 import를 만나면, 모듈 로드를 시도한다.

모듈을 로드 할때, Top-Level Code(함수나 클래스 정의 밖에 있는 모듈의 최상위 레벨 코드)를 실행 시킨다.

 

2. ENV_1 을 찾으려고 시도(attribute lookup)

Top-Level code에서 Settings 정의, setting = None, get_setting, __getattr__이 모듈 네임스페이스 등록되었지만, ENV_1 이라는 클래스 정의, 함수, 전역 변수 가 없는 상태이다.

 

python은 기본적으로 constant.__dict__(모듈 전역 네임스페이스)에서 ENV_1 키를 찾으려고 하지만 없기 때문에, AttributeError가 발생한다.

 

3. fallback

AttributeError가 발생하면, python은 __getattr__가 정의 되어 있는지 확인하고, 그걸 호출해서 값을 제공한다.

 

 

 

정리 하자면,

python은 기존 방식대로 찾아보고, 없으면 __getattr__을 호출해서 값을 제공한다는 뜻이다.

 

상황 설명

위의 상황은 환경 변수와 같은 싱글톤을 사용할 만한 상황에서, 기존에는 전역변수로 되어 있던 데이터를 캡슐화하여 숨긴다.

하지만, 기존 코드가 from a import REDIS_ENV 와 환경변수를 호출 하고 있었다면, 해당 import 코드들을 바꾸지 않고, 하위 호환을 할 수 있다는 것이다.

 

문제점

당연하지만 여러 문제점이 있다.

 

1. typing 문제

__getattr__의 가장 큰 문제점은 typing이 사라진다. 동적으로 호출하니까 당연한 이야기이긴 하다.

하지만, 그래서 python 개발자들 사이에서 사용에 대해서 많은 말들이 오간다.
(python은 원래 duck typing이다 vs 협업을 위해 빡세게 타입을 쓰자)

 

타입 체커를 이용해서, from typing import TYPE_CHECKING 방식이 있지만, 일반적으로 개발자들이 원하는 typing과는 거리가 멀다.

 

2. 값 고정

from constant import ENV_1는 스냅샷이여서, import를 하는 순간 값을 복사해서 ENV_1에 바인딩 하는 과정을 거친다.

즉, 다른 코드로 인해서 constant내의 setting 전역변수가 변경되더라도, ENV_1의 값은 그대로이다.

 

하지만, 아래와 같이 사용하면, 매 접근 시마다, constant 모듈에서 attribute lookup이 다시 일어나서, 동적으로 사용이 가능하다.

import constant
print(constant.ENV_1)

 

3. 굳이?

이렇게 쓰는 경우는 정말 정말 적다.

 

활용

# kill_switch.py
from pathlib import Path

BASE_DIR = Path(__file__).parent
STOP_FILE = BASE_DIR / "stop"


class Flags:
    def __getattr__(self, name: str) -> bool:
        if name == "STOP":
            return STOP_FILE.exists()
        raise AttributeError(name)


_flags = Flags()


def __getattr__(name: str):
    return getattr(_flags, name)

 

# main.py
import time
import kill_switch


if __name__ == "__main__":
    while True:
        if kill_switch.STOP:
            print("정지 신호 감지")
            break

        print("실행중")
        time.sleep(1)

 

실행 위치에 stop 이라는 이름의 파일이 생성되면, 무한 루프를 탈출하도록 kill switch를 만들어 보았다.