brunch

You can make anything
by writing

C.S.Lewis

by 레오군 Sep 02. 2019

모바일 앱 로그분석, 어떻게 시작해야 할까?

Firebase와 BigQuery를 이용한 로그분석 시스템 구축하기

데이터 파이프라인을 잘 구축한다는 건 어떤 의미일까요? 기술적으로는 어떤 데이터베이스를 이용할지 선택하고, 스트리밍과 배치를 적절히 설계하고, 수집된 데이터의 전처리 프로세스를 만들고… 등등의 수많은 고려사항이 있을 텐데요. 개발자가 아닌 분석가 입장에서 ‘데이터 파이프라인을 잘 만든다’는 건 약간 다른 측면의 이야기라고 생각합니다. 저는 다음과 같은 질문을 해 볼 수 있을 것 같네요. 서비스 데이터와 유저 행동 데이터가 잘 구조화되어서 저장되고 있고, 서로 연계해서 보는 데 어려움이 없는가? 


면접에서 많이 듣는 말 1위 vs. 입사하고 많이 하는 말 1위…


서비스를 이용하는 사용자들이 남기는 로그는 서비스 로그와 행동 로그로 구분됩니다. 서비스 로그는 transaction의 결과를 기록하는 로그입니다. 가입하거나, 예약하거나, 결제하는 등 하나의 transaction이 완료되었을 때 각각의 서비스 로그가 남게 됩니다. 반면 행동 로그는 transaction에 이르기까지 사용자들이 서비스에서 하는 하나하나의 action에 대한 로그를 의미합니다. 특정 상품을 클릭하거나, 검색하거나, 배너를 스와이프하는 등의 action을 예로 들 수 있습니다.

서비스 로그는 기본적인 서비스 운영을 위해서 필수적으로 쌓고 관리해야 하므로 이 데이터를 활용하는 데는 대부분 큰 문제가 없습니다. (모든 변경분을 다 쌓을지, 최종 수정된 내용만 남길지 정도의 고려사항은 있겠지만...) 

반면 행동 로그의 경우 데이터양도 훨씬 많고, 설계하는 과정에서의 자유도도 높아서 수집이나 활용이 상대적으로 까다로운 편입니다. 당장 볼 수 없다고 해서 서비스에 큰 문제가 생기는 것도 아니고요. 그러다 보니 ‘일단 되는대로 다 쌓자! 근데 어떻게 봐야 할지 모르겠다. 나중에 누군가 보겠지 뭐…’ 상태로 방치되는 경우가 많습니다. 

이 글에서는 마이리얼트립 모바일 앱에서의 행동 로그를 어떤 방식으로 수집해서 보고 있는지를 간단히(?) 소개하려고 합니다. 단, 연동에 대한 기술적인 부분보다는 이벤트 구조를 어떻게 설계하고, 어떤 식으로 적재하고, 어떻게 조회하는지… 분석가 관점에서의 로그 수집과 처리에 대한 이야기를 주로 말씀드릴 예정입니다.




행동 로그 설계하기

행동 로그를 어떻게 설계하느냐에 따라서, 얻을 수 있는 정보의 수준은 완전히 달라집니다. 행동 로그를 보는 가장 단순한 방식은, 발생한 이벤트의 숫자를 count 하는 것입니다. (ex. 가입하기 버튼 클릭 수는 100회입니다.) 하지만 단순 이벤트 숫자 집계만으로는 원하는 수준의 인사이트를 얻기 어렵겠죠. 사실 행동 로그 설계의 핵심은 이벤트의 속성(property)을 어떤 수준으로 함께 남길 것인가? 를 정의하는 부분입니다. 속성에 대해서 약간 더 부연설명을 하자면, 특정 이벤트가 발생했을 때 함께 남길 수 있는 이벤트(혹은 사용자)에 대한 세부정보라고 생각하시면 됩니다. 가령, ‘예약하기’ 버튼을 누르는 이벤트가 있다고 하면, 아래와 같은 것들이 property가 될 수 있겠네요.

이벤트의 속성을 남기지 않았다면, 예약하기 버튼을 클릭한 숫자 정도만 확인이 가능합니다. 하지만 이벤트의 property를 함께 기록했다면 어떤 상품을 클릭했는지, 그 상품의 가격이 얼마였는지, 어떤 화면에서 클릭했는지…와 같은 훨씬 더 자세한 정보를 얻을 수 있습니다. 만약 사용자 property까지 함께 기록했다면? 해당 이벤트를 한 사용자가 어떤 특성이 있는지도 확인할 수 있습니다. 즉, 하나의 이벤트가 발생했을 때 훨씬 더 입체적으로 정보를 얻을 수 있게 됩니다. 아래 표를 보면 property를 남기는 수준에 따라서 얻을 수 있는 인사이트의 수준이 크게 차이 남을 확인하실 수 있습니다.




BigQuery에 로그 적재하기

마이리얼트립은 Firebase-BigQuery 로 이어지는 파이프라인을 이용해서 앱 로그를 남기고 있습니다. BigQuery는 구글이 제공하는 대용량 데이터베이스인데요. TB 단위의 스캔에도 전혀 무리 없는 빠른 속도, 매우 저렴한 저장비용, 무제한에 가까운 용량, 관리의 편의성, Firebase 프레임웍과의 연동… 등 앱 로그를 쌓는 용도로 사용하기에 아주 좋은 선택입니다. BigQuery에 대한 자세한 소개, 그리고 Firebase를 BigQuery와 연동하기 위한 설정에 대한 디테일한 내용은 아래 링크를 참고하시길 바랍니다.  

구글 BigQuery 공식 문서

변성윤 님의 BigQuery 튜토리얼

이민우 님의 BigQuery 시작하기


BigQuery에 쌓인 이벤트 로그는 아래와 같은 포맷의 스키마를 갖습니다. event에 딸린 key가 있고(위에서 설명한 event property 명칭에 해당합니다), 해당 key에 매핑된 value (event property의 구체적인 값에 해당합니다)가 있습니다. 특이한 점은 value가 자료형에 따라 구분되어 있다는 건데요. string, integer, float, double 각 자료형에 따라서 컬럼이 구분되어 있고, 추후 조회를 할 때도 자료형에 따라 정확한 컬럼을 지정해야 값을 확인할 수 있습니다. 참고로, 아래는 event property를 예로 들었지만 user property도 적재되는 방식은 동일합니다.

scheme 예시


event 안에 N개의 key가 있고, 각 key에는 매핑되는 value가 있습니다 (4개 type 중 하나)


또 한가지 주목해야 하는 부분은 하나의 이벤트에 복수 개의 property가 존재하는 경우, row 안에 row가 내재한 구조로 쌓인다는 점인데요 (BigQuery에서는 이런 유형의 컬럼을 Record type으로 분류합니다. 일명 nested 구조…) 구글은 이걸 장점이라고 홍보하던데 -_-; 사실 분석하는 입장에서는 이런 nested 구조 때문에 쿼리 작성의 난이도가 굉장히 높습니다ㅠㅜ 간단히 설명해 드리면, record type의 데이터를 조회할 때는 항상 unnest 한 다음에 쿼리를 해야한다는 건데요. 이 부분은 뒤에서 자세히 설명해 드리도록 하겠습니다.

(저도 nested 구조에 익숙하지 않아서 처음에는 엄청 고생하다가, 이 글을 다섯 번쯤 정독하고 나서야 경건한 마음으로 콘솔에 쿼리 작성을 할 수 있었습니다…)




BigQuery에서 로그 조회하기

unnest를 활용하여 기본적인 쿼리를 작성하는 방법은 아래와 같습니다. from 문 뒤에 unnest를 이용해서 특정 record type을 임시로 펼치고, 그 컬럼을 불러와서 정보를 조회합니다.


# unnest 사용 예시
# 검색어 입력 방법(search_type)에 따라서 count하는 단순한(!) 쿼리

SELECT
event_params.value.string_value,
COUNT(*) as cnt
FROM
`dataset.table_name`,
UNNEST(event_params) AS event_params
WHERE
_TABLE_SUFFIX BETWEEN '20190801' AND '20190807'
AND event_name ='search'
AND event_params.key = 'search_type'
GROUP BY 1
ORDER BY 2 DESC


위 케이스는 하나의 property를 사용하는 일반적인 상황에서 쓸 수 있는데요. 만약, 특정 record type에 있는 property 여러 개를 동시에 사용하는 쿼리를 짜고 싶다면 어떻게 해야 할까요? 가령 ‘특정 검색어’에 대해서만 ‘search_type’을 구분해서 보고 싶다면? 좀 번거롭긴 하지만 사용하려고 하는 property 개수만큼 해당 record를 unnest 하면, 복수 property를 활용하는 쿼리문을 작성할 수 있습니다.


# 2개 이상의 property를 활용하는 쿼리
# 사용하려는 property 개수만큼 unnest를 한다 -_-

SELECT
x.value.string_value AS search_term, 
y.value.string_value as search_type
COUNT(*) as cnt
FROM 
`dataset.table_name`,
UNNEST(event_params) as x,
UNNEST(event_params) as y
WHERE
_TABLE_SUFFIX BETWEEN '20190801' AND '20190807'
AND event_name ='search'
AND x.key = 'search_term'
AND y.key = 'search_type'
GROUP BY 1,2
ORDER BY 3 DESC


다음으로, parameter 안에 있는 value를 꺼내서 select나 group by의 기준으로 쓰고 싶다면? 아래와 같이 subquery를 이용하면 됩니다. name, key, value가 좀 헷갈릴 수 있는데, 자꾸 사용하다 보면 어쨌든 익숙해지긴 하더군요.


# parameter 안에 있는 value를 사용하고 싶을 때는 subquery를 활용
# 처음 봤을 땐 대략 정신이 멍해지지만, 익숙해지면 또 어떻게든 짜게 된다...

SELECT
(SELECT params.value.string_value FROM UNNEST(event_params) params
 WHERE params.key = 'city_name') AS city_name,
COUNT(*) as screen_view  
FROM
`dataset.table_name`,
UNNEST(event_params) AS event_params
WHERE
_TABLE_SUFFIX BETWEEN '20190801' AND '20190807'
AND event_name = 'screen_view'
AND key = 'screen_name'
AND value.string_value = 'city'  
GROUP BY 1
ORDER BY 2 DESC


이런 식으로 이벤트와 사용자의 property를 잘 정의해서 BigQuery에 쌓아두면 사용자의 행동 로그를 굉장히 자세한 레벨에서 분석할 수 있습니다. 기본적인 노출이나 클릭 이벤트 집계는 물론이고, 주요 페이지에 대한 퍼널 분석이라던지, 핵심 기능에 대한 사용성 확인, 신규 피쳐에 대한 A/B 테스트 성과 확인 등이 모두 가능합니다. user property를 잘 남겼다면 특정 행동을 한 유저 리스트를 추출하거나, 적합한 프로모션을 위한 세밀한 타겟팅도 할 수 있습니다.


다만 한가지 문제가 있는데요. nested 되어서 쌓이는 DB 구조 때문에 여러 가지 복잡한 조건이 포함된 쿼리를 작성하는 게 굉장히 어렵습니다. ㅠㅜ 기본적인 SQL 문법을 알고 있는 사람이라도 하더라도, 쿼리를 작성할 때 아래 사항을 챙기느라 꽤나 시행착오를 겪어야 합니다.


기본적으로 unnest가 항상 필요

unnest하는 과정에서 불필요하게 생성되는 많은 row로 인해서 쿼리 스캔 비용이 늘어남

event, parameter.key, parameter.value가 매번 헷갈림

value의 타입이 string, integer, float, double 중 어느 것인지를 쿼리할때마다 정확히 지정해야 함

parameter.value를 꺼내 쓰려면 subquery 등 굉장히 복잡한 문법이 필요함


사실 정확히 말하면… 쿼리 작성이 어려운 게 문제라기보다는, 쿼리 작성이 어려워지면서 로그를 점점 소극적으로 보게 된다는 문제가 생깁니다. 물론 빈번하게 활용되는 쿼리들은 초기에 일괄 세팅해서 편하게 볼 수 있도록 했지만, 이후 추가로 분석할만한 다양한 주제가 생겼을 때 쿼리 작성이 까다로워서 진행 자체가 늦어지거나, 세세한 데이터를 충분히 보지 못하는 경우가 종종 발생했거든요. 




분석이 편한 로그 테이블 만들기

불편했지만 어찌어찌 적응하면서 지내던 저와 달리, 마이리얼트립 그로스팀에서 바른생활과 톤앤매너를 담당하고 있으며 2019년 4월의 MRTer (a.k.a 우수사원)로 선정된 안성환님은 이 문제를 꼭 해결하고 싶어했습니다. 몇 번의 시행착오를 거치긴 했지만, 결과적으로 성환님은 굉장히 깔끔한 방법으로 로그 테이블 전처리에 성공했는데요. 짧게 소개해 드리겠습니다.


nested 된 테이블 예시


위와 같이 nested 된 테이블을 ‘사람이 보기 편한’ 형태로 flatten 하려면 어떻게 해야 할까요? 약간 복잡한 subquery를 사용해야 하지만, key를 기준으로 unnest 시키면서 value의 type을 하나하나 잘 지정해주면 그리 어렵진 않습니다.


# nested된 컬럼 일괄 flatten 하기

SELECT event_name,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'name') as name,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'price') as price,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'city') as city
FROM `dataset.table_name`
WHERE event_name = 'click_reservation'


이렇게 flatten을 하면, 아래와 같이 매우 예쁜(!) 테이블이 나오게 됩니다.


자, 그럼 이제 이 쿼리를 기반으로 UDF(User Defined Function)를 만들고, 기존 테이블에다가 이 함수를 적용한 결과를 새 테이블로 저장하면, 놀랍도록 깔끔한 데이터셋을 얻을 수 있습니다. (이 과정을 자동화하는 pipeline은 데이터플랫폼 팀의 도움을 많이 받았습니다) 아래에 적혀진 것처럼, paramValueByKey 라는 마법의(!) 함수를 통해 nested 된 테이블의 flatten 작업을 깔끔하게 진행하고 새로운 테이블에 저장할 수 있었습니다.


# UDF 예시

CREATE TEMP FUNCTION paramValueByKey(k STRING, params ARRAY<STRUCT<key STRING, value STRUCT<string_value STRING, int_value INT64, float_value FLOAT64, double_value FLOAT64 >>>) AS (
  (SELECT x.value FROM UNNEST(params) x WHERE x.key=k)
); 
CREATE OR REPLACE TABLE `dataset.new_table`
PARTITION BY date
OPTIONS (description="flatten table partitioned by date")
as 
SELECT
date,
event_name,
paramValueByKey('screen_name', event_params).string_value as screen_name,
paramValueByKey('content_id', event_params).int_value as content_id,
paramValueByKey('price', event_params).double_value as price
FROM `dataset.old_table`


이런 식으로 테이블이 깔끔하게 정리되고 나면, 똑같은 데이터를 추출하는 데 필요한 쿼리도 굉장히 심플해집니다. 위에서 잠깐 언급했던 검색어별 검색방법을 확인하는 쿼리(복수의 property를 활용했던 사례)를 예로 들어보면, before 대비 after 쿼리가 엄청나게 간결해진 것을 확인하실 수 있습니다. 저 정도면 사내 SQL 교육을 수료한 멤버들이 직접 궁금한 정보를 찾아보는데 전혀 무리가 없는 테이블 구조라고 할 수 있습니다. (데이터가 흐르는 조직!)


#### BEFORE ####

SELECT x.value.string_value AS search_term, y.value.string_value as search_type, COUNT(*) as cnt
FROM `dataset.old_table`, UNNEST(event_params) as x, UNNEST(event_params) as y
WHERE _TABLE_SUFFIX BETWEEN '20190801' AND '20190807'
AND event_name ='search' AND x.key = 'search_term' AND y.key = 'search_type'
GROUP BY 1,2
ORDER BY 3 DESC

#### AFTER ####

SELECT search_term, search_type, count(*) as cnt
FROM `dataset.new_table`
WHERE date BETWEEN '2019-08-01' AND '2019-08-07'
AND event_name ='search'
GROUP BY 1,2
ORDER BY 3 DESC


이처럼 심플한 구조로 로그데이터를 저장하게 되면 여러 장점이 있습니다. 무엇보다 쿼리를 작성하는 과정이 간편해지면서 훨씬 더 다양한 데이터를 적극적으로 살펴보게 되었고요. unnest 과정에서 생기는 불필요한 row가 사라지면서, 쿼리 비용도 훨씬 절감할 수 있었습니다. (BigQuery의 경우, 스캔하는 데이터 양 기준으로 쿼리에 대한 과금을 합니다) 기존 쿼리를 수정하거나 개선하는 경우에도, 분석을 하는 시간이 크게 단축되어서 생산성이 크게 향상되었습니다. 


마무리

이상으로, 마이리얼트립에서 어떤 식으로 행동 로그를 정의하고, 수집하고, 조회하는지… 에 대해서 간략하게 소개해 드렸습니다. 로그 분석은 초반에 설계를 잘 하는 것 만큼이나 QA를 꼼꼼하게 하고, 이후 서비스가 업데이트 될 때마다 꾸준히 잘 챙기는 게 중요한데요. (전체를 갈아엎고 새로 싹 만드는 일의 난이도가 10이라면, 이후 변경사항을 꾸준히 잘 챙기는 일의 난이도는 100쯤 되는 것 같네요…)


로깅 작업을 꼼꼼하게 잘 챙기는 개발자는 있지만 (제가 N개 회사를 다녀봤는데, 마이리얼트립 개발자분들은 진짜 잘 챙겨주십니다…ㅎㅎ), 그것과 별개로 이걸 좋아하는(?) 개발자는 없다고 생각합니다. 엄청난 꼼꼼함과 완벽함이 요구되는 무한 반복 업무거든요 -_-;; (애초에 사람이 할 일이 아닌 것 같기도…)


개인적으로 마이리얼트립 개발자분들이 로깅을 꼼꼼하게 잘 챙겨주시는 큰 이유는 실제로 앱 로그 데이터가 여기저기서 잘 활용되고 있고, 같이 일하는 멤버들 사이에서 굉장히 중요한 데이터로 인식되고 있다는 점 때문이 아닐까 싶습니다. 마이리얼트립에서는 A/B테스트를 하거나, 새로운 기능을 출시했을 때, 서비스에 이상 징후가 있을 때 굉장히 열심히 행동 로그를 살펴보는 편이거든요. 누가 언제 볼지 모르는 데이터를 일단 쌓는 것과, 모두가 굉장히 열심히 보고 있는 데이터를 쌓는 건… 동기부여의 차원이 전혀 다를 테니까요!





더 공부하고 싶다면?

그로스해킹 : 데이터와 실험을 통해 성장하는 서비스를 만드는 방법




브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari