Context API를 활용한 DropZone 구현하기

ant·2025년 7월 29일

react-component

목록 보기
3/3


평소처럼 Mantine의 Dropzone을 사용하고 있었는데, 문득 내부 구현이 궁금해졌습니다. 어떻게 이렇게 깔끔한 API를 제공할 수 있을까?

// Mantine Dropzone 사용 중...
<Dropzone onDrop={handleDrop}>
  <Dropzone.Idle>기본 상태</Dropzone.Idle>
  <Dropzone.Accept>허용 상태</Dropzone.Accept>
  <Dropzone.Reject>거부 상태</Dropzone.Reject>
</Dropzone>

마침 그때 디자인 팀에서 연락이 왔습니다.

[디자이너]: "파일 업로드 컴포넌트를 우리 디자인 시스템에 맞게 다시 만들어야 할 것 같아요. Mantine 스타일이 브랜드와 안 맞아서..."

[나]: "아, 그럼 Dropzone도 자체 구현해야 하는 건가요?"

[디자이너]: "네, 그리고 상태별로 다른 애니메이션이랑 아이콘도 써야 하고... 기본, 허용, 거부, 로딩 상태 모두 다르게 보여주세요!"

순간 두 가지 생각이 스쳐갔습니다. "어차피 다시 만들어야 한다면, Mantine의 내부 구현을 분석해서 더 나은 방식으로 만들어보자!"

그렇게 Mantine의 Dropzone 컴포넌트를 뜯어보다가 Context API + 컴파운드 컴포넌트 패턴의 놀라운 조합을 발견했습니다.

1부: Mantine 분석과 자체 구현의 필요성

Mantine Dropzone 분석하기

먼저 Mantine의 Dropzone이 어떻게 동작하는지 살펴봤습니다. 정말 간단한 API로 복잡한 상태 관리를 숨기고 있더군요.

// Mantine의 놀라운 API
<Dropzone onDrop={handleDrop}>
  <Dropzone.Idle>파일을 드래그하세요</Dropzone.Idle>
  <Dropzone.Accept>파일을 놓으세요</Dropzone.Accept>
  <Dropzone.Reject>지원하지 않는 파일입니다</Dropzone.Reject>
</Dropzone>

[나]: "어떻게 이렇게 간단한 API로 복잡한 상태 관리를 할 수 있지?"

자체 구현 시 고려사항

디자인 시스템에 맞게 자체 구현하면서도 Mantine의 좋은 패턴은 그대로 가져가고 싶었습니다. 하지만 처음엔 단순하게 접근했죠.

// 처음에 생각한 방식 (문제가 많음)
const DropZone = () => {
  const [dragState, setDragState] = useState<'idle' | 'accept' | 'reject'>(
    'idle',
  );
  const [loading, setLoading] = useState(false);

  const renderContent = () => {
    if (loading) {
      return <LoadingSpinner />;
    }

    if (dragState === 'accept') {
      return (
        <div>
          <CheckIcon />
          <span>파일을 놓으세요</span>
        </div>
      );
    }

    if (dragState === 'reject') {
      return (
        <div>
          <WarningIcon />
          <span>지원하지 않는 파일 형식입니다</span>
        </div>
      );
    }

    return (
      <div>
        <UploadIcon />
        <span>파일을 드래그하거나 클릭하세요</span>
      </div>
    );
  };

  return (
    <div onDragOver={handleDragOver} onDrop={handleDrop}>
      {renderContent()}
    </div>
  );
};

2부: Context API를 활용한 컴파운드 컴포넌트 설계

컴파운드 컴포넌트 패턴이란?

컴파운드 컴포넌트 패턴은 여러 컴포넌트가 협력해서 하나의 기능을 구현하는 패턴입니다. HTML의 <select><option>의 관계와 비슷해요.

// 우리가 목표로 하는 API
<DropZone onDrop={handleDrop}>
  <DropZone.Idle
    icon="Upload"
    mainMessage="파일을 드래그하거나 클릭하세요"
    supportMessage="최대 10MB, JPG, PNG, PDF 지원"
  />
  <DropZone.Accept mainMessage="파일을 놓으세요" />
  <DropZone.Reject
    mainMessage="지원하지 않는 파일입니다"
    supportMessage="JPG, PNG, PDF만 업로드 가능합니다"
  />
</DropZone>

Context API를 통한 상태 공유

핵심 아이디어는 Context API로 DropZone의 모든 상태를 중앙 관리하고, 컴파운드 컴포넌트들이 이 상태를 구독하는 것입니다.

import {
  createContext,
  useContext,
  useCallback,
  useState,
  type ReactNode,
} from 'react';

type DropzoneStatus = 'idle' | 'accept' | 'reject' | 'loading';

type DropzoneContextValue = {
  // 상태
  status: DropzoneStatus;
  files: File[];
  error: string | null;

  // 액션
  setStatus: (status: DropzoneStatus) => void;
  setFiles: (files: File[]) => void;
  setError: (error: string | null) => void;
  resetState: () => void;

  // 드래그 이벤트 핸들러
  handleDragEnter: (e: DragEvent) => void;
  handleDragLeave: (e: DragEvent) => void;
  handleDragOver: (e: DragEvent) => void;
  handleDrop: (e: DragEvent) => void;
};

const DropzoneContext = createContext<DropzoneContextValue | null>(null);

export const useDropzoneContext = () => {
  const context = useContext(DropzoneContext);
  if (!context) {
    throw new Error('Dropzone 컴포넌트 내에서만 사용할 수 있습니다.');
  }
  return context;
};

type DropzoneProviderProps = {
  children: ReactNode;
  onDrop?: (files: File[]) => void;
  accept?: string[];
  maxSize?: number;
};

export const DropzoneProvider = ({
  children,
  onDrop,
  accept = [],
  maxSize = 10 * 1024 * 1024, // 10MB
}: DropzoneProviderProps) => {
  const [status, setStatus] = useState<DropzoneStatus>('idle');
  const [files, setFiles] = useState<File[]>([]);
  const [error, setError] = useState<string | null>(null);

  const validateFiles = useCallback(
    (fileList: File[]) => {
      const validFiles: File[] = [];
      let hasError = false;

      fileList.forEach(file => {
        // 파일 타입 검증
        if (accept.length > 0) {
          const isAccepted = accept.some(acceptType => {
            if (acceptType.endsWith('/*')) {
              return file.type.startsWith(acceptType.slice(0, -1));
            }
            return file.type === acceptType;
          });

          if (!isAccepted) {
            setError(`지원하지 않는 파일 형식입니다: ${file.name}`);
            hasError = true;
            return;
          }
        }

        // 파일 크기 검증
        if (file.size > maxSize) {
          setError(`파일 크기가 너무 큽니다: ${file.name}`);
          hasError = true;
          return;
        }

        validFiles.push(file);
      });

      return { validFiles, hasError };
    },
    [accept, maxSize],
  );

  const handleDragEnter = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setStatus('accept');
    setError(null);
  }, []);

  const handleDragLeave = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setStatus('idle');
  }, []);

  const handleDragOver = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDrop = useCallback(
    (e: DragEvent) => {
      e.preventDefault();
      e.stopPropagation();

      const droppedFiles = Array.from(e.dataTransfer?.files || []);
      const { validFiles, hasError } = validateFiles(droppedFiles);

      if (hasError) {
        setStatus('reject');
        return;
      }

      setFiles(validFiles);
      setStatus('loading');
      onDrop?.(validFiles);
    },
    [validateFiles, onDrop],
  );

  const resetState = useCallback(() => {
    setStatus('idle');
    setFiles([]);
    setError(null);
  }, []);

  const value: DropzoneContextValue = {
    status,
    files,
    error,
    setStatus,
    setFiles,
    setError,
    resetState,
    handleDragEnter,
    handleDragLeave,
    handleDragOver,
    handleDrop,
  };

  return (
    <DropzoneContext.Provider value={value}>
      {children}
    </DropzoneContext.Provider>
  );
};

메인 DropZone 컴포넌트 구현

import type { ReactNode, HTMLAttributes } from 'react';
import { DropzoneProvider, useDropzoneContext } from './context';
import { Accept, Idle, Reject, Loading } from './status';
import { dropzoneContainer } from './styles';

type DropzoneProps = {
  children: ReactNode;
  onDrop?: (files: File[]) => void;
  accept?: string[];
  maxSize?: number;
  className?: string;
} & Omit<HTMLAttributes<HTMLDivElement>, 'onDrop'>;

// 내부 컨테이너 컴포넌트 - Context를 사용
const DropzoneContainer = ({
  children,
  className,
  ...restProps
}: Omit<DropzoneProps, 'onDrop' | 'accept' | 'maxSize'>) => {
  const {
    handleDragEnter,
    handleDragLeave,
    handleDragOver,
    handleDrop,
    status,
    error,
  } = useDropzoneContext();

  const { container } = dropzoneContainer({
    status,
    error: !!error,
  });

  return (
    <div
      className={`${container} ${className || ''}`}
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
      {...restProps}
    >
      {children}
    </div>
  );
};

// 메인 Dropzone 컴포넌트
const Dropzone = ({
  children,
  onDrop,
  accept,
  maxSize,
  ...containerProps
}: DropzoneProps) => {
  return (
    <DropzoneProvider onDrop={onDrop} accept={accept} maxSize={maxSize}>
      <DropzoneContainer {...containerProps}>{children}</DropzoneContainer>
    </DropzoneProvider>
  );
};

// 컴파운드 컴포넌트 연결
Dropzone.Accept = Accept;
Dropzone.Idle = Idle;
Dropzone.Reject = Reject;
Dropzone.Loading = Loading;

export default Dropzone;

각 상태별 컴포넌트 구현

Idle 상태 컴포넌트

import type { ReactNode } from 'react';
import { useDropzoneContext } from '../context';
import { statusBaseClasses } from '../styles';
import Icon from '../../Icon';

type IdleProps = {
  icon?: string;
  mainMessage: ReactNode;
  supportMessage?: ReactNode;
  children?: ReactNode;
};

const Idle = ({
  mainMessage,
  supportMessage,
  icon = 'Upload',
  children,
}: IdleProps) => {
  const { status } = useDropzoneContext();

  // idle 상태일 때만 렌더링
  if (status !== 'idle') return null;

  const { container, mainText, supportText } = statusBaseClasses({
    status: 'idle',
  });
  const IconComponent = Icon[icon as keyof typeof Icon];

  return (
    <div className={container}>
      {children || (
        <>
          <IconComponent className="dropzone-icon" />
          <span className={mainText}>{mainMessage}</span>
          {supportMessage && (
            <span className={supportText}>{supportMessage}</span>
          )}
        </>
      )}
    </div>
  );
};

export default Idle;

Accept 상태 컴포넌트

import type { ReactNode } from 'react';
import { useDropzoneContext } from '../context';
import { statusBaseClasses } from '../styles';
import Icon from '../../Icon';

type AcceptProps = {
  mainMessage: ReactNode;
  children?: ReactNode;
};

const Accept = ({ mainMessage, children }: AcceptProps) => {
  const { status } = useDropzoneContext();

  // accept 상태일 때만 렌더링
  if (status !== 'accept') return null;

  const { container, mainText } = statusBaseClasses({ status: 'accept' });

  return (
    <div className={container}>
      {children || (
        <>
          <Icon.CircleCheckFill className="dropzone-icon" />
          <span className={mainText}>{mainMessage}</span>
        </>
      )}
    </div>
  );
};

export default Accept;

Reject 상태 컴포넌트

import type { ReactNode } from 'react';
import { useDropzoneContext } from '../context';
import { statusBaseClasses } from '../styles';
import Icon from '../../Icon';

type RejectProps = {
  mainMessage: ReactNode;
  supportMessage?: ReactNode;
  children?: ReactNode;
};

const Reject = ({ mainMessage, supportMessage, children }: RejectProps) => {
  const { status, error } = useDropzoneContext();

  // reject 상태일 때만 렌더링
  if (status !== 'reject') return null;

  const { container, mainText, supportText } = statusBaseClasses({
    status: 'reject',
  });

  return (
    <div className={container}>
      {children || (
        <>
          <Icon.CircleWarningFill className="dropzone-icon" />
          <span className={mainText}>{error || mainMessage}</span>
          {supportMessage && (
            <span className={supportText}>{supportMessage}</span>
          )}
        </>
      )}
    </div>
  );
};

export default Reject;

Loading 상태 컴포넌트

import type { ReactNode } from 'react';
import { useDropzoneContext } from '../context';
import { statusBaseClasses } from '../styles';
import Loader from '../../Loader';

type LoadingProps = {
  mainMessage: ReactNode;
  supportMessage?: ReactNode;
  children?: ReactNode;
};

const Loading = ({ mainMessage, supportMessage, children }: LoadingProps) => {
  const { status } = useDropzoneContext();

  // loading 상태일 때만 렌더링
  if (status !== 'loading') return null;

  const { container, mainText, supportText } = statusBaseClasses({
    status: 'loading',
  });

  return (
    <div className={container}>
      {children || (
        <>
          <Loader />
          <span className={mainText}>{mainMessage}</span>
          {supportMessage && (
            <span className={supportText}>{supportMessage}</span>
          )}
        </>
      )}
    </div>
  );
};

export default Loading;

Context API + 컴파운드 컴포넌트의 핵심 패턴

여기서 가장 중요한 패턴은 각 컴포넌트가 Context를 구독해서 자신이 렌더링될 조건을 스스로 판단한다는 것입니다.

// 🎯 핵심 패턴: 조건부 렌더링의 분산
const Idle = ({ mainMessage, supportMessage }: IdleProps) => {
  const { status } = useDropzoneContext(); // Context 구독

  if (status !== 'idle') return null; // 자체 조건 판단

  return <div>{/* Idle UI */}</div>;
};

const Accept = ({ mainMessage }: AcceptProps) => {
  const { status } = useDropzoneContext(); // Context 구독

  if (status !== 'accept') return null; // 자체 조건 판단

  return <div>{/* Accept UI */}</div>;
};

이 패턴의 혁신적인 점

1️⃣ 중앙 집중식 상태, 분산형 렌더링

// ❌ 기존 방식: 중앙에서 모든 조건 처리
const BadDropzone = () => {
  const [status, setStatus] = useState('idle');

  const renderContent = () => {
    if (status === 'idle') return <IdleUI />;
    if (status === 'accept') return <AcceptUI />;
    if (status === 'reject') return <RejectUI />;
    if (status === 'loading') return <LoadingUI />;
  };

  return <div>{renderContent()}</div>; // 모든 판단을 여기서!
};

// ✅ 새로운 방식: 각자가 자신의 조건 판단
const GoodDropzone = ({ children }) => {
  return (
    <DropzoneProvider>
      <div>{children}</div>
      {/* 조건 판단은 각 컴포넌트가! */}
    </DropzoneProvider>
  );
};

2️⃣ 선언적 API로 의도 명확화

// 사용하는 쪽에서 보면 각 상태가 무엇을 하는지 명확
<Dropzone onDrop={handleDrop}>
  <Dropzone.Idle mainMessage="파일을 드래그하세요" supportMessage="최대 10MB" />
  <Dropzone.Accept mainMessage="파일을 놓으세요" />
  <Dropzone.Reject mainMessage="잘못된 파일입니다" />
  <Dropzone.Loading mainMessage="업로드 중..." />
</Dropzone>

이런 방식의 핵심 장점:

  1. 상태 추가 용이성: 새 상태 컴포넌트만 추가하면 됨
  2. 독립적 테스트: 각 상태별 컴포넌트를 독립적으로 테스트
  3. 코드 분할: 각 상태의 로직이 완전히 분리됨
  4. 타입 안전성: 각 상태별로 다른 props 타입 적용 가능
  5. 재사용성: 특정 상태만 필요한 곳에서 선택적 사용

3부: Panda CSS와 타입 안전성

Panda CSS를 활용한 스타일 시스템

import { sva } from '@styles/css';

export const dropzoneContainer = sva({
  slots: ['container'],
  base: {
    container: {
      position: 'relative',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      border: '2px dashed {colors.border.normal}',
      rounded: 'md',
      minHeight: 200,
      cursor: 'pointer',
      bg: 'bg.surface',
      transition: 'all 0.2s ease',

      '&:hover': {
        borderColor: 'border.hover',
        bg: 'bg.hover',
      },
    },
  },
  variants: {
    status: {
      idle: {
        container: {
          borderColor: 'border.normal',
          bg: 'bg.surface',
        },
      },
      accept: {
        container: {
          borderColor: 'status.success',
          bg: 'success.light',
          '--icon-color': '{colors.status.success}',
        },
      },
      reject: {
        container: {
          borderColor: 'status.error',
          bg: 'error.light',
          '--icon-color': '{colors.status.error}',
        },
      },
      loading: {
        container: {
          borderColor: 'primary.500',
          bg: 'primary.light',
          '--icon-color': '{colors.primary.500}',
        },
      },
    },
    error: {
      true: {
        container: {
          borderColor: 'status.error',
          bg: 'error.light',
        },
      },
    },
  },
  defaultVariants: {
    status: 'idle',
    error: false,
  },
});

export const statusBaseClasses = sva({
  slots: ['container', 'mainText', 'supportText'],
  base: {
    container: {
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      textAlign: 'center',
      gap: 2,

      '& .dropzone-icon': {
        width: 12,
        height: 12,
        fill: 'var(--icon-color, {colors.text.secondary})',
        mb: 3,
      },
    },
    mainText: {
      fontSize: 'lg',
      fontWeight: 'semibold',
      color: 'text.primary',
    },
    supportText: {
      fontSize: 'sm',
      color: 'text.secondary',
      mt: 1,
    },
  },
  variants: {
    status: {
      idle: {
        container: { color: 'text.secondary' },
      },
      accept: {
        container: { color: 'status.success' },
        mainText: { color: 'status.success' },
      },
      reject: {
        container: { color: 'status.error' },
        mainText: { color: 'status.error' },
      },
      loading: {
        container: { color: 'primary.500' },
        mainText: { color: 'primary.500' },
      },
    },
  },
});

타입 안전성 보장

export type BaseProps = {
  mainMessage: ReactNode;
  supportMessage?: ReactNode;
};

// 각 상태별 컴포넌트는 필요한 props만 받도록 제한
type IdleProps = BaseProps & {
  icon: SvgNames; // 아이콘 타입도 제한
  children?: ReactNode;
  status?: 'idle' | 'reject';
};

type AcceptProps = Omit<BaseProps, 'supportMessage'>; // supportMessage 불필요

type RejectProps = BaseProps; // 모든 메시지 필요

실제 사용법

const FileUploadPage = () => {
  const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);

  const handleDrop = async (files: File[]) => {
    console.log('업로드할 파일:', files);

    // 파일 업로드 로직
    try {
      await uploadFiles(files);
      setUploadedFiles(files);
    } catch (error) {
      console.error('업로드 실패:', error);
    }
  };

  return (
    <div className="upload-container">
      <Dropzone
        onDrop={handleDrop}
        accept={['image/*', 'application/pdf']}
        maxSize={10 * 1024 * 1024} // 10MB
        className="custom-dropzone"
      >
        <Dropzone.Idle
          icon="Upload"
          mainMessage="파일을 드래그하거나 클릭하세요"
          supportMessage="최대 10MB, JPG, PNG, PDF 지원"
        />
        <Dropzone.Accept mainMessage="파일을 놓으세요!" />
        <Dropzone.Reject
          mainMessage="지원하지 않는 파일입니다"
          supportMessage="JPG, PNG, PDF만 업로드 가능합니다"
        />
        <Dropzone.Loading
          mainMessage="파일 업로드 중..."
          supportMessage="잠시만 기다려주세요"
        />
      </Dropzone>

      {uploadedFiles.length > 0 && (
        <div className="uploaded-files">
          <h3>업로드된 파일</h3>
          {uploadedFiles.map(file => (
            <div key={file.name}>{file.name}</div>
          ))}
        </div>
      )}
    </div>
  );
};

Context 기반 고급 활용

const AdvancedFileUpload = () => {
  const handleDrop = async (files: File[]) => {
    // Context의 setStatus를 이용해서 수동 상태 제어도 가능
    try {
      await processFiles(files);
    } catch (error) {
      // 에러 발생 시 reject 상태로 수동 변경
    }
  };

  return (
    <Dropzone onDrop={handleDrop}>
      {/* 커스텀 UI도 Context 상태를 활용 가능 */}
      <CustomDropzoneStatus />

      <Dropzone.Idle mainMessage="기본 상태" />
      <Dropzone.Accept mainMessage="받을 준비 완료" />
      <Dropzone.Reject mainMessage="거부" />
      <Dropzone.Loading mainMessage="처리 중" />
    </Dropzone>
  );
};

// Context를 활용한 커스텀 컴포넌트
const CustomDropzoneStatus = () => {
  const { status, files, error } = useDropzoneContext();

  return (
    <div className="custom-status">
      현재 상태: {status}
      {files.length > 0 && <span>파일 개수: {files.length}</span>}
      {error && <span className="error">{error}</span>}
    </div>
  );
};

실무에서의 Context API 활용의 핵심

const FileUploadWithProgress = () => {
  return (
    <Dropzone onDrop={handleUpload}>
      {/* 여러 컴포넌트가 동일한 상태를 공유 */}
      <UploadProgressBar /> {/* Context에서 files 정보 구독 */}
      <FilePreviewList /> {/* Context에서 files 정보 구독 */}
      <DropzoneStatusDisplay /> {/* Context에서 status 정보 구독 */}
      {/* 기본 상태 컴포넌트들 */}
      <Dropzone.Idle mainMessage="파일 드래그" />
      <Dropzone.Accept mainMessage="놓으세요" />
      <Dropzone.Reject mainMessage="잘못된 파일" />
      <Dropzone.Loading mainMessage="업로드 중" />
    </Dropzone>
  );
};

// 모든 컴포넌트가 Context를 통해 동일한 상태에 접근
const UploadProgressBar = () => {
  const { files, status } = useDropzoneContext();

  if (status !== 'loading' || files.length === 0) return null;

  return (
    <div className="progress-bar">
      업로드 중: {files.map(f => f.name).join(', ')}
    </div>
  );
};

const FilePreviewList = () => {
  const { files } = useDropzoneContext();

  return (
    <div className="file-preview">
      {files.map(file => (
        <FilePreview key={file.name} file={file} />
      ))}
    </div>
  );
};

정리하며

Keep

  • Context API 활용: 상태를 중앙 집중식으로 관리하면서 컴포넌트 간 공유
  • 관심사 분리: 각 상태별 UI 로직이 독립적인 컴포넌트로 완벽 분리
  • 조건부 렌더링 분산: 각 컴포넌트가 자신의 표시 조건을 스스로 판단
  • 확장성: 새로운 상태 추가 시 새 컴포넌트만 만들면 됨
  • 타입 안전성: TypeScript로 각 상태에 맞는 props와 Context 타입 강제
  • 재사용성: Context 기반으로 어떤 조합이든 자유롭게 구성 가능

Problem

  • 학습 곡선: Context API + 컴파운드 컴포넌트 패턴을 팀원들이 이해해야 함
  • 초기 설계 비용: 단순한 DropZone에 비해 초기 구축 시간이 더 필요
  • Context Provider 필수: 반드시 Provider로 감싸야 하는 구조적 제약
  • 성능 고려사항: Context 값 변경 시 모든 구독 컴포넌트 리렌더링

Try

  • Context 최적화: useMemo, useCallback을 활용한 불필요한 리렌더링 방지
  • Progress Context 추가: 업로드 진행률을 별도 Context로 분리하여 성능 최적화
  • 애니메이션 통합: 상태 전환 시 자연스러운 애니메이션 효과
  • 테스트 전략: Context Provider를 활용한 독립적인 단위 테스트
  • DevTools 연동: React DevTools에서 Context 상태 추적 개선

실무에서의 Context API + 컴파운드 컴포넌트 위력

[기획자]: "이제 드래그 상태에서 파일 개수도 보여주면 안 될까요? 그리고 업로드 진행률도..."

[나]: (예전 같으면 벙쪘을 상황) "네! Context에 files 정보가 있으니까 FileCountDisplay 컴포넌트만 추가하면 됩니다!"

[기획자]: "오, 그럼 기존 상태들은 영향 없나요?"

[나]: "전혀 없어요. Context를 구독하는 새 컴포넌트만 추가하면 끝이에요!"

// 기획 변경 대응: 단순히 컴포넌트 하나만 추가
<Dropzone onDrop={handleDrop}>
  <FileCountDisplay /> {/* 새로 추가! */}
  <UploadProgressBar /> {/* 새로 추가! */}
  <Dropzone.Idle mainMessage="드래그하세요" />
  <Dropzone.Accept mainMessage="놓으세요" />
  <Dropzone.Reject mainMessage="잘못된 파일" />
  <Dropzone.Loading mainMessage="업로드 중" />
</Dropzone>;

// 새 컴포넌트는 Context만 구독하면 끝
const FileCountDisplay = () => {
  const { files } = useDropzoneContext();
  return files.length > 0 ? <span>파일 {files.length}</span> : null;
};

Context API + 컴파운드 컴포넌트 패턴의 진정한 가치

이건 단순히 코드 분리 문제가 아닙니다. 이 패턴의 핵심은:

1. 상태 공유의 투명성

  • 모든 컴포넌트가 동일한 상태를 실시간으로 공유
  • Props drilling 없이 깊은 계층의 컴포넌트도 상태 접근 가능
  • 상태 변경 시 관련된 모든 UI가 자동으로 동기화

2. 조합형 개발 방식

  • 레고 블록처럼 필요한 컴포넌트만 조합해서 사용
  • 새로운 요구사항 = 새로운 컴포넌트 추가
  • 기존 컴포넌트 수정 없이 기능 확장

이제 복잡한 상태 관리가 필요한 모든 컴포넌트에 이 패턴을 적용할 수 있습니다!

Context API의 상태 공유 능력과 컴파운드 컴포넌트의 조합형 개발 방식을 결합하면, 확장 가능하고 유지보수하기 쉬운 컴포넌트 시스템을 구축할 수 있거든요.

특히 파일 업로드처럼 복잡한 상태 변화와 다양한 UI 조합이 필요한 경우에는 이런 접근법이 정말 빛을 발합니다!

6개의 댓글

comment-user-thumbnail
2025년 8월 4일

저도 실무에서 컴파운드 컴포넌트 패턴을 적용해봤는데 사용성이 정말 좋더라구요.
자연스럽게 리팩토링되는 코드 흐름을 보면서 많이 공감했습니다. 상태 관리와 UI 분리의 강점을 다시 한번 느꼈어요!

1개의 답글
comment-user-thumbnail
2025년 8월 22일

컴파운드 컴포넌트와 Context API 조합의 장점을 정말 명확하게 정리해주셨네요! 특히 '레고 블록처럼 필요한 컴포넌트만 조합'이라는 표현이 딱 맞는 것 같아요 Context 값이 자주 변경되는 상황에서 최적화에 대한 이슈는 발생하지 않았나요? 잘 읽었습니다 :)

1개의 답글
comment-user-thumbnail
2025년 8월 23일

기존 사용하시던 Mantine의 패턴을 분석해서 더 나은 방식으로 구현한 접근법이 정말 좋네요. 컴파운드 컴포넌트 패턴의 장점을 제대로 활용하셨네요. 특히 새로운 요구사항이 들어와도 기존 컴포넌트 수정 없이 Context 구독하는 컴포넌트만 추가하면 된다는 부분이 실무에서 정말 큰 도움이 될 것 같아요.

답글 달기
comment-user-thumbnail
2025년 8월 25일

기존 코드가 어떻게 변하였는지 매끄럽게 이어져 읽기 좋았습니다! 확실이 확장이 더 필요한경우 후자의 경우가 더 용이한것 같습니다. 잘 읽고 갑니다!

답글 달기