[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (3)”

[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (3)”

9월 23, 2022

오늘 할 것

프론트엔드 단에서, 저번에 만들어 두었던 게시물 목록 api를 받아와 뿌려주고, 회원가입을 구현하겠습니다.

프론트엔드 단에서 연결해 보기

위의 파일 중, script.js 파일을 건드릴 겁니다.

위와 같이 입력해 봅니다. 아주 간단하지만 이는 에러를 내뿜을 겁니다. Cross Origin Resource Sharing 에러인데요,

CORS 에러를 처리하기 위해서, 간단하게 백엔드 단에서 설정을 추가해주겠습니다.

먼저, 가상환경이 활성화되어있는지 확인 후 위의 명령어를 입력해 줍니다.

그리고, 위의 코드를 입력해 주세요.

이후 HTML을 열어 보면 에러가 해결되고, 콘솔에 무언가가 찍혀 있을 겁니다.

주의사항 : Live Server 플러그인이 아니라, 직접 브라우저로 HTML 파일을 열어 주세요!

현재 제 상태의 경우 포스트가 아예 없네요. 포스트를 몇 개 만들어 주겠습니다.

데이터베이스에 직접 값을 추가해도 되고, 위와 같이 ORM을 이용해서 값을 넣어줘도 됩니다!

cd backend -> flask shell 을 한 후 위의 명령어를 입력해 보세요.

아무튼, 위와 같이 게시물 생성이 완료되었습니다.

그리고 위와 같이 자바스크립트 코드를 수정해 보면, – 데이터를 받아온 후, 콘솔에 찍어 본다면,

우리의 게시물이 잘 찍히는 걸 볼 수가 있네요, 그런데, 페이지네이션을 10개마다 적용했으므로 게시물의 수가 좀 부족해 보입니다. 임의의 100개의 게시물을 만들어 보겠습니다.

먼저, flask shll 명령어를 입력하여 우리의 api가 로드된 플라스크 셸 환경을 띄웁니다.

그리고, 게시물 모델을 가져온 후 위의 명령어로 현재 데이터베이스에 존재하는 모든 게시물의 목록을 불러올 수 있습니다. 먼저 그것들을 다 삭제하겠습니다.

모든 게시물 목록에 대해서 순회하며 게시물을 지울 수 있겠네요. find_all() 메서드를 사용해 본다면 위와 같이 [] 빈 리스트가 나올 겁니다.

그리고, 직관적으로 제목과 id를 알 수 있도록 위와 같은 명령어를 플라스크 셸에서 입력하겠습니다.

PostModel.find_all() 을 사용해 보면, 성공적으로 데이터베이스에 100개의 게시물이 저장된 것을 확인할 수 있네요!

좋습니다! 이제 제가 매우 싫어하는 HTML과 자바스크립트를 만져볼 시간입니다. HTML이 약간 수정되었는데, 필자가 사용하고 있는 HTML 전체 코드는 아래와 같습니다. (index.html)

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="./assets/css/style.css" type="text/css" />
    <title>Flastagram</title>
</head>

<body>
    <!-- navbar start -->
    <nav class="navbar">
        <div class="nav-wrapper">
            <img src="./assets/img/logo.png" class="brand-img" alt="brand image">
            <input type="text" class="search-box" placeholder="search">
            <div class="nav-items">
                <img src="./assets/img/home.PNG" class="icon" alt="">
                <img src="./assets/img/messenger.PNG" class="icon" alt="">
                <img src="./assets/img/add.PNG" class="icon" alt="">
                <img src="./assets/img/explore.PNG" class="icon" alt="">
                <img src="./assets/img/like.PNG" class="icon" alt="">
                <div class="icon user-profile">
                </div>
            </div>
        </div>
    </nav>
    <!-- navbar end -->

    <section class="main">
        <div class="wrapper">
            <div class="left-col">
                <!-- status start -->
                <div class="status-wrapper">
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                    <div class="status-card">
                        <div class="profile-pic">
                            <img src="./assets/img/profile-ex.jpg" alt="pic">
                        </div>
                        <p class="username">goddessana</p>
                    </div>
                </div>
                <!-- status end -->

                <!-- single post -->
                <div class="post">
                    <div class="info">
                        <div class="user">
                            <div class="profile-pic">
                                <img src="./assets/img/add.PNG" alt="">
                            </div>
                            <p class="author"></p>
                        </div>
                        <img src="./assets/img/option.PNG" class="options" alt="">

                    </div>
                    <img src="./assets/img/cover-1.jpg" class="post-image" alt="">
                    <div class="post-content">
                        <div class="reaction-wrapper">
                            <img src="./assets/img/like.PNG" class="icon" alt="">
                            <img src="./assets/img/comment.PNG" class="icon" alt="">
                            <img src="./assets/img/send.PNG" class="icon" alt="">
                            <img src="./assets/img/save.PNG" class="save icon" alt="">
                        </div>
                        <p class="likes">1,999,231 likes</p>
                        <p class="description"></p>
                        <span class="author"></span>-
                        <span class="title"></span>
                        <p class="content"></p>
                        <p class="post-time"></p>
                    </div>
                    <div class="comment-wrapper">
                        <img src="./assets/img/smile.PNG" class="icon" alt="pic">
                        <input type="text" class="comment-box" placeholder="Add a comment!">
                        <button class="comment-btn">post</button>
                    </div>
                </div>
                <!-- single post -->

            </div>


            <div class="right-col">
                <div class="profile-card">
                    <div class="profile-pic">
                        <img src="./assets/img/profile-ex.jpg" alt="">
                    </div>
                    <div>
                        <p class="username">discus</p>
                        <p class="sub-text">blue diamond.</p>
                    </div>
                    <button class="action-btn">switch</button>
                </div>
                <p class="suggestion-text">
                    Suggestions for You
                </p>
                <div class="profile-card">
                    <div class="profile-pic">
                        <img src="./assets/img/profile-ex.jpg" alt="">
                    </div>
                    <div>
                        <p class="username">discus</p>
                        <p class="sub-text">blue diamond.</p>
                    </div>
                    <button class="action-btn">switch</button>
                </div>
                <div class="profile-card">
                    <div class="profile-pic">
                        <img src="./assets/img/profile-ex.jpg" alt="">
                    </div>
                    <div>
                        <p class="username">discus</p>
                        <p class="sub-text">blue diamond.</p>
                    </div>
                    <button class="action-btn">switch</button>
                </div>
                <div class="profile-card">
                    <div class="profile-pic">
                        <img src="./assets/img/profile-ex.jpg" alt="">
                    </div>
                    <div>
                        <p class="username">discus</p>
                        <p class="sub-text">blue diamond.</p>
                    </div>
                    <button class="action-btn">switch</button>
                </div>
                <div class="profile-card">
                    <div class="profile-pic">
                        <img src="./assets/img/profile-ex.jpg" alt="">
                    </div>
                    <div>
                        <p class="username">discus</p>
                        <p class="sub-text">blue diamond.</p>
                    </div>
                    <button class="action-btn">switch</button>
                </div>
                <div class="profile-card">
                    <div class="profile-pic">
                        <img src="./assets/img/profile-ex.jpg" alt="">
                    </div>
                    <div>
                        <p class="username">discus</p>
                        <p class="sub-text">blue diamond.</p>
                    </div>
                    <button class="action-btn">switch</button>
                </div>
            </div>
        </div>
    </section>
    <script src="./assets/js/script.js"></script>
</body>

</html>

이제, 만들어두었던 scripts.js 파일에 아래의 내용을 입력해 봅니다.

// API 기본 URL들을 정의합니다.
const postListBseUrl = "http://127.0.0.1:5000/posts/";
// #TODO : .env 로 url 주소 얻어오기
/** Flask API 로부터 데이터를 가져옵니다.
 * TODO : GetData() 의 인자에 따라서 페이지를 다르게 가져오게 해야 합니다.
 * promise 객체를 반환합니다.
 */
async function getPostListDatafromAPI() {
  // TODO : 본 함수에서 페이지 id를 인자로 받아 원하는 페이지 띄울 수 있도록 처리
  try {
    const somePromise = await fetch(postListBseUrl);
    const result = somePromise.json();
    return result;
  } catch (error) {
    console.log(error);
  }
}
/**
 * post Div 전체를 복사합니다.
 */
function copyDiv() {
  const postDiv = document.querySelector(".post");
  const newNode = postDiv.cloneNode(true);
  newNode.id = "copied-posts";
  postDiv.after(newNode);
}
/**
 * getPostListDatafromAPI() 로부터 게시물 목록 데이터를 불러옵니다.
 * 불러온 데이터 결과의 길이만큼 (페이지네이션 처리) 게시물을 반복해 그립니다.
 */
function loadPosts() {
  getPostListDatafromAPI()
    .then((result) => {
      for (let i = 0; i < result.length; i++) {
        copyDiv();
        const authorNameElements = document.querySelectorAll(".author");
        for (const authorName in authorNameElements) {
          authorNameElements[authorName].innerText =
            result[result.length - 1 - i]["author_name"];
        }
        const titleElement = document.querySelector(".title");
        titleElement.innerText = result[result.length - 1 - i]["title"];
        const contentElement = document.querySelector(".content");
        contentElement.innerText = result[result.length - 1 - i]["content"];
        if (i == 0) {
          document.getElementById("copied-posts").style.display = "none";
        }
      }
    })
    .catch((error) => {
      console.log(error);
    });
}
loadPosts(); // 최종 함수 호출

그리고 만들어두었던 index.html 파일을 열어 볼까요? 아래와 같이 게시물 열 개가 나타나는 것을 확인할 수 있을 겁니다.

우리가 만든 API를 프론트엔드 단에서 호출하여 사용하는 것까지 구현해 보았네요. 미약하지만 말이죠!

이제, 백엔드 단에서 회원가입을 구현해 보겠습니다. 

이 유저는 일반회원으로 로그인 된 것이 맞아!” 라는 토큰을 생성해 클라이언트에 보내줄 겁니다. 그러면, 클라이언트는 해당 토큰을 어딘가에 저장해둔 후, 우리에게 요청을 보낼 때마다 해당 토큰을 같이 보내줄 겁니다. 그러면 서버는 요청을 보낸 사용자가 누구인지를 알 수 있고, “이 게시물은 볼 수 있어!” 나 “관리자 페이지에는 접속할 수 없어!” 를 판단할 수 있게 됩니다. 이를 위해 먼저 회원가입을 구현하고, 다음 포스팅에서 JWT를 구현해 보겠습니다.

회원가입 구현

로그인을 하려면 기본적으로 회원가입된 유저가 존재해야 하겠죠? 회원가입을 구현해 보겠습니다. resources 아래에 user.py 를 만들어 주세요.

회원가입은 다음과 같은 로직으로 이루어집니다. 클라이언트가 우리에게 “이 정보로 회원을 만들어 줘!” 라고 하면, 우리는 “이 닉네임이 가능한 닉네임인가?, 중복되는 닉네임은 없나?” 등등을 확인한 후, 성공한다면 201 상태 코드와 함께 “회원가입을 축하합니다!” 등의 메시지를 보내줄 겁니다.

먼저 모델 단에서 검증을 위한 메서드를 하나 추가해 주겠습니다.

api.models.user.UserModel 클래스 아래에 find_by_email() 메서드를 하나 추가해 주세요. 이메일로 특정될 수 있는 유저를 찾는 기능을 가집니다.

이제 Resource 단에서 구현해 볼 차례입니다. 구현 전, 로직을 잠깐 다시 되짚어보겠습니다.

1. 클라이언트는 “이 정보로 회원가입을 진행해 줘!” 라고 서버에 요청을 보냅니다.

2. 서버는 클라이언트가 보낸 정보를 살펴보고, 해당 데이터로 회원가입을 진행해도 되는지를 판단합니다.

3. 회원가입을 진행해도 되는 데이터라면, 해당 데이터를 이용해서 회원가입을 진행합니다.

우리는 /register/ 에 대한 POST 요청을 보낸다면 회원가입을 진행시켜 줄 겁니다. “아 그러면 resource를 작성하고 등록해야겠네요?” 라는 아이디어가 떠오를 겁니다.

resources/user.py 에 아래의 내용을 입력해 보겠습니다.

from api.models.user import UserModel
from flask_restful import Resource, reqparse
_user_parser = reqparse.RequestParser()
_user_parser.add_argument("username", type=str, required=True, help="This field cannot be blank.")
_user_parser.add_argument("password", type=str, required=True, help="This field cannot be blank.")
_user_parser.add_argument("email", type=str, required=True, help="This field cannot be blank.")
class UserRegister(Resource):
    """
    회원가입을 처리합니다.
    username, email 은 데이터베이스에서 유일한 값이어야 하므로,
    사용자가 데이터베이스에 존재하는 email 이나 username 으로 회원가입을 시도한다면,
    적절한 에러 메시지와 함께 "잘못된 요청을 보냈어!" 라는 400 상태 코드를 응답합니다.
    """
    def post(self):
        data = _user_parser.parse_args()
        if UserModel.find_by_username(data["username"]):
            return {"message": "A user with that username already exists"}, 400
        elif UserModel.find_by_email(data["email"]):
            return {"message": "A user with that email already exists"}, 400
        user = UserModel(**data)
        user.save_to_db()
        return ({"message": "User created successfully."},)

위의 코드를 잘 살펴보세요. post 메서드를 구현함으로서 post 요청을 받을 수 있고, post 요청을 받은 데이터에 중복된 이름이나 이메일이 존재한다면 회원가입을 거부하고, 그렇지 않다면 받아들여진 정보로 데이터베이스에 회원을 추가합니다.

이후, api/__init__.py 에 우리가 만들어준 UserRegister 리소스를 등록합니다.

api/__init__.py 의 전체 코드입니다.

from flask import Flask, jsonify
from flask_restful import Api
from dotenv import load_dotenv
from flask_cors import CORS
from flask_jwt_extended import JWTManager
from flask_migrate import Migrate
from marshmallow import ValidationError
from .db import db
from .ma import ma
from .models import user, post, comment
from .resources.post import PostList, Post
from .resources.user import UserRegister
def create_app():
    app = Flask(__name__)
    CORS(app, resources={r"*": {"origins": "*"}})
    load_dotenv(".env", verbose=True)
    app.config.from_object("config.dev")
    app.config.from_envvar("APPLICATION_SETTINGS")
    app.config.update(RESTFUL_JSON=dict(ensure_ascii=False))
    api = Api(app)
    jwt = JWTManager(app)
    migrate = Migrate(app, db)
    db.init_app(app)
    ma.init_app(app)
    migrate.init_app(app, db)
    @app.before_first_request
    def create_tables():
        db.create_all()
    @app.errorhandler(ValidationError)
    def handle_marshmallow_validation(err):
        return jsonify(err.messages), 400
    # register Resources...
    api.add_resource(PostList, "/posts/")
    api.add_resource(Post, "/posts/<int:id>")
    api.add_resource(UserRegister, "/register/")
    return app

이제 /register/ 로 접근하면, 회원가입을 진행할 수 있을 겁니다.

그런데, 이렇게 여기서 회원가입을 마무리하기엔 너무 아쉽네요. 비밀번호 암호화, 비밀번호 확인 절차를 구현하도록 하겠습니다.

그렇다면 각각의 로직들을 어디서 구현하면 좋을까요?

필자의 생각으로는, 비밀번호 암호화-> Resource, 비밀번호 확인 -> 은 Schema 에서 구현하는 게 좋아 보입니다. View 단에서 검증된 데이터를 처리하고, Schema 에서 어떤 데이터가 들어와야 하는지, 어떤 데이터가 들어오면 안 되는지를 정하기 때문입니다.

 marshmallow 에는 이를 지원하는 @validates_schema 데코레이터를 지원합니다. 바로 예제를 보며 이해해 보겠습니다. schemas/user.py 에 아래의 코드를 작성하겠습니다.

from api.ma import ma
from marshmallow.fields import String
from marshmallow import validates_schema
from marshmallow.exceptions import ValidationError
from api.models.user import UserModel
from marshmallow import fields
fields.Field.default_error_messages["required"] = "해당 필드를 입력해 주세요."
fields.Field.default_error_messages["validator_failed"] = "해당 필드에 대한 검증이 실패했습니다."
fields.Field.default_error_messages["null"] = "해당 필드는 null 이 될 수 없습니다."
class UserRegisterSchema(ma.SQLAlchemyAutoSchema):
    password_confirm = String(required=True)
    class Meta:
        load_instance = True
        model = UserModel
        load_only = [
            "username",
            "email",
            "password",
            "password_confirm",
        ]
    @validates_schema
    def validate_password(self, data, **kwargs):
        if data["password"] != data["password_confirm"]:
            raise ValidationError("비밀번호가 일치하지 않습니다.", "password_confirm")

입력할 때에 password_confirm 이라는 값이 새로 필요하므로, String 필드를 하나 더 추가해 주었고, load_only 에 password_confrim 을 추가해 줬습니다. required=True 이므로 해당 필드는 꼭 써야 하는 필드입니다.

그러면, 서버 단에서 회원가입을 위해서 클라이언트로부터 JSON 형태로 받아올 데이터의 형태는 아래와 같을 겁니다.

{
 "username" : "코딩싫어109", // 중복되면 안 됨, 꼭 입력받아야 함
 "email" : "hello@naver.com", // 중복되면 안 됨, 꼭 입력받아야 함
 "password" : "helloworld", // 꼭 입력받아야 함
 "password_confirm" : "helloworld" // 꼭 입력받아야 하고, "password" 와 값이 같아야 함
}

이에 대한 데이터 검증은 marshmallow 가 쉽게 해 줍니다. api/resources/user.py 에 아래의 코드를 입력합니다.

from api.models.user import UserModel
from flask_restful import Resource, request
from api.schemas.user import UserRegisterSchema
register_schema = UserRegisterSchema()
class UserRegister(Resource):
    """
    회원가입을 처리합니다.
    username, email 은 데이터베이스에서 유일한 값이어야 하므로,
    사용자가 데이터베이스에 존재하는 email 이나 username 으로 회원가입을 시도한다면,
    적절한 에러 메시지와 함께 "잘못된 요청을 보냈어!" 라는 400 상태 코드를 응답합니다.
    비밀번호는 데이터베이스에 직접 저장되면 안 되므로,
    저장 시 SHA256 알고리즘을 사용하여 해싱하여 저장합니다.
    """
    def post(self):
        data = request.get_json()
        validate_result = register_schema.validate(data)
        if validate_result:
            return validate_result, 400
        else:
            if UserModel.find_by_username(data["username"]):
                return {"bad request": "중복된 사용자 이름입니다."}, 400
            elif UserModel.find_by_email(data["email"]):
                return {"message": "중복된 이메일입니다."}, 400
            user = register_schema.load(data)
            user.save_to_db()
            return {"success": f"{user.username} 님, 가입을 환영합니다!"}, 201

(아직 해싱은 하지 않은 상태입니다.) register_schema.validate(data) 를 통해서 검증을 진행하고, 검증에 실패 시에는 post요청에 대한 응답으로 schema 단에서 작성한 에러 메시지가 출력되도록 하였습니다. 모든 데이터가 제대로 되었고, 회원가입이 진행된 후에는 “~~님, 환영합니다!” 라는 성공 메시지와 함께 적절한 상태 코드인 201을 응답하도록 하였네요.

기타 데이터 검증 (이메일 형식 검증, 비밀번호 자릿수 검증, 부적절한 닉네임 검증) 은 독자들의 역할로 남기도록 하겠습니다. :)

이제 회원가입 마지막 단계인 비밀번호 해싱을 해 보도록 하겠습니다. 유저 객체를 생성할 때에 그것이 진행되겠죠? json 데이터를 파이썬의 객체로 바꾸는 작업은 이곳에서 이루어집니다.

위의 data 에는 우리가 보낸 json이 딕셔너리 형태로 담기고 .load를 통해서 그것을 파이썬의 객체로 만드는 것이 schema의 .load 메서드입니다. 그러면, 받아온 데이터를 -> 해싱한 다음, -> load 에 넣어주면 되겠네요!

우리가 사용하려는 werkzeug 의 generate_password_hash 는 비밀번호를 102개의 문자로 바꾸어 주므로, 이를 온전하게 저장하기 위해서 데이터베이스를 수정할 필요가 있습니다.

models.user 에서 아래와 같이 수정해 줍니다.

삽질을 하느라 코드가 꼬인 듯 싶네요. 권장되는 방법은 절대 아닙니다. 데이터베이스를 업데이트 하기 위해서, (migrations 폴더, flastagram.db) 삭제 -> backend/ 에서 터미널로 flask db init -> flask db migrate -> flask db upgrade 수행해 줍니다.

이후 위와 같이 유저 생성 로직을 바꿔 주겠습니다.

  1. if validate_result: 로 1차 검증을 진행합니다. 만약 이 부분에서 비밀번호가 다르거나 – 혹은 입력되어야 하는 필드가 입력되지 않았다면 에러 메시지를 응답해 줄 겁니다.
  2. else문에 걸린다면 입력된 데이터 중 사용자 이름과 이메일이 중복되는지, 중복되지 않는지 확인할 겁니다.
  3. 위의 검증을 모두 통과했다면, 검증된 데이터를 generate_password_hash() 를 통해서 유저를 데이터베이스에 저장합니다.

회원가입을 진행하면,

잘 저장되어 있는 것을 볼 수 있네요. :)

Leave A Comment

Avada Programmer

Hello! We are a group of skilled developers and programmers.

Hello! We are a group of skilled developers and programmers.

We have experience in working with different platforms, systems, and devices to create products that are compatible and accessible.