@teamsparta/react-logger
v0.1.1
Published
## 시작
Maintainers
Keywords
Readme
@teamsparta/react-logger
시작
설치
pnpm add @teamsparta/react-logger초기화
/app/_global/_logging/index.ts,/src/features/log/react-logger/index.ts와 같은 파일에서 Logger를 정의하고 생성하는 코드를 작성합니다.
import { sendCPLog } from '@teamsparta/cross-platform-logger';
import { createLogger } from '@teamsparta/react-logger';
type Context = Record<string, unknown>;
type EventMap = {
kdt_lms_atani_click: {
button_text: string;
question_text?: string;
category?: string;
};
kdc_virtualApply_submit: {
product_name: string;
product_id: string;
button_text: string;
course_id: string;
};
scc_lecture_start: {
enrolled_id: string;
course_id: string;
course_title: string;
start_time: number;
end_time?: number;
};
sc_lxp_popup_impression: {
popup_name: string;
button_name: string;
};
kdt_apply_page_view: Record<string, unknown>;
};
type EventParams = {
[K in keyof EventMap]: { eventName: K } & EventMap[K];
}[keyof EventMap];
export const [Logger, useLogger] = createLogger<Context, EventParams>({
send: ({ eventName, ...restParams }) => sendCPLog(eventName, restParams),
pageView: {
onPageView: ({ eventName, ...restParams }) =>
sendCPLog(eventName, restParams),
},
impression: {
onImpression: ({ eventName, ...restParams }) =>
sendCPLog(eventName, restParams),
},
DOMEvents: {
onClick: ({ eventName, ...restParams }) => sendCPLog(eventName, restParams),
onFocus: ({ eventName, ...restParams }) => sendCPLog(eventName, restParams),
onSubmit: ({ eventName, ...restParams }) =>
sendCPLog(eventName, restParams),
},
});- root에 Provider를 배치합니다.
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>
<Logger.Provider initialContext={{}}>{children}</Logger.Provider>
</body>
</html>
);
}사용
- Click
요소 클릭 시 로깅하고 싶을 때 사용합니다.
export function Menu({
name,
href,
selected = false,
leftAddon,
buttonText,
}: Props) {
return (
<Logger.Click
enabled={Boolean(buttonText)}
params={{
eventName: 'kdt_lms_atani_click',
button_text: buttonText ?? '',
}}
>
<Link href={href}>
<S.AIDiagnosisQuizLinkContainer selected={selected}>
{leftAddon}
<Text as="span" font="bodyCompact" color={vars.text.secondary}>
{name}
</Text>
</S.AIDiagnosisQuizLinkContainer>
</Link>
</Logger.Click>
);
}- Page View
특정 페이지/뷰에 진입한 시점을 1회 기록하고 싶을 때 사용합니다.
export function ApplyPage() {
return (
<>
<Logger.PageView params={{ eventName: 'kdt_apply_page_view' }} />
<ApplyContent />
</>
);
}- Impression
요소가 화면에 노출된 시점을 1회 기록하고 싶을 때 사용합니다.
export function PopupBanner({ popupName }: Props) {
return (
<Logger.Impression
params={{
eventName: 'sc_lxp_popup_impression',
popup_name: popupName,
button_name: '',
}}
>
<Banner />
</Logger.Impression>
);
}- 기타 dom event
클릭 외의 DOM 이벤트(focus, submit 등) 발생 시 로깅하고 싶을 때 사용합니다.
export function ApplyForm({ productId, productName, courseId }: Props) {
return (
<Logger.DOMEvent
type="onSubmit"
params={{
eventName: 'kdc_virtualApply_submit',
product_id: productId,
product_name: productName,
course_id: courseId,
button_text: '신청하기',
}}
>
<form onSubmit={handleSubmit}>
<Logger.DOMEvent
type="onFocus"
params={{
eventName: 'kdt_lms_atani_click',
button_text: 'email_input_focus',
}}
>
<input type="email" name="email" />
</Logger.DOMEvent>
<button type="submit">신청하기</button>
</form>
</Logger.DOMEvent>
);
}- log
JSX 로 감싸기 어려운 비동기 콜백/타이머/외부 라이브러리 콜백 등에서 직접 로깅해야 할 때 사용합니다.
export function LectureStartButton({
enrolledId,
courseId,
courseTitle,
}: Props) {
const { log } = useLogger();
return (
<button
onClick={async () => {
// 비동기로 동작하는 HTTP 요청의 응답값을 바탕으로 로깅을 해야 하는 경우
const { startTime } = await startLecture({ enrolledId });
log({
eventName: 'scc_lecture_start',
enrolled_id: enrolledId,
course_id: courseId,
course_title: courseTitle,
start_time: startTime,
});
}}
>
학습 시작
</button>
);
}무엇을 해결하나요?
1. 로깅 관심사 분리
이벤트 핸들러 안에 sendCPLog(...)와 같은 로깅 로직이 섞이면 비즈니스 로직 파악이 어렵게 됩니다. 이를 분리하여 비즈니스 로직 파악을 용이하게 합니다.
Before
function ApplyButton({ courseId, productId, productName }: Props) {
function handleClick() {
sendCPLog('kdc_virtualApply_submit', {
course_id: courseId,
product_id: productId,
product_name: productName,
button_text: '신청하기',
});
router.push(`/apply/${courseId}`);
}
return <Button onClick={handleClick}>신청하기</Button>;
}After
function ApplyButton({ courseId, productId, productName }: Props) {
return (
<Logger.Click
params={{
eventName: 'kdc_virtualApply_submit',
course_id: courseId,
product_id: productId,
product_name: productName,
button_text: '신청하기',
}}
>
<Button onClick={() => router.push(`/apply/${courseId}`)}>
신청하기
</Button>
</Logger.Click>
);
}2. 로깅 로직을 선언적으로 작성
"어떤 요소에 어떤 이벤트가 붙는지" 가 JSX 트리에 그대로 드러납니다. 핸들러 코드를 파악할 필요 없이 컴포넌트 구조만 보고 어떤 로깅이 발생하는지 파악이 쉬워집니다.
function ApplyForm({ courseId }: Props) {
return (
<Logger.DOMEvent
type="onSubmit"
params={{ eventName: 'kdc_virtualApply_submit' }}
>
<form onSubmit={() => submit()}>
<Logger.DOMEvent
type="onFocus"
params={{ eventName: 'kdc_apply_input_focus' }}
>
<input />
</Logger.DOMEvent>
<button type="submit">신청</button>
</form>
</Logger.DOMEvent>
);
}3. Type-safe
로깅 시 필요한 속성들의 타입이 자동으로 추론됩니다.
Before
// eventName에 오타가 발생했고(submitt가 아니라 submit)
// product_name, product_id 등 필수 parameter가 누락되었지만 타입 에러가 발생하지 않음.
sendCPLog('kdc_virtualApply_submitt', { course_id: 'r24' });After
<Logger.Click
params={{
// 오타로 인해 타입 에러 발생.
eventName: 'kdc_virtualApply_submitt',
// product_id, product_name 등 필수 parameter가 누락되어 타입 에러 발생
course_id: 'r24',
}}
>4. 로깅 로직에 관한 추상화 레벨 통일
레포마다 sendCPLog 직접 호출, 자체 wrapper, 컴포넌트 추상화 등 여러 방식이 존재하고 있습니다. @teamsparta/react-logger로 방식을 통일하면 코드 이해 비용을 줄일 수 있습니다.
Before
// 레포 A, sendCPLog 그대로 사용
sendCPLog('cta_click', { course_id });
// 레포 B, sendCPLog를 한 번 감싸서 사용
sendLog('cta_click', { course_id });
// 레포 C, 컴포넌트로 추상화
<LoggingClick>
<button onClick={onClick}>생성하기</button>
</LoggingClick>;After
<Logger.Click params={{ eventName: 'cta_click', course_id }}>5. 로깅 로직 구현 간단화
impression 처럼 IntersectionObserver 보일러플레이트가 필요한 케이스도 <Logger.Impression>을 통해 간단하게 로깅 로직을 구현할 수 있습니다. 즉 "어떻게 찍어야 할지"라는 고민을 생략할 수 있습니다.
Before
function Banner({ popupName }: Props) {
const ref = useRef<HTMLDivElement>(null);
const fired = useRef(false);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !fired.current) {
sendCPLog('sc_lxp_popup_impression', {
popup_name: popupName,
button_name: '',
});
fired.current = true;
observer.disconnect();
}
},
{ threshold: 0.2 },
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [popupName]);
return <div ref={ref}>{/* ... */}</div>;
}After
function Banner({ popupName }: Props) {
return (
<Logger.Impression
params={{
eventName: 'sc_lxp_popup_impression',
popup_name: popupName,
button_name: '',
}}
>
<div>{/* ... */}</div>
</Logger.Impression>
);
}심화 사용
조건부 로깅
enabled가 false로 평가되면 로깅을 하지 않습니다.
<Logger.Click
params={{ eventName: 'kdt_admin_action', action: 'delete' }}
enabled={(context) => context.userId !== null}
>
<Button>삭제</Button>
</Logger.Click>Context 업데이트와 사용
setContext 로 Provider 의 context 를 업데이트하고, params 를 함수형으로 작성하면 로깅 시점의 최신 context 값을 꺼내 사용할 수 있습니다.
function CategoryTabs() {
const { setContext } = useLogger();
return (
<>
<button
onClick={() => setContext((prev) => ({ ...prev, category: 'react' }))}
>
React
</button>
<button
onClick={() => setContext((prev) => ({ ...prev, category: 'node' }))}
>
Node
</button>
</>
);
}
function StartButton() {
return (
<Logger.Click
params={(context) => ({
eventName: 'kdt_lms_atani_click',
button_text: '시작하기',
category: context.category, // 'react' or 'node'
})}
>
<Button>시작하기</Button>
</Logger.Click>
);
}도메인별 Logger 분리
공통 context와 전용 context가 따로 필요한 경우, Logger 인스턴스를 분리해서 사용할 수 있습니다. 각 Provider 는 독립 Context 라 동시에 공존이 가능합니다.
<UserLogger.Provider initialContext={{ userId: 'u1', pageType: 'lms' }}>
<UserHeader />
<CourseLogger.Provider initialContext={{ course_id: 'react-2024', round: 3 }}>
<CoursePage />
</CourseLogger.Provider>
</UserLogger.Provider>API 요약
// 팩토리
createLogger<Context, EventParams>(config): [Logger, useLogger]
// LoggerConfig
type LoggerConfig<Context, EventParams> = {
send?: EventFunction; // log() 호출 시
DOMEvents?: { // Click/DOMEvent 용
onClick?: EventFunction;
onFocus?: EventFunction;
onBlur?: EventFunction;
onChange?: EventFunction;
// ... 모든 React DOM 이벤트
};
impression?: {
onImpression: EventFunction;
options?: { threshold?: number; freezeOnceVisible?: boolean };
};
pageView?: { onPageView: EventFunction };
};
// 컴포넌트
<Logger.Provider initialContext>
<Logger.Click params enabled?>
<Logger.Impression params options? enabled?>
<Logger.PageView params enabled?>
<Logger.DOMEvent type params enabled?>
// 훅
const { log, getContext, setContext } = useLogger();에러 처리
로깅은 부가 기능 이라는 원칙을 지킵니다. 로깅 시 에러가 발생해도 UI를 깨뜨리지 않습니다.
AI로 빠르게 셋업
Claude Code를 Plan Mode로 설정하고 다음 프롬프트를 입력하면 EventMap 정의와 Provider 배치까지 자동으로 진행됩니다.
@teamsparta/react-logger 를 이 레포에 도입하려고 합니다. 아래 가이드대로 셋업하세요.
시작 전에 사전 확인 3개를 사용자에게 묻고, 답에 맞춰 Step 1~2 를 순서대로 적용하세요.
# 사전 확인 (사용자에게 질문)
1. `package.json` 에 `@teamsparta/react-logger` 가 설치되어 있는가? 없으면 `pnpm add @teamsparta/react-logger` 를 먼저 안내.
2. 이벤트 송신 SDK 는 무엇인가? (예: `@teamsparta/cross-platform-logger` 의 `sendCPLog`, Amplitude, Hackle 등) — Step 1 의 핸들러 본문이 이 SDK 호출로 채워집니다.
3. 프레임워크 — Next.js App Router / Pages Router / Vite / CRA 중 무엇? Provider 배치 위치가 달라집니다.
# Step 1. EventMap 정의 + Logger 인스턴스 생성
파일 위치는 **기존 프로젝트 컨벤션을 우선** 따릅니다. 파일을 만들기 전에 다음 순서로 적절한 위치를 결정하세요.
1. 프로젝트의 `src/`, `app/`, `apps/*/src` 등 루트를 훑어 기존 feature/module 폴더 구조를 파악 (예: `src/features/*`, `src/shared/*`, `src/lib/*`, `app/_global/*` 등).
2. `sendCPLog`, `track`, `amplitude`, `analytics`, `logger` 같은 기존 로깅 / 트래킹 관련 파일이 이미 있다면 그 위치를 우선 (예: `src/features/log/*`, `src/shared/lib/analytics/*` 등 기존 폴더 안에 자연스럽게 합류).
3. 컴포넌트 파일 컨벤션 (`Foo/index.tsx` vs `Foo.tsx`) 과 export 스타일도 동일하게 맞춥니다.
4. 파일 위치를 결정했으면 사용자에게 한 줄로 보고하고 진행. 명확한 컨벤션이 없을 때만 폴백으로 Next.js App Router → `app/_global/_logging/index.ts`, 그 외 → `src/features/log/react-logger/index.ts` 사용.
아래 보일러플레이트를 결정한 위치에 생성하고 `EventMap` 만 도메인에 맞게 채우세요. 이벤트가 아직 정해져 있지 않으면 첫 1개만 예시로 두고 나머지는 `// TODO` 주석으로.
```ts
import { sendCPLog } from '@teamsparta/cross-platform-logger';
import { createLogger } from '@teamsparta/react-logger';
type Context = Record<string, unknown>;
type EventMap = {
// TODO: 도메인 이벤트 추가
// 예: kdt_apply_cta_click: { course_id: string; button_text: string };
// 예: kdt_apply_page_view: Record<string, unknown>;
};
type EventParams = {
[K in keyof EventMap]: { eventName: K } & EventMap[K];
}[keyof EventMap];
export const [Logger, useLogger] = createLogger<Context, EventParams>({
send: ({ eventName, ...restParams }) => sendCPLog(eventName, restParams),
pageView: {
onPageView: ({ eventName, ...restParams }) =>
sendCPLog(eventName, restParams),
},
impression: {
onImpression: ({ eventName, ...restParams }) =>
sendCPLog(eventName, restParams),
},
DOMEvents: {
onClick: ({ eventName, ...restParams }) => sendCPLog(eventName, restParams),
onFocus: ({ eventName, ...restParams }) => sendCPLog(eventName, restParams),
onSubmit: ({ eventName, ...restParams }) =>
sendCPLog(eventName, restParams),
},
});
```
규칙 / 함정:
- `EventParams` 는 반드시 `{ [K in keyof EventMap]: { eventName: K } & EventMap[K] }[keyof EventMap]` 형태의 discriminated union. `EventMap[keyof EventMap]` 만 쓰면 discriminator 가 없어 타입 안전성이 깨집니다.
- payload 안에 직접 `eventName` 필드를 넣지 말고, 위 매핑 타입이 자동으로 붙이게 둡니다.
- 송신 SDK 가 `sendCPLog` 가 아니면 핸들러 본문(`sendCPLog(eventName, restParams)`) 만 해당 SDK 호출로 교체. 구조는 동일.
- `send` 누락 시 `useLogger().log()` 가 silent no-op. 비동기 콜백에서 직접 로깅할 일이 있으면 반드시 등록.
- `DOMEvents` 에 등록하지 않은 이벤트 타입을 `<Logger.DOMEvent type="onMouseEnter">` 식으로 사용하면 동작하지 않습니다. 보통 `onClick` / `onFocus` / `onSubmit` 으로 충분.
- `impression`, `pageView` 는 해당 컴포넌트를 쓸 때만 등록. 안 쓰면 빼도 됩니다.
# Step 2. Provider 배치
프레임워크별 위치:
- Next.js App Router → `app/layout.tsx` 의 `<body>` 안
- Next.js Pages Router → `pages/_app.tsx` 최상위
- CRA / Vite → `src/main.tsx` 또는 `src/App.tsx` 최상위
```tsx
import { Logger } from '@/features/log/react-logger'; // 실제 경로로 교체
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>
<Logger.Provider initialContext={{}}>{children}</Logger.Provider>
</body>
</html>
);
}
```
함정:
- `initialContext` 는 필수 prop. 비어 있어도 `{}` 로 명시.
- Provider 밖에서 `useLogger()` 호출 시 throw — 테스트/스토리북도 동일하게 감싸야 합니다.
- 비동기 사용자 정보 주입에는 Provider 의 init 콜백이 없습니다. 셋업 단계에서는 빈 context 로 시작하고, 사용자가 별도로 요청하면 그때 `setContext` 패턴 안내.
# 작업 진행 원칙
- Step 1 (logger 파일 생성, EventMap 1개 예시) → Step 2 (Provider 삽입) 순서로 마무리한 뒤 결과를 사용자에게 보고. 실제 사용 예시(`<Logger.Click>` 등)는 README 의 "사용" 섹션을 참고해 사용자가 직접 적용.
- 심화 주제 (`setContext` 로 동적 context, 도메인별 Logger 인스턴스 분리, `enabled` 응용 등) 는 사용자가 별도로 물어볼 때만 안내. 셋업 단계에서 먼저 들이밀지 마세요.
- 기존에 `sendCPLog` 직접 호출 코드가 있어도 자동 마이그레이션 하지 말 것 — 별도 작업.