
지난 시간에 이어 프로젝트에서 부족했던 부분을 계속해서 보완했습니다. 이번 시간에는 백엔드 API가 없을 때 대처하는 방법과 다양한 UI 컴포넌트 구현 과정을 정리했습니다.
현재 백엔드에 리뷰 기능이 구현되지 않은 상태입니다. 프론트엔드 개발 시 백엔드 API가 완성되지 않았을 때, 가짜 응답을 만들어주는 모킹(Mocking) 서버를 활용하면 개발을 끊김 없이 진행할 수 있습니다.
MSW(Mock Service Worker) 라이브러리를 사용하면 실제 브라우저의 네트워크 요청을 가로채서 우리가 정의한 모킹 API 응답을 반환하도록 만들 수 있습니다.
# MSW 라이브러리 설치
npm i msw --save-dev
# public 폴더에 서비스 워커 초기화
npx msw init public/ --save
초기화 후에는 mock/api.ts 에 필요한 API 라우팅을 작성하고, src/mock/browser.ts 에 워커를 등록해 앱 실행 시 함께 동작하도록 설정합니다.
// src/mock/api.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/reviews', () => {
return HttpResponse.json([
{ id: 1, content: '정말 좋은 책입니다!', score: 5 },
{ id: 2, content: '추천합니다.', score: 4 },
]);
}),
];
// src/mock/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './api';
export const worker = setupWorker(...handlers);
MSW 덕분에 백엔드 완성을 기다리지 않고 프론트엔드 개발과 테스트를 독립적으로 진행할 수 있습니다.
모킹 서버에서 반환할 응답 데이터는 faker.js 라이브러리를 활용해 생성합니다. 이름, 이메일, 긴 텍스트 등 실제와 유사한 형태의 다양한 가짜 데이터를 쉽게 만들 수 있습니다.
npm install @faker-js/faker --save-dev
import { faker } from '@faker-js/faker';
const mockReviews = Array.from({ length: 10 }, () => ({
id: faker.string.uuid(),
content: faker.lorem.sentences(2),
score: faker.number.int({ min: 1, max: 5 }),
userName: faker.person.fullName(),
createdAt: faker.date.recent().toISOString(),
}));
입력 폼에서 데이터를 받아올 때, 문자열이 아닌 숫자로 바로 처리해야 하는 경우가 있습니다. 이때 useForm() 의 valueAsNumber 옵션을 사용하면 입력값을 숫자로 자동 변환해서 받아올 수 있어 편리합니다.
const { register } = useForm<{ score: number }>();
<input
type="number"
{...register('score', { valueAsNumber: true })}
/>
외부 UI 라이브러리 사용을 최소화하고 리액트 기본 기능들을 활용해 여러 컴포넌트를 직접 구현했습니다.
드롭다운 메뉴의 바깥 영역을 클릭했을 때 메뉴가 닫히도록 만들려면 ref 를 활용해야 합니다. ref.current.contains() 함수를 사용하면 현재 클릭된 위치가 컴포넌트 내부인지 외부인지 식별할 수 있습니다.
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return <div ref={dropdownRef}>{/* 드롭다운 내용 */}</div>;
탭 UI의 경우, 탭을 감싸는 전체 컨테이너와 내부 아이템 컴포넌트를 분리하지 않고 하나의 파일에서 구현하면 데이터 전달과 관리가 수월해집니다.
| 컴포넌트 | 특징 |
|---|---|
| 모달 | 기존 화면 위에 새로운 창을 띄워 사용자 상호작용을 유도합니다 |
| 토스트 | 성공/실패 같은 단순 알림을 화면 구석에 잠깐 띄웠다 사라지게 합니다 |
이런 오버레이 형태의 컴포넌트들은 react-dom 에서 제공하는 createPortal 을 활용하면 좋습니다. 기존 부모 DOM 계층 구조에 갇히지 않고 최상위 요소에 직접 렌더링되도록 위치를 조정할 수 있습니다.
import { createPortal } from 'react-dom';
interface Props {
children: React.ReactNode;
onClose: () => void;
}
function Modal({ children, onClose }: Props) {
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.getElementById('modal-root') as HTMLElement,
);
}
모달이나 토스트를 열고 닫는 반복적인 상태 제어 로직은 커스텀 훅으로 분리해두면 코드 중복을 깔끔하게 제거할 수 있습니다.
// hooks/useModal.ts
function useModal() {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return { isOpen, open, close };
}
상품 목록처럼 제공할 데이터가 많을 때, 사용자가 화면 하단으로 이동하면 서버에서 데이터를 추가로 받아오는 방식입니다.
브라우저 내장 API인 IntersectionObserver 와 TanStack Query의 useInfiniteQuery 를 조합해 데이터를 연속적으로 불러오도록 구현합니다.
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['books'],
queryFn: ({ pageParam = 1 }) => fetchBooks(pageParam),
getNextPageParam: (lastPage, allPages) =>
lastPage.hasNext ? allPages.length + 1 : undefined,
});
// 하단 감지 요소에 ref 연결
const observerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (observerRef.current) observer.observe(observerRef.current);
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);
return (
<>
{/* 도서 목록 렌더링 */}
<div ref={observerRef} />
</>
);
사용자가 스크롤을 내리다 감지 요소가 뷰포트에 들어오는 순간 fetchNextPage 가 실행되어 다음 페이지 데이터를 불러옵니다.