diff --git a/build.gradle b/build.gradle index e19cdeb..21b634d 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ dependencies { // Database implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Monitoring implementation 'org.springframework.boot:spring-boot-starter-actuator' diff --git a/docker-compose.yml b/docker-compose.yml index 031ddea..09e8ba6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,9 +17,14 @@ services: SPRING_DATASOURCE_USERNAME: ${DB_USERNAME:-url_shortener} SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + depends_on: mysql: condition: service_healthy + redis: + condition: service_healthy healthcheck: test: @@ -113,7 +118,20 @@ services: app: condition: service_healthy + redis: + image: redis:7.4-alpine + ports: + - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - redis-data:/data + volumes: mysql-data: prometheus-data: - grafana-data: \ No newline at end of file + grafana-data: + redis-data: \ No newline at end of file diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/cache/ShortUrlCache.java b/src/main/java/com/backendsystemdesignlab/urlshortener/cache/ShortUrlCache.java new file mode 100644 index 0000000..51f65a9 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/cache/ShortUrlCache.java @@ -0,0 +1,32 @@ +package com.backendsystemdesignlab.urlshortener.cache; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; + +@Component +public class ShortUrlCache { + + private static final String KEY_PREFIX = "short-url:"; + private static final Duration TTL = Duration.ofHours(1); + + private final StringRedisTemplate redisTemplate; + + public ShortUrlCache(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public Optional find(String shortCode) { + String longUrl = redisTemplate.opsForValue() + .get(KEY_PREFIX + shortCode); + + return Optional.ofNullable(longUrl); + } + + public void save(String shortCode, String longUrl) { + redisTemplate.opsForValue() + .set(KEY_PREFIX + shortCode, longUrl, TTL); + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/controller/RedirectController.java b/src/main/java/com/backendsystemdesignlab/urlshortener/controller/RedirectController.java index b1083f2..a63f088 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/controller/RedirectController.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/controller/RedirectController.java @@ -1,5 +1,6 @@ package com.backendsystemdesignlab.urlshortener.controller; +import com.backendsystemdesignlab.urlshortener.service.RedirectService; import com.backendsystemdesignlab.urlshortener.service.ShortUrlService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -16,13 +17,13 @@ @RequestMapping("/api/v1") public class RedirectController { - private final ShortUrlService shortUrlService; + private final RedirectService redirectService; @GetMapping("/{shortCode}") public ResponseEntity redirect( @PathVariable String shortCode ) { - String longUrl = shortUrlService.getLongUrl(shortCode); + String longUrl = redirectService.findLongUrl(shortCode); return ResponseEntity .status(HttpStatus.FOUND) // 302 FOUND diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/exception/ShortUrlNotFoundException.java b/src/main/java/com/backendsystemdesignlab/urlshortener/exception/ShortUrlNotFoundException.java new file mode 100644 index 0000000..53e0278 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/exception/ShortUrlNotFoundException.java @@ -0,0 +1,12 @@ +package com.backendsystemdesignlab.urlshortener.exception; + +public class ShortUrlNotFoundException extends RuntimeException { + + public ShortUrlNotFoundException() { + super("단축 URL을 찾을 수 없습니다."); + } + + public ShortUrlNotFoundException(String shortCode) { + super("단축 URL을 찾을 수 없습니다. shortCode=" + shortCode); + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java b/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java new file mode 100644 index 0000000..5bd689e --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java @@ -0,0 +1,36 @@ +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.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 +public class RedirectService { + + private final ShortUrlRepository shortUrlRepository; + private final ShortUrlCache shortUrlCache; + private final Base62Encoder base62Encoder; + + public String findLongUrl(String shortCode) { + return shortUrlCache.find(shortCode) + .orElseGet(() -> findFromDatabase(shortCode)); + } + + private String findFromDatabase(String shortCode) { + long id = base62Encoder.decode(shortCode); + + ShortUrl shortUrl = shortUrlRepository.findById(id) + .orElseThrow(() -> new ShortUrlNotFoundException(shortCode)); + + shortUrlCache.save(shortCode, shortUrl.getLongUrl()); + + return 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 88c37d3..2c54350 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.encoding.Base62Encoder; +import com.backendsystemdesignlab.urlshortener.exception.ShortUrlNotFoundException; import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; import lombok.RequiredArgsConstructor; @@ -36,19 +37,12 @@ public String getLongUrl(String shortCode) { try { id = base62Encoder.decode(shortCode); } catch (IllegalArgumentException | ArithmeticException exception) { - throw shortUrlNotFoundException(); + throw new ShortUrlNotFoundException(); } return shortUrlRepository.findById(id) .map(ShortUrl::getLongUrl) - .orElseThrow(this::shortUrlNotFoundException); - } - - private ResponseStatusException shortUrlNotFoundException() { - return new ResponseStatusException( - HttpStatus.NOT_FOUND, - "단축 URL을 찾을 수 없습니다." - ); + .orElseThrow(ShortUrlNotFoundException::new); } private void validateUrl(String longUrl) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b093671..26cb324 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,6 +13,7 @@ spring: url: ${DB_URL:jdbc:mysql://localhost:3306/url_shortener?serverTimezone=Asia/Seoul&characterEncoding=UTF-8} username: ${DB_USERNAME:url_shortener} password: ${DB_PASSWORD} + jpa: hibernate: ddl-auto: update @@ -21,6 +22,13 @@ spring: hibernate: format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + connect-timeout: 2s + timeout: 2s + management: endpoints: web: diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/controller/RedirectControllerTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/controller/RedirectControllerTest.java index 1234585..2c7d34d 100644 --- a/src/test/java/com/backendsystemdesignlab/urlshortener/controller/RedirectControllerTest.java +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/controller/RedirectControllerTest.java @@ -1,5 +1,6 @@ package com.backendsystemdesignlab.urlshortener.controller; +import com.backendsystemdesignlab.urlshortener.service.RedirectService; import com.backendsystemdesignlab.urlshortener.service.ShortUrlService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -24,10 +25,13 @@ class RedirectControllerTest { @MockitoBean private ShortUrlService shortUrlService; + @MockitoBean + private RedirectService redirectService; + @Test @DisplayName("단축 URL 요청 시 원본 URL로 302 리다이렉트한다") void redirect() throws Exception { - given(shortUrlService.getLongUrl("2TX")) + given(redirectService.findLongUrl("2TX")) .willReturn("https://www.google.com"); mockMvc.perform(get("/api/v1/2TX")) @@ -41,7 +45,7 @@ void redirect() throws Exception { @Test @DisplayName("존재하지 않는 단축 URL은 404를 반환한다") void redirectNotFound() throws Exception { - given(shortUrlService.getLongUrl("missing")) + given(redirectService.findLongUrl("missing")) .willThrow(new ResponseStatusException( HttpStatus.NOT_FOUND, "단축 URL을 찾을 수 없습니다." diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java new file mode 100644 index 0000000..e554e43 --- /dev/null +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java @@ -0,0 +1,99 @@ +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.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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class RedirectServiceTest { + + @InjectMocks + private RedirectService redirectService; + + @Mock + private ShortUrlCache shortUrlCache; + @Mock + private ShortUrlRepository shortUrlRepository; + @Mock + private Base62Encoder base62Encoder; + + @Test + void 캐시에_URL이_있으면_DB를_조회하지_않는다() { + String shortCode = "2TX"; + String longUrl = "https://www.google.com"; + + given(shortUrlCache.find(shortCode)) + .willReturn(Optional.of(longUrl)); + + String result = redirectService.findLongUrl(shortCode); + + assertThat(result).isEqualTo(longUrl); + + then(shortUrlCache).should().find(shortCode); + then(base62Encoder).shouldHaveNoInteractions(); + then(shortUrlRepository).shouldHaveNoInteractions(); + } + + @Test + void 캐시에_URL이_없으면_DB를_조회하고_캐시에_저장한다() { + String shortCode = "2TX"; + String longUrl = "https://www.google.com"; + long id = 12345L; + + ShortUrl shortUrl = ShortUrl.create(longUrl); + + given(shortUrlCache.find(shortCode)) + .willReturn(Optional.empty()); + + given(base62Encoder.decode(shortCode)) + .willReturn(id); + + given(shortUrlRepository.findById(id)) + .willReturn(Optional.of(shortUrl)); + + String result = redirectService.findLongUrl(shortCode); + + assertThat(result).isEqualTo(longUrl); + + then(shortUrlCache).should().find(shortCode); + then(base62Encoder).should().decode(shortCode); + then(shortUrlRepository).should().findById(id); + 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)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> redirectService.findLongUrl(shortCode)) + .isInstanceOf(ShortUrlNotFoundException.class); + + then(shortUrlCache).should().find(shortCode); + then(shortUrlRepository).should().findById(id); + then(shortUrlCache).shouldHaveNoMoreInteractions(); + } +} \ No newline at end of file