무언가를 결정할 때 데이터를 기반으로 결정하자는 방법인
데이터 주도 의사 결정은 꽤 중요한 의미를 가집니다.
무언가를 다른 사람들에게 설득하고 이해시키기 위해서는 주장 뿐만아니라 근거가 필요하죠
데이터는 그 자체로 근거가 되어준다는 점에서 조직의 의사결정을 투명하고 납득가능하게 만들어줍니다.
그 외에도
1. 성과 측정
2. 개선 방향성 탐색
3. 유저 페르소나 정의
등등 데이터가 있으면 그 데이터를 기반으로 여러가지 기획을 내놓을 수도 있고
개선 방향을 잡아나갈수도 있죠
이제 프론트엔드 개발자의 관점에서 이벤트 로깅을 생각해보겠습니다.
프론트엔드 개발자는 프로덕트의 최전선에서 사용자와 직접 의사소통하는 포지션을 가집니다.
사용자는 백엔드의 api와 직접 소통하는 것이 아니라
백엔드의 api를 적절하고 쉽게 다룰 수 있도록 도와주는 GUI 즉 웹을 이용하여 API와 소통하고
이 GUI는 프론트엔드 개발자가 설계한 결과물이지요
이벤트 로깅은 서비스에서 유저의 특정 행동을 포착하면 그 행동에 대한 데이터를 "로깅"해두는 것을 의미합니다.
저같은 경우에는 적절한 로깅 시스템 구현을 위해 여러가지 레퍼런스를 찾아보았고 그중
https://velog.io/@moreso/data-event-design-for-frontend
이분의 로깅 시스템 설계에 크게 영감을 받아 유사하지만 저희 서비스에 맞는 형태로 가공시켜 사용하고있습니다.
?? 그럼 해피엔딩 아님? 뭘 더함?
이벤트 로깅이 까다로운 이유는 다양하지만 대표적으로 이야기해보면 다음과 같은 어려움이 있을 수 있습니다.
1. 복잡한 로깅로직과 비즈니스 / UI 로직의 혼재로 인해 끔찍한 코드가 생성될 수 있다.
2. 로깅 시스템은 결국 유저의 행동을 다룬다는 점에서 도메인 종속적으로 변하기 쉽다.
3. 모든 것을 로깅해버리면 안된다 -> (쓸데없는 로그 데이터들 사이에서 좋은 데이터를 찾아다니는 건 비효율적이죠)
4. 이벤트가 발생한 시점의 추가적인 맥락이 필요할 수 있다 (근데 상황마다 다 다른 추가맥락이 필요한..)
따라서 정리해보면 제 생각에 괜찮은 로깅 시스템이란
1. 다른 로직들과의 관심사 분리가 가능하며
2. 도메인 종속적이지 않으며
3. 필요할 때에만 로그를 찍을 수 있고
4. 적당히 타입세이프하면서 적당히 추가적인 맥락도 유연하게 처리할 수 있어야한다.
라고 정의할 수 있을 것 같습니다.
네 너무 무섭습니다.
다행히 위 글에서 제시하는 방법론을 통해 대부분의 문제는 해결할 수 있습니다
원문 자체도 엄청나게 긴 내용이다보니 짧게 정리해보면 다음과 같을 수 있겠네요
(글이 참 좋으니까 시간 되시는 분들은 원문을 읽어보시길 권합니다.)
이벤트가 발생한 위치와 이벤트의 종류를 분리해서 생각하자.
type FEATURE = "냠냠맨티스토리"
type PAGE = "443"
type AT = "comment"
type TARGET = "comment-input"
type ACTION = "click"
프론트엔드에서 발생하는 모든 이벤트는
기능 / 페이지 / 위치 / 대상 / 액션을 기반으로 나누어 생각할 수 있다는 관점을 제시합니다.
위 타입을 통해 이벤트의 이름을 정의해보면
냠냠맨티스토리_443_comment_comment-input_click
이런식이 되겠지요 제법 괜찮아보입니다.
하지만 이걸 그냥 하나로 퉁쳐버리게 되면
냠냠맨티스토리에서 코멘트 인풋을 클릭한 이벤트만 골라서 보고싶을 때마다
엄청나게 많은 경우의수를 가진 이벤트의 파도속에서 내가 원하는 이벤트를 찾아 다녀야하겠죠
그런데 여기에서 이벤트를 종류 / 위치로 나누어 생각하면 다음과 같이 변합니다.
이벤트 종류 : 냠냠맨_comment-input_click
이벤트 위치 : 냠냠맨_443_comment_comment-input
이제 제 티스토리 블로그에서 사용자가 댓글 인풋을 클릭한 경우를 찾고싶으면
이벤트 종류를 검색하면 되는 구조가 되었습니다.
이것이 기본적인 로그 시스템의 아이디어입니다.
관심사 분리는 로깅을 위한 Wrapper 컴포넌트를 두거나
cloneElement를 통해 로깅에대한 이벤트리스너를 외부에서 주입시키는 것을 통해 해결하였고요
계획은 그럴듯한데.. 그래서 결과는?
꽤 나쁘지 않았습니다.
물론 구현하면서 여러가지 어려움이 있기는 했지만
(유저의 로그인 여부를 알아내기와 같은 것은 외부의 지저분한 로직에게서 얻는 방법밖에 없으니까요)
오히려 꽤 좋았기 때문에 더 개선점들이 보였습니다 제가 생각하는 개선점들은 다음과 같았는데요
1. 이벤트에 대한 정보가 도메인 종속적이다
-> 예를 들어 이벤트를 발생시킨 유저의 정보 | 이벤트 발생 당시의 state 등을 로그에 포함시켜주고싶은 경우가 있었습니다.
그런데 이것들을 넣어주다보면 다른 프로젝트에서 이 로그시스템을 쓰기 위해서는
다른 프로젝트에서는 그에 맞게 유저에 대한 정의를 고쳐줘야하겠죠..
2. 이벤트를 발행하는 방식이 리액트와 결합되었다.
-> 이벤트를 발행하는 로직을 작성하는 과정에서 리액트 문법을 많이 사용하게 되었고
결론적으로 이벤트로깅 === 리액트가 되어버렸습니다.
그렇기 때문에 지금 당장의 프로젝트에서는 잘 사용할 수 있지만 다른 프로젝트에 이식하는 것은 불가능한 코드가 되었네요
그러나 회사에서 여러가지 MVP를 시도해보고 싶다는 니즈가 생겼고
그 MVP 들에도 기존의 로그시스템을 붙이고싶을 것 같아서 이 로깅 시스템을 이식가능하게 만들려면 어떻게 해야할까?를 고민해봤습니다.
기존 로그 시스템을 사용하는 방법
<ClickLogger
eventName={["냠냠맨티스토리", "comment-input", "click"]}
eventPath={["냠냠맨티스토리", "432", "comment", "comment-input"]}
>
<button>로그를 써봅시다.</button>
</ClickLogger>
const ClickLogger = ({
eventName,
eventPath,
eventProperty,
children,
}: ClickLoggerProps) => {
const { track } = useLogging();
const child = React.Children.only(children);
return React.cloneElement(child, {
onClick: (event: Event) => {
track(eventName, eventPath, eventProperty);
if (child.props.onClick) {
child.props.onClick(event);
}
},
});
};
다음과 같이 clone을 통해 로깅에 관련한 클릭 리스너를 래퍼컴포넌트를 통해 주입하고 있었습니다.
이 구현에서 핵심은 track 메서드를 반환하는 인터페이스를 준수하는 훅을 구현하는 것이죠
실제로 useLogging 훅의 내부는 유저의 상태나 맥락 등 적절한 로깅을 위한 내용들을 어찌어찌 가져와야할 것입니다.
그리고 저 track 이벤트의 구현부분에서 백엔드로의 요청과 같은 세부사항들도 같이 구현되어야겠죠
이렇듯 로깅 시스템을 이식가능한 구조로 만들기 위해서는 이렇게 끈끈한 결합을 느슨하게 분리하고
로깅 시스템 자체는 분리시키며 자세한 로직에 대한 세부구현들은 외부로부터 주입받도록 구성해야했습니다.
즉 이런 경우 다음과 같은 요구사항이 생깁니다.
1. 이벤트에 대한 세부 타입 정의 / 이벤트의 유저나 세부사항들에 대한 구조적 정의는 모두 외부에서 주입가능해야한다.
2. 로깅 이벤트가 발생했을때 수행할 로직은 외부에서 주입 가능해야한다.
이러한 요구사항이 생기게 되었고 이걸 구현할 방법을 고민해봐야했어요
느슨한 결합을 구현하는 Pub-Sub 패턴
Pub-Sub 패턴은 퍼블리시 / 섭스크라이브의 줄임말이라고도 생각할 수 있는데요
뉴스레터와 같은 구조를 생각해볼 수 있습니다.
뉴스레터의 경우에는 콘텐츠를 생산하고 발행하는 퍼블리셔가 존재할 수 있으며
뉴스레터를 구독하는 섭스크라이버가 존재할 수 있습니다.
그런데 퍼블리셔는 섭스크라이버가 누구인지는 전혀 상관하지 않으며
섭스크라이버 역시 퍼블리셔의 세부사항은 알필요가 없습니다.
뉴스레터는 그저 뉴스가 발행되면 섭스크라이버가 해당 뉴스를 구독하고있다면 그 뉴스를 섭스크라이버에게 전달해주죠
이것이 pub-sub 패턴이라고 생각할 수 있습니다.
발행자 - 중간관리자 - 구독자의 형태를 통해 발행자와 구독자 모두 중간관리자에게 의존하면
구독자와 발행자간의 결합을 느슨하게 만들 수 있다는 개념입니다.
여기에서 중간관리자는 이벤트 버스, 이벤트 채널이라고도 부르는 것 같네요
바닥부터 만들어보고 싶으니까 먼저 pub-sub을 구현하는 간단한 패키지를 만들어보겠습니다.
import { EventCreator, EventHandler, EventHandlersMap } from './type';
export class PubSubManager<Event extends EventCreator<string, {}>> {
private subscribers: {
[eventType in Event['type']]?: Array<EventHandler<Event>>;
} = {};
subscribe(eventType: Event['type'], handler: EventHandler<Event>) {
if (!this.subscribers[eventType]) {
this.subscribers[eventType] = [];
}
this.subscribers[eventType]?.push(handler);
}
unsubscribe(eventType: Event['type'], handler: EventHandler<Event>) {
const handlers = this.subscribers[eventType];
if (handlers) {
this.subscribers[eventType] = handlers.filter((h) => h !== handler);
}
}
publish(event: Event) {
const handlers =
this.subscribers?.[event?.type as unknown as Event['type']] || [];
handlers.forEach((handler) => handler(event));
}
initiate(handlerObject: EventHandlersMap<Event>) {
const entries = Object.entries(handlerObject) as [
Event['type'],
EventHandlersMap<Event>[Event['type']],
][];
entries.forEach(([eventType, handlers]) => {
handlers?.forEach((handler) => {
this.subscribe(eventType, handler);
});
});
}
initialize() {
this.subscribers = {};
}
}
타입정의는 불필요하게 오버헤드를 불러올수있으니 굳이 보여드리진 않겠습니다.
간단하게 구독 / 발행 / 전부 구독 / 초기화 네가지 일을 할 수 있는 클래스를 만들어주었습니다.
코드가 순수하고 기본 문법이외에 의존하는 것이 없으니 테스트하는 것도 어렵지 않습니다.
import { PubSubManager } from './core';
describe('testing pubsub-manager', () => {
const pubsub = new PubSubManager();
it('can subscribe', () => {
const handler = jest.fn();
pubsub.subscribe('hello', handler);
pubsub.publish({ type: 'hello' });
expect(handler).toHaveBeenCalled();
expect(handler).toHaveBeenCalledTimes(1);
});
it('can unsubscribe', () => {
const handler = jest.fn();
pubsub.subscribe('hello', handler);
pubsub.unsubscribe('hello', handler);
pubsub.publish({ type: 'hello' });
expect(handler).not.toHaveBeenCalled();
});
it('can initialize', () => {
const handler = jest.fn();
pubsub.subscribe('hello', handler);
pubsub.initialize();
pubsub.publish({ type: 'hello' });
expect(handler).not.toHaveBeenCalled();
});
it('can initiate', () => {
const handler = jest.fn();
pubsub.initiate({
hello: [handler, handler],
hi: [handler, handler],
});
pubsub.publish({ type: 'hello' });
pubsub.publish({ type: 'hi' });
expect(handler).toHaveBeenCalled();
expect(handler).toHaveBeenCalledTimes(4);
});
});
대충 사용사례에 맞게 넣어주면 잘 동작하는 걸 볼 수 있네요
해피패스일때의 동작은 잘 되고 있으니
사용자가 멀쩡하게 사용해줄거라 기대하고 엣지케이스는 테스트하지않겠습니다.
이제 이 pubsubManager를 이용하여 이벤트로깅 작업을 위한 편의기능들을 포함하고 있는
로거 매니저를 만들어보겠습니다.
로거 매니저 타입 만들기
export type LogAtomCreator<
Feature extends string,
Page extends string,
At extends string,
Target extends string,
Action extends string,
> = {
feature: Feature;
page: Page;
at: At;
target: Target;
action: Action;
};
우선 라이브러리 사용자에게 자율을 보장하되 제가 생각하는 이벤트 디자인은 따르도록 하고 싶습니다.
그렇기 때문에 사용자가 각 Feature / Page / At / Target / Action의 양식은 지키면서 주입하도록 만들겠습니다.
import { LogAtomDefault } from '../default/default-type';
export type LogNamePathCreator<
T extends LogAtomDefault,
Glue extends string = '_',
> = {
eventName: `${T['feature']}${Glue}${T['target']}${Glue}${T['action']}`;
eventNameTuple: readonly [T['feature'], T['target'], T['action']];
eventPath: `${T['feature']}${Glue}${T['page']}${Glue}${T['at']}${Glue}${T['target']}`;
eventPathTuple: readonly [T['feature'], T['page'], T['at'], T['target']];
};
개발자 편의상 뭉탱이로된 이벤트 조합들 사이에서 이벤트를 찾아다니는 것은 매우 힘듭니다.
그러니 튜플형태로 관리하면 편한데요 튜플을 문자열로 변환하는 과정에서
접착제 역할을 수행해줄 문자열의 타입도 외부에서 주입할 수 있게 짜주었습니다.
import { DeviceHelper } from '../../device-helper/device-helper';
import { LogEventCreator } from '../@types/creator/log-event-creator';
import {
LogAtomDefault,
LogEventDetailDefault,
} from '../@types/default/default-type';
import { LogNamePathCreator } from '../@types/creator/log-name-path-creator';
export class LoggerService<
EventDetail extends LogEventDetailDefault,
LogAtom extends LogAtomDefault,
Glue extends string = '_',
> {
private glue: Glue;
constructor(config?: {
defaultOptions?: {
glue?: Glue;
};
}) {
this.glue = config?.defaultOptions?.glue ?? ('_' as Glue);
}
nameTupleToString(
tuple: LogNamePathCreator<LogAtom, Glue>['eventNameTuple'],
) {
return tuple.join(this.glue) as LogNamePathCreator<
LogAtom,
Glue
>['eventName'];
}
pathTupleToString(
tuple: LogNamePathCreator<LogAtom, Glue>['eventPathTuple'],
) {
return tuple.join(this.glue) as LogNamePathCreator<
LogAtom,
Glue
>['eventPath'];
}
nameStringToTuple(eventName: LogNamePathCreator<LogAtom, Glue>['eventName']) {
return eventName.split(this.glue) as unknown as LogNamePathCreator<
LogAtom,
Glue
>['eventNameTuple'];
}
pathStringToTuple(eventPath: LogNamePathCreator<LogAtom, Glue>['eventPath']) {
return eventPath.split(this.glue) as unknown as LogNamePathCreator<
LogAtom,
Glue
>['eventPathTuple'];
}
protected createLogEnvironment(
envObj?: Omit<EventDetail['eventEnvironment'], 'device' | 'environment'>,
) {
const deviceHelper = new DeviceHelper();
const device = deviceHelper.getDevice();
const environment = process.env.NODE_ENV;
if (!envObj) {
return { device, environment };
}
return {
device,
environment,
...envObj,
};
}
createLogEvent(event: {
type: EventDetail['type'];
eventProperty?: EventDetail['eventProperty'];
eventName: LogNamePathCreator<LogAtom, Glue>['eventNameTuple'];
eventPath: LogNamePathCreator<LogAtom, Glue>['eventPathTuple'];
eventUser: EventDetail['eventUser'];
eventEnvironment?: EventDetail['eventEnvironment'];
}): LogEventCreator<EventDetail, LogAtom> {
const eventEnvironment = this.createLogEnvironment(event.eventEnvironment);
const eventProperty = event.eventProperty ?? {};
const eventTime = new Date().toISOString();
const eventName = this.nameTupleToString(event.eventName);
const eventPath = this.pathTupleToString(event.eventPath);
return {
eventEnvironment,
eventProperty,
eventTime,
eventName,
eventPath,
type: event.type,
eventUser: event.eventUser,
} as LogEventCreator<EventDetail, LogAtom>;
}
}
DeviceHelper는 제가 편의상 만든 코드로 유저의 디바이스, 기기환경을 쉽게 볼 수 있도록 도와줍니다.
Glue와 타입을 이용하여 튜플을 문자열로 / 문자열을 튜플로 변환하는 작업과
이벤트 시간 생성 / 기기환경 등을 측정하는 일을 자동으로 수행해주도록 하고
프로젝트에 따라 가변적일 수 밖에 없는 유저의 상태 / 해당 이벤트의 맥락과 같은 부분들은
객체형태만 준수한다면 외부에 100% 열린 형태로 주입받도록 되었으니
로깅 클래스는 좀 더 순수하게 로깅에 집중할 수 있어지겠네요
이제 이 패키지를 쓰는 입장에서는
1. 유저에 대한 정의
2. 외부에서 주입할 프로퍼티나 추가적인 환경
3. 이벤트의 종류와 위치
세가지만 주의하면서 코드를 짜면 되는 것이죠
이제 리스너를 구현해야할텐데 우리는 이미 위에서 코어클래스를 작성했었죠
import { PubSubManager } from '../../pub-sub/core/core';
import { LogEventDefault } from '../@types/default/default-type';
export class LoggerPubSubManager<
Event extends LogEventDefault = LogEventDefault,
> extends PubSubManager<Event> {
constructor() {
super();
}
}
그대로 상속받아서 써버리면 아주 편할것입니다.
제네릭에 올수있는 타입만 로그이벤트 기본 구성요소를 갖출 수 있게 더 좁혀준 뒤 상속받아서 쓰겠습니다.
실제로 사용하는 코드를 만들어보겠습니다.
export type FEATURE = 'traning';
export type PAGE = 'landing' | 'main' | 'login' | 'info' | 'cart';
export type AT =
| 'dialog'
| 'success-toast'
| 'error-toast'
| 'bottom-sheet'
| 'funnel'
| 'default';
export type TARGET = 'cta-button' | 'login-button';
export type ACTION = 'click' | 'page' | 'view' | 'pop-up' | 'toast';
export type MyAtom = LogAtomCreator<FEATURE, PAGE, AT, TARGET, ACTION>;
type EventVariable = 'CREATE_USER' | 'UPDATE_USER';
export type MyEventEnvironment = LogEventEnvironment;
export type MyEventUser = {
age: number;
isLogin: boolean;
};
export type MyEventProperty = LogEventProperty;
export type UserLogEvent = LogEventCreator<
LogEventDetailCreator<
EventVariable,
MyEventUser,
MyEventProperty,
MyEventEnvironment
>,
MyAtom,
'_'
>;
export const loggerService = new LoggerService<UserLogEvent, MyAtom>();
export const loggerPubSub = new LoggerPubSubManager<UserLogEvent>();
export type LogParam = LogParamCreator<typeof loggerService>;
export const publishLogEvent = (log: LogParam) => {
const logEvent = loggerService.createLogEvent(log);
loggerPubSub.publish(logEvent);
};
견고하게 사용할 수 있도록 타입정의를 추가해주고 퍼블리시를 편하게 할 수 있게 유틸함수를 하나 만들어줬습니다.
약간의 단점은 초기에 타입세이프하게 설정을 하기 위해서 타입정의에 대한 오버헤드가 조금 발생하게되는데
이건.. 어떻게 해결해야할지 모르겠네요
이제 코어기능들은 모두 구현했으니 프레임워크와 통합시켜봅시다.
'use client';
import { loggerPubSub } from '@/src/@infrastructure/logger/domain';
import React from 'react';
export const LogProvider = ({ children }: React.PropsWithChildren) => {
React.useEffect(() => {
loggerPubSub.initiate({
CREATE_USER: [
(event) => {
console.group('logger event pubsub');
console.log('😌 여기서 api 요청 날려버리면 됨 😉');
console.log('로그이벤트내용', event);
console.groupEnd();
},
],
UPDATE_USER: [
(event) => {
console.group('logger event pubsub');
console.log('😌 여기서 api 요청 날려버리면 됨 😉');
console.log('로그이벤트내용', event);
console.groupEnd();
},
],
});
return () => loggerPubSub.initialize();
}, []);
return children;
};
useEffect를 통해 이벤트리스너를 달아주는것처럼 이벤트를 들어줄 리스너를 부착시켜주겠습니다.
실제로 백엔드에 로그 요청을 보내는 일과 같은 것은 이벤트리스너 안에서 수행하면 되겠네용
<button
onClick={() => {
publishLogEvent({
type: 'CREATE_USER',
eventName: ['traning', 'cta-button', 'click'],
eventPath: ['traning', 'cart', 'bottom-sheet', 'cta-button'],
eventUser: {
age: 20,
isLogin: true,
},
});
}}
{...prop}
>
{prop.children}
</button>
사용하는 부분에선 이렇게 사용할 수 있겠습니다.
이제 여기에서 type / user 정의등 계속 반복적으로 사용할 부분들을 알아서 주입해주는
커스텀훅을 만들거나 귀찮은일을 대신 해줄 래퍼 컴포넌트를 만들어주면 해피엔딩이겠지만
이미 여기까지도 읽으신 분들이 거의 없을 것 같으니 여기서 줄이겠습니다.
마치며
패키지를 만드는 것에서 가장 어려운 것은 기능이 제공하는 핵심적인 가치는 유지하면서
이 패키지를 제어하는 것은 외부여야 한다는 점인 것 같습니다.
강력한 기능을 가지면서 사용성도 좋은 라이브러리를 만드는 분들이 참 대단하네요
마지막으로 읽어주셔서 감사합니다.
'best' 카테고리의 다른 글
React Animate Presence 개념부터 구현까지 (0) | 2024.03.03 |
---|---|
개발에 대한 나의 현재 생각 (2) | 2024.02.15 |
High Order Component 를 아시나요? (1) | 2024.01.02 |
useFunnel을 제공하는 라이브러리 만들기 (2) | 2023.12.13 |
husky와 commitlint로 jira 이슈번호 자동화 시키기 (2) | 2023.11.27 |