thumbnail
로깅과 비즈니스 로직 분리를 통한 선언적 컴포넌트 관리
React / cloneElement / SRP
2025.11.09.
14 min read

대규모 프로젝트를 운영하다보면 유저 동향 및 플로우를 파악하기 위해 로깅을 하는데요. 개발자입장에서 로깅은 비즈니스 영역이 아니기 때문에 분리관리하는것은 매우 중요합니다.

이번 글은 비즈니스 로직과 혼재되어있던 로깅 로직을 분리해, 선언적으로 로깅을 관리하도록 리팩토링한 과정을 소개해보겠습니다.

기존 코드

먼저 기존 방식을 살펴보려고 해요. 다음은 더보기 혹은 탭 버튼을 클릭했을 때 사용자 인터랙션이 로깅되도록 구현된 코드입니다.

 <HeadingBox
      id=//..
      title=//..
      link={link ?? ''}
      className={className}
      gapage="로깅페이지"
      gasection="로깅영역"
      gatags={['로깅이름', '더보기']}
     >
  <TabMenu
    data={data}
    state={TabState}
    id={id}
    gaEventKey="로깅페이지"
    gaEventSubKey="로깅영역"
    gaFnArgs={['로깅이름', '']}  // 두번째 값은 TabList onclick에서 조작
  />
</HeadingBox>

위 코드에서 ga로 시작하는 props들이 로깅 관련 값들입니다. 이 코드는 다음과 같은 문제점들이 있어요.

문제점

1. 비즈니스 props와 로깅 props가 한 컴포넌트 인터페이스에 섞임

코드를 보면 각 컴포넌트 props 역할이 빠르게 파악되시나요?? 일단 전 전혀 파악되지 않았습니다.

개발자가 신경 쓰는 부분은 로깅이 아니라 비지니스 로직일 때가 많은데, 로깅 관련 코드가 함께 섞여 있었기 비즈니스 로직에 대한 가독성이 저하되는 문제점이 있었습니다.

특히 HeadingBox 컴포넌트는 단순한 더보기 버튼일 뿐인데 역할에 비해 너무 많은 props를 넘기고 있습니다.

해당 패턴은 로직이 복잡해지거나 로깅이 많아질수록 가독성은 점점 떨어질 수 밖에 없습니다.

2. 로깅 props로 인한 복잡한 제네릭 타입이 강제된 컴포넌트 설계

TabMenu 컴포넌트는 아래와 같은 탭 UI를 공통으로 사용하기 위해 만든 컴포넌트인데요

탭스와이퍼

비즈니스 로직뿐아니라 로깅 props도 같이 넘겨줘야했기 때문에 아래와 같이 제네릭으로 설계되어있었어요.

interface TabProps<K extends TGTMEventKey, SubK extends TGTMEventSubKey<K>>
  extends TOptional<IGTMEventProps<K, SubK>> {
  data: TTabData[];
  state: RecoilState<number>;
  initialSlide?: number;
  id: string;
}

export default function TabMenu<K extends TGTMEventKey, SubK extends TGTMEventSubKey<K>>({
  data,
  state,
  id,
  initialSlide,
  ...rest
}: TabProps<K, SubK>) {
  return (
    <>
      <TabList data={data} state={state} id={id} initialSlide={initialSlide} {...rest} />
      <PanelList data={data} state={state} id={id} />
    </>
  );
}

코드를 보면 한눈에 봐도 다소 복잡해 보입니다. 또한 사용하는 입장에서는 이 제네릭이 어떤 역할을 하는지 먼저 파악해야 하고, 설령 이해했다 하더라도 해당 코드가 비즈니스 로직과 강하게 결합되어 있지는 않은지, 사용했을 때 예상치 못한 사이드 이펙트가 발생하지는 않을지 노심초사하게 됩니다.

심지어 비즈니스 로직은 제네릭과 결합되어있지도 않습니다. 즉 오로지 로깅 props 때문에 강제로 제네릭 컴포넌트로 사용해야하는거죠.

3. TabMenu 호출부에서 불가능한 로깅

<TabMenu
    data={data}
    state={TabState}
    id={id}
    gaEventKey="로깅페이지"
    gaEventSubKey="로깅영역"
    gaFnArgs={['로깅이름', '']}  // 두번째 값은 TabList onclick에서 조작
  />

TabMenu 컴포넌트의 gaFnArgs props에서 두 번째 배열 요소에는 어떤 탭이 선택되었는지에 대한 로깅 정보가 들어가야하는데요.

하지만 탭 UI를 공통 컴포넌트인 TabMenu로 분리해두었기 때문에, TabMenu를 호출하는 쪽에서는 실제로 어떤 탭이 선택되었는지 알 수 없습니다. 결국 두 번째 배열 요소를 비워둔 채 전달할 수밖에 없었고, 결국 해당 의도를 설명하기 위한 주석까지 남게 되는 부가적인 문제도 발생했습니다.

즉 과도한 공통화로 인해 불편함이 생겨버린거죠.

4. 컴포넌트 내부에서 로깅 필요 유무에 따른 불필요한 분기처리


interface HeadingBoxProps<
  T extends ElementType,
  K extends TGTMEventKey,
  SubK extends TGTMEventSubKey<K>,
> extends TOptional<IGTMEventProps<K, SubK>> {
  id: string;
  title: string | React.ReactNode;
  children: React.ReactNode;
  link?: string;
  headerChildren?: React.ReactNode;
  className?: string;
}

export default function HeadingBox<
  T extends ElementType,
  K extends TGTMEventKey,
  SubK extends TGTMEventSubKey<K>,
>({
  id,
  title,
  children,
  link,
  className,
  ...rest
}: HeadingBoxProps<T, K, SubK>) {
  const renderLink = () => {
    if (!link) return null;

    if (rest.gaEventKey && rest.gaEventSubKey && rest.gaFnArgs) {
      return (
        <CustomLinkWithGaEvent
          href={link}
          className={cn(st['link-icon'])}
          gaEventKey={rest.gaEventKey}
          gaEventSubKey={rest.gaEventSubKey}
          gaFnArgs={rest.gaFnArgs}
          {...rest}
        >
          <Icon type="arrowRight20" pAlt="더보기" />
        </CustomLinkWithGaEvent>
      );
    }

    return (
      <CustomLink
        href={link}
        className={cn(st['link-icon'])}
        {...rest}
      >
        <Icon type="arrowRight20" pAlt="더보기" />
      </CustomLink>
    );
  };

  return (
    <section className={cn(st['wrap-heading'], className)} aria-labelledby={id}>
      <Heading as={as} id={id} title={title}>
        {renderLink()}
      </Heading>
      {children}
    </section>
  );
}

HeadingBox는 단순히 ‘더보기’ 앵커를 렌더링하는 공통 UI 컴포넌트입니다. 하지만 페이지에 따라 로깅이 필요한 경우도 있고 필요하지 않은 경우도 있기 때문에, 이를 대응하기 위해 로깅 관련 props를 전달할지 여부에 따라 컴포넌트 렌더링을 if 문으로 분기 처리해야 하는 불필요한 복잡함이 존재했습니다.

UI 자체는 동일한데 말이죠. 단지 로깅 여부 때문에 렌더링 로직이 달라지는 구조였던 셈입니다.

개선 코드

그래서 비즈니스 로직과 로깅 로직을 서로 분리하고, 가독성 좋은, 선언적 코드로 구현할 수 있을까 고민하다 토스 기술 블로그에서 저희와 비슷한 고민을 한 흔적을 발견했습니다.

그리고 토스의 방법을 차용해서 GaEventTag 이라는 컴포넌트를 새로 만들었어요.

'use client'

import { Children, cloneElement, type ReactElement, useCallback } from 'react'
import { useGTMEvent } from '@/src/common/hooks/ga/useGTM'
import type { GTMEventPage, GTMEventProps, GTMEventSection, GTMEventTags } from '@/src/common/utils/ga/types'

interface GaEventTagProps {
  children: ReactElement
}

export default function GaEventTag<P extends GTMEventPage, S extends GTMEventSection<P>>({
  children,
  page,
  section,
  tags = [] as GTMEventTags<P, S>,
}: GTMEventProps<P, S> & GaEventTagProps) {
  const sendGAEvent = useGTMEvent(page, section)
  const child = Children.only(children)

  const handleClick = useCallback(
    (event: React.MouseEvent<HTMLElement>) => {
      sendGAEvent(...tags)

      const originalOnClick = child.props?.onClick
      if (typeof originalOnClick === 'function') {
        originalOnClick(event)
      }
    },
    [sendGAEvent, tags, child.props?.onClick],
  )

  return cloneElement(child, {
    onClick: handleClick,
  })
}

React Children API, cloneElement API를 활용해 GaEventTag 가 감싸는 컴포넌트가 단일 엘리먼트인지 판단하고

cloneElement(child, { onClick: handleClick })으로 자식의 props에 onClick을 덮어쓴 새 엘리먼트를 반환합니다.

실제 사용 시 GaEventTag 컴포넌트는

<GaEventTag page="로깅페이지" section="로깅영역" ...>
  <button>클릭</button>
</GaEventTag>

이렇게 쓰면 child를 복제해서 onClick을 주입하고 실제 렌더링 결과는 다음과 같습니다.

<button onClick={...GA 로깅이 포함되어있음}>클릭</button>

gaEventKey=“로깅페이지” gaEventSubKey=“로깅영역” gaFnArgs={[‘로깅이름’, ”]}

GaEventTag 적용

만들어놓은 GaEventTag를 다음과 같이 적용했어요.

 <div className={cn(LIST_TAB_CLASS)} role="tablist">
        {tabList.map((item, index) => {
          const selected = item === selectedTab
          return (
            <GaEventTag key={`${item}Tab`} page="로깅페이지" section="로깅영역" tags={['로깅이름', item]}>
              <Button
                variant={selected ? 'solid-round' : 'outline-round'}
                size="xsmall"
                color={selected ? selectedTabColor : 'bright-01'}
                onClick={()=> 
                    // 비즈니스 로직..
                }
              >
                {item}
              </Button>
            </GaEventTag>
          )
        })}
      </div>

위 코드는 기존 TabMenu 컴포넌트 내부 <TabList data={data} state={state} id={id} initialSlide={initialSlide} {...rest} /> 컴포넌트를 밖으로 꺼낸 형태인데요. 이렇게 외부로 노출시킨 이유는 GaEventTag 컴포넌트는 단일 엘리먼트만(Children.only(children)) 감쌀 수 있기 때문입니다.

또한, 앞서 언급했던 TabMenu 호출부에서 발생했던 로깅 문제를 해결하기 위해, GaEventTag를 반복문 내부에서 사용하도록 구조를 수정했습니다. 이를 통해 각 반복문의 item 값을 로깅 정보로 주입할 수 있도록 했습니다.

하지만 여기서 또 다른 문제가 발생했는데요. 앞서 설명한 것처럼 GaEventTag의 자식은 반드시 단일 엘리먼트여야 하는데, HeadingBox 컴포넌트는 내부에서 탭 UI를 감싸는 구조였기 때문에 GaEventTag를 적용하기 어려웠습니다.

그런데 구조를 다시 살펴보니, 사실 HeadingBox가 탭 UI를 감싸고 있을 필요는 없더라구요. 그래서 두 컴포넌트를 부모-자식 관계가 아닌 같은 레벨의 컴포넌트로 사용하도록 구조를 수정했습니다.

<>
<GaEventTag  page="로깅페이지" section="로깅영역" tags={['로깅이름', '더보기']}>
 <HeadingBox
      id={id}
      title=//...
      link={link ?? ''}
      className={className}
   />
</GaEventTag>
// 탭 ui 반복문...
<div className={cn(LIST_TAB_CLASS)} role="tablist">
        {tabList.map((item, index) => {
          const selected = item === selectedTab
          return (
          >
           <GaEventTag key={`${item}Tab`} page="로깅페이지" section="로깅영역" tags={['로깅이름', item]}>

           //.. 나머지 코드
</>

GaEventTag 이점

GaEventTag을 활용하면서 얻은 이점은 다음과 같습니다.

1. UI 컴포넌트에서 불필요한 로깅 props 제거

기존 HeadingBox에 존재하던 gapage, gasection, gatags와 같은 로깅 관련 props를 제거하고, 컴포넌트가 비즈니스 UI와 관련된 props만 관리되도록 수정되었습니다. 이 과정에서 로깅을 위해 사용되던 불필요한 제네릭 함수들도 함께 제거할 수 있었고, 컴포넌트 내부에 존재하던 if 분기 로직 역시 정리할 수 있었습니다.

2. 로깅의 선언적 관리

앞서 설명한 것처럼 비즈니스 UI는 UI 역할만 담당하고, 로깅은 GaEventTag가 담당하도록 책임을 분리했습니다. 덕분에 로깅을 컴포넌트 외부에서 선언적으로 관리할 수 있게 되었고, 개발자는 더 이상 비즈니스 로직을 수정할 때 로깅까지 함께 고려할 필요가 없어졌습니다.

보완점

비록 기존보다 개선된 코드지만 아직 아쉬운 부분이 존재합니다.

  1. 개발자가 실수로 GaEventTag 컴포넌트 자식으로 다수 엘리먼트를 주입한다면 런타임단계에서 에러가 발생합니다.

문제는 GaEventTag는 자식 엘리먼트를 알 수 없기때문에 컴파일 단계에선 파악이 불가능합니다.

  1. Heading, 탭 ui 내 동일한 로깅 props가 들어가는 케이스가 있습니다. 동일함에도 개발자가 일일히 로깅 props를 추가해줘야해요.
// page, section props가 동일함
<GaEventTag page="로깅페이지" section="로깅영역" tags={['로깅이름', '더보기']}>
 <HeadingBox
//..
 <GaEventTag key={`${item}Tab`} page="로깅페이지" section="로깅영역" tags={['로깅이름', item]}>
              <Button

위 문제점들을 개선하기 위해 더 나은 방안을 강구하는 것이 숙제일 듯 합니다.

Thank You for Visiting My Blog, Have a Good Day 😆