Skip to content
Open
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
123 changes: 123 additions & 0 deletions chapter-08/url/FileUrlRepository.java
Original file line number Diff line number Diff line change
@@ -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<String, UrlMapping> 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<Map<String, UrlMapping>> typeRef = new TypeReference<Map<String, UrlMapping>>() {
};
Map<String, UrlMapping> 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<UrlMapping> findByLongUrl(String longUrl) {
return urlMappingCache.values().stream()
.filter(mapping -> mapping.getLongUrl().equals(longUrl))
.findFirst();
}

public Optional<UrlMapping> 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<UrlMapping> findAll() {
return new ArrayList<>(urlMappingCache.values());
}


public void refresh() {
loadFromFile();
}
}
88 changes: 88 additions & 0 deletions chapter-08/url/UrlController.java
Original file line number Diff line number Diff line change
@@ -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<UrlShortenResponse> 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<Void> 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<UrlRedirectResponse> getUrlInfo(@PathVariable String shortUrl) {
try {
// FileUrlRepository를 통해 매핑 정보 직접 조회
Optional<UrlMapping> 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<Map<String, Object>> getStats() {
try {
Map<String, Object> 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();
}
}
}
26 changes: 26 additions & 0 deletions chapter-08/url/UrlMapping.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
14 changes: 14 additions & 0 deletions chapter-08/url/UrlRedirectResponse.java
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions chapter-08/url/UrlShortenRequest.java
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions chapter-08/url/UrlShortenResponse.java
Original file line number Diff line number Diff line change
@@ -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;
}
108 changes: 108 additions & 0 deletions chapter-08/url/UrlShorteningService.java
Original file line number Diff line number Diff line change
@@ -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<UrlMapping> 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();
}
}
Loading