[트러블슈팅] 이벤트 리스너 중복 이슈

응애개발자·2025년 9월 23일

Electron + React 프로젝트에서 이벤트 리스너 중복 문제 해결하기

이 글은 삼성 청년 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);
	};
},

코드를 보면 리스너를 등록하고 언마운트 될 때 이벤트를 해제하는 것을 알 수 있었다. 이벤트를 잘 해제하는 것 같은데 무엇이 문제일까... 하고 고민을 해보았다.


가설 1: onCallCancel과 이벤트가 꼬였다?

특정 페이지에서는 Cancel 버튼을 누를 때 첫 페이지로 돌아가게 하는 로직이 있다. 현재 문제가 확인 버튼을 누르면 첫 페이지로 돌아가는 것이니, 연관성이 있을 수 있다고 판단했다.

이벤트 전달 과정:

Sub Renderer → Sub Preload → Main Process → Main Preload → Main Renderer

이 과정에서 onCallCancelonCallConfirm이 서로 잘못 구독했거나 하는 부분이 있나 확인해보았다.

결론: 이 부분에서는 문제를 발견하지 못했다. 애초에 이벤트가 꼬이는 문제였다면 첫 번째 플로우부터 제대로 동작하지 않았을 것이다.


가설 2: 이벤트 리스너 해제를 하지 않았다 ✅

가장 현실적으로 확률이 높은 이유였다.

확인을 위해 onCallConfirm에 첫 화면으로 돌아가게 하는 로직을 넣은 부분을 찾았고, 딱 한 곳이 있었다.


💡 문제 원인 발견

예상했던 그 문제가 맞았다!

이벤트 리스너 해제 누락

문제 코드

은행 업무 플로우 마지막에 첫 화면으로 돌아갈 수 있는 "확인" 버튼이 있었는데, 마지막에 한번만 사용되고 다른 버튼과 달리 onConfirm 하나만 필요하다 보니

useSubMonitorListeners 훅을 거치지 않고 바로 IPC 통신으로 이벤트를 등록해버린 것이다.

문제 발생 과정

문제의 핵심
1. 마지막 화면에서 useSubMonitorListeners 훅을 사용하지 않아 언마운트 시 자동 클린업이 적용되지 않음
-> onCallConfirm 에 화면 첫 부분으로 돌아가는 리스너 잔류

2. 이후 onCallConfirm을 재등록하는 과정에서 남아있던 리스너가 중복 실행

3. 결과적으로 첫 화면으로 돌아가는 동작이 발생

🛠️ 해결 방법 비교

문제를 해결하기 위한 방법을 세 가지로 정리해보았다.

방법 1: 모든 이벤트 제거

onCallConfirm: (callback: () => void) => {
	// 기존 리스너를 모두 제거하고 새로 등록
	ipcRenderer.removeAllListeners("call-confirm");
	
	const handler = (): void => callback();
	ipcRenderer.on("call-confirm", handler);
},

리스너를 등록할때마다 기존 리스너를 모두 제거하는 가장 간단한 해결책이다.

하지만 이벤트를 등록한 쪽이 아닌 새로 생긴 이벤트가 기존의 리스너를 지우는 방식이기 때문에,

이벤트 관리 책임이 불명확해져 향후 유지보수에 문제가 될 수 있어 제외했다.

방법 2: 문제가 되는 곳에서 직접 cleanup

기존 코드 (문제):

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 을 빼먹으면 같은 문제가 발생할 것이기 때문에

가장 좋은 방법은 아니라고 판단했다.

방법 3: 커스텀 훅 개선 (채택)

애초에 이 문제가 발생한 근본 원인은 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]);
};

사용 예시 비교

이 방법을 선택하면 일관성을 유지하면서, 이런 문제가 재발할 가능성을 줄여주기 때문에 최종적으로 커스텀 훅을 개선하는 방안을 선택했다!

실제로 아주 잘 작동해 기존의 문제가 사라졌다.

마치며...

해결 완료

📚 배운 점

1. 이벤트 리스너 관리의 중요성

간단한 문제였지만, 이벤트 정리를 소홀히 한 덕분에 코드를 뜯어보느라 꽤 많은 시간을 썼다.

React + Electron 환경에서 이벤트 리스너를 다룰 때 주의할 점:

  • 이벤트 등록해제는 항상 쌍을 이뤄야 한다
  • useEffect의 cleanup 함수를 활용해 컴포넌트 언마운트 시 자동으로 정리되도록 해야 한다
  • IPC 통신 이벤트는 특히 조심해야 한다 (한 번 등록되면 앱 전체에 영향)

2. 일관성 있는 패턴의 중요성

프로젝트에서 이미 useSubMonitorListeners라는 패턴이 있었는데, 급하게 개발하다 보니 확장성을 고려하지 않고 직접 IPC를 호출하는 예외 상황을 만들었다.

교훈

  • 기존 패턴이 있다면 먼저 그 패턴을 개선할 방법을 고민하자
  • 예외 케이스를 만들기보다는 기존 시스템을 유연하게 만드는 것이 장기적으로 이득
  • 시간에 쫓기더라도 일관된 아키텍처를 유지하는 것이 결국 디버깅 시간을 줄인다

긴 글 읽어주셔서 감사합니다! 비슷한 문제를 겪고 계신 분들께 도움이 되었으면 좋겠습니다. 🙂

0개의 댓글