Skip to content

@teamsparta/react-logger

시작

설치

sh
pnpm add @teamsparta/react-logger

초기화

  1. /app/_global/_logging/index.ts, /src/features/log/react-logger/index.ts 와 같은 파일에서 Logger를 정의하고 생성하는 코드를 작성합니다.
tsx
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),
  },
});

  1. root에 Provider를 배치합니다.
tsx
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <Logger.Provider initialContext={{}}>{children}</Logger.Provider>
      </body>
    </html>
  );
}

사용

  1. Click

요소 클릭 시 로깅하고 싶을 때 사용합니다.

tsx
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>
  );
}

  1. Page View

특정 페이지/뷰에 진입한 시점을 1회 기록하고 싶을 때 사용합니다.

tsx
export function ApplyPage() {
  return (
    <>
      <Logger.PageView params={{ eventName: 'kdt_apply_page_view' }} />
      <ApplyContent />
    </>
  );
}

  1. Impression

요소가 화면에 노출된 시점을 1회 기록하고 싶을 때 사용합니다.

tsx
export function PopupBanner({ popupName }: Props) {
  return (
    <Logger.Impression
      params={{
        eventName: 'sc_lxp_popup_impression',
        popup_name: popupName,
        button_name: '',
      }}
    >
      <Banner />
    </Logger.Impression>
  );
}

  1. 기타 dom event

클릭 외의 DOM 이벤트(focus, submit 등) 발생 시 로깅하고 싶을 때 사용합니다.

tsx
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>
  );
}

  1. log

JSX 로 감싸기 어려운 비동기 콜백/타이머/외부 라이브러리 콜백 등에서 직접 로깅해야 할 때 사용합니다.

tsx
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

tsx
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

tsx
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 트리에 그대로 드러납니다. 핸들러 코드를 파악할 필요 없이 컴포넌트 구조만 보고 어떤 로깅이 발생하는지 파악이 쉬워집니다.

tsx
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

tsx
// eventName에 오타가 발생했고(submitt가 아니라 submit)
// product_name, product_id 등 필수 parameter가 누락되었지만 타입 에러가 발생하지 않음.
sendCPLog('kdc_virtualApply_submitt', { course_id: 'r24' });

After

tsx
<Logger.Click
  params={{
    // 오타로 인해 타입 에러 발생.
    eventName: 'kdc_virtualApply_submitt',
    // product_id, product_name 등 필수 parameter가 누락되어 타입 에러 발생
    course_id: 'r24',
  }}
>

4. 로깅 로직에 관한 추상화 레벨 통일

레포마다 sendCPLog 직접 호출, 자체 wrapper, 컴포넌트 추상화 등 여러 방식이 존재하고 있습니다. @teamsparta/react-logger로 방식을 통일하면 코드 이해 비용을 줄일 수 있습니다.

Before

tsx
// 레포 A, sendCPLog 그대로 사용
sendCPLog('cta_click', { course_id });

// 레포 B, sendCPLog를 한 번 감싸서 사용
sendLog('cta_click', { course_id });

// 레포 C, 컴포넌트로 추상화
<LoggingClick>
  <button onClick={onClick}>생성하기</button>
</LoggingClick>;

After

tsx
<Logger.Click params={{ eventName: 'cta_click', course_id }}>

5. 로깅 로직 구현 간단화

impression 처럼 IntersectionObserver 보일러플레이트가 필요한 케이스도 <Logger.Impression>을 통해 간단하게 로깅 로직을 구현할 수 있습니다. 즉 "어떻게 찍어야 할지"라는 고민을 생략할 수 있습니다.

Before

tsx
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

tsx
function Banner({ popupName }: Props) {
  return (
    <Logger.Impression
      params={{
        eventName: 'sc_lxp_popup_impression',
        popup_name: popupName,
        button_name: '',
      }}
    >
      <div>{/* ... */}</div>
    </Logger.Impression>
  );
}

심화 사용

조건부 로깅

enabled가 false로 평가되면 로깅을 하지 않습니다.

tsx
<Logger.Click
  params={{ eventName: 'kdt_admin_action', action: 'delete' }}
  enabled={(context) => context.userId !== null}
>
  <Button>삭제</Button>
</Logger.Click>

Context 업데이트와 사용

setContext 로 Provider 의 context 를 업데이트하고, params 를 함수형으로 작성하면 로깅 시점의 최신 context 값을 꺼내 사용할 수 있습니다.

tsx
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 라 동시에 공존이 가능합니다.

tsx
<UserLogger.Provider initialContext={{ userId: 'u1', pageType: 'lms' }}>
  <UserHeader />

  <CourseLogger.Provider initialContext={{ course_id: 'react-2024', round: 3 }}>
    <CoursePage />
  </CourseLogger.Provider>
</UserLogger.Provider>

API 요약

ts
// 팩토리
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 배치까지 자동으로 진행됩니다.

md
@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` 직접 호출 코드가 있어도 자동 마이그레이션 하지 말 것 — 별도 작업.