관련 코드는 GitHub 에 공개되어 있습니다.
안녕하세요. 프론트엔드 개발자 송상현입니다.
프론트엔드 개발자 여러분, 그리고 이벤트 로깅에 대해 고민하시거나 관심있는 모든 여러분, 이벤트 로깅은 잘 하고 계신가요? 여러분 서비스의 유저 행동 데이터는 잘 관리되고 잘 활용되고 있나요?
필자 회사에서 운영하는 웹 서비스는 내부 서비스들이 계속해서 생겨나고 빠르게 확장되는 플랫폼 서비스입니다. 빠르게 변화하는 IT 스타트업의 프론트엔드 개발자로서, 지속가능한 이벤트 로그 설계와 구현 방법은 업무를 할 때 늘 따라오는 고민 중 하나였습니다.
프론트엔드 사이드의 이벤트 로깅 구현뿐 아니라, 더 앞단인 설계 단계부터 지속가능한 이벤트 로그 아키텍처를 고민하며 다양한 시도와 시행착오를 겪었습니다. 물론 여전히 부족하지만 지난 시간동안의 이벤트 로깅에 대한 고민과 경험의 일부를 공유하고자 합니다.
이벤트 로깅과 프론트엔드의 관계에 대한 이야기를 시작으로, 실제 웹 서비스를 실례로 이벤트 로깅 요구사항을 구현하고 문제점들을 개선하는 순서로 진행하겠습니다.
이벤트 로깅 개선에 대한 내용은 다음 네 가지입니다.
코드에 대한 이해 필요 없이 이벤트 로깅 설계에 대해서만 관심 있다면 개선 사항 중 1번만 읽으셔도 좋습니다. 반대로 프론트엔드 영역의 코드 레벨의 개선에만 관심 있다면 2번 또는 3번부터 읽으셔도 무방합니다.
웹이나 앱 서비스에서 유저가 특정한 행동을 하면 그 행동에 대한 데이터가 기록됩니다. 이러한 사용자 행동 데이터를 이벤트 로그 또는 데이터 이벤트라고 부르고, 이것을 로깅하는 작업을 이벤트 로깅이라고 합니다.
대표적인 이벤트 로그의 종류로는
등이 있습니다.
이 밖에도 스와이프 같은 UX 동작이나 유저가 호출한 api에 대한 정보도 이벤트 로그라고 할 수 있습니다. 이벤트 로그를 어느 영역까지 수집할지에 대한 기준은 회사마다 다르기 때문에 이 분류는 항상 달라질 수 있습니다.
서비스를 만드는 사람들에게 이벤트 로깅은 굉장히 중요합니다. 사용자 행동 데이터는 서비스의 개선 방향성이나 우선순위를 정할 때 중요한 기준이 되기 때문입니다.
이벤트 로깅을 하지 않으면 기껏 만든 서비스 기능을 유저가 어떻게 사용하고 있는지 파악하기 어렵습니다. 우리의 의도대로 유저가 서비스를 이용하는지도 알 수 없고, 지속적으로 성과 측정을 하기도 쉽지 않습니다.
이렇듯 중요한 이벤트 로깅과 프론트엔드는 뗄 수 없는 관계입니다. 대부분의 데이터 이벤트 로깅이 발생하는 트리거가 "유저 행동" 이기 때문입니다. 특정 컴포넌트가 "클릭됐을 때", 특정 컴포넌트가 유저의 "Viewport에 보여졌을 때" 등, 이러한 유저의 행동들을 핸들링 하는 것이 프론트엔드 역할이기 때문에 대부분의 이벤트 로깅은 프론트엔드 개발자의 담당입니다.
이벤트 로깅, 자동화는 안되나요?
사실 이벤트 로깅 자동화는 불가능에 가깝습니다. 굉장히 어려운 일이기도 하고, 이벤트 로깅의 완전한 자동화가 올바른 방법이 아닐 가능성도 높습니다.
모든 사용자 인터렉션을 전부 수집하는게 비효율적일 수 있습니다. 불필요하게 과도한 데이터 로그로 인해 비용 문제가 발생할 수 있습니다.
데이터 이벤트는 커스텀이 자주 이루어집니다. 이벤트 로깅을 할 때, 해당 유저 행동과 함께 분석해야하기 위해 같이 전송해야 하는 데이터들의 요구사항은 맥락에 따라 항상 변하기 때문입니다.
다음과 같은 이유들로 이벤트 로깅의 자동화는 굉장히 어렵습니다. 결국 프론트엔드 개발자의 UI/UX 개발 과정에는 이벤트 로깅 작업이 (거의) 항상 동반될 수 밖에 없습니다. 이벤트 로그가 코드로서 프로젝트에 남게 되는 것이죠. 다시 말해, 이벤트 로깅은 프론트엔트 아키텍처 관점에서 보면 "비즈니스 로직" 이라고 말 할 수 있습니다.
프론트엔드 개발자가 데이터 이벤트 컨벤션 설계에 참여해야 하는 이유가 바로 이것입니다. 데이터 이벤트 컨벤션을 구조적으로 잘 설계할수록, 이벤트 컨벤션에서 비즈니스 로직으로 이어지는 개발 프로세스를 더 생산성있게 만들 수 있기 때문입니다.
유저 행동 데이터를 어떻게 분류하고 수집할지에 대한 고민과 설계는 일반적으로 데이터 분석가와 PM의 역할이고 그들에게 전문성이 있습니다. 하지만 프론트엔드 개발자 역시 유저가 보고 행동하는 모든 것을 만드는 역할을 하기 때문에, 유저와 가장 맞닿아 있는 직군 중 하나라는 사실을 기억해야 합니다. 프론트엔드의 전문성을 가지고 다른 직군과 함께 유저 행동에 대해 함께 고민한다면 더 생산성 있는 설계로 이어질 수 있습니다
필자의 회사에서 운영하는 웹 서비스를 예시로 가상의 데이터 이벤트 컨벤션을 만들고 이벤트 로깅까지 하는 코드를 작성해봅시다.
다양한 인사이트 콘텐츠를 제공하는 "인사이트" 서비스의 메인 페이지입니다. 주요 기능을 담당하는 컴포넌트로 상단의 인기 콘텐츠와 하단의 리스트 콘텐츠가 눈에 띕니다.
- 인사이트 콘텐츠를 클릭 할 때 이벤트 로깅을 해주세요.
- 인기 콘텐츠는 contentsId, 리스트 콘텐츠는 contentsId, category 정보를 프로퍼티로 넘겨주세요.
이벤트 로깅 요청사항이 들어왔네요! 만들어야 할 데이터 이벤트는 총 두 가지 입니다. "상단의 인기 콘텐츠"와 "하단의 리스트 콘텐츠" 컴포넌트의 클릭 이벤트입니다. 적절한 이벤트 이름을 네이밍하고 이벤트 프로퍼티와 함께 이벤트가 로깅되도록 해야 합니다.
tracker.event
라는 이벤트 로깅 메소드가 있다고 가정하고, 다음과 같이 컴포넌트에 이벤트 로깅 기능을 작성해봅시다. tracker.event
는 이벤트 이름과 이벤트 프로퍼티 객체를 arguments로 받습니다.
1. 인기 콘텐츠의 콘텐츠 카드
<PopularContentsCard
onClick={({ contentsId }) => {
tracker.event('insight__main__popularContents__contents__click', {
contentsId,
});
handleContentsClick(contentsId);
}}
/>
2. 리스트 콘텐츠의 콘텐츠 카드
<ContentsCard
onClick={({ contentsId, category }) => {
tracker.event('insight__main__listContents__contents__click', {
contentsId,
category,
});
handleContentsClick(contentsId);
}}
/>
<PopularContentsCard>
컴포넌트를 예시로 구현 과정을 간단히 알아봅시다. 먼저 인사이트 서비스의 상위 개념부터 클릭하는 대상까지의 흐름을 따라 데이터 이벤트 이름을 지었습니다.
기능 (Feature) | 페이지 (Page) | 위치 (At) | 대상 (Target) | 액션 (Action) |
---|---|---|---|---|
인사이트(insight) | 메인(main) | 인기 콘텐츠(popularContents) | 콘텐츠(contents) | 클릭(click) |
-> insight__main__popularContents__contents__click
그리고 각 컴포넌트의 onClick
메소드에 이벤트 로깅에 필요한 데이터를 프로퍼티로 받아 이벤트 로깅하도록 구현했습니다.
<PopularContentsCard
onClick={({ contentsId }) => {
tracker.event('insight__main__popularContents__contents__click', {
contentsId,
});
/* 생략 */
}}
/>
완성한 <PopularContentsCard>
컴포넌트를 클릭하면
짠! 다음과 같이 이벤트 로깅이 성공적으로 이루어집니다.
이벤트 로깅의 요구사항은 해결됐습니다. 서비스 흐름에 따라 데이터 이벤트를 네이밍해서 데이터 이벤트들의 구분도 잘 되는 것 같습니다.
하지만 과연 이러한 데이터 이벤트 컨벤션과 코드 구조가 최선의 방식일까요? 더 좋은 방법이 있지 않을까요?
금방 설계한 이벤트 로깅 프로세스를 자세히 뜯어보며 개선점들을 찾아봅시다. 크게 네 개의 꼭지로 나눠 각 문제들을 인식한 후 하나하나 해결하는 일련의 과정을 거치며 더 좋은 설계로 한 걸음씩 개선해 보겠습니다.
설계한 데이터 이벤트 네이밍을 자세히 들여다보며 개선점을 찾아봅시다.
insight__main__popularContents__contents__click
언뜻 보기에는 유저 행동을 나타내는 구분들이 충분히 네이밍이 녹아져 있어 문제가 없어 보입니다. 결론부터 얘기하면 위와 같은 네이밍 컨벤션을 가지고 서비스를 계속 키워나가면 데이터 이벤트 "관리"와 "분석" 측면에서 어려움을 겪게 됩니다. 하나씩 자세한 이유를 살펴봅시다.
데이터 이벤트의 관리가 어려워지는 이유는 이미 존재하던 기능이 다른 형태로 추가될 때마다 데이터 이벤트를 매번 새로 정의해야 하기 때문입니다.
인사이트 서비스로 예를 들어볼까요? 서비스가 커져감에 따라 "인사이트 콘텐츠 클릭" 이 일어나는 기능들이 추가됐다고 가정하고 데이터 이벤트를 추가로 설계해 봅시다.
콘텐츠 검색 | 추천 콘텐츠 |
---|---|
현재 데이터 이벤트 네이밍에 컨벤션을 따르면, 인사이트 콘텐츠를 클릭하는 기능이 새로 추가될 때마다 해당하는 데이터 이벤트 이름을 새로 정의해야 합니다.
데이터 이벤트 플로우 | 이름 |
---|---|
인사이트 > 메인 > 리스트 콘텐츠 > 콘텐츠 > 클릭 | insight__main__listContents__contents__click |
인사이트 > 메인 > 인기 콘텐츠 > 콘텐츠 > 클릭 | insight__main__popularContents__contents__click |
인사이트 > 검색 > 리스트 콘텐츠 > 콘텐츠 > 클릭 | insight__search__listContents__contents__click |
인사이트 > 상세 > 추천 콘텐츠 > 콘텐츠 > 클릭 | insight__detail__recommendContents__contents__click |
인사이트 > 상세 > 관련 콘텐츠 > 콘텐츠 > 클릭 | insight__detail__relatedContents__contents__click |
... | ... |
다음과 같이 "인사이트 콘텐츠 클릭" 이라는 같은 유저 행동에 대한 이벤트 이름의 볼륨이 점점 커지게 됩니다. 같은 유저 행동을 나타내는 데이터 이벤트들이 산재되고, 이것들의 연관성이 직관적으로 드러나지 않아 이벤트가 많아질수록 종합적으로 관리하기가 어려워집니다.
데이터를 효과적으로 분석하기도 어려워집니다. "인사이트의 콘텐츠를 클릭함" 이라는 동일한 유저 행동에 대한 데이터 이벤트가 점점 늘어나지만, 이것들을 묶어주는 공통 키워드가 없기 때문입니다.
데이터를 분석할 때 "어디서 인사이트 콘텐츠가 많이 클릭됐는지" 확인하고 비교하려면 데이터 이벤트들의 이름을 일일이 탐색해야 하는 번거로움이 생깁니다.
음, 그래도 이 정도면 그렇게 복잡할 것 같지 않은데요?
그런 생각이 든다면 큰 규모의 서비스를 예로 들어 볼까요? 네이버 웹툰의 경우 메인 페이지에만 열 개가 넘는 "웹툰 콘텐츠 클릭" 기능이 존재합니다. 서비스 전체로는 수십개가 넘게 존재하겠죠?
네이버 웹툰 |
---|
네이버 웹툰 서비스가 유저 행동을 어떻게 수집하는지는 알 수 없지만, 만약 기존 컨벤션과 같은 방식으로 이벤트 로깅이 설계돼 있다면 이벤트 로그를 관리하고 분석하는데 어려움이 있을 것이라 상상할 수 있습니다.
"(1) 데이터 이벤트 관리가 어려워지고", "(2) 데이터 분석이 어려워지는" 가장 큰 이유는 하나의 데이터 이벤트가 유저 행동에 대해 너무 많은 정보를 갖고 있기 때문입니다.
요구사항 | 데이터 이벤트 |
---|---|
'인사이트'의 > '메인 페이지'의 > '인기 콘텐츠'의 > '콘텐츠'를 > '클릭'함 | insight__main__popularContents__contents__click |
현재 데이터 이벤트 네이밍은 유저 액션이 "무엇인지", 그리고 해당 동작이 "어디서 일어났는지"에 대한 정보를 모두 가지고 있는 상태입니다. 좀 더 쉽게 "What"과 "Where"로 구분해 볼까요?
현재 데이터 이벤트 네이밍에는 유저가 "무슨 동작을 일으켰는지(What)" 와 "어디서 동작이 일어났는지(Where)" 정보가 모두 포함돼 있습니다. 분리될 수 있는 정보들이 계층 구분 없이 혼합돼있어 가독성이 떨어지고 관리 복잡도가 늘어납니다.
유저 행동을 나타내는 요구사항에서 "What"과 "Where"을 뽑아내 구분하여 이벤트 네이밍에 녹일 수 있다면 더 확장성 있는 데이터 이벤트 컨벤션이 될 수 있을 것 같습니다.
유저 행동을 나타내는 데이터 이벤트는 다섯 개의 요소로 구분됩니다.
기능 (Feature) | 페이지 (Page) | 위치 (At) | 대상 (Target) | 액션 (Action) |
---|---|---|---|---|
인사이트 | 메인 페이지 | 인기 콘텐츠 | 콘텐츠 | 클릭 |
기능
, 대상
, 액션
은 "무슨 동작이 일어났는지"를 뜻하고, 기능
, 페이지
, 위치
, 대상
는 해당 "동작이 어디서 일어났는지"를 뜻하는 구분입니다. 기능
과 대상
은 관점에 따라 "What"과 "Where"의 의미를 둘 다 가지고 있습니다.
What (기능 > 대상 > 액션) | Where (기능 > 페이지 > 위치 > 대상) |
---|---|
'인사이트'의 > '콘텐츠'를 > '클릭'함 | '인사이트'의 > '메인 페이지'의 > '인기 콘텐츠'의 > '콘텐츠' |
유저 행동을 "What"과 "Where"로 구분했으니, 다음 단계는 두 요소의 계층 관계를 정의합니다. 유저 이벤트의 "What"은 상위 개념이고, "Where"은 여러 개가 따라 붙을 수 있는 하위 개념입니다.
What (기능 > 대상 > 액션) | Where (기능 > 페이지 > 위치 > 대상) |
---|---|
'인사이트'의 > '콘텐츠'를 > '클릭'함 | '인사이트'의 > '메인 페이지'의 > '인기 콘텐츠'의 > '콘텐츠' |
'인사이트'의 > '메인 페이지'의 > '리스트 콘텐츠'의 > '콘텐츠' | |
'인사이트'의 > '검색 페이지'의 > '리스트 콘텐츠'의 > '콘텐츠' | |
... |
이제 복잡했던 기존 데이터 이벤트 이름을 "What"으로 대체할 수 있습니다. 하위 분류인 "Where"은 액션이 어디서 발생했는지를 하향식으로 나타내므로 eventPath
로 명명합니다. 여기에 각 유저 이벤트에 추가적인 정보를 제공하는 프로퍼티의 타입 명세까지 추가하겠습니다.
EventName (What) | EventPath (Where) | EventProperty |
---|---|---|
click__insight__contents | 인사이트 > 메인 > 인기_콘텐츠 > 콘텐츠 | { id: string } |
인사이트 > 메인 > 리스트_콘텐츠 > 콘텐츠 | { id: string, category: string } | |
인사이트 > 검색 > 리스트_콘텐츠 > 콘텐츠 | { id: string, keyword: string } | |
... | ... |
이제 위에서 설계한 데이터 이벤트 컨벤션을 바탕으로, 이벤트 로그 요구사항을 다음과 같이 구글 스프레드 시트로 작성해서 관리합니다.
개선 전 이벤트 네이밍을 따르면 10개 row에 대해 각각 개별 데이터 이벤트가 정의 됐겠지만, "What" 관점으로 이벤트 네이밍을 개선해 4개의 이벤트 볼륨으로 표현할 수 있게 됐습니다. 서비스가 커지면서 데이터 row가 점점 많아질수록, 중복되는 유저 행동 이벤트도 함께 많아지기 때문에록 이벤트 볼륨의 효율은 계속 증가합니다.
그런데 구글 스프레드 시트에 "What" -> "Where" 계층 구조가 나타나지 않네요?
맞습니다. 오히려 반대인 "Where" -> "What" 구조로 작성돼 있습니다. 이벤트 명세서를 작성할 땐 "Where"에서 "What"으로의 흐름으로 작성하는 것이 좋은 측면이 있기 때문입니다.
데이터 이벤트의 "What" -> "Where" 계층 구조는 "유저가 어떤 행동을 했는가"에 중점을 찍고 뻗어나갑니다. 덕분에 이벤트 볼륨의 복잡도를 관리하고 데이터를 효율적으로 분석할 때 좋은 구조라고 할 수 있습니다.
반면 데이터 이벤트의 "Where" -> "What" 계층 구조는 서비스 기획의 관점과 일치한다는 특징이 있습니다. 서비스 기획으로 새로운 요구사항이 추가될 때는 "Where"의 흐름을 따라 정의되기 때문입니다.
만약 인사이트 서비스에 검색 기능이 추가된다면,
위와 같은 유저 스토리가 만들어지고, 해당 내용을 기반으로 더 구체적인 기획과 디자인 요구사항이 생겨납니다.
데이터 이벤트의 명세는 기획 내용을 바탕으로 추가됩니다. 서비스 기획과 이벤트 명세의 흐름이 일치한다면, 서비스 요구사항이 추가될 때 이벤트 명세의 변경도 쉬워집니다.
예를 들어, 콘텐츠 검색 기능이 추가될 때 관련된 데이터 이벤트 명세는 다음과 같이 간단히 추가할 수 있습니다.
데이터 이벤트 네이밍을 개선하여 구글 스프레드 시트에 다음과 같이 이벤트 명세를 정의했습니다.
명세는 문서일 뿐이고, 우리는 이 명세를 바탕으로 코드로 옮겨야 합니다. 어떻게 옮길 수 있을까요? 개발 단위마다 추가되는 수십개의 데이터 이벤트들을 코드로 옮기는 작업을 매번 해야 할까요?
단순히 코드로 옮기는 것만이 끝이 아니라, 기존 데이터 이벤트의 수정 작업이 생긴다면 구글 스프레드 시트와 코드의 일치율을 하나하나 대조해야 합니다. 수십, 수백개까지 쌓여가는 데이터 이벤트와 코드를 지속적으로 동기화하는 건 개발 생산성과 개발 경험의 엄청난 하락으로 이어질 수 있습니다.
문서와 코드간의 동기화를 위한 가장 직관적인 해결책은 문서를 기반으로 코드를 generate 하는 스크립트를 만드는 것입니다. 해당 구글 스프레드시트를 기반으로 데이터 이름 객체인 EVENT_NAME
과 이벤트 프로퍼티의 타입 검사를 위한 EventProperty
타입을 생성하는 작업을 진행해봅시다.
구글 스프레드 시트를 읽어오는 방법은 총 두 가지가 있습니다.
url을 이용하는 방법은 간단하지만 정규화가 잘 안된 raw한 response가 내려오고, 문서가 반드시 public 해야 한다는 제한이 있습니다. 우리의 경우는 문서가 private 해야 할 가능성이 높고, 조건에 맞으면 무료로 사용할 수 있기 때문에 Google Sheets API를 이용하는 방법이 더 적절합니다.
Google Sheets API를 사용하기 위해선 구글 클라우드 콘솔을 통해 프로젝트를 생성하고 '서비스 계정'과 '서비스 계정의 개인 key'를 발급받아야 합니다.
위 과정을 통해 획득한 데이터를 아래와 같이 .env
파일에 넣고 관리합니다.
GOOGLE_SERVICE_ACCOUNT_EMAIL=/* 구글 클라우드 서비스 계정 */
GOOGLE_PRIVATE_KEY=/* 서비스 계정의 개인 키 (-----BEGIN PRIVATE KEY----- 로 시작함) */
Google Sheets API 인증 준비가 끝났습니다!
Google Sheets API 를 직접 사용하는 방법도 있지만, Google Sheets API를 javascript로 래핑한 인터페이스를 제공하는 편리한 오픈 소스(node-google-spreadsheet
)가 있습니다. 이것을 활용하도록 하겠습니다.
이제 구글 스프레드시트 패칭을 위한 준비가 모두 끝났습니다.
이후 작업인
eventPath
구조 데이터로 파싱EVENT_NAME
객체, EventProperty
타입 파일 생성에 관련한 구체적인 내용은 지면이 부족해 생략하도록 하겠습니다. GitHub 에서 자세한 구현체를 보실 수 있습니다.
구글 스프레드시트 문서의 데이터 이벤트 명세를 바탕으로 EVENT_NAME
객체와 EventProperty
타입을 생성했습니다.
export const EVENT_NAME = {
"인사이트": {
"메인": {
"인기_콘텐츠": {
"콘텐츠": {
"click": "click__insight__contents",
"view": "view__insight__contents"
}
},
"카테고리_슬라이더": {
"카테고리": {
"click": "click__insight__category"
}
},
"리스트_콘텐츠": {
"콘텐츠": {
"click": "click__insight__contents",
"view": "view__insight__contents"
}
}
},
"상세페이지": {
"콘텐츠_헤더": {
"공유하기": {
"click": "click__insight__share"
}
},
"추천_콘텐츠": {
"콘텐츠": {
"click": "click__insight__contents",
"view": "view__insight__contents"
}
},
"관련_콘텐츠": {
"콘텐츠": {
"click": "click__insight__contents",
"view": "view__insight__contents"
}
}
}
}
};
export type EventProperty = {
"인사이트": {
"메인": {
"인기_콘텐츠": {
"콘텐츠": {
"click": {
"contentsId": string
},
"view": {
"contentsId": string
}
}
},
"카테고리_슬라이더": {
"카테고리": {
"click": {
"name": string
}
}
},
"리스트_콘텐츠": {
"콘텐츠": {
"click": {
"contentsId": string,
"category": string,
"isBookmarked": boolean
},
"view": {
"contentsId": string
}
}
}
},
"상세페이지": {
"콘텐츠_헤더": {
"공유하기": {
"click": {
"type": 'kakao' | 'facebook' | 'url'
}
}
},
"추천_콘텐츠": {
"콘텐츠": {
"click": {
"contentsId": string
},
"view": {
"contentsId": string
}
}
},
"관련_콘텐츠": {
"콘텐츠": {
"click": {
"contentsId": string
},
"view": {
"contentsId": string
}
}
}
}
}
};
EVNET_NAME
과 EventProperty
모두 이벤트 명세서의 서비스 기획 흐름과 똑같은 구조로 설게됐음을 볼 수 있습니다.
이벤트 명세 (구글 스프레드 시트)
인사이트 > 메인 > 인기_콘텐츠 > 콘텐츠 > click |
---|
생성된 코드
key (인사이트 > 메인 > 인기_콘텐츠 > 콘텐츠 > click) | value |
---|---|
EVENT_NAME['인사이트']['메인']['인기_콘텐츠']['콘텐츠']['click'] | click__insight__category |
EventProperty['인사이트']['메인']['인기_콘텐츠']['콘텐츠']['click'] | { "contentsId": string } |
EVENT_NAME
과 EventProperty
두 객체 모두 EventPath
를 따라가면 해당되는 이벤트 정보의 value로 이어집니다. 이벤트 명세와 생성된 코드의 설계 구조와 완전히 동일하므로 굉장히 기획 친화적인 구조라고 할 수 있습니다.
첫 번째 개선으로 이벤트 네이밍 컨벤션을 변경해 확장성을 용이하게 하고,
두 번째 개선으로 이벤트 명세서를 코드로 제너레이트 하는 작업을 진행했습니다.
생성된 데이터 이벤트 데이터를 이용해 이벤트 로깅 전송 로직을 수정한 후, 컴포넌트 레벨의 문제를 발견하고 해결해 봅시다.
before 데이터 네이밍 개선
<ContentsCard
onClick={({ contentsId, category }) => {
tracker.event('insight__main__listContents__contents__click', {
contentsId,
category,
});
handleContentsClick(contentsId);
}}
/>
after 데이터 네이밍 개선
생성된 EVENT_NAME
객체를 통해 이벤트 이름을 가져오고, 이벤트를 로깅하는 tracker.event
메소드의 두 번째 인자로 eventPath
를 받도록 수정했습니다.
<ContentsCard
onClick={({ contentsId, category }) => {
// 이벤트 로깅
tracker.event(
EVENT_NAME['인사이트']['메인']['리스트_콘텐츠']['콘텐츠']['click'], // 1. eventName
'인사이트 > 메인 > 리스트_콘텐츠 > 콘텐츠', // 2. eventPath
{ contentsId, category }, // 3. eventProperty
);
// 컴포넌트 기능
handleContentsClick(contentsId);
}}
/>
수정된 이벤트 네이밍을 반영했으니 이제 컴포넌트 구조에서 문제점을 찾아볼까요? 코드 관점에서 가장 큰 문제는 컴포넌트 기능과 이벤트 로깅 로직의 결합도가 너무나도 높다는 것입니다.
컴포넌트의 목적은 "콘텐츠 카드의 기능 동작"이고, 이벤트 로깅의 목적은 "유저 행동 데이터 수집"입니다. 위 컴포넌트는 분리할 수 있는 두 기능이 합쳐져 있고, 심지어 한 쪽이 변경되면 다른 기능에 영향을 줄 수 있는 위험한 구조입니다.
목적이 다른 기능들을 서로 의존관계가 없게 만드는 것은 좋은 설계의 기본입니다. 이를 지키지 못할 시 우리는 다음과 같은 어려움을 겪게 됩니다.
"컴포넌트 기능 제작을 끝낸 뒤에, 데이터 로깅 작업을 해야겠다."
컴포넌트 기능 개발과 이벤트 로깅을 분리해서 작업하기 어려워집니다. 이벤트 로그가 컴포넌트 타입과 구조에 영향을 끼치기 때문이죠. 컴포넌트 기능을 만들면서 이후에 추가될 이벤트 로그의 존재를 염두에 두며 작업을 해야합니다.
// 1. 컴포넌트 기능만 개발할 때
<ContentsCard
onClick={handleContentsClick}
/>
// 2. 컴포넌트에 이벤트 로깅 기능이 추가될 때
<ContentsCard
onClick={({ contentsId, category }) => {
// onClick이 category도 받을 수 있도록 변경해야함
tracker.event(
EVENT_NAME['인사이트']['메인']['리스트_콘텐츠']['콘텐츠']['click'],
'인사이트 > 메인 > 리스트_콘텐츠 > 콘텐츠',
{ contentsId, category },
);
handleContentsClick(contentsId);
}}
/>
컴포넌트 onClick
에 여러 기능이 혼합되면서 가독성도 떨어집니다. 여러가지 측면에서 컴포넌트가 오염된다고 볼 수 있습니다.
"이 컴포넌트 재사용해야하는데, 클릭 이벤트 로깅할 때 변경된 요구사항 반영해주세요."
서비스가 커져서 인사이트 검색 페이지가 생겼습니다. 검색 페이지 콘텐츠 카드의 UI와 기능이 이전에 만들었던 컴포넌트와 동일하네요. 메인 페이지에서 사용한 <ContentsCard>
컴포넌트를 재활용하면 좋을 것 같습니다.
그런데 다른 점이 하나 있네요. 검색 페이지의 콘텐츠 카드를 클릭 할 때 로깅해야하는 이벤트 프로퍼티에, 검색 키워드인 keyword
데이터를 함께 보내줘야 한다고 합니다.
// 1. 메인 페이지의 콘텐츠 카드
<ContentsCard
onClick={({ contentsId, category }) => {
tracker.event(
EVENT_NAME['인사이트']['메인']['리스트_콘텐츠']['콘텐츠']['click'],
'인사이트 > 메인 > 리스트_콘텐츠 > 콘텐츠',
{ contentsId, category },
);
handleContentsClick(contentsId);
}}
/>
// 2. (재사용 하고 싶었던) 검색 페이지의 콘텐츠 카드
<ContentsCard
onClick={({ contentsId, keyword }) => {
// onClick 메소드에 category가 빠지고, keyword가 추가됨
tracker.event(
EVENT_NAME['인사이트']['검색']['리스트_콘텐츠']['콘텐츠']['click'],
'인사이트 > 검색 > 리스트_콘텐츠 > 콘텐츠',
{ contentsId, keyword },
);
handleContentsClick(contentsId);
}}
/>
다음과 같이 설계하면 위 코드처럼 컴포넌트의 재사용이 어려워집니다. 컴포넌트 UI와 기능이 완전히 동일하더라도, 변경된 이벤트 로그 요구사항에 따라 컴포넌트의 명세를 수정해야합니다.
컴포넌트 기능과 이벤트 로깅이 서로 너무 의존돼 있기 때문에, 컴포넌트의 기능은 전혀 바뀌지 않았지만 컴포넌트를 수정해야 하는 상황이 나타납니다.
이러한 문제들을 해결하기 위해서 컴포넌트와 이벤트 로깅의 결합을 끊는 개선이 반드시 필요합니다.
'컴포넌트 기능'와 '이벤트 로깅'의 의존성을 분리한다는 것은 코드의 분리는 물론이고, 변경이 일어날 때 서로 영향을 끼치지 않아야함을 의미합니다. 그렇기에 컴포넌트 기능인 onClick
에서 이벤트 로깅 일어나는 형태는 이벤트 로깅과 컴포넌트 기능이 서로 의존된 상태라고 볼 수 있습니다.
아래처럼 이벤트 로깅하는 기능을 다른 컴포넌트로 분리하여 의존성 문제를 해결할 수 있습니다.
import { EVENT_NAME } from './dataEvent';
const ClickEventLogging = ({
children,
path, // ['인사이트', '메인', '리스트_콘텐츠', '콘텐츠']
property, // { insightId: 'abc', category: '엔젤투자', isBookmarked: true }
}: {
children: React.ReactElement;
path: string[];
property: Record<string, string | number | boolean>;
}) => {
const [feature, page, at, target] = path;
// eventName: insight__contents__click
const eventName = EVENT_NAME[feature][page][at][target]['click'];
// eventPath: 인사이트 > 메인 > 리스트_콘텐츠 > 콘텐츠
const eventPath = [feature, page, at, target].join(' > ');
const child = React.Children.only(children);
return React.cloneElement(child, {
onClick: () => {
// 이벤트 로깅
tracker.event(
eventName,
eventPath,
property,
);
if (child.props.onClick) {
child.props.onClick();
}
},
});
};
이벤트 로깅 기능을 완전히 위임하는 <ClickEventLogging>
컴포넌트를 만들었습니다. 이벤트 path 데이터를 prop으로 받아 컴포넌트 내부에서 eventName
과 eventPath
value를 알아냅니다.
React의 cloneElement 를 사용해 자식 컴포넌트의 onClick
이 일어날때 이벤트 로깅이 함께 작동하도록 합니다.
사용 예시입니다.
<ClickEventLogging
path={['인사이트', '메인', '리스트_콘텐츠', '콘텐츠']}
property={{ contentsId: 'abc', category: '엔젤투자', isBookmarked: true }}
>
<button onClick={() => console.log('콘텐츠 Click')}>
인사이트 콘텐츠 카드
</button>
</ClickEventLogging>
컴포넌트를 클릭하면 path
가 eventName
과 eventPath
로 잘 파싱되고 이벤트 로깅이 잘 이루어지는 걸 볼 수 있습니다.
이제 <ContentsCard>
컴포넌트는 이벤트 로깅에 영향을 받지 않고, 이벤트 로깅을 해주는 <ClickEventLogging>
컴포넌트도 내부에 어떤 컴포넌트가 존재하든 상관없이 클릭 이벤트가 일어났을 때 정해진 이벤트를 로깅할 수 있습니다.
자식 컴포넌트의 prop을 변경하는 것보다,
<ClickEventLogging>
에서 자체onClick
을 만들어 처리하는 방법도 가능하지 않나요?
가능합니다. 자식 컴포넌트의 prop을 변경하지 않고, <ClickEventLogging>
안에서 Element를 만들어 자체 onClick
핸들러에서 이벤트 로깅을 처리할 수도 있습니다. 자식 컴포넌트에서 클릭이 일어나면, 이벤트 버블링이 일어나 클릭 이벤트가 상위 컴포넌트로 전파되기 때문에 이 또한 가능한 방법입니다. 아래와 같이 설계할 수 있습니다.
const ClickEventLogging = ({
children,
path,
property,
}: {
children: React.ReactElement;
path: string[];
property: Record<string, string | number | boolean>;
}) => {
const [feature, page, at, target] = path;
const eventName = EVENT_NAME[feature][page][at][target]['click'];
const eventPath = [feature, page, ][, target].join(' > ');
const child = React.Children.only(children);
// 자식 Element가 click되면 함께 클릭 됨
const handleEventLogging = () => {
tracker.event(
eventName,
eventPath,
property,
);
}
return (
<div onClick={handleEventLogging}>
{child}
</div>
)
};
하지만 이벤트 로깅을 위해 별도의 element를 추가한다면 또 다른 문제를 일으킬 수 있습니다. 이벤트 로깅이 일어나는 컴포넌트와 그렇지 않은 컴포넌트 주변의 html 구조가 달라지기 때문입니다.
// 1. css-selctor가 <div>에 적용됨
<ChildToBlue>
<div>파란 버튼</div>
</ChildToBlue>
// 2. css-selctor가 <ClickEventLogging>에 적용됨
<ChildToBlue>
<ClickEventLogging props={/* */}>
<div>파란 버튼(?)</div>
</ClickEventLogging>
</ChildToBlue>
만약 위와 같은 구조에서 <ChildToBlue>
가 자식 컴포넌트의 background-color
를 파란색으로 변경하는 기능이 있다면, <ClickEventLogging>
가 별도의 element로 삽입되는 2번의 경우에는 예상대로 동작하지 않게 됩니다.
결국 이벤트 버블링을 이용한 이벤트 로깅은, 이벤트 로깅의 의존 대상이 컴포넌트에서 html 마크업으로 변경됐을 뿐 완전한 의존성 분리라고 보기 어렵습니다.
코드를 돌려 의존성이 최소화된 구조로 다시 수정합시다.
const ClickEventLogging = ({
children,
path,
property,
}: {
children: React.ReactElement;
path: string[];
property: Record<string, string | number | boolean>;
}) => {
const [feature, page, at, target] = path;
const eventName = EVENT_NAME[feature][page][at][target]['click'];
const eventPath = [feature, page, ][, target].join(' > ');
const child = React.Children.only(children);
return React.cloneElement(child, {
onClick: () => {
tracker.event(
eventName,
eventPath,
property,
);
if (child.props.onClick) {
child.props.onClick();
}
},
});
};
위 개선을 통해 의존성 분리 외에 다른 장점들도 나타납니다. 먼저 기획과 코드의 거리가, 좀 더 직접적으로 표현하면 이벤트 명세서 문서와 코드의 거리가 굉장히 가까워졌다는 점입니다. <ClickEventLogging>
컴포넌트를 사용할 때 구글 스프레드시트 문서의 이벤트 경로를 그대로 옮겨 적기만 하면 되기 때문입니다.
인사이트 Google SpreadSheet |
---|
<ClickEventLogging
path={['인사이트', '메인', '리스트_콘텐츠', '콘텐츠']}
property={{ contentsId: 'abc', category: '엔젤투자', isBookmarked: true }}
>
<button onClick={() => console.log('콘텐츠 Click')}>
인사이트 콘텐츠 카드
</button>
</ClickEventLogging>
서비스 기획과 코드의 거리가 가까울수록 개발 생산성은 올라갑니다. 기획 내용이 그대로 코드로 전해져 고민할 시간이 줄어들기 때문이죠. 그리고 기획에 대한 이해만 있다면 코드 파악이 빠르다는 점에서 협업 측면에도 유리하게 작용합니다. 변경 대응과 디버깅이 빨라지는건 당연하구요!
또 다른 장점으로 <ClickEventLogging>
를 작성할 때마다 EVENT_NAME
객체를 직접 import 하지 않는다는 점도 있습니다. 데이터의 직접 참조가 없어지고 추상화 벽이 세워졌기 때문에, 그런 관점에서도 더 좋은 아키텍처로 개선됐다고 할 수 있습니다.
이제 마지막 관문입니다. 많은 것을 개선했지만 아직 중요한 개선점이 하나 남아있습니다. 바로 잘못된 입력에 대한 타입 검증이 거의 이루어지지 않는다는 점입니다.
1. 오타 및 잘못된 type 입력
// error 1: "리스트_1234콘텐츠"
// error 2: { contentsId: 1234 } <- not string type
// error 3: { isboooookmarked: 'true' } <- wrong
<ClickEventLogging
path={['인사이트', '메인', '리스트_1234콘텐츠', '콘텐츠']}
property={{ contentsId: 1234, category: '엔젤투자', isboooookmarked: 'true' }}
>
{/* */}
</ClickEventLogging>
이를테면 path
는 string[]
타입으로 타입 검사를 하기 때문에 이벤트 경로에 맞지 않는 값을 입력해도 오류로 나타나지 않습니다. property
의 경우도 Record<string, string | number | boolean
타입으로 검사하기 때문에 타입이 틀리거나 key 값이 적절하지 않아도 문제가 발생하지 않습니다.
2. path 경로에 맞지 않는 event property 타입 입력
// error: { categoryId: 'abc' } 는 "인사이트 > 메인 > 리스트_콘텐츠 > 콘텐츠" 의 프로퍼티가 아님
<ClickEventLogging
path={['인사이트', '메인', '리스트_콘텐츠', '콘텐츠']}
property={{ categoryId: 'abc' }}
>
{/* */}
</ClickEventLogging>
path
경로와 상관없는 property
정보가 입력될 가능성도 있습니다. path
와 property
의 각 타입이 틀리지 않더라도, 두 prop이 서로 같은 경로가 아니면 잘못된 입력으로 간주돼야 하지만 이러한 타입 검증도 전혀 이루어지고 있지 않습니다.
이러한 버그는 무서운 점은 컴파일 단계는 물론이고, 런타임에도 잡히지 않아서 버그를 인지하지 못하고 넘어갈 가능성이 높다는 것입니다.
아무래도 타입스크립트를 이용한 강력한 타입 검증을 통해 이벤트 로깅의 안정성을 높일 필요가 있어 보입니다. 잠재적 버그 발생과 QA 피로도를 낮추기 위해 이벤트 로깅의 잘못된 입력을 검증하는 타입을 설계해 봅시다.
<ClickEventLogging
path={['인사이트', '메인', '리스트_콘텐츠', '콘텐츠']}
property={{ insightId: 'abc', category: '엔젤투자', isBookmarked: true }}
>
{/* */}
</ClickEventLogging>
<ClickEventLogging>
컴포넌트에서 설계할 타입들이 검증해야 할 목록은 다음 두 가지입니다.
EVENT_NAME
트리 구조에 따른 path
입력 검증path
와 일치하는 EventProperty
타입 검증먼저 path
입력을 검증하는 타입을 설계 해봅시다.
type EventPath<T, TPath extends string[] = []> = TPath['length'] extends 4
? TPath
: keyof T extends infer Key extends string
? Key extends keyof T
? EventPath<T[Key], [...TPath, Key]>
: never
: never;
EventPath
타입은 제네릭으로 주어진 객체(T
)의 경로를 나타내는 타입입니다. 여기서 T
는 우리가 만든 이벤트 객체 타입인 typeof EVENT_NAME
이 됩니다.
두번째 제네릭인 TPath
는 객체 경로 key의 배열입니다. T
객체의 키 타입을 가져와서 제네릭 인자 Key
로 추론(infer
)하고, Key
가 T
의 키 타입에 속하면 Key
를 TPath
배열에 추가합니다. 이것을 TPath
배열의 길이가 4가 될때까지 반복합니다. 모든 데이터 이벤트의 객체 경로가 4 단계이기 때문입니다. (ex. ['인사이트', '메인', '인사이트_리스트', '콘텐츠_카드']
)
결과적으로 주어진 이벤트 객체의 모든 경로를 나타내는 타입이 반환됩니다.
type AllEventPath = EventPath<typeof EVENT_NAME>;
이제 <ClickEventLogging>
컴포넌트의 path
가 AllEventPath
에 속한 경로 타입과 일치하지 않으면 타입 오류가 나타나도록 다음과 같이 수정할 수 있습니다.
export const ClickEventLogging<Path extends AllEventPath>({
children,
path,
property,
}: {
children: JSX.Element;
path: Path;
property: /* Next Step! */
}) => {
/* 생략 */
이제 EVENT_NAME
객체 경로에 해당하지 않는 path
를 입력하면 타입 오류가 나타나는 것을 확인할 수 있습니다.
다음은 입력된 이벤트 path
경로와 일치하는 property
타입을 검증하는 타입을 설계할 차례입니다. 직전에 <ClickEventLogging>
컴포넌트를 수정할 때 추가한 제네릭 타입 Path
를 이용해 다음과 같은 관점으로 property
의 유효성을 검사하는 또 다른 제네릭 타입을 만들어봅시다.
path: Path;
property: EventPropertyForPath<Path>
import { EVENT_NAME, EventProperty } from './dataEvent';
/* 생략 */
type AllEventPath = EventPath<typeof EVENT_NAME>;
type EventAction = 'click' | 'view';
type EventPropertyForPath<Path, Action extends EventAction> = Path extends [
infer Feature,
infer Page,
infer At,
infer Target,
]
? Feature extends keyof EventProperty
? Page extends keyof EventProperty[Feature]
? At extends keyof EventProperty[Feature][Page]
? Target extends keyof EventProperty[Feature][Page][At]
? Action extends keyof EventProperty[Feature][Page][At][Target]
? EventProperty[Feature][Page][At][Target][Action]
: never
: never
: never
: never
: never
: never;
EventPropertyForPath
타입은 주어진 제네릭 타입(Path
)의 경로에 따라 EventProperty
에서 속성을 가져오는 타입입니다. 여기서 제네릭 Path
는 ['인사이트', '메인', '리스트_콘텐츠', '콘텐츠']
와 같은 이벤트 경로 배열입니다. 두 번째 제네릭은 click
, view
와 같은 이벤트 액션 타입이 해당됩니다.
EventPath
의 경로는 총 4단계이기 때문에 이에 따라 Feature
, Page
, At
, Target
네 단계의 key를 배열로 추론(infer
) 한 후, 객체 경로에 따라 EventProperty
타입 객체의 키 타입과 일치하는지 확인합니다. 추론한 4단계의 key들과 Action
까지 일치한다면 해당 속성의 타입을 반환합니다. 만약 하나라도 키 타입이 일치하지 않는다면 never 타입을 반환합니다.
이제 다음과 같이 컴포넌트 타입을 개선할 수 있습니다.
export const ClickEventLogging<Path extends AllEventPath>({
children,
path,
property,
}: {
children: JSX.Element;
path: Path;
property: EventPropertyForPath<Path, 'click'>;
}) => {
/* 생략 */
이 타입을 적용하면 EventProperty
자체의 타입 검증은 물론이고, 주어진 path
경로에 일치하는 EventProperty
가 아니면 타입 오류가 일어나게 됩니다.
1. property의 잘못된 타입 검증 |
---|
2. path 경로와 일치하지 않는 propery 검증 |
---|
수정된 컴포넌트와 타입은 다음과 같습니다.
type.ts
import { EventProperty, EVENT_NAME } from '@/dataEvent';
type EventPath<T, TPath extends string[] = []> = TPath['length'] extends 4
? TPath
: keyof T extends infer Key extends string
? Key extends keyof T
? EventPath<T[Key], [...TPath, Key]>
: never
: never;
export type AllEventPath = EventPath<typeof EVENT_NAME>;
type EventAction = 'click' | 'view';
type EventPropertyForPath<Path, Action extends EventAction> = Path extends [
infer Feature,
infer Page,
infer At,
infer Target,
]
? Feature extends keyof EventProperty
? Page extends keyof EventProperty[Feature]
? At extends keyof EventProperty[Feature][Page]
? Target extends keyof EventProperty[Feature][Page][At]
? Action extends keyof EventProperty[Feature][Page][At][Target]
? EventProperty[Feature][Page][At][Target][Action]
: never
: never
: never
: never
: never
: never;
// Action 별 이벤트 로깅 컴포넌트의 Props 타입 추상화
export type EventLoggingComponentProps<Path extends AllEventPath, Action extends EventAction> = {
children: JSX.Element;
path: Path;
property: EventPropertyForPath<Path, Action>;
};
ClickEventLogging.tsx
import React from 'react';
import { EVENT_NAME } from '@/dataEvent';
import { AllEventPath, EventLoggingComponentProps } from './types';
const ClickEventLogging = <Path extends AllEventPath>({
children,
path,
property,
}: EventLoggingComponentProps<Path, 'click'>) => {
const [feature, page, at, target] = path;
const eventName = (EVENT_NAME as any)[feature][page][at][target]['click'];
const eventPath = [feature, page, at, target].join(' > ');
const child = React.Children.only(children);
return React.cloneElement(child, {
onClick: () => {
/**
* event logging!
*/
tracker.event(
eventName,
eventPath,
property,
);
if (child.props.onClick) {
child.props.onClick();
}
},
});
};
전체 코드 구성은 GitHub 에서 확인할 수 있습니다.
이벤트 로깅 개선의 긴 여정이 끝났습니다. 주요 개선점들을 요약하면 다음과 같습니다.
이벤트 로그의 관리와 분석을 더 쉽게 하기 위한 데이터 이벤트 컨벤션을 설계했습니다. 유저 행동의 "What"과 "Where"을 분리하는 구조적 계층 설계가 핵심이었습니다.
설계한 이벤트 컨벤션을 구글 스프레드 시트로 관리했습니다. 해당 문서의 데이터 이벤트 명세를 코드로 변환해 개발 생산성을 높였고, 이 과정에서 서비스 기획의 흐름과 변환된 이벤트 객체 구조를 동일하게 설계해 기획 친화적으로 만들었습니다.
이벤트 로깅의 역할을 하는 컴포넌트를 설계했습니다. 컴포넌트 기능과 이벤트 로깅의 의존성이 완전히 끊어져서 변경이 쉬워지고 컴포넌트 재사용성이 증가했습니다.
Typescript를 이용해 빌드나 런타임에서 잡지 못하는 잘못된 이벤트 로깅을 사전에 방지했습니다. 이로 인해 버그 발생 가능성을 낮추고, 개발 경험을 올렸습니다.
가장 좋은 형태의 이벤트 로깅 설계는 없다고 생각합니다. 상황이 변함에 따라 좋은 설계의 형태도 함께 변하기 마련이니까요. 서비스의 유형, 규모, 확장 방향성 등 서비스 내,외부의 다양한 맥락들 속에서 이벤트 로깅 설계의 정답지도 항상 변할 수 밖에 없는 것 같습니다.
정해진 정답이 없는 상황에서 좋은 설계를 정해두고 그 설계에 요구사항을 맞추는 건 좋지 않다고 생각합니다. 주어진 요구사항과 함께 앞으로의 방향성을 예측하기 위해 노력하고, 지속가능한 설계를 끊임없이 고민하는 태도가 조금이라도 더 좋은 설계로 이어지지는 길이 아닐까요?
프론트엔드 개발자의 입장에서 기획 친화적인 설계가 유저와 코드의 거리를 좁히고, 좁혀진 거리만큼 개발 생산성이 늘어난다는 걸 체감할 수 있었던 프로세스 개선이었습니다.
긴 글 읽어주셔서 감사합니다. 우리 모두 치열하고 즐겁게 고민해봐요!😁
정말 잘 배우고 갑니다! 다시 확인해보니 제가 적용할때 놓친 부분이 있었습니다. 제대로 타입 체킹이 되네요 (-ㅜ)
(수정전) 다만 (2) Event Property 검증 타입 설계의 "주어진 path 경로에 일치하는 EventProperty가 아니면 타입 오류가 일어나게 됩니다." 는 달성되지 않은 것 같습니다.
로깅 아키텍처를 고민중인데 많이 참고가 되었습니다. 감사합니다. 🙇
다만 <ClickEventLogging>
컴포넌트 방식은 onClick 이벤트 핸들러에서 로깅 메서드를 직접적으로 호출하지 않아도 된다는 장점은 있지만, 결국 <ContentsCard>
를 재사용하는 컴포넌트에서 <ClickEventLogging>
을 import 하여 <ContentsCard>
를 감싸주고 있으니 <ContentsCard>
를 사용하는 컴포넌트 관점에서는 의존성 문제가 해결되었다고 보기엔 어렵지 않을까요? 👀
또한 재사용의 단위가 <ContentsCard>
와 같은 전체 클릭요소가 아니라 클릭 가능한 특정 버튼들이 들어있는 조합형 요소일 경우에는 해당 컴포넌트에서 직접적으로 <ClickEventLogging>
을 import 하여 클릭 범위마다 컴포넌트 합성이 일어날 것 같은데 이렇게 되면 "컴포넌트 기능과 이벤트 로깅의 의존성 분리를 이루었다." 라고 말하기엔 무언가 부족한 상태가 아닐지 의견을 여쭙고 싶습니다.
많이 배워갑니다! 감사합니다:)