프론트엔드

오픈소스 프로젝트 메모리 누수 문제 해결하기

고은수 2025. 5. 1. 02:39

 

    개요

    전부터 오픈소스에 기여해보고 싶다는 생각은 하고 있었는데, 어떻게 하는지 방법도 잘 모르고 바쁘다 보니까 막상 손이 잘 안 갔다. 그러다가 모 커뮤니티에서 `이런 거 하려면 얼마나 듬? 혼자 GPT로 해결할 수 있나?`라는 제목의 안 눌러보고는 못 배기는 글을 발견했다. 글의 작성자는 디자이너인 거 같은데, Allusion이라는 이미지 관리 도구를 잘 사용하고 있다고 한다. 그런데 이미지가 대용량으로 들어있는 폴더를 열면 썸네일을 만드는 과정에서 메모리 사용량이 너무 많이 올라가고, 떨어지지 않아서 프로그램이 터져버린다고 한다. 관련 이슈도 이미 올려져 있었다.(Issue) 대충 보니까 익숙한 React + Electron 앱이라서 해볼까 하다가도, 내가 해결할 수 있는 문제인가? 싶기도 하고 그때는 면접 준비 등 여러 가지 일정이 너무 많았어서 그냥 넘어갔었던 것 같다. 그러다가 이번 주말에 갑자기 떠올라서 후두다닥 도전해 봤다.

    Allusion은 어떤 프로젝트?

    `Allusion`은 이미지 관리 도구이다. 사실 나 같은 일반인들은 별로 사용할 일이 없고, 디자이너, 일러스트레이터 등 이미지 레퍼런스를 무지 많이 쌓아두고 사용하는 사람들을 위한 앱이다. 하나의 이미지는 여러 카테고리에 동시에 속할 수 있는데 그러다 보니까 폴더 별로 정리하기가 어렵다. 아티스트들은 이미지를 수 백, 수 천장씩 사용하는데, 이미지들에 태그나 컬렉션을 달아서 카테고리별로 분류하고, 빠르게 검색하거나 시각적 정렬, 썸네일로 이미지 탐색 등의 기능들을 제공하는 앱이다.

    문제 파악

    들어가기 전에 뭐 때문에 메모리 누수가 일어나는지 생각해 봤다.

    1. 메모리 사용량을 보면 이미지 처리 후에 GC 되지 않고 참조를 메모리에 계속 유지하고 있을 것 같다.
    2. 왜 그럴까? 아마 전역 변수나 클로저 때문이 아닐까??
    3. 아니면 WeakMap을 사용해야 하는데 Map을 사용했나?
    4. 가비지컬렉터 자체의 문제? 음 이건 아니지 않을까?

    이 정도로만 생각해 보고 테스트를 해봤다. 테스트 환경은 M1 MacBook Air, 8GB RAM이다.
    용량이 크지 않은 이미지들로(3MB 이하), 1GB 정도의 폴더를 만들어서 테스트해 봤을 땐 램이 확 올라가긴 하지만 기다리니까 별문제 없이 썸네일들이 잘 나왔고 램 사용량도 다시 정상적으로 내려갔다.
    그런데 좀 극단적으로 큰 이미지(최대 40MB)들이 들어있는 4GB 정도의 폴더로 테스트하니까 위의 이슈대로 심각한 문제가 발생했다.

    위의 그림처럼 램도 엄청나게 잡아먹고, 썸네일도 안 나오고, 속도도 무지하게 느려졌다. 강제 종료 창이 몇 번 떴는데, 대기하기를 누르니까 그냥 컴퓨터가 재부팅되었다(!). 이슈는 진짜였다. 그럼 이제 썸네일을 만드는 과정의 파이프라인을 살펴보자.
    일단 썸네일을 생성하는 과정은 메인스레드가 아니라 워커스레드에서 담당하고 있다. 4개의 워커가 각각 동시에 최대 4개의 썸네일을 처리할 수 있고, 처리 대기 중인 요청은 큐에서 대기한다. 메인스레드부터 보자.
     

    const listeners = new Map<ID, Callback[]>();
    
    export const generateThumbnailUsingWorker = action(
      async (file: ClientFile, thumbnailFilePath: string, timeout = 10000) => {
        const msg: IThumbnailMessage = {
          filePath: file.absolutePath,
          thumbnailFilePath,
          thumbnailFormat,
          fileId: file.id,
        };
    
        return new Promise<void>((resolve, reject) => {
          setTimeout(() => {
            if (listeners.has(msg.fileId)) {
              reject();
              listeners.delete(msg.fileId);
            }
          }, timeout);
    
          const existingListeners = listeners.get(file.id);
          if (existingListeners) {
            existingListeners.push((success) => (success ? resolve() : reject()));
            return;
          }
    
          listeners.set(msg.fileId, [(success) => (success ? resolve() : reject())]);
          workers[lastSubmittedWorker].postMessage(msg);
          lastSubmittedWorker = (lastSubmittedWorker + 1) % workers.length;
        });
      },
    );

     

    1. `listeners` Map 객체는 파일 ID를 키로 하고, 콜백 함수 배열을 값으로 저장한다.
    2. 새로운 요청이면 파일 정보를 워커에게 메시지로 전달한다. 썸네일 생성 성공/실패에 따라 Promise를 반환한다.
    3. 같은 파일 ID에 대한 요청이 이미 있으면 새 콜백을 기존 리스너 배열에 추가한다.
    4. 지정된 시간 안에 작업이 완료되지 않으면 Promise를 reject 하고 리스너를 제거한다.
    5. 라운드 로빈으로 각 워커에 작업을 분산한다.
    const generateThumbnailData = async (filePath: string): Promise<ArrayBuffer | null> => {
      const inputBuffer = await fse.readFile(filePath);
      const inputBlob = new Blob([inputBuffer]);
      const img = await createImageBitmap(inputBlob);
    
      let width = img.width;
      let height = img.height;
      if (img.width >= img.height) {
        width = thumbnailMaxSize;
        height = (thumbnailMaxSize * img.height) / img.width;
      } else {
        height = thumbnailMaxSize;
        width = (thumbnailMaxSize * img.width) / img.height;
      }
    
      const canvas = new OffscreenCanvas(width, height);
    
      const ctx2D = canvas.getContext('2d');
      if (!ctx2D) {
        console.warn('No canvas context 2D (should never happen)');
        return null;
      }
    
      ctx2D.drawImage(img, 0, 0, width, height);
    
      const thumbBlob = await canvas.convertToBlob({ type: `image/${thumbnailFormat}`, quality: 0.75 });
      const reader = new FileReaderSync();
      const buffer = reader.readAsArrayBuffer(thumbBlob);
      return buffer;
    };

    이건 실제로 썸네일을 생성하는 워커 코드이다.

    1. 받은 파일 경로에서 파일을 읽어 Blob 객체로 변환한다.
    2. ImageBitmap을 생성하고 OffscreenCanvas에 썸네일을 그린다.
    3. 캔버스를 압축하고 썸네일을 반환한다.

    정상적인 시나리오라면, timeout(10초) 전에 워커가 썸네일 생성을 완료하고, 해당 파일 ID의 콜백을 찾아 호출되고 Promise는 resolve 된다. 그리고`listeners.delete(fileId)`로 해당 작업 콜백이 정상적으로 제거되고, 모든 메모리와 자원이 적절히 해제될 것이다.
    하지만 timeout이 발생되면 Promise가 reject 되고 해당 파일 ID에 대한 리스너는 삭제된다.
    그런데! 여기서 워커는 타임아웃을 인식하지 못하고 작업을 계속 진행하게 되고, 이후 워커가 작업을 최종적으로 완료하면 결과를 메인 스레드로 보내고 콜백을 찾지만 타임아웃으로 이미 삭제된 상태이다. 이 과정에서 참조가 제대로 정리되지 않아 메모리 누수가 발생하는 것으로 의심된다.

    해결과정

    명시적으로 메모리 해제하기

    원래 워커 스레드에서 작업이 완료되면 Javascript 엔진이 자동으로 GC를 해 주긴 한다. 하지만 이를 작업이 완료되면 즉시 명시적으로 해제하도록 했다.

    const generateThumbnailData = async (filePath: string): Promise<ArrayBuffer | null> => {
      let inputBuffer = null;
      let inputBlob = null;
      let img = null;
      let canvas = null;
      let ctx2D = null;
      let thumbBlob = null;
      let buffer = null;
    
      try {
        inputBuffer = await fse.readFile(filePath);
        inputBlob = new Blob([inputBuffer]);
        img = await createImageBitmap(inputBlob);
    
        let width = img.width;
        let height = img.height;
        if (img.width >= img.height) {
          width = thumbnailMaxSize;
          height = (thumbnailMaxSize * img.height) / img.width;
        } else {
          height = thumbnailMaxSize;
          width = (thumbnailMaxSize * img.width) / img.height;
        }
    
        canvas = new OffscreenCanvas(width, height);
    
        ctx2D = canvas.getContext('2d');
        if (!ctx2D) {
          console.warn('No canvas context 2D (should never happen)');
          return null;
        }
    
        ctx2D.drawImage(img, 0, 0, width, height);
    
        thumbBlob = await canvas.convertToBlob({ type: `image/${thumbnailFormat}`, quality: 0.75 });
        const reader = new FileReaderSync();
        buffer = reader.readAsArrayBuffer(thumbBlob);
        return buffer;
      } catch (error) {
        console.error('Error generating thumbnail data:', error);
        return null;
      } finally {
        // Explicit Memory Deallocation
        if (img) {
          try {
            img.close();
          } catch (e) {
            console.warn('Failed to close ImageBitmap:', e);
          }
        }
    
        inputBuffer = null;
        inputBlob = null;
        canvas = null;
        ctx2D = null;
        thumbBlob = null;
      }
    };

    실제로 Javascript에서 이렇게 메모리를 명시적으로 해제하는 게 의미가 있을까? 브라우저에서 Javascript 코드가 실행될 때, Javascript 엔진과 가비지 컬렉터가 관리하는 힙 메모리 영역 외에도, 브라우저 엔진이 직접 관리하는 네이티브 메모리 영역이 존재한다. 이 영역은 가비지 컬렉터가 직접 접근하지 못하고, 이미지 데이터, 캔버스 버퍼, WebGL 텍스처 등을 저장하는 영역이다. 내 경우엔 `Offscreen Canvas`와 압축이 해제된 이미지라 크기가 큰 `ImageBitmap`은 썸네일 작업에서 대부분의 메모리를 사용하는데, 작업이 완료되면 Javascript 참조는 사라지지만, 네이티브 메모리를 사용하는 자원들은 즉시 해제되지 않고 브라우저의 내부 로직에 따라 나중에 해제되기 때문에 이를 그전에 명시적으로 해제하도록 했다.

    큐 사이즈 제한하기

    기존에는 큐 사이즈에 제한이 없었다. 그러니까 사용자가 빠르게 스크롤하게 되면 큐에는 다음에 작업할 썸네일 생성 요청들이 계속 쌓이게 되는데, 그렇게 되면 정작 화면에 표시되어야 할 이미지들보다 지나가서 표시되지 않아도 될 이미지들이 먼저 처리될 뿐만 아니라 요청들이 쌓이는 것 자체만으로도 리소스를 불필요하게 많이 사용하게된다. 그래서 큐 최대 크기를 설정해주고 큐가 가득 차있을때 새로운 요청이 들어오게 되면 가장 오래된 요청을 큐에서 제거하도록 했다.

    const MAX_QUEUE_LENGTH = 8;
    
    const addToQueue = (data: IThumbnailMessage) => {
      if (queue.length >= MAX_QUEUE_LENGTH) {
        queue.shift();
        console.log(`Queue full (${MAX_QUEUE_LENGTH}). Removed oldest request.`);
      }
    
      queue.push(data);
    };

    큐의 최대 크기는 8로 설정했는데, 사실 많이 고민하거나 테스트해서 나온 수치는 아니다. 내 맥북 기준으로 뷰포트에 4x5 = 20개의 썸네일이 나오고 대충 좀 큰 모니터면 2배정도 들어가겠지~하면 40개니까 여유있게 한줄정도 더 넣어서 45개정도 되지 않을까 했다. 4개의 스레드가 4개씩 처리하므로 한번에 총 16개씩 처리할 수 있고, 45-16 = 29 < 8x4 = 32... 정도면 되겠지 해서 8개로 설정했다.

    4x5=20

    timeout 시 요청 삭제

    // ThumbnailGeneration.tsx
    setTimeout(() => {
      if (listeners.has(msg.fileId)) {
        workers[lastSubmittedWorker].postMessage({
          type: 'cancel',
          fileId: msg.fileId,
          });
        reject();
        listeners.delete(msg.fileId);
      }
    }, timeout);
        
    // ThumbnailGenerator.worker.ts
    ctx.addEventListener('message', async (event) => {
      if (event.data.type === 'cancel') {
        const index = queue.findIndex((item) => item.fileId === event.data.fileId);
        if (index !== -1) {
          queue.splice(index, 1);
          console.log(`Cancelled request for fileId: ${event.data.fileId}`);
        }
        return;
      }
    
      await processMessage(event.data);
    });

    타임아웃이 발생하면 큐에서 대기중인 요청을 삭제하도록 해서 불필요한 작업을 방지했다. 사실 대기중인 요청을 삭제하는 것 보다 현재 처리 중인 작업이 타임아웃 되었을 때 취소하는게 의미가 큰데, createImageBitmap(), canvas.convertToBlob()과 같은 네이티브 API 호출은 일단 시작되면 중단할 방법이 없기 때문에 약간의 개선정도 되는 것 같다.

    마무리

    이렇게 최적화하니 확실히 메모리 누수가 발생하고 앱이 터지는 문제는 해결됐다! 썸네일 생성은 실패하는 경우가 있긴 했는데, 이 때도 스크롤을 왔다 갔다 하니 제대로 썸네일이 생성되었다. 이대로 정리해서 PR을 날렸는데. . . .

    https://github.com/allusion-app/Allusion/issues/649

    이걸 왜 이제 봤을까,,ㅜ 메인테이너들이 번아웃이 와서 2년 전에 이미 놔버린 프로젝트라고 한다 🥲

    다행히 다른 분이 관리하는 포크가 있는듯해서 거기에다가도 PR을 날렸다.

    흑흑 머지 됐으면 좋겠다........ 

     

    + 성공!

    ^-^ b