Flask 코어 아키텍처 및 요청 처리 흐름
Flask의 소스 코드를 분석할 때 가장 흥미로운 두 가지 질문은 다음과 같습니다. 첫째, Flask 애플리케이션의 전체 라이프사이클은 어떻게 실행되는가? 둘째, request, session, g와 같은 객체들은 모듈 레벨에서 임포트되어 전역 변수처럼 사용되는데, 어떻게 동시성 환경에서 각 요청의 데이터를 정확하게 격리하고 반환할 수 있는가? 본 글에서는 이 두 가지 핵심 메커니즘을 소스 코드 수준에서 파헤쳐 봅니다.
1. WSGI 서버 구동과 app.run()
Flask 프로젝트를 실행할 때 호출하는 app.run() 메서드는 내부적으로 Werkzeug의 WSGI 서버를 구동합니다.
def run(self, host=None, port=None, debug=None, load_dotenv=True, **options):
# 환경 변수 및 설정 로드 로직 생략
from werkzeug.serving import run_simple
run_simple(host, port, self, **options)
WSGI 프로토콜과 웹 서버 구현
WSGI(Web Server Gateway Interface) 프로토콜을 준수하는 웹 서버가 있어야 Python 웹 애플리케이션을 실행할 수 있습니다. Django는 기본적으로 wsgiref를 사용하지만, Flask는 werkzeug을 사용합니다. 두 방식의 구현 차이를 살펴보겠습니다.
wsgiref를 이용한 WSGI 서버
from wsgiref.simple_server import make_server
class SimpleWSGIApp:
def __call__(self, env, start_resp):
path = env.get('PATH_INFO', '/')
start_resp('200 OK', [('Content-Type', 'text/html; charset=utf-8')])
if path == '/home':
return [b'<h1>Welcome to Home</h1>']
elif path == '/about':
return [b'<h1>About Us</h1>']
return [b'<h1>404 Not Found</h1>']
if __name__ == '__main__':
app_instance = SimpleWSGIApp()
httpd = make_server('127.0.0.1', 8080, app_instance)
print("Serving on port 8080...")
httpd.serve_forever()
Werkzeug를 이용한 WSGI 서버
from werkzeug.wrappers import Request, Response
from werkzeug.serving import run_simple
@Request.application
def handle_http_request(werkzeug_req):
user_agent = werkzeug_req.user_agent.string
return Response(f"User Agent: {user_agent}", mimetype='text/plain')
if __name__ == '__main__':
run_simple('127.0.0.1', 5000, handle_http_request)
Werkzeug의 @Request.application 데코레이터를 사용하면 뷰 함수가 Request 객체를 인자로 받고 Response 객체를 반환하도록 추상화됩니다. run_simple은 이 함수를 WSGI 앱으로 래핑하여 지정된 포트에서 HTTP 요청을 수신할 때마다 해당 함수를 실행합니다.
2. 요청 디스패치와 wsgi_app
run_simple에 전달된 self는 Flask의 app 객체 자체입니다. HTTP 요청이 들어오면 WSGI 서버는 app()을 호출하고, 이는 __call__ 매직 메서드를 트리거하여 self.wsgi_app(environ, start_response)을 실행합니다.
def wsgi_app(self, environ, start_response):
req_context = self.request_context(environ)
exc = None
try:
try:
req_context.push()
resp = self.full_dispatch_request()
except Exception as e:
exc = e
resp = self.handle_exception(e)
except:
exc = sys.exc_info()[1]
raise
return resp(environ, start_response)
finally:
if exc is not None and self.should_ignore_error(exc):
exc = None
req_context.pop(exc)
이 코드의 핵심 흐름은 다음과 같습니다:
req_context = self.request_context(environ): 현재 요청의 환경 변수(environ)를 기반으로RequestContext객체를 생성합니다. 이 객체에는request,session등의 데이터가 바인딩됩니다.req_context.push(): 생성된 컨텍스트를 스택에 푸시하여 현재 스레드/코루틴의 활성 컨텍스트로 설정합니다.resp = self.full_dispatch_request(): URL 라우팅을 수행하고 해당 뷰 함수를 실행하여 응답을 생성합니다.req_context.pop(exc): 요청 처리가 완료되면 컨텍스트를 스택에서 제거하여 메모리 누수를 방지합니다.
3. 스레드 및 코루틴 격리: Local과 LocalProxy
req_context.push()는 내부적으로 LocalStack의 push 메서드를 호출합니다.
def push(self, context_obj):
current_stack = getattr(self._local, 'stack', None)
if current_stack is None:
current_stack = []
self._local.stack = current_stack
current_stack.append(context_obj)
return current_stack
여기서 self._local은 Flask가 직접 구현한 Local 객체입니다. 멀티스레딩 환경에서 전역 변수를 안전하게 사용하기 위해 Python의 threading.local을 사용하는 것과 동일한 원리입니다.
import threading
import time
thread_safe_storage = threading.local()
def worker_task(task_id):
thread_safe_storage.payload = f"Data-{task_id}"
time.sleep(0.5)
print(f"Task {task_id} reads: {thread_safe_storage.payload}")
threads = [threading.Thread(target=worker_task, args=(i,)) for i in range(5)]
for t in threads:
t.start()
위 코드에서 각 스레드는 thread_safe_storage라는 동일한 전역 객체에 접근하지만, payload 값은 각 스레드마다 독립적으로 격리되어 데이터 충돌이 발생하지 않습니다.
Flask의 Local은 threading.local을 확장하여 스레드뿐만 아니라 그린렛(Greenlet) 기반의 코루틴 환경에서도 데이터를 격리할 수 있도록 식별자(ID) 기반의 딕셔너리 구조를 사용합니다. 이를 통해 request, session, g와 같은 객체들이 전역적으로 임포트되더라도 각 요청 컨텍스트에 맞게 안전하게 격리될 수 있습니다.
4. 프록시 객체를 통한 데이터 추출: request.method
우리가 코드에서 request.method를 호출할 때, 이 request는 실제 Request 객체가 아니라 LocalProxy로 래핑된 프록시 객체입니다.
request = LocalProxy(partial(_fetch_context_attribute, 'request'))
def _fetch_context_attribute(attr_name):
top_context = _request_ctx_stack.top
if top_context is None:
raise RuntimeError("Working outside of request context.")
return getattr(top_context, attr_name)
request.method에 접근할 때의 내부 동작 흐름은 다음과 같습니다:
request객체의 속성에 접근하면LocalProxy의__getattr__메서드가 호출됩니다.__getattr__은self._get_current_object()를 실행하여 실제 타겟 객체를 가져옵니다._get_current_object()는 초기화 시 전달된__local속성(여기서는partial(_fetch_context_attribute, 'request')편함수)을 호출합니다.- 편함수가 실행되면서
_fetch_context_attribute('request')가 호출되고, 이는 현재 스레드/코루틴의LocalStack최상단에 있는RequestContext에서 실제request객체를 추출하여 반환합니다. - 최종적으로
__getattr__은 추출된 실제request객체에서method속성을 리플렉션하여 반환합니다.
이러한 프록시 패턴과 편함수의 조합 덕분에, 개발자는 복잡한 컨텍스트 스택을 직접 관리할 필요 없이 전역 변수처럼 보이는 request 객체를 통해 현재 요청에 종속된 정확한 데이터에 투명하게 접근할 수 있습니다.