Impression log 사용하기
들어가며
사용자의 행동을 추적하고 분석하기 위해서는 유저의 활동 기록을 남기는 작업이 필수적인데요, 이를 로깅이라고 합니다. 제품을 개발하는 입장에서는 이러한 로깅을 통해 프로그램의 품질을 개선하고 예기치 않게 발생하는 문제를 해결하는 데 도움을 받을 수 있습니다.
프론트엔드에서 주로 기록하는 로그는 크게 세 가지입니다. 페이지 뷰(page view), 클릭(click), 임프레션(impression) 이 바로 그것인데요. 오늘 글에서 다뤄볼 주제는 임프레션 로그입니다. 이 글에선 임프레션 로그가 무엇이고 React 기반의 프론트엔드 환경에서 어떻게 구현할 수 있을지, 그리고 구현 중에 마주할 수 있는 문제들과 그 해결 방법에 대해 알아보겠습니다.
임프레션 로그(Impression Log)
임프레션 로그는 사용자의 화면에 타겟이 되는 요소가 노출되었을때 발생하는 로그입니다. 쉽게 말해 사용자가 "이 요소를 눈으로 보았는지" 에 대한 지표라고 할 수 있습니다. 페이지가 열렸을 때 발생하는 페이지 뷰 로그와는 다르게 특정 요소가 노출되었을때 발생한다는 점이 특징입니다.
위 그림의 아래쪽 요소처럼, 화면에 노출되지 않은 요소는 로깅에서 제외되어야 합니다. 사용자에게 노출되지 않은 요소가 Impression Log에 포함되면 정확한 데이터를 얻을 수 없습니다. 이렇게 찍힌 로그는 곧 부실한 근거가 되어 앞으로의 의사 결정에 악영향을 미칠 우려가 있습니다.
때문에, 다음과 같이 로깅을 적용하면 안됩니다.
export const MainComponent = () => {
return (
<>
{Array.from({ length: 10 }).map((_, i) => (
<TargetComponent key={i} num={i} />
))}
</>
);
};
export const TargetComponent = ({ num }: { num: number }) => {
useEffect(function logImpression() => {
console.log(num + 1, 'impression');
}, []);
return (
<div>
<p>사용자에게 {num + 1}번째로 보여질 내용입니다.</p>
</div>
);
};
위 코드는 어떤 부분이 문제일까요?
코드상으로 보면, Impression Log가 컴포넌트 마운트 시에 찍히고 있습니다. 그러나 마운트가 되었다는 것이 사용자의 화면에 노출되었다는 의미는 아닙니다. 요소가 뷰 포트 바깥에 있는 경우에도 컴포넌트는 마운트 되기 때문입니다.
우리는 다른 방법을 찾아야 합니다. 실제 사용자의 뷰 포트에 들어왔을 경우에만 로그를 남기도록 하려면 어떻게 해야할까요?
Intersection Observer 활용
사용자의 뷰 포트를 감지하는 작업에는 Web API인 Intersection Observer를 활용할 수 있습니다. 이를 이용하여 사용자의 뷰 포트에 노출된 요소를 감지하고, 제대로 노출되었을 경우에만 로그를 남기도록 코드를 짜보겠습니다.
export const MainComponent = () => {
return (
<>
{Array.from({ length: 10 }).map((_, i) => (
<TargetComponent key={i} num={i} />
))}
</>
);
};
export const TargetComponent = ({ num }: { num: number }) => {
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(num + 1, 'impression');
}
});
});
if (elementRef.current) {
observer.observe(elementRef.current);
}
return () => {
if (elementRef.current) {
observer.unobserve(elementRef.current);
}
};
}, []);
return (
<div ref={elementRef}>
<p>사용자에게 {num + 1}번째로 보여질 내용입니다.</p>
</div>
);
};
Intersection Observer로 대상 요소를 observe하여 뷰 포트 안 쪽에 요소가 들어왔을 경우에만 로그를 남기도록 수정되었습니다. 브라우저에서 실제 동작을 확인해보겠습니다.
우리가 의도한대로, 스크롤을 내려 요소가 사용자에게 노출되는 순간 로그가 찍히는 모습입니다.
하지만 지금 코드에는 아직 문제가 남아있습니다.
요소가 뷰포트에 들어올때 로그를 찍는 것까진 좋았지만, 뷰포트에서 벗어난 후 다시 들어올 때마다 로그를 찍는건 의도하지 않은 상황입니다. 이러면 실제 유저에게 노출된 수 이상으로 로그가 많이 찍히게 되는 과다 집계로 이어집니다. 하나의 요소에 대한 impression 로그는 한 페이지에서 한 번만 노출되도록 설정해주어야 합니다.
이를 위해선 컴포넌트 mount 이후 unmount가 발생하기 전까지 유지되어야 하며, 요소가 노출 되었는지 여부를 확인할 수 있는 값이 필요합니다.
이는 우리가 자주 사용하는 내부 상태를 이용하여 구현할 수 있습니다.
useState 활용
위의 코드를 state를 이용하여 생명주기 내에서 한 번만 실행되도록 수정해보겠습니다.
export const TargetComponent = ({ num }: { num: number }) => {
const elementRef = useRef<HTMLDivElement>(null);
const [isImpression, setIsImpression] = useState(false);
useEffect(() => {
if (isImpression) {
return;
}
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setIsImpression(true);
console.log(num + 1, 'impression');
}
});
});
if (elementRef.current) {
observer.observe(elementRef.current);
}
return () => {
if (elementRef.current) {
observer.unobserve(elementRef.current);
}
};
}, [isImpression]);
return (
<div
style={{
height: 100 + 'px',
border: '1px dotted #aaa',
background: '#c4e6de',
}}
ref={elementRef}
>
<p>사용자에게 {num + 1}번째로 보여질 내용입니다.</p>
</div>
);
};
컴포넌트 내부에 플래그 변수의 역할을 할 상태를 하나 만들고, 이를 useEffect 내부에서 값을 변경시켜 주는 것으로 반복 호출을 방지하였습니다. 여기서 놓치기 쉬운 부분인데, 내부의 상태가 변한 이후엔 함수가 바뀐 상태를 볼 수 있도록 잊지 않고 dependency에 상태를 넣어주어야 합니다.
이제야 좀 괜찮은 impression 로그가 찍히게 되었네요. 요소가 노출되었을때만, 중복으로 로그를 찍지 않는 컴포넌트가 되었습니다. 여기서 조금만 더 발전시켜 보겠습니다.
유저가 노출을 인지하는 시간
화면상에 요소가 그려졌다는 것이 유저가 그 요소를 인지했다는 말과 동일하다고 볼 수 있을까요? 그랬다면 좋겠지만, 아쉽게도 그렇지 않습니다. 우리가 스크롤을 빠르게 넘겼을때, 화면 상의 요소들은 그려졌더라도 유저가 이를 인식하기 위해선 시간이 필요합니다. setTimeout() 메서드를 이용해서 일정 시간이 지난 후 로그를 찍도록 수정해보겠습니다.
export const TargetComponent = ({ num }: { num: number }) => {
const elementRef = useRef<HTMLDivElement>(null);
const [isImpression, setIsImpression] = useState(false);
const impressionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const delayTime = 2000;
useEffect(() => {
if (isImpression) {
return;
}
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
if (impressionTimeoutRef.current === null) {
console.log(num + 1, 'impression timeout waiting...');
impressionTimeoutRef.current = setTimeout(() => {
setIsImpression(true);
console.log(num + 1, 'impression');
}, delayTime);
}
} else {
if (impressionTimeoutRef.current !== null) {
clearTimeout(impressionTimeoutRef.current);
console.log(num + 1, 'out of focus before impression');
impressionTimeoutRef.current = null;
}
}
});
});
if (elementRef.current) {
observer.observe(elementRef.current);
}
return () => {
if (elementRef.current) {
observer.unobserve(elementRef.current);
}
if (impressionTimeoutRef.current !== null) {
clearTimeout(impressionTimeoutRef.current);
}
};
}, [isImpression, num]);
return (
<div
style={{
height: '100px',
border: '1px dotted #aaa',
background: '#c4e6de',
}}
ref={elementRef}
>
<p>사용자에게 {num + 1}번째로 보여질 내용입니다.</p>
</div>
);
};
export default App;
isIntersecting이 true일때, setTimeout() 함수를 추가하고 impression 로그를 찍는 함수를 전달하였습니다. 만약 로그가 찍히기 전 화면에서 벗어난다면, clearTimeout() 함수를 통해 실행중인 타이머를 중지시킵니다.
한 번 로그가 찍힌 이후에는, 위쪽의 early return문에 걸려 타이머 함수는 더 이상 실행되지 않습니다.
추가로, 타이머가 제대로 작동하는지 확인하기 위해 로깅 전/후로 콘솔 로그를 추가했습니다.
브라우저에서 어떻게 동작하는지 확인해보겠습니다.
- 요소가 노출되었을때 타이머 함수가 실행되고, (impression timeout waiting...)
- 2초가 지나면 impression 로그가 찍히며 (impression)
- 그 전에 뷰 포트에서 이탈하면 타이머가 clear되고, impression 로그도 찍히지 않습니다. (out of focus before impression)
이렇게 우리는 보다 정확한 impression 로그를 찍을 수 있게 되었습니다.
마치며
오늘은 impression 로그에 대한 개념과 간단한 구현에 대해 알아보았습니다. 실제 사용되는 로그는 이보다 더 복잡하며, 서비스에 따라 고려해야 할 조건들도 많습니다. 가령, 다른 페이지로 이동하는 동작처럼 보이지만 실제로는 레이어가 쌓여있는 구조에서 유저가 두 페이지를 넘나드는 케이스를 어떻게 핸들링 할 지, 앱에서 페이지를 확인할 경우 하단 바에 요소가 가려지는 케이스를 어떻게 처리해줄지 등... 로깅이라는 동작은 생각보다 더 까다롭습니다.
하지만 그만큼 중요한 부분이기에, 한 번쯤은 블로그 글로써 정리해두고 싶었습니다. 혹여 글에서 제가 놓친 부분이나 코드에 대한 지적은 언제든 환영합니다. 편하게 댓글 남겨주시면 반영하겠습니다.
마지막으로, 긴 글 읽어주셔서 감사드립니다.