1 Client - 기본기능 구현

source: categories/study/react_restapi_graphql/react_restapi_graphql2.md

1.1 (공지) 폰트 이슈

1.2 환경 세팅


npm install -g yarn
yarn init -y

위와 같이 yarn을 설치하고 yarn init -y를 실행하면 아래 package.json 파일이 생성됩니다.


{
  "name": "react-study2",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

package.json에 다음과 같은 내용을 추가해줍니다.


{
  "name": "react-study2",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "client",
    "server"
  ]
}

privatetrue로 주는 이유는 workspaces를 이용하려면 privatetrue로 줘야된다는 설정이 있기 때문입니다.
그리고 workspaceclientserver라는 워크스페이스를 만들도록 하겠습니다.
그리고 이 워크스페이스를 각각 실행하는 명령어로 scripts를 지정해주겠습니다.


{
  "name": "react-study2",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "client",
    "server"
  ],
  "scripts": {
    "client": "yarn workspace client start",
    "server": "yarn workspace server start"
  }
}

위와 같이 작성해주시면 되겠습니다.
그럼 위의 워크스페이스에 지정해준 명칭 그대로(client, server) 폴더를 만들겠습니다.


root_folder/
|-- client/
|-- server/
|-- package.json

server 폴더는 나중에보기로하고 지금은 client 폴더에서 프론트엔드 진영에서 컴포넌트들을 다 생성을 하고 프론트엔드 안에서만 동작을하는 목(mock) 데이트를 활용해서 동작하는 것까지 구현을 해보도록 하겠습니다.
터미널에서 client 폴더로 이동하겠습니다.
그리고 다시 yarn init -y를 해줍니다.


cd client
yarn init -y


root_folder/
|-- client/
|   `-- package.json
|-- server/
|-- package.json

그럼 client 폴더 안에 package.json 파일이 생성된걸 확인할 수 있습니다.
이 상태에서 패키지들을 설치하겠습니다.


yarn add react react-dom next node-sass @zeit/next-sass axios

@zeit/next-sass: next.js 라이브러리에서 sass를 쓰기위한 라이브러리이다.


{
  "name": "client",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "@zeit/next-sass": "^1.0.1",
    "axios": "^0.21.3",
    "next": "^11.1.2",
    "node-sass": "^6.0.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  }
}

dependencies는 이정도이고 이번엔 devDependencies를 설치해보겠습니다.


yarn add --dev webpack@4

next.js에서 webpack 5버전은 문제가 좀 있다라는 메시지를 몇번 봤기 때문에 4버전을 설치합니다.


{
  "name": "client",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "@zeit/next-sass": "^1.0.1",
    "axios": "^0.21.3",
    "next": "^11.1.2",
    "node-sass": "^6.0.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "devDependencies": {
    "webpack": "4"
  }
}

지금부터 본격적인 컴포넌트 생성을 해보도록 하겠습니다.
next.js에서 기반이 되는 폴더가 pages 폴더입니다.
pages 폴더는 반드시 있어야됩니다.


root_folder/
|-- client/
|   |-- pages/
|   `-- package.json
|-- server/
|-- package.json

그리고 pages 폴더 안에 index.js 파일이 반드시 있어야됩니다.


root_folder/
|-- client/
|   |-- pages/
|       `-- index.js
|   `-- package.json
|-- server/
|-- package.json

그리고 index.js 파일에 다음과 같이 Home 컴포넌트를 작성해줍니다.


// client/pages/index.js
const Home = () => (
    <>
        <h1>SIMPLE SNS</h1>
    </>
)

export default Home;

그리고 client/package.jsonscripts에 명령어를 추가하도록 하겠습니다.


{
  "name": "client",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "@zeit/next-sass": "^1.0.1",
    "axios": "^0.21.3",
    "next": "^11.1.2",
    "node-sass": "^6.0.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "devDependencies": {
    "webpack": "4"
  },
  "scripts": {
    "start": "next"
  }
}

다시 루트 폴더로 이동해서 yarn run client 명령어를 실행합니다.


cd ../
yarn run client

yarn run v1.22.10
$ yarn workspace client start
$ next
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info  - Using webpack 5. Reason: Enabled by default https://nextjs.org/docs/messages/webpack5
event - compiled successfully
Attention: Next.js now collects completely anonymous telemetry regarding usage.
This information is used to shape Next.js' roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://nextjs.org/telemetry

위와 같이 3000번 포트에서 화면을 확인할 수 있다는 메시지가 나옵니다.
화면을 확인해보시면

위와 같이 아까 만든 페이지가 뜨는 것을 확인하실 수 있습니다.

1.3 (추가) Next.js 11 대응

Next.js 버전이 11로 업데이트됨에 다라 환경세팅에 약간의 변동사항이 있습니다.
하단 내용을 참고해주세요.
바뀐 내용은 다음과 같습니다.

1.3.1 npm package 설치


// == before ==
yarn add react react-dom next node-sass @zeit/next-sass axios
yarn add --dev webpack@4

// == after == 
yarn add react react-dom next sass axios
yarn add --dev webpack

1.3.2 next.config.js


// == before ==
const withSass = require('@zeit/next-sass')
module.exports = withSass()

// == after ==
const path = require('path')

module.exports = {
  sassOptions: {
    includePaths: [path.resolve(__dirname, './pages')],
  },
}


root_folder/
|-- client/
|   |-- pages/
|       `-- index.js
|   `-- next.config.js
|   `-- package.json
|-- server/
|-- package.json

1.3.3 client/package.json


{
  "name": "client",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "axios": "^0.21.3",
    "next": "^11.1.2",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "sass": "^1.39.0"
  },
  "devDependencies": {
    "webpack": "^5.52.0"
  },
  "scripts": {
    "start": "next"
  }
}

위와 같이 수정된 상태에서 아래와 같이 실행하면 동일하게 실행될 것이다.


cd ..
yarn run client

yarn run v1.22.10
$ yarn workspace client start
$ next
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info  - Using webpack 5. Reason: Enabled by default https://nextjs.org/docs/messages/webpack5
event - compiled successfully

1.4 목록뷰 구현하기

본격적으로 메시지 리스트를 보여주는 클라이언트 컴포넌트를 만들어보겠습니다.
components라는 폴더를 만듭니다.


root_folder/
|-- client/
|   |-- components/
|       `-- MsgList.js
|   |-- pages/
|       `-- index.js
|   `-- next.config.js
|   `-- package.json
|-- server/
|-- package.json


// client/components/MsgList.js
const MsgList = () => (
    <ul className='messages'>

    </ul>
)

export default MsgList;

이 상태에서 MsgItem.js 파일을 만들어봅시다.


root_folder/
|-- client/
|   |-- components/
|       `-- MsgItem.js
|       `-- MsgList.js
|   |-- pages/
|       `-- index.js
|   `-- next.config.js
|   `-- package.json
|-- server/
|-- package.json


// client/components/MsgItem.js
const MsgItem = ({
    userId,
    timestamp,
    text
                 }) => (
    <li className='messages__item'>
        <h3>
            {userId}{' '}
            <sub>
                {new Date(timestamp).toLocaleTimeString('ko-KR', {
                    year: 'numeric',
                    month: 'numeric',
                    day: 'numeric',
                    hour: '2-digit',
                    minute: '2-digit',
                    hour12: true // 오전 오후 시간이 보여지도록 하는 설정
                })}
            </sub>
        </h3>

        {text}
    </li>
)

export default MsgItem

그리고 다시 MsgList.js를 아래와 같이 수정해줍니다.


// client/components/MsgList.js
import MsgItem from "./MsgItem";

const UserIds = ['roy', 'jay'];
const getRandomUserId = () => UserIds[Math.round(Math.random())];

// 아래와 같이 배열 length가 50인 빈 배열을 만들어줍니다.
// 그리고 fill 메소드로 0값을 채워줍니다.
// fill 메소드로 값을 채워주기 전까진 map 메소드를 돌릴 수 없습니다.
const msgs = Array(50).fill(0).map((_, i) => ({
    id: i + 1,
    userId: getRandomUserId(),
    timestamp: 1234567890123 + i * 1000 * 60, // 1분마다 하나씩
    text: `${i + 1} mock text`
}))


// [
//     {
//         id: 1,
//         userId: getRandomUserId(),
//         timestamp: 1234567890123,
//         text: "1 mock text"
//     }
// ]

const MsgList = () => (
    <ul className='messages'>
        {
            msgs.map(x => <MsgItem key={x.id} {...x} />)
        }
    </ul>
)

export default MsgList;


// client/pages/index.js
import MsgList from "../components/MsgList";

const Home = () => (
    <>
        <h1>SIMPLE SNS</h1>
        <MsgList />
    </>
)

export default Home;


yarn run client

yarn run v1.22.10
$ yarn workspace client start
$ next
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info  - Using webpack 5. Reason: Enabled by default https://nextjs.org/docs/messages/webpack5
event - compiled successfully
event - build page: /next/dist/pages/_error
wait  - compiling...
event - compiled successfully
event - build page: /
wait  - compiling...
event - compiled successfully

위와 같이 1분 단위로 목록이 나오는 것을 볼 수 있습니다.
랜덤하게 어떨 땐 roy였다가 어떨 땐 jay였다가..
시간도 1분 단위로 계속 증가하는 것이 보이죠?
채팅목록은 제일 최신 메시지가 제일 위로 와야되는데 순서가 반대로되었네요.
순서를 수정해봅시다.


// client/components/MsgList.js
import MsgItem from "./MsgItem";

const UserIds = ['roy', 'jay'];
const getRandomUserId = () => UserIds[Math.round(Math.random())];

// 아래와 같이 배열 length가 50인 빈 배열을 만들어줍니다.
// 그리고 fill 메소드로 0값을 채워줍니다.
// fill 메소드로 값을 채워주기 전까진 map 메소드를 돌릴 수 없습니다.
const msgs = Array(50).fill(0).map((_, i) => ({
    id: i + 1,
    userId: getRandomUserId(),
    timestamp: 1234567890123 + i * 1000 * 60, // 1분마다 하나씩
    text: `${i + 1} mock text`
})).reverse();


// [
//     {
//         id: 1,
//         userId: getRandomUserId(),
//         timestamp: 1234567890123,
//         text: "1 mock text"
//     }
// ]

const MsgList = () => (
    <ul className='messages'>
        {
            msgs.map(x => <MsgItem key={x.id} {...x} />)
        }
    </ul>
)

export default MsgList;

아니면 아래와 같이 수정해줘도됩니다.


// client/components/MsgList.js
import MsgItem from "./MsgItem";

const UserIds = ['roy', 'jay'];
const getRandomUserId = () => UserIds[Math.round(Math.random())];

// 아래와 같이 배열 length가 50인 빈 배열을 만들어줍니다.
// 그리고 fill 메소드로 0값을 채워줍니다.
// fill 메소드로 값을 채워주기 전까진 map 메소드를 돌릴 수 없습니다.
const msgs = Array(50).fill(0).map((_, i) => ({
    id: 50 - i,
    userId: getRandomUserId(),
    timestamp: 1234567890123 + (50 - i) * 1000 * 60, // 1분마다 하나씩
    text: `${50 - i} mock text`
}))


// [
//     {
//         id: 1,
//         userId: getRandomUserId(),
//         timestamp: 1234567890123,
//         text: "1 mock text"
//     }
// ]

const MsgList = () => (
    <ul className='messages'>
        {
            msgs.map(x => <MsgItem key={x.id} {...x} />)
        }
    </ul>
)

export default MsgList;

1.5 스타일 작성


root_folder/
|-- client/
|   |-- components/
|       `-- MsgItem.js
|       `-- MsgList.js
|   |-- pages/
|       `-- index.js
|       `-- index.scss
|   `-- next.config.js
|   `-- package.json
|-- server/
|-- package.json


/* client/pages/index.scss */
body {
  margin: 0;
}
#__next {
  margin: 10px;
}
h1 {
  text-align: center;
  margin: 20px 0;
}
.messages {
  list-style: none;
  padding: 0;
  margin: 0;
  &__input {
    display: flex;
    width: 80%;
    max-width: 700px;
    min-width: 400px;
    margin: 0 auto 10px;
    > * {
      box-sizing: border-box;
    }
    textarea {
      padding: 10px;
      flex-grow: 1;
    }
    button {
      margin-left: 5px;
      width: 60px;
    }
  }
  &__item {
    position: relative;
    margin: 10px 0;
    padding: 15px 15px 5px;
    border-radius: 5px;
    border: solid 1px #aaa;
    h3 {
      font-size: 0.85em;
      margin: 0 0 10px;
    }
    sub {
      font-weight: normal;
      margin-left: 5px;
      vertical-align: baseline;
    }
    p {
      margin: 10px 0;
    }
    &:hover .messages__buttons {
      display: block;
    }
    .messages__input {
      width: 100%;
    }
  }
  &__buttons {
    display: none;
    position: absolute;
    text-align: right;
    right: 10px;
    bottom: 10px;
  }
}

1.6 새글쓰기 기능 구현 (Create)


root_folder/
|-- client/
|   |-- components/
|       `-- MsgItem.js
|       `-- MsgList.js
|   |-- pages/
|       `-- _app.js
|       `-- index.js
|       `-- index.scss
|   `-- next.config.js
|   `-- package.json
|-- server/
|-- package.json


// client/pages/_app.js
import './index.scss'

// next가 서버사이드 렌더링을 하기위해 필요한 컴포넌트입니다.
// 그래서 아래와 같은 기본 공식이 있습니다.
// 기본 공식 그대로 코드를 작성하시면됩니다.
const App = ({ Component, pageProps }) => <Component {...pageProps} />

// ctx는 context의 줄임말입니다.
App.getInitialProps = async ({ctx, Component}) => {
    // 각 컴포넌트별로 getInitialProps가 정의되어있을 때, ctx를 넘겨서
    const pageProps = await Component.getInitialProps?.(ctx)
    // 그거에대한 pageProps를 return합니다.
    // 이 pageProps를 가지고 각각의 컴포넌트를 구성하는 형태인 것 같습니다.
    return { pageProps }
}

export default App


yarn run client


// client/next.config.js
const path = require('path')

module.exports = {
    sassOptions: {
        includePaths: [path.resolve(__dirname, './pages')],
    },
}


화면에 보이는 것까지 성공을 했는데, 이제는 메시지를 직접 입력하고 입력한 메시지가 화면에 출력되는걸 구현해볼겁니다.


root_folder/
|-- client/
|   |-- components/
|       `-- MsgInput.js
|       `-- MsgItem.js
|       `-- MsgList.js
|   |-- pages/
|       `-- _app.js
|       `-- index.js
|       `-- index.scss
|   `-- next.config.js
|   `-- package.json
|-- server/
|-- package.json

MsgInput.js 컴포넌트는 MsgItem.jsMsgList.js에서 모두 사용할 것입니다.

  • MsgList.js에선 Create 용도로 사용할 것이고
  • MsgItem.js에선 Update 용도로 사용할 것이다.

// client/components/MsgInput.js
import {useRef} from "react";

// MsgInput 컴포넌트는 MsgList, MsgItem 컴포넌트에서 사용할 것입니다.
// MsgList에선 Create의 목적으로
// MsgItem에선 Update의 목적으로 사용할 것입니다.
// 때문에 목적이 다르기 때문에 목적이 다른 메소드를 하나로 일괄해서 쓰기위해서 메소드 이름을 mutate라고 정의해보겠습니다.
const MsgInput = (mutate) => {
    // react에서 form 데이터를 다루는 방식이 여러가지가 있겠지만,
    // 많은 분들이 onChange 메소드 또는 onInput 메소드들을 쓰시는 걸로 알고 있습니다.
    // 그런데 저는 이런 방법 말고 reference 방법을 이용하는 것을 선호합니다.
    // 이것은 개인적인 선호도이기 때문에 꼭 이렇게하는 것이 좋다라는 것은 아닙니다.
    // 이 방법이 마음에 안드시는 분들은 onChange 또는 onInput 등 으로 하셔도 될거같습니다.
    const textRef = useRef(null); // 초기값이 없으면 안됩니다. null값이라도 설정해야됩니다.
    const onSubmit = e => {
        e.preventDefault();
        e.stopPropagation();
        const text = textRef.current.value; // text 변수에 textRef.current.value 값을 담습니다.
        textRef.current.value = ''; // 그리고 바로 textRef의 값을 지워줍니다.

        mutate(text); // 상위에서 mutate라는 메소드를 받아서 호출할 때 text를 인자값으로 넘겨줍니다. // Create용 메소드가 내려올 수도 있고, Update용 메소드가 내려올수도 있고~
    }

    return (
        <form className='messages__input' onSubmit={onSubmit}>
            <textarea
                ref={textRef}
                placeholder="내용을 입력하세요."
            />
            <button type='submit'>완료</button>
        </form>
    )
}

export default MsgInput


// client/components/MsgList.js
import MsgItem from "./MsgItem";
import MsgInput from "./MsgInput";

const UserIds = ['roy', 'jay'];
const getRandomUserId = () => UserIds[Math.round(Math.random())];

// 아래와 같이 배열 length가 50인 빈 배열을 만들어줍니다.
// 그리고 fill 메소드로 0값을 채워줍니다.
// fill 메소드로 값을 채워주기 전까진 map 메소드를 돌릴 수 없습니다.
const msgs = Array(50).fill(0).map((_, i) => ({
    id: 50 - i,
    userId: getRandomUserId(),
    timestamp: 1234567890123 + (50 - i) * 1000 * 60, // 1분마다 하나씩
    text: `${50 - i} mock text`
}))


// [
//     {
//         id: 1,
//         userId: getRandomUserId(),
//         timestamp: 1234567890123,
//         text: "1 mock text"
//     }
// ]

const MsgList = () => {
    const onCreate = text => {
        const newMsg = {
            id: msgs.length,
            userId: getRandomUserId(),
            timestamp: Date.now(),
            text: `${msgs.length} ${text}`,
        }
        msgs.unshift(newMsg);
    }

    return (
        <>
            <MsgInput mutate={onCreate}/>
            <ul className='messages'>
                {
                    msgs.map(x => <MsgItem key={x.id} {...x} />)
                }
            </ul>
        </>
    )
}

export default MsgList;

아래와 같이 props 형태로 받아야됩니다.


// client/components/MsgInput.js
import {useRef} from "react";

// MsgInput 컴포넌트는 MsgList, MsgItem 컴포넌트에서 사용할 것입니다.
// MsgList에선 Create의 목적으로
// MsgItem에선 Update의 목적으로 사용할 것입니다.
// 때문에 목적이 다르기 때문에 목적이 다른 메소드를 하나로 일괄해서 쓰기위해서 메소드 이름을 mutate라고 정의해보겠습니다.
const MsgInput = ({mutate}) => {
    // react에서 form 데이터를 다루는 방식이 여러가지가 있겠지만,
    // 많은 분들이 onChange 메소드 또는 onInput 메소드들을 쓰시는 걸로 알고 있습니다.
    // 그런데 저는 이런 방법 말고 reference 방법을 이용하는 것을 선호합니다.
    // 이것은 개인적인 선호도이기 때문에 꼭 이렇게하는 것이 좋다라는 것은 아닙니다.
    // 이 방법이 마음에 안드시는 분들은 onChange 또는 onInput 등 으로 하셔도 될거같습니다.
    const textRef = useRef(null); // 초기값이 없으면 안됩니다. null값이라도 설정해야됩니다.
    const onSubmit = e => {
        e.preventDefault();
        e.stopPropagation();
        const text = textRef.current.value; // text 변수에 textRef.current.value 값을 담습니다.
        textRef.current.value = ''; // 그리고 바로 textRef의 값을 지워줍니다.

        mutate(text); // 상위에서 mutate라는 메소드를 받아서 호출할 때 text를 인자값으로 넘겨줍니다. // Create용 메소드가 내려올 수도 있고, Update용 메소드가 내려올수도 있고~
    }

    return (
        <form className='messages__input' onSubmit={onSubmit}>
            <textarea
                ref={textRef}
                placeholder="내용을 입력하세요."
            />
            <button type='submit'>완료</button>
        </form>
    )
}

export default MsgInput

다시 인풋창에 텍스트를 입력하고 완료 버튼을 눌렀는데, 추가가 되어야할 거 같은데, 추가가 안됐습니다.


// client/components/MsgList.js
import MsgItem from "./MsgItem";
import MsgInput from "./MsgInput";

const UserIds = ['roy', 'jay'];
const getRandomUserId = () => UserIds[Math.round(Math.random())];

// 아래와 같이 배열 length가 50인 빈 배열을 만들어줍니다.
// 그리고 fill 메소드로 0값을 채워줍니다.
// fill 메소드로 값을 채워주기 전까진 map 메소드를 돌릴 수 없습니다.
const msgs = Array(50).fill(0).map((_, i) => ({
    id: 50 - i,
    userId: getRandomUserId(),
    timestamp: 1234567890123 + (50 - i) * 1000 * 60, // 1분마다 하나씩
    text: `${50 - i} mock text`
}))


// [
//     {
//         id: 1,
//         userId: getRandomUserId(),
//         timestamp: 1234567890123,
//         text: "1 mock text"
//     }
// ]

const MsgList = () => {
    const onCreate = text => {
        const newMsg = {
            id: msgs.length,
            userId: getRandomUserId(),
            timestamp: Date.now(),
            text: `${msgs.length} ${text}`,
        }
        msgs.unshift(newMsg);
        console.log(msgs);
    }

    return (
        <>
            <MsgInput mutate={onCreate}/>
            <ul className='messages'>
                {
                    msgs.map(x => <MsgItem key={x.id} {...x} />)
                }
            </ul>
        </>
    )
}

export default MsgList;

실제로 msgs라는 배열에 newMsg가 추가는 되었을 겁니다.
위와 같이 console.log로 찍어보겠습니다.
찍어보면,

위와 같이 51개가 되었거든요?
id값이 50으로 들어갔네요. (이건 이따 수정)
여튼 방금 추가된 text가 추가된 것을 볼 수 있습니다.

이렇게 추가되었는데 화면상에는 렌더링이 안되는겁니다.

그 이유는 컴포넌트 입장에서는 msgs가 변경되었다는 것을 감지하지 못한겁니다.
그래서 변경을 감지하게끔 하기 위해서는 이를 state로 바꿔줄 필요가 있습니다.


// client/components/MsgList.js
import MsgItem from "./MsgItem";
import MsgInput from "./MsgInput";
import {useState} from "react";

const UserIds = ['roy', 'jay'];
const getRandomUserId = () => UserIds[Math.round(Math.random())];

// 아래와 같이 배열 length가 50인 빈 배열을 만들어줍니다.
// 그리고 fill 메소드로 0값을 채워줍니다.
// fill 메소드로 값을 채워주기 전까진 map 메소드를 돌릴 수 없습니다.
const originalMsgs = Array(50).fill(0).map((_, i) => ({
    id: 50 - i,
    userId: getRandomUserId(),
    timestamp: 1234567890123 + (50 - i) * 1000 * 60, // 1분마다 하나씩
    text: `${50 - i} mock text`
}))


// [
//     {
//         id: 1,
//         userId: getRandomUserId(),
//         timestamp: 1234567890123,
//         text: "1 mock text"
//     }
// ]

const MsgList = () => {
    const [msgs, setMsgs] = useState(originalMsgs);
    const onCreate = text => {
        const newMsg = {
            id: msgs.length + 1,
            userId: getRandomUserId(),
            timestamp: Date.now(),
            text: `${msgs.length + 1} ${text}`,
        }
        setMsgs(msgs => ([newMsg, ...msgs]))
    }

    return (
        <>
            <MsgInput mutate={onCreate}/>
            <ul className='messages'>
                {
                    msgs.map(x => <MsgItem key={x.id} {...x} />)
                }
            </ul>
        </>
    )
}

export default MsgList;

위와 같이 state로 바꾸게되면 텍스트를 입력할 때마다 바로바로 화면에 렌더링되는 것을 볼 수 있습니다.

1.7 메시지 수정, 삭제 기능 구현 (Update, Delete)


// client/components/MsgList.js
import MsgItem from "./MsgItem";
import MsgInput from "./MsgInput";
import {useState} from "react";

const UserIds = ['roy', 'jay'];
const getRandomUserId = () => UserIds[Math.round(Math.random())];

// 아래와 같이 배열 length가 50인 빈 배열을 만들어줍니다.
// 그리고 fill 메소드로 0값을 채워줍니다.
// fill 메소드로 값을 채워주기 전까진 map 메소드를 돌릴 수 없습니다.
const originalMsgs = Array(50).fill(0).map((_, i) => ({
    id: 50 - i,
    userId: getRandomUserId(),
    timestamp: 1234567890123 + (50 - i) * 1000 * 60, // 1분마다 하나씩
    text: `${50 - i} mock text`
}))


// [
//     {
//         id: 1,
//         userId: getRandomUserId(),
//         timestamp: 1234567890123,
//         text: "1 mock text"
//     }
// ]

const MsgList = () => {
    const [msgs, setMsgs] = useState(originalMsgs);
    const [editingId, setEditingId] = useState(null);
    const onCreate = text => {
        const newMsg = {
            id: msgs.length + 1,
            userId: getRandomUserId(),
            timestamp: Date.now(),
            text: `${msgs.length + 1} ${text}`,
        }
        setMsgs(msgs => ([newMsg, ...msgs]))
    }

    // 바뀐 text값, 그리고 어떤 text인지 알기위해서 id 값도 받아야합니다.
    const onUpdate = (text, id) => {
        // 아래처럼 state 안에서 기존 데이터를 받아오게끔하면 좀 더 안정적입니다.
        // 그래서 setState를 아래처럼 함수형으로 사용하는 것을 추천한다고합니다.
        setMsgs(msgs => {
            const targetIndex = msgs.findIndex(msg => msg.id === id);
            // findindex로 일치하는 값이 없으면 -1 반환
            if (targetIndex < 0) return msgs;
            const newMsgs = [...msgs]
            newMsgs.splice(targetIndex, 1, {
                ...msgs[targetIndex], // 기존 속성들을 받아오고,
                text // text만 새걸로 업데이트해주면된다.
            })
            return newMsgs;
        })
    }

    return (
        <>
            <MsgInput mutate={onCreate}/>
            <ul className='messages'>
                {
                    msgs.map(x => <MsgItem key={x.id}
                                           {...x}
                                           onUpdate={onUpdate}
                                           startEdit={() => setEditingId(x.id)} // setEditingId가 실행될 때 id가 넘어와야하므로 왼쪽과 같이 작성해준다.
                                           isEditing={editingId === x.id}
                    />)
                }
            </ul>
        </>
    )
}

export default MsgList;


// client/components/MsgInput.js
import {useRef} from "react";

// MsgInput 컴포넌트는 MsgList, MsgItem 컴포넌트에서 사용할 것입니다.
// MsgList에선 Create의 목적으로
// MsgItem에선 Update의 목적으로 사용할 것입니다.
// 때문에 목적이 다르기 때문에 목적이 다른 메소드를 하나로 일괄해서 쓰기위해서 메소드 이름을 mutate라고 정의해보겠습니다.
// id 값도 인자값으로 넘겨주지만 안들어올 수도 있으므로 기본값을 undefined로 한다.
const MsgInput = ({mutate, id = undefined}) => {
    // react에서 form 데이터를 다루는 방식이 여러가지가 있겠지만,
    // 많은 분들이 onChange 메소드 또는 onInput 메소드들을 쓰시는 걸로 알고 있습니다.
    // 그런데 저는 이런 방법 말고 reference 방법을 이용하는 것을 선호합니다.
    // 이것은 개인적인 선호도이기 때문에 꼭 이렇게하는 것이 좋다라는 것은 아닙니다.
    // 이 방법이 마음에 안드시는 분들은 onChange 또는 onInput 등 으로 하셔도 될거같습니다.
    const textRef = useRef(null); // 초기값이 없으면 안됩니다. null값이라도 설정해야됩니다.
    const onSubmit = e => {
        e.preventDefault();
        e.stopPropagation();
        const text = textRef.current.value; // text 변수에 textRef.current.value 값을 담습니다.
        textRef.current.value = ''; // 그리고 바로 textRef의 값을 지워줍니다.

        // mutate 메소드에 id 값도 넘겨준다.
        mutate(text, id); // 상위에서 mutate라는 메소드를 받아서 호출할 때 text를 인자값으로 넘겨줍니다. // Create용 메소드가 내려올 수도 있고, Update용 메소드가 내려올수도 있고~
    }

    return (
        <form className='messages__input' onSubmit={onSubmit}>
            <textarea
                ref={textRef}
                placeholder="내용을 입력하세요."
            />
            <button type='submit'>완료</button>
        </form>
    )
}

export default MsgInput


// client/components/MsgItem.js
import MsgInput from "./MsgInput";

const MsgItem = ({
    id,
    userId,
    timestamp,
    text,
    onUpdate,
    isEditing,
    startEdit
                 }) => (
    <li className='messages__item'>
        <h3>
            {userId}{' '}
            <sub>
                {new Date(timestamp).toLocaleTimeString('ko-KR', {
                    year: 'numeric',
                    month: 'numeric',
                    day: 'numeric',
                    hour: '2-digit',
                    minute: '2-digit',
                    hour12: true // 오전 오후 시간이 보여지도록 하는 설정
                })}
            </sub>
        </h3>
        {isEditing ? (
            <>
                <MsgInput mutate={onUpdate} id={id}/>
            </>
        ): text}

        <div className="messages__buttons">
            <button onClick={startEdit}>수정</button>
        </div>
    </li>
)

export default MsgItem

이렇게 변경은되는데, 수정창이 안사라진다.
이는 isEditing 값이 계속 true로 남아있기 때문입니다.

위와 같이 false로 놓고보면 값이 바뀐게 보이죠?
즉, editing이 완료되었을 때를 알려주는 메소드가 또 필요할거 같습니다.


// client/components/MsgList.js
import MsgItem from "./MsgItem";
import MsgInput from "./MsgInput";
import {useState} from "react";

const UserIds = ['roy', 'jay'];
const getRandomUserId = () => UserIds[Math.round(Math.random())];

// 아래와 같이 배열 length가 50인 빈 배열을 만들어줍니다.
// 그리고 fill 메소드로 0값을 채워줍니다.
// fill 메소드로 값을 채워주기 전까진 map 메소드를 돌릴 수 없습니다.
const originalMsgs = Array(50).fill(0).map((_, i) => ({
    id: 50 - i,
    userId: getRandomUserId(),
    timestamp: 1234567890123 + (50 - i) * 1000 * 60, // 1분마다 하나씩
    text: `${50 - i} mock text`
}))


// [
//     {
//         id: 1,
//         userId: getRandomUserId(),
//         timestamp: 1234567890123,
//         text: "1 mock text"
//     }
// ]

const MsgList = () => {
    const [msgs, setMsgs] = useState(originalMsgs);
    const [editingId, setEditingId] = useState(null);
    const onCreate = text => {
        const newMsg = {
            id: msgs.length + 1,
            userId: getRandomUserId(),
            timestamp: Date.now(),
            text: `${msgs.length + 1} ${text}`,
        }
        setMsgs(msgs => ([newMsg, ...msgs]))
    }

    // 바뀐 text값, 그리고 어떤 text인지 알기위해서 id 값도 받아야합니다.
    const onUpdate = (text, id) => {
        // 아래처럼 state 안에서 기존 데이터를 받아오게끔하면 좀 더 안정적입니다.
        // 그래서 setState를 아래처럼 함수형으로 사용하는 것을 추천한다고합니다.
        setMsgs(msgs => {
            const targetIndex = msgs.findIndex(msg => msg.id === id);
            // findindex로 일치하는 값이 없으면 -1 반환
            if (targetIndex < 0) return msgs;
            const newMsgs = [...msgs]
            newMsgs.splice(targetIndex, 1, {
                ...msgs[targetIndex], // 기존 속성들을 받아오고,
                text // text만 새걸로 업데이트해주면된다.
            })
            return newMsgs;
        })
        doneEdit();
    }

    // update가 완료되었다는 것을 알려줍니다.
    const doneEdit = () => setEditingId(null)

    return (
        <>
            <MsgInput mutate={onCreate}/>
            <ul className='messages'>
                {
                    msgs.map(x => <MsgItem key={x.id}
                                           {...x}
                                           onUpdate={onUpdate}
                                           startEdit={() => setEditingId(x.id)}
                                           isEditing={editingId === x.id}
                    />)
                }
            </ul>
        </>
    )
}

export default MsgList;

Delete


// client/components/MsgList.js
import MsgItem from "./MsgItem";
import MsgInput from "./MsgInput";
import {useState} from "react";

const UserIds = ['roy', 'jay'];
const getRandomUserId = () => UserIds[Math.round(Math.random())];

// 아래와 같이 배열 length가 50인 빈 배열을 만들어줍니다.
// 그리고 fill 메소드로 0값을 채워줍니다.
// fill 메소드로 값을 채워주기 전까진 map 메소드를 돌릴 수 없습니다.
const originalMsgs = Array(50).fill(0).map((_, i) => ({
    id: 50 - i,
    userId: getRandomUserId(),
    timestamp: 1234567890123 + (50 - i) * 1000 * 60, // 1분마다 하나씩
    text: `${50 - i} mock text`
}))


// [
//     {
//         id: 1,
//         userId: getRandomUserId(),
//         timestamp: 1234567890123,
//         text: "1 mock text"
//     }
// ]

const MsgList = () => {
    const [msgs, setMsgs] = useState(originalMsgs);
    const [editingId, setEditingId] = useState(null);
    const onCreate = text => {
        const newMsg = {
            id: msgs.length + 1,
            userId: getRandomUserId(),
            timestamp: Date.now(),
            text: `${msgs.length + 1} ${text}`,
        }
        setMsgs(msgs => ([newMsg, ...msgs]))
    }

    // 바뀐 text값, 그리고 어떤 text인지 알기위해서 id 값도 받아야합니다.
    const onUpdate = (text, id) => {
        // 아래처럼 state 안에서 기존 데이터를 받아오게끔하면 좀 더 안정적입니다.
        // 그래서 setState를 아래처럼 함수형으로 사용하는 것을 추천한다고합니다.
        setMsgs(msgs => {
            const targetIndex = msgs.findIndex(msg => msg.id === id);
            // findindex로 일치하는 값이 없으면 -1 반환
            if (targetIndex < 0) return msgs;
            const newMsgs = [...msgs]
            newMsgs.splice(targetIndex, 1, {
                ...msgs[targetIndex], // 기존 속성들을 받아오고,
                text // text만 새걸로 업데이트해주면된다.
            })
            return newMsgs;
        })
        doneEdit();
    }

    // delete는 text가 필요없고 id만 있으면됩니다.
    const onDelete = (id) => {
        setMsgs(msgs => {
            const targetIndex = msgs.findIndex(msg => msg.id === id);
            // findindex로 일치하는 값이 없으면 -1 반환
            if (targetIndex < 0) return msgs;
            const newMsgs = [...msgs]
            newMsgs.splice(targetIndex, 1) // update와의 차이점, splice를 해준 다음에 그 자리에 새로운 값을 안 넣어주면된다.
            return newMsgs;
        })
    }

    // update가 완료되었다는 것을 알려줍니다.
    const doneEdit = () => setEditingId(null)

    return (
        <>
            <MsgInput mutate={onCreate}/>
            <ul className='messages'>
                {
                    msgs.map(x => <MsgItem key={x.id}
                                           {...x}
                                           onUpdate={onUpdate}
                                           onDelete={() => onDelete(x.id)} // onDelete가 실행될 때 id가 넘어와야하므로 왼쪽과 같이 작성해준다.
                                           startEdit={() => setEditingId(x.id)} // setEditingId가 실행될 때 id가 넘어와야하므로 왼쪽과 같이 작성해준다.
                                           isEditing={editingId === x.id}
                    />)
                }
            </ul>
        </>
    )
}

export default MsgList;


// client/components/MsgItem.js
import MsgInput from "./MsgInput";

const MsgItem = ({
    id,
    userId,
    timestamp,
    text,
    onUpdate,
    onDelete,
    isEditing,
    startEdit
                 }) => (
    <li className='messages__item'>
        <h3>
            {userId}{' '}
            <sub>
                {new Date(timestamp).toLocaleTimeString('ko-KR', {
                    year: 'numeric',
                    month: 'numeric',
                    day: 'numeric',
                    hour: '2-digit',
                    minute: '2-digit',
                    hour12: true // 오전 오후 시간이 보여지도록 하는 설정
                })}
            </sub>
        </h3>
        {isEditing ? (
            <>
                <MsgInput mutate={onUpdate} id={id}/>
            </>
        ): text}

        <div className="messages__buttons">
            <button onClick={startEdit}>수정</button>
            <button onClick={onDelete}>삭제</button>
        </div>
    </li>
)

export default MsgItem

삭제가 잘 됩니다.

위와 같이 MsgList에서 id 값을 안넘겨주고 MsgItem에서 위와 같이 id 값을 넘겨줘도됩니다.

한가지 아쉬운점은 위와 같이 수정 버튼을 누르면 기존에 작성되어있던 text가 다 날라가버립니다.
위 아쉬운점 하나만 수정하고 이번 챕터 마무리합시다.



// client/components/MsgInput.js
import {useRef} from "react";

// MsgInput 컴포넌트는 MsgList, MsgItem 컴포넌트에서 사용할 것입니다.
// MsgList에선 Create의 목적으로
// MsgItem에선 Update의 목적으로 사용할 것입니다.
// 때문에 목적이 다르기 때문에 목적이 다른 메소드를 하나로 일괄해서 쓰기위해서 메소드 이름을 mutate라고 정의해보겠습니다.
// id 값도 인자값으로 넘겨주지만 안들어올 수도 있으므로 기본값을 undefined로 한다.
// text 값도 인자값으로 넘겨준다. 하지만 없을수도 있으므로 기본값을 빈문자열로 정의해준다.
const MsgInput = ({mutate, text = '', id = undefined}) => {
    // react에서 form 데이터를 다루는 방식이 여러가지가 있겠지만,
    // 많은 분들이 onChange 메소드 또는 onInput 메소드들을 쓰시는 걸로 알고 있습니다.
    // 그런데 저는 이런 방법 말고 reference 방법을 이용하는 것을 선호합니다.
    // 이것은 개인적인 선호도이기 때문에 꼭 이렇게하는 것이 좋다라는 것은 아닙니다.
    // 이 방법이 마음에 안드시는 분들은 onChange 또는 onInput 등 으로 하셔도 될거같습니다.
    const textRef = useRef(null); // 초기값이 없으면 안됩니다. null값이라도 설정해야됩니다.
    const onSubmit = e => {
        e.preventDefault();
        e.stopPropagation();
        const text = textRef.current.value; // text 변수에 textRef.current.value 값을 담습니다.
        textRef.current.value = ''; // 그리고 바로 textRef의 값을 지워줍니다.

        // mutate 메소드에 id 값도 넘겨준다.
        mutate(text, id); // 상위에서 mutate라는 메소드를 받아서 호출할 때 text를 인자값으로 넘겨줍니다. // Create용 메소드가 내려올 수도 있고, Update용 메소드가 내려올수도 있고~
    }

    return (
        <form className='messages__input' onSubmit={onSubmit}>
            <textarea
                ref={textRef}
                defaultValue={text}
                placeholder="내용을 입력하세요."
            />
            <button type='submit'>완료</button>
        </form>
    )
}

export default MsgInput


// client/components/MsgItem.js
import MsgInput from "./MsgInput";

const MsgItem = ({
    id,
    userId,
    timestamp,
    text,
    onUpdate,
    onDelete,
    isEditing,
    startEdit
                 }) => (
    <li className='messages__item'>
        <h3>
            {userId}{' '}
            <sub>
                {new Date(timestamp).toLocaleTimeString('ko-KR', {
                    year: 'numeric',
                    month: 'numeric',
                    day: 'numeric',
                    hour: '2-digit',
                    minute: '2-digit',
                    hour12: true // 오전 오후 시간이 보여지도록 하는 설정
                })}
            </sub>
        </h3>
        {isEditing ? (
            <>
                <MsgInput mutate={onUpdate} text={text} id={id}/>
            </>
        ): text}

        <div className="messages__buttons">
            <button onClick={startEdit}>수정</button>
            <button onClick={onDelete}>삭제</button>
        </div>
    </li>
)

export default MsgItem

수정을 눌렀을 때 현재 textarea의 내용이 그대로 들어있는 것을 볼 수 있다.

Note

이번 시간에 하나의 완성된 서비스를 아주 빠르게 구현해봤습니다.
물론 프론트엔드단만 했고 서버와의 데이터 통신은 하나도 구현하지 않았고, 오로지 클라이언트에서 임의로 만들어낸 목 데이터를 가지고기본적인 CRUD 기능만 구현을 했습니다.

다음 시간부터 REST API를 하면서 서버쪽 작업을 하면서 JSON DB를 활용해서 서버와 통신하는 것까지 구현해보도록 하겠습니다.