Python import는 어떻게 동작하는가 - 내 supabase_client가 None이었던 이유를 찾아서

Python import는 어떻게 작동하는가
내 supabase_client가 None이었던 이유를 찾아서
최근에 개인 프로젝트를 하면서 Python을 만져보고 있다. 그런데 내가 기존에 쓰던 언어들과는 참 다른 특징을 가지고 있어서, 프로젝트를 진행하면서 혼란이 왔었다.
당시 내게 혼란을 줬던 코드는 다음과 같다.
# main.py
from dotenv import load_dotenv
from .core.dependencies import supabase_client
from .core import SUPABASE_URL, SUPABASE_KEY
load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
supabase_client = await create_async_client(
SUPABASE_URL,
SUPABASE_KEY,
)
yield
이렇게 main.py에서 서버가 시작될 때 supabase_client를 초기화하도록 했다. 앱의 맨 처음 시작 부분에 load_dotenv()를 호출하도록 했다.
# core/dependencies.py
import os
from supabase import AsyncClient
from typing import Optional
# Supabase 프로젝트 URL과 anon 키
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
# Supabase 클라이언트 생성
supabase_client: Optional[AsyncClient] = None
dependencies.py 의 코드는 다음과 같았는데, 코드를 실행하면 당연 main.py에서 supabase_client를 초기화하기 때문에 정상적으로 작동해야 할 터였다.
그런데 그러지 않았다. 대체 왜?
로그를 찍어보니 SUPABASE_URL과 SUPABASE_KEY가 빈 값이었다. 왜 그런걸까... 기억을 더듬어보니 기존에는 잘 작동하고 있다가 core/dependencies.py 상단에 있던 load_dotenv()를 main.py 으로 옮기고 난 이후부터 문제가 발생하기 시작했다. 그렇게 파이썬에 대한 낯선 여정이 시작되었다.
핵심 1. Python 인터프리터의 작동 방식
Python 인터프리터는 한 줄씩 실행한다
먼저 첫번째 문제, Python의 인터프리터가 어떻게 작동하는지 알지 못했다. Python의 인터프리터는 한 줄씩 읽는다. 이것은 알고 있었다. 아니, 말 그대로 그 문장만 알고 있었다. 저 한 줄씩 읽는다는 말은, 한 줄씩 실행한다는 말이다. 어디서부터? 맨 위에서부터!
Python은 한 줄씩 내려가며 코드를 읽는다. import문을 읽으면 그 즉시 import를 하는 모듈을 실행한다.
main.py의 맨 위에서부터 시작
main.py의 상단엔 여러 import 문들이 있다. Python의 인터프리터는 한 줄씩 읽어가면서 해당 코드를 실행한다. import문을 읽으면 그 즉시 import 하는 모듈을 실행한다. 문제는 여기서 발생한다. from .core.dependencies import supabase_client 코드를 읽을 때 인터프리터는 .core.dependencies 파일을 읽는다. 그 상태로 SUPABASE_URL과 SUPABASE_KEY에 값을 할당하는 거다. 그러면 무엇이 문제가 되는 걸까?
그렇다. load_dotenv()는 실행도 되지 않은 채 os.getenv()가 실행된다!
그래서 코드를 수정했다. 설정 파일을 관리하는 config.py를 만들고 그 안에 환경변수들을 넣어놨다.
import os
from dotenv import load_dotenv
# 파일이 import 되는 시점에 .env 파일을 최우선으로 로드
load_dotenv()
# 환경 변수를 읽어서 파이썬 상수로 정의
SUPABASE_URL: str | None = os.getenv("SUPABASE_URL")
SUPABASE_KEY: str | None = os.getenv("SUPABASE_KEY")
# env 값을 제대로 읽는지 확인
print(f"SUPABASE_URL: {SUPABASE_URL}")
print(f"SUPABASE_KEY: {SUPABASE_KEY}")
# 필수 환경 변수가 설정되었는지 확인
if not SUPABASE_URL or not SUPABASE_KEY:
raise ValueError("SUPABASE_URL and SUPABASE_KEY must be set in .env file")
자, 이제 main.py에서 config.py를 import 하는 순간 config.py에 있는 load_dotenv()가 실행될 것이다. 예상대로, 값이 제대로 읽히는 것을 확인했다!
**하지만 여기서 끝이 아니었다.**
앱에서 서버로 통신을 아무리 보내도, 서버에서는 'NoneType' object has no attribute 'auth' 오류를 뿜어냈다. 이 오류는 supabase_client.auth.get_user(token) 코드에서 발생했다. 대체 왜 그런걸까. 분명 env 값을 제대로 불러오는 것도 확인했고, main.py에서 supabase_client가 제대로 초기화되어서 <class 'supabase._async.client.AsyncClient'>로 나오는 것까지 확인했는데!
핵심 2. Python의 변수를 import 하는 것과 패키지를 import 하는 것은 다르다.
원래 main.py에서 .core.dependencies 로부터 supabase_client 변수를 import 해서 초기화해주고 있지 않았나?
그건 dependencies.py의 supabase_client 변수를 초기화 해주는 것이 아니었다. main.py에서 supabase_client를 재할당하면 main.py 네임스페이스의 supabase_client만 바뀌고, dependencies 모듈의 전역 변수는 여전히 None으로 남아있다. 즉, core.dependencies.py의 supabase_client는 main.py에서 사용되고 있지 않았다.
그럼 다른 모듈의 변수를 사용하려면 어떻게 해야하나?
그럼 대체 어떻게 다른 모듈 내 변수를 그대로 가져와 사용할 수 있는가? 해결법은 의외로 간단했는데, 그것은 바로 모듈 자체를 import 해서 사용하는 것이었다.
기존에 변수를 import 하던 방식에서
from .core.dependencies import supabase_client
@asynccontextmanager
async def lifespan(app: FastAPI):
supabase_client = await create_async_client(
SUPABASE_URL,
SUPABASE_KEY,
)
모듈을 import 하는 것으로 변경했다. 그리고 "모듈.변수" 형식으로 호출하여 해당 모듈의 변수를 사용할 수 있었다.
from .core import dependencies
@asynccontextmanager
async def lifespan(app: FastAPI):
# 모듈 자체를 import하고, 모듈.변수 형태로 접근한다.
dependencies.supabase_client = await create_async_client(
SUPABASE_URL,
SUPABASE_KEY,
)
yield
이렇게 코드를 고치자 비로소 문제가 해결되었다.
마무리하며
겉으로 보면 비슷한 import 문법이지만, Python은 다른 언어들과 전혀 다르게 동작한다. 이번 경험 덕분에 ‘from import’와 ‘import module’의 차이를 몸으로 배웠다. 값이 변할 수 있는 전역 상태는 반드시 모듈을 import해서 다루어야 한다는 교훈을 얻었다. import를 어떻게 하느냐에 따라서 오류가 쉽게 발생할 수 있는 만큼, 이런 것들은 전혀 잊지 않고 기억하기 위해 글을 적었다.
