이번 글에서는 토스페이먼츠의 프론트엔드 챕터에서 로깅 방법을 개선한 과정을 보고 현재 회사에 적용한 방법을 소개해 보겠습니다.
현재 회사에는 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를 사용했습니다.
export const LogContext = createContext<LoggerParamsProp[]>([])
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,
})
}
}
각 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
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(노출)을 구현하기 위해서는 추가 조건들이 있었습니다.
위 조건을 해결하기 위해서는 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
기존에 명시된 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
위의 코드를 합쳐서 최종적으로는 아래와 같은 코드가 나오게 됩니다.
이제 프론트엔드들은 비즈니스 로직에만 신경 쓸 수 있게되었습니다.
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>
);
}
하지만 현재의 로깅 모듈 구조로는 오직 비즈니스 로직만 신경쓰면 된다고 말하기는 어렵습니다.
왜냐하면 제작후 적용하다보니 아래와 같은 에로사항이 생겼습니다.
아직 완벽하게 해결되지는 못했지만, 현재의 환경을 개선하기 위한 노력을 계속 해보려고합니다.
피드백은 언제나 환영입니다 :)