React

[React/리액트] 파일 업로드와 미리보기 기능 구현하기

solfa 2024. 7. 28. 21:27

구현할 기능 및 문제 상황

다음과 같이 갤러리에서 이미지를 업로드하는 기능을 구현해야 했다.

각자 다른 페이지에서 두 번 씩이나 쓰이기 때문에 이미지를 등록하는 기능을 hook으로 구현해서 재사용을 할 예정이다.

구현에 앞서 이미지 등록에 필요한 개념들을 정리해보고자 한다!

 


이미지 등록 관련 개념 정리

1. Blob이란?

Blob(Binary Large Object)은 큰 파일(이미지, 비디오 등)을 다루기 위한 데이터 타입이다. 읽기 전용의 원시 데이터를 나타내며 주로 대용량 바이너리 데이터를 다루기 위해 사용된다. 파일 업로드/다운로드, 바이너리 데이터를 이미지로 생성, 미디어 파일 작업 등에 유용하다.

주요 기능은 다음과 같다.

 

1. Blob 생성

Blob() 생성자를 사용하여 배열 또는 문자열로부터 Blob을 생성할 수 있다.

const blob = new Blob(["안녕하세요"], { type: 'text/plain' });

 

2. Blob 메서드

- slice(start, end, contentType): 원본 Blob의 지정된 범위 데이터를 포함하는 새로운 Blob을 생성한다.

- size: Blob의 크기를 바이트 단위로 반환한다.

- type: Blob의 MIME 타입을 반환한다.

 

3. URL.createObjectURL()

Blob 콘텐츠에 접근할 수 있는 임시 URL을 생성한다.

파일을 업로드하기 전에 브라우저에서 파일을 미리보기 위해 URL을 생성한다. 생성된 URL은 클라이언트 측에서만 사용된다.

=> 파일 미리보기 기능을 구현할 때 사용된다!

const url = URL.createObjectURL(blob);

 

 


코드로 훅 구현하기

import { useState, useEffect } from 'react'

type UploadImage = {
  file: File
  thumbnail: string
  type: string
}


export default function useImageUploader() {
  const [selectedImage, setSelectedImage] = useState<UploadImage | null>(null)

  useEffect(() => {
    return () => {
      if (selectedImage) {
        URL.revokeObjectURL(selectedImage.thumbnail)
      }
    }
  }, [selectedImage])

  const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    const fileList = e.target.files
    if (fileList && fileList[0]) {
      const url = URL.createObjectURL(fileList[0])
      setSelectedImage({
        file: fileList[0],
        thumbnail: url,
        type: fileList[0].type.split('/')[0],
      })
    }
  }

  return {
    selectedImage,
    handleUpload,
  }
}

 

타입 설명

type UploadImage = {
  file: File;
  thumbnail: string;
  type: string;
};

 

  • file: 사용자가 업로드한 파일 자체를 나타낸다.
  • thumbnail: 파일의 썸네일 URL! 미리보기에 사용된다. URL.createObjectURL을 통해 생성된 URL!
  • type: 파일의 MIME 타입으로, image/jpeg, image/png 등 파일의 형식을 나타낸다.

 

 

  1.  

 

코드 설명

const [selectedImage, setSelectedImage] = useState<UploadImage | null>(null)

 

 

useState를 사용해 업로드된 이미지 파일을 저장한다.

 

  useEffect(() => {
    return () => {
      if (selectedImage) {
        URL.revokeObjectURL(selectedImage.thumbnail)
      }
    }
  }, [selectedImage])

 

useEffect 사용 이유

React 컴포넌트에서 URL.createObjectURL를 사용하여 파일 미리보기를 구현할 때 useEffect를 사용했다.

URL.createObjectURL를 호출할 때마다 고유한 객체 URL이 생성되기 때문에 이런 방식은 메모리를 소비하게 된다. 따라서 메모리 누수를 방지하기 위해서 생성된 URL을 해제해주는 부분을 추가했다. selectedImage가 변경될 때만 객체 URL을 생성하도록!

관련 문서는 다음과 같다.

 

MDN 문서에서도 이렇게 설명하고 있다.

Memory management

Each time you call createObjectURL(), a new object URL is created, even if you've already created one for the same object. Each of these must be released by calling URL.revokeObjectURL() when you no longer need them.

 

  const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    const fileList = e.target.files
    if (fileList && fileList[0]) {
      const url = URL.createObjectURL(fileList[0])
      setSelectedImage({
        file: fileList[0],
        thumbnail: url,
        type: fileList[0].type.split('/')[0],
      })
    }
  }

 

 

이 코드를 자세히 설명하면 다음과 같다.

const fileList = e.target.files;

 

 

파일 입력 요소에서 선택된 파일 목록을 가져온다.

fileList는 FileList 객체로, 선택된 파일들을 포함한다.

if (fileList && fileList[0]) {

 

 

 

 

파일 목록이 존재하고 첫 번째 파일이 있는지 확인한다.

 

const url = URL.createObjectURL(fileList[0]);

 

URL.createObjectURL 메서드를 사용하여 파일의 임시 URL을 생성한다.

파일의 미리보기를 위해 사용한다!

setSelectedImage({
  file: fileList[0],
  thumbnail: url,
  type: fileList[0].type.split('/')[0],
});

 

setSelectedImage를 사용하여 selectedImage 상태를 업데이트한다.

type: fileList[0].type.split('/')[0],

 

 

파일 형식들이 대부분 image/jpeg, image/png와 같이 슬래시(/)를 기준으로 구분이 되기 때문에 

MIME 타입의 첫 번째 부분인 주요 파일 형식을 명확하게 추출해주는 부분이다!

 


페이지에서 훅 사용하기

import { useRef } from 'react'
import useImageUploader from '../../hooks/useImageUpload'

export default function FirstRegisterStoreInfo() {
  const navigate = useNavigate()
  const fileInputRef = useRef<HTMLInputElement>(null)
  const { selectedImage, handleUpload } = useImageUploader()
  }

  return (
    <StyledContainer>
            ...
          <StyledSection>
            <StyledLabel>가게 배너 사진 등록</StyledLabel>
            <StyledUploadBox onClick={() => fileInputRef.current?.click()}>
              {selectedImage ? (
                <img
                  src={selectedImage.thumbnail}
                  alt="Preview"
                  style={{ width: '100%', height: '100%', objectFit: 'cover' }}
                />
              ) : (
                <>
                  <StyledUploadImg src={UploadImg} alt="업로드 아이콘" />
                  <StyledUploadText>
                    갤러리에서 이미지 등록하기
                  </StyledUploadText>
                </>
              )}
            </StyledUploadBox>
            <input
              type="file"
              ref={fileInputRef}
              style={{ display: 'none' }}
              onChange={handleUpload}
            />
          </StyledSection>
            ...
    </StyledContainer>
  )
}

 

import useImageUploader from '../../hooks/useImageUpload';
const { selectedImage, handleUpload } = useImageUploader();

 

useImageUploader 훅을 불러와서 selectedImage와 handleUpload 함수를 사용한다.

 

const fileInputRef = useRef<HTMLInputElement>(null);

 

 

useRef를 사용하여 파일 입력 요소에 접근한다. 왜 useRef를 사용했는지는 

https://5ffthewall.tistory.com/111

 

[React/리액트]React에서 요소에 접근과 제어하는 방법 / useRef와 getElementByIdref 비교

위 글에서 파일 업로드를 구현하다가 궁금증이 생겼다. 대부분의 파일 업로드 코드들이 useRef를 사용해서 나도 useRef로 값을 참조했는데 useState를 써도 될 것 같다는 생각이 들었다. 그래서 직접

5ffthewall.tistory.com

이 글에서 설명한다.

<StyledUploadBox onClick={() => fileInputRef.current?.click()}>
  {selectedImage ? (
    <img
      src={selectedImage.thumbnail}
      alt="Preview"
      style={{ width: '100%', height: '100%', objectFit: 'cover' }}
    />
  ) : (
    <>
      <StyledUploadImg src={UploadImg} alt="업로드 아이콘" />
      <StyledUploadText>갤러리에서 이미지 등록하기</StyledUploadText>
    </>
  )}
</StyledUploadBox>
<input
  type="file"
  ref={fileInputRef}
  style={{ display: 'none' }}
  onChange={handleUpload}
/>

 

StyledUploadBox를 클릭하면 fileInputRef.current?.click()가 호출되어 파일 선택 창이 열린다.

파일이 선택되면 handleUpload 함수가 호출되어 selectedImage 상태가 업데이트된다.

selectedImage가 있으면 미리보기 이미지를 표시하고 없으면 기본 업로드 아이콘과 텍스트를 표시하도록 조건부 렌더링을 하였다.

 <img
      src={selectedImage.thumbnail}
      alt="Preview"
      style={{ width: '100%', height: '100%', objectFit: 'cover' }}
    />

 

URL.createObjectURL로 만들어두었던 미리보기 (클라이언트에서만 쓰는 url)를 img의 src에 넣어주면 된다!

참고로 서버로 url을 보낼 때는 저 url을 보내는게 아니라 바로 fileList[0]를 보내면 된다 ㅎㅎ


다른 페이지에서 같은 훅을 재사용하는 방법은 다음과 같다.

...
import useImageUploader from '../../hooks/useImageUpload'
import { useRef } from 'react'

interface RegisterMenuProps {
  index: number
  menu: {
    name: string
    price: string
  }
  onChange: (e: React.ChangeEvent<HTMLInputElement>, index: number) => void
}

export const RegisterMenu = ({ index, menu, onChange }: RegisterMenuProps) => {
  const { selectedImage, handleUpload } = useImageUploader()
  const fileInputRef = useRef<HTMLInputElement>(null)

  return (
    <StyledMenuRow>
      <StyledUploadBox onClick={() => fileInputRef.current?.click()}>
        <StyledUploadImg
          src={selectedImage ? selectedImage.thumbnail : Camera}
          alt={selectedImage ? '미리보기' : '업로드 아이콘'}
          isThumbnail={!!selectedImage}
        />
      </StyledUploadBox>
      <input
        type="file"
        ref={fileInputRef}
        style={{ display: 'none' }}
        onChange={handleUpload}
      />
      <StyledMenuInputContainer>
        <StyledMenuInput
          type="text"
          name="name"
          placeholder="메뉴 이름"
          value={menu.name}
          onChange={(e) => onChange(e, index)}
        />
        <StyledMenuInput
          type="text"
          name="price"
          placeholder="가격"
          value={menu.price}
          onChange={(e) => onChange(e, index)}
        />
      </StyledMenuInputContainer>
    </StyledMenuRow>
  )
}

 

 

이런 식으로 재사용이 가능하도록 만들 수 있다!

 


완성된 화면

 

728x90