포스트

칸이 5,000개인 화면에서 문자열을 5,000번 만들고 있었다

칸이 5,000개인 화면에서 문자열을 5,000번 만들고 있었다

칸이 잔뜩 있는 화면을 만들어 본 적 있다면 익숙할 것이다. 스프레드시트, 캘린더, 픽셀 에디터, 공연장 좌석 배치도, 지하철 노선의 시간표. 이런 화면은 칸 하나하나가 DOM 요소 하나가 되기 쉽고, 그러면 화면에 요소가 순식간에 수천 개가 된다.

내 경우는 점자 편집기였다. 도메인은 낯설어도 문제의 모양은 흔하다. 32칸 × 26줄짜리 격자에 글자를 한 칸씩 찍는 화면이고, 칸 하나가 <div> 하나다. 스프레드시트라고 생각하고 읽어도 아무 차이가 없다.

여기에 드래그로 여러 칸 고르기를 넣고 나서 뒤늦게 셈을 해봤다. 마우스를 끄는 동안 초당 수십 번, 화면 한 판 920칸이 전부 다시 계산된다. 칸마다 CSS 클래스 문자열을 새로 만들면서.

그래서 flyweight를 넣었다. CPU는 17배 빨라졌고 만들어지는 문자열은 920개에서 0개가 됐다. 다만 메모리가 줄었다는 말은 반만 맞았다. 이 글은 그 셈과, 내가 처음에 잘못 잡았던 방향까지의 기록이다. 코드는 Semojum/FE 에 있다.

문제는 칸이 많고 자주 다시 그리는 것

용어를 셋만 맞추고 가자. 칸은 글자 하나가 들어가는 네모, 줄은 칸 32개, 판은 줄 26개다. 스크롤하면 판이 계속 이어진다.

한 판을 그리면 이만큼이 생긴다.

단위요소 수
한 줄줄 <div> 1 + 줄번호 1 + 칸 32 = 34
한 판눈금줄 34 + 26줄 × 34 = 약 920
화면에 붙는 판보이는 판 + 앞뒤 2판씩 ≈ 5~6판
합계약 5,000~5,500

총량 자체는 이미 손을 써 뒀다. 화면 근처의 판만 DOM에 붙이고(가상 스크롤), 나머지는 content-visibility: auto 로 브라우저가 배치·그리기를 건너뛴다. 문서가 300판이어도 DOM은 5,000개에서 안 늘어난다.

그래서 문제는 총량이 아니라 다시 그리는 횟수와 단위였다. 칸 하나에서 상태가 바뀌면, 예를 들어 커서가 옮겨가면 그 판의 920개가 통째로 다시 계산된다. 그리고 그때마다 칸마다 이 함수가 돈다.

1
2
3
4
5
6
7
8
9
10
11
// 칸 하나의 CSS 클래스를 만든다. 렌더마다, 칸마다 배열을 만들고 join한다.
const cellCls = (row, selected, isCaret, highlighted, dimmed, find, inSelection) =>
  [
    'flex h-[19px] w-[19px] shrink-0 items-center justify-center …', // 크기·정렬 (늘 같음)
    'border-[#e4ebf5]',                                              // 테두리 (늘 같음)
    isCaret ? 'bg-[#5b8ce6] text-white'          // 커서가 놓인 칸
      : inSelection ? 'bg-[#5b8ce6]/35'          // 드래그로 고른 칸
      : find === 'active' ? 'bg-[#f9c74f] …'     // 검색 결과 중 지금 보는 것
      : /* … 아홉 갈래 … */ 'bg-white',           // 아무것도 아닌 칸
    dimmed && !isCaret ? 'text-[#c8ccd4]' : '',  // 흐리게 표시할 글자인지
  ].join(' ');

배열을 만들고 join 하니 호출 한 번마다 배열 하나와 문자열 하나가 새로 생긴다. 920칸이면 판 하나 그릴 때마다 그게 920쌍이다.

공유할 수 있는 것과 없는 것

flyweight 는 여러 객체가 똑같이 갖고 있는 부분을 밖으로 빼서 하나만 두고 나눠 쓰는 패턴이다. 객체마다 다른 부분만 남기고 같은 부분은 공유한다.

여기에 대보면 이렇게 갈린다.

  • DOM 노드는 공유할 수 없다. 칸마다 화면에 자리가 있어야 하니 트리에 하나씩 존재해야 한다. 여기는 손댈 수 없다.
  • 클래스 문자열은 공유할 수 있다. 위 코드의 분기를 세어 보면 아홉 가지다. 커서, 고른 칸, 검색 활성, 검색 결과, 커서가 놓인 줄, 수정 불가 칸, 경고, 강조, 기본. 거기에 흐리게 표시 여부가 붙으니 서로 다른 결과는 18개뿐이다.

칸이 920개인데 결과의 종류가 18개라면, 920번 만들 이유가 없다. 18개를 미리 만들어 두고 꺼내 쓰면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 아홉 가지 모양에 번호를 준다. 고르는 순서가 곧 우선순위다.
const TONE = { caret: 0, sel: 1, findActive: 2, /* … */ plain: 8 } as const;

// [모양][흐리게] 두 겹 배열. 열여덟 개를 앱 시작할 때 한 번만 이어 붙인다.
const CELL_CLASS: string[][] = TONE_CLASS.map((tone) => [
  `${CELL_BASE} ${tone}`,
  `${CELL_BASE} ${tone} ${CELL_DIM}`,
]);

// 이제 이 함수는 아무것도 만들지 않는다. 어느 칸인지 골라서 꺼내 줄 뿐이다.
export const cellClassFor = (row, selected, isCaret, highlighted, dimmed, find, inSelection) => {
  const tone = isCaret ? TONE.caret : inSelection ? TONE.sel : /* … */ TONE.plain;
  return CELL_CLASS[tone][dimmed && !isCaret ? 1 : 0];
};

덤이 하나 붙는다. 같은 값이 아니라 같은 참조가 나가므로, React가 className 이 바뀌었는지 볼 때 글자를 하나하나 비교하지 않고 참조만 비교하고 끝낸다.

재 봤다

추정으로 넘어가기 싫어서 세 가지를 놓고 실제로 쟀다. 한 판(920칸)을 2,000프레임 그리는 셈, 즉 184만 번 호출이다.

1
2
3
4
5
6
7
8
const CELLS = 920, FRAMES = 2000;
const run = (fn, label) => {
  let sink = 0;                       // 결과를 안 쓰면 엔진이 통째로 지워버린다
  const t0 = performance.now();
  for (let f = 0; f < FRAMES; f++)
    for (let i = 0; i < CELLS; i++) sink += fn(i % 9, (i & 7) === 0).length;
  console.log(label, (performance.now() - t0).toFixed(1), 'ms');
};
방식시간
1) 배열 만들고 join84~87 ms
2) flyweight에 문자열 키로 조회32~33 ms
3) flyweight에 배열 색인으로 조회4.9~5.1 ms

3번은 1번의 약 17배다. 세 번 돌려 편차는 ±3% 안이었다.

조회 자체가 할당이면 flyweight가 아니다

처음에 나는 2번으로 짰다. 상태를 문자열 키로 만들어 객체에서 꺼내는, 흔한 방식이다.

1
return CELL_CLASS[`${tone}|${dimmed && !isCaret ? 1 : 0}`];   // ← 여기

미리 만들어 뒀으니 됐다고 생각했는데, 칸마다 `${tone}|${dim}` 이라는 문자열을 새로 만들고 있었다. 920칸이면 920개다. 만들지 않으려고 넣은 패턴인데 만드는 대상만 바뀐 셈이다.

두 겹 배열 색인으로 바꾸니 조회하는 동안 아무것도 만들지 않게 됐고, 같은 flyweight인데도 6.3배가 더 빨라졌다.

flyweight를 제대로 넣었는지는 미리 만들어 뒀는가가 아니라 꺼내 쓰는 동안 아무것도 만들지 않는가로 확인해야 한다. 키를 조합하는 순간 원점이다.

그런데 줄어든 건 메모리가 아니었다

제목만 보면 메모리 이야기지만, 재보니 결이 달랐다. 만들어진 문자열을 전부 붙들고 있게 해서 힙을 쟀다.

1
2
3
4
5
const N = 2_000_000;
const hold = new Array(N);              // 배열 자체는 측정 시작 전에 미리 잡아 둔다
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < N; i++) hold[i] = fn(i % 9, (i & 7) === 0);
const after = process.memoryUsage().heapUsed;
방식200만 개를 붙들었을 때 힙 증가서로 다른 값
배열과 join+86 MB18개
flyweight+0.0 MB18개

값의 종류는 양쪽 다 18개인데 한쪽만 86MB를 쓴다. 같은 글자를 담은 서로 다른 객체가 200만 개 있었기 때문이다. flyweight 쪽은 배열 안에 18개짜리 문자열을 가리키는 참조만 200만 개 들어 있어서 힙이 늘지 않는다.

다만 실제 앱에서 86MB가 상주하는 건 아니다. 렌더가 끝나면 그 920개는 곧바로 쓰레기가 된다. 정확히 말하면 flyweight가 줄인 것은 상주 메모리가 아니라 GC가 치워야 할 쓰레기의 양이고, 체감으로 돌아오는 것은 그 쓰레기를 만들고 치우는 데 쓰던 CPU 시간이다. 드래그처럼 초당 수십 번 도는 경로에서는 이게 프레임을 먹는다.

flyweight만으로는 부족했다

여기서 멈추면 절반이다. 문자열을 안 만들어도 UI 라이브러리는 여전히 920개를 훑는다. 만드는 비용은 없앴지만 비교하는 비용은 그대로다.

그래서 React.memo 를 한 겹 더 내려 줄 단위로 감쌌다. 넘어온 값이 지난번과 같으면 이 조각은 다시 그리지 않는다는 뜻이다. 커서와 선택, 마우스오버는 한두 줄에만 영향을 주므로 다시 훑는 양이 920에서 34가 된다.

잘려 보이면 전체 화면으로 열기.

메모가 실제로 서려면 넘기는 값이 렌더마다 같아야 한다. 값이 매번 새로 만들어지면 지난번과 같은지 비교하는 일이 늘 실패해서, 메모를 붙여도 전부 다시 그린다. 여기서 세 군데를 손봤다. 셋 다 격자와 무관한 일반적인 함정이다.

  • 객체 대신 원시값을 넘긴다. 고른 구간을 {from, to} 객체로 넘기면 렌더마다 새 참조라 메모가 늘 깨진다. 숫자 두 개로 쪼개 넘겼다.
  • ?? [] 같은 기본값을 조심한다. 값이 없는 줄에 mask[i] ?? [] 를 쓰고 있었다. 이건 렌더마다 새 빈 배열이라, 이 한 줄로 메모 전체가 무력해진다. 공유 상수 하나를 만들어 그걸 넘겼다.
  • 필요한 결론만 넘긴다. 줄 조각이 앞뒤 줄과 같은 묶음인지 알아야 했는데, 그러자고 전체 줄 배열을 넘기면 그 배열 참조가 바뀔 때마다 모든 줄이 다시 그려진다. 판단은 위에서 하고 불리언 두 개만 넘겼다.

?? [] 한 줄이 React.memo 를 통째로 무력화한다. 메모를 붙일 때는 성능을 재기 전에 넘기는 값 중에 렌더마다 새로 만들어지는 게 없는지부터 훑는 편이 빠르다.

이 방법이 통하는 조건

도메인과 무관하게, 아래 셋이 겹치면 같은 처방이 듣는다. 하나라도 빠지면 굳이 할 이유가 없다.

  1. 같은 모양의 작은 요소가 수백에서 수천 개쯤 있다. 표의 셀, 캘린더의 날짜, 리스트의 배지, 타일맵의 타일 같은 것들이다.
  2. 그 요소의 겉모습이 정해진 몇 가지 상태에서만 나온다. 경우의 수를 실제로 세어 봤을 때 수십 개 이하여야 한다. 사용자 입력이 그대로 스타일이 되면 나눠 쓸 수 없다.
  3. 자주 다시 그린다. 드래그나 실시간 갱신, 애니메이션처럼 초당 여러 번 도는 경로가 있어야 한다. 한 번 그리고 마는 화면이면 의미가 없다.

정리, 그리고 아직 못 잰 것

  • 공유할 수 있는 것과 없는 것을 먼저 가른다. DOM 노드는 못 나눠 쓰고, 그 부수 객체는 나눠 쓸 수 있다.
  • 경우의 수를 세어 본다. 18가지뿐이면 920번 만들 이유가 없다.
  • 꺼내 쓰는 것 자체가 할당이면 flyweight가 아니다. 문자열 키 대신 색인을 쓰니 6.3배 차이가 났다.
  • flyweight는 만드는 비용을 없애고 메모는 훑는 양을 줄인다. 둘은 대체재가 아니라 짝이다.

솔직하게 남길 것이 하나 있다. 위 숫자는 전부 함수 단위 벤치마크다. 실제 앱에서 드래그할 때 프레임이 몇 ms 줄었는지는 아직 못 쟀다. 다음 단계는 줄 하나를 텍스트 한 덩어리로 그리고 강조 표시를 그 위에 사각형으로 얹어 DOM을 30분의 1로 줄이는 것인데, 칸 단위 배경색을 다시 설계해야 해서 실측 결과를 보고 정할 생각이다.

숫자 없이 빨라졌을 것이라고 적는 것보다는, 잰 것과 못 잰 것을 갈라 적는 편이 나중의 나에게 쓸모 있다.

참고 자료

  • memo : props가 그대로면 다시 그리지 않게 하는 방법과, 메모가 깨지는 흔한 이유
  • useMemo : 렌더마다 새로 만들어지는 값을 붙잡아 두기
  • content-visibility : 화면 밖 요소의 배치와 그리기를 건너뛰기
  • Map : 키 순서가 보장되는 자료구조, 상한을 둔 캐시에 쓸 때
  • Flyweight pattern : 패턴의 원래 정의
  • 이번 작업 커밋: c6fd4cd
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.