Expo Router에서 버튼 연타 시 페이지가 중복 푸쉬되는 문제 해결 (es-toolkit)

NARARIA03·2025년 2월 16일
1
post-thumbnail

개요

예전 react-navigation/stack을 사용할 때는 페이지 중복 푸쉬 문제가 없었기 때문에, 이번 Melissa 앱을 개발할때도 페이지 이동을 router.push()router.back()을 사용해 구현했다.

예를 들어 메인 페이지에서 채팅 페이지로 이동시키는 버튼 컴포넌트를 아래처럼 구현했다.

// src/pages/Calendar/ChatButton/index.tsx

import { useRouter } from "expo-router";
import { shadowProps } from "@/src/constants/shadowProps";
import * as S from "./styles";


function ChatButton(): JSX.Element {
  const router = useRouter();

  const handleChattingPress = () => router.push("/(app)/chatting");

  return (
    <S.Btn style={shadowProps} onPress={handleChattingPress}>
      <S.ButtonImage source={require("@/assets/images/chatButton.png")} contentFit="cover" />
    </S.Btn>
  );
}

export default ChatButton;

그런데, 이후 테스트 과정에서 버튼을 연타하면 페이지 push가 여러 번 발생한다는 사실을 우연히 알게 되었다.

이를 해결하기 위해 관련 이슈를 많이 찾아봤는데, 대부분 대안으로 버튼의 onPress 함수에 debounce를 적용해서 해결한 사례가 많이 보였다.


Debounce?

ThrottleDebounce 모두 자주 실행되는 이벤트나 함수의 실행 빈도를 조절하는 방법이지만 조절 방법에 차이가 존재한다.

ThrottleDebounce의 동작에 대한 이미지와 함께 간단히 정리해보자.
(출처: http://xandeadx.ru/blog/javascript/956)

  • Throttle: 꾸준히 실행되는 이벤트/함수를 특정 시간 간격마다 실행하도록 한다.
    ex) 자동 완성 기능, scroll 이벤트 리스너 등에 사용
  • Debounce: 여러 번 발생한 이벤트를 묶어 한 번만 실행되도록 한다.
    ex) API 요청, resize 이벤트 리스너 등에 사용

lodash vs es-toolkit

debounce 기능을 직접 구현할 수도 있지만 개발 편의성과 안정성을 위해 라이브러리를 사용하기로 결정했고, lodashes-toolkit 중 하나를 사용하기로 생각했다.

사용자 수는 압도적으로 lodash가 많지만, es-toolkit이 가지는 장점들이 마음에 들어 최종적으로 es-toolkit을 사용하기로 결정했다.

  1. lodash의 마지막 업데이트는 4년 전이다.
  2. es-toolkit의 번들 사이즈는 lodash에 비해 현저히 작고, 트리 쉐이킹을 지원한다.
  3. es-toolkit의 유틸 함수들의 실행 성능이 lodash보다 뛰어나다.
  4. 국내(Toss)에서 관리중인 오픈소스 라이브러리다. 공식 문서

React Native에서 es-toolkit 사용하기

우선 설치는 각자 사용하는 패키지 매니저로 간단히 설치하면 된다.

pnpm add es-toolkit
pnpm run ios|android

하지만, 설치 후 debounce 함수를 사용하려고 하면 에러가 발생한다.

Warning: TypeError: 0, _esToolkit.debounce is not a function 
(it is undefined)

관련 문제를 찾아보니 metro.config.js에서 es-toolkit 경로를 명시해 metro 번들러에게 알려줘야 한다는 것을 알았다. (출처: https://zenn.dev/kazutoyo/scraps/26982ba8d77d9a)

프로젝트 루트에 metro.config.js를 생성하고, 아래와 같이 추가해주자.

// metro.config.js

const { getDefaultConfig } = require("@expo/metro-config");
const path = require("path");

/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);

config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (moduleName.startsWith("es-toolkit")) {
    const esToolkitPath = path.resolve(__dirname, "node_modules/es-toolkit/dist/index.js");
    return {
      filePath: esToolkitPath,
      type: "sourceFile",
    };
  }
  return context.resolveRequest(context, moduleName, platform);
};

module.exports = config;

이제 에러가 발생하지 않는다!


es-toolkit의 debounce 함수 이해하기

공식 문서가 잘 되어있지만, 헷갈렸던 부분 위주로 다시 정리하며 이해해보려고 한다.

먼저, es-toolkit 문법lodash 호환성 문법에 차이점이 존재한다는 것에 주의해야 한다. es-toolkit은 lodash에서 마이그레이션 하기 쉽도록 es-toolkit/compat을 함께 제공하고 있는데, 문법이 다르므로 헷갈리지 않도록 주의하자.

debounce 함수는 어떤 args도 받을 수 있는 함수를 첫 번째 인자로 받고, 두 번째 인자로 debounce 시간을 받고, 마지막으로 options를 선택적으로 받는다.

options 객체는 signaledge를 선택적으로 받을 수 있는데, edges 배열 값을 조절해 함수 실행 시기를 조절할 수 있다.

만약 trailing이라면 기다렸다가 실행하므로 지연이 발생하지만, leading을 주게 되면 일단 함수를 바로 실행하고 이후 실행을 무시하게 된다.

우리는 버튼 클릭 시 즉시 페이지는 이동하되, 중복 push를 막으려는 목적이므로 edges 옵션에 leading을 줘야 한다.

좌측 leading과 우측 trailing의 차이를 확인할 수 있다. 버튼을 누르고 페이지가 이동하는 시점을 확인하면 된다. 차이가 잘 보이도록 debounceMs는 1초로 잡았다.


debounce를 활용해 스택 중복 푸쉬 문제 해결

현재 Melissa 앱에는 버튼을 통한 페이지 이동이 많지 않지만, 추후 페이지가 더 많아진다면 debounceMs를 조절할 때 불편할 것 같았다.

그래서 동일한 debounceMs를 보장하기 위해 스택 네비게이터 push 전용 헬퍼 함수를 구현하고, 이를 활용하기로 마음먹었다.

// src/libs/esToolkit.ts

import { debounce } from "es-toolkit";

export const preventDoublePress = <F extends (...args: any[]) => void>(fn: F) => {
  return debounce(fn, 500, { edges: ["leading"] });
};

이제 preventDoublePress 함수를 사용하면, 자동으로 500msleading 옵션이 포함된 debounce가 적용된다. 500ms 정도면 적절하게 버튼 연타를 커버해줬다.

앞서 살펴본 ChatButton 컴포넌트에 preventDoublePress 함수를 적용해 Stack 중복 푸쉬 문제가 해결되는지 확인해보자.

// src/pages/Calendar/ChatButton/index.tsx

import { useRouter } from "expo-router";
import { preventDoublePress } from "@/src/libs/esToolkit";
import { shadowProps } from "@/src/constants/shadowProps";
import * as S from "./styles";

function ChatButton(): JSX.Element {
  const router = useRouter();

  const handleChattingPress = preventDoublePress(() => router.push("/(app)/chatting"));

  return (
    <S.Btn style={shadowProps} onPress={handleChattingPress}>
      <S.ButtonImage source={require("@/assets/images/chatButton.png")} contentFit="cover" />
    </S.Btn>
  );
}

export default ChatButton;

Stack 중복 푸쉬 문제가 잘 해결되는 것을 확인할 수 있다!


정리

처음에는 debounce를 활용하는 해결책이 근본적 해결책이라기보단 일종의 우회책(해외에서는 hacking이라고 부르는 것 같다)같이 느껴져 사용을 꺼렸는데, 아무리 찾아봐도 근본적 해결책이라고 느껴지는 방안이 없어 테스트 해보게 되었다.

그런데 막상 debounce를 적용해보니 잘 해결되었고, 부수적인 문제도 아직까지 발생하지 않아 나쁘지 않은 해결책인 것 같다고 느꼈다.

글 읽어주셔서 감사합니다. 혹시 expo-router를 사용해 페이지 이동을 구현하던 중 버튼 연타 시 페이지가 여러 번 push되는 문제를 겪고 계시다면, 도움이 되었길 바랍니다.

이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다!


profile
신입 프론트엔드 개발자입니다. React와 RN 생태계를 좋아합니다.

2개의 댓글

comment-user-thumbnail
2025년 6월 21일

참고가 되어 도움이 되었어요. 좋은 글 감사합니다!

1개의 답글