지난 시간에 드래그 앤 드롭 기능 구현으로 사용자가 인터페이스 내에서 직관적이고 효율적으로 아이템을 조작할 수 있게 하였다. 이러한 기능은 파일을 쉽고 빠르게 업로드하고 조직화할 수 있게 해주며, 실제 물리적인 객체를 조작하는 것과 유사한 경험을 제공한다.
이번 시간에는 드래그 앤 드롭 기능을 확장하여, 파일 프리뷰 기능을 추가함으로써 사용자 경험을 한층 더 향상시키는 방법을 탐구해보자. 파일 프리뷰는 사용자가 업로드할 파일을 미리 보고, 불필요한 업로드를 방지하여 최종 결과에 대한 확신을 줄 수 있다.
예를 들어, 이미지 파일의 경우 미리보기를 통해 사진의 내용을 확인할 수 있고, 문서의 경우 형식을 미리 볼 수 있다. 이는 사용자가 업로드 프로세스를 더 효율적으로 관리하고, 실수를 줄일 수 있는 방법을 제공한다.
Zustand를 사용하는 프로젝트인만큼 이것의 주요 장점인 설정이 간단하고, 컴포넌트 간 직관적인 전달을 파일 상태 관리에 활용해보기로 했다. 특히, 이번 프로젝트에서는 사용자가 버튼을 클릭해서 파일을 업로드하는 방법과 드래그 앤 드롭을 사용해 파일을 업로드하는 두 가지 방법을 다 지원하기 때문에 여러 컴포넌트에서 파일 데이터에 접근할 수 있게 하고, 수정해야 할 경우 중복 코드를 줄일 수 있게 하는 장점이 있다.
가장 먼저 파일 상태를 저장하고 관리하는 기본적인 zustand 스토어를 구성해보자.
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(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을 생성하고 관리하는 기능을 추가해보자.
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
함수는 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);
URL.createObjectURL(file)
메소드를 사용하여 각 파일에 대한 Blob URL을 생성한다.null
이 아닌 객체만을 필터링하여 최종적인 파일 목록을 구성합니다. 이는 타입 가드 file is IFile
를 사용하여 타입스크립트의 타입 체크를 만족시킨다.set((state) => ({
files: [...state.files, ...filesWithPreview]
}));
set
을 호출하여 현재 상태(state.files
)에 새로 생성된 파일 정보 배열(filesWithPreview
)을 추가한다. 이는 기존 파일 목록에 새로운 파일 목록을 이어붙임으로써, 애플리케이션에서 관리하는 전체 파일 목록을 최신 상태로 유지한다.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: () => {
set((state) => {
state.files.forEach((file) => URL.revokeObjectURL(file.preview)); // 모든 Blob URL 해제
return { files: [] };
});
}
state.files.forEach((file) => URL.revokeObjectURL(file.preview))
를 통해 상태에 저장된 모든 파일의 Blob URL을 반복하며 해제할 수 있다.{ files: [] }
로 설정하여 모든 파일 정보를 클리어한다.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;
지난 시간에 생성한 ChatInput 컴포넌트는 사용자의 메시지 입력과 파일 드래그 앤 드롭을 처리하며, 파일과 메시지를 전송하는 기능을 관리한다.
<HStack spacing={2}>
{files.map((file) => (
<FilePreview key={file.preview} file={file} onRemove={removeFile} />
))}
</HStack>
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
짱이네요.. 미리보기넘예뻐여