Post

Hit Me Up ; README.md 방문자 수 카운팅 서비스

Hit Me Up ; README.md 방문자 수 카운팅 서비스

Hit Me Up ; URL 방문자 수 카운팅 서비스 (ver.1)

hits

예전 Readme에 여러가지 뱃지들을 추가할 때 방문자 수를 보여주는 뱃지를 추가했었다. 그런데 최근 어느순간부터 뱃지가 제대로 나오지 않았다.

원래 hits.seeyoufarm.com 에서 뱃지를 만들었었는데 더이상 지원하지 않는지 표시가 안되었다.

구글링을 많이 해 보았지만 대체할만한 서비스가 없어서 직접 만들어보기로 했다.

프로젝트 구성 전

일단 프로젝트를 구성할 때 가장 먼저 생각했던 건 무료로 운영하고자 했다는 점이다. 여러 무료 호스팅 서비스를 살펴본 결과 FirebaseGoogle Cloud Run 을 선정했다.

정적인 단일 페이지 호스팅을 Firebase로, 백엔드 로직을 Cloud Run으로 배포해 두었고 아직 하는중이지만 도메인도 커스텀 매핑 하고자 한다.

익숙한 Java언어 대신 Kotlin공부차 Kotlin + Spring Boot 프로젝트로 백엔드를 구성했다. 정적인 웹 페이지는 html과 css로 간단하게 만들었다.

Database는 Firestore로 간단하게 사용하고자 했다. (NoSQL)

프로젝트 로직

메인 로직에 관해 간단하게 설명하고자 한다.

방문자 카운터의 핵심 기능은 URL마다 방문자 수를 카운팅하는 것이다.

  1. 사용자가 웹사이트에서 GitHub 페이지 URL과 배지 옵션(타이틀, 색상 등)을 설정
  2. 시스템은 해당 URL에 대한 마크다운 코드와 HTML 코드 생성
  3. 사용자는 생성된 마크다운 코드를 자신의 GitHub README에 붙여넣기
  4. 누군가 해당 GitHub 페이지를 방문할 때마다 배지 이미지를 요청하게 되고, 이때 카운터가 증가함
  5. 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에 배포하기 위해 DockerfileCloudBuild.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를 적절히 라우팅할지에 대한 설정이 불분명했다.

  1. CORS 설정 추가: 백엔드 Spring Boot 애플리케이션에 CORS 필터를 추가
  2. Firebase Hosting 리다이렉션 설정: firebase.json 파일에 리다이렉션 규칙을 추가하여 /api/** 경로로 오는 모든 요청을 Cloud Run 서비스로 전달하도록 구성

Firestore 문서 ID 문제

처음에는 URL을 그대로 Firestore 문서 ID로 사용하려 했으나, Path should point to a Document Reference: hits 오류가 발생했다. Firestore는 문서 ID에 /, ., [, ] 등의 특수 문자를 허용하지 않기 때문이다.

  1. Base64 인코딩 사용: URL을 안전한 문서 ID로 변환하기 위해 Base64 인코딩을 사용하고, Firestore에서 사용할 수 없는 문자들을 대체
  2. 트랜잭션 내 문서 존재 여부 확인: 문서가 존재하지 않을 경우 초기값으로 생성하는 로직을 트랜잭션 내에 구현

도메인 매핑 문제

DuckDNS 에서 무료 도메인을 사용하려 했으나, Cloud Run 도메인 매핑은 여러 개의 A 레코드와 AAAA 레코드를 요구했다. 그러나 DuckDNS는 하나의 도메인에 단일 A 레코드만 설정할 수 있어 직접 도메인 매핑이 불가능했다.

  1. 리전 변경 시도: 먼저 서울 리전에서는 도메인 매핑 자체가 지원되지 않아 도쿄 리전으로 변경
  2. Firebase Hosting을 프록시로 사용: DuckDNS 도메인을 Cloud Run에 직접 연결하는 대신, Firebase Hosting을 프록시로 사용하는 방식으로 전환. Firebase Hosting은 단일 A 레코드 설정만으로도 작동함
  3. firebase.json 파일 설정: Firebase Hosting에서 Cloud Run 백엔드로 API 요청을 리다이렉트하도록 설정
  4. 마크다운 URL 수정: 기존에는 Cloud Run URL을 직접 사용하던 마크다운 코드를 Firebase Hosting URL을 사용하도록 변경

이 부분은 나중에 다시 따로 도메인을 구매한다면 다른 방법으로도 도전해 볼 계획이다.

배포 구성 파일 문제

Cloud Build를 사용하여 GitHub 저장소에서 자동 배포를 설정했으나, 배포할 때마다 빌드 실패나 서비스 시작 문제가 발생했다.

  1. Firebase 설정 문제 해결: Firebase 인증 정보를 Cloud Run에 전달하는 과정에서 문제가 발생. 여러 참고자료를 바탕으로 열심히 오류를 수정함… 이런 설정 파트는 처음 마주하면서 놓치는 부분이 생길 수 있어 항상 어려운 것 같다.

마치며

개발 초보라 기능도 간단하고 많이 부족한 서비스지만 더 좋은 카운팅 서비스가 나오기 전까진 쓰려고 한다. 빨리 도메인도 바꾸고 서버 스펙도 올리고 싶은데 운영 비용이 아깝다는 생각이 자꾸 들어서 쉽게 손이 안간다… 여러 일정으로 바쁜 시기지만 뿌듯했다. 추가로 오늘 수 / 전체 수 형태도 지원하면 좋을 것 같다.

참고

This post is licensed under CC BY 4.0 by the author.