
최근에 개인 프로젝트를 하면서 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 으로 옮기고 난 이후부터 문제가 발생하기 시작했다. 그렇게 파이썬에 대한 낯선 여정이 시작되었다.
먼저 첫번째 문제, Python의 인터프리터가 어떻게 작동하는지 알지 못했다. Python의 인터프리터는 한 줄씩 읽는다. 이것은 알고 있었다. 아니, 말 그대로 그 문장만 알고 있었다. 저 한 줄씩 읽는다는 말은, 한 줄씩 실행한다는 말이다. 어디서부터? 맨 위에서부터!
Python은 한 줄씩 내려가며 코드를 읽는다. import문을 읽으면 그 즉시 import를 하는 모듈을 실행한다.
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'>로 나오는 것까지 확인했는데!
원래 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를 어떻게 하느냐에 따라서 오류가 쉽게 발생할 수 있는 만큼, 이런 것들은 전혀 잊지 않고 기억하기 위해 글을 적었다.