이 글은 삼성 청년 SW AI 아카데미에서 진행한 "동행" 프로젝트에서 발생한 이벤트 리스너 중복 문제에 대해 다룹니다.
프로젝트 개발 중 아주 치명적인 이슈가 발생했다.
사용자가 확인 버튼을 누르면 다음 화면으로 넘어가야 하는데, 갑자기 종료 멘트를 치면서 첫 화면으로 돌아가버리는 오류였다...!
이 오류가 발생하면 시연이 불가능하기 때문에 어떻게든 해결해야 했다.
여러 번 테스트를 해보면서 이슈가 발생하는 환경을 파악했다.
문제가 발생하는 상황
1. 시니어 사용자 모드로 은행 업무를 한 번 진행
2. 첫 화면으로 돌아와서 다른 업무를 진행하려고 할 때 발생
3. 키패드의 다른 버튼들은 정상 작동하지만, "확인" 버튼만 문제 발생
처음 시니어 사용자 모드로 은행 업무를 볼 때는 전혀 문제가 없지만, 두 번째 플로우에서 이슈가 발생하는 것으로 보아 어딘가에서 이벤트가 꼬였음을 예상할 수 있었다.

가장 문제가 될 확률이 큰 곳은 useSubMonitorListeners 훅을 실행해 이벤트를 등록하는 부분이었기 때문에, 이 부분을 중점적으로 확인했다.
import { useEffect } from "react";
export const useSubMonitorListeners = (
onNumberUpdate: (value: string) => void,
onConfirm: () => void,
onCancel: () => void
): void => {
useEffect(() => {
// 새로운 리스너 등록
const numberCleanup = window.mainAPI.onSubNumberUpdate(onNumberUpdate);
const confirmCleanup = window.mainAPI.onCallConfirm(onConfirm);
const cancelCleanup = window.mainAPI.onCallCancel(onCancel);
// 언마운트 시 이벤트 해제
return (): void => {
numberCleanup();
confirmCleanup();
cancelCleanup();
};
}, [onNumberUpdate, onConfirm, onCancel]);
};
// 이벤트 해제 구현부
onCallConfirm: (callback: () => void) => {
const handler = (): void => callback();
ipcRenderer.on("call-confirm", handler);
return (): void => {
// 이벤트 해제
ipcRenderer.removeListener("call-confirm", handler);
};
},
onCallCancel: (callback: () => void) => {
const handler = (): void => callback();
ipcRenderer.on("call-cancel", handler);
return (): void => {
// 이벤트 해제
ipcRenderer.removeListener("call-cancel", handler);
};
},
코드를 보면 리스너를 등록하고 언마운트 될 때 이벤트를 해제하는 것을 알 수 있었다. 이벤트를 잘 해제하는 것 같은데 무엇이 문제일까... 하고 고민을 해보았다.
특정 페이지에서는 Cancel 버튼을 누를 때 첫 페이지로 돌아가게 하는 로직이 있다. 현재 문제가 확인 버튼을 누르면 첫 페이지로 돌아가는 것이니, 연관성이 있을 수 있다고 판단했다.
이벤트 전달 과정:
Sub Renderer → Sub Preload → Main Process → Main Preload → Main Renderer
이 과정에서 onCallCancel과 onCallConfirm이 서로 잘못 구독했거나 하는 부분이 있나 확인해보았다.
결론: 이 부분에서는 문제를 발견하지 못했다. 애초에 이벤트가 꼬이는 문제였다면 첫 번째 플로우부터 제대로 동작하지 않았을 것이다.
가장 현실적으로 확률이 높은 이유였다.
확인을 위해 onCallConfirm에 첫 화면으로 돌아가게 하는 로직을 넣은 부분을 찾았고, 딱 한 곳이 있었다.
예상했던 그 문제가 맞았다!

은행 업무 플로우 마지막에 첫 화면으로 돌아갈 수 있는 "확인" 버튼이 있었는데, 마지막에 한번만 사용되고 다른 버튼과 달리 onConfirm 하나만 필요하다 보니
useSubMonitorListeners 훅을 거치지 않고 바로 IPC 통신으로 이벤트를 등록해버린 것이다.

문제의 핵심
1. 마지막 화면에서useSubMonitorListeners훅을 사용하지 않아 언마운트 시 자동 클린업이 적용되지 않음
->onCallConfirm에 화면 첫 부분으로 돌아가는 리스너 잔류
2. 이후onCallConfirm을 재등록하는 과정에서 남아있던 리스너가 중복 실행
3. 결과적으로 첫 화면으로 돌아가는 동작이 발생
문제를 해결하기 위한 방법을 세 가지로 정리해보았다.
onCallConfirm: (callback: () => void) => {
// 기존 리스너를 모두 제거하고 새로 등록
ipcRenderer.removeAllListeners("call-confirm");
const handler = (): void => callback();
ipcRenderer.on("call-confirm", handler);
},
리스너를 등록할때마다 기존 리스너를 모두 제거하는 가장 간단한 해결책이다.
하지만 이벤트를 등록한 쪽이 아닌 새로 생긴 이벤트가 기존의 리스너를 지우는 방식이기 때문에,
이벤트 관리 책임이 불명확해져 향후 유지보수에 문제가 될 수 있어 제외했다.
기존 코드 (문제):
window.mainAPI.send("set-sub-mode", "confirm", { label: buttonText });
window.mainAPI.onCallConfirm(() => navigate(link)); // cleanup 없음!
개선 코드:
useEffect(() => {
const cleanup = window.mainAPI.onCallConfirm(() => navigate(link));
const timer = setTimeout(() => {
setIsVisible(true);
window.mainAPI.send("set-sub-mode", "confirm", { label: buttonText });
}, 1000);
return (): void => {
clearTimeout(timer);
if (typeof cleanup === "function") {
cleanup(); // 명시적으로 cleanup 호출
}
};
}, []);
마지막 페이지에 cleanup 을 수행하지 않아 발생한 문제이므로
문제가 발생한 지점에서 cleanup 을 수행하게 해주는 방법이다!
좋은 방법이라고 생각했으나, 이렇게 해결하게 되면 이후에 다른 곳에서도 명시적인 cleanup 을 빼먹으면 같은 문제가 발생할 것이기 때문에
가장 좋은 방법은 아니라고 판단했다.
애초에 이 문제가 발생한 근본 원인은 useSubMonitorListeners 훅이 세 개의 이벤트를 모두 필수로 받아야 하고,
onConfirm만 필요한 경우에 사용할 수 없었던 것이 문제였다.
모든 키패드 관련 이벤트 등록에서 useSubMonitorListeners 를 사용하는 것이 일관된 패턴으로 모든 이벤트 리스너를 관리할 수 있기 때문에
useSubMonitorListeners 를 개선해보았다.
개선된 훅
interface SubMonitorListenersOptions {
// optional 사용
onNumberUpdate?: (value: string) => void;
onConfirm?: () => void;
onCancel?: () => void;
}
export const useSubMonitorListeners = (options: SubMonitorListenersOptions): void => {
const { onNumberUpdate, onConfirm, onCancel } = options;
useEffect(() => {
const cleanups: (() => void)[] = [];
// 필요한 리스너만 선택적으로 등록
if (onNumberUpdate) {
const numberCleanup = window.mainAPI.onSubNumberUpdate(onNumberUpdate);
cleanups.push(numberCleanup);
}
if (onConfirm) {
const confirmCleanup = window.mainAPI.onCallConfirm(onConfirm);
cleanups.push(confirmCleanup);
}
if (onCancel) {
const cancelCleanup = window.mainAPI.onCallCancel(onCancel);
cleanups.push(cancelCleanup);
}
// 등록된 리스너만 정리
return (): void => {
cleanups.forEach((cleanup) => cleanup());
};
}, [onNumberUpdate, onConfirm, onCancel]);
};
사용 예시 비교

이 방법을 선택하면 일관성을 유지하면서, 이런 문제가 재발할 가능성을 줄여주기 때문에 최종적으로 커스텀 훅을 개선하는 방안을 선택했다!
실제로 아주 잘 작동해 기존의 문제가 사라졌다.

간단한 문제였지만, 이벤트 정리를 소홀히 한 덕분에 코드를 뜯어보느라 꽤 많은 시간을 썼다.
React + Electron 환경에서 이벤트 리스너를 다룰 때 주의할 점:
useEffect의 cleanup 함수를 활용해 컴포넌트 언마운트 시 자동으로 정리되도록 해야 한다프로젝트에서 이미 useSubMonitorListeners라는 패턴이 있었는데, 급하게 개발하다 보니 확장성을 고려하지 않고 직접 IPC를 호출하는 예외 상황을 만들었다.
교훈
긴 글 읽어주셔서 감사합니다! 비슷한 문제를 겪고 계신 분들께 도움이 되었으면 좋겠습니다. 🙂