
한 줄 요약: 필터 체크박스를 누를 때마다 목록 전체를 다시 그리면 항목이 수천 개일 때 화면이 끊깁니다. 느린 곳은 거르는 계산이 아니라 걸러 낸 결과를 화면에 붙이는 단계였고, 조건을 모아 한 번만 계산하기, 개수를 먼저 보여주기, 화면에는 한 번에 붙이기 세 가지로 고쳤습니다.
증상: 체크박스 하나에 목록 전체가 다시 그려졌다
블로그스팟 스킨에 필터 체크박스를 달았을 때 생긴 문제입니다. 체크박스를 하나 누를 때마다 목록 전체를 다시 그렸더니 화면이 눈에 띄게 끊겼습니다. 항목이 수천 개라서 생긴 일입니다. 필터가 하나일 때는 티가 나지 않다가, 여러 개를 연달아 누르는 순간 반응이 늦어졌습니다.
이런 증상은 필터 UI를 직접 만들어 본 분이라면 한 번쯤 겪습니다. 원인을 짐작으로 고치면 엉뚱한 곳을 만지게 되므로, 이 글은 어디가 느렸는지를 먼저 나누고 그다음에 고친 순서를 따라갑니다. 독자는 같은 순서로 자기 스킨이나 목록 화면의 병목을 찾아볼 수 있습니다. 코드는 블로그스팟 스킨 안의 자바스크립트를 기준으로 하지만, 항목을 걸러서 화면에 다시 그리는 구조라면 어디에나 같은 생각이 적용됩니다.
다루는 범위도 먼저 밝혀 둡니다. 이 글은 필터를 브라우저 안에서 걸러 내고 목록을 다시 그리는 구조를 전제로 하며, 서버에서 목록을 나눠 받아 오는 방식은 다루지 않습니다. 또 코드는 스킨 안에서 실제로 쓰는 형태를 줄여 옮긴 것이어서, 변수 이름은 자기 스킨에 맞게 바꿔 쓰시면 됩니다.
느린 곳부터 단계별로 나눠 본다
짐작으로 고치지 않으려면 단계마다 걸린 시간을 따로 봐야 합니다. 브라우저 개발자 도구를 열고 단계를 console.time과 console.timeEnd로 감싸면 됩니다. MDN 문서에 따르면 console.time은 이름(label)을 붙인 타이머를 시작하고, 같은 이름으로 console.timeEnd를 부르면 그동안 지난 시간을 밀리초로 출력합니다. 이름을 생략하면 "default"라는 이름이 쓰이고, 한 페이지에서 동시에 돌릴 수 있는 타이머는 최대 10,000개입니다.
결과가 뜻밖이었습니다. 조건을 따져 걸러 내는 일은 수천 개라도 순식간이었습니다. 느린 것은 그다음, 걸러 낸 결과를 화면에 붙이는 부분이었습니다. 값을 코드에서 직접 읽어 비교하고 싶다면 MDN은 performance.mark와 performance.measure를 권하므로, 전후 수치를 남기고 싶은 분은 그쪽을 쓰시면 됩니다.
- 걸러 내기 시작 전과 끝에 각각 타이머를 둡니다.
- 화면에 붙이는 코드 앞뒤에 다른 이름의 타이머를 둡니다.
- 필터를 하나 켰을 때와 여러 개 켰을 때를 나눠 비교합니다.

거르는 건 빠르고 그리는 건 느린 이유
화면에 항목을 붙이면 브라우저는 스타일을 다시 계산하고 배치(레이아웃)를 다시 잡아야 합니다. web.dev 문서는 레이아웃 비용이 DOM 크기와 관계가 있고, DOM이 클수록 대체로 레이아웃 비용이 커진다고 설명합니다. 문서가 든 예로는 요소 1,618개짜리 트리에서 프레임마다 28밀리초가 넘는 레이아웃 시간이 나왔는데, 60프레임 기준 한 프레임의 시간은 약 16밀리초입니다.
이 수치는 제 스킨의 결과가 아니라 문서의 예시입니다. 다만 원리는 같습니다. 배열을 거르는 일은 메모리 안의 계산이라 수천 개여도 빠르고, 항목을 화면에 붙이는 일은 붙일 때마다 브라우저가 해야 할 일이 늘어납니다. 같은 문서는 레이아웃을 강제로 반복시키는 레이아웃 스래싱도 설명하며, 스타일 읽기를 먼저 모아서 하고 쓰기를 뒤에 하라고 권합니다. 항목마다 붙이는 반복문 안에서 화면 값을 읽으면 이 문제가 겹쳐 생깁니다.
그래서 병목을 찾을 때는 "계산이 느린가, 그리기가 느린가"를 먼저 가르면 됩니다. 계산이 느리면 알고리즘을 손보고, 그리기가 느리면 화면에 닿는 횟수와 양을 줄입니다. 이번 경우는 뒤쪽이었기 때문에 아무리 거르는 코드를 다듬어도 체감은 거의 변하지 않았을 것입니다.
조건을 모아 한 번만 훑는다
처음 코드는 체크박스마다 각각 목록을 훑었습니다. 필터가 6개면 여섯 번 훑는 셈입니다. 이것을 켜져 있는 조건만 먼저 모은 다음 목록을 한 번만 훑도록 바꿨습니다.
// 고쳐 쓰기 전 — 필터마다 따로 훑는다
var result = items;
filters.forEach(function(f){
result = result.filter(function(it){ return match(it, f); });
});
// 고친 뒤 — 조건을 모아 한 번만 훑는다
var active = filters.filter(function(f){ return f.on; });
var result = items.filter(function(it){
return active.every(function(f){ return match(it, f); });
});
큰 속도 차이는 아니었지만 코드가 읽기 쉬워졌습니다. 지금 어떤 조건이 걸려 있는지가 한 줄로 보입니다. 속도보다 이 점이 유지보수에서 더 값졌습니다. 나중에 필터를 하나 더 추가해도 active 목록에 항목이 늘 뿐 반복문 구조는 바뀌지 않기 때문입니다.
개수를 먼저 보여준다
진짜 효과가 있었던 것은 이쪽입니다. 걸러 낸 개수를 먼저 화면에 쓰고, 목록은 그다음에 붙입니다. 실제로 빨라진 것은 아닙니다. 다만 누른 직후에 숫자가 바뀌니 반응이 있다는 느낌이 듭니다. 아무 변화가 없다가 한참 뒤에 목록이 통째로 바뀌는 것과는 체감이 다릅니다.
필터를 누른 뒤 가장 답답한 순간은 "눌렸는지 안 눌렸는지 모르는 시간"입니다. 개수는 계산이 끝나는 즉시 한 줄로 쓸 수 있어서, 목록을 그리는 무거운 단계가 남아 있어도 먼저 응답을 줄 수 있습니다. 체감 속도는 실제 처리 시간과 별개로 설계할 수 있다는 점을 보여주는 사례입니다.
구현은 단순합니다. 필터 상태가 바뀌면 걸러 낸 배열의 길이를 개수 표시 영역에 먼저 쓰고, 이어서 목록 그리기 함수를 부릅니다. 순서만 바꾼 것이라 기존 코드에 크게 손대지 않아도 됩니다. 다만 개수와 목록이 같은 배열에서 나와야 숫자와 화면이 어긋나지 않습니다. 개수 표시는 눈속임이 아니라 사용자에게 실제로 필요한 정보이기도 하므로, 목록이 길수록 더 쓸모가 있습니다. 표시 위치는 사용자가 방금 누른 필터 가까이에 두는 편이 반응을 알아차리기 쉽습니다.

화면에는 한 번에 붙인다
마지막이 그리는 방식입니다. 항목 하나씩 화면에 붙이면 그때마다 브라우저가 다시 계산합니다. 그래서 문자열로 다 만들어 놓고 마지막에 한 번만 넣었습니다. 이 방법이 가장 확실했고, 항목이 많을수록 차이가 커졌습니다.
// 항목마다 붙이지 않는다
var parts = [];
result.forEach(function(it){ parts.push(cardHTML(it)); });
container.innerHTML = parts.join(''); // 마지막에 한 번
MDN 문서에 따르면 innerHTML에 값을 넣으면 문자열을 HTML로 해석해서 요소의 기존 자식을 모두 새 결과로 바꿉니다. 그래서 목록 영역 전체를 새로 채우는 이 경우에 맞습니다. 반대로 기존 목록 뒤에 이어 붙일 때는 insertAdjacentHTML이 맞는데, 문서는 이 메서드가 대상 요소를 다시 해석하지 않아 기존 요소를 건드리지 않고 innerHTML보다 훨씬 빠르다고 설명합니다.
주의할 점도 있습니다. 두 방법 모두 문자열을 HTML로 읽기 때문에 사용자가 입력한 값이 섞이면 XSS 위험이 있습니다. MDN은 일반 텍스트라면 textContent를 쓰고, HTML이 필요하면 안전하게 정리한 값만 넣으라고 안내합니다. 또한 DocumentFragment로 묶어 한 번에 붙이는 방법도 있지만, 같은 문서는 그 성능 이점이 과장되기 쉽고 차이는 미미하니 가독성을 우선하라고 말합니다.
세 가지 변경을 비교하면
무엇이 실제 효과였고 무엇이 정리에 가까웠는지 한눈에 보도록 표로 묶었습니다. 이 표의 효과 칸은 제가 개발자 도구로 보며 고친 느낌을 정리한 것이고, 전후 수치를 기록한 것은 아닙니다.
| 바꾼 것 | 고치기 전 | 고친 뒤 | 효과 |
|---|---|---|---|
| 조건 모아 한 번 훑기 | 필터마다 목록을 따로 훑음 | 켜진 조건만 모아 한 번에 훑음 | 큰 속도 차이는 없음, 코드가 읽기 쉬워짐 |
| 개수 먼저 표시 | 목록이 바뀔 때까지 변화 없음 | 누르자마자 숫자가 먼저 바뀜 | 실제 속도는 같지만 체감 반응이 좋아짐 |
| 화면에 한 번에 붙이기 | 항목마다 붙임 | 문자열로 만든 뒤 한 번만 넣음 | 가장 확실하게 빨라졌고 항목이 많을수록 차이 큼 |
예시입니다. 항목이 3,000개라고 가정하면, 항목마다 붙이는 방식은 화면 쓰기가 3,000번이고 문자열을 모아 넣는 방식은 1번입니다. 실제 시간은 기기마다 다르지만 브라우저가 해야 할 일의 횟수가 이만큼 달라진다는 점은 계산으로 알 수 있습니다.
따라 하신다면 효과가 확실했던 순서를 권합니다. 먼저 console.time으로 거르기와 그리기를 나눠 시간을 보고, 그리기가 느리면 한 번에 붙이기부터 적용합니다. 그다음 개수 먼저 표시로 체감을 올리고, 마지막으로 조건 모으기로 코드를 정리합니다. 조건 모으기는 속도보다 유지보수를 위한 정리라는 점을 기억해 두시면 됩니다.
한계와 다음 단계
항목이 아주 많아지면 이 방법도 한계가 있습니다. 지금은 걸러 낸 결과를 전부 그립니다. 수만 개가 되면 화면에 보이는 만큼만 그리는 방식으로 바꿔야 합니다. 지금 규모에서는 필요하지 않아 넣지 않았습니다.
또 실제 소요 시간을 숫자로 기록해 두지는 않았습니다. 개발자 도구로 보면서 고쳤고, 전후 수치를 남기지 않은 것은 아쉬운 부분입니다. 같은 작업을 하실 분은 처음에 console.time이나 performance.measure로 전후 수치를 남겨 두시기 바랍니다. 이번 수정의 판단 기준은 세 가지였습니다. 느린 곳을 계산이 아니라 그리기로 좁혔는지, 바꾼 것마다 효과를 따로 구분했는지, 지금 규모에서 필요 없는 최적화는 넣지 않았는지입니다. 마지막 기준 덕분에 화면에 보이는 만큼만 그리는 복잡한 방식은 다음 단계로 미뤄 둘 수 있었습니다. 수정 작업을 AI 도구에 맡기는 경우에는 작업 모드를 써 보는 순서를 정리한 글처럼 맡길 단위를 나누는 방법이 참고가 되고, 도구마다 잘하는 일이 다르다는 점은 작업 유형별 비교 글에서 볼 수 있습니다.
확인 자료
- MDN, console.time() (타이머 이름, 밀리초 출력, performance.mark/measure 권고)
- MDN, Element.innerHTML (기존 자식 교체, XSS 주의, textContent)
- MDN, Element.insertAdjacentHTML() (기존 요소를 건드리지 않는 삽입)
- MDN, DocumentFragment (한 번에 삽입, 성능 이점은 미미)
- web.dev, 큰 레이아웃과 레이아웃 스래싱 피하기 (DOM 크기와 레이아웃 비용, 읽기 먼저 쓰기 나중)