diff --git a/docker-compose.yml b/docker-compose.yml index 14c2c26..bb40dd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,8 @@ services: DB_POOL_MAX_SIZE: ${DB_POOL_MAX_SIZE:-10} + SHORT_CODE_STRATEGY: ${SHORT_CODE_STRATEGY:-sequence} + depends_on: mysql: condition: service_healthy diff --git a/docs/04-experiment.md b/docs/04-experiment.md index d89b22c..88ab7d4 100644 --- a/docs/04-experiment.md +++ b/docs/04-experiment.md @@ -448,7 +448,39 @@ DB Only보다 약 2.16배 높은 전체 평균 처리량과 낮은 p95를 기록 ![Redis Stress Test](images/stress-redis.png) -## 16. 실험 한계 +## 16. 단축 코드 생성 전략 비교 + +Sequence ID + Base62 방식과 SHA-256 Hash + Base62 방식의 +단축 URL 생성 성능을 비교했다. + +두 방식 모두 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 방식은 Hash 방식보다 RPS가 약 25.1% 높았고, +평균 응답 시간과 p95도 더 낮았다. + +현재 구현에서 Sequence 방식은 `INSERT → ID 발급 → short_code UPDATE`를 수행한다. +Hash 방식은 `중복 코드 조회 → SHA-256 계산 → INSERT`를 수행하며, +저장 충돌 재시도를 위해 저장 트랜잭션을 분리했다. + +**로컬 단일 MySQL 환경에서는 충돌 검사와 재시도가 필요 없는 +Sequence + Base62 방식이 더 단순하고 높은 처리량을 보였다. +따라서 현재 기본 생성 전략으로 Sequence 방식을 유지한다.** + +다만 전략별 한 번만 측정했으며, +해시 연산과 DB 조회·트랜잭션 비용을 각각 분리해 측정하지는 않았다. + +## 17. 실험 한계 - 로컬 Docker 환경에서 실행했다. - k6, 애플리케이션, MySQL, Redis가 같은 장비의 자원을 사용했다. @@ -460,7 +492,7 @@ DB Only보다 약 2.16배 높은 전체 평균 처리량과 낮은 p95를 기록 - Stress Test의 k6 최종 결과는 모든 VU 구간을 합산한 값이므로, 특정 VU 구간의 값은 Grafana 시계열을 통해 판단했다. - Redis Stress Test의 처리량 한계가 애플리케이션, Redis 또는 로컬 환경 중 어디에서 발생했는지는 추가로 분리하지 않았다. -## 17. 후속 실험 +## 18. 후속 실험 - [x] Redis Cache Aside 적용 - [x] Redis 적용 전후 부하 테스트 @@ -469,8 +501,5 @@ DB Only보다 약 2.16배 높은 전체 평균 처리량과 낮은 p95를 기록 - [x] Platform Thread와 Virtual Thread 비교 - [x] HikariCP Pool 크기 비교 - [x] 더 높은 VU로 Stress Test 수행 -- [ ] 조건별 3회 측정 후 중앙값 비교 -- [ ] 단축 코드 생성 전략 비교 - - Sequence ID + Base62 - - Hash + 충돌 처리 - - 분산 ID + Base62 \ No newline at end of file +- [x] Sequence ID + Base62와 Hash + 충돌 처리 비교 +- [ ] 분산 ID + Base62 비교 \ No newline at end of file diff --git a/k6/create-load.js b/k6/create-load.js new file mode 100644 index 0000000..37fcd4d --- /dev/null +++ b/k6/create-load.js @@ -0,0 +1,44 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + vus: Number(__ENV.VUS || 20), + duration: __ENV.DURATION || '1m', + + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<1000'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +const RUN_ID = __ENV.RUN_ID || 'local'; + +export default function () { + /* + * Hash 방식에서 같은 URL을 반복 요청하면 + * 기존 코드를 반환하는 조회 경로가 실행된다. + * + * 실제 생성 성능을 비교하기 위해 요청마다 + * 서로 다른 longUrl을 사용한다. + */ + const longUrl = `https://example.com/${RUN_ID}/${__VU}/${__ITER}`; + + const response = http.post( + `${BASE_URL}/api/v1/data/shorten`, + JSON.stringify({ + longUrl: longUrl, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + check(response, { + 'shorten status is 2xx': (res) => + res.status >= 200 && res.status < 300, + }); +} \ No newline at end of file diff --git a/results/short-code-generation/create-hash-20vu.json b/results/short-code-generation/create-hash-20vu.json new file mode 100644 index 0000000..fc63018 --- /dev/null +++ b/results/short-code-generation/create-hash-20vu.json @@ -0,0 +1,133 @@ +{ + "metrics": { + "http_reqs": { + "count": 77933, + "rate": 1298.5955861946923 + }, + "http_req_sending": { + "avg": 0.02495120167322926, + "min": 0.006, + "med": 0.02, + "max": 41.789, + "p(90)": 0.032, + "p(95)": 0.038 + }, + "http_req_duration": { + "max": 467.427, + "p(90)": 23.289800000000003, + "p(95)": 30.137199999999954, + "avg": 15.17851957450626, + "min": 3.908, + "med": 12.146, + "thresholds": { + "p(95)<1000": false + } + }, + "http_req_failed": { + "passes": 0, + "fails": 77933, + "thresholds": { + "rate<0.01": false + }, + "value": 0 + }, + "iteration_duration": { + "avg": 15.378397084457212, + "min": 4.021083, + "med": 12.337708, + "max": 467.778625, + "p(90)": 23.507333600000003, + "p(95)": 30.383824999999963 + }, + "http_req_connecting": { + "max": 6.642, + "p(90)": 0, + "p(95)": 0, + "avg": 0.011304902929439387, + "min": 0, + "med": 0 + }, + "iterations": { + "count": 77933, + "rate": 1298.5955861946923 + }, + "data_received": { + "count": 14899795, + "rate": 248274.90308605786 + }, + "vus_max": { + "min": 20, + "max": 20, + "value": 20 + }, + "http_req_waiting": { + "p(95)": 29.860799999999987, + "avg": 15.011057921547803, + "min": 3.855, + "med": 12.011, + "max": 467.278, + "p(90)": 23.088 + }, + "vus": { + "min": 20, + "max": 20, + "value": 20 + }, + "http_req_receiving": { + "avg": 0.14251045128507617, + "min": 0.013, + "med": 0.066, + "max": 110.449, + "p(90)": 0.261, + "p(95)": 0.454 + }, + "checks": { + "passes": 77933, + "fails": 0, + "value": 1 + }, + "data_sent": { + "count": 15451387, + "rate": 257466.06647743637 + }, + "http_req_tls_handshaking": { + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0, + "med": 0 + }, + "http_req_duration{expected_response:true}": { + "avg": 15.17851957450626, + "min": 3.908, + "med": 12.146, + "max": 467.427, + "p(90)": 23.289800000000003, + "p(95)": 30.137199999999954 + }, + "http_req_blocked": { + "p(95)": 0.008, + "avg": 0.018988143661864423, + "min": 0.001, + "med": 0.005, + "max": 9.821, + "p(90)": 0.007 + } + }, + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": {}, + "checks": { + "shorten status is 2xx": { + "name": "shorten status is 2xx", + "path": "::shorten status is 2xx", + "id": "9d3cc5a6164524f56dde377dfce89cb7", + "passes": 77933, + "fails": 0 + } + } + } +} \ No newline at end of file diff --git a/results/short-code-generation/create-sequence-20vu.json b/results/short-code-generation/create-sequence-20vu.json new file mode 100644 index 0000000..0c038ba --- /dev/null +++ b/results/short-code-generation/create-sequence-20vu.json @@ -0,0 +1,133 @@ +{ + "root_group": { + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": {}, + "checks": { + "shorten status is 2xx": { + "name": "shorten status is 2xx", + "path": "::shorten status is 2xx", + "id": "9d3cc5a6164524f56dde377dfce89cb7", + "passes": 97495, + "fails": 0 + } + }, + "name": "", + "path": "" + }, + "metrics": { + "vus": { + "value": 20, + "min": 20, + "max": 20 + }, + "http_req_duration{expected_response:true}": { + "p(90)": 17.139, + "p(95)": 22.233500000000014, + "avg": 12.123366131596507, + "min": 3.792, + "med": 10.596, + "max": 254.824 + }, + "http_req_connecting": { + "avg": 0.010780368224011473, + "min": 0, + "med": 0, + "max": 6.77, + "p(90)": 0, + "p(95)": 0 + }, + "iteration_duration": { + "p(95)": 22.4492, + "avg": 12.292302568716453, + "min": 3.893917, + "med": 10.750541, + "max": 255.035792, + "p(90)": 17.335675200000008 + }, + "http_req_duration": { + "med": 10.596, + "max": 254.824, + "p(90)": 17.139, + "p(95)": 22.233500000000014, + "avg": 12.123366131596507, + "min": 3.792, + "thresholds": { + "p(95)<1000": false + } + }, + "http_req_failed": { + "passes": 0, + "fails": 97495, + "thresholds": { + "rate<0.01": false + }, + "value": 0 + }, + "http_reqs": { + "count": 97495, + "rate": 1624.5978934833338 + }, + "http_req_blocked": { + "med": 0.004, + "max": 9.566, + "p(90)": 0.007, + "p(95)": 0.008, + "avg": 0.017171649828190052, + "min": 0.001 + }, + "vus_max": { + "value": 20, + "min": 20, + "max": 20 + }, + "checks": { + "passes": 97495, + "fails": 0, + "value": 1 + }, + "http_req_receiving": { + "min": 0.013, + "med": 0.052, + "max": 24.353, + "p(90)": 0.196, + "p(95)": 0.354, + "avg": 0.10637411149289834 + }, + "http_req_tls_handshaking": { + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0 + }, + "http_req_waiting": { + "p(95)": 22.03110000000002, + "avg": 11.996838001948772, + "min": 3.455, + "med": 10.489, + "max": 250.847, + "p(90)": 16.962600000000005 + }, + "http_req_sending": { + "p(95)": 0.033, + "avg": 0.020154018154776295, + "min": 0.005, + "med": 0.017, + "max": 5.25, + "p(90)": 0.027 + }, + "data_received": { + "count": 17852017, + "rate": 297475.24706527166 + }, + "iterations": { + "count": 97495, + "rate": 1624.5978934833338 + }, + "data_sent": { + "count": 19725426, + "rate": 328692.60503268254 + } + } +} \ No newline at end of file diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategy.java b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategy.java new file mode 100644 index 0000000..6dc0dc9 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategy.java @@ -0,0 +1,62 @@ +package com.backendsystemdesignlab.urlshortener.creation; + +import com.backendsystemdesignlab.urlshortener.exception.ShortCodeGenerationException; +import com.backendsystemdesignlab.urlshortener.generator.HashShortCodeGenerator; +import com.backendsystemdesignlab.urlshortener.generator.ShortCodeGenerationContext; +import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; +import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.short-code", + name = "strategy", + havingValue = "hash" +) +public class HashShortUrlCreationStrategy implements ShortUrlCreationStrategy { + + private static final int MAX_ATTEMPTS = 5; + + private final ShortUrlRepository shortUrlRepository; + private final HashShortCodeGenerator hashShortCodeGenerator; + private final ShortUrlWriter shortUrlWriter; + + @Override + public String create(String longUrl) { + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + String shortCode = hashShortCodeGenerator.generate(ShortCodeGenerationContext.hash(longUrl, attempt)); + + Optional existing = shortUrlRepository.findByShortCode(shortCode); + + if(existing.isPresent()) { + if (existing.get().getLongUrl().equals(longUrl)) { + return shortCode; + } + continue; + } + + try { + shortUrlWriter.save(shortCode, longUrl); + return shortCode; + } catch (DataIntegrityViolationException exception) { + /* + * findByShortCode() 이후 다른 요청이 + * 같은 코드를 먼저 저장했을 수 있다. (동시성) + */ + Optional concurrentResult = shortUrlRepository.findByShortCode(shortCode); + if (concurrentResult.isPresent() && concurrentResult.get().getLongUrl().equals(longUrl)) { + return shortCode; + } + } + } + + throw new ShortCodeGenerationException(); + } + +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/creation/SequenceShortUrlCreationStrategy.java b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/SequenceShortUrlCreationStrategy.java new file mode 100644 index 0000000..f4dcba9 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/SequenceShortUrlCreationStrategy.java @@ -0,0 +1,42 @@ +package com.backendsystemdesignlab.urlshortener.creation; + +import com.backendsystemdesignlab.urlshortener.generator.SequenceBase62Generator; +import com.backendsystemdesignlab.urlshortener.generator.ShortCodeGenerationContext; +import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; +import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.short-code", + name = "strategy", + havingValue = "sequence", + matchIfMissing = true +) +public class SequenceShortUrlCreationStrategy implements ShortUrlCreationStrategy { + + private final ShortUrlRepository shortUrlRepository; + private final SequenceBase62Generator sequenceBase62Generator; + + @Override + @Transactional + public String create(String longUrl) { + ShortUrl shortUrl = ShortUrl.create(longUrl); + ShortUrl savedShortUrl = shortUrlRepository.save(shortUrl); + + String shortCode = sequenceBase62Generator.generate( + ShortCodeGenerationContext.sequece( + longUrl, + savedShortUrl.getId() + ) + ); + + savedShortUrl.assignShortCode(shortCode); // Dirty Checking + + return shortCode; + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlCreationStrategy.java b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlCreationStrategy.java new file mode 100644 index 0000000..3611552 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlCreationStrategy.java @@ -0,0 +1,6 @@ +package com.backendsystemdesignlab.urlshortener.creation; + +public interface ShortUrlCreationStrategy { + + String create(String longUrl); +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlWriter.java b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlWriter.java new file mode 100644 index 0000000..f19a759 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlWriter.java @@ -0,0 +1,21 @@ +package com.backendsystemdesignlab.urlshortener.creation; + +import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; +import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class ShortUrlWriter { + + private final ShortUrlRepository shortUrlRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) // 기존 트랜잭션과 분리된 새 트랜잭션을 시작 + public void save(String shortCode, String longUrl) { + ShortUrl shortUrl = ShortUrl.create(shortCode, longUrl); + shortUrlRepository.saveAndFlush(shortUrl); + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/exception/ShortCodeGenerationException.java b/src/main/java/com/backendsystemdesignlab/urlshortener/exception/ShortCodeGenerationException.java new file mode 100644 index 0000000..f272226 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/exception/ShortCodeGenerationException.java @@ -0,0 +1,7 @@ +package com.backendsystemdesignlab.urlshortener.exception; + +public class ShortCodeGenerationException extends RuntimeException { + public ShortCodeGenerationException() { + super("충돌로 인해 단축 코드를 생성하지 못했습니다."); + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGenerator.java b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGenerator.java new file mode 100644 index 0000000..1006683 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGenerator.java @@ -0,0 +1,65 @@ +package com.backendsystemdesignlab.urlshortener.generator; + +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Component +public class HashShortCodeGenerator implements ShortCodeGenerator { + + private static final String ALPHABET = + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + private static final BigInteger BASE = + BigInteger.valueOf(ALPHABET.length()); + + private static final int CODE_LENGTH = 7; + + private static final BigInteger CODE_SPACE = BASE.pow(CODE_LENGTH); + + @Override + public String generate(ShortCodeGenerationContext context) { + String longUrl = context.longUrl(); + + if (longUrl == null || longUrl.isBlank()) { + throw new IllegalArgumentException("Hash 방식에는 원본 URL이 필요합니다."); + } + + String source = longUrl + ":" + context.attempt(); + byte[] digest = sha256(source); + + BigInteger value = new BigInteger(1, digest) // byte[] -> BigInteger (256진수) 양수인 큰 정수 + .mod(CODE_SPACE); // 0 ~ 62^7 - 1 + + return encodeFixedLength(value); + } + + private byte[] sha256(String source) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + return messageDigest.digest(source.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException( + "SHA-256 알고리즘을 사용할 수 없습니다.", + exception + ); + } + } + + private String encodeFixedLength(BigInteger value) { + char[] result = new char[CODE_LENGTH]; + + for (int index = CODE_LENGTH - 1; index >= 0; index--) { + BigInteger[] division = value.divideAndRemainder(BASE); // [0]: 몫, [1]: 나머지 + + result[index] = ALPHABET.charAt(division[1].intValue()); + + value = division[0]; + } + + return new String(result); + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SequenceBase62Generator.java b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SequenceBase62Generator.java new file mode 100644 index 0000000..edf4849 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SequenceBase62Generator.java @@ -0,0 +1,23 @@ +package com.backendsystemdesignlab.urlshortener.generator; + +import com.backendsystemdesignlab.urlshortener.encoding.Base62Encoder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SequenceBase62Generator implements ShortCodeGenerator { + + private final Base62Encoder base62Encoder; + + @Override + public String generate(ShortCodeGenerationContext context) { + Long id = context.sequenceId(); + + if (id == null) { + throw new IllegalArgumentException("Sequence 방식은 DB ID가 필요합니다."); + } + + return base62Encoder.encode(id); + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerationContext.java b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerationContext.java new file mode 100644 index 0000000..ba20087 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerationContext.java @@ -0,0 +1,29 @@ +package com.backendsystemdesignlab.urlshortener.generator; + +public record ShortCodeGenerationContext( + String longUrl, + Long sequenceId, + int attempt +) { + public static ShortCodeGenerationContext sequece( + String longUrl, + Long sequenceId + ) { + return new ShortCodeGenerationContext( + longUrl, + sequenceId, + 0 + ); + } + + public static ShortCodeGenerationContext hash( + String longUrl, + int attempt + ) { + return new ShortCodeGenerationContext( + longUrl, + null, + attempt + ); + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerator.java b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerator.java new file mode 100644 index 0000000..5ea8b36 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerator.java @@ -0,0 +1,6 @@ +package com.backendsystemdesignlab.urlshortener.generator; + +public interface ShortCodeGenerator { + + String generate(ShortCodeGenerationContext context); +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java b/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java index 79ad3d9..4bfca8e 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java @@ -1,15 +1,12 @@ package com.backendsystemdesignlab.urlshortener.service; import com.backendsystemdesignlab.urlshortener.cache.ShortUrlCache; -import com.backendsystemdesignlab.urlshortener.encoding.Base62Encoder; import com.backendsystemdesignlab.urlshortener.exception.ShortUrlNotFoundException; import com.backendsystemdesignlab.urlshortener.metrics.RedirectMetrics; import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; @Service @RequiredArgsConstructor @@ -17,7 +14,6 @@ public class RedirectService { private final ShortUrlRepository shortUrlRepository; private final ShortUrlCache shortUrlCache; - private final Base62Encoder base62Encoder; private final RedirectMetrics redirectMetrics; public String findLongUrl(String shortCode) { @@ -26,11 +22,9 @@ public String findLongUrl(String shortCode) { } private String findFromDatabase(String shortCode) { - long id = base62Encoder.decode(shortCode); - redirectMetrics.recordDbLookup(); - ShortUrl shortUrl = shortUrlRepository.findById(id) + ShortUrl shortUrl = shortUrlRepository.findByShortCode(shortCode) .orElseThrow(() -> new ShortUrlNotFoundException(shortCode)); shortUrlCache.save(shortCode, shortUrl.getLongUrl()); diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java b/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java index 2c54350..efebabe 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java @@ -1,9 +1,6 @@ package com.backendsystemdesignlab.urlshortener.service; -import com.backendsystemdesignlab.urlshortener.encoding.Base62Encoder; -import com.backendsystemdesignlab.urlshortener.exception.ShortUrlNotFoundException; -import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; -import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; +import com.backendsystemdesignlab.urlshortener.creation.ShortUrlCreationStrategy; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -14,35 +11,14 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class ShortUrlService { - private final ShortUrlRepository shortUrlRepository; - private final Base62Encoder base62Encoder; + private final ShortUrlCreationStrategy shortUrlCreationStrategy; - @Transactional public String createShortUrl(String longUrl) { validateUrl(longUrl); - ShortUrl shortUrl = ShortUrl.create(longUrl); - ShortUrl savedShortUrl = shortUrlRepository.save(shortUrl); - - return base62Encoder.encode(savedShortUrl.getId()); - } - - @Transactional(readOnly = true) - public String getLongUrl(String shortCode) { - long id; - - try { - id = base62Encoder.decode(shortCode); - } catch (IllegalArgumentException | ArithmeticException exception) { - throw new ShortUrlNotFoundException(); - } - - return shortUrlRepository.findById(id) - .map(ShortUrl::getLongUrl) - .orElseThrow(ShortUrlNotFoundException::new); + return shortUrlCreationStrategy.create(longUrl); } private void validateUrl(String longUrl) { diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/url/domain/ShortUrl.java b/src/main/java/com/backendsystemdesignlab/urlshortener/url/domain/ShortUrl.java index 52ad8fc..276a908 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/url/domain/ShortUrl.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/url/domain/ShortUrl.java @@ -14,6 +14,9 @@ public class ShortUrl { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "short_code", unique = true, length = 16) + private String shortCode; + @Column(name = "long_url", nullable = false, length = 2048) private String longUrl; @@ -27,7 +30,33 @@ private ShortUrl(String longUrl) { this.createdAt = LocalDateTime.now(); } + private ShortUrl(String shortCode, String longUrl) { + this.shortCode = shortCode; + this.longUrl = longUrl; + this.createdAt = LocalDateTime.now(); + } + public static ShortUrl create(String longUrl) { return new ShortUrl(longUrl); } + + public static ShortUrl create(String shortCode, String longUrl) { + if (shortCode == null || shortCode.isBlank()) { + throw new IllegalArgumentException("단축 코드는 비어 있을 수 없습니다."); + } + + return new ShortUrl(shortCode, longUrl); + } + + public void assignShortCode(String shortCode) { + if (shortCode == null || shortCode.isBlank()) { + throw new IllegalArgumentException("단축 코드는 비어 있을 수 없습니다."); + } + + if (this.shortCode != null) { + throw new IllegalStateException("단축 코드는 이미 할당되었습니다."); + } + + this.shortCode = shortCode; + } } diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/url/repository/ShortUrlRepository.java b/src/main/java/com/backendsystemdesignlab/urlshortener/url/repository/ShortUrlRepository.java index 0d488e9..12d0443 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/url/repository/ShortUrlRepository.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/url/repository/ShortUrlRepository.java @@ -3,5 +3,11 @@ import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface ShortUrlRepository extends JpaRepository { + + Optional findByShortCode(String shortCode); + + boolean existsByShortCode(String shortCode); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8626b6b..8702dd1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,4 +56,7 @@ management: app: cache: - enabled: ${CACHE_ENABLED:true} \ No newline at end of file + enabled: ${CACHE_ENABLED:true} + + short-code: + strategy: ${SHORT_CODE_STRATEGY:sequence} \ No newline at end of file diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategyTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategyTest.java new file mode 100644 index 0000000..2e5a604 --- /dev/null +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategyTest.java @@ -0,0 +1,171 @@ +package com.backendsystemdesignlab.urlshortener.creation; + +import com.backendsystemdesignlab.urlshortener.exception.ShortCodeGenerationException; +import com.backendsystemdesignlab.urlshortener.generator.HashShortCodeGenerator; +import com.backendsystemdesignlab.urlshortener.generator.ShortCodeGenerationContext; +import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; +import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class HashShortUrlCreationStrategyTest { + + @InjectMocks + private HashShortUrlCreationStrategy strategy; + + @Mock + private ShortUrlRepository shortUrlRepository; + + @Mock + private HashShortCodeGenerator hashShortCodeGenerator; + + @Mock + private ShortUrlWriter shortUrlWriter; + + @Test + void 충돌이_없으면_생성한_코드로_저장한다() { + String longUrl = "https://www.google.com"; + String shortCode = "abc1234"; + + ShortCodeGenerationContext context = ShortCodeGenerationContext.hash(longUrl,0); + + given(hashShortCodeGenerator.generate(context)).willReturn(shortCode); + + given(shortUrlRepository.findByShortCode(shortCode)).willReturn(Optional.empty()); + + String result = strategy.create(longUrl); + + assertThat(result).isEqualTo(shortCode); + then(hashShortCodeGenerator).should().generate(context); + then(shortUrlRepository).should().findByShortCode(shortCode); + then(shortUrlWriter).should().save(shortCode, longUrl); + } + + @Test + void 같은_URL의_코드가_이미_있으면_기존_코드를_반환한다() { + String longUrl = "https://www.google.com"; + String shortCode = "abc1234"; + + ShortCodeGenerationContext context = ShortCodeGenerationContext.hash(longUrl,0); + ShortUrl existingShortUrl = ShortUrl.create(shortCode, longUrl); + + given(hashShortCodeGenerator.generate(context)).willReturn(shortCode); + given(shortUrlRepository.findByShortCode(shortCode)).willReturn(Optional.of(existingShortUrl)); + + String result = strategy.create(longUrl); + + assertThat(result).isEqualTo(shortCode); + then(hashShortCodeGenerator).should().generate(context); + then(shortUrlRepository).should().findByShortCode(shortCode); + then(shortUrlWriter).shouldHaveNoInteractions(); + } + + @Test + void 다른_URL과_충돌하면_attempt를_증가시켜_재시도한다() { + String longUrl = "https://www.google.com"; + + String collidedCode = "abc1234"; + String retryCode = "xyz9876"; + + ShortCodeGenerationContext firstContext = ShortCodeGenerationContext.hash(longUrl,0); + ShortCodeGenerationContext secondContext = ShortCodeGenerationContext.hash(longUrl,1); + + ShortUrl otherShortUrl = ShortUrl.create(collidedCode, "https://www.naver.com"); + + given(hashShortCodeGenerator.generate(firstContext)).willReturn(collidedCode); + given(hashShortCodeGenerator.generate(secondContext)).willReturn(retryCode); + given(shortUrlRepository.findByShortCode(collidedCode)).willReturn(Optional.of(otherShortUrl)); + given(shortUrlRepository.findByShortCode(retryCode)).willReturn(Optional.empty()); + + String result = strategy.create(longUrl); + + assertThat(result).isEqualTo(retryCode); + then(hashShortCodeGenerator).should().generate(firstContext); + then(hashShortCodeGenerator).should().generate(secondContext); + then(shortUrlRepository).should().findByShortCode(collidedCode); + then(shortUrlRepository).should().findByShortCode(retryCode); + then(shortUrlWriter).should().save(retryCode, longUrl); + } + + @Test + void 최대_재시도_횟수까지_충돌하면_예외가_발생한다() { + String longUrl = "https://www.google.com"; + + for (int attempt = 0; attempt < 5; attempt++) { + String shortCode = "code00" + attempt; + ShortCodeGenerationContext context = ShortCodeGenerationContext.hash(longUrl, attempt); + ShortUrl existingShortUrl = ShortUrl.create(shortCode, "https://other-" + attempt + ".com"); + + given(hashShortCodeGenerator.generate(context)).willReturn(shortCode); + given(shortUrlRepository.findByShortCode(shortCode)).willReturn(Optional.of(existingShortUrl)); + } + + assertThatThrownBy(() -> strategy.create(longUrl)) + .isInstanceOf(ShortCodeGenerationException.class); + then(hashShortCodeGenerator).should(times(5)).generate(any(ShortCodeGenerationContext.class)); + then(shortUrlWriter).shouldHaveNoInteractions(); + } + + @Test + void 저장_직전에_UNIQUE_충돌이_발생하면_다음_attempt로_재시도한다() { + String longUrl = "https://www.google.com"; + + String firstCode ="abc1234"; + String secondCode = "xyz9876"; + + ShortCodeGenerationContext firstContext = ShortCodeGenerationContext.hash(longUrl,0); + ShortCodeGenerationContext secondContext = ShortCodeGenerationContext.hash(longUrl,1); + + given(hashShortCodeGenerator.generate(firstContext)).willReturn(firstCode); + given(hashShortCodeGenerator.generate(secondContext)).willReturn(secondCode); + + given(shortUrlRepository.findByShortCode(firstCode)).willReturn(Optional.empty(), Optional.empty()); + given(shortUrlRepository.findByShortCode(secondCode)).willReturn(Optional.empty()); + + willThrow(new DataIntegrityViolationException("short_code UNIQUE 제약조건 위반")).given(shortUrlWriter).save(firstCode, longUrl); + + String result = strategy.create(longUrl); + + assertThat(result).isEqualTo(secondCode); + then(hashShortCodeGenerator).should().generate(firstContext); + then(hashShortCodeGenerator).should().generate(secondContext); + then(shortUrlWriter).should().save(firstCode, longUrl); + then(shortUrlWriter).should().save(secondCode, longUrl); + } + + @Test + void 저장_중_동일_URL이_먼저_저장되면_기존_코드를_반환한다() { + String longUrl = "https://www.google.com"; + String shortCode = "abc1234"; + + ShortCodeGenerationContext context = ShortCodeGenerationContext.hash(longUrl,0); + ShortUrl concurrentlySaved = ShortUrl.create(shortCode, longUrl); + + given(hashShortCodeGenerator.generate(context)).willReturn(shortCode); + given(shortUrlRepository.findByShortCode(shortCode)).willReturn(Optional.empty(), Optional.of(concurrentlySaved)); + + willThrow(new DataIntegrityViolationException("short_code UNIQUE 제약조건 위반")).given(shortUrlWriter).save(shortCode, longUrl); + + String result = strategy.create(longUrl); + + assertThat(result).isEqualTo(shortCode); + then(shortUrlRepository).should(times(2)).findByShortCode(shortCode); + then(shortUrlWriter).should().save(shortCode, longUrl); + } +} \ No newline at end of file diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlWriterTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlWriterTest.java new file mode 100644 index 0000000..e9813ac --- /dev/null +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlWriterTest.java @@ -0,0 +1,40 @@ +package com.backendsystemdesignlab.urlshortener.creation; + +import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; +import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +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.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class ShortUrlWriterTest { + + @InjectMocks + private ShortUrlWriter shortUrlWriter; + + @Mock + private ShortUrlRepository shortUrlRepository; + + @Test + void shortCode와_longUrl을_저장한다() { + String shortCode = "abc1234"; + String longUrl = "https://www.google.com"; + + shortUrlWriter.save(shortCode, longUrl); + + ArgumentCaptor shortUrlCaptor = ArgumentCaptor.forClass(ShortUrl.class); + then(shortUrlRepository).should().saveAndFlush(shortUrlCaptor.capture()); + + ShortUrl savedShortUrl = shortUrlCaptor.getValue(); + assertThat(savedShortUrl.getShortCode()).isEqualTo(shortCode); + assertThat(savedShortUrl.getLongUrl()).isEqualTo(longUrl); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGeneratorTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGeneratorTest.java new file mode 100644 index 0000000..0226241 --- /dev/null +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGeneratorTest.java @@ -0,0 +1,44 @@ +package com.backendsystemdesignlab.urlshortener.generator; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class HashShortCodeGeneratorTest { + + private final HashShortCodeGenerator generator = new HashShortCodeGenerator(); + + @Test + void 동일한_URL과_attempt는_동일한_코드를_생성한다() { + ShortCodeGenerationContext context = ShortCodeGenerationContext.hash("https://www.google.com", 0); + + String first = generator.generate(context); + String second = generator.generate(context); + + assertThat(first).isEqualTo(second); + assertThat(first).hasSize(7); + } + + @Test + void attempt가_다르면_다른_코드를_생성한다() { + String first = generator.generate(ShortCodeGenerationContext.hash("https://www.google.com", 0)); + String second = generator.generate(ShortCodeGenerationContext.hash("https://www.google.com", 1)); + + assertThat(first).isNotEqualTo(second); + } + + @Test + void 생성된_코드는_Base62_문자로만_구성된다() { + String shortCode = generator.generate(ShortCodeGenerationContext.hash("https://www.google.com", 0)); + assertThat(shortCode).matches("[0-9a-zA-Z]{7}"); + } + + @Test + void URL이_비어있으면_예외가_발생한다() { + assertThatThrownBy( + () -> generator.generate(ShortCodeGenerationContext.hash("", 0)) + ).isInstanceOf(IllegalArgumentException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java index 1814cfc..3b0e5ad 100644 --- a/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java @@ -30,8 +30,6 @@ class RedirectServiceTest { @Mock private ShortUrlRepository shortUrlRepository; @Mock - private Base62Encoder base62Encoder; - @Mock private RedirectMetrics redirectMetrics; @Test @@ -47,8 +45,8 @@ class RedirectServiceTest { assertThat(result).isEqualTo(longUrl); then(shortUrlCache).should().find(shortCode); - then(base62Encoder).shouldHaveNoInteractions(); then(shortUrlRepository).shouldHaveNoInteractions(); + then(redirectMetrics).shouldHaveNoInteractions(); } @Test @@ -62,10 +60,7 @@ class RedirectServiceTest { given(shortUrlCache.find(shortCode)) .willReturn(Optional.empty()); - given(base62Encoder.decode(shortCode)) - .willReturn(id); - - given(shortUrlRepository.findById(id)) + given(shortUrlRepository.findByShortCode(shortCode)) .willReturn(Optional.of(shortUrl)); String result = redirectService.findLongUrl(shortCode); @@ -73,30 +68,27 @@ class RedirectServiceTest { assertThat(result).isEqualTo(longUrl); then(shortUrlCache).should().find(shortCode); - then(base62Encoder).should().decode(shortCode); - then(shortUrlRepository).should().findById(id); + then(redirectMetrics).should().recordDbLookup(); + then(shortUrlRepository).should().findByShortCode(shortCode); then(shortUrlCache).should().save(shortCode, longUrl); } @Test void 캐시와_DB에_URL이_없으면_예외가_발생한다() { String shortCode = "2TX"; - long id = 12345L; given(shortUrlCache.find(shortCode)) .willReturn(Optional.empty()); - given(base62Encoder.decode(shortCode)) - .willReturn(id); - - given(shortUrlRepository.findById(id)) + given(shortUrlRepository.findByShortCode(shortCode)) .willReturn(Optional.empty()); assertThatThrownBy(() -> redirectService.findLongUrl(shortCode)) .isInstanceOf(ShortUrlNotFoundException.class); then(shortUrlCache).should().find(shortCode); - then(shortUrlRepository).should().findById(id); + then(redirectMetrics).should().recordDbLookup(); + then(shortUrlRepository).should().findByShortCode(shortCode); then(shortUrlCache).shouldHaveNoMoreInteractions(); } } \ No newline at end of file