반응형

file 타입의 <input> 태그와 File API를 이용해 컴퓨터에 저장된 이미지를 업로드할 수 있다. 이미지 태그 자체를 “파일선택” 버튼으로 기능하도록 할 수 있고, 라벨의 스타일을 수정하는 방식으로 “파일 선택”(파일 필드) 스타일을 변경할 수도 있다.

 

 

기본 구조


업로드한 이미지는 컴포넌트 내부 상태(image)로 관리하고, 업로드 하기 전엔 기본 프로필 사진(fallbackUrl)을 표시하도록 한다.

 

<input> 태그의 typefile로 명시하면 “파일 선택” 버튼이 표시된다. accept 속성엔 허용할 파일 유형을 .확장자 형태로 입력한다. 확장자는 대소문자를 구분하지 않는다. 여러 값을 입력할 땐 콤마 , 로 구분한다.

 

특정 타입(MIME 유형)의 모든 확장자를 허용하고 싶으면 타입/* 을 입력한다. 허용하지 않은 파일 유형은 파일 선택 창에서 선택할 수 없다. (파일 유형 참고글)  

 

  • 확장자 입력 예시 : .jpg, .png, .pdf (jpg png pdf 파일 허용)
  • 특정 타입의 모든 확장자 허용 : image/* video/* audio/*

 

기본적으로 1개 파일만 선택해서 업로드할 수 있으며 multiple 속성을 true로 설정하면 여러 파일을 업로드할 수 있다(multiple 속성을 명시하지 않으면 1개 파일만 업로드할 수 있다).

const fallbackUrl =
  'https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png';
const [image, setImage] = useState('');

const uploadHandler = () => {};

return (
  <>
    // 생략
    <img
      src={image || fallbackUrl} // 이미지를 업로드하기 전엔 fallback 이미지 표시
      alt="profile"
      className="w-28 h-28 rounded-full" // Tailwind CSS 스타일
    />
    <input
      accept="image/jpg,image/png,image/jpeg"
      type="file"
      onChange={uploadHandler}
    />
  </>
);

 

업로드 핸들러 — File 객체 / FileReader API


// DataURL 사용 예시
const uploadHandler = ({ target }) => {
  const file = target.files[0]; // File 객체에 선택한 이미지 파일 정보가 담김
  const reader = new FileReader(); // FileReader 인스턴스 생성

  // 에러없이 읽기를 마쳤을 때 onload 이벤트 호출
  reader.onload = () => {
    setImage(reader.result); // FileReader를 통해 DataURL로 읽은 결과(DataURL) 업데이트
  };

  reader.readAsDataURL(file); // FileReader API로 File 객체 읽기
};

return (
  // 생략
  <img
    src={image || fallbackUrl}
    alt="profile"
    className="w-28 h-28 rounded-full" // Tailwind CSS 스타일
  />
);

 

File 객체

💡 File 객체는 Blob 기능을 상속받아 확장된 것으로, Blob을 사용할 수 있는 곳이면 File도 사용할 수 있다.

 

“파일 선택”을 클릭해서 업로드할 파일을 선택하면 onChange에 할당한 핸들러(uploadHandler)가 호출된다. 선택한 파일 정보는 event.target.files (FileList)객체에 저장되며, 파일명, 사이즈(byte), 파일 유형 등의 정보를 볼 수 있다.

 

FileList에 담긴 File 객체(업로드한 파일)

 

1개 파일만 업로드 할 수 있으므로(multiple 속성을 안줘서) 첫번째 인덱스의 files[0] 파일 정보를 불러온다. File 객체엔 선택한 파일에 대한 정보만 있을 뿐 파일 데이터는 없다. 파일 데이터를 읽으려면 FileReader API를 이용해야 한다.

const file = event.target.files[0];

 

FileReader API

new FileReader()로 새로운 FileReader 인스턴스를 생성한다.

const reader = new FileReader();

 

FileReader는 File 객체와 Blob 형식의 데이터를 “비동기적”으로 읽을 수 있다. 파일을 읽는 방법은 4가지가 있다. 이미지 파일을 읽어서 src에 사용해야 하므로 readAsDataURL() 메서드를 이용한다.

 

readAsText(blob, [encoding]) : 지정한 인코딩(기본값 utf-8)의 텍스트 문자열로 데이터 읽기

# 이미지 파일을 텍스트로 읽으면 아래처럼 깨진 문자로 나온다
�PNG 
IHDR��Ͷz|iCCPkCGColorSpaceGenericRGB8��U]hU>���+$΃Ԧ���5��lRф���e�m�,�l�A���ݝi&3���i)>A������[�'!j��-��P��(���G��	�3����k������~��s����,[��%,�-�������:t�}�}�

 

readAsDataURL(blob) : base64로 인코딩한 DataURL로 데이터 읽기

# DataURL 예시
data:image/png;base64,iVBORw0KGgoAAAANSU...

 

  • base64로 인코딩된 문자열은 브라우저가 파싱해서 원래 데이터로 만들 수 있다
  • 주소창에 DataURL을 쳐보면 파일 내용이 표시된다
  • 즉 DataURL은 파일 정보를 주소처럼 활용하는 것
  • <img> 태그의 src 값에도 사용할 수 있다
  • URL.createObjectURL(blob)을 대신 사용할 수도 있다

 

readAsArrayBuffer(blob) : ArrayBuffer로 데이터 읽기

 

readAsArrayBuffer 메서드로 데이터를 읽으면 ArrayBuffer 객체를 반환하며, 버퍼링 처럼 데이터를 일정한 크기로 잘라서 서버로 보낼 때 사용한다.

 

ArrayBuffer 객체 예시

 

readAsBinaryString(blob) : 바이너리(이진) 형식으로 데이터 읽기

# readAsBinaryString 메서드로 데이터를 읽으면 아래처럼 이진 데이터를 반환한다
PNG
IHDRØØͶz|iCCPkCGColorSpaceGenericRGB8U]hU>›¹³+$΃Ԧ¦’þ5”´lRфÚèþe³mÜ,“l´AÉìݝi&3ãü¤i)>AÁ¨à“àÿ[Á'!j«í‹-¢´P¢ƒ(øÐúG¡Ò	ë¹3³»“¸k½ËÜùæœï~çÞsîސ¸,[–Þ%,®-åÓâ³ÇæÄÄ:tÁ}Ð}Ð-+Ž•*•&ã¿Úíï ÆÞ×ö·÷ÿgë®PGˆÝ…ج8Ê"âeþŲ]€AûÈ	×bø	Ä;lœ âõWžð²Ï™‘2ˆ_E,(ªŒþÄۈç#öZsðێ<5¨­)"ËEÉ6«šN#Ó½ƒû¶EÝkÄۃO³0}߸ö—*r–ᇟUäÜtˆ¯.i³Åÿe¹i	ñ#]»¼…r

 

abort() : 데이터 읽기 취소(onloadstart 이벤트가 발생했을 때 사용할 수 있음)

 

데이터 읽기 이벤트 (Progress Event)

readAsDataURL() 메서드 인자에 파일 객체를 추가하면 파일을 읽기 시작한다.

reader.readAsDataURL(file);

 

데이터를 성공적으로 읽었다면 onload 이벤트가 호출(fire)된다. 따라서 onload 이벤트의 핸들러를 작성해야 한다. onload 외에도 읽기 과정에서 발생하는 다양한 이벤트(Progress Event)를 사용할 수 있다.

 

  • onloadstart : 읽기 시작
  • onprogress : 읽기 도중
  • onloadend : 읽기 완료(성공/실패 여부 관계없이)
  • onload : 에러 없이 읽기 성공 — 자주 사용함 ⭐️
  • onerror : 읽기 에러 — 자주 사용함 ⭐️
  • onabort : 읽기 동작 중단

 

onloadstart 이벤트가 발생했을 때 abort() 메서드로 데이터 읽기를 취소할 수 있다.

// 데이터를 읽기 시작할 때 호출
reader.onloadstart = () => {
  reader.abort(); // 데이터 읽기 취소
};

// 성공 여부에 관계없이 데이터 읽기를 완료했을 때 호출
reader.onloadend = () => {
  console.log(reader.error); // 데이터 읽기를 취소했으므로 에러 발생
  // reader.error.code -> 에러 코드 확인
  // reader.error.name -> 에러 이름 확인
  // reader.error.message -> 에러 메시지 확인
};

 

데이터 읽기 결과 조회

데이터 읽기를 마친 결과는 reader.result에서 확인할 수 있다. 이벤트가 발생한 대상이 FileReader 이므로 event.target.result 에서 확인해도 동일하다. 데이터 읽기 도중 문제가 발생했을 땐 reader.error에서 에러 내용을 확인할 수 있다.

reader.onload = (e) => {
  setImage(reader.result); // e.target.result === reader.result
};

reader.readAsDataURL(file);

 

onload 이벤트가 발생했을 때(데이터 읽기를 성공적으로 마친 후) FileReader 객체

 

참고로 reader.readyState에서 읽기 작업에 대한 현재 상태를 확인할 수 있다.

 

  • 0(EMPTY) : 리더 생성 — FileReader 인스턴스 생성 직후
  • 1(LOADING) : 읽기 메서드 호출
  • 2(DONE) : 작업 완료 (읽기 성공/오류/중단 포함)

 

URL.createObjectURL 활용

💡 업로드한 파일을 서버로 보내지 않고 브라우저 내에서만 사용할 때 활용(미리 보기 등). 서버로 보낼땐
DataURL을 FormData 객체로 만들어서 보내면 된다(참고 링크).

 

URL.createObjectURL(blob)은 인자에 명시한 Blob(File) 객체를 가리키는 URL(DOMString)을 “동기적”으로 생성하며, DOM에서 참조할 수 있다. 아래 같은 문자열 형태로 되어 있다.

# Blob URL 예시
blob:http://localhost:3000/90e56ed1-ba85-4e19-b40c-65a4495383a0

 

Blob URL은 자신을 생성한 windowdocument(브라우저) 에서만 유효하기 때문에 다른 window에서 재활용할 수 없다. window 창이 사라지면 Blob URL도 없어진다.

 

FileReader.readAsDataURL(blob)로 생성한 DataURL 처럼, URL.createObjectURL로 만든 Blob URL(DOMString)도 주소창에 치면 파일 내용이 표시된다.

 

DataURL은 <img> 태그의 src 속성에 적용할 때마다 문자열을 파싱해서 이미지로 만들기 때문에 속도가 느리다. 반면 Blob 객체의 URL 주소는 메모리에 등록해서 사용하기 때문에 더 빠르다.

 

메모리에 추가된 Blob URL은 가비지 콜렉터가 따로 청소하지 않는다. 따라서 <img> 태그의 src 속성에 적용해서 DOM과 바인딩했다면, revokeObjectURL 메서드로 URL을 해제(폐기) 해줘야 메모리 누수를 방지할 수 있다. URL을 해제한 후에 Blob URL을 주소창에 쳐보면 아무것도 나오지 않는다.

// Blob URL 사용 예시
const [image, setImage] = useState('');
const imageRef = useRef(null);

const uploadHandler = ({ target }) => {
  const file = target.files[0]; // File 객체에 선택한 이미지 파일 정보가 담김
  const imageUrl = URL.createObjectURL(file); // File 객체에 대한 Blob URL 생성

  imageRef.current.onload = () => {
    URL.revokeObjectURL(imageUrl); // 이미지 로드를 완료하면 Blob URL 폐기
  };

  setImage(imageUrl);
  // imageRef.current.src = imageUrl -> 이미지 태그 src에 직접 할당할 수도 있음
};

return (
  // 생략
  <img
    src={image || fallbackUrl}
    ref={imageRef}
    alt="profile"
    className="w-28 h-28 rounded-full" // Tailwind CSS 스타일
  />
);

 

이미지 클릭 시 파일 선택창 표시


ref 객체를 만들어서 <input> 태그 ref 속성 값에 할당하고, <img> 태그를 <button>으로 감싼다. 버튼을 클릭했을 때 <input> 엘리먼트에 접근하여 click() 이벤트가 실행되도록 핸들러를 작성하면 된다.

const inputRef = useRef(null);
// ...생략

return (
  <>
    // ...생략
    <button
      className="w-fit" // Tailwind CSS 스타일
      type="button"
      onClick={() => inputRef.current.click()}
    >
      <img
        src={image || fallbackUrl}
        alt="profile"
        className="w-28 h-28 rounded-full" // Tailwind CSS 스타일
      />
    </button>
    <input
      accept="image/jpg,image/png,image/jpeg"
      type="file"
      ref={inputRef}
      onChange={uploadHandler}
    />
  </>
);

 

이런 방식으로 다른 엘리먼트에서 file 타입의 <input>을 핸들링할 수 있다. ref 객체를 사용하지 않고 아래처럼 Input 태그를 직접 선택 하는 방식으로 작성할 수도 있다.

document.getElementById('inputId').click(); // === inputRef.current.click()

 

파일 Input 스타일 수정


file 타입의 <input> 태그는 브라우저마다 조금씩 다른 기본 UI를 가진다. 아쉽게도 이 UI는 CSS 스타일로 변경할 수 없다. 스타일을 수정하고 싶다면 <input> 태그를 화면에서 숨기고 <label> 태그에 원하는 스타일을 정의해야 된다.

 

File 필드는 브라우저마다 조금씩 다른 기본 UI를 가진다 - 출처 Hello Inyoung

 

💡 radio 타입의 <input>은 1개만 선택 할 수 있고, checkbox 타입의 <input>은 여러 개 선택 할 수 있다. name 속성은 체크박스의 이름을 나타내며 같은 분류의 체크박스를 그룹으로 묶을 때 사용한다. React / Vue에서 특정 <input> 태그를 식별할 때 name 속성을 활용하기도 한다. form 태그로 데이터를 전송하면 <input>name, value 속성 값이 ...?name=value 형태로 전송된다(참고)

 

💡 <label> 태그와 <input> 태그를 연결하면 <label>textContent 영역만 클릭해도 <input> 체크박스를 핸들링할 수 있다. <label> 태그의 for 속성과 <input> 태그의 id 속성 값을 동일하게 입력하면 두 태그를 연결할 수 있다. 접근성을 고려해 <input><label> 태그는 같이 쓰는게 좋다. <input> 태그가 <label> 안쪽에 있다면 연결된 상태가 되므로 for, id를 입력하지 않아도 된다.

 

 

  1. Label ⇄ Input 태그 연결
    <label> 태그의 htmlFor 속성과 <input> 태그의 id 속성을 동일하게 작성하면 두 태그가 연결된다. 그럼 <label>textContent 콘텐츠 영역을 클릭해도 input 박스를 핸들링 할 수 있다.
  2. Input 태그 숨기기
    <input> 태그에 hidden 속성을 추가해서 숨겨진 필드로 정의한다. 그럼 화면에서 보이지 않는다.
  3. Label 태그에 원하는 스타일 지정
    Input 태그를 숨김 필드로 만들었으므로 "파일찾기" 버튼이 더이상 표시되지 않는다. 이제 <label> 태그에 원하는 스타일을 지정해주면 된다.

 

Tailwind Components를 살펴보면 다른 사용자가 Tailwind CSS로 이미 작성해놓은 다양한 버튼 스타일이 있어서 참고하기 좋다. 버튼과 비슷한 느낌을 줘야하므로 cursor: pointer; 스타일도 추가한다.

// input type=”file” 태그 스타일 수정 예시
// ...생략
const buttonDesign =
  'p-2 pl-5 pr-5 bg-transparent border-2 border-blue-500 text-blue-500 text-lg rounded-lg hover:bg-blue-500 hover:text-gray-100 active:translate-y-px';

return (
  // 생략
  <label
    htmlFor="input-file"
    className={classNames(buttonDesign, 'cursor-pointer')}
  >
    {image === '' ? 'UPLOAD IMAGE' : 'CHANAGE IMAGE'}{' '}
    {/* <label>의 콘텐츠 영역 */}
    <input
      hidden // 파일 필드("파일 찾기" 버튼) 숨기기
      accept="image/jpg,image/png,image/jpeg" // 모든 이미지 타입을 허용할 땐 image/*
      id="input-file"
      type="file"
      onChange={dataURLHandler}
    />
  </label>
);

 

🔍️ classNames 라이브러리를 사용해서 클래스를 더 쉽게 추가할 수 있다. 특정 변수/상태의 true false에 따라 클래스를 추가하거나, 추가하지 않을 수 있다(값이 true면 추가 / false면 추가 안함)

import classNames from 'classnames';

const btnClass = classNames(
  'p-2 pl-5 pr-5', // 기본 지정 클래스
  { 'bg-gray-100': isDarkMode }, // isDarkMode가 true로 평가되면 'bg-gray-100' 클래스 추가
);

return <button className={btnClass} />;

 

같은 파일 다시 올리기


파일 선택 → 확인 버튼을 누른 후, Input 태그 onChange 핸들러에서 받은 event.target.value 값엔 선택한 파일에 대한 가짜경로.파일명 문자열이 할당되어 있다.

console.log(event.target.value); // C:\fakepath\Profile_OpenPeeps.png

 

<img> 태그 src 속성에 할당된 이미지 state를 삭제(빈 문자열로 변경)하는 “삭제 버튼”을 구현했다고 가정해본다. ➊image1.png 업로드 → ➋삭제 → ➌image1.png 업로드를 다시 해보면 아무런 일도 일어나지 않는다. Input의 onChange 이벤트는 데이터(input.value)가 변경됐을 때만 동작하기 때문이다.

 

image1.png를 처음 업로드했을 때 C:\fakepath\image1.png 값이 input.value에 할당된 상태이므로, 이와 동일한 파일을 업로드해서 onChange 이벤트가 발생하지 않은 것이다.

 

같은 파일을 다시 올렸을 때도 onChange 이벤트가 발생하도록 하려면, onChange 핸들러에 input.value (event.target.value) 값을 비워주는 코드를 추가하면 된다.

const [image, setImage] = useState('');

// input 태그의 onChange 핸들러
const uploadHandler = ({ target }) => {
  const file = target.files[0];
  const reader = new FileReader();

  reader.onload = () => {
    setImage(reader.result);
  };

  reader.readAsDataURL(file);
  target.value = ''; // 같은 파일을 다시 올려도 이벤트가 발생할 수 있도록 input.value 값 초기화
};

// 이미지 삭제 버튼 onClick 핸들러
const deleteImageHandler = () => {
  setImage('');
};

 

완성 코드 — Codepen


See the Pen Preview selected image using input type="file" by ColorFilter (@colorfilter) on CodePen.

 

 

레퍼런스


 


글 수정사항은 노션 페이지에 가장 빠르게 반영됩니다. 링크를 참고해 주세요
반응형