diff --git a/chapter-08/url/FileUrlRepository.java b/chapter-08/url/FileUrlRepository.java new file mode 100644 index 0000000..459f38e --- /dev/null +++ b/chapter-08/url/FileUrlRepository.java @@ -0,0 +1,123 @@ +package com.shareround.demo.url; + +import org.springframework.stereotype.Repository; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.core.type.TypeReference; + +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Repository +@Component +@Slf4j +public class FileUrlRepository { + + private final ObjectMapper objectMapper; + private final String filePath; + private final Map urlMappingCache = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + public FileUrlRepository(@Value("${url.storage.file.path:url-mappings.json}") String filePath) { + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + this.filePath = filePath; + loadFromFile(); + } + + private void loadFromFile() { + try { + File file = new File(filePath); + if (!file.exists()) { + log.info("URL 매핑 파일이 존재하지 않음, 새로 생성: {}", filePath); + saveToFile(); + return; + } + + TypeReference> typeRef = new TypeReference>() { + }; + Map loadedMappings = objectMapper.readValue(file, typeRef); + + urlMappingCache.clear(); + urlMappingCache.putAll(loadedMappings); + + // ID 생성기 초기화 + long maxId = urlMappingCache.values().stream() + .mapToLong(mapping -> mapping.getId() != null ? mapping.getId() : 0) + .max() + .orElse(0); + idGenerator.set(maxId + 1); + + log.info("URL 매핑 파일 로드 완료: {} 개 매핑", urlMappingCache.size()); + } catch (IOException e) { + log.error("URL 매핑 파일 로드 실패: {}", e.getMessage()); + throw new RuntimeException("URL 매핑 파일 로드 실패", e); + } + } + + private synchronized void saveToFile() { + try { + objectMapper.writeValue(new File(filePath), urlMappingCache); + log.debug("URL 매핑 파일 저장 완료"); + } catch (IOException e) { + log.error("URL 매핑 파일 저장 실패: {}", e.getMessage()); + throw new RuntimeException("URL 매핑 파일 저장 실패", e); + } + } + + public Optional findByLongUrl(String longUrl) { + return urlMappingCache.values().stream() + .filter(mapping -> mapping.getLongUrl().equals(longUrl)) + .findFirst(); + } + + public Optional findByShortUrl(String shortUrl) { + return Optional.ofNullable(urlMappingCache.get(shortUrl)); + } + + public boolean existsByShortUrl(String shortUrl) { + return urlMappingCache.containsKey(shortUrl); + } + + public UrlMapping save(UrlMapping urlMapping) { + if (urlMapping.getId() == null) { + urlMapping.setId(idGenerator.getAndIncrement()); + } + + urlMappingCache.put(urlMapping.getShortUrl(), urlMapping); + saveToFile(); + + log.debug("URL 매핑 저장: {} -> {}", urlMapping.getLongUrl(), urlMapping.getShortUrl()); + return urlMapping; + } + + public void incrementAccessCount(String shortUrl) { + UrlMapping mapping = urlMappingCache.get(shortUrl); + if (mapping != null) { + mapping.setAccessCount(mapping.getAccessCount() + 1); + saveToFile(); + } + } + + public long count() { + return urlMappingCache.size(); + } + + + public List findAll() { + return new ArrayList<>(urlMappingCache.values()); + } + + + public void refresh() { + loadFromFile(); + } +} diff --git a/chapter-08/url/UrlController.java b/chapter-08/url/UrlController.java new file mode 100644 index 0000000..fc4076f --- /dev/null +++ b/chapter-08/url/UrlController.java @@ -0,0 +1,88 @@ +package com.shareround.demo.url; + +import org.springframework.web.bind.annotation.*; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import lombok.extern.slf4j.Slf4j; + +import java.net.URI; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/api/v1") +@Slf4j +public class UrlController { + + private final UrlShorteningService urlShorteningService; + + public UrlController(UrlShorteningService urlShorteningService) { + this.urlShorteningService = urlShorteningService; + } + + @PostMapping("/shorten") + public ResponseEntity shortenUrl(@RequestBody UrlShortenRequest request) { + try { + UrlShortenResponse response = urlShorteningService.shortenUrl(request.getLongUrl()); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("URL 단축 실패: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @GetMapping("/{shortUrl}") + public ResponseEntity redirectUrl(@PathVariable String shortUrl) { + try { + String longUrl = urlShorteningService.redirectUrl(shortUrl); + + // 301 Moved Permanently 상태 코드로 리디렉션 + return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY) + .location(URI.create(longUrl)) + .build(); + } catch (RuntimeException e) { + log.warn("단축 URL을 찾을 수 없음: {}", shortUrl); + return ResponseEntity.notFound().build(); + } catch (Exception e) { + log.error("URL 리디렉션 실패: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @GetMapping("/info/{shortUrl}") + public ResponseEntity getUrlInfo(@PathVariable String shortUrl) { + try { + // FileUrlRepository를 통해 매핑 정보 직접 조회 + Optional mapping = urlShorteningService.getUrlRepository().findByShortUrl(shortUrl); + if (mapping.isPresent()) { + UrlMapping urlMapping = mapping.get(); + UrlRedirectResponse response = new UrlRedirectResponse( + urlMapping.getLongUrl(), + urlMapping.getShortUrl(), + urlMapping.getAccessCount() + ); + return ResponseEntity.ok(response); + } else { + return ResponseEntity.notFound().build(); + } + } catch (Exception e) { + log.error("URL 정보 조회 실패: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @GetMapping("/stats") + public ResponseEntity> getStats() { + try { + Map stats = new HashMap<>(); + stats.put("totalMappings", urlShorteningService.getTotalMappings()); + stats.put("timestamp", LocalDateTime.now()); + return ResponseEntity.ok(stats); + } catch (Exception e) { + log.error("통계 정보 조회 실패: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} diff --git a/chapter-08/url/UrlMapping.java b/chapter-08/url/UrlMapping.java new file mode 100644 index 0000000..298ec22 --- /dev/null +++ b/chapter-08/url/UrlMapping.java @@ -0,0 +1,26 @@ +package com.shareround.demo.url; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UrlMapping { + + private Long id; + private String longUrl; + private String shortUrl; + private LocalDateTime createdAt; + private Long accessCount; + + public UrlMapping(String longUrl, String shortUrl) { + this.longUrl = longUrl; + this.shortUrl = shortUrl; + this.createdAt = LocalDateTime.now(); + this.accessCount = 0L; + } +} diff --git a/chapter-08/url/UrlRedirectResponse.java b/chapter-08/url/UrlRedirectResponse.java new file mode 100644 index 0000000..19554d0 --- /dev/null +++ b/chapter-08/url/UrlRedirectResponse.java @@ -0,0 +1,14 @@ +package com.shareround.demo.url; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UrlRedirectResponse { + private String longUrl; + private String shortUrl; + private Long accessCount; +} diff --git a/chapter-08/url/UrlShortenRequest.java b/chapter-08/url/UrlShortenRequest.java new file mode 100644 index 0000000..03b2d37 --- /dev/null +++ b/chapter-08/url/UrlShortenRequest.java @@ -0,0 +1,13 @@ +package com.shareround.demo.url; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UrlShortenRequest { + + private String longUrl; +} diff --git a/chapter-08/url/UrlShortenResponse.java b/chapter-08/url/UrlShortenResponse.java new file mode 100644 index 0000000..a64350e --- /dev/null +++ b/chapter-08/url/UrlShortenResponse.java @@ -0,0 +1,16 @@ +package com.shareround.demo.url; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UrlShortenResponse { + private String shortUrl; + private String longUrl; + private LocalDateTime createdAt; +} diff --git a/chapter-08/url/UrlShorteningService.java b/chapter-08/url/UrlShorteningService.java new file mode 100644 index 0000000..a8a4e6d --- /dev/null +++ b/chapter-08/url/UrlShorteningService.java @@ -0,0 +1,108 @@ +package com.shareround.demo.url; + +import lombok.Getter; +import org.springframework.stereotype.Service; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.zip.CRC32; + +@Getter +@Service +@Slf4j +public class UrlShorteningService { + + private final FileUrlRepository urlRepository; + + private static final String BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + private static final int SHORT_URL_LENGTH = 8; + private static final int MAX_COLLISION_ATTEMPTS = 10; + + public UrlShorteningService(FileUrlRepository urlRepository) { + this.urlRepository = urlRepository; + } + + public UrlShortenResponse shortenUrl(String longUrl) { + log.info("URL 단축 요청: {}", longUrl); + + Optional existingMapping = urlRepository.findByLongUrl(longUrl); + if (existingMapping.isPresent()) { + UrlMapping mapping = existingMapping.get(); + log.info("기존 단축 URL 반환: {}", mapping.getShortUrl()); + return new UrlShortenResponse(mapping.getShortUrl(), mapping.getLongUrl(), mapping.getCreatedAt()); + } + + String shortUrl = generateShortUrl(longUrl); + + UrlMapping mapping = new UrlMapping(longUrl, shortUrl); + mapping = urlRepository.save(mapping); + + log.info("새로운 단축 URL 생성: {} -> {}", longUrl, shortUrl); + return new UrlShortenResponse(mapping.getShortUrl(), mapping.getLongUrl(), mapping.getCreatedAt()); + } + + public String redirectUrl(String shortUrl) { + log.info("URL 리디렉션 요청: {}", shortUrl); + + UrlMapping mapping = urlRepository.findByShortUrl(shortUrl) + .orElseThrow(() -> new RuntimeException("단축 URL을 찾을 수 없습니다: " + shortUrl)); + + urlRepository.incrementAccessCount(shortUrl); + + log.info("파일에서 URL 반환: {}", mapping.getLongUrl()); + return mapping.getLongUrl(); + } + + private String generateShortUrl(String longUrl) { + String originalUrl = longUrl; + int attempts = 0; + + while (attempts < MAX_COLLISION_ATTEMPTS) { + // CRC32 해시 계산 + CRC32 crc32 = new CRC32(); + crc32.update(originalUrl.getBytes(StandardCharsets.UTF_8)); + long hashValue = crc32.getValue(); + + String shortUrl = encodeToBase62(hashValue); + + if (!urlRepository.existsByShortUrl(shortUrl)) { + return shortUrl; + } + + originalUrl = longUrl + "_" + attempts; + attempts++; + log.warn("단축 URL 충돌 발생, 재시도 {}/{}: {}", attempts, MAX_COLLISION_ATTEMPTS, shortUrl); + } + + throw new RuntimeException("단축 URL 생성에 실패했습니다. 최대 시도 횟수 초과"); + } + + private String encodeToBase62(long value) { + if (value == 0) { + return "0".repeat(UrlShorteningService.SHORT_URL_LENGTH); + } + + StringBuilder result = new StringBuilder(); + long num = Math.abs(value); // 음수 방지 + + while (num > 0) { + result.insert(0, BASE62_CHARS.charAt((int) (num % 62))); + num /= 62; + } + + while (result.length() < UrlShorteningService.SHORT_URL_LENGTH) { + result.insert(0, '0'); + } + + if (result.length() > UrlShorteningService.SHORT_URL_LENGTH) { + result = new StringBuilder(result.substring(result.length() - UrlShorteningService.SHORT_URL_LENGTH)); + } + + return result.toString(); + } + + public long getTotalMappings() { + return urlRepository.count(); + } +} diff --git "a/chapter-08/\355\231\215\354\244\200\355\230\201_URL \353\213\250\354\266\225\352\270\260 \354\204\244\352\263\204.md" "b/chapter-08/\355\231\215\354\244\200\355\230\201_URL \353\213\250\354\266\225\352\270\260 \354\204\244\352\263\204.md" new file mode 100644 index 0000000..310e4b5 --- /dev/null +++ "b/chapter-08/\355\231\215\354\244\200\355\230\201_URL \353\213\250\354\266\225\352\270\260 \354\204\244\352\263\204.md" @@ -0,0 +1,117 @@ +# URL 단축기 설계 + +https://api-credit.kkuda.kr/api/v1/company?findType=3&value=소정&page=1 과 같이 긴 URL이 입력으로 주어졌을 때 서비스는 https://api-credit.kkuda.kr/api/v1/y7ke-ocwj와 같은 단축 URL을 결과로 제공해야 하는 상황입니다. + +현재 책에서 산정한 기능적 요구사항은 아래와 같습니다. + +1. URL 단축 : 주어진 긴 URL을 훨씬 짧게(7자리) 줄인다. +2. URL 리디렉션 : 축약된 URL로 HTTP 요청이 오면 원래 URL로 안내한다. + +또한 성능적 요구사항은 아래와 같습니다. + +1. 매일 1억 개의 단축 URL을 생성해야 한다. +2. URL 단축 서비스는 10년간 운영하기 때문에 최소 3650억개의 레코드를 생성하고 보관해야 한다. +3. 축약 전 URL의 평균 길이는 100이다. + +--- + +### API 엔드포인트 + +URL 단축기는 기본적으로 두 개의 엔드포인트가 필요합니다. long url을 short url로 변환하는 url 단축용 엔드포인트와 단축 url로 http 요청이 들어왔을 때 원래의 long url을 보내주는 용도의 엔드포인트가 필요합니다. + +스크린샷 2025-07-18 오후 10 41 31 + + +위 그림은 URL 단축기에 필요한 두 엔드포인트 중 단축 url을 원래의 long url로 바꿔주는 리디렉션 엔드포인트를 설명합니다. 브라우저에 단축 url을 보냈을 때 이를 받은 서버는 윈래의 Long url로 바꾸어서 301 상태 코드와 함께 원래의 long url을 내려줍니다. 이후 클라이언트에서 원래의 long url을 가지고 서버에 요청을 보냅니다. + +리디렉션 엔드포인트를 구현하는 방식은 두 가지로 나뉩니다. 하나는 long url과 short url을 매핑하여 해당 매핑 정보를 저장하고 영구적으로 사용하는 방식이고, 다른 하나는 ‘일시적’으로 long url과 short url을 매핑하는 것입니다. 일시적 매핑이기 때문에 클라이언트의 요청은 언제나 단축 url 서버에 해당 url과 매핑된 long url을 얻어 해당 long url로 요청을 보내야합니다. + +두 방법 모두 장단점을 가집니다. + +long url과 short url을 영구적으로 매핑할 경우 처음 short url을 사용할 경우에만 해당 short url과 매핑되는 long url을 확인하기 위해 url 서버의 리디렉션 엔드포인트로 요청을 보냅니다. 이후 브라우저에서는 이 응답을 캐시하여 추후 같은 short url을 사용할 때는 캐시된 long url을 사용하면 됩니다. 따라서 url 서버의 부하를 줄일 수 있습니다. + +하지만 클릭 발생률이나 발생 위치를 추적하는 트래픽 분석 측면에서는 일시적으로 long url과 short url을 매핑하는 방식이 유리합니다. + +영구적 매핑의 경우 301 Permanently Moved 응답 코드를 사용하고 일시적 매핑의 경우는 302 Found 응답 코드를 사용하는것이 일반적입니다. + +--- + +### URL 단축 + +그렇다면 이제는 long url을 short url로 단축해봅시다. url 단축에는 해시 함수를 사용합니다. + +long url을 해시 값으로 대응시켜 해당 long url에 고유한 short url을 생성해야 합니다. 이때 우리는 long url을해시 값으로 대응시킬 해시 함수를 찾아야 합니다. + +스크린샷 2025-07-18 오후 10 41 45 + + +위 그림과 같이 url 단축에 사용될 해시 함수은 아래와 같은 요구사항을 만족해야 합니다. + +1. 입력으로 주어지는 긴 url이 다른 값이면 해시 값도 달라야 한다. +2. 계산된 해시 값은 원래 입력으로 주어졌던 긴 url로 복원될 수 있어야 한다. + +2번째 요구사항을 마치 우리가 만들 해시 함수가 short url을 다시 long url로 변환하는 리디렉션에 사용될것 같이 이야기하지만 결국에는 관계형데이터베이스를 사용합니다. 이 부분은 뒤에서 살펴보겠습니다. + +--- + +### 상세 설계 + +지금까지는 url 단축기를 만드는 과정을 개괄적으로 살펴보았다면 이제는 상세히 설계해봅시다. + +먼저 url서버의 리디렉션 엔드포인트를 위해서는 long url과 short url을 매핑해야 합니다. 이때 해시 테이블을 사용할 수 있지만, 메모리를 사용해야 하기 때문에 관계형 데이터베이스의 테이블로 long url과 그에 해당되는 short url을 저장합니다. + +다음으로는 long url을 short url로 단축할 때 사용할 해시 함수를 구현해야 합니다. + +책에서는 hashValue를 단축된 url 값으로 지칭하기 때문에 동일하게 지칭하겠습니다. short url은 [0-9, a-z, A-Z]의 문자들로 구성되기 때문에 총 62개의 문자의 조합입니다. 가장 앞에서 우리는 최소 3650억 개의 레코드를 생성하는 url 단축기를 만들어야 하기 때문에 short url의 길이 n을 결정하는 조건은 아래와 같습니다. + +62의 n제곱 ≥ 3650억을 만족하는 n의 최솟값은 7입니다. 변환될 short url의 길이를 7로 정하고 long url을 단축하는 해시 함수를 살펴봅시다. + +해시 함수를 구현하는 방법은 ‘해시 후 충돌 해소’ 방법과 ‘base-62 변환’법으로 크게 두 가지입니다. + +먼저 ‘해시 후 충돌 해소’ 방법을 살펴봅시다. + +이 방법은 이미 잘 알려진 CRC32, MD5, SHA-1와 같은 해시 함수를 사용하는 방법입니다. CRC32 해시 함수를 사용하면 long url을 8자리의 short url로 줄일 수 있습니다. 8자리도 충분히 줄인 url이라 생각되지만, 책에서는 7자리를 만족하기 위해 CRC32 해시 함수로 줄인 8자리의 값에 마지막 값을 잘라 냅니다. 처음 7개 글자만 사용하는 것입니다. + +하지만 이 방식은 해시 결과가 충돌할 확률이 생깁니다. (높아집니다) 그래서 해시 함수의 결과가 충돌했을 때 즉 중복된 해시 결과가 생성되었을 때 long url 뒤에 사전에 정한 문자열을 추가하고 다시 해시 함수에 넣어 중복된 해시 결과를 해소합니다. 아래 그림과 같습니다. + +스크린샷 2025-07-18 오후 10 42 04 + +하지만 ‘해시 후 충돌 해소’ 방법은 위 그림과 같이 short url이 생성되면 항상 이미 생성된 url인지 데이터베이스에 질의를 해야 하기 때문에 오버헤드가 존재합니다. 책에서는 이를 블룸 필터가 대안이 될 수 있다고 소개합니다. + +두 번째는 ‘base-62 변환’ 방법입니다. + +진법 변환은 url 단축기를 구현할 때 흔히 사용되는 방법입니다. 62진법을 선택한 이유는 생성될 short url이 62개의 문자의 조합으로 만들어질 수 있기 때문입니다. + +10진수인 1157을 64진법으로 표현하면 2TX로 표현됩니다. 이렇게 진법 변환을 통해 1157를 2TX로 줄이는 방법이 ‘base-62 변환’ 방법입니다. + +‘해시 후 충돌 해소’ 방법과 ‘base-62 변환’ 방법 사이의 차이를 표로 나타냈습니다. + +스크린샷 2025-07-18 오후 10 42 19 + +--- + +### URL 단축기 상세 설계 + +책에서는 301 응답 코드를 내려주는 영구적 매핑 방식과 62진법 변환 기법을 사용하여 리디렉션 엔드포인트와 url 단축 엔드포인트를 설계했습니다. + +스크린샷 2025-07-18 오후 10 42 34 + +책에서 설계한 URL 단축기는 입력으로 long url을 받고 데이터베이스에 이미 매핑된 short url이 없는지 확인합니다. +short url이 있다면 이전에 url 단축을 진행했던 long url이므로 해당 short url을 반환합니다. +만약 해당 long url이 데이터베이스에 없다면 새로운 유일한 ID를 생성하고 62진법 변환을 적용해 ID를 단축 url로 만듭니다. +이때 주의할 점은 long url 자체를 진법 변환에 사용하는것이 아니라 유일 조건으로 생성된 ID를 62진법으로 변환해 단축 url을 만듭니다. +만들어진 ID와 short url은 long url과 함께 데이터베이스 스키마로 저장되고 테이블의 PK는 ID로 넣고 나머지 칼럼으로 short url과 long url을 저장합니다. + +스크린샷 2025-07-18 오후 10 49 35 + +이때 유일한 ID를 생성하는 과정이 중요한 지점입니다. 특히 이 ID는 전역적으로 유일성이 보장되어야 하기 때문에 분산된 환경이라면 유일한 ID 생성에 유의해야 합니다. + +그리고 URL 리디렉션 메커니즘을 시스템 설계 관점에서는 아래 그림과 같이 설계했습니다. + +스크린샷 2025-07-18 오후 10 42 47 + +쓰기보다 읽기를 더 자주하는 시스템이기 때문에 의 쌍을 캐시에 저장해 성능을 높였습니다. + +사용자가 short url을 클릭하면, 로드밸런서가 해당 요청을 웹 서버에 전달합니다. + +단축 url이 이미 캐시되어 있다면 캐시에 매핑된 long url을 클라이언트에 전달하고, 캐시에 없다면 데이터베이스에 접근합니다.