달력 컴포넌트 성능 최적화, 알고리즘과 자료구조

김동하·2026년 1월 5일

업무

목록 보기
3/6

개요

이커머스 관련 회사에서 일할 때, 어드민에서 배송일자를 관리하는 페이지에 달력을 추가해야 했다. 요구 사항은 두 가지였다.

  • 사용자가 직접 달력에 '휴무'나 '배송 가능'을 지정할 수 있어야 하고
  • 해당 날짜가 '공휴일', '휴무', '마감'의 경우, 다른 컬러의 UI가 보여야 한다

달력의 동작 방식은 간단했다. 서버에서는 해당 월에 존재하는 공휴일과 휴무 데이터를 아래와 같이 응답으로 보냈고,

  "data": [
        {
            "dayOffDate": "2026-01-05",
            "dayOfWeek": "목",
            "dayOffType": [
                "휴무"
            ]
        },
        {
            "dayOffDate": "2026-01-13",
            "dayOfWeek": "금",
            "dayOffType": [
                "휴무",
                "마감"
            ]
        },
        {
            "dayOffDate": "2026-01-16",
            "dayOfWeek": "월",
            "dayOffType": [
                "공휴일",
                "휴무"
            ]
        },

    ],

프론트에서는 달력 UI를 만들고, 서버 데이터를 순회하여 dayOffType공휴일이거나 휴무일 때 dayOffDate의 날짜에 렌더링 되는 텍스트를 다른 컬러로 변경하면 되었다.

문제

처음엔 단순히 find를 사용한 선형 탐색을 생각했다. 프론트에서 달력을 렌더링할 때, 서버에서 받은 날짜 === 프론트에 있는 날짜면 해당 날짜에 dayOffType 배열을 순회하면서 다른 컬러의 UI를 보여준다.

const CalendarDay = () => {
   // ... 
 
   const dayMetaInfo = dayList?.find(item => item.dayOffDate === date) || null;
 
   // ... 
 
   return (
     <Day>
     // ...
       {dayMetaInfo.map((day, dayIndex) => (
           <Fragment key={dayIndex}>
             <div
               className={cn('day-on-calendar', {
                 'closed-day': isEqual(day, '마감'),
                 'holiday-day': isEqual(day, '공휴일'),
                 'day-off-day': isEqual(day, '휴무'),
               })}
               onClick={() => {
                 if (isEqual(day, '마감') || isEqual(day, '공휴일')) return;
                 onClickDay(day, '휴무');
               }}
             >
               {day}
             </div>
           </Fragment>
         ));
       }      
      // ...
     </Day>
    )
}

그런데 추가 요구 사항이 생겼다.

  • 현재는 한달짜리 달력만 나오는데, 추후에 1년 전체 달력이 한눈에 보일 수도 있다.

위 처럼 1년 달력이 한 번에 나오는 경우, 현재 선형탐색으로는 외부 루프 x 내부 루프의 시간복잡도를 가지므로 O(n × m)이다.

// 외부 루프: days.map() - n번 순회
const renderDays = useCallback(() => {
  return days.map((_day, _) => {  // n번 반복 
    return (
      <CalendarDay
        // ...
        dayList={dayList}  // 서버의 dayList 
      />
    );
  });
}, [...]);

// 내부: CalendarDay에서 find() - m번 순회
const CalendarDay = React.memo(({ dayList, date }) => {
  const dayMetaInfo = dayList?.find(item => item.dayOffDate === date) || null;  // m번 반복
  // ...
});

return <>renderDays()</> // 실제 렌더

이렇게 되면 보통 25,000회가 넘는 탐색이 실행된다.

어차피 달력은 12개월이 최대치이므로 성능 상으로 큰 영향을 미치거나 데이터가 늘수록 퍼포먼스가 나빠지진 않지만, 이러한 사소한 부분도 개선할 여지가 있다면 개선하고 싶었다.(작은 것부터 조금씩 고쳐봐야 큰 것도 고칠 수 있다 생각하기에) 또한, 어드민의 사용자(앱에서 운영을 담당하시는 분들)의 네트워크 환경이 안 좋은 경우가 종종 있는데 이러한 경우 렌더링이 느려질 수 있기에 개선을 하기로 결정했다.

이진 탐색

가장 먼저 시간 복잡도를 줄이기로 하고 고민하던 차에 서버에서 받는 데이터가 정렬되어 있다는 점에서 착안하여 이진 탐색을 떠올렸다.

이진 탐색은 정렬된 데이터를 순회할 때 O(log n)이므로 2026-02-05 날짜를 기준으로 서버 데이터를 탐색하여 dayMetaInfo을 얻을 때 이진 탐색을 사용하면 복잡도를 줄일 수 있었다.

// 서버 API 응답이 오름차순 정렬되어 온다.
{
 "data": [
   { "dayOffDate": "2026-02-05", // ... },  
   { "dayOffDate": "2026-02-13", // ... },
   { "dayOffDate": "2026-02-16", // ... },
   ...
 ]
}

또한, YYYY-MM-DD날짜 형식이 문자열 비교에 적합했다. 이진 탐색에선 상방과 하방을 정해놓고 mid를 옮겨 가며 target(여기서는 프론트에서 렌더링 중인 day)을 찾아야 하는데, 아래 처럼 문자열 비교가 가능했기에 이진 탐색도 가능했다.

"2026-02-05" < "2026-02-13" // true

그래서 대략적인 이진 탐색 코드를 구성해보면 아래와 같았다.

// binarySearch
if (midKey === target) {
  return data[mid];
} else if (target < midKey) {  // 문자열 비교가 가능하다
  high = mid - 1;  // 왼쪽으로
} else {
  low = mid + 1;   // 오른쪽으로
}

본격적인 개발에 앞서 이진 탐색이 선형 탐색에 비해 얼마나 차이를 낼 수 있을까 테스트 해보았다.

dateList = [
  "2026-02-01",
  "2026-02-02",
  "2026-02-03",
  ...
  "2026-12-31"
] // 그럴 경우는 없겠지만 복잡도 계산은 최악을 가정하므로 서버에서 365일에 대한 데이터를 받았다고 하고

target = "2026-12-31" // 하필 현재 렌더링 하려는 날짜도 2026-12-31

// find가 하는 방식
for (let i = 0; i < dateList.length; i++) {
  if (dateList[i] === target) return i
}

먼저 선형 탐색이 경우 찾는 값이 없거나 배열 마지막에 있는 경우 모두 순회해야 한다.

즉, O(N)은 365다.

반면 이진 탐색의 탐색 횟수는 9회로 획기적으로 줄어든다.

1: mid = 182, midKey = "2026-07-02"
    -> target > midKey
    -> 오른쪽으로 (low = 183)

2: mid = 273, midKey = "2026-10-01"
    -> target > midKey
    -> 오른쪽으로 (low = 274)
.
.
.

8: mid = 363, midKey = "2026-12-30"
    -> target > midKey
    -> 오른쪽으로 (low = 364)

9: mid = 364, midKey = "2026-12-31"
    -> target === midKey
    -> 찾았음!

즉, 이진 탐색의 경우 O(log N) = 9로 굉장한 이점을 볼 수 있다.

그래서 코드로 작성해보면

getKey를 인자로 받고 내부에선 mid만 사용한다. 그럴 일은 없지만 while을 돌기 때문에 만에 하나 혹시나를 대비하여 maxIterations 를 둬서 최대치를 넘게 되면 경고를 뱉게 했다.
(지피티한테 검사 받으니까 어차피 이진탐색 log₂이니까 Math.ceil(Math.log2(data.length)) + 1가 더 괜찮다고 하더라)

이제 지피티를 닦달하여 실제 얼마나 차이가 나는지 확인해보자

모두 휴무라고 가정하고

렌더링 날짜수 x dateList 크기만큼 순회한다.

이진 탐색의 경우 평균 1일 평균 dateList를 4.26번 순회하고

선형 탐색의 경우 17.71번 순회한다.

약 4배 가량 빠르다는 것을 확인할 수 있다. O(MN)을 O(M log N)으로 줄였다

Map 객체

알고리즘 공부할 때 가장 인상 깊었던 말이 성능은 결국 자료구조에서 차이가 난다는 말이었다. 알고리즘을 뭔가 뚝딱뚝딱 고도화하면 성능 차이를 낼 수 있을 거 같지만, 실질적인(어쩌면 더 간단한) 방법은 자료구조 자체를 변경하는 것이다.

결국 프론트에서 만든 날짜 배열과 서버에서 받은 날짜 배열, 두 배열을 순회하면서 비교하는 문제인데 배열이 아니라 객체를 만들어서 O(1)로 탐색하면 더 빨라질 것이다.

그럼 dateList를 Map 객체로 변경해보자

// dateList를 Map으로 변환 (useMemo로 최적화)
const dateMap = useMemo(() => {
  if (!dateList || !Array.isArray(dateList) || dateList.length === 0) {
    return new Map<string, DayOffData>();
  }
  return new Map(
    dateList.map((item: DayOffData) => [item.dayOffDate, item])
  );
}, [dateList]);

이렇게 되면 '2016-01-01', ... 날짜를 키로 Map 객체가 완성된다.

렌더링 할 때 그냥 get으로 가져오면 O(1)로 처리가 된다.

const CalendarDay = () => {
    // ... 
  
    const dayMetaInfo = dateMap.get(date) || null;
  
    // ... 
  
    return (
      <Day>
      ...

조금 빨라진 거 같기도 하고..

결론

프론트 업무를 하면서 FCP, LCP와 관련하여 브라우저 최적화 말고는 다른 최적화를 다룰 일이 없는데, 이러한 사소한 최적화부터 고민하다보면 나중에 큰 문제에 직면했을 때도 잘 해결할 수 있지 않을까 한다. 예전에 토스 기술 블로그에서 트리를 활용해서 UI 패턴을 구현한 글을 보고 실무에서도 분명 알고리즘이나 자료구조를 적재적소 활용할 수 있겠다고 생각해 그러한 부분들을 고민해보려고 한다.

까불지 말고 그냥 Map을 잘 쓰자

profile
프론트엔드 개발

0개의 댓글