🔥 문제
현재 사진 올리기를 위한 업로드 버튼이 필요하다.
원래 기존에 있던 업로드의 경우, 필요없는 기능도 많고, 또한 버튼 스타일링이 적용될 수 없는 상태였다.
또한,
DOM
으로 직접 제어하자니, 이는 React에서 DOM을 지양하는 철학과 상충된다고 판단하여, 리액트스럽게 해결하고자 했다.어떤 방법이 존재할까?
⭐ 해결 방법
일단 내가 생각한 큰 로직은 2개가 있었다. 다음과 같다.
1. React.forwardRef
사용
컴포넌트 재사용을 위해서는 추상성을 늘려야 한다. 따라서 forwardRef를 써서 상위에서 업로드를 재현하면 어떨까?
위의 방법으로 적용하면, 분명 추상성이 늘어나서 재사용성이 증가할 것이라는 판단을 했다.
그러나, 위의 로직은 결국 상위 컴포넌트에서 이미지에 대한 모든 것을 제어해야 한다. 즉, 업로드 컴포넌트를 들어갔을 때,
ref
역시 상위에서 제어해야 하므로, 코드에 대한 복잡성이 늘어나며, 다른 모듈과의 결합성이 늘어날 것 같다는 우려가 들었다.2. Upload
에서 모든 것을 해결
이는 매우 구체성이 높은 Upload 컴포넌트를 구현하자는 의미였다.
사실 굉장히 무식할 수도 있고, 재사용성은 매우 떨어지지만, 컴포넌트가 해야하는 기능성이 모듈 상에서 응집도 높게 구현되어 있다. 따라서, 나는 이러한 방식이 팀원들에게 설명하기도 좋을 것 같으므로 이를 채택하였다.
구체적인 해결 방안
- 다음과 같이 간단한 파일과 input을 세팅할 수 있는 코드를 생성한다.
const [file, setFile] = useState<string | null>(null); const inputRef = useRef<HTMLInputElement>(null); const handleButtonClick = useCallback(() => { if (inputRef.current) { inputRef.current.click(); } }, []);
- 다음과 같이
onChange
를 세팅한다.
const onChange = (e: Event) => { /* eslint-disable prefer-destructuring */ const target = e.target as HTMLInputElement; const imageFile: File = (target.files as FileList)[0];
이렇게 한다면, 콘솔을 찍을 때,
image
의 정보를 확인할 수 있다.- 그 다음, 우리는 버튼을 사용하기로 했다. 버튼을 만들어주자.
<Button buttonType="primary" width={88} height={40} border reversal borderRadius={20} onClick={handleButtonClick} > + 올리기 </Button>
- 만약 파일들이 있다면, 우리는 파일들의 정보에 맞춰 이미지만 만들어준다. 그럼 일반적인 레이아웃은 끝이다.
<ImagesBox> {file.urls.map((url) => ( <ImageContainer key={`${url}`} src={url as string} alt="이미지" width={uploadType === 'single' ? 312 : 92} height={uploadType === 'single' ? 312 : 92} css={ImageContainerCSS} /> ))} </ImagesBox>
2차전 - 업로드 기능을 구현하자.
업로드 기능은
input
의 attribute에 따라서 크게 2가지로 나뉜다.하나는
multiple
한 input과, 다른 하나는 그렇지 않은 input이다.그렇지만 공통적으로 다음과 같은 로직이 존재한다.
- e.target.files를 확인한다.
- 각 files에 따라 fileReader를 만들어준다.
- 그리고 각 fileReader에 따라 load가 되면, 비로소 현재 file state를 업데이트해준다.
- 그리고 우리는
URL
을 읽을 수 있어야 화면상에 이미지를 보여줄 수 있다.fileReader
의readAsDataURL
을 넣어주자.
이 로직을 구현하면 다음과 같다.
const onChange = (e: ChangeEvent) => { /* eslint-disable prefer-destructuring */ const target = e.target as HTMLInputElement; const imageFiles: FileList | null = target.files; if (!imageFiles) return; if ( (!imageIdx && uploadType === 'single' && file.files.length === 1) || (!imageIdx && uploadType === 'multiple' && file.files.length === 3) ) { /* eslint-disable no-alert */ alert( uploadType === 'single' ? IMAGE_FILE_LENGTH_ERROR : IMAGE_FILES_LENGTH_ERROR ); } for (let i = 0; i < imageFiles.length; i += 1) { const { name, size } = imageFiles[i]; // const nowFile = imageFiles[i]; if (!name.match(FILE_EXTENSION_REGEX)) { /* eslint-disable no-alert */ alert(IMAGE_FILE_EXTENSION_ERROR); return; } if (size > IMAGE_MAX_SIZE) { /* eslint-disable no-alert */ alert(IMAGE_FILE_SIZE_ERROR); return; } const fileReader = new FileReader(); fileReader.onload = () => { setFile((state) => ({ ...state, files: imageIdx !== null ? state.files.map((stateFile, stateIdx) => imageIdx === stateIdx ? imageFiles[i] : stateFile ) : [ ...(uploadType === 'single' ? [] : state.files), imageFiles[i], ], urls: imageIdx !== null ? state.urls.map((stateUrl, urlIdx) => imageIdx === urlIdx ? fileReader.result : stateUrl ) : [ ...(uploadType === 'single' ? [] : state.urls), fileReader.result, ], })); }; fileReader.readAsDataURL(imageFiles[i]); setImageIdx(() => null); } target.value = ''; };
이제,
input
에 넣어주면 제대로 onChange
에 따라서 이미지가 바뀌는 것을 확인할 수 있다.Error - File이 제대로 등록되지 않는 현상 발생
확인 결과, 로드하기 전과 로드됐을 때 발생하는 로직 사이에서
file
이 유실되는 현상을 발견했다.생각을 해보니... 결과적으로
onLoad
라는 것은 어떤 특정 이벤트이고, 이벤트는 비동기적으로 동작한다.따라서 그 과정 속에서 동기적으로 동작하던
File
은 이미 다 날라가버리고 없는 상태였던 것이다.
그렇다면 어떻게 해야할까. 나는
cashing
을 이용했다.그렇다면 여기서 의문이 들 것이다. 어째서 이것이 동작할 수 있을까?
결과적으로 특정 FileList가 사라진다고 하더라도, 아직 내가 특정 변수를 통해 캐싱하고 참조하고 있다면, 렉시컬 환경에서 가비지 컬렉터에 의해 유실되지 않는다. 왜냐하면, 그것이 클로저의 원리이고,자바스크립트 엔진 가비지 컬렉터의 원리이기 때문이다.
따라서, 나는 캐싱을 이용하여 잘 처리해줄 수 있었다.
for (let i = 0; i < imageFiles.length; i += 1) { const { name, size } = imageFiles[i]; const nowFile = imageFiles[i]; if (!name.match(FILE_EXTENSION_REGEX)) { /* eslint-disable no-alert */ alert(IMAGE_FILE_EXTENSION_ERROR); return; } if (size > IMAGE_MAX_SIZE) { /* eslint-disable no-alert */ alert(IMAGE_FILE_SIZE_ERROR); return; } const fileReader = new FileReader(); fileReader.onload = () => { setFile((state) => ({ ...state, files: imageIdx !== null ? state.files.map((stateFile, stateIdx) => imageIdx === stateIdx ? nowFile : stateFile ) : [...(uploadType === 'single' ? [] : state.files), nowFile], urls: imageIdx !== null ? state.urls.map((stateUrl, urlIdx) => imageIdx === urlIdx ? fileReader.result : stateUrl ) : [ ...(uploadType === 'single' ? [] : state.urls), fileReader.result, ], })); }; fileReader.readAsDataURL(imageFiles[i]); setImageIdx(() => null); }
3단계 - ref
를 통한 간접 제어를 해주자.
그렇지만 우리의 난관은,
input
이 보이지 않는데 어떻게 input
처리를 하느냐이다.나는 이에 대해서
inputRef
를 통해 DOM을 간접적으로 조작하는 방식을 택했다.const handleButtonClick = useCallback(() => { if ( (uploadType === 'single' && file.files.length === 1) || (uploadType === 'multiple' && file.files.length === 3) ) { /* eslint-disable no-alert */ alert( uploadType === 'single' ? IMAGE_FILE_LENGTH_ERROR : IMAGE_FILES_LENGTH_ERROR ); } if (inputRef.current) { inputRef.current.click(); } }, [file.files.length, uploadType]);
보면,
inputRef.current
를 통해 클릭을 제어해줬다.여기서
inputRef.current
를 해준 이유는, useRef
의 값이 <HTMLInputRef>
일 수도, null
일 수도 있기 때문이다.그렇다면, 잘 되는지 테스트하자.
잘 올라지는 것을 확인 가능하다.

4단계 - 센스있게 이미지 버튼 클릭시 동작도 제어해주자.
만약 n번째 이미지만 바꾸고 싶다면 어떻게 해야 할까?
이를 고심한 결과, 3단계와 동일한 로직으로 처리해주면 된다고 판단했다.
이때,
idx
를 통해 조작해주는 것이 포인트이다.const [imageIdx, setImageIdx] = useState(null); ... const handleImageClick = useCallback((idx) => { setImageIdx(() => idx); if (inputRef.current) { inputRef.current.click(); } }, []); ... fileReader.onload = () => { setFile((state) => ({ ...state, files: imageIdx !== null ? state.files.map((stateFile, stateIdx) => imageIdx === stateIdx ? nowFile : stateFile ) : [...(uploadType === 'single' ? [] : state.files), nowFile], urls: imageIdx !== null ? state.urls.map((stateUrl, urlIdx) => imageIdx === urlIdx ? fileReader.result : stateUrl ) : [ ...(uploadType === 'single' ? [] : state.urls), fileReader.result, ], })); };
결과적으로
idx
가 몇 번째인지에 따라서, file
의 값이 map
을 통해 바뀐다.실제로 잘 동작하는지 테스트한다.
잘 올려진다!
