서비스 로깅
서비스 내 유저의 행위를 기록하는 것은 중요하다. 어디에서 어떤 동작을 하여 어떤 결과를 얻었는지를 기록함으로써, 유저의 행동을 추적하고, 문제가 발생했을 때 원인을 파악할 수 있다. 서비스 로그를 남길 때 기본적으로 두 가지를 고려한다. 로그의 종류와 로그의 형식이다.
로그의 종류
로그의 종류는 남기고자 하는 것을 정의하는 것이다. 큰 단위로 생각하면 서비스 로그와 개발용 로그로 나눌 수 있다. 서비스 로그는 유저의 행동을 기록하고, 개발용 로그는 각종 에러나, 디버깅 등의 개발에 필요한 추적용 기록이다. 이 글에선 서비스 로그에 집중하여 서비스 로그 기준으로 로그의 종류를 나눈다. 서비스 로그에서의 종류는 결국 유저의 어떤 행동을 기록할 것인지를 결정하는 것이다. 유저의 행동, 기록에서 기본적인 것들은 아래와 같이 생각해 볼 수 있다.
- 화면 진입
- 화면(Viewport)에 노출된 요소
- 유저의 액션
- 클릭, 스크롤, 키보드 입력 등 Window/DOM 이벤트
로그의 형식
로그의 형식은 유저 식별자, 시간, 각 로그의 고유 아이디 등 로그의 맥락적인 정보를 일관되게 표현하는 목적이다. 로그의 형식은 결국 로그를 분석하기 위한 것이기에, 로그를 분석하기 위한 목적에 맞게 로그의 형식을 결정해야 한다. 예를 들면 특정 기간 동안 어떤 진입이 많았는지, 유저들은 전체 퍼널에서 어떤 화면까지 진입했고 어디에서 이탈하는 지 등을 쉽게 파악할 수 있어야 한다.
맥락적인 정보
- 로그 시각
- 가장 기본적인 정보다. "언제"라는 정보는 어떤 분석에서든 꼭 필요하다.
- 유저 식별자
- 모바일 앱 환경이라면 기기/유저마다의 고유한 식별자가 있을 것이고, 그렇지 않은 일반 브라우저 환경이라면 브라우저 스토리지에 임의의 식별자를 추가할 수 있다.
- 로그 식별자
- 분석 자체에 필요하다기보단, 특정 로그를 서로 공유하는 데 더 필요하다. 또한 로그 식별자를 잘 설정해서 실수로 인한 로그의 중복을 피할 수 있도록 설계할 수 있다.
- 세션 식별자
- 세션 식별자는 유저의 세션을 구분하기 위한 것이고 유저 식별자와는 다르다. 세션은 유저가 서비스에 접속한 시점부터 브라우저를 닫을 때까지의 기간을 의미한다. 세션 식별자를 통해 유저의 세션을 구분하고, 세션별로 어떤 행동을 했는지를 파악할 수 있다.
- 추가 정보
- 각 로그의 종류마다 추가로 필요한 정보가 따로 있다. 진입 로그는 어떤 화면으로 진입했는지, 클릭 로그는 어떤 버튼, 엘리먼트가 대상인지, 스크롤 로그는 어떤 위치로 스크롤 했는지 등의 필요한 정보는 각각 다르고, 이를 로그 형식에 자유롭게 추가하고 구분할 수 있는 적절한 방식을 찾아야 한다.
형식
위 맥락적인 정보를 표현할 수 있는 형식을 생각해 보면, 기본적으론 JSON 형식에, 깊이(depth)의 차이를 두어 기본 정보와 추가 정보를 구분할 수 있다.
{
"timestamp": "2024-07-06T06:54:20.976Z",
"userId": "user-1234",
"logId": "log-1234",
"sessionId": "session-1234",
"type": "view",
"data": {
"screen": "home",
"referrer": "https://example.com/",
"device": "mobile",
"os": "iOS",
"browser": "Safari"
}
}이런 형태의 정의라면 로그의 종류에 따라 type을 추가하고, 추가 정보는 data에 넣어서 표현할 수 있다. 이렇게 하면 로그의 형식을 일관되게 유지하면서도, 로그의 종류에 따라 필요한 정보를 추가할 수 있다.
더 고려할 점
위 형식으로 서비스 로그를 남기면 어떨까? 각 로그는 어떤 행동을 표현하는지 어느정도 명확하게 확인할 수 있지만, 로그의 그룹화는 불편할 수 있다. 예를 들어, A 화면에 "다음"이라는 버튼이 있고, B 화면에도 "다음" 버튼이 있다면 어떻게 구분할 수 있을까. 혹은 특정 유저가 A화면에서 행동한 이벤트들을 모아보고 싶다면 어떻게 해야할까. 단일 로그의 내용 자체는 표현하는 데 문제없지만, 분석에 있어서는 여전히 불명확한 지점이 있다. 이러한 정보들을 모두 추가 정보(위의 data필드)에 담으면 괜찮을까? 문제를 해소할 수는 있겠지만 인지적인 측면에선 조금 더 고민해 볼 필요가 있다. data 필드는 말 그대로 "추가적인" = "선택적인" 정보 이기에 분석에서 어떤 그룹화의 키로 사용하기엔 모호한 면이 있다. 이런 경우에는 로그의 그룹화를 위한 정보로 따로 정의해 추가해 주는 게 더 편리하다.
앞서 말한 로그의 구분은 그룹화 키를 하나 더 정의하여 해소한다. 여기에서 구분하고자 하는 것은 "화면"이고 대부분의 거의 모든 로그는 특정 화면에 종속적이기에 "화면"에 대한 값을 정의해 그룹화의 키로 사용하는 것을 생각해 볼 수 있다.
interface LogData {
...
type: "view" | "click" | "scroll" | ..;
screen: string; // 화면 식별자를 추가한다.
data: {
...
}
}이와 같이 분석에 필요한 키는 각 필요에 따라 유연하지만 일관되게 정의하고 추가할 수 있어야한다.
구현
이제 로그의 종류와 형식을 잘 정의했다면 실제 서비스 코드에서 로그를 전송하도록 해야 한다. 간단한 직접 호출 방식, 선언적 방식, 디자인 시스템과의 결합을 알아볼 건데, TossTech - 프론트엔드 로깅 신경 안 쓰기 글의 요약본이다. 더 자세한 내용은 해당 글을 자세히 읽어보길 추천한다.
직접 호출
<button onClick={() => {
log({type: "click", screen: "home", data: { content: '다음' });
//...서비스로직
}}>다음</button>하지만 남기고 싶은 모든 곳에 위와 같은 로그 함수를 일일이 추가, 호출하는 것은 비효율이고 놓치기 쉽다. 서비스 로직과의 결합도 그리 보기 좋아 보이진 않는다. 프론트엔드 서비스는 보통 컴포넌트라는 UI 단위를 구분해 사용한다. 선언적 작성의 장점이 있기 때문인데, 이 방식에 로그를 녹여볼 수도 있다.
선언적 방식
<LogClick>
<button onClick={() => { /*...서비스로직 */}}>다음</button>
</LogClick>
}LogClick 컴포넌트는 다음과 같은 구현으로 자식 컴포넌트의 onClick 핸들러를 가로채고 로그를 남길 수 있다.
function LogClick({children, params}: {children: ReactNode, params: LogData}) {
const child = Children.only(children);
return React.cloneElement(child, {
onClick: (e) => {
log(params);
if (child.props && typeof child.props['onClick'] === 'function') {
return child.props['onClick'](...args);
}
}
});
}이 방식은 선언적이고, 로그를 남기고자 하는 컴포넌트에만 추가하면 되기에 편리하며, 서비스 로직과의 분리가 잘 이뤄진다. 하지만 결국 로그를 남기고자 하는 모든 컴포넌트에 추가해야 하고, 컴포넌트의 깊이가 증가하는 단점이 있다. 컴포넌트의 첫 번째 자식(1-depth child)에만 적용할 수 있어 컴포넌트 추상화에 prop-drilling 등 걸림돌이 되곤 한다.
디자인 시스템 결합
조금 더 고민해보면, 대부분의 서비스는 공통된 디자인 시스템을 사용한다. 그렇기에 이런 로그 방식을 디자인시스템에 내제화 하면 어떨까?
interface Props extends ComponentProps<typeof TdsButton> {
logParams?: LogPayloadParameters;
}
export const Button = forwardRef(function Button({ logParams, ...props }: Props, ref: ForwardedRef<any>) {
return (
<LogClick params={logParams} component="button">
<TdsButton ref={ref} {...props} />
</LogClick>
);
});확실히 더 편리해졌다. 하지만 완전한 형태의 편리함까진 아니다. 필요한 로그 파라미터는 여전히 버튼 컴포넌트에 직접 작성해야 하고, 컴포넌트의 깊이가 깊어질수록 로그 파라미터를 전달하는 것이 번거로워진다. 이런 문제를 해결하기 위한, 또 다른 방법은 없을까 고민해 볼 만한 지점이라 생각한다.