React + typescript 환경에서
html2canvas 라이브러리를 활용하여
AWS S3에서 불러온 이미지를 포함한 DOM 캡처 구현하기
이 프로젝트는 쿠키 아이템들 (몸통, 눈코입, 모자, 옷, 악세사리 등)을 모두 S3에 저장하고 링크를 받아서 띄워주는 형태로 구현했다. 그리고 쿠키 컴포넌트를 만들어서 position을 사용해서 각자의 위치에 맞게 위로 쌓아올려서 옷 입히기를 구현했다.
구워진 캐릭터는 하나의 png로 저장되어서 AWS에 저장 |
|---|
최종 50레벨에 도달하면 캐릭터를 굽고 새로운 반죽을 생성할 수 있는데, 이 때 구워진 캐릭터는 내가 만든 쿠키 페이지에서 열람할 수 있었다.
❓ 만약 내가 만든 쿠키 페이지에서 이전에 구운 쿠키들의 의상 목록을 각각 url로 받아서 띄운다면 어떻게 될까?
당연히 너무 느린 로딩 문제가 있을 것이다.
또한 어차피 이제 바꿔 입힐 수 없는데 각각의 url로 받아올 필요가 없었다.
그래서 여러 이미지를 쌓아올린 페이지를 하나의 png로 변환해서 S3에 저장하고 불러오는 방식으로 구현하기로 했다.
처음 React DOM 캡처를 위해 라이브러리를 찾아볼 때까지는 큰 문제가 없으리라 생각했다.
(하지만 정말 오랜 시간이 들어서 해결할 수 있었다...)
사용한 라이브러리는 html2canvas 이다.
대표적인 라이브러리로 dom-to-image와 html2canvas가 있었다. dom-to-image 가 더 빠르고 가볍다고 하지만, image가 dom에 포함되어 있을 때 깨지는 경우가 있다고 해서 html2canvas 를 사용하기로 결정했다.
// 구현했던 bakePng 코드 중 일부입니다.
const ImgType = 'image/png';
const downloadPng = async (
element: React.RefObject<HTMLElement> | null,
options: DownloadOptions,
) => {
if (!element || !element.current) return null;
const { fileName, imgQuality, canvasOptions } = options;
try {
const canvas = await html2canvas(element.current, {
...defaultOptions,
...canvasOptions,
useCORS: true,
});
return canvas.toDataURL(ImgType, imgQuality);
} catch (err) {
console.log('img download fail error', err);
}
};
캡처하고 싶은 DOM에 useRef를 사용해서 bakePng(divRef, { fileName: props.name })의 형식으로 bakePng 코드를 불러와서 캡처를 구현했다.
하지만 페이지를 로드해서 굽기 버튼을 누르자마자 쏟아지는 CORS 에러를 만날 수 있었다..
당황했지만,, CORS가 별거냐! 하는 마음으로 검색을 실시했다.
그 결과 충격적인 사실을 알게 되었다.
그것은 바로,, 이 문제는 html2canvas - google - aws 의 복합 문제로 그 누구도 고쳐주려고 하지 않아서 예쁜 방법으로는 해결할 수 없다는 것이었다.
이미지 출처 : 나
[참고] AWS S3 CORS(동일 출처 정책)
[참고] canvas 내부 이미지 CORS 문제 해결하기

담당 백엔드 팀원분과 열심히 논의한 결과 대세를 따르기로 했다. 가장 많이 나오는 방법은 useCORS: true를 붙이거나 proxy 를 설정해주는 등의 방법이 있었는데 나에게는 전혀 해당되지 않았다. 그래서 이미지 url에 timestamp 등을 붙여서 Origin을 살짝 수정하는 방식을 차용했다.
let img = new Image();
img.src = /^data:image/.test(props.body) ? props.body : props.body + '?' + new Date().getTime();
따라하지 마십시오. 어차피 실패했습니다. 여전히 CORS만 나를 반겼다.
많은 사람들이 저 방법으로 성공한 것 같았으나 이유를 알 수 없게도 나는 실패했다.
그래서 결국 백엔드에서 url을 우회하는 API를 제작해주었다.
아쉽게도 나는 JAVA 코드를 제대로 읽을 줄 모른다. 그렇지만 이걸 안보여주면 해결 방안이라고 할 수 있나? 싶어서 적어본다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/html2canvas")
public class ProxyController {
@GetMapping("/proxy.json")
@CrossOrigin("*")
public byte[] html2canvasProxy(@RequestParam String url) {
byte[] data = null;
try {
URL s3Url = new URL(URLDecoder.decode(url,
StandardCharsets.UTF_8));
HttpURLConnection connection = (HttpURLConnection)
s3Url.openConnection();
connection.setRequestMethod("GET");
if(connection.getResponseCode() == 200) {
data = IOUtils.toByteArray(connection.getInputStream());
} else {
System.out.println("responseCode : "
+ connection.getResponseCode());
}
} catch (MalformedURLException e) {
data = "wrong URL".getBytes(java.nio.charset.StandardCharsets.UTF_8);
} catch(Exception e) {
e.printStackTrace();
System.out.println(e);
}
return Base64.getEncoder().encode(data);
}
url을 새로운 Base64 코드로 변환해주는 API이다.
API 사용 과정은
1. 기존 url을 Proxy API로 보내서 Base64 코드 받기
2. Base64 코드를 사용해서 DOM에 캐릭터 이미지 띄우기
3. html2canvas로 그 DOM 캡처하기
프론트 코드도 간단하게 보여주자면,
export default function Bake({ ...props }: QookieInfo) {
const divRef = useRef<HTMLDivElement>(null);
const Base64 = 'data:image/png;base64,';
// 1. Proxy API(getS3UrlHandler)를 사용해서 새로운 Base64 코드 받아오기
const makeBakeProps = async () => {
const eyeUrl = await getS3UrlHandler(props.eye);
const mouthUrl = await getS3UrlHandler(props.mouth);
return {
...props,
eye: Base64 + eyeUrl,
mouth: Base64 + mouthUrl,
};
};
// 굽기 버튼을 누르면 bakePng 함수에 ref DOM을 보내서 png url로 받아오기
const handleBakeClick = async () => {
const res = await bakePng(divRef);
if (res) {
const imgFile = await converUrltoFile(res);
// new File 형태로 바꿔서 S3에 png 파일 등록
qookieApi.bakeQookieReq(imgFile).then(() => {
resetList();
});
}
};
return (
// 캡처할 DOM에 ref 설정
// styled-component 사용했습니다.
<BakeSize ref={divRef}>
// 새로 받은 Base64 코드를 넣기
<Qookie {...bakeProps} />
</BakeSize>
);
}
BakePng.ts
import html2canvas from 'html2canvas';
const ImgType = 'image/png';
export const bakePng = async (
element: React.RefObject<HTMLElement> | null,
) => {
if (!element || !element.current) return null;
try {
const canvas = await html2canvas(element.current, {
...defaultOptions,
useCORS: true,
});
return canvas.toDataURL(ImgType, 1);
} catch (err) {
console.log('img download fail error', err);
}
};
DOM 캡처를 구현하는 방법을 배울 것이라 생각하고 시작했지만, 결국 CORS 에러를 다루는 방법을 더 많이 학습한 것 같다. 또한 프론트에서 나는 에러를 혼자서 해결하려고 하기 보다는 백엔드와 함께 고민하면서 해결하기 위해 노력한 점이 프로젝트를 무사히 끝낼 수 있게 이끌었던 것 같다.