diff --git a/docker-compose.yml b/docker-compose.yml index bb40dd2..24de4bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: DB_POOL_MAX_SIZE: ${DB_POOL_MAX_SIZE:-10} SHORT_CODE_STRATEGY: ${SHORT_CODE_STRATEGY:-sequence} + SHORT_CODE_NODE_ID: ${SHORT_CODE_NODE_ID:-1} depends_on: mysql: diff --git a/docs/04-experiment.md b/docs/04-experiment.md index 88ab7d4..8231bb2 100644 --- a/docs/04-experiment.md +++ b/docs/04-experiment.md @@ -450,35 +450,59 @@ DB Only보다 약 2.16배 높은 전체 평균 처리량과 낮은 p95를 기록 ## 16. 단축 코드 생성 전략 비교 -Sequence ID + Base62 방식과 SHA-256 Hash + Base62 방식의 -단축 URL 생성 성능을 비교했다. +단축 코드 생성 방식에 따른 생성 API 성능을 비교했다. -두 방식 모두 Platform Thread, HikariCP 최대 커넥션 10개, -20 VU, 1분 조건에서 측정했으며, +- Sequence ID + Base62 +- SHA-256 Hash + Base62 +- Snowflake ID + Base62 + +모든 전략은 Platform Thread, HikariCP 최대 커넥션 10개, +20 VU, 1분 조건에서 측정했다. 실제 신규 생성 경로를 실행하기 위해 요청마다 서로 다른 URL을 사용했다. -| 지표 | Sequence + Base62 | Hash + Base62 | -|---|---:|---:| -| 요청 수 | 97,495 | 77,933 | -| RPS | 1,624.60 | 1,298.60 | -| 평균 응답 시간 | 12.12ms | 15.18ms | -| p95 | 22.23ms | 30.14ms | -| 최대 응답 시간 | 254.82ms | 467.43ms | -| 실패율 | 0% | 0% | +| 지표 | Sequence + Base62 | Hash + Base62 | Snowflake + Base62 | +|---|---:|---:|---:| +| 요청 수 | 97,495 | 77,933 | 99,631 | +| RPS | 1,624.60 | 1,298.60 | 1,660.20 | +| 평균 응답 시간 | 12.12ms | 15.18ms | 11.85ms | +| p95 | 22.23ms | 30.14ms | 21.52ms | +| 최대 응답 시간 | 254.82ms | 467.43ms | 274.23ms | +| 실패율 | 0% | 0% | 0% | + +각 전략의 저장 흐름은 다음과 같다. + +```text +Sequence +INSERT → Auto Increment ID 발급 → Base62 → UPDATE + +Hash +중복 코드 SELECT → SHA-256 → Base62 → INSERT + +Snowflake +분산 ID 생성 → Base62 → INSERT +``` +Snowflake 방식은 단일 실행에서 가장 높은 RPS와 +가장 낮은 평균·p95 응답 시간을 기록했다. + +Sequence 방식도 Snowflake 방식과 비슷한 성능을 보였지만, +DB에서 ID를 발급받은 후 short_code를 갱신하기 때문에 +생성 요청마다 INSERT와 UPDATE가 발생한다. + +Hash 방식은 충돌 확인을 위한 조회와 저장 재시도 처리가 필요해 +세 전략 중 가장 낮은 처리량과 가장 높은 응답 지연을 기록했다. -이번 측정에서 Sequence 방식은 Hash 방식보다 RPS가 약 25.1% 높았고, -평균 응답 시간과 p95도 더 낮았다. +Snowflake 방식은 DB ID 발급이나 사전 충돌 조회 없이 +한 번의 INSERT로 저장할 수 있다는 장점이 있다. +다만 서버별 nodeId 관리, 시스템 시간 역행 처리, +생성기 동기화와 같은 운영 고려사항이 추가된다. -현재 구현에서 Sequence 방식은 `INSERT → ID 발급 → short_code UPDATE`를 수행한다. -Hash 방식은 `중복 코드 조회 → SHA-256 계산 → INSERT`를 수행하며, -저장 충돌 재시도를 위해 저장 트랜잭션을 분리했다. +현재 단일 MySQL 환경에서는 Sequence 방식이 가장 단순하다. +다중 애플리케이션 인스턴스로 확장할 경우에는 +DB Auto Increment 의존성이 없는 Snowflake 방식을 적용할 수 있다. -**로컬 단일 MySQL 환경에서는 충돌 검사와 재시도가 필요 없는 -Sequence + Base62 방식이 더 단순하고 높은 처리량을 보였다. -따라서 현재 기본 생성 전략으로 Sequence 방식을 유지한다.** +단, 전략별 한 번만 측정했으며 Sequence와 Snowflake의 차이가 작으므로 +이번 결과만으로 Snowflake의 성능 우위를 일반화할 수는 없다. -다만 전략별 한 번만 측정했으며, -해시 연산과 DB 조회·트랜잭션 비용을 각각 분리해 측정하지는 않았다. ## 17. 실험 한계 @@ -491,6 +515,8 @@ Sequence + Base62 방식이 더 단순하고 높은 처리량을 보였다. - 고정 VU 및 단계적 VU 증가 방식에서는 응답 시간이 짧을수록 더 많은 요청이 발생한다. - Stress Test의 k6 최종 결과는 모든 VU 구간을 합산한 값이므로, 특정 VU 구간의 값은 Grafana 시계열을 통해 판단했다. - Redis Stress Test의 처리량 한계가 애플리케이션, Redis 또는 로컬 환경 중 어디에서 발생했는지는 추가로 분리하지 않았다. +- 단축 코드 생성 전략도 조건별 한 번만 측정해 Sequence와 Snowflake의 작은 차이가 실행 환경의 변동인지 확인하지 못했다. +- Snowflake 전략은 단일 애플리케이션 인스턴스에서만 실행했으며, 서로 다른 nodeId를 사용하는 다중 인스턴스 환경은 검증하지 않았다. ## 18. 후속 실험 @@ -501,5 +527,4 @@ Sequence + Base62 방식이 더 단순하고 높은 처리량을 보였다. - [x] Platform Thread와 Virtual Thread 비교 - [x] HikariCP Pool 크기 비교 - [x] 더 높은 VU로 Stress Test 수행 -- [x] Sequence ID + Base62와 Hash + 충돌 처리 비교 -- [ ] 분산 ID + Base62 비교 \ No newline at end of file +- [x] Sequence ID + Base62, Hash, Snowflake ID + Base62 비교 diff --git a/results/short-code-generation/create-distributed-20vu.json b/results/short-code-generation/create-distributed-20vu.json new file mode 100644 index 0000000..4fd2f35 --- /dev/null +++ b/results/short-code-generation/create-distributed-20vu.json @@ -0,0 +1,133 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": {}, + "checks": { + "shorten status is 2xx": { + "path": "::shorten status is 2xx", + "id": "9d3cc5a6164524f56dde377dfce89cb7", + "passes": 99631, + "fails": 0, + "name": "shorten status is 2xx" + } + } + }, + "metrics": { + "iterations": { + "count": 99631, + "rate": 1660.2013944218659 + }, + "http_req_duration": { + "med": 10.133, + "max": 274.231, + "p(90)": 17.065, + "p(95)": 21.5235, + "avg": 11.853619034236337, + "min": 3.303, + "thresholds": { + "p(95)<1000": false + } + }, + "http_req_duration{expected_response:true}": { + "p(90)": 17.065, + "p(95)": 21.5235, + "avg": 11.853619034236337, + "min": 3.303, + "med": 10.133, + "max": 274.231 + }, + "http_req_blocked": { + "min": 0.001, + "med": 0.004, + "max": 11.009, + "p(90)": 0.007, + "p(95)": 0.008, + "avg": 0.017452319057307094 + }, + "checks": { + "fails": 0, + "passes": 99631, + "value": 1 + }, + "vus": { + "value": 20, + "min": 20, + "max": 20 + }, + "http_req_waiting": { + "avg": 11.731666067790302, + "min": 3.254, + "med": 10.03, + "max": 272.117, + "p(90)": 16.9, + "p(95)": 21.329 + }, + "http_req_connecting": { + "avg": 0.010909666670012346, + "min": 0, + "med": 0, + "max": 7.403, + "p(90)": 0, + "p(95)": 0 + }, + "http_req_tls_handshaking": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + }, + "http_req_receiving": { + "med": 0.049, + "max": 56.28, + "p(90)": 0.179, + "p(95)": 0.335, + "avg": 0.10149857975931394, + "min": 0.013 + }, + "http_req_sending": { + "max": 50.63, + "p(90)": 0.028, + "p(95)": 0.035, + "avg": 0.020454386686873997, + "min": 0.005, + "med": 0.016 + }, + "data_sent": { + "count": 20456988, + "rate": 340885.0659259806 + }, + "data_received": { + "count": 19645965, + "rate": 327370.5823264162 + }, + "vus_max": { + "max": 20, + "value": 20, + "min": 20 + }, + "iteration_duration": { + "avg": 12.025698954783055, + "min": 3.421208, + "med": 10.291916, + "max": 275.632167, + "p(90)": 17.2705, + "p(95)": 21.756729 + }, + "http_req_failed": { + "passes": 0, + "fails": 99631, + "thresholds": { + "rate<0.01": false + }, + "value": 0 + }, + "http_reqs": { + "count": 99631, + "rate": 1660.2013944218659 + } + } +} \ No newline at end of file diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/creation/DistributedShortUrlCreationStrategy.java b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/DistributedShortUrlCreationStrategy.java new file mode 100644 index 0000000..07ed86f --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/DistributedShortUrlCreationStrategy.java @@ -0,0 +1,31 @@ +package com.backendsystemdesignlab.urlshortener.creation; + +import com.backendsystemdesignlab.urlshortener.encoding.Base62Encoder; +import com.backendsystemdesignlab.urlshortener.generator.SnowflakeIdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.short-code", + name = "strategy", + havingValue = "distributed" +) +public class DistributedShortUrlCreationStrategy implements ShortUrlCreationStrategy { + + private final SnowflakeIdGenerator snowflakeIdGenerator; + private final Base62Encoder base62Encoder; + private final ShortUrlWriter shortUrlWriter; + + @Override + public String create(String longUrl) { + long distributedId = snowflakeIdGenerator.nextId(); + String shortCode = base62Encoder.encode(distributedId); + + shortUrlWriter.save(shortCode, longUrl); + + return shortCode; + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGenerator.java b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGenerator.java new file mode 100644 index 0000000..de4a864 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGenerator.java @@ -0,0 +1,86 @@ +package com.backendsystemdesignlab.urlshortener.generator; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Component +public class SnowflakeIdGenerator { + + // 0 | timestamp 41비트 | nodeId 10비트 | sequence 12비트 + /* + * 2026-01-01T00:00:00Z + * + * Unix Epoch 전체를 저장하지 않고 프로젝트 전용 기준 시각부터의 + * 경과 시간을 저장해 41비트를 더 오래 사용할 수 있게 한다. + */ + private static final long CUSTOM_EPOCH = Instant.parse("2026-01-01T00:00:00Z").toEpochMilli(); + private static final int NODE_ID_BITS = 10; // 최대 1024개의 서버 구분 + private static final int SEQUENCE_BITS = 12; // 한 서버당 1 밀리초에 최대 4096개 ID 생성 가능 + + private static final long MAX_NODE_ID = (1L << NODE_ID_BITS) - 1; + private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1; + + private static final int NODE_ID_SHIFT = SEQUENCE_BITS; + private static final int TIMESTAMP_SHIFT = SEQUENCE_BITS + NODE_ID_BITS; + + private final long nodeId; // 서버 번호 + + private long lastTimestamp = -1L; // 마지막 생성 시각 + private long sequence = 0L; // 같은 밀리초 안에서의 순번 + + public SnowflakeIdGenerator(@Value("${app.short-code.node-id:1}") long nodeId) { + if (nodeId < 0 || nodeId > MAX_NODE_ID) { + throw new IllegalArgumentException("nodeId는 0 이상 " + MAX_NODE_ID + " 이하여야 합니다."); + } + this.nodeId = nodeId; + } + + public synchronized long nextId() { + // 한 번에 한 스레드만 이 메서드를 실행 + // 단, synchronized는 현재 애플리케이션 프로세스 내부에서만 동작 + // 서버가 여러 대라면 서버 간 중복은 서로 다른 nodeId로 방지 + long currentTimestamp = currentTimeMillis(); + + if (currentTimestamp < lastTimestamp) { // 원인 : NTP 시간 보정, 가상머신 시간 변경, 서버 시간 수동 변경 + throw new IllegalStateException("시스템 시간이 이전 시각으로 이동했습니다."); + } + + if (currentTimestamp == lastTimestamp) { // 같은 밀리초 안에서 여러 ID를 만들고 있다 + sequence = (sequence + 1) & MAX_SEQUENCE; + + /* + * 같은 밀리초에 4,096개의 ID를 모두 사용한 경우 + * 다음 밀리초까지 기다린다. + */ + if (sequence == 0) { + currentTimestamp = waitUntilNextMillis(lastTimestamp); + } + } else { + sequence = 0L; + } + + lastTimestamp = currentTimestamp; + + long timeStampPart = (currentTimestamp - CUSTOM_EPOCH) << TIMESTAMP_SHIFT; + long nodePart = nodeId << NODE_ID_SHIFT; + + return timeStampPart | nodePart | sequence; + } + + private long waitUntilNextMillis(long timestamp) { + long currentTimestamp = currentTimeMillis(); + + while (currentTimestamp <= timestamp) { + Thread.onSpinWait(); // 현재 스레드는 아주 짧은 시간 동안 반복문을 돌며 시간을 확인 + currentTimestamp = currentTimeMillis(); + } + return currentTimestamp; + } + + //테스트할 때 시간을 직접 제어 + protected long currentTimeMillis() { + return System.currentTimeMillis(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8702dd1..c71d33a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -59,4 +59,5 @@ app: enabled: ${CACHE_ENABLED:true} short-code: - strategy: ${SHORT_CODE_STRATEGY:sequence} \ No newline at end of file + strategy: ${SHORT_CODE_STRATEGY:sequence} + node-id: ${SHORT_CODE_NODE_ID:1} \ No newline at end of file diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/creation/DistributedShortUrlCreationStrategyTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/creation/DistributedShortUrlCreationStrategyTest.java new file mode 100644 index 0000000..0f04c2e --- /dev/null +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/creation/DistributedShortUrlCreationStrategyTest.java @@ -0,0 +1,47 @@ +package com.backendsystemdesignlab.urlshortener.creation; + +import com.backendsystemdesignlab.urlshortener.encoding.Base62Encoder; +import com.backendsystemdesignlab.urlshortener.generator.SnowflakeIdGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class DistributedShortUrlCreationStrategyTest { + + @InjectMocks + private DistributedShortUrlCreationStrategy strategy; + + @Mock + private SnowflakeIdGenerator snowflakeIdGenerator; + + @Mock + private Base62Encoder base62Encoder; + + @Mock + private ShortUrlWriter shortUrlWriter; + + @Test + void 분산_ID를_BASE62로_변환해_저장한다() { + String longUrl = "https://example.com/distributed"; + long distributedId = 123456789L; + String shortCode = "8M0kX"; + + given(snowflakeIdGenerator.nextId()).willReturn(distributedId); + given(base62Encoder.encode(distributedId)).willReturn(shortCode); + + String result = strategy.create(longUrl); + + assertThat(result).isEqualTo(shortCode); + then(snowflakeIdGenerator).should().nextId(); + then(base62Encoder).should().encode(distributedId); + then(shortUrlWriter).should().save(shortCode, longUrl); + } +} \ No newline at end of file diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java new file mode 100644 index 0000000..4ea8e4f --- /dev/null +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java @@ -0,0 +1,71 @@ +package com.backendsystemdesignlab.urlshortener.generator; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class SnowflakeIdGeneratorTest { + + @Test + void 생성된_ID는_양수이다() { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1L); + + long id = generator.nextId(); + + assertThat(id).isPositive(); + } + + @Test + void 연속으로_생성한_ID는_서로_다르다() { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1L); + + long firstId = generator.nextId(); + long secondId = generator.nextId(); + + assertThat(firstId).isNotEqualTo(secondId); + assertThat(secondId).isGreaterThan(firstId); + } + + @Test + void 많은_ID를_생성해도_중복되지_않는다() { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1L); + int count = 100_000; + + List ids = IntStream + .range(0, count) + .mapToObj(index -> generator.nextId()) + .toList(); + Set uniqueIds = new HashSet<>(ids); + + assertThat(uniqueIds).hasSize(count); + } + + @Test + void 여러_스레드에서_생성해도_중복되지_않는다() { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1L); + int count = 100_000; + + List ids = IntStream + .range(0, count) + .parallel() + .mapToObj(index -> generator.nextId()) + .toList(); + Set uniqueIds = new HashSet<>(ids); + + assertThat(uniqueIds).hasSize(count); + } + + @Test + void nodeId가_허용_범위를_벗어나면_예외가_발생한다() { + assertThatThrownBy( + () -> new SnowflakeIdGenerator(1024L) + ).isInstanceOf(IllegalArgumentException.class); + } +} \ No newline at end of file