Hit Me Up ; README.md 방문자 수 카운팅 서비스
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에 붙여넣기
- 누군가 해당 GitHub 페이지를 방문할 때마다 배지 이미지를 요청하게 되고, 이때 카운터가 증가함
- SVG 형식으로 방문자 수 배지를 생성하여 반환
고민한 부분
페이지에서 뱃지를 생성하고 새로고침을 할 때 방문자수가 늘어선 안된다. 이를 미리보기라는 상태 (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에 전달하는 과정에서 문제가 발생. 여러 참고자료를 바탕으로 열심히 오류를 수정함… 이런 설정 파트는 처음 마주하면서 놓치는 부분이 생길 수 있어 항상 어려운 것 같다.
마치며
개발 초보라 기능도 간단하고 많이 부족한 서비스지만 더 좋은 카운팅 서비스가 나오기 전까진 쓰려고 한다. 빨리 도메인도 바꾸고 서버 스펙도 올리고 싶은데 운영 비용이 아깝다는 생각이 자꾸 들어서 쉽게 손이 안간다… 여러 일정으로 바쁜 시기지만 뿌듯했다.
추가로 오늘 수 / 전체 수
형태도 지원하면 좋을 것 같다.