React, DRF API를 이용한 velog 따라 만들어보기 5장

大 炫 ·2020년 9월 21일
2

Velog Clone

목록 보기
5/8
post-thumbnail

시작하기 앞서

5장을 시작하기 앞서, 교육과정 중 jsp와 Spring에 대해서 조금 배울예정이기에 결과물을 올리는 텀이 좀 많이 길어질 듯 하다.
그래서 지금껏 만든 결과물까지 일단 올리고, 시간을 가진뒤에 리팩토링과 나머지 기능구현을 추가해보도록 하겠다 !

ps. 사실 댓글(댓글은 websocket이 아니라도 되지만)과 실시간 채팅의 욕심으로 websocket을 정말 많이 검색해봤는데 .. django의 내부 template으로 세팅하는 법은 간단했지만 drf api와 react를 이용해서 구현하는 자료가 많이 없어서 연구하고 내 입맛대로 코드를 변형시키는 시간이 생각보다 너무 길어졌고, 아직까지 엉성하게라도 구현을 성공시키지 못했다. 조금 벽을 느끼는 계기가 됬고 다른언어의 websocket도 공부해보고 python의 문법도 제대로 배워야 가능할것같아서 .. 사실 내 마음에 들게 완성시키는 기간이 많이 길어질 수도 있다.. !

5장의 내용은..

home화면에 새글작성시 최신순으로 contents가 추가되고 작성자의 profile에 따라 content의 view또한 알맞게 작성이 되게끔 frontend와 backend의 설정을 5장에서 진행하겠다.

완성된 모습은 아래와 같다.

보기와같이 유저 profile, datetime등 article을 먼저 만들었고 그 외 좋아요기능과 댓글 수 그리고 오른쪽에 filter기능도 아직 구현하지 못했다.

새글작성 버튼 클릭시 위와같은 경로로 이동하게되며 오른쪽 엉성하지만 미리보기처럼 보이게 또한 해놨다.

profile이미지 업로드 할때처럼 이미지를 미리보기 할 수 있고 home에 추가되기전에 마지막으로 article에 담겨서 보여질 내용을 보이게끔 설계했다. 오른쪽 section에 filter기능을 구현해보기 위해서 일단은 추가해놓은 모습이고 체크박스 형태이다. 후에 기능을 추가해보겠다 !

이제 코드를 살펴보자
먼저 frontend단위부터 살펴보겠다.

frontend/src/components/Borad.js

import React, { useState, useEffect } from 'react';
import '../css/Board.css';
import '../css/Thumbnail.css'
import { useHistory } from 'react-router'


function Board(props){
    let [filterList] = useState([{id : 1, language: 'Python'}, {id : 2, language : 'React'}, {id : 3, language : 'Java'}, {id : 4, language :'C#'}, {id : 5, language:'C'}, {id : 6, language:'C++'}, {id : 7, language:'GO'}, {id : 8, language:'Javascript'}, {id : 9, language:'Html,CSS'}])
    let Today = new Date();
    let date = Today.getFullYear() + "-" + Today.getMonth() + "-" + Today.getDate()
    let [languagefilter, setLanguagefilter] = useState("")

    const histoty = useHistory()
    let [goback, setGoback] = useState(false)
    let [imgGoback, setImgGoback] = useState(false)

    let [img , setImg] = useState()
    let [imgURL, setImgURL] = useState()
    let [title , setTitle] = useState()
    let [content , setContent] = useState("")
    let [userPhoto, setUserPhoto] = useState()

    let sendData;
    const handleEffect = (handleSubmit) => {
        sendData = {
            image : imgURL,
            title : title,
            content : content,
            date : date,
            comment : 0,
            like : 0,
            username : props.user,
            language : languagefilter,
            profileImage : userPhoto
        }
        handleSubmit()
    }
    
    const handleSubmit = () => {
        let form_data = new FormData();
        let fileField = document.querySelector('input[type="file"]');
        form_data.append('title', sendData.title);
        form_data.append('content', sendData.content);
        form_data.append('date', sendData.date);
        form_data.append('comment', sendData.comment);
        form_data.append('language', sendData.language);
        form_data.append('like', sendData.like);
        form_data.append('username', sendData.username);
        form_data.append('image', fileField.files[0])
        form_data.append('profileImage', sendData.profileImage)

        fetch("http://localhost:8000/api/Todos/", {
            method : 'POST',
            headers: {
                Authorization : `JWT ${localStorage.getItem('token')}`,
            },
            body : form_data
        })
        .then(res => res.json())
        .catch(error => console.error('Error:', error))
        .then(response => console.log('Success:', JSON.stringify(response)));
    };

    useEffect(()=>{
        fetch('http://localhost:8000/user/current/', {
          headers: {
            Authorization: `JWT ${localStorage.getItem('token')}`
          }
        })
        .then(res => res.json())
        .then(json => {
          // 현재 유저 정보 받아왔다면, 로그인 상태로 state 업데이트 하고
          if (json.id) {
            //유저정보를 받아왔으면 해당 user의 프로필을 받아온다.
        }fetch('http://localhost:8000/user/auth/profile/' + json.id + '/update/',{
                method : 'PATCH',
                headers: {
                    Authorization: `JWT ${localStorage.getItem('token')}`
                },
            })
            .then((res)=>res.json())
            .then((userData)=> {
                setUserPhoto(userData.photo)
            })
            .catch(error => {
                console.log(error);
              });;
        }).catch(error => {
            console.log(error)
          });
    },[userPhoto])

  return(
    <> 
    {
        goback === false
        ?(
        <section className="container-section">
            <article className="write-container">
            <div className="post-title">
                <textarea name="" id="" cols="30" rows="10"  placeholder="제목을 입력하세요" onChange={(e)=>{setTitle(e.target.value)}}></textarea>
            </div>
            <div className="post-contents">
            <textarea className="post-textarea" placeholder="내용을 입력하세요" onChange={(e)=>{setContent(e.target.value)}}></textarea>
                <div>
                    <div className="contents-scroll">
                        <input type="hidden" name="textType" value="HTML" id="textType"></input>
                    </div>
                </div>
            </div>
            <footer className="post-comment">
                <button className="exit-btn transparent-btn" onClick={()=>{histoty.goBack()}}>✔ 나가기</button>
                <div>
                    <button className="transparent-btn" onClick={()=>{
                        setGoback(true)
                    }}>발행</button>
                </div>
            </footer>
            </article>
            <article className="view-container">
                <div className="view-margin">
                    <h1>{title}</h1>
                    <div className="view-content">
                        <div>
                            <p>
                                {
                                content.split('\n').map( (line, i) => {
                                    return (<span key={i}>{line}<br/></span>)
                                })
                                }
                            </p>
                        </div>
                    </div>
                </div>
            </article>
        </section>
        )
        :(
        <div className="thumbnail_container">
            <div className="thumbnail_section">
                <div className="left_section">
                    <section className="left_container">
                        <h3>포스트 미리보기</h3>
                        <button className="upButton">
                            <label htmlFor="file" className="img-up">
                                <input type="file" id="file" accept=".jpg, .png, .jpeg, .gif" onChange={(e)=>{
                                    e.preventDefault();
                                    let reader = new FileReader();
                                    let file = e.target.files[0];
                                    reader.onloadend = () => {
                                    setImg(file)
                                    setImgURL(reader.result)
                                    }
                                    reader.readAsDataURL(file);
                                    setImgGoback(true)
                                }}></input>
                        이미지 업로드</label></button>
                            <button className="upButton" onClick={()=>{
                                setImg(null)
                                setImgURL(null)
                                setImgGoback(false)
                            }}>이미지 제거</button>
                        <div className="left_container2">
                            <div className="left_container3">
                                <div className="img_container">
                                    <div className="img_container2">
                                        {
                                            imgGoback === false
                                            ? <svg width="107" height="85" fill="none" viewBox="0 0 107 85"><path fill="#868E96" d="M105.155 0H1.845A1.844 1.844 0 0 0 0 1.845v81.172c0 1.02.826 1.845 1.845 1.845h103.31A1.844 1.844 0 0 0 107 83.017V1.845C107 .825 106.174 0 105.155 0zm-1.845 81.172H3.69V3.69h99.62v77.482z"></path><path fill="#868E96" d="M29.517 40.84c5.666 0 10.274-4.608 10.274-10.271 0-5.668-4.608-10.276-10.274-10.276-5.665 0-10.274 4.608-10.274 10.274 0 5.665 4.609 10.274 10.274 10.274zm0-16.857a6.593 6.593 0 0 1 6.584 6.584 6.593 6.593 0 0 1-6.584 6.584 6.591 6.591 0 0 1-6.584-6.582c0-3.629 2.954-6.586 6.584-6.586zM12.914 73.793a1.84 1.84 0 0 0 1.217-.46l30.095-26.495 19.005 19.004a1.843 1.843 0 0 0 2.609 0 1.843 1.843 0 0 0 0-2.609l-8.868-8.868 16.937-18.548 20.775 19.044a1.846 1.846 0 0 0 2.492-2.72L75.038 31.846a1.902 1.902 0 0 0-1.328-.483c-.489.022-.95.238-1.28.6L54.36 51.752l-8.75-8.75a1.847 1.847 0 0 0-2.523-.081l-31.394 27.64a1.845 1.845 0 0 0 1.22 3.231z"></path></svg>
                                            : <img src={imgURL} alt=""></img>
                                        }
                                        
                                    </div>
                                </div>
                            </div>
                            <div className="title-margin">
                                <h4>{title}</h4>
                                <textarea name="viewContent">{content}</textarea>
                            </div>
                        </div>
                    </section>
                </div>
                <div className="line_section"></div>
                <div className="right_section">
                    <div className="fillter_section">
                    <section>
                <ul>
                    {
                    filterList.map((a)=>{
                        return(
                        <li key={a.id}>
                            <input id={a.language} className="filters-input__checkbox" value="action" type="checkbox" data-type="genres" onChange={()=>{
                                let ReLang = [...languagefilter]
                                ReLang.push(a.language)
                                setLanguagefilter(ReLang)
                            }}></input>
                            <label className="input__label | filters-input__label--checkbox" htmlFor={a.language}>
                            <span>{a.language}</span>
                            <span className="filters-input__tick">
                                <svg focusable="false" aria-hidden="true">
                                <use xlinkHref="#check">
                                    <svg viewBox="0 0 24 24" id="check" xmlns="http://www.w3.org/2000/svg"><path d="M9 21.035l-9-8.638 2.791-2.87 6.156 5.874 12.21-12.436L24 5.782z"></path></svg>
                                </use>
                                </svg>
                            </span>
                            </label>
                        </li>
                        )
                    })
                    }
                </ul>
                </section>
                    </div>
                    <div>
                        <button className="upButton" onClick={()=>{
                            handleEffect(handleSubmit)
                            setGoback(false)
                            histoty.goBack()
                        }}>출간하기</button>
                    </div>
                </div>
            </div>
        </div>
        )
    }
    </>
  )
}

export default Board;

사실상 미리보기의 Thumbnail과, Borad를 분리 해야하지만 그냥 붙여놓은 모습이고, 사실상 여지껏 포스팅한 내용의 반복이라 살펴볼 내용은 아래내용정도 인듯하다.

let sendData;
    const handleEffect = (handleSubmit) => {
        sendData = {
            image : imgURL,
            title : title,
            content : content,
            date : date,
            comment : 0,
            like : 0,
            username : props.user,
            language : languagefilter,
            profileImage : userPhoto
        }
        handleSubmit()
    }
    
    const handleSubmit = () => {
        let form_data = new FormData();
        let fileField = document.querySelector('input[type="file"]');
        form_data.append('title', sendData.title);
        form_data.append('content', sendData.content);
        form_data.append('date', sendData.date);
        form_data.append('comment', sendData.comment);
        form_data.append('language', sendData.language);
        form_data.append('like', sendData.like);
        form_data.append('username', sendData.username);
        form_data.append('image', fileField.files[0])
        form_data.append('profileImage', sendData.profileImage)

        fetch("http://localhost:8000/api/Todos/", {
            method : 'POST',
            headers: {
                Authorization : `JWT ${localStorage.getItem('token')}`,
            },
            body : form_data
        })
        .then(res => res.json())
        .catch(error => console.error('Error:', error))
        .then(response => console.log('Success:', JSON.stringify(response)));
    };

image파일을 포함한 FormData를 넘겨야하기때문에 위와같이 form_data의 빈객체를 생성 , 후에 우리가 backend로 넘겨줘야할 객체들을 각각의 내용을 sendData에 정의해주고 그 내용을 append해준뒤에 넘겨주는 모습이다.
특이점은

body : form_data

이처럼 json형태로 변환해주지 않아도 바로 넘겨줄 수 있다는 점에 주목해보면 좋을 듯 하다 !

frontend/src/components/Trend.js

import React,{useState, useEffect} from 'react';
import {Link} from 'react-router-dom';
import '../css/Trend.css';

function Trend(){
    let [filterList] = useState([{id : 1, language: 'Python'}, {id : 2, language : 'React'}, {id : 3, language : 'Java'}, {id : 4, language :'C#'}, {id : 5, language:'C'}, {id : 6, language:'C++'}, {id : 7, language:'GO'}, {id : 8, language:'Javascript'}, {id : 9, language:'Html,CSS'}])
    let [todolist, setTodoList] = useState([])
    let [userPhoto, setUserPhoto] = useState()
    
    // views.py에서 권한이 없이 데이터조회를 가능하게 했기때문에 posts의 정보를 불러올 수 있다.
    useEffect(()=>{
        (()=> {
        try{
            fetch('http://localhost:8000/api/Todos/')
            .then((res)=>res.json())
            .then((posts)=>{
                setTodoList(posts)
            })
        } catch(e){
            console.log(e)
        }
        })();
    },[])

    useEffect(()=>{
        fetch('http://localhost:8000/user/current/', {
          headers: {
            Authorization: `JWT ${localStorage.getItem('token')}`
          }
        })
        .then(res => res.json())
        .then(json => {
          // 현재 유저 정보 받아왔다면, 로그인 상태로 state 업데이트 하고
          if (json.id) {
            //유저정보를 받아왔으면 해당 user의 프로필을 받아온다.
        }fetch('http://localhost:8000/user/auth/profile/' + json.id + '/update/',{
                method : 'PATCH',
                headers: {
                    Authorization: `JWT ${localStorage.getItem('token')}`
                },
            })
            .then((res)=>res.json())
            .then((userData)=> {
                setUserPhoto(userData.photo)
            })
            .catch(error => {
                console.log(error);
              });;
        }).catch(error => {
            console.log(error)
          });
    },[userPhoto])

    return(
        <div className="trend-section">
            <main className="trend-main">
                <div className="main-section">
                        {
                            todolist.slice(0).reverse().map((a)=>{
                                return(
                                    <div className="article" key={a.id}>
                                        <Link to="/">
                                            <div className="arcticle-img">
                                                <img src={"http://localhost:8000" + a.image} alt=""></img>
                                            </div>
                                        </Link>
                                        <div className="article-content">
                                            <Link to="/">
                                                <h4>{a.title}</h4>
                                                <div className="desc-wrapper">
                                                    <p>{a.content}</p>
                                                </div>
                                            </Link>
                                            <div className="sub-info">
                                                <span>{a.date}</span>
                                                <span className="separator">·</span>
                                                <span>{a.comment}개의 댓글</span>
                                            </div>
                                        </div>
                                        <div className="article-footer">
                                            <Link to="/">
                                                <img src={a.profileImage} alt=""></img>
                                                <span>"by " <b>{a.username}</b></span>
                                            </Link>
                                            <div className="likes">
                                                <svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M18 1l-6 4-6-4-6 5v7l12 10 12-10v-7z"></path></svg>
                                                {a.like}
                                            </div>
                                        </div>
                                    </div>
                                )
                            })
                        }
                </div>
            </main>
            <aside className="pDRpR">
            <div className="eyrfCG">
                <div className="filter__head">
                </div>
                <section>
                <ul>
                    {
                    filterList.map((a)=>{
                        return(
                        <li key={a.id}>
                            <input id={a.language} className="filters-input__checkbox" value="action" type="checkbox" data-type="genres"></input>
                            <label className="input__label | filters-input__label--checkbox" htmlFor={a.language}>
                            <span>{a.language}</span>
                            <span className="filters-input__tick">
                                <svg focusable="false" aria-hidden="true">
                                <use xlinkHref="#check">
                                    <svg viewBox="0 0 24 24" id="check" xmlns="http://www.w3.org/2000/svg"><path d="M9 21.035l-9-8.638 2.791-2.87 6.156 5.874 12.21-12.436L24 5.782z"></path></svg>
                                </use>
                                </svg>
                            </span>
                            </label>
                        </li>
                        )
                    })
                    }
                </ul>
                </section>
            </div>  
            </aside>
        </div>
    )
}
export default Trend;

이코드는 home화면에서 Navi의 최신항목을 선택중일 때 표시될 content를 Trend라고 설정해놨다. 사실 살펴볼 만한 코드는 아래코드 밖에 없다.

todolist.slice(0).reverse().map()

사실 나도 처음에는 별 생각이없었는데 새 글 작성 test시 저장되고 불러올 때 순서가 최신순서가 아닌 처음 작성한 view가 먼저 보이게 되니, 역순으로 출력하기 위해서 map함수에 slice(0).reverse()로 역전시켜준 모습이다 !

Navi.js에는 content에 맞게끔 component가 페이지전환없이 바뀔 수 있도록 설정해준 모습이다.

frontend/src/components/Navi.js

import React, { useState } from 'react';
import {Link} from 'react-router-dom';
import Trend from './Trend'
import '../css/Navi.css';

function Navi(){
    let [underline, setUnderline] = useState({left:"0%"})
    let [choseContent, setChoseContent] = useState(<Trend/>)
    return(
        <>
        <div className="navi-container">
            <div className="navi-box">
                <Link className="navi-" to="/" onClick={()=>{
                    setUnderline({left:"0%"})
                    setChoseContent(<Trend/>)
                }}>
                    <span role = "img" aria-label = "하트">🤞최신</span>
                </Link>
                <Link className="navi-" to="/" onClick={()=>{
                    setUnderline({left:"50%"})
                    setChoseContent("")
                }}>
                    <span role = "img" aria-label = "질문">🤷‍♂️Q & A</span>
                </Link>
                <div className="navi-underline" style={underline}></div>
            </div>
        </div>
        {choseContent}
        </>
    )
}
export default Navi;

이제 backend 코드를 살펴볼건데 사실 여지껏 했던 내용의 반복, 조금씩의 변형일 뿐이다.

우선 post라는 app을 startapp만들어주고 setting.py 에 추가해준 뒤 모델먼저 보겠다 !

backend/post/models.py

from django.db import models

class Todo(models.Model):
    title = models.CharField(max_length=50)
    content = models.TextField()
    image = models.ImageField(upload_to='post_images', blank=True)
    date = models.DateTimeField(auto_now_add=True)
    comment = models.IntegerField(default=0)
    like = models.IntegerField(default=0)
    username = models.CharField(max_length=200)
    language = models.CharField(max_length=200)
    profileImage = models.CharField(default='/media/red.jpg', max_length=500)

    def __str__(self):
        return self.title

migrate잊지말자 ~

backend/post/serializers.py

from rest_framework import serializers
from .models import Todo

class TodoSerializer(serializers.ModelSerializer):
    class Meta:
        model = Todo
        fields = '__all__'

사실 살펴볼 내용은 views.py에 두줄밖에 없다. 우선 보자

backend/post/views.py

from .serializers import TodoSerializer
from .models import Todo
from rest_framework.views import APIView
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework import status
# Create your views here.


class TodoView(APIView):
    parser_classes = (MultiPartParser, FormParser)

    authentication_classes = []   #이거 두줄은 권한이 없는 상태에서 데이테 요청을 가능하게
    permission_classes = []       #만듬 settings.py에서도 아마 가능할 것 같음.

    def get(self, request, *args, **kwargs):
        Todos = Todo.objects.all()
        serializer = TodoSerializer(Todos, many=True)
        return Response(serializer.data)

    def post(self, request, *args, **kwargs):
        Todos_serializer = TodoSerializer(data=request.data)
        if Todos_serializer.is_valid():
            Todos_serializer.save()
            return Response(Todos_serializer.data, status=status.HTTP_201_CREATED)
        else:
            print('error', Todos_serializer.errors)
            return Response(Todos_serializer.errors, status=status.HTTP_400_BAD_REQUEST)

주석처리해놓은 부분!
user가 작성한 post는 원래 권한없이는 get등으로 조회가 불가능한데
저 두줄으로인해 get방식등의 데이터요청을 가능하게 만들어 Trend.js에서 가져오기를 할 수 있어 로그인하지않은 유저들 또한 post를 볼 수 있게됬다 !

backend/post/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('Todos/', views.TodoView.as_view(), name= 'Todos_list'),
]

backend/api/urls.py

from django.contrib import admin
from django.urls import path, include
from rest_framework_jwt.views import obtain_jwt_token, verify_jwt_token, refresh_jwt_token
from .views import validate_jwt_token
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path("admin/", admin.site.urls),

    path('validate/', validate_jwt_token),
    path('login/', obtain_jwt_token),
    
    path('verify/', verify_jwt_token),
    path('refresh/', refresh_jwt_token),
    
    path('user/', include('user.urls')),
    path('api/', include('post.urls')),
]
urlpatterns += \
    static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

admin 페이지도 필요하다면 추가해주면 될 듯하다 !

5장 마치며

이렇게 5장까지 직접 velog를 따라하면서 따라하기 위해서 무엇이 필요한지, 어떤 검색을 해야하는지 어떻게 접근해야하는지 등 혼자 공부하며 많은 시행착오를 통해 많은 공부가 됬었다. 이제는 리팩토링 그리고 아직 구현 못한 기능들을 추가하면서 spring과 react의 redux등 공부를 하며 천천히 공부한 내용을 하나씩 올리겠다 !

ps. DRF을 쓰면서 느낀점

framework를 이번에 처음 접하게되어 아무 강의없이 혼자서 검색하며 코드를 분석하고 헤딩하며 많은 시행착오를 겪었다.
덕분에 framework는 어떤 규칙이 분명하게 정해져있고, 우리는 규칙에 벗어나지 않게 class들을 활용하면서 입맛에 바꾸는 , 상당히 유용한 시간이었다.
나는 맨땅에 헤딩하며, 공부의 필요성을 많이 느꼈고, 천천히 간다면 남들보다 뛰어나진 않아도 남들과 비슷한 수준까지 성장 할 수 있다는 자신감을 velog copy 프로젝트를 진행하면서 얻었다.

profile
대현

0개의 댓글