mainImage

미니 PC에 운영체제만 올려둔 서버를 바라보고 있으면 늘 비슷한 생각이 들었습니다. Docker도 올릴 수 있고, CI/CD도 붙일 수 있고, 모니터링 스택도 깔 수 있습니다. 그런데 정작 그 위에서 의미 있는 부하가 발생하지 않으면, 시스템을 운영한다는 감각은 좀처럼 생기지 않습니다.

Git Ranker는 그 문제를 해결하기 위해 시작한 프로젝트입니다. 겉으로는 GitHub 활동을 점수와 티어로 보여주는 서비스지만, 안쪽에서는 외부 API 수집, 배치 처리, 랭킹 재계산, 배지 캐싱, 모니터링이 함께 돌아갑니다. 이 글에서는 처음 기획한 문제의식과, 그 아이디어가 현재 코드베이스의 설계로 어떻게 구체화되었는지를 함께 정리해보려 합니다.

1. 텅 빈 홈랩에서 정말 배우고 싶었던 것

홈랩을 꾸린 이유는 단순히 서버 한 대를 갖고 싶어서가 아니었습니다. 저는 서비스가 실제로 돌아가며 남기는 흔적을 보고 싶었습니다.

개발 단계에서는 대개 기능이 정상 동작하는지에만 집중하게 됩니다. 하지만 운영 단계에서 중요한 질문은 조금 다릅니다.

  • 왜 CPU 사용량이 특정 시간대에만 치솟는가
  • 왜 외부 API 호출이 실패했을 때 일부 데이터만 갱신되는가
  • 왜 배치가 끝난 뒤 랭킹이 예상과 다르게 흔들리는가
  • 왜 응답 속도 저하가 애플리케이션 문제인지, DB 문제인지, 네트워크 문제인지 빠르게 구분할 수 없는가

이런 질문은 트래픽과 데이터가 실제로 쌓여야만 드러납니다. 결국 제게 필요했던 것은 예쁜 CRUD 화면이 아니라, 서버를 바쁘게 만들면서도 원인을 추적할 수 있는 애플리케이션이었습니다.

2. 왜 하필 Git Ranker였나

GitHub 잔디는 개발자의 꾸준함을 보여주기에는 좋은 지표입니다. 다만 잔디만으로는 활동의 맥락을 충분히 설명하기 어렵습니다. 오타 한 줄을 고친 커밋도 1 commit이고, 리뷰를 여러 차례 거쳐 머지된 PR도 결국 잔디 한 칸으로 보일 수 있기 때문입니다.

그래서 Git Ranker는 활동량이 아니라 기여의 성격을 조금 더 드러내는 방향으로 출발했습니다. 현재 코드 기준 점수 계산식은 아래와 같습니다.

점수 = commit*1 + issue*2 + review*5 + prOpened*5 + prMerged*8

이 주제를 고른 이유는 두 가지였습니다.

  • 접근성이 높습니다. GitHub를 쓰는 개발자라면 결과를 직관적으로 이해할 수 있고, 배지로 바로 공유할 수도 있습니다.
  • 내부 구현 밀도가 높습니다. 사용자에게 보이는 화면은 단순하지만, 뒤에서는 GitHub GraphQL 호출, 점수 계산, 랭킹 재산정, 배치 운영, 캐시, 관측성이 모두 필요합니다.

즉, Git Ranker는 홈랩에서 운영을 연습하기 위한 주제로 적당히 화려하면서도, 적당히 까다로운 프로젝트였습니다.

현재 구현으로 정리한 서비스 흐름

단계 현재 구현
사용자 등록 GitHub OAuth 로그인 후 가입 시점에 전체 활동을 수집
일일 갱신 매일 0시 KST에 Spring Batch Job 실행
점수 계산 이전 연도 기준선과 올해 활동을 합쳐 점수 재산정
랭킹 계산 DB의 Window Function으로 순위와 백분위 일괄 계산
배지 제공 SVG 배지를 별도 API로 서빙하고 1시간 캐시 적용
운영 관측 Prometheus, Loki, Promtail, Grafana를 Docker Compose로 구성

3. 기획이 실제 설계로 굳어진 방식

초기 메모에서는 “GitHub ID만 입력하면 바로 등록되는 서비스”를 먼저 떠올렸습니다. 하지만 구현을 진행하면서 몇 가지 설계는 그대로 남았고, 몇 가지는 운영 관점에서 크게 바뀌었습니다.

3.1 읽기와 쓰기를 분리하기 위해 OAuth를 붙였다

처음에는 로그인 없는 등록을 생각했습니다. 접근 장벽이 낮고, 초기 트래픽을 모으기 쉬워 보였기 때문입니다.

그런데 실제 서비스로 생각해보면 곧바로 문제가 생깁니다. 누가 누구의 계정을 등록할 수 있는지, 누가 수동 갱신을 실행할 수 있는지, 누가 삭제를 요청할 수 있는지를 구분할 방법이 없습니다.

그래서 현재 구현에서는 공개 조회는 열어두고, 상태를 바꾸는 동작만 인증을 요구하는 구조를 택했습니다.

.requestMatchers(
        "/api/v1/ranking/**",
        "/api/v1/users/{username}",
        "/api/v1/badges/**"
).permitAll()
.requestMatchers(
        "/api/v1/users/*/refresh",
        "/api/v1/auth/me",
        "/api/v1/users/me"
).authenticated()

이 구조 덕분에 전체 랭킹 조회, 공개 프로필 조회, 배지 조회는 누구나 할 수 있습니다. 반면 수동 전체 갱신과 회원 탈퇴는 본인만 수행할 수 있습니다. 처음의 “접근성” 목표를 완전히 버린 것이 아니라, 읽기 접근성과 쓰기 소유권을 분리해서 둘 다 가져가려는 방향으로 바뀐 셈입니다.

3.2 점수 파이프라인은 “전체 스캔 + 기준선 + 증분 갱신”으로 정리됐다

점수 시스템에서 가장 중요한 것은 계산식보다 어떤 범위의 데이터를 어떤 주기로 다시 볼 것인가였습니다.

현재 구현은 세 단계를 갖습니다.

  1. 가입 시점에는 GitHub 가입일부터 현재까지의 활동을 한 번에 수집합니다.
  2. 가입 연도가 현재 연도보다 이전이라면, 이전 연도까지의 누적 활동을 baseline으로 저장합니다.
  3. 이후 매일 배치에서는 올해 활동만 다시 조회하고, 필요하면 저장된 baseline과 합쳐 전체 점수를 재계산합니다.

이 구조는 단순히 “어제 하루만 더한다”보다 조금 무겁지만, 운영상 더 안전합니다. 과거 데이터는 기준선으로 고정하고, 변동 가능성이 큰 올해 데이터만 다시 보게 하면 누락 보정과 점수 일관성을 함께 가져갈 수 있기 때문입니다.

배치 Job도 이 전략에 맞춰 두 단계로 구성했습니다.

return new JobBuilder("dailyScoreRecalculationJob", jobRepository)
        .listener(gitHubCostListener)
        .start(scoreRecalculationStep())
        .next(rankingRecalculationStep())
        .build();

첫 번째 Step에서는 사용자별 점수를 다시 계산하고, 두 번째 Step에서는 전체 랭킹을 다시 계산합니다. 또한 Chunk 기반 처리와 재시도, Skip 정책을 함께 둬서 외부 API 오류가 전체 작업을 바로 무너뜨리지 않게 했습니다.

.<User, User>chunk(chunkSize, transactionManager)
.retry(GitHubApiRetryableException.class)
.retryLimit(3)
.skip(GitHubApiNonRetryableException.class)
.skipLimit(100)

여기서 기본 chunk-size100이고, 스케줄은 매일 0시 KST입니다. 홈랩처럼 리소스가 넉넉하지 않은 환경에서는, 한 번의 거대한 트랜잭션보다 잘게 끊긴 실패 가능 단위가 훨씬 다루기 쉽습니다.

3.3 GitHub API는 병렬로 읽되, Rate Limit을 운영 대상처럼 다룬다

GitHub 활동 수집은 이 프로젝트의 가장 비싼 작업입니다. 현재 구현은 GitHub GraphQL에서 연도별 데이터를 병렬로 조회하고, 머지된 PR 수는 별도 블록으로 수집해 합칩니다.

Flux<String> queries = Flux.concat(
        Flux.just(queryBuilder.buildMergedPRBlock(username)),
        Flux.fromStream(IntStream.rangeClosed(joinYear, currentYear).boxed())
                .map(year -> queryBuilder.buildYearlyContributionQuery(username, year, githubJoinDate))
);

중요한 점은 “빠르게 조회한다”보다 “운영 중에도 감당 가능하게 조회한다”였습니다. 그래서 토큰을 하나만 쓰지 않고 GitHubTokenPool로 관리하고, 남은 한도가 임계치 아래로 내려가면 경고를 남기거나 예외를 발생시키도록 만들었습니다.

이 프로젝트에서 GitHub API는 단순한 데이터 소스가 아니라, 비용과 실패 가능성을 가진 외부 시스템입니다. 그래서 호출 자체보다도, 호출을 어떤 속도와 예산으로 운영할지가 더 중요한 설계 포인트였습니다.

3.4 랭킹 재계산은 애플리케이션 루프보다 DB가 더 잘한다

점수가 계산된 뒤에는 순위와 티어를 다시 매겨야 합니다. 이 작업을 애플리케이션에서 사용자 수만큼 반복문으로 돌릴 수도 있지만, 현재 구현은 DB의 Window Function으로 한 번에 처리합니다.

RANK() OVER (ORDER BY total_score DESC) as new_rank,
CUME_DIST() OVER (ORDER BY total_score DESC) as new_percentile

그리고 이 백분위에 점수 임계값을 함께 적용해 IRON부터 CHALLENGER까지 티어를 계산합니다. 하위 티어는 절대 점수 기준으로, 상위 티어는 최소 점수와 상대 백분위를 함께 보는 하이브리드 구조입니다.

이 선택은 홈랩 환경에서 특히 의미가 있습니다. 랭킹 계산을 애플리케이션 메모리로 끌고 오지 않고 DB에 맡기면, 로직이 더 명확해지고 애플리케이션 코드도 단순해집니다. “무엇을 자바에서 풀고, 무엇을 DB에 맡길 것인가”라는 질문에 대한 하나의 답이기도 했습니다.

3.5 배지와 관측성까지 포함해야 비로소 서비스가 된다

Git Ranker를 단순 계산기로 만들고 싶지는 않았습니다. 결과를 외부에서 소비할 수 있어야 하고, 장애가 났을 때 원인을 추적할 수 있어야 했습니다.

그래서 현재 구현에는 두 가지가 함께 들어 있습니다.

  • 배지 API: /api/v1/badges/{nodeId} 로 SVG 배지를 서빙하고, 1시간 캐시를 둡니다.
  • 관측성 스택: Actuator 메트릭, Prometheus, Loki, Promtail, Grafana를 docker-compose.yml로 함께 운영합니다.

배지를 붙이면 읽기 트래픽이 생기고, 트래픽이 생기면 메트릭과 로그를 보고 싶어집니다. 즉, 공유 기능과 관측성은 별개 기능이 아니라 운영을 시작하게 만드는 장치에 가깝습니다.

4. 초안의 가설과 지금 구현이 달라진 지점

초기 기획 메모와 현재 코드를 나란히 놓고 보면, 무엇을 배우고 싶었는지는 비슷하지만 그 해법은 꽤 달라졌습니다.

항목 초기 가설 현재 구현 바뀐 이유
등록 방식 GitHub ID만 입력하는 무인증 등록 GitHub OAuth 로그인 후 등록 소유권 확인과 삭제/수동 갱신 권한 분리가 필요했기 때문
초기 데이터 범위 최근 1년 위주 집계 GitHub 가입일부터 전체 스캔 가입 직후에도 의미 있는 점수를 보여주고, 이후 배치 전략과 연결하기 위해
일일 갱신 방식 어제 하루 데이터만 누적 이전 연도 기준선 + 올해 전체 재조회 점수 일관성과 누락 보정, 운영 단순화를 함께 잡기 위해
핵심 관심사 배치와 인프라 학습 배치 + 인증 + Rate Limit + 관측성 실제 서비스를 운영하려면 문제들이 함께 따라왔기 때문

이 변화가 의미하는 바는 분명합니다. 처음에는 “배치를 잘 돌려보자”로 시작했지만, 구현을 진행할수록 주제는 “외부 의존성이 있는 서비스를 어떻게 운영 가능한 형태로 만들 것인가”로 확장됐습니다.

5. 이 프로젝트에서 배우고 싶은 것

Git Ranker를 통해 배우고 싶은 것은 여전히 분명합니다.

  • 대용량 사용자 데이터를 매일 안정적으로 갱신하는 배치 시스템
  • 외부 API의 실패와 Rate Limit을 감안한 데이터 수집 전략
  • 읽기 트래픽과 공유 기능을 고려한 API 및 캐시 설계
  • 메트릭, 로그, 대시보드를 갖춘 관측 가능한 홈랩 운영 방식

앞으로의 글에서는 이 주제를 더 구체적으로 파고들 생각입니다. 왜 Spring Batch를 선택했는지, GitHub GraphQL을 어떤 방식으로 조합했는지, 점수와 티어를 어떻게 계산하는지, 로그와 메트릭을 어떻게 운영 자산으로 바꿨는지를 차례대로 정리해보려 합니다.

6. 마치며

Git Ranker는 “재미로 보는 개발자 랭킹”이라는 가벼운 아이디어에서 출발했지만, 구현을 시작하고 나니 예상보다 훨씬 많은 운영 질문을 던지는 프로젝트가 됐습니다.

그래서 저는 이 프로젝트를 단순한 사이드 프로젝트라기보다, 홈랩 위에서 운영을 연습하기 위한 작은 실험장에 가깝게 보고 있습니다. 이 글은 그 실험의 출발점입니다. 다음 글부터는 이 기획이 실제 코드와 아키텍처로 바뀌는 과정을 하나씩 더 구체적으로 정리해보겠습니다.

댓글남기기