React 18 주요 변경점

우동이·2022년 4월 17일
79

React

목록 보기
1/5
post-thumbnail

React v18 주요 변경점

1. Automatic Batching

  • React-18v 부터 상태 업데이트(setState)를 하나로 통합해서 배치처리를 한 후 리렌더링을 진행합니다.
    • 리렌더링 관련 성능 개선
  • 과거 React-17v 에서는 이벤트 핸들러 내부에서 발생하는 상태 업데이트만 배치처리를 지원했습니다.
    • 하지만 이벤트 핸들러 내부에 fetch()등 과 같은 콜백을 받아 처리하는 메소드가 존재할 경우 내부의 콜백이 모두 완료된 후에는 Automatic Batching이 처리되지 않았습니다.

1.1 React-17v

이벤트 핸들러 내부에서 상테 업데이트가 여러번 발생

  • 소스코드
import React, { useState } from "react";
import "./App.css";

function App() {
  // 2가지의 상태 존재
  const [number, setNumber] = useState(0);
  const [boolean, setBoolean] = useState(false);

  // 하나의 핸들러에 2가지 상태를 업데이트
  const onClickCreateNumber = () => {
    setNumber((prev) => prev + 1);
    setBoolean((prev) => !prev);
  };

  console.log("리렌더링");
  return (
    <>
      <div>{number}</div>
      <button onClick={onClickCreateNumber}>button</button>
    </>
  );
}

export default App;
  • 결과
    • 하나의 핸들러에서 2가지의 상태 업데이트가 이루어졌음에도 불구하고 리렌더링은 1번만 일어나고 있습니다.

이벤트 핸들러 내부에서 fetch()함수를 활용하여 상태 업데이트 여러번 발생

  • 소스코드
import React, { useState } from "react";
import "./App.css";

function App() {
  const [number, setNumber] = useState(0);
  const [boolean, setBoolean] = useState(false);

  const onClickCreateNumber = () => {
    // fetch()를 활용해서 콜백함수 내부에서 여러개의 상태 업데이트
    fetch("https://jsonplaceholder.typicode.com/posts/1").then((response) => {
      setNumber((prev) => prev + 1);
      setBoolean((prev) => !prev);
    });
  };

  console.log("리렌더링");
  return (
    <>
      <div>{number}</div>
      <button onClick={onClickCreateNumber}>button</button>
    </>
  );
}

export default App;
  • 결과
    • 버튼 클릭 1번당 리렌더링이 2번 발생하고 있습니다.
    • Automatic Batching이 작동하지 않고 있음

1.2 React-18v

이벤트 핸들러 내부에서 fetch()함수를 활용하여 상태 업데이트 여러번 발생

  • 소스코드
import React, { useState } from "react";
import "./App.css";

function App() {
  const [number, setNumber] = useState(0);
  const [boolean, setBoolean] = useState(false);

  const onClickCreateNumber = () => {
    // fetch()를 활용해서 콜백함수 내부에서 여러개의 상태 업데이트
    fetch("https://jsonplaceholder.typicode.com/posts/1").then((response) => {
      setNumber((prev) => prev + 1);
      setBoolean((prev) => !prev);
    });
  };

  console.log("리렌더링");
  return (
    <>
      <div>{number}</div>
      <button onClick={onClickCreateNumber}>button</button>
    </>
  );
}

export default App;
  • 결과
    • 버튼 클릭 1번당 리렌더링이 1번 발생하고 있습니다.
    • React-17v와 다르게 Automatic Batching이 작동하고 있습니다.

이벤트 핸들러 내부에서 2가지 상태를 활용할 경우

  • 소스코드
    • 일반적인 상태 업데이트 + 콜백함수 내부에서 상태 업데이트를 진행할 경우 Automatic Batching이 작동할까?
import React, { useState } from "react";
import "./App.css";

function App() {
  const [number, setNumber] = useState(0);
  const [boolean, setBoolean] = useState(false);

  const onClickCreateNumber = () => {
    // 핸들러 내부에서 상태 업데이트 (콜스택)
    setNumber((prev) => prev + 1);

    // fetch() 콜백함수 내부에서 상태 업데이트 (태스트 큐)
    fetch("https://jsonplaceholder.typicode.com/posts/1").then((response) => {
      setBoolean((prev) => !prev);
    });
  };

  console.log("리렌더링");
  return (
    <>
      <div>{number}</div>
      <button onClick={onClickCreateNumber}>button</button>
    </>
  );
}

export default App;
  • 결과
    • 버튼 클릭 1번당 리렌더링이 2번 발생하고 있습니다.
      • 동시 사용 불가
    • 결론적으로 혼용하여 사용할 경우 React-18v에서도 Automatic Batching이 작동하지 않습니다.
    • Automatic Batching은 지원 범위를 기존 콜스택에서 태스크 큐까지 확장한 것입니다.
      • 콜스택: JS 엔진에서 일반적으로 Job을 처리하는 일반적인 자료구조
      • 태스크 큐: 비동기 콜백 등을 처리하는 자료구조

Tip) Automatic Batching 기능을 사용하고 싶지 않다면?

  • react-domflushSync()를 활용하여 Automatic Batching 기능을 off 할 수 있습니다.

  • 소스코드

import React, { useState } from "react";
// flushSync() import
import { flushSync } from "react-dom";
import "./App.css";

function App() {
  const [number, setNumber] = useState(0);
  const [boolean, setBoolean] = useState(false);

  const onClickCreateNumber = () => {
    // flushSync() 활용
    flushSync(() => {
      setNumber((prev) => prev + 1);
    });

    flushSync(() => {
      setBoolean((prev) => !prev);
    });
  };

  console.log("리렌더링");
  return (
    <>
      <div>{number}</div>
      <button onClick={onClickCreateNumber}>button</button>
    </>
  );
}

export default App;

2. Concurrent Feature

  • 기존 React에서 추구하고 있는 Concurrent Mode( 동시성 )를 18v 부터 하나의 기능으로 지원하게 되었습니다.

Concurrent Mode란?

  • 자바스크립트는 싱글 스레드기반 언어이다보니 여러 작업을 동시에 처리할 수 없었습니다.
    • React에서도 UI 렌더링 도중에 일어나는 모든 작업은 차단합니다.
  • 하지만 React는 Concurrent Mode를 사용해 여러 작업을 동시에 처리할 수 있도록 기능들을 확대하고 있었습니다.
  • 동시성이라는 개념을 활용하여 여러 작업을 동시에 처리하도록 React는 구현하고 있습니다.
    • 여러 작업을 작은 단위로 나눈 후 작업들 간의 우선순위를 정합니다.
    • 정해진 우선순위에 따라 작업을 수행하는 방법입니다.
    • 즉 실제로는 동시에 작업이 수행되지는 않지만 작업 간의 전환이 매우 빠르기 때문에 동시에 수행되는 것처럼 보입니다.
  • 왜 React는 Concurrent Mode를 개발하려고 하는가?
    • 사용자 경험에서 아주 중요한 역할을 가집니다.
    • 디바운스와 쓰로틀링
      • 기본적으로 Input 관련 기능을 이용할 때 디바운스 / 쓰로틀링을 활용합니다.
        • 이 문서에서는 디바운스 / 쓰로틀링이 무엇인지 설명하지 않습니다.
        • 궁금하시면 꼭 찾아보시길 권장합니다.
      • 사용자 경험을 개선하기 위해 자주 활용되지만 한계점이 존재
        • 디바운스: 무조건 일정 시간을 기다려야 함
        • 쓰로틀링: 성능이 좋은 기기에서는 사용자 경험을 높일 수 있지만 성능이 안좋은 기기에서는 버벅거리는 현상이 발생
      • Concurrent Mode는 이와 같은 한계점을 해결할 수 있습니다.
        • 작업간의 우선순위를 정하여 사용자 입력 / 다른 작업들을 동시에 처리되는 경험을 보여줄 수 있고 개발자가 설정한 Delay에 의존되는 것이 아닌 사용자 기기 성능에 따라 달라지게 됩니다.
    • suspense 기능
      • React는 suspense기능을 지원하여 해당 페이지를 불러오기 전 로딩 기능을 지원하고 있습니다.
      • 하지만 기기 성능이 좋다보니 빠른 렌더링을 지원함에도 불구하고 의미없는 로딩을 보여주게 됩니다.
      • Concurrent Mode는 일정 시간동안 현재 페이지를 유지하면서 다음 페이지의 렌더링에 대한 작업을 동시에 진행하게 됩니다.

2.1 createRoot

  • 기존 React-17v의 render()와는 다르게 createRoot API를 활용해야 합니다.
    • 동시성 API / Automatic Batching 지원
  • 소스코드
    • React-17v와 React-18v의 차이점
// React-17v
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(<App />, document.getElementById("root"));

// React-18v
import React from "react";
import App from "./App";

import { createRoot } from "react-dom/client";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(<App />);

2.2 startTransition

  • 기존에 사용자 경험을 개선하기위 사용했던 디바운스 / 쓰로틀링 / setTimeout 등의 기능을 지원합니다.

    • 일반적으로 자바스크립트에서 활용하는 setTimeout 동작방식과 다르게 동작합니다.
      • 태스트 큐를 활용하지 않으며 동기적으로 즉시 실행
  • useTransition 훅을 활용하여 isPending 상태값을 가져와 렌더리 결과를 분기 처리 가능

    • isPending: state 변경 직후에도 UI를 리렌더링 하지 않고 UI를 잠시 유지하는 상태
    • 각 상태 업데이트에 대한 우선순위를 설정할 수 있는 Hook 입니다.
  • startTransition만 사용할 수 있습니다.

    • startTransition: 클릭이나 키 입력에 의해 우선순위가 높은 상태 업데이트가 발생할 경우 내부에 선언한 상태 업데이트는 중단되고 클릭이나 키 입력이 끝난 후 이후에 해당 상태 업데이트가 발생합니다.
  • 소스코드

    • searchQuery 상태 업데이트 진행 중 inputValue의 상태 업데이트가 발생하게 되면 잠시 중단하고 inputValue 상태 업데이트가 완료되면 searchQuery 상태 업데이트가 완료됩니다.
    • 디바운스 / 쓰로틀링을 활용하지 않고 기기 성능에 따라 최적화가 가능해집니다!
      • 기존 디바운스 / 쓰로틀링은 setTimeout을 활용해 특정 시간을 무저건 기다려야 했음
import React, { useTransition, useState } from "react";

import "./App.css";

function App() {
  const [isPending, startTransition] = useTransition();
  const [inputValue, setInputValue] = useState();
  const [searchQuery, setSearchQuery] = useState();

  const onClickCreateNumber = (e) => {
    const input = e.target.value;

    setInputValue(input);

    // React에게 searchQuery의 상태 업데이트는 inputValue 상태보다 지연시켜! 라고 알리기
    startTransition(() => {
      setSearchQuery(input);
    });
  };

  console.log("리렌더링", isPending, inputValue, searchQuery);
  return (
    <>
      <input onChange={onClickCreateNumber} />
      // isPending값이 true일 경우 searchQuery 상태가 우선순위에 밀려 pending 상태임으로 버튼 클릭 불가
      // searchQuery 상태 업데이트가 완료되면 버튼 클릭 가능 => 이것을 활용해 로딩 기능 구현 가능
      <button disabled={isPending}>button</button>
    </>
  );
}

export default App;
  • useTransition() 에서 timeout을 설정할 수 있습니다.
    • 최대 얼마까지 기다릴 것인지 시간을 설정합니다.
    • 아래의 코드는 5초 동안 렌더링을 기다리다가 5초가 지나도 pending값이 변하지 않으면 강제로 렌더링 됩니다.
const [isPending, startTransition] = useTransition({ timeoutMs: 5000 });

2.3 Suspense와 SSR

  • 기존 React SSR 방식은 waterfall 방식을 사용하고 있었습니다.

    • Server는 React 코드를 전달 받음 => HTML로 변환 => React는 다시 변환된 HTML 코드를 전달 받음 => hydrate
      • hydrate: HTML 문서에 자바스크립트를 붙이는 작업
    • 특정 부분에서 병목현상이 발생할 경우 성능 이슈가 발생하게 됩니다.
  • React-18v 는 독립적으로 각각의 렌더링이 가능한 기능이 추가 되었습니다.

    • 기존의 createRoot 대신 hyrateRoot 사용
    • 즉 유저에게 처음 보여주는 페이지 전체를 그려 내려주는 것이 아닌 빠르게 준비되는 부분부터 보여줍니다.
    • HTML Streaming API + Suspense를 연계하여 SSR 설계 지원
    • Selective hydrating 기능 지원

HTML Streaming

  • Server에서 HTML 문서를 내려주는 것을 의미합니다.

  • 기존의 React는 renderToString()을 활용하였습니다.

    • 완성된 HTML 문서를 전달 받음
  • React-18v 부터는 pipeToNodeWritable()를 활용해 HTML코드를 작은 청크로 나눈 후 보내줄 수 있습니다.

예시 소스코드

  • <Comments /> 컴포넌트는 오래걸리는 컴포넌트 임으로 suspense를 적용한 상태이고 해당 컴포넌트가 렌더링 되기 전에는 <Spinner /> 컴포넌트를 보여주는 상황입니다.
<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

  • 실제 내부 코드
    • 아직 <Comments /> 컴포넌트는 보이지 않고 <Spinner /> 컴포넌트만 보여주고 있습니다.
<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
   </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section id="comments-spinner">
    <!-- Spinner -->
    <img width=400 src="spinner.gif" alt="Loading..." />
  </section>
</main>
  • <Comments /> 컴포넌트 렌더링 준비가 완료되면 React는 추가적으로 HTML 코드를 스트리밍합니다.
    • <Spinner /> 컴포넌트 대신에 <Comments /> 컴포넌트를 보여주고 있습니다.
<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

  • 결론
    • 기존에는 SSR을 구현할 때 SuspenserenderToString()과 같이 사용할 수 없었으며 다른 서드파티 라이브러리의 도움이 필요했습니다.
    • React-18v 부터는 응답이 오래걸리는 컴포넌트는 <Suspense>를 적용하여 초기 렌더링 속도를 개선할 수 있습니다!

Selective hydrating

  • 상단의 HTML Streaming 기능을 통해 하나의 컴포넌트가 영향을 미치는 빈도수는 감소하였지만 극단적으로 하나의 컴포넌트가 너무 복잡할 경우 계속 <Spinner /> 만 보여지게 됩니다.

  • 즉 다른 컴포넌트들은 렌더링이 완료되고 내려받은 자바스크립트 코드를 hydration 해야하는데 아직 렌더링 되지 않는 컴포넌트 때문에 기다려야 하는 현상이 발생합니다.

  • React-18v 부터는 <Suspense> 를 활용해 구현할 경우 해당 컴포넌트가 아직 렌더링되지 않았어도 상관없이 다른 컴포넌트들은 hydration을 시작할 수 있게 되었습니다.

  • 아래의 그림처럼 네비게이션 영역에서는 먼저 hydration이 완료되어 유저는 다른 클릭 이벤트를 먼저 수행할 수 있습니다.

    • 사용자 경험 향상!

  • 그리고 어떤 것들을 먼저 hydration 시킬 것인지 우선순위를 정할 수 있습니다.

예시 코드

  • hydration<Sidebar /> 컴포넌트와 <Comments />Suspense를 적용한 상태에서 기본적으로 DOM Tree에 배치된 순서에 따라서 순차적으로 진행됩니다.
  • <Sidebar /> 컴포넌트가 <Comments /> 보다 먼저 hydraion 진행
<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

  • 만약 사용자가 <Sidebar /> 컴포넌트 hydration이 완료되기 전 <Comments /> 컴포넌트의 클릭 이벤트를 발생시킨다면 React는 <Comments />우선순위를 높여 먼저 hydration을 진행합니다.

  • <Comments />의 hydration이 완료되면 <Sidebar />의 hydration을 진행합니다.

  • 결론적으로 사용자의 관심사에 따라 인터랙션이 가능한 컴포넌트 부터 기능을 제공할 수 있게 되었습니다.

3. React Server Component(RSC)

  • React에서 node.js와 같은 Server 역할을 수행하는 Server Component 기능을 제공합니다.
    • 브라우저가 받아오는 용량을 줄이기 위해 서버에서 실행되는 컴포넌트입니다.
    • .client.jsx, .server.jsx, .jsx 3개의 파일로 구성
  • 기존 SSR 기능과는 다르게 RSC는 HTML 파일을 가져오지 않고 JSON 데이터를 가져오게 됩니다.

기존 SSR 방식

  • renderToString 함수를 통해 초기 렌더링 결과를 HTML String으로 반환합니다.
  • 위에서 내려받은 마크업이 포함된 HTML 문서를 먼저 사용자에게 보여줍니다.
  • 그리고 나머지 청크파일을 다시 받은 후 ReactDOM.hydrate 함수를 통해 자바스크립트 / 변경된 곳을 업데이트 합니다.
    • 예시
      • input 태그에 검색어 입력
      • onChange 동작 => 내부 검색 Fetch API 작동 => 검색 결과를 받아옴
      • 받은 검색 결과를 다시 React에 전달하여 컴포넌트 렌더링 진행
import ReactDOMServer from 'react-dom/server'; 

const ReactDOMServer = require('react-dom/server');

ReactDOMServer.renderToString(element)

변경된 SSR+RSC 방식

  • 예시

    • input 태그에 검색어 입력

    • onChange 동작 => 렌더링 서버에 Fetch 키워드 전달

    • 렌더링 서버에서 Fetch API 요청 => 검색결과를 받아 비 HTML 형식으로 클라이언트에게 전달

    • 클라이언트는 UI로 렌더링 진행

      • React 컴포넌트가 아니기 때문에 컴포넌트 처리 비용이 절약
      • 불필요한 청크 파일을 받아오는 것을 막을 수 있음
  • 소스 코드

    • 서버 컴포넌트는 API 호출 방식을 사용하지 않고 직접 DB에 접근하여 note 데이터를 받아오고 있습니다.
      • 이것이 RSA의 장점!
    • 받아온 데이터를 바탕으로 NodeEditor라는 컴포넌트를 구성하고 있습니다.
      • NodeEditor 컴포넌트는 클라이언트에게 내려줄 컴포넌트 입니다.
      • 서버 컴포넌트가 리렌더링 되도 클라이언트 컴포넌트가 기존에 가지고 있는 DOM / State들은 유지됩니다.
        • 서버에서 내려주는 props를 바탕으로 생성
      • 또한 NodeEditor 컴포넌트를 RSC가 import할 때 필요할때만 dynamic하게 import할 수 있습니다.
    • 즉 RSA는 서버에게 직접 JSON 데이터를 받거나, 클라이언트에서 필요한 전처리 과정, 파일 시스템 등을 수행하고 클라이언트 컴포넌트는 React의 순수 컴포넌트 기능을 제공하게 됩니다.
// Note.server.js - Server Component 

import db from 'db.server';
// (A1) We import from NoteEditor.client.js - a Client Component.
import NoteEditor from 'NoteEditor.client';

function Note(props) {
    const {id, isEditing} = props;
    // (B) Can directly access server data sources during render, e.g. databases 
    const note = db.posts.get(id);
     return (
        <div>
                <h1>{note.title}</h1>
                <section>{note.body}</section>
                {/* (A2) Dynamically render the editor only if necessary */}
                    {isEditing ? <NoteEditor note={note} /> : null }
        </div>
        );
}
  • RSC의 장점 정리

    • Zero-Bundle-Size Components

      • 서버 컴포넌트는 번들에 포함되지 않기 때문에 번들 사이즈가 감소합니다.
    • Full Access to the Backend

      • API 호출을 통해 여러 데이터를 불러올 필요 없이 DB 접근 / 파일 시스템 등을 접근할 수 있습니다.
    • Automatic Code Splitting

      • 기존 lazy loading 방식을 자동으로 지원합니다.
      // 기존 방식
      // NOTE: *before* Server Components import React from 'react'; 
      
      // one of these will start loading *when rendered on the client*: 
      const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js')); 
      const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js')); 
      function Photo(props) { 
        // Switch on feature flags, logged in/out, type of content, etc: 
        if (FeatureFlags.useNewPhotoRenderer) { 
          return <NewPhotoRenderer {...props} />; 
        } else { 
          return <PhotoRenderer {...props} />;
        } 
      };
      
      // 개선된 방식 => 자동으로 코드 스플리팅이 적용되어 렌더링이 필요한 시점에 import
      import React from 'react'; 
      
      // one of these will start loading *once rendered and streamed to the client*: 
      import OldPhotoRenderer from './OldPhotoRenderer.client.js'; 
      import NewPhotoRenderer from './NewPhotoRenderer.client.js'; 
      
      function Photo(props) { // Switch on feature flags, logged in/out, type of content, etc: 
        if (FeatureFlags.useNewPhotoRenderer) { 
          return <NewPhotoRenderer {...props} />;
        } else { 
          return <PhotoRenderer {...props} />; 
        } 
      }
  • 번외 - RSA의 Rule

    • RSA는 요청 당 한번만 수행되기 때문에 상태 변화 Hook 미지원

      • useState, useReducer, useEffect, useLayoutEffect

      • state or effect가 포함된 커스텀 Hook 미지원

    • 폴리필 하지 않는 한 브라우저 API 미지원

      • 브라우저 API를 활용한 함수들도 미지원
    • 다른 서버 컴포넌트 / 다른 클라이언트 컴포넌트 혹 / HTML Tag 렌더링 가능

4. New Hooks

  • 새로 추가된 Hook들은 상세하게 다루지 않기 때문에 공식문서를 참조하시길 바랍니다.

  • useId

    • 난수 ID를 생성하는 Hook 입니다.
    • 클라이언트와 서버간의 hydration의 불일치를 피하면서 유니크 아이디를 생성 기능을 제공
      • 특정 key값을 생성하는 것이 아닙니다.
    • 참고: 공식문서
  • useSyncExternalStore

    • 기존의 useMutableSource hook에서 변경되었습니다.
    • 동시성 기능을 사용할 때 전역 상태 관리 라이브러리의 상태가 업데이트 되지 않을 경우 강제로 업데이트를 발생시키는 Hook입니다.
      • 라이브러리 제작 활용
    • 참고: 공식문서
  • useDeferredValue

    • 트리에서 급하지 않은 부분의 재랜더링을 지연할 수 있는 기능을 지원하는 Hook 입니다.
    • 디바운스와 비슷하지만 고정된 지연시간이 없고 렌더링이 반영되는 시점에 지연 렌더링을 시도합니다.
      • 지연된 렌더링은 인터럽트가 가능 => 사용자 입력을 차단하지 않음
    • 참고: 공식문서
  • useInsertionEffect

    • Css-in-JS 라이브러리를 활용할 때 스타일 삽입 성능 문제를 해결할 수 있는 Hook 입니다.

      • 라이브러리 제작 활용
    • Dom이 한번 mutate된 이후 실행되지만 layout effect가 발생하기 전 새 레이아웃을 한번 읽을 수 있기 때문에 사전에 계산할 수 있는 기회가 주어집니다.

    • 기존 useLayoutEffect와 비슷하지만 다른점은 DOM 노드에 대한 참조에 접근할 수 있게 됩니다.

    • 참고: 공식문서

    5. 참고 문헌

  • https://reactjs.org/blog/2022/03/29/react-v18.html

  • https://github.com/reactwg/react-18/discussions/37

  • https://tecoble.techcourse.co.kr/post/2021-07-24-concurrent-mode/

  • https://velog.io/@jay/React-18-%EB%B3%80%EA%B2%BD%EC%A0%90

  • https://yceffort.kr/2022/04/react-18-changelog

profile
아직 나는 취해있을 수 없다...

2개의 댓글

comment-user-thumbnail
2022년 8월 2일

좋은 글 감사합니다!

답글 달기
comment-user-thumbnail
2023년 4월 8일

정리 깔끔하네요! 감사합니다!

답글 달기