가상 스크롤로 장부 화면 만들기: 느린 원인은 Intl.DateTimeFormat
제조 공장 한 곳의 생산 관리 앱을 만들고 있습니다. 그 안에 수주대장이라는 화면이 있는데, 공장이 쓰던 생산 엑셀을 칸 순서까지 그대로 옮긴 표입니다. 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 한도가 압축 전과 후 중 어느 쪽에 걸리는지, 스트리밍 응답에도 똑같이 걸리는지는 확인하지 못했습니다. 그래서 압축 전 크기를 기준으로 보수적으로 잡아 두었습니다.
함께 읽기
- 클릭했는데 화면이 늦게 바뀌는 느낌을 줄이는 방법사용자가 버튼이나 링크를 눌렀는데 아무 반응이 없는 것처럼 보이면, 실제 로딩 시간이 길지 않아도 서비스가 느리다고 느낍니다. 이 문제는 단순한 성능 문제가 아니라 체감 성능과 피드백의 문제입니다.
- Next.js 앱에서 클릭이 한 박자 느리게 느껴질 때 살펴본 것들서버가 멀면 응답이 느린 건 어쩔 수 없습니다. 제 경우 오리진 응답 지연에 CDN 우회 경로까지 겹쳐 페이지 응답에 1초 가까이 걸리는 상황이었고, 네트워크 쪽은 당장 손댈 수 없었습니다. 그래서 응답 속도 대신 "눌렀을 때 반응하는 속도"를 올리는 쪽으로 방향을 잡았습니다. 느린 것과 느리게 느껴지는 것은 생각보다 …
- Next.js loading.tsx와 404: 버튼이 화면보다 먼저 살아나게 하기관리 화면 왼쪽 위에 사이드바를 접고 펴는 ☰ 버튼이 있습니다. 폭 하나 바꾸는 버튼인데, 대시보드에서 누르면 2초 넘게 아무 반응이 없었습니다. 레이아웃만 바꾸는 일이 왜 이렇게 오래 걸리는지부터 들여다봤습니다. Next.js 16 App Router 로 만든 제조 공장의 생산 관리 앱입니다.
- Next.js 웹앱을 Electron 데스크톱 앱으로 만들기 전에 따져볼 것사내 웹 도구를 쓰다 보면 어느 순간 "이거 그냥 데스크톱 앱으로 만들면 안 되나"라는 말이 나옵니다.
- Leaflet 지도가 느려지는 3가지 원인: React key, SVG 렌더러, 가드 위치지도 위에 H3 육각 격자를 얹은 화면을 만들었습니다. 셀을 누르면 인접한 여섯 셀이 함께 칠해지는, 영업 구역을 나눠 보는 화면입니다.