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

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

10월 27, 2022

node.js 를 이용해서 간단한 웹 서버 띄우기

# 왜 프론트엔드 단에서의 웹 서버와 백엔드 단에서의 웹 서버를 따로 띄우는지는, 이후의 배포 과정에서 다룹니다.

먼저, frontend 폴더 아래에 server 라는 폴더를 만들고, 그 안에 server.js 를 만들어 주겠습니다.

터미널을 server/ 폴더로 이동해 준 다음, npm init 명령어를 입력합니다.

이후, 대부분 엔터 키를 눌러 줌으로서 package.json 파일을 생성합니다. 필자는 아래와 같이 생성했습니다.

{
  "name": "server",
  "version": "1.0.0",
  "description": "frontend server",
  "main": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node server.js"
  },
  "author": "goddessana",
  "license": "ISC"
}

이제 server.js 의 변화를 감지하고 자동 재시작해주도록 해 주는 nodemon 을 설치하겠습니다.

npm install nodemon -global

우리는 서버를 열 때에 express 를 사용할 것이므로, 그것도 설치해 주겠습니다.

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

var app = express();

// static 파일들(js, css, img)의 기본 디렉토리로서 상위 경로(../) 를 사용하겠다!
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"));
});

이후 위의 코드를 입력하여 서버 파일을 작성합니다. 단순히 /flastagram/ 에 대한 라우팅만 다룹니다.

이후, 터미널의 경로가 flastagram/frontend/server 인 것을 확인하고, npm run start 를 입력하여 서버를 열어줍니다. 3000번 포트에서 서버가 열렸음을 알 수 있네요. 브라우저에, localhost:3000/flastagram/posts 를 입력하면 아래와 같을 겁니다.

CSS가 제대로 적용 안 된 것 같네요.

그것은 server.js 파일의 위의 부분과 관계가 있습니다. static 파일들의 기본 디렉토리로서, 상위 경로를 사용하므로, 우리 서버는 static 파일들을 /frontend 에서 찾게 됩니다.

그러므로, index.html 에 있는 모든 ./assets 을 /assets 로 바꾸어 줍니다.

그러면, 자바스크립트와 CSS, 이미지 파일들이 모두 잘 로드된 것을 알 수 있네요.

좋습니다. 일단은, ctrl+c 를 입력하여 서버를 닫아 줍니다.

웹 서버란 무엇인가?

이쯤에서, 웹 서버란 무엇인가? 에 대해 다뤄 보겠습니다. 어쩌면 생뚱맞은 이야기일 수도 있습니다. “지금까지 Flask 다루다가, 갑자기 처음 보는 NodeJS 만지더니, 이제는 “웹 서버” 를? 블로그 주인장 갑자기 왜 이래..?”

이번 파트에서는 본격적으로 이미지 업로드를 다룰 텐데, 우리는 이미지 업로드를 구현하기 위해서 werkzeug 와 같은 WSGI 웹 애플리케이션 라이브러리를 사용해 볼 것입니다.

.. 벌써 위의 문장에서 새로운 단어가 두 개나 등장했습니다. werkzeug 란 또 뭘까요? WSGI 는 또 뭘까요? WSGI 는, wsgi.org 에는 아래와 같이 소개되어 있습니다.

WSGI is the Web Server Gateway Interface. It is a specification that describes how a web server communicates with web applications, and how web applications can be chained together to process one request.

WSGI is a Python standard described in detail in PEP 3333.

WSGI는 웹 서버 게이트웨이 인터페이스입니다. 웹 서버가 웹 응용 프로그램과 통신하는 방법과 웹 응용 프로그램을 연결하여 하나의 요청을 처리하는 방법을 설명하는 사양입니다.

WSGI는 PEP 3333에 자세히 설명된 Python 표준입니다.

https://flask.palletsprojects.com/en/2.2.x/patterns/fileuploads/

설명을 들어보니 WSGI 는, 웹 서버가 웹 애플리케이션 프로그램이랑, 뭐 사용자의 요청을 처리하는 스펙이라고 합니다.

“뭐래..”

라고 하실 여러분들을 위해서 천천히 알아봅시다. 그것의 시작은 웹 서버입니다. 사실, 이걸 위한 빌드업이었습니다.

클라이언트로부터 들어오는 요청의 두 가지 종류

우리는 웹 서버에게 여러 가지 요청을 보냅니다. “내가 가지고 있는 사진을 보내줘!” 라고 요청할 수도 있고, “나를 로그인시켜줘!” 라고 요청할 수도 있습니다. 어쩌면 “이 물건 사고 싶으니, 결제 해 줘!” 를 요청할 수도 있겠네요. 이를 “동적 웹 페이지 요청”, “정적 웹 페이지 요청” 이라고 부릅니다.

위의 내용을 약간 바꾸어서 생각해 보겠습니다.

  1. 저는 “색종이” 를 가지고 있는 상인입니다.
  2. 길 가던 사람 “철수” 는 저에게 “색종이 한 장 주세요~” 를 요청했습니다.
  3. 그러면, 저는 단지 가지고 있는 색종이 한 장을 철수에게 건네주면 됩니다.
  4. 그런데, 길 가던 또 다른 사람 “미미” 는 저에게 “종이학 접어주세요!” 를 요청했습니다.
  5. 그러면, 저는 “미미” 의 요청에 응답하기 위해서 제가 가지고 있는 색종이로 학을 접어 “미미” 에게 종이학을 접어 응답해 주었습니다.

미미와 철수가 저에게 요청한 것의 근본적인 차이는 “내가 종이학을 접어야 하는가?” 에 있습니다. 이를 다시 “웹 서버와 클라이언트” 로 바꾸어 생각해 보겠습니다.

  1. “철수” 가 저에게 “색종이 한 장 주세요~” 라고 요청한 것은 “정적 웹 페이지 요청” 이라고 할 수 있습니다. 클라이언트가 서버에게 “JS 파일 하나 주세요~” 혹은, “이미지 파일 하나 주세요~” 를 요청하면, 서버는 색종이 하나 가져다 주듯이 그것을 응답해 주면 됩니다. 변하지 않는 파일을 응답해 줬네요. “정적 웹 페이지 요청” 입니다.
https://ko.wikipedia.org/wiki/%EC%A0%95%EC%A0%81_%EC%9B%B9_%ED%8E%98%EC%9D%B4%EC%A7%80
  1. “미미” 가 저에게 “종이학 하나 접어주세요~” 라고 요청한 것은 “동적 웹 페이지 요청” 이라고 할 수 있습니다. 클라이언트가 서버에게 해당 요청을 보낼 때마다 저는 가지고 있는 종이로 종이학을 접어주어야 합니다. 이는 “동적 웹 페이지 요청”입니다.
https://ko.wikipedia.org/wiki/%EB%8F%99%EC%A0%81_%EC%9B%B9%ED%8E%98%EC%9D%B4%EC%A7%80

위의 내용을 아주 조금만 바꿔본다면, 아래와 같습니다.

그런데, 생각보다 서버에게 들어오는 요청이 많자 저는 외국에서 “종이를 접어주는 전문가” 한 명을 모셔오고, 간단한 요청 정도는 쉽게 처리할 수 있는 “인포메이션 직원” 한 명을, 그리고 “인포메이션 직원” 과 “종이접기 전문가” 사이를 통역해줄 수 있는 “통역사” 한 명을 고용했습니다.

뭔가, 체계가 잡혔네요. 여기서 각각의 사람들에게 이름을 붙여 주겠습니다.

  1. Nginx는, 클라이언트로부터의 정적 요청(색종이 한 장 주세요!)을 처리하고, 만약 동적 요청(종이학 접어줘!) 이 들어온다면, WSGI 서버를 호출하는 역할을 합니다.
  2. WSGI 서버는 WSGI 애플리케이션을 호출할 겁니다. 그것이 바로, 우리의 플라스크 앱이 될 겁니다.
  3. Flask application 은 WSGI 애플리케이션 입니다. WSGI 서버가 호출한 요청을 알아차리고 그것에 맞게 응답해줄 수 있기 때문입니다.

그런데, 위의 과정이 잘 이해되었다고 가정해도 독자들의 마음속에는 무언가 찝찝한 것이 남아있을 겁니다.

python ./app.py 했을 때 서버가 실행된 건 뭐지?
나는 Nginx, gunicorn, uwsgi 같은 거 설치 안 했는데..;

이제야 Werkzeug 에 대해서 알아볼 시간이 되었습니다.

Werkzeug란?

Werkzeug 는 WSGI Middleware 의 역할을 하는 라이브러리입니다.

웹 개발을 하다 보면, 공통적으로 무언가 필요한 기능이 있죠. 쿠키, 세션 처리, 인증 처리, 라우팅 등이 있을 겁니다. 위에서 소개했던 WSGI Middleware 는 해당 기능들을 추가해줍니다. 또한 이것은 아주 기본적인 웹 서버의 역할도 합니다.

별도의 설정을 하지 않으면, Flask 는 기본적으로 Werkzeug 를 WSGI Middleware 으로서 사용합니다. 아주 기본적인 개발용 웹 서버를 내장하고 있기에, 프로덕션 환경에서 사용하지 말라는 메시지가 나왔던 것이죠. 고마운 Werkzeug 덕분에, 별도의 서버 구축 없이 우리의 서버를 열 수 있었습니다.

이미지 업로드 구현하기

이미지 업로드는 어떻게 이루어질까요? 우리는 이미지를 데이터베이스에 저장하지 않을 겁니다. 다만, 그것의 경로를 저장할 겁니다.

먼저, api 폴더 아래에 utils 폴더를 만들고, 그 안에 __init__.py, image_upload.py 를 작성해 주겠습니다.

import os, 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]

위의 코드만 봐서는, 무엇을 해야 할지 감이 잘 안 잡힐 것 같습니다.

지금까지 게시물 업로드를 진행할 때를 잠깐 되돌아 볼까요. 우리는 게시물 작성을, 모델을 작성하고, 그것에 대한 스키마를 작성한 다음, 리소스를 작성하고 그것을 __init__.py 에 작성함으로서 구현했었습니다.

그러면, 마찬가지로 스키마를 작성하겠습니다. 지금까지의 스키마와 다른 점이라면 모델을 다루지 않는다는 겁니다. 그리고 우리 서버에서, 스키마의 역할은 데이터 검증이었습니다. 만약에 사진 파일의 형식이 잘못되었다면 서버는 이를 통과시켜 주면 안 됩니다.

marshmallow 에는 아쉽게도 이미지 필드를 검증하기 위해 무언가가 준비되어 있지 않습니다. 그렇다면, 직접 작성하면 됩니다. 우리는 해당 이미지가 Werkzeug 의 FileStorage 인스턴스가 맞는지를 검증하겠습니다.

schemas/image.py 파일을 만들고, 아래의 내용을 입력합니다. 스키마 클래스 하나를 작성하고, 커스텀 필드를 추가해줬습니다. 해당 커스텀 필드에는 _deserialize 메서드가 포함되어 있네요.

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)

맨 마지막 두 줄에서 ImageSchema 를 하나 작성했고, 필드는 image 만 두었습니다. 이미지 업로드 요청을 보내는데 이미지가 없으면 안 되므로 required = True 옵션을 주었습니다.

우리의 이미지 스키마에서는, 서버로 보내진 파일이 werkzeug 에서 제공하는 FileStorage 의 인스턴스가 맞는지 확인하고, 그렇지 않으면 에러를 발생시키는 역할을 합니다. 이제, 리소스를 작성하겠습니다. resources/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

그리고, 우리가 만든 리소스를 api/__init__.py 에 등록해줍니다.

import os

BASE_DIR = os.path.dirname(os.path.dirname(__file__))
UPLOADED_IMAGES_DEST = os.path.join(BASE_DIR, "static", "images")

그리고, common.py 에는 “static 폴더 아래의 images 폴더 아래에 이미지를 업로드하겠다” 라는 의미의 UPLOADED_IMAGES_DEST 설정을 하나 추가해줍니다.

api/__init__.py 에는 아래의 코드를 꼭 삽입해 주세요!

테스트를 하기 전에, 아래와 같이Flask-uploads 에서 werkzeug 변경 사항을 수정하기 위해서 아래와 같이 venv/lib/site-packages/flask_uploads.py 의 26, 27번째 줄을 바꿔 주세요. 꼭 필요합니다!

그러면, 진짜 테스트를 해 볼 시간입니다!

먼저, 로그인을 진행하고 위의 access token 값을 복사해 둡니다.

이후, 위와 같이 /upload/image 에 POST 요청을 보내는데,

  1. Content-Type 은 꼭 multipart/form-data 로 설정해 줍니다.
  2. 우리는 이미지 업로드에 JWT가 필요하게끔 설정해 두었으므로 토큰을 붙여넣어 줍니다.

그리고, body 탭에서는 위와 같이 key 를 image 로, 파일 타입은 File 로 해 주신 다음,

이미지 파일을 업로드 해 보겠습니다. 이미지가 성공적으로 업로드되었다는 메시지가 떴네요.

그러면 static/images/user_철수 에 이미지가 서버에 잘 업로드된 것을 확인할 수 있습니다!

게시물 / 프로필 사진과의 연동

이제, 업로드는 되었지만 미션이 하나 남았습니다. 우리는 이걸 게시물이나 프로필 사진 등과 붙여서 사용해야 합니다. 우리가 사용할 때에는 아래의 과정이 이루어질 겁니다.

  1. 프로필 사진을 설정하거나 게시물을 작성할 때에, 이미지를 업로드합니다.
  2. 이미지가 업로드된다면, 그 이미지는 static/ 폴더 아래 어딘가에 저장될 겁니다.
  3. 게시물 작성 버튼을 누르면, 해당 이미지의 경로를 게시물 혹은 유저 테이블에 저장합니다.
  4. 그리고 게시물이나 프로필을 조회할 때, 해당 테이블에 있는 이미지 파일의 경로를 뿌려줌으로서 프론트엔드 단에서 이미지를 조회할 수 있게끔 하겠습니다.

우리가 지금까지 구현한 것은 단순히 “이미지 업로드” 이지, “이미지 조회와 삭제” 는 구현하지 않았습니다. 서버에 파일은 올라갔지만 이를 조회할 방법은 구현해놓지 않았다는 것입니다. 그리고, 프로필 사진 업로드와 게시물 사진 업로드는 각각 다른 곳에 구현되는 것이 좋아 보입니다.

필자의 경우, 각각 이러한 규칙을 정해놓았습니다.

  • 게시물을 위한 사진은, static/images/post/년/월/일/ 아래에 저장될 겁니다.
  • 프로필을 위한 사진은, static/images/유저명/ 아래에 저장될 겁니다.

생각해보면, “저장된다” 라는 로직은 같고 “저장되는 위치가 어디인가?” 만 다릅니다. 이러한 경우, 중복된 코드를 줄이기 위해서 공통 기능이 구현되어 있는 클래스를 미리 작성한 후, 그것을 상속받아 “게시물 사진 업로드”, “프로필 사진 업로드” 를 구현하겠습니다. 먼저 이를 위한 추상 클래스를 하나 작성해 줍시다. 공통된 기능은 이곳에서 구현될 겁니다.

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

그리고, 각각의 저장 로직에서는 폴더명만 달라지므로, 이를 상속받은 클래스들에서는 set_folder_name 메서드를 재정의해주면 됩니다.

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()

아래의 프로필 사진 업로드의 경우, JWT를 통하여 사용자 이름을 가져오고, 그것을 통하여 폴더를 구성해야 하므로 jwt_required 데코레이터를 작성해 주었습니다.

그리고, 우리가 작성한 리소스를 api/__init__.py 에 등록해 주겠습니다.

그리고 위의 주소에 파일을 첨부하여 사진을 전송하면, 우리가 의도했던 대로 파일 경로를 응답해주는 것을 볼 수 있습니다.

프로필 이미지도 잘 응답해주는 것을 볼 수 있네요. :)

이미지가 잘 업로드되었습니다! 공통된 기능을 파악하고, 그것을 묶어서 클래스로 작성하고, 그것을 상속받아 코드의 중복을 줄였습니다.

그리고, 이미지 조회를 위해서 아래와 같은 함수 하나를 추가로 작성하겠습니다.

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

그리고, resources/image.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

filename 과 folder 를 받아서, flask 의 send_file() 을 사용하여 이미지 파일을 응답하도록 했습니다.

새로운 엔드포인트를 만들었으므로 리소스 등록을 해 줍니다.

이미지 파일을 잘 응답해주는 것을 확인할 수 있네요. :)

이미지 조회를 위한 API 를 구성했으니, 이제 이미지 파일 삭제를 위한 API 를 구축해 보겠습니다. 위의 소스코드에서 예측하셨겠지만, 이를 처리하기 위해서 Image 클래스의 delete 메서드를 정의하겠습니다.

    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

위의 부분을 복사해서,

같은 주소로 사진 삭제 요청을 보내 보세요. 실시간으로 서버에서 게시물이 삭제되는 걸 확인할 수 있을 겁니다. :)

프로필과 게시물의 데이터베이스 수정과 사진 연결

아래와 같이 단순히 필드 하나를 추가하는 것은 확장성이 좋지 않은 방법이지만, 간단하게 그것을 구현하기 위해서 수행함을 알려드립니다. 예를 들면, 게시물에 사진을 두 장 이상 올리려고 한다면 image_2 처럼 String 필드를 하나 더 추가해야 되겠죠. 이는 끔찍한 방법입니다. 만약 조금 더 공격적으로 코드를 작성할 수 있었다면, 사진 테이블을 하나 작성하고 그것을 게시물에 연결했을 것 같아요. 스터디원 분들은, 조금 더 효율적인 데이터베이스 테이블 형식을 고민해 보시고 추후 적용해 보세요!

좋아요, 이제 사진의 업로드와 조회는 구현되었습니다. 이제 해야 할 것은, 우리의 모델들에 그것들을 연결하는 겁니다.

우리의 데이터베이스에는 사진 파일 자체는 저장되지 않습니다. 사진 파일의 경로가 저장될 겁니다. 우리가 위에서 사진 파일의 경로로 사진을 조회하고 삭제한 이유도 그것 때문입니다. 현재 게시물과 프로필에는 하나의 사진만 들어갈 수 있으므로 단순히 필드를 하나 추가해주겠습니다.

마지막 줄에서처럼, image 필드를 추가해 주고,

이곳에서도 마지막 줄처럼 이미지 필드를 추가해 줍니다.

데이터베이스에 변경사항이 있으므로, flask db migrate, flask db upgrade 를 수행해 줍니다.

유저 테이블, 게시물 테이블에 필드가 하나씩 추가된 것을 확인했다면, 성공입니다.

이제 잠시 프론트엔드 단으로 와서, 이곳에서 필요한 정보가 무엇인지 생각해 봅시다. 게시물 하나에 대해서 작성자명, 제목, 게시글은 잘 표시되고 있네요. 그렇다면 게시물을 뿌려주기 위해서 추가해야 할 것은 1. 게시물에 대한 사진, 2. 사진에는 나타나지 않았지만 게시물이 언제 수정되었는지, 2. 게시물에 대한 좋아요 수, 3. 게시물의 하트 여부입니다.

우리는 지금까지 이미지를 구현했으므로 이미지를 붙여 처리해 보겠습니다. 이전과는 다르게, 게시물 API에서 게시물에 대해서 응답해 줄 때에는 사진에 관한 것도 응답해줘야 한다는 것을 이젠 아실 겁니다.

                <!-- 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를 여러 번 반복해 그림으로서 이루어집니다. 예상하셨겠지만, 우리의 게시물 커버 이미지가 들어가는 곳은 <img src="/assets/img/cover-1.jpg" class="post-image" alt=""> 입니다. 이 곳의 이미지를 바꿔야 하고, 이미지를 바꾸려면 img 태그의 src 속성을 우리가 응답해 주면 되겠네요.

좋아요, 과감하게 지워 줍시다.

script-js 에서도, 아래와 같이 함수를 조금 수정해 줍니다. 이를 위해서 html도 수정해 주었습니다.

// 맨 위에 imageRetrieceBseUrl 을 정의해줍니다.
const imageRetrieveBseUrl = "http://127.0.0.1:5000/statics/";


/**
 * getPostListDatafromAPI() 로부터 게시물 목록 데이터를 불러옵니다.
 * 불러온 데이터 결과의 길이만큼 (페이지네이션 처리) 게시물을 반복해 그립니다.
 */
function loadPosts() {
  getPostListDatafromAPI()
    .then((result) => {
      for (let i = 0; i < result.length; i++) {
        copyDiv();
        // 커버 이미지 요소를 선택하고 그립니다.
        const coverImageElements = document.querySelector(".post-image");
        coverImageElements.src = imageRetrieveBseUrl + 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);
    });
}

그리고 새로고침을 한다면, 옆과 같이 원래 있던 이미지가 떠오르지 않고 에러 메시지가 반겨줄 겁니다. 우리의 API에서 null을 반환했다고 하네요.

실제로 API를 확인해 보면 null로 바뀌어 있습니다. 이 부분에, 우리의 이미지를 넣어 봅시다.

그렇기 위해서는, 일단 이미지의 경로가 붙어 있는 게시물의 작성이 필요하겠네요. 지금의 글쓰기 API는 이미지 필드를 따로 전송하지 않아도 작성 처리가 되지만, 인스타그램에서는 사진 없이는 게시물을 쓸 수가 없죠. 이 부분을 수정합니다.

사실 코드 한 줄이면..

좋아요, 이제 우리의 게시물 테이블의 image 필드에는 /post/2022/11/04/mina.jpg 와 같은 값이 들어가야 합니다.

본격적인 프론트엔드 단에서의 작업을 하기 전에 파일 이름들을 바꾸어 주겠습니다.

  • 기존의 index.html -> post_list.html
  • 기존의 style.css -> post_list.css
  • post_create.html 새로 생성
  • post_creace.css 새로 생성
  • post_create.css 새로 생성

을 해 주겠습니다. 파일명들이 바뀌었으니 post_list.html 에서 파일을 불러오는 코드들도 바꿔야겠네요!

그리고, 비루한 CSS와 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>
/**
 * 사용자가 이미지 선택을 완료하면,
 * 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()));
  }
}
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;
}

새로운 HTML 파일이 생성되었으니, server.js 에도 해당 파일을 등록해 줘야겠네요.

그리고, flastagram/post-create 로 접속해 보세요. 아래와 같은 화면이 나올 겁니다.

디자인 구린 거 압니다. 하지만 어쩌겠습니까, 이게 저인데..

잠시 우리의 게시물 작성 로직을 살펴보고 넘어가겠습니다. 현재의 HTML에 대하여 자바스크립트 코드는 아래와 같이 구성됩니다.

알아야 할 것은, 우리의 게시물 작성 로직이 2회로 나누어진다는 것입니다. 게시물 작성이 이루어지기 전에 이미지 업로드가 이루어지고, 이미지 업로드가 성공하면 그것의 경로를 받아올 겁니다. 아래와 같은 값이었습니다.

그리고, 우리는 이것을 게시물 작성 API 의 image 필드에 담아 보낼 겁니다. 위의 자바스크립트 로직에 의해서, 사진을 업로드하고 / “작성하기” 버튼을 누르면 우리는 서버에게 아래와 같은 json 을 body 에 담아 보내게 됩니다.

프론트엔드 단에서, 로그인을 구현하지는 않았으므로 일단은 임의의 JWT 토큰을 발급받아 아래의 곳에 붙여넣고 요청을 보내 주세요.

임의의 글을 작성하고, 작성하기 버튼을 누르면,

게시물 목록 페이지로 이동할 것이고, 작성된 게시물이 새로 추가된 것을 볼 수 있네요! :)

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.