웹/프론트엔드

[React] 재귀적인 구조에서 검색기능 구현하기

이민훈 2022. 8. 15. 05:17

프론트엔드 개발을 하면서 컴퓨터의 폴더 구조처럼 재귀적인 구조로 데이터를 렌더링해야 할 때가 있다.

아래 데이터를 보면 Directory 타입은 이름과 하위 Directory들을 가지고 있다.

 

interface Directory {
  name: string;
  directory?: Array<Directory>;
}

const directory: Array<Directory> = [
  { name: "직박구리", directory: [] },
  {
    name: "가마우지",
    directory: [
      {
        name: "동고비",
        directory: [{ name: "곤줄박이" }, { name: "할미새사촌" }],
      },
      {
        name: "조롱이",
        directory: [
          { name: "말똥박이", directory: [{ name: "오목눈이" }] },
          { name: "지빠귀" },
        ],
      },
    ],
  },
  { name: "나무발발이" },
];

 

리액트로 해당 구조를 렌더링해 보자.

먼저 폴더들을 반복해서 렌더링할 List 컴포넌트가 필요하다.

 

import { Fragment } from "react";
import { Directory } from "../../App";
import Item from "../Item";

interface ListProps {
  data: Array<Directory>;
  depth: number;
}

const List = ({ data, depth }: ListProps) => {
  return (
    <>
      {data?.map((item, index) => (
        <Fragment key={index}>
          <Item data={item} depth={depth} />
        </Fragment>
      ))}
    </>
  );
};

export default List;

 

그다음 자신과 하위 디렉터리들을 다시 List 컴포넌트를 호출해 렌더링하면 재귀적인 폴더 구조를 렌더링할 수 있다.

depth를 받는 이유는 계층 구조를 표현하기 위함이다.

 

import { Directory } from "../../App";
import List from "../List";

interface ItemProps {
  data: Directory;
  depth: number;
}

const Item = ({ data, depth }: ItemProps) => {
  return (
    <>
      <div>
        <span style={{ paddingInlineStart: depth * 20 }}>{data.name}</span>
      </div>
      <div>
        <List data={data.directory ?? []} depth={depth + 1} />
      </div>
    </>
  );
};

export default Item;

 

폴더의 계층구조가 제대로 표현되었다.

이제 폴더 검색 기능을 추가해 보자.

 

제대로 렌더링 된 폴더 구조

 

먼저 검색이 가능하게끔 string 타입의 state를 하나 만들어 input 태그로 state를 변경할 수 있게끔 했다.

 

function App() {
  const [searchWord, setSearchWord] = useState<string>("");
  return (
    <div>
      <input
        value={searchWord}
        onChange={(e) => setSearchWord(e.target.value)}
      />
      <List data={directory} depth={0} searchWord={searchWord} />
    </div>
  );
}

export default App;

 

그런 다음, List 컴포넌트에서 디렉터리의 이름 중에 searchWord를 포함하고 있는 것만 렌더링 해보자

 

import { Fragment } from "react";
import { Directory } from "../../App";
import Item from "../Item";

interface ListProps {
  data: Array<Directory>;
  depth: number;
  searchWord: string;
}

const List = ({ data, depth, searchWord }: ListProps) => {
  return (
    <>
      {data?.map((item, index) => (
        <Fragment key={index}>
          {item.name.includes(searchWord) && (
            <Item data={item} depth={depth} searchWord={searchWord} />
          )}
        </Fragment>
      ))}
    </>
  );
};

export default List;

 

제일 최상단의 가마우지 폴더는 검색이 잘 되나, 하위 뎁스에 포함된 폴더들은 검색이 안된다.

이 방법으로는 원하는 기능을 구현할 수 없으니 다시 되돌려놓자.

 

최상단 폴더인 가마우지

 

하위 폴더인 할미새사촌

 

List 컴포넌트에서 본인의 폴더 이름에 searchWord가 포함되지 않는 경우 하위 컴포넌트 자체를 렌더링 시키지 않아서 그렇다.

이런 경우에 제일 최상단에서 폴더를 재귀 탐색하여, 미리 데이터를 가공하거나

핸들러를 계속 넘겨 해결하거나 전역 스토어를 사용해 해결하는 등 다양한 방법으로 해결이 가능하겠지만,

저 폴더들이 서버로부터 받아오는 데이터고 폴더를 펼칠 때 자식 데이터들을 불러온다고 가정해 보자.

 

처음부터 모든 데이터를 들고 있는 게 아니기 때문에 최상단에서 가공하거나 하는 등의 방법도 상당히 복잡해진다.

또한 state 관리가 되게 복잡해지고 구현 난이도가 상당히 올라가게 된다.

이때 아주 간단하게, Dom에 접근하여 Document의 기본 메서드를 이용해 해결하는 방법이 있다.

 

import { useEffect, useRef, useState } from "react";
import { Directory } from "../../App";
import List from "../List";

interface ItemProps {
  data: Directory;
  depth: number;
  searchWord: string;
}

const Item = ({ data, depth, searchWord }: ItemProps) => {
  const [visible, setVisible] = useState<boolean>(true);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (ref?.current) {
      setVisible(
        data.name.includes(searchWord) ||
          ref?.current?.getElementsByTagName("a").length > 0
      );
    }
  }, [ref, searchWord]);

  return (
    <>
      {data.name.includes(searchWord) && <a />}
      {visible && (
        <div>
          <span style={{ paddingInlineStart: depth * 20 }}>{data.name}</span>
        </div>
      )}
      <div ref={ref}>
        <List
          data={data.directory ?? []}
          depth={depth + 1}
          searchWord={searchWord}
        />
      </div>
    </>
  );
};

export default Item;

 

먼저 한줄 씩 보자. 아래 구문은 폴더 이름에 검색한 단어가 포함되면 a 태그를 삽입하는 구문이다.

 

{data.name.includes(searchWord) && <a />}

 

하위 폴더들을 탐색하기 위해 하위 폴더들을 감싼 태그에 ref값을 주입시켰다.

 

  <div ref={ref}>
    <List
      data={data.directory ?? []}
      depth={depth + 1}
      searchWord={searchWord}
    />
  </div>

 

가장 중요한 useEffect 부분인데, searchWord가 바뀔 때,

폴더 이름에 검색한 단어가 포함되는지 혹은 자식 노드(하위 폴더)중에 a태그가 하나라도 있다면 스테이트 값을 true로 바꾼다.

getElementsByTagName은 돔 아래에 해당 태그를 모두 탐색해 주기 때문에, 계층에 상관없이 탐색이 가능하다.

 

  useEffect(() => {
    if (ref?.current) {
      setVisible(
        data.name.includes(searchWord) ||
          ref?.current?.getElementsByTagName("a").length > 0
      );
    }
  }, [ref, searchWord]);

 

검색 기능이 제대로 작동하는 것을 볼 수 있다.

 

오목을 검색했을 때 오목눈이 폴더를 자식으로 가진 모든 폴더들이 렌더링되는 모습

 

생각 외로 이처럼 dom 요소에 직접 접근해 해결할 수 있는 문제가 많다.

해결하기 힘든 문제를 만났을 때, html 태그와 돔에 직접 접근하는 방법을 사용해 해결할 수 있는지 한 번쯤 고민해 보자.