본문으로 건너뛰기

[Open WebUI] Chat.svelte 비동기 onMount 메모리 누수 수정

PR 링크: open-webui/open-webui#21962 상태: Merged | 변경: +69 / -66

들어가며

Open WebUI의 Chat.svelte에서 심각한 메모리 누수가 발생하고 있었습니다. 원인은 비동기 onMount 함수와 동기 onDestroy 함수 간의 타이밍 불일치입니다. 대화를 전환할 때 onDestroy가 먼저 호출되고, 이후 비동기 onMount의 이벤트 리스너 등록이 실행되면 더 이상 해제할 방법이 없어져 전체 DOM 트리가 메모리에 잔류하는 문제였습니다.

핵심 코드 분석

Before: 비동기 onMount + 별도 onDestroy

let pageSubscribe = null;
let showControlsSubscribe = null;

onMount(async () => {
    window.addEventListener('message', onMessageHandler);
    $socket?.on('events', chatEventHandler);
    audioQueue.set(new AudioQueue(document.getElementById('audioElement')));

    pageSubscribe = page.subscribe(async (p) => { /* ... */ });
    showControlsSubscribe = showControls.subscribe(async (value) => { /* ... */ });
    // ... 비동기 초기화 작업 ...
});

onDestroy(() => {
    try {
        pageSubscribe();
        showControlsSubscribe();
        window.removeEventListener('message', onMessageHandler);
        $socket?.off('events', chatEventHandler);
        $audioQueue?.destroy();
    } catch (e) { console.error(e); }
});

문제: onMountasync이므로 내부 코드가 비동기로 실행됩니다. 대화를 빠르게 전환하면 onDestroyonMount보다 먼저 실행될 수 있습니다. 이 경우 pageSubscribe는 아직 null이므로 구독 해제가 되지 않고, 이후 onMount에서 등록된 리스너도 영원히 해제되지 않습니다.

After: 동기 onMount + 반환 함수로 정리

onMount(() => {
    window.addEventListener('message', onMessageHandler);
    $socket?.on('events', chatEventHandler);

    const audioQueueInstance = new AudioQueue(document.getElementById('audioElement'));
    audioQueue.set(audioQueueInstance);

    const pageSubscribe = page.subscribe(async (p) => { /* ... */ });
    const showControlsSubscribe = showControls.subscribe(async (value) => { /* ... */ });
    const selectedFolderSubscribe = selectedFolder.subscribe(async (folder) => { /* ... */ });

    const init = async () => {
        // 비동기 초기화 작업을 별도 함수로 분리
    };
    init();

    return () => {
        try {
            pageSubscribe();
            showControlsSubscribe();
            selectedFolderSubscribe();
            window.removeEventListener('message', onMessageHandler);
            $socket?.off('events', chatEventHandler);
            audioQueueInstance?.destroy();
            audioQueue.set(null);
        } catch (e) { console.error(e); }
    };
});

핵심 변경:

  1. onMount를 동기 함수로 변경하고, 비동기 로직은 init() 함수로 분리
  2. 모든 구독/리스너를 로컬 변수로 캡처하여 반환 함수에서 해제
  3. audioQueueInstance를 로컬 변수로 캡처하여 정확한 인스턴스를 파괴

왜 이게 좋은가

  1. 메모리 누수 완전 해결: 동기 onMount이므로 이벤트 리스너와 구독이 즉시 등록되고, 반환 함수가 정리를 보장합니다. 비동기/동기 타이밍 불일치가 사라집니다.
  2. AudioQueue 누수 방지: 기존에는 $audioQueue(스토어 값)를 통해 파괴했는데, 스토어 값이 이미 변경된 상태에서 파괴하면 이전 인스턴스가 누수됩니다. 로컬 변수로 캡처하여 정확한 인스턴스를 파괴합니다.
  3. Svelte 라이프사이클 패턴 준수: onMount의 반환 함수는 Svelte가 자동으로 컴포넌트 파괴 시 호출하므로, 등록/해제의 대칭이 언어 차원에서 보장됩니다.

대화가 길어지면 페이지 크래시를 유발하던 심각한 버그를 해결한 중요한 PR입니다.

참고 자료

댓글

관련 포스트

PR Analysis 의 다른글