웹에서 FileList와 react-hook-form을 이용해 이미지 입력 필드 다루기

Einere·2025년 2월 18일
0
post-thumbnail

이미지 첨부 필드 구현하기

회사에서 이미지 등록 및 수정 양식을 구현해야 하는 일이 생겼다.

현재 나는 프로젝트에서 react-hook-form 을 사용중이었다.

이미지를 서버에 업로드하는 CREATE 기능은 수월하게 구현하였다.

interface FormType {
  someImg: FileList | undefined;
}

const { register, handleSubmit } = useForm<FormType>();

<form onSubmit={handleSubmit((formValues) => {
  const filesList = formValues.someImg; // formValues 의 타입은 FileList
  const file = fileList.item(0); // file의 타입은 File

  // API를 이용해 전송
})}>
  <input type="file" {...register('someImg')} />
</form>

하지만 등록된 이미지를 서버로부터 받아와 보여주고, 사용자가 새로 업로드한 이미지를 전송해 UPDATE 하는 기능을 구현하기에는 조금 무리가 있었다.

왜냐하면, input[type="file"] 요소는 내부적으로 선택한 파일을 FileList 를 이용해 관리한다. (그래서 FormType.someImg 의 타입이 FileList 인 것이다.) 당연히 네이티브 input 요소에 등록되어 관리되는 someImg 상태의 타입도 FileList 로 관리된다.

그런데 defaultValues 속성에 기본값을 설정해주려면 FileList 인스턴스를 만들어 할당해줘야 하는데, 이 녀석은 생성자가 존재하지 않는다. 생성이 불가능한 객체라는 것이다.

const { ... } = useForm<FormType>({
  defaultValues: {
    someImg: new FileList(...); // 🚨 불가능!!
  }
});

그래서 나는 서버에서 받아온 이미지를 어떻게 보여줄까 고민하다가, 빠르게 구현하기 위해 이미지 주소를 그냥 하위 컴포넌트로 전달하여, 초기 이미지가 보여지도록 했다.

// 서버에서 받아온 이미지 URL
declare const someImgURL: string;

// 하위 컴포넌트에게 내려주기 위한 객체
const initialImg = {
  someImg: someImgURL
};

// 하위 컴포넌트에서
<img src={initialImg.someImg} />

일단 어찌 돌아가게는 만들었다. 하지만 양식 상태를 편하게 관리하려고 react-hook-form 을 도입했는데, react-hook-form 으로 관리할 수 없는 별도의 initialImg 객체를 사용해야 하는게 마음에 들지 않았다.

File 과 FileList

react-hook-form 만으로 초기 이미지를 보여주려면 어떻게 해야 할까? 당연히 useFormdefaultValues 속성에 기본 이미지 값을 넣어주는 것이다.

하지만 위에서 봤듯이 FileList 는 생성자가 없기 때문에 사용할 수 없다. 그렇다면 무슨 값을 넣어줘야 할까?

우선 File 객체를 넣으면 서버에서 받아온 이미지를 보여줄 순 있지만, 사용자가 이미지 첨부를 하게되면 문제가 발생한다. FileFileList 는 타입이 다르기 때문에, 이를 다루는 로직도 달라져야 하기 때문이다.

쉽사리 해결 방법이 떠오르지 않으니 해당 문제는 제쳐두고, 역순으로 구현을 해보자.

첨부한 이미지 혹은 서버에서 받아온 이미지를 보여주기 위한 <img/> 태그를 추가한다.

<form onSubmit={handleSubmit(...)}>
  <input type="file" {...register('someImg')} />
  {/* 여기서 이미지를 보여준다. */}
  <img src={imgSrc} />
</form>

이제 imgSrc 를 구하는 로직을 구현하자.

const imgSrc = useMemo(() => {
  // 사용자가 첨부한 사진을 최우선으로 보여준다.
  if(selectedSomeImg) { 
    return URL.createObjectURL(selectedSomeImg); 
  }
  // 첨부한 사진이 없다면 서버로부터 받은 이미지를 보여준다.
  else if(initialImg?.someImg) { 
    return initialImg.someImg; // string
  }
  // 마지막으로 기본 이미지를 보여준다.
  else { 
    return basicProfileImg; 
  }
}, [...]);

다음으로 imgSrc 를 구하는데 필요한 selectedSomeImg 를 구하는 로직을 구현해보자.

// 이 코드는 someImg 가 FileList 임을 가정하고 작성된 코드다.
const someImg = watch('someImg'); 
// 민약 defaultValues 에 File 을 지정한다면 여기서 에러가 발생할 것이다.
const selectedSomeImg = someImg?.item(0); 

그렇다면 분기처리를 해야 할까?

const someImg = watch('someImg'); 
let selectedSomeImg = null;

if(someImg instanceof FileList) {
  selectedSomeImg = someImg.item(0);
}
if(someImg instanceof File) {
  selectedSomeImg = someImg;
}

물론 위 코드처럼 분기처리를 할 순 있지만, 이런 자질구레한 분기문이 꼭 필요한걸까? 라는 의문이 들었다.

또한 상황에 따라 someImg 의 타입이 바뀌는 것은 정적 분석을 이용하는 타입 시스템에서는 알 수 없는 부분이다. (타입 시스템에서는 해당 변수의 타입이 FileList | File 이 되는 것이다. 슈뢰딩거의 고양이… 🐈📦)

지원하지 않는다면 만들어 쓰자

기분이 찜찜해 곰곰히 생각해보니, 갑자기 Array 와 유사한 ArrayLike 가 생각났다. FileList 를 사용할 수 없다면 비슷한 클래스를 구현해 사용하면 되지 않을까 싶었다.

그래서 바로 코드 작성을 해봤다.

class PseudoFileList {
  // 내부적으로 파일 배열을 관리하기 위한 필드
  #fileList: Array<File>;

  // 생성자
  constructor(files: Array<File>) {
    this.#fileList = files;
  }

  // FileList 와 동일한 메소드를 제공하여 호환성 확보
  item(index: number) {
    if (0 <= index && index < this.#fileList.length) {
      return this.#fileList[index];
    } else {
      throw new Error('[PsuedoFileList] Out of range');
    }
  }
}

PsuedoFileList 는 간단하다. Array<File> 을 필드로 가지고 있으며, item 메소드를 구현하여 특정 순번의 파일에 접근할 수 있도록 한다.

현재 코드 내에서 모든 FileList 를 사용하는 곳은 item 메소드를 통해 파일 객체를 가져오고 있기 때문에, 기존 코드를 크게 수정할 필요 없이 깔끔하고 편하게 리팩토링을 할 수 있었다.

// 서버에서 받아온 이미지 URL 로 만든 파일 객체
declare const someImgFile: File;

const { watch, formState } = useForm<FormType>({
  defaultValues: () => {
    return {
      // FileList 도 아니고 File 도 아닌, PseudoFileList 를 기본 값으로 지정!
      someImg: new PseudoFileList([someImgFile]),
    };
  },
});

const someImg = watch('someImg'); // 타입은 PseudoFileList
const selectedSomeImg = someImg.item(0); // FileList 사용하듯이 사용할 수 있다.

이제 의도한 대로 동작한다! 🎉
서버에서 받아온 이미지를 보여주기 위해 react-hook-form 외부의 값을 사용하지도 않고, 타입이 달라서 if else 분기문을 추가해야할 필요도 없다.

그러나 아직 해결해야 할 것이 남아있다.

바로 타입 에러가 발생한다. 왜냐하면 PsuedoFileListFileList 에 속하는 타입이 아니기 때문이다.

FileList 가 아니라 타입 에러가 난다는 얘기

[FileList 가 아니라 타입 에러가 난다는 얘기]

타입을 맞춰주기

이를 해결하기 위해, PsuedoFileListFileList 를 구현하도록 해보자.

// FileList 를 구현해준다.
class PseudoFileList implements FileList {
  // ...

  [index: number]: File; // numeric indexor 를 구현해준다.

  get length(): number { // length 속성도 구현해준다.
    return this.#fileList.length;
  }

  [Symbol.iterator](): IterableIterator<File> { // iterator 프로토콜을 만족하도록 해준다. 
    const fileList = this.#fileList;
    const max = fileList.length;
    let i = 0;

    return {
      next() {
        i += 1;
        return {
          value: fileList[i - 1],
          done: i - 1 === max,
        };
      },
      [Symbol.iterator]() {
        return this;
      },
    };
  }
}
  • [index: number]: File
  • length
  • [Symbol.iterator]

요 세가지 속성만 구현해주면, FileList 를 무사히 구현하게 되고, PseudoFileList 를 사용하는데 있어서 타입 미스매치 에러도 발생하지 않는다. 😎

PseudoFileList 는 인자로 string 이 아닌 File 을 받기 때문에, 서버에서 받아온 이미지 URL로 파일을 만드는 로직을 살펴보자.

// axios 를 이용해 url -> blob -> file 로 변환하는 함수
function getFileFromURL(url: string | undefined): File | undefined {
  return axios({
    url,
    method: 'GET',
    responseType: 'blob',
  }).then((result) => {
    const response = result.data;

    if (isAxiosResponse<Blob>(result)) {
      const fileName = decodeURI(getFileNameFromHTTP(url, result.headers));
      return new File([response.data], fileName);
    }

    if (response instanceof File) {
      return response;
    }
  });
}
// useQuery 를 이용해 서버 상태를 편하게 가져오자
const { data: someImgFile } = useQuery({
    queryKey: ['GET_IMAGE', 'SOME_IMG', someImgURL],
    queryFn: () => getFileFromURL(someImgURL),
  });

const { ... } = useForm<FormType>({
  defaultValues: async () => {
    return {
      // getFileFromURL 를 통해 받은 파일 객체가 truthy 하다면 초기 값으로 설정한다. 
      someImg: someImgFile ? new PseudoFileList([someImgFile]) : undefined,
    };
  },
});

axios 를 이용해 데이터를 blob 형태로 받아 File 객체를 만드는 로직인데, 실제로 돌려보면 이미지가 제대로 보이지 않는다. 파일 객체가 정상적으로 생성되기는 하지만 데이터가 깨진 것인지, <img/> 태그에 src 로 넣어주면 이미지가 제대로 보이지 않았다.

아무래도 API 응답 데이터가 조금 깨진게 원인이 아닐까 생각한다. 그래서 이것저것 다른 방법들을 시도해보다 보니 fetch 로 이미지를 가져오니 정상적으로 동작하는 것을 확인했다. (옛날에 axios 와 fetch API 가 내부적으로 동작이 조금 다르다는 얘길 들은 것 같기도 한데.. 그게 원인인걸까?)

export function getFileFromURL(url: string | undefined) {
  if (isFalsy(url)) {
    return undefined;
  }

 // fetch API 를 사용하니 해결되었다..!
  return fetch(url, {
    method: 'GET',
  })
    .then((response) => {
      return Promise.all([response.blob(), response.headers.get('content-disposition')]);
    })
    .then(([blob, contentDisposition]) => {
      return new File(
        [blob],
        decodeURI(contentDisposition ? contentDisposition : url.substring(url.lastIndexOf('/') + 1)),
      );
    });
}

요렇게하면 이미지 URL 로 File 을 만들고, PseudoFileList 를 만들어 서버에서 받아온 이미지도 보여주고, 사용자가 새로운 이미지를 첨부해도 잘 보여진다.

파일 첨부 예시

[파일 첨부 예시]

서버에서 받아온 이미지 보여주기 예시

[서버에서 받아온 이미지 보여주기 예시]

profile
웹 기술로 문제를 해결하는, 지속가능한 엔지니어를 지향합니다.

0개의 댓글