개요
마실가실 개발이 한창이다. 벌써 다음 주가.. 최종발표..? 산책 경로를 썸네일로 저장하는 작업을 진행하면서 발생한 이슈에 대해 다뤄보려 한다. 유저가 산책을 기록 또는 공유한 후, 해당 산책로를 시각적으로 확인할 수 있는 이미지를 제작하는 로직이 필요했다. 썸네일 생성을 자동화하고자 한다고도 볼 수 있을 것 같다. 여튼 해당 로직을 구현하기 위해 유저가 산책을 완료한 후, 카카오맵 API를 통해 그려진 경로 데이터를 활용하고자 했다.
발생한 문제
위 사진은 마실가실에서 산책이 완료된 시점 지도에 경로가 그려진 모습이다. { 위도, 경도 }[] 형태로, 일정 시간마다 GeoLocation을 이용해 데이터를 담는다. 이 모습을 온전히 캡쳐하여 썸네일로 사용하면 끝이잖아?라는 생각과 함께 이전 챗팟 프로젝트에서 사용한 HTML2Canvas 라이브러리를 활용하고자 했다.
이렇게 쉬웠다면 얼마나 좋았을까.. 사용중인 Map 컴포넌트에 useRef 훅을 통해 컴포넌트의 DOM 요소에 접근하고, 이를 캡쳐하고자 하였다. 그러나 카카오맵 API는 맵 폴리곤 이미지를 실시간으로 생성하여 조립하는 방식으로 지도를 렌더링하기에, DOM 캡쳐를 통해서는 원하는 형태의 소스를 얻을 수 없었다.
카카오 데브톡에도 비슷한 고민을 하신 분이 계셨어서, 해당 글의 답변이 갈피를 잡는 데에 큰 도움이 되었다. 결론은 라이브러리를 활용해 카카오맵을 캡쳐할 수 없다는 것이다. 폴리곤 이미지를 직접 조립한 후 그 위에 경로를 그려줘야 한다. 또한 저작권 문제로 추가적인 워터마크까지 그려줘야 한다. 조립 후 좌표를 맞춰 산책 경로를 그려주는 건 내 능력 밖이기에.. 다른 방안을 생각해보았다.
해결방안 모색
첫 번째, 카카오맵 API의 정적 맵 기능을 활용한다.
두 번째, CanvasAPI를 활용해 사용자의 산책경로를 직접 그려 이미지로 추출한다.
정적 맵 기능을 활용하는 방법은 몇 가지 문제가 있었다. 해당 요소가 필요할 때마다 맵 컴포넌트를 렌더링해야 하므로, 네트워킹이 굉~장히 무거워진다. 또한 마커 기능을 지원하지 않아 산책 경로를 그려줄 수 있는 방법이 없다. 따라서 두 번째 해결 방안인 CanvasAPI를 활용해보기로 했다. 로직도 무겁지 않고, 저작권 문제에서도 자유롭다.
적용
그래서 산책경로 위치 배열을 담고 있는 데이터를 입력받고 썸네일 이미지를 반환하는 drawPath 함수를 만들어주었다. 127, 35 주변의 값으로 이루어진 위치 데이터를 캔버스에 그리기 위해서는 변환이 필요했다. 400x400 캔버스를 2차원 좌표로 바라보고 스케일링을 진행해주었다. 코드 전문은 🔗이 곳을 통해 확인할 수 있다. 단계별 로직은 아래와 같다.
먼저 입력받은 위치 배열을 통해 각 축의 최소, 최대 값을 찾아주었다.
for (const p of path) {
minLat = Math.min(minLat, p.lat);
maxLat = Math.max(maxLat, p.lat);
minLng = Math.min(minLng, p.lng);
maxLng = Math.max(maxLng, p.lng);
}
이 값의 오차를 캔버스 크기(400x400)와 나누어 비율을 구해주었다. 경로가 너무 붙어있는 것을 방지하기 위해 오프셋을 조금 주었다.
const CANVAS_SIZE = 480;
const CANVAS_OFFSET = CANVAS_SIZE * 0.2;
const scaleX = (canvas.width - CANVAS_OFFSET * 2) / (maxLng - minLng);
const scaleY = (canvas.height - CANVAS_OFFSET * 1.5) / (maxLat - minLat);
이후 입력받은 모든 위치 배열 데이터를 구해놨던 비율과 곱한 후, 캔버스에 그려주었다.
if (path.length === 1) {
x = canvas.width / 2;
y = canvas.height / 2;
} else {
x = CANVAS_OFFSET + (path[i].lng - minLng) * scaleX;
y = canvas.height - CANVAS_OFFSET - (path[i].lat - minLat) * scaleY;
}
// 경로 그리기
if (i === 0) {
pathCanvas.moveTo(x, y);
} else {
pathCanvas.lineTo(x, y);
}
산책이 완료될 때마다 drawPath 함수를 호출해 경로에 따른 이미지를 생성하고, 이를 기본 썸네일로 지정하여 서버에 전송하게 된다.
const pathCanvas = drawPath(newData.path);
pathCanvas?.canvas.toBlob((file) => {
if (!file) {
return;
}
imageMutation.mutate(file, {
onSuccess: ({ imageUrl }) => {
newData["thumbnailUrl"] = imageUrl;
logUploadMutation.mutate(newData, {
onSuccess: (res) => {
const { id } = res;
router.push(`/log/${id}`);
closeLoadingSpinner();
setModalView("LOG_RECORD_DONE_VIEW");
openModal({
onClickUploadPost: () => {
router.push(`/post/create?logId=${id}`);
closeModal();
},
onClickCancel: () => {
closeModal();
},
logData,
});
}, ...
사실 프로젝트 기획 단계부터 해당 기능이 우리 팀에 있어 꽤 중요한 챌린지 요소였다. 그래도 우려했던 것보단 가벼운 로직으로 나쁘지 않은 결과물이 나온 것 같다고 생각한다. 문제에 대한 해결책을 한 걸음 뒤로 물러나 찾아보는 것도 중요하다는 것을 알 수 있었던 좋은 기회이지 않았나 싶다.