catsridingCATSRIDING|OCEANWAVES
Dev

URL 단축 시스템 디자인하기

jynn@catsriding.com
Jan 10, 2025
Published byJynn
999
URL 단축 시스템 디자인하기

Designing a Scalable URL Shortener System

현대 웹 서비스는 효율성과 확장성을 고려한 시스템 설계가 필수적입니다. URL 단축기는 시스템 디자인 면접에서 고전적으로 다뤄져 온 주제이자, 설계의 기본 원칙을 배우고 실습하기에 적합한 사례로 자주 언급됩니다. 이 시스템의 기능 구현은 단순해 보이지만, 대규모 트래픽 처리, 데이터 일관성 유지, 사용자 증가에 따른 확장성 확보 등의 요구사항이 추가되면 고려해야 할 요소가 많은 시스템입니다.

이 글에서는 가상 면접 사례로 배우는 대규모 시스템 설계 기초를 기반으로, URL 단축기의 주요 요구사항을 도출하는 것으로 시작해 이를 점진적으로 해결해 나가는 과정을 통해 설계와 구현을 단계별로 살펴봅니다. 이를 통해 시스템 설계의 기초 원리를 이해하고 실무에서의 설계 경험을 한층 더 강화해보도록 하겠습니다.

Gathering Requirements

URL 단축 서비스를 설계하기 위해 먼저 시스템 요구사항을 정의해야 합니다. 가상 면접 사례와 글로벌 서비스의 공개된 데이터를 기반으로, 비슷한 규모의 서비스가 어떤 요구사항을 가지는지 분석해 보겠습니다. 이러한 분석을 토대로, 로컬 환경에서도 실행 가능한 수준의 요구사항을 설정하여 시스템 설계를 검증할 계획입니다.

Insights from Global Services

가상 면접 사례로 배우는 대규모 시스템 설계 기초에서는 가상 면접관으로부터 URL 단축 서비스의 요구사항을 다음과 같이 도출합니다. 매일 1억 개의 단축 URL이 생성되며, 읽기/쓰기 비율은 10:1로 설정됩니다. 이는 초당 약 11,570건의 읽기 요청과 초당 약 1,157건의 쓰기 요청을 처리할 수 있는 성능이 필요합니다. 또한, 1년 동안 누적되는 데이터는 평균 원본 URL 길이를 100자로 가정했을 때 약 3.65TB의 저장 공간이 요구됩니다.

Bitly는 URL 단축 서비스의 대표 주자입니다. 현재 공개된 통계에 의하면, Bitly는 하루 약 2억 8,600만 건의 클릭 요청을 처리하며, 하루 약 3,100만 개의 URL을 단축한다고 합니다. 이를 기반으로, 대략적으로 초당 3,310건의 읽기 요청과 초당 359건의 쓰기 요청이 발생한다고 추정할 수 있습니다. 1년에 약 113억 건의 URL이 단축되며, 원본 URL의 평균 길이를 100bytes로 가정했을 경우 Bitly의 1년 데이터 저장 용량은 약 1.13TB로 추정됩니다.

TinyURL에 대한 구체적인 공식 데이터는 공개되지 않았지만, Bitly보다 작은 규모의 서비스로 추측되며 업계 표준과 유사 사례를 참고하면, 약 10억 개의 URL을 관리하고 있을 가능성이 있습니다. 읽기/쓰기 요청의 비율을 Bitly와 유사한 10:1로 가정할 경우, 초당 약 700건의 읽기 요청과 약 70건의 쓰기 요청이 발생할 것으로 예상됩니다. 이를 기반으로 TinyURL의 1년 데이터 저장 용량은 약 0.22TB로 계산됩니다.

Twitter는 단축 URL 서비스를 제공하지는 않지만, 트윗 데이터의 구조가 단축 URL 데이터와 유사한 점이 있어 트래픽 통계를 참고하면 좋을 듯합니다. 트위터는 하루 약 5억 개의 트윗이 생성되며, 초당 6,000건의 쓰기 요청을 처리한다고 알려져 있습니다. 읽기 요청은 이보다 훨씬 많아, 일반적인 SNS의 읽기/쓰기 비율인 100:1~10,000:1을 적용하면 초당 60만~6천만 건의 읽기 요청이 발생할 수 있습니다. 트윗 데이터 크기를 평균 250~300bytes로 가정하면, Twitter의 1년 데이터 저장 용량은 약 473~567PB로 추정됩니다.

다양한 글로벌 서비스를 비교한 결과, URL 단축 서비스는 대규모 트래픽과 데이터를 안정적으로 처리하기 위한 설계가 필수적이라는 점을 확인할 수 있었습니다. 아래 표는 각 서비스의 주요 요구사항을 대략적으로 요약한 내용입니다:

ServiceRead Requests/sec (RPS)Write Requests/sec (WPS)Annual Data Volume
가상 면접11,5701,1573.65TB
Bitly3,3103591.13TB
TinyURL700700.22TB
Twitter600,000~60,000,0006,000473~567PB

Scaling Down for Local Development

리소스가 제한적인 로컬 환경에서는 실제 서비스 환경의 모든 요구사항을 충족하기 어렵습니다. 따라서 실제 요구사항을 기반으로 적절히 조정한 시나리오를 통해 시스템을 설계하고 구현하려고 합니다. 이를 위해 아래와 같은 기준을 설정했습니다:

항목요구사항
쓰기 연산초당 100건(하루 기준 약 864만 건)
읽기 연산초당 1,000건(하루 기준 약 8,640만 건)
저장 용량하루 1GB(대략 1,000만건 저장)

이와 같이 조정된 환경은 실제 서비스 요구사항을 일부 간소화했지만, 시스템 설계와 주요 요소를 검증하기에 적절한 수준으로 보입니다. 로컬 환경에서의 테스트를 통해 주요 성능 지표를 측정하고, 서비스 확장성을 평가할 수 있는 기반을 마련하려고 합니다.

Designing System Architecture

다음은 시스템의 구조를 설계하고, 필요한 도구와 API 프로세스를 정의하는 작업을 진행하겠습니다.

Key Components of System Infrastructure

효율적이고 확장 가능한 시스템을 구축하기 위해서는 각 구성 요소의 역할과 계층 구조를 명확히 정의하는 것이 중요합니다. 시스템 아키텍처는 요구사항에 따라 여러 계층으로 구성되며, 각 계층은 고유한 기능을 수행하면서 서로 협력하여 안정적인 서비스 제공을 목표로 합니다. 아래는 URL 단축 서비스를 위한 주요 아키텍처 계층과 각 계층의 역할입니다:

designing-a-scalable-url-shortener-system_04.png

  • Load Balancer: 클라이언트의 요청을 여러 서버로 분배하여 서버 간 부하를 균등화하며, 무중단 서비스를 위해 복수의 인스턴스와 연동됩니다.
  • Application Servers: URL 단축 및 복원 로직을 처리하는 주요 비즈니스 로직이 구현되어 있으며, 수평 확장(Scale-Out)을 지원하여 트래픽 급증 시에도 안정적인 처리가 가능하도록 구축합니다.
  • Database Layer: URL 매핑 데이터를 안전하게 저장하기 위해 RDBMS를 사용하며, 읽기와 쓰기를 분리한 마스터/슬레이브 레플리케이션 구조를 채택합니다. 쓰기 작업은 마스터 서버에서 처리하고, 읽기 작업은 슬레이브 서버에서 병렬로 처리하여 성능과 안정성을 모두 확보합니다. 또한, 인덱스를 활용한 최적화를 통해 검색 속도를 향상시킵니다.
  • Caching Layer: 읽기 요청의 최적화를 위해 메모리 기반의 캐시 시스템을 도입하여 데이터베이스 부하를 줄입니다. 자주 요청되는 데이터를 메모리에 저장함으로써 낮은 지연 시간과 빠른 응답 속도를 제공합니다. TTL(Time to Live) 설정을 통해 오래된 데이터를 자동으로 제거하고, 캐시 클러스터 구조를 통해 높은 가용성과 확장성을 보장합니다. 이를 통해 대량의 읽기 요청도 효율적으로 처리할 수 있습니다.

이 외에도 모니터링 시스템과 로그 시스템을 도입한다면, 서비스 상태를 실시간으로 파악하고 장애 발생 시 신속히 대응할 수 있어 더욱 안정적인 시스템 운영이 가능합니다.

위와 같은 시스템 구성 요소를 동일하게 구축하는 것은 많은 자원과 비용이 요구되므로 쉽지 않습니다. 💸 그런 이유로 로컬 환경에 맞춰 시나리오를 조정하기는 했는데, 개발 장비에서 구동 중인 데이터베이스와 Redis가 이 시나리오를 충족할 수 있을지 확인해 봐야 할 것 같습니다. 󰄴

ModelOSCPUMemoryStorage
💻 MacBook PromacOS SequoiaApple M1 Max 10 CPU64GB1TB SSD

일반적인 RDBMS의 성능은 SSD의 IOPS와 대역폭에 크게 영향을 받으며, 이 장비에서는 읽기 성능이 초당 약 10,000~20,000QPS, 쓰기 성능이 초당 약 5,000~10,000QPS에 이를 것으로 예상됩니다.

QPS
QPS란 Queries Per Second의 약자로, 초당 처리할 수 있는 데이터베이스 쿼리의 수를 나타냅니다. 이 값은 데이터베이스가 얼마나 효율적으로 요청을 처리할 수 있는지를 평가하는 중요한 지표입니다.

또한, Redis는 메모리 기반의 캐시 스토어로, 초당 약 150,000~200,000QPS의 읽기 성능과 약 100,000~120,000QPS의 쓰기 성능을 제공할 것으로 기대됩니다. 이 성능은 AOF(Append Only File)와 RDB(Redis Database Backup)와 같은 영구 저장 메커니즘을 비활성화한 상태를 기준으로 예측되었습니다. 이러한 설정은 쓰기 성능을 더욱 향상시키며, 네트워크 병목 없이 최적의 성능을 발휘할 수 있습니다.

EngineRead Performance (QPS)Write Performance (QPS)
 Database10,000~20,0005,000~10,000
 Redis150,000~200,000100,000~120,000

위에서 예측한 성능을 바탕으로, 맥북에서 구동중인 데이터베이스와 Redis는 현재 시나리오를 충분히 충족할 수 있을 것으로 보입니다. 특히, Redis의 높은 읽기 성능은 데이터베이스 부하를 크게 줄이며, 서비스의 안정성과 응답 속도를 확보하는 데 기여할 것입니다.

Detailed API Design and Workflow

URL 단축 서비스는 두 가지 주요 API를 중심으로 작동합니다. 여기서는 API의 구체적인 프로세스를 설계하고, 캐시 및 데이터베이스 상태에 따라 어떻게 작동할지 정의합니다:

  • 단축 URL 생성 API: 사용자가 제공한 원본 URL을 단축 URL로 변환하여 저장하고 반환하는 기능을 수행합니다.
  • 단축 URL 복원 API: 단축 URL을 입력받아 원본 URL을 조회하고 반환하는 기능을 담당합니다.
URL Shortener API

단축 URL 생성 API는 이전에 등록한 원본 URL이 있을 수 있습니다. 따라서 원본 URL이 이미 등록된 경우와 등록되지 않은 경우를 구분하여 프로세스를 설계해보겠습니다.

먼저, 원본 URL이 이미 등록된 경우, 새로 생성하지 않고 데이터베이스에서 기존 정보를 조회해 반환합니다. 이렇게 하면 리소스를 절약하고 처리 속도를 높일 수 있습니다:

designing-a-scalable-url-shortener-system_00.png

  1. 사용자 요청: 사용자가 변환할 원본 URL을 입력합니다.
  2. 데이터베이스 조회: 데이터베이스에서 동일한 URL의 단축 버전이 존재하는지 확인합니다.
  3. 데이터 조회 성공: 데이터베이스에 데이터가 존재한다면 기존 단축 URL을 반환합니다.
  4. 캐시 갱신: 데이터베이스 저장 후, 해당 데이터를 캐시에 업데이트하여 향후 요청 시 빠르게 조회될 수 있도록 합니다.
  5. 응답 반환: 클라이언트에게 기존 단축 URL을 JSON 형식으로 반환하며, HTTP 상태 코드 200을 명시적으로 포함해 요청이 성공적으로 처리되었음을 전달합니다.

그리고, 원본 URL이 등록되지 않은 경우, 새로운 단축 URL을 생성해야 하며, 이를 위해 다음과 같은 절차를 따릅니다:

designing-a-scalable-url-shortener-system_01.png

  1. 사용자 요청: 사용자가 변환할 원본 URL을 입력합니다.
  2. 데이터베이스 조회: 데이터베이스에서 동일한 URL의 단축 버전이 존재하는지 확인합니다.
  3. 해시 생성: 데이터베이스 데이터가 존재하지 않는다면, 원본 URL에 대해 고유 해시 값을 생성합니다. 해시 충돌을 방지하기 위해 적절한 암호화 알고리즘을 사용해야 하는데, 이는 다음 섹션에서 상세히 살펴보도록 하겠습니다.
  4. 데이터베이스 저장: 생성된 해시와 원본 URL을 데이터베이스에 저장합니다.
  5. 캐시 갱신: 데이터베이스 저장 후, 해당 데이터를 캐시에 업데이트하여 향후 요청 시 빠르게 조회될 수 있도록 합니다.
  6. 응답 반환: 새로 생성된 단축 URL을 클라이언트에게 JSON 형식으로 반환합니다. HTTP 상태 코드 역시 200을 명시적으로 포함해 요청이 성공적으로 처리되었음을 전달합니다.
URL Resolver API

단축 URL 복원 API는 단축 URL을 입력받아 원본 URL을 반환합니다. 요청된 단축 URL을 캐시 스토어에서 조회(Cache Lookup)하고, 캐시 히트(Cache Hit)에 성공한 경우와 캐시 미스(Cache Miss)가 발생한 경우로 프로세스를 나눌 수 있습니다.

Cache Lookup
캐시 스토어에서 특정 키에 대응하는 값을 조회하는 과정입니다.

Cache Hit
캐시 스토어에 요청된 키가 존재하여, 값이 성공적으로 반환되는 상황입니다.

Cache Miss
캐시 스토어에 요청된 키가 존재하지 않아, 값이 반환되지 않는 상황입니다.

캐시 히트(Cache Hit)에 성공한 경우에는 이를 즉시 사용자에게 전달합니다. 이렇게 하면 별도의 추가 작업 없이 빠른 응답 속도를 보장할 수 있습니다.

designing-a-scalable-url-shortener-system_02.png

  1. 사용자 요청: 클라이언트가 단축 URL을 입력합니다.
  2. 캐시 조회: 요청된 단축 URL이 캐싱 시스템에 존재하는지 확인합니다.
  3. 캐시 히트: 캐시에 데이터가 존재하면 데이터베이스를 조회하지 않고 즉시 원본 URL을 반환합니다.
  4. 응답 반환: HTTP 상태 코드 301을 사용해 요청된 리소스가 영구적으로 이동했음을 알리며, Location 헤더에 원본 URL을 포함하여 클라이언트가 해당 URL로 즉시 리디렉션되도록 합니다.

캐시 미스(Cache Miss)가 발생하면 데이터베이스를 조회하여 원본 URL을 검색해야 합니다. 검색 결과를 기반으로 원본 URL을 반환하고, 이후 요청에서 빠른 처리가 가능하도록 캐시를 갱신합니다.

designing-a-scalable-url-shortener-system_03.png

  1. 사용자 요청: 클라이언트가 단축 URL을 입력합니다.
  2. 캐시 조회(Cache Lookup): 요청된 단축 URL이 캐시 스토어에 존재하는지 확인합니다.
  3. 캐시 미스(Cache Miss): 캐시에서 데이터를 찾을 수 없는 경우, 데이터베이스를 조회하여 필요한 정보를 가져와야 합니다.
  4. 데이터베이스 조회: 데이터베이스에서 해당 단축 URL에 매핑된 원본 URL을 검색합니다. 데이터베이스에 데이터가 없으면 예외를 던지고, HTTP 404 상태 코드를 반환하여 요청이 실패했음을 클라이언트에 알립니다.
  5. 데이터베이스 조회 성공: 데이터베이스에서 원본 URL을 성공적으로 조회한 경우 해당 데이터를 반환합니다.
  6. 캐시 갱신: 데이터베이스 조회가 성공하면 해당 데이터를 캐시에 저장하여 향후 요청 시 빠르게 처리될 수 있도록 합니다.
  7. 응답 반환: HTTP 상태 코드 301을 사용해 요청된 리소스가 영구적으로 이동했음을 알리며, Location 헤더에 원본 URL을 포함하여 클라이언트가 해당 URL로 즉시 리디렉션되도록 합니다.

이렇게 캐시 스토어와 데이터베이스를 조합하여 API 프로세스를 설계해보았습니다. 이를 통해 시스템의 요청 처리 효율성을 높이고 사용자 경험을 향상할 수 있을 것입니다.

Defining Specifications

단축 URL 서비스는 고유성과 효율성을 동시에 충족해야 하며, 이를 위해 체계적이고 세밀한 설계가 필요합니다. 이 섹션에서는 URL 단축을 위한 핵심 요소들을 분석하고, 이를 기반으로 시스템의 설계 방안을 구체화합니다.

URL Encoding Strategies

단축 URL 생성에서는 중복을 방지하고 효율적인 길이를 유지하는 것이 필요합니다. 이를 위해 두 가지 주요 인코딩 전략을 활용할 수 있습니다:

  • 해싱 기반 방식: 해시 함수를 사용하여 고유한 해시 값을 생성한 뒤, 단축 URL의 요구사항에 맞는 길이로 잘라내는 방식입니다. 길이가 예측되어 간결한 URL을 제공하며, 구현이 간단한 장점이 있지만, 길이가 짧아진 만큼 충돌 가능성이 커지므로 이에 대한 적절한 대응이 필요합니다.
  • 62진법 변환 방식: 각 URL에 고유 ID를 할당하고 이것을 62진수로 변환하는 방식입니다. 각 자리에서 62개의 문자(영어 대소문자 52개 + 숫자 10개)를 활용해 조합을 생성하며, 충돌 가능성이 없다는 장점이 있습니다. 하지만, 유일성이 보장되는 ID가 반드시 필요하며, 쉽게 분석이 가능한 ID를 사용할 경우 보안상 문제가 될 소지가 있으므로 이를 방지하기 위해 추가적인 난수 삽입이나 암호화를 고려해야 할 수도 있습니다.

62진법 변환 방식은 충돌 가능성을 원천적으로 제거하는 특성 때문에 단축 URL 서비스에서 일반적으로 채택되는 방식입니다. 하지만, 이 방식은 고유 ID 생성 로직을 별도로 설계해야 하는 추가 작업이 필요합니다.

그래서, 저는 여기서 해싱 기반 방식을 토대로 충돌 발생 시 허용 길이를 점진적으로 늘리는 전략을 선택하고자 합니다. 예를 들어 초기 길이를 6자로 설정하고, 충돌이 발생하면 해시 값의 다음 자리를 추가로 사용하며, 필요 시 길이를 더 확장하는 방식입니다. 이 접근법은 URL의 간결함을 유지하면서도 해싱 알고리즘만으로 설계를 단순화할 수 있다는 실용적인 장점이 있습니다. 충돌 해결 과정에서 데이터베이스의 부하가 증가할 수 있지만, 시스템의 간결성 고려했을 때 현실적인 대안이라고 판단됩니다. 물론, 사용자가 증가함에 따라 충돌 발생율이 높아지게 되면 더 고도화한다는 것을 염두에 두고 내린 결정입니다. 😉

Hashing Algorithm

해시 함수는 입력 데이터를 고정된 길이의 해시 값으로 변환하여 고유성을 보장하는 기술입니다. 다양한 해시 함수가 존재하며, 여기서는 SHA-1 방식을 채택하였습니다. 그 이유는 다음과 같습니다.

SHA-1은 160비트의 고정 길이 해시 값을 생성하며, 대규모 URL 데이터셋에서도 충돌 가능성이 낮아 단축 URL의 고유성을 효과적으로 보장할 수 있습니다. 또한, 다양한 플랫폼에서 표준으로 지원되므로 구현과 통합이 용이합니다. CRC32와 같은 짧은 해시는 충돌 가능성이 높아 대규모 시스템에 적합하지 않으며, SHA-256은 보안이 강력하지만 단축 URL 용도로는 불필요하게 복잡하고 연산 비용이 큽니다. 반면, SHA-1은 단축 URL 생성의 요구사항에 가장 적합한 처리 속도와 고유성 보장 수준을 제공합니다.

따라서 SHA-1 방식은 현재 구현하려는 도메인의 특성과 요구사항에 적합한 선택으로 판단됩니다.

Optimal Hash Length Calculation

가상 면접 사례에서는 매일 1억건의 쓰기 요청을 기준으로 10년간 약 3,650억 개의 URL이 생성될 것으로 가정하고 있습니다. 제가 설정한 로컬 테스트 환경에서는 초당 100건의 쓰기 요청을 기준으로 1년에 약 3억 1,536만 건, 10년간 약 31억 5,360만 건의 URL 생성이 필요합니다. 이러한 요구사항을 바탕으로, 가장 적합한 최적의 URL 길이를 찾아야 합니다.

SHA-1 해시 값을 기반으로 단축 URL의 길이를 설정하며, 시스템 요구사항을 고려해 충돌 가능성을 최소화하고 충분한 URL 공간을 확보할 수 있도록 계산해야 합니다.

  • 사용 가능한 문자: 62개 (영어 대소문자 52개 + 숫자 10개)
  • 예상 URL 생성량: 31억 5,360만 (10년 기준)

SHA-1 해시 값을 활용하여 길이에 따라 생성 가능한 URL 조합 수는 각 자리마다 62개 문자 중 하나를 선택할 수 있기 때문에, 가능한 조합 수는 62의 거듭제곱 단위로 증가합니다:

URL 길이거듭제곱가능한 조합 수단위 변환
162¹62-
262²3,844-
362³238,328-
462⁴14,776,336약 0.1억
562⁵916,132,832약 9.1억
662⁶56,800,235,584약 568억
762⁷3,521,614,606,208약 3조 5,216억
862⁸218,340,105,584,896약 2경 1,834조

현재 10년간 약 31억 개의 URL 생성을 예상하고 있으므로, 길이를 6으로 설정하면 약 568억 개의 조합이 가능하여 충분한 URL 공간을 확보하고 충돌 발생 가능성을 효과적으로 줄일 수 있습니다. 여기에, 충돌 발생에 대비하여 최대 허용 길이를 8자까지 설정한다면, 충돌 이슈도 효과적으로 해소할 수 있을 것으로 계산됩니다.

Hash Length Adjustment

SHA-1의 기본 출력 길이는 160비트입니다. 단축 URL의 요구사항을 충족하기 위해 최소 6자의 해시 길이를 설정하고, 필요 시 최대 8자까지 확장하는 과정을 다음과 같이 진행합니다:

  1. URL 해싱: 원본 URL을 SHA-1 알고리즘으로 해싱한 뒤, 해시 값의 앞부분에서 최소 6자를 추출합니다.
  2. 고유성 검증: 생성된 해시 값이 데이터베이스에 이미 존재하는지 확인합니다.
  3. 충돌 처리: 충돌이 발생하면 해시 길이를 최대 8자까지 확장하여 고유한 값을 생성합니다.

Database Schema Design

단축 URL과 원본 URL의 매핑을 체계적으로 관리하기 위해 아래와 같은 데이터베이스 스키마를 작성했습니다:

Column NameTypeUniqueNullableDescription
idBIGINT🚫Primary Key
short_urlVARCHAR(8)🚫단축 URL
long_urlVARCHAR(2048)🚫🚫원본 URL
client_ipVARCHAR(45)🚫🚫최초 요청자 IP
created_atTIMESTAMP🚫🚫단축 URL 생성 시간

단축 URL은 최대 8자까지 허용하며, 유니크 제약 조건을 적용하여 각 단축 URL이 고유하도록 설계합니다. 원본 URL은 최대 약 2,000자를 허용하여 대부분의 웹 주소를 충분히 저장할 수 있도록 합니다. 이를 기반으로 설계된 MySQL DDL은 다음과 같습니다:

create table urls
(
    id         bigint auto_increment primary key,
    short_url  varchar(8)    not null unique,
    long_url   varchar(2048) not null,
    client_ip  varchar(45)   not null default '127.0.0.1',
    created_at timestamp     not null default current_timestamp
);

Implementing APIs

이전까지의 과정에서 요구사항을 정리하고, 구체적인 시스템 구조를 설계하였습니다. 이제 구현을 통해 설계를 구체화하고, 요구사항에 부합하는지 검증해 보겠습니다.

Setting Up Development Environment

개발 환경을 설정하는 첫 단계로, 개발 장비에 캐시 스토어와 데이터베이스를 선택하고 구성해야 합니다. 저는 널리 사용되며 안정성이 검증된 Redis와 MySQL을 선택하였으며, 이를  Docker를 활용해 구축하였습니다. 아마 이 글을 읽는 개발자분들께서는 이미 캐시 스토어와 데이터베이스를 구축해 두셨을 거라 보고, 이 과정은 생략하도록 하겠습니다.

다음으로, Spring Boot Initializr를 사용해 프로젝트를 초기화합니다. 기본적인 웹 의존성과 함께, 데이터 접근을 위해 JPA와 Spring Data Redis를 사용할 예정입니다. 이를 위해 필요한 의존성은 다음과 같습니다:

build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
  • Java 21
  • Spring Boot 3.4.1
  • MySQL 8
  • Redis

Implementing URL Shortener API

단축 URL 생성 API는 사용자가 입력한 URL을 고유한 단축 URL로 변환하고 이를 저장한 후 반환하는 기능을 수행합니다. 이에 대한 요청 형식은 다음과 같습니다:

Request
POST /shorten HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.8 (Macintosh; OS X/15.2.0) GCDHTTPRequest
Content-Length: 107

{
  "url": "http://localhost:3000/posts/designing-a-scalable-url-shortener-system#database-schema-design"
}

요청을 처리하는 컨트롤러는 클라이언트로부터 들어온 HTTP POST 요청을 처리하며, URL 단축 작업을 수행하는 비즈니스 로직으로 전달합니다:

UrlController.java
@PostMapping("/shorten")
public ResponseEntity<?> urlShortenerApi(
        HttpServletRequest servletRequest,
        @RequestBody ShortenUrlRequest request) {
    String clientIp = clientIpResolver.resolve(servletRequest);
    UrlResponse response = shortenUrlUseCase.shorten(request.toCommand(clientIp));
    return ResponseEntity
            .ok(ApiResponse.success(response, "Successfully generated a short url."));
}

컨트롤러는 다음과 같은 단계를 거쳐 요청을 처리합니다:

  1. IP 주소 확인: 요청을 보낸 클라이언트의 IP 주소를 확인합니다. 이는 요청자의 정보 추적 및 잠재적인 분석을 위한 데이터로 활용됩니다.
  2. 비즈니스 로직 호출: 클라이언트로부터 전달받은 데이터를 단축 URL 생성 로직으로 전달합니다.
  3. 결과 반환: 단축 URL 생성 작업이 완료되면, HTTP 상태 코드 200과 함께 JSON 형식의 응답을 클라이언트에 반환합니다.

컨트롤러에서 위임된 요청은 애플리케이션 계층에서 처리됩니다. 애플리케이션 계층의 구현은 아래와 같습니다:

UrlService.java
@Override
public UrlResponse shorten(ShortenUrlCommand command) {
    Url url = registerIfAbsent(command);
    storeUrlCachePort.cache(url);

    log.info("shorten: Successfully generated a short url - id={}, shortUrl={}, clientIp={}",
            url.getId(),
            url.getShortUrl(),
            url.getClientIp());

    return new UrlResponse(url.getShortUrl(), url.getLongUrl());
}

private Url registerIfAbsent(ShortenUrlCommand command) {
    return !loadUrlPort.isUrlRegistered(command.url())
            ? persistUrlPort.persist(UrlFactory.newInstance(command))
            : loadUrlPort.load(command);
}

이 계층은 단축 URL 생성과 관련된 핵심 비즈니스 로직을 담당하며, 다음과 같은 작업을 수행합니다:

  1. 단축 URL 생성 및 등록: 요청된 URL이 이미 등록되어 있는지 확인하고, 등록되지 않은 경우 새로운 단축 URL을 생성하여 데이터베이스에 저장합니다. 이를 통해 중복된 URL 생성 및 리소스 낭비를 방지합니다.
  2. 캐싱 처리: 생성된 단축 URL은 캐시 계층에 저장됩니다. 이는 이후 동일한 요청에 대해 빠르게 응답할 수 있도록 데이터베이스 조회를 최소화하는 역할을 합니다.
  3. 응답 객체 작성 및 반환: 단축 URL 생성이 성공적으로 완료되면, 애플리케이션 계층에서 응답 객체를 작성하여 반환합니다. 이 객체는 생성된 단축 URL과 요청된 원본 URL을 포함합니다.

구현된 API가 성공적으로 단축 URL을 등록한 경우, 응답에는 HTTP 상태 코드 200과 함께 다음 정보가 포함됩니다:

Payload
{
  "status": "SUCCESS",
  "data": {
    "shortUrl": "890cfc",
    "originalUrl": "http://localhost:3000/posts/designing-a-scalable-url-shortener-system#database-schema-design"
  },
  "message": "Successfully generated a short url."
}
  • status: 요청 처리 결과를 나타내며, 성공 시 SUCCESS 값을 가집니다.
  • data: 생성된 단축 URL과 원본 URL이 포함된 객체입니다.
    • shortUrl: 생성된 단축 URL
    • originalUrl: 입력받은 원본 URL
  • message: 요청 결과에 대한 설명 메시지로, 단축 URL 생성이 성공했음을 알립니다.

이러한 응답 구조는 클라이언트가 요청 결과를 직관적으로 확인하고 단축 URL을 바로 활용할 수 있도록 간결하게 설계되었습니다.

구현된 API가 요구사항에 부합하는지 검증하기 위해 K6를 활용하여 테스트를 진행해보겠습니다. 이를 위해 다음과 같은 가상의 시나리오를 작성하였습니다:

부하 조건설정 값
요청 비율초당 100건
테스트 지속 시간10초
초기 가상 사용자 수100명
최대 가상 사용자 수200명

다음은 위에서 정의한 부하 테스트 조건을 실행하기 위해 작성된 테스트 스크립트입니다:

shorten-url-load-test.js
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    constant_rps: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '10s',
      preAllocatedVUs: 100,
      maxVUs: 200,
    },
  },
};

function generateRandomString(length) {
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
  let result = '';
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * characters.length));
  }
  return result;
}

function generateRandomUrl() {
  const base = 'https://www.catsriding.com/';
  const randomLength = Math.floor(Math.random() * (2048 - base.length)) + 1;
  const randomPath = generateRandomString(randomLength);
  return `${base}${randomPath}`;
}

export default function () {
  const url = 'http://localhost:8080/shorten';
  const payload = JSON.stringify({
    url: generateRandomUrl(),
  });

  const params = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  const res = http.post(url, payload, params);

  check(res, {
    'status is 200': (r) => r.status === 200,
  });
}

테스트를 실행하면 총 1,000건의 요청이 초당 100건의 비율로 처리됩니다. 각 요청의 응답 시간이 측정되며, 시스템의 성능을 평가하기 위한 주요 지표로 사용됩니다. 이 과정에서 평균 응답 시간, 요청 성공률, 그리고 처리 안정성을 확인할 수 있습니다:

$ k6 run shorten-url-load-test.js

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  ()  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: scripts/k6/shorten-url-load-test.js
        output: -

     scenarios: (100.00%) 1 scenario, 200 max VUs, 40s max duration (incl. graceful stop):
              * constant_rps: 100.00 iterations/s for 10s (maxVUs: 100-200, gracefulStop: 30s)


     ✓ status is 200

     checks.........................: 100.00% ✓ 10000    
     data_received..................: 146 kB  15 kB/s
     data_sent......................: 1.2 MB  118 kB/s
     http_req_blocked...............: avg=33.16µs  min=1µs    med=4µs     max=2.59ms   p(90)=149.3µs p(95)=225.05µs
     http_req_connecting............: avg=23.98µs  min=0s     med=0s      max=2.54ms   p(90)=12.2µs  p(95)=192µs   
     http_req_duration..............: avg=24.84ms  min=4.18ms med=11.23ms max=499.01ms p(90)=23.02ms p(95)=43.34ms 
       { expected_response:true }...: avg=24.84ms  min=4.18ms med=11.23ms max=499.01ms p(90)=23.02ms p(95)=43.34ms 
     http_req_failed................: 0.00%   ✓ 01000 
     http_req_receiving.............: avg=141.17µs min=10µs   med=70µs    max=2.79ms   p(90)=159µs   p(95)=296.49µs
     http_req_sending...............: avg=43.77µs  min=6µs    med=23µs    max=11.77ms  p(90)=44.1µs  p(95)=61.24µs 
     http_req_tls_handshaking.......: avg=0s       min=0s     med=0s      max=0s       p(90)=0s      p(95)=0s      
     http_req_waiting...............: avg=24.65ms  min=4.15ms med=11.1ms  max=498.71ms p(90)=22.63ms p(95)=43.21ms 
     http_reqs......................: 1000    99.846476/s
     iteration_duration.............: avg=26.77ms  min=4.88ms med=13.35ms max=503.28ms p(90)=24.94ms p(95)=47.94ms 
     iterations.....................: 1000    99.846476/s
     vus............................: 2       min=1       max=3  
     vus_max........................: 100     min=100     max=100


running (10.0s), 000/100 VUs, 1000 complete and 0 interrupted iterations
constant_rps ✓ [======================================] 000/100 VUs  10s  100.00 iters/s

부하 테스트를 통해 시스템이 설정된 조건에서 안정적으로 동작하는지 확인하였습니다. 테스트 결과는 다음과 같습니다:

  • 모든 요청이 성공적으로 처리되어 실패율은 0%를 기록하였습니다.
  • 평균 응답 시간은 약 24.84ms로, 성능 요구사항을 충족하였습니다.
  • 시스템은 부하 조건에서 안정적으로 동작하며, 데이터 손실이나 응답 지연 없이 설정된 시나리오를 유지하였습니다.

현재 테스트를 통해 생성된 1,000건의 데이터가 데이터베이스에 정확히 기록되었는지 확인할 필요가 있습니다. 이를 위해 아래 SQL 쿼리를 실행하여 전체 생성된 레코드 수와 단축 URL 길이에 따른 레코드 분포를 조회했습니다:

select
    count(urls.id)                       as `Total records`,     -- 전체 레코드 수
    sum(if(length(short_url) = 6, 1, 0)) as `Total of length 6`, -- 길이 6인 URL 수
    sum(if(length(short_url) = 7, 1, 0)) as `Total of length 7`, -- 길이 7인 URL 수
    sum(if(length(short_url) = 8, 1, 0)) as `Total of length 8`  -- 길이 8인 URL 수
from
    urls;

쿼리 결과, 데이터베이스가 현재 요구사항에 부합하는 쓰기 성능을 제공할 수 있을 것으로 판단됩니다:

Total recordsTotal of length 6Total of length 7Total of length 8
1,0001,00000

추가 테스트를 통해 부하를 점진적으로 증가시키며 최대 처리 가능 범위를 확인한 결과, 초당 500건까지는 안정적으로 처리되었으나, 이를 초과하면 성능 저하와 함께 요청 누락이 점차 발생하기 시작했습니다. 또한, 레코드 증가에 따라 일부 해시 충돌도 발생했습니다. 이는 현재 높은 수준의 안정성을 보이지만, 처리 성능을 한계까지 끌어올릴 경우 개선이 필요한 부분이 있음을 시사합니다. 🧪

Total recordsTotal of length 6Total of length 7Total of length 8
48,83148,769620

캐싱 메커니즘이 제대로 작동하는지 확인하는 과정도 중요합니다. URL 매핑 캐시 데이터는 단축 URL 복원 요청 시 데이터베이스 부하를 줄이고, 빠른 응답 속도를 제공하는 데 큰 역할을 할 것입니다. 🏎 💨

designing-a-scalable-url-shortener-system_05.png

Implementing URL Resolver API

단축 URL 복원 API는 생성된 단축 URL을 활용하여 원본 URL을 획득하고 사용자가 해당 URL로 자동으로 리디렉션되도록 구현됩니다. 이 API는 캐시와 데이터베이스 계층을 효율적으로 활용하여 빠른 응답성과 안정성을 제공합니다. 사용자 요청은 단축 URL을 통해 처리되며, 아래와 같은 HTTP 요청이 서버로 전송되어 원본 URL로 신속하게 리디렉션됩니다.

Request
GET /e9a128 HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.8 (Macintosh; OS X/15.2.0) GCDHTTPRequest

컨트롤러는 HTTP GET 요청을 처리하며, URL의 패스로 전달된 단축 URL 값을 바인딩하여 비즈니스 로직으로 처리를 위임하고, 그 처리 결과를 전달 받아 적절한 형태로 가공하여 클라이언트에게 반환합니다:

UrlController.java
@GetMapping("/{shortUrl}")
public ResponseEntity<?> urlResolverApi(
        HttpServletRequest servletRequest,
        @PathVariable String shortUrl) {
    String clientIp = clientIpResolver.resolve(servletRequest);
    UrlResponse response = resolveUrlUseCase.resolve(ResolveUrlRequest.bind(shortUrl).toQuery(clientIp));
    return ResponseEntity
            .status(MOVED_PERMANENTLY)
            .header(LOCATION, response.originalUrl())
            .header(CACHE_CONTROL, maxAge(1, DAYS).getHeaderValue())
            .body(ApiResponse.success(response, "Successfully resolved a url."));
}

컨트롤러는 단축 URL 복원 요청을 효율적으로 처리하기 위해 아래의 단계를 수행합니다:

  1. IP 주소 확인: 클라이언트의 IP 주소를 확인하여 요청 기록 및 분석에 활용합니다.
  2. 비즈니스 로직 호출: 전달받은 단축 URL을 기반으로 원본 URL을 조회하며, 캐시 및 데이터베이스 계층과 상호작용합니다.
  3. 결과 반환: HTTP 상태 코드 301을 사용하여 원본 URL로 리디렉션을 수행합니다. 원본 URL은 응답 헤더의 Location 필드에 포함하여, 클라이언트가 즉시 해당 URL로 리디렉션될 수 있도록 보장합니다. 또한, Cache-Control 설정을 통해 브라우저에서 일정 시간 동안 캐시하도록 설정합니다. 이는, 서버 부하를 효과적으로 줄이고 응답 속도를 높이는 데 기여합니다.

컨트롤러에서 위임된 URL 복원 처리는 애플리케이션 계층에서 이루어지며, 캐시와 데이터베이스를 활용하여 원본 URL을 찾아 반환합니다:

UrlService.java
@Override
public UrlResponse resolve(ResolveUrlQuery query) {
    CacheLookupResult<Url> result = lookupUrlCachePort.lookup(query);
    result = handleCacheMiss(query, result);

    log.info("resolve: Successfully resolved a url - id={}, shortUrl={}, clientIp={}",
            result.domain().getId(),
            result.domain().getShortUrl(),
            query.clientIp());

    return new UrlResponse(result.domain().getShortUrl(), result.domain().getLongUrl());
}

private CacheLookupResult<Url> handleCacheMiss(ResolveUrlQuery query, CacheLookupResult<Url> result) {
    if (result.isCacheHit()) return result;

    result = result.resolveCacheMiss(loadUrlPort.load(query));
    storeUrlCachePort.cache(result.domain());
    return result;
}

애플리케이션 계층의 주요 동작은 다음과 같습니다:

  1. 캐시 조회: 캐시 스토어에서 데이터를 조회합니다. 캐시 히트 여부에 따라 처리 방식이 달라집니다.
  2. 캐시 미스 처리: 캐시 데이터를 찾을 수 없는 경우, 데이터베이스에서 원본 URL 정보를 조회하여 결과를 반환합니다. 조회된 데이터는 캐시에 저장하여 향후 동일 요청에 대해 빠른 응답이 가능하도록 합니다.
  3. 응답 객체 작성 및 반환: 조회된 데이터를 기반으로 응답 객체를 생성하여 클라이언트에게 반환합니다.

캐시 조회 시, 캐시 히트 상태를 효율적으로 관리하기 위해 래퍼 클래스를 도입하여 캐시 데이터와 상태를 관리할 수 있도록 작성하였습니다:

UrlRedisAdapter.java
@Override
public CacheLookupResult<Url> lookup(ResolveUrlQuery query) {
    String key = generateKey(query.shortUrl());
    try {
        String serialized = redisTemplate.opsForValue().get(key);
        checkCacheHit(serialized);
        CachedUrl cachedUrl = objectMapper.readValue(serialized, CachedUrl.class);
        log.info("lookup: Cache Hit - key={}", key);
        return CacheLookupResult.hit(cachedUrl, CachedUrl::toDomain);
    } catch (CacheMissException e) {
        log.info("lookup: Cache Miss - key={}", key);
        return CacheLookupResult.miss();
    } catch (Exception e) {
        log.error("lookup: Failed to convert cache to domain - key={}", key);
        return CacheLookupResult.miss();
    }
}

캐시 조회 로직은 아래와 같은 단계로 처리됩니다:

  1. 캐시 데이터 검색: RedisTemplate을 사용해 캐시에 저장된 데이터를 키를 기반으로 조회합니다.
  2. 캐시 히트 검증: 캐시 스토어에서 데이터를 찾을 수 없는 경우, 예외를 발생시켜 곧바로 캐시 미스를 처리하는 로직으로 흐름을 제어합니다.
  3. 데이터 역직렬화: 캐시에서 조회한 데이터를 ObjectMapper를 사용해 전용 객체로 역직렬화합니다.
  4. 캐시 조회 결과 반환: 캐시 히트인 경우, 래퍼 클래스에 캐시 데이터와 함께 캐시 히트 상태로 기록하여 반환합니다. 캐시 미스가 발생하면, 래퍼 클래스에서 캐시 미스 상태로 설정하여 이후 데이터베이스 조회가 이어질 수 있도록 처리합니다.

지금까지 단축 URL 복원 API 구현이 완료되었습니다. 🎉 구현한 로직이 성공적으로 작동하면, 클라이언트는 HTTP 상태 코드 301과 함께 리디렉션 정보를 포함한 응답을 수신하게 됩니다. 이를 통해 원본 URL로 자동으로 이동하게 됩니다:

Response
HTTP/1.1 301 
Location: https://www.catsriding.com/posts/designing-a-scalable-url-shortener-system
Cache-Control: max-age=86400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 13 Jan 2025 05:41:53 GMT
Keep-Alive: timeout=60
Connection: keep-alive

현재 요청이 브라우저를 통한 것이 아닐 수 있습니다. 그래서, 원본 URL 값을 응답 페이로드에 JSON 형식으로 포함하여 다양한 클라이언트 환경에서 이에 대응할 수 있도록 하는 것이 좋을것 같습니다.

Payload
{
  "status": "SUCCESS",
  "data": {
    "shortUrl": "e9a128",
    "originalUrl": "https://www.catsriding.com/posts/implementing-unique-id-generator-based-on-snowflake-algorithm-in-java"
  },
  "message": "Successfully resolved a url."
}

이번에도 성능을 검증하기 위해 K6를 활용하여 부하 테스트를 진행해 보겠습니다. 요구사항은 초당 1,000건이지만, 단축 URL 생성 API 부하 테스트에서 생성된 캐시 데이터만 활용할 예정이라 더 높은 부하를 적용했습니다:

부하 조건설정 값
요청 비율초당 2,500건
테스트 지속 시간10초
초기 가상 사용자 수4000명
최대 가상 사용자 수5000명

위 부하 테스트 조건은 캐시 데이터 활용의 효율성을 검증하고, 시스템의 최대 처리 용량을 확인하기 위해 설정되었습니다. 이를 기반으로 작성된 스크립트는 다음과 같습니다:

resolve-url-load-test.js
import http from 'k6/http';
import { check } from 'k6';

const shortUrls = [
  '939518', 'd7b17e', 'c550ee', '46d81f', '2e7210', '526b04', '94cfcb', '23450e', 'acf6a7',
  '4b09e3', '8c9fb6', 'ba2ee1', '5129cd', '52bb47', 'bc3a0e', 'dfa586', '122f3b', '8a3333',
  'bdeccc', 'e6c5c3', 'b50ecf', '221bcd', '436579', '036ed1', '41211b', '7d5515', '2ed759',
  '1b9376', '99773f', 'e6366e', '6f5dd6', '2e321b', '685739', '2c5180', 'ac7442', '98e11a',
  '96298d', '2b0f87', '359802', '7099fc', '4a58c8', '6b8c45', '5d3521', '9a9942', '195e30',
  'a3be35', '88c94b', '34938c', '02735f', 'c9bb3a', '2caceb', '5dd273', 'e3b732', 'dc6694',
  'd703d8', 'a5a7b5', '15acff', 'ce0282', '2b7885', '423150', 'e5ce21', '3bab05', '90e8e6',
  'a7fdfb', '09c14f', '7bd6d7', '4cfb1e', 'a466be', '791256', 'f3c2c6', '475301', '6d3c46',
  '7ca711', '2460f3', '67b779', 'c6e6e6', 'f0a073', '6c0418', '7af9c6', '57ba92', 'b4b395',
  '844f5e', '259978', 'ba2316', '037b5e', 'bc68a9', '3aa37a', 'ef8b82', '851eb4', '93f243',
  'f387b5', '89dd67', 'fc610d', '756bff', '035cdf', '23960c', '3ad579', '3780d0', '4db0e1',
];

export const options = {
  scenarios: {
    constant_rps: {
      executor: 'constant-arrival-rate',
      rate: 2500,
      timeUnit: '1s',
      duration: '10s',
      preAllocatedVUs: 4000,
      maxVUs: 5000,
    },
  },
};

export default function () {
  const shortUrl = shortUrls[Math.floor(Math.random() * shortUrls.length)];
  const url = `http://localhost:8080/${shortUrl}`;

  const res = http.get(url, {
    redirects: 0,
  });

  check(res, {
    'status is 301': (r) => r.status === 301,
  });
}

테스트를 실행한 결과는 아래와 같습니다:

$ k6 run scripts/k6/resolve-url-load-test.js

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  ()  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: scripts/k6/resolve-url-load-test.js
        output: -

     scenarios: (100.00%) 1 scenario, 5000 max VUs, 40s max duration (incl. graceful stop):
              * constant_rps: 2500.00 iterations/s for 10s (maxVUs: 4000-5000, gracefulStop: 30s)


     ✓ status is 301

     checks.........................: 100.00% ✓ 250000     
     data_received..................: 58 MB   5.8 MB/s
     data_sent......................: 2.2 MB  215 kB/s
     http_req_blocked...............: avg=49.15µs min=0s      med=1µs      max=58.38ms p(90)=81µs  p(95)=96µs  
     http_req_connecting............: avg=43.46µs min=0s      med=0s       max=58.3ms  p(90)=63µs  p(95)=76µs  
     http_req_duration..............: avg=1.15ms  min=390µs   med=664µs    max=62.4ms  p(90)=925µs p(95)=1.49ms
       { expected_response:true }...: avg=1.15ms  min=390µs   med=664µs    max=62.4ms  p(90)=925µs p(95)=1.49ms
     http_req_failed................: 0.00%   ✓ 025000 
     http_req_receiving.............: avg=48.25µs min=9µs     med=46µs     max=3.2ms   p(90)=72µs  p(95)=79µs  
     http_req_sending...............: avg=7.15µs  min=2µs     med=5µs      max=453µs   p(90)=13µs  p(95)=16µs  
     http_req_tls_handshaking.......: avg=0s      min=0s      med=0s       max=0s      p(90)=0s    p(95)=0s    
     http_req_waiting...............: avg=1.09ms  min=363µs   med=609µs    max=62.2ms  p(90)=864µs p(95)=1.43ms
     http_reqs......................: 25000   2498.438975/s
     iteration_duration.............: avg=1.24ms  min=422.5µs med=712.33µs max=75.06ms p(90)=1ms   p(95)=1.55ms
     iterations.....................: 25000   2498.438975/s
     vus............................: 2       min=1         max=2   
     vus_max........................: 4000    min=4000      max=4000


running (10.0s), 0000/4000 VUs, 25000 complete and 0 interrupted iterations
constant_rps ✓ [======================================] 0000/4000 VUs  10s  2500.00 iters/s

테스트 결과를 분석해보면 다음과 같습니다:

  • 실패율 0%로, 모든 요청이 성공적으로 완료되었습니다.
  • 평균 응답 시간은 약 15ms로 매우 빠르게 처리되었습니다.
  • 부하 조건에 안정적으로 대응하며, 캐시가 효율적으로 작동했음을 확인할 수 있었습니다.

캐시 데이터를 활용한 조회 성능 테스트 결과, 초당 약 2,000건은 무난히 처리되었습니다. 하지만, 초당 2,500건을 넘는 부하를 가하는 경우, 요청이 실패하기 시작했습니다. 🫣 이는 기본 서버 설정에서 발생할 수 있는 쓰레드 풀 부족이나 큐 사이즈 초과와 같은 제약 때문일 가능성이 높습니다.

현재 작성한 시나리오에 대한 충분한 성능을 보여주고 있지만, 더 높은 부하를 처리해야 하는 상황에 대비한다면 다음과 같은 해결책을 고려할 수 있습니다:

  • 쓰레드 풀 크기 조정: 요청을 처리할 수 있는 쓰레드 수를 늘려, 동시 요청 처리 능력을 향상시킵니다.
  • 큐 사이즈 확대: 처리되지 않은 요청이 대기할 수 있는 큐의 크기를 조정하여 요청 손실을 방지합니다.
  • 리소스 최적화: 서버의 메모리와 CPU 리소스를 효율적으로 활용하기 위해 JVM 설정을 포함한 시스템 자원 최적화를 진행합니다. JVM 힙 크기 조정, 가비지 컬렉션(Garbage Collection) 전략 최적화 등을 통해 성능을 개선할 수 있습니다.
  • 메시지 큐 도입: 󱀏Apache Kafka와 같은 메시징 시스템을 활용해 요청을 비동기적으로 처리할 수 있는 구조를 설계합니다. 이를 통해 요청이 폭발적으로 증가하더라도 메시지 큐에 적재하여 순차적으로 처리함으로써 시스템 부하를 완화하고 처리 속도를 최적화할 수 있습니다.
  • 스케일링: 서버 인스턴스를 추가하여 요청 부하를 여러 서버에 분산시킵니다. 이 과정에서 로드 밸런서를 활용해 안정적인 분산 처리를 보장합니다.

이와 같은 설정 변경 및 최적화를 통해 더 높은 부하 상황에서도 안정적인 성능을 유지할 가능성을 높일 수 있습니다. 또한, 더 많은 부하 테스트를 통해 최적의 설정을 점진적으로 찾아가는 것이 중요합니다. 💭

Final Thoughts on URL Shortener System

지금까지 가상 면접 사례로 배우는 대규모 시스템 설계 기초를 기반으로, URL 단축기의 주요 요구사항을 도출하고 이를 점진적으로 구체화하며 시스템 디자인의 기초 원리를 살펴보았습니다.

현재 시스템은 로컬 환경에서의 테스트를 통해 기본적인 작동 원리와 일정 수준의 트래픽까지는 안정적으로 처리할 수 있음을 확인했습니다. 하지만 서비스 규모가 확장되고 더 높은 트래픽을 처리해야 하는 상황이 온다면, 분산 시스템 도입, URL 인코딩 방식 개선, 쓰기 요청 최적화, 대규모 요청 안정성 강화 등 여러 측면에서의 개선이 요구될 것입니다.

이러한 가상의 시나리오를 기반으로 시스템 디자인을 진행하며, 실무 경험을 강화하고 대규모 시스템 설계의 기초 원리를 적용해볼 수 있었습니다. 다음에는 또 다른 시나리오를 기반으로 새로운 시스템 설계와 구현을 시도해보겠습니다. 🏗

이 글에 포함된 코드는 주요 로직에 초점을 맞추기 위해 많은 부분이 생략되었습니다. 프로젝트의 전체 소스 코드는 이 🐙 GitHub Repository에서 확인할 수 있습니다.


  • Architecture