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

3d 오브젝트를 만드는데는 여러 프로그램이 있습니다. 이중에서 오픈소스 기반 무료인 blender를 통해서 원하는 오브젝트를 만들고, 최종적으로 gltf 혹은 glb파일을 만들어서 웹에 애니메이션 효과를 주고자 배우기 시작하게 되었습니다.

 

 

 

3d viewport  : 왼쪽 오브젝트들이 존재하는 격자가 존재하는 공간으로, 3d 오브젝트를 시각적으로 보여주는 공간

Outliner : 우측 상단에  Camera cube등이 표시된 공간이며  존재하는 3d오브젝트에 대한 개요를 보여줌

Property Editor : 우측 하단의 각각의 오브젝트 클릭시 적용할 수 있는 editor 및 옵션들이 나오는 영역

 

 Viewport 화면 전환

마우스 휠 : 화면 확대/축소

마우스 휠 + 이동  :  Viewport 화면 각도 제어
Shift + 마우스 휠  + 이동 : Viewport 화면 xyz축 제어

Number pad 1~9 :  특정 xyz축 각도에 대해서 이동

 

 

Object 이동 회전  크기  변형

이동 : G . 회전 : R , 크기 : S , 변형 : T

좌측 버튼을 클릭 시 원하는 기능 선택 가능 가능함. 이때 N을 누를 경우 object의 세부 정보 확인 가능

G가 적용된 상태에서 x,y,z를 누르면 각각 x,y,z축에 대한 기능 적용 가능

초기화는 Alt+ 각각의 단축키 (ex : alt+g 는 이동 초기화)

여러 Object  클릭하기

드래그를 통해 클릭하기 or  shift하고 각각 클릭하기

 

Object  생성하기

상단 add를 통해서 오브젝트를 추가할 수 있습니다. 단축키로는 Shift + a 로 생성할 수 있습니다

이때 신규 오브젝트는  Cursor(분홍 동그라미)의 위치에 생성됩니다. 이떄 cursor는 Shitt + S로 오리진으로 이동시킬 수 있습니다.

 

Snap 기능

G로 오브젝트 이동중에 G + B를 누르면 스냅 기능이 적용되며 다른 오브젝트에 붙일 때 사용할 수 있다.

 

 

Shading

Shading : 오브젝트에 색깔과 재질을 입히는 작업

상단 shading 탭을 클릭하여 작업 가능함

6시 하단의 하나하나의 창을  node라고 하며, 새로 추가한 오브젝트의 경우에는 클릭 시 new 버튼을 통해 추가할 수 있다.

 

basic color : 색 조정

metallic : 금속 느낌 (0~1)

Roughness : 표면 거칠기와 반사조절

 

마우스 올리고 백스페이스 누를 경우 기본값으로 초기화됨

이때 오브젝트끼리 동일한 material을 공유할 수 있음

 

Shading 탭에서 상단을 보면 위측에 4개의 버튼을 확인할 수 있습니다

각각의 버튼을 통해서 Viewport에 보이는 화면을 다르게 할 수 있습니다.

좌측에서 우측으로 갈 수록 실제 화면과 유사해집니다. 대신 컴퓨터 리소스를 많이 사용하게 됩니다.

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

 

 

 

+ Recent posts