23.09 / 이미지 렌더링 문제

군데군데 깨진 이미지

상황 인지

계속되던 문제, But Why?

위성 이미지를 가져오다 보면 종종 회색 타일들이 보이는 문제가 있다. 지도 로딩이 끝나지 않아서 대체 이미지가 떠 있는 상태 그대로 가져오는 것인데, 꽤나 오래 전부터 이 사실을 인지하고 있었고 해결하기 위해 다양한 방식을 도입해 보았다.

결론부터 말하자면 여전히 저 문제는 발생하고 있지만, 어느 정도는 의도한 상황이다. 에러 못 잡아서 변명하는 것 같지만, 지금부터 글을 읽다 보면 어떤 가치들을 트레이드 오프 했는지 충분히 납득할 수 있으리라 생각한다.

진행 과정

지도 생성 방식의 차이

지금 맵샷은 3가지의 지도 타입을 제공하고 있다. 그 중 한 가지(네이버)는 내가 직접 좌표 계산을 해서 위성 이미지 타일 하나하나를 호출하고 있기 때문에 에러 감지가 비교적 단순하다. Http request 성공 / 실패 여부로 확인하면 된다.

하지만 나머지 2가지(카카오, 구글)는 해당 회사에서 제공해주는 자바스크립트 라이브러리로 브라우저에서 동적으로 생성하기 때문에, 이미지 호출 성공 여부가 전적으로 해당 라이브러리 내부 구현에 달려 있다.

내가 문제를 겪고 있는 것은 후자의 방식으로 생성하고 있는 지도들이다.

첫 번째 시도, tilesloaded 이벤트

카카오 지도 API Docs

타일 이미지 로딩이 완료되면 발생하는 이벤트이며, 해당 이벤트를 감지하면 지도 로딩 여부를 판별할 수 있을 것이라 생각했다. 그러나 문제가 존재했다.

내 지도는 크다

내가 생성하는 위성 이미지는 최소 4000px, 최대 6000px의 정사각형 이미지다. 한번에 워낙 많은 지도 이미지를 호출하다 보니 특정 타일들이 아예 요청이 블락되거나 로딩이 되지 않았고, 해당 이벤트 자체가 아무리 기다려도 발생하지 않았다.

만약 사람이 직접 눈으로 보고 판단하는 상황이면 지도를 클릭한 채 몇 번 좌우로 흔들어주면 다시 지도 로딩이 실행되지만, 코드를 통해 해당 과정을 진행하던 내가 할 수 있는 방법은 딱히 존재하지 않았다.

두 번째 시도, 점진적 확장

// 지도 관련 일부 html 코드 발췌

var increaseWidth = 500;

var timerId = setInterval(function () {
    
    // 지도 이미지를 동적으로 점점 키워나간다
    
    if (dynamicWidth <= goalWidth) {
        dynamicWidth += increaseWidth;
    } else {
        clearInterval(timerId);
    }
    
    mapImage.style.height = dynamicWidth + "px";
    mapImage.style.width = dynamicWidth + "px";
                
    // 중략....
}, 1000);

작은 정사각형으로 시작해서, 커다란 정사각형으로 만들어 가는 방법을 선택해봤다. 점차 크기를 늘려간다면 지도 호출이 조금씩 계속해서 발생할 것이고, 회색 타일을 최소화 할 수 있지 않을까 하는 생각이었다.

해당 방법은 어느 정도의 성과를 거두긴 했으나, 사이즈 확장이 끝남과 동시에 크롤링을 시작하다 보니 테두리 쪽 이미지들은 미처 로딩이 끝나지 않았다.

// 지도 관련 일부 html 코드 발췌

var increaseWidth = 500;

var timerId = setInterval(function () {

    // 지도 이미지를 동적으로 점점 키워나간다
    // 이미지가 목표한 크기보다 커진다면 목표치로 다시 줄인 후
    // 생성을 종료한다
    
    if (dynamicWidth <= goalWidth && !finalLoading) {
        dynamicWidth += increaseWidth;

    } else if (dynamicWidth > goalWidth && !finalLoading) {
        finalLoading = true;
        dynamicWidth = goalWidth;
        
    } else if (finalLoading) {
        clearInterval(timerId);
    }

    mapImage.style.height = dynamicWidth + "px";
    mapImage.style.width = dynamicWidth + "px";

}, 1000);

그래서 보완한 방법은 지도 이미지를 실제 가져올 사이즈보다 더 크게 로딩한 후, 캡쳐 직전에 정사이즈로 축소해 테두리 쪽 이미지들을 버리는 방식으로 사용했다. 해당 작업으로 회색 타일 현상을 크게 개선할 수 있었다.

성능 이슈

그러나 이 방식에는 성능 문제가 존재했다. 이미지 수집 작업을 AWS Lambda에서 담당하고 있는데, interval이 1초 간격으로 발생하다 보니 6000px의 지도를 생성할 때는 지도 렌더링 시간에만 약 15초 가량이 소요되었다. 문제는 Lambda의 응답 용량 제한 때문에 이미지를 한번에 캡쳐해서 보내지 않고일정하게 분할 후 전송하는데, 해당 과정에서 AWS API Gateway의 기본 타임아웃 정책인 30s를 종종 넘어버렸다.

세 번째 시도, 선 요청 후 계산

// Lambda 코드 일부 발췌

for (let y = 0; y < goal_width; y += WIDTH) {
    for (let x = 0; x < goal_width; x += WIDTH) {
      
      // 브라우저를 스크롤하며 이동한다
      await page.evaluate((_x, _y) => {
        window.scroll(_x, _y);
      }, x, y);
    
      // 해당 영역을 캡쳐한다
      let imageBuffer = await page.screenshot({
        type: "jpeg"
      });

      let gen_uuid = uuidv4();

      // 캡쳐한 이미지를 키값과 함께 전송한다
      await axios.post(domain + "/image/storage", {
        "uuid": gen_uuid,
        "base64EncodedImage": imageBuffer.toString('base64'),
      }, {
        headers: header
      });

    
      let response = {
        "uuid": gen_uuid,
        "x": x,
        "y": y
      };

      response_arr.push(response);
    }
  }

  await browser.close();
  
  return {
    // 유저에게 키 값을 전달해준다
    body: JSON.stringify(response_arr)
  }  

타임아웃 빈도를 줄이기 위해 Lambda에서 실행되는 코드를 살펴보기 시작했다.

내가 여기서 시간을 줄일 수 있는 곳은 정해져 있었다. 한 개의 브라우저를 사용하기 때문에 윈도우를 스크롤 하며 캡쳐하는 코드는 무조건 순서대로 돌아야 한다. 하지만 axios, http 요청은 굳이 await로 응답을 기다릴 이유는 없었다.

그래서 개선한 코드는 다음과 같다.

  // Lambda 코드 일부 발췌
  
  let count = 0;
  let total_count = parseInt(goal_width / WIDTH) * parseInt(goal_width / WIDTH);

  for(let y = 0; y < goal_width; y += WIDTH){
    for(let x = 0; y < goal_width; x += WIDTH){
      // 중략...
      
      // 나중에응답이 오면 카운트를 증가시킨다
      
      axios.post(domain + "/image/storage", {
        "uuid": gen_uuid,
        "base64EncodedImage": imageBuffer.toString('base64'),
      }, {
        headers: header
      })
      .then(function (response) {
        count++;
      })
      .catch(function (error) {
        count++;
      });
      
      // 중략...
    }
  }
  
  await browser.close();
  
  // 카운트가 모두 올라갈때까지, 요청에 대한 응답이 끝날 때까지 대기한다
  
  function waitForCondition() {
    return new Promise(resolve => {
      function checkFlag() {
        if (total_count === count) {
          resolve();
        } else {
          setTimeout(checkFlag, 100); 
        }
      }
      checkFlag();
    });
  }

  await waitForCondition();
  
  return {
    // 유저에게 키 값을 전달해준다
    body: JSON.stringify(response_arr)
  }  

일단 이미지를 서버로 전송 후, 보관이 완료되었다고 응답이 오면 카운트를 증가시킨다. 풀 카운트가 된다면 유저에게 키 값을 전달해주었다.

해당 방식으로 25s ~ 30s 사이에서 이루어지던 크롤링 작업을 15s ~ 20s 이내로 확 줄일 수 있었다. 그러나 또 다시 간헐적으로 회색 타일들이 고개를 들기 시작했다.

기존에는 await를 통해 비교적 천천히 진행되던 작업의 속도가 빠르게 진행되다 보니 또 다시 회색 타일이 생기기 시작한 것이었다. 머리가 아파오기 시작했다.

최종장, 생각을 해보자

더 이상 내 머릿속에서 코드 레벨로 무언가를 시도할 아이디어가 떠오르지 않았다. 그래서 회색 타일 등장 빈도를 계산해보기 시작했다.

아침, 저녁 시간 때에는 아무리 찍어도 회색 타일을 보기는 힘들었다. 하지만 점심 이후의 시간 때에는 지도 이용량이 많아서 그런지 10번 정도 이미지를 생성하면 아주 가끔, 1번씩 회색 타일이 등장했다.

그런데 독특한 특징을 한 가지 발견했는데, 회색 타일이 발생했을 때 같은 좌표로 계속 이미지 생성을 요청하면 회색 타일이 좀처럼 사라지지 않았지만 아주 살짝이라도 중심 좌표를 이동한다면 회색 타일이 금세 사라지는 것이었다. 이제 나는 선택을 해야 했다. 작업 속도를 늦춰서라도 지도의 안정성을 올릴 것인지, 속도가 빨라도 약간의 불안정적인 방법을 선택할 것인지 말이다.

나는 후자의 방법을 선택했다. 아무리 안정적인 지도 생성법 이라고 해도 전자의 방식은 타임아웃 에러 빈도가 너무 높았다. 일단 결과물이 뭐라도 나와야 유저 입장에서도 추가적인 액션이 가능한데, 계속 서버 에러 문구만 나오면 너무 답답할 것 같았다.

대신 앞으로 안내 문구를 추가적으로 페이지에 게시할 예정이다. 회색 타일이 발생했을 때 좌표를 살짝만 이동해 보라는 문구를 넣는다면 금방 관련 문제가 해결될 것이고, 서버 에러 문구가 계속 나오는 것 보다는 유저 입장에서도 충분히 납득 가능한 대응책이라고 생각한다.

23.09.02 추가

별도의 FAQ 페이지를 추가해서 대응책을 안내했다.

Last updated