[FE] html2canvas를 사용하여 DOM 캡처 구현하기 | AWS CORS 에러

쭈리·2023년 12월 10일

FE

목록 보기
2/10
post-thumbnail

✏️ 요약

React + typescript 환경에서
html2canvas 라이브러리를 활용하여
AWS S3에서 불러온 이미지를 포함한 DOM 캡처 구현하기

🎨 구현 화면

이 프로젝트는 쿠키 아이템들 (몸통, 눈코입, 모자, 옷, 악세사리 등)을 모두 S3에 저장하고 링크를 받아서 띄워주는 형태로 구현했다. 그리고 쿠키 컴포넌트를 만들어서 position을 사용해서 각자의 위치에 맞게 위로 쌓아올려서 옷 입히기를 구현했다.

캡처 페이지(굽기 클릭 시 실행) 구워진 캐릭터는 하나의 png로 저장되어서 AWS에 저장

최종 50레벨에 도달하면 캐릭터를 굽고 새로운 반죽을 생성할 수 있는데, 이 때 구워진 캐릭터는 내가 만든 쿠키 페이지에서 열람할 수 있었다.

❓ 만약 내가 만든 쿠키 페이지에서 이전에 구운 쿠키들의 의상 목록을 각각 url로 받아서 띄운다면 어떻게 될까?

당연히 너무 느린 로딩 문제가 있을 것이다.
또한 어차피 이제 바꿔 입힐 수 없는데 각각의 url로 받아올 필요가 없었다.
그래서 여러 이미지를 쌓아올린 페이지를 하나의 png로 변환해서 S3에 저장하고 불러오는 방식으로 구현하기로 했다.

😓 발생한 문제

처음 React DOM 캡처를 위해 라이브러리를 찾아볼 때까지는 큰 문제가 없으리라 생각했다.
(하지만 정말 오랜 시간이 들어서 해결할 수 있었다...)

사용한 라이브러리는 html2canvas 이다.
대표적인 라이브러리로 dom-to-imagehtml2canvas가 있었다. 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 에러를 다루는 방법을 더 많이 학습한 것 같다. 또한 프론트에서 나는 에러를 혼자서 해결하려고 하기 보다는 백엔드와 함께 고민하면서 해결하기 위해 노력한 점이 프로젝트를 무사히 끝낼 수 있게 이끌었던 것 같다.

profile
화면 아래에 논리를 펼치는 프론트엔드 엔지니어 🐥

0개의 댓글