서비스 로깅

서비스 내 유저의 행위를 기록하는 것은 중요하다. 어디에서 어떤 동작을 하여 어떤 결과를 얻었는지를 기록함으로써, 유저의 행동을 추적하고, 문제가 발생했을 때 원인을 파악할 수 있다. 서비스 로그를 남길 때 기본적으로 두 가지를 고려한다. 로그의 종류와 로그의 형식이다.

로그의 종류

로그의 종류는 남기고자 하는 것을 정의하는 것이다. 큰 단위로 생각하면 서비스 로그와 개발용 로그로 나눌 수 있다. 서비스 로그는 유저의 행동을 기록하고, 개발용 로그는 각종 에러나, 디버깅 등의 개발에 필요한 추적용 기록이다. 이 글에선 서비스 로그에 집중하여 서비스 로그 기준으로 로그의 종류를 나눈다. 서비스 로그에서의 종류는 결국 유저의 어떤 행동을 기록할 것인지를 결정하는 것이다. 유저의 행동, 기록에서 기본적인 것들은 아래와 같이 생각해 볼 수 있다.

로그의 형식

로그의 형식은 유저 식별자, 시간, 각 로그의 고유 아이디 등 로그의 맥락적인 정보를 일관되게 표현하는 목적이다. 로그의 형식은 결국 로그를 분석하기 위한 것이기에, 로그를 분석하기 위한 목적에 맞게 로그의 형식을 결정해야 한다. 예를 들면 특정 기간 동안 어떤 진입이 많았는지, 유저들은 전체 퍼널에서 어떤 화면까지 진입했고 어디에서 이탈하는 지 등을 쉽게 파악할 수 있어야 한다.

맥락적인 정보

형식

위 맥락적인 정보를 표현할 수 있는 형식을 생각해 보면, 기본적으론 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>
  );
});

확실히 더 편리해졌다. 하지만 완전한 형태의 편리함까진 아니다. 필요한 로그 파라미터는 여전히 버튼 컴포넌트에 직접 작성해야 하고, 컴포넌트의 깊이가 깊어질수록 로그 파라미터를 전달하는 것이 번거로워진다. 이런 문제를 해결하기 위한, 또 다른 방법은 없을까 고민해 볼 만한 지점이라 생각한다.