jjunhub

URL 단축기 서버 설계하기 - 2편 본문

Architecture

URL 단축기 서버 설계하기 - 2편

jjunhub 2025. 4. 19. 19:26

부제 : [가상 면접 사례로 배우는 대규모 시스템 설계 기초] 8장 실습 - 2
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              

개요

앞서 작성한 URL 단축기 서버 설계하기 - 1편에 대한 서버 구현 시에 발생했던 이슈들을 작성해볼 예정이다. 서비스 설명만 이전에 있던 내용들을 다시 가져왔다.

 

 

구현 결과 레포지토리

https://github.com/jjunhub/short-url-generator

 

서비스 설명

요약

사용자가 입력한 원본 URL을 단축된 URL로 변경하여 제공하는 서비스이다. 이 서비스는 하루에 10억명의 방문자가 존재하며, 서로 다른 원본 URL을 10억개 이상 저장할 수 있어야한다. 사용자는 단축된 URL로 접속할 경우, 원본 URL로 redirect된다. 사용자는 단축된 URL로 접속할 경우, 그에 대한 횟수를 날짜별로 저장한다. 사용자는 shortUrlId를 통해서 전체 URL 주소와 기타 정보를 확인할 수 있다. 사용자는 각 shortUrlId 별 일자 별 통계를 제공받을 수 있다.

기능 요구 사항

  1. 사용자가 입력한 원본 URL을 단축된 URL로 변경하여 제공한다.
       이 때 단축된 URL은 다음과 같은 구조를 띈다. “서버 호스트 주소 + /r + /{shortUrlId}”
       예시 : http://www.google.com/test/abcdedasdasdsads -> http://www.jjunhub.com/s/1abd2ef
  2. 사용자가 하나의 원본 URL로 여러 번 단축된 URL로 변경을 시도한다면, 항상 다른 shortUrlId 값이 제공되어야한다.
  3. shortUrlId는 영어문자와 숫자로 이루어진한 문자열로 구성되어야하며 최대한 짧은 값을 가져야한다.
  4. 사용자는 단축된 URL로 접속할 경우, 원본 URL로 **redirect** 된다.
  5. 사용자가 단축된 URL로 접속할 경우, 그에 대한 횟수를 날짜별로 저장한다.
  6. 사용자는 shortUrlId를 통해서 전체 URL 주소와 기타 정보를 확인할 수 있다.
  7. 사용자는 각 shortUrlId 별 일자 별 통계를 제공받을 수 있다. 이는 shortUrlId와 날짜 정보를 통해서 요청을 받는다.

 

도메인 로직 설계 고민 

중복되지 않는 shortUrlId를 생성할 때, 어떤 알고리즘을 사용할 것인가?

  • 10억개 이상 저장될 수 있으며, 숫자와 문자로 이루어진 shortUrlId를 생성해야 한다.
  • 숫자와 문자로 이루어진 shortUrlId는 0~9, a~z, A~Z의 문자로 구성되므로 총 10 + 26 + 26 = 62개의 문자로 구성된다.
  • 따라서 Base62 Encoding을 사용하여 shortUrlId를 생성하는 것이 적절하다고 판단하였다.
  • Base62 Encoding은 한 자리마다 약 62개의 문자를 사용하므로, 8자리의 shortUrlId를 생성하면 62^8 = 약 21조개의 문자를 생성할 수 있다.

 

원본 링크와 매핑되는 shortUrlId를 중복 없이 어떻게 생성할 것인가?

  • (UUID Random 값  + Salt) + Hashing + Base62 Encoding <- 선택
    • Random 값과 Salt를 결합하여 Hashing과 Base62 인코딩을 진행하고 그 결과가 DB에 이미 존재하지 않는다면, shortUrlId로 저장하고, 존재한다면 다시 Random 값을 생성하여 Hashing을 진행한다.
    • 장점: shortUrlId의 길이가 거의 일정하게 유지되고, 사용자가 추측하기 어렵다.
    • 단점: 충돌 해결을 위해, hashing + salt를 여러 번 진행해야 할 수도 있고, DB에 요청을 매번 보내야한다.
  • DB의 Unique ID + Base62 Encoding <- Unique Id 관리 테이블 필요하여 미선택
    • DB에서 unique ID를 불러와서 Base62로 encoding하여 shortUrlId를 생성한다.
    • 장점: 충돌이 발생할 확률이 매우 낮다.
    • 단점: shortUrlId의 길이가 일정하지 않고, unique ID가 일정하게 증가한다면 사용자가 추측하기 쉬울 수 있다.

 

shortUrlId의 날짜별로 통계를 저장해야하는데 어떻게 저장할 것인가?

  • 날짜별로 통계를 저장하기 위해서는 날짜별로 shortUrlId의 클릭 수를 저장해야 한다.
  • 이를 위해서는 날짜별로 shortUrlId의 클릭 수를 저장할 수 있는 DB 스키마를 설계해야 한다.
  • 날짜별로 shortUrlId의 클릭 수를 저장하는 DB 스키마는 다음과 같다.
    • short_url_id: varchar(30)
    • click_date: date
    • click_count: bigint
  • 이 과정에서 (shortUrlId, date)를 복합키 형태의 PK로 설정하여 데이터를 저장한다.
  • 복합키로 인해서, 중복되지 않는 데이터를 저장할 수 있으며 클러스터드 인덱스를 사용하여 조회 성능을 향상시킬 수 있다.

 

shortUrlId의 클릭 수를 날짜별로 저장하고, 이를 통계로 집계해야하는데 어떻게 집계할 것인가?

  • redis에 저장된 shortUrlId의 클릭 수를 약 1시간마다 집계하여, DB에 저장한다.
  • 이를 위해서는 shortUrlId의 클릭 수를 집계하는 스케줄러를 도입했다.

 

API 명세서

더보기

POST  /s

  • Feature
    • 사용자는 해당 API를 통해 원본 URL을 입력하면, 단축된 URL을 제공받는다.
    • 서버 측에서는 해당 API를 통해 shortUrlId를 생성하고, 원본 URL과 함께 DB에 저장해야한다.
    • 서버 측에서는 shortUrlId를 생성할 때, alphanumberic한 문자열로 생성해야한다.
    • 서버 측에서는 shortUrlId를 생성할 때, 중복되지 않는 값을 생성해야한다.
    • 서버 측에서는 shortUrlId를 생성할 때, 가능한 짧은 값을 생성해야한다.
  • Request
         ```json
         {
             "originalUrl": "https://jjunhub.tistory.com/10"
         }
         ```
  • Response
         ```json
         {
            "data" : {
               "shortUrlId": "ab1267c",
               "originalUrl": "https://jjunhub.tistory.com/10",
               "createdAt": "2021-06-07T11:38:16+0000"
             }
         }
         ```

GET  /r/{shortUrlId}

  • Feature
    • 사용자는 해당 API를 통해 원본 URL로 redirect된다.
    • 서버 측에서는 해당 API로 접속하는 횟수를 통계 데이터로 저장해야한다.
  • Request
    None
  • Response
    originalUrl로 Redirect 302

GET  /g/{shortUrlId}

  • Feature
    • 사용자는 해당 API를 통해서 원본 URL과 기타 정보를 확인할 수 있다.
  • Request
    None
  • Response
       ```json
           {  
             "data" : {
               "shortUrlId": "ab1267c",
               "originalUrl": "https://jjunhub.tistory.com/10",
               "createdAt": "2021-06-07T11:38:16+0000"
             }
           }
       ```

GET  /st/{shortUrlId}?date=2025-02-26

  • Feature
    • 사용자는 해당 API를 통해 각 shortUrlId date에 해당하는 통계 정보를 획득할 수 있다.
  • Request
    None
  • Response
       ```json
           {
             "data" : {
               "shortUrlId": "abc",
               "date": "2025-02-26",
               "clickCount": 100
             }
           }
       ```

 

서버 아키텍처

infra부분만 포트앤어댑터를 적용한 구조

포트 앤 어댑터 패턴으로 주요 도메인 로직과 인프라 부분을 분리했다. 이 과정에서 이점을 본 것은 다음과 같다.

  1. 인프라 변경에도 영향받지 않는 도메인 로직
    코드 구현 중에도 로컬 캐시와 글로벌 캐시가 계속 고민되었다. 따라서 우선적으로 도메인 로직을 작성하고 캐시 관련 부분은 인터페이스화 하여 자세한 구현을 뒤로 미루었다. 그리고 처음에 Caffeine 의존성을 이용하여 로컬 캐시로 작성했엇지만, 최종적으로 Redis의존성을 활용한 글로벌 캐시로 결정하게 되었는데 이 과정에서 도메인 로직 관련된 코드를 하나도 수정하지 않아도 됐다.
  2. 제어가능한 영역 증가
    무작위 값으로 생성되는 short_url_id 값에 대해 인터페이스 - 구현체 구조로 의존성 역전 원칙을 이루었다. 이를 통해 실서비스에서는 무작위 값으로 로직을 수행하고, 테스트 코드에서는 지정한 값으로 생성하여 추가적인 로직들을 테스트하는데 용이하였다.

트러블 슈팅

레디스면 동시성 제어 되는거 아니였나?

싱글 스레드로 이루어진 레디스를 쓰면 통계를 정확하게 저장할 줄 알았다. 그러나 어떻게 로직을 구성하느냐에 따라 또 문제가 발생할 수 있었다. 물론 이건 레디스의 문제가 아니라, 사용하는 사람(=나 ㅋㅋ)의 문제이긴 하다.

 

레디스를 통해 수행해야할 것은 2가지였다.  이중 첫번째 역할은 간단해서 딱히 문제될 것은 없었지만, 두번째 역할은 간단해보였는데 문제가 발생했다. 

  1. short_url_id에 해당하는 원본 URL 제공해주기
  2. short_url_id에 해당하는 조회수 집계하기

조회수 집계를 위해 다음과 같이 레디스로 2번의 요청을 보내 집계를 진행했다.

원본 URL 요청 후, 조회수를 1 증가시키는 과정

 

그리고 코드 작성을 완료한 뒤, 부하 테스트를 해보니 10만번을 요청하여도 그에 훨씬 못미치는 결과가 집계되었다.

이에 대한 이유는 다음과 같다.

 

2명의 유저가 동일한 key 값으로 조회한 경우

위처럼 두 사용자가 동시에 같은 key 값으로 요청을 전송한 경우 업데이트 값이 반영이 되지 않는 lost update가 발생하는 것이다.

 

현재는 레디스에서 서버로 값을 가져온 뒤에, 그 값을 1 증가시키고 레디스에 그 값을 저장하는 구조다. 이를 해결하기 위해선, 서버로 값을 가져오고 1을 더한 뒤에 레디스에 저장하는 것이 아니라 레디스에서 곧바로 1을 증가시키면 된다. 이 방법을 구현하기 위해선 2가지 선택지가 있었다.

  1. Atomic 명령어
    • 레디스에는 INCR나 HINCRBY 같은 원자적(atomic) 명령어가 있어 동시성 문제 없이 값을 안전하게 증가시킬 수 있다.
  2. Lua Script
    • 여러 명령을 하나의 트랜잭션처럼 실행하고자 할 경우, Lua 스크립트를 사용하여 복잡한 로직도 원자적으로 처리할 수 있다.

이 중 1번을 선택하면 다음과 같이 Sequence Diagram이 수정된다.

 

이렇게 해서, 10만건의 요청을 동시에 받아도 정확하게 통계를 집계할 수 있다. 이를 코드로 구현하면 다음과 같다.

더보기
    @Override
    @Async
    public void incrementClickCount(String shortUrlId) {
        Optional.ofNullable(redisTemplate)
                .ifPresent(template -> {
                    Long count = template.opsForValue().increment("clickCache::"+shortUrlId);
                    log.info("Click count for {} is {}", shortUrlId, count);
                });
    }

레디스에서 모든 key를 대상으로 조회수를 가져와서 DB에 적재하자.

( 한 가지 참고할 것은, 현재 배치 서버는 1대만 존재해서 배치 서버 간의 동시성은 고려하지 않아도 된다. )

주기적으로 레디스에서 모든 조회수들을 가져와서 DB에 적재해야한다. 이 과정을 위해 2가지 작업을 진행했다.

  1. 레디스에 조회수가 1 이상인 key들을 모두 set의 형식으로 레디스에 따로 저장한다. -> clickCountKeys
  2. 스프링에서 스케쥴링을 통해서 레디스의 clickCache:*를 가져와서, 각 key 별로 조회수를 집계한다.

여기서 하나 주의 해야할 것은 조회수를 집계하는 와중에도 조회수는 증가하고 있다는 것이다. 따라서 조회수를 DB에 반영하고 난 뒤에 레디스의 조회수 값을 0으로 바꾸는 것이 아니라, 집계된 양만큼 감소시키는 것이 포인트이다. 여기서 조금 더 고도화를 한다면, 다음과 같은 사항들에 대해 고도화할 수 있다.

  1. 만약 key 값들이 너무 많아진다면?
    • 멀티 쓰레드 방식으로 Scan 방식과 커서 방식을 통해서, N개씩 가져와서 처리한다.
    • 하나의 서버로 처리하는 것으로도 모자라다면, 서버를 추가하여 각 서버마다 prefix를 나눠서 배치 처리한다.
  2. 하나의 스레드에서 배치 처리를 맡아서 할 때, 쓰레드가 무언가의 이유로 종료된다면?
    •  만약 정확도가 엄청나게 중요한 비즈니스가 아니라면, 한 번쯤 종료되는 것은 괜찮다고 생각한다. 어차피 스케쥴링을 통해서 다음 스케쥴링에 반영되지 않은 조회수들에 대해서 반영되기 때문이다.
    • 만약 반복적으로 종료되거나, 정확도가 중요한 비즈니스라면 날짜 정보를 적재 정보에 포함하여 실패 또는 중복 저장을 방지할 수 있다.

 

테스트 결과

apache bench를 통해 단순 요청을 계산했다.

결과는 추후 첨부할 예정이다.

 

마치며

  1. 레디스라고 해서 동시성을 모두 해결하는 건 아니다. 해당 이슈는 쓰는 사람 몫이다.
  2. 간단한 서비스여도, 트래픽이 몰린다는 가정 1개가 들어간다면 고려해야할 사항이 많이 생기는 것 같다.
  3. 배치 기능을 제공하는 데 있어서, 중간에 배치 서버가 꺼진다면?이라는 가정은 필수적으로 고려해야한다.