스크래치 갤러리 : Nudake 스타일 Canvas 인터랙션 제작기

스크래치 갤러리 : Nudake 스타일 Canvas 인터랙션 제작기

이벤트 동작, 배경 처리, 모바일 환경 및 성능 최적화 등을 작업한 방법들에 대하여

·

6 min read

해당 작업은 Nudake 공식사이트의 인터렉션과 페이지 구조를 참고해 만들었습니다.



🍰 Nudake

  • 사이트명: 누데이크

  • 제작기간: 2024.04 ~ 2024.04

  • 주요 기술 스택: HTML, CSS, JavaScript, Canvas

  • 주요 라이브러리: GSAP, Lodash

  • 대상 플랫폼: PC 및 모바일 반응형


🔗 LINK


📍 POINT

웹과 모바일에서 사용되는 이벤트 차이 (mouse/touch)

이번 프로젝트는 canvas를 활용해 마치 복권을 긁는듯한 스크래치 갤러리를 만들어 보았다. 웹 환경에서는 mousedown → mousemove → mouseup 이벤트의 차례로 해당 동작을 구현할 수 있었는데, 모바일 화면에서는 전혀 동작하지 않았다. 알아보니 canvas는 모바일 환경에서 mouse 관련 이벤트가 아닌, touch 관련 이벤트를 사용해야 한다는 것을 새롭게 알게 되었다. 각 마우스 이벤트를 touchstart → touchmove → touchend 로 변경해줬다.

모바일 디바이스 여부 알아내기

이벤트 리스너를 디바이스 환경에 따라 적용하기 위해서는 우선 현재 접속한 환경이 모바일 환경인지 아닌지를 판별하는 것이 가장 중요했다. 아래와 같이 모바일 환경에서는 모바일용 이벤트 리스너를, 그렇지 않은 경우에는 일반적인 이벤트 리스너를 적용해 해결할 수 있었다.

function isMobileDevice() {
  const userAgent = navigator.userAgent;
  const mobile = /(iPhone|iPad|Android|BlackBerry|Windows Phone)/i.test(
    userAgent
  );
  return mobile;
}

if (isMobileDevice()) {
    window.addEventListener("touchstart", onMouseDown);
  } else {
    window.addEventListener("mousedown", onMouseDown);
  }

캔버스 지우개 효과 : globalCompositeOperation

캔버스를 이용한 지우개 효과는 기존의 배경을 지우는 것이 아니라, 오히려 그림을 그리는 개념으로 접근해야 한다. 서로 다른 이미지를 미리 겹쳐두고 캔버스 API인 globalCompositeOperation의 속성을 destination-out으로 설정하면 그림을 그린 부분이 투명하게 처리되어 뒷 배경이 비치게 된다.

source-over (default)
destination-out
출처 : w3schools

포인터의 절대좌표 구하기

현재 누르고 있는 지점에 도형을 그리기 위해서는 해당 좌표값이 필요한데, 처음에는 offsetX,Y의 값으로 작성을 했다가 전혀 예상하지 못한 구간에 도형이 그려지는 현상을 발견하게 되었다.

현재 프로젝트는 위와 같은 구조로 main 영역이 전체 화면을 차지하면서 header, footer가 absolute로 main 영역 위에 위치하고 있다. 때문에 이벤트가 발생할 때 main이 아닌 영역에 포인터가 닿으면 해당 영역의 박스기준으로 상대좌표 값을 받게되고 해당 좌표가 window를 기준으로 도형이 그려지면서 의도한대로 표현이 되지 않았던 것이다.

offsetX,Y 대신 clientX,Y를 사용하면 브라우저 페이지 내에서 스크롤을 무시하는 페이지의 좌측상단을 0으로 측정한 절대좌표 값을 반환해 문제를 해결할 수 있었다.

점을 선으로 만들기

function drawCircles(e) {
  ctx.globalCompositeOperation = "destination-out";
  ctx.beginPath();
  ctx.arc(e.clientX, e.clientY, 10, 0, Math.PI * 2);
  ctx.fill();
  ctx.closePath();
}

기존에 작성했던 drawCircles 함수는 일반 동작을 수행하는데 문제는 없지만, 여러 점이 이어져 선처럼 보이게 연출한 것이기 때문에 빠른 속도로 mousemove/touchmove 이벤트를 작동시키면 하나의 점으로 떨어져 보이는 현상이 발생했다.

이를 해결하기 위해서는 포인터가 지나간 경로의 이전 좌표와 다음 좌표를 기억하고 두 좌표 간의 거리 내에 존재하는 좌표들에 모두 점을 그려주는 방식으로 해결할 수 있었다. (해당 부분은 수학적 이해가 많이 필요하기 때문에 외부의 도움을 받았다 🥹) 이제 빠른속도로 이동해도 부드러운 선을 표현할 수 있게 되었다.


페이지 로딩 최적화를 위한 이미지 프리로드 기능 구현

기존에 작성된 drawImg 함수를 사용하게 되면 아래처럼 배경 이미지의 전환이 발생할 때 마다 새로운 이미지를 호출하게 된다. 요즘은 대부분의 인터넷 환경이 좋기 때문에 큰 문제가 되지는 않지만, 만약 속도가 느릴 경우 또는 CPU 성능이 잠시 저하 되었을 때 이미지 로딩이 완료되기 전에 다음 이미지로 설정되어, 로딩이 늦어지면 현재 이미지와 다음 이미지가 겹치는 현상이 발생했다.

개발자 도구 - 네트워크 탭
function drawImg() {
  const img = imgs[currentIdx];
  const firstDrawing = ctx.globalCompositeOperation === "source-over";
  ctx.globalCompositeOperation = "source-over";
  const nextImage = imgs[(currentIdx + 1) % imgs.length];
  canvasParent.style.backgroundImage = `url(${nextImage})`;
}

drawImg 함수에서는 이미지를 로드하는 작업과 동시에 다음 이미지를 설정하고 있기 때문에 배경 이미지를 불러오는 과정이 꼬이지 않도록 화면이 로딩 될 때 전체 이미지를 비동기로 미리 preload 시키는 방법을 사용했다. 모든 이미지의 로드가 완료된 후에 다음 단계로 진행할 수 있도록 수정했고이제 더 이상 새로운 이미지를 호출하지 않고도 잘 작동한다.

function preloadImages() {
  return Promise.all(
    imgs.map((src) => {
      return new Promise((resolve, reject) => {
        const img = new Image();
        img.onload = () => resolve(img);
        img.onerror = reject;
        img.src = src;
      });
    })
  ).then((images) => {
    loadedImgs.push(...images);
  });
}

async function init() {
  // 생략
  await preloadImages();
  drawImg();;
}

GSAP를 활용한 부드러운 사용자 경험 제공

이미지가 로딩되거나 배경 이미지가 전환될 때 GSAP을 활용하여 opacity를 조절함으로써 이미지 간 부드러운 전환 효과를 구현했다. GSAP은 다양한 콜백 함수를 제공하여 애니메이션의 시작, 진행, 완료 시점 등에서 추가 작업을 수행할 수 있다. 현재 코드에서는 onComplete 콜백 함수를 활용해 애니메이션이 완료된 후에 추가 작업을 수행하고 있다.

function drawImg() {
    isChanging = true;
  // 생략
  gsap.to(canvas, {
    opacity: 0,
    duration: firstDrawing ? 0 : 1,
    onComplete: () => {
      canvas.style.opacity = 1;
      ctx.globalCompositeOperation = "source-over";
      const nextImage = imgs[(currentIdx + 1) % imgs.length];
      canvasParent.style.backgroundImage = `url(${nextImage})`;
      isChanging = false;
    },
  });
}

GSAP은 여러 브라우저에서 일관된 애니메이션 실행을 제공하여 크로스 브라우징을 보장하고, 성능을 최적화해 사용자 경험을 향상시킨다. 이러한 특성으로 인해 GSAP은 웹 및 앱 개발에서 사용자 경험을 향상시키기 위한 라이브러리로 매우 유용하게 사용될 수 있다. (적극추천)


문제 해결 : CPU성능 최적화와 여러가지 호환성 이슈

쓰로틀링으로 함수 호출 빈도 조정하기

const checkPercent = _.throttle(() => {
  const percent = getScrupedPercent(ctx, canvasWidth, canvasHeight);
  if (percent > 50) {
    currentIdx = (currentIdx + 1) % imgs.length;
    drawImg();
  }
}, 1000);

checkPercent 함수는 move 이벤트가 발생할 때 마다 현재 화면에서 지워진 영역이 50%이상 되는지 검증하기 위해 호출이 되는데, 짧은 시간 내에 여러번 호출되어 CPU 사용량에 무리를 주게된다. 쓰로틀링을 사용하면 최고수치가 이미지가 전환되는 순간에 30%대에 그치지만, 쓰로틀링을 사용하지 않으면 90%대는 우습게 가는 것을 확인할 수 있다.

쓰로틀링 X
쓰로틀링 O

카카오톡 인앱 브라우저 스크롤 이슈

아래에 첨부된 GIF를 보면 알 수 있듯, 카카오톡 내 브라우저에서 사이트를 열면 touchmove 이벤트가 아닌 브라우저 자체가 들썩이며 계속해서 resize 이벤트가 발생했다. 문제는 디스코드 등 다른 모바일 인앱 브라우저 환경에서는 다 작동을 잘했다는 것이다. 😇

구글링을 해보면서 얻은 조언으로 여러가지 방법들이 있었지만 모두 해결이 되지 않았다.

  1. 100vh를 사용한 것이 문제다 JS에서 따로 vh계산을 해서 대응하라 → 안됨

  2. body에 특정 이벤트 들을 막아서 해결해라 → 기존 동작도 작동 안해버림, 안됨

카카오 인앱 브라우저에서는 기존 기능 사용 X
카카오 인앱 브라우저에서 외부 브라우저로 이동

그렇게 막막해 하고 있던 찰나 새로운 돌파구를 찾게 되는데 특정 인앱 브라우저에서 해당 웹페이지가 열리게 되면 바로 외부 브라우저를 호출 해버리는 것이다. (번개애비님 블로그 글이 아주 큰 도움이 되었다.) 아래와 같은 코드로 해당 이슈는 마무리 했다.

function goToExternalBrowser() {
  const userAgt = navigator.userAgent.toLowerCase();
  const target_url = "<https://nudake-canvas.vercel.app/>";

  if (userAgt.match(/kakaotalk/i)) {
    location.href =
      "kakaotalk://web/openExternal?url=" + encodeURIComponent(target_url);
  }
}

export { goToExternalBrowser };

CSS Nesting 호환성 이슈

그렇게 성공적인 프로젝트 마무리를 하나 싶었는데, 의외의 복병이 발생하게 된다. 바로 아이폰 12pro 기종을 사용하고 있던 동생의 사파리 브라우저에서는 사이트가 모두 깨져서 보이는 것 🤦‍♀️ 원인이 뭘까 고민해보았지만, 주변 지인들의 환경에서는 문제가 없었기 때문에 혹시나 Nesting 구조로 작업했던 CSS가 문제가 아닐까 하고 기존 pure CSS 작성법으로 구조를 수정했다.

혹시나 했지만 역시나 원인이 맞았고 이렇게 최대한 모든 기기에서 같은 화면 보여주기를 성공했다. 이렇게 얻은 교훈 : SCSS를 사용하는 것 아닌 이상 pure Nesting 구조는 아직 사용하지 말기…


프로젝트 진행을 통한 전체적인 경험과 소감

프로젝트를 진행하면서 웹과 모바일 간 호환성 문제를 해결하는 데 고생을 많이 했다. 사용자에게 다양한 환경에서 일관된 화면을 제공한다는 것이 보통 노력이 들어가는 일이 아니구나 라는 것을 경험할 수 있었다. 기술적인 어려움을 해결하면서 문제 해결 능력과 창의성을 함께 키워갈 수 있었던 시간이었다. 앞으로도 다양한 프로젝트를 통해 더 나은 개발자로 성장하고 싶다.