최근 특정 과제를 제작하는 과정에서 이미지 파일을 새로고침해도 유지하도록 만들어야 하는 상황이 있었다.
사실 제일 쉬운건 매 새로고침때마다 서버에게 이미지 파일을 요청하는 것이 될 것이지만, 솔직히 계속 사용하는 이미지 데이터를 위해 서버에 요청이 지속적으로 가게 하는 것은 아무리 적은 요청이라 하더라도 좋은 방식은 아니라고 생각했다.
그렇게 판단한 이유는 아래와 같다
한번 전달받은 이미지에 대해서 http 캐싱이 이루어지므로 계속 그 이미지를 사용하게 될 수 있겠지만, 그때마다 캐싱값이 변경되지 않았다는 점을 전송하는 "if-modified-since" 헤더를 포함한 요청을 날리고 서버는 304 not modified를 전달하는 과정 자체가 불필요할 수 있다.
만약, 사용자가 인터넷이 몹시 불안정하고 느린 환경일 경우 전송받아야 할 이미지가 핵심 서비스라고 한다면 네트워크에 오롯이 의존하게 하는 것은 불안정한 상태라고 판단했다.
그래서, 결론적으로 말하면 이미지 파일을 로컬스토리지에 저장하는 것을 택하였다.
사실, 파일 자체를 input에 전달하여 사용하는 일은 그리 어렵지 않았다. 그리고 구글에 검색해보면 이미지 파일을 다루는 방법들이라고 올라온 내용들을 상당히 많이 볼 수 있다. 문제는, 해당 방법의 다수가 리엑트에서 왠지모르게 작동을 안한다는점과, 로컬스토리지에는 문자열 값만 전송이 가능하다는 점에 있었다
그래서, 해결점을 찾기 위한 긴 여정을 떠나게 되었다
우선 구글링을 해봤을 때 javascript로 이미지를 로컬스토리지에 저장하는 방식을 검색해보면 아래와 같은 내용을 손쉽게 만날 수 있다.
(검색어는 javascript image file localstorage 로 검색)
참고로, 이 일을 계기로 항상 스택오버플로우에서 코멘트가 달린 답변이 항상 옳은 답변일 것이라는 것은 경계해야 될 자세라는 것을 알게 되는 계기가 되기도 했다
대부분의 답변을 보면 FileReader 을 이용해 만든 인스턴스의 메서드를 이용해서 파일처리를 하라는 답변이 달린 것을 볼 수 있다.
document.getElementById('file').addEventListener('change', (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onloadend = () => {
// convert file to base64 String
const base64String = reader.result.replace('data:', '').replace(/^.+,/, '');
// store file
localStorage.setItem('wallpaper', base64String);
// display image
document.body.style.background = `url(data:image/png;base64,${base64String})`;
};
reader.readAsDataURL(file);
});
FileReader의 MDN 정의를 보면 아래와 같다.
설명에 따르면, FileReader 생성자는 각종 File 과 Blob 을 비동기적으로 다룰 수 있도록 하는 인터페이스를 제공하는 함수라고 말하고 있다.
여기서 File 생성자와 Blob 생성자가 무슨 차이인지 조금 더 살펴보자면 간단하게 말해서
Blob : Binary Large Object의 줄임말로, 이 세상의 수많은 데이터는 2진수로 이루어져 있고, 이 데이터는 일반적으로 버퍼라는 자료구조를 통해 메모리에 저장된 뒤 사용된다. 이때 그 크기가 너무 큰 2진수 데이터를 버퍼만으로 다루기에는 무리가 있으므로 데이터의 사이즈와 MIME 타입, 그리고 필요에 따라서 splice를 하여 데이터를 청크단위로 전송할 수 있도록 분리하는 편리한 기능을 제공한다.
File : File 생성자는 Blob 생성자의 기능을 상속받음과 동시에, 옵션으로 lastModified 나 filename을 설정하는 등 커스터마이징 기능이 추가된 생성자이다.
하지만, 위 코드에서 사용되는, FileReader의 내부 메서드, ononloadend 를 사용할 때에 문제가 하나 있었다.
그 어떤 방법을 사용하더라도, onload 이벤트를 감지하지 못했던 것이다
즉 다시말해서,
function onChange (target){
console.log(target) // 제대로 타겟 input 노드가 들어오는 것을 확인할 수 있다.
const reader = new FileReader();
reader.onloadend = (file) => console.log(file) // FileReader의 콜백 함수는 전혀 실행되지 않았다.
}
참고로, FileReader 객체 내에는 다른 콜백을 처리하는 메서드들이 있었지만, 그 어느것도 제대로 작동하지 않았다.
위에서 언급했던 첫 코드에서 보면, 뭔가 이상하다 싶은 점이 하나 있을 것이다.
응? 함수 내에서 그냥 FileReader 호출해서 인스턴스 만들면 자동으로 target과 연동되는건가? 왜 저것만 써져있지?
그렇다. stack overflow에서 줄줄이 사탕으로 나오는 수많은 답변들을 보면, 마치 FileReader만 호출하면 알아서 연동되어서 파일을 읽는것처럼 해놓고 콜백함수를 호출하면 자동으로 그것을 읽을 수 있던것처럼 되어있던 것이다 (허탈)
계속 고민해보니 그런 마법같은 일이 있을 리도 없고, 당연히 이 FileReader에 뭔가 더 설명이 필요하다는 생각이 들어 검색을 해보았다.
<input type="file" id="upload" multiple />
let file = document.getElementById('upload');
file.onchange = function(e) {
var files = e.target.files; // FileList 객체
console.log(files); // { 0: File, 1: File, length: 2 }
console.log(files[0]);
}
input을 통해서 들어오는 파일은 저렇게 "e.target.files" 내부에 저장이 된다.
이 파일리스트 유사배열 객체의 내에 저장되는 file 객체는 아래와같은 정보를 담고 있다.
name: 'test.png', // 파일 이름
size: 74120, // byte 단위 파일 크기
lastModified: 1495791249810, // 최종 업로드날짜
type: 'image/png'
}
아까 위에서 말했듯, File 생성자로 만들어진 인스턴스는 타입과 파일이름 그리고 최종변경시간등이 들어있는 것을 알 수 있다.
그런데 정작, 파일 자체는 객체 내부에 들어있지 않는데 이는 의도적으로 숨겨져 있는 형태로, 파일을 보기 위해서는 FileReader에서 제공하는 메서드를 이용해서 read를 해야 한다. (마치 객체에서 getter을 사용하는 것과 같은 의미이다.)
이것은 마치 Node.js가 이벤트 기반 개발을 할 때에 콜백함수들을 호출해서 확인하는 것과 같은 원리이다.
이때 FileReader가 파일을 get하는 방식으로는 크게 4가지가 존재한다
1. readAsText
2. readAsDataURL
3. readAsArrayBuffer
4. readAsBinaryString
텍스트를 읽을 용도로 사용한다. 즉, 타겟으로 들어오는 File의 타입이 텍스트일 경우, 숨겨져 있는 텍스트 파일을 read하기 위해서는 이 메서드를 활용하면 된다. 참고로, read한다는 소리는 binary data의 나열을 텍스트에 맞춰서 읽겠다는 의미가 되므로, 텍스트가 아닌 binary data를 읽을 경우 외계어(?) 를 만나게 된다.
binary data를 base64를 기반으로 하는 인코딩으로 변환하여 데이터 URL을 만들어주는 것을 의미한다.
여기서 base 64란,
즉, 들어오는 이진수 데이터를 8비트마다 쪼개어 ASCII 영역의 문자열로 해독하여(즉, 영어와 숫자) 변환한다는 뜻이다.
base64란 위에서 말하는 것처럼 ASCII 문자열로 변환할 수 있는 가장 최대의 경우의 수를 표현한 것이라고 보면 된다.
이 data url을 이용하면 언제든지 img의 src에 들어갈 용도로 사용할 수 있다. 즉, 이 데이터 링크 자체를 가지고 이미지를 표현할 수 있다는 뜻이 된다. 브라우저가 할 일은, 이 src에 들어가는 base64로 인코딩된 데이터를 다시 디코드하여 이미지로 전환하면 되기만 하면 될 뿐이기 때문이다.
참고로
전역객체에 존재하는 생성자 URL 에 정적 메서드로 존재하는 createObjectURL을 통해서도 blob url을 만들어낼 수 있다.
이 역시 base64를 기반으로 하는 data link로 사용할 수 있다는 점에서는 서로 비슷한 면을 보이지만 가장 큰 단점은
document와 생명주기를 같이 한다 라는 점이다.
즉, createObjectURL을 이용해서 만든 data link를 localstorage에 저장하고 새로고침을 하게 된다면 이미 document가 사라져버리고 새로운 DOM이 만들어져서 할당이 되기 때문에 해당 url은 유효하지 않게 된다. 하지만 base64로 전환한 데이터링크는 그 자체가 그냥 이미지의 2진 데이터를 base64를 기반으로 문자열로 전환시켜놓은 것이기 때문에 언제든지 사용이 가능하다.
데이터를 버퍼 형식으로 읽어들여서 가져오는 방식이다. 즉, 현재 img 파일은 특정 확장자에 귀속된 형태로 변환되어 있으므로 이 데이터를 서버로 전송하기 위해선 이진수로 변경해야 하는데, 이 때에 그 데이터들을 메모리에 탑재시키는 공간을 확보하는 것이 바로 Buffer 객체이다. 즉, 서버에 데이터를 전송하고 싶을 때 사용하면 된다.
ArrayBuffer은 메모리 공간에 탑재된 이진데이터를 활용할 인터페이스 자체를 만들어서 전송하는 개념이라면, BinaryString은 말 그대로 이진수 데이터를 스트링 형태로 만들어 전송하는 방식이다. (라고 말하긴 하는데, 솔직히 MDN에서도 서버전송을 위해 이걸 쓸 바에야 ArrayBuffer로 전송하길 추천한다고 써져 있으므로 별로 중요하진 않은 메서드이다)
참고로, 서버에 데이터를 전송하고 싶을 때에 타겟을 늘 이렇게 변경하기 보다는, formdata에 저장하여 전송하는 편이 훨씬 간편하다.
FormData 생성자로 인스턴스를 만든 후, 여기에서 제공하는 "append"라는 메서드를 이용해서 데이터를 집어넣으면 알아서 전환할 필요 없이 필요한 값을 서버전송에 적절한 형태로 가공해서 전달해주기 때문에 이미지 전달을 위해서는 이 방법을 이용하는 편이 좋다.
정말 기나긴 이야기를 하게 되었는데, 결국 하고싶었던 말은 FileReader 생성자로 만든 인스턴스의 메서드를 활용하려면 위에서 제공하는 메서드를 이용해서 데이터를 "READ" 한 뒤에, 내부 콜백 메서드 "ON" 시리즈들을 이용하여 추가적인 처리를 하면 된다.
참고로 나는 해당 로직을 함수로 따로 뺴서 처리하였다.
const convertBase64 = (file: any) => {
return new Promise(resolve => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};
});
};
자, 이제 스텍오버플로우에서 설명했던 내용들에서 생겨났던 여러 오해들을 잡을 수 있었고, 정상적으로 이미지 링크를 로컬스토리지에 저장하는 것 까지 완료되었다.
그런데, 문제가 발생한 것은 바로 onChange와 관련된 일이었다.
input에 들어오는 파일의 변화에 따라서 콜백 함수가 호출되는 것 까지는 이해하고 있었다. 그런데 문제는, 어떤 상황때문에 동일 파일을 업로드하게 되는 경우라도 onchange 함수가 호출되기를 바랐으나, 그렇게 되지 않았던 것이다.
그 이유는 바로, input 노드에 존재하는 FileList 유사배열 객체가 동일 파일이 들어왔기 때문에 변화라고 생각하지 않으므로 콜백이 호출되지 않았다는 점이다.
이것은 굳이 내 케이스가 아니더라도, 예를 들어 미리보기와 같은 시스템을 만들 때에도 크게 문제가 되는데
현재 input에 여러 파일이 업로드 가능하도록 설정해놓고 파일을 여러개 업로드한 후, 이 파일리스트를 읽어서 UI로 표현했다고 하자.
이 때, list를 filter하면 리스트 배열 자체는 변동이 될 지 몰라도, 업로드된 파일은 input의 filelist에 그대로 남아있는 상태라서 변동이 되지 않는다.
즉, onchange를 동일 파일에 대해서도 발동시키고 싶거나, 업로드한 파일을 제거하기 위해서는 이 file list 유사배열 객체를 손봐서 업데이트할 필요성이 생긴다는 뜻이 된다.
이 때에 사용하는 것이 바로 "DataTransfer" 생성자이다.
사실 DataTransfer 자체는 input을 위해서기 보다는 drag & drop을 위해 사용하는 API이지만, 간편하게 fileList 인스턴스를 만들 수 있기 때문에 활용한다.
아니, 그냥 input node 객체에 있는 fileList를 적절하게 변경해서 할당하면 안돼나요?
안된다. 그 이유는, input이 가지고 있는 files 객체 내에 프로토타입 슬롯에는 FileList.prototype이 존재해야 하기 때문이다.
filter을 통해서 전달되는 값은 "배열" 이고, 배열 객체의 prototype슬롯에는 Array.prototype이 들어있기 때문에 안된다.
그래서 가장 간단한 방법으로 사용하는 것이 DataTransfer 생성자가 만들어내는 인스턴스 내의 프로퍼티 "files"를 활용하는 것이다.
const dataTranster = new DataTransfer();
Array.from(files)
.filter(file => file.lastModified != removeTargetId)
.forEach(file => {
dataTranster.items.add(file);
});
document.querySelector('#file-input').files = dataTranster.files;
DataTransfer이 만든 인스턴스 객체 내에 데이터를 삽입하려면 프로퍼티 내의 "items"가 보유한 add 메서드를 호출하면 된다.
그러면 자동으로 내부 인스턴스의 files 프로퍼티에 담기게 되고, 이 files객체를 input 노드의 files 프로퍼티에 삽입할 수 있는 것이다.
나의 경우, 새로운 파일이 들어오면 초기화를 시켜줘서 언제든 onchange가 발생하도록 만들어주면 되었으므로 아래와 같이 사용하였다.
const resetFileList = (target: EventTarget & HTMLInputElement) => {
const dataTransfer = new DataTransfer();
target.files = dataTransfer.files;
};