레시피 목록

input type=file onChange 미작동 문제 해결

2025년 8월 10일 4분 읽기
디버깅

모바일과 웹 환경에서 파일 업로드 UI를 커스터마이징할 때 흔히 쓰는 패턴이 있습니다. <input type="file">은 숨겨두고 클릭 가능한 label이나 div로 파일 선택을 제어하면서, 선택된 이미지를 미리보기로 보여주다가 삭제 버튼으로 지우는 구조입니다.

UI적으로는 깔끔합니다. 코드도 짧고, 디자인도 원하는 대로 나옵니다. 그런데 이 방식에는 특정 상황에서 onChange 이벤트가 아예 동작하지 않는 문제가 숨어 있습니다.

삭제하고 같은 파일을 다시 올리면 아무 일도 일어나지 않았다

아래 같은 코드에서 이미지를 업로드한 뒤 UI상에서 삭제하고 동일한 파일을 다시 업로드하면 onChange 이벤트가 아예 발생하지 않습니다.

jsx
<input
  type="file"
  id="fileInput"
  style={{ display: 'none' }}
  onChange={handleFileChange}
/>

<label htmlFor="fileInput">이미지 선택</label>

{preview && (
  <div>
    <img src={preview} alt="미리보기" />
    <button onClick={handleRemove}>삭제</button>
  </div>
)}

핸들러에 로그를 찍어봐도 조용합니다. 파일 선택창은 정상적으로 뜨고, 파일도 고르는데, 핸들러만 호출되지 않습니다. 다른 파일을 고르면 또 잘 됩니다.

브라우저가 일부러 이벤트를 막고 있었다

브라우저는 <input type="file">의 현재 값을 value 속성에 파일 경로 형태로 저장합니다. 사용자가 파일을 선택하면 이 값이 설정되고, 다음에 파일을 선택할 때 이전 값과 비교하기 때문입니다. 동일한 경로라면 값이 변하지 않았다고 판단해서 change 이벤트 자체를 발생시키지 않습니다. 불필요한 이벤트 중복 호출을 막기 위한 설계인 거죠.

여기서 중요한 점은 미리보기를 삭제하는 것과 inputvalue를 초기화하는 것은 완전히 별개라는 사실입니다. React 상태에서 previewnull로 설정해도 input 엘리먼트 자체의 파일 참조는 그대로 남아 있기 때문입니다.

화면에서는 지워졌지만, 브라우저 입장에서는 아무것도 지워지지 않은 것입니다. 이전에 선택한 파일과 동일한 파일을 다시 선택하는 상황이 되는 거죠.

이 현상은 플랫폼마다 구체적인 기준이 조금씩 다릅니다.

플랫폼/브라우저동일 파일 재선택 시 동작비고
안드로이드 Chrome/WebView거의 항상 onChange 미발생문제 가장 자주 발생
iOS Safari메타데이터 변동 시 발생 가능카메라 촬영/갤러리 선택 시 파일이 달라질 수 있음
데스크톱 Chrome/Edge미발생표준 동작
데스크톱 Firefox미발생표준 동작

상태만이 아니라 input의 값까지 지워야 했다

UI에서 이미지를 삭제할 때 input.value를 함께 초기화하면 됩니다. value를 빈 문자열로 설정하면 브라우저가 파일 참조를 내부적으로 지우고 files 속성도 비워집니다. 이후 같은 파일을 선택하더라도 브라우저는 이전 값이 없는 상태에서 새로운 선택이 이루어진 것으로 인식해서 change 이벤트를 정상적으로 발생시키죠.

jsx
import { useRef, useState } from "react";

function ImageUploader() {
  const fileInputRef = useRef(null);
  const [preview, setPreview] = useState(null);

  function handleFileChange(e) {
    const file = e.target.files?.[0];
    if (file) {
      const url = URL.createObjectURL(file);
      setPreview(url);
    }
  }

  function handleRemove() {
    setPreview(null);
    if (fileInputRef.current) {
      fileInputRef.current.value = ""; // 핵심: 파일 값 초기화
    }
  }

  return (
    <>
      <input
        type="file"
        ref={fileInputRef}
        style={{ display: "none" }}
        onChange={handleFileChange}
      />
      <label htmlFor="fileInput">이미지 선택</label>

      {preview && (
        <div>
          <img src={preview} alt="미리보기" />
          <button onClick={handleRemove}>삭제</button>
        </div>
      )}
    </>
  );
}

위 코드처럼 새로운 파일을 업로드하기 전, 기존 input을 초기화하면 문제가 해결됩니다.


명령형 코드가 걸린다면 input을 통째로 갈아끼우는 방법도 있다

ref.current.value = ""가 직관적이지 않다고 느낀다면 다른 방법도 있습니다. React의 key prop을 변경하면 해당 컴포넌트를 아예 언마운트 후 새로 마운트합니다.

jsx
function ImageUploader() {
  const [preview, setPreview] = useState(null);
  const [inputKey, setInputKey] = useState(0);

  function handleFileChange(e) {
    const file = e.target.files?.[0];
    if (file) {
      setPreview(URL.createObjectURL(file));
    }
  }

  function handleRemove() {
    setPreview(null);
    setInputKey(prev => prev + 1); // key 변경으로 input 재생성
  }

  return (
    <>
      <input
        key={inputKey}
        type="file"
        style={{ display: "none" }}
        onChange={handleFileChange}
      />
      {/* ... */}
    </>
  );
}

key가 바뀌면 React는 이전 <input>을 DOM에서 제거하고 새 <input>을 삽입합니다. 완전히 새로운 엘리먼트라서 value를 직접 건드리지 않아도 내부 상태가 초기화되는 것입니다.

두 방법을 정리하면 이렇습니다.

방법동작 방식장점단점
ref.current.value = ""input의 value를 직접 초기화DOM 조작 최소화명령형 코드, ref 필요
key 변경input 컴포넌트를 언마운트 후 재마운트React스러운 방식매번 DOM 노드 재생성

같은 코드를 세 번째 복사할 때 훅으로 뺐다

파일 업로드 로직이 여러 곳에서 반복된다면 훅으로 분리하면 재사용하기 좋습니다.

tsx
import { useRef, useState } from 'react';

function useFileUpload() {
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [preview, setPreview] = useState<string | null>(null);
  const [file, setFile] = useState<File | null>(null);

  function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const selected = e.target.files?.[0];
    if (selected) {
      setFile(selected);
      setPreview(URL.createObjectURL(selected));
    }
  }

  function handleRemove() {
    setPreview(null);
    setFile(null);
    if (fileInputRef.current) {
      fileInputRef.current.value = '';
    }
  }

  function openFilePicker() {
    fileInputRef.current?.click();
  }

  return { fileInputRef, preview, file, handleFileChange, handleRemove, openFilePicker };
}
tsx
function ImageUploader() {
  const { fileInputRef, preview, handleFileChange, handleRemove, openFilePicker } = useFileUpload();

  return (
    <>
      <input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleFileChange} />
      <button onClick={openFilePicker}>이미지 선택</button>
      {preview && (
        <div>
          <img src={preview} alt="미리보기" />
          <button onClick={handleRemove}>삭제</button>
        </div>
      )}
    </>
  );
}

마치며

input[type=file]onChange가 특정 상황에서만 동작하지 않는 이유는 브라우저의 의도된 설계 때문입니다. "값이 변하지 않으면 이벤트를 발생시키지 않는다"는 원칙은 일반 input에도 동일하게 적용되는데, 파일 업로드에서는 "UI상 삭제"와 "실제 input 초기화"가 별개라는 점이 이를 더 헷갈리게 만듭니다.

결국 핵심은 간단합니다. 사용자가 "삭제"를 눌렀다면, React 상태와 input의 실제 값 모두를 함께 초기화해야 한다는 것입니다.

혹시 지금 파일 업로드 컴포넌트를 갖고 있다면, 같은 파일을 두 번 올려보시길 권합니다. 생각보다 조용히 지나가고 있었을지도 모릅니다.