RSS
프론트엔드

가상 스크롤로 장부 화면 만들기: 느린 원인은 Intl.DateTimeFormat

작성자
듀오랩스 대표·8분 읽기

제조 공장 한 곳의 생산 관리 앱을 만들고 있습니다. 그 안에 수주대장이라는 화면이 있는데, 공장이 쓰던 생산 엑셀을 칸 순서까지 그대로 옮긴 표입니다. 1년치만 봐도 4천 줄이 넘습니다. 처음에는 100줄씩 쪽으로 나눠 두었습니다. 직접 써 보다가 이 부분은 확실히 엑셀보다 불편하다는 생각이 들었습니다. 엑셀은 수천 줄을 스크롤 한 번으로 내리는데, 이 화면은 100줄마다 「다음」을 눌러야 했습니다.

쪽을 키우면 HTML 이 먼저 터지는 구조

처음 떠올린 해법은 단순했습니다. 한 쪽에 보이는 줄 수를 고르게 하자. 100, 300, 500, 1,000줄씩. 만들어 놓고 크기를 재 보니 이야기가 달라졌습니다.

칸이 가장 많은 탭(칸 38개) 보내는 HTML 화면 요소 수
100줄 1.2MB 4,944
300줄 3.3MB 13,620
500줄 5.4MB 22,298
1,000줄 10.6MB 43,980

로컬 개발 서버에서 잰 값입니다. 서버 컴포넌트가 줄마다 칸을 완성된 HTML 로 그려 보내니, 줄 수에 비례해 무게가 늘어납니다. 그리고 Vercel 함수의 응답 본문에는 4.5MB 한도가 있습니다. 500줄부터 넘습니다. 결국 300줄에서 막고 쪽 번호를 붙여 커밋했는데, 커밋하는 순간에도 엑셀보다 불편한 건 그대로라는 걸 알고 있었습니다.

칸 글자만 보내고 보이는 줄만 그리는 표

방향을 바꿨습니다. 서버는 거른 줄 전부를 보내되 HTML 이 아니라 칸 글자 배열만 보냅니다. 브라우저는 스크롤 위치를 보고 화면에 걸친 줄과 그 앞뒤 12줄만 그립니다. 위아래 나머지는 높이만 가진 빈 줄 두 개로 채워서 스크롤 막대가 전체 길이를 알게 합니다. 흔히 가상 스크롤(virtualization)이라고 부르는 방식이고, 라이브러리 없이 100줄 남짓으로 짰습니다.

모두 탭 보내는 양 화면 요소 수
쪽 방식, 1,000줄 5.5MB 23,580
가상 스크롤, 4,600줄 전부 1.6MB 1,396

줄은 네 배가 넘게 보내는데 무게는 3분의 1이 됐습니다. 머리줄과 합계줄은 position: sticky 로 위아래에, 수주번호 칸은 왼쪽에 붙였습니다. 그래서 오른쪽으로 밀어도 어느 주문인지 보입니다.

막상 바꾸고 나니 예상하지 못한 곳이 둘 깨졌습니다. 하나는 줄을 골라 한 번에 처리하는 기능의 체크 칸입니다. 원래는 폼에 묶인 체크박스를 querySelectorAll 로 읽었는데, 이제 화면 밖 줄의 체크박스는 DOM 에 없습니다. 고른 줄을 useSyncExternalStore 로 만든 작은 저장소에 따로 두고, 거르기가 바뀌면 비우게 했습니다. 다른 하나는 브라우저 찾기입니다. 그리지 않은 줄은 ⌘F 에 걸리지 않습니다. 이건 고칠 방법이 마땅치 않아서 표 아래에 「찾을 때는 위 검색 칸을 쓴다」고 적어 두었습니다.

칸 너비도 한 번 틀렸습니다. 처음에는 글자 수로 너비를 어림했는데, 굵게 쓰는 수주번호 20240325-12-1 이 말줄임표로 잘렸습니다. 지금은 웹 글꼴이 다 내려온 뒤 canvas 의 measureText 로 실제 글자 폭을 재서 정합니다. 같은 글자는 한 번만 잽니다. 그래도 기타사항처럼 긴 칸은 잘리는데, 그런 칸에 마우스를 올리면 전체 글자를 띄웁니다. 칸이 overflow: hidden 이라 CSS 말풍선은 같이 잘려서, 화면 기준으로 고정된 상자 하나를 옮겨 쓰는 방식으로 했습니다.

1.2초의 범인은 날짜 함수 하나

무게는 줄었는데 화면은 여전히 느렸습니다. 개발 서버에서 모두 탭이 1.2초에서 3.7초까지 걸렸습니다. 처음엔 이력까지 붙여 읽는 DB 쿼리를 의심했습니다. 그래서 단계별로 시간을 나눠 쟀습니다.

모두 탭 4,593줄 고치기 전
DB(이력 포함) 171~199ms
정렬 143~146ms
칸 계산 296~301ms

DB 가 아니었습니다. 정렬과 칸 계산이 DB 의 두 배 넘게 걸리고 있었습니다. 칸 계산이라고 해 봐야 카드 중량을 곱하고 날짜를 글자로 바꾸는 정도입니다. 따라가 보니 날짜를 한국 날짜로 바꾸는 함수가 이렇게 생겼습니다.

export function kstDay(d = new Date()) {
  return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul", year: "numeric", month: "2-digit", day: "2-digit" }).format(d);
}

부를 때마다 Intl.DateTimeFormat 을 새로 만듭니다. 시간대 데이터를 읽고 로캘을 풀어 포매터를 세우는 일이 한 번에 몇 마이크로초씩 듭니다. 수천 줄에 날짜 칸이 여럿이고, 정렬 비교에서도 이 함수를 불렀으니 수만 번이 됩니다.

서머타임이 없는 나라의 날짜 계산

한국은 지금 서머타임이 없어서 늘 UTC+9 입니다. 그러면 9시간을 더한 시각의 UTC 날짜가 곧 한국 날짜입니다.

const KST_MS = 9 * 60 * 60 * 1000;
export function kstDay(d = new Date()) {
  return new Date(d.getTime() + KST_MS).toISOString().slice(0, 10);
}

바꾸기 전에 예전 함수와 결과를 견줬습니다. 1990년부터 2040년까지 7시간 13초 간격으로 6만 2천여 개 시각을 넣어 보니 다른 결과가 하나도 없었습니다. 1990년부터 잡은 데는 이유가 있습니다. 한국은 1987년과 1988년 여름에 서머타임을 했고, 그 기간에는 이 계산이 한 시간 틀립니다. 이 앱은 2024년 이후 자료만 다루니 문제가 없지만, 오래된 날짜를 다루는 곳이라면 그대로 쓰면 안 됩니다.

모두 탭 4,593줄 고치기 전 고친 뒤
정렬 143~146ms 25ms
칸 계산 296~301ms 7ms
화면 응답(개발 서버) 1,209~3,753ms 약 530ms

예전에 100줄만 보내던 쪽 방식보다, 지금 4,600줄 전부를 보내는 쪽이 더 빨리 열립니다. 이 함수는 앱 전체가 쓰고 있어서 다른 화면도 함께 빨라졌습니다. 운영과 같은 빌드로 다시 재 보니 수주대장은 약 470ms 였고, 시간 대부분은 이력을 읽는 DB 쿼리였습니다.

아직 남은 숙제, 전체 기간

기본 보기는 「최근 12개월 접수 + 아직 안 끝난 것」이라 몇 년이 쌓여도 보내는 양이 1.6MB 언저리에 머뭅니다. 문제는 「전체 기간」입니다. 지금 1.9MB 이고 한 줄이 약 0.35KB 이니, 지금 주문량이면 2년쯤 뒤에 4.5MB 근처에 닿습니다. 그때는 스크롤 막대가 가리키는 구간만 받아 오도록 바꿀 생각입니다. 표는 이미 보이는 줄만 그리는 구조라, 자료를 받는 부분만 바꾸면 됩니다.

한 가지는 아직 확신이 없습니다. 측정은 모두 로컬에서 압축하기 전 크기로 했습니다. gzip 을 거치면 1.6MB 가 144KB 가 되는데, Vercel 의 4.5MB 한도가 압축 전과 후 중 어느 쪽에 걸리는지, 스트리밍 응답에도 똑같이 걸리는지는 확인하지 못했습니다. 그래서 압축 전 크기를 기준으로 보수적으로 잡아 두었습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.