해결된 질문
작성
·
19
·
수정됨
0
문서 수가 수백개 이상으로 늘어나면 findDoc(), childrenOf() 등의 함수에서 전체 배열을 순회하는 부분이 성능면에서 문제가 될 수 있을까요? 만약 그렇다면 최적화할 수 있는 방법은 어떤 것이 있을까요?
답변 2
0
안녕하세요 쌀밥님 😊
질문 주셔서 감사합니다.
이 내용에서 다루는 최적화 방법을 완전히 이해하려면 약간의 컴퓨터 과학적 배경 지식이 필요합니다.
특히 다음 세 가지 개념을 알고 있으면 훨씬 쉽게 이해할 수 있습니다.
첫째는 시간 복잡도(Time Complexity) 개념으로, 어떤 코드가 데이터의 양이 늘어날수록 얼마나 느려지는지를 나타내는 지표입니다. 예를 들어 배열을 처음부터 끝까지 모두 훑는 코드는 O(n), 중첩 반복문이 있는 코드는 O(n²)처럼 표현합니다.
둘째는 자료구조(Data Structure) 중 배열(Array)과 맵(Map, 해시맵 HashMap)입니다. 배열은 순서대로 저장하는 구조이지만 검색할 때는 처음부터 끝까지 찾아야 하고, 맵은 키(key)를 이용해 값을 거의 즉시 찾을 수 있는 구조입니다.
셋째는 인덱스(Index) 개념으로, 데이터베이스나 검색 시스템에서 “필요한 데이터를 빠르게 찾기 위해 미리 만들어두는 참조용 지도”를 말합니다.
이 세 가지 개념이 이 글의 모든 최적화 논리의 토대가 됩니다.
지금의 구조는 state.docs
배열 전체를 매번 find()
나 filter()
로 훑어보는 방식이라, 문서가 수백 개나 수천 개로 늘어나면 자연스럽게 성능 저하가 생깁니다. 초반에는 아무 문제가 없어 보여도, 트리 렌더링이 일어날 때 각 노드마다 childrenOf()
가 반복적으로 호출되고, 거기에 문서 이동, 제목 수정, 즐겨찾기 토글 같은 이벤트가 동시에 일어나면 한 프레임 안에서 선형 탐색이 여러 번 겹치게 됩니다. 이렇게 되면 체감상 O(n²) 형태로 느려져서 브라우저가 잠시 멈추는 현상이 발생할 수 있습니다.
이 문제를 근본적으로 해결하려면 세 가지가 함께 고려되어야 합니다. 첫째, 조회를 거의 즉시 끝낼 수 있게 만드는 인덱스 구조 설계. 둘째, DOM 갱신 자체를 최소화하는 렌더링 방식. 셋째, 저장과 렌더 호출 빈도를 조절해 메인 스레드가 갑자기 과부하되지 않도록 하는 것입니다.
이 원리를 조금 더 쉽게 설명하면 “자주 일어나는 일은 최대한 빠르게, 드물게 일어나는 일은 조금 느려도 괜찮게 만든다”는 개념입니다. 문서 탐색은 매우 자주 일어나는 작업이므로 반드시 빠르게 만들어야 하고, 반면 문서를 새로 만들거나 이동하거나 삭제하는 일은 상대적으로 덜 자주 일어나기 때문에 그때만 약간의 추가 비용을 내도 전체 앱의 체감 속도는 훨씬 빨라집니다.
이 구조를 쉽게 구현할 수 있는 도구가 바로 자바스크립트의 Map입니다.
Map은 일반 객체(Object)와 비슷하게 키(key)와 값(value)을 짝지어 저장하는 자료 구조이지만, 몇 가지 중요한 차이가 있습니다.
첫째, Map은 어떤 타입이든 키로 사용할 수 있습니다. 숫자, 문자열뿐 아니라 객체나 심볼도 가능합니다.
둘째, get
, set
, has
, delete
, size
같은 전용 메서드를 통해 데이터를 매우 빠르게 읽고 쓸 수 있습니다.
셋째, Map은 내부적으로 해시 구조를 사용하기 때문에 평균적으로 데이터를 찾거나 넣는 데 걸리는 시간이 거의 일정합니다. 즉, 데이터가 아무리 많아져도 조회 속도가 거의 변하지 않습니다.
이걸 전화번호부로 비유해 보면 훨씬 직관적입니다.
일반 배열을 탐색하는 것은 마치 “전화번호부를 처음부터 끝까지 한 줄씩 넘기며 이름을 찾는 방식”이라면, Map은 “이름을 입력하자마자 바로 번호가 뜨는 스마트폰 연락처 검색”에 가깝습니다.
이처럼 Map을 이용하면 id
로 문서를 즉시 찾거나 특정 부모 ID에 속한 자식 문서들을 한 번에 가져올 수 있습니다.
이 원리를 현재 코드에 적용하려면 두 가지 관점에서 인덱스를 설계하면 됩니다.
하나는 id → 문서 객체
형태의 인덱스이고,
다른 하나는 parentId → [자식 문서들]
형태의 인덱스입니다.
앱이 처음 실행될 때 한 번 전체 데이터를 훑어서 이 두 인덱스를 미리 만들어 두고, 이후에는 문서가 새로 만들어지거나 수정되거나 이동되거나 삭제될 때마다 인덱스를 즉시 갱신하도록 유지하면 됩니다.
이렇게 하면 문서를 찾을 때 전체 배열을 훑을 필요가 없고, 필요한 정보만 즉시 꺼내올 수 있습니다. 또한 변경 시에도 그 부모에 해당하는 작은 그룹만 다시 정렬하면 되므로 전체를 다시 계산할 필요가 없습니다.
정렬 기준은 기존과 동일하게 order
를 우선으로 하고, 값이 같을 때만 title
을 비교하는 것이 좋습니다. localeCompare
는 CPU 비용이 상대적으로 크기 때문에 보조 비교로만 사용하는 것이 효율적입니다.
아래 코드는 이런 인덱스를 실제로 구현하는 최소한의 예시입니다. 호출부는 그대로 두고, 내부 동작만 교체하는 방식이라 전체 코드 구조를 바꾸지 않아도 안전하게 적용할 수 있습니다.
// 인덱스
const index = { byId: new Map(), byParent: new Map() };
const byOrder = (a,b)=> (a.order - b.order) || a.title.localeCompare(b.title);
function ensureBucket(pid){
const key = pid ?? null;
if (!index.byParent.has(key)) index.byParent.set(key, []);
return index.byParent.get(key);
}
function buildIndexes(){
index.byId.clear(); index.byParent.clear();
for (const d of state.docs){
index.byId.set(d.id, d);
ensureBucket(d.parentId).push(d);
}
for (const arr of index.byParent.values()) arr.sort(byOrder);
}
// 조회 헬퍼 내부 구현만 교체
function findDoc(id){ return index.byId.get(id) || null; }
function childrenOf(pid){
const bucket = index.byParent.get(pid ?? null) || [];
return bucket.slice(); // 외부 변형 방지
}
여기서 나오는 “버킷(bucket)”이라는 단어는, 같은 부모를 가진 문서들을 하나의 묶음 단위로 저장해 둔 배열을 뜻합니다.
즉, index.byParent
라는 Map 안에 부모 ID → [해당 부모의 자식 문서 목록]
이 저장되는데, 이 자식 문서 배열 하나하나가 바로 “버킷”입니다.
쉽게 말해, “한 부모 밑에 있는 아이들 폴더를 한 바구니(bucket)에 담아두고, 부모별로 바구니를 따로 관리하는 구조”라고 생각하면 됩니다.
이 덕분에 특정 부모 밑의 자식 문서들만 빠르게 찾거나 정렬할 수 있습니다.
여기서 중요한 것은 “항상 최신 상태를 유지하는 것”입니다.
Map 구조는 빠르지만, 한 곳이라도 갱신을 놓치면 실제 데이터(state.docs
)와 인덱스(Map) 사이에 불일치가 생깁니다.
따라서 createDoc
, updateDoc
, moveDoc
, archiveDoc
, restoreDoc
, removeDoc
등 모든 변경 함수가 반드시 인덱스를 함께 갱신해야 합니다.
이걸 보장하기 위해 기존 함수를 얇게 감싸서 “상태 변경이 일어날 때마다 인덱스도 함께 갱신되도록” 만드는 것이 안전한 방식입니다.
그 원리는 간단합니다.
문서를 새로 만들면 byId
에 등록하고, 해당 부모의 버킷(즉, 자식 문서 배열)에 새 문서를 정렬된 위치에 끼워 넣습니다.
제목이나 순서를 바꾸면 같은 부모 버킷 안에서 한 번 빼고 정렬 기준에 맞게 다시 넣습니다.
문서를 다른 부모로 옮길 때는 이전 부모의 버킷에서 제거하고 새 부모의 버킷에 추가합니다.
아카이브는 자신과 자식들을 인덱스에서 제거하고, 복원은 반대로 다시 삽입합니다.
특히 이동이나 수정 같은 경우는 “이전 부모”와 “새 부모”를 구분해서 둘 다 업데이트해야 버킷이 꼬이지 않습니다.
// createDoc 래핑 원리: 상태 반영 → byId 등록 → 부모 버킷에 정렬 삽입
const _createDoc = createDoc;
createDoc = function(payload){
const id = _createDoc(payload);
const d = state.docs.find(x => x.id === id);
index.byId.set(id, d);
// 정렬 비용 최소화를 위해 '이진 삽입' 같은 방식으로 들어갈 위치만 찾아 넣는 것이 유리
const arr = ensureBucket(d.parentId);
let lo=0, hi=arr.length;
while(lo<hi){ const mid=(lo+hi)>>1; (byOrder(arr[mid], d)<=0)? lo=mid+1 : hi=mid; }
arr.splice(lo, 0, d);
return id;
};
렌더링과 관련된 병목은 조회와는 별도의 문제입니다.
예를 들어 childrenOf()
를 한 함수 안에서 여러 번 부르면 매번 같은 데이터를 다시 가져오게 되므로 불필요한 연산이 쌓이게 됩니다.
이럴 때는 한 번만 호출해 변수에 저장해 두고 재사용하는 것이 훨씬 효율적입니다.
또한 변경이 일어난 부모 섹션만 부분적으로 다시 그리도록 분기하면 체감 속도가 눈에 띄게 좋아집니다.
만약 한 화면에 수천 개의 문서를 펼쳐서 보여줘야 하는 상황이라면, 화면에 실제로 보이는 부분만 렌더링하는 “가상 스크롤(virtual scroll)” 방식을 사용하는 것도 좋은 선택입니다.
그리고 normalizeOrders(pid)
함수는 이미 byParent
버킷이 정렬되어 있으니, 굳이 다시 정렬을 반복하지 말고 그 순서대로 0부터 순서 번호를 다시 매기기만 하면 됩니다.
제목 검색이나 정렬이 자주 발생한다면, titleLower
속성을 문서에 추가해두어 미리 소문자로 저장해두는 것도 좋습니다.
이렇게 하면 매번 toLowerCase()
를 호출하지 않아 CPU 사용량을 줄일 수 있습니다.
// renderNode 안에서 중복 호출을 없애는 간단한 형태
const kids = childrenOf(doc.id);
const hasChildren = kids.length > 0;
// hasChildren 사용 및 kids 재사용 …
저장과 렌더 호출은 사용자의 조작이 빠르게 이어질 때 짧은 시간 안에 여러 번 발생할 수 있습니다.
이럴 때 localStorage
저장이 너무 자주 일어나면 메인 스레드를 잠시 멈추게 만들어 브라우저가 끊기는 느낌을 줄 수 있습니다.
따라서 저장 함수를 바로 실행하지 않고, 약간의 지연을 둬서 일정 시간 동안 요청이 여러 번 들어오면 한 번만 실행되도록 하는 것이 좋습니다. 이를 “쓰로틀(throttle)”이라고 부릅니다.
쓰로틀을 추가하면 기능은 그대로 유지하면서도 화면이 훨씬 부드러워집니다.
let saveQueued = false;
function saveThrottled(){
if (saveQueued) return;
saveQueued = true;
setTimeout(()=>{ save(); saveQueued = false; }, 250);
}
이 구조를 적용할 때 반드시 지켜야 할 점이 있습니다.
첫째, load()
가 실행된 직후 단 한 번 buildIndexes()
를 호출해 초기 인덱스를 반드시 만들어야 합니다.
둘째, 문서를 생성하거나 수정하거나 이동하는 등 모든 변경 함수가 반드시 인덱스와 동기화되어야 합니다.
셋째, findDoc()
과 childrenOf()
의 이름과 인자 구조는 그대로 유지해야 기존 코드들이 모두 정상적으로 동작합니다.
이렇게 하면 조회는 항상 빠르게 유지되고, 렌더링은 필요한 부분만 다시 그리게 되며, 저장은 쓰로틀 덕분에 부드럽게 이루어집니다.
이 방법은 문서가 수백 개 정도밖에 되지 않아도 트리 전체를 자주 렌더링하거나 문서 이동이 잦은 상황에서 큰 효과를 볼 수 있습니다.
Map 인덱스를 적용했는데도 여전히 프레임 드랍이 느껴진다면, 먼저 childrenOf()
의 중복 호출을 없애고, 전체를 새로 그리는 대신 필요한 부분만 다시 그리도록 바꾸는 것이 비용 대비 효과가 가장 큽니다.
데이터가 수천 개로 늘어나거나 한 화면에서 수많은 노드를 동시에 펼쳐야 하는 경우에는 가상 스크롤을 함께 도입하는 것이 좋습니다.
저장 쓰로틀은 어떤 규모에서도 유용하므로 기본적으로 활성화해 두는 것을 권장합니다.
결국 핵심은 단순합니다.
Map 인덱스 두 개(byId, byParent)를 만들어 문서를 빠르게 찾고, 중복 호출을 제거해 DOM 연산량을 줄이며, 저장과 렌더링을 쓰로틀로 제어해 메인 스레드가 버벅이지 않게 만드는 것입니다.
이 세 단계를 함께 적용하면, 문서 수가 수천 개로 늘어나더라도 현재 코드 기반에서 부드럽고 안정적인 성능을 유지할 수 있습니다.
또 한 가지 덧붙이자면, 실제로 데이터를 장기간 보관하거나 탭 간 동기화가 필요한 상황이라면, 메모리 기반 Map 인덱스만으로는 부족할 수 있습니다. 이런 경우에는 IndexedDB 같은 브라우저 내장 데이터베이스를 함께 활용해 인덱스를 디스크에 캐시 형태로 저장해 두면, 새로고침 후에도 빠른 초기 조회가 가능합니다. 이렇게 하면 메모리와 디스크 양쪽에서 균형 잡힌 구조로 더 안정적인 성능을 얻을 수 있습니다.
감사합니다!
0
안녕하세요, 인프런 AI 인턴입니다. 쌀밥님.
문서의 수가 많아지면, 배열을 순회하는 연산인 findDoc()
와 childrenOf()
와 같은 함수를 실행할 때 성능 문제가 발생할 수 있습니다. 이러한 경우를 대비해 효율적인 자료구조와 최적화를 통해 성능을 개선할 수 있습니다. 다음은 몇 가지 최적화 방법입니다:
데이터 구조 최적화: 배열 순회를 줄이기 위해 데이터 구조를 개선할 수 있습니다. 객체를 사용하여 문서 ID를 키로 저장하면 특정 문서를 찾는 시간이 O(1)로 줄어듭니다.
인덱싱: 문서들을 정렬하거나 인덱스를 생성하여, 검색 시간을 단축할 수 있습니다.
메모이제이션: 이미 계산된 결과를 저장해두고, 필요할 때 사용해 배열 순회를 최소화할 수 있습니다.
상태 관리 라이브러리 사용: Redux나 MobX 같은 라이브러리를 사용하면 글로벌 상태를 효율적으로 관리할 수 있습니다.
가상화 기술 사용: 수많은 데이터를 한 번에 렌더링하기보다는 ‘가상화’ 기법을 활용해 현재 화면에 보이는 데이터만 렌더링하도록 하는 방법이 있습니다. “react-window”나 “react-virtualized” 같은 라이브러리를 사용할 수 있는 환경이라면, 이러한 기술을 적용해 볼 수 있습니다.
비동기 로딩: 한 번에 모든 데이터를 로드하기보다, 사용자가 필요로 할 때 해당 데이터를 로드하는 방식으로 성능 최적화를 할 수 있습니다.
유사한 사례에 대한 해결책을 다음 게시글에서 참고할 수 있습니다:
- 배열의 효율적인 탐색 및 최적화 전략
- 상태 관리 및 데이터 구조 최적화
이러한 방법들을 고려해 보신다면, 수백 개의 문서도 효율적으로 관리할 수 있을 것입니다.
저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.