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마다 방문자 수를 카운팅하는 것이다.
고민한 부분
Hit Me Up 서비스 페이지에서 뱃지를 생성하고 새로고침을 할 때 방문자수가 늘어선 안된다. 이를 미리보기라는 상태 (preview) 로 만들어 웹페이지에서는 뱃지 생성 및 DB 저장만 일어나고 카운트가 늘진 않는다.
@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)
}
URL을 안전하게 저장하기 위해 Base64 인코딩을 사용했다
방문자 수 증가 로직은 트랜잭션 내에서 처리하여 동시성 문제를 방지했다
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 설정이 필요했다.
그리고 Firebase Hosting과 Cloud Run을 연결하기 위해 firebase.json 파일을 작성했다.
{
"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 Hosting으로 프론트엔드를, Cloud Run으로 백엔드를 배포했지만, 프론트엔드에서 백엔드 API를 호출할 때 CORS(Cross-Origin Resource Sharing) 오류가 발생했다. Firebase에서 어떻게 Cloud Run API를 적절히 라우팅할지에 대한 설정이 불분명했다.
/api/** 경로로 오는 모든 요청을 Cloud Run 서비스로 전달하도록 구성했다.처음에는 URL을 그대로 Firestore 문서 ID로 사용하려 했으나, Path should point to a Document Reference: hits 오류가 발생했다. Firestore는 문서 ID에 /, ., [, ] 등의 특수 문자를 허용하지 않기 때문이다.
DuckDNS 에서 무료 도메인을 사용하려 했으나, Cloud Run 도메인 매핑은 여러 개의 A 레코드와 AAAA 레코드를 요구했다. 그러나 DuckDNS는 하나의 도메인에 단일 A 레코드만 설정할 수 있어 직접 도메인 매핑이 불가능했다.
이 부분은 나중에 다시 따로 도메인을 구매한다면 다른 방법으로도 도전해 볼 계획이다.
Cloud Build를 사용하여 GitHub 저장소에서 자동 배포를 설정했으나, 배포할 때마다 빌드 실패나 서비스 시작 문제가 발생했다.
마치며
개발 초보라 기능도 간단하고 많이 부족한 서비스지만 꾸준히 개선하며 쭉 운영할 계획이다. 서비스를 중단하진 않을것이다. 빨리 도메인도 바꾸고 서버 스펙도 올리고 싶은데 운영 비용이 아깝다는 생각이 자꾸 들어서 쉽게 손이 안간다... 여러 일정으로 바쁜 시기지만 뿌듯했다.
구상중인 release v2에선 추가로 오늘 수 / 전체 수 형태도 지원하면 좋을 것 같다.
저도 오류가 나서 해결하려고 찾아보던 중이였는데..
이미 멋지게 만들어 주셨군요!
제 깃허브에도 적용했어요 :D