[프로젝트] 게시판 front 리펙토링 (5) 게시글 수정 요청하기

rin·2020년 6월 8일
3

React

목록 보기
13/16
post-thumbnail

목표
1. 로그인 후 게시판으로 넘어 올 때, 상태유지하도록 변경하기
2. 모달을 띄워 상세글을 확인 할 수 있도록 변경한다.
3. 게시글 수정 요청을 전송하고 출력 데이터에도 (리다이렉트없이) 반영되도록 한다.

스토어 내 상태값 유지하면서 페이지 전환하기

BoardContainermapStateToPropsaccountId: state.main.accountId를 추가하고, Board 컴포넌트에 값을 전달해보자.
Board 컴포넌트에 값을 전달해서 출력하면 아마 다음처럼 null 값이 뜰 것이다.

문제는 MainContainer에 있다. 바로 로그인 요청이 성공할 시 location.href로 페이지를 이동하는 코드 때문이다. 이 경우 라우터의 자동 url 감지로 라우팅에는 성공하지만 (BoardContainer가 랜더링됨) 상태가 보존되지 않고 초기화된다. 따라서 loginSuccess 액션으로 갱신된 accountId 값도 다시 null이 되는 것.

이를 해결하기 위한 훅이 react-router-dom이 지원하는 useHistory이다.

import { useHistory } from "react-router-dom";

// ( ... 생략 )

const MainContainer = ({accountId, isLogged, isLoginPage,  errorMessage, loginSuccess, loginFail, pageChange, joinFail}) => {
    let history = useHistory();

    const loginSubmit = async (id, password) => {
        var response = await loginApi(id, password);
        if (typeof response.data.code != "undefined"){
            loginFail(response.data.message);
        } else {
            loginSuccess(id);
            alert("로그인에 성공하셨습니다. 게시판으로 이동합니다.");
            history.push("/board");
        }
    }
    
    // ( ... 후략 )
    
}

사용하는 방법은 보다시피 매우 간단하다. useHistory()를 이용해 객체를 생성한 뒤 push(${link})를 통해 라우팅이 가능하다. 라우터를 사용하면 스토어의 상태를 보존하기 때문에 다음처럼 계정 아이디가 올바르게 나오는 것을 확인할 수 있다.

모달 추가하기

게시글을 읽거나 수정, 삭제하는 작업은 모두 모달에서 이뤄지게 할 것이다.

Store

우선, "board" 스토어에 필요한 상태와 액션을 추가할 것이다.
모달을 열고 닫는 작업은 모두 액션을 수행함으로써 일어날 것이다. 정확히 말하면 모달을 열거나 닫으려는 시도를 하면 상태값인 isModalOpentrue <> false로 토글될 것이고, 이 boolean값을 이용해 모달을 보여주거나 숨길 것이다.

🔎 type.js

export default {
    CHANGE_PAGE: 'BOARD/CHANGE_PAGE',
    CLICK_ROW: 'BOARD/CLICK_ROW', // 게시글을 클릭한다.
    CLOSE_MODAL: 'BOARD/CLOSE_MODAL' // 모달을 닫는다.
}

🔎 action.js

//생성 함수 추가
export const clickRow = (rowData) => ({
    type: type.CLICK_ROW,
    payload: rowData
})

export const closeModal = () => ({
    type: type.CLOSE_MODAL,
})

🔎 reducer.js

const initialState = {
    pageNumber: 0,
    pageSize: BOARD_PAGE_SIZE,
    selectedData: [],
    isModalOpen: false, // 모달을 열거나 닫을 때 사용한다.
    modalData: {} // 모달을 열었을 때 보여줄 데이터
}

export default handleActions({
        [type.CHANGE_PAGE]: (state, action) => ({
            ...state,
            pageNumber: action.payload.pageNumber,
            pageSize: action.payload.pageSize,
            selectedData: action.payload.selectedData
        }),
        [type.CLICK_ROW]: (state, action) => ({ // 모달을 연다
            ...state,
            isModalOpen: true,
            modalData: action.payload,
        }),
        [type.CLOSE_MODAL]: (state, action) => ({ // 모달을 닫는다.
            ...state,
            isModalOpen: false,
            modalData: {},
        })
    }, initialState
)

BoardContainer

새로 추가된 스토어 내용에 맞춰서 BoardContainer도 변경해준다.

const mapStateToProps = state => ({
    pageNumber : state.board.pageNumber,
    pageSize : state.board.pageSize,
    selectedData : state.board.selectedData,
    isModalOpen: state.board.isModalOpen,
    modalData: state.board.modalData,
    accountId: state.main.accountId
})

const mapDispatchToProps = dispatch => ({
    changePage : (pageNumber, pageSize) => dispatch(changePage(pageNumber, pageSize)),
    clickRow: (rowData) => dispatch(clickRow(rowData)),
    closeModal: () => dispatch(closeModal())
})

컴포넌트를 리턴할 때 isModalOpen이 true일 경우에만 모달을 랜더링하도록 아래처럼 작성해준다. (아직 ContentsModal을 만들지 않아서 에러가 뜰 것이다. 바로 다음에 컴포넌트를 생성하니 걱정하지 말자 🙅🏻)

return (
        <div>
            <Board
                pageNumber={pageNumber}
                pageSize={pageSize}
                selectedData={selectedData}
                handleChangePageNumber={handleChangePageNumber}
                handleChangePageSize={handleChangePageSize}
                handleRowClick={clickRow}
                columns={columns}
                data={selectedData}
                accountId={accountId}
            />
            { isModalOpen ?
            <ContentsModal
                isModalOpen={isModalOpen}
                modalData={modalData}
                handleClose={closeModal}
            />
            : null}
        </div>
    );

clickRow 액션 생성 함수는 Board 컴포넌트에 handleRowClick={clickRow}로 전달되고 있다. 이름에서 유추할 수 있듯이 각 행을 클릭했을 때 액션이 수행되도록 할 것이다.

Board 컴포넌트 내 MaterialTable의 onRowClick props에 다음과 같이 전달해주자.

// ( ... 생략 )
export default function Board({pageNumber, pageSize, selectedData, columns, data, accountId, handleChangePageNumber, handleChangePageSize, handleRowClick}) {

    return (
        <MaterialTable
            onChangePage={handleChangePageNumber}
            onChangeRowsPerPage={handleChangePageSize}
            icons={tableIcons}
            title={"게시판 (login user : "+accountId+")"}
            columns={columns}
            data={selectedData}
            options={{
                paginationType: "stepped",
                pageSize: BOARD_PAGE_SIZE
            }}
            onRowClick={(event, rowData) => { // 추가된 부분!!!
                handleRowClick(rowData);
            }}
        />
    );
}

ContentsModal

components 하위에 ContentsModal.js파일을 생성한다. 모달도 Material-Ui를 사용할 것이다.

export default function ContentsModal({isModalOpen, modalData, handleClose}) {
    const classes = useStyles();
    // getModalStyle is not a pure function, we roll the style only on the first render
    const [modalStyle] = React.useState(getModalStyle);
    
    const body = (
        <div style={modalStyle} className={classes.paper}>
            <h2 id="simple-modal-title">Text in a modal</h2>
            <p id="simple-modal-description">
                Duis mollis, est non commodo luctus, nisi erat porttitor ligula.
            </p>
        </div>
    );

    return (
        <div>
            <Modal
                open={isModalOpen}
                onClose={handleClose}
                aria-labelledby="simple-modal-title"
                aria-describedby="simple-modal-description"
            >
                {body}
            </Modal>
        </div>
    );
}

모달 컴포넌트의 props 중 openonClose에 집중하자.
모달이 열리거나 닫히는 것은 전적으로 open의 "값"에 달려있다. boolean 타입을 인수로 받으며 나는 상태값인 isModalOpen을 사용하였다.

  • isModalOpen은 다음과 같이 움직인다.
    • (게시글 클릭 → ) rowClick 액션 발동 → true로 셋
    • (모달이 닫힘 → ) handleClose 액션 발동 → false로 셋

이 흐름을 잘 파악하고 있어야한다.
특히, isModalOpen이 false가 돼야 모달이 닫히는게 아니다.

  1. 모달을 닫으려는 작업이 감지되면
  2. handleClose 액션이 호출되고
  3. 마지막으로 isModalOpen이 false가 되면서 동시에 Modal 컴포넌트의 open props가 false가 된다.
  4. 결과적으로는 이 때 닫히는 것이다.

게시글을 클릭했을 때 아래 이미지처럼 모달이 뜨면 성공이다. esc 키를 누르거나 모달 밖을 클릭했을 때 모달이 닫기는지도 확인한다.

모달에 데이터 출력하기

다음은 진짜 데이터를 출력해 볼 것이다.

ContentsModal

material-ui의 컴포넌트를 몇가지 추가해주자.

import {Button, Modal, TextareaAutosize, TextField} from '@material-ui/core';

body는 다음처럼 구성할 것이다.
여기서 사용하는 modalData는 rowClick 액션으로 갱신된 상태값이다.

const body = (
        <div style={modalStyle} className={classes.paper}>
            <h2 id="simple-modal-title">{modalData.title}</h2>
            <p id="simple-modal-description">
                {modalData.contents}
                <br/><br/>
                <p>작성자 : {modalData.writer.accountId}</p>
                <p>작성일 : {modalData.createdAt}</p>
                <Button color="primary">Modify</Button>
                <Button onClick={() => {handleClose();}}>Close</Button>
            </p>
        </div>
    );

데이터가 잘 출력되는 것을 확인 할 수 있다. CLOSE 버튼을 누르면 모달이 닫히는지 확인하자.
위에서 모달이 닫히는 것과 상태값의 상관관계에 대해서 설명했었는데, <Button onClick={() => {handleClose();}}>Close</Button>에서 클릭시에 handleClose라는 액션을 호출하는 것도 그 때문이다.

수정 버튼 활성화하기

한 모달에서 사용자에게 보여줄 컴포넌트만 변경할 수 있도록 isModify라는 ContentsModal의 자체 상태값을 정의할 것이다.
이 값은 다음처럼 사용된다.
예상 할 수 있듯이 MODIFY 버튼을 클릭하면 setIsModify(true)를 호출하고, 다른 버튼을 클릭하면 setIsModify(false)를 호출할 것이다. (새로 선택된 게시글은 read 부터 보여줘야하기 때문에)

전체 코드는 아래와 같다.

export default function ContentsModal({isModalOpen, modalData, handleClose}) {
    const classes = useStyles();
    // getModalStyle is not a pure function, we roll the style only on the first render
    const [modalStyle] = React.useState(getModalStyle);
    const [isModify, setIsModify] = useState(false);

    const read = (
        <div style={modalStyle} className={classes.paper}>
            <h2 id="simple-modal-title">{modalData.title}</h2>
            <p id="simple-modal-description">
                {modalData.contents}
                <br/><br/>
                <p>작성자 : {modalData.writer.accountId}</p>
                <p>작성일 : {modalData.createdAt}</p>
                <Button color="primary" onClick={() => setIsModify(true)}>Modify</Button>
                <Button onClick={() => {
                    setIsModify(false);
                    handleClose();
                }}>Close</Button>
            </p>
        </div>
    );

    const modify = (
        <div style={modalStyle} className={classes.paper}>
            <form className={classes.form} noValidate onSubmit={(event) => {
                event.preventDefault();
            }}>
                <TextField
                    id="outlined-full-width"
                    label="제목"
                    fullWidth
                    InputLabelProps={{
                        shrink: true,
                    }}
                    variant="outlined"
                    defaultValue={modalData.title}
                    margin="normal"
                    required
                />
                <TextField
                    id="outlined-multiline-static"
                    label="내용"
                    multiline
                    rows={4}
                    defaultValue={modalData.contents}
                    variant="outlined"
                    fullWidth
                    margin="normal"
                    required
                />
                <br/><br/>
                <p>작성자 : {modalData.writer.accountId}</p>
                <p>작성일 : {modalData.createdAt}</p>
                <Button type="submit" color="primary" className={classes.submit}>Save</Button>
                <Button onClick={() => setIsModify(false)}>Back</Button>
                <Button onClick={() => {
                    setIsModify(false);
                    handleClose();
                }}>Close</Button>
            </form>
        </div>
    )


    return (
        <div>
            <Modal
                open={isModalOpen}
                onClose={handleClose}
                aria-labelledby="simple-modal-title"
                aria-describedby="simple-modal-description"
            >
                {isModify ? modify : read}
            </Modal>
        </div>
    );
}
게시글 클릭 시MODIFY 버튼 클릭 시

게시글 수정하기

수정을 위한 비즈니스 로직 작성하기

게시글 수정의 로직은 다음처럼 흘러갈 것이다.

  1. 모달(ContentsModal 컴포넌트)에서 SAVE 버튼을 클릭한다.
  2. 상태값으로 유지되고 있는 값들(e.g. titie, contents ..)을 모아 오브젝트로 만든다.
  3. 상위 컴포넌트(BoardContainer 컴포넌트)에 2를 전달한다.
  4. 액션을 호출해 서버에 PUT 요청을 한다.
  5. 요청이 성공하면 변경된 부분을 상태값(selectedData)에 반영한다.

ContentsModal

Main 컴포넌트에서도 사용했지만 material-ui에서 제공하는 컴포넌트는 댑스가 깊어 value를 직접 가져오는 것이 불가능하다.
따라서 onChange prop에 setState 함수를 전달해 데이터가 변경될 때마다 상태값을 갱신해주도록 할 것이다.

현재는 사용하는 데이터가 title과 contents 뿐이므로 (작성자명과 작성날짜는 변경불가하며, 수정날짜는 서버에서 자동으로 갱신한다.) 두 값에 대한 상태를 정의해준다.
초기화 값은 현재 띄워진 모달의 값. 즉, 변경되기 전의 원래데이터이다.
modify 객체의 TextField에 onChange를 이용하여 상태들을 각각 갱신시켜주자.

그리고 폼이 전송됐을 때 실행할 로직을 익명함수를 이용하여 작성해 주었다.

  1. form 고유의 submit 이벤트를 중단한다.
  2. 상태값들을 모아서 오브젝트로 만든다. 여기서 사용되는 title과 contents는 위에서 정의한 ContentsModal 고유의 상태값이다.
  3. 상위 컴포넌트에서 가져온 함수에 2를 넘긴다.
    handleModify는 추후에 BoardContainer에서 정의할 함수이다.

전체 코드는 아래와 같다.

import React, {useState} from 'react';
import {makeStyles} from '@material-ui/core/styles';
import {Button, Modal, TextField, Typography} from '@material-ui/core';

function rand() {
    return Math.round(Math.random() * 20) - 10;
}

function getModalStyle() {
    const top = 50 + rand();
    const left = 50 + rand();

    return {
        top: `${top}%`,
        left: `${left}%`,
        transform: `translate(-${top}%, -${left}%)`,
    };
}

const useStyles = makeStyles((theme) => ({
    paper: {
        position: 'absolute',
        width: 400,
        backgroundColor: theme.palette.background.paper,
        border: '2px solid #000',
        boxShadow: theme.shadows[5],
        padding: theme.spacing(2, 4, 3),
    },
}));


export default function ContentsModal({isModalOpen, modalData, handleClose, handleModify}) {
    const classes = useStyles();
    // getModalStyle is not a pure function, we roll the style only on the first render
    const [modalStyle] = React.useState(getModalStyle);

    const [isModify, setIsModify] = useState(false);
    const [title, setTitle] = useState(modalData.title);
    const [contents, setContents] = useState(modalData.contents);

    const combineData = () => {
        return {
            title: title,
            contents: contents
        }
    }

    const read = (
        <div style={modalStyle} className={classes.paper}>
            <h2 id="simple-modal-title">{modalData.title}</h2>
            <Typography variant="body1" gutterBottom>{modalData.contents}</Typography>
            <hr/>
            <Typography variant="body2" gutterBottom>작성자 : {modalData.writer.accountId}</Typography>
            <Typography variant="body2" gutterBottom>작성일 : {modalData.createdAt}</Typography>
            <Button color="primary" onClick={() => setIsModify(true)}>Modify</Button>
            <Button onClick={() => {
                setIsModify(false);
                handleClose();
            }}>Close</Button>
        </div>
    );

    const modify = (
        <div style={modalStyle} className={classes.paper}>
            <form className={classes.form} noValidate onSubmit={(event) => {
                event.preventDefault();
                var updatedData = combineData();
                handleModify(updatedData);
            }}>
                <TextField
                    id="outlined-full-width"
                    label="제목"
                    fullWidth
                    InputLabelProps={{
                        shrink: true,
                    }}
                    variant="outlined"
                    defaultValue={modalData.title}
                    margin="normal"
                    onChange={event => setTitle(event.target.value)}
                    required
                />
                <TextField
                    id="outlined-multiline-static"
                    label="내용"
                    multiline
                    rows={4}
                    defaultValue={modalData.contents}
                    variant="outlined"
                    fullWidth
                    margin="normal"
                    onChange={event => setContents(event.target.value)}
                    required
                />
                <hr/>
                <Typography variant="body2" gutterBottom>작성자 : {modalData.writer.accountId}</Typography>
                <Typography variant="body2" gutterBottom>작성일 : {modalData.createdAt}</Typography>
                <Button type="submit" color="primary" className={classes.submit}>Save</Button>
                <Button onClick={() => setIsModify(false)}>Back</Button>
                <Button onClick={() => {
                    setIsModify(false);
                    handleClose();
                }}>Close</Button>
            </form>
        </div>
    )


    return (
        <div>
            <Modal
                open={isModalOpen}
                onClose={handleClose}
                aria-labelledby="simple-modal-title"
                aria-describedby="simple-modal-description"
            >
                {isModify ? modify : read}
            </Modal>
        </div>
    );
}

read와 modify를 보면 <p> 태그 대신 Typography라는 컴포넌트를 사용하고 있는데, 계속 <p> 태그를 중첩해서 쓸 수 없다고 오류가 나길래 바꿔줬다 😑 실행하면 UI는 동일하다!

Store

handleModify를 정의해 주기전에 스토어에 필요한 것들을 추가할 것이다. 서버단의 데이터 업데이트가 성공 했을 때 액션 생성함수를 호출하고 상태값을 갱신시키는 작업이 필요하다.

아래 코드들은 추가된 내용만 있으니 이전 코드에 이어 붙이면 된다.

🔎 type.js

MODIFY_DATA: 'BOARD/MODIFY_DATA'

🔎 action

import {get, update} from "../../api/boardApi";

// ( .. 생략 )

export const modifyData = (id, updatedData, allData) => dispatch => {
    return update(id, updatedData)
        .then(response => {
            allData.forEach(function (element) {
                if(element.id == id){
                    for (var key in updatedData){
                        element[key] = updatedData[key];
                    }
                    return;
                }
            })

            dispatch({
                type: type.MODIFY_DATA,
                payload: allData,
            })
        }).catch(error => {
            /* error control */
        })
}

🔎 BoardApi

export function update(id, updatedData) {
    return axios.put(DOMAIN+'/api/boards/'+id, updatedData);
}

액션 생성 미들웨어(modifyData)에서 받는 값들은 다음과 같다.

  • id : 수정된 데이터 고유 아이디
  • updatedData : 사용자가 새로 작성한 값
  • allData : selectedData

서버에 데이터 갱신 요청이 성공하면 변경된 값을 클라이언트에서 바로 반영할 수 있도록 현재 노출되는 데이터인 selectedData를 수정할 것이다.
forEach로 전체를 순회하며 변경된 데이터를 발견하면 변경된 값(updatedData)로 갱신해준다. 아직까지는 스토어에 보관된 selectedData는 갱신되지 않았으며 최종적으로 dispatch된 액션 생성 객체에 이 값을 payload로써 전달한다.

🔎 reducer.js

[type.MODIFY_DATA]: (state, action) => ({
    ...state,
    selectedData: action.payload,
})

리듀서에서는 payload(위에서 allData)를 selectedData에 전달하면서 값이 갱신된다. → 리다이렉트없는 재랜더링을 통해서 클라이언트에서도 변경된 데이터를 바로 확인할 수 있다.

BoardContainer

ContentsModal에 전달할 handleModify를 정의해주자.
ContentsModal은 오브젝트 형태의 updatedData를 전달할 것이고, handleModify에서는 액션 생성 미들웨어에 적절한 값들을 인수로 전달해주기만 하면된다.
(여기서 modifyData와 closeModal은 액션 생성 함수이고, modalData와 selectedData는 스토어의 state이다.)

주의할 점은 selectedData를 전달하는게 아닌 복사본인 selectedData.slice(0)를 전달하는 것이다. selectedData를 전달하면 값이 아닌 레퍼런스가 전달되므로 modifyData의 then(..) 내부의 forEach()에서 데이터를 갱신할 때 상태가 실시간으로 바뀌어버린다. 즉 dispatch된 액션 생성 객체를 전달하기도 전에 selectedData가 갱신되는 것이다.

전체 코드는 다음과 같다

import React, {useEffect, useState} from "react";
import {connect} from 'react-redux';
import {changePage, clickRow, closeModal, modifyData} from "../store/modules/board/action";
import Board from "../components/Board";
import ContentsModal from "../components/ContentsModal";
import main from "../store/modules/main/reducer";

const BoardContainer = ({pageNumber, pageSize, selectedData, isModalOpen, modalData, accountId, changePage, clickRow, closeModal, modifyData}) => {

    useEffect(() => {
        changePage(pageNumber, pageSize);
    }, [])

    const columns = [
        {title: '제목', field: 'title'},
        {title: '작성자', field: 'writer.accountId'},
        {title: '작성일시', field: 'createdAt'},
    ]

    const handleChangePageNumber = (pageNumber) => {
        changePage(pageNumber, pageSize);
    };

    const handleChangePageSize = (pageSize) => {
        changePage(pageNumber, pageSize);
    };

    const handleModify = (updatedData) => {
        modifyData(modalData.id, updatedData, selectedData.slice(0));
        closeModal();
    }

    return (
        <div>
            <Board
                pageNumber={pageNumber}
                pageSize={pageSize}
                selectedData={selectedData}
                handleChangePageNumber={handleChangePageNumber}
                handleChangePageSize={handleChangePageSize}
                handleRowClick={clickRow}
                columns={columns}
                data={selectedData}
                accountId={accountId}
            />
            { isModalOpen ?
            <ContentsModal
                isModalOpen={isModalOpen}
                modalData={modalData}
                handleClose={closeModal}
                handleModify={handleModify}
            />
            : null}
        </div>
    );
}

const mapStateToProps = state => ({
    pageNumber : state.board.pageNumber,
    pageSize : state.board.pageSize,
    selectedData : state.board.selectedData,
    isModalOpen: state.board.isModalOpen,
    modalData: state.board.modalData,
    accountId: state.main.accountId
})

const mapDispatchToProps = dispatch => ({
    changePage : (pageNumber, pageSize) => dispatch(changePage(pageNumber, pageSize)),
    clickRow: (rowData) => dispatch(clickRow(rowData)),
    closeModal: () => dispatch(closeModal()),
    modifyData: (id, updatedData, allData) => dispatch(modifyData(id, updatedData, allData)),
})

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(BoardContainer)

새로고침없이 데이터가 잘 갱신되는지 확인하자.
위와 같은 상태에서 SAVE를 클릭하면 모달이 닫히고 제목이 변경되어야 할 것이다.

내가 쓴 글만 수정 버튼 나타나게 하기

서버쪽에서 자격이 없으면 수정이 안되게 막고 있긴 하나, 클라이언트에서도 실수가 없도록 막아줄 것이다.
어려운건 아니고, accountId를 비교해 같을 때만 수정버튼이 나오게 한 줄 추가하는 것이 끝이다. 🤔

BoardContainer

<ContentsModal
      userLoggedIn={accountId} // 추가 된 부분
      isModalOpen={isModalOpen}
      modalData={modalData}
      handleClose={closeModal}
      handleModify={handleModify}
/>

이미 Board 컴포넌트로 보내기 위해 accountId를 받고 있었으니 ContentsModal에도 userLoggedIn 이란 props로 보내준다.

ContentsModal

const read = (
        <div style={modalStyle} className={classes.paper}>
            <h2 id="simple-modal-title">{modalData.title}</h2>
            <Typography variant="body1" gutterBottom>{modalData.contents}</Typography>
            <hr/>
            <Typography variant="body2" gutterBottom>작성자 : {modalData.writer.accountId}</Typography>
            <Typography variant="body2" gutterBottom>작성일 : {modalData.createdAt}</Typography>
            // 추가된 부분
            {typeof userLoggedIn != 'undefined' && userLoggedIn == modalData.writer.accountId ?
                <Button color="primary" onClick={() => setIsModify(true)}>Modify</Button>
            : null}
            <Button onClick={() => {
                setIsModify(false);
                handleClose();
            }}>Close</Button>
        </div>
    );

사용자가 로그인한 상태이고, 글 작성자와 동일할 때만 Modify 버튼이 나타나도록 변경한다.

실행해보자! 🙋🏻

내가 쓴 글내가 쓰지 않은 글

모든 코드는 github에서 확인할 수 있습니다.

profile
🌱 😈💻 🌱

0개의 댓글