Flask 사용법

김무성·2022년 2월 15일
3

API

목록 보기
1/4

Flask

  • 웹 애플리케이션 개발을 위한 파이썬 프레임워크
    Api server의 역할을 더 많이 한다.

  • 파이썬으로 웹 개발을 할 때 많이 쓰이는 프레임워크로 가장 유명한 것은 Django(장고)이지만 Django는 너무 무겁고 기능이 많아서 복잡한 반면 Flask는 필요한 기능만 최대한 라이트하게 개발을 할 수 있음

  • Flask는 micro프레임워크로 최소한의 구성 요소와 요구 사항을 제공하기 때문에 시작하기 쉽고 필요에 따라 유연하게 사용할 수 있음

  • 쉽게 확장할 수 있도록 설계되어 있기 때문에 개발을 하는 입장에서는 본인이 필요한 도구와 라이브러리를 자유롭게 선택해서 적용 가능하다는 게 가장 큰 장점
    데이터베이스를 통합하거나 계정 인증 등을 포함하는 복잡한 앱 뿐만 아니라 단순한 정적 웹 사이트를 만드는 데도 유용하다.

Flask로 Hello World!

  • 플라스크로 프로젝트를 할 폴더에 가상환경을 만들고, 플라스크를 설치(pip install Flask)
    Flask 공식 문서에서도 가상환경을 통한 설치를 안내하고 있다.

  • 프로젝트 폴더 안에 app.py라는 스크립트 파일을 만들어 아래 코드를 넣고 파일을 실행

from flask import Flask

app = Flask(__name__)

@app.route('/')
def home():
    return 'Hello, World!'

if __name__ == '__main__':
    app.run(debug=True)
  • app.run() 괄호 안에 debug=True라고 명시하면 해당 파일의 코드를 수정할 때마다 Flask가 변경된 것을 인식하고 다시 시작
    스터디 용도로 코딩을 할 때 내용을 바로 반영해서 결과를 확인하기 편리하다.

  • Running on http://127.0.0.1:5000/라는 메시지로 로컬 환경에서 5000번 포트를 통해 해당 웹 페이지를 확인할 수 있음

Route (Url에 웹페이지 연결하기)

  • 웹 브라우저에서 URL을 방문하면 서버에 요청을 보내고, 서버는 그 요청을 처리해서 브라우저에 응답을 반환하며 반환된 결과를 HTML 문서로 보내주면 그 웹 페이지를 브라우저가 띄워줌

  • Flask에서는 URL을 방문 할 때 준비된 함수가 트리거되도록 바인딩 하기 위해 route() 데코레이터를 사용하며 이를 라우팅이라 함

from flask import Flask

app = Flask(__name__)

@app.route('/')
@app.route('/home')
def home():
    return 'Hello, World!'

@app.route('/user')
def user():
    return 'Hello, User!'

if __name__ == '__main__':
    app.run(debug=True)
  • “/”이나 “/home”이나 둘 다 같은 페이지를 출력하도록 연결했기 때문에 http://127.0.0.1:5000/ 뒤에 “/home”이라고 붙여도 동일하게 헬로 월드가 출력되는 페이지가 뜸

  • “/user”로 접속하면 헬로 유저가 뜸

  • @app.route('/경로') 를 쓸 때 URL 경로는 반드시 “/” (슬래시)로 시작해야 함

동적 Url 다루기

  • Flask에서는 뷰 함수에 바인딩 할 URL을 지정할 때 <variablename>을 명시하는 게 가능
    이 변수는 뷰 함수에 인수 형태로 전달이 된다._
from flask import Flask

app = Flask(__name__)

@app.route('/')
@app.route('/home')
def home():
    return 'Hello, World!'

@app.route('/user/<user_name>/<int:user_id>')
def user(user_name, user_id):
    return f'Hello, {user_name}({user_id})!'

if __name__ == '__main__':
    app.run(debug=True)
  • 반환되는 값으로 f-string 포맷을 활용해서 문자열에 변수를 넣고 h1 태그를 적용함

  • “/user/유저이름/유저아이디” 형식의 URL을 입력 받았을 때 유저이름과 유저아이디를 활용한 내용의 HTML 문서를 반환할 수 있음

  • URL은 문자열이기 때문에 < converter : variable_name > 구문을 사용해서 URL에서 받은 변수 타입을 변형할 수 있음

  • converter로 사용할 수 있는 옵션
    - string (기본값) : slash가 포함되지 않은 문자열
    - int
    - float
    - path : string과 동일하지만 slash를 포함한다.
    - uuid : UUID(범용고유식별자) 형식

HTML 렌더링

  • 뷰 함수에서 return하는 응답은 일반 텍스트, 데이터 등 다양한 형식이 될 수 있는데, 일반적으로는 웹 페이지에서 렌더링 할 HTML을 직접 반환하게 됨
from flask import Flask

app = Flask(__name__)

@app.route('/')
@app.route('/home')
def home():
    return '''
    <h1>이건 h1 제목</h1>
    <p>이건 p 본문 </p>
    <a href="https://flask.palletsprojects.com">Flask 홈페이지 바로가기</a>
    '''

@app.route('/user/<user_name>/<int:user_id>')
def user(user_name, user_id):
    return f'Hello, {user_name}({user_id})!'

if __name__ == '__main__':
    app.run(debug=True)

Template

  • Flask에서는 보여지는 부분과 처리하는 부분을 나누기 위해 템플릿이라는 기능을 제공
    클라이언트에게 돌려줄 웹 페이지로 단순한 HTML 형식의 문자열을 작성해도 웹페이지가 잘 나타나긴 하지만 애플리케이션을 만들고자 한다면 모든 페이지마다 HTML 파일을 각각 작성할 게 아니라 일관된 구조와 기능을 가진 템플릿(template)을 활용해야 함

  • Flask에서는 Jinja2라는 템플릿 엔진을 사용해서 애플리케이션 내 변수와 반복문, 조건문 등을 포함하는 HTML 파일을 렌더링할 수 있음

템플릿 렌더링해서 Url연결하기

from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
    return render_template("index.html")
if __name__ == '__main__':
    app.run(debug=True)
  • 프로젝트 폴더 내에 templates라는 폴더 생성
    폴더 이름은 반드시 templates여야 한다.

  • 안에 index.html이라는 파일을 생성

<!DOCTYPE html>
<html>
<body>
    <h1>메인 화면</h1>
</body>
</html>
  • app.py를 실행하면 Running on http://127.0.0.1:5000/라는 메시지가 뜸
    로컬 환경에서 5000번 포트를 사용한다는 뜻

  • 첫 화면이 index.html을 띄워줌
    @app.route('/')에 바인딩 된 함수가 render_template("index.html")이라고 되어 있기 때문에 해당 문서를 렌더링해서 반환하는 것

템플릿에서 변수 사용하기 (Filter 기능 활용)

  • render_template() 괄호 안에 렌더링 할 HTML 파일 이름에 이어 변수명을 키워드 인수를 추가해주면 되며 둘 이상의 변수를 추가하려면 쉼표로 구분

  • 실제로 템플릿에서 이 변수를 사용할 때는 {{ 변수명 }}과 같은 식으로 불러와서 사용

  • 변수를 템플릿 내에서 파이썬 함수처럼 filter 기능으로 처리하는 방법도 있음
    {{ 변수명 | 필터 }}와 같은 식으로 써주면 된다.

템플릿에서 if 조건문 사용하기

  • {% if문 %} 괄호 안에 조건문을 써주고, {% endif %}로 마무리
{% if template_variable == "Hello" %}
  <p>{{ template_variable }}, World!</p> 
{% endif %}
  • elif와 else도 파이썬에서 직접 사용하는 것과 같은 문법으로 사용할 수 있음
{% if template_variable < 20 %}
  <p>{{ template_variable }}은 20보다 작다.</p> 
{% elif template_variable > 20 %}
   <p>{{ template_variable }}은 20보다 크다.</p> 
{% else %}
   <p>{{ template_variable }}은 20이다.</p> 
{% endif %}

템플릿에서 for 반복문 사용하기

  • {% for문 %} 괄호 안에 반목문을 써주고, {% endfor %}로 마무리
<ul>
{% for x in range(10) %}
    <li>{{ x }}</li>
{% endfor%}
</ul>
  • 딕셔너리도 활용 가능
<ul>
{% for key, value in template_dict.items() %}
    <li>{{ key }} : {{ value }}</li>
{% endfor%}
</ul>
  • dictsort 필터를 사용할 때는 뒤에 .items()를 붙여주지 말고 사용
<ul>
{% for key, value in template_dict | dictsort %}
    <li>{{ key }} : {{ value  }}</li>
{% endfor%}
</ul>

템플릿 상속 (Inheritance)

  • 웹 사이트 레이아웃의 일관성 유지, 또는 header와 footer를 여러곳에 사용하기 위해서 템플릿 상속 기능을 사용

  • 부모문서를 만들고, 자식문서가 들어갈 부분에 {% block content %} {% endblock %}라고 작성

  • 자식문서 윗부분에 {% extends 부모문서이름 %}라고 명시한 후 {% block content %}와 {% endblock %}사이에 내용을 작성

Example

  • base.html이라는 부모문서 작성
<html>
  <head>
    <title>내 웹사이트</title>
  </head>
  <body>
  {% block content %}{% endblock %}
  </body>
</html>
  • index.html이라는 자식문서 작성
{% extends "base.html"  %}
{% block content %}
    <p>자식문서에 포함될 내용</p>
{% endblock %}
  • index.html이라는 문서를 render_template("index.html")와 같이 렌더링 해서 확인하면 아래와 같이 구성이 된다는 걸 알 수 있음
<html>
  <head>
    <title>내 웹사이트</title>
  </head>
  <body>
    <p>자식문서에 포함될 내용</p>
  </body>
</html>

코드 예시

  • Flask 템플릿 내에서 변수 필터링 및 조건문/반복문, 템플릿 상속을 테스트하기 위한 간단한 예시

  • 학생들의 정보와 시험 성적을 딕셔너리로 만들어놓은 후, 메인 페이지에서 이름을 확인하면 해당 학생의 성적이 리스트로 출력되는 페이지

app.py

from flask import Flask, render_template
app = Flask(__name__)
student_data = {
    1: {"name": "슈퍼맨", "score": {"국어": 90, "수학": 65}},
    2: {"name": "배트맨", "score": {"국어": 75, "영어": 80, "수학": 75}}
}
@app.route('/')
def index():
    return render_template("index.html", 
            template_students = student_data)
@app.route("/student/<int:id>")
def student(id):
    return render_template("student.html", 
            template_name=student_data[id]["name"], 
            template_score=student_data[id]["score"])
if __name__ == '__main__':
    app.run(debug=True)

base.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>시험성적 확인 사이트</title>
</head>
<body>
    <div>
        <a href="/">메인화면 바로가기</a>
    </div>
    {% block content %}{% endblock %}
</body>
</html>

index.html

{% extends "base.html"  %}
{% block content %}
    <h1>시험 성적 확인하기</h1>
    <p>이름을 클릭하세요.</p>
    <ul>
        {% for key, value in template_students | dictsort %}
            <li><a href="/student/{{ key }}">{{ value["name"] }}</a></li>
        {% endfor%}
    </ul>
{% endblock %}

student.html

{% extends "base.html"  %}
{% block content %}
    <h2>{{ template_name }} 님의 성적</h2>
    <ul>
        {% for key, value in template_score.items() %}
            <li>{{ key }} : {{ value }}</li>
        {% endfor%}
    </ul>
{% endblock %}

Form

  • 사용자로부터 정보를 입력 받는 방식
    계정에 로그인하고, 상품을 주문하거나 설문조사를 하는 등 사용자로부터 정보를 수집하는 것

  • 정보를 깔끔하고 체계적으로 수집하는 것은 쉽지 않은데 Flask를 사용하면 필드를 표시하고 데이터를 쉽게 수집하는 절차를 깔끔히 처리할 수 있음

Flask에서 form 활용을 위한 프로젝트 폴더, 파일 관리

  • Flask(플라스크)로 웹 앱을 만들기 위한 프로젝트 폴더 내에서 파일 구조를 어떻게 가져가면 편한지 간단한 형태로 구성
    - forms.py : form을 활용하기 위한 클래스를 담아놓는 파일. 유효성 검사와 같은 기능을 포함해놓을 수 있고, 이후에 flask 앱에서는 여기서 미리 생성해놓은 form 클래스를 가져다 쓰기만 하면 된다.
    - app.py : Flask 앱을 구동시킬 실행시킬 파일. URL와 템플릿을 연결하는 route 데코레이터와 그에 해당하는 뷰 함수가 담겨 있다.
    - *.html : 모든 HTML 문서, 즉 템플릿 파일은 templates라는 폴더 안에 담아놓아야 한다. 이 템플릿 내에서 내가 쓸 폼을 플라스크 변수 형태로 담아 사용할 수 있다.

FlaskForm을 활용하여 클래스 만들기 (forms.py)

  • 프로젝트 폴더 내에서 forms.py를 만들고 아래와 같이 회원 가입을 위한 form을 구성하는 파일 생성
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length, Email, EqualTo
class RegistrationForm(FlaskForm):
    username =  StringField("아이디", 
                            validators=[DataRequired(), Length(min=4, max=20)])
    email =  StringField("이메일", 
                            validators=[DataRequired(), Email()])
    password = PasswordField("비밀번호", 
                            validators=[DataRequired(), Length(min=4, max=20)])
    confirm_password = PasswordField("비밀번호 확인", 
                            validators=[DataRequired(), EqualTo("password")] )
    submit = SubmitField("가입")
  • FlaskWTF의 FlaskForm를 불러오는데, 내가 원하는 form을 만들기 위해서는 FlaskForm이라는 부모 클래스를 상속 받아 자식 클래스를 만들어 사용
    자식클래스를 선언할때 소괄호로 부모클래스를 포함시키게 되면 자식클래스에서는 부모클래스의 속성과 메소드는 기재하지 않아도 알아서 포함이 된다.
  • wtforms라는 라이브러리를 사용하여 원하는 폼 지정
    • StringField: 문자열
    • PasswordFiel: 화면에 표시되지 않아야 할 패스워드
    • SubmitField: 제출 버튼
  • wtforms.validators를 통해 유효성 검사
    • DataRequired: 필수입력값인지
    • Length: 길이는 어떻게 제한하는지
    • Email: 이메일인지
    • EqualTo: 이미 입력한 값과 같은 값을 입력했는지
  • 예제에서 생성한 클래스를 보면 일단 폼의 종류와 함께, 그 입력란의 label이 무엇인지 적어주고, validators를 통해 유효성 검사 항목을 포함해주면 끝

GET, POST 요청 처리에 따라 실행될 뷰 함수 (app.py)

  • 웹에서는 클라이언트가 서버와 통신 할 때마다 요청(request)이라는 걸 하게 됨

  • Flask에서는 웹 페이지를 route하게 되면 기본적으로 GET 요청만 지원
    해당 URL에 접속했을 때 브라우저 창에 어떤 내용을 표시할지 요청하는 거다.

  • 만약 웹 사이트를 통해 사용자가 form을 입력해서 그 양식을 제출하면 POST 요청으로 전송
    이 경우에는 route() 데코레이터의 method 인수로 POST를 지정해서 그 요청을 처리할 수가 있다.

@ app.route ( "/", methods = [ "GET", "POST"])
  • 위와 같이 하면 GET 요청과 POST 요청 모두 처리할 수 있음
    method를 지정하지 않으면 기본값은 GET이다.

  • 실제로 위에서 만든 forms.py의 클래스를 가져와 회원가입 기능을 app.py에 연결하려면 아래와 같이 적어주면 됨

from flask import Flask, render_template, url_for, flash, redirect
from forms import RegistrationForm

app = Flask(__name__)
app.config["SECRET_KEY"] = 'd2707fea9778e085491e2dbbc73ff30e'

@app.route('/')
def home():
    return render_template('layout.html')

@app.route('/register', methods=["GET", "POST"])
def register():
    form = RegistrationForm()
    if form.validate_on_submit():
        # 알람 카테고리에 따라 부트스트랩에서 다른 스타일을 적용 (success, danger) 
        flash(f'{form.username.data} 님 가입 완료!', 'success')
        return redirect(url_for('home'))
    return render_template('register.html', form=form)

if __name__ == '__main__':
    app.run(debug=True)
  • from forms import RegistrationForm로 일단 forms.py에서 작성한 내 회원가입 폼을 불러오기

  • app.config["SECRET_KEY"] = "복잡한 문자열" 코드, CSRF(Cross-Site Request Forgery)라는 웹 애플리케이션을 공격하는 걸로부터 보호하기 위해 사용
    SECRET_KEY 값을 생성할 때 파이썬 secrets 모듈을 활용, import secrets 한 후 secrets.token_hex(16)라고 적어주면 이렇게 복잡한 문자열이 하나 생성된다.

  • ‘/register’라는 URL을 회원가입으로 사용하기 위해 methods=["GET", "POST"]을 사용해 데코레이터를 만들어 라우팅 시킨 후 그 아래에 register()라는 뷰 함수를 작성
    • form이라는 인스턴스를 생성

    • 클라이언트가 ‘/register’ URL에 그냥 접속했다면 (폼을 작성해 POST 요청한 게 아니라 그냥 GET 요청) 한 것이기 때문에 render_template('register.html', form=form)을 돌려줘야 함

      회원가입 페이지를 바로 띄워주며 이때 form이라는 템플릿 변수에다가 미리 생성한 form 인스턴스를 연결
      했다.

    • 만약 클라언트가 POST 요청을 통해 form을 정상적으로 제출했다면 if 조건문 form.validate_on_submit()으로 확인해서 요청을 처리

    • 처리한 후 redirect(url_for('home'))을 반환하여 home이라는 뷰 함수와 연결된 곳, 즉 루트 URL로 리다이렉트 시킴

      url_for() 안에는 URL을 써주는 게 아니라 뷰 함수이름을 써줘야 한다

  • flash는 부트스트랩을 사용해서 알림 메시지를 띄우기 위해 사용
    메시지와 함께 카테고리 success나 danger 중 하나를 써주면 초록색, 혹은 붉은 색으로 HTML 문서에 알림을 띄울 수 있다.

form이 포함된 템플릿 작성 (register.html)

  • Jinja2 템플릿을 활용한 register.html
<form action="/" method="post">

	{{ form.hidden_tag() }}

	{{ form.username.label }}
    {{ form.username() }}
    
    {{ form.email.label }}
    {{ form.email() }}
    
    {{ form.password.label }}
    {{ form.password() }}
    
    {{ form.confirm_password.label }}
    {{ form.confirm_password() }}
    
    {{ form.submit() }}

</form>
  • 실제로 렌더링 된 HTML 파일
<form action="/" method="post">
    
    <input id="csrf_token" name="csrf_token" type="hidden" value="IjRiNzcxYTc5Mjg1NDNkZWY2MTJlZDgzOTlkMzdkN2U5NDBhMGVjNmEi.X4mQ0w.0DG_S08r-lH5AmF0XTAxRhYwngo">
    
    <label for="username">아이디</label>
    <input id="username" name="username" required="" type="text" value="">
    
    <label for="email">이메일</label>
    <input id="email" name="email" required="" type="text" value="">
    
    <label for="password">비밀번호</label>
    <input id="password" name="password" required="" type="password" value="">
    
    <label for="confirm_password">비밀번호 확인</label>
    <input id="confirm_password" name="confirm_password" required="" type="password" value="">
    
    <input id="submit" name="submit" type="submit" value="가입">

</form>
  • 첫번째 {{ template_form.hidden_tag() }}는 화면에 표시되지는 않지만 CSRF로부터 보호하는 데 필요한 작업을 처리하므로 실제 HTML 문서를 보면 복잡한 코드가 자동으로 포함

  • 이후에 {{ form.username.label() }}과 같은 식으로 레이블을 가져오고, 양식을 작성할 부분에는 {{ form.username }}이라고 적어주면 완료

※ 템플릿 상속, 스타일 적용

  • 부트스트랩 활용 (알림 메시지, validator로 에러 메시지도 띄워보기)

  • layout.html이라는 레이아웃 템플릿을 만들고, 회원가입 페이지에 활용할 register.html에서는 이 레이아웃을 상속 받아 사용

layout.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
</head>
<body>
    <main role="main" class="container">
        <div class="row">
            <div class="col-md-8">
                {% with messages = get_flashed_messages(with_categories=true) %}
                    {% if messages %}
                        {% for category, message in messages %}
                            <div class="alert alert-{{ category }}">
                                {{ message }}
                            </div>
                        {% endfor %}
                    {% endif %}
                {% endwith %}
                {% block content %}
                {% endblock %}
            </div>
        </div>
    </main>
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx" crossorigin="anonymous"></script>
</body>
</html>
  • 부트스트랩 스타일을 활용을 위해 여기에 있는 코드를 < link >태그로 붙여놓음

  • {% with messages = get_flashed_messages(with_categories=true) %} 라는 with 문을 활용해, 만약 메시지가 있을 경우 카테고리(success, danger)에 따라 출력

  • {% block content %}와 {% endblock %} 사이에 상속받을 HTML 파일 내용을 넣어줄 예정

  • 실제 register.html 파일은 이렇게 구성 됨

{% extends "layout.html"  %}
{% block content %}
    <div class="content-section">
        <form method="post" action="">
            {{ form.hidden_tag() }}
            
            <fieldset class="form-group">
                <legend class="border-bottom mb-4">가입하기</legend>
                <div class="form-group">
                    {{ form.username.label(class="form-control-label") }}
                    {% if form.username.errors %}
                        {{ form.username(class="form-control form-control-lg is-invalid") }}
                        <div class="invalid-feedback">
                            {% for error in form.username.errors %}
                                <span>{{ error }}</span>
                            {% endfor %}
                        </div>
                    {% else %}
                        {{ form.username(class="form-control form-control-lg") }}
                    {% endif %}
                </div>
                <div class="form-group">
                    {{ form.email.label(class="form-control-label") }}
                    {% if form.email.errors %}
                        {{ form.email(class="form-control form-control-lg is-invalid") }}
                        <div class="invalid-feedback">
                            {% for error in form.email.errors %}
                                <span>{{ error }}</span>
                            {% endfor %}
                        </div>
                    {% else %}
                        {{ form.email(class="form-control form-control-lg") }}
                    {% endif %}
                </div>
                <div class="form-group">
                    {{ form.password.label(class="form-control-label") }}
                    {% if form.password.errors %}
                        {{ form.password(class="form-control form-control-lg is-invalid") }}
                        <div class="invalid-feedback">
                            {% for error in form.password.errors %}
                                <span>{{ error }}</span>
                            {% endfor %}
                        </div>
                    {% else %}
                        {{ form.password(class="form-control form-control-lg") }}
                    {% endif %}
                </div>
                <div class="form-group">
                    {{ form.confirm_password.label(class="form-control-label") }}
                    {% if form.confirm_password.errors %}
                        {{ form.confirm_password(class="form-control form-control-lg is-invalid") }}
                        <div class="invalid-feedback">
                            {% for error in form.confirm_password.errors %}
                                <span>{{ error }}</span>
                            {% endfor %}
                        </div>
                    {% else %}
                        {{ form.confirm_password(class="form-control form-control-lg") }}
                    {% endif %}
                </div>
                
            </fieldset>
            <div class="form-group">
                {{ form.submit(class="btn btn-outline-info") }}
            </div>
        </form>
    </div>
{% endblock content %}
  • 이러면 아래와 같은 화면 등장

출처

profile
graph data scientist

0개의 댓글