본문 바로가기

프로젝트/엘리스 AI 트랙

나의 첫 프로젝트(3), "Flask에서 React까지"

[17] state에 배열 추가하기

포트폴리오에 등록한 정보를 바탕으로 유저들의 데이터를 한 페이지에 나열하는 기능을 만들려고 했다. 

리스트를 state로 지정하고 나서, 유저 목록을 가져오면 seState로 상태를 저장해야하는데,

배열의 경우에는 concat을 사용하여 해결하였다.

 

// React
const [userList, setUserList] = useState([])
   const users = []

useEffect(() => {
    async function bring_user_list() {
        try {
            const response = await axios.get(`${process.env.REACT_APP_BASE_URL}/userList`)

            for(let i=0; i < response.data.length; i++) {
                const id = response.data[i].id
                const user = response.data[i].user_name

                users.push({id, user})
            }
            setUserList((current)=> current.concat(users)) // state에 배열 추가할 때는 concat 사용하기

        } catch(err) {
            console.log(err)
        }
    }

    bring_user_list()
}, [])

 

# Flask
@bp.route("/userList", methods=["GET"])
def userList() :
    result = []
    query = User.query.all()

    for i in query :
        data = User.to_dict(i)
        result.append(data)
        
    return jsonify(result)

 

유저 정보를 카드형식으로 나열한 모습. 임포스터는 누구???


[18] 유저 검색하기

유저의 이름을 검색했을 때 해당되는 카드만 나열하도록 하는 기능을 구현했다.

복잡할거라고 생각했지만, 의외로 filter함수와 map함수의 콜라보레이션으로 심플하게(?) 구현이 가능했다.

 

{userList.filter((data)=> {
        if(search == null) {
            return data
        } else if(data.user.includes(search)) {
            return data
        }
    }).map((data) => 
           <UserCard 
           	key={data.id}
           	id={data.id}
           	user={data.user}
           />
       ) 
}

[19] DB에 최신 버전으로 저장하기

포트폴리오 글을 수정하고나서 GET요청으로 데이터를 받아왔을 때, 맨 처음 넣었던 데이터만 불러오고, 새롭게 수정한 최신버전 데이터는 가져오지 못하는 문제가 발생했다. mySQL을 확인해보니 한 유저의 수정된 데이터가 차곡차곡(?) 쌓여가는데 맨 위에 있는 첫번째 데이터만 불러오고 있었다. 결국 이 문제를 해결하려면 유저 데이터에 새롭게 입력된 정보를 추가하는것이 아니라 기존 정보를 변경하면 해결 될 것이라고 보았다.

 

잘못된 예시> 수정하고 난 이후에도 최신버전이 반영이 되지 않는 상황이다.

data = request.get_json()

school_name  = data['school_name']
school_major = data['school_major']
degree       = data['degree']
key_id       = get_jwt_identity()

# 새로운 데이터를 추가만 해주는 상황
new_major = Major(school_name, school_major, degree, key_id)

db.session.add(new_major)
db.session.commit()

 

올바른 예시) 이렇게 바꿔줘야 최신버전으로 DB에 변경해서 넣어주고 요청시 수정된 데이터를 받아올 수 있다.

data = request.get_json()

school_name  = data['school_name']
school_major = data['school_major']
degree       = data['degree']
key_id       = get_jwt_identity()

# 우선 DB에 저장정보를 만들어 둔 다음
new_major = Major(school_name, school_major, degree, key_id)

db.session.add(new_major)
db.session.commit()

# 수정할 정보도 다시한번 입력하고 commit() 한번 더 하면 끝!
user_major = Major.query.filter(Major.key_id == get_jwt_identity()).first() 

user_major.school_name  = school_name
user_major.school_major = school_major
user_major.degree       = degree
user_major.key_id       = key_id

db.session.commit()

 

▷▶ 이런방법으로 사용했을 때, DB 서버에 요청하는 횟수가 많아져서 한번에 요청가능한 범위를 넘어버려

서버가 다운되는 현상이 종종 생겼다. 더 나은 코드로 리팩토링할 필요가 있어보였다.


[20] 이미지 파일 업로드

포트폴리오 프로젝트를 진행하면서 가장 많은 시간을 쏟고, 가장 많은 삽질을 했던 부분이 이미지 파일 기능 구현이었다. 처음에는 이미지 파일의 URL 경로를 저장한다는 개념 자체가 이해가 잘 되지 않아서 헷갈리는게 많았다. 우선, 이미지 파일은 이미지 파일은 FormData를 사용해서 전송할 수 있다. FormData를 post요청 보낼 때는 헤더에 추가해서 보내줘야한다. 다음으로 이미지 파일의 URL 경로를 DB에 저장해야 한다. 실제로 이미지 파일을 저장하면 용량이 크기 때문에 이미지의 URL 경로를 데이터로 저장하는 것이 효율적이다. URL 경로에는 절대경로와 상대경로가 있는데, 유동적으로 경로를 찾을 수 있는 상대경로로 저장한다.

 

▷▶ 프로필 사진에 이미지 업로드 기능 구현에 성공했을 때 집안에서 소리지르면서 뛰어다녔던게 생각난다 ㅎㅎ.. 

 

// 프론트에서 이미지 파일 POST 
const [files, setFiles] = useState("static/d.jpg")

    const fileChangeHandler = async(e) => {
        
        const imageFile = Array.from(e.target.files)
        const formData = new FormData(); // 이미지 파일은 FormData를 사용해서 전송
        formData.append("imageFile", imageFile[0]) // formData.append(key값, value값)

        try {
            const response = await axios.post(`${process.env.REACT_APP_BASE_URL}/profile`, formData, {
                headers: {
                    "Authorization": `Bearer ${localStorage.getItem('jwt')}`,
                    "content-type": "multipart/form-data" // FormData보낼 때 헤더에 같이 보내기
                }
            })

            console.log(response)
            console.log(response.data)
            console.log(response.status)

            setFiles(response.data) // POST로 보내고 반환받은 이미지 url 받아서 적용하기

        } catch(err) {
            console.log(err)

        }
    }

 

// 프론트에서 이미지 파일 GET 
useEffect(() => {
        const bring_data = async() => {
            try {
                const response = await axios.get(`${process.env.REACT_APP_BASE_URL}/profile`, {
                    headers: {
                        "Authorization": `Bearer ${localStorage.getItem('jwt')}`
                    }
                })
                console.log("이미지 : " + response.data)

                setFiles(response.data) // GET으로 반환받은 이미지 url 받아서 저장하기
                
            } catch(err) {
                console.log(err)
            }
          }
          
        bring_data()
    }, [files]) // files state 바뀔 때마다 실행되도록

 

// src에 이미지 저장된 로컬 url 주소 넣기
<img src={`${process.env.REACT_APP_BASE_URL}/${files}`} alt="프로필 이미지" />

 

절대 경로 vs 상대 경로

1. 절대경로

  • 최상위 디렉토리에서부터 시작하는 고유 경로이다.
  • C://로 시작하는 파일의 전체 경로 Full Path를 의미한다.
  • 외부 파일을 연결할 때 주로 사용한다.

2. 상대경로

  • 작성중인 파일이 든 디렉토리를 기준으로 하는 경로이다.
  • 현재 내 위치를 기준으로 상대적인 파일의 위치를 나타낸다.
  • 내부 파일을 연결할 때 주로 사용한다.

 

from flask import Blueprint, request, session, jsonify, abort, Flask, current_app
from models import *
from db_connect import db
from flask_jwt_extended import *
from werkzeug.utils import secure_filename
from dateutil.parser import parse
import random

# 백엔드에서 이미지 파일 GET, POST 요청
@bp.route("/profile", methods=["GET", "POST"])
@jwt_required()
def profile() :
    if request.method == "GET" :
        user_profile = User.query.filter(User.id == get_jwt_identity()).first()
        image_url = user_profile.image_url
        return jsonify(image_url)
    else :
        # 프론트에서 파일 받아오고 암호화 해서 상대경로로 저장하기
        files = request.files["imageFile"]
        filename = secure_filename(files.filename) # secure_filename은 암호화 되어있는지 확인해주는 것일 뿐

        # 로컬에 상대경로 저장하기
        random_url = str(random.random()) # 같은 이름의 이미지 파일이 충돌하지 않도록 난수 설정
        file_location = './static/'+ random_url + filename # 직접 url 저장 경로를 설정해주어야 한다
        files.save(file_location)

        # DB에 상대경로 저장하기
        DB_location = 'static/'+ random_url + filename # DB에서 저장할 때는 url주소를 조립하기 쉽게 설정해서 저장함
        user_profile = User.query.filter(User.id == get_jwt_identity()).first()
        user_profile.image_url = DB_location

        db.session.commit()

        return jsonify(DB_location)

[Final] 서버 배포하기

난이도 ★

 

프로젝트를 개발단계에서 어느정도 마무리를 한 뒤에, 서버를 실제로 배포하기 위한 작업에 들어갔다. 

Azure 클라우드 서버를 이용하여 서버를 배포했는데, 배포한 웹사이트 화면을 띄우는 것부터가 난관이었다.

그 이후에도 계속되는 오류에 지쳐서 여기까지만 할까 라는 생각도 수도 없이 했지만, 최종발표날 전까지 남은 이틀을 새벽 6시, 아침 7시까지 밤을 새가면서 문제를 해결해보기 위해 끝까지 프로젝트의 끈을 놓지 않았다...

코치님과 똑똑하신 레이서님들의 밤샘 도움으로 결국 웹사이트 배포에 성공하게 되었다!! ㅠㅜ

 

1) 개발환경 <-> 배포환경

프로젝트 개발단계에서는 dev 브랜치에 git push를 하고 코치님의 코드리뷰를 통해 master 브랜치로 MR을 했었다. git-lab에서 가져오기 위해서는 git clone --branch dev [HTTP URL주소] 명령어를 쓸 수 있다. 또한 수정된 사항을 배포환경에 반영하기 위해서는 git push로 원격저장소에 올려둔 다음,  git pull을 해서 npm run build 명령어를 사용한다.

 

로컬과 배포 환경은 다르기 때문에 배포환경에 맞게 코드를 세팅하다보면 로컬 개발쪽에서는 웹사이트에 어떻게 나오는지 알 수가 없어서 git-lab으로 push하고 다시 pull하는 작업을 반복했었다. 다른 레이서분이 친절하게 해결법을 알려주었다. package.json 파일에다가 "proxy": "http://localhost:5000/" 을 넣으면 배포환경에 맞게 적었어도 로컬 개발환경에서 해당 부분을 알 수 있게 된다. 또는 remote SSH 확장 프로그램 깔아서 배포환경의 코드를 보고 수정할 수도 있다.

 

2) mySQL access denied

개발 단계에서 mySQL을 VScode에 설치하여 데이터를 확인했었었다. 그러나 배포를 하고난 뒤에는 배포환경에서의 mySQL이 따로 DB가 생긴다는 것을 뒤늦게 깨달았다. 그래서 배포환경에 있는 mySQL로 들어가서 migration을 다시 진행하려고 했다. 그때 마다 계속 access denied for user 'root'@'localhost' 라는 오류가 발생했다. 결국 mySQL의 비밀번호를 재설정해주는 방식으로 문제를 해결하였다.

 

use mysql;

update user set plugin='mysql_native_password' where user='root';

flush privileges;

alter user 'root'@'localhost' IDENTIFIED BY 비밀번호; (비밀번호는 문자열로 넣어줘야함)

flush privileges;

3) 404 ERROR

서버를 배포하고나서 가장 큰 문제가 다른 페이지로 이동이 되지 않는게 골치가 아팠다. 자꾸 404 ERROR를 뱉어내서 레이서분들과 새벽에 서로 소통하며 4층에 갇힌 주민들(?)이라고 표현했다. 200 OK를 받아야 정상적으로 작동하는 것이기 때문에 서버 배포에 성공하신 분들을 2층에 계신 주민(?)이라고 우스갯소리로 말하기도 했다. 나 또한 역시 404 ERROR로 밤잠을 설치면서 해결하기 위해 노력했다. 서버를 배포할 때는 Nginx를 사용하여 프론트에서 쓰는 3000포트와 백엔드에서 쓰는 5000포트를 location /api라는 특정 URL로 서로 연결해 준다.  그렇기 때문에 기존의 코드에서 API 요청을 보내는 프론트쪽과 백엔드쪽에서 모두 /api 를 붙여주는 작업을 해야 한다.

 

프론트 주소에서 /api를 뒤에 붙여주면 5000번 포트로 연결해주겠다는 의미이다 (by Nginx)

404 ERROR는 일단 url주소가 문제였던것 같다. 원래는 .env로 React에서 환경변수를 세팅했고${process.env.REACT_APP_BASE_URL}로 axios 요청을 보낼때 썼었는데, .env파일은 그냥 삭제해버렸고, await axios.get(`${process.env.REACT_APP_BASE_URL}/api/join`) 요청을 await axios.get("/api/join")으로 바꾸었다. 그리고 Flask 서버에서도 모든 API 주소 앞에 @bp.route("/api/join", methods=["POST"]) 으로 바꾸었다. 매번 실행마다 (리액트)npm run build, (엔지닉스)sudo service nginx restart, (파이썬)python3 app.py 을 세번 다 돌렸다. 그렇게 무수한 삽질 끝에 200 OK 사인을 받아내고 2층 주민이 되었다..... (감동 ㅠㅜ)

4) PM2로 Flask서버 관리

pm2는 Flask에서 서버가 먹통되어도 자동으로 계속 서버 켜주는 장치이다.

DB에 요청이 많아져서 서버가 다운되는 경우가 왕왕 있었는데, 개발자님 조언으로 pm2를 깔아 서버 관리를 해보았다.

 

- sudo npm install -g pm2
- pm2 start "python3 app.py" 또는 pm2 start app.py 실행 (윈도하고 리눅스하고 환경이 달라서 다를 수있음)
- 에러나면 pm2 stop 0 -> pm2 stop 1 -> pm2 del 0 -> pm2 del 1 방법으로 삭제하기

현실적으로 먹통되면 pm2 restart 0 명령어 쓰기.
현재 상태 어떤지 보고 싶으면 pm2 list 명령어로 cpu랑 mem 볼수있고 online이면 정상이다.
uptime 오른쪽에 리핏하는 표시있는데 그건 몇번꺼졌다 켜졌는지 나오는 숫자알려주는 것이다.

6) Datepicker 호환성

DatePicker 라이브러리를 사용해 날짜를 저장했지만 Flask와 React서버에서 저장하는 방식이 달라 충돌을 일으켜 저장이 되지 않는 문제가 발생했다. 파이썬에 있는 deutil 라이브러리를 깔아서 플라스크 서버에 저장할 때 DateTime을 parsing하여 서로 호환되도록 바꿔주었다.

 

# mpdels.py

class User(db.Model):
    __tablename__ = 'user'

    id            = db.Column(db.Integer,  primary_key=True, nullable=False, autoincrement=True)
    user_id       = db.Column(db.String(100), nullable=False, unique=True)
    user_pw       = db.Column(db.String(500), nullable=False, unique=True)
    user_name     = db.Column(db.String(100), nullable=False)
    image_url     = db.Column(db.String(100), default='static/images/default.jpg')
    introduce     = db.Column(db.String(100))

 

from flask import Blueprint, request, session, jsonify, abort, Flask, current_app
from models import *
from db_connect import db
from flask_jwt_extended import *
from werkzeug.utils import secure_filename
from dateutil.parser import parse
import random

@bp.route("/api/project", methods=["GET", "POST"])
@jwt_required()
def project() :
    if request.method == "GET" :
        query = Project.query.filter(Project.key_id == get_jwt_identity()).first()
        data = Project.to_dict(query)
        return jsonify(data)
    else :
        data = request.get_json()

        project_name  = data['project_name']
        project_desc  = data['project_desc']
        start_date    = data['start_date']
        end_date      = data['end_date']
        key_id        = get_jwt_identity()

        # dateutil로 parsing하기
        start_date = parse(start_date).date()
        end_date = parse(end_date).date()

        new_project = Project(project_name, project_desc, start_date, end_date, key_id)
       	
        db.session.add(new_project)
        db.session.commit()

        user_project = Project.query.filter(Project.key_id == get_jwt_identity()).first()
        user_project.project_name = project_name
        user_project.project_desc = project_desc
        user_project.start_date   = start_date
        user_project.end_date     = end_date
        user_project.key_id       = key_id
        
        db.session.commit()

        return jsonify({"result": "success"})

6) 이미지 파일 업로드

로컬에서는 이미 업로드 기능이 잘 되었지만 서버에 배포하고나니 문제가 다시 생겼다. 사실 서버 배포 과정에서 엘리스 최종발표 전까지도 이미지 파일 업로드 기능은 마무리 짓지 못했다. 이미지 파일이 저장된 경로가 개발환경에서랑 배포환경에서 차이가 있었는데 저장된 이미지 파일의 절대경로를 찾을 수가 없었다. 이것만큼은 포기하고 싶지 않아서 주말동안 계속 삽질하면서 이미지 파일 업로드 기능을 다시 돌려놓기 위해 노력했고, 도움을 받아 마침내 방법을 찾았다!!

 

[엘리스 레이서분의 가르침]

Nginx 설정을 통해서 요청을 서버쪽으로 우회시키는 방식으로만 접근을 할수있어요 그래서 저희가 아무리 :5000/static/ 뭐이런식으로 쳐도 안나오는거에요 5000에 접근권한이 없으니까 암튼 이 개념을 이해하셔야 하는거구요.. nginx에서 허락한 곳에만 저희는 접근할수 있다는거.. 암튼그래서 location static/images <- 얘가 해주는건 저희가 domain주소/static/images/....어쩌구 이미지경로.jpeg이런식으로 url창에 쳐서 볼수있게 해줘요 대신 조건은 정말 static/images/ 안에 그 이미지가 존재를 해야겠죠? 그리고 location /static/images 에서 location /static 만 쓰게 될 경우 React에서도 /static을 사용하는 경로가 있어서(?) 리액트가 망가지는 것 같아요.

백엔드에서 이미지 경로저장할때
./static/images <- 여기다 실제파일 세이브
/static/images/어쩌구.jpeg <- 유저 프로필 이미지 경로 저장

 

Ngnix에서 이미지 파일 경로에 접근할 수 있도록 세팅해둔 상태

위의 말대로 Ngnix를 통해 우리는 서버에 접속할 수 있는 권한을 받게 되고 이를 통해 이미지 파일 경로를 알아낼 수 있었다.  결국... 찾아낸 이미지 파일의 절대경로...!!! 

더보기

http://52.231.70.15/static/images/default.jpg

(기본이미지 파일 절대경로)

 

이미지 파일 업로드 기능 구현 코드
// 프론트 엔드

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import styled from 'styled-components';

const ProfileBox = ({ nick_name }) => {
    
    const [isEdit, setIsEdit] = useState(true) // 조건부 렌더링 state
    const [files, setFiles] = useState("http://52.231.70.15/static/images/default.jpg")

    ////////////////////////////////////
    /// profile image 설정           ///
    ///////////////////////////////////

    const fileChangeHandler = async(e) => {

        const imageFile = Array.from(e.target.files)
        const formData = new FormData();
        formData.append("imageFile", imageFile[0])

        try {
            const response = await axios.post("/api/profile", formData, {
                headers: {
                    "Authorization": `Bearer ${localStorage.getItem('jwt')}`,
                    "content-type": "multipart/form-data"
                }
            })

            console.log(response)
            console.log(response.data)
            console.log(response.status)

            setFiles(`/${response.data}`)

        } catch(err) {
            console.log(err)

        }
    }

    useEffect(() => {
        const bring_image_data = async() => {
            try {
                const response = await axios.get("/api/profile", {
                    headers: {
                        "Authorization": `Bearer ${localStorage.getItem('jwt')}`
                    }
                })

                setFiles(`/${response.data}`)
                
            } catch(err) {
                console.log(err)
            }
          }
          
          bring_image_data()
    }, [files])

  
    const show_box = (
        <Container>
            <Form>
                <Image src={`${files}`} alt="프로필 이미지" />
                <Hello>안녕하세요</Hello>
                <Hello><u><strong>{nick_name}</strong></u> 님</Hello>
                <button onClick={() => {setIsEdit(false)}}>수정</button>
            </Form>
        </Container>
    )

    const edit_box = (
        <Container>
            <Form onSubmit={profileChangeHandler}>
                <label>프로필 이미지를 골라주세요</label>
                <br />
                <input type="file" className="file" accept="image/*" onChange={fileChangeHandler}/>
                <br />
                <button type="submit">확인</button>
            </Form>
        </Container>
    )

    return (
        <>
            {isEdit ? show_box : edit_box}
        </>       
    )
}

 

# 백엔드

@bp.route("/api/profile", methods=["GET", "POST"])
@jwt_required()
def profile() :
    if request.method == "GET" :
        user_profile = User.query.filter(User.id == get_jwt_identity()).first()
        DB_location = user_profile.image_url
        return jsonify(DB_location)
    else :
        # 프론트에서 파일 받아오고 암호화 해서 상대경로로 저장하기
        files = request.files["imageFile"]
        filename = secure_filename(files.filename)

        # 로컬에 상대경로 저장하기
        random_url = str(random.random())
        file_location = './static/images/'+ random_url + filename
        files.save(file_location)

        # DB에 상대경로 저장하기
        DB_location = 'static/images/'+ random_url + filename
        user_profile = User.query.filter(User.id == get_jwt_identity()).first()
        user_profile.image_url = DB_location

        db.session.commit()

        return jsonify(DB_location)

 

6) 추가로 고민해볼 기능

  • refresh token 사용하기
  • 새로운 항목 추가했을 때 DB 저장하기 -> array를 state에 저장하고, 추가 버튼을 누를 때마다 array에 element를 push 
  • DatePicker DB데이터 state로 저장하기
  • 이미지 기본 프로필 선택할 수 있도록 하기
  • DatePicker에서 년원일로 바꾸기
  • MySQL을 ORM 쓰지않고 작성하는 방법 배우기
  • sqlalchemy.exc.TimeoutError: QueuePool limit of size 10 overflow 10 reached, connection timed out, timeout 30.00 오류 해결하기

 


[부록] 프로젝트 시연 영상