대시보드 API 통합과 커넥션 풀 병목
대시보드 조회 API 최적화 중에 병렬 호출로 발생한 커넥션 풀 경합을 하나의 기록 조회와 애플리케이션 집계로 개선하기
대시보드에는 다음 다섯 가지 통계가 표시됩니다.
- 시간대별 스크린타임
- 카테고리별 사용 시간
- 자주 방문한 웹사이트
- 시간대별 작업 패턴
- 가장 오래 머문 웹사이트
초기에는 각 기능을 별도 API로 제공했습니다.
기능별 책임이 명확하고, 클라이언트가 필요한 통계만 선택해서 호출할 수 있다는 장점이 있었습니다.
GET /v1/users/me/screen-timesGET /v1/users/me/category-analysesGET /v1/users/me/frequently-visited-websitesGET /v1/users/me/work-patternGET /v1/users/me/longest-stayed-website
대시보드 화면에서는 다섯 통계가 모두 필요했으므로 클라이언트는 Promise.all()로 동시에 호출했습니다.
await Promise.all([
getScreenTimes(),
getCategoryAnalyses(),
getFrequentlyVisitedWebsites(),
getWorkPattern(),
getLongestStayedWebsite()
])사용자 한 명일 때는 일부 카드가 먼저 표시될 수 있고 응답 시간도 짧았습니다.
하지만 동시 사용자가 증가하자 대시보드의 P95 지연 시간이 급격히 늘어났습니다.
논리 요청 하나가 DB 작업 다섯 개가 되는 구조
사용자가 대시보드를 한 번 열 때 HTTP 요청 5개가 동시에 발생합니다.
사용자 100명이 동시에 접속하면 애플리케이션은 짧은 순간에 500개 요청을 받습니다.
각 API는 독립된 트랜잭션에서 서로 비슷한 기간의 history를 조회하고 집계했습니다.
병렬 호출은 작업 자체를 줄이지 않습니다.
오히려 한 사용자가 동시에 여러 커넥션을 요구하므로 HikariCP의 활성 커넥션이 빠르게 최대치에 도달했습니다.
풀이 비면 나머지 요청은 대기 상태로 커넥션이 반환되기를 기다렸고, 커넥션 획득 시간이 API 지연에 포함되었습니다.
CPU와 메모리 사용량도 함께 확인했습니다.
리소스가 완전히 고갈된 상태는 아니었고, 응답 지연이 커지는 구간에서 Hikari 대기 요청과 커넥션 획득 시간이 함께 상승했습니다.
따라서 단순히 CPU Limit이나 커넥션 풀 크기를 늘리기보다, 한 화면 조회가 만드는 DB 작업 수부터 줄이기로 했습니다.
API만 합치면 충분한가
컨트롤러에서 기존 서비스 다섯 개를 순서대로 호출하는 통합 API도 생각할 수 있습니다.
fun getDashboard(query: GetDashboardQuery) =
DashboardResult(
screenTime = getScreenTime(query),
category = getCategoryAnalyses(query),
frequentlyVisited = getFrequentlyVisitedWebsites(query),
workPattern = getWorkPattern(query),
longestStayed = getLongestStayedWebsite(query)
)이 방식은 HTTP 요청을 하나로 줄이지만 DB 쿼리는 여전히 다섯 번 실행합니다.
순차 실행하면 한 요청이 커넥션을 동시에 여러 개 요구하지는 않지만, 같은 기간의 기록을 반복해서 읽고 각 쿼리의 집계 비용도 그대로 남습니다.
반대로 다섯 서비스를 서버 내부에서 스레드별로 병렬 실행하면 HTTP 요청만 하나일 뿐, 커넥션 풀에는 기존과 비슷한 부하를 만듭니다.
트랜잭션과 JDBC 커넥션은 일반적으로 스레드에 바인딩되므로 여러 스레드가 하나의 커넥션을 공유하지 않습니다.
통합의 목표를 HTTP 개수 감소가 아니라 중복 데이터 조회 제거로 잡았습니다.
기록을 한 번만 조회하기
선택한 기간과 겹치는 기록을 웹사이트 정보와 함께 한 번 조회합니다.
현재 구현의 핵심 조회는 CustomHistoryRepositoryImpl.findDashboardHistories()에 모았습니다.
override fun findDashboardHistories(
userId: UUID,
startedAt: Instant,
endedAt: Instant
): List<DashboardHistoryProjection> {
val visitedAt =
greatest(HISTORY.VISITED_AT, startedAt)
.`as`(DashboardHistoryProjection::visitedAt)
val closedAt =
least(HISTORY.CLOSED_AT, endedAt)
.`as`(DashboardHistoryProjection::closedAt)
return dsl
.select(
HISTORY.WEBSITE_ID,
WEBSITE.DOMAIN,
WEBSITE.FAVICON_URL,
WEBSITE.CATEGORY,
visitedAt,
closedAt
)
.from(HISTORY)
.join(WEBSITE)
.on(WEBSITE.ID.equal(HISTORY.WEBSITE_ID))
.where(
HISTORY.USER_ID.equal(userId)
.and(HISTORY.VISITED_AT.lessThan(endedAt))
.and(HISTORY.CLOSED_AT.greaterThan(startedAt))
)
.orderBy(HISTORY.VISITED_AT)
.fetchInto()
}GREATEST(visited_at, startedAt)과 LEAST(closed_at, endedAt)으로 기간 경계를 넘는 기록을 SQL 결과에서 잘라냅니다.
애플리케이션은 모든 기록이 요청 범위 안에 있다는 전제로 집계할 수 있습니다.
이 조회 결과를 다섯 집계 함수가 공유합니다.
외부 트랜잭션을 길게 잡지 않도록 대시보드 조회 메서드는 Propagation.NOT_SUPPORTED로 실행했습니다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
fun getMyDashboard(
userId: UUID,
query: GetMyDashboardQuery
): GetMyDashboardResult =
with(query) {
val startedAt = period.getStartedDate(date).atStartOfDay(timeZone.id).toInstant()
val endedAt = startedAt + period.amount
val histories =
historyRepository.findDashboardHistories(
userId = userId,
startedAt = startedAt,
endedAt = endedAt
)
GetMyDashboardResult(
getScreenTimeResult =
getScreenTime(
GetScreenTimeQuery(
screenTimeUnit = period.screenTimeUnit,
startedAt = startedAt,
endedAt = endedAt,
histories = histories
)
),
getCategoryAnalysesResult =
getCategoryAnalyses(GetCategoryAnalysisQuery(histories)),
getFrequentlyVisitedWebsitesResult =
getFrequentlyVisitedWebsites(GetFrequentlyVisitedWebsitesQuery(histories)),
getWorkPatternResult =
getWorkPattern(
GetWorkPatternQuery(
timeZone = timeZone,
histories = histories
)
),
getLongestStayedWebsiteResult =
getLongestStayedWebsite(GetLongestStayedWebsiteQuery(histories))
)
}애플리케이션 집계를 선택한 이유
기존에는 데이터베이스가 기능별 GROUP BY, SUM, COUNT, 시간대 변환을 각각 수행했습니다.
DB 집계는 전송 행을 줄이고 대량 데이터에서 효율적일 수 있습니다.
하지만 이 화면에서는 같은 사용자와 같은 기간의 비교적 제한된 기록을 다섯 방식으로 반복 집계한다는 특성이 있었습니다.
한 번 가져온 histories를 메모리에서 재사용하면 다음 이점이 있습니다.
- 기간 및 타임존 경계 계산을 한 코드 경로에서 처리한다.
- 다섯 쿼리가 각각 다른 경계 조건을 사용해 통계가 어긋나는 문제를 줄인다.
- MySQL timezone table이나 DB별 날짜 함수에 대한 의존도를 낮춘다.
- 기능이 추가되어도 같은 원본 기록을 다시 조회하지 않는다.
시간 복잡도는 집계별로 대부분 O(N)입니다.
웹사이트와 카테고리별 그룹화는 HashMap을 사용하고, 결과 정렬은 고유 웹사이트 수를 W라 할 때 O(WlogW)입니다.
다섯 집계를 독립적으로 순회하므로 전체는 상수 배수가 붙은 O(N)이며, 조회된 기록 목록도 O(N) 메모리를 사용합니다.
사용자가 한 기간에 수십만 건의 기록을 갖는다면 DB 집계나 사전 계산 스냅샷이 더 적합할 수 있습니다.
현재 조회 범위와 데이터 분포에서는 중복 쿼리를 제거하는 이점이 더 컸습니다.
성능 테스트
로컬 환경에서 사용자 100명을 만들고, 사용자마다 웹사이트가 분산된 기록 100건을 저장했습니다.
워밍업 이후 100명이 동시에 한 번씩 대시보드를 조회하도록 했습니다.
기존 방식은 한 논리적 대시보드 조회마다 API 5개를 Promise.all()처럼 병렬 호출했고, 변경 방식은 통합 API 하나를 호출했습니다.
| 측정 항목 | 기존 | 변경 |
|---|---|---|
| 논리 조회당 HTTP 요청 | 5건 | 1건 |
| P95 응답 시간 | 286.0ms | 60.0ms |
기존 방식의 지연 시간은 다섯 요청 중 마지막 응답이 도착한 시점을 논리적 대시보드 완료 시간으로 측정했습니다.
개별 API 하나의 응답 시간이 아니라 사용자가 화면의 모든 통계를 볼 수 있게 되는 시점을 비교한 것입니다.
통합 후에는 동시에 생성되는 HTTP 요청과 DB 커넥션 획득 경쟁이 5분의 1로 줄고, 기간 기록 조회도 다섯 번에서 한 번으로 줄었습니다.
그래서 단일 사용자의 작은 데이터에서는 차이가 크지 않아도, 동시 사용자 테스트에서는 P95가 286.0ms에서 60.0ms로 79.0% 감소했습니다.
UX에서의 Trade-off
기존 방식은 빠르게 끝난 카드부터 화면에 표시할 수 있습니다.
통합 API는 모든 집계가 끝나야 하나의 응답을 반환하므로 부분 렌더링에는 불리합니다.
이번 대시보드는 다섯 통계가 같은 날짜와 기간을 기준으로 한 세트이며, 화면 진입 시 항상 함께 사용됩니다.
실제 병목도 개별 집계 연산보다 요청 증폭과 커넥션 풀 경합에서 나타났습니다.
그래서 부분 표시보다 전체 화면의 안정적인 완료 시간을 우선했습니다.
일부 통계가 선택 기능으로 바뀌거나 하나의 집계만 유독 오래 걸리게 된다면 다음 구조를 다시 검토할 수 있습니다.
- 핵심 통계와 부가 통계를 분리한 2개의 API
- 서버 전송 이벤트를 통한 점진 응답
- 전날까지의 통계를 미리 계산한 스냅샷과 오늘 데이터의 결합
정리
이 개선은 컨트롤러 엔드포인트 다섯 개를 하나로 합친 것에 그치지 않습니다.
| 구분 | HTTP 요청 | 기간 조회 | 집계 방식 |
|---|---|---|---|
| 개선 전 | 5개 | 5회 | DB에서 기능별 집계 |
| 개선 후 | 1개 | 1회 | 애플리케이션에서 5개 집계 |
커넥션 풀 크기를 키우는 것은 대기 시점을 늦출 수 있지만, 한 화면이 만드는 중복 작업은 그대로입니다.
먼저 논리적 사용자 요청 하나가 시스템 내부에서 몇 개의 HTTP 요청과 DB 작업으로 증폭되는지 줄였고, 그 결과 100명 동시 테스트의 P95를 286ms에서 60ms로 개선할 수 있었습니다.