Intersection Observer API 를 이용한 impression 개선

MaxlChan·2022년 6월 23일
0
post-thumbnail

도입 배경

회사 sdk를 통해 오퍼월에 유입되는 End-user에게 어떤 offer(단순하게 쇼핑몰 상품이라고 생각하면 된다)가 노출되어지고 있는지를 파악하기 위해 기존에는 간단히 아래와 같이 다루고 있었다.

즉 request가 1번 이루어질 때마다 response로 내려준 오퍼들 전부가 유저에게 노출되었다고 간주하고 관련 로그를 DB에 기록하고 있었다.

하지만 유저가 끝까지 스크롤을 내리지 않거나, 다른 페이지로 이동함으로 인해 최종 beacon에 포함된 특정 offer는 유저에게 실제로 노출되지 않을 가능성이 있었다.

위 스크린샷을 예시로 들면, offerwall_impression으로 기록되는 건 12개이지만, 실제로 유저에게 보여지는 오퍼는 6개이다.

이러한 점을 개선하기 위해 Intersection Observer API를 도입하여 client 측에서 유저에게 실제로 보여지는 offer를 tracking하기로 하였다.

Intersection Observer API

Intersection Observer API는 감지하고자 하는 target 요소와 지정한 상위 요소 (혹은 최상위 document viewport) 사이 intersection 변화를 비동기적으로 관찰하는 방법이다.

과거에는 intersection와 비슷한 기능을 구현하기 위해, 즉 특정 요소의 위치 값을 getBoundingClientRect와 같은 메소드를 이용하여 가져왔지만, 이는 메인 스레드에서 수많은 계산이 이루어지고, css reflow를 유발시킬 수 있어 브라우저 성능을 저하시킨다.

하지만 Intersection Observer은 메인 스레드에 영향을 주지 않으면서 target요소와 상위요소의 교차 변경 사항을 관찰하고 getBoundingClientRect가 제공하는 결과 값도 반환해줄 수 있는 장점을 가진다.

참고자료: https://toruskit.com/blog/how-to-get-element-bounds-without-reflow/#call-getboundingclientrect()

IntersectionObserver API를 지원하지 않는 브라우저(IE)에서도 사용할 수 있도록 polyfill이 지원되고, 회사에서 공식적으로 지원하는 디바이스 OS 버전에서도 IntersectionObserver polyfill이 사용될 수 있도록 추가적인 polyfill(weak-map)도 함께 넣어주었다.

// polyfill.js
import 'core-js/es/weak-map';
import 'intersection-observer';

React에서 커스텀하게 쓸만한 라이브러리를 찾던 중 react-intersection-observer라는 라이브러리가 weekly 다운로드 수가 월등히 많고 버전관리도 잘되는 것 같아서 해당 라이브러리를 사용하기로했다. 커스텀 Hook 등 몇 가지 사용 방법 중에서 Render props 방식을 선택했다.

const Impression = ({ children }) => {
  const [count, setCount] = React.useState(0);
  const [log, setLog] = React.useState([]);
  const handleChange = inView => {
    setCount(count + 1);
    setLog([...log, inView]);
  };

  return (
    <InView triggerOnce threshold={0.5} onChange={handleChange}>
      {({ inView, ref }) => (
        <div style={{ position: 'relative' }} ref={ref}>
          {children}
          <Layer 
            inView={inView}
            count={count}
            log={log.join(' => ')} 
          />
      </div>
      )}
    </InView>
  );
};

...
...

const defaultOfferItem = (
  <Impression>
    <OfferItem {...defaultProps} />
  </Impression>
);

intersection-observer가 잘 작동하는지 확인하기 위해서 간단하게 log를 남겨보았고, threshold 0.5 임계점이 넘어 갈때 잘 작동하는 듯 보인다. 본격적으로 하나의 컴포넌트로 분리시켜 offer가 visible할 때에 handleOfferVisible 액션을 트리거하도록 하였다.

Polling 및 Redux-saga actionChannel 적용

이제 observer로 인해 visible하다고 감지된 오퍼를 beacon으로 보내주기만 하면 된다.
각 오퍼 하나에 한번씩 post request를 보내는것은 네트워크 트래픽이 과도하게 많아질 수 있기 때문에, visible된 오퍼들을 redux store에 모아놓았다가 주기적으로(5초) polling하여 impression들을 보내기로 결정하였다.
모든 작업은 redux-saga에서 진행하였다.

function* handleOfferVisible({ offer }) {  
  const sentKeys = yield select(sentKeysSelector);
  const impressionKey = offer.impressionKey;
  
  // observer가 실행되어 다시 노출된 동일한 오퍼에 대한 중복 방지
  if (sentImpressionKeys.has(impressionKey)) {
    return;
  }
  
  const impression = offer.newImpression();
  // 유저에게 노출된 오퍼를 모두 store에 저장
  yield put(addSentOwImpressionKey(impressionKey));
  yield put(addOwImpression([impression]));
}

// 5초마다 consumeOwImpression 트리거를 통한 polling
// 🚨 해당 코드는 차후 CPU 점유 문제가 있으므로, 참고하지 않도록 유의해주세요.
function* subscribePollingOwImpression() {
  const POLLING_INTERVAL = 5000;
  while (true) {
    yield put(consumeOwImpression(0));
    yield call(delay, POLLING_INTERVAL);
  }
}

function* handleConsumeOwImpression(maxRetries = 0) {
  // store에 모아둔 모든 impression을 select
  const impressions = yield select(owImpressionsSelector);
  if (!impressions.length) return;
  // request 보내기 전, 다음 polling을 위해 store을 clear
  yield put(resetOwImpression());
  let retries = 0;
  
  while (retries <= maxRetries) {
    try {
      const res = yield call(sendImpression, { impressions });
    
      if (!(res.status >= 200 && res.status < 300)) {
        throw new Error();
      }
      return;
    } catch (e) {
      yield call(delay, 2 ** retries * 1000);
      retries += 1;
    }
  }
  // retry request까지 전부 실패했을 경우 다시 store에 저장
  yield put(revertFailedOwImpressions(impressions));
}

추가적으로 polling 주기와 별도로 유저 interaction에 의해 여러 다른 액션이 발생때에도 미들웨어로 consumeOwImpression 액션을 호출해주도록 하였다. (ex. 유저 클릭으로 인해 다른 페이지로 이동할 때에나, 오퍼월이 종료되었을 때 등의 상황에서 store에 남은 impression들도 남김없이 보내야하기 때문에)

하지만 이런 경우
1) polling 주기로 dispatch 되는 액션
2) interaction에 의해 dispatch 되는

위 두가지가 거의 동시에 호출되는 상황이 발생해 중복된 impression을 보낼 수도 있다는 판단을 하였다.
물론 request전 바로 직전에 store를 비워주기는 하지만
최대한 안전하게 beacon을 보내주기 위해 saga의 actionChannel effect를 사용해보기로 했다

function* watchConsumeOwImpressionChannel() {
  const channel = yield actionChannel(CONSUME_OW_IMPRESSION, buffers.sliding(1));

  while (true) {
    const { maxRetries } = yield take(channel);
    yield call(handleConsumeOwImpression, maxRetries);
  }
}

function* rootSaga() {
   yield fork(watchAddOwImpressionChannel);
}

consumeOwImpression 액션에 대해서 하나의 채널로 등록해주었다. action channel을 등록해주면 saga가 해당 액션을 take할 준비가되지 않은 상태에서, 동일한 action이 dispatch되면 그 task를 채널에 자체적으로 버퍼링해준다.

위 코드를 통해 설명하자면 yield call(handleConsumeOwImpression)이 실행되어 Saga가 blocking되어있는 동안 동일한 consumeOwImpression 작업이 전달되면 takeEvery effect와 같이 바로 새로운 task가 실행되는 것이 아니라 channel에 의해 내부적으로 대기열에 추가된다.

yield call(handleConsumeOwImpression) 가 종료된 후 다음 yield take(channel)문이 실행되면 대기열에 대기중인 task가 큐잉되어 하나씩 saga가 진행되는 방식이다. 이로써 중복적인 impression을 보내는 가능성을 없앨 수 있었다. (사실 takeLeading같은 effect를 사용하면 더 간단하게 해결할 수 있었을 것 같긴한데, 당시 saga 버전 업데이트가 되지않았던 터라 migration은 차후에 하는것으로..)

중복적인 impression없이 5초 주기로 polling을 잘 해주는 듯 보인다.

CPU 점유 문제로 인한 코드 변경

이제 production에서도 누락없이 impression이 잘 되는지 확인하기 위해 배포만하면 된다.
그러나 배포직전 다른 동료분께서 혹시 polling으로 인해 background에서의 cpu가 영향이 있는지 확인해보자고 하셨고, safari로 확인해보니 미세하지만 polling주기에 따라 background에서도 지속적으로 cpu를 차지하는 것으로 확인되었다. impression을 담은 store가 비어져있어서 early return을 통해 beacon을 쏴주지는 않더라도, delay effect 내부적으로 인터벌하게 setTimeout을 계속 호출한다는 것을 간과하고 있었던 것이다.

실제로 safari inspector로 확인해보니, background에서 테스트했을 때 javascript 코드가 주기적으로 실행되어 cpu를 차지하고있었다.

각 앱에 탑재되어있는 회사 sdk는 특정 앱의 경우에 sdk가 빠른 속도로 open되게 하기 위해서 미리 자바스립트 코드를(웹뷰) prefetch하여 사전에 실행시켜두는 경우가 있다.

하지만 유저가 오퍼월에 실제로 진입해서 사용하는 것과 관계없이 위처럼 background에서도 cpu를 차지하며 주기적으로 실행되는 자바스크립트 코드가 있다면, 퍼블리셔 앱 퍼포먼스에 영향을 줄 수 있다. 특히 많은 양의 cpu를 차지하는 게임 앱과 같은 경우에는 0.1% 라는 작게 보이는 수치도 퍼블리셔 앱에 어떠한 영향을 가져다줄지 모르기 때문에 결국 impression을 보내주기 위한 다른 방안을 택하기로 하였다.

function* watchAddOwImpressionChannel() {
  const THROTTLE_DELAY = 500;
  const channel = yield actionChannel(ADD_OW_IMPRESSION, buffers.sliding(1));

  while (true) {
    yield take(channel);
    yield call(delay, THROTTLE_DELAY);
    yield flush(channel);
    yield put(consumeOwImpression(0));
    yield call(delay, 5000);
  }

기존의 subscribePollingOwImpression 함수를 위와 같이 개선해주었다.(함수명도 더이상 목적에 부합하기 않기에 변경하였다.) impression을 store에 추가할 때 dispatch하는 addOwImpression 액션에 대해 동일하게 action channel을 만들어 주기적으로 polling하는 것이 아닌, 액션이 dispatch될 때에만 특정 간격을 두어 impression beacon을 보내주도록 하였다. 그리고 일정 시간 동안 impression을 모아서 한번만 consumeOwImpression이 dispatch될 수 있도록 throttle도 걸어주었다.

앞으로 개선해야할 점

배포 후에 새로운 방식과 기존 방식이 남기는 로그를 비교해보고 누락되는 경우는 없는지,
request 실패시 retry가 잘 작동하는 지 등을 확인할 예정이다.

특히 오퍼월이 종료되면서 store에 남아있는 impression을 beacon으로 보낼때 fetch 요청이 취소되지 않고 서버에 잘 도달하는 지에 대한 검증도 필요할 것 같다. (테스트를 해보았을 때에는 도달하지 않는 경우로 보이는 케이스가 종종 보였다.)

만약 의도대로 되지 않는다면, fetch를 시도할때 keepalive라는 flag option을 추가하거나 혹은 sendBeacon API를 적용하여 페이지가 종료되거나 다른 페이지로 navigate되었을 때에도 POST request가 cancelled되지 않고 보장되도록 시도해볼 예정이다.

참고자료: https://css-tricks.com/send-an-http-request-on-page-exit/

profile
한가지를 알아도 제대로 알자

0개의 댓글