React 18 변경점

dante Yoon·2021년 6월 19일
87

react

목록 보기
1/19
post-thumbnail

안녕하세요 단테입니다.

프론트 개발에 관심이 있는 분들이라면 오늘의 주제에 큰 흥미를 느끼실 것이라 생각합니다.
21년 6월 8일, 리엑트 코어 개발자들이 리엑트 공식 홈페이지에 리엑트 18에 새롭게 추가되는 기능들을 소개했습니다. 또한 깃허브 저장소에 새로운 기능들을 주제로한 디스커션들을 열고 이전 버전들과 비교해 어떤 변경점들이 있는지 설명했으며 메인테이너 및 컨트리뷰터들과 활발하게 토의하는 모습 또한 볼 수 있었습니다.

오늘은 새로운 18버전에는 어떤 흥미로운 기능들이 소개되었는지 함께 살펴보는 시간을 가지려고 합니다.
본 포스팅에 사용되는 설명 및 그림들은 react-18 저장소의 디스커션에 올라온 설명들을 많이 참고하였으며 특히 suspense ssr architecture 주제를 설명하는데 사용한 많은 자료들은 글을 작성하기에 앞서 리엑트 팀 Dan Abramov의 허락을 받았음을 미리 밝힙니다.

무슨 내용을 다루나요?

1. suspense ssr architecture (html streaming & selective hydration)

2. state batch update

3. transition

아래 내용은 다루지 않습니다.

server component

New Suspense SSR Architecture

리엑트의 서버사이드 렌더링은 다음의 스텝으로 이뤄집니다.
1. 서버 사이드에서 페이지를 그리는데 필요한 데이터들을 fetch합니다.
2. 렌더링 서버(서버사이드)에서 데이터를 처리한 후 html을 response에 내려 보냅니다.
3. 클라이언트(브라우저)에서는 필요한 자바스크립트 코드를 다운받습니다.
4. 클라이언트(브라우저)에서는 서버사이드에서 렌더링된 html 파일에 자바스크립트 파일을 연결시킵니다. (hydration)

각 단계들은 synchronous 하게 진행되며 전체 과정은 Top-Bottom / Waterfall 모델의 형태를 띄고 있습니다. 전체 컴포넌트 중 특정 부분만 느리게 처리가 되어도 앱 전체가 제일 느린 부분이 완전히 준비될 때까지 기다려야 하므로 비효율적인 시간소모가 발생됩니다.

서버사이드에서 html을 클라이언트로 보내주기 전에 먼저 렌더링에 필요한 데이터들을 api서버에 호출해야 하는 상황이 있습니다. api 응답이 느리다면 해당 api를 사용하는 컴포넌트로 인해 사용자는 첫 페이지가 뜨기전까지 오랜 시간을 기다려야합니다.
data fetching은 클라이언트단의 코드를 개선한다고 완전히 해결할 수 없는 상황이기에 초기 페이지를 빠르게 보내기 위해서 아래의 선택지에서 취사선택을 해야합니다.

1. SSR에서 해당 api fetch를 제외 시키고 CSR 단계에서 useEffect에서 호출하는 것으로 대신한다.
2. 페이지를 구성하는 10개의 컴포넌트 중 9개의 컴포넌트가 해당 api 응답과 무관하더라도 딜레이를 감수한다.

초기 페이지가 브라우저에서 그려지기 시작하더라도 아직 딜레마가 하나 더 남아있습니다. 브라우저는 리엑트와 같은 싱글페이지 웹 어플리케이션을 실행할때 자바스크립트를 다음과 같은 과정으로 처리하는데요,
1. Fetch JS
2. Load JS
3. Hydrate
페이지가 버튼 클릭과 같은 유저 인터렉션 반응하기 위해서는 이벤트핸들러와 같은 JS 코드들이 html에 동화되어야 합니다.
이것을 수화(hydration)과정이라고 하는데 자바스크립트 파일 번들용량이 크거나 복잡하다면 위의 과정이 오래걸릴 수 밖에 없겠죠. 또한 페이지를 모든 컴포넌트가 완전히 수화과정을 완료하기 전까지는 페이지 기능을 정상적으로 사용할 수 없습니다.

여러분과 제가 사용하는 고사양의 컴퓨터에서 이런 딜레이는 큰 문제가 되지 않을 수도 있습니다. 하지만 사양이 낮은 디바이스 환경에서 hydration 딜레이는 충분히 발생할 수 있으며 나쁜 UX를 제공하는 원인이 될 수 있기 때문에 개발자가 고려해야할 사항입니다.

1. 용량이 크거나 복잡한 로직이 담긴 컴포넌트를 제외시킨다.
2. JS 코드들이 완전히 수화될때까지 유저를 기다리게 한다.

결국 리엑트를 사용하는 모던 웹에서 interaction blocking과 slow FTP(first time to paint) 두 가지의 UX문제는 서로 트레이드 오프적인 관계를 지닐 수 밖에 없습니다.

리엑트 17버전에서의 기존 폭포수 렌더링 방식

서버에서 각 컴포넌트 렌더링에 필요한 데이터를 먼저 처리하고 전체 페이지를 렌더링해 클라이언트에 내려줍니다.
https://github.com/reactwg/react-18/discussions/37

자바스크립트 번들을 다운받고 브라우저가 불러올때까지 기다립니다. 그 이후 hydration을 진행합니다.

18버전부터는 유저에게 처음 보여지는 페이지 전체를 그려 내려주는 것이 아니라 빠르게 준비되는 부분부터 렌더링/수화 시켜주며 이 기능을 Streaming HTMLSelective Hydration 으로 명명하고 있습니다.

위의 두가지 기능이 어떻게 사용되어지는지 react18저장소에서 친절하게 설명해주고 있는데요, 예시 코드, 그림과 함께 하나씩 살펴보겠습니다.

HTML Streaming

서버에서 html을 보내주는 것을 html streaming이라고 하는데요, renderToString 을 사용한 전통적인 방식으로 SSR을 구현하면 브라우저에서는 서버에서 보내주는 html 페이지를 하나의 파일로 통째로 받았었습니다. 새로운 버전에서 서버는 pipeToNodeWritable를 이용해 html 코드를 작은 청크로 나누어 보내줄수 있습니다.

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

전체 앱 중 첫 페이지를 렌더링하는데 시간이 오래 걸리는 <Comments/> 컴포넌트를 <Suspense> 로 감쌌습니다. 리엑트에게 해당 컴포넌트를 렌더링할 준비가 되기 전까지는 fallback props로 넘긴 <Spinner/>를 대신 보여달라고 말한 것인데요, 실제로 사용자는 아래와 같은 페이지를 보게됩니다.

화면 아래에서는 이런 일들이 일어나고 있습니다. <Comments/>와 관련된 태그가 전혀 보이지를 않습니다.

<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/> 컴포넌트를 렌더링할 준비가 모두 끝나면 리엑트는 추가적인 html 코드를 스트리밍하는데요, 앞서 대신 보여줬던 폴백 엘리먼트와 대체해줍니다.

<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>

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

이전 버전까지는 전체 페이지가 준비되기까지는 사용자가 페이지의 다른 부분을 전혀 볼 수 없었습니다.
준비된 부분부터 보는 것이 가능하다는 것은 렌더링 전체 과정에 있어서
FTTB(First Time To Byte)시간이 줄어든다는 뜻이며 이것은 단순한 사용자 느낌을 떠나 정량적인 수치로도 렌더링 퍼포먼스의 향상이 있다는 것을 의미합니다.

위의 예제코드에서 등장한 <Suspense> 는 전체 페이지를 각각의 작은 청크로 나누어 렌더링할 수 있게 도와줍니다. 응답을 받는데 오래걸리는 컴포넌트에는 <Suspense>를 사용해 나머지 영역의 초기 렌더링 속도에 영향을 미치지 않게 할 수 있습니다.

<Suspense> 태그는 리엑트 18에서 처음 소개된 것이 아닙니다. 이 태그는 2018년도 처음 소개되어 클라이언트 사이드 렌더링 단계에서 큰 번들의 자바스크립트 코드들을 작은 청크들로 나누어 로드될수 있게 해주는 역할을 React.lazy와 함께 수행했습니다. 이 기능을 코드 스플리팅이라고 합니다.

React18 이전에도 코드 스플리팅을 구현해 큰 번들의 자바스크립트 코드를 청크로 잘게 나누어 로드되는 시간들을 분산시킬 수 있었습니다. 하지만 서버사이드 렌더링을 구현할 때 사용되는 renderToString과 함께 사용할 수 없었고, 정상적인 SSR환경을 구축하기 위해서는 loadable-component와 같은 서드파티 라이브러리를 함께 사용해야 했습니다. React18부터는 서드파티 라이브러리를 활용하지 않고도 <Suspense/>를 SSR 환경에서도 정상적으로 이용할 수 있게 되었습니다.

selective hydrating (선택적 수화)

이제 최소한 복잡하고 용량이 큰 <Comments/> 컴포넌트 때문에 페이지 전체가 FCP (First Content Paint)에서 손해를 보는 상황에서는 벗어날 수 있게 되었습니다. 하지만 극단적인 예시를 들어 일부 컴포넌트의 용량이 너무나 크고 복잡하다고 가정해볼까요? 다른 엘리먼트들이 javascript를 다운 받고 hydration 할 준비를 다 마쳤더라도 언제 풀릴지 모르는, 빙글빙글 돌아가는 <Spinner/>만 계속 목 놓고 바라보고 있을지도 모릅니다. 마치 여러분의 사랑스러운 형제들이 분명히 시간 약속을 잡았음에도 외출시간이 다 되도록 아직도 옷을 입기는 커녕 화장실에서 나오지 않는것처럼요. (아! 저는 외동이라서 그런 고충은 잘 모릅니다.)

이렇게 렌더링하는데 비용이 큰 컴포넌트들을 <Suspense>로 감쌈으로 인해 해당 부분이 여전히 폴백 엘리먼트를 내보내고 있어도 그와 상관없이 페이지의 다른 부분은 hydrating을 시작할 수 있게 되었습니다.

이전 버전의 Top-Down 방식과 다르게 JS 번들이 로드된 컴포넌트들은 먼저 hydration을 시작할 수 있습니다.

기존에는 번들 로드가 느린 <Comments/> 때문에 모든 페이지가 완전하게 인터렉션하게 될때까지 기다려야 했지만 이제는 사이드바나 네비게이션바가 본인들의 역할을 보다 빨리 할 수 있게 되었습니다. 유저가 해당 페이지가 완전히 동작되기 이전에 다른 곳으로 이동하고 싶다면 이미 hydration이 완료된 네비게이션 바를 이용할 수 있습니다. 빠른 인터렉션을 제공해 더 나은 UX를 제공할 수 있게 되는 것입니다.

또한 사용자의 인터렉션에 따라 어떤 것을 먼저 hydration시킬지에 대한 우선순위를 정할 수 있게 되었습니다.
아래와 같이 <SideBar/><Comments/> 두 태그가 <Suspense> 로 둘러싸여 있다고 할 때 일반적으로 hydration은 돔트리에 배치된 순서에 따라 순차적으로 진행이 됩니다. 다음과 같은 상황에서는
<SideBar/><Comments/>보다 먼저 hydration이 진행됩니다.

<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

사용자가 <Sidebar/>가 hydration 되기 이전에 <Comments/> 에 대에 관심을 가지고 버튼 클릭을 발생시켰다면

리엑트는 해당 클릭 이벤트를 기록하고 <Comments/> 부분에 대한 hydration의 우선순위를 높여서 진행합니다.

<Comments/> 에 대한 hydration이 완료되면 앞서서 기록 놓았던 클릭 이벤트를 실행하고 남은 <Sidebar/> 의 처리도 마저 진행합니다.

선택적 수화 로 인해 항상 정해진 순서를 따르지 않고 사용자가 관심있는 부분부터 인터렉션 가능한 콘텐츠를 제공할 수 있게 되었습니다.

<Suspense/> 를 좀 더 세분화 해서 여러 부모 자식 관계를 가진 컴포넌트를 대상으로 적용시킨다면 해당 기능의 장점이 좀 더 극명하게 나타납니다.

<Layout>
  <NavBar />
  <Suspense fallback={<BigSpinner />}>
    <Suspense fallback={<SidebarGlimmer />}>
      <Sidebar />
    </Suspense>
    <RightPane>
      <Post />
      <Suspense fallback={<CommentsGlimmer />}>
        <Comments />
      </Suspense>
    </RightPane>
  </Suspense>
</Layout>

아래의 그림에서는 사용자가 <Comments/> 컴포넌트 안에 있는 <Comment/>들 중 첫번째 엘리먼트를 먼저 클릭했다고 가정하겠습니다. 클릭한 요소를 둘러싸고 있는 <Suspense/> 중 최상위 부모 엘리먼트 부터 hydration을 시작하게 됩니다. 인터렉션과 관계없는 <Suspense/> 로 둘러싸인 형제 엘리먼트는 일단 hydration을 스킵하고 인터렉션이 발생한 요소부터 실행하기 때문에 hydration이 즉시 일어나는 것 같은 느낌을 줄 수 있습니다.

state batch update

배칭(batching)은 업데이트 대상이 되는 상태값들을 하나의 그룹으로 묶어서 한번의 리렌더링에 업데이트가 모두 진행될 수 있게 해주는 것을 의미합니다.

한 함수 안에서 setState를 아무리 많이 호출시키더라도 리렌더링은 단 한번만 발생합니다.

function handleClick() {
    setCount(c => c + 1); // Does not re-render yet
    setFlag(f => !f); // Does not re-render yet
    // React will only re-render once at the end (that's batching!)
  }

batch update를 사용함으로 불필요한 리렌더링을 줄일 수 있어서 퍼포먼스적으로 큰 이점을 얻을 수 있는데요, 이전 버전에서도 이런 batch update가 지원되었지만 클릭과 같은 브라우저 이벤트에서만 적용이 가능하고 api 호출에 콜백으로 넣은 함수나 timeouts 함수에서는 작동하지 않았습니다.

function handleClick() {
    fetchSomething().then(() => {
      // React 17 and earlier does NOT batch these because
      // they run *after* the event in a callback, not *during* it
      setCount(c => c + 1); // Causes a re-render
      setFlag(f => !f); // Causes a re-render
    });
  }

setTimeout(() => {
  setCount(c => c + 1); // re-render occurs
  setFlag(f => !f); // re-render occurs again!
}, 1000);

automatic batching

18버전부터는 React.createRoot 를 이용해 브라우저 이벤트 뿐만 아니라 timeouts, promises를 비롯한 모든 이벤트에서 batching이 자동으로 적용되게 할 수 있습니다. 리엑트 팀은 여러 상황에서 발생할 수 있는 렌더링 횟수를 줄임으로 인해 퍼포먼스 개선을 기대할 수 있다고 하는데요, 이 기능을 automatic batching이라고 합니다.

데모: 리엑트 18에서 createRoot를 이용해 automatic batching 이용하기
데모: 리엑트 18에서 render를 이용해 automatic batching 이용하지 않기

앞선 예제코드에서는 작동하지 않았던 배칭 업데이트가 리엑트 18버전의 createRoot 아래에서는 자동으로 적용되는 것을 확인할 수 있습니다.

// onClick 핸들러
function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}

// setTimeout
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}, 1000);

// fetch API
fetch(/*...*/).then(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
})

// addEventListener callback
elm.addEventListener('click', () => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
});

만약 automatic batching을 사용하고 싶지 않다면, ReactDOM.flushSync()를 이용해 해당 상태 업데이트 호출을 배치 대상에서 제외시킬 수 있습니다.


import { flushSync } from 'react-dom'; // Note: react-dom, not react

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // React has updated the DOM by now
  flushSync(() => {
    setFlag(f => !f);
  });
  // React has updated the DOM by now
 }

Transition

해당 기능은 상태 업데이트를 함에 있어서 우선순위를 정하는데 도움을 줍니다. 리엑트 팀은 상태 업데이트 대상을 두가지로 나누었으며 이를 통해 transition이 의미하는 바가 무엇인지 파악할 수 있습니다.

1. Urgent updates: 버튼 클릭, 키보드 입력과 같이 직관적으로 보았을 때 업데이트가 즉각적으로 일어나는 것을 기대하는 상태 값들을 대상으로 합니다.

2. Transition updates: 사용자가 상태 값의 변화에 따른 모든 업데이트가 뷰에 즉각적으로 일어나는 것을 기대하지 않습니다.

어떤 문제를 해결하나요?

타이핑과 같이 빈번하게 일어나는 이벤트에 따라 큰 화면이 업데이트 되어야 한다면 각 이벤트마다 일어나는 리렌더링이 해당 화면에 렉을 일으키거나 스무스한 UI를 제공하지 못하는 요인으로 작용할 수 있습니다.

검색 사이트에서 auto complete 기능이나 검색 필터링 기능을 사용한다고 했을때 검색한 결과값을 이용해 상태를 업데이트 하기 위해 보통 이러한 코드를 작성하게 됩니다.

// show what was typed
setInputValue(input);
 // show results
setSearchQuery(input);

검색 결과 리스트가 매우 길거나 많지 않더라도 사이트에서 검색 결과 값을 가지고 내부적으로 복잡한 작업을 진행할 수 있으며 유저의 이벤트 값이 약간이라도 달라지더라도 페이지 UI에 큰 변화를 불러이르키기에 이 때 발생하는 렉을 최적화할 수 있는 명확한 방법을 제시하기가 매우 어렵습니다.

페이지에서 사용자의 타이핑에 따라 화면이 달라지는 부분은 크게 입력 폼결과 창 두가지 입니다.
입력 창은 네이티브 이벤트를 발생하는 UI이므로 유저는 타이핑이 입력창에 즉각적으로 반영되기를 기대할 것입니다.
결과 창은 직관적으로 어디에서 검색 결과를 가져오는 작업을 하는 공간으로 느껴지기에 입력 창보다 UI 업데이트가 느린 것에 대해 자연스럽게 받아들여집니다.

// Urgent: Show what was typed
setInputValue(input);

// Not urgent: Show the results
setSearchQuery(input);

입력 창과 결과 창에 사용하는 상태 값은 항상 동일한 시간에 업데이트되기 때문에 이로인해 결과 값에 따라 입력 창의 업데이트가 지연될 가능성이 발생합니다. 지금까지는 리엑트가 모든 상태 업데이트의 우선순위를 동일하게 처리하였기 때문에 사람이 기대하는 것에 맞춰서 뷰의 각 부분에 세밀하게 우선순위를 주어 렌더링하는 것이 매우 어려웠습니다.

리엑트 18에서 소개된 startTransition을 이용해 각 상태 업데이트에 대한 우선순위를 정해줄 수 있게 되었습니다.

import { startTransition } from 'react';


// Urgent: Show what was typed
setInputValue(input);

// Mark any state updates inside as transitions
startTransition(() => {
  // Transition: Show the results
  setSearchQuery(input);
});

startTransition으로 둘러싸인 부분은 클릭이나 키 입력에 의해 우선순위 높은 상태 업데이트가 발생하게 되면 렌더링 업데이트가 중단되고 키 입력이 다 끝난 이후의 업데이트만 발생하게 됩니다.

transition을 이용해 UI가 크게 달라지는 부분이 빈번하게 발생하더라도 사용자와 페이지간의 상호작용을 신속하고 원활하게 유지할 수 있습니다. 또한 더 이상 사용자에게 보여지는 부분과 관련이 없는 콘텐츠를 렌더링하는데 있어서 시간을 낭비하지 않아도 됩니다.

유저에게 transition 업데이트가 백그라운드에서 진행됨을 알려주고 싶을 수 있습니다. 이때는 useTransition 훅을 이용해 <Spinner/>와 같은 UI를 표시해줄 수 있습니다.

import { useTransition } from 'react';

const [isPending, startTransition] = useTransition();
...
{isPending && <Spinner />}
...

보통 오토컴플리트 구현을 위해 debounce 기능을 사용합니다. debounce에는 setTimeout을 사용하는데요, startTransition은 setTimeout과는 다르게 함수 호출 스케쥴을 뒤로 미루는 것이 아닙니다. startTransition에 넘겨지는 콜백함수는 동기적으로 호출되며 콜백함수 안에서 일어나는 상태 업데이트는 transitions로 마킹되어 리엑트가 업데이트를 처리할때 어떤 우선순위로 처리해야 할지를 알려줍니다. 이 말은 즉 timeOut 함수와 같이 macroTask에 의해 둘러싸인 상태 업데이트보다 먼저 처리됨을 의미합니다. 고사양의 디바이스에서는 이러한 차이가 미세할 수 있지만, 최적화가 필수적인 디바이스 환경에서 이러한 차이는 큰 영향을 미칠 수 있습니다.

startTransition은 크게 리엑트가 UI 업데이트를 위해 크고 복잡한 일을 함으로 써 대기 시간이 발생하거나 느린 네트워크 환경에서 데이터를 받아오기 위해 기다리는 상황에서 사용한다고 합니다.

react-18 transition discussion 에서는 향후 네트워크 업데이트 상황에 transition 기능을 어떻게
사용하는지에 관한 포스트가 올라올 예정이라고 하니 더욱 많은 예시 코드를 볼수 있을 것이라고 기대합니다.

글을 마치며

이번 리엑트 18 알파버전에서 볼 수 있는 기술들 중 user experience 개선과 관련된 부분이 많은 것은 웹을 구현하는 개발자들의 입장에서는 상당히 고무적인 것 같습니다.

특히 새로운 서버사이드 렌더링 아키텍쳐는 사용자가 크고 작게 경험했던 초기 렌더링 속도 문제를 많이 해결해줄 수 있을 것이라고 생각합니다.

Transition 주제를 공부할 때는 개인적으로 Lodash의 debounce와 RxJS의 switchMap 을 이용해 검색자동완성 기능을 구현하느라 애를 먹었던 기억이 있었는데, 이제는 리엑트에서 자체적으로 제공하는 훅을 이용해 간편하게 최적화 시킬 수 있다는 점이 고맙게 다가왔습니다.

현재 공개된 기능들은 알파버전으로 프로덕션에서 사용하기에는 다소 이르지만 가까운 미래에 더 좋은 품질의 웹을 사용자들에게 제공할 수 있는 가능성을 시사함으로 관심있게 지켜볼 이유는 충분하다고 생각합니다. 프론트엔드 생태계에 더 편리한 도구를 전해 준 리엑트 팀에게 박수를 보내며 글을 맺습니다.


참고 및 자료인용:
1. New Suspense SSR Architecture in React18
2. Automatic batching for fewer renders in React 18
3. New feature: startTransition

profile
성장을 향한 작은 몸부림의 흔적들

9개의 댓글

comment-user-thumbnail
2021년 6월 25일

영상으로만 보고 잘 정리 되지 않았던 점들이 너무 깔끔하게 잘 이해됐슴다... 감사합니다!

답글 달기
comment-user-thumbnail
2021년 6월 25일

역시 한글번역은 너무 잘 읽히네요...ㅎㅎ 좋은 글 감사합니다!

답글 달기
comment-user-thumbnail
2021년 6월 26일

잘 읽고 갑니다 :)

답글 달기
comment-user-thumbnail
2022년 4월 12일

좋은 글 감사합니다! 너무 정리 잘되어 있네요 ㅎㅎ

1개의 답글
comment-user-thumbnail
2022년 7월 6일

감사합니다!

1개의 답글