여러분은 집에 누수가 난 적이 있는가?
물론 나 본적이 있을 수 있지만, 웬만하면 현대에는 잘 겪기 어려울 것이다.
웬 갑자기 누수로 이번 글을 시작하나?
뜬금없다고 느낄수도 있을 것이다.
필자가, 제목에 밝혔듯이 이번 글은 메모리 leak. 흔히 말하는 메모리 누수에 대한 얘기다.
사실, 필자도 커리어 전반에 걸쳐서 이렇게 누수라는 경험을 해보기는 처음이었다.
막상 겪어보니, 찾기도 쉽지 않았고... 당황스러웠다.
시작은 QA를 하면서였다.
"민수님. 제 컴퓨터가 너무 버벅이는데... 이거 브라우저가 터지네요 ㅜㅜ"
"앗! 넵! 한 번 봐 볼게요. 잠시만요!..."
이후 필자는, 브라우저 탭을 먼저, 호버해 봤다.

흠...
메모리 사용량 많다고 뜨면서... 그래도 어느 정도겠지 싶었다.
허나...
내 예상을 훨씬 뛰어넘는 메모리 사용량이었다.
뭐가 문제일까?
그래서 하나씩 탐사를 시작했다.
일단, 가장 먼저 해야될 것은 무엇일까?
물론 메모리 이슈가 나는 지점을 찾는 것이다.
그보다 전에, 선행되어야 할 것은 과연 우리가 만드는 프로덕트가 어떤 특성을 가진 프로덕트인지 파악하는 것이다.
현재, 필자가 만드는 화면은 자세히는 알려드릴 수 없지만, 16k화질의 이미지를 이미지 캔버스에 렌더링 시키는 화면이다.
이때, 캔버스 기반인 Konvajs(https://konvajs.org/)를 쓰지만, 결국 본질은 캔버스에 16k x 16k 초고화질 사진을 그리는 데 가용되는 메모리 용량이었다.
그래서 얼마나 가용되는 지 궁금해서, gpt와 티키타카를 좀 해봤다.

결론적으로 못해도, 1기가 이상은 무조건 들어간다는 것.
흠... 사실 이것도 정말 많이 잡히고 있는 것이라고 생각이 들었다.
하지만, 우리가 직면한 문제는 3기가 이상의 과도하게 잡히면서 브라우저 자체가 버벅이는 현상이다.
이건 왜 그럴까?
이제 하나씩 퍼포먼스와 메모리 탭 기반으로 분석한 내용을 설명하겠다.
퍼포먼스 탭을 잘 살펴보면,
결국 하나 씩 유추를 해볼 수 있다.

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

결론적으로, JS쪽과 Canvas쪽을 의심할 수 있었다.
하지만, 정황 증거만으로는 명확하지 않다.
어떤 코드인지와 줄 수가 어떤 것인지도 알 수는 없을까?
그래서 여기서 한 단계 더 짚어보자.

하지만, 메모리 탭에서 마저 제대로 분석은 어려웠다. ㅜㅜ
이유는 지금 필자가 구현하는 화면에서는 단순한 js를 만지는 것이 아닌, canvas기반의 gpu가속 메모리를 사용중이었기 때문이다.
그래서 실질적으로 js로 하고는 있지만, 이게 결국은 gpu를 건드리는 부분이라 제대로 확인이 안 되었다...

그러다, 필자가 구현하는 뷰 자체가 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 필터용 캐시를 무턱대고 만들지 말자"는 게 핵심이었다.
나는 두 군데에 조건을 달아 줬다.
// 캐시가 없을 때만 생성
if (!imageNode.isCached()) {
imageNode.cache({ pixelRatio: 1 }); // 저해상도 1×
}
// 필터만 교체
applyFilter(imageNode, rgbChannel);
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 함수도 아래 처럼 바꿨다.
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;
}
}
// ❶ 제너레이터: 픽셀을 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)
}
}
}
pixelRatio는 Konva가 캐시용 오프스크린 Canvas를 만들 때 "원본보다 몇 배 해상도로 그릴지"를 결정하는 값이다.
| 설정값 | 오프스크린 Canvas 크기 | 메모리 사용량(RAM·GPU) | 필터·렌더링 연산량 |
|---|---|---|---|
| pixelRatio: 1 | 원본 W × H | W × H × 4 byte | W × H 회 반복 |
| pixelRatio: 2 | (2W) × (2H) = 4배 픽셀 | 4배 메모리 | 4배 연산 |
| pixelRatio: 4 | (4W) × (4H) = 16배 픽셀 | 16배 메모리 | 16배 연산 |

보이는 바와 같이, 이제는 프레임 드롭도 없고, 메모리 누수에 따른 버벅임 문제도 사라졌다.
isCached() 체크 필수
결론적으로, 16k 고해상도 이미지 처리를 통해,
단순히 메모리 누수만이 아닌,
gpu 메모리, 프레임 드롭에 대한 최적화 등,
어쩌면 이번 경험을 통해, 나만의 초강력 누수방지 테이프를 만든 것이 아닐까? 생각해 본다.
또, 생각의 확장을 통해, 어떤 식으로 더 좋게 바꿀 수 있을 지 계속 시도하고 고민해 본 흔적을 남겼기에...
비록 힘들었지만 재밌었다.
긴 글 읽어주셔서 감사하다.