Python 개발자라면 웹 프레임워크 하면 떠오르는 삼각형이 있다. 빠르고 쉽게 구축 가능한 Flask, 전통의 강호인 Django, 떠오르는 챌린저인 FastAPI.
그 중 특히 Flask 사용자라면 모르고 지나가기 쉬운 웹 서버, 요청, 응답에 관하여 간단하게 알아보자.
웹 서버?
Flask에서 웹 서버를 구동하고 싶으면, Flask 인스턴스를 생성하고 flask.run 메서드를 실행시키면 그만이다. 따라서, 플라스크를 하나의 인스턴스로 오해하기 쉽지만, 사실 Flask는 두 개의 객체가 공존하고 있다. 바로 CGI(Python에서는 WSGI/ASGI라는 이름으로 불린다)와 웹 서버 컨테이너다.
CGI
Common Gateway Interface의 약어로, 다른 언어에서는 CGI라는 이름을 더 많이 사용하지만, Python에서는 동기냐 비동기냐에 따라 wsgi(Web Server Gateway Interface), asgi(Asynchronous Server Gateway Interface)로 두가지로 분류한다.
FastAPI를 사용해봤으면 더 이해가 쉬울텐데, FastAPI는 Python과 구조가 굉장히 흡사하다. 어노테이션으로 구분된 각 API의 라우팅 경로, APIRouter/Blueprint를 이용한 url prefix 및 객체 생성 등 많은 부분이 유사한 구조를 띄고 있다.
그러나 결정적인 차이는 실제 서버를 구동시키는 부분에서 차이가 드러나는데, Flask 객체로 서버를 시작하는 Flask와 다르게 FastAPI는 uvicorn이라는 별도의 객체를 받아와 uvicorn 위에서 서버를 굴린다. Flask 객체는 CGI가 포함된 완성된 서버의 구조를 띄지만, FastAPI의 경우 자체 CGI를 사용하지 않고 웹 서버 컨테이너만으로 구성되었기 때문에, CGI 없이는 웹 서버를 구동할 수 없는 것이다.
따라서, FastAPI는 게이트웨이로 성능이 뛰어난 uvicorn을 채택했고, 이것은 FastAPI가 Node.js에 버금가는 빠른 속도를 지닐 수 있는 비결이 되었다.
웹 서버 컨테이너
웹 서버 컨테이너는 웹 서버와 웹 컨테이너로 분리되는데, 웹 서버가 웹 프레임워크의 코어 부분으로 들어오는 요청을 핸들링하여 웹 컨테이너로 전송하면, 웹 컨테이너는 들어온 요청(Request)을 분석하여 응답(Response)을 돌려준다. 이 두개를 합쳐 WAS(Web Application Server)라고 부른다.
요청과 응답
요청과 응답은 주소, 헤더, 데이터의 3가지로 구성되어있으며 주소에는 해당 요청의 목적지가, 헤더에는 요청의 속성이 Key-Value Store 형태로 저장되어있다. 데이터는 우리가 흔히 말하는 바디로, 실제 보내는 요청의 데이터가 String/Integer으로 dump 되어 포함된다. 즉, 어떤 값을 보내던지 String 혹은 Integer 타입으로 받게 된다는 것. 만약 보내는 데이터가 이미지/사운드/비디오 등, String 형태가 아닌 데이터라면 바이너리 형태로 디코딩되어 전송된다.
흔히들 Query-String으로 부르는, GET 요청에 데이터를 담아보내는 방식(ex. http://localhost:8000/api?key=testkey&?value=testvalue)은 바디에 데이터가 담기는 형태가 아니라, URL에 데이터를 집어넣는 방식이다. 따라서 목표 지점에서 URL로 데이터가 나타나게 되며, 지정된 인코딩 방식(일반적으로 UTF-8)이 아니라면 바이너리 형태로 표시된다.
컴파일 없이 update-alternative 모듈로 파이썬을 설치할 경우 가장 흔하게 발생하는 No module name: apt-pkg처럼 베이스가 되는 OS쪽에 문제가 생긴것이었다. 이젠 별별 에러를 들고 찾아오는 사람들이 많아져서 어디서 문제가 생겼는지 대충 감이 온다. 이게 짬에서 나오는 바이브라는 건가...
그래서 대체 누가 DNS를 이상한거로 바꿔놨는지는 3일인 지난 지금도 불명이다. 애초에 쓰는 사람도 두명 뿐인데, 그 중 한명은 장기 출장으로 외부로 나가있었고, 다른 한 사람은 그걸 만진 적이 없다고 하니...
더해서 pip 패키지가 urllib3.connection 관련해 에러를 띄우는 것을 보니, pip 는 내부적으로 urllib3으로 요청을 보내 패키지 목록을 파악하고 다운로드를 실행하는 것으로 보인다.
그리고 에러메세지 최후방의 /simple/pandas라는 주소가 왠지 자꾸 걸려서 www.pypi.org 뒤에 저 주소를 붙여 접속을 시도해봤더니... 마치 alpine linux의 index같은 패키지 사이트가 등장했다. 기타 matplotlib, scipy등 다른 모듈들도 테스트해봤지만 동일한 위치를 가르키고 있었다. 저 simple이 index 파일의 역할을 하고 있는 듯 하다.
그리고 /simple/pandas 내부에는 각종 판다스 버전의 아카이브가 하이퍼링크로 존재하고 있었다. 각종 OS 버전이 붙은거로 봐서, uname등으로 다운로드를 시도하는 서버의 아키텍처를 파악하고 거기에 맞는 위치의 filename 및 version으로 리디렉션 해주는게 아닐까? f'pandas-{version}-{uname}' 이런 식으로...
파일 최상단에 Flask라는 인스턴스를 생성하고, 해당 인스턴스에 데코레이터를 달아 선언한 주소로 리디렉션해주는 플라스크의 특성상(아마도 웹 프레임워크의 구조로 어쩔수 없는 특성상), 기본 구동 파일인 app.py 파일에 모든 라우터가 몰려있을 수밖에 없다.
계층/소속이 구분되지 않는 소규모 서버에선 상관이 없겠지만, 서버가 커지고 기능이 방대해질수록 특정 파일 하나만 지나치게 비대해지는 경우가 발생한다. 그럴 경우에 라우터가 몰려있는 파일의 코드 라인은 수만줄에 달하며(농담이 아니라 진짜 수만줄이었다) 유지 및 보수에 심각한 애로사항이 발생한다. 주로 개발자 및 운영자에게...
그렇다면 @app.route()는 어차피 Flask 인스턴트를 받아 연동되는 것이 아닌가? 그럼 메인 파일인 main.py파일을 다른 곳으로 import해서 사용하면 안되냐고 묻는 사람들이 있겠지만, 결론부터 말하자면, 불가능하다. main 함수를 import받아와 사용한 Flask 인스턴트는 route 경로로 리디렉션이 불가능하다.
그래서, 갈수록 커지고 방대해지는 이 답없는 펑션들을 각 계층에 맞게 분리해려는 의도에서 나온 것이 바로 Blueprint이다.
How to use Blueprint?
사용법은 간단하다. 우선 자신이 라우터로 사용할 빈 파일에 Blueprint를 import 받아오고, 인스턴스를 생성한다.
# router.py
from flask import Blueprint
api=Blueprint('api', name)
- 위 두개는 반드시 들어가야 하는 필수 변수
- 추가적으로 투입하는 변수 중 자주 사용하는 변수가 \`\`\`url\_prefix=<String>\`\`\`인데, 해당 Blueprint의 기초 경로를 고정시킨다. 즉, url\_prefix='/game'이라면 해당 Blueprint 인스턴스로 생성되는 API들은 따로 작성하지 않아도 모두 \`\`\`/game\`\`\`이라는 경로를 갖는다.
- 다음으로, Flask 메인 인스턴스가 있는 위치에서 Flask 인스턴스에 Blueprint 객체를 추가해준다.
# app.py
from flask import Flask
from router import api
app=Flask(name)
app.register_blueprint(api)
개발에 있어 로그는 굉장히 중요한 덕목이다. 여기서 에러가 뜨는지 안뜨는지, 조건문 내부로 진입이 되는지 안되는지, 무수히 많은 라이브러리와 클래스와 함수를 통과한 이 값이 어떻게 나타나는지는 진짜로 돌려보기 전까지는 아무도 모른다.
물론 이것을 위한 방법으로 print 문이 있다. 바로 print("val=",{value}). 간단한 한 줄로 함수 값 확인, 조건문 진입, 각 분기를 통과했음을 알려주는 알림 역할까지. 더해서 System.out.println에 비해 얼마나 간단하고 심플한가. 찬양하라, python. Viva 반 로섬.
그러나 만능같은 print에도 한계는 있다. 라이브러리 한두 개 가져와서 사용하는 연습용 예제라면 상관지만, 모듈이 열개가 넘어가고 코드 줄 수가 200줄, 300줄씩 줄줄히 늘어지게 되면 이야기가 좀 달라진다. jupyter notebook 이용자에게는 조금 와닿지 않을지도 모르지만, pycharm 이용자에게는 굉장히 머리아픈 사태가 도래한다.
젤나가 맙소사. 저 많은 모듈의 값을 어떻게 일일히 확인할것이며, 또 어느 print문이 어느 모듈의 몇번 줄에서 발생했는지 어떻게 검색할 것인가.
이를 위해 있는 디버깅 툴이지만, 실제 서비스를 운영하다보면 짧게는 수 시간에서 수 개월 분량의 운영 기록이 필요하기도 한다. 디버깅은 본격적인 서비스를 배포하기 전, 개발 단계에서 사용하는 방법이다.
이를 위해 등장한 것이 바로 로그이다. 디버깅, 혹은 테스트 러닝 때도 모든 출력이 기록으로 남으며, 약간 설정을 만져야 하지만 어떤 모듈의 몇번 줄에서 발생한 문제인지도 확인 가능하다. 그래서 대부분의 언어는 자체 logging 클래스를 지원하며, IDE에 따라서는 추가적인 기능이 있는 경우도 있다고 한다(직접 확인하지는 못했다).
그럼 로그의 기본적인 형태를 알아보자. 로그는 기본적으로 다섯 단계로 나뉜다. 각 단계는 DEBUG, INFO, WARN, ERROR, CRITICAL의 5단계로 이루어지며, 각 단계는 int값으로 레벨이 결정된다.
logging 모듈 내부
그럼 이 로그는 어떻게 사용하는 걸까? 가장 쉬운 예시는 마치 print문처럼 사용하는 것이다. 단 두줄로 출력이 끝난다.
import logging
logging.info('Protoss Zealot')
그러나 이대로 실행하면 터미널 창에는 아무것도 나타나지 않을 것이다. 실행법은 분명히 맞다. 하지만 왜 나타나지 않을까? 그건 logging의 설정 기본값이 warn 이하의 메세지는 출력하지 않도록 하고 있기 때문이다.
filename에 경로 및 파일 이름을 설정해주면 해당 위치에 alleat.log 파일이 생성되고, 내부에 로그 발생 내역이 적히게 된다.
하지만 이대로는 아쉽다. 조금 더 고차원적인 로깅을 위해서는 조금 알아두어야 할 것이 있다.
바로 logger 객체, handler 객체와 formatter 객체다. 이 객체들에 대해서 정말 길고 자세하게 설명해놓은 문서는 파이썬 공식 문서에 있으니 원하신다면 이쪽을 참고해주시길 바란다. 간단하고 심플한 설명을 원한다면 3단 아이스크림으로 표현 가능하다. 1층에는 logger 객체가 기본 골조가 되고, 2층에 handler 객체가, 3층에는 formatter 객체가 있는 것이다. 핸들러 위에 포매터, 로거 위에 핸들러를 얹는 구조라고 생각하면 된다.
import logging
import logging.handlers
바로 로깅 모듈의 하위 경로인 handlers 모듈이다. 참고로 저 handlers를 from logging import handlers로 호출하려고 하면 호출되지 않는다. 파이썬의 패키지와 경로에 관한 이야기인데, 자세한 이야기는 나중에 다루도록 한다.
저 두 라이브러리를 호출하고 나서는 logger 객체를 선언해야 한다. 로거 객체의 선언은 다음과 같다.
logger 변수에는 로그의 기본 뼈대인 로거 객체를 담고, hdlr 변수에는 로그의 성격을 결정할 핸들러 객체를 담는다. 마지막으로 formatter 변수에는 로그 파일 내부에 적힐 로그의 형태를 담는다.
이제는 아까 말한대로 3단 아이스크림처럼 척척 쌓아올리면 끝. 그럼 저 로거는 파일명이 server.log이고(filename='server.log'), 자정마다 새 파일이 생성되며('when='midnight'), 간격은 하루(interval=1)인 로거가 되고 형태꼴은 {로거이름, 로깅레벨, 발생시간, 메세지, 발생한파일명, 발생한줄}의 정보가 담긴 로그를 적게 된다.
그리고 마지막으로 아까 로깅 클래스의 수준이 warn이라고 했었다. 이제 우리는 이것을 DEBUG 수준까지 낮춰주면 logging.info로 로그 내역을 적을 수 있다.
그럼 이렇게 효율좋게 완성된 로그를 매 파일마다 일일히 써내야 할까? 귀찮고, 그럴 필요도 없다. 우리는 새로 set_log.py 파일을 만들어 로거를 생성하는 메소드(혹은 클래스)를 정의해 원하는 파일에서 import만 행하면 된다. 이게 바로 객체 지향 프로그래밍의 장점이 아니겠는가?