대시보드의 필요성
Issuefy 프로젝트를 진행하면서 기존 홈 화면을 개선할 필요가 있다고 느꼈습니다. 기존 홈 화면은 즐겨찾기 한 리포지토리와 이슈만을 표시하고 있어, 사용자에게 제공하는 정보가 제한적이었습니다. 또한 홈 화면에 필요한 핵심적인 구성요소가 부족하다고 판단했습니다. 이러한 문제를 해결하고 사용자 경험을 향상하기 위해 사용자 맞춤 대시보드를 추가하기로 결정했습니다.
접근 및 구현계획
대시보드 구현을 위해 두 가지 접근 방식을 검토했습니다.
RDB 직접 쿼리 방식
이 방식은 데이터베이스에서 필요한 정보를 직접 추출하여 대시보드를 구성합니다.
SELECT
u.id,
COUNT(DISTINCT l.login_date) as visit_count,
COUNT(r.id) as repo_add_count
FROM users u
LEFT JOIN logins l ON u.id = l.user_id AND l.login_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
LEFT JOIN repositories r ON u.id = r.user_id AND r.created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY u.id
- 변경에 취약함: DB 테이블 구조나 대시보드 표시 수치 변경 시 SQL과 애플리케이션 코드를 모두 수정해야 함
- 유연성 부족: 쿼리 결과를 서비스 레이어에서 추가 계산해야 하므로, 요구사항 변경 시 여러 계층의 코드 수정 필요
- 데이터 활용도 저하: 이미 수집된 로그 데이터를 활용하지 않아 중복 저장 문제 발생
로그 쿼리 방식
이 방식은 애플리케이션에서 생성된 로그를 분석하여 대시보드 정보를 구성합니다.
sum(count_over_time({job="issuefylog"} |= "GET /api/login" [7d])) by (user_id)
sum(count_over_time({job="issuefylog"} |= "POST /api/subscriptions" [7d])) by (user_id)
- 높은 유연성: 요구사항 변경 시 LogQL 쿼리 수정만으로 빠른 대응 가능
- 데이터 활용도 증가: 기존 로그 데이터의 효과적 활용
- 실시간 정보 제공: 지속적으로 기록되는 로그를 통한 실시간 정보 반영
비교 결과, 로그 쿼리 방식이 요구사항 변경에 더 유연하게 대응할 수 있고, 기존 로그 데이터를 효과적으로 활용할 수 있다고 판단하여 이 방식을 선택했습니다. 로그 분석 도구로는 이미 사용 중이던 Loki의 LogQL을 선택했습니다. Elasticsearch 같은 강력한 도구도 있었지만, LogQL이 간단한 대시보드 구현에 충분한 기능을 제공한다고 판단했기 때문입니다.
세부 계획
현재 Issuefy의 로그는 아래와 같은 형식으로 수집되고 있으며, 이를 통해 다음과 같은 주요 메트릭을 추적할 수 있습니다.
2024-09-05 18:31:16.976 [http-nio-8080-exec-9] [0a2240d7] [INFO] [0fb0b345] [lv*******e6] s.i.i.c.AuthenticationController - Response: 200 GET /api/login - Method: login
2024-09-05 18:31:16.976 [http-nio-8080-exec-7] [0a2240d7] [INFO] [3a3d7e22] [lv*******e6] s.i.i.c.SubscriptionController - Request: POST /api/subscriptions - Method: addRepository
- 사용자의 로그인 활동 (GET /api/login)
- 리포지토리 추가 활동 (POST /api/subscriptions)
이러한 로그 데이터를 기반으로 사용자별 맞춤 대시보드를 구성하기 위해 다음과 같은 구현 계획을 수립했습니다.
- 배치 위치
- 사용자의 시선을 가장 먼저 끌 수 있도록 대시보드를 즐겨찾기 목록 상단에 배치하기로 했습니다. 이를 통해 사용자가 자신의 활동 정보를 쉽게 확인할 수 있게 됩니다.
- 표시 정보
- 주간 랭크: 사용자의 활동 수준을 한눈에 파악할 수 있는 지표
- 주간 방문 수: 일주일 동안 사용자가 얼마나 자주 서비스를 이용했는지 보여주는 지표
- 주간 리포지토리 추가 수: 사용자의 적극적인 참여도를 나타내는 지표
- 랭크 산정 방식
- 랭크는 방문 수와 리포지토리 추가 수에 가중치를 적용하고 정규화 과정을 거쳐 계산합니다. 이를 통해 S, A, B, C, D 등급으로 사용자의 활동을 분류합니다.
- 시각적 피드백
- 사용자의 랭크에 따라 Issuefy 로고의 그라데이션 색상을 적용하여 D 랭크는 보라색에 가깝게, S 랭크로 갈수록 파란색에 가까워지도록 설계했습니다.
구현 상세
대시보드 데이터 요청 처리
클라이언트에서 홈 화면이 렌더링될 때 대시보드를 요청하면, getDashBoardFromLoki 메서드가 호출됩니다. 현재 날짜와 일주일 전 날짜를 계산하고, Loki에 쿼리를 보내 방문 횟수와 리포지토리 추가 횟수를 조회합니다.
이를 바탕으로 랭크를 계산하고 응답을 구성합니다.
public DashBoardResponse getDashBoardFromLoki(String githubId) {
ErrorCode lokiError = ErrorCode.LOKI_EXCEPTION;
LocalDate today = LocalDate.now();
LocalDate startWeek = today.minusDays(MINUS_DAYS);
String visitCount = Optional.ofNullable(getNumberOfWeeklyVisit(githubId, lokiError, startWeek, today))
.map(LokiQueryVisitDto::getVisitCount)
.orElse("0");
String addRepositoryCount = Optional.ofNullable(
getNumberOfWeeklyRepositoryAdded(githubId, lokiError, startWeek, today))
.map(LokiQueryAddRepositoryDto::getAddRepositoryCount)
.orElse("0");
String rank = calculateRank(visitCount, addRepositoryCount);
return DashBoardResponse.of(startWeek, today, rank, visitCount, addRepositoryCount);
}
Loki 쿼리 실행
WebClient를 이용해 Loki에 실제로 쿼리를 보내는 메서드입니다. 현재 로그에 사용자의 GithubId는 마스킹 처리가 되어있기 때문에 마스킹 처리하여 쿼리에 포함시킵니다. 방문 횟수와 리포지토리 추가 횟수를 각각 조회하는 두 개의 쿼리 메서드가 있습니다.
private LokiQueryVisitDto getNumberOfWeeklyVisit(String githubId, ErrorCode lokiError, LocalDate startWeek,
LocalDate endWeek) {
String maskGithubId = JwtAuthenticationFilter.maskId(githubId);
String rawLokiQuery = String.format(LokiQuery.NUMBER_OF_WEEKLY_VISIT.getQuery(), maskGithubId, startWeek,
endWeek);
try {
return webClient.get()
.uri(uriBuilder -> uriBuilder.path("/loki/api/v1/query")
.queryParam("query", "{query}")
.build(rawLokiQuery))
.retrieve()
.bodyToMono(LokiQueryVisitDto.class)
.block();
} catch (Exception e) {
throw new LokiException(lokiError.getMessage(), lokiError.getStatus());
}
}
private LokiQueryAddRepositoryDto getNumberOfWeeklyRepositoryAdded(String githubId, ErrorCode lokiError,
LocalDate startWeek, LocalDate endWeek) {
String maskGithubId = JwtAuthenticationFilter.maskId(githubId);
String rawLokiQuery = String.format(LokiQuery.NUMBER_OF_WEEKLY_REPOSITORY_ADDED.getQuery(), maskGithubId,
startWeek, endWeek);
try {
return webClient.get()
.uri(uriBuilder -> uriBuilder.path("/loki/api/v1/query")
.queryParam("query", "{query}")
.build(rawLokiQuery))
.retrieve()
.bodyToMono(LokiQueryAddRepositoryDto.class)
.block();
} catch (Exception e) {
throw new LokiException(lokiError.getMessage(), lokiError.getStatus());
}
}
LogQL 쿼리 관리
쿼리 수정이 필요할 때 enum만 수정하면 되도록 쿼리를 enum으로 정의하여 관리합니다.
public enum LokiQuery {
NUMBER_OF_WEEKLY_VISIT(
"sum(count_over_time({job=\"issuefylog\"} |= \"[%s]\" |= \"Response: 200 GET /api/login - Method: login \" [6d]))"),
NUMBER_OF_WEEKLY_REPOSITORY_ADDED(
"sum(count_over_time({job=\"issuefylog\"} |= \"[%s]\" |= \"Request: POST /api/subscriptions - Method: addRepository \" [6d]))");
private final String query;
}
랭크 계산
랭크 계산 방문 횟수와 리포지토리 추가 횟수를 기반으로 사용자의 랭크를 계산합니다.
각 지표에 가중치를 적용하고, 로그 스케일 변환과 정규화 과정을 거쳐 최종 점수를 산출합니다.
현재는 방문횟수에 35%, 리포지토리 추가 횟수에 65% 가중치를 적용하고 있습니다.
이 점수를 바탕으로 S부터 D까지의 랭크를 부여합니다.
private static final int MAX_SCORE = 100;
private static final double VISIT_WEIGHT = 0.35;
private static final double REPOSITORY_WEIGHT = 0.65;
private static final int MINUS_DAYS = 6;
....
private String calculateRank(String visitCount, String addRepositoryCount) {
int score = calculateScore(visitCount, addRepositoryCount);
return DashBoardRank.getRankLabel(score);
}
private int calculateScore(String visitCount, String addRepositoryCount) {
double visits = Math.log(Double.parseDouble(visitCount) + 1) / Math.log(2);
double addedRepos = Math.log(Double.parseDouble(addRepositoryCount) + 1) / Math.log(2);
// 정규화 과정
double normalizedVisits = Math.min(visits / 10, 1);
double normalizedRepos = Math.min(addedRepos / 7, 1);
double rawScore = (normalizedVisits * VISIT_WEIGHT + normalizedRepos * REPOSITORY_WEIGHT) * MAX_SCORE;
return (int)Math.min(rawScore, MAX_SCORE);
}
....
public enum DashBoardRank {
S(80, "S"),
A(60, "A"),
B(40, "B"),
C(20, "C"),
D(0, "D");
private final int threshold;
private final String label;
public static String getRankLabel(int score) {
for (DashBoardRank rank : values()) {
if (score >= rank.threshold) {
return rank.label;
}
}
return D.label;
}
}
응답 구성
계산된 결과를 바탕으로 JSON 형태의 응답을 구성합니다. 시작 날짜, 종료 날짜, 랭크, 방문 횟수, 리포지토리 추가 횟수가 포함됩니다.
{
"startDate": "2024-08-30",
"endDate": "2024-09-05",
"rank": "D",
"visitCount": "0",
"addRepositoryCount": "1"
}
구현 결과
이렇게 구현한 결과, 대시보드의 기능성과 사용자 경험이 개선되었습니다.
기존의 로그 데이터를 의미 있는 형태로 가공하여 활용도를 높였고, 이를 통해 사용자가 자신의 랭크와 활동을 확인할 수 있게 함으로써 서비스 이용에 재미 요소를 더했습니다.
개선점
현재 구현된 대시보드에는 몇 가지 개선이 필요한 부분이 있습니다.
데이터 다양성 확대 및 로깅 전략 개선
현재 대시보드에서 제공되는 정보의 다양성과 깊이가 부족한 상황입니다. 글로벌 로거가 컨트롤러 레이어의 요청-응답과 서비스 레이어의 예외 발생 시에만 로그를 기록하고 있어, 사용자에게 특별히 의미 있는 데이터를 제공하기 어렵습니다.
또한, 현재의 GithubId 마스킹 전략(앞 2자리, 뒷 2자리만 유지)은 유사한 아이디를 가진 사용자들의 데이터가 함께 쿼리 되어 부정확한 결과를 초래할 수 있습니다.
이러한 문제를 해결하기 위해 로깅 전략을 수정하여 더 다양하고 세분화된 로그를 수집하고, 마스킹 방식을 해시코드 기반으로 전환할 계획입니다.
대시보드 쿼리 성능 최적화
대시보드 정보의 로딩 속도에 상당한 개선의 여지가 있습니다. 현재 즐겨찾기 등의 다른 정보는 약 20ms 내에 로드되는 반면, 대시보드 정보는 약 900ms가 소요되어 상대적으로 느린 편입니다.
이러한 속도 차이는 대시보드 정보를 얻기 위해 별도의 모니터링 인스턴스에 위치한 Loki 컨테이너에 쿼리 요청을 보내고, 그 결과를 API 서버에서 재가공하는 과정을 거치기 때문입니다. 반면 다른 정보들은 RDS 엔드포인트를 통한 연결로 쿼리가 상대적으로 빠르게 제공됩니다.
이러한 성능 차이를 해결하기 위해, 병목 지점을 정확히 파악하고 그 결과를 바탕으로 쿼리 최적화나 캐싱 도입 등 여러 방식을 고민하고 적용하여 성능을 개선할 계획이 있습니다.
'Issuefy' 카테고리의 다른 글
[Issuefy] Jmeter를 사용한 성능 테스트 및 스케일링 분석 (0) | 2024.09.23 |
---|---|
[Issuefy] Server-Sent Events를 이용한 실시간 알림 기능 도입기 (4) | 2024.09.13 |
[Issuefy] 의심스러운 로그 탐지 및 대응기 (3) | 2024.06.06 |
[Issuefy] 통합 모니터링 도입기 (2) | 2024.06.05 |