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

들아가며...

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

 

<input
  type="text"
  value={percent}
  onChange={(e) => handleInputChange(e.target.value)}
  placeholder="0.00~100.00"
  min="0.00"
  max="100.00"
/>

아래와 같이 0.00에서 100.00까지 값이 입력가능한 인풋이 있습니다.

 

이때 컴퍼넌트에서 우리는 해당 컴퍼넌트가 control 되야하는지 유무에 따라서 

값을 state로 다뤄야할 지 ref로 다뤄야 할 지 결정해야 합니다.

저 같은 경우에는 값이 음수는 입력이 되지 않고, 100을 초가화지 않는 2자리 소수의 값을 입력 받기 위해 100이상의 값은 100, 0이하의 값은 0으로 바꾸려고 합니다.

즉 input값이 controlled입니다.

 

따라서 state를 이용해서 초기값을 관리하고 있는데, 이때 초기값은 undefined로 지정했습니다.

  const [percent, setPercent] = useState<string>();

 

하지만 React에서 input의 초기값을 undefined로 설정할 경우에는 uncontrolled라고 해당 input을 여깁니다.

이를 제가 값이 입력될 떄는 controlled 요소처럼 다루어서 위와 같은 경고문이 발생했습니다.

 

입력전에는 placeholder 값이 보이도록 하기 위해서 초기값을 undefined로 지정했는데 placeholder는 빈문자열이 와도 보이게 됩니다.

따라서 초기값을 "" 로 설정하면 해결 할 수 있습니다.

 

  const [percent, setPercent] = useState<string>('');

 

 

 

이때 string 타입이라고 제네릭으로 지정했는데 왜 초기값이 undefined가 가능한 가에 대해서는

useState의 경우에는 2가지 타입을 받을 수 있고, undefined의 경우 따로 입력하지 않아도 두 번째 타입에 의해 할당이 가능합니다.

   /**
     * Returns a stateful value, and a function to update it.
     *
     * @version 16.8.0
     * @see {@link https://react.dev/reference/react/useState}
     */
    function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
    // convenience overload when first argument is omitted
    /**
     * Returns a stateful value, and a function to update it.
     *
     * @version 16.8.0
     * @see {@link https://react.dev/reference/react/useState}
     */
    function useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>];
728x90

오랜만에, deep dive 교재를 다시 읽다가 단순히 규칙처럼 사용했던 불변성에 대해서 왜 그랬을까? 라는 고민을 하게 되었고 작성하게 되었습니다.

 

자바스크립트 객체의 특징

우선 객체에 대해서 먼저 알 필요가 있습니다.

JS에서 객체는 JAVA나 C++와 같은 클래스 기반 객체지향 언어와 다르게, 동적으로 프로퍼티를 삭제 및 추가할 수 있다. 

그렇기 때문에 객체의 경우에 정확히 얼마만큼의 메모리 공간을 확보해야 할지 정할 수 없습니다. 

또한 원시값들을 처리하는 것 처럼 객체가 수정될 때마다 새로운 메모리공간에 수정된 객체를 추가하는 작업은 메모리의 효율이 떨어지고 성능을 저하시킨다.

 

따라서 자바스크립트에서는 해당 객체의 직접적인  주소를 변수에 할당하는 것이 아니라, 해당 객체가 위치하는 메모리 주소를 할당한다. 이를 원시값에서 주소를 직접적으로 할당하는 것과 구별짓기 위해서 교재에서는 ' 공유 에 의한 전달'(참조에 의한 전달)이 라고 한다.

(공유에 의한 전달이 좀 더 정확한 표현이라 느끼나 참조에 의한 전달이 보편적으로 사용되는 느낌입니다.)

deep dive 교재 151pg 참고

즉, 동적인 객체를 효율적으로 관리하기 위해서 위와 같은 공유에 의한 전달 방법을 사용하지만, 그러다 보니 여러 객체가 동일한 하나의 객체를 공유하는 문제도 발생하게 된다. 하지만 객체를 하나하나 프토퍼티 값까지 확인하는 것이 아니라 주소 값을 확인하기 때문에 효율적으로 관리할 수 있게 된다.

 

리액트에서 불변성이 중요한 이유

리액트도 결국 자바스크립트 기반의 라이브러리입니다. 리액트에서는 업데이트 전 후의 두 가지의 가상 돔을 diffing 알고리즘으로 비교하여 변경된 부분만 리렌더링합니다. 따라서 변화를 감지하기 위해서 불변 객체를  사용하여 참조(공유)하고 있는 값이 다르다면 객체가 변했다고 인지하도록 합니다. 

이때 원시값들처럼 객체를 변경할 때 객체 자체를 변경하는 것이 아니라 참조(공유)되고 있는 주소 값이 다르게 하는 것을 불변성을 지킨다고 합니다.


React will ignore your update if the next state is equal to the previous state, as determined by an Object.is comparison. This usually happens when you change an object or an array in state directly:
// 출처 : https://react.dev/reference/react/useState

https://ko.legacy.reactjs.org/tutorial/tutorial.html#why-immutability-is-important

 

 

그리고 불변성을 지키기 위해선 spread문법을 활용하여 새로운 객체를 만드는 것을 공식문서에서는 추천합니다.

setObj({
  ...obj,
  x: 10
});

 

 

참고문헌 

자바스크립트 deepdive 교재

https://react.dev/reference/react/useState

 

useState – React

The library for web and native user interfaces

react.dev

https://legacy.reactjs.org/docs/reconciliation.html?

 

Reconciliation – React

A JavaScript library for building user interfaces

legacy.reactjs.org

 

+ Recent posts