Day 44 - React(TypeScript) 기반의 동적 UI 개발, 04

이유승·2025년 2월 9일

* 프로그래머스, 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 5기 강의 수강 내용을 정리하는 포스팅.

* 원활한 내용 이해를 위해 수업에서 제시된 자료 이외에, 개인적으로 조사한 자료 등을 덧붙이고 있음.



다양한 UI 패턴

React.js + TypeScript 컴포넌트 구현 예시

1. 드롭다운 (Dropdown)

구현 원리

  • 상태 관리: 드롭다운 메뉴의 열림/닫힘 상태를 useState로 관리합니다.
  • 이벤트 처리: 버튼 클릭으로 메뉴를 토글하며, 외부 클릭 감지를 통해 자동으로 메뉴를 닫습니다.
  • 옵션 선택: 사용자가 옵션을 선택하면 부모 컴포넌트에 해당 값을 전달합니다.

코드 예시

import React, { useState, useRef, useEffect } from 'react';

interface DropdownProps {
  options: string[];
  onSelect: (option: string) => void;
}

const Dropdown: React.FC<DropdownProps> = ({ options, onSelect }) => {
  const [open, setOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);

  const handleClickOutside = (event: MouseEvent) => {
    if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
      setOpen(false);
    }
  };

  useEffect(() => {
    document.addEventListener('mousedown', handleClickOutside);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, []);

  return (
    <div ref={dropdownRef} style={{ position: 'relative', display: 'inline-block' }}>
      <button onClick={() => setOpen(!open)}>Toggle Dropdown</button>
      {open && (
        <ul
          style={{
            listStyle: 'none',
            padding: 0,
            margin: 0,
            position: 'absolute',
            background: '#fff',
            boxShadow: '0 2px 5px rgba(0,0,0,0.15)',
          }}
        >
          {options.map((option, index) => (
            <li
              key={index}
              onClick={() => {
                onSelect(option);
                setOpen(false);
              }}
              style={{ padding: '8px 12px', cursor: 'pointer' }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default Dropdown;



2. 탭 (Tabs)

구현 원리

  • 상태 관리: 활성화된 탭의 인덱스를 useState로 관리합니다.
  • 탭 전환: 각 탭 버튼 클릭 시 활성 탭을 변경하며, 이에 맞춰 콘텐츠를 렌더링합니다.
  • 스타일링: 활성 탭과 비활성 탭에 차별화된 스타일을 적용해 사용자가 현재 상태를 쉽게 인지할 수 있도록 합니다.

코드 예시

import React, { useState } from 'react';

interface Tab {
  label: string;
  content: React.ReactNode;
}

interface TabsProps {
  tabs: Tab[];
}

const Tabs: React.FC<TabsProps> = ({ tabs }) => {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <div>
      <div style={{ display: 'flex', borderBottom: '1px solid #ccc' }}>
        {tabs.map((tab, index) => (
          <button
            key={index}
            onClick={() => setActiveIndex(index)}
            style={{
              padding: '10px 20px',
              border: 'none',
              borderBottom: activeIndex === index ? '2px solid blue' : 'none',
              background: 'none',
              cursor: 'pointer',
            }}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div style={{ padding: '20px' }}>{tabs[activeIndex].content}</div>
    </div>
  );
};

export default Tabs;



3. 모달 (Modal)

구현 원리

  • 포털 사용: 모달 컴포넌트를 ReactDOM.createPortal을 사용하여 별도의 DOM 노드(예: #modal-root)에 렌더링합니다.
  • 상태 관리: 부모 컴포넌트에서 모달의 열림/닫힘 상태를 관리하며, isOpen prop으로 전달합니다.
  • 이벤트 처리: 모달 배경 클릭 또는 Escape 키 입력 시 모달을 닫습니다.

코드 예시

import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
  useEffect(() => {
    const handleEsc = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        onClose();
      }
    };
    if (isOpen) {
      document.addEventListener('keydown', handleEsc);
    }
    return () => {
      document.removeEventListener('keydown', handleEsc);
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        backgroundColor: 'rgba(0,0,0,0.5)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
      onClick={onClose}
    >
      <div
        style={{ background: '#fff', padding: '20px', borderRadius: '4px' }}
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>,
    document.getElementById('modal-root') as HTMLElement
  );
};

export default Modal;
  • 참고: 모달 포털을 사용하려면 HTML 파일에 요소가 있어야 합니다.

4. 토스트 (Toast)

구현 원리

  • 일시적 표시: 토스트 메시지는 일정 시간 후 자동으로 사라집니다.
  • 타이머 사용: useEffect를 활용해 지정된 시간(기본 3000ms) 후에 onClose를 호출합니다.
  • 고정 위치: 화면의 특정 위치(여기서는 오른쪽 하단)에 고정하여 표시합니다.

코드 예시

import React, { useEffect } from 'react';

interface ToastProps {
  message: string;
  duration?: number;
  onClose: () => void;
}

const Toast: React.FC<ToastProps> = ({ message, duration = 3000, onClose }) => {
  useEffect(() => {
    const timer = setTimeout(() => {
      onClose();
    }, duration);
    return () => clearTimeout(timer);
  }, [duration, onClose]);

  return (
    <div
      style={{
        position: 'fixed',
        bottom: '20px',
        right: '20px',
        background: '#333',
        color: '#fff',
        padding: '10px 20px',
        borderRadius: '4px',
        boxShadow: '0 2px 5px rgba(0,0,0,0.3)',
      }}
    >
      {message}
    </div>
  );
};

export default Toast;

확장 아이디어: 여러 토스트 메시지를 관리하기 위해 Toast Manager 컴포넌트를 추가로 구현할 수 있습니다.


5. 무한 스크롤 (Infinite Scroll)

구현 원리

  • Intersection Observer: 특정 요소(로더 div)가 뷰포트에 들어오면 loadMore 함수를 호출하여 추가 데이터를 불러옵니다.
  • 상태 관리: 추가 데이터가 존재하는지(hasMore) 여부를 체크하여 불필요한 호출을 방지합니다.
  • 옵저버 관리: 컴포넌트가 언마운트되거나 hasMore 상태가 변경될 때 옵저버를 해제합니다.

코드 예시

import React, { useEffect, useRef } from 'react';

interface InfiniteScrollProps {
  loadMore: () => Promise<void>;
  hasMore: boolean;
  children: React.ReactNode;
}

const InfiniteScroll: React.FC<InfiniteScrollProps> = ({ loadMore, hasMore, children }) => {
  const loader = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!loader.current || !hasMore) return;
    
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMore();
        }
      },
      { threshold: 1.0 }
    );
    
    observer.observe(loader.current);
    
    return () => {
      if (loader.current) {
        observer.unobserve(loader.current);
      }
    };
  }, [loader, loadMore, hasMore]);

  return (
    <div>
      {children}
      {hasMore && (
        <div ref={loader} style={{ height: '100px', textAlign: 'center' }}>
          Loading more...
        </div>
      )}
    </div>
  );
};

export default InfiniteScroll;

마무리 및 기대 효과

  • 각 컴포넌트는 React의 상태 관리와 이벤트 처리 메커니즘을 활용하여 재사용성과 유지보수성을 높였습니다.

  • 필요에 따라 디자인, 접근성(ARIA) 적용, 성능 최적화, 그리고 에러 핸들링을 추가하면 더욱 견고한 컴포넌트를 만들 수 있습니다.

드롭다운:

  • 외부 클릭 감지로 보다 직관적인 UX 제공

탭:

  • 명확한 상태 전환으로 사용자에게 피드백 제공

모달:

  • 포털 사용을 통해 DOM 계층 구조를 깔끔하게 관리

토스트:

  • 타이머 기반의 간결한 알림 시스템 구현

무한 스크롤:

  • Intersection Observer로 효율적인 데이터 로드를 실현



반응형 웹 (responsive web)

개요

  • 반응형 웹 디자인은 다양한 디바이스(모바일, 태블릿, 데스크탑 등)에서 일관되고 최적화된 사용자 경험을 제공하기 위해 레이아웃, 이미지, 타이포그래피 등이 자동으로 조정되는 디자인 기법입니다.
  • 주요 기법으로는 CSS 미디어 쿼리, 유연한 그리드 시스템, 플렉스박스/그리드 레이아웃 등이 있으며, React와 같이 컴포넌트 기반으로 개발할 때도 이러한 기법들을 효과적으로 적용할 수 있습니다.



왜 사용하는가?

  • 다양한 화면 크기 대응: 기기마다 화면 크기와 해상도가 달라 동일한 레이아웃이 적합하지 않음
  • 유지보수의 어려움: 각 디바이스별로 별도 레이아웃을 제공하면 코드 관리가 복잡해짐
  • 사용자 경험: 작은 화면에서 정보가 과도하게 노출되거나, 데스크탑에서는 공간 활용이 부족할 수 있음



개선 방안

  • CSS 미디어 쿼리 활용: 기본적인 레이아웃은 CSS로 구성하고, 미디어 쿼리를 통해 화면 크기에 따른 스타일 변경을 적용
  • 조건부 렌더링: React 컴포넌트 내에서 화면 크기를 감지하여 다른 컴포넌트를 렌더링하거나, 다른 스타일을 적용
  • 라이브러리 활용: react-responsive 같은 라이브러리를 사용하면 조건부 렌더링을 쉽게 구현할 수 있음



구현 방법 및 코드 예시

CSS 미디어 쿼리 기반 구현

React 컴포넌트 (ResponsiveComponent.tsx)

import React from 'react';
import './ResponsiveComponent.css';

const ResponsiveComponent: React.FC = () => {
  return (
    <div className="container">
      <div className="sidebar">Sidebar Content</div>
      <div className="main-content">Main Content</div>
    </div>
  );
};

export default ResponsiveComponent;

CSS (ResponsiveComponent.css)

.container {
  display: flex;
  flex-direction: row;
}

.sidebar {
  width: 25%;
  background-color: #f2f2f2;
  padding: 20px;
}

.main-content {
  width: 75%;
  padding: 20px;
}

/* 화면 너비가 768px 이하인 경우 레이아웃 변경 */
@media (max-width: 768px) {
  .container {
    flex-direction: column;
  }
  .sidebar,
  .main-content {
    width: 100%;
  }
}

react-responsive 라이브러리 활용

react-responsive를 사용하면 React 컴포넌트 내에서 쉽게 조건부 렌더링을 적용할 수 있습니다.

라이브러리 설치

npm install react-responsive

React 컴포넌트 (ResponsiveMedia.tsx)

import React from 'react';
import MediaQuery from 'react-responsive';

const ResponsiveMedia: React.FC = () => {
  return (
    <div>
      {/* 데스크탑 및 태블릿 가로 모드 */}
      <MediaQuery minWidth={769}>
        <div style={{ backgroundColor: '#e0f7fa', padding: '20px' }}>
          데스크탑/태블릿 레이아웃
        </div>
      </MediaQuery>
      
      {/* 모바일 화면 */}
      <MediaQuery maxWidth={768}>
        <div style={{ backgroundColor: '#ffe0b2', padding: '20px' }}>
          모바일 레이아웃
        </div>
      </MediaQuery>
    </div>
  );
};

export default ResponsiveMedia;



기대 효과

  • 일관된 사용자 경험: 모든 디바이스에서 최적화된 레이아웃 제공
  • 유지보수 용이성: CSS 미디어 쿼리와 조건부 렌더링으로 코드 관리가 단순해짐
  • 빠른 개발 및 확장성: 라이브러리 활용과 컴포넌트 기반 설계로 새로운 디바이스 대응이 용이함
profile
프론트엔드 개발자를 준비하고 있습니다.

0개의 댓글