파일 이미지 미리보기 프리뷰 기능 만들기 (React, zustand)

미키오·2024년 7월 17일
3

DizzyCode

목록 보기
4/5
post-thumbnail

1. 들어가며..

지난 시간에 드래그 앤 드롭 기능 구현으로 사용자가 인터페이스 내에서 직관적이고 효율적으로 아이템을 조작할 수 있게 하였다. 이러한 기능은 파일을 쉽고 빠르게 업로드하고 조직화할 수 있게 해주며, 실제 물리적인 객체를 조작하는 것과 유사한 경험을 제공한다.
이번 시간에는 드래그 앤 드롭 기능을 확장하여, 파일 프리뷰 기능을 추가함으로써 사용자 경험을 한층 더 향상시키는 방법을 탐구해보자. 파일 프리뷰는 사용자가 업로드할 파일을 미리 보고, 불필요한 업로드를 방지하여 최종 결과에 대한 확신을 줄 수 있다.

예를 들어, 이미지 파일의 경우 미리보기를 통해 사진의 내용을 확인할 수 있고, 문서의 경우 형식을 미리 볼 수 있다. 이는 사용자가 업로드 프로세스를 더 효율적으로 관리하고, 실수를 줄일 수 있는 방법을 제공한다.

Zustand

Zustand를 사용하는 프로젝트인만큼 이것의 주요 장점인 설정이 간단하고, 컴포넌트 간 직관적인 전달을 파일 상태 관리에 활용해보기로 했다. 특히, 이번 프로젝트에서는 사용자가 버튼을 클릭해서 파일을 업로드하는 방법과 드래그 앤 드롭을 사용해 파일을 업로드하는 두 가지 방법을 다 지원하기 때문에 여러 컴포넌트에서 파일 데이터에 접근할 수 있게 하고, 수정해야 할 경우 중복 코드를 줄일 수 있게 하는 장점이 있다.

가장 먼저 파일 상태를 저장하고 관리하는 기본적인 zustand 스토어를 구성해보자.

IFile 인터페이스

IFile 인터페이스는 개별 파일의 데이터 구조를 정의한다.

export interface IFile {
  file: File;
  // 초기 설정에서는 미리보기 URL 없이 파일 객체만 저장
}

File 객체는 파일의 메타데이터(이름, 마지막 수정된 시간, 크기 등)와 함께 바이너리 컨텐츠에 대한 접근을 제공한다.

export interface IFileState {
  files: IFile[];
  addFiles: (newFiles: File[]) => void;
  removeFile: (fileToRemove: IFile) => void;
  clearFiles: () => void;
}

const useFilesStore = create<IFileState>((set) => ({
  files: [],
  addFiles: (newFiles: File[]) => {
    const filesToAdd = newFiles.map((file) => ({
      file,
    }));
    set((state) => ({
      files: [...state.files, ...filesToAdd]
    }));
  },
  removeFile: (fileToRemove: IFile) => {
    set((state) => ({
      files: state.files.filter((file) => file.file !== fileToRemove.file)
    }));
  },
  clearFiles: () => {
    set(() => ({ files: [] }));
  },
}));

export default useFilesStore;
  • files: IFile 객체의 배열을 저장한다. 이 배열은 애플리케이션에서 관리하는 모든 파일들의 리스트를 나타낸다.
  • addFiles: 새로운 파일들을 상태에 추가하는 함수이다. 이 함수는 File[] 타입의 배열을 매개변수로 받아, 각 파일을 IFile 객체로 변환한 후 상태의 files 배열에 추가한다.
  • removeFile: 상태에서 특정 파일을 제거하는 함수이다. 이 함수는 제거하고자 하는 IFile 객체를 매개변수로 받아, 해당 파일을 files 배열에서 제외한다.
  • clearFiles: 상태에서 모든 파일을 제거하는 함수이다. 이 함수는 files 배열을 비우는 역할을 하며, 파일 업로드 필드를 초기화할 때 유용하게 사용된다.

Blob이란?

Blob(Binary Large Object)은 대용량 이진 데이터를 처리하는 데 사용되는 웹 API이다. Blob은 파일과 같은 원시 데이터를 메모리에 저장하지 않고 URL을 통해 접근할 수 있는 객체로 변환하여 브라우저에서 직접 다룰 수 있게 한다.

URL.createObjectURL()

Blob URL은 URL.createObjectURL() 메소드를 통해 생성된다. 이 메소드는 Blob 객체를 매개변수로 받아, 그 객체를 가리키는 유일한 URL을 반환한다. 생성된 URL은 브라우저의 메모리에 있는 데이터를 가리킨다.

즉, 이미지 파일을 Blob으로 변환하면, 이를 <img> , <video>태그의 src 속성에 URL 형태로 삽입할 수 있어, 브라우저에 이미지를 바로 띄울 수 있다.

URL.revokeObjectURL()

Blob URL은 브라우저의 리소스를 사용하기 때문에, 더 이상 필요하지 않을 때는 적절하게 해제해야 한다. 이를 위해 URL.revokeObjectURL() 메소드를 사용하여 Blob URL에 연결된 리소스를 해제하고, 메모리 누수를 방지할 수 있다. 예를 들어, 파일을 상태에서 제거하거나, 애플리케이션에서 파일 미리보기 목록을 클리어할 때 해당 URL을 해제하는 것이 좋다.

이제 Blob 관련 코드를 적용하여 파일의 미리보기 URL을 생성하고 관리하는 기능을 추가해보자.

IFile 인터페이스

export interface IFile {
  name: string;
  type: string;
  size: number;
  preview: string;  // Blob URL 추가
  file: File;
}

IFile 인터페이스에서 preview 속성은 위에서 생성된 Blob URL을 저장하는 역할을 한다.

이 URL을 통해 사용자가 파일을 업로드한 직후에 바로 파일의 미리보기를 볼 수 있다. 예를 들어, 이미지 파일의 경우 <img src={file.preview} />와 같이 사용하여 이미지를 화면에 표시할 수 있다.

addFiles

addFiles 함수는 File[] 타입의 newFiles 배열을 인자로 받는다. 이 배열은 사용자가 파일 입력 필드를 통해 선택한 파일들을 포함하고 있다.

const filesWithPreview = newFiles.map((file) => {
   try {
     const preview = URL.createObjectURL(file);  // Blob URL 생성
     return {
       file,
       name: file.name,
       size: file.size,
       type: file.type,
       preview,
     };
   } catch (error) {
     console.error('Failed to create URL:', file, error);
     return null;
   }
}).filter((file): file is IFile => file !== null);
  • Blob URL 생성: URL.createObjectURL(file) 메소드를 사용하여 각 파일에 대한 Blob URL을 생성한다.
  • 파일 정보 객체 생성 : 각 파일의 정보(이름, 크기, 타입, Blob URL)와 함께 객체를 생성한다.
  • 필터링: 생성된 파일 객체 중 null이 아닌 객체만을 필터링하여 최종적인 파일 목록을 구성합니다. 이는 타입 가드 file is IFile를 사용하여 타입스크립트의 타입 체크를 만족시킨다.
set((state) => ({
   files: [...state.files, ...filesWithPreview]
}));
  • 상태 업데이트 함수 set을 호출하여 현재 상태(state.files)에 새로 생성된 파일 정보 배열(filesWithPreview)을 추가한다. 이는 기존 파일 목록에 새로운 파일 목록을 이어붙임으로써, 애플리케이션에서 관리하는 전체 파일 목록을 최신 상태로 유지한다.

removeFile

removeFile 함수는 사용자가 선택한 특정 파일을 상태에서 제거할 때 사용된다.

removeFile: (fileToRemove: IFile) => {
    URL.revokeObjectURL(fileToRemove.preview);  // Blob URL 해제
    set((state) => ({
      files: state.files.filter((file) => file.preview !== fileToRemove.preview)
    }));
}

Blob URL 해제: URL.revokeObjectURL(fileToRemove.preview)를 호출하여 해당 파일의 Blob URL에 연결된 리소스를 해제한다.

상태 업데이트: set 함수를 사용하여 상태를 업데이트할 수 있다. state.files.filter((file) => file.preview !== fileToRemove.preview)를 통해 현재 상태에서 fileToRemove와 일치하지 않는 파일들만 남기고, 해당 파일을 제거한다. 파일 식별을 위해 preview URL을 사용했다.

clearFiles

clearFiles 함수는 상태에 저장된 모든 파일을 제거할 때 사용된다.

clearFiles: () => {
    set((state) => {
      state.files.forEach((file) => URL.revokeObjectURL(file.preview));  // 모든 Blob URL 해제
      return { files: [] };
    });
}
  • 모든 Blob URL 해제: state.files.forEach((file) => URL.revokeObjectURL(file.preview))를 통해 상태에 저장된 모든 파일의 Blob URL을 반복하며 해제할 수 있다.
  • 상태 초기화: 리턴 시 상태를 { files: [] }로 설정하여 모든 파일 정보를 클리어한다.

FilePreview

FilePreview 컴포넌트는 개별 파일의 미리보기를 담당한다. 이미지 파일일 경우 이미지를 표시하고, 그렇지 않은 경우 파일 아이콘과 이름을 표시한다.

import { Box, Image, Text, VStack, IconButton } from '@chakra-ui/react';
import { FaFileAlt } from 'react-icons/fa';
import { CloseIcon } from '@chakra-ui/icons';
import { IFile } from '@/types/chat';

interface FilePreviewProps {
  file: IFile;
  onRemove: (file: IFile) => void;
}

const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
  const isImage = file.type?.startsWith('image/');

  return (
    <VStack spacing={2} align={'center'} position={'relative'}>
      {isImage ? (
        <Image
          src={file.preview}
          alt={`preview of ${file.name}`}
          boxSize={'100px'}
          objectFit={'cover'}
        />
      ) : (
        <Box
          padding={2}
          borderRadius={'md'}
          bg={'gray.200'}
          boxSize={'100px'}
        >
          <FaFileAlt size={'24px'} color={'gray.600'} />
          <Text ml={2} fontSize={'sm'}>
            {file.name}
          </Text>
        </Box>
      )}
      <Text fontSize={'xs'} color={'gray.500'}>
        {file.name}
      </Text>
      <IconButton
        aria-label={'Remove file'}
        icon={<CloseIcon />}
        size={'sm'}
        position={'absolute'}
        top={1}
        right={1}
        onClick={() => onRemove(file)}
      />
    </VStack>
  );
};

export default FilePreview;
  • isImage: 파일 타입을 확인하여 이미지 파일 여부를 결정한다.
  • Image: 이미지 파일인 경우, Chakra UI의 Image 컴포넌트를 사용해 Blob URL로부터 이미지 미리보기를 표시한다.
  • Box 및 FaFileAlt: 이미지가 아닌 파일의 경우, FaFileAlt 아이콘과 파일 이름을 표시한다.
  • IconButton: 파일을 제거할 수 있는 버튼을 제공하며, 클릭 시 부모 컴포넌트의 onRemove 메소드를 호출한다.

ChatInput

지난 시간에 생성한 ChatInput 컴포넌트는 사용자의 메시지 입력과 파일 드래그 앤 드롭을 처리하며, 파일과 메시지를 전송하는 기능을 관리한다.

<HStack spacing={2}>
    {files.map((file) => (
        <FilePreview key={file.preview} file={file} onRemove={removeFile} />
    ))}
</HStack>
  • HStack: Chakra UI에서 제공하는 컴포넌트로, 자식 요소들을 가로 방향으로 정렬한다.
  • files.map(...): files 배열을 반복 처리하여 각 파일에 대한 FilePreview 컴포넌트를 생성한다. files 배열은 업로드된 파일들의 정보를 담고 있으며, useFilesStore에서 관리됩니다.
  • FilePreview 컴포넌트: file 객체와 onRemove 함수를 props로 전달받는다.
  • onRemove={removeFile}: 파일을 삭제할 때 호출할 함수이다. 이 함수는 useFilesStore 훅에서 제공되며, 지정된 파일을 상태에서 제거한다.
const ChatInput = () => {
  const { files, addFiles, removeFile, clearFiles } = useFilesStore();
  const [content, setContent] = useState('');

  const onDrop = (acceptedFiles: File[]) => {
    const newFiles = acceptedFiles.map((file) => ({
      file,
      name: file.name,
      size: file.size,
      type: file.type,
      preview: URL.createObjectURL(file),
    }));
    addFiles(newFiles);
  };

  const { getRootProps, getInputProps } = useDropzone({
    onDrop,
    noClick: true,
    noKeyboard: true,
  });

  return (
    <Box {...getRootProps()} mt={4} bg={'gray.700'}>
      <Flex alignItems={'center'} justifyContent={'center'} height={'3.2rem'}>
        <Input
          value={content}
          onChange={(e) => setContent(e.target.value)}
          onKeyPress={(e) => {
            if (e.key === 'Enter' && content.trim()) {
            }
          }}
          variant={'filled'}
          placeholder={'Type a message...'}
          bg={'gray.700'}
          color={'gray.100'}
        />
      </Flex>
      <input {...getInputProps()} style={{ display: 'none' }} />
      <HStack spacing={2}>
        {files.map((file) => (
          <FilePreview key={file.preview} file={file} onRemove={removeFile} />
        ))}
      </HStack>
    </Box>
  );
};

export default ChatInput;

완성!

오늘은 파일 상태 관리를 위한 Zustand의 사용과 파일 미리보기를 위한 Blob의 활용법을 탐구해보았다.

파일 미리보기 기능은 사용자가 업로드할 내용을 더 정확하게 파악하게 하며, 실수를 줄이고 데이터 관리를 최적화한다. 이는 단순히 버튼을 눌러서 파일을 업로드 하는 것보다 사용자 경험이 향상된다.

여전히 사용자 경험 향상을 위한 코드 구현은 필수 기능만을 구현하는 것보다 훨씬 시간도 많이 걸리고 번거롭지만 다양한 UI/UX 문제에 대한 해결책을 모색하며 우리 프로젝트의 사용자 인터페이스가 더 직관적이고 효과적으로 변하는 것을 볼 수 있어서 흥미롭다. 프런트개발 재밌다! :D

📚 Bibliography

profile
교육 전공 개발자 💻

4개의 댓글

comment-user-thumbnail
2024년 7월 18일

짱이네요.. 미리보기넘예뻐여

1개의 답글
comment-user-thumbnail
2024년 7월 20일

동영상도 해주세요

1개의 답글