Retoday의 대시보드는 스크린 타임, 카테고리별 사용량, 자주 방문한 웹사이트, 시간대별 활동, 가장 오래 머문 웹사이트를 보여줍니다. 초기에는 화면 하나를 열 때 다섯 API를 호출하고, 각 API가 별도의 쿼리로 통계를 계산했습니다.

기록이 많은 사용자의 주간 대시보드를 동시에 조회하자 응답이 늦어졌습니다. API를 하나로 합치는 방법부터 집계 위치를 바꾸는 방법, 계산 결과를 스냅샷으로 저장하는 방법까지 비교했습니다.

문제 현상

서로 다른 사용자 100명이 주간 대시보드를 반복 조회하는 테스트를 진행했습니다. 방문 기록은 10,000ms 이상 체류해야 생성되므로, 하루 24시간을 이 간격으로 나눈 8,640건을 사용자별 하루 기록 수로 잡았습니다. 일주일이면 사용자당 60,480건, 전체 6,048,000건입니다.

항목테스트 조건
조회 대상2026년 9월 28일~10월 4일의 종료된 기록
데이터사용자 100명, 사용자당 기록 60,480건·웹사이트 100개
API 서버3대, 각각 0.5 CPU·1,536MiB·커넥션 풀 10개
DBMySQL 8.4.11, 2 CPU·4GiB, 버퍼 풀 2GiB
부하100 VU, 응답 완료 후 다음 조회, 15,000ms 예열 후 30,000ms씩 3회

네 가지 조회 방식을 같은 코드베이스에서 구성하고, 같은 데이터와 서버 자원으로 비교했습니다. 이후 성능 결과는 모두 주간 조회 기준입니다. 응답 시간은 화면에 필요한 응답을 모두 받을 때까지 측정했습니다. 다섯 API를 호출하는 방식은 하나라도 실패하면 화면 조회 실패로 집계했습니다.

CPU는 할당한 코어 대비 평균 사용률, 메모리는 컨테이너의 최대 사용량을 할당량으로 나눈 비율입니다. API 자원은 세 대의 합계이며, 메모리는 JVM 힙이 아닌 컨테이너 working set입니다. 응답 시간과 자원 지표는 실행별 값을 구한 뒤 세 값의 중앙값을 사용했습니다. 실패율은 세 실행의 실패 수와 완료 수를 합산해 계산했습니다.

지표분리 API + SQL 집계
평균 응답 시간13,228ms
p95 응답 시간16,384ms
p99 응답 시간17,131ms
API 평균 CPU 사용률4.0%
API 최대 메모리 사용률39.8% (1,833MiB)
DB 평균 CPU 사용률99.1%
DB 최대 메모리 사용률32.8% (1,341MiB)
실패율0% (0 / 858건)

실패한 요청은 없었지만, 평균 응답 시간이 13,228ms였고 p95는 16,384ms까지 늘어났습니다. API CPU 사용률은 4.0%에 머문 반면 DB CPU는 99.1%를 사용하고 있었습니다.

문제 원인

같은 기록을 반복해서 읽고 집계하는 쿼리

다섯 API가 반환하는 통계는 달라도 집계 대상은 같은 사용자의 방문 기록입니다. 웹사이트별 방문 횟수와 체류 시간을 계산하는 쿼리에서 핵심 부분을 보면 다음과 같습니다.

1WITH source AS (
2    SELECT h.website_id,
3           GREATEST(h.started_at, CAST(:range_start AS DATETIME(6))) AS s,
4           LEAST(COALESCE(h.ended_at, CAST(:now AS DATETIME(6))),
5                 CAST(:range_end AS DATETIME(6))) AS e
6    FROM history h
7    WHERE h.user_id = :user_id
8      AND h.started_at < :range_end
9      AND (h.ended_at > :range_start OR h.ended_at IS NULL)
10)
11SELECT website_id,
12       COUNT(*) AS visit_count,
13       SUM(TIMESTAMPDIFF(MICROSECOND, s, e)) AS stay_micros
14FROM source
15GROUP BY website_id;

조회 기간과 겹치는 기록을 고르고, 기간 밖의 체류 시간을 제외한 뒤 웹사이트별로 합산합니다. 실행 계획에서는 웹사이트 100개를 반환하기 위해 기록 60,480건을 읽는 것을 확인했습니다.

-> Sort: item_order (actual time=102..102 rows=100 loops=1)
    ...
    -> Materialize CTE raw_websites (actual time=101..101 rows=100 loops=1)
        ...
        -> Covering index range scan on h using idx_history_user_id_ended_at_statistics
           ... (actual time=0.306..25.6 rows=60480 loops=1)

(user_id, ended_at, started_at, website_id) 커버링 인덱스를 사용해도 합계를 구하려면 해당 기간의 기록을 읽어야 했습니다. 다른 통계 쿼리에서도 같은 기록을 다시 읽고 집계했습니다. 실행 계획은 부하 종료 후 별도로 수집했으므로, 여기서는 동시 요청의 지연이 아니라 처리하는 행 수를 확인했습니다.

DB 집계가 끝나기를 기다리는 커넥션

API 세 대의 커넥션은 총 30개인데, 화면 하나에서 요청 다섯 개가 병렬로 발생했습니다. 부하 중 커넥션 대기 요청은 최대 442개였고, 커넥션을 얻기까지 평균 10,687ms를 기다렸습니다. 획득한 커넥션은 평균 920ms 동안 점유했습니다.

DB는 CPU를 거의 모두 사용하며 집계를 처리하고 있었고, 뒤의 요청은 커넥션 풀에서 기다렸습니다. API 연산보다 반복되는 DB 집계와 그로 인한 커넥션 대기가 먼저 해결할 병목이었습니다.

문제 해결

첫 번째 방법: API를 하나로 통합하기

/dashboard API 하나에서 기존 집계 쿼리 다섯 개를 순서대로 호출하도록 변경했습니다. 하나의 읽기 전용 트랜잭션에서 실행하므로, 화면당 HTTP 요청과 커넥션 획득을 한 번으로 줄일 수 있었습니다.

지표분리 API + SQL 집계통합 API + SQL 집계
평균 응답 시간13,228ms13,221ms
p95 응답 시간16,384ms17,260ms
p99 응답 시간17,131ms18,179ms
API 평균 CPU 사용률4.0%2.1%
API 최대 메모리 사용률39.8% (1,833MiB)36.4% (1,675MiB)
DB 평균 CPU 사용률99.1%100.1%
DB 최대 메모리 사용률32.8% (1,341MiB)31.9% (1,308MiB)
실패율0% (0 / 858건)0% (0 / 844건)

커넥션 대기 요청은 최대 442개에서 70개로 줄었지만 평균 응답 시간은 거의 같았고, p95와 p99는 더 길어졌습니다. DB CPU도 여전히 할당량을 모두 사용했습니다.

API를 합쳐도 같은 기록을 읽고 집계하는 쿼리는 다섯 개 그대로였습니다. 쿼리 하나가 끝나면 커넥션을 반환하던 이전 방식과 달리, 통합 후에는 다섯 쿼리가 모두 끝나야 반환했습니다. 요청당 평균 커넥션 점유 시간도 920ms에서 4,453ms로 늘었습니다.

요청 수는 줄었지만 DB의 반복 집계 비용은 남았습니다. 이번에는 원본 기록을 한 번만 읽고 API에서 통계를 계산해 보기로 했습니다.

두 번째 방법: 원본을 한 번 조회해 애플리케이션에서 집계하기

해당 기간의 기록을 한 번 조회한 뒤 스크린 타임, 카테고리 분석 등 다섯 집계 함수에 전달했습니다. 각 함수는 같은 목록을 순회하며 합산하고 정렬합니다.

RawDashboardBenchmarkService.kt
1@Transactional(propagation = Propagation.NOT_SUPPORTED)
2fun read(userId: UUID, query: GetMyDashboardQuery): GetMyDashboardResult = with(query) {
3    val start = period.getStartedDate(date).atStartOfDay(timeZone.id).toInstant()
4    val end = start + period.amount
5    val now = clock.instant()
6    val sources = histories.findHistoriesWithWebsite(userId, start, end).map {
7        DashboardSource(it.domain, it.faviconUrl, it.category, maxOf(it.startedAt, start), minOf(it.endedAt ?: now, end))
8    }
9    dashboard.aggregate(sources, query, start, end)
10}

원본 조회를 마치면 커넥션을 반환하도록 서비스의 트랜잭션도 제거했습니다. 이후 집계는 커넥션을 점유하지 않고 수행합니다.

지표통합 API + SQL 집계통합 API + 애플리케이션 집계
평균 응답 시간13,221ms15,503ms
p95 응답 시간17,260ms20,168ms
p99 응답 시간18,179ms22,263ms
API 평균 CPU 사용률2.1%91.8%
API 최대 메모리 사용률36.4% (1,675MiB)79.7% (3,671MiB)
DB 평균 CPU 사용률100.1%36.9%
DB 최대 메모리 사용률31.9% (1,308MiB)28.3% (1,159MiB)
실패율0% (0 / 844건)0% (0 / 738건)

DB CPU 사용률은 36.9%로 낮아졌지만, API CPU는 91.8%, 최대 메모리는 할당량의 79.7%까지 늘었습니다. 평균과 p95, p99도 모두 길어졌습니다.

집계된 소수의 행을 받던 이전 방식과 달리, 이제는 기록 60,480건을 전달받아 객체로 만들어야 했습니다. 원본 조회의 실행 계획에서도 반환 행 수가 60,480건인 것을 확인했습니다. 조회가 끝난 뒤에도 각 집계 함수의 순회와 중간 컬렉션 생성은 남아 있었습니다.

GC 지표로 추정한 조회당 JVM 메모리 할당량은 약 1MiB에서 376MiB로 늘었습니다. 이는 조회 후 남은 메모리가 아니라 처리 중 생성된 객체의 누적 할당량입니다. 원본 전체를 전송하고 객체로 변환한 뒤 집계하는 과정이 API 자원 부담으로 이어졌습니다. 다만 CPU 프로파일링은 하지 않았으므로 객체 변환·집계·GC 중 어느 작업이 얼마나 차지했는지는 구분하지 않았습니다.

집계 위치를 옮겨도 화면을 열 때마다 과거 기록을 다시 계산한다는 문제는 남았습니다. 이미 종료된 날짜의 통계라면 계산 결과를 저장해 재사용할 수 있지 않을까요?

세 번째 방법: 일간 통계를 스냅샷으로 저장하기

사용자·시간대·날짜별 통계를 배치에서 계산해 statistics 테이블에 저장했습니다. 주간 조회에서는 최대 7일치 스냅샷을 합칩니다.

1CREATE TABLE statistics (
2    ...
3    user_id BINARY(16) NOT NULL,
4    time_zone VARCHAR(16) NOT NULL,
5    date DATE NOT NULL,
6    data LONGTEXT NOT NULL,
7    ...
8);
9
10CREATE UNIQUE INDEX uk_statistics_user_id_time_zone_date
11    ON statistics (user_id, time_zone, date);

일별 1위만 저장하면 주간 1위를 구할 수 없으므로, 웹사이트별 방문 횟수와 체류 시간의 합계를 저장했습니다. 조회할 때 이를 합쳐 순위를 구하고, 카테고리와 파비콘은 현재 웹사이트 정보를 반영합니다.

진행 중인 기록도 해당 날짜의 종료 경계까지 잘라 스냅샷에 포함합니다. 예를 들어 어제 23시 50분에 시작한 기록이 아직 종료되지 않았다면, 어제 스냅샷에는 자정까지의 10분을 저장합니다. 오늘 조회에서는 자정 이후 구간만 계산합니다. 원본 기록의 endedAt을 변경하는 것은 아닙니다.

DashboardService.kt
1histories = crossing.map {
2    DashboardStatistics.History(
3        id = requireNotNull(it.id),
4        domain = it.domain,
5        startedAt = maxOf(it.startedAt, start),
6        endedAt = minOf(it.endedAt ?: end, end)
7    )
8}

하루 안에 종료된 기록은 집계값으로 저장하고, 진행 중이거나 날짜 경계를 넘긴 기록은 ID와 잘린 구간을 함께 저장합니다. 날짜별 구간의 체류 시간은 합산하되, 같은 ID의 방문 횟수와 시간대별 활동은 중복 계산하지 않습니다. 조회할 때 원본에서 읽는 범위도 스냅샷이 없는 구간으로 제한합니다. 따라서 과거 날짜의 스냅샷이 모두 있으면 진행 중인 기록을 확인하기 위해 그 날짜의 원본을 다시 읽을 필요가 없습니다.

기록 종료 시각의 정확도와 허용 오차는 하트비트 처리가 담당합니다. 이후 하트비트에서 종료 시각을 보정하더라도 이미 저장한 구간은 유지하며, 그 오차를 보정하기 위해 스냅샷을 재생성하지 않습니다. 정기 배치도 이미 생성된 날짜는 건너뛰고 누락된 날짜만 생성합니다.

아래 성능 수치는 이 처리 변경 전, 날짜 경계의 기록을 원본에서 다시 조회하던 구현으로 측정한 결과입니다. 당시 주간 조회는 스냅샷 7건과 미집계 원본 7건을 불러왔습니다. 진행 중 구간을 저장하는 현재 구현은 기능 회귀 테스트를 수행했으며, 부하 성능은 아직 재측정하지 않았습니다.

지표통합 API + 애플리케이션 집계통합 API + 스냅샷
평균 응답 시간15,503ms324ms
p95 응답 시간20,168ms666ms
p99 응답 시간22,263ms863ms
API 평균 CPU 사용률91.8%101.6%
API 최대 메모리 사용률79.7% (3,671MiB)37.4% (1,725MiB)
DB 평균 CPU 사용률36.9%17.7%
DB 최대 메모리 사용률28.3% (1,159MiB)23.3% (956MiB)
실패율0% (0 / 738건)0% (0 / 27,440건)

평균 응답 시간은 324ms, p95는 666ms, p99는 863ms로 줄었습니다. DB CPU는 17.7%, API 최대 메모리는 37.4%로 낮아졌습니다. 원본 전체를 읽고 객체로 만드는 작업과 요청마다 반복하던 집계가 줄어든 결과입니다.

다만 API CPU는 여전히 할당량을 거의 모두 사용했습니다. 응답이 끝나면 다음 요청을 보내는 조건에서 처리량도 1,000ms당 5.55회에서 306.85회로 늘었기 때문입니다. DB 부하는 낮아지고 API CPU가 포화된 결과이므로, 추가 처리량을 확보하려면 API의 연산과 할당 비용을 살펴볼 필요가 있습니다. 어떤 함수가 다음 병목인지는 별도의 프로파일링이 필요합니다. 표본 수집 방식에 따라 CPU 사용률은 100%를 조금 넘을 수 있습니다.

회귀 테스트

최초 분리 API와 비교하면 주간 p95는 16,384ms에서 666ms로 약 95.9% 줄었습니다. 평균 커넥션 획득 대기도 10,687ms에서 91ms로 줄었으며, 네 방식 모두 HTTP 실패와 커넥션 획득 타임아웃은 없었습니다.

부하 중에는 응답의 체류 시간, 방문 횟수, 시간대별 활동 합계 등을 검사했습니다. 별도로 첫 번째·마지막 사용자의 주간 통계 전체 값을 API 세 대에서 비교했고, 배열 순서를 정규화한 결과가 모두 일치했습니다.

한계점

이번 테스트는 기록이 많은 사용자 100명이 종료된 과거 기록을 조회한 조건입니다. 운영의 일반적인 사용량이나 기록 생성과 조회가 겹치는 부하, 장시간 실행까지 검증한 결과는 아닙니다.

스냅샷 생성 비용은 조회 응답 시간에서 제외했습니다. 사용자 100명의 7일치 통계 700개를 생성하는 배치는 프로세스 시작을 포함해 약 100,000ms가 걸렸습니다. 하트비트에 따른 종료 시각 보정은 저장된 스냅샷에 소급 반영하지 않습니다. 과거 기록의 명시적인 수정·삭제까지 통계에 반영해야 한다면 별도의 갱신이 필요합니다. 스냅샷이 없는 구간에서는 원본 처리 비용이 남습니다.