[Week13] React(TypeScript) 기반의 동적 UI 개발 (9) - 04/08

Kyulee·2026년 4월 9일

TIL 

목록 보기
65/90
post-thumbnail

지난 시간에 이어 프로젝트에서 부족했던 부분을 계속해서 보완했습니다. 이번 시간에는 백엔드 API가 없을 때 대처하는 방법다양한 UI 컴포넌트 구현 과정을 정리했습니다.


1. 모킹 서버 구축 (MSW)

현재 백엔드에 리뷰 기능이 구현되지 않은 상태입니다. 프론트엔드 개발 시 백엔드 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 덕분에 백엔드 완성을 기다리지 않고 프론트엔드 개발과 테스트를 독립적으로 진행할 수 있습니다.


2. 가짜 데이터 생성 (Faker.js)

모킹 서버에서 반환할 응답 데이터는 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(),
}));

valueAsNumber 옵션

입력 폼에서 데이터를 받아올 때, 문자열이 아닌 숫자로 바로 처리해야 하는 경우가 있습니다. 이때 useForm()valueAsNumber 옵션을 사용하면 입력값을 숫자로 자동 변환해서 받아올 수 있어 편리합니다.

const { register } = useForm<{ score: number }>();

<input
  type="number"
  {...register('score', { valueAsNumber: true })}
/>

3. 다양한 UI 컴포넌트 구현

외부 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 가 실행되어 다음 페이지 데이터를 불러옵니다.

profile
안녕하세요 매일의 배움을 기록으로 자산화하는 개발자 이규현입니다 😊

0개의 댓글