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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
20 changes: 19 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
grafana-data:
redis-data:
Original file line number Diff line number Diff line change
@@ -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<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,13 +17,13 @@
@RequestMapping("/api/v1")
public class RedirectController {

private final ShortUrlService shortUrlService;
private final RedirectService redirectService;

@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(
@PathVariable String shortCode
) {
String longUrl = shortUrlService.getLongUrl(shortCode);
String longUrl = redirectService.findLongUrl(shortCode);

return ResponseEntity
.status(HttpStatus.FOUND) // 302 FOUND
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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"))
Expand All @@ -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을 찾을 수 없습니다."
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}