프론트엔드 로깅 신경 안 쓰기 (Logging Component)

Philip Yoon·2024년 6월 15일
1

react

목록 보기
2/2
post-thumbnail
post-custom-banner

개요

이번 글에서는 토스페이먼츠의 프론트엔드 챕터에서 로깅 방법을 개선한 과정을 보고 현재 회사에 적용한 방법을 소개해 보겠습니다.

로깅 방식

현재 회사에는 3가지 경우에서 로깅을 찍고 있었습니다.


1. 유저가 해당 페이지에 진입한 경우 (page_view, view_content)
2. 유저가 특정 버튼을 클릭한 경우 (click)
3. 유저가 특정 영역에 진입한 경우 (impression)

세가지 상황을 기존 코드로 표현했을때는 아래와 같이 표현 되었습니다.


function MainPage() {
  // ...
  const popup = usePopup();
  const logger = useLogger();
  
  const [entry, impressionRef] = useIntersectionObserver({ once: true })
  
  useEffect(() => {
    // 스크린 로그 요청
    logger.send({
    	navigation: 'main_page',
        category: 'page_view',
			extra: {
				user_props: { -: data.isTrial ? '1' : '0' }
				event_props: { 
				transaction_id : transactionId,
				team_id : teamId,
				object_value,
        }
      }
    })
  },[])

useEffect(() => {
	// 특정 역역에 진입한 경우 imprssion 로그 요청
    if (!entry?.isIntersecting) {
      return
    }
    logger.send({
    	navigation: 'main_page',
        category: 'impression',
			extra: {
				user_props: { -: data.isTrial ? '1' : '0' }
				event_props: { 
				transaction_id : transactionId,
				team_id : teamId,
				object_value,
        }
      }
    })
  }, [entry])


  return (
    <>
    // ...
    <Button
    ref={impressionRef}
    onClick={async () => {
      try {
        // 클릭 로그 요청
        logger.send({ 
          navigation: 'main_page',
	      category: 'click',
         });
        await handleClickNext();
        // ...    
        } catch (error) {
        // 토스트 팝업 로그 요청
        logger.send({
          navigation: 'main_page',
	      category: 'modal_view',
          params: { title: PAGE_TITLE, message: error.message }
        });
        popup.open(error.message);
       }
     }}>
     다음
    </Button>
    // ...
   </>
  );
}

위 코드에는 유저가 페이지에 접속했을 때 찍는 스크린 로그, 유저가 클릭했을 때 찍는 클릭 로그, 유저가 특정 역역에 진입 했을 때 찍는 impression 로그, 유저가 모달을 봤을 때 찍는 팝업 로그들이 있습니다. 그리고 이 로그들은 프론트엔드 개발자가 직접 해당하는 코드 안에 작성했어야 했습니다.

페이지에서 수행하는 로직이 복잡할수록, 또는 로깅이 많을수록 가독성이 떨어졌습니다.
따라서 개발자가 신경 쓰는 부분은 로깅이 아니라 비지니스 로직일 때가 많은데, 로깅 관련 코드들에의해서 비즈니스 로직들이 한눈에 들어오기 쉽지 않은 구조였습니다.

따라서 비즈니스 로직과 로깅 로직을 분리를 결정했습니다.

로깅 선언적으로 관리하기

오직 ‘해당 영역에 로깅을 한다’라는 관심사만 남겨두고 코드를 최소한으로 작성할 방법을 고민한 결과 Params, View, Click, Impression과 같은 로깅 컴포넌트를 합성 컴포넌트로 만들었습니다. 로깅 컴포넌트는 로깅을 수행하는 역할만 가능합니다. 또한 props drilling을 해결하기 위해 context를 사용했습니다.

LogContext

export const LogContext = createContext<LoggerParamsProp[]>([])

useLogger

Params, View, Click, Impression 로깅 컴포넌트에서 로그를 보내 줄 수 있도록 hooks를 만들어줬습니다.

export const useLogger = (category: Category) => {
  const contextParams = mergeParams(...useContext(LoggerContext))

  return (_params?: LoggerParamsProp) => {
    const { extra, ...params } = mergeParams(contextParams, _params ?? {})
    log.send({
      ...params,
      category,
      extra,
    })
  }
}

Params

각 log component에서 navigation과 같은 중복되는 코드를 줄이기 위해 provider를 통해서 데이터를 받아 올 수 있도록 설정해두었습니다.

import React, { useContext, PropsWithChildren } from 'react'

import { LoggerContext } from '../context'
import { LoggerPramsProp as LoggerParams } from '../types'

type ParamsProps = PropsWithChildren<LoggerParams>

const Params: React.FC<ParamsProps> = ({ children, ...params }) => {
  const getters = [...useContext(LoggerContext), params]

  return <LoggerContext.Provider value={getters}>{children}</LoggerContext.Provider>
}

export default Params

View

page_view를 logging하기 위한 component이고, Params에 비동기 데이터를 설정했을때를 대비해서 dep는 props를 통해서 검증했습니다.

const View: React.FC<PropsWithChildren<ViewProps>> = ({ deps, children}) => {
  const router = useRouter()

  const sendContentViewLogger = useLogger('page_view')


  useEffect(() => {
    if (!router.isReady || deps?.some((dep) => !dep)) {
      return
    }
    sendContentViewLogger()
  }, [router.isReady, deps, sendContentViewLogger])

  return children
}

const ViewWithParams: React.FC<PropsWithUBLParams<PropsWithChildren<ViewProps>>> = ({
  deps,
  children,
  isReady,
  ...params
}) => {
  return (
    <Params {...params}>
      <View deps={deps} isReady={isReady}>
        {children}
      </View>
    </Params>
  )
}

export default ViewWithParams

Impression

impression(노출)을 구현하기 위해서는 추가 조건들이 있었습니다.

  1. impression을 구현하기 위해서는 intersectionObserver가 필요하다.
  2. element 하위에서 ref를 선언 할 수 있다. 해당 ref도 같이 사용 할 수 있어야한다.

위 조건을 해결하기 위해서는 ref를 합칠 방법이 필요했고, Chakra UI토스를 참조해서 combined해주는 hooks를 만들고, cloneElement를 통해서 구현했습니다.

import { Children, ReactElement, cloneElement, useEffect } from 'react'

import { useCombinedRefs, useIntersectionObserver } from '@packages/hooks'

import { useLogger } from '../context'

import Params, { PropsWithLoggerParams } from './Params'

type ImpressionProps = {
  children: ReactElement
}

const Impression: React.FC<ImpressionProps> = ({ children }) => {
  const sendImpressionLogger = useLogger('impression')
  const [entry, impressionRef] = useIntersectionObserver({ once: true })

  const child = Children.only(children)
  const ref = useCombinedRefs<HTMLDivElement>((child as any).ref, impressionRef)

  useEffect(() => {
    if (!entry?.isIntersecting) {
      return
    }
    sendImpressionLogger()
  }, [entry, sendImpressionLogger])

  return cloneElement(child, {
    ref: ref,
  })
}

const ImpressionWithParams: React.FC<PropsWithLoggerParams<ImpressionProps>> = ({ children, ...params }) => {
  return (
    <Params {...params}>
      <Impression>{children}</Impression>
    </Params>
  )
}

export default ImpressionWithParams

Click

기존에 명시된 element에서 onClick을 추가하기 위해 cloneElement를 사용했습니다.
forwardRef를 사용했던 이유는 서비스내에서 impression,click순으로도 사용 할 가능성이 있기에, ref를 사용 가능 하도록 해두었습니다.

import React, { cloneElement, forwardRef } from 'react'

import { useLogger } from '../context'

import Params, { PropsWithLogParams } from './Params'

type ClickProps = {
  children: React.ReactElement
}

const Click = forwardRef(({ children }: { children: React.ReactElement }, ref) => {
  const sendClickLogger = useLogger('click')

  return cloneElement(children, {
    onClick: (...args: any[]) => {
      sendClickLogger()
      if (children.props && typeof children.props['onClick'] === 'function') {
        return children.props['onClick'](...args)
      }
    },
    ref,
  })
})

Click.displayName = 'Click'

const ClickWithParams: React.FC<PropsWithLogParams<ClickProps>> = forwardRef(({ children, ...params }, ref) => {
  return (
    <Params {...params}>
      <Click ref={ref}>{children}</Click>
    </Params>
  )
})

ClickWithParams.displayName = 'ClickWithParams'

export default ClickWithParams

Final

위의 코드를 합쳐서 최종적으로는 아래와 같은 코드가 나오게 됩니다.
이제 프론트엔드들은 비즈니스 로직에만 신경 쓸 수 있게되었습니다.


import Logger from '@packages/logger/react'

function MainPage() {
  const popup = usePopup();
  
  return (  
    <Logger.Params navigation="main_page">
    	<Logger.Impression>
          <Logger.View>
              <Logger.Click>
                  // ...
                  <Button onClick={async () => {
                   try {   
                     await registerCard(cardInfo);
                     // ...
                    } catch (error) {
                      popup.open(error.message);
                    }
                  }}> 
                    다음
                  </Button>
              </Logger.Click>
	        </Logger.View>
        </Logger.Impression>
    </Logger.Params>  
  );
}

마치며

하지만 현재의 로깅 모듈 구조로는 오직 비즈니스 로직만 신경쓰면 된다고 말하기는 어렵습니다.
왜냐하면 제작후 적용하다보니 아래와 같은 에로사항이 생겼습니다.

  1. 로깅에 대한 조건식으로 인해 로깅 컴포넌트에 조건이 계속 추가된다.
  2. 레거시의 로깅 데이터 구조 때문에 이전 레거시 로깅과 완벽한 1:1 매핑이 불가능하다.

아직 완벽하게 해결되지는 못했지만, 현재의 환경을 개선하기 위한 노력을 계속 해보려고합니다.
피드백은 언제나 환영입니다 :)

참조

https://toss.tech/article/engineering-note-5

profile
FE 개발자 윤현준입니다.
post-custom-banner

0개의 댓글