Intersection Observer API 를 이용한 오퍼월 impression 개선
도입 배경
Tapjoy offerwall을 이용하는 End-user에게 어떤 offer가 노출되어지고 있는지를 파악하기 위해 기존 탭조이에서는 간단히 아래와 같이 다루고 있었다.
즉 request가 1번 이루어질 때마다 response로 내려준 오퍼들 전부가 유저에게 노출되었다고 간주하고 관련 로그를 DB에 기록하고 있었다.
하지만 유저가 끝까지 스크롤을 내리지 않거나, 다른 페이지로 이동함으로 인해 최종 beacon에 포함된 특정 offer는 유저에게 실제로 노출되지 않을 가능성도 있었다.(아래 스크린샷 참고) 이러한 점을 개선하기 위해 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.jsimport 'core-js/es/weak-map';
import 'intersection-observer';
react-intersection-observer라는 library도 지원하고 있어서 해당 라이브러리를 사용하기로했다. 커스텀 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를 남겨보았고 잘 작동하는 듯 보인다. 본격적으로 하나의 컴포넌트로 분리시켜 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은 차후에 하는것으로..)
CPU 점유 문제로 인한 코드 변경
이제 production에서도 누락없이 impression이 잘 되는지 확인하기 위해 배포만하면 된다. 그러나 배포직전 다른 동료분께서 혹시 polling으로 인해 background에서의 cpu가 영향이 있는지 확인해보자고 하셨고, safari로 확인해보니 미세하지만 polling주기에 따라 background에서도 지속적으로 cpu를 차지하는 것으로 확인되었다. impression을 담은 store가 비어져있어서 early return을 통해 beacon을 쏴주지는 않더라도, delay
effect 내부적으로 인터벌하게 setTimeout을 계속 호출한다는 것을 간과하고 있었던 것이다.
각 퍼블리셔 앱에 탑재되어있는 탭조이 sdk는 특정 앱의 경우에오퍼월이 빠른 속도로 열리게 하기 위해서 미리 자바스립트 코드를(웹뷰) 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/