Note 332 Flask, Jinja, Bootstrap
데이터 엔지니어의 관점에서 중요한 것 중 하나는 데이터 분석가가 원하는 데이터를 쉽고 빠르게 가져갈 수 있게 하는 것이다. DB에 적재되어 있는 데이터를 데이터 분석가가 직접 코딩으로 읽어올 수도 있겠지만, 만약 데이터를 읽어오고 다운로드를 받거나, 데이터를 조회하는 프로세스가 하나의 플랫폼으로 구현되어 있다면 데이터 분석가의 수고가 크게 줄어들 수 있다. 이 플랫폼을 구축하는 데이터 엔지니어나, 플랫폼을 사용하는 데이터 분석가, 사용자는 웹 어플리케이션을 사용함으로써 비교적 쉽게 플랫폼을 구축할 수 있다.
이번 포스팅에서는 API 웹 어플리케이션 중 하나인 Flask에 대해 다뤄보자.
Flask
Flask는 마이크로 웹 프레임워크이다(Micro Web Framework). 여기서 웹 프레임워크란 웹 서비스나 웹 API 등을 제공하고 웹 개발을 배포할 수 있게 하는 도구들의 모음이다. Flask는 마이크로라는 단어가 붙은 것처럼 최소한의 도구들만을 사용하는 웹 프레임워크이고, 가볍고 작은 용량으로 간단한 웹 어플리케이션을 제작할 수 있도록 한다. 특히, 다른 언어보다 비교적 쉬운 파이썬을 사용하여 웹 어플리케이션을 개발할 수 있다는 장점이 있다.
공식문서: https://flask.palletsprojects.com/en/1.1.x/
Jinja
웹 페이지의 사용자는 다른 상황에도 똑같은 정보를 받는 것을 원하는 것이 아니라 상황에 따라 각각 다른 정보를 전달받길 원한다. 예를 들어, 사용자가 SNS에 댓글을 달았는데 이 댓글이 반영되지 않고 예전에 보였던 화면만 계속 출력된다면 사용자가 원하는 정보가 제대로 반영이 되지 않은 것이다. 웹 페이지는 주로 HTML로 많이 작성이 되는데 사용자가 댓글을 입력했을 때 새로운 댓글을 화면에 출력하기 위해서는 HTML은 댓글이 달릴 때마다 업데이트가 되어서 새로운 정보를 추가해야 된다. 할 때마다 HTML을 새로 짜는 것은 시간 소모가 큰 일이기 때문에 사용자의 입력을 변수로 받아서 자동으로 HTML에 반영되게 하면 어떨까?
여기서 사용할 수 있는 것이 Jinja이다. Jinja는 웹 템플릿 엔진으로 맞춤형 웹 페이지를 자동으로 생산할 수 있게 한다. Flask와 Jinja를 함께 사용하면 Flask에서 정보를 전달 받고 이 정보를 Jinja에 전달해서 원하는 정보를 웹에 효과적으로 출력할 수 있다.
공식문서: https://jinja.palletsprojects.com/en/2.11.x/templates/#variables
Bootstrap
부트스트랩은 프론트엔드에 대한 최소한의 지식으로 웹을 다양하고 예쁘게 꾸밀 수 있도록 도와주는 도구이다. 특별한 설치없이 HTML의 헤더 부분에 자바스크립트와 CSS 링크만 넣어줘도 동작한다는 것이 장점이다.
공식문서: https://getbootstrap.com/docs/5.0/components/accordion/
Flask, Jinja, Bootstrap은 함수나 컴포넌트들이 매우 다양하기 때문에 개념으로 아는 것보다 실습을 하면서 원하는 요소들을 사용 해보는 것이 좋을 것 같다. 실습에서 사용되지 않은 다른 많은 내용들은 필요할 때마다 공식문서를 참고해서 작업해보자.
Route, Blueprint, Application Factory
Flask 실습을 하기 전 route, Blueprint와 어플리케이션 팩토리(Application Factory)의 개념에 대해 알아보자.
지난 포스팅에서 웹에 대한 공부를 할 때 IP 주소, 포트번호에 대해 간략하게 다뤘다. 배달의 예시를 들면 IP 주소는 나의 아파트 주소, 포트번호는 나의 집 주소가 될 수 있는데 route는 나의 방 주소라고 생각하면 된다. 즉, 나의 주소의 세부적인 정보로써 최종적으로 사용자의 웹이 어디에 있는가를 알 수 있도록 한다.
URL을 보면 https://sports.news.naver.com/news?oid=311&aid=0001433467 이렇게 ‘/’ 형태로 길게 이어진 엔드 포인트 들이 있는데 Flask에서 라우트는 이 엔드 포인트를 의미함으로써 함수와 연결되어서 어떤 주소의 웹에 어떤 정보를 표현할 것인지 정할 수 있게 한다.
Blueprint란 한 파일에 여러 가지 기능을 복잡하게 표현하지 않고, 각 기능별로 다른 파일을 나눠서 사용하는 것을 말한다. 기능에 따라 여러 개의 라우트들이 생길텐데 이 라우트들을 한 파일에서 관리하기에는 실수가 발생할 수 있고, 에러를 해결할 때도 각 기능을 독립적으로 확인하는 것이 더 수월하기 때문에 사용한다.
Blueprint를 사용할 때처럼 기능이 여러개가 되면 프로젝트가 커지고, 사용해야 하는 라이브러리들이 많아진다. Application Factory는 이렇게 많은 라이브러리들을 전역적으로 불러왔을 때 여러 라이브러리들이 각각 다른 기능들 때문에 뒤엉켜서 순환 참조되는 것을 막기 위해 사용하는 패턴이다. 따라서 특정 라우트와 함수 내에서만 원하는 라이브러리를 import 해서 사용한다.
Flask, Jinja, Bootstrp 실습
1) main_bp, main_bp는 templates 폴더의 index.html 파일로 스타일이 적용된다.
import csv
from flask import Blueprint, render_template
from mini_flask_app import CSV_FILEPATH
main_bp = Blueprint('main', __name__)
@main_bp.route('/', methods = ['GET']) # methods는 GET 방식. 즉, 웹에 정보를 출력할 것이다.
def index():
user_list = []
with open(CSV_FILEPATH, newline = '') as csvfile: # 저장되어 있는 users.csv 열어서 user_list에 정보 저장
data = csv.DictReader(csvfile)
for row in data:
user_list.append(row)
return render_template('index.html', user_list = user_list) # render_template함수를 써서 index.html의 템플릿을 웹에 적용
# index.html에 user_list를 변수로 넘겨서 이 정보를 웹에서 사용
<!DOCTYPE html>
<html>
<head>
<!-- Bootstrap에서 템플릿 구조를 가져왔다. 세부적인 사항은 나중에 더 조정해서 꾸며보자. -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.bundle.min.js" integrity="sha384-b5kHyXgcpbZJO/tY9Ul7kGkf1S0CWuKcCD38l8YkeH8z8QjE0GmW1gYU5S9FOnJ0" crossorigin="anonymous"></script>
<!-- CSS only -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl" crossorigin="anonymous">
</head>
<body>
<div class="navbar navbar-dark bg-dark shadow-sm">
<div class="container">
<a href="#" class="navbar-brand d-flex align-items-center">
<strong>Mini Flask</strong>
</a>
</div>
</div>
<section class="py-5 text-center container">
<div class="row py-lg-5">
<div class="col-lg-6 col-md-8 mx-auto">
<h1 class="fw-light">Mini Flask Application</h1>
<br>
<p class="lead text-muted">
Mini Flask 어플리케이션 메인 페이지에 온 것을 환영합니다
<br>
원하시는대로 페이지를 구현해 보세요!
</p>
</div>
</div>
</section>
</body>
</html>
2) user_bp를 작성하고, GET, POST, PATCH, DELETE methods를 활용해보자.
POSTMAN을 활용해서 methods를 정해서 입력해보자.
import os
import csv
import json
from flask import Blueprint, request
from mini_flask_app import CSV_FILEPATH
user_bp = Blueprint('user', __name__)
CSV_FILEPATH = os.path.join(os.getcwd(), 'mini_flask_app', 'users.csv')
def get_user_list(): # users 파일에 있는 사용자 정보중 이름만 저장해서 리스트로 리턴
user_list = []
with open(CSV_FILEPATH, newline = '') as csv_file:
data = csv.DictReader(csv_file)
for row in data:
user_list.append(row['username'])
return user_list
def write_csv_file(user_list): # 수정된 내용의 리스트를 입력받아 기존 파일에 수정된 정보를 기록
with open(CSV_FILEPATH,'w',newline = '') as csv_file:
data = csv.writer(csv_file, delimiter = ',')
data.writerow(['id', 'username']) #writerow는 한 줄만 쓸 때
data.writerows([[idx, val] for idx,val in enumerate(user_list)]) # writerows는 리스트처럼 여러 줄을 쓸 때
@user_bp.route('/user') #여기서 라우트가 /user지만 __init__.py에서 prefix가 /api이므로 최종적인 엔드포인트는 /api/user가 된다.
#그리고 methods를 따로 설정해주지 않아서 기본으로 GET methods가 사용됨.
def get_user(): # 내 서버주소 뒤에 쿼리를 전달(key,value 형식으로) => 127.0.0.1:5000/api/user?param1=test1
"""
`username` 을 키로 한 값을 쿼리 파라미터 값으로 넘겨주면
해당 값을 가진 유저를 리턴해야 합니다.
- `username` 값이 주어지지 않은 경우:
- 리턴 값: "No username given"
- HTTP 상태 코드: `400`
- `username` 이 주어졌지만 해당되는 유저가 없는 경우:
- 리턴 값: "User '{ username }' doesn't exist"
- HTTP 상태 코드: `404`
- 주어진 `username` 값으로 유저를 정상적으로 조회한 경우:
- 리턴 값: 'users.csv' 파일에 저장된 유저의 `id` 를 문자열로 변경한 값
- HTTP 상태 코드: `200`
"""
user_list = get_user_list()
name = request.args.to_dict() # 입력받은 파라미터를 가져와서 dict로 표현
if len(name) == 0:
return "No username given" , 400
elif name['username'] in user_list:
return str(user_list.index(name['username']) + 1), 200
else:
return f"User '{ name['username'] }' doesn't exist", 404
@user_bp.route('/user', methods=['PATCH']) # PATCH methods는 일부를 업데이트 할 때 사용.
def update_user():
"""
쿼리 파라미터로 `username` 과 `new_username`를 입력받아 기존 users 파일의 내용을
업데이트한다.
- 쿼리 파라미터에 `username` 혹은 `new_username` 가 없는 경우:
- 리턴 값: "No username/new_username given"
- HTTP 상태 코드: `400`
- 쿼리 파라미터에서 주어진 `username` 에 해당하는 유저가 'users.csv'
파일에 존재하지 않은 경우:
- 리턴 값: "User '{ username }' doesn't exist"
- HTTP 상태 코드: `400`
- 쿼리 파라미터에서 주어진 `new_username` 이 이미 사용 중인 경우:
- 리턴 값: "Username '{ new_username }' is in use"
- HTTP 상태 코드: `400`
- 정상적으로 주어진 `username` 을 `new_username` 변경한 뒤 'users.csv' 파일에 기록한 경우:
- 리턴 값: "OK"
- HTTP 상태 코드: `200`
"""
user_list = get_user_list()
name = request.args.to_dict()
try:
if len(name['username']) == 0 or len(name['new_username']) == 0:
return "No username/new_username given", 400
elif name['username'] not in user_list:
return f"User '{ name['username'] }' doesn't exist", 400
elif name['new_username'] in user_list:
return f"Username '{ name['new_username']}' is in use", 400
else:
idx = user_list.index(name['username'])
user_list[idx] = name['new_username']
write_csv_file(user_list)
return 'OK', 200
except:
return "No username/new_username given", 400
@user_bp.route('/user', methods=['POST']) # POST는 보통 바디에 JSON 형태의 데이터가 입력으로 들어온다.
#POSTMAN에서 바디에 JSON 정보를 입력해보자. ex) {"username":"kim"}
def create_user():
"""
JSON 으로 전달되는 데이터로 새로운 유저를 users.csv 파일에 추가.
- 주어진 JSON 데이터에 `username` 키가 없는 경우:
- 리턴 값: "No username given"
- HTTP 상태 코드: `400`
- 주어진 JSON 데이터의 `username` 을 사용하는 유저가 이미 'users.csv' 파일에 존재하는 경우:
- 리턴 값: "User '{ username }' already exists"
- HTTP 상태 코드: `400`
- 주어진 JSON 데이터의 `username` 으로 정상적으로 생성한 경우:
- 리턴 값: "Created user '{ username }'"
- HTTP 상태 코드: `200`
"""
user_list = get_user_list()
try:
name = request.get_json() # json 형식으로 받을거라 args.to_dict가 아니라 get_json
if len(name) == 0:
return "No username given", 400
elif name['username'] in user_list:
return f"User '{ name['username'] }' already exists", 400
else:
user_list.append(name['username'])
write_csv_file(user_list)
return f"Created user '{ name['username'] }'", 200
except:
return "No username given", 400
@user_bp.route('/user', methods=['DELETE'])
def delete_user():
"""
`username` 을 키로 한 값을 쿼리 파라미터 값으로 넘겨주면 해당 값을 가진 유저를 users.csv 파일에서 제거
- `username` 값이 주어지지 않은 경우:
- 리턴 값: "No username given"
- HTTP 상태 코드: `400`
- `username` 이 주어졌지만 해당되는 유저가 없는 경우:
- 리턴 값: "User '{ username }' doesn't exist"
- HTTP 상태 코드: `404`
- 주어진 `username` 값을 가진 유저를 정상적으로 삭제한 경우:
- 리턴 값: "OK"
- HTTP 상태 코드: `200`
"""
user_list = get_user_list()
try:
name = request.args.to_dict()
if len(name) == 0:
return "No username given", 400
elif name['username'] not in user_list:
return f"User '{ name['username'] }' doesn't exist", 404
else:
user_list.remove(name['username'])
write_csv_file(user_list)
return "OK", 200
except:
return "No username given", 400
3) 위에서 작성한 Blueprint를 import해서 하나의 __ init __ 함수에서 실행
애플리케이션 팩토리 패턴에 따라 작성한다.
import os
from flask import Flask
CSV_FILEPATH = os.path.join(os.getcwd(), __name__, 'users.csv')
def create_app(config=None):
app = Flask(__name__)
if config is not None:
app.config.update(config)
from mini_flask_app.views.main_views import main_bp # 상위폴더인 mini_flask_app의 view의 파일의 main_view.py 파일 import
from mini_flask_app.views.user_views import user_bp
app.register_blueprint(main_bp) # Blueprint한 main_bp 파일 가져옴.
app.register_blueprint(user_bp, url_prefix='/api') # Blueprint한 user_bp 파일을 가져옴, bp 파일의 라우트의 앞에 /api가 추가됨.
return app
if __name__ == '__main__':
app = create_app()
app.run()
4) Flask 실행
Flask 앱 관련 파일들이 담긴 mini_flask_app 폴더의 상위 폴더에서 실행시켜야 한다.
FLASK_APP=mini_flask_app flask run