728x90

들어가며...

useNavigation훅을 통해서 상태를 저장할 수 있습니다. 해당 상태는 uri에 직접적으로 반영이 되지 않는 상태입니다.
따라서  새로고침 시 막연하게 초기화 될 거라고 생각을 했었습니다. 하지만 실제 테스트 해 본 결과 상태가 초기화가 되지 않았습니다. 심지어 ChagGPT 조차 상태가 초기화 된다고 하더군요..



import { useNavigate } from 'react-router';

const Home = () => {
  const navigate = useNavigate();

  return (
    <div>
      <h1>Home</h1>
      <button
        onClick={() => navigate('/test', { state: { message: '저장됨' } })}
      >
        Test 페이지로 이동
      </button>
    </div>
  );
};

export default Home;

 

message에 저장됨이라는 상태를 여전히 가지고 있었습니다. useNavigate훅은 결국 History API 기반의 라이브러리 이기에 혹시 HTML 파일에서도 확인해보았고 여전히 초기화되지 않음을 확인하였습니다.

 

따라서 History API의 state의 스펙에 대해서 알아보기로 생각을 하였습니다.

결론은 브라우저에서는 Session History에  상태를 기록을 하는데, 해당 상태는 복원이 가능해야 합니다. 따라서 메모리에 기록이 되어야 하기 때문에, 새로고침을 하여도 다른 탭을 열거나 기존 탭을 닫는 방식이 아니면 상태가 유지가 됩니다.


History에 대해서

시작점은 History였습니다. 당연히 MDN에는 상태의 생명주기에 대한 언급이 있지 않을까라는 막연한 기대감에 접근하게 되었습니다.

History API는 history 전역 객체를 통해 브라우저 세션 히스토리(웹 익스텐션 히스토리와 혼동해서는 안 됩니다.)에 대한 접근을 제공합니다. 사용자의 방문 기록을 앞뒤로 탐색하고, 방문 기록 스택의 내용을 조작할 수 있는 유용한 메서드와 속성을 노출합니다.

 

해당 문서에서 History State에  대해 찾아보니 "History.state 속성은 현 history에 해당하는 state값을 나타냅니다." 라는 내용이 끝이더군요.

따라서 더 깊게 파고들기 위해서는 HTML의 History 스펙을 확인할 필요가 있었습니다.

history.state : Returns the classic history API state of the active session history entry, deserialized into a JavaScript value.

 

이제 Session history에 대해서 뭔저 알 필요가 생겼네요.

 

세션 히스토리 엔트리는 여러 가지 데이터를 포함하는 구조체(struct)로, 다음과 같은 요소를 가집니다.

  • step: 방문 순서를 나타내는 0 이상의 정수 또는 "pending"(초기값 "pending").
  • URL: 해당 히스토리 엔트리에 저장된 페이지의 URL.
  • document state: 해당 페이지의 문서 상태.
  • classic history API state: window.history.pushState()나 window.history.replaceState()를 통해 저장된 직렬화된 상태 데이터. 기본적으로 StructuredSerializeForStorage(null)로 초기화됨.
  • navigation API state: 네비게이션 API 관련 직렬화된 상태 데이터. 초기값은 StructuredSerializeForStorage(undefined).
  • navigation API key: 네비게이션 API에서 사용하는 UUID(고유 식별자).
  • navigation API ID: 개별 네비게이션 요청을 구분하는 또 다른 UUID(고유 식별자).
  • scroll restoration mode: 스크롤 복원 방식 ("auto"가 기본값).
  • scroll position data: 문서에서 스크롤이 복원될 위치 데이터.
  • persisted user state: 사용자가 입력한 데이터 등 브라우저가 유지할 상태 (기본값 null).

history의 state의 경우에 Session history에 보관되고 있습니다. 이때 직렬화를 거치는데 직렬화를 2가지 목적으로 사용됩니다.

 

1. 미리 처리된(preparsed) 상태를 URL에 저장하는 것

  • 간단한 경우에는, URL에 상태를 저장하면 개발자가 따로 파싱(parsing) 하지 않아도 된다.
  • 다만, URL이 사용자들 사이에서 공유될 수도 있으므로, 어떤 경우든 결국에는 파싱이 필요하다.
  • 하지만 URL에 저장된 상태를 빠르게 가져올 수 있으므로, 약간의 최적화 효과가 있다.

2. URL에 저장하지 않고, 현재 문서(Document)에서만 필요한 상태를 저장하는 것

  • 특정 상태는 새로운 문서가 열리면 다시 생성해야 하므로 URL에 저장하기 적절하지 않다.
  • 이런 경우, 직렬화된 상태를 이용해 브라우저 히스토리에서 해당 상태를 보존하고 복원할 수 있다.

 

이후 공식스펙에는 7.4.6.5 Persisted history entry state 이 존재하는데, 유저의 편의를 위해 히스토리를 복원할 수 있는 방법이 존재해야 합니다.

 

공식스펙을 확인했지만,, 아직까지 왜 새로고침 시에도 값이 유지되는지에 대해서는 명확하지 않습니다.

이를 위해서 크롬에서는 세션 히스토리를 어떻게 사용하는지에 대해서 확인해보았습니다.

 

크롬의 History Session

브라우저의 세션 히스토리는 각 탭에서 발생한 탐색을 추적하여 뒤로 가기/앞으로 가기 탐색과 세션 복원을 지원한다. 이는 chrome://history 같은 기록(history)과는 다른데, 기록은 프로필의 수명 동안 사용자가 방문한 주요 프레임의 URL을 모든 탭에 걸쳐 저장한다.

 

위에서 스펙에서 확인한대로 복원 기능을 가지고 있네요.

 

그리고 복원 기능에 대해서 확인하면 아래와 같습니다.

탭의 공동 세션 히스토리는 유지되므로, Chromium을 다시 시작하거나, 탭을 닫은 후, 또는 다른 기기에서 탭을 복원할 수 있다. 이를 위해 각 NavigationEntry와 그 내부 FrameNavigationEntries 트리의 상태를 PageState 객체 및 기타 메타데이터를 사용하여 직렬화해야 한다. 새로운 값을 안전하게 저장하고 복원하는 방법에 대해서는 Modifying Session History Serialization을 참고하라.

 

마지막으로  Modifying Session History Serialization 에서 직렬화 방법을 변경시 디스크에 남아있는 데이터에 의한 호완 문제가 생길 수 있다는 점을 경고하고 있는데, 이를 통해서 크롬에서 disk에 Session History가 저장되어있음을 알 수 있습니다. 따라서 새로고침시에도 History.state가 유지가 됩니다. 

Note that changing the serialization format is high risk and should be approached carefully. Mistakes or missed steps can cause backwards compatibility problems, because the effects can continue to live on disk between different versions of Chromium

 

 

느낀점

사실 처음에는 History.state의 생명주기 라고 검색하면 금방 해결할 수 있는 지식이라 생각했습니다. 하지만 관련 키워드로는 검색했을 때 나오지 않더군요. 공식 스펙을 보는 것도 제로초님이 영상에서 보시는 걸 본적은 있지만 직접 본 적은 없어서 많이 어렵게 느껴졌네요. 해당 스펙이 존재한다는 것이 명시되었을 뿐 어떻게 구현할 지는 브라우저마다 다를 수 있다는 사실도 어려웠습니다. 영어를 번역하면서 열심히 읽었는데, 혹시 오역이 있었을까에 대한 막연한 걱정도 조금 남아있네요. 혹시 잘못된 부분이 있으면 지적부탁드립니다 ㅠ

 

그래도 덕분에 history가 트리구조가 아니라 리스트 구조가 되었다는 등 많은 정보도 확인할 수 있었네요..
또한 ChatGpt 를 사용할 떄 좀 더 유의할 필요성을 느꼈습니다.  사실 새로고침 시 history.state가 초기화 되는지 여부 정보는 고차원적인 지식은 아니라 생각했는데 ChatGpt가 잘못 알려주더군요.
그리고 확실하게 새로고침에서 history.state가 사라지진 않는다는 지식을 얻어가네요.

참고링크

.https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/modifying_session_history_serialization.md

 

Chromium Docs - Modifying Session History Serialization

Modifying Session History Serialization Note: Please expand these steps as needed. See also NavigationEntryImpl comments for how to save and restore values outside of PageState, which is less common. Overview The following (non-exhaustive) steps are requir

chromium.googlesource.com

https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface

 

HTML Standard

 

html.spec.whatwg.org

https://developer.mozilla.org/ko/docs/Web/API/History

 

History - Web API | MDN

History 인터페이스는 브라우저의 세션 기록, 즉 현재 페이지를 불러온 탭 또는 프레임의 방문 기록을 조작할 수 있는 방법을 제공합니다.

developer.mozilla.org

https://chromium.googlesource.com/chromium/src/+/master/docs/session_history.md#persistencehttps://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/modifying_session_history_serialization.md

 

Chromium Docs - Modifying Session History Serialization

Modifying Session History Serialization Note: Please expand these steps as needed. See also NavigationEntryImpl comments for how to save and restore values outside of PageState, which is less common. Overview The following (non-exhaustive) steps are requir

chromium.googlesource.com

 

728x90

과거에 next.js의 튜토리얼을 진행하면서 얕게 streaming에 대해 학습한 적이 있습니다. 그 당시에 깊게 이해하지 못한 부분에 대해서 다시 한번 정리하고자 포스팅을 작성하게 되었습니다.

 

전통적인 SSR의 단점

SSR은 서버에서 완성된 HTML을 클라이언트에 전송해줍니다. SSR이 진행되는 과정은 아래와 같습니다.

1. 먼저, 특정 페이지에 필요한 모든 데이터가 서버에서 가져와집니다.
2. 그런 다음, 서버에서 해당 페이지의 HTML을 렌더링합니다.
3. 이후, 페이지의 HTML, CSS, 그리고 JavaScript가 클라이언트로 전송됩니다.
4. 전송된 HTML과 CSS를 사용하여 비상호작용 UI가 화면에 표시됩니다.
5. 마지막으로, React가 UI를 하이드레이션(hydration) 하여 상호작용할 수 있도록 만듭니다.

그렇기에 한 페이지 내에서도 모든 구역이 동시에 작업이 완료되진 않습니다. 하지만 우린 완성된 HTML을 받기 때문에, 하나라도 오래 걸리는 작업이 생기면 HTML을 받지 못하는 문제점을 겪게 됩니다.

출처 : https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#what-is-streaming

 

 

이를 보완해주는 것이 Next.js의 Streaming 기능입니다.

 

Streaming이란?

Next.js에서는 Streaming을 아래와 같이 정의합니다

Streaming 데이터를 전송하는 기술로, 하나의 라우트를 더 작은 "청크(chunks)"로 분할하여 서버에서 클라이언트로 준비되는 대로 점진적으로 스트리밍할 수 있도록 합니다.
Streaming is a data transfer technique that allows you to break down a route into smaller "chunks" and progressively stream them from the server to the client as they become ready.

 

 

그 결과  Time To First Byte (TTFB) , First Contentful Paint (FCP) ,Time to Interactive (TTI) 를 향상 시킬 수 있습니다.

 

상호작용 시작 시간  |  Lighthouse  |  Chrome for Developers

Lighthouse의 상호작용 시작 시간 측정항목과 이 측정항목을 측정하고 최적화하는 방법을 알아보세요.

developer.chrome.com

 

Streaming 사용방법

Streaming은 크게 2가지의 경우로 사용법이 구분됩니다.

1. 페이지단위로 사용하기

2. 컴퍼넌트단위로 사용하기

 

1. 페이지 단위로 사용하기

export default function Loading() {
  // You can add any UI inside Loading, including a Skeleton.
  return <LoadingSkeleton />
}

 

Lodaing.tsx 파일은 React Suspense를 기반으로 하는 Next.js의 특별한 파일로, 페이지 콘텐츠가 로드되는 동안 대신 표시할 Fallback UI를 생성할 수 있도록 해줍니다. 보통 Layout.tsx에 위치합니다.

 

2. 컴퍼넌트 단위로 사용하기

import { Suspense } from 'react'
import { PostFeed, Weather } from './Components'
 
export default function Posts() {
  return (
    <section>
      <Suspense fallback={<p>Loading feed...</p>}>
        <PostFeed />
      </Suspense>
      <Suspense fallback={<p>Loading weather...</p>}>
        <Weather />
      </Suspense>
    </section>
  )
}

 

이 경우에는 react에서 제공하는 Suspense를 활용하여 Streaming을 사용할 수 있습니다.

 

 

 

참고 링크

 

https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#what-is-streaming

 

Routing: Loading UI and Streaming | Next.js

Built on top of Suspense, Loading UI allows you to create a fallback for specific route segments, and automatically stream content as it becomes ready.

nextjs.org

https://nextjs.org/learn/dashboard-app/streaming

 

App Router: Streaming | Next.js

Improve your application's loading experience with streaming and loading skeletons.

nextjs.org

 

 

 

 

728x90

React, Vue , Angular 는 보통 프론트엔드 UI 프레임워크로 묶여서 자주 언급됩니다. 사실 처음 개발을 시작할 때, 유저 수가 가장 많은 React를 선택했고 React기반의 RN 이나 Next.js를 사용해봤지만 아직 다른 UI프레임워크를 경험한 적이 없습니다. 그래서 React 다음으로 유저수가 많은 Vue의 공식문서를 통해 학습하고 간단한 TODOLIST를 만들며 느낀점을 공유하고자 합니다.

 

VUE 에 대해 전해 들은 장점

VUE가 쉽다는 얘기를 평소에 많이 들었습니다. 면접을 갔을 때도 생산성에 대한 질문도 많이 받았고, VUE를 쓰는 기업이라면 VUE의 생산성에 대해서 긍정적인 얘기를 합니다.  또한 React와 다른 양방향 바인딩에 대한 얘기도 많이합니다. 

https://www.youtube.com/watch?v=-tVaahsXpwk&list=PLfLgtT94nNq3Br68sEe26jkOqCPK_8UQ-&index=1

 

또한 Vue를 쓰다 React를 쓰는 경우 리렌더링을 잡기 어렵다는 얘기도 많이 들었습니다.

 

하지만 React를 쓰는 입장에서 사실 생산성이란 익숙함에서 더욱 기인하지 않을까?라는 생각이 들었고, 굳이 Vue에 대해 학습할 필요성을 느끼기 보단, React를 더욱 잘 쓰는 방법이 제 관심사였습니다.

하지만 최근 들어 Vue에 대해서 경험하고자 간단하게 Todolist를 작성하면서 학습하게 되었습니다.

 

 

Vue와 React의 비교

1. 진입점

진입점의 경우에는 비슷하다고 느꼈습니다.

// react의 main.tsx

import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
    <App />
)

 

// vue의 main.ts

import { createApp } from 'vue';
import './style.css';
import App from './App.vue';

createApp(App).mount('#app');

 

 함수명이 Root와 app이 다르고 render냐 mount냐 다르지만 결국 함수들이 선언적으로 되어있고, 의도하는 바는 동일해 보입니다.

 

2.  SFC와 JSX(TSX)

Vue의 경우에는 SFC라는 .vue로 파일이 구성되어 있고, React의 경우 컴퍼넌트를 jsx 혹은 tsx에서 사용합니다.

 

//App.vue

<script lang="ts">
import { defineComponent } from 'vue';
import TodoApp from './components/TodoApp.vue';

export default defineComponent({
  components: {
    TodoApp,
  },
});
</script>

<template>
  <div id="app">
    <h1>Todo List</h1>
    <TodoApp />
  </div>
</template>

<style>
#app {
  font-family: Arial, sans-serif;
  text-align: center;
  margin: 20px;
}
</style>

 

// App.tsx
import TodoApp from "./components/TodoApp";

function App() {
  return (
    <div>
      <h1>Todo List</h1>
      <TodoApp />
    </div>
  );
}

 

SFC는 Single-File Components 의 약자로  html, css, js를 하나의 컴퍼넌트에서 관리하는 Vue의 파일형식입니다.

template에서 html , script 에서 js , style에서 css를 다룹니다. 물론 별도의 module을 import하는 방식으로 css와 js를 관심사 분리할 수도 있습니다. 하지만  Vue측의 입장은 "관심사항의 분리가 파일 유형의 분리와 동일한 것이 아니다는 관점으로 바라보는 것이 중요하다"입니다. 하나의  컴퍼넌트가 명확하게 관심사가 분리가 되어있다면 , 굳이 css나 js를 따로 분리하기보다 응집되는 것 또한 매력적일 수 있기 때문입니다.

이때 style의 경우에는  scoped 태그를  적용함으로서 module.css 역할을 할 수 있게 할 수 있습니다.

 

3. 상태 관리

vue의 경우에는 v-model을 통한 양방향 바인딩을 지원합니다.

아래는 vue로 만든 간단한 todolist 입니다.

<script lang="ts">
import { defineComponent, ref } from 'vue';

// Todo 타입 정의
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

export default defineComponent({
  name: 'TodoApp',
  setup() {
    // 상태 정의
    const newTodo = ref<string>(''); // 입력 필드 상태
    const todos = ref<Todo[]>([]); // 투두리스트 배열

    // 할 일 추가
    const addTodo = () => {
      if (newTodo.value.trim() === '') return;
      todos.value.push({
        id: Date.now(), // 고유 ID 생성
        text: newTodo.value.trim(),
        completed: false,
      });
      newTodo.value = '';
    };

    // 할 일 삭제
    const removeTodo = (index: number) => {
      todos.value.splice(index, 1);
    };

    // 할 일 완료/미완료 토글
    const toggleComplete = (index: number) => {
      todos.value[index].completed = !todos.value[index].completed;
    };

    return {
      newTodo,
      todos,
      addTodo,
      removeTodo,
      toggleComplete,
    };
  },
});
</script>

<template>
  <div>
    <div class="input-container">
      <input
        type="text"
        v-model="newTodo"
        placeholder="할 일을 입력하세요"
        @keyup.enter="addTodo"
      />
      <button @click="addTodo">추가</button>
    </div>
    <ul>
      <li
        v-for="(todo, index) in todos"
        :key="todo.id"
        :class="{ completed: todo.completed }"
      >
        <span @click="toggleComplete(index)">{{ todo.text }}</span>
        <button @click="removeTodo(index)">삭제</button>
      </li>
    </ul>
  </div>
</template>

 

vue의 경우에는 ref나 reactive등을 활용하여 상태관리를 할 수 있습니다. 

이때 v-model을 활용한 양방향 바인딩을 통해서 손쉽게 이벤트 핸들러 및 상태관리를 할 수 있습니다.

 

 

Vue를 사용하며 느낀점

vue를 사용하며 느낀점은 만약 바닐라 자바스크립트까지만 사용한 시점에서 새로운 프레임워크를 배운다면 vue가 react보다 사용이 쉽다고 느껴졌습니다. 컴퍼넌트의 경우에 매번 리랜더링되는 react와 다르게 Vue의 SFC파일은 변경된 상태에 대해서만 감지하여 최적화 측면에서도 수월한 점이 존재하다고 느꼈습니다. 

 

하지만 사용 유저가 많다는 커뮤니티적인 장점도 존재하고, 익숙함이 존재하는 시점에서 제게는 React가 더 쉽게 느껴졌습니다. Vue만의 독특한 문법들도 많이 존재하였습니다. 새로 배우는 입장에서 외울게 많다는 게 비용으로 느껴졌고, 좀 더 javascript 답게 코드를 작성할 수 있는 것은 React라는 느낌을 받았습니다. 

또한 오픈소스의 양적 측면에서도 React가 유리하다고 느꼈습니다.

 

다만 React와 Vue의 공식문서를 읽으면서 css의 중첩을 막기 위해 module이나 css-in-js를 사용하여 지역단위로  css를 적용하는 방식이나  사이드 이펙트를 감지하기 위해서 watcher 혹은 useEffect를 사용하는 방식이 결국 프레임워크는 도구나 사용법의 차이지 도달하는 방향은 유사하다는 느낌을 받았습니다.

 

 

 

 

 

728x90

부모님이 컴퓨터에 익숙하지 않다보니, 대신 예약을 해드리는 일이 종종 있습니다.  골프 예약 같은 경우에 경쟁이 치열하다 보니 종종 실패하더군요... 그래서 관련해서 도움을 받을 수 있는 예약 도우미 확장자를 만들었습니다.

 

그 과정에 보편적으로 쓸만한 기능들 그리고 트러블 슈팅 내역을 공유하고자 하여 작성하게 되었습니다.

 

제가 생각하는 기능은

1. 단축키를 통해서 원하는 시간에 예약하기

2. 그리고 해당 페이지에서 예약 과정 처리하기입니다.

 

이 과정에서 필요하다 느낀 기능은 알람, 페이지 접근, 스크립트 사용, 저장소 사용입니다. 

(확장 프로그램의 경우에 결국 브라우저가 아니기 떄문에 웹api에서 제공하는 alert대신 를 chrome api에서 notification을 사용해야 하더군요...) 돔 요소를 제어하는 거는 기존 프론트엔드에서 하던 js를 사용하면 되지만 다른 기능들을 구현하기 위해선 추가적인 확장 프로그램에서 어떤 기능을 제공하는지를 알 필요가 있었습니다.

 

우선 공식 문서를 통해서 학습하려고 했습니다. 개인적으로 구글측 공식문서의 경우에 하나의 완성된 앱이 아닌, 부분적인 코드 위주로 보여줘서 처음부터 보면 어렵게 느껴지더군요..


그래서 블로그를 서칭해서 완성된 예시 2개를 참고했습니다.

https://yscho03.tistory.com/103

 

크롬 확장 프로그램(Chrome Extension)을 개발해보자

크롬 확장 프로그램 (Chrome Extension) 소개 Chrome Extension 이란? Chrome 브라우저의 작은 소프트웨어 프로그램이다. 활용분야 생산성 도구 웹 페이지 콘텐츠 보강 정보 집계 게임 동작방식 웹과 동일하

yscho03.tistory.com

 

https://m2kdevelopments.medium.com/19-understanding-chrome-extensions-commands-3bfa5ecd62cd

 

19 Understanding Chrome Extensions Commands

The chrome.commands API is used to enable the chrome extension run actions from keyboard shortcuts.

m2kdevelopments.medium.com

 

첫 번째 게시글은 한글로 되어있다보니, 어떻게 확장 프로그램 앱이 구성되어있는지, 그리고 등록은 어떻게 하는지에 대해서 파악하기 좋았습니다. 

그리고 두 번쨰 게시글에서는 command 입력에 대해서 전체적인 흐름을 잡기 좋았습니다.

이후 필요한 기능에 대해서 공식 문서를 확인하며 적용시켰습니다.

이제 학습 과정에 대해서 공유드렸고, 어떻게 구현했는지에 대해서 설명하고자 합니다.

 

확장프로그램 개발하기

완벽한 코드를 공유하기보다는 어떤 식으로 기능을 구현했는지를 설명하고자 합니다.


manifest.json

manifest.json은 확장 프로그램의 진입점 역할을 합니다. 어떤 기능을 하는지, 어떤 권한을 하는지를 작성할 수 있습니다.

이제 어떤 키값을 설정했고, 그에 대한 설명을 이어나가려고 합니다.

확장 프로그램을 만들려면 최소한의 키가 필요한데 이는 name, description , version ,manifest_version, icons입니다.
각각 확장 프로그램의 이름, 설명, 프로그램 버전 관리, 사용하고 있는 manifest의 버전, icon입니다.

이 중 manifest버전은 크롬에서 제공하고 있는 버전을 표시하는 것으로 가장 최신인 버전이 3입니다.

 

모든 키 값을 사용하진 않고, 설명하기엔 방대하기에 관련 내용은 상단 manifest.json링크를 통해 확인하면 됩니다.

저는 제가 사용한 키 값에 대해서만 우선 설명하려고 합니다.

// manifest.json

{
  "name": "예약 도우미",
  "description": "~~~ 설명.",
  "version": "0.01",
  "manifest_version": 3,
  "permissions": ["storage", "notifications", "scripting"],
  "host_permissions": ["https://특정사이트.co.kr/*"],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "/images/icons-16.png",
      "32": "/images/icons-32.png",
      "48": "/images/icons-48.png",
      "128": "/images/icons-128.png"
    }
  },
  "commands": {
    "_execute_action": {
      "suggested_key": {
        "default": "Ctrl+Shift+Y"
      },
      "description": "Execute action when the extension icon is clicked"
    },
    "start_reservation": {
      "suggested_key": {
        "default": "Alt+T"
      },
      "description": "예약 시작"
    },
    "fill_cert_no": {
      "suggested_key": {
        "default": "Alt+U"
      },
      "description": "인증 번호 입력"
    }
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches":["https://특정사이트.co.kr/*"],
      "js": ["content.js"]
    }
  ],
  "icons": {
    "16": "/images/icons-16.png",
    "32": "/images/icons-32.png",
    "48": "/images/icons-48.png",
    "128": "/images/icons-128.png"
  }
}

 

permission :확장 프로그램이 특정 권한을 사용할 때 유저에게 안내를 하기 위해 사용합니다. 권한이 등록되지 않은 기능은 작동하지 않으므로 내가 어떤 기능을 구현하고 그러기 위해 필요한 권한에 대해서 확인할 필요가 있습니다.

제가 사용한 권한은 아래 3가지입니다.

  • storage: 브라우저의 저장소(local storage)에 데이터를 저장하거나 읽는 권한.
  • notifications: 알림(notification)을 생성하는 권한.
  • scripting: 실행 중인 탭에 스크립트를 주입하는 권한.

host_permissions :  확장 프로그램이 작동할 도메인을 지정합니다.  모든 url에 대해서라면 <all_urls> 을 사용하면 됩니다.

 

action :  확장 프로그램을 클릭했을 떄 열리는 페이지에 대해서 정의합니다.

commands : 키보드 단축키를 등록하기 위해서 사용합니다. 사용할 수 키등이 존재하니  공식문서를 확인하길 바랍니다.

이때 각각의 키에 대해서 입력을 하면 추후 background(service_worker)에서  사용할 수 있습니다. 유의할 점은 _execute_action의 경우에는 팝업창을 여는데 사용됩니다. 

그리고 각각의 운영체제에 따른 입력 값을 지정할 수도 있습니다.

 

backgrond :  다른 페이지를 이용중일떄도 작동하게 할 내용(백그라운드에서)을 담을 수 있습니다.  저는 commands를 통해 등록한 키가 감지되면 background.js에서 특정 함수가 작동하도록 구현하였습니다.

content  : 컨텐츠 스크립트는 지정된 URL에서 실행됩니다., matches:를 통해서 특정 페이지에서만 스크립트가 실행하도록 하였습니다.

 

popup.html

팝업창의 경우 일반적인 html과 똑같습니다. 해당 페이지에서는 브라우저 api를 사용할 수 있습니다.(ex : alert) 

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="popup.css" />
  </head>
  <body>
    <div>
      <label for="startInput">시작 시간 (예 : 11:00)</label>
      <input type="text" id="startInput" placeholder="11:00" maxlength="5" />
    </div>
    <div>
      <label for="endInput">종료 시간 (예: 13:00):</label>
      <input type="text" id="endInput" placeholder="15:00" maxlength="5" />
    </div>
    <button id="submit">시간 설정</button>
    <script src="popup.js"></script>
  </body>
</html>

 

 

이후  popup.js에서 버튼이 클릭될 경우 storage에 해당 시간을 저장하여 추후 다른 script에서도 사용할 수 있도록 하였습니다.

document.addEventListener('DOMContentLoaded', () => {
  const submitButton = document.getElementById('submit');
  const startInput = document.getElementById('startInput');
  const endInput = document.getElementById('endInput');

  submitButton.addEventListener('click', () => {
    const startTime = startInput.value.trim();
    const endTime = endInput.value.trim();

    if (!startTime || !endTime) {
      alert('시작 시간과 종료 시간을 모두 입력해주세요.');
      return;
    }

    if (!/^\d{2}:\d{2}$/.test(startTime) || !/^\d{2}:\d{2}$/.test(endTime)) {
      alert('시간 형식이 잘못되었습니다. 예: 11:00');
      return;
    }
	// chrome.storage api
    chrome.storage.local.set({ startTime, endTime }, () => {
      alert(`희망예약시간이 ${startInput} ~ ${endInput}으로 설정되었습니다`);
    });
  });
});

 

 

background.js

 

background.js에서 탭에 접근해서 돔 요소를 조종하려고 합니다. 하지만 일반적으로 tab에 접근할 경우에는 권한 문제가 발생합니다. activate_tabs를 통해서 간단하게 해결할 수도 있지만, 그럴 경우 사용자에게 불안감을 야기시킵니다.

따라서 크롬에서는 권한을 최소화 하는 방식을 권장합니다

이에 대한 대안이 content.js를 사용하는 방식입니다.

 

  • 콘텐츠 스크립트는 웹 페이지의 DOM과 상호작용할 수 있는 중간다리 역할을 합니다.
  • 백그라운드 스크립트에서 데이터를 전달받아 DOM을 수정하거나 이벤트를 실행합니다.

우선 코드를 먼저보겠습니다.

 

 

// 커맨드 감지 리스너
chrome.commands.onCommand.addListener((command) => {
  chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
    if (tabs.length === 0) {
      notify('활성화된 탭이 없습니다.');
      return;
    }

    const tab = tabs[0];
    const url = tab.url || '';

    // 허용된 URL에서만 명령 실행
    if (!url.startsWith('url주소')) {
      notify('이 페이지에서는 확장 프로그램을 사용할 수 없습니다.');
      return;
    }

    // 명령 처리
    if (command === 'start_reservation') {
      chrome.storage.local.get(['startTime', 'endTime'], (data) => {
        const { startTime, endTime } = data;

        if (!startTime || !endTime) {
          notify('저장된 시간이 없습니다. 시간을 먼저 설정해주세요.');
          return;
        }

        const startNumeric = parseInt(startTime.replace(':', ''), 10);
        const endNumeric = parseInt(endTime.replace(':', ''), 10);

        // 콘텐츠 스크립트로 시간 범위를 전달
        chrome.scripting.executeScript(
          {
            target: { tabId: tab.id },
            files: ['content.js'],
          },
          () => {
            chrome.tabs.sendMessage(tab.id, {
              action: 'start_reservation',
              startNumeric,
              endNumeric,
            });
          }
        );
      });
    }

    if (command === 'fill_cert_no') {
      // 콘텐츠 스크립트에 fill_cert_no 액션 전달
      chrome.scripting.executeScript(
        {
          target: { tabId: tab.id },
          files: ['content.js'],
        },
        () => {
          chrome.tabs.sendMessage(tab.id, { action: 'fill_cert_no' });
        }
      );
    }
  });
});

function notify(message) {
  chrome.notifications.create({
    type: 'basic',
    iconUrl: '/images/icons-32.png',
    title: '알림',
    message: message,
  });
}

 

chrome.scripting.executeScript :  백그라운드 스크립트에서 특정 탭에 스크립트를 삽입할 때 사용됩니다. 이를 통해 웹 페이지의 DOM에 접근하거나, 콘텐츠 스크립트를 실행할 수 있습니다.

chrome.runtime.sendMessage :  크롬 확장 프로그램 내에서 서로 다른 스크립트 간에 메시지를 주고받는 데 사용

 

// content.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'start_reservation') {
    const { startNumeric, endNumeric } = message;

    // 예약 실행 로직
    executeReservation(startNumeric, endNumeric);
  }

  if (message.action === 'fill_cert_no') {
    // 인증 번호 입력 로직
    fillCertNo();
  }
});

 

이후 content.js에서도 리스너를 통해서 해당 함수를 사용합니다.

 

 

 

느낀점

환경자체가 다르고 사용하는 기능이 다르기 때문에 낯설게 느껴졌습니다. 모든 기능을 사용하거나 파악하기는 초기에 힘들기에 필요한 기능 위주로 검색하고 사용하는 것이 효율적이라고 느껴졌습니다.

 

모든 코드를 공개하지 않은 이유는.. 모든 프로젝트가 같은 로직을 사용하진 않을것이기 때문에, 그리고 저는 특정 사이트에만 적용되도록 구현했어서 공개할 필요는 없다고 생각했습니다. 다만 기본적인 원리 위주로  공개를 할 경우 타 프로젝트에 적용시에 더 유용하다 판단하였습니다.

 

728x90

들어가며...

 

최근 WebRTC 에 대한 관심이 생겨서 클론 코딩을 해보았다. 이를 정리하고자 포스팅을 시작했습니다.

WebRTC  : 출처 (MDN)
(Web Real-Time Communication)은 웹 애플리케이션과 사이트가 중간자 없이 브라우저 간에 오디오나 영상 미디어를 포착하고 마음대로 스트림할 뿐 아니라, 임의의 데이터도 교환할 수 있도록 하는 기술입니다

 

흔히 ZEP, gather 와 같은 메타버스 플랫폼 경우에 WebRTC를 활용하여 실시간 스트리밍을 가능하게 해줍니다.

 

 

1단계 장치 정보 받기

WebRTC로 P2P 통신을 하기 이전에 우선 장치 정보를 받아올 필요가 있습니다. 이때 사용하는 함수가
navigator.mediaDevices.getUserMedia 입니다. 이때 MediaDevices.enumerateDevices() 을 이용하면 그전 단계에서 

사용 가능한 디바이스 정보를 가져올 수 있습니다.

  const getMediaStream = useCallback(
    async (faceMode?: string) => {
      if (localStream) {
        return localStream;
      }

      try {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const videoDevices = devices.filter(
          (divice) => divice.kind === 'videoinput'
        );
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: true,
          video: {
            width: { min: 640, ideal: 1280, max: 1920 },
            height: { min: 360, ideal: 720, max: 1080 },
            frameRate: { min: 16, ideal: 30, max: 30 },
            facingMode: videoDevices.length > 0 ? faceMode : undefined,
          },
        });
        setLocalStream(stream);
        return stream;
      } catch (error) {
        console.log('failed to get stream', error);
        setLocalStream(null);
        return null;
      }
    },
    [localStream]
  );

 

 

2단계 PEER 연결하기

 

복잡한 P2P 연결에 대해서 알기 위해선 우선 네트워크에 관한 지식이 필요합니다. 비디오 스트리밍의 경우 데이터를 서버를 통해 직접적으로 전달하면 많은 부하가 생깁니다. 따라서 P2P 연결을 하는데, 이를 위해선 상대방의 IP 주소가 필요합니다. 이때 사용할 기술들은 보통 NAT, STUN 서버 ,ICE 입니다.

NAT (Network Address Translation)와 STUN

  • NAT는 사설 네트워크(private network) 내에서 사용하는 private IP 주소를 공인 IP(public IP 주소)로 변경하는 기술입니다. 이는 보안상 이유로 외부와의 직접적인 연결을 차단하고, 여러 장치가 하나의 공인 IP를 공유할 수 있게 해줍니다.
  • STUN (Session Traversal Utilities for NAT) 서버는 NAT 방화벽을 통과할 수 있도록 도와주는 역할을 합니다. 이 서버는 클라이언트에게 자신의 공인 IP 주소포트 번호를 알려주어, 외부에서 접근할 수 있도록 합니다. 하지만 STUN만으로는 NAT 뒤에 있는 장치 간의 연결을 완전히 해결할 수 없을 때도 있습니다.

ICE (Interactive Connectivity Establishment)

  • ICE는 WebRTC에서 두 피어 간의 연결을 설정할 때 최적의 경로를 찾는 프레임워크입니다. ICE는 STUN과 TURN 서버를 활용하여, NAT 뒤에 있는 두 장치가 서로 연결할 수 있도록 도와줍니다.
    • STUN은 NAT 방화벽을 통과할 수 있도록 공인 IP와 포트를 알려주는 데 사용됩니다.
    • TURN은 직접적인 연결이 불가능한 경우 중계 서버를 통해 연결을 지원합니다.

 

따라서 우리는 장치정보를 바탕으로 peer를 생성할 것 입니다. 이때 구글의 무료 스턴 서버를 사용하였고, 연결이 되면 peer의 stream을 통해서 p2p 연결 후 데이터를 주고 받습니다.

  const createPeer = useCallback(
    (stream: MediaStream, initiator: boolean) => {
      const iceServers: RTCIceServer[] = [
        {
          urls: [
            'stun:stun.1.google.com:19302',
            'stun:stun1.1.google.com:19302',
            'stun:stun2.1.google.com:19302',
            'stun:stun3.1.google.com:19302',
          ],
        },
      ];
      const peer = new Peer({
        stream,
        initiator,
        trickle: true,
        config: { iceServers },
      });

      peer.on('stream', (stream) => {
        setPeer((prevPeer) => {
          if (prevPeer) {
            return { ...prevPeer, stream };
          }
          return prevPeer;
        });
      });
      peer.on('error', console.error);
      peer.on('close', () => handleHangup({}));

      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const rtcPeerConnection: RTCPeerConnection = (peer as any)._pc;

      rtcPeerConnection.onconnectionstatechange = async () => {
        if (
          rtcPeerConnection.iceConnectionState === 'disconnected' ||
          rtcPeerConnection.iceConnectionState === 'failed'
        ) {
          handleHangup({});
        }
      };

      return peer;
    },
    [handleHangup]
  );

 

 

때 보편적으로 RTCPeerConnection 를 통해서 연결을 합니다. 하지만 해당 프로젝트에서는  simple-peer 라이브러리를 활용하여 해당 단계를 쉽게 구현할 수 있습니다.

 

이를 통해서 스턴 서버를 통해서 ice 정보를 받고 스트리밍이 된다면 상대방의 stream 정보를 받아서 peer에 할당합니다. 

 

이후 해당 함수를 각각 전화거는 사람과 받는 사람이 사용하여 연결을 완료할 수 있습니다.

const completePeerConnection = useCallback(
    async (connectionData: {
      sdp: SignalData;
      ongoingCall: OngoingCall;
      isCaller: boolean;
    }) => {
      if (!localStream) {
        console.log('Missing the localStream');
        return;
      }

      if (peer) {
        peer.peerConnection?.signal(connectionData.sdp);
        return;
      }

      const newPeer = createPeer(localStream, true);

      setPeer({
        peerConnection: newPeer,
        partipantUser: connectionData.ongoingCall.participants.receiver,
        stream: undefined,
      });

      newPeer.on('signal', async (data: SignalData) => {
        if (socket) {
          // emit offer
          socket.emit('webrtcSignal', {
            sdp: data,
            ongoingCall,
            isCaller: true,
          });
        }
      });
    },
    [createPeer, localStream, ongoingCall, peer, socket]
  );

  const handleJoinCall = useCallback(
    async (ongoingCall: OngoingCall) => {
      setIsCallEnded(false);

      setOngoingCall((prev) => {
        if (prev) {
          return { ...prev, isRinging: false };
        }
        return prev;
      });
      const stream = await getMediaStream();
      if (!stream) {
        console.log('Could not get stream in handleJoinCall');
        return;
      }

      const newPeer = createPeer(stream, true);

      setPeer({
        peerConnection: newPeer,
        partipantUser: ongoingCall.participants.caller,
        stream: undefined,
      });

      newPeer.on('signal', async (data: SignalData) => {
        if (socket) {
          // emit offer
          socket.emit('webrtcSignal', {
            sdp: data,
            ongoingCall,
            isCaller: false,
          });
        }
      });
    },
    [createPeer, getMediaStream, socket]
  );

 

 

이때 ICE 정보의 경우 SDP(Session Description Protocol)에 담겨서 전송되고, 이는 Socket의 webritcSignal을 통해서 유저간 실시간으로 주고 받습니다. 그리고 해당 SDP 정보를 기반으로 p2p 연결을 하면 데이터가 stream 됩니다.

 

자세한 통화 프로젝트의 코드는 https://www.youtube.com/watch?v=dpWAqVSjgTM&list=PL63c_Ws9ecIS8ReV9MISpUGU71CK5cy0V 통해서 확인할 수 있습니다.

 

 

 

 

 

참고문헌 및 출처

https://webrtc.org/?hl=ko

 

WebRTC

An open framework for the web that enables Real-Time Communications (RTC) capabilities in the browser.

webrtc.org

 

https://developer.mozilla.org/ko/docs/Web/API/WebRTC_API

 

WebRTC API - Web API | MDN

WebRTC(Web Real-Time Communication)은 웹 애플리케이션과 사이트가 중간자 없이 브라우저 간에 오디오나 영상 미디어를 포착하고 마음대로 스트림할 뿐 아니라, 임의의 데이터도 교환할 수 있도록 하는

developer.mozilla.org

https://www.npmjs.com/package/simple-peer

 

simple-peer

Simple one-to-one WebRTC video/voice and data channels. Latest version: 9.11.1, last published: 3 years ago. Start using simple-peer in your project by running `npm i simple-peer`. There are 296 other projects in the npm registry using simple-peer.

www.npmjs.com

 

 

https://www.youtube.com/watch?v=dpWAqVSjgTM&list=PL63c_Ws9ecIS8ReV9MISpUGU71CK5cy0V

https://gh402.tistory.com/38

 

[WebRTC] WebRTC란 무엇일까?

🎞 WebRTC란 무엇인가? Web Real-Time Communication의 약자로 웹/앱에서 별다른 소프트웨어 없이 카메라, 마이크 등을 사용하여 실시간 커뮤니케이션을 제공해주는 기술이다. 우리가 잘 알고있는 화상통

gh402.tistory.com

https://gh402.tistory.com/45

 

[WebRTC] NAT, ICE, STUN, TURN 이란? ( WebRTC를 이해하기 위해 필요한 지식들)

WebRTC를 사용하기 전, 기본적으로 익혀야 할 지식들!! 🌎 NAT(Network Address Translation) '나'는 누구인지 '이름'으로 구별할 수 있듯, 각 기기에도 자신만의 이름이 있다. 그것이 바로 IP이고 이 IP는 고

gh402.tistory.com

 

 

728x90

React 프로젝트에서 주로 React-router를 활용하여 SPA(Single Page Application)을 구현합니다. 하지만 SPA도 브라우저의 히스토리가 사용됩니다. 이를 간단하게 웹 API를 사용하여 약식으로 구현해보고자 합니다.

 

완성된 프로젝트의 소스코드 링크는 아래와 같습니다.

 

https://github.com/suhong99/spa_simple_router

 

GitHub - suhong99/spa_simple_router: React와 History API 사용하여 SPA Router 기능 구현

React와 History API 사용하여 SPA Router 기능 구현. Contribute to suhong99/spa_simple_router development by creating an account on GitHub.

github.com

 

구현할 내용은 about과 main 페이지를 직접 구현한 라우터를 위해여서 이동하는 것이고 이때 브라우저의 뒤로가기 버튼을 통해서 전 페이지로 이동할 수 있어야 합니다.

 

 

구현하기

우선 SPA에 사용할 브라우저 API를 먼저 언급하려고 합니다.

History API 를 통해서  실제 페이지를 이동했던 것 처럼 방문했던 페이지들을 기록해야 합니다.

History 객체란

  • 브라우저가 관리하는 객체로, 사용자가 방문한 페이지의 URL 정보를 담고 있습니다.
  • pushState 메서드를 호출하면 브라우저의 히스토리 스택에 새로운 기록이 추가됩니다.

이후 우리가 브라우저 상단의  뒤로가기 버튼 클릭시에 windown의 popState 이벤트가 발생하게 됩니다. 이 경우에 

히스토리 스택에서 이전 URL을 참고하게 됩니다.

 

이제 실제 구현해보려고 합니다.

Router

흔히 React-Router 라이브러리를 사용하게 되면 App에 browswer라우터를 등록하게 됩니다. 따라서 저희는 우선 Router를 먼저 구현해보려고 합니다.

import React, {
  Children,
  useEffect,
  useState,
  ReactNode,
  isValidElement,
} from 'react';

interface RouterProps {
  children: ReactNode;
}

const Router: React.FC<RouterProps> = ({ children }) => {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const onPopState = () => setCurrentPath(window.location.pathname);
    window.addEventListener('popstate', onPopState);

    return () => window.removeEventListener('popstate', onPopState);
  }, []);

  return (
    <>
      {Children.map(children, (child) => {
        if (isValidElement(child) && child.props.path === currentPath) {
          return child;
        }
        return null;
      })}
    </>
  );
};

export default Router;

 

우리는 url의 경로가 바뀌게 되면 보여주는 컴퍼넌트가 달라집니다. 이때 달라지는 컴퍼넌트가 페이지 단위가 됨녀 SPA가 완성이 되는 것입니다.

.따라서 App을  Router로 감싼 이후에 내부에 있는 Route를 통해서 각각의 경로와 경로에 일치하는 컴퍼넌트를 감지할 것 입니다.

 

이때 뒤로가기가 작동하게 하기 위해서 useEffect를 통해서 사이드 이펙트를 감지할 것이고,  발생할 때마다 현재 경로를useState로 선택한 지역상태를 통해 관리할 것 입니다.

그리고 이를 통해서 컴퍼넌트가 렌더링 가능한 컴퍼넌트이고, 경로가 일치하면 반환하는 식으로 구현하였습니다.

  {Children.map(children, (child) => {
        if (isValidElement(child) && child.props.path === currentPath) {
          return child;
        }
        return null;

유의사항 : 이때 map은 js의 map 이 아닌 리엑트 Children의 map입니다.

Route

이제 하위에서 경로와 렌더링할 컴퍼넌틀틀 받는 Route 컴퍼넌트를 작성할 것 입니다.

import React, { ReactElement } from 'react';

interface RouteProps {
  path: string;
  component: ReactElement;
}

const Route: React.FC<RouteProps> = ({ component }) => component;

export default Route;

 

컴퍼넌트와 props를 등록하여서 Router에서 사용할 수 있도록 만들어줍니다.

 

useRouter 훅

import { useCallback } from 'react';

const useRouter = () => {
  const push = useCallback((path: string) => {
    window.history.pushState({}, '', path);
    const popStateEvent = new PopStateEvent('popstate');
    window.dispatchEvent(popStateEvent);
  }, []);

  return { push };
};

export default useRouter;

이후 페이지 이동시에는 pushState함수를 이용해서 History API에 방문하는 페이지를 기록해줍니다.

 

 

해당 훅의 사용방법은 아래와 같습니다.

 

import React from 'react';
import useRouter from '../router/hook/useRouter';

interface NaviButtonProps {
  text: string;
  url: string;
}

const NaviButton: React.FC<NaviButtonProps> = ({ text, url }) => {
  const { push } = useRouter();

  const handleClick = () => {
    push(url);
  };

  return (
    <button
      onClick={handleClick}
      style={{
        backgroundColor: 'rgba(0, 0, 0, 0.7)',
        color: 'white',
        border: 'none',
        borderRadius: '8px',
        padding: '10px 20px',
        cursor: 'pointer',
      }}
    >
      {text}
    </button>
  );
};

export default NaviButton;

 

 

 

 

참고문헌

 

https://developer.mozilla.org/ko/docs/Web/API/Window/popstate_event

 

popstate - Web API | MDN

Window 인터페이스의 popstate 이벤트는 사용자의 세션 기록 탐색으로 인해 현재 활성화된 기록 항목이 바뀔 때 발생합니다. 만약 활성화된 엔트리가 history.pushState() 메서드나 history.replaceState() 메서

developer.mozilla.org

 

https://ko.react.dev/reference/react/isValidElement

 

isValidElement – React

The library for web and native user interfaces

ko.react.dev

https://developer.mozilla.org/ko/docs/Web/API/History_API

 

History API - Web API | MDN

History API는 history 전역 객체를 통해 브라우저 세션 히스토리(웹 익스텐션 히스토리와 혼동해서는 안 됩니다.)에 대한 접근을 제공합니다. 사용자의 방문 기록을 앞뒤로 탐색하고, 방문 기록 스택

developer.mozilla.org

 

728x90

들어가며

redux는 어떻게 상태 관리를 하는가에 대해서 알아보기 위해서 createStore에 대해서 알아보려고 합니다.

createStore는 deprecated인데 왜 해당 함수에 대해서 알아보냐에 대한 답변은 아래와 같습니다.

 

The standard method for creating a Redux store. It uses the low-level Redux core 
createStore
 method internally, but wraps that to provide good defaults to the store setup for a better development experience.

 

공식 문서에 의하면 configureStore 는 middleware등 추가적인 기능을 createStore 함수를 기반으로 같이 쓸 수 있게 만든 함수이기 때문입니다.

따라서 핵심 상태 관리 원리는 여전히 createStore에 남아있고 다만 유저는 configureStore를 사용하는 것이 권장되기에 derpecated 된 것 입니다.

 

createStore 살펴보기

 

https://github.com/reduxjs/redux/blob/master/src/createStore.ts

 

코드 전문은 아래와 같습니다. 

export function createStore<
  S,
  A extends Action,
  Ext extends {} = {},
  StateExt extends {} = {}
>(
  reducer: Reducer<S, A>,
  enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & NoInfer<Ext>

export function createStore<
  S,
  A extends Action,
  Ext extends {} = {},
  StateExt extends {} = {},
  PreloadedState = S
>(
  reducer: Reducer<S, A, PreloadedState>,
  preloadedState?: PreloadedState | undefined,
  enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & NoInfer<Ext>
export function createStore<
  S,
  A extends Action,
  Ext extends {} = {},
  StateExt extends {} = {},
  PreloadedState = S
>(
  reducer: Reducer<S, A, PreloadedState>,
  preloadedState?: PreloadedState | StoreEnhancer<Ext, StateExt> | undefined,
  enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & NoInfer<Ext> {
  if (typeof reducer !== 'function') {
    throw new Error(
      `Expected the root reducer to be a function. Instead, received: '${kindOf(
        reducer
      )}'`
    )
  }

  if (
    (typeof preloadedState === 'function' && typeof enhancer === 'function') ||
    (typeof enhancer === 'function' && typeof arguments[3] === 'function')
  ) {
    throw new Error(
      'It looks like you are passing several store enhancers to ' +
        'createStore(). This is not supported. Instead, compose them ' +
        'together to a single function. See https://redux.js.org/tutorials/fundamentals/part-4-store#creating-a-store-with-enhancers for an example.'
    )
  }

  if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    enhancer = preloadedState as StoreEnhancer<Ext, StateExt>
    preloadedState = undefined
  }

  if (typeof enhancer !== 'undefined') {
    if (typeof enhancer !== 'function') {
      throw new Error(
        `Expected the enhancer to be a function. Instead, received: '${kindOf(
          enhancer
        )}'`
      )
    }

    return enhancer(createStore)(
      reducer,
      preloadedState as PreloadedState | undefined
    )
  }

  let currentReducer = reducer
  let currentState: S | PreloadedState | undefined = preloadedState as
    | PreloadedState
    | undefined
  let currentListeners: Map<number, ListenerCallback> | null = new Map()
  let nextListeners = currentListeners
  let listenerIdCounter = 0
  let isDispatching = false


  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = new Map()
      currentListeners.forEach((listener, key) => {
        nextListeners.set(key, listener)
      })
    }
  }

 
  function getState(): S {
    if (isDispatching) {
      throw new Error(
        'You may not call store.getState() while the reducer is executing. ' +
          'The reducer has already received the state as an argument. ' +
          'Pass it down from the top reducer instead of reading it from the store.'
      )
    }

    return currentState as S
  }

  
  function subscribe(listener: () => void) {
    if (typeof listener !== 'function') {
      throw new Error(
        `Expected the listener to be a function. Instead, received: '${kindOf(
          listener
        )}'`
      )
    }

    if (isDispatching) {
      throw new Error(
        'You may not call store.subscribe() while the reducer is executing. ' +
          'If you would like to be notified after the store has been updated, subscribe from a ' +
          'component and invoke store.getState() in the callback to access the latest state. ' +
          'See https://redux.js.org/api/store#subscribelistener for more details.'
      )
    }

    let isSubscribed = true

    ensureCanMutateNextListeners()
    const listenerId = listenerIdCounter++
    nextListeners.set(listenerId, listener)

    return function unsubscribe() {
      if (!isSubscribed) {
        return
      }

      if (isDispatching) {
        throw new Error(
          'You may not unsubscribe from a store listener while the reducer is executing. ' +
            'See https://redux.js.org/api/store#subscribelistener for more details.'
        )
      }

      isSubscribed = false

      ensureCanMutateNextListeners()
      nextListeners.delete(listenerId)
      currentListeners = null
    }
  }

 
  function dispatch(action: A) {
    if (!isPlainObject(action)) {
      throw new Error(
        `Actions must be plain objects. Instead, the actual type was: '${kindOf(
          action
        )}'. You may need to add middleware to your store setup to handle dispatching other values, such as 'redux-thunk' to handle dispatching functions. See https://redux.js.org/tutorials/fundamentals/part-4-store#middleware and https://redux.js.org/tutorials/fundamentals/part-6-async-logic#using-the-redux-thunk-middleware for examples.`
      )
    }

    if (typeof action.type === 'undefined') {
      throw new Error(
        'Actions may not have an undefined "type" property. You may have misspelled an action type string constant.'
      )
    }

    if (typeof action.type !== 'string') {
      throw new Error(
        `Action "type" property must be a string. Instead, the actual type was: '${kindOf(
          action.type
        )}'. Value was: '${action.type}' (stringified)`
      )
    }

    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }

    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    const listeners = (currentListeners = nextListeners)
    listeners.forEach(listener => {
      listener()
    })
    return action
  }

  function replaceReducer(nextReducer: Reducer<S, A>): void {
    if (typeof nextReducer !== 'function') {
      throw new Error(
        `Expected the nextReducer to be a function. Instead, received: '${kindOf(
          nextReducer
        )}`
      )
    }

    currentReducer = nextReducer as unknown as Reducer<S, A, PreloadedState>

   
    dispatch({ type: ActionTypes.REPLACE } as A)
  }

 
  function observable() {
    const outerSubscribe = subscribe
    return {
     
      subscribe(observer: unknown) {
        if (typeof observer !== 'object' || observer === null) {
          throw new TypeError(
            `Expected the observer to be an object. Instead, received: '${kindOf(
              observer
            )}'`
          )
        }

        function observeState() {
          const observerAsObserver = observer as Observer<S>
          if (observerAsObserver.next) {
            observerAsObserver.next(getState())
          }
        }

        observeState()
        const unsubscribe = outerSubscribe(observeState)
        return { unsubscribe }
      },

      [$$observable]() {
        return this
      }
    }
  }


  dispatch({ type: ActionTypes.INIT } as A)

  const store = {
    dispatch: dispatch as Dispatch<A>,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  } as unknown as Store<S, A, StateExt> & Ext
  return store
}

 

결국 요약만 하면  currentState를 통해 상태를 기억하고  외부에서 받아온 reducer를 통해서 상태를 업데이트 합니다. 
Map으로 선언한 listener를 통해서 구독자를 관리합니다. 이후 반환값으로 상태를 얻는 getState, 구독하는 subscribe, 상태를 업데이트하기 위한 dispatch함수들을 반환합니다.

(상태 관리에 좀 더 집중하기 위해 여타 에러처리와 replaceReducer , observable에 대해서는 넘어가겠습니다.)


이제 너무 방대한 양이기에 이를 간단하게 요약해서 코어만 추출해보려고 합니다.

 

createStore 코어만 작성하기

const createStoreCore = (reducer) => {
  let state;
  let listeners = new Map();
  let listenerIdCounter = 0;

  const getState = () => state;

  const subscribe = (listener) => {
    const id = listenerIdCounter++;
    listeners.set(id, listener);
    
    return () => {
      listeners.delete(id);
    };
  };

  const dispatch = (action) => {
    state = reducer(state, action);
    listeners.forEach((listener) => listener());
  };

  dispatch({});

  return { getState, subscribe, dispatch };
};

 

상태를 수정하는 것은 reducer를 통해서 받아서 상태가 순수할 수 있도록 하고,
listners를 통해 구독시킵니다. 이후 dispatch를 할 때 (상태가 수정될 때) 는  구독자에게 알려야 하고, 구독이 끝났을 때는 listeners에서 제거해야 합니다.

 

이전에 '리엑트 훅을 활용한 마이크로 상태 관리 책'을 통해서  확인한  zustand, jotai, valtio 등과 원리는 비슷하네요.
다만 상태는 createStore 내부에서 클로저를 통해 관리하고 있습니다.


react에서는 어떻게 리렌더링이 되는가?

redux에서는 상태 관리와 리렌더링의 주체가 관심사가 나뉘어져 있습니다.

리렌더링은 react-redux 라이브러리의 훅들을 기반으로 진행됩니다.

대부분의 상태관리 라이브러리는 외부 상태를 useSyncExternalStore 훅을 통해서 합니다.

이때 redux에서는 구 버전(useSyncExternalStore 훅이 나온 18버전 이전)의 리엑트에 대응하기 위해서 

use-sync-external-store  을 활용합니다.

 

해당 코드는 useSelector 내부에서 확인할 수 있습니다.

export const initializeUseSelector = (fn: uSESWS) => {
  useSyncExternalStoreWithSelector = fn
}

 

dispatch 시 내부에서 listener에게 상태 변화가 감지가 되고, 콜백함수로 작동하여 리렌더링을 일으킵니다.

 

 

참고문헌

 

https://redux-toolkit.js.org/api/configureStore

 

configureStore | Redux Toolkit

 

redux-toolkit.js.org

https://ko.react.dev/reference/react/useSyncExternalStore

 

useSyncExternalStore – React

The library for web and native user interfaces

ko.react.dev

https://github.com/reduxjs/react-redux

 

GitHub - reduxjs/react-redux: Official React bindings for Redux

Official React bindings for Redux. Contribute to reduxjs/react-redux development by creating an account on GitHub.

github.com

 

728x90

들아가며...

최근 react와 router을 활용한 검색기능에 대한 질문을 받은 적이 있습니다. 그런데 막상 질문을 받았을 떄, 검색을 하지 않고서는 잘 기억이 나지 않더군요. 실제로 직접 url을 활용한 검색 기능을 구현한 적도 없었고,,,
따라서 간단한 검색 예제를 직접 만들면서 학습했습니다.

 

구현할 검색 서비스

검색 서비스의 경우에는 실제로도 URI 기반으로 작동을 많이 하는데, 이는 타인에게 검색 결과를 공유하기가 수월해 지기 때문입니다. 단순하게 경로를 복사하여 전송해주는 것만으로 사람들은 제가 보고 있는 것과 동일한 페이지를 볼 수 있습니다.

https://github.com/suhong99/StudyRepo/tree/main/search-ex

 

StudyRepo/search-ex at main · suhong99/StudyRepo

Contribute to suhong99/StudyRepo development by creating an account on GitHub.

github.com

예제 소스코드는 위 링크에 있고  UI의 경우 간단하게 챗 gpt를 활용하여 만들었습니다.
예제도 chat gpt를 활용하여 30개의 아티클 제목을 만들었습니다.

 

 

구현 기능은 아래와 같습니다.

 

1. search와 루트에서 공유될 검색창을 만든다.

2. 홈에서는 전체 데이터를 불러온다.

3. 검색창에 내용 입력 후 enter시 search 페이지로 이동한다.

4. search 페이지에서는 입력 내용 기반으로 필터링하여 관련된 아티클만 보여준다.

 

 

해당 기능을 구현하려면 아래의 기능들이 필요합니다.

1. input 창에서 enter 입력 시 입력창의 내용을 URI에 반영하기 

2. 이후 검색 페이지에서는 URI에 있는 퀴리문에서 검색어를 추출하기

3. 추출된 검색어를 바탕으로 데이터를 필터링하기

 

이제 위 순서대로 구현 및 설명을 진행해보려고 합니다.

경로설정은 아래와 같이 하였고, loader를 사용하지 않을 생각이여서 BrowserRouter를 활용하였습니다.

    <Routes>
        <Route element={<Layout />}>
          <Route path="/" Component={Home} />
          <Route path="/search" Component={Search} />
        </Route>
      </Routes>

 

 

 

1. input 창에서 enter 입력 시 입력창의 내용을 URI에 반영하기 

앞서 말했듯이 input 창의 경우에는 Home과 Search에서 공유되어야 합니다. 따라서 Layout에 위치해야합니다.

 

import { useRef } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';

function Layout() {
  const navigate = useNavigate();
  const searchRef = useRef<HTMLInputElement>(null);

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'Enter' && searchRef.current) {
      navigate(`/search?query=${encodeURIComponent(searchRef.current.value)}`);
    }
  };

  return (
    <div>
      <header>
        <div> 아티클 검색 </div>
        {`검색창 : `}
        <input
          type="text"
          placeholder="Search..."
          ref={searchRef}
          onKeyDown={handleKeyDown}
        />
      </header>
      <main>
        <Outlet />
      </main>
    </div>
  );
}

export default Layout;

 

enter를 눌렀을 때 URI에 검색창 내용이 반영되면서 이동해야 합니다. 

이때 enter를 칠 때만 검색되게 할 것이므로 input 요소는 리렌더링이 불필요한 요소(uncontrolled)입니다. 따라서 ref 를 통해서 접근해 값을 가져올 것 입니다.

 

이때 페이지 이동에 사용할 함수는 useNavigate 훅의 반환값입니다.

기본적인 사용법은 아래와 같습니다. 이동할 루트를 지정하고, state를 통해서 키와 value를 담을 수 있습니다.

저 같은 경우에는 해당 방법이 아닌  ?(쿼리문) 를 활용하여 상태를 담았습니다.

 

navigate("/new-route", { state: { key: "value" } });


// 동일한 기능
navigate(`/search?query=${encodeURIComponent(searchRef.current.value)}`);
  
navigate('/search', {
    state: { query: encodeURIComponent(searchRef.current.value) },
  });

 

이때 encodeURIComponent는 자바스크립트 내장함수로 URI에 내용을 담을 때 특수문자등이 훼손되지 않도록 인코딩해주는 함수입니다.

 

2. 검색 페이지에서 쿼리문 추출하기 

검색창에 react라고 검색한 경우 아래와 같이 URI가 수정됩니다.

http://localhost:5173/search?query=react

 

이때 Router에서는 useLocation 훅을 활용하여 쿼리문 및 URL을 추출할 수 있습니다.

  const location = useLocation();

  console.log(location, location.search);

location 과 location.search

 

useLocation 훅의 반환값을 보면 pathname에는 URL이 담겨있고, search에 쿼리문이 있음을 확인할 수 있습니다.

이제 location.search에 반환된 쿼리문에서 해당하는 값을 추출해야 합니다.

 

이떄 사용할 수 있는 겂이 URLSearchParams입니다. URLSearchParams 생성자에 쿼리문을 집어넣어서 값을 확인할 수 있습니다.

기본적으로 URI에는 여러개의 쿼리 값을 추가할 수 있습니다. 또한 URLSearchParams는 iterator이기 때문에 반복문으로 추출할 수 있습니다.

  const searchParams = new URLSearchParams(location.search);

  for (const [key, value] of searchParams) {
    console.log(key, value);
  }

 

 

 

저희는 query라는 key값에서만 값을 추출할 것이기 때문에, 위 방법 대신 get 메서드를 통해서 값을 추출하려고 합니다.

  // URL에서 쿼리 파라미터 추출
  const searchParams = new URLSearchParams(location.search);
  const query = searchParams.get('query') || '';

 

3. 검색어를 통해 필터링하기

마지막으로 필터링하는 방법은 아래와 같습니다. 더 복잡하고 효율적인 필터링을 구현할 수도 있겠지만, 이 포스팅에서는 쿼리문 추출에 좀 더 집중하고자 합니다. 제목을 소문자로 바꾼 후, 검색어를 제목에 포함한 아티클만 보여주느 함수입니다.

  const filteredData = DATA.filter((item) =>
    item.title.toLowerCase().includes(query.toLowerCase())
  );

 

 

완성본은 아래와 같습니다.

import React from 'react';
import { useLocation } from 'react-router-dom';
import CardList from '../components/CardList';
import { DATA } from '../dummy/data';

const Search: React.FC = () => {
  const location = useLocation();

  // URL에서 쿼리 파라미터 추출
  const searchParams = new URLSearchParams(location.search);
  const query = searchParams.get('query') || '';


  // 검색어에 따른 필터링
  const filteredData = DATA.filter((item) =>
    item.title.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div>
      {filteredData.length > 0 ? (
        <CardList list={filteredData} />
      ) : (
        <div>데이터가 없습니다</div>
      )}
      <CardList list={filteredData} />
    </div>
  );
};

export default Search;

 

느낀점...

최근 next 위주의 프로젝트를 하다보니 Router를 사용할 일이 적었습니다.  막상 검색 없이 구현하려니 막막하더군요.
이번 기회에 URLSearchParams와 Router의 훅들에 대해 복습할 기회를 얻어서 좋았습니다.

해당 검색을 간단하게 보완하려면 인풋을 state로 바꾸고  디바운스를 걸어서 약간의 최적화를 한 후에 검색어가 입력시마다 검색 결과가 바뀌도록 할 수 있긴 하지만, 주 관심사가 아니라서 여기서 마치겠습니다.

참고 문헌

 

https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams

 

URLSearchParams - Web APIs | MDN

The URLSearchParams interface defines utility methods to work with the query string of a URL.

developer.mozilla.org

 

https://reactrouter.com/en/main/hooks/use-navigate

 

useNavigate | React Router

 

reactrouter.com

 

https://reactrouter.com/en/main/hooks/use-location

 

useLocation | React Router

 

reactrouter.com

 

728x90

예전 프로젝트에서 tanstack query의 in useInfiniteQuery를 활용하여 무한스크롤을 구현한 적이 있습니다.

그 당시에 굉장히 수월하게 구현할 수 있었는데, 원티드 프리온보딩에서 라이브러리 없이 Intersection Oberserver API을 활용하여 무한스크롤을 구현하라는 사전과제를 받은 김에 정리하고자 글을 작성하게 되었습니다.

 

 

 Intersection Observer API란?

MDN에 따르면 Intersection Observer API의 필요성은 아래와 같습니다.

역사적으로, 요소의 가시성 또는 관련된 두 요소 사이의 상대적 가시성을 감지하는 것은 해결책을 신뢰할 수 없고 브라우저와 사용자가 접근하는 사이트를 느리게 만드는 어려운 작업이었습니다. Web이 성숙해짐에 따라, 이러한 종류의 정보의 요구가 늘어났습니다. 교차 정보는 다음과 같은 많은 이유로 필요합니다.

 

즉, 특정 요소가 얼만큼 노출되었는지(교차 정보)를 확인할 필요성이 점점 늘어났고 이를 위해 생긴 API입니다.

이때 상호작용 요소를 메인스레드가 아닌 콜백함수에서 관리함으로써 , 브라우저는 적합하다고 판단되는 대로 교차 관리를 자유롭게 최적화할 수 있게 됩니다.

 

문법

new IntersectionObserver(callback)
new IntersectionObserver(callback, options)

각각 callback과 options의 타입을 확인하면 아래와 같습니다.

// callback의 타입
interface IntersectionObserverCallback {
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver): void;
}

// options의 타입
interface IntersectionObserverInit {
    root?: Element | Document | null;
    rootMargin?: string;
    threshold?: number | number[];
}

 

  • 콜백함수
    콜백 함수는 대상 요소가 지정한 가시성 임계값을 넘을 때 호출됩니다. 콜백 함수는 두 개의 매개변수를 입력받는데, 
    - entries는 콜백함수가 생길 때 발생한 정보를 담고 있습니다.
    - observer는 관측자에 대한 정보를 담고 있습니다.
  • options
    option은 콜백함수가 발생하는  조건에 대해 커스텀 할 때 쓸 수 있습니다. 선택적인 요소라서 필요할 때 적용하면 됩니다.
    - root  :  상호작용의 경우에 뷰포트 기준으로 할 때가 많습니다. 하지만 특정 모달 내부의 스크롤 등  관측 대상자의 상위 요소를 지정해줘야 할 경우에 사용합니다.
    -rootMargin : 이미지 등 미리 보여야할 경우에는 뷰포트에 도달하기 전에 사용해야할 수 있습니다. 이때 해당 관측대상 기준으로 미리 감지할 수 있는 여백의 크기를 결정합니다.
    -threshold : 관측 대성이 얼만큼의 가시성을 확보해야 상호 작용할 지 설정할 떄 사용합니다.

이를 바탕으로 무한 스크롤에 사용한 옵션의 예시는 아래와 같습니다.

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          fn();
        }
      },
      { threshold: 1.0 }
    );

 

관측 대상이 완벽하게 보일 때  fn이라는 함수를  실행하겠다 입니다.

 

 Intersection Observer를 활용하여 React에서 무한스크롤 구현하기

무한스크롤을 구현하는 방법은 여러가지가 있습니다.  스크롤 높이 기준으로 구현할 수도 있고, 특정 물체가 감지될 때마다 다음페이지를 요청하는 옵저버 방식도 있습니다.

Intersection Observer API를 통해 구현할 옵저버 방식으로 감지대상이 감지되면 다음 페이지에 대한 정보를 요청할 것 입니다.

 

우선 완성된 화면은 아래와 같습니다.  상품 리스트를 담고, 상품의 가격을 전부다 합산한 내용이 우측 상단에 표시했습니다. 그리고 하단으로 내리다 불러온 자료 끝부분에 도달하면, 새로운 요청을 합니다.

 

코드 구조는 다음과 같습니다.
App에서 무한스크롤 영역과 가격 총합을 보여주는 Header부분이 있습니다. 이때 가격을 계산하는 부분은 커스텀훅으로 따로 구현하였습니다.

import './App.css';
import useTotalPrice from './hooks/useTotalPrice';
import Header from './components/Header';
import InfinityScroll from './components/InfinityScroll';

function App() {
  const { totalPrice, updateTotalPrice } = useTotalPrice();

  return (
    <main className="wrapper">
      <Header totalPrice={totalPrice} />
      <InfinityScroll updateTotalPrice= {updateTotalPrice}/>
    </main>
  );
}

export default App;

 

이제 저희의 관심사는 InfinityScroll에 대해서 알아보겠습니다.

import { MockData } from '../const/mock';
import useFetchData from '../hooks/useFetchData';
import useInfiniteScroll from '../hooks/useInfiniteScroll';
import CardList from './CardList';
import Spinner from './Spinner';

const InfinityScroll: React.FC<{
  updateTotalPrice: (datas: MockData[]) => void;
}> = ({ updateTotalPrice }) => {
  const { list, isLastPage, isLoading, fetchData } =
    useFetchData(updateTotalPrice);
  const observerRef = useInfiniteScroll(fetchData, isLoading, isLastPage);

  return (
    <>
      <CardList list={list} />
      {isLoading && <Spinner />}
      {!isLastPage ? (
        <div ref={observerRef} className="observer" />
      ) : (
        <div className="end">마지막 페이지 입니다</div>
      )}
    </>
  );
};

export default InfinityScroll;

 

카드는 배열을 매개변수로 넘기면 되고, 로딩중일때는 로딩처리, 마지막 페이지일 때는 마지막 페이지라 알려주고 아닌 경우에는 oberserRef를 할당항 옵저버 div가 렌더링되도록 하였습니다.

 

useFetchData 훅을 통해서 마지막 페이지인지, 반환된 배열은 어떤건지, 로딩처리, 데이터 요청 시 사용하는 함수가 반환됩니다. 

그리고 useInifinieScroll훅에 요청함수와 로딩중인지, 마지막 페이지인지를 전달해주고 ref를 반환받고 있습니다.

 

결국 useInfiniteScroll이 어떻게 구현되어있는지만 확인하면 됩니다.

import { useEffect, useRef } from 'react';

function useInfiniteScroll(
  callback: () => void,
  isLoading: boolean,
  isLastPage: boolean
) {
  const observerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (isLoading || isLastPage) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          callback();
        }
      },
      { threshold: 1.0 }
    );

    const currentRef = observerRef.current;

    if (currentRef) {
      observer.observe(currentRef);
    }

    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, [isLoading, isLastPage, callback]);

  return observerRef;
}

export default useInfiniteScroll;

해당 훅에서는 로딩이나 마지막 페이지일때는 useEffect가 작동하지 않게 하였습니다. 

observerRef를 선언한 후에, useEffect 내부에서는 Intersectionobserver를 통해서 대상 옵저버 div가 완전히 보일 때 콜백함수 (매개변수로 넘겨준 콜백함수(fetchData함수))를 실행하도록 하였습니다. 

이후 관측대상이 존재한다면 (observerRef.current가 참이라면) 관측을 시작하고, 컴퍼넌트가 업데이터 되거나 언마운트될 떄 관측을 취소하도록 하였습니다.

 

그리고  반환된 observerRef는 InfinityScroll 컴퍼넌트의 관측자 div의 ref에 할당되어있습니다.

 

 

참고문헌 및 링크

https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

 

Intersection Observer API - Web API | MDN

Intersection Observer API는 상위 요소 또는 최상위 문서의 viewport와 대상 요소 사이의 변화를 비동기적으로 관찰할 수 있는 수단을 제공합니다.

developer.mozilla.org

 

https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver/IntersectionObserver

 

IntersectionObserver() - Web API | MDN

IntersectionObserver() 생성자는 새로운 IntersectionObserver 객체를 생성하고 반환합니다.

developer.mozilla.org

 

 

 

728x90

들어가며

Proxy에서 처음 접하게 된 것은 제로초님의 쇼츠였습니다.  객체를 가공해서 사용할 수 있게 하는 일종어가며

Proxy에서 처음 접하게 된 것은 제로초님의 쇼츠였습니다. 객체를 가공해서 사용할 수 있게 하는  미들웨어 느낌의 객체입니다. 해당 쇼츠에서는 객체가 에러를 뱉도록 가공하는 예시를 보여줬었습니다.

 

리액트 훅을 활용한 마이크로 상태관리에서 Valtio가  Proxy 기반으로 상태관리를 한다는 사실을 접하게 되고, proxy란 무엇이고 Valtio에서는 어떻게 사용하는지 자세히 알아볼겸 포스팅하게 되었습니다.

 

 

프록시란?

mdn에서 나타낸 프록시의 정의는 아래와 같습니다.

Proxy 객체를 사용하면 원래 Object 대신 사용할 수 있는 객체를 만들지만, 이 객체의 속성 가져오기, 설정 및 정의와 같은 기본 객체 작업을 재정의할 수 있습니다. 프록시 객체는 일반적으로 속성 액세스를 기록하고, 입력의 유효성을 검사하고, 형식을 지정하거나, 삭제하는 데 사용됩니다.

 

let proxy = new Proxy(target, handler)
  • target – 감싸게 될 객체로, 함수를 포함한 모든 객체가 가능함
  • handler – 동작을 가로채는 메서드인 '트랩(trap)'이 담긴 객체로, 여기서 프락시를 설정함

그리고 이때 handler에 사용할 수 있는 메서드는 아래와 같습니다.

https://ko.javascript.info/proxy#ref-1033

 

아래는 javainfo에서 보여준 get메서드를 활용한 proxy 사용 예시입니다. 

let numbers = [0, 1, 2];

numbers = new Proxy(numbers, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; 
    }
  }
});

console.log( numbers[1] ); // 1
console.log( numbers[123] ); // 0

이와 같이 객체를 가공하여 추가적인 기능을 삽입할 때 사용할 수 있는 툭수한 객체라고 생각하면 됩니다.

 

이제 Valtio에 대해서 알아보고  Valtio에서는 Proxy를 어떻게 사용하는지 알아보려고 합니다.

Valtio란?

리액트 훅을 활용한 마이크로 상태관리의 저자이자 Valtio를 만든 다이시 카토는 아래와 같이 Valtio를 설명합니다.

갱신 모델의 상태 관리 라이브러리이자 상태 사용 추적 기법 기반으로 렌더링 최적화를 하는 라이브러리

 

proxy는 상태를 감지하는 변경가능한 상태를 만들고, snapshot을 통해서 변경 불가능한 객체(Object.freeze 기반)를 만든다.

import { proxy, useSnapshot } from 'valtio';

const state = proxy({
  count1: 0,
  count2: 0,
});

const Counter1 = () => {
  const snap = useSnapshot(state);
  const inc = () => ++state.count1;
  return (
    <>
      {snap.count1} <button onClick={inc}>+1</button>
    </>
  );
};

const Counter2 = () => {
  const snap = useSnapshot(state);
  const inc = () => ++state.count2;
  return (
    <>
      {snap.count2} <button onClick={inc}>+1</button>
    </>
  );
};

const ValtioCounter = () => (
  <>
    <div>
      <Counter1 />
    </div>
    <div>
      <Counter2 />
    </div>
  </>
);

export default ValtioCounter;

 

Counter 1 2는 각각 상태 count 12 를 사용하고 있다. zustand나 redux처럼 별도의 selector를 사용하지 않기에, 리렌더링 최적화가 안될거 같지만 앞서 언급한 상태 사용 추적기법에 의해서 리렌더링이 최적화가 되어있다.

1을 눌렀을 때 2가 리렌더링 되지 않음

 

useSnapshot 훅에서  나온  스냅샷을 컴퍼넌트가 사용하게 되면 해당 컴퍼넌트가 특정 상태를 사용하는 것을 기억한다. 이후 상태가 변경하였으면 proxy에서 특정 상태의 변경을 알리고 이를 통해  useSnapshot훅이 컴퍼넌트가 사용하기 있는 객체가 변경 되었으면 리렌더링을 하는 것이다.

 

Valtio내부 코드 확인

state (proxy함수 리턴값) 와 snap을 각각 콘솔로 찍어보면 Proxy 객체임을 확인할 수 있습니다. 그리고  각각의 Handler를 보면 state에는 deleteProperty와 set이 존재하고  snap에는 get, getOwnPropertyDescription ,has , ownKeys 프로퍼티가 존재하는 것을 확인할 수 있습니다.

 

이제 각각의 코드를 확인하려고 합니다. 분량이 많아서 전체코드는 다루지 않겠습니다.

각각의 코드를 확인하기전에 proxy, useSnapshot 모두 Proxy를 다룰 때,  proxy-compare 라이브러리에 의존하고 있고, 해당 부분은 관심사의 밖이기에 간단하게 어떤 역할을 하는지만 언급하려고 합니다. 자세한 내용은 상단 링크의 레포에서 확인할 수 있습니다.

 

proxy

proxy의 경우에는  레포지토리의 src\vanilla.ts 경로에 존재합니다.  

vanilla.ts 에서는 크게 3부분으로 나누어져 있습니다. 모든 코드를 보여드리기엔 많기에 간단한 예시 정도만 보여드리려고 하고,  어떻게 상태 사용 추적이 되는지에 포커스를 두려고 합니다.

 

1. 타입선언 부분 및 기본 상수값으로 쓰는 함수들

예시)

type Primitive = string | number | boolean | null | undefined | symbol | bigint
type Op =
  | [op: 'set', path: Path, value: unknown, prevValue: unknown]
  | [op: 'delete', path: Path, prevValue: unknown]
type Listener = (op: Op, nextVersion: number) => void

const canProxyDefault = (x: unknown): boolean =>
  isObject(x) &&
  !refSet.has(x) &&
  (Array.isArray(x) || !(Symbol.iterator in x)) &&
  !(x instanceof WeakMap) &&
  !(x instanceof WeakSet) &&
  !(x instanceof Error) &&
  !(x instanceof Number) &&
  !(x instanceof Date) &&
  !(x instanceof String) &&
  !(x instanceof RegExp) &&
  !(x instanceof ArrayBuffer) &&
  !(x instanceof Promise)

 

2. 내부 함수 및 내부 변수
예시) 

// internal states
const proxyStateMap: WeakMap<ProxyObject, ProxyState> = new WeakMap()
const refSet: WeakSet<object> = new WeakSet()
const snapCache: WeakMap<object, [version: number, snap: unknown]> =
  new WeakMap()
const versionHolder = [1, 1] as [number, number]
const proxyCache: WeakMap<object, ProxyObject> = new WeakMap()

// internal functions
let objectIs: (a: unknown, b: unknown) => boolean = Object.is
let newProxy = <T extends object>(target: T, handler: ProxyHandler<T>): T =>
  new Proxy(target, handler)
let canProxy: typeof canProxyDefault = canProxyDefault
let createSnapshot: typeof createSnapshotDefault = createSnapshotDefault
let createHandler: typeof createHandlerDefault = createHandlerDefault

3. 모듈 외부로 export할 함수들

예시) proxy, snapshot 

 

 

이제 다시 proxy에 조금 더 집중해보려고 합니다. proxy 함수의 전체 코드는 아래와 같습니다.

export function proxy<T extends object>(baseObject: T = {} as T): T {
  if (!isObject(baseObject)) {
    throw new Error('object required')
  }
  const found = proxyCache.get(baseObject) as T | undefined
  if (found) {
    return found
  }
  let version = versionHolder[0]
  const listeners = new Set<Listener>()
  const notifyUpdate = (op: Op, nextVersion = ++versionHolder[0]) => {
    if (version !== nextVersion) {
      version = nextVersion
      listeners.forEach((listener) => listener(op, nextVersion))
    }
  }
  let checkVersion = versionHolder[1]
  const ensureVersion = (nextCheckVersion = ++versionHolder[1]) => {
    if (checkVersion !== nextCheckVersion && !listeners.size) {
      checkVersion = nextCheckVersion
      propProxyStates.forEach(([propProxyState]) => {
        const propVersion = propProxyState[1](nextCheckVersion)
        if (propVersion > version) {
          version = propVersion
        }
      })
    }
    return version
  }
  const createPropListener =
    (prop: string | symbol): Listener =>
    (op, nextVersion) => {
      const newOp: Op = [...op]
      newOp[1] = [prop, ...(newOp[1] as Path)]
      notifyUpdate(newOp, nextVersion)
    }
  const propProxyStates = new Map<
    string | symbol,
    readonly [ProxyState, RemoveListener?]
  >()
  const addPropListener = (prop: string | symbol, propValue: unknown) => {
    const propProxyState =
      !refSet.has(propValue as object) && proxyStateMap.get(propValue as object)
    if (propProxyState) {
      if (import.meta.env?.MODE !== 'production' && propProxyStates.has(prop)) {
        throw new Error('prop listener already exists')
      }
      if (listeners.size) {
        const remove = propProxyState[2](createPropListener(prop))
        propProxyStates.set(prop, [propProxyState, remove])
      } else {
        propProxyStates.set(prop, [propProxyState])
      }
    }
  }
  const removePropListener = (prop: string | symbol) => {
    const entry = propProxyStates.get(prop)
    if (entry) {
      propProxyStates.delete(prop)
      entry[1]?.()
    }
  }
  const addListener = (listener: Listener) => {
    listeners.add(listener)
    if (listeners.size === 1) {
      propProxyStates.forEach(([propProxyState, prevRemove], prop) => {
        if (import.meta.env?.MODE !== 'production' && prevRemove) {
          throw new Error('remove already exists')
        }
        const remove = propProxyState[2](createPropListener(prop))
        propProxyStates.set(prop, [propProxyState, remove])
      })
    }
    const removeListener = () => {
      listeners.delete(listener)
      if (listeners.size === 0) {
        propProxyStates.forEach(([propProxyState, remove], prop) => {
          if (remove) {
            remove()
            propProxyStates.set(prop, [propProxyState])
          }
        })
      }
    }
    return removeListener
  }
  let initializing = true
  const handler = createHandler<T>(
    () => initializing,
    addPropListener,
    removePropListener,
    notifyUpdate,
  )
  const proxyObject = newProxy(baseObject, handler)
  proxyCache.set(baseObject, proxyObject)
  const proxyState: ProxyState = [baseObject, ensureVersion, addListener]
  proxyStateMap.set(proxyObject, proxyState)
  Reflect.ownKeys(baseObject).forEach((key) => {
    const desc = Object.getOwnPropertyDescriptor(
      baseObject,
      key,
    ) as PropertyDescriptor
    if ('value' in desc && desc.writable) {
      proxyObject[key as keyof T] = baseObject[key as keyof T]
    }
  })
  initializing = false
  return proxyObject
}

 

 

1. 유효성 검사 및 캐시 여부 확인

proxy의 시작부분에서 우선 proxy의 매개변수로 들어오는 값이 객체인줄 확인하고, 캐쉬 여부를 합니다. 동일한 객체에 대해서 캐시를 한 경우에는   캐시 된 값을 얼리리턴합니다.

  if (!isObject(baseObject)) {
    throw new Error('object required')
  }
  const found = proxyCache.get(baseObject) as T | undefined
  if (found) {
    return found
  }

 

2. 버전 관리 및 리스너 등록을 위한 변수 선언

  let version = versionHolder[0]
  const listeners = new Set<Listener>()
  let checkVersion = versionHolder[1]

version은 객체의 현재 버전을 저장하는 변수로, 상태가 업데이트 될 경우 버전이 증가합니다. 이후 checkversion 변수를 활용하여 상태 변경 여부를 확인하고 필요한 경우에만 동기화합니다.

 

listeners는 해당 상태 업데이트시 등록된 리스너에게 알리기 위해서 사용하는 변수입니다.

 

3. 프록시 생성 

let initializing = true
  const handler = createHandler<T>(
    () => initializing,
    addPropListener,
    removePropListener,
    notifyUpdate,
  )
  const proxyObject = newProxy(baseObject, handler)
  proxyCache.set(baseObject, proxyObject)
  const proxyState: ProxyState = [baseObject, ensureVersion, addListener]
  proxyStateMap.set(proxyObject, proxyState)
  Reflect.ownKeys(baseObject).forEach((key) => {
    const desc = Object.getOwnPropertyDescriptor(
      baseObject,
      key,
    ) as PropertyDescriptor
    if ('value' in desc && desc.writable) {
      proxyObject[key as keyof T] = baseObject[key as keyof T]
    }
  })
  initializing = false
  return proxyObject
}

 

이후 새 프록시 객체를  생성하고,  캐쉬 및 상태관리를 합니다. proxy내부 하위 객체에 대해서도 Reflect객체를 활용하여 proxy로 만들고  최종적으로는 프록시 객체를 반환합니다.

 

결국 프록시를 생성하는 것은 알겠고, 캐시를 통해 효율적 상태관리를 하고, 하위 객체에 대한 등록도 하는 것은 알겠는데

proxy를 만들면서 어떤식으로 객체를 핸들링하는지 파악하려면, handler에 사용된 함수들에 대해서 알 필요가 있습니다.

 

4. creatHandler함수

아래는 creatHandler 에 등록 creatHandlerDefault 함수입니다.

const createHandlerDefault = <T extends object>(
  isInitializing: () => boolean,
  addPropListener: (prop: string | symbol, propValue: unknown) => void,
  removePropListener: (prop: string | symbol) => void,
  notifyUpdate: (op: Op) => void,
): ProxyHandler<T> => ({
  deleteProperty(target: T, prop: string | symbol) {
    const prevValue = Reflect.get(target, prop)
    removePropListener(prop)
    const deleted = Reflect.deleteProperty(target, prop)
    if (deleted) {
      notifyUpdate(['delete', [prop], prevValue])
    }
    return deleted
  },
  set(target: T, prop: string | symbol, value: any, receiver: object) {
    const hasPrevValue = !isInitializing() && Reflect.has(target, prop)
    const prevValue = Reflect.get(target, prop, receiver)
    if (
      hasPrevValue &&
      (objectIs(prevValue, value) ||
        (proxyCache.has(value) && objectIs(prevValue, proxyCache.get(value))))
    ) {
      return true
    }
    removePropListener(prop)
    if (isObject(value)) {
      value = getUntracked(value) || value
    }
    const nextValue =
      !proxyStateMap.has(value) && canProxy(value) ? proxy(value) : value
    addPropListener(prop, nextValue)
    Reflect.set(target, prop, nextValue, receiver)
    notifyUpdate(['set', [prop], value, prevValue])
    return true
  },
})

매개변수로는 4가지를 받습니다

 

  • isInitializing
    객체가 초기화 상태인지 확인하는 함수
  • addPropListener
    특정 속성에 리스너를 추가하는 함수입니다.속성의 변경을 감지하고, 구독자에게 알림을 보내기 위해 사용
  • removePropListener
    속성에 연결된 리스너를 제거하는 함수
  • notifyUpdate
    속성 값이 변경되었을 때 변경 사항을 알리는 함수로 변경된 속성의 이름과 값, 이전 값 등을 구독자에게 전달함

 

 

또한 앞서 console.log에서 확인했듯이 creatHandler는  set 와 deleteProperty  두가지 핸들러 메서드를 반환하는 것을 확인할 수 있습니다.

이제 각각의 핸들러 메서드에서 어떤 작업이 진행되는지 알아봅시다.

  • deleteProperty
    1. 삭제하려는 속성의 기존 값을 Reflect.get으로 가져옴
    2. 그 다음, 해당 속성에 연결된 리스너를 제거함
    3. Reflect.deleteProperty를 통해 속성을 삭제
    4. 속성이 성공적으로 삭제되면, notifyUpdate를 호출하여 삭제 작업을 listener에게 알림
    5.삭제가 여부를 반환합니다.
  •  set
    1. 속성의 이전 값이 존재하는지 확인하고 초기화 중이 아니라면, Reflect.has로 해당 속성이 이미 있는지 확인하고, 이전 값을 가져옵니다.
    2.만약 이전 값과 새 값이 동일하거나, 새 값이 이미 캐시된 프록시 값과 동일하다면 true를 반환하고 설정 작업을 종료
    3. 새로운 값이 설정될 속성에 대해 기존 리스너를 제거함
    4.  새 값이 객체일 경우, getUntracked를 호출해 프록시되지 않은 원본 객체를 가져오거나, 새 값이 프록시 가능한 경       우 proxy()를 사용해 새로운 프록시로 변환.
    5. addPropListener를 통해 새 값에 대한 리스너를 추가
    6. Reflect.set을 호출하여 속성에 새로운 값을 설정
    7.  notifyUpdate를 호출해 속성 값이 변경되었음을 알림

결국 proxy는 캐시를 활용한 최적화에 대한 작업과 쓰기 등록 시 구독자에게 알려준다는 사실을 알았습니다. 하지만 

컴퍼넌트가 해당 객체의 어떤 속성을 사용하는지 어떻게 알고 리렌더링 시키는지에 대해서 알기 위해선 useSnapshot훅을 분석할 필요성이 있습니다.

 

useSnapshot

import {
  useCallback,
  useDebugValue,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useSyncExternalStore,
} from 'react'
import {
  affectedToPathList,
  createProxy as createProxyToCompare,
  isChanged,
} from 'proxy-compare'
import { snapshot, subscribe } from './vanilla.ts'
import type { Snapshot } from './vanilla.ts'


export function useSnapshot<T extends object>(
  proxyObject: T,
  options?: Options,
): Snapshot<T> {
  const notifyInSync = options?.sync
  // per-proxy & per-hook affected, it's not ideal but memo compatible
  const affected = useMemo(
    () => proxyObject && new WeakMap<object, unknown>(),
    [proxyObject],
  )
  const lastSnapshot = useRef<Snapshot<T>>()
  let inRender = true
  const currSnapshot = useSyncExternalStore(
    useCallback(
      (callback) => {
        const unsub = subscribe(proxyObject, callback, notifyInSync)
        callback() // Note: do we really need this?
        return unsub
      },
      [proxyObject, notifyInSync],
    ),
    () => {
      const nextSnapshot = snapshot(proxyObject)
      try {
        if (
          !inRender &&
          lastSnapshot.current &&
          !isChanged(
            lastSnapshot.current,
            nextSnapshot,
            affected,
            new WeakMap(),
          )
        ) {
          // not changed
          return lastSnapshot.current
        }
      } catch {
        // ignore if a promise or something is thrown
      }
      return nextSnapshot
    },
    () => snapshot(proxyObject),
  )
  inRender = false
  useLayoutEffect(() => {
    lastSnapshot.current = currSnapshot
  })
  if (import.meta.env?.MODE !== 'production') {
    condUseAffectedDebugValue(currSnapshot as object, affected)
  }
  const proxyCache = useMemo(() => new WeakMap(), []) // per-hook proxyCache
  return createProxyToCompare(currSnapshot, affected, proxyCache, targetCache)
}

 

proxy를 캐시하는 내용과 유효성 검사등의 내용에 대해서는 생략하겠습니다.

내부에서 어떻게 proxy의 변경을 감지하고 useSnapshot 훅의 리턴값을 사용만 하면 특정 상태에 대한 구독이 되는지에 대해서 알아보려고 합니다.

우선 proxy의 핸들러 메서드를 통해서 쓰기 및 삭제 시 listener들에게 알림이 간다는 사실을 확인했습니다.

이때 리스너가 알림을 받았을 때, 리렌더링을 하게 하는 훅이 useSyncExternalStore 훅입니다. 

 

useSysncstore의 훅은 3가지의 매개변수를 받을 수 있습니다.

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
  • subscribe: 하나의 callback 인수를 받아 store에 구독하는 함수입니다. store가 변경될 때, 제공된 callback이 호출되어 React가 getSnapshot을 다시 호출하고 (필요한 경우) 컴포넌트를 다시 렌더링하도록 해야 합니다. subscribe 함수는 구독을 정리하는 함수를 반환해야 합니다.
  • getSnapshot: 컴포넌트에 필요한 store 데이터의 스냅샷을 반환하는 함수입니다. store가 변경되지 않은 상태에서 getSnapshot을 반복적으로 호출하면 동일한 값을 반환해야 합니다. 저장소가 변경되어 반환된 값이 다르면 (Object.is와 비교하여) React는 컴포넌트를 리렌더링합니다.
  • optional getServerSnapshot: store에 있는 데이터의 초기 스냅샷을 반환하는 함수입니다. 서버 렌더링 도중과 클라이언트에서 서버 렌더링 된 콘텐츠의 하이드레이션 중에만 사용됩니다. 서버 스냅샷은 클라이언트와 서버 간에 동일해야 하며 일반적으로 직렬화되어 서버에서 클라이언트로 전달됩니다. 이 함수가 제공되지 않으면 서버에서 컴포넌트를 렌더링할 때 오류가 발생합니다.

이제 다시 useSnapshot 훅을 보면 1. useCallback으로 감싸진 subscribe부분, 2. 화살표 함수로된 getSnapshot, 
3. ()=>snapshot(proxyObject)인 getServerSnapshot 을 확인할 수 있습니다.

 const currSnapshot = useSyncExternalStore(
    useCallback(
      (callback) => {
        const unsub = subscribe(proxyObject, callback, notifyInSync)
        callback() // Note: do we really need this?
        return unsub
      },
      [proxyObject, notifyInSync],
    ),
    () => {
      const nextSnapshot = snapshot(proxyObject)
      try {
        if (
          !inRender &&
          lastSnapshot.current &&
          !isChanged(
            lastSnapshot.current,
            nextSnapshot,
            affected,
            new WeakMap(),
          )
        ) {
          // not changed
          return lastSnapshot.current
        }
      } catch {
        // ignore if a promise or something is thrown
      }
      return nextSnapshot
    },
    () => snapshot(proxyObject),
  )

 

 

1. subscribe 함수에서 전달된 callback에서는 proxyObject의 상태 변화를 감지하고 상태가 변경될 때마다 이 callback이 호출되어 새로운 스냅샷을 가져오고 컴포넌트를 리렌더링합니다.
2. getSnapshot 함수에 전달된 화살표 함수 내부에 있는 isChanged 함수는 상태가 변경되었는지 확인하는 역할을 합니다. 만약 상태가 변하지 않았으면 리렌더링을 방지합니다.

 

이제 proxy 객체가 변화가 되었을 때, 왜 컴퍼넌트가 리렌더링하는지는 알게 되었습니다. 하지만 아직 어떻게 특정 컴퍼넌트가 객체를 사용하고 있는지에 대해서는 언급이 없었습니다.

 

해당 내용은 createProxyToCompare 이란 별칭으로 사용된  proxy-compare 라이브러리의 createProxy 함수에 있습니다.

공식 문서에서 createProxy 함수에 대한 설명은 아래와 같습니다.

createProxy  함수는 최상위에서 프록시를 생성하고, 접근할 때 중첩된 객체들도 프록시로 만들어, get/has 프록시 핸들러를 통해 어떤 프로퍼티가 접근되었는지를 추적합니다.


해당 함수에 관한 내용은 방대하기에 소스코드 링크만 남겨두고,  어떻게 proxy 핸들러에서 접근을 감지하는지에 대한 부분만 간단하게 추츨하는지 알아보기 위해  proxy의 핸들러의 내용만 추출하면 아래와 같습니다.

 

 const handler: ProxyHandler<T> = {
    get(target, key) {
      if (key === GET_ORIGINAL_SYMBOL) {
        return origObj;
      }
      recordUsage(KEYS_PROPERTY, key);
      return createProxy(
        Reflect.get(target, key),
        state[AFFECTED_PROPERTY] as Affected,
        state[PROXY_CACHE_PROPERTY],
        state[TARGET_CACHE_PROPERTY],
      );
    },
    has(target, key) {
      if (key === TRACK_MEMO_SYMBOL) {
        recordObjectAsUsed();
        return true;
      }
      recordUsage(HAS_KEY_PROPERTY, key);
      return Reflect.has(target, key);
    },
    getOwnPropertyDescriptor(target, key) {
      recordUsage(HAS_OWN_KEY_PROPERTY, key);
      return Reflect.getOwnPropertyDescriptor(target, key);
    },
    ownKeys(target) {
      recordUsage(ALL_OWN_KEYS_PROPERTY);
      return Reflect.ownKeys(target);
    },
  };
  if (isTargetCopied) {
    handler.set = handler.deleteProperty = () => false;
  }
  return [handler, state] as const;
};

 

이떄 get이 proxy를 읽을 때 핸들링하는 내용입니다.  key에 접근할 때, recordUsage를 호출해 KEYS_PROPERTY와 함께 사용 기록을 남기고, 다시 createProxy를 호출하여 그 값을 프록시로 감쌉니다. 따라서 우리가 컴퍼넌트에서 useSnapshot훅의 반환 proxy를 사용하게 되면 get 핸들러 메서드에 의해 기록되어서 추후  상태가 변경되었을 때  관련 상태를 사용한 컴퍼넌트만 리렌더링이 되게 됩니다.

 

요약

리액트 훅을 활용한 마이크로 상태관리훅을 통해서 proxy와 Valtio의 상태 사용 추적이 어떻게 일어나는지에 대해서 알아봤습니다.

proxy를 통해서 원본 객체에 핸들링 메서드를 적용할 수 있는데, Valtio에서는 이를 통해서 proxy의 상태 변화를 감지하고 이를 기반으로 불변의 snapshot을 찍습니다. 이때 상태관리하는 프록시에서는 proxy의 핸들러 메서드 중 set과 deleteProperty를 통해 변경을 listener들에게 알립니다.

 

그리고 리엑트 컴퍼넌트에서는  useSnapshot 훅 내부의 useSyncExternalStore와 createProxy를 통해서 2차 가공된 proxy 객체를 얻습니다. 해당 객체는 원본 proxy가 상태 변화를 할 때마다 새로운 proxy가 형성되는 불변 객체이며 컴퍼넌트에서 가공된 proxy객체(추후 스냅샷이라 부름)를 사용할 경우  createProxy훅 내부에 있는 get 메서드에 의해서 사용이 감지되고 등록이 됩니다. 이후 proxy객체가 변경이되면 useSyncExternalStore훅에 의해서 이전 상태와 차이점이 있는지를 비교하고 있을 경우 리렌더링이 됩니다.

 

 

참고문헌 및 출처

리액트 훅을 활용한 마이크로 상태관리 (다이시 카토 지음)

https://github.com/pmndrs/valtio

 

GitHub - pmndrs/valtio: 🧙 Valtio makes proxy-state simple for React and Vanilla

🧙 Valtio makes proxy-state simple for React and Vanilla - pmndrs/valtio

github.com

 

https://ko.javascript.info/proxy#ref-1036

 

Proxy와 Reflect

 

ko.javascript.info

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Reflect

 

Reflect - JavaScript | MDN

Reflect 는 중간에서 가로챌 수 있는 JavaScript 작업에 대한 메서드를 제공하는 내장 객체입니다. 메서드의 종류는 프록시 처리기와 동일합니다. Reflect는 함수 객체가 아니므로 생성자로 사용할 수

developer.mozilla.org

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Proxy

 

Proxy - JavaScript | MDN

Proxy 객체를 사용하면 한 객체에 대한 기본 작업을 가로채고 재정의하는 프록시를 만들 수 있습니다.

developer.mozilla.org

https://www.npmjs.com/package/proxy-compare

 

proxy-compare

Compare two objects using accessed properties with Proxy. Latest version: 3.0.0, last published: 5 months ago. Start using proxy-compare in your project by running `npm i proxy-compare`. There are 39 other projects in the npm registry using proxy-compare.

www.npmjs.com

https://ko.react.dev/reference/react/useSyncExternalStore

 

useSyncExternalStore – React

The library for web and native user interfaces

ko.react.dev

 

+ Recent posts