이미지 업로드 기능 구현하기 (React, Redux-toolkit + TypeScript)

eonisal·2024년 1월 22일
0

개인 프로젝트에서 처음으로 구현해본 이미지 업로드 기능. 단순히 업로드한 이미지 파일을 서버로 보내는건 간단하지만, 내가 원하는 이미지 업로드 기능은 다음의 사항을 만족해야했다.

  • 업로드한 이미지를 화면에 미리보기로 보여주기
  • 이미지 업로드를 한 뒤, 다시 업로드를 하면 기존에 업로드했던 이미지를 유지한 채 새로 업로드한 이미지를 추가하고 미리보기로 보여주기
  • 미리보기 영역의 이미지에서 X 버튼을 클릭하여 이미지 업로드를 취소하기

구현하기 위한 로직 자체는 그렇게 어렵진 않았는데 번번이 들어오는 타입스크립트의 태클(?)때문에 어려웠다. 왜 뜨는 에러인지 원인을 파악하고 새로운 타입에 대해 알고 해결하는 방법을 찾아 해결하는 그 과정이 어려웠음

많은 자료를 찾아보고 GPT를 괴롭히면서 구현했다.

힘들게 구현했지만 정리를 안해두니 역시 그새 까먹었다. 코드 자체도 살짝 가물가물했지만 코드를 이렇게 짜게된 이유, 과정이 잘 기억이 안났다.

구현한 방법에 트러블 슈팅 과정을 핵심 부분만 추가해서 다시 상기하고 정리해두기 위해 이 글을 작성한다.

찾았던 자료들과 커밋 내역, GPT 대화 내역들을 뒤져가며 부랴부랴 다시 정리하는 이미지 업로드 기능 구현기.

역시 트러블슈팅은 귀찮아도 그때그때 정리해두자.. ^ ㅜ

📨 이미지 업로드 폼 만들기

이미지 업로드 폼을 만들어야 하는데 그냥 스타일링이라 아래의 포스팅을 참고하면 쉽게 만들 수 있는 정도니 분량상 생략

input[type="file"] 커스텀하기

🌌 이미지 업로드

업로드한 이미지 미리보기

업로드한 이미지 파일을 화면에 보여주는 방법으로는 크게 2가지 방법이 있다.

  1. FileReader 를 사용
  2. createObjectURL 을 사용

이 두 방법은 어떤 분이 잘 정리해주신 아래의 포스팅을 참고해보면 좋을듯하다.

createObjectURL을 사용해서 이미지 업로드 후 미리보기

나는 createObjectURL을 사용하여 업로드한 이미지를 blob 형태로 반환하여 이를 state에 저장하고 화면에 보여주는 방법으로 구현했다.

input 태그에 업로드한 이미지는 File 이라는 타입의 데이터이다. [input 요소].files 를 콘솔에 출력해보면 다음과 같은 모습을 볼 수 있다.

input 요소에 이미지를 한개만 업로드한 상태의 모습이다.

FileList라는 타입의 객체이고, 이 FileList 객체를 이루고있는 File 타입의 요소가 업로드한 이미지 데이터이다. 업로드한 이미지가 하나뿐이니 0번째 인덱스인 첫번째 File 요소만 나오고 length 속성도 값이 1이라고 나오고 있는걸 볼 수 있다.

이 File 타입의 이미지를 화면상에 보여주기 위해선 이 이미지 파일을 뜻하는 어떠한 경로가 필요한데, 이미지를 blob 형태로 변환하여 그 경로를 얻을 수 있다.

blob은 javascript에서 이미지, 사운드, 비디오 같은 멀티 데이터를 다룰 때 사용한다. URL.createObjectURL() 함수의 인자로 업로드한 이미지(= File 타입의 이미지 데이터)를 지정하여 이미지를 blob 형태로 변환할 수 있는데, 이미지를 blob 형태로 변환하면 다음과 같이 이 이미지를 뜻하는 url을 얻을 수 있다.

그래서 이 url을 state로 저장하고, 그 state를 순회하여 img 태그를 쓰던지 해서 화면에 보여주는 방식으로 여러장의 이미지 미리보기를 구현할 수 있다.

이미지 업로드용 input 태그에 change 이벤트 발생 시 실행되는 imagePreview 함수를 구성한다.

const [ showImages, setShowImages ] = useState<string[]>([]);  // blob 형태로 변경한 이미지 url 들을 담기 위한 배열 state

const imagePreview = (e: React.ChangeEvent<HTMLInputElement>) => {
  const imgFiles = e.target.files;  // 업로드한 이미지들을 담은 FileList 객체
  if (!imgFiles) return;
  let imgUrls = showImages;  // 기존에 업로드 했던 이미지 보존 
  for (let i = 0; i < imgFiles.length; i++) {
    if (imgUrls.length >= 4) break;  // 이미지 개수는 최대 4개
    imgUrls.push(URL.createObjectURL(imgFiles[i]));
  };
  
  setShowImages(imgUrls);
}

업로드한 이미지들을 담은 FileList 객체를 input 요소의 .files로 가져와 imgFiles에 할당한 후 imgFiles를 순회하여 각 이미지 들을 blob 형태로 변환해 imgUrls 배열에 push 한다. 이 imgUrls 배열을 blob 이미지를 담는 배열 state인 showImages로 업데이트한다.

단, 이미지를 업로드한 후에 다시 이미지를 업로드 하는 경우 이미 미리보기로 나오고 있던 이미지는 유지한 채 새로 추가한 이미지 미리보기를 추가하도록 하기 위해서, imgUrls를 기존의 showImages로 할당한 후에 이미지 blob 들을 추가하여 imgUrls로 showImages를 업데이트 해준다.

이 showImages를 순회하여 미리보기 영역에 이미지를 보여주면 된다.

업로드한 이미지 파일 다루기

미리보기 이미지는 이렇게 보여주고, imagePreview 함수에서 처음에 가져온 업로드한 이미지 파일(imgFiles)은 서버로 전송해야 한다. 나는 이미지를 비롯한 input 정보들을 담기 위한 전역 state를 다음과 같이 Redux 슬라이스로 지정했다.

// contentSlice.ts
import { PayloadAction, createSlice } from "@reduxjs/toolkit";

export interface ContentState {
  userId: string;
  reviewTitle: string;
  category: string;
  productName: string;
  productLink: string;
  productImages: FileList | null;
  reviewContent: string;
  grade: 0 | 1 | 2 | 3 | 4 | 5;
}

const initialState: ContentState = {
  userId: "",
  reviewTitle: "",
  category: "",
  productName: "",
  productLink: "",
  productImages: null,
  reviewContent: "",
  grade: 0,
};

const contentSlice = createSlice({
  name: 'content',
  initialState,
  reducers: {
    resetContent: (state) => {
      return initialState;
    },
    setUserId: (state, action: PayloadAction<string>) => {
      state.userId = action.payload;
    },
    setReviewTitle: (state, action: PayloadAction<string>) => {
      state.reviewTitle = action.payload;
    },
    setCategory: (state, action: PayloadAction<string>) => {
      state.category = action.payload;
    },
    setProductName: (state, action: PayloadAction<string>) => {
      state.productName = action.payload;
    },
    setProductLink: (state, action: PayloadAction<string>) => {
      state.productLink = action.payload;
    },
    setGrade: (state, action: PayloadAction<0 | 1 | 2 | 3 | 4 | 5>) => {
      state.grade = action.payload;
    },
    setProductImages: (state, action: PayloadAction<FileList | null>) => {
      state.productImages = action.payload;
    },
    setReviewContent: (state, action: PayloadAction<string>) => {
      state.reviewContent = action.payload;
    }
  }
});

export const { resetContent, setUserId, setReviewTitle, setCategory, setProductName, setProductLink, setGrade, setProductImages, setReviewContent } = contentSlice.actions;
export default contentSlice.reducer;

사용자가 입력하는 여러 정보를 담는 속성들로 이루어진 content라는 객체 state이고, 이 content의 productImages 속성에 사용자가 업로드한 이미지 파일을 저장하기 위해 타입을 FileList | null 타입으로 지정하고 초기값을 null 로 지정해 두었다.

여기서 다른 리듀서들은 state가 단순 텍스트나 넘버 값이기 때문에 저렇게 하면 되지만 setProductImages가 문제였다.

productImages 또한 미리보기 이미지와 마찬가지로 사용자가 이미지 업로드를 여러번 하는 경우를 대비해 기존 값을 유지한채 새로운 값을 추가해야 한다.

그래서 setProductImages를

setProductImages: (state, action: PayloadAction<FileList | null>) => {
  return [ ...state.productImages, ...action.payload ]
}

이렇게 하면 되지 않을까 생각했다. 하지만 FileList | null 형식은 배열이 아니라는 에러가 떴다.

혹은 setProductImages를 다른 리듀서와 똑같이 놔두고 호출하는 쪽에서

dispatch(setProductImages([ ...state.productImages, ...imgFiles ]));

이렇게 하면

'any[]' 형식의 인수는 'FileList' 형식의 매개 변수에 할당될 수 없습니다.
'item' 속성이 'any[]' 형식에 없지만 'FileList' 형식에서 필수입니다.ts(2345)
lib.dom.d.ts(8283, 5): 여기서는 'item'이(가) 선언됩니다.

이런 에러가 발생한다.

첫번째 에러는 FileList는 배열이 아니기 때문에 spread 할 수 없다는 것 같고, 두번째 에러는 setProductImages의 인자는 FileList | null 타입으로 정해져 있는데 [ ...state.productImages, ...imgFiles ]가 any[] 타입이기 때문인 것 같다.

두번째 에러에서는 왜 애초에 FileList인 productImages와 imgFiles를 spread 한 것은 문제삼지 않고 [ ...state.productImages, ...imgFiles ]를 any[] 타입으로 추론하는지는 아직도 모르겠다.

아무튼 두 경우 모두 FileList 타입의 값을 spread 해서 발생하는 에러인 것은 분명해 보인다. FileList에 대한 이해가 필요했다.

FileList

나는 FileList가 인덱스가 지정된 요소들로 이루어져있고 length 속성도 있길래 그냥 배열같이 사용할 수 있는 객체인가 싶었는데 배열과는 명확히 다른 것이었다.

FileList는 File 타입의 요소들로 이루어진 배열처럼 보이지만 배열이 아니라 유사 배열 객체(Array-like) 이다.

이 유사 배열 객체는 배열처럼 .files[0] 과 같은 인덱스 접근이나, .length 가 가능하고 iterable 하기 때문에 순회도 가능하지만 배열이 아니기 때문에 map, filter, forEach 등의 배열 메서드는 사용할 수 없다. 때문에 spread 연산자 또한 사용할 수 없다. 그리고 FileList 객체는 immutable 한 값이기 때문에 직접 내용을 수정할 수 없다.

FileList를 배열처럼 사용하려면 Array.from 으로 직접 배열로 바꿔줘야 한다.

const imagePreview = (e: React.ChangeEvent<HTMLInputElement>) => {
  const imgFiles = e.target.files;
  if (!imgFiles) return;

  let imgUrls = showImages.slice(); // 기존 이미지 보존 
  for (let i = 0; i < imgFiles.length; i++) {
    if (imgUrls.length >= 4) break;  // 이미지 개수는 최대 4개
    imgUrls.push(URL.createObjectURL(imgFiles[i]));
  };

  setShowImages(imgUrls);

  // FileList를 배열로 변환 후 합침
  const newImagesArray = Array.from(content.productImages || []).concat(Array.from(imgFiles));
  dispatch(setProductImages(newImagesArray as FileList));
};

productImages와 imgFiles를 각각 배열화 시킨 뒤 concat 메서드로 두 배열을 합쳐주었다.
(setProductImages는 그냥 다른 리듀서들처럼 두고 호출하는 쪽에서 인자를 변형해서 넣어주었다)

실제 배열 타입이 되었기 때문에 배열의 메서드들을 이용하는게 가능했다.

productImages만 뒤에 || [] 를 붙인 이유는 productImages가 FileList | null 타입이기 때문에 값이 null 인 경우에는 Array.from 을 적용하면 에러가 발생하기 때문이다.

content.productImages ? Array.from(content.productImages) : []

이처럼 타입 좁히기로 productImages가 null인 경우를 배제해줘도 될듯

이렇게 기존 productImages와 imgFiles를 합친 새로운 배열을 생성했고, 이 배열을 FileList로 타입 단언을 한 뒤 setProductImages의 인자로 지정!

는 에러.

'File[]' 형식을 'FileList' 형식으로 변환한 작업은 실수일 수 있습니다. 두 형식이 서로 충분히 겹치지 않기 때문입니다. 의도적으로 변환한 경우에는 먼저 'unknown'으로 식을 변환합니다.
'item' 속성이 'File[]' 형식에 없지만 'FileList' 형식에서 필수입니다.ts(2352)

FileList 와 File[]

FileList와 File[]은 아예 서로 다른 형식이며, 직접적으로 변환할 수 없다고 한다.

Array.from(content.productImages || []).concat(Array.from(imgFiles)) 인 newImagesArray의 타입은 File[] 타입이다. 이건 생각해보면 당연하다. FileList가 File 타입의 요소로 이루어진 것이니까 얘를 배열화 시키면 당연히 File[] 타입일듯.

근데 이렇게 FileList 를 실제로 File[] 로 만들어버리는건 되는데 File[] 을 FileList 타입으로 단언하는건 왜 안될까 싶었는데, 생각해보니 타입 단언을 하려면 충족해야 하는 조건이 있었다.

A 타입을 B 타입으로 단언하는 A as B 인 상황일 때

  • A가 B의 슈퍼 타입이다
  • A가 B의 서브 타입이다

이 두가지 조건 중 하나를 만족하는 경우에만 타입 단언이 가능하다.

그래서 File[] -> FileList 로의 타입 단언이 불가능한걸로 봐도 이 둘은 완전히 별개의 타입이라는 것을 알 수 있다.

File[] 타입을 FileList 타입으로 변환할 다른 방법을 찾아야 했다.

DataTranfser

File[] -> FileList 의 직접적인 변환은 불가능하다. 하지만 DataTranfer 라는 객체를 이용하는 방법이 있었다.

DataTranfer는 드래그 앤 드롭 기능을 위해 자바스크립트에서 제공하는 객체이다.

드래그 앤 드롭 기능에 대한 자세한 내용 참고 : 드래그 앤 드롭 기능 이해 & 구현하기

DataTransfer 객체는 드래그 앤 드롭 작업 중에 드래그되고 있는 데이터를 보관하기 위해 사용되는 객체이고, 드래그 앤 드롭 시에 FileList 를 사용한다. DataTransfer 객체의 files 속성으로 DataTransfer 객체가 갖고 있는 데이터를 얻을 수 있는데 그 데이터가 FileList 객체인 것이다.

FileList MDN 문서를 보면 FileList 유형의 객체는 input 요소의 files 속성이나 Drag and Drop API의 DataTransfer에서 가져올 수 있다고 한다.

따라서 이 DataTransfer 객체에 Array.from(content.productImages || []).concat(Array.from(imgFiles)) 의 요소들을 직접 추가하고, DataTransfer.files로 그 요소들로 이루어진 FileList 객체를 얻어내는 방법을 적용할 수가 있다.

File[] 타입인 배열의 요소들을 DataTransfer 객체에 추가하고 그 DataTransfer 객체의 files 속성으로 동일한 요소의 새로운 FileList 객체를 얻어내서 File[] -> FileList 변환을 한 것과 같게 하는 일종의 꼼수이다.

const [ showImages, setShowImages ] = useState<string[]>([]);  // blob 형태로 변경한 이미지 url 들을 담기 위한 배열 state

const imagePreview = (e: React.ChangeEvent<HTMLInputElement>) => {
  const imgFiles = e.target.files;
  if (!imgFiles) return;
  let imgUrls = showImages;  // 기존에 업로드 했던 이미지 보존 
  for (let i = 0; i < imgFiles.length; i++) {
    if (imgUrls.length >= 4) break;  // 이미지 개수는 최대 4개
    imgUrls.push(URL.createObjectURL(imgFiles[i]));
  };
  console.log('imgUrls : ', imgUrls);
  setShowImages(imgUrls);

  // 이전에 새로 추가한 이미지를 유지한채 또다시 새로 추가한 이미지가 있으면 추가
  const newImagesArray = Array.from(content.productImages || []).concat(Array.from(imgFiles));

  // combinedFilesArray를 FileList 타입의 값으로 변환 
  const dataTransfer = new DataTransfer();
  
  newImagesArray.forEach((file) => {
    dataTransfer.items.add(file);
  });
  
  const newImgFiles = dataTransfer.files;
  dispatch(setProductImages(newImgFiles));
};

DataTransfer 객체에 요소를 추가하는건 [DataTransfer 객체].items.add(요소) 라고 하면 된다.

DataTransfer.item 은 DataTransfer 객체가 담고있는 모든 드래그 데이터의 목록인 DataTransferItemList 객체를 반환하고, 이 객체의 add 메서드로 요소를 추가할 수 있다.

이렇게 기존의 productImages에 imgFiles를 합친 새로운 FileList를 얻어 setProductImages로 productImages를 업데이트 시켜줌으로써, 유저가 업로드한 이미지를 미리보기로 보여줄 state와 서버로 보낼 실제 이미지 파일의 state를 만드는 imagePreview 함수가 완성되었다.

이후 productImages와 다른 정보들을 적절히 서버로 전송하고 백엔드에서 이들을 처리해서 이미지 업로드 기능 구현 완료!

🗑️ 이미지 삭제(업로드 취소)

미리보기용 이미지에의 우측 상단에 X 버튼을 달아놓았는데 이걸 누르면 해당 이미지 업로드를 취소할 수 있게 했다. 이 이미지 삭제 기능은 간단하다.

const handleDeleteImage = (idx: number) => {
  setShowImages(showImages.filter((_, index) => index !== idx));
  const updatedImages = content.productImages ? Array.from(content.productImages).filter((_, index) => index !== idx) : null;
  dispatch(setProductImages(updatedImages as FileList | null));
};

X 버튼을 누르면 실행되는 handleDeleteImage 함수. showImages 에서 클릭한 이미지의 인덱스에 해당하는 요소를 제거하여 다시 업데이트 해주면 미리보기 이미지 삭제는 끝.

그리고 productImages도 배열화 한 후 마찬가지로 filter 메서드로 해당 인덱스의 요소를 제거해 상태를 다시 업데이트 해주면 된다.

그런데 여기서는 updatedImages가 FileList | null 로 타입 단언이 된다. 위에서 이게 안되가지고 번거롭게 DataTransfer 써서 했는데 이건 된다.

왜 되지 싶었는데 updatedImages가 File[] | null 타입이었다. 위에서는 FileList 배열화를 Array.from(content.productImages || []) 이렇게 해서 그냥 File[] 타입이 됐는데, 지금은 삼항연산자로 했으니 File[] | null 타입이 된다.

다른건 이것뿐이다. imagePreview 함수에서는 FileList | null 로 타입 단언하려는게 File[] 타입인 상황이었고 여기서는 File[] | null 인 것.

근데 타입 단언이 되는걸로 보아 File[] | null이 FileList | null의 서브 타입이거나 슈퍼 타입이라는 얘기인데, 둘중 뭐일지는 모르겠다.

File[]이 FileList의 서브 타입도, 슈퍼 타입도 아닌데 File[] | null이 FileList | null의 서브 타입 혹은 슈퍼 타입일 수가 있는건가? File[]과 FileList에 둘다 null인 경우가 붙어서 뭔가 연관이 있는걸로 되는 판정인건가... 아무튼 모르겠다.

지피티는 File[] | null 이 FileList | null 의 서브타입이라고 하는데 말이 자꾸 바뀌어서 딱히 신뢰가 안간다.

어쨋든 되긴 하니까 굳이 또 DataTransfer 쓰던가 해서 타입 변환 하지않고 일단 그냥 저렇게 했다. 타입 단언을 남발하는건 좋지 않겠지만 뭐.. 일단 한번 씀 ㅎ


이렇게 이미지 업로드 기능 구현 완료! 새삼 타입과 타입간의 관계를 이해하는게 중요하다는 것을 다시 느끼게 된 거 같다. 단순히 input 요소로 이미지 업로드 폼을 커스터마이징하고, 업로드한 이미지를 어떤 식으로 다루는지나 찾아보면 될 줄 알았는데 그 과정에서 생각지도 못하게 이것저것 알게 되었다.



참고자료

input[type="file"] 커스텀하기
createObjectURL을 사용해서 이미지 업로드 후 미리보기
이미지 다중 업로드 및 미리보기
[TypeScript] File 타입과 FileList 타입
FileList는 배열이 아니다.
파일업로드 input[files] FileList 동적으로 변경하기
드래그 앤 드롭 기능 이해 & 구현하기

profile
언제까지_이렇게_살아야돼_

0개의 댓글

관련 채용 정보