[Deadlock] 프로젝트 개발기 - 3

2024년 09월 11일
10

Next.js 14.2.7 / Typescript / Tailwindcss을 기반으로 작성된 글입니다.

실제 인게임에서는 아이템 정보를 제목 + 내용으로만 검색할 수 있게 되어 있지만, 저는 제목 / 내용 / 제목 + 내용 별로 검색할 수 있는 기능을 만들어보기로 했습니다.

검색 기능 구현하기

기본적으로 검색할 키워드를 입력할 input 요소가 필요할 것이고, 해당 요소에 입력한 키워드를 변수나 상태로 저장해 키워드가 아이템 정보 안에 포함되는지를 확인하는 방식으로 구현하고자 했습니다.
그러기 위해서 가장 먼저 필요한 것은 input 요소에 입력한 키워드를 가져오는 것인데, addEventListener를 활용해 input 이벤트를 감지하고, 해당 이벤트 타겟의 값을 가져오는 것으로 이를 구현할 수 있습니다.

const [keyword, setKeyword] = useState(""); //검색할 키워드
 
window.addEventListener("input", search); //두번째 인자로 받은 콜백함수는 해당 이벤트가 감지되었을 때 실행
 
//검색 이벤트가 감지되었을 때 이벤트 타겟의 value(여기서는 input 요소에 입력한 키워드)를 상태로 저장
const search = (e) => {
    setKeyword(e.target.value);
};

이런 식으로 만들면 아래와 같은 결과를 얻을 수 있습니다.

키워드 검색
키워드 검색

키를 하나씩 입력할 때마다 입력 이벤트를 감지하고 콜백함수를 실행하는 모습입니다.
키워드를 상태로 저장하는 함수를 콜백함수로 받았기 때문에 키를 하나씩 입력할 때마다 화면이 리렌더링되게 되고 이는 계속해서 화면이 깜빡이는 듯한 인상을 줄 수 있습니다.

키를 입력할 때마다 화면이 깜빡인다
키를 입력할 때마다 화면이 깜빡인다

Debounce

일반적으로 이렇게 짧은 시간에 다량의 이벤트가 발생하는 경우 생기는 부하를 해결하기 위해 Debounce와 Throttle이라는 방법으로 이벤트를 제어하는데, 저는 Debounce 기법을 사용했습니다.
Debounce란 이벤트가 발생할 때마다 자체적인 타이머를 발생시키고, 타이머가 실행 중일 때 같은 이벤트가 감지될 경우 타이머를 초기화함으로써 가장 마지막, 혹은 가장 처음 실행된 이벤트만 취급하는 방법입니다.
이로써 연속적으로 발생하는 이벤트를 하나의 그룹 이벤트로 그룹화하여 무의미한 호출을 최소화할 수 있습니다.

코드로 나타내면 다음과 같습니다.

//typescript에서 사용할 경우 타입을 지정할 필요가 있습니다.
let debounceTimer: NodeJS.Timeout | null;
 
const search = (e) => {
    //이미 타이머가 실행중이면 타이머를 제거하고 새로 생성함으로써 초기화합니다.
    if (debounceTimer) {
        clearTimeout(debounceTimer);
    }
 
    /*
    250ms 이후 실행될 콜백함수를 설정해둡니다.
    250ms가 지나기 전에 새로 이벤트가 감지될 경우
    콜백함수는 실행되지 않고 타이머가 초기화됩니다.
    */
    debounceTimer = setTimeout(() => {
        setKeyword(e.target.value);
    }, 250);
};

이렇게 하면 입력 이벤트를 감지하는 횟수가 획기적으로 줄어들고 불필요한 리렌더링을 최소화할 수 있습니다.

console 창으로 확인한 모습
console 창으로 확인한 모습
깜빡이는 문제가 사라졌다
깜빡이는 문제가 사라졌다

filter()와 includes()

이제 입력받은 키워드를 바탕으로 '검색'을 해야 합니다. 저는 JS의 내장 함수인 filter()includes()를 통해 이를 구현하고자 했습니다.
filter()는 입력받은 콜백함수의 조건을 통과하는 배열의 요소만 남기는 함수이고, includes()는 배열의 항목에 원하는 요소가 있는지 여부를 판단하는 함수입니다. 이 둘을 조합하면 간단하게 검색 기능을 만들 수 있습니다.

가령 제목으로 검색을 하려 할 경우, 아래와 같은 코드로 구현할 수 있습니다.

useEffect(() => {
    //item.localization.ko(아이템의 한국 이름)가 keyword를 포함하는 item만 남게 된다
    setItemList(items.filter((item) => item.localization.ko.includes(keyword)));
}, [keyword]); //keyword가 바뀔 때만 재실행

사전에 item의 프로퍼티에 item의 이름 외에도 다양한 데이터를 넣어놨기 때문에, 해당 속성을 바꿔주는 것으로 내용 검색이나 제목 + 내용 검색도 쉽게 구현할 수 있습니다.
다만 제목 + 내용의 경우 여러 데이터에서 검색한 값을 합쳐야 하는 절차를 거쳐야 합니다. 여기서 중복된 객체의 값을 제거하는 방법을 찾아야 했습니다.

중복된 객체를 제거하는 방법

중복된 요소를 제거하는 방법을 생각하면 가장 먼저 떠오르는 것은 Set입니다. 요소가 완전히 똑같은 2개의 객체를 Set에 넣으면 중복이 제거될까요?

const set1 = new Set();
set1.add({ a: 1, b: 2 });
set1.add({ a: 1, b: 2 });
 
console.log(set1); // Set(2) {{a:1, b:2},{a:1, b:2}}

결과는 제거되지 않습니다. 객체 타입은 원시 타입이 아닌 참조 타입이기 때문에 두 개의 객체는 겉으로는 똑같이 보여도 서로 다른 메모리 주소를 참조하고 있고, 자바스크립트는 이를 다른 객체로 인식해 중복을 제거하지 않습니다.

const item1 = {
    id: 1,
    localization: {
        en: "Basic Magazine",
        ko: "기본 탄창",
    },
    desc: {
        en: "Increases ammo.",
        ko: "탄약이 증가합니다.",
    },
};
 
const item2 = {
    id: 1,
    localization: {
        en: "Basic Magazine",
        ko: "기본 탄창",
    },
    desc: {
        en: "Increases ammo.",
        ko: "탄약이 증가합니다.",
    },
};
 
console.log(item1 === item2); //false

실제로 두 객체는 id부터 localization, desc의 하위 요소까지 전부 일지하지만 두 객체는 서로 다른 객체로 취급이 되는 것을 볼 수 있습니다. 그래서 Set에 Item 타입의 객체 배열을 넣어도, 중복이 제거되지 않았습니다.

그러므로 두 객체를 비교하기 위해서는 다른 방법을 사용해야 합니다. 객체의 모든 요소를 비교하는 방법도 있겠지만, 저는 객체의 고유한 요소인 id를 비교하는 방법을 취했습니다. filter()와 findIndex() 함수를 사용해 객체의 id가 같지만 전체 배열에서의 index가 다른 요소가 있을 경우 중복된 요소로 간주하고 제거하는 방법으로 중복 제거 기능을 구현했습니다.

//제목 검색결과
const title = items.filter((item) => item.localization.ko.includes(keyword));
 
//내용 검색결과
const desc = items.filter((item) => item.desc?.ko.includes(keyword));
 
//두 검색결과를 합친 배열을 순회하며 id가 같으나 index가 다른 요소들을 제거(중복 제거)
const ret = [...title, ...desc].filter(
    (value, index, arr) => index === arr.findIndex((e) => value.id === e.id),
);
 
setItemList(ret);

이로써 검색 기능을 구현하는 로직에 대해서 알아보았습니다. 다음에는 아이템 빌드 기능을 구현해보도록 하겠습니다.