Github, Notion 방문자 수 카운팅 서비스 : Hit Me Up
GitHub README와 Notion에 무료로 방문자 수 배지를 추가하는 방법. Hit Me Up 서비스 사용 가이드와 실전 예제를 소개합니다.
Hit Me Up ; URL 방문자 수 카운팅 서비스 (ver.1)
예전 Readme에 여러가지 뱃지들을 추가할 때 방문자 수를 보여주는 뱃지를 추가했었다. 그런데 최근 어느순간부터 뱃지가 제대로 나오지 않았다.
원래 hits.seeyoufarm.com 에서 뱃지를 만들었었는데 더이상 지원하지 않는지 표시가 안되었다.
구글링을 많이 해 보았지만 대체할만한 서비스가 없어서 직접 만들어보기로 했다.
프로젝트 구성 전
일단 프로젝트를 구성할 때 가장 먼저 생각했던 건 무료로 운영하고자 했다는 점이다. 여러 무료 호스팅 서비스를 살펴본 결과 Firebase 와 Google Cloud Run 을 선정했다.
정적인 단일 페이지 호스팅을 Firebase로, 백엔드 로직을 Cloud Run으로 배포해 두었고 아직 하는중이지만 도메인도 커스텀 매핑 하고자 한다.
익숙한 Java언어 대신 Kotlin공부차 Kotlin + Spring Boot 프로젝트로 백엔드를 구성했다. 정적인 웹 페이지는 html과 css로 간단하게 만들었다.
Database는 Firestore로 간단하게 사용하고자 했다. (NoSQL)
프로젝트 로직
메인 로직에 관해 간단하게 설명하고자 한다.
방문자 카운터의 핵심 기능은 URL마다 방문자 수를 카운팅하는 것이다.
- 사용자가 웹사이트에서 GitHub 페이지 URL과 배지 옵션(타이틀, 색상 등)을 설정
- 시스템은 해당 URL에 대한 마크다운 코드와 HTML 코드 생성
- 사용자는 생성된 마크다운 코드를 자신의 GitHub README 혹은 Notion에 붙여넣기
- 누군가 해당 GitHub 페이지 혹은 Notion 페이지를 방문할 때마다 배지 이미지를 요청하게 되고, 이때 카운터가 증가함
- SVG 형식으로 방문자 수 배지를 생성하여 반환
고민한 부분
Hit Me Up 서비스 페이지에서 뱃지를 생성하고 새로고침을 할 때 방문자수가 늘어선 안된다. 이를 미리보기라는 상태 (preview) 로 만들어 웹페이지에서는 뱃지 생성 및 DB 저장만 일어나고 카운트가 늘진 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@GetMapping("/count/increment", produces = ["image/svg+xml"])
fun getBadge(
@RequestParam("url") encodedUrl: String,
@RequestParam("count_bg", defaultValue = "#79C83D") countBg: String,
@RequestParam("title_bg", defaultValue = "#555555") titleBg: String,
@RequestParam("title", defaultValue = "hits") title: String,
@RequestParam("edge_flat", defaultValue = "false") edgeFlat: Boolean,
@RequestParam("preview", defaultValue = "false") preview: Boolean,
): ResponseEntity<String> {
val url = URLDecoder.decode(encodedUrl, StandardCharsets.UTF_8)
// preview 모드일때는 카운트 증가 안함. 현재 카운트 조회만 함.
val count = if (preview) {
hitsService.getHits(url)
} else {
hitsService.incrementHits(url)
}
// 배지 생성
val svg = SvgGenerator.generateSvg(title, formattedTitleBg, formattedCountBg, edgeFlat, count)
return ResponseEntity.ok()
.header("Cache-Control", "no-cache, no-store, must-revalidate")
.body(svg)
}
Firestore 데이터 관리
URL을 안전하게 저장하기 위해 Base64 인코딩을 사용했다
방문자 수 증가 로직은 트랜잭션 내에서 처리하여 동시성 문제를 방지했다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
fun incrementHits(url: String): Long {
val docId = getDocumentIdFromUrl(url)!!
val documentRef = firestore.collection(collection).document(docId)
try {
// 트랜잭션 내에서 카운터 증가
return firestore.runTransaction { transaction ->
val document = transaction.get(documentRef).get()
val currentCount = if (document.exists()) {
(document.getLong("count") ?: 0)
} else {
0
}
val newCount = currentCount + 1
// 새 데이터로 문서 업데이트
transaction.set(
documentRef,
mapOf(
"url" to url,
"count" to newCount,
"lastUpdated" to com.google.cloud.Timestamp.now()
)
)
newCount
}.get()
} catch (e: Exception) {
logger.error("방문자 수 증가 중 오류 발생", e)
return 1 // 기본값 반환
}
}
뱃지는 방문자 수를 사용해 SVG 뱃지를 생성했다. 그리고 이를 마크다운 형식과 html형식으로 붙여넣을 수 있도록 만들었다.
배포 과정
Cloud Run에 배포하기 위해 Dockerfile
과 CloudBuild.yaml
설정이 필요했다.
참고로 디폴트로 .yaml 을 인식하기 때문에 .yml 이라고 마음대로 만들면 인식이 안될수도있다. (경험담)
그리고 Firebase Hosting과 Cloud Run을 연결하기 위해 firebase.json
파일을 작성했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"hosting": {
"public": "public",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "/api/**",
"run": {
"serviceId": "hitmeup-backend",
"region": "asia-northeast1"
}
},
{
"source": "**",
"destination": "/index.html"
}
]
}
}
처음에는 리전을 서울 (asia-northeast1)
로 설정해 배포했다. 하지만 cloud run의 커스텀 도메인 매핑은 서울을 지원하지 않았다.
- 해당 문서 참조 : 커스텀 도메인 매핑
Hit Me Up은 Github 저장소에서 더 자세히 확인할 수 있다.
어려웠던 점
Firebase와 Cloud Run 연동 문제
Firebase Hosting으로 프론트엔드를, Cloud Run으로 백엔드를 배포했지만, 프론트엔드에서 백엔드 API를 호출할 때 CORS(Cross-Origin Resource Sharing) 오류가 발생했다. Firebase에서 어떻게 Cloud Run API를 적절히 라우팅할지에 대한 설정이 불분명했다.
- CORS 설정 추가: 백엔드 Spring Boot 애플리케이션에 CORS 필터를 추가했다.
- Firebase Hosting 리다이렉션 설정: firebase.json 파일에 리다이렉션 규칙을 추가하여
/api/**
경로로 오는 모든 요청을 Cloud Run 서비스로 전달하도록 구성했다.
Firestore 문서 ID 문제
처음에는 URL을 그대로 Firestore 문서 ID로 사용하려 했으나, Path should point to a Document Reference: hits 오류가 발생했다. Firestore는 문서 ID에 /, ., [, ]
등의 특수 문자를 허용하지 않기 때문이다.
- Base64 인코딩 사용: URL을 안전한 문서 ID로 변환하기 위해 Base64 인코딩을 사용하고, Firestore에서 사용할 수 없는 문자들을 대체했다.
- 트랜잭션 내 문서 존재 여부 확인: 문서가 존재하지 않을 경우 초기값으로 생성하는 로직을 트랜잭션 내에 구현했다.
도메인 매핑 문제
DuckDNS 에서 무료 도메인을 사용하려 했으나, Cloud Run 도메인 매핑은 여러 개의 A 레코드와 AAAA 레코드를 요구했다. 그러나 DuckDNS는 하나의 도메인에 단일 A 레코드만 설정할 수 있어 직접 도메인 매핑이 불가능했다.
- 리전 변경 시도: 먼저 서울 리전에서는 도메인 매핑 자체가 지원되지 않아 도쿄 리전으로 변경
- Firebase Hosting을 프록시로 사용: DuckDNS 도메인을 Cloud Run에 직접 연결하는 대신, Firebase Hosting을 프록시로 사용하는 방식으로 전환. Firebase Hosting은 단일 A 레코드 설정만으로도 작동한다.
- firebase.json 파일 설정: Firebase Hosting에서 Cloud Run 백엔드로 API 요청을 리다이렉트하도록 설정
- 마크다운 URL 수정: 기존에는 Cloud Run URL을 직접 사용하던 마크다운 코드를 Firebase Hosting URL을 사용하도록 변경
이 부분은 나중에 다시 따로 도메인을 구매한다면 다른 방법으로도 도전해 볼 계획이다.
배포 구성 파일 문제
Cloud Build를 사용하여 GitHub 저장소에서 자동 배포를 설정했으나, 배포할 때마다 빌드 실패나 서비스 시작 문제가 발생했다.
- Firebase 설정 문제 해결: Firebase 인증 정보를 Cloud Run에 전달하는 과정에서 문제가 발생. 여러 참고자료를 바탕으로 열심히 오류를 수정했다… 이런 설정 파트 는 처음 마주하면서 놓치는 부분이 생길 수 있어 항상 어려운 것 같다.
마치며
개발 초보라 기능도 간단하고 많이 부족한 서비스지만 꾸준히 개선하며 쭉 운영할 계획이다. 서비스를 중단하진 않을것이다. 빨리 도메인도 바꾸고 서버 스펙도 올리고 싶은데 운영 비용이 아깝다는 생각이 자꾸 들어서 쉽게 손이 안간다… 여러 일정으로 바쁜 시기지만 뿌듯했다.
구상중인 release v2에선 추가로 오늘 수 / 전체 수
형태도 지원하면 좋을 것 같다.