Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 49 additions & 24 deletions docs/04-experiment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 실험 한계

Expand All @@ -491,6 +515,8 @@ Sequence + Base62 방식이 더 단순하고 높은 처리량을 보였다.
- 고정 VU 및 단계적 VU 증가 방식에서는 응답 시간이 짧을수록 더 많은 요청이 발생한다.
- Stress Test의 k6 최종 결과는 모든 VU 구간을 합산한 값이므로, 특정 VU 구간의 값은 Grafana 시계열을 통해 판단했다.
- Redis Stress Test의 처리량 한계가 애플리케이션, Redis 또는 로컬 환경 중 어디에서 발생했는지는 추가로 분리하지 않았다.
- 단축 코드 생성 전략도 조건별 한 번만 측정해 Sequence와 Snowflake의 작은 차이가 실행 환경의 변동인지 확인하지 못했다.
- Snowflake 전략은 단일 애플리케이션 인스턴스에서만 실행했으며, 서로 다른 nodeId를 사용하는 다중 인스턴스 환경은 검증하지 않았다.

## 18. 후속 실험

Expand All @@ -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 비교
- [x] Sequence ID + Base62, Hash, Snowflake ID + Base62 비교
133 changes: 133 additions & 0 deletions results/short-code-generation/create-distributed-20vu.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
3 changes: 2 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@ app:
enabled: ${CACHE_ENABLED:true}

short-code:
strategy: ${SHORT_CODE_STRATEGY:sequence}
strategy: ${SHORT_CODE_STRATEGY:sequence}
node-id: ${SHORT_CODE_NODE_ID:1}
Loading