React Context API 를 활용해 GA 시스템 개선하기전역 배열 관리의 지옥에서 Context 기반 격리 시스템으로작성일 2024.08.09페이지가 생성된 시간 2026.02.03 00:47:10
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가 언제 지울지 모르겠네...
}, []);
구체적인 문제점
-
복잡한 클린업 로직
- 컴포넌트가 언마운트될 때 전역 배열을 청소해야 하는데, 다른 컴포넌트도 쓰고 있으면 망한다
- "이거 내가 지워도 되나?" 싶은 불안감이 코드 전체에 스며있었다
-
사이드이펙트 폭발
- A 컴포넌트에서 배열을 수정하면 B 컴포넌트에서 예상치 못한 동작
- 누가 언제 배열을 수정하는지 추적 불가능
-
격리 불가능
- 템플릿 패널과 유사 템플릿 리스트가 동시에 떠있으면 데이터가 섞임
- 각 영역별로 독립적인 추적이 불가능
-
타이밍 이슈
- 컴포넌트 라이프사이클과 전역 변수 상태가 따로 논다
- 언마운트 순서에 따라 데이터가 날아가기도 함
해결: Context로 완벽하게 격리된 Ref 스토어
핵심 아이디어는 간단하다. Context로 감싼 영역마다 독립적인 ref 스토어를 만들자.
설계 원칙
- No Re-render:
ref만 사용하여 리렌더링 방지 - Perfect Isolation: Context 범위마다 완전히 격리된 저장소
- 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 내부에 전송 로직을 다 넣으려 했다. 하지만 문제가 있었다:
- 전송 타이밍이 다양하다: 어떤 곳은 스크롤 끝에, 어떤 곳은 언마운트 시에만, 어떤 곳은 둘 다
- 외부 의존성: 스크롤 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를 위한 뷰 추적 외에도 어디든 쓸 수 있다:
- 이미지 목록 뷰 추적
- 상품 목록 노출 추적
- 광고 노출 추적
- 추천 콘텐츠 뷰 추적
필요한 건 두 가지:
ViewedItemContextProvider로 영역 감싸기callback으로 전송 로직 주입하기
전역 변수 시절엔 "하나 더 추가하면 다른 곳 터지겠지?" 하는 불안감이 있었다. 이제는 Provider만 추가하면 끝이다.
마치며
전역 변수로 관리하던 시절을 돌이켜보면:
- 클린업 로직을 짤 때마다 "이거 지워도 되나?" 하는 불안감
- 어디선가 터지는 예상치 못한 사이드이펙트
- 디버깅 할 때마다 "누가 이거 건드렸지?" 하며 git blame
Context로 바꾸니:
- 컴포넌트 구조에 자연스럽게 맞는 격리된 저장소
- Provider 언마운트 = 자동 정리
- 다른 영역과 절대 안 섞이는 안정성
React의 Context와 Ref를 조합하면 UI에 영향 없이 부수 효과를 완벽하게 격리할 수 있다. 전역 변수나 모듈 레벨 변수로 관리하다가 클린업 지옥을 경험하고 있다면, 이 패턴을 고려해보면 좋겠다.