본문 바로가기
파이썬/파이썬 플라스크

ep 14-2. Flask 인스타그램 클론코딩 (5)

by L_SU 2022. 11. 17.

이번 시간엔 먼저 프론트단에서 서버를 띄워보도록 하겠습니다.

 

먼저 frontend 폴더에 server라는 이름의 폴더를 생성해주고,

그 아래 server.js 파일을 생성해줍니다.

그리고 위와 같이 만들어준 server 폴더로 이동해 npm init 명령을 통해 package.json 파일을 생성해줍니다.

필자는 다음과 같이 정보를 입력했습니다.

npm install nodemon -global 명령을 입력해 nodemon을 설치해줍니다.

nodemon은 server.js 파일의 변화가 생기면 이를 자동 재시작해주는 역할을 수행합니다.

그 후, npm install express 으로 서버를 열 때,

사용하게 될 express도 살처해줍니다.

 

var express = require("express");
var path = require("path");

var app = express();

// static 파일들의 기본 디렉토리로서 상위 경로 사용
app.use(express.static(path.join(__dirname, "..")));

// 3000번 대에서 서버를 엶.
app.listen(3000, (err) => {
  if (err) return console.log(err);
  console.log("The server is listening on port 3000");
});

app.get("/flastagram/posts", function (req, res) {
  res.sendFile(path.join(__dirname, "..", "post_list.html"));
});

 

위와 같이 server.js 파일을 작성합니다.

/flastagram/ 에 대한 라우팅을 다루는 코드입니다.

서버를 성공적으로 연 것을 확인 했다면,

ctrl+c 를 통해 서버를 닫습니다.

 

 

이미지 업로드 구현

저희는 지금까지 이미지를 직접 폴더에 추가해서 그 경로를 추적하는 방식으로 작업을 했습니다.

이는 너무 번거롭고, 이 방법을 유지한다면 어느 유저도 이미지를 업로드 할 수 없기에

업로드를 할 수 있도록 구현해주도록 하겠습니다.

 

먼저, 기능적인 면은 백엔드에서 구현하기에 backend 폴더로 이동해줍니다.

이후 api 폴더에 utils 라는 이름의 폴더를 생성해주고,

그 아래 __init__.py 라는 파일과 image_upload.py라는 파일도 생성해줍니다.

 

import os
import re
from typing import Union
from werkzeug.datastructures import FileStorage
from flask_uploads import UploadSet, IMAGES

IMAGE_SET = UploadSet("images", IMAGES)


def save_image(image, folder, name=None):
    """FileStorage 인스턴스를 받아서, 폴더에 저장합니다."""
    return IMAGE_SET.save(image, folder, name)


def get_path(filename, folder):
    """filename, folder 를 받아 이미지의 절대 경로를 반환합니다."""
    return IMAGE_SET.path(filename, folder)


def find_image_any_format(filename, folder):
    """
    확장자가 없는 파일 이름과 찾고자 하는 폴더명을 받아, 해당 폴더에 이미지가 존재하는지를 반환합니다.
    """
    for _format in IMAGES:
        image = f"{filename}.{_format}"
        image_path = IMAGE_SET.path(filename=image, folder=folder)
        if os.path.isfile(image_path):
            return image_path
    return None


def _retrieve_filename(file):
    """
    FileStorage 오브젝트를 받아 파일 이름을 반환합니다.
    """
    if isinstance(file, FileStorage):
        return file.filename
    return file


def is_filename_safe(file):
    """
    파일 이름이 안전한지를 확인합니다.
    - a-z, 혹은 A-Z 로 시작해야만 합니다.
    - a-z A-Z 0-9 and _().- 외의 문자는 포함될 수 없습니다.
    - . 이후에는, 우리가 허용한 확장자만 와야 합니다.
    """
    filename = _retrieve_filename(file)
    allowed_format = "|".join(IMAGES)
    regex = f"^[a-zA-Z0-9][a-zA-Z0-9_()-\.]*\.({allowed_format})$"
    return re.match(regex, filename) is not None


def get_basename(file):
    """
    파일의 기본 이름을 가져옵니다.
    get_basename('images/profiles/hello.png') 는 'hello.png' 를 반환할 겁니다.
    """
    filename = _retrieve_filename(file)
    return os.path.split(filename)[1]


def get_extension(file):
    """
    파일의 확장자명을 가져옵니다.
    get_extension('profile.png') 는 'png' 를 반환할 겁니다.
    """
    filename = _retrieve_filename(file)
    return os.path.splitext(filename)[1]

image_upload.py 에 대한 코드입니다.

 

우리가 게시글 작성 API를 구현할 때를 회상해봅시다.

모델 작성 > 스카마 작성 > 리소스 작성 > __init__.py 에 작성

순서로 진행했습니다.

이 업로드 또한, API이기에 이 순서를 따라갈 것입니다.

현재 모델 작업을 해줬기 때문에 스카마를 작성해주도록 하겠습니다.

 

schemas 폴더에 image.py 파일을 생성해줍니다.

 

from marshmallow import Schema, fields
from werkzeug.datastructures import FileStorage


class FileStorageField(fields.Field):
    default_error_messages = {"error": "유효한 이미지 파일이 아닙니다."}

    def _deserialize(self, value, attr, data, **kwargs):
        if value is None:
            return None

        if not isinstance(value, FileStorage):
            self.fail("invalid")

        return value


class ImageSchema(Schema):
    image = FileStorageField(required=True)

다음과 같이 예외처리 및 이미지 업로드에 대한 코드를 작성해줍니다.

 

이후 리소스 폴더로 이동해 image.py 파일을 생성합니다.

 

from flask_restful import Resource
from flask_uploads import UploadNotAllowed
from flask import request
from flask_jwt_extended import jwt_required, get_jwt_identity


from api.utils import image_upload
from api.schemas.image import ImageSchema

image_schema = ImageSchema()


class ImageUpload(Resource):
    @jwt_required()
    def post(self):
        """이미지를 업로드합니다."""
        data = image_schema.load(request.files)
        print(data)
        user_id = get_jwt_identity()
        folder = f"user_{user_id}"
        try:
            image_path = image_upload.save_image(data["image"], folder=folder)
            basename = image_upload.get_basename(image_path)
            return {"message": f"{basename}이미지가 성공적으로 업로드되었습니다."}, 201
        except UploadNotAllowed:
            extension = image_upload.get_extension(data["image"])
            return {"message": f"{extension} 는 적절하지 않은 확장자 이름입니다."}, 400

다음과 같이 코드를 짜주면 되겠습니다.

리소스까지 작성했으니 이제 __init.py__에 등록해주기 전에, config/common.py 파일에 다음과 같이 코드를 한 줄 추가해줍니다.

 

이제 __init__.py 파일에 등록해주도록 하겠습니다.

위와 같이 코드 작성에 필요한 것들을 import 해줍니다.

 

이렇게 한줄 추가해주면 되겠습니다.

그리고 만들어준 리소스를 import 해줍니다.

이후 등록해주면 되겠습니다.

 

마지막으로 flask_uploads 에서 werkzeug 변경사항을 수정하기 위해 위와 같이 26, 27번째줄 import 내용을 수정해줍니다.

부분에 컨트롤 클릭을 하면 해당 파일로 이동하실 수 있습니다.

 

테스트를 위해 저번에 만든 계정으로 로그인 해줍니다.

 

이후 헤더에 내용을 다음과 같이 추가해줍니다.

 

key 값을 image로 설정해주고, text를 file로 설정해줍니다.

 

이후 value에 바꾸고 싶은 사진을 넣어 POST 요청을 보내보면 위와 같이 정상 작동하는 것을 확인해볼 수 있습니다.

 

 

게시물/프로필 사진 API 구현

사진을 업로드하는 API를 구현했으니, 이를 활용해서 게시물에서나 프로필 사진 등을 나타낼 때에 사용할 수 있게 만들어야 합니다.

 

먼저 프론트로 넘어가기 전에 백에서 좀 더 작업을 해주도록 하겠습니다.

 

리소스를 조금만 더 수정해도록 하겠습니다.

from flask_restful import Resource
from flask_uploads import UploadNotAllowed
from flask import request, send_file, send_from_directory, url_for
from flask_jwt_extended import jwt_required, get_jwt_identity
import time, traceback, os
from api.utils import image_upload
from api.schemas.image import ImageSchema

image_schema = ImageSchema()


class AbstractImageUpload(Resource):
    """
    이미지 업로드를 위한 클래스입니다.
    이 클래스를 상속받는 자식 클래스들은 다음의 공통 기능을 가집니다.
    - 자식 클래스에서 정의된 폴더명 (folder 변수) 아래에 이미지를 업로드합니다.
    - 이미지를 삭제합니다.

    해당 클래스를 상속받는 자식 클래스들은 folder 라는 변수를 필히 재정의해야 합니다.
    """

    def set_folder_name(self):
        """
        이미지가 저장될 폴더명을 재정의하고 싶다면,
        해당 메서드를 오버라이딩해야 합니다.
        """
        return None

    def post(self):
        """이미지를 업로드하기 위해 HTTP POST 메서드를 사용합니다."""
        data = image_schema.load(request.files)
        folder = self.set_folder_name()
        try:
            image_path = image_upload.save_image(data["image"], folder=folder)
            basename = image_upload.get_basename(image_path)
            return {
                "message": f"{basename}이미지가 성공적으로 업로드되었습니다.",
                "path": image_path,
            }, 201
        except UploadNotAllowed:
            extension = image_upload.get_extension(data["image"])
            return {"message": f"{extension} 는 적절하지 않은 확장자 이름입니다."}, 400
        
class PostImageUpload(AbstractImageUpload):
    """
    게시물 이미지를 업로드합니다.
    """

    def set_folder_name(self):
        return "post/" + time.strftime("%Y/%m/%d")


class ProfileImageUpload(AbstractImageUpload):
    """
    프로필 이미지를 업로드합니다.
    """

    def set_folder_name(self):
        return f"profile/{get_jwt_identity()}"

    @jwt_required()
    def post(self):
        return super(ProfileImageUpload, self).post()

다음과 같이 코드를 수정해주면 되겠습니다.

 

등록했던 리소스도 다음과 같이 수정해줍시다.

 

포스트맨으로 게시글 사진, 프로필 사진 둘 다 잘 작동하는 지 확인 해주시면 되겠습니다.

 

 

등록에 대한 작업이 끝났으니,

이번엔 불러오는 작업을 해봅시다.

 

def get_path_without_basename(path):
    """
    파일의 확장자명을 제외하고 경로를 반환합니다.
    예를 들면, get_path_without_basename('hello/world/brothers.jpg') 는,
    'hello/world/' 를 반환할 겁니다.
    """
    return "/".join(path.split("/")[:-1])

utils/image_upload/py 에 다음과 같은 코드를 추가해줍니다.

 

class Image(Resource):
    def get(self, path):
        """
        이미지가 존재한다면 그것을 응답합니다.
        """
        filename = image_upload.get_basename(path)
        folder = image_upload.get_path_without_basename(path)

        if not image_upload.is_filename_safe(filename):
            return {"message": "적절하지 않은 파일명입니다."}, 400

        try:
            return send_file(image_upload.get_path(filename=filename, folder=folder))
        except FileNotFoundError:
            return {"message": "존재하지 않는 이미지 파일입니다."}, 404

    def delete(self, filename):
        pass

resources/image.py 에도 다음과 같은 코드를 추가해줍니다.

 

만든 리소스를 사용할 수 있도록 등록해줍니다.

 

마지막으로 포스트맨으로 확인해줍니다.

 

 

등록하고, 불러오는 작업을 해봤으니 이제 삭제하는 기능을 구현해봅시다.

 

   def delete(self, path):
        """
        이미지가 존재한다면 그것을 삭제합니다.
        """
        filename = image_upload.get_basename(path)
        folder = image_upload.get_path_without_basename(path)

        if not image_upload.is_filename_safe(filename):
            return {"message": "적절하지 않은 파일명입니다."}, 400

        try:
            os.remove(image_upload.get_path(filename, folder=folder))
            return {"message": "이미지가 삭제되었습니다."}, 200
        except FileNotFoundError:
            return {"message": "이미지를 찾을 수 없습니다."}, 404
        except:
            traceback.print_exc()
            return {"message": "이미지 삭제에 실패하였습니다. 잠시 후 다시 시도해 주세요."}, 500

resources/image.py 에서 pass로 틀만 잡아뒀던 delete 메소드를 위와 같이 구현합니다.

 

이 역시 postman 으로 확인해줍니다!

 

데이터베이스 연결

 

유저 모델에 다음과 같이 image 라는 이름을 가진 필드를 생성해줍니다.

이는 이미지 자체를 저장하는 필드가 아닌 이미지 경로를 저장할 것입니다.

 

모델을 수정해줬음으로, flask db migrate, flask db upgrade 를 입력해

마이그레이트 및 적용해줍니다.

 

                <!-- 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="" 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 -->

다시 프론트 단에 와서 우리가 구현해준 것을 적용하기 위해 다음과 같이 index.html 파일의 코드를 일부 수정해줍니다.

 

무엇이 달라졌는지 길어서 보기 힘드실 수 있습니다.

이 부분의 이미지 경로를 지웠습니다.

이는 우리가 응답을 해줄 것이기 때문입니다.

 

마저 수정해줍시다. 빨간 점 라인을 보시면 되겠습니다.

그리고 백엔드로 다시 넘어와 게시물 작성을 위한 작업을 합니다.

먼저 스카마에서 post.py 파일을 수정합니다.

7번째 줄처럼 추가해주면 되겠습니다.

 

그리고 다음과 같이 파일을 구성해주면 되는데

index.html > post_list.html

syle.css > post_list.css

index.html > post_list.html

으로 수정해주시고, 나머지 파일들은 새로 생성해주면 되겠습니다.

post_list.html 에서 경로를 변경된 이름으로 수정해줍니다.

 

post_create.html 코드

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <link rel="stylesheet" href="/assets/css/post_create.css" type="text/css" />
    <title>게시물 작성하기</title>
</head>

<body>
    <iframe name="dummyframe" id="dummyframe" style="display: none;"></iframe>
    <form id="post-form" target="dummyframe">
        <!-- 피드 이미지 미리보기 -->
        <div id="preview-image"></div>

        <!-- 피드 이미지 선택하기 -->
        <label for="imagefile" class="select-image" id="select-image">
            피드 이미지 선택하기
        </label>
        <input type="file" id="imagefile" name="imagefile" accept="image/*" onchange="getImageResponse(event);" />

        <!-- 이미지 경로를 저장하기 위한 숨겨진 input -->
        <input type="text" id="image" name="image" hidden="true">

        <!-- 제목, 내용 입력하기 -->
        <p><input type="text" id="input-title" name="title" placeholder="제목을 입력하세요."></p>
        <p><textarea id="input-content" name="content" placeholder="내용을 입력하세요."></textarea></p>

        <!-- 제출하기 -->
        <input type="submit" value="작성하기" onclick="submitPostData();">
    </form>
    <script src="/assets/js/post_create.js"></script>
</body>

</html>

 

post_create.js 코드

/**
 * 사용자가 이미지 선택을 완료하면,
 * 1. 업로드한 이미지를 띄워주고
 * 2. 서버에 이미지를 업로드합니다.
 * 3. 이미지 업로드의 성공 여부에 따라 에러 메시지를 띄워줍니다.
 * 4. 이미지 업로드가 성공한다면 그것의 path 를 숨겨져 있는 input 태그의 value 로 넣어줍니다.
 */
async function getImageResponse(event) {
  loadPreviewImage(event);
  result = await submitImage();
  // 201로 성공적으로 이미지가 업로드되었다면,
  // 성공 메시지를 띄워주고 해당 이미지의 경로를 반환
  // 그렇지 않다면, 에러 메시지를 띄워줌
  let response = await result.json();
  if (result.status == 201) {
    alert(response["message"]);
    const path = response["path"];
    const imageInput = document.querySelector("#image");
    imageInput.setAttribute("value", path);
  } else {
    alert(JSON.stringify(response));
  }
}

/**
 * 업로드한 이미지를 미리 확인합니다.
 */
function loadPreviewImage(event) {
  var reader = new FileReader();
  reader.onload = function (event) {
    var img = document.createElement("img");
    img.setAttribute("src", event.target.result);
    document.querySelector("div#preview-image").appendChild(img);
  };
  reader.readAsDataURL(event.target.files[0]);
}

/**
 * input 태그에서 선택한 이미지를 서버에 전송합니다.
 * fetch() 의 결과를 반환합니다.
 */
async function submitImage() {
  // 이미지 파일을 서버에 전송하기 위해 form 생성
  const fileInput = document.querySelector("#imagefile");
  const formData = new FormData();
  formData.append("image", fileInput.files[0]);
  const options = {
    method: "POST",
    body: formData,
  };

  // 이미지 업로드 API 요청
  const result = await fetch(
    "http://127.0.0.1:5000/upload/post/image/",
    options
  );

  return result;
}

/**
 * form 태그 안에 있는 내용을 JSON 으로 변환합니다.
 */
function getFormJson() {
  let form = document.querySelector("#post-form");
  let data = new FormData(form);
  let serializedFormData = serialize(data);
  return JSON.stringify(serializedFormData);
}

/**
 * form 태그 안에 있는 내용을 dictionary 형태로 반환합니다.
 */
function serialize(rawData) {
  let serializedData = {};
  for (let [key, value] of rawData) {
    if (key == "imagefile") {
      continue;
    }
    if (value == "") {
      console.log("hello");
      serializedData[key] = null;
    }
    serializedData[key] = value;
  }
  return serializedData;
}

/**
 * 정제된 데이터를 넣어 게시물 작성 요청을 보냅니다.
 */
async function submitPostData() {
  // 인증을 위한 header 설정
  var myHeaders = new Headers();
  myHeaders.append(
    "Authorization",
    "Bearer " // 토큰을 이곳에다 붙여넣으세요.
  );
  myHeaders.append("Content-Type", "application/json");

  // 보낼 데이터 설정
  var raw = getFormJson();

  // 최종 옵션 설정
  var requestOptions = {
    method: "POST",
    headers: myHeaders,
    body: raw,
    redirect: "follow",
  };

  // 게시물 저장 요청
  const response = await fetch("http://127.0.0.1:5000/posts/", requestOptions);

  if (response.status == 201) {
    window.location.href = "http://localhost:3000/flastagram/posts/";
  } else {
    alert(JSON.stringify(await response.json()));
  }
}

 post_create.css 코드

body {
    max-width: max-content;
    margin: auto;
}

input[type="file"] {
    display: none;
}

.custom-file-upload {
    display: inline-block;
}

#preview-image{
    width: 300px;
    height: 300px;
    background-color: darkgrey;
}

#preview-image > img{
    width: 300px;
    height: 300px;
}


#input-content {
    resize: none;
    width: 300px;
    height: 300px;
}

#input-title {
    width: 300px;
}

 post_list.js 코드

const postListBseUrl = "http://127.0.0.1:5000/posts/";
const imageBseUrl = "http://127.0.0.1:5000/statics/";
// #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 coverImageElements = document.querySelector(".post-image");
        coverImageElements.src =
          imageBseUrl + result[result.length - 1 - i]["image"];
        // 저자 이름 요소를 선택하고, 그립니다.
        const upAuthorElement = document.querySelector(".author-up");
        upAuthorElement.innerText =
          result[result.length - 1 - i]["author_name"];
        const downAuthorElement = document.querySelector(".author-down");
        downAuthorElement.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"];
        // 게시물이 없다면 none 처리를 합니다.
        if (i == 0) {
          document.getElementById("copied-posts").style.display = "none";
        }
      }
    })
    .catch((error) => {
      console.log(error);
    });
}

loadPosts();

 

 server.js에서도 해당 HTML 파일을 등록해줍니다.

이후 서버를 실행시켜  flastagram/post-create 주소로 접속해봅시다.

 

그럼 다음과 같이 게시글을 작성하는 화면이 나올 것입니다.

 

현재 로그인 기능을 구현하지 않았기 때문에 postman에서 로그인 API를 이용해 토큰값을 임의로 가져와줍니다.

 

하고 작성하기를 누르면 게시글 목록 페이지로 넘어가며, 작성이 완료 됩니다.