
회사에서 이미지 등록 및 수정 양식을 구현해야 하는 일이 생겼다.
현재 나는 프로젝트에서 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 객체를 사용해야 하는게 마음에 들지 않았다.
react-hook-form 만으로 초기 이미지를 보여주려면 어떻게 해야 할까? 당연히 useForm 의 defaultValues 속성에 기본 이미지 값을 넣어주는 것이다.
하지만 위에서 봤듯이 FileList 는 생성자가 없기 때문에 사용할 수 없다. 그렇다면 무슨 값을 넣어줘야 할까?
우선 File 객체를 넣으면 서버에서 받아온 이미지를 보여줄 순 있지만, 사용자가 이미지 첨부를 하게되면 문제가 발생한다. File 과 FileList 는 타입이 다르기 때문에, 이를 다루는 로직도 달라져야 하기 때문이다.
쉽사리 해결 방법이 떠오르지 않으니 해당 문제는 제쳐두고, 역순으로 구현을 해보자.
첨부한 이미지 혹은 서버에서 받아온 이미지를 보여주기 위한 <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 분기문을 추가해야할 필요도 없다.
그러나 아직 해결해야 할 것이 남아있다.
바로 타입 에러가 발생한다. 왜냐하면 PsuedoFileList 는 FileList 에 속하는 타입이 아니기 때문이다.

[FileList 가 아니라 타입 에러가 난다는 얘기]
이를 해결하기 위해, PsuedoFileList 가 FileList 를 구현하도록 해보자.
// 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;
},
};
}
}
요 세가지 속성만 구현해주면, 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 를 만들어 서버에서 받아온 이미지도 보여주고, 사용자가 새로운 이미지를 첨부해도 잘 보여진다.

[파일 첨부 예시]

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