뒤로가기
React 18 Concurrent Features로 검색 UI/UX 개선하기
들어가며
최근 진행 중인 프로젝트에서 검색과 필터링 기능을 구현하면서 몇 가지 문제점을 발견했습니다
- 입력할 때마다 전체 목록을 필터링하면서 UI가 버벅거리는 현상
- 검색어 입력 중에 불필요한 상태 업데이트로 인한 성능 저하
- 필터 적용 시 즉각적인 URL 업데이트로 인한 사용자 경험 저하
이러한 문제들을 React 18에서 도입된 Concurrent Features를 활용하여 어떻게 해결했는지 공유하고자 합니다.
기존 구현의 문제점
먼저 기존 코드를 살펴보겠습니다
const HomeFilter = () => {
const [active, setActive] = useState(initialFilter);
const handleTypeClick = (filter: string) => {
setActive(filter);
// 즉시 URL 업데이트
const newUrl = formUrlQuery({
params: searchParams.toString(),
key: "filter",
value: filter.toLowerCase(),
});
router.push(newUrl);
};
// ... 렌더링 로직
};
이 구현의 주요 문제점들은
- 즉각적인 URL 업데이트: 사용자가 필터를 클릭할 때마다 즉시 URL이 업데이트되어 불필요한 라우팅이 발생
- 동기적인 상태 업데이트: 모든 상태 업데이트가 즉시 처리되어 UI 렌더링을 차단
- 검색 입력 시 성능 저하: 사용자 입력마다 전체 목록을 필터링하면서 성능 저하 발생
React 18의 Concurrent Features 소개
React 18은 이러한 문제들을 해결하기 위한 두 가지 주요 기능을 도입했습니다
1. useTransition
긴급하지 않은 상태 업데이트를 지연시킬 수 있는 훅입니다.
const [isPending, startTransition] = useTransition();
2. useDeferredValue
상태 값의 업데이트를 지연시킬 수 있는 훅입니다.
const deferredValue = useDeferredValue(value);
React 18의 Concurrent Features와 도입 배경
React 18에서 도입된 Concurrent Features는 큰 규모의 상태 업데이트를 처리할 때 발생하는 성능 문제를 해결하기 위해 설계되었습니다. 특히 프로젝트에서 마주한 문제들을 해결하기 위해 다음과 같은 사고 과정을 거쳤습니다
1. useTransition: 우선순위가 낮은 업데이트 처리
🤔 고민한 점
- URL 업데이트는 즉각적으로 필요한가?
- 사용자 경험을 해치지 않으면서 지연시킬 수 있는 작업은 무엇인가?
💡 해결 방법
const [isPending, startTransition] = useTransition();
const handleFilter = (filter: string) => {
// 1. 즉시 실행되어야 하는 UI 업데이트
setActiveFilter(filter);
// 2. 나중에 처리해도 되는 URL 업데이트
startTransition(() => {
router.push(`?filter=${filter}`);
});
};
✨ 기대 효과
- URL 업데이트로 인한 불필요한 리렌더링 방지
- 사용자 인터랙션에 대한 즉각적인 피드백 제공
isPending
상태를 통한 로딩 표시 가능
2. useDeferredValue: 비용이 큰 연산 지연
🤔 고민한 점
- 매 입력마다 전체 목록을 필터링하는 것이 필요한가?
- 사용자 입력과 결과 표시 사이의 적절한 균형점은 어디인가?
💡 해결 방법
const searchQuery = useState('');
const deferredQuery = useDeferredValue(searchQuery);
// 무거운 필터링 연산은 deferredQuery를 사용
const filteredResults = useMemo(() => {
return posts.filter(post =>
post.title.includes(deferredQuery)
);
}, [deferredQuery]);
✨ 기대 효과
- 입력 중에도 UI 반응성 유지
- 불필요한 중간 상태 계산 방지
- 최적화된 사용자 경험 제공
실시간 성능 비교
React 18 성능 비교
- 아이템 1
- 아이템 2
- 아이템 3
- 아이템 4
- 아이템 5
- 아이템 6
- 아이템 7
- 아이템 8
- 아이템 9
- 아이템 10
- 아이템 11
- 아이템 12
- 아이템 13
- 아이템 14
- 아이템 15
- 아이템 16
- 아이템 17
- 아이템 18
- 아이템 19
- 아이템 20
- 아이템 21
- 아이템 22
- 아이템 23
- 아이템 24
- 아이템 25
- 아이템 26
- 아이템 27
- 아이템 28
- 아이템 29
- 아이템 30
- 아이템 31
- 아이템 32
- 아이템 33
- 아이템 34
- 아이템 35
- 아이템 36
- 아이템 37
- 아이템 38
- 아이템 39
- 아이템 40
- 아이템 41
- 아이템 42
- 아이템 43
- 아이템 44
- 아이템 45
- 아이템 46
- 아이템 47
- 아이템 48
- 아이템 49
- 아이템 50
⚠️무거운 계산으로 인한 UI 지연 발생
개선된 구현
이제 이러한 기능들을 활용하여 어떻게 코드를 개선했는지 살펴보겠습니다.
1. 검색 컨테이너 개선
export function SearchContainer({
initialPosts,
initialFilteredPosts,
initialQuery,
initialFilter,
}: SearchContainerProps) {
const [searchQuery, setSearchQuery] = useState(initialQuery);
const [filterQuery, setFilterQuery] = useState(initialFilter);
// 검색어와 필터의 업데이트를 지연
const deferredSearchQuery = useDeferredValue(searchQuery);
const deferredFilterQuery = useDeferredValue(filterQuery);
// 필터링 로직을 메모이제이션
const filteredPosts = useMemo(() => {
if (
deferredSearchQuery === initialQuery &&
deferredFilterQuery === initialFilter
) {
return initialFilteredPosts;
}
return initialPosts.filter((post) => {
const matchesQuery = post.title
.toLowerCase()
.includes(deferredSearchQuery.toLowerCase());
const matchesFilter = deferredFilterQuery
? post.tags[0].name.toLowerCase() === deferredFilterQuery.toLowerCase()
: true;
return matchesQuery && matchesFilter;
});
}, [
deferredSearchQuery,
deferredFilterQuery,
initialPosts,
initialFilteredPosts,
initialQuery,
initialFilter,
]);
const isFiltering =
searchQuery !== deferredSearchQuery || filterQuery !== deferredFilterQuery;
return (
<>
<LocalSearch onSearch={setSearchQuery} />
<HomeFilter onFilter={setFilterQuery} />
<PostList posts={filteredPosts} isFiltering={isFiltering} />
</>
);
}
2. 필터 컴포넌트 개선
export function HomeFilter({ onFilter }: HomeFilterProps) {
const [active, setActive] = useState(currentFilter);
const [isPending, startTransition] = useTransition();
const handleFilterClick = (filter: string) => {
const newFilter = filter === active ? '' : filter;
setActive(newFilter);
onFilter(newFilter);
// URL 업데이트를 지연
startTransition(() => {
let newUrl = '';
if (newFilter) {
newUrl = formUrlQuery({
params: searchParams.toString(),
key: 'filter',
value: newFilter.toLowerCase(),
});
} else {
newUrl = removeKeysFromUrlQuery({
params: searchParams.toString(),
keysToRemove: ['filter'],
});
}
if (newUrl) {
router.push(newUrl, { scroll: false });
}
});
};
return (
<div className="mt-10 flex flex-wrap gap-3">
{filters.map((filter) => (
<Button
key={filter.value}
onClick={() => handleFilterClick(filter.value)}
disabled={isPending}
className={cn(
'transition-all duration-200',
isPending && 'opacity-70'
)}
>
{filter.name}
</Button>
))}
</div>
);
}
주요 개선 포인트
-
useDeferredValue를 활용한 검색 최적화
- 검색어 입력 시 즉시 렌더링되는 UI와 지연되어 업데이트되는 검색 결과를 분리
- 입력 중에도 UI가 반응적으로 유지됨
-
useTransition을 활용한 URL 업데이트 최적화
- 필터 변경 시 URL 업데이트를 낮은 우선순위로 처리
- 사용자 경험을 방해하지 않는 부드러운 전환 효과
-
로딩 상태 표시 개선
isPending
상태를 활용한 시각적 피드백- 사용자가 현재 진행 중인 작업을 인지할 수 있도록 함
결론
React 18의 Concurrent Features를 활용함으로써
- 성능 향상: 불필요한 렌더링 감소와 우선순위 기반 업데이트로 전반적인 성능 개선
- UX 개선: 부드러운 전환과 적절한 로딩 표시로 사용자 경험 향상
- 코드 품질: 상태 업데이트의 우선순위를 명확히 하여 코드의 의도가 더 명확해짐
이러한 개선은 단순히 기능적인 측면뿐만 아니라, 사용자가 체감하는 앱의 품질을 한 단계 높이는 결과를 가져왔습니다.
참고자료
0