React Context API 를 활용해 GA 시스템 개선하기
전역 배열 관리의 지옥에서 Context 기반 격리 시스템으로
작성일 2024.08.09
페이지가 생성된 시간 2026.02.03 00:47:10

1

ViewedItemContext로 GA 이벤트 추적 개선하기

전역 배열 관리의 지옥에서 Context 기반 격리 시스템으로

들어가며

사용자가 템플릿 목록을 스크롤하며 어떤 항목을 보았는지 추적하는 건 Google Analytics(GA)를 통한 사용자 행동 분석에서 중요한 데이터다. 하지만 기존 방식은... 솔직히 엉망이었다.

ViewedItem이 뭔데?

type ViewedItem = {
  idx: string; // Viewable Item ID
  position: number; // 리스트에서의 위치
  timestamp: number; // 뷰된 시각
};

간단하다. 사용자가 스크롤하면서 뷰포트에 노출된 아이템들의 ID를 모아서, 특정 시점(스크롤 끝, 화면 이탈 등)에 GA로 보내는 것. 문제는 이 간단한 데이터를 어떻게 관리하느냐였다.

문제: 전역 변수 지옥

기존에는 viewedItem이라는 전역 배열로 뷰 추적 데이터를 관리했다.

// 어딘가에 있는 전역 변수
let viewedItem: ViewedItem[] = [];

// 컴포넌트 A
useEffect(() => {
  viewedItem.push(...items);
  return () => {
    // 클린업 로직... 근데 다른 컴포넌트가 쓰고 있으면 어쩌지?
    viewedItem = viewedItem.filter(...);
  };
}, []);

// 컴포넌트 B
useEffect(() => {
  viewedItem.push(...otherItems);
  // 아 A가 언제 지울지 모르겠네...
}, []);

구체적인 문제점

  1. 복잡한 클린업 로직

    • 컴포넌트가 언마운트될 때 전역 배열을 청소해야 하는데, 다른 컴포넌트도 쓰고 있으면 망한다
    • "이거 내가 지워도 되나?" 싶은 불안감이 코드 전체에 스며있었다
  2. 사이드이펙트 폭발

    • A 컴포넌트에서 배열을 수정하면 B 컴포넌트에서 예상치 못한 동작
    • 누가 언제 배열을 수정하는지 추적 불가능
  3. 격리 불가능

    • 템플릿 패널과 유사 템플릿 리스트가 동시에 떠있으면 데이터가 섞임
    • 각 영역별로 독립적인 추적이 불가능
  4. 타이밍 이슈

    • 컴포넌트 라이프사이클과 전역 변수 상태가 따로 논다
    • 언마운트 순서에 따라 데이터가 날아가기도 함

해결: Context로 완벽하게 격리된 Ref 스토어

핵심 아이디어는 간단하다. Context로 감싼 영역마다 독립적인 ref 스토어를 만들자.

설계 원칙

  1. No Re-render: ref만 사용하여 리렌더링 방지
  2. Perfect Isolation: Context 범위마다 완전히 격리된 저장소
  3. Component Lifecycle Aligned: 컴포넌트 구조에 자연스럽게 맞는 생명주기

어떻게 격리했나?

<ViewedItemContextProvider>  ← 이 범위 안에서만 유효한 ref 스토어
  ├── ref: Set<string>()      ← 전역 변수 아님, 이 Provider 전용
  ├── Consumer.ScrollEnd      ← 스크롤 끝나면 전송
  ├── Consumer.UnMounted      ← 언마운트 시 전송 (유실 방지)
  └── 자식 컴포넌트들           ← useViewedItemContext()로 접근
</ViewedItemContextProvider>

<ViewedItemContextProvider>  ← 다른 Provider = 완전히 다른 스토어
  └── 독립적인 ref 스토어      ← 위와 절대 안 섞임
</ViewedItemContextProvider>

왜 Context로 해결했나?

  • 각 Provider마다 독립적인 ref 인스턴스가 생긴다
  • 컴포넌트 트리 구조에 자연스럽게 맞는다
  • Provider가 언마운트되면 자동으로 정리된다 (클린업 필요 없음!)

구현 상세

1. Context 구조

type ViewedItemContextValue = {
  add: (idx: string) => void; // 뷰된 아이템 추가
  send: () => void; // GA 이벤트 전송
};

Provider가 생성될 때 이 콜백을 받아서, send() 호출 시 실행한다. 각 영역(템플릿 패널, 유사 템플릿 리스트)마다 다른 GA 이벤트를 보내야 하니까, 콜백으로 주입하는 것.

2. Ref 기반 상태 관리

const viewedItemIdxRef = useRef(new Set<string>()); // 현재 뷰된 아이템
const sentItemIdxRef = useRef(new Set<string>()); // 이미 전송된 아이템
const viewLoadNumRef = useRef(0); // 전송 횟수

왜 State가 아닌 Ref인가?

GA 이벤트 추적은 UI와 무관한 부수 효과다. 사용자가 아이템을 보는 걸 추적한다고 화면이 바뀔 이유는 없다. useState를 쓰면 매번 리렌더링이 발생해서 성능 낭비다. useRef로 조용히 데이터만 관리한다.

더 중요한 건, 이 ref 스토어가 Provider 인스턴스에 종속된다는 점이다. 전역 변수와 달리:

  • Provider 마운트 시 ref 생성
  • Provider 언마운트 시 자동으로 가비지 컬렉션
  • 다른 Provider와 절대 안 섞임

3. 중복 제거 로직

const add = useCallback((idx: string) => {
  if (viewedItemIdxRef.current.has(idx)) return; // 이미 추가됨
  if (sentItemIdxRef.current.has(idx)) return; // 이미 전송됨
  viewedItemIdxRef.current.add(idx);
}, []);

같은 아이템을 여러 번 추적하지 않도록 Set으로 중복을 제거한다.

4. Consumer 패턴

export const ViewedItemContextConsumer = {
  ScrollEnd: ScrollConsumer, // 스크롤이 끝에 도달하면 send 호출
  UnMounted: UnMountedConsumer, // 컴포넌트 언마운트 시 send 호출
};

다양한 전송 타이밍에 대응할 수 있도록 Consumer를 분리했다:

  • ScrollEnd: 스크롤 멈추면 데이터 전송
  • UnMounted: 컴포넌트 사라질 때 남은 데이터 전송 (유실 방지)

왜 Provider와 분리했나?

처음엔 Provider 내부에 전송 로직을 다 넣으려 했다. 하지만 문제가 있었다:

  1. 전송 타이밍이 다양하다: 어떤 곳은 스크롤 끝에, 어떤 곳은 언마운트 시에만, 어떤 곳은 둘 다
  2. 외부 의존성: 스크롤 ref, Intersection Observer 등 Provider가 알 필요 없는 것들

그래서 관심사를 분리했다:

  • Provider: 데이터 저장 & 관리 책임
  • Consumer: 전송 타이밍 결정 & 트리거 책임

이게 전역 변수 방식과의 결정적인 차이다. 전역 변수는 "언제 정리할지"가 불명확했지만, Context는 컴포넌트 라이프사이클에 자연스럽게 맞는다.

사용 예시

Provider 설정

<ViewedItemContextProvider callback={send}>
  <ViewedItemContextConsumer.ScrollEnd scrollRef={scrollRef} />
  <ViewedItemContextConsumer.UnMounted />

  {/* 실제 컨텐츠 */}
  <Header />
  <ListView />
</ViewedItemContextProvider>

자식 컴포넌트에서 사용

const { add: addItem } = useViewedItemContext();

const { targetRef } = useIntersectionObserver(() => {
  // 뷰포트에 들어온 아이템들을 Context에 추가
  contents.forEach((item) => {
    addItem(item.key.toString());
  });
});

뭐가 달라졌나?

1. 데이터 격리

Before: 전역 배열에 다 섞임

// 전역 어딘가에...
let viewedItem: ViewedItem[] = [];

// A
useEffect(() => {
  viewedItem.push(...AItems);
}, []);

// B
useEffect(() => {
  viewedItem.push(...BItems); // 섞인다!
}, []);

sendGA(viewedItem); // 어느 영역 데이터인지 알 수 없음

After: 각 Provider마다 독립 스토어

<ViewedItemContextProvider callback={sendA}>
  <AContent />  {/* 독립된 ref 스토어 */}
</ViewedItemContextProvider>

<ViewedItemContextProvider callback={sendB}>
  <BContent />  {/* 완전히 다른 ref 스토어 */}
</ViewedItemContextProvider>

2. 클린업 지옥 탈출

Before: 수동 클린업의 공포

useEffect(() => {
  viewedItem.push(...items);

  return () => {
    // 이거 지워도 되나...?
    viewedItem = viewedItem.filter((item) => !items.includes(item));
  };
}, []);

After: 자동 정리

// Provider 언마운트되면 ref도 자동 가비지 컬렉션
// 클린업 로직 불필요

3. 성능 & 안정성

  • 리렌더링 0회: ref만 쓰므로 수백 개 아이템 추가해도 렌더링 없음
  • 사이드이펙트 0건: Provider 범위 내에서만 수정, 외부 영향 없음
  • 타입 안전: TypeScript로 잘못된 사용을 컴파일 타임에 차단

적용 범위

처음에는 관리가 복잡해진 패널만 작업되었지만 추후 다른분들이 리팩터링을 진행해주셔서 현재는 모든 패널이 이 모듈을 쓰고있다.

확장성

이 패턴은 GA를 위한 뷰 추적 외에도 어디든 쓸 수 있다:

  • 이미지 목록 뷰 추적
  • 상품 목록 노출 추적
  • 광고 노출 추적
  • 추천 콘텐츠 뷰 추적

필요한 건 두 가지:

  1. ViewedItemContextProvider로 영역 감싸기
  2. callback으로 전송 로직 주입하기

전역 변수 시절엔 "하나 더 추가하면 다른 곳 터지겠지?" 하는 불안감이 있었다. 이제는 Provider만 추가하면 끝이다.

마치며

전역 변수로 관리하던 시절을 돌이켜보면:

  • 클린업 로직을 짤 때마다 "이거 지워도 되나?" 하는 불안감
  • 어디선가 터지는 예상치 못한 사이드이펙트
  • 디버깅 할 때마다 "누가 이거 건드렸지?" 하며 git blame

Context로 바꾸니:

  • 컴포넌트 구조에 자연스럽게 맞는 격리된 저장소
  • Provider 언마운트 = 자동 정리
  • 다른 영역과 절대 안 섞이는 안정성

React의 Context와 Ref를 조합하면 UI에 영향 없이 부수 효과를 완벽하게 격리할 수 있다. 전역 변수나 모듈 레벨 변수로 관리하다가 클린업 지옥을 경험하고 있다면, 이 패턴을 고려해보면 좋겠다.

©2024 dlwl98
github
PostsAbout