본문으로 건너뛰기

[Open WebUI] MessageInput 컴포넌트 메모리 누수 수정: 비동기 이벤트 리스너 생명주기 관리

PR 링크: open-webui/open-webui#21968 상태: Merged | 변경: +58 / -44

들어가며

Svelte에서 onMount가 async 함수일 때, 내부의 await 이후 코드는 비동기적으로 실행됩니다. 반면 onDestroy는 동기적으로 즉시 실행됩니다. 컴포넌트가 빠르게 마운트/언마운트되면, onDestroy가 먼저 호출된 후에 onMountawait 이후 코드가 실행될 수 있습니다. 이 시점에서 등록된 이벤트 리스너는 해제 함수가 이미 호출된 후에 등록되므로, DOM 트리 전체가 메모리에 남아 페이지 크래시를 유발할 수 있습니다.

핵심 코드 분석

기존: onMount/onDestroy 분리 패턴

Before:

onMount(async () => {
    // 동기 초기화...
    window.addEventListener('keydown', handleKeyDown);
    await tick();

    const dropzoneElement = document.getElementById('chat-pane');
    dropzoneElement?.addEventListener('dragover', onDragOver);
    dropzoneElement?.addEventListener('drop', onDrop);
    dropzoneElement?.addEventListener('dragleave', onDragLeave);
});

onDestroy(() => {
    window.removeEventListener('keydown', handleKeyDown);
    const dropzoneElement = document.getElementById('chat-pane');
    if (dropzoneElement) {
        dropzoneElement.removeEventListener('dragover', onDragOver);
        dropzoneElement.removeEventListener('drop', onDrop);
        dropzoneElement.removeEventListener('dragleave', onDragLeave);
    }
});

변경: 클린업 반환 + isDestroyed 가드

After:

onMount(() => {
    // 동기 초기화...
    window.addEventListener('keydown', onKeyDown);

    let isDestroyed = false;
    let dropzoneElement: HTMLElement | null = null;

    const initialize = async () => {
        await tick();
        if (isDestroyed) return;  // 이미 파괴된 경우 리스너 등록 안 함

        dropzoneElement = document.getElementById('chat-pane');
        if (dropzoneElement) {
            dropzoneElement.addEventListener('dragover', onDragOver);
            dropzoneElement.addEventListener('drop', onDrop);
            dropzoneElement.addEventListener('dragleave', onDragLeave);
        }
    };
    initialize();

    return () => {
        isDestroyed = true;
        window.removeEventListener('keydown', onKeyDown);
        if (dropzoneElement) {
            dropzoneElement.removeEventListener('dragover', onDragOver);
            dropzoneElement.removeEventListener('drop', onDrop);
            dropzoneElement.removeEventListener('dragleave', onDragLeave);
        }
    };
});

왜 이게 좋은가

  • 레이스 컨디션 제거: isDestroyed 플래그로 컴포넌트 파괴 후 리스너 등록을 방지합니다.
  • 클로저로 참조 보장: dropzoneElement를 클로저 변수로 캡처하여, 클린업 시 정확히 같은 요소에서 리스너를 해제합니다. 기존 방식은 document.getElementById로 다시 조회하므로 DOM이 변경되면 다른 요소를 참조할 위험이 있었습니다.
  • Svelte 권장 패턴: onMount에서 클린업 함수를 반환하는 것이 onDestroy를 별도로 사용하는 것보다 안전합니다.
  • 두 컴포넌트 동시 수정: chat/MessageInput.sveltechannel/MessageInput.svelte 모두에 동일한 패턴이 적용되었습니다.

참고 자료

댓글

관련 포스트

PR Analysis 의 다른글