2차 프로젝트 어몽어스 (Among Earth) 회고

midohree·2021년 1월 27일
0
post-thumbnail
post-custom-banner

어몽어스 (Among Earth)

Among Earth는 스트리트 뷰 이미지를 이용해 가상 여행을 경험할 수 있는 웹 애플리케이션 입니다.

배포 주소 👉 https://www.among-earth.site
깃헙 바로가기 👉 https://github.com/among-earth

이 글의 목적

프로젝트 진행 기간 동안 있었던 이슈 사항이나 사건들을 되짚어보는 시간을 갖고자 회고록을 작성한다. 2주 동안의 짧은 프로젝트 준비 기간이였지만 너무 많은 시행착오들이 있었기에 전반적인 상황을 되돌아 볼 기회가 없었다. 프로젝트가 끝난지 한달이 넘어가지만 아직도 미처 구현시키지 못 한 부분들이 마음의 짐으로 남아있기에 프로젝트를 멀리서 바라보는 관점으로 회고록을 작성해보고자 한다.

내가 왜 이 프로젝트를 시작하게 되었는지부터 무엇을 알게 되었고 배웠는지, 코드 작성은 어떻게 했는지, 설계한 계획과 목표를 어느정도 달성했는지, 회고록을 작성하면서 나 스스로와 프로젝트를 점검하고자 한다.

프로젝트의 시작

나는 여행을 굉장히 좋아한다. 대학을 다닐때는 방학에 맞춰, 대학을 졸업 한 후로는 1년에 최소 두 번은 연례행사처럼 여행을 다녔다. 언제부터였는지는 모른다. 파워 집순이인 나는 집에 있는게 좋다가도 문득 떠나고싶어 좀이 쑤시고 다음 여행은 언제 어디로 갈지 계획부터 세우고 보는 사람이 되었다. (사실 일주일 전에 티켓을 예약해 떠나는 무계획 급여행도 즐겨했지만..)

그렇게 꾸준히 여행을 다녔음에도 불구하고 아직도 궁금한 곳이 수두룩 빽빽이다. 새로운 곳에 대한 호기심도 있지만, 친구랑 갔던 곳은 혼자 가보면 어떨지, 겨울에 가봤던 곳의 여름 풍경은 어떨지, 이미 다녀왔던 곳에 대한 향수(?)로 사진첩을 돌아보기 일쑤였다. 물론 이제 취업을 하고 나면 전처럼 지속적으로 여행을 다니긴 힘들겠지만, 어쨋든 여행은 나에게 꼭 해야 하는 필수적인 압박이 아닌 늘 함께하는 일상이나 마찬가지이다.

1차 프로젝트를 진행하는 동안 여행이 너무 가고싶었지만 (아마 이때는 어디든 먼 풍경을 보러 가고 싶었을 때..^^), 바로 2차 프로젝트를 시작 했어야 하기 때문에 집에서 내가 핀 해놓은 곳의 스트리트뷰를 보면서 대리만족했다. 더군다나 코로나 바이러스로 언제쯤 다시 해외 여행을 갈 수 있을지 모르는 상황이였기 때문에 더 암담(?)해 했었다. 이때

'나와 같은 집구석 여행자들에게 다시 여행을 떠나는 듯한 기분을 제공해줄 수 있는 프로젝트를 만들면 어떨까?'

라는 생각이 머리를 스쳤고, 아이디어 컨펌을 받은 후에 프로젝트의 상세한 계획들을 짤 수 있었다.

프로젝트 타임라인

프로젝트를 진행하면서 있었던 주요 이슈들을 시간순으로 정렬해보았다. 한 기능이 완료되면 다음 기능을 구현했으니 어찌보면 기능순(?) 이라고 봐도 무방 할 것 같다. 아래의 타임라인을 요약하자면 구글 스트리트뷰 이미지를 받아와 이를 처리하는 과정에서 있었던 일과 배운점에 대한 이야기들이다.

배열에서의 비동기 작업과 병렬처리

구글 스트리트 뷰의 정적이미지 경로를 응답으로 받기 위해, 여행경로에 있는 포인트들의 위도와 경도, 카메라 각도를 매개변수로 설정해놓은 HTTP URL들을 경로 순서대로 배열에 담아두었다. 문제는 이 배열에 있는 경로들이 구글에 요청을하고 응답을 받을 때, 배열의 순서가 보장된 형태로 응답을 받아야만했다. 그래야 스트리트 뷰 이미지가 연결되게 보여지기 때문이다. 또한 모든 URL들의 요청을 병렬로 처리하고, 모두 응답이 완료 되었을 때 나머지 로직들을 처리하게끔 하고싶었다.

Promise.all은 요소 전체가 프로미스인 배열(이터러블한 객체)를 받고 새로운 프로미스를 반환한다. 배열 안 프로미스가 모두 처리되면 새로운 프로미스가 이행되는데, 각각의 프로미스의 결과값을 담은 배열이 새로운 프로미스 result가 된다. Promise.all은 병렬적으로 promise들을 처리하지만 실행 순서는 보장하지 않는다. (만약 프로미스가 순차적으로 실행됨을 보장 받고 싶으면 reduce를 함께 사용 해 줄 수 있다.) 하지만 반환되어지는 프로미스의 이행 값은 입력값으로 주어진 프로미스의 순서와 일치하며 완료 순서에 영향을 받지 않는다.

때문에 내 상황에서는 반환되어지는 배열의 순서가 원 배열의 순서와 같느냐가 중요했기 때문에 promise.all을 사용했다. Promise.all을 사용해 결과로 오는 배열의 순서는 Promise.all에 전달되는 프로미스 순서와 상응한다. 비슷하게 Promise.allSettled() 도 있지만, 나는 요청이 하나라도 실패하면 사용자가 다시 시도하게끔 하고싶었기 때문에 Promise.all()을 사용하기로 했다.

따라서 이 문제는 아래와 같은 순서로 처리하였다.

  1. fetch를 사용해 url을 프로미스로 매핑했다.
  2. Promise.all은 모든 작업이 이행될 때 까지 기다린다.
  3. 배열 내의 모든 Promise가 통과하면 Promise 배열을 반환한다.
  4. 반환된 Promise result를 for..of문을 사용해 처리한다.
const getAllImagePaths = async urls => {
  let copyPaths = [];

  try {
    const requests = urls.map(url => fetch(url));
    const results = await Promise.all(requests);

    for (let result of results) {
      const { url } = result;
     
      copyPaths.push(url);
    }

    return copyPaths;
  } catch (err) {
    const { response } = err;

    if (response) alert(MESSAGES.GET_PHOTOS_FAIL);
  }
};

내 프로젝트 내에선 배열을 이용해 비동기로 데이터를 받아오거나 요청하는 작업들이 많았다. 기본적으로 앱 자체가 구글 API 기반으로 되어있기 때문에, (Google Maps API, Google Directions API, Google Satic Streetview API 등등 구글 API를 정말 많이 사용했다.) 그래서 각 기능과 각 상황에 맞게 배열을 처리 하는 방법을 달리 했는데, 이렇게 외부 API를 통해 데이터를 어떻게 요청하고 처리할 것인지 고민했었던게 제일 재밌지 않았나 싶다. 콘솔에 내가 원하는 데이터가 올바르게 출력되었을 때의 희열감이란.. 대부분의 비동기 요청은 Axios를 사용했는데, 다음엔 axios말고 다른 라이브러리도 사용해보고싶다.

캔버스 애니메이션 Frame per Speed (Fps) 조절하기

1차 프로젝트에서도 캔버스를 사용하긴 했지만, 여전히 캔버스를 다루는 일은 익숙하지 않다. (현업에서 캔버스를 이용한 동적인 웹이나 다양한 인터렉션 코드는 어떻게 짜여있는지가 매우 궁금한 1인..) 내 프로젝트에서는 응답으로 받은 url들을 캔버스에 gif처럼 보여지게끔 이미지가 한프레임씩 넘어가도록 구현했어야 했는데, 큰 주요 포인트는 두가지였다.

  1. 이미지가 한 프레임 당 한 장씩 보여져야 한다.
  2. 애니메이션 타이밍을 컨트롤 할 수 있어야 한다.

아래는 캔버스에 이미지가 한장씩 나타나게 하는 로직이다. 캔버스에 애니메이션이 동작하게 하기 위해서는 requestAnimationFrame() 메서드(이하 rAF)의 인자로 다음으로 그려질 애니메이션을 콜백으로 넣어 호출하면 된다.

raF의 콜백으로 들어가는 render() 함수 내부에서 아래의 draw()를 실행시키고, 인자로 받은 프레임카운트가 증가할 때마다 이미지 경로가 담겨있는 배열의 인덱스가 똑같이 증가 되게끔 설정했다. 또한 CORS를 통하지 않고 다른 origin으로 가져온 외부 출처 데이터들을 바로 canvas에 그리면 canvas는 데이터가 안전하지 않은 것으로 여겨 보안오류가 발생 할 수 있기 때문에, crossOrigin을 설정해줘야한다. crossOrigin 속성을 'anonymous'로 설정하여 교차 출처의 인증되지 않은 다운로드를 허용하도록 구성했다.

const draw  = (canvas, context, paths, frameCount) => {
  const img = new Image();

  img.setAttribute('crossorigin', 'anonymous');
  img.src = paths[frameCount];

  let scale = Math.max(canvas.width / img.width, canvas.height / img.height);

  context.drawImage(img, 0, 0, img.width * scale, img.height * scale);
};

여기까지 하니 프레임당 이미지가 하나씩 나오긴 하지만, 문제는 이미지들이 너무 빨리 업데이트 된다는 것이었다. 정말 눈 깜짝하면 이미 한 턴이 끝나있을 정도로..(울음)
왜냐하면 애니메이션을 구현하기 위해 setTimeout, setInterval을 사용했던 전과 달리 rAF는 일관된 프레임속도로 실행되게끔 최선을 다 하는게 목표이기 때문에 컴퓨터 성능에 따라 "가능한 빨리" 움직이려고 하기 때문이다.

그래서 raF의 콜백 함수로 시간을 측정해주는 로직을 추가해주었다. 페이지의 탐색 시작부터 측정된 고해상도 시간인 DOMHighResTimeStamp를 사용했는데 이 DOMHighResTimeStamp는 밀리초로 측정되고 밀리초의 1000분의 1까지 정확하게 측정이 가능하다. 시간이 아닌 메인 컨텍스트를 기준으로 측정을 하기 때문에 초당 프레임과 각 프레임 사이의 간격을 컨텍스트가 실행되는 시간차를 계산해 설정 할 수 있다. 현재 시간과 비교하려면 현재 시간에 대해 window.performance.now()를 사용하면 된다.

즉, requestAnimationFrame에 애니메이션 프레임이 발생하도록 예약된 시간을 나타내는 단일 인수를 콜백 함수로 반환시켜줬고, 현재 예약된 시간에서 이전에 예약 된 시간을 빼서 작은 시간 증분을 계산 해주는 로직을 작성해 구현시켰다.

let frameCount = 0;
let animationFrameId;
let interval, now, newtime, then, elapsed;

const startAnimating = fps => {
  interval = 1000 / fps;
  then = window.performance.now();
  
  render(newtime);
};

const render = newtime => {
  now = newtime;
  elapsed = now - then;

  if (elapsed > interval) {
    then = now - (elapsed % interval);
    context.clearRect(0, 0, canvas.width, canvas.height);
    frameCount++; // 프레임 카운트를 1씩 증가시켜 draw 함수 내부에서 이미지 프레임이 넘어가게 한다.

    draw(canvas, context, paths, frameCount);

    if(frameCount === paths.length - 1) frameCount = 0; // 이미지 턴이 끝나면 프레임을 0으로 재할당시켜 다시 시작하게 한다.
  }

  animationFrameId = requestAnimationFrame(render);
};

startAnimating(20); // 애니메이션이 프레임당 실행될 속도를 정해준다. (FpS)

이 과정이 프로젝트를 진행하면서 제일 애를 먹은 곳이 아닐까 싶다. 초기 계획과 다르게 계획을 변경해서 진행했던터라 '이게 맞는건가?' 라는 생각이 머릿속에서 떠나질 않았다. 시간적 부담과 상황에 맞춰 구현을 하긴 했지만 좌표수가 많아지면 애니메이션이 아예 뜨지를 않는 치명적인 약점을 갖고있다.

그래서 애니메이션이 나오지 않으면 에러를 잡아 에러 컴포넌트를 사용자에게 보여줘 다른 루트를 선택하게끔 하고 싶었다. 그런데 캔버스 애니메이션이 작동되는지 안되는지를 확인하는 것 조차도 어려웠다. 코드 전반적으로 여기저기에 콘솔을 찍어봤을 때, 콘솔에는 애니메이션이 작동 중인 듯이 이미지 경로가 계속 바뀌지만 화면상으론 아무런 애니메이션이 작동하지 않았다.

다시 뒤엎고 초기 계획으로 돌아가거나 다른 플랜을 짜야 하나 고민을 많이 했었다. 추후에는 경유지를 선택할 수 있는 가짓수를 줄이고, 추천해주는 근처 랜드마크의 반경 미터 거리도 줄여서 최대한 애니메이션이 작동 할 수 있는, 최단거리와 최소좌표수를 사용자가 선택할수 있게끔하는 방향으로 UI를 변경시켰다. 물론 이렇게 했음에도 커브길이 많다던지, 거리가 조금 길어진다던지 하면 애니메이션이 작동하질 않는다. 그래도 UI를 변경함으로 1차 예방정도는 했다고 생각한다. 하지만 기능 구현이 되지 않았다고 UI를 변경 시키는게 옳은건가..? 라는 생각엔 여전히 의문이다.

캔버스 이미지를 서버로 전송하기

캔버스에 이미지들이 잘 나오니 캔버스의 이미지를 서버로 전송시키는 기능을 구현해야 했다.

toDataURL() 을 통해서 캔버스의 데이터를 받아온다. 이 메서드를 사용하면 캔버스 영역을 base64로 즉시 반환한다. 리턴되는 값은 'data:image/png;base64' 로 시작되는 base64 문자열로 인코딩된 아주 긴~ 값이다. base64는 미디어를 통해 전송되거나 저장되어야 하는 바이너리 데이터를 인코딩할 때 일반적으로 사용된다. 따라서 이 값을 img 태그에 사용해 부여해줄 수도 있고, 데이터베이스에 파일 대신 저장시키는 것도 가능하다.

그럼 이 데이터값을 어떻게 파일화 시켜야 할까?? 우선 base64로 인코딩 되어있는 데이터를 디코딩 시켜준다. 그 후 Blob 객체에 디코딩한 base64 데이터 값을 FormData()에 담는 방식으로 구현한 후 서버로 전송시켰다. 서버에서는 req.file로 데이터를 확인 할 수 있다.

추가적으로, axios로 폼데이터를 서버로 전송 시킬 때 헤더에 processData: false contentType: false 를 추가해줬다. proceessData 옵션을 false로 명시하지 않으면 FormData에 추가한 파일데이터가 string값으로 변환되어 전송이 된다. 또한 직접 formData 객체를 만들어 전송시키는거기 때문에 contentType: false로 설정해줘야지 그렇지 않으면 기본값인 application/x-www-form-urlencoded;로 인식해 파일 데이터 그대로 서버에 전송시키기가 어려울 수 있다.

const sendBlobImage = async (canvasRef, travelId, points) => {
  const canvas = canvasRef.current;
  const dataUrl = canvas.toDataURL('image/png');

  let blobBin = atob(dataUrl.split(',')[1]);
  let fileArr = [];

  for(let i = 0; i < blobBin.length; i++) {
    fileArr.push(blobBin.charCodeAt(i));
  }

  const file = new Blob([new Uint8Array(fileArr)], { type: 'image/png' });
  
  let formData = new FormData();
  formData.append('travelImage', file, `${travelId}.png`);
  formData.append('points', JSON.stringify(points));

  try {
    await axios({
      method : 'POST',
      url : `/travels/${travelId}`,
      data: formData,
      headers: {
        'processData': false,
        'contentType': false,
      },
    });
  } catch (err) {
    const { response } = err;
    if (response) alert(MESSAGES.PHOTO_FAIL);
  }
};

마지막으로,

처음 어몽어스의 계획은 구글로부터 스트리트 뷰 정적이미지 API를 통해 이미지 경로를 받아와서, 이를 이미지 파일로 전환 한 후 gif 파일로 만들어 유저가 볼 수 있게 구현하는거였다. 가능하다면 gif 파일을 다운로드 할 수도 있고, 여행 종료 후 모든 유저들의 gif를 giphy 홈페이지 처럼 구경 할 수 있게 하려던게 원래 계획이였다.

하지만 실제 파일 데이터가 아닌 구글 API로 받은 문자열로만 이루어진 이미지 경로들을 gif로 변환하려고 하니 이미지들을 파일화시켜 데이터베이스 어디엔가 저장시켜놓고, 저장시켜놓은 이미지들을 모두 다시 서버로 불러와서 gif로 변환하는 작업을 하는게 과연 효율적인걸까? 하는 생각이 들었다. 기본적으로 한 여행 애니메이션을 보여주기 위해서는 최소 50장의 이미지가 쓰이니깐 데이터베이스를 불필요하게 낭비하는 것 같다 라는 생각이 들었다. 그래서 캔버스에 이미지들을 한 프레임씩 보여주고, 이 캔버스 데이터를 서버로 전송시켜 데이터베이스에 저장하는 흐름으로 계획을 전면 수정했다.

계획을 수정함으로써 오는 또다른 이슈들이 있었다. 우선 폼데이터로는 이미지 한 장만 가져 올 수 있었기 때문에 AWS s3에 이미지를 업로드 시키면서 이미지 경로가 담긴 배열을 이미지랑 같이 업로드 시키면, 추후에 파일을 가져왔을 때 같이 받은 데이터를 가공할 수 있을 거란 생각을 했지만, 이미지와 함께 다른 데이터를 전송시키는게 불가능했다. 이럴거면 굳이 AWS s3를 사용했어야 하나 라는 생각이다. 물론 파일을 업로드하기엔 최적화되어있지만, 나는 파일에 해당되는 관련 정보들을 함께 업로드 시키고 싶었기 때문에 무리가 있지 않았나 싶다.

그래서 정보저장용 데이터베이스를 하나 더 둘까 했지만, 그럴거면 초기 계획대로 했어도 될 것 같다. 물론 이것도 해보기 전까진 모른다.

팀으로 진행했던 저번 프로젝트와는 달리 혼자 모든 계획 설계부터 구현까지 진행하려고 하니 벅찼던건 사실이다. 내가 설계한 계획이 맞는지 확신이 없었기 때문에 프로젝트 하면서 원활한 소통과 토의와 토론을 즐겨하던 나의 전 팀원들.. 그리고 동기들이 정말 그리웠다. 지금 생각해보면 조금은 겁쟁이(?)의 태도로 프로젝트를 진행했던 것 같기도 하다. 하지만 불확실하지만 내가 결정한 것에 대한 후회는 없다. 무작정 겁만 먹고 아무것도 하지 않으면 진전이 될 수 없다는걸 알기에..

그래서 탄탄하고 세세한 초기 기획 설계와 이를 기반으로 한 정확하고 빠른 기능 구현 이 이번 프로젝트에서 제일 아쉬웠던 점이다. 나름 세세하게 짰다고 생각했음에도 불구하고 허술했고, 다음 프로젝트를 한다면 어떤 식으로 계획을 짤지 조금 더 확실하게 느꼈다. (다음 프로젝트를 할땐!!! 조금 덜 아쉽게 할 수 있어!!!)

어찌되었든 겁쟁이 김도희였지만 온전히 내 몫인 일을 무사히 잘 끝마쳤다는데에 스스로에게 박수를 주고싶다. 그리고 2주간의 희노애락을 함께 한 어몽어스야 이제 안녕!

post-custom-banner

0개의 댓글