From 53559bfae2142f74b0298d74ddf93ca1c671a80a Mon Sep 17 00:00:00 2001 From: LocKey Date: Tue, 4 Aug 2026 10:35:41 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20shortCode=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generator/HashShortCodeGenerator.java | 4 ++++ .../generator/SequenceBase62Generator.java | 21 ++++++++++++++++++ .../generator/ShortCodeGenerator.java | 6 +++++ .../urlshortener/service/RedirectService.java | 8 +------ .../urlshortener/service/ShortUrlService.java | 22 +++++-------------- .../urlshortener/url/domain/ShortUrl.java | 15 +++++++++++++ .../url/repository/ShortUrlRepository.java | 6 +++++ .../service/RedirectServiceTest.java | 22 ++++++------------- 8 files changed, 65 insertions(+), 39 deletions(-) create mode 100644 src/main/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGenerator.java create mode 100644 src/main/java/com/backendsystemdesignlab/urlshortener/generator/SequenceBase62Generator.java create mode 100644 src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerator.java 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..e712a36 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGenerator.java @@ -0,0 +1,4 @@ +package com.backendsystemdesignlab.urlshortener.generator; + +public class HashShortCodeGenerator { +} 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..4cec4ec --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SequenceBase62Generator.java @@ -0,0 +1,21 @@ +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(String longUrl, Long id) { + if (id == null) { + throw new IllegalArgumentException("Sequence 방식은 DB ID가 필요합니다."); + } + + return base62Encoder.encode(id); + } +} 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..f72cc99 --- /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(String longUrl, Long id); +} 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..0be41a2 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java @@ -1,7 +1,6 @@ package com.backendsystemdesignlab.urlshortener.service; -import com.backendsystemdesignlab.urlshortener.encoding.Base62Encoder; -import com.backendsystemdesignlab.urlshortener.exception.ShortUrlNotFoundException; +import com.backendsystemdesignlab.urlshortener.generator.SequenceBase62Generator; import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; import lombok.RequiredArgsConstructor; @@ -18,7 +17,7 @@ public class ShortUrlService { private final ShortUrlRepository shortUrlRepository; - private final Base62Encoder base62Encoder; + private final SequenceBase62Generator sequenceBase62Generator; @Transactional public String createShortUrl(String longUrl) { @@ -27,22 +26,11 @@ public String createShortUrl(String 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; + String shortCode = sequenceBase62Generator.generate(longUrl, savedShortUrl.getId()); - try { - id = base62Encoder.decode(shortCode); - } catch (IllegalArgumentException | ArithmeticException exception) { - throw new ShortUrlNotFoundException(); - } + savedShortUrl.assignShortCode(shortCode); - return shortUrlRepository.findById(id) - .map(ShortUrl::getLongUrl) - .orElseThrow(ShortUrlNotFoundException::new); + return shortCode; } 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..92260e8 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; @@ -30,4 +33,16 @@ private ShortUrl(String longUrl) { public static ShortUrl create(String longUrl) { return new ShortUrl(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/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 From 3d5618b924934e040cce177075a3483914d14a82 Mon Sep 17 00:00:00 2001 From: LocKey Date: Tue, 4 Aug 2026 11:23:06 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Hash=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=95=20=EC=BD=94=EB=93=9C=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generator/HashShortCodeGenerator.java | 63 ++++++++++++++++++- .../generator/SequenceBase62Generator.java | 4 +- .../generator/ShortCodeGenerationContext.java | 29 +++++++++ .../generator/ShortCodeGenerator.java | 2 +- .../urlshortener/service/ShortUrlService.java | 3 +- .../generator/HashShortCodeGeneratorTest.java | 44 +++++++++++++ 6 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerationContext.java create mode 100644 src/test/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGeneratorTest.java diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGenerator.java b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGenerator.java index e712a36..1006683 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGenerator.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/HashShortCodeGenerator.java @@ -1,4 +1,65 @@ package com.backendsystemdesignlab.urlshortener.generator; -public class HashShortCodeGenerator { +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 index 4cec4ec..edf4849 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SequenceBase62Generator.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SequenceBase62Generator.java @@ -11,7 +11,9 @@ public class SequenceBase62Generator implements ShortCodeGenerator { private final Base62Encoder base62Encoder; @Override - public String generate(String longUrl, Long id) { + public String generate(ShortCodeGenerationContext context) { + Long id = context.sequenceId(); + if (id == null) { throw new IllegalArgumentException("Sequence 방식은 DB 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 index f72cc99..5ea8b36 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerator.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/ShortCodeGenerator.java @@ -2,5 +2,5 @@ public interface ShortCodeGenerator { - String generate(String longUrl, Long id); + String generate(ShortCodeGenerationContext context); } diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java b/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java index 0be41a2..7016f6f 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java @@ -1,6 +1,7 @@ package com.backendsystemdesignlab.urlshortener.service; 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; @@ -26,7 +27,7 @@ public String createShortUrl(String longUrl) { ShortUrl shortUrl = ShortUrl.create(longUrl); ShortUrl savedShortUrl = shortUrlRepository.save(shortUrl); - String shortCode = sequenceBase62Generator.generate(longUrl, savedShortUrl.getId()); + String shortCode = sequenceBase62Generator.generate(ShortCodeGenerationContext.sequece(longUrl, savedShortUrl.getId())); savedShortUrl.assignShortCode(shortCode); 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 From cda16e0a9221d7d41f83ab277e7cc365794c3517 Mon Sep 17 00:00:00 2001 From: LocKey Date: Tue, 4 Aug 2026 14:52:10 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=8B=A8=EC=B6=95=20URL=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=A0=84=EB=9E=B5=EA=B3=BC=20Hash=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 + .../HashShortUrlCreationStrategy.java | 55 ++++++ .../SequenceShortUrlCreationStrategy.java | 40 +++++ .../creation/ShortUrlCreationStrategy.java | 6 + .../ShortCodeGenerationException.java | 7 + .../urlshortener/service/ShortUrlService.java | 17 +- .../urlshortener/url/domain/ShortUrl.java | 14 ++ src/main/resources/application.yml | 5 +- .../HashShortUrlCreationStrategyTest.java | 158 ++++++++++++++++++ 9 files changed, 289 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategy.java create mode 100644 src/main/java/com/backendsystemdesignlab/urlshortener/creation/SequenceShortUrlCreationStrategy.java create mode 100644 src/main/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlCreationStrategy.java create mode 100644 src/main/java/com/backendsystemdesignlab/urlshortener/exception/ShortCodeGenerationException.java create mode 100644 src/test/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategyTest.java 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/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..bd546ee --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategy.java @@ -0,0 +1,55 @@ +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; + + @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 { + ShortUrl shortUrl = ShortUrl.create(shortCode, longUrl); + shortUrlRepository.saveAndFlush(shortUrl); + return shortCode; + } catch (DataIntegrityViolationException exception) { + // UNIQUE 충돌이면 다음 attempt로 재시도 + } + } + + 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..bc5ade0 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/SequenceShortUrlCreationStrategy.java @@ -0,0 +1,40 @@ +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; + +@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 + 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/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/service/ShortUrlService.java b/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java index 7016f6f..817ca54 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.generator.SequenceBase62Generator; -import com.backendsystemdesignlab.urlshortener.generator.ShortCodeGenerationContext; -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; @@ -17,21 +14,13 @@ @Transactional(readOnly = true) public class ShortUrlService { - private final ShortUrlRepository shortUrlRepository; - private final SequenceBase62Generator sequenceBase62Generator; + private final ShortUrlCreationStrategy shortUrlCreationStrategy; @Transactional public String createShortUrl(String longUrl) { validateUrl(longUrl); - ShortUrl shortUrl = ShortUrl.create(longUrl); - ShortUrl savedShortUrl = shortUrlRepository.save(shortUrl); - - String shortCode = sequenceBase62Generator.generate(ShortCodeGenerationContext.sequece(longUrl, savedShortUrl.getId())); - - savedShortUrl.assignShortCode(shortCode); - - return shortCode; + 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 92260e8..276a908 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/url/domain/ShortUrl.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/url/domain/ShortUrl.java @@ -30,10 +30,24 @@ 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("단축 코드는 비어 있을 수 없습니다."); 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..b119ccf --- /dev/null +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategyTest.java @@ -0,0 +1,158 @@ +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.given; +import static org.mockito.BDDMockito.then; +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; + + @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); + ArgumentCaptor captor = ArgumentCaptor.forClass(ShortUrl.class); + then(hashShortCodeGenerator).should().generate(context); + then(shortUrlRepository).should().findByShortCode(shortCode); + then(shortUrlRepository).should().saveAndFlush(captor.capture()); + + ShortUrl savedShortUrl = captor.getValue(); + + assertThat(savedShortUrl.getShortCode()).isEqualTo(shortCode); + assertThat(savedShortUrl.getLongUrl()).isEqualTo(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(shortUrlRepository).should(never()).saveAndFlush(any(ShortUrl.class)); + } + + @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); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ShortUrl.class); + then(shortUrlRepository).should().saveAndFlush(captor.capture()); + assertThat(captor.getValue().getShortCode()).isEqualTo(retryCode); + assertThat(captor.getValue().getLongUrl()).isEqualTo(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(shortUrlRepository).should(never()).saveAndFlush(any(ShortUrl.class)); + } + + @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()); + given(shortUrlRepository.findByShortCode(secondCode)).willReturn(Optional.empty()); + given(shortUrlRepository.saveAndFlush( + argThat(shortUrl -> firstCode.equals(shortUrl.getShortCode())) + )).willThrow(new DataIntegrityViolationException("short_code UNIQUE 계약조건 위반")); + + String result = strategy.create(longUrl); + + assertThat(result).isEqualTo(secondCode); + then(hashShortCodeGenerator).should().generate(firstContext); + then(hashShortCodeGenerator).should().generate(secondContext); + then(shortUrlRepository).should(times(2)).saveAndFlush(any(ShortUrl.class)); + } +} \ No newline at end of file From 37fc6bb7d90bddf38ecc0f4b6b7a611ebfda8be5 Mon Sep 17 00:00:00 2001 From: LocKey Date: Tue, 4 Aug 2026 16:58:39 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20Hash=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HashShortUrlCreationStrategy.java | 13 ++++- .../SequenceShortUrlCreationStrategy.java | 2 + .../urlshortener/creation/ShortUrlWriter.java | 21 +++++++ .../urlshortener/service/ShortUrlService.java | 2 - .../HashShortUrlCreationStrategyTest.java | 55 ++++++++++++------- .../creation/ShortUrlWriterTest.java | 40 ++++++++++++++ 6 files changed, 107 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlWriter.java create mode 100644 src/test/java/com/backendsystemdesignlab/urlshortener/creation/ShortUrlWriterTest.java diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategy.java b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategy.java index bd546ee..6dc0dc9 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategy.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategy.java @@ -25,6 +25,7 @@ public class HashShortUrlCreationStrategy implements ShortUrlCreationStrategy { private final ShortUrlRepository shortUrlRepository; private final HashShortCodeGenerator hashShortCodeGenerator; + private final ShortUrlWriter shortUrlWriter; @Override public String create(String longUrl) { @@ -41,11 +42,17 @@ public String create(String longUrl) { } try { - ShortUrl shortUrl = ShortUrl.create(shortCode, longUrl); - shortUrlRepository.saveAndFlush(shortUrl); + shortUrlWriter.save(shortCode, longUrl); return shortCode; } catch (DataIntegrityViolationException exception) { - // UNIQUE 충돌이면 다음 attempt로 재시도 + /* + * findByShortCode() 이후 다른 요청이 + * 같은 코드를 먼저 저장했을 수 있다. (동시성) + */ + Optional concurrentResult = shortUrlRepository.findByShortCode(shortCode); + if (concurrentResult.isPresent() && concurrentResult.get().getLongUrl().equals(longUrl)) { + return shortCode; + } } } diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/creation/SequenceShortUrlCreationStrategy.java b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/SequenceShortUrlCreationStrategy.java index bc5ade0..f4dcba9 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/creation/SequenceShortUrlCreationStrategy.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/creation/SequenceShortUrlCreationStrategy.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor @@ -22,6 +23,7 @@ public class SequenceShortUrlCreationStrategy implements ShortUrlCreationStrateg private final SequenceBase62Generator sequenceBase62Generator; @Override + @Transactional public String create(String longUrl) { ShortUrl shortUrl = ShortUrl.create(longUrl); ShortUrl savedShortUrl = shortUrlRepository.save(shortUrl); 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/service/ShortUrlService.java b/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java index 817ca54..efebabe 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/service/ShortUrlService.java @@ -11,12 +11,10 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class ShortUrlService { private final ShortUrlCreationStrategy shortUrlCreationStrategy; - @Transactional public String createShortUrl(String longUrl) { validateUrl(longUrl); diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategyTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategyTest.java index b119ccf..2e5a604 100644 --- a/src/test/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategyTest.java +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/creation/HashShortUrlCreationStrategyTest.java @@ -19,8 +19,7 @@ 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.given; -import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -36,6 +35,9 @@ class HashShortUrlCreationStrategyTest { @Mock private HashShortCodeGenerator hashShortCodeGenerator; + @Mock + private ShortUrlWriter shortUrlWriter; + @Test void 충돌이_없으면_생성한_코드로_저장한다() { String longUrl = "https://www.google.com"; @@ -50,15 +52,9 @@ class HashShortUrlCreationStrategyTest { String result = strategy.create(longUrl); assertThat(result).isEqualTo(shortCode); - ArgumentCaptor captor = ArgumentCaptor.forClass(ShortUrl.class); then(hashShortCodeGenerator).should().generate(context); then(shortUrlRepository).should().findByShortCode(shortCode); - then(shortUrlRepository).should().saveAndFlush(captor.capture()); - - ShortUrl savedShortUrl = captor.getValue(); - - assertThat(savedShortUrl.getShortCode()).isEqualTo(shortCode); - assertThat(savedShortUrl.getLongUrl()).isEqualTo(longUrl); + then(shortUrlWriter).should().save(shortCode, longUrl); } @Test @@ -77,7 +73,7 @@ class HashShortUrlCreationStrategyTest { assertThat(result).isEqualTo(shortCode); then(hashShortCodeGenerator).should().generate(context); then(shortUrlRepository).should().findByShortCode(shortCode); - then(shortUrlRepository).should(never()).saveAndFlush(any(ShortUrl.class)); + then(shortUrlWriter).shouldHaveNoInteractions(); } @Test @@ -104,11 +100,7 @@ class HashShortUrlCreationStrategyTest { then(hashShortCodeGenerator).should().generate(secondContext); then(shortUrlRepository).should().findByShortCode(collidedCode); then(shortUrlRepository).should().findByShortCode(retryCode); - - ArgumentCaptor captor = ArgumentCaptor.forClass(ShortUrl.class); - then(shortUrlRepository).should().saveAndFlush(captor.capture()); - assertThat(captor.getValue().getShortCode()).isEqualTo(retryCode); - assertThat(captor.getValue().getLongUrl()).isEqualTo(longUrl); + then(shortUrlWriter).should().save(retryCode, longUrl); } @Test @@ -127,7 +119,7 @@ class HashShortUrlCreationStrategyTest { assertThatThrownBy(() -> strategy.create(longUrl)) .isInstanceOf(ShortCodeGenerationException.class); then(hashShortCodeGenerator).should(times(5)).generate(any(ShortCodeGenerationContext.class)); - then(shortUrlRepository).should(never()).saveAndFlush(any(ShortUrl.class)); + then(shortUrlWriter).shouldHaveNoInteractions(); } @Test @@ -142,17 +134,38 @@ class HashShortUrlCreationStrategyTest { given(hashShortCodeGenerator.generate(firstContext)).willReturn(firstCode); given(hashShortCodeGenerator.generate(secondContext)).willReturn(secondCode); - given(shortUrlRepository.findByShortCode(firstCode)).willReturn(Optional.empty()); + + given(shortUrlRepository.findByShortCode(firstCode)).willReturn(Optional.empty(), Optional.empty()); given(shortUrlRepository.findByShortCode(secondCode)).willReturn(Optional.empty()); - given(shortUrlRepository.saveAndFlush( - argThat(shortUrl -> firstCode.equals(shortUrl.getShortCode())) - )).willThrow(new DataIntegrityViolationException("short_code UNIQUE 계약조건 위반")); + + 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(shortUrlRepository).should(times(2)).saveAndFlush(any(ShortUrl.class)); + 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 From 200523cd555a9110aad6f9fbe2ba20655745b7e4 Mon Sep 17 00:00:00 2001 From: LocKey Date: Wed, 5 Aug 2026 16:07:40 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=8B=A8=EC=B6=95=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=83=9D=EC=84=B1=20=EC=A0=84=EB=9E=B5=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=20=EA=B2=B0=EA=B3=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/04-experiment.md | 43 +++++- k6/create-load.js | 44 ++++++ .../create-hash-20vu.json | 133 ++++++++++++++++++ .../create-sequence-20vu.json | 133 ++++++++++++++++++ 4 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 k6/create-load.js create mode 100644 results/short-code-generation/create-hash-20vu.json create mode 100644 results/short-code-generation/create-sequence-20vu.json 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