일반

임프레션을 추적해보자. React Impression Tracker 개발기

leesche 2023. 12. 10. 20:41

배경

저는 한 스타트업 광고 시스템의 웹프론트엔드를 개발하고 있습니다. 제가 속한 팀은 현재 성과형 광고를 구축해 운영하고 있는데요. 성과형 광고란, 광고의 노출, 클릭 등의 성과가 발생할 때마다 병원에게 광고비를 받는 시스템입니다. 팀은 비교적 최근에 구성됐는데, 구성되고 나서 눈에 띄게 떠오르기 시작한 이슈 중 하나는 임프레션 및 클릭 데이터(이하 이벤트)의 누락 및 재수집 이슈였습니다.

여기서 임프레션이란? 유저가 특정 요소를 인지했다고 판단하는 기준입니다. 예를 들어, 유저가 보고 있는 화면에 어떤 상품이 50% 이상 노출된 채로 1초가 흘렀다면, 그 유저가 그 상품을 인지했다고 판단하고 Viewed 이벤트를 수집합니다. 임프레션 수집은 해당 광고의 성과 데이터를 수집하는데 중요한 요소입니다. 저희 회사의 웹뷰 제품에서는 임프레션 이벤트 수집이 계속해서 누락되고 있었습니다.

개발

라이브러리 적용을 위한 초기 단계에서, 임프레션 측정을 위한 여러 도구들을 탐색해보았습니다. 'impression-tracker-react-hook' 라이브러리는 회사의 요구를 만족시키기에 적합해 보였지만, 이 라이브러리는 제가 필요로 하는 기능 보다 더 많은 기능을 가지고 있었으며, 다운로드 횟수가 상대적으로 적고, 유지보수가 활발하게 이루어지지 않는 것으로 보였습니다. 이러한 이유로, "사내에서 개발하여 직접 유지보수하는 것이 최선이다"라는 결론에 도달했습니다. 회사의 웹뷰 프로젝트들이 Turborepo, 즉 모노레포 구조를 사용하고 있었기 때문에, 코드를 쉽게 공유하고 재사용하는 이점, 따로 버전 관리를 할 필요가 없는 이점, 그리고 보안 이점을 누리기 위해 Turborepo의 Internal Package 기능을 사용하기로 결정했습니다.

사전조사 및 POC

기존에 개발되어 있던 네이티브 앱은 어떻게 임프레션 측정을 하고 있었을까?시간 제약과 기존 라이브러리 학습의 어려움 때문에, 웹에서 사용 가능한 다른 방법을 찾아야 했습니다. 이미 Intersection Observer라는 훌륭한 웹 API의 존재를 알고 있었기에, 이 API를 활용하여 요구사항에 부합하는 라이브러리를 개발할 수 있을지 조사했습니다.

iOS 앱에서는 ImpressionKit을 가져와서 내부 도구로 만들어 사용하고 있었습니다.

Android 앱에서는 getLocalVisibleRect를 사용하고 있었으나, 웹 개발에서는 그와 같은 접근 방식을 사용할 수 없었습니다.

Intersection Observer의 적용

Intersection Observer API에 대해 조사한 결과, 이 API가 회사의 요구사항에 잘 맞는다고 판단했습니다. 그래서 리액트 생태계에서 널리 사용되는 react-intersection-observer 라이브러리의 도입을 고려했지만, 의존성을 최소화하고 싶은 바람에, Intersection Observer API만을 활용하여 직접 구현하기로 결정했습니다.

바닐라 자바스크립트로 POC(Proof Of Concept) 구현

라이브러리 개발에 앞서, Intersection Observer API의 작동 방식을 정확히 이해해야 했습니다. 공식 문서를 통해 이를 학습하는 것도 중요하지만, 실제로 코드를 작성하면서 경험해보는 것이 더 빠른 이해로 이어졌습니다. 그래서 저는 바닐라 자바스크립트를 사용하여 여러 시행착오를 겪으며 기본적인 구현을 완료했습니다. 소스 코드는 여기에서 볼 수 있습니다.

라이브러리의 핵심은 Intersection Observer API 였습니다. 해당 API를 각 엘리먼트에 적용하여, 해당 엘리먼트의 노출 상태를 관리했습니다. 노출 상태는 클래스를 통해 간단하게 관리할 수 있었습니다.

노출 기준을 충족한 후 일정 시간이 경과되면, 이를 측정하고 기록하기 위한 로직을 콜백 함수 내에 구현했습니다. 또한, 불필요한 재측정을 방지하기 위해 조건을 만족한 엘리먼트의 ID는 별도로 관리했습니다. 이를 통해 필요한 경우 해당 엘리먼트를 unobserve하는 것도 가능했습니다.

이제 기본적인 개념 검증(POC) 단계를 마쳤으니, 본격적인 라이브러리 개발에 착수할 차례입니다.

개발을 시작하면서 마주친 문제들

어디서부터 시작해야 할지 모르겠다.

처음에는 “PoC를 한 소스 코드를 리액트로만 변경하면 될 것”이라고 생각했습니다. 하지만 진도가 잘 나가지 않았습니다. 문제가 무엇일까 고민하다가 사용자가 어떻게 사용할지를 생각하지 않고, 단순히 자바스크립트를 리액트로 옮기고 있었기 때문에 라이브러리화가 어려웠다는 것을 깨달았습니다. 내부 구현은 어떻게든 할 수 있을 테니 인터페이스부터 시작하기로 마음먹었습니다.

"이 목록 형태의 컴포넌트의 요소의 임프레션을 측정해야 할 때, 사용자가 이 라이브러리를 어떻게 쉽게 사용할 수 있을까"를 고민했습니다.

첫 번째로 가장 익숙한 'use-' 형태의 커스텀 hook을 떠올렸습니다. react-intersection-observer를 사용할 때 'useInView'가 가장 쉽게 떠오르기도 했고요. 그렇다면 hook의 인터페이스는 어떨까요?

일단 엘리먼트를 라이브러리에게 전달해야 합니다. 라이브러리에서 제공한 저장소에 사용자는 엘리먼트를 저장하고, 라이브러리는 그 엘리먼트에 접근 가능해야 합니다. 여기서 저는 리액트의 ref 객체를 떠올렸습니다.

/** 임프래션 상태를 추적할 엘리먼트를 담고 있는 Ref 객체 */
targetRef: RefObject<E>

 

라이브러리 사용자는 라이브러리에게 자신이 만든 함수를 전달할 수 있어야 합니다. 라이브러리 내부에서 그 함수를 사용자가 원하는 시점에 호출할 수 있어야 합니다.

/**
 * hook 사용자가 hook 내부에 전달할 맥락 정보
 * hook은 내부적으로 onImpressed 함수를 호출할 때 이 변수를 인자로 전달한다.
 */
context: Context

/**
 * 엘리먼트의 임프레션 상태가 IMPRESSED가 될 경우 호출할 콜백 함수
 */
onImpressed: (args: { context: Context }) => void

hook 사용자는 자신의 엘리먼트의 임프레션 조건을 hook을 생성할 때 전달해야 합니다. 즉, 라이브러리에 뷰포트에 엘리먼트가 몇 퍼센트 노출되어야 하는지, 노출된 채로 몇 초 동안 있어야 하는지 설정할 수 있어야 합니다.

/**
 * 시각적 노출 역치 (0에서 1까지, 예를 들어 0.5 이면 노출 역치는 50%)
 * 이 역치에 도달하면, 노출 시간 역치를 측정하기 시작한다.
 */
visibilityThreshold?: number

/**
 * 노출 시간의 역치(ms)로, 시각적 노출 역치를 초과한 순간부터 측정하기 시작한다.
 * 노출 시간의 역치에 도달하는 순간, onImpressed 함수를 호출한다.
 */
durationThreshold?: number

hook 사용자는 임프레션이 어떤 조건에서 측정될지 intersection oberserver의 세부적인 옵션을 전달할 수 있어야 합니다. 라이브러리는 사용자가 어떤 화면에서 엘리먼트의 임프레션을 측정하는지 모르니까요.

/**
 * Intersection Observer 인터페이스의 읽기 전용 "root" 속성은 요소 또는 문서를 식별합니다.
 * 경계는 관찰자의 대상인 요소에 대한 뷰포트의 경계 상자로 처리됩니다.
 * root가 null이면 실제 Document 뷰포트의 경계가 사용됩니다.
 */
root?: HTMLElement | null

/**
 * root 주변의 여백. CSS 마진 속성과 유사한 값을 가질 수 있습니다.
 * 예: "10px 20px 30px 40px" (위, 오른쪽, 아래, 왼쪽).
 */
rootMargin?: string

hook 사용자는 실시간으로 임프레션 상태를 시각화할지를 hook에 전달할 수 있어야 합니다.

/** 실시간으로 임프레션 추적 상태를 시각화할지 여부 */
isImpressionVisible?: boolean

이렇게 하면서 대강의 인터페이스를 만들었습니다.

비즈니스 요구사항에 따른 특별한 경우의 문제

이제 구현을 시작하면 되는 상황이었으나, 중요한 문제가 하나 남아 있었습니다. 임프레션을 측정할 때, 중요한 전제 조건이 필요했습니다: 임프레션을 측정할 요소의 컨텐츠가 정상적으로 로드된 후에만 측정이 이루어져야 합니다. 예를 들어, 상품 이미지 로딩 중 에러가 발생하여 중요한 이미지가 없는 요소를 사용자가 '인지'했다고 판단하는 것은 부적절합니다.

회사의 기준에 따르면, 임프레션 측정 대상 요소에 이미지가 포함된 경우, 가장 큰 이미지가 로드된 후에만 임프레션 측정이 시작됩니다. 즉, 요소가 사용자에게 50% 이상 노출되었더라도 해당 요소의 이미지가 성공적으로 로드되지 않으면 임프레션을 측정할 수 없습니다.

이미지 로드 여부는 라이브러리에서 판단할 수 없으며, 이를 알 필요도 없습니다. 그러나 임프레션 측정을 시작할지 여부는 사용자가 결정할 수 있어야 합니다. 이를 위해 인터페이스 변경이 필요하며, hook을 호출할 때 사용자는 다음 함수를 사용할 수 있어야 합니다.

/** 이 함수를 호출하면 임프레션 추적을 시작합니다. */
startTracking: () => void;

이제 구현해봅시다!

ref 타입 에러

useImpressionTracker를 테스트하기 위해 간단한 사용 예제를 만들었지만, 시작부터 문제에 부딪혔습니다. 임프레션을 추적할 컴포넌트에 ref를 전달하려 했으나 타입 에러가 발생했습니다.

function App() {
  return (
    <div className="item-list">
      {[...Array(100)].map((_, index) => {
        return <Item index={index} />;
      })}
    </div>
  );
}

const Item = ({ index }: { index: number }) => {
  const onImpressed = () => {
    console.log(`Item ${index} is impressed.`);
  };
  const { ref, startTracking } = useImpressionTracker({
    onImpressed,
  });

  return (
    // 타입 에러 발생
    <div ref={ref} className="item"> 
      item {index}
    </div>
  );
};
Type 'RefObject<HTMLElement>' is not assignable to type 'LegacyRef<HTMLDivElement> | undefined'.
  Type 'RefObject<HTMLElement>' is not assignable to type 'RefObject<HTMLDivElement>'.
    Property 'align' is missing in type 'HTMLElement' but required in type 'HTMLDivElement'.ts(2322)
lib.dom.d.ts(10017, 5): 'align' is declared here.
index.d.ts(127, 9): The expected type comes from property 'ref' which is declared here on type 'DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>'

이 오류는 div 컴포넌트가 RefObject<HTMLDivElement> 타입의 ref prop을 기대하는 반면, useImpressionTracker 훅은 RefObject<HTMLElement>를 반환하기 때문에 발생합니다. 이 문제를 해결하기 위해, useImpressionTracker 훅의 반환 타입을 RefObject<HTMLElement>에서 RefObject<any>로 변경하여, 어떤 HTML 요소도 수용할 수 있도록 수정했습니다.

그리고 useImpressionTracker 훅의 인터페이스를 변경하여 동적으로 RefObject 의 제네릭 타입을 받을 수 있도록 했습니다. 위와 같이 수정하면, 사용자는 자유롭게 임프레션 추적 대상을 선택할 수 있으며, 타입 에러 없이 ref를 사용할 수 있게 됩니다.

export type UseImpressionTracker = <
  E extends HTMLElement,
  Context = unknown
>(useImpressionTrackerArgs: {
// ...
}) => {
  ref: RefObject<E>;
// ...

그리고 hook을 사용하는 측에서 제네릭 타입으로 ref를 전달할 엘리먼트의 타입을 넣어줬습니다.

// ...
const { ref, startTracking } = useImpressionTracker<HTMLDivElement>({
    onImpressed,
});
// ...

임프레션의 상태를 어떻게 관리할 것인가?

useImpressionTracker 훅 내에서 추적 중인 요소의 임프레션 상태를 식별하기 위해, 지역 상태를 활용하기로 결정했습니다. 이를 통해 각 요소의 상태를 더욱 정확하게 추적할 수 있습니다.

상태를 명확하게 정의하고 관리하기 위해, 열거형(enum)을 사용하여 각 상태를 선언했습니다.

export enum ImpressionStatus {
  /**
   * 선행조건(예: 이미지 로딩)을 달성하기 전 상태
   */
  NONE,

  /**
   * 선행조건을 만족하고 엘리먼트의 시각적 노출 역치에 도달하지 않은 상태
   */
  UNDER_VISUAL_THRESHOLD,

  /**
   * 선행조건을 만족하고 엘리먼트의 시각적 노출 역치에 도달한 상태
   * 이때부터 노출 시간을 측정하기 시작한다.
   */
  OVER_VISUAL_THRESHOLD,

  /**
   * OVER_VISUAL_THRESHOLD 상태에서 노출 시간의 역치에 도달한 상태
   * 한 번 IMPRESSED 상태가 되면 다른 상태로 변하지 않습니다.
   */
  IMPRESSED,
}
const [impressionStatus, setImpressionStatus] = useState<ImpressionStatus>(ImpressionStatus.NONE)

성능 최적화: Intersection Observer의 관리

const trackerInfoRef = useRef<{
  observer: IntersectionObserver | null
}>({ observer: null })

성능 문제를 방지하기 위해, Intersection Observer 인스턴스를 ref 객체 내에 저장하여 불필요한 리렌더링을 방지했습니다.

서버 사이드 렌더링(SSR)과의 호환성

// ...
if (typeof window === 'object') {
  trackerInfoRef.current.observer ??= new IntersectionObserver(
// ...
  • 일부 웹뷰 제품에서 서버 사이드 렌더링을 사용하는 경우, window 객체가 없을 수 있으므로 이를 고려해야 했습니다. 따라서 IntersectionObserver의 사용 가능 여부를 조건부 로직으로 처리했습니다.

가상 스크롤과의 호환성

가상 스크롤 기법은 화면에 실제로 렌더링되는 요소만을 생성함으로써 성능을 향상시킵니다. 그러나 이 기법을 사용할 때, 이 훅은 요소가 다시 나타날 때마다 임프레션을 재측정하는 문제가 있었습니다.

이 문제를 해결하기 위해, 훅이 해당 요소에 대한 임프레션 측정 여부를 인식할 수 있도록 인터페이스를 수정했습니다. 이를 통해 개발자는 hasImpressed 플래그를 사용하여 요소가 이미 임프레션되었는지 여부를 훅에 전달할 수 있습니다.이 인터페이스를 추가하고 구현에서는, hasImpressed가 참이면 임프레션 추적을 시작하지도 않고, 이미 임프레션이 된 상태라고 표시해주었습니다.

/**
 * 엘리먼트를 추적하지 않고 이미 임프레션되었다고 취급할지 여부
 */
hasImpressed?: boolean

이 인터페이스를 추가하고 구현에서는, hasImpressed가 참이면 임프레션 추적을 시작하지도 않고, 이미 임프레션이 된 상태라고 표시해주었습니다.

// ...
if (!targetRef.current || !trackerInfoRef.current.observer || hasImpressed) {
  return
}
// ...
// ...
if (hasImpressed) {
  addColoredLayer(target, impressionStatusColor.IMPRESSED)
  return
}
// ...

완성

이렇게 완성된 코드는 다음과 같이 동작합니다. 실제 소스 코드는 오픈 소스로 추후에 공개할 예정입니다. 

결론

이 프로젝트를 통해 여러 가지 중요한 교훈을 얻었는데요. 이러한 교훈들이 앞으로 어떤 방향으로 나아갈지에 대한 명확한 계획을 수립하는 데 도움이 되었습니다.

배운 점 및 향후 방향

하나의 라이브러리를 문제 정의부터 시작해 구현하고 서비스에 적용까지 해본 경험:

이 과정을 통해, 소프트웨어 개발의 전체 라이프사이클을 체험하게 되었습니다. 문제를 정의하고 요구사항을 분석하는 것부터 시작하여, 인터페이스를 설계하고, 구현을 관리하고, 마지막으로 실제 서비스 환경에서의 적용까지 이르는 과정은 저에게 소프트웨어 엔지니어링의 다양한 측면을 이해할 수 있는 기회를 제공했습니다.

인터페이스의 중요성을 깨달았습니다. 요구사항 분석 단계에서 인터페이스 정의의 중요성이 강조되었으며, 이를 통해 명확하고 유지보수가 용이한 코드를 작성하는 방법을 배웠습니다.

React 및 웹 API에 대한 깊은 이해:

이 프로젝트는 React와 같은 프론트엔드 프레임워크의 내부 동작 원리를 탐구하고, Intersection Observer와 같은 웹 API를 활용하여 성능과 사용성을 최적화하는 방법을 배울 수 있는 계기가 되었습니다. 이를 통해, 사용자의 뷰포트 내에서 요소의 가시성을 효과적으로 추적하고 관리하는 방법에 대해 배울 수 있었습니다.

앞으로의 계획

라이브러리 확장:

현재의 훅 기반 구조를 유지하면서도, 더 많은 사용 사례를 수용할 수 있도록 컴포넌트 형태의 지원을 추가하려고 합니다. 이를 통해 개발자들이 더 다양한 방식으로 라이브러리를 활용할 수 있도록 할 것입니다.

품질 향상을 위한 리팩토링 및 테스트 자동화:

코드의 안정성과 유지보수성을 높이기 위해, 리팩토링을 계속 진행할 계획입니다. 또한, 회귀 문제를 방지하고 지속적인 품질 향상을 보장하기 위해 자동화된 테스트 스위트의 구축을 목표로 하고 있습니다.

오픈소스 커뮤니티와의 협력:

이 라이브러리를 오픈소스로 제공하여, 더 많은 개발자들이 이를 사용하고 기여할 수 있도록 하고 싶습니다. 커뮤니티의 피드백과 제안을 받아들여, 이 도구를 더욱 강력하고 유용하게 만들면 어떨까요? 이를 통해, 라이브러리는 지속적으로 성장하고 발전할 수 있는 기반을 마련할 것입니다.

이러한 경험과 배움을 바탕으로, 저와 동료들은 기술의 성장과 발전에 기여하는 더 많은 기회를 찾을 것이며, 이 라이브러리가 우리 자신뿐만 아니라 다른 많은 개발자들에게도 가치와 이점을 제공할 수 있기를 기대합니다.

이 프로젝트를 통해 함께 한 모든 분들께 다시 한번 감사드립니다. 이들의 도움 없이는 이 프로젝트의 완성이 불가능했을 것입니다.

참고