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

달력의 동작 방식은 간단했다. 서버에서는 해당 월에 존재하는 공휴일과 휴무 데이터를 아래와 같이 응답으로 보냈고,
"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년 달력이 한 번에 나오는 경우, 현재 선형탐색으로는 외부 루프 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)으로 줄였다
알고리즘 공부할 때 가장 인상 깊었던 말이 성능은 결국 자료구조에서 차이가 난다는 말이었다. 알고리즘을 뭔가 뚝딱뚝딱 고도화하면 성능 차이를 낼 수 있을 거 같지만, 실질적인(어쩌면 더 간단한) 방법은 자료구조 자체를 변경하는 것이다.
결국 프론트에서 만든 날짜 배열과 서버에서 받은 날짜 배열, 두 배열을 순회하면서 비교하는 문제인데 배열이 아니라 객체를 만들어서 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을 잘 쓰자