<우리가 누수에 대처하는 방법(memory leak)>

강민수·2025년 6월 21일
1

아하 모먼트

목록 보기
11/11
post-thumbnail

1. Intro

여러분은 집에 누수가 난 적이 있는가?

물론 나 본적이 있을 수 있지만, 웬만하면 현대에는 잘 겪기 어려울 것이다.
웬 갑자기 누수로 이번 글을 시작하나?

뜬금없다고 느낄수도 있을 것이다.

필자가, 제목에 밝혔듯이 이번 글은 메모리 leak. 흔히 말하는 메모리 누수에 대한 얘기다.

사실, 필자도 커리어 전반에 걸쳐서 이렇게 누수라는 경험을 해보기는 처음이었다.

막상 겪어보니, 찾기도 쉽지 않았고... 당황스러웠다.

시작은 QA를 하면서였다.

"민수님. 제 컴퓨터가 너무 버벅이는데... 이거 브라우저가 터지네요 ㅜㅜ"

"앗! 넵! 한 번 봐 볼게요. 잠시만요!..."

2. 누수 탐사의 시작

이후 필자는, 브라우저 탭을 먼저, 호버해 봤다.

흠...
메모리 사용량 많다고 뜨면서... 그래도 어느 정도겠지 싶었다.

허나...
내 예상을 훨씬 뛰어넘는 메모리 사용량이었다.

뭐가 문제일까?
그래서 하나씩 탐사를 시작했다.

1) 프로덕트 분석

일단, 가장 먼저 해야될 것은 무엇일까?

물론 메모리 이슈가 나는 지점을 찾는 것이다.

그보다 전에, 선행되어야 할 것은 과연 우리가 만드는 프로덕트가 어떤 특성을 가진 프로덕트인지 파악하는 것이다.

현재, 필자가 만드는 화면은 자세히는 알려드릴 수 없지만, 16k화질의 이미지를 이미지 캔버스에 렌더링 시키는 화면이다.

이때, 캔버스 기반인 Konvajs(https://konvajs.org/)를 쓰지만, 결국 본질은 캔버스에 16k x 16k 초고화질 사진을 그리는 데 가용되는 메모리 용량이었다.

그래서 얼마나 가용되는 지 궁금해서, gpt와 티키타카를 좀 해봤다.

결론적으로 못해도, 1기가 이상은 무조건 들어간다는 것.

흠... 사실 이것도 정말 많이 잡히고 있는 것이라고 생각이 들었다.

하지만, 우리가 직면한 문제는 3기가 이상의 과도하게 잡히면서 브라우저 자체가 버벅이는 현상이다.

이건 왜 그럴까?

이제 하나씩 퍼포먼스와 메모리 탭 기반으로 분석한 내용을 설명하겠다.

2) 퍼포먼스 탭 분석

퍼포먼스 탭을 잘 살펴보면,
결국 하나 씩 유추를 해볼 수 있다.

위의 사진을 다시 요약해보면,

결론적으로, JS쪽과 Canvas쪽을 의심할 수 있었다.
하지만, 정황 증거만으로는 명확하지 않다.

어떤 코드인지와 줄 수가 어떤 것인지도 알 수는 없을까?
그래서 여기서 한 단계 더 짚어보자.

3) 메모리 탭 분석

하지만, 메모리 탭에서 마저 제대로 분석은 어려웠다. ㅜㅜ

이유는 지금 필자가 구현하는 화면에서는 단순한 js를 만지는 것이 아닌, canvas기반의 gpu가속 메모리를 사용중이었기 때문이다.

그래서 실질적으로 js로 하고는 있지만, 이게 결국은 gpu를 건드리는 부분이라 제대로 확인이 안 되었다...

4) 병목 지점 발견

그러다, 필자가 구현하는 뷰 자체가 image를 캔버스에 올리고 이미지 자체의 픽셀에 필터를 적용하는 로직이 적용된 것을 의심하기 시작했다.

그리고, 다시 필터를 적용하는 버튼을 누르자, 버벅임을 감지했고, 이에 따라 이쪽 부분이 문제임을 확실하게 판단했다.(사실, 지금 와서 살펴보면, konvajs에서도 여기 이렇게 명시적으로 필터 시에 메모리 누수를 조심하라고 적어 놓았다.(konvajs에서 명시한 cache 정책 링크(https://konvajs.org/docs/performance/Shape_Caching.html))

이후, 이 부분을 브라우저 퍼포먼스에서 찍어봤다.

역시나 applyFilter라는 함수 자체가 문제가 있었다.

물론, 그보다 더 큰 본질은 아래 부분이었다.

이미지 노드 캐싱

실제로 내가 쓰는 Konva.js에서는 이미지에 필터를 먹이려면 캐싱을 해야 한다.
공식 문서 흐름 그대로 따라, 나도 처음엔 이렇게 짜 놨다.

const imageRef = useRef(null);

useEffect(() => {
  if (image && imageRef.current) {
    imageRef.current.cache();      // ← 문제의 캐싱
  }
}, [image]);

그런데 이럴 경우 메모리가 두 배로 터진다.

구분어떤 메모리?언제 생기나?
① 원본 이미지 버퍼고해상도 16K 이미지를 캔버스에 뿌릴 때drawImage()
② 필터용 캐시 버퍼imageRef.current.cache() 호출 시Off-screen Canvas + GPU 텍스처

이미지 한 장당 ①+② 두 덩어리가 생기니, 16K처럼 덩치 큰 이미지는 곧장 GB 단위로 불어난다.

결국 "RGB 필터용 캐시를 무턱대고 만들지 말자"는 게 핵심이었다.

3. 해결책

나는 두 군데에 조건을 달아 줬다.

1) 이미지 노드 쪽 – 처음 한 번만 캐시

// 캐시가 없을 때만 생성
if (!imageNode.isCached()) {
  imageNode.cache({ pixelRatio: 1 });   // 저해상도 1×
}
// 필터만 교체
applyFilter(imageNode, rgbChannel);

2) applyFilter 내부 – 재호출될 때 텍스처 정리

function safeCache(node: Konva.Node) {
  if (node.isCached()) node.clearCache();          // ① 기존 텍스처 반환
  node.cache({ pixelRatio: 1 });                   // ② 새 캐시 1개만
}

function applyFilter(node: Konva.Node, ch: RGBChannelType) {
  node.filters(
    ch === RGBChannelType.all   ? [] :
    ch === RGBChannelType.red   ? [redFilter] :
    ch === RGBChannelType.green ? [greenFilter] :
                                  [blueFilter],
  );

  safeCache(node);
  node.getLayer()?.batchDraw();                    // ③ 레이어 한 번만 다시 그림
}

=> 허나 이렇게 해도, 문제는 그럼에도 불구하고 1초 정도 가까이 블로킹이 걸렸다.

그래서 필자는 이 filter 함수도 아래 처럼 바꿨다.

3) 제네레이터 함수로 filter함수 전환 처리

AS-IS

function applyFilter(
  node: Group | Shape<ShapeConfig>,
  rgbChannel: RGBChannelType,
) {
  switch (rgbChannel) {
    case RGBChannelType.all:
      node.filters([]);
      break;
    case RGBChannelType.red:
      node.filters([redFilter]);
      break;
    case RGBChannelType.green:
      node.filters([greenFilter]);
      break;
    case RGBChannelType.blue:
      node.filters([blueFilter]);
      break;
  }

  node.cache();
}

function redFilter({ data }: ImageData) {
  const length = data.length;

  for (let i = 0; i < length; i += 4) {
    const red = data[i];
    data[i + 1] = red;
    data[i + 2] = red;
  }
}

function greenFilter({ data }: ImageData) {
  const length = data.length;

  for (let i = 0; i < length; i += 4) {
    const green = data[i + 1];
    data[i] = green;
    data[i + 2] = green;
  }
}

function blueFilter({ data }: ImageData) {
  const length = data.length;

  for (let i = 0; i < length; i += 4) {
    const blue = data[i + 2];
    data[i] = blue;
    data[i + 1] = blue;
  }
}

TO-BE

// ❶ 제너레이터: 픽셀을 chunk 단위(예: 10 000)로 내보냄
function* pixelChunks(data: Uint8ClampedArray, chunk = 10_000) {
  for (let i = 0; i < data.length; i += chunk) {
    yield i
  }
}

// ❂ 비동기 필터 실행
function runFilterAsync(
  node: Konva.Image,
  ch: RGBChannelType,
  chunkSize = 10_000,
) {
  const ctx = node.getContext()
  const width = node.width()
  const height = node.height()
  const imgData = ctx.getImageData(0, 0, width, height)
  const { data } = imgData

  const g = pixelChunks(data, chunkSize)

  function step() {
    const start = performance.now()

    // 8 ms 예산 안에서 여러 chunk 처리
    for (
      let next = g.next();
      !next.done && performance.now() - start < 8;
      next = g.next()
    ) {
      const offset = next.value
      if (typeof offset === 'number') {
        for (
          let i = offset;
          i < offset + chunkSize && i < data.length;
          i += 4
        ) {
          for (
            let i = offset;
            i < offset + chunkSize && i < data.length;
            i += 4
          ) {
            switch (ch) {
              case RGBChannelType.red: {
                const r = data[i]
                data[i + 1] = r
                data[i + 2] = r
                break
              }

              case RGBChannelType.green: {
                const g = data[i + 1]
                data[i] = g
                data[i + 2] = g
                break
              }

              case RGBChannelType.blue: {
                const b = data[i + 2]
                data[i] = b
                data[i + 1] = b
                break
              }
            }
          }
        }

        if (!g.next().done) {
          requestAnimationFrame(step) // 다음 프레임으로 yield
        } else {
          ctx.putImageData(imgData, 0, 0) // 필터 완료
          node.getLayer()?.batchDraw()
        }
      }

      requestAnimationFrame(step)
    }
  }
}

4. 최적화 포인트

메모리 관리

  • isCached() 체크로 불필요한 캐시 생성 방지
  • clearCache() 로 기존 GPU 텍스처 즉시 반환
  • pixelRatio: 1 설정으로 메모리 사용량 최소화

성능 개선

  • batchDraw() 한 번만 호출하여 렌더링 최적화
  • 필터 변경 시에만 캐시 재생성
  • 불필요한 프레임 렌더링 방지

비동기 처리 최적화

  • 제네레이터 함수로 대용량 이미지 데이터를 chunk 단위로 분할 처리
  • requestAnimationFrame을 활용한 논블로킹 처리로 UI 응답성 보장
  • 8ms 예산 시간을 설정하여 60fps 유지
  • performance.now() 기반 시간 관리로 프레임 드롭 방지

pixelRatio 최적화의 중요성

pixelRatioKonva가 캐시용 오프스크린 Canvas를 만들 때 "원본보다 몇 배 해상도로 그릴지"를 결정하는 값이다.

설정값오프스크린 Canvas 크기메모리 사용량(RAM·GPU)필터·렌더링 연산량
pixelRatio: 1원본 W × HW × H × 4 byteW × H 회 반복
pixelRatio: 2(2W) × (2H) = 4배 픽셀4배 메모리4배 연산
pixelRatio: 4(4W) × (4H) = 16배 픽셀16배 메모리16배 연산

메모리 영향

  • 오프스크린 Canvas: 가로 × 세로 × 4 byte(RGBA) 버퍼 할당
  • GPU 텍스처: 동일 크기로 VRAM에 업로드
  • pixelRatio가 2배 → 메모리 사용량 4배 증가

CPU/GPU 연산 영향

  • drawImage, getImageData, 필터 루프가 픽셀 수에 정비례
  • 텍스처 업로드·블렌딩 비용도 픽셀 수만큼 증가
  • pixelRatio: 1 = 원본 해상도 → 최소 연산량

5. 결과

개선 효과

  • 메모리 사용량: 3GB+ → 1GB 이하로 감소
  • 브라우저 안정성: 크래시 현상 해결
  • 사용자 경험: 버벅임 현상 완전 해결
  • UI 응답성: 제네레이터 비동기 처리로 블로킹 현상 제거

보이는 바와 같이, 이제는 프레임 드롭도 없고, 메모리 누수에 따른 버벅임 문제도 사라졌다.

교훈

  • 고해상도 이미지 처리 시 메모리 관리의 중요성
  • 캐싱 전략의 신중한 설계 필요
  • pixelRatio 설정이 메모리와 성능에 미치는 기하급수적 영향
  • 비동기 처리를 통한 UI 블로킹 방지의 필요성
  • 성능 도구를 활용한 체계적인 분석의 가치

권장사항

  • 16K 고해상도 이미지의 경우 반드시 pixelRatio: 1 사용
  • 필터 품질보다 메모리 효율성을 우선시
  • 캐시 생성 전 isCached() 체크 필수
  • 대용량 데이터 처리 시 제네레이터와 requestAnimationFrame 활용

6.결론

결론적으로, 16k 고해상도 이미지 처리를 통해,
단순히 메모리 누수만이 아닌,
gpu 메모리, 프레임 드롭에 대한 최적화 등,
어쩌면 이번 경험을 통해, 나만의 초강력 누수방지 테이프를 만든 것이 아닐까? 생각해 본다.

또, 생각의 확장을 통해, 어떤 식으로 더 좋게 바꿀 수 있을 지 계속 시도하고 고민해 본 흔적을 남겼기에...

비록 힘들었지만 재밌었다.

긴 글 읽어주셔서 감사하다.

profile
개발도 예능처럼 재미지게~

0개의 댓글