코드를 보고 있는 개발자

프론트엔드 로깅 신경 안 쓰기

#Frontend
최진영 · 토스페이먼츠 Frontend Developer
2023년 12월 20일

제품을 개발하다 보면 사용자가 어떻게 제품을 사용하는지, 제품을 사용할 때 어떤 행동을 했는지 알아야 할 때가 있어요. 그래서 우리는 로그 데이터를 사용해요. 로그를 기록하는 것을 '로깅'이라고 하는데요. 로깅으로 수집한 데이터로 사용자의 행동을 분석하거나, A/B 테스트의 결과를 확인하거나, 재현하기 어려운 환경에서 디버깅 할 수 있어요.

이번 글에서는 토스페이먼츠 프론트엔드 챕터에서 로깅 방법을 개선한 과정을 소개해 볼게요.

들어가기 전에

로깅을 어떻게 개선했는지 소개하기 전에, 기존 방식을 살펴보려고 해요. 다음은 카드를 등록하는 페이지의 코드예요.

import { Button, useToaster } from '@tossteam/tds';
// ...

const REGISTER_CARD_SCREEN_LOG_ID = 123;
const REGISTER_CARD_CLICK_LOG_ID = 456;
const REGISTER_CARD_POPUP_LOG_ID = 789;

const PAGE_TITLE  = '카드 정보를 입력해주세요';

function RegisterCardPage() {
  // ...
  const toast = useToaster();
  const logger = useLogger();

  useEffect(() => {
    // 스크린 로그 요청
    logger.screen({
      logId: REGISTER_CARD_SCREEN_LOG_ID,
      params: { title: PAGE_TITLE } 
    });
  },[])

  return (
    <>
    // ...
    <Button onClick={async () => {
      try {
        // 클릭 로그 요청
        logger.click({ 
          logId: REGISTER_CARD_CLICK_LOG_ID,
          params: { title: PAGE_TITLE, button: '다음' }
         });
        await registerCard(cardInfo);
        // ...    
        } catch (error) {
        // 토스트 팝업 로그 요청
        logger.popup({
          logId: REGISTER_CARD_POPUP_LOG_ID,
          type: 'toast',
          params: { title: PAGE_TITLE, message: error.message }
        });
        toast.open(error.message);
       }
     }}>
     다음
    </Button>
   </>
  );
}

위 코드에는 유저가 페이지에 접속했을 때 찍는 스크린 로그, 유저가 클릭했을 때 찍는 클릭 로그, 유저가 토스트나 모달을 봤을 때 찍는 팝업 로그들이 있어요. 그리고 이 로그들은 프론트엔드 개발자가 직접 코드 안에 작성했어요. 정의한 액션이 일어날 때마다 액션 직전에 로그 식별자인 logId와 함께 로깅해요.

이 코드를 여러 차례 개선을 거쳐 아래와 같이 변경했어요.

import { Button, useToaster } from '@tosspayments/log-tds';
import { LogScreen } from '@tosspayments/log-core';

function RegisterCardPage() {
  const toast = useToaster();
  
  return (  
    <LogScreen title="카드 정보를 입력해주세요">   
      // ...
      <Button onClick={async () => {
       try {   
         await registerCard(cardInfo);
         // ...
        } catch (error) {
          toast.open(error.message);
        }
      }}> 
        다음
      </Button>
    </LogScreen>  
  );
}

로깅과 관련된 로직들이 사라진 게 보이시나요? 개발자들이 직접 로깅 하는 코드는 대부분 사라지고 import 문이 바뀌었어요. 또 남아있는 로깅 로직은 선언적으로 작성되었고, LOG_IDPAGE_TITLE 와 같은 상수도 사라진 걸 볼 수 있어요.

어떻게 이렇게 변경했을까요?

로깅 지식 없애기

위에서 봤던 예제 코드에서 가장 신경 쓰였던 부분은 어디였나요? 혹시 저와 같은 부분을 생각하고 계신가요?

맞아요. 코드 처음부터 등장하는 LOG_ID라는 상수값이 신경 쓰였을 거예요. 토스의 기존 로깅 시스템을 모르는 사람이라면 이 값이 왜 필요하지? 라는 생각도 들 거고요.

const REGISTER_CARD_SCREEN_LOG_ID = 123;
const REGISTER_CARD_CLICK_LOG_ID = 456;
const REGISTER_CARD_POPUP_LOG_ID = 789;

logId는 간단히 말하면 로그 식별자예요. 여러 로그를 식별해 주는 id 값으로, 로그를 요청할 때 필수에요. 그래서 프론트엔드 개발자는 로그를 심을 때마다 문서에서 logId를 복사해서 코드에 붙여넣는 작업을 반복적으로 해야 했어요.

로깅을 하려면 logId를 알아야 한다는 게 번거롭다고 생각했어요. 그래서 logId를 없애고 프론트엔드 개발자가 몰라도 되는 로그 식별자를 만들었어요. 서비스의 이름과 페이지의 라우트, 그리고 로깅 타입과 이벤트 타입 등 여러 정보를 합쳐서 자동으로 식별자를 만들고 logName이라는 이름을 붙였어요. 사람이 미리 정의하고 전달하는 대신, 애플리케이션이 스스로 식별자를 생성하고 이를 이용해서 로깅 하는 구조죠.


function createLogName({ logType, eventType }: { logType: LogType, eventType?: EventType }) {
  const serviceName = packageJson.name;
  const routerPath = location.pathname;

  const screenName = `payments_${serviceName}__${rotuerPath}`;
  const eventName = `::${eventType}__${eventName}`;

  const logName = `${screenName}${logType === 'event'? eventName : ''}`;

  return logName;
}

logName을 도입함으로써 개발자는 더 이상 logId를 신경 쓸 필요가 없어졌어요. 매번 logId를 복사해서 코드에 붙여넣기를 하지 않아도 되고, 커뮤니케이션 비용도 자연스럽게 줄었어요. 로깅 작업을 하기 위해 개발자가 알아야 하는 지식이 하나 없어져서 기존의 불편함이 사라졌고요. 기존 코드와 비교해 볼게요. 다음과 같이 logId를 파라미터로 전달했던 코드가 사라진 걸 알 수 있어요.

function RegisterCardPage() {
  // ...
  // (제거됨)
  // const REGISTER_CARD_SCREEN_LOG_ID = 123; 
  // const REGISTER_CARD_CLICK_LOG_ID = 456;
  // const REGISTER_CARD_POPUP_LOG_ID = 789;

  useEffect(() => {
    logger.screen({
      // logId: REGISTER_CARD_SCREEN_LOG_ID, (제거됨)
      params: { title: PAGE_TITLE }
    });
  },[])

  return (
    <>
      // ...
      <Button onClick={async () => {
        try {
          logger.click({ 
            // logId: REGISTER_CARD_CLICK_LOG_ID, (제거됨)
            params: { title: PAGE_TITLE, button: '다음' }
          });
          await registerCard(cardInfo);
          // ...    
        } catch (error) {
          logger.popup({ 
            // logId: REGISTER_CARD_POPUP_LOG_ID, (제거됨)
            type: 'toast', 
            params: { title: PAGE_TITLE, message: error.message }
          });
          toast.open(error.message);
        }
      }}>
        다음
      </Button>
    </>
  );
}

하지만 아직 불편한 부분들은 남아있어요. 카드를 등록하는 페이지에 좀 더 복잡한 로직을 추가해 볼게요.

function RegisterCardPage() {
  // ...

  return (
    // ...
    <Button onClick={async () => {
      try {
        logger.click({ params: { title: PAGE_TITLE, button: '다음' }});
        await validatecCrdNumber({ cardNumber });
        await validateCardOwner({ cardNumber, name });
        await registerCard({ cardNumber, ... });
        router.push('/identification');     
      } catch (error) {
        logger.popup({ 
          type: 'toast', 
          params: { title: PAGE_TITLE, message: error.message }
        });
        toast.open(error.message);
      }
    }}>
      다음
    </Button>
  );
}

코드를 보면 다음 버튼을 눌렀을 때 동작하는 로직이 한눈에 읽히나요? 로직을 읽기 전에 로깅과 관련된 코드가 먼저 눈에 들어오진 않나요?

실제로 페이지에서 수행하는 로직이 복잡할수록, 또는 로깅이 많을수록 가독성이 떨어졌어요. 페이지의 비즈니스 로직만 읽고 싶어도 로깅 관련 코드가 함께 섞여 있었기 때문이에요. 개발자가 신경 쓰는 부분은 로깅이 아니라 비지니스 로직일 때가 많은데, 매번 로깅 관련 코드를 한번 마주치고 난 뒤에 비즈니스 로직을 읽어야 하니 불편했어요.

로깅 선언적으로 관리하기

그래서 비즈니스 로직으로부터 로깅 로직을 격리하는 것, 그리고 어떻게 하면 읽기 좋게 선언적으로 코드를 작성할 수 있을지 고민했어요. 오직 ‘해당 영역에 로깅을 한다’라는 관심사만 남겨두고 코드를 최소한으로 작성할 방법을 고민한 끝에 LogScreen, LogClick 과 같은 로깅 컴포넌트를 만들었어요. 로깅 컴포넌트는 로깅을 수행하는 역할만 해요.

export function LogScreen({ children, params }: Props) {
  const router = useRouter();
  const logger = useLogger();

  useEffect(() => {
    if (router.isReady) {
      logger.screen({ params });
    }
  }, [router.isReady]);

  return <>{children}</>;
}
export function LogClick({ children, params }: Props) {
  const child = Children.only(children);
  const logger = useLog();

  return cloneElement(child, {
    onClick: (...args: any[]) => {
      logger.click({ params });

      if (child.props && typeof child.props['onClick'] === 'function') {
        return child.props['onClick'](...args);
      }
    },
  });
}

이제 개발자는 오직 로깅하고 싶은 화면이나 버튼에 LogScreen, LogClick 컴포넌트를 사용하도록 바뀌었어요. 로깅을 좀 더 선언적으로 처리할 수 있게 됐죠. 또 비즈니스 로직에서 로깅 관련 로직을 격리하자는 처음의 목표도 달성했어요. ‘어느 시점에 로직을 남겨야 하지?’와 같은 구현도 신경 쓸 필요 없이 로깅 컴포넌트를 사용하기만 하면 되고요.

function RegisterCardPage() {
  // ...

  return (
    <LogScreen params={{ title: PAGE_TITLE }}>
    // ...
      <LogClick params={{ title: PAGE_TITLE, button: '다음' }}>
        <Button onClick={async () => {
          try {
            await validatecCrdNumber({ cardNumber });
            await validateCardOwner({ cardNumber, name });
            await registerCard({ cardNumber, ... });
            router.push('/identification');     
          } catch (error) {
            logger.popup({ 
              type: 'toast', 
              params: { title: PAGE_TITLE, message: error.message }
            });
            toast.open(error.message);
          }
        }}>
          다음
        </Button>
      </LogClick>
    </LogScreen>
  );
}

로깅으로부터 코드 보호하기

로깅을 어느 정도 고도화한 뒤에 아래와 같은 패턴의 요구사항이 많아졌어요.

  • ”스크린 로그에 찍힌 페이지 제목이 스크린 하위의 버튼들에도 찍히면 좋겠어요!“
  • “특정 영역에는 전부 A 로그 파라미터를 추가해 주세요!“
  • “이 서비스에서 사용하고 있는 식별자가 모든 로그 파라미터에 찍히게 해주세요!“

이런 요구사항을 처리하려면 코드가 좀 더 복잡해질 수밖에 없었는데요. 이번에도 예시와 함께 볼게요. 카드를 등록하는 페이지의 모든 로그에 페이지 타이틀과 사용자의 id가 남도록 작성된 코드예요.

function RegisterCardPage() {
  const { userId } = useUser();
  // ...

  return ( 
    <LogScreen params={{ title: PAGE_TITLE }}>
      // ...
      <RegisterCardForm
        logParameters={{ title: PAGE_TITME, userId }} 
        onSubmit={...}
      />
      <LogClick params={{ title: PAGE_TITLE, userId, button: '다음' }}>  
        <Button onClick={...}> 
          다음
        </Button>
      </LogClick>
    </LogScreen>
  );
}

function RegisterCardForm({ logParameters, onSubmit }) {
  return ( 
    <form onSubmit={onSubmit}>
      <CardNumberField />
      <LogClick params={{ ...logParameters, button: '카드번호 초기화' }}>
        <Button>
          카드번호 초기화
        </Button>
      </LogClick>
      // ...
    </form>
  );
}

이런 코드에서는 로그 파라미터를 전달하기 위해 하위 컴포넌트의 props를 수정할 수밖에 없어요. logParameters라는 props를 추가해서 로그 파라미터를 전달해야 했고, 전달해야 하는 컴포넌트의 깊이가 깊어질수록 prop drilling이 심해졌어요.

또, logParameters 라는 컴포넌트의 역할과는 거리가 먼 props가 추가되면서 컴포넌트의 인터페이스가 어색해졌어요. RegisterCardForm이라는 컴포넌트는 form이라는 역할에 필요하지 않은 logParameters라는 props가 추가된 것처럼요.

이런 문제를 해결하기 위해 리액트의 Context를 사용했어요. 로그 파라미터를 관리하는 Context를 만들고 Provider로 특정 영역에 로그 파라미터들을 주입할 수 있게 만들었어요. 각 컴포넌트는 가장 가까운 LogParamsContext를 읽어서 로깅해주기만 하면 props 전달 없이도 로그 파라미터를 각 컴포넌트에 전달할 수 있었어요.

interface Props {
  children: ReactNode;
  params?: LogPayloadParameters;
}

const LogParamsContext = createContext<LogPayloadParameters | null>(null);

export function LogParamsProvider({ children, params }: Props) {
  return (
    <LogParamsContext.Provider
      value={params}
    >
      {children}
    </LogParamsContext.Provider>
  );
}

export function useLogParams() {
  return useContext(LogParamsContext);
}
function useLogger() {  
  const parentParams = useLogParams();
	
  const log = (params) => {
    logClient.request({ params: { ...parentParams, ...params });
  }
	// ...
}

LogScreen 하위에 찍힌 로그는 모두 LogScreen의 로그 파라미터를 받을 수 있도록 LogScreenchildrenLogParamsProvider로 감싸도록 만들었어요.

export function LogScreen({ children, params }: Props) {
  const router = useRouter();
  const logger = useLogger();

  useEffect(() => {
    if (router.isReady) {
      logger.screen({ params });
    }
  }, [router.isReady]);

  return <LogParamsProvider params={params}>{children}</LogParamsProvider>
}

이렇게 함으로써 특정 영역 하위 로깅에 로그 파라미터가 필요한 경우 logParameters를 props로 전달할 필요가 없어졌어요. 오직 LogParamsProviderLogScreen을 활용하면 이전처럼 props를 만들어서 전달하거나 전역 상태를 직접 만들 필요 없이, 여러 로그 파라미터를 주입할 수 있게 되었어요. 로깅을 위해 인터페이스를 해치는 코드를 작성하거나 비즈니스 로직에 불필요한 데이터를 전달하지 않도록 바뀌었죠.

function RegisterCardPage() {
  // ...
  return (
    <LogScreen params={{ title: PAGE_TITLE, userId }}>   
      // ...
      <RegisterCardForm onSubmit={...}/> 
      <LogClick params={{ button: '다음' }}>
        <Button onClick={...}>
          다음  
        </Button>   
      </LogClick>  
    </LogScreen> 
  );
}

function RegisterCardForm({ onSubmit }) { 
  return (  
    <form onSubmit={onSubmit}>   
      <CardNumberField />
      <LogClick params={{ button: '카드번호 초기화' }}>
        <Button>
          카드번호 초기화 
        </Button>
      </LogClick>
      // ... 
    </form>  
  );
}

로깅 숨기기

로깅하는 코드를 쭉 살펴보니, 비슷한 패턴이 정말 많았어요. 예를 들면 buttonLogClick으로 감싼다거나, radioLogClick으로 감싼다거나, toast가 뜰 때 logger.popup을 찍는 등 다양한 서비스에서 공통으로 나타나는 패턴들이 많았어요. 그리고 이런 코드는 토스의 디자인 시스템인 TDS의 컴포넌트와 함께 자주 사용됐어요.

import { Button, useToaster } from '@tossteam/tds';

function RegisterCardPage() {
  const toast = useToaster();
  // ...

  return (
    // ...
    <LogClick params={{ button: '다음' }}>  
      <Button onClick={() => {  
        try { 
          // ...
        } catch (error) {
          logger.popup({ 
            type: 'toast',    
            params: { message: error.message }  
          }); 
          toast.open(error.message);
        } 
       }}>  
       다음  
      </Button>
    </LogClick>
  );
}

TDS 또한 우리가 핸들링하는 영역이고, 어느 제품에서나 모두 사용하고 있으니 TDS 인터페이스를 그대로 따르는 TDS 로깅 컴포넌트를 만들면 어떨까? 라는 생각을 하기 시작했어요. 인터페이스는 일반 TDS와 같되, 내부적으로는 여러 이벤트의 로그를 찍는 컴포넌트로요.

이런 아이디어를 기반으로 TDS 컴포넌트에 로그에 대한 책임을 추가로 부과하는 컴포넌트, 즉 하나의 로그 데코레이터를 단 TDS 컴포넌트들을 만들었어요. TDS를 감싸서 특정 이벤트가 발생했을 때 로깅하는 컴포넌트죠.

interface Props extends ComponentProps<typeof TdsButton> {
  logParams?: LogPayloadParameters;
}

export const Button = forwardRef(function Button({ logParams, ...props }: Props, ref: ForwardedRef<any>) {
  return (
    <LogClick params={{ ...logParams, button: getInnerTextOfReactNode(props.children) }} component="button">
      <TdsButton ref={ref} {...props} />
    </LogClick>
  );
});
import { LogPayloadParameters } from '@tosspayments/log-core';
import { useLog } from '@tosspayments/log-react';
import { useToaster as useTDSToaster } from '@tossteam/tds-pc';
import { useMemo } from 'react';

interface LogParams {
  logParams?: LogPayloadParameters;
}

export function useToaster() {
  const toaster = useTDSToaster();
  const log = useLog();

  return useMemo(() => {
    return {
      open: (options: Parameters<typeof toaster.open>[0] & LogParams) => {
        log.popup({
          component: 'toast',
          params: { popupTitle: options.message, ...options?.logParams },
        });

        return toaster.open(options);
      },
      close: toaster.close,
    };
  }, [log, toaster]);
}

이제 프론트엔드 개발자는 import 문만 수정하면 로깅을 할 수 있어요. @tossteam/tds에서 @tosspayments/log-tds만 변경하면 컴포넌트가 스스로 로깅 하도록 변경할 수 있는 거죠. 또한 이미지의 ARIA 라벨이나 버튼 텍스트와 같은 UI 요소를 매번 로그 파라미터로 넘기지 않아도 자동으로 로깅할 수 있게 해서 중복 코드들도 사라졌어요.

import { Button, useToaster } from '@tosspayments/log-tds';

function RegisterCardPage() {
  // ...

  return (
  // ...
    <Button onClick={() => {
      try {    
        ... 
      } catch (error) {  
        toast.open(error.message);
      }
    }}>  
     다음
    </Button>
  );
}

정리하기

개선된 코드를 다시 한번 볼까요?

  • logId 를 넘겨주지 않아요.
  • LogScreen을 이용해서 선언적으로 로그를 작성할 수 있어요.
  • 로그에 페이지 타이틀을 전달하지 않아도 돼요.
  • 버튼이나 토스트는 아예 로깅을 하지 않아도 돼요.

로깅에 관한 코드는 import문을 제외하면 단 한 줄, LogScreen만 있어요.

import { Button, useToaster } from '@tosspayments/log-tds';
import { LogScreen } from '@tosspayments/log-core';

function RegisterCardPage() {
  const toast = useToaster();

  return (
    <LogScreen title="카드 정보를 입력해주세요">
      // ...
      <Button onClick={async () => {
        try {
          await registerCard(cardInfo);
          // ... 
        } catch (error) {
          toast.open(error.message);  
        }
      }}>
        다음
      </Button>   
    </LogScreen>
  );
}

그럼에도 불구하고…

‘로깅을 최대한 신경 쓰지 않게 하자’는 목표로 시작한 프로젝트였지만 현재의 로깅 모듈 구조로는 해당 목표를 완수했다고 말하기 어려워요. 아래와 같은 이유 때문이에요.

  • 특정 서비스에 국한된 로그파라미터는 서비스 개발자가 일일이 추가해줘야 해요.
  • 서비스 개발자가 매번 LogScreen를 이용해서 스크린 로깅을 추가해줘야 해요.
  • TDS를 커스텀하게 만들어서 사용하는 경우에는 로그 TDS 컴포넌트를 사용할 수 없어요.

그래서 토스페이먼츠의 프론트엔드 챕터는 앞으로도 ‘어떻게 이런 문제를 해결하고 로깅을 더 신경 쓰지 않을 수 있을까’를 계속 고민하고 더 좋은 방법을 찾아나갈 예정이에요.

Write 최진영 Review 임재후 Edit 한주연

댓글 0댓글 관련 문의: toss-tech@toss.im
연관 콘텐츠
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.