Luft 성능 리포트 2: 더 많은 코호트에 대한 리텐션 집계

Luft 성능 리포트 2: 더 많은 코호트에 대한 리텐션 집계

Jaewan Park — 박재완 @hueypark

배경

Airbrige 리텐션 리포트가 제공하는 스타트-이벤트 설정은 Install, Deeplink Open, Deeplink Pageview 3가지로 한정됩니다. 이로 인해 다양한 (보통 지금보다 많은) 코호트에 대해 리텐션을 알고 싶은 사용자 요구사항을 만족시키지 못하고 있습니다. 지금까지 Airbrige 는 MMP로서 사용자 확보에 초점을 맞추었기 때문에 이것만으로 충분한 가치를 제공할 수 있었지만, 고객사가 많아지며 다양한 스타트 이벤트를 설정하고 싶은 고객의 목소리가 들려오고 있습니다.

문제

하지만 현재 Luft 쿼리 성능으로 더 많은 유저에 대한 리텐션을 집계하기 어렵습니다. 물론 수평 확장이 가능한 디자인으로 개발되었기 때문에 무수히 많은 EC2 인스턴스를 사용한다면 불가능하지 않지만 비용 관점에서 현실적이지 않습니다.
더 자세히 설명하면 스타트 이벤트 설정을 Install 에서 Open 으로 변경하면 집계해야 할 사용자 수가 약 100배 가량 증가하고, 이에 따라 쿼리 응답속도도 늘어납니다. 따라서 감당할 수 있는 비용으로 다양한 리텐션 리포트를 제공하기 위해 성능개선이 필요합니다.

성능 프로파일과 작업방향 설정

작업방향을 설정하기 위해 성능개선 대상 쿼리 두가지를 선정했습니다. 선정을 위해 현실적인 워크로드 몇 가지를 검토 후 결정하였고, 스타트 이벤트 Open, 1개월에 대한 일단위 리텐션 을 제공하는 것은 가시성이 낮았기 때문에 스타트 이벤트 Open, 3일에 대한 분단위 리텐션 에 더 집중했습니다.
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션
이어서 Luft 에서 리텐션 집계를 위해 사용하는 쿼리의 동작방식에 대해 간단히 설명드리겠습니다. 아래에서 보는 것처럼 리텐션 집계는 총 5개의 단계로 이루어집니다.
1.
Scan: 스토리지에서 유저 데이터 읽어 옵니다.
2.
User grouping: 각 스토리지에서 읽어온 데이터를 각 유저별로 모으고 시간 순으로 정렬합니다.
3.
User aggregation: 유저 수준에서 리텐션을 집계합니다.
4.
Local aggregation reduction: 유저 수준에서 집계된 리텐션 데이터를 그룹 수준으로 합칩니다.
다음 단계로 전달되는 데이터 량을 줄이기 위해 로컬 노드를 활용합니다.
5.
Global aggregation reduction: 유저 수준에서 집계된 리텐션 데이터를 그룹 수준으로 합칩니다.
%%{init: {"flowchart": {"htmlLabels": false}} }%%

flowchart LR

scan1[Scan 1]
scan2[Scan 2]
groupBy1[User grouping 1]
groupBy2[User grouping 2]
userAgg1[User agg 1]
userAgg2[User agg 2]
groupRedLocal1["Local agg reduction 1"]
groupRedLocal2["Local agg reduction 2"]
groupRed1["Global agg reduction 1"]
groupRed2["Global agg reduction 2"]

scan1 --> groupBy1
scan1 --> groupBy2
scan2 --> groupBy1
scan2 --> groupBy2

groupBy1 --> userAgg1
groupBy2 --> userAgg2

userAgg1 --> groupRedLocal1
userAgg2 --> groupRedLocal2

groupRedLocal1 --> groupRed1
groupRedLocal1 --> groupRed2
groupRedLocal2 --> groupRed1
groupRedLocal2 --> groupRed2




Mermaid
복사
* 다른 번호의 다음 단계로 전달되는 경우 네트워크 I/O 비용이 발생합니다.
이어서 프로파일 결과를 보면 대부분의 CPU 시간이 User aggregation 과 Local aggregation reduction 에서 발생하는 것을 확인 할 수 있습니다. 또 일반적인 쿼리에서 눈에 띄던 네트워크 I/O 비용은 거의 없습니다.
cohortUserLevelAggregation.Transform = User aggregation cohortGroupLevelReducer.Transform = Local aggregation reduction
우리는 이 데이터를 바탕으로 컴퓨팅 성능을 개선하고, 필요에 따라 Compute 노드를 추가하면 더 많은 코호트에 대한 리텐션 집계가 가능할 것이라는 가설을 만들었고, 프로파일 기반으로 몇 가지 예상 작업을 목록화 하여 성능개선을 시작했습니다.

구현

성능개선 작업은 주로 CPU 프로파일 결과를 기반으로 진행되었습니다. I/O 블로킹 부분에서도 가능성은 발견되었지만, 이번 구현에는 포함되지 않았습니다. 개선작업은 크게 불필요 작업 제거, 중복 계산 방지, 효율적 자료구조 활용, 계산 지연 전략 적용 크게 4가지 전략으로 구분 할 수 있었습니다.
각 전략에 따른 세부 항목은 응답시간 변화, 변경 전후 비교, 개선 이유 이 세가지 요소로 나누어 정리했습니다.

1. 불필요 작업 제거

1.1. 구간 목록별로 집계할 필요가 없는 데이터를 미리 계산해 스킵

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 4% 증가
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 8% 감소
변경 전: 각 단위구간마다 계산없이 건너뛸 수 있는지 판정합니다.
for _, aggAndIvl := range aggAndIvls { if aggAndIvl.canSkip { continue } // Do something. }
Go
복사
변경 후: 여러 단위구간을 하나로 합쳐 건너뛸 수 있는지 판정합니다.
for _, aggAndIvls := range aggs { if aggAndIvls.canSkip { continue } for _, ivl := range aggAndIvls.ivls { // Do something. } }
Go
복사
개선 이유
불필요한 중복 연산이 제거 되었고, 분기예측 실패로 인한 성능감소가 없어졌습니다.
기타
일단위 리텐션은 오히려 응답시간이 증가하여 쿼리 옵티마이저가 적용여부를 제어해야 합니다.

1.2. 불필요한 집계 구간을 연산에서 제외

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 현상 유지
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 46% 감소
변경 전: 모든 단위구간에 대한 집계합니다.
for i, ivl := range ivls { // Do something. }
Go
복사
변경 후: 필요한 단위구간에 대해서만 집계합니다.
// Pre calculate min and max index. for i := minIdx; i < maxIdx; minIdx++ { ivl := ivls[i] // Do something. }
Go
복사
개선 이유
계산을 위해 준비하는 총 구간에 비해 비해 각 집계의 단위구간이 짧기 때문에 의미있었습니다.

1.3.1. Metric 중 데이터가 없는 구간은 집계 연산에서 제외

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 5% 감소
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 74% 감소
변경 전: 모든 집계 데이터를 합칩니다.
for i, agg := range next.Metricss { curr.Metricss[i] += agg }
Go
복사
변경 후: 값이 있는 경우에만 집계 데이터를 합칩니다.
if hasEvent { nextAggregator.HasValue = true } // Do something. if next.HasValue { for i, agg := range next.Metricss { curr.Metricss[i] += agg } }
Go
복사
개선 이유
분단위 리텐션의 경우 하나의 구간에 이벤트가 없을 가능성이 높으므로 큰 폭의 성능개선이 있었습니다.
기타
많은 구간에 이벤트가 있는 경우 불필요한 검사 로직 추가 비용이 집계 데이터를 합치는 비용보다 클 수 있기 때문에 쿼리 옵티마이저가 적용여부를 제어해야 합니다.

1.3.2. 1.3.1. Metric 중 데이터가 없는 구간은 집계 연산에서 제외 에서 필요한 구간만 처리하게 개선

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 11% 증가
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 31% 감소
변경 전: Flag 값을 활용해 값 보유 여부를 판정합니다.
if hasEvent { nextAggregator[ivlIdx].HasValue = true } // Do something. if next.HasValue { for i, agg := range next.Metricss { curr[ivlIdx].Metricss[i] += agg } }
Go
복사
변경 후: 값이 있는 경우의 인덱스를 별도 관리해 값 보유 여부를 판정합니다.
if hasEvent { hasValueIdxs[ivlIdx] = struct{}{} } // Do something. for hasValueIdx := range hasValueIdxs { for i, agg := range next.Metricss { curr[hasValueIdx].Metricss[i] += agg } }
Go
복사
변경 이유
분단위 리텐션의 경우 집계 연산에서 제외되는 구간이 매우 많았기 때문에 값 목록 순회 자체를 줄이는 것이 의미있었습니다.
기타
하지만 일단위 리텐션은 오히려 느려졌고, 쿼리 옵티마이저가 적용여부를 제어해야 합니다.

1.4. 이미 계산이 종료된 구간에 대해서는 스타트 이벤트 보유여부 판정을 하지 않음

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 2% 증가
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 10% 감소
변경 전: 계산이 종료되어 더이상 불필요한 단위구간도 판정합니다.
for _, aggAndIvl := range aggAndIvls { if aggAndIvl.Overlap(startIvl) { aggAndIvl.hasStartEvent = true } // Do something. }
Go
복사
변경 후: 계산이 종료된 단위구간은 판정조차 하지 않습니다.
// aggAndIvls shuld be sorted. for i := preCalculatedStartIdx; i < len(aggAndIvls); i++ { startIvl := aggAndIvls[i] if aggAndIvl.Overlap(startIvl) { aggAndIvl.hasStartEvent = true } // Do something. }
Go
복사
변경 이유
과거 구간에 대한 불필요한 계산이 줄어듭니다.
각 구간이 순차적이고 서로 겹치지 않는 경우에만 적용할 수 있는데, 리텐션 쿼리는 이에 해당했습니다.

1.5. 리턴 이벤트를 가져올 때 불필요한 컬럼 안 가져오게 수정

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 32% 감소
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 5% 감소
변경 전: 이벤트의 모든 컬럼을 User aggregation 단계까지 가져와 리턴 이벤트를 판정합니다.
변경 후: 꼭 필요한 컬럼만 User aggregation 단계로 전달합니다.
변경 이유
가져오는 컬럼의 절대량이 줄어들고 불필요한 중복 필터링이 제거되었습니다.
이 비효율성은 초기 구현체의 흔적으로 당시 모든 이벤트와 컬럼을 한 번에 가져 올 수 밖에 없었기 때문에 이런 최적화가 불가능했습니다.
리텐션 쿼리의 경우는 리턴 이벤트에서 시간 정보만으로 집계가 가능해 적용할 수 있었습니다.

1.6.1. 코호트 조건을 User grouping 단계에서 처리하는 옵션 끔

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 24% 감소
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 32% 감소
변경 전: 코호트 조건을 User grouping 단계에서 처리하는 옵션을 활용합니다.
변경 후: 코호트 조건을 User grouping 단계에서 처리하는 옵션을 활용하지 않습니다.
변경 이유
코호트 조건을 User grouping 단계에서 처리하는 옵션이 대부분 유의미하게 동작한 이유는, 코호트 조건으로 필터되는 유저가 매우 많기 때문입니다.
하지만 스타트 이벤트 Open 일 경우에는 대상이 거의 모든 유저이기 때문에 유저는 거의 필터되지 않고 불필요한 연산만 많아졌습니다.
기타
이 옵션도 쿼리 옵티마이저가 적용여부를 제어해야 합니다.

1.6.2. 1.6.1. 코호트 조건을 User grouping 단계에서 처리하는 옵션 끔 할 경우 스타트 이벤트 기준으로 자동으로 만들어지는 코호트 조건도 만들지 않게 수정

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 3% 감소
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 11% 감소
변경 전: 스타트 이벤트 기준으로 자동으로 만들어지는 코호트 조건을 만듭니다.
변경 후: 스타트 이벤트 기준으로 자동으로 만들어지는 코호트 조건을 만들지 않습니다.

1.7. User aggregation + Local aggregation reduction 단계 합치기

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 2% 감소
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 18% 감소
변경 전: User aggregation, Local aggregation reduction 작업을 별도 단계로 수행합니다.
변경 후: User aggregation, Local aggregation reduction 작업을 하나의 단계로 합쳐 수행합니다.
변경 이유
다음 스테이지로 보내기 위한 불필요한 연산이 없어졌습니다.
Channel 로 전달하기, memory pool 사용, marshalling 등의 작업이 제거되었습니다.
합쳐진 두 단계가 모두 단일 노드에서 실행됨을 보장받을 수 있기 때문에 적용 가능했습니다.
기타
기대한 것보다 개선폭이 커 다른 단계도 합치는 것을 검토 중입니다.

2. 중복 계산 방지

2.1. 매번 다시 계산되던 정보를 상위 자료구조가 미리 계산해 공용으로 사용하게 수정

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 현상 유지
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 23% 감소
변경 전: 매번 시간 관련 정보를 계산합니다.
ivl := ivls[i] minTime := ivl.MinTime() maxTime := ivl.MaxTime()
Go
복사
변경 후: 미리 계산해 놓은 시간 관련 정보를 재활용합니다.
// Pre calculate min, max times. minTime := minTimes[i] maxTime := maxTimes[i]
Go
복사
변경 이유
불필요한 중복 계산이 줄어들었습니다.

2.2. 집계용 aggregator 구조체가 내부에 임시 계산값을 안 가지게 수정

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 3% 감소
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 6% 감소
변경 전: aggregater 가 내부에 가진 임시값을 활용합니다.
val := aggregator.Evaluate(val)
Go
복사
변경 후: aggregater 가 내부에 임시값을 가지지 않습니다.
val := aggregator.Evaluate(prevVal, val)
Go
복사
변경 이유
불필요한 aggregator 임시값 초기화 과정이 제거되었습니다.
내부에 임시값을 가지지 않기 때문에 aggregator 복사를 하지 않고 공유해 사용합니다.

2.3. 불필요한 로컬변수 메모리 할당 줄이기

응답시간 변경
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 현상 유지
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 6% 감소
변경 전: 로컬 변수를 필요시마다 재할당합니다.
for _, data := range datas { buf := make([]byte, 0, 1024) // Do something. }
Go
복사
변경 후: 가능한 경우 로컬 변수를 재활용합니다.
buf := make([]byte, 0, 1024) for _, data := range datas { buf = buf[:0] // Do something. }
Go
복사
변경 이유
불필요한 메모리 할당이 줄어 들었습니다.

3. 효율적 자료구조 활용

3.1. 데이터를 저장용 자료구조를 2차원 배열에서 1차원 배열로 통합

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 6% 감소
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 35% 감소
변경 전: 2차원 배열로 데이터를 저장합니다.
type Metricss [][]flaot64 var cur, next Metricss // Do something. for i, metrics := range next { for j, val := range metrics { curr[i][j] += val } }
Go
복사
변경 후: 1차원 배열로 데이터를 저장합니다.
type Metricss []float64 var cur, next Metricss // Do something. for i, val := range next { curr[i] += val }
Go
복사
변경 이유
Go 컴파일러가 더 최적화된 바이너리를 만들어 줍니다.
메모리 단편화가 줄어듭니다.
기타
안타깝게도 인간 입장에서 가독성은 떨어집니다.

4. 계산 지연 전략 적용

4.1. 더 가벼운 검사 먼저하기

응답시간 변화
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 1% 감소
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 15% 감소
변경 전: 계산을 건너뛰기 위해 무거운 검사를 먼저 합니다.
for _, aggAndIvl := range aggAndIvls { if aggAndIvl.HeavyCheck() { continue } if aggAndIvl.LightCheck() { continue } // Do something. }
Go
복사
변경 후: 계산을 건너뛰기 위해 가벼운 검사를 먼저합니다.
for _, aggAndIvl := range aggAndIvls { if aggAndIvl.LightCheck() { continue } if aggAndIvl.HeavyCheck() { continue } // Do something. }
Go
복사
변경 이유
가벼운 검사를 통해 계산을 건너뛰는 경우가 많은 것을 프로파일에서 확인 할 수 있었습니다.

한계와 더 해야할 작업

스케일 아웃 효율이 나쁨

스타트 이벤트 Open, 1개월에 대한 일단위 리텐션 는 아직 오래 걸리기 때문에 스케일 아웃이 용이한 Compute 노드를 활용해 성능개선을 시도했습니다. 하지만 아래에서 보는 것처럼 Compute 노드 추가만으로는 충분히 효율적인 성능향상을 얻을 수 없었습니다.
4 Historical + 4 Compute: 29.18초 → 20.3초
4 Historical + 8 Compute: 20.3초 → 16.31초
4 Historical + 16 Compute 노드: 16.31초 → 14.44초
이는 두가지 이유 때문으로 추정되는데, 첫째는 Compute 노드가 추가됨에 따라 ScanUser grouping 단계에서 활용되던 네트워크 I/O 감소 최적화가 불가능해진 것이고, 둘째는 노드 수 증가로 인한 네트워크 I/O 총 비용 증가입니다.
하지만 AWS LambdaAmazon S3 Express One Zone 을 활용해 Historical (스토리지에 직접 접근하는) 노드를 빠르게 스케일 아웃하는 것을 준비중이며, 제대로 동작한다면 스케일 아웃에 의한 성능감소를 획기적으로 개선할 수 있을 것으로 기대합니다.

특정 쿼리에만 적용되는 최적화

이 성능개선은 기본적으로 특정 쿼리에만 적용되는 최적화입니다. 물론 일부 작업은 다른 쿼리들에도 유의미한 개선을 주겠지만, 이번 작업에서도 향상된 쿼리 옵티마이저가 필요한 이유가 여러가지 발견되었습니다. 가까운 미래에 더 고도화된 쿼리 옵티마이저를 도입해 전체 쿼리의 성능개선을 함께 신경써야 할 필요가 있습니다.

마치며

이번 작업을 통해 리텐션 쿼리의 응답시간을 아래와 같이 개선하여 Luft 에서 더 많은 코호트에 대해 리텐션을 제공할 수 있게 되었습니다.
1.
스타트 이벤트 Open, 1개월에 대한 일단위 리텐션: 1분 11.21초 → 29.18초
2.
스타트 이벤트 Open, 3일에 대한 분단위 리텐션: 5분 42.99초 → 4.4초
아직 Airbrige 리텐션 리포트 에 적용되지 않았지만, 머지 않은 미래에 Airbrige 에도 적용되길 바랍니다. 또 위에서 이야기한 한계점을 극복해 성능부족이 기능 추가를 가로막는 장벽이 되지 않게 노력하겠습니다.
감사합니다. 머지않은 미래에 다시 돌아오겠습니다.
ᴡʀɪᴛᴇʀ
Jaewan Park @hueypark Backend Engineer @AB180
유니콘부터 대기업까지 쓰는 제품. 같이 만들어볼래요? 에이비일팔공에서 함께 성장할 다양한 직군의 동료들을 찾고 있어요! → 더 알아보기