Diary/Develop Note

[공지시스템] Virtualization으로 성능 15배 개선 - 26,000개 SelectBox를 0.2초로

Juyeong 2025. 10. 24. 10:59

시리즈

1편 [공지시스템] PG사에서 계층형 공지 시스템 만들기 - 대규모 조직을 위한 설계 배경

2편 [공지시스템] 설계 문서로 시작하는 개발 - 전체 아키텍처를 그리며 배운 점들

3편 [공지시스템] DB 설계와 구현 - 타겟팅 로직부터 파일 업로드

4편 [공지시스템] API 구현 - ORM 대신 스토어드 프로시저를 선택한 이유

5편 [공지시스템] 계층형 SelectBox 구현기 - 대용량 데이터를 계층 구조로 다루는 UI 전략

6편 [공지시스템] 웹/모바일 팝업 구현기 - Swiper와 localStorage로 사용자 경험 개선하기

7편 [공지시스템] Virtualization으로 성능 15배 개선 - 26,000개 SelectBox를 0.2초로 (현재글)


1. 배경 - 운영 반영 후 문제 발견

5편에서 계층형 SelectBox를 구현했습니다. 개발 환경에서는 잘 작동했는데, 운영에 배포하고 나서 문제를 발견했습니다.

배포 직후 모니터링

배포가 완료된 후 모니터링 하려는데,

가맹점 드롭다운을 클릭하는 순간... 화면이 약 3초간 멈췄습니다. 

그 짧은 순간 온갖 생각이 들더라고요..

개발 환경 vs 운영 환경

문제를 확인해보니 데이터 규모 차이가 원인이었습니다.

개발 환경 (로컬):
├─ 총판: 10개
├─ 지사: 50개  
├─ 대리점: 200개
└─ 가맹점: 500개
→ 드롭다운이 빠르게 열림 

운영 환경 (실제):
├─ 총판: 150개
├─ 지사: 800개
├─ 대리점: 2,500개
└─ 가맹점: 7,000개 
→ 드롭다운이 3초 후에 열림

개발할 때는 테스트 데이터만 사용해서 문제를 눈치채지 못했습니다.


2. 원인 분석 - 왜 느린가?

Chrome DevTools로 프로파일링

정확한 원인을 파악하기 위해 Chrome DevTools의 Performance 탭을 사용했습니다.

측정 방법:

  1. Performance 탭 열기
  2. Record 버튼 클릭
  3. 드롭다운 클릭
  4. Stop 버튼 클릭
  5. 결과 분석

드롭다운 클릭 시 소요 시간:

1. Render (렌더링)        : 1,500ms
   - 7,000개 DOM 노드 생성
   - React Virtual DOM 연산
   
2. Style Calculation       : 800ms
   - 각 옵션의 CSS 스타일 계산
   
3. Layout                  : 400ms
   - 7,000개 요소의 위치/크기 계산
   
4. Paint                   : 300ms
   - 화면에 실제로 그리기
────────────────────────────────
총 소요 시간               : 3,000ms (3초)

근본 원인

문제는 간단했습니다. 화면에 보이지도 않는 데이터를 전부 렌더링하고 있었던 것입니다.

// 기존 코드
<Autocomplete
  options={storeOptions} // 7,000개
  renderOption={(props, option) => (
    <li {...props}>
      <Checkbox checked={isSelected} />
      {option.name}
    </li>
  )}
/>

실제 상황:

  • 드롭다운 높이: 약 300px
  • 한 화면에 보이는 옵션: 약 10개
  • 실제로 렌더링하는 옵션: 7,000개

나머지 6,990개는 스크롤해야 보이는데도 미리 다 만들어두고 있었습니다.


3. 해결 방법 - 가상화(Virtualization)

가상화란?

가상화의 핵심 아이디어는 단순합니다.

"화면에 보이는 것만 실제로 렌더링하고, 나머지는 필요할 때 만든다"

개념을 그림으로 표현하면 이렇습니다:

┌─────────────────┐
│ 1. Apple     ▼  │ ← 화면에 보임 (DOM에 존재 O)
│ 2. Banana       │ ← 화면에 보임 (DOM에 존재 O)
│ 3. Cherry       │ ← 화면에 보임 (DOM에 존재 O)
├─────────────────┤ ← 스크롤 경계
│ 4. Date         │ ← 화면 밖 (DOM에 존재 X)
│ 5. Elderberry   │ ← 화면 밖 (DOM에 존재 X)
│ ...             │
│ 7000. Zucchini  │ ← 화면 밖 (DOM에 존재 X)
└─────────────────┘

실제 DOM에 생성: 10개
가상으로 존재: 6,990개

사용자가 스크롤하면 그때그때 필요한 항목만 DOM에 추가합니다.

직접 구현 vs 라이브러리

가상화를 구현하는 방법은 크게 두 가지입니다:

1. 라이브러리 사용

  • react-window: 가볍고 빠름 (5KB)
  • react-virtualized: 기능 많음 (80KB)

2. 직접 구현

  • 가장 가벼움
  • 커스터마이징 자유로움

고민 끝에 직접 구현을 선택했습니다. 이유는:

  • AutoComplete 컴포넌트와 깊이 통합해야 해서
  • 라이브러리 의존성을 줄이고 싶어서
  • 팀 내에서 원리를 이해하고 싶어서

4. 구현 - VirtualizedAutocomplete

핵심 아이디어

가상화를 구현하는 방법은 의외로 간단합니다:

1단계: 처음엔 50개만 보여주기

const [visibleItemCount, setVisibleItemCount] = useState(50);

const visibleOptions = useMemo(() => {
  return filteredOptions.slice(0, visibleItemCount);
}, [filteredOptions, visibleItemCount]);

2단계: 스크롤을 감지하기

const handleScroll = (event) => {
  const listbox = event.currentTarget;
  const { scrollTop, scrollHeight, clientHeight } = listbox;
  
  // 하단 80%에 도달하면
  const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
  if (scrollPercentage >= 0.8) {
    loadMore(); // 추가 로딩
  }
};

3단계: 50개씩 추가로 로드하기

const loadMore = () => {
  setVisibleItemCount(prev => 
    Math.min(prev + 50, filteredOptions.length)
  );
};

이게 전부입니다. 복잡해 보이지만 원리는 단순합니다.

전체 코드

실제 구현한 컴포넌트입니다:

import React, { useState, useMemo, useCallback, memo } from "react";
import { Autocomplete, TextField, Checkbox } from "@mui/material";

const VirtualizedAutocomplete = memo(({
  id,
  label,
  options,
  selectedValues,
  onSelectionChange,
  disabled,
}) => {
  // 처음엔 50개만 보여줌
  const [visibleItemCount, setVisibleItemCount] = useState(50);
  const [inputValue, setInputValue] = useState("");

  // 검색어로 필터링
  const filteredOptions = useMemo(() => {
    if (!inputValue) return options;
    
    return options.filter(option =>
      option.name.toLowerCase().includes(inputValue.toLowerCase())
    );
  }, [options, inputValue]);

  // 실제로 렌더링할 항목 (50개씩)
  const visibleOptions = useMemo(() => {
    return filteredOptions.slice(0, visibleItemCount);
  }, [filteredOptions, visibleItemCount]);

  // 선택 여부 확인을 위한 Set (빠른 조회)
  const selectedSet = useMemo(() => 
    new Set(selectedValues.map(v => v.id)),
    [selectedValues]
  );

  // 스크롤 이벤트 핸들러
  const handleScroll = useCallback((event) => {
    const listbox = event.currentTarget;
    const { scrollTop, scrollHeight, clientHeight } = listbox;
    
    // 하단 80% 도달 시 추가 로딩
    if (scrollTop + clientHeight >= scrollHeight * 0.8) {
      setVisibleItemCount(prev => 
        Math.min(prev + 50, filteredOptions.length)
      );
    }
  }, [filteredOptions.length]);

  // 검색어 변경 시 다시 50개부터 시작
  const handleInputChange = useCallback((event, newValue) => {
    setInputValue(newValue);
    setVisibleItemCount(50);
  }, []);

  // 선택 변경 핸들러
  const handleChange = useCallback((event, newValue) => {
    onSelectionChange(newValue);
  }, [onSelectionChange]);

  return (
    <Autocomplete
      id={id}
      multiple
      options={visibleOptions}
      value={selectedValues}
      onChange={handleChange}
      onInputChange={handleInputChange}
      disabled={disabled}
      disableCloseOnSelect
      getOptionLabel={(option) => option.name}
      renderOption={(props, option) => (
        <li {...props}>
          <Checkbox 
            checked={selectedSet.has(option.id)}
            style={{ marginRight: 8 }}
          />
          {option.name}
        </li>
      )}
      renderInput={(params) => (
        <TextField
          {...params}
          label={label}
          placeholder="검색..."
          helperText={`${visibleOptions.length}/${filteredOptions.length}개 로드됨`}
        />
      )}
      ListboxProps={{
        onScroll: handleScroll,
        style: { maxHeight: 300 }
      }}
    />
  );
});

코드가 길어 보이지만, 핵심은:

  1. 처음엔 50개만 보여주고
  2. 스크롤하면 50개씩 추가하고
  3. 검색하면 다시 50개부터 시작

이 세 가지입니다.


5. 추가 최적화

가상화만으로도 충분히 빨라졌지만, 몇 가지를 더 개선했습니다.

Set으로 선택 여부 확인

문제:

// 느린 방법 (O(n))
const isSelected = selectedValues.includes(option.id);
// 7,000개 배열을 매번 순회해서 확인

해결:

// 빠른 방법 (O(1))
const selectedSet = useMemo(() => 
  new Set(selectedValues.map(v => v.id)),
  [selectedValues]
);

const isSelected = selectedSet.has(option.id);
// 해시 테이블로 즉시 확인

Array의 includes()는 O(n) 시간이 걸리지만, Set의 has()는 O(1)입니다. 7,000개 데이터에서는 체감할 수 있는 차이입니다.

useCallback으로 함수 메모화

// 매 렌더링마다 새 함수를 만들지 않음
const handleScroll = useCallback((event) => {
  // ...
}, [filteredOptions.length]);

const handleChange = useCallback((event, newValue) => {
  onSelectionChange(newValue);
}, [onSelectionChange]);

함수를 메모화하면 자식 컴포넌트의 불필요한 재렌더링을 방지할 수 있습니다.

검색 디바운싱

검색어를 입력할 때마다 필터링하면 비효율적입니다. "가맹점"을 입력하면 'ㄱ', '가', '가ㅁ', '가맹', '가맹ㅈ', '가맹점' 총 6번 필터링하게 됩니다.

import { debounce } from 'lodash';

const debouncedFilter = useMemo(
  () => debounce((searchTerm) => {
    setInputValue(searchTerm);
  }, 300),
  []
);

입력이 멈춘 후 300ms 뒤에 필터링하도록 개선했습니다.


6. 성능 개선 결과

수치로 보는 개선

항목 Before After 개선율

초기 렌더링 3,000ms 200ms 15배
메모리 사용 200MB 30MB 7배
DOM 노드 수 7,000개 50개 140배
검색 속도 1,500ms 10ms 150배

Chrome DevTools로 다시 측정한 결과입니다:

Before (일반 렌더링):
├─ Render:  1,500ms
├─ Style:   800ms
├─ Layout:  400ms
└─ Paint:   300ms
   Total:   3,000ms

After (가상화):
├─ Render:  120ms
├─ Style:   50ms
├─ Layout:  20ms
└─ Paint:   10ms
   Total:   200ms 

개선율: 93.3%

7. 배운 것들

기술적으로 배운 것

"실제 데이터 규모로 테스트해야 한다"

가장 큰 교훈입니다. 개발 환경에서 500개 데이터로 테스트하고 "잘 된다.!" 하고 넘어갔다가, 운영에서 7,000개 데이터로 성능 문제를 발견했습니다. 운영의 데이터를 개발 DB에 모두 붓는 것이 어렵기에, 최대한 대용량 데이터를 다루는 상황을 가정하고 개발을 진행했는데, 제가 놓친 부분이 있었습니다.

다행히 사용자들이 문의하기 전에 바로 모니터링하면서 발견해서 빠르게 고칠 수 있었습니다.

 

앞으로는:

  • 요구사항 분석 단계에서 "최대 데이터 규모"를 꼭 확인
  • 개발 환경도 운영급 데이터로 테스트

하려 합니다. 

 

"가상화는 생각보다 간단하다"

처음엔 가상화가 어려운 개념인 줄 알았습니다. 하지만 원리는 단순합니다:

  • 보이는 것만 렌더링
  • 스크롤하면 추가 로딩
  • 그게 전부

복잡한 라이브러리 없이도 충분히 구현할 수 있었습니다.

 

"알고리즘의 중요성"

Array.includes() vs Set.has()
O(n) vs O(1)

이론으로만 배웠던 시간복잡도가 실무에서 체감되는 순간이었습니다. 7,000개 데이터에서는 작은 최적화도 큰 차이를 만듭니다.

프로세스 개선

기존 개발 프로세스:

요구사항 → 구현 → 개발 환경 테스트 → 배포 → (성능 문제 잠재)

개선된 프로세스:

요구사항 
  ↓
최대 데이터 규모 확인 
  ↓
구현
  ↓
운영급 데이터로 테스트 
  ↓
성능 프로파일링 
  ↓
배포
  ↓
배포 직후 모니터링
  ↓
안정적 운영

 

8. 마무리

숫자로 보는 성과

렌더링 시간:   3,000ms → 200ms (15배 빠름)
메모리 사용:   200MB → 30MB (7배 감소)
DOM 노드:      7,000개 → 50개 (140배 감소)

배포 후 모니터링: 즉시 문제 발견 및 해결 ✅
운영 안정성:      문제 예방 ✅
개발 프로세스:    개선 ⬆️

시리즈를 끝내며

이렇게 공지시스템 구현 시리즈가 끝났습니다:)

처음엔 공지사항을 구현하라기에, 간단한 CRUD (게시판) 이겠거니 생각했는데요.

막상 만들어보니 요구사항에 맞춰 성능을 고려하며, 사용자 경험을 최대화 하는 일은 간단하지 않더라고요.

누군가는 게시판 기능이 가장 간단하다고 말하겠지만,

기본을 제대로 하는 것이 중요하다고 생각하기에 블로그를 계기로 제가 제대로 만들었는지 되돌아볼 수 있었어요.

사소하거나 쉬운 개발은 없다는 생각도 합니다.

겸손한 마음으로 최선을 다하다보면 어제보다 나은 개발자가 되어있을거라 생각해요

긴 글 읽어주셔서 감사합니다!