diff --git a/README.md b/README.md index 5a0e549..51f6dcc 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,16 @@ - 관측(로그/메트릭/트레이싱)은 엣지/오리진 모두에서 수집 ### 4. APIs (스케치) -- GET `/{code}`: 코드로 리다이렉트 수행 +- GET `/api/v1/urls/{shortUrl}`: 코드로 리다이렉트 수행 - 301/302 Location: `` - 캐시 제어 헤더: 엣지 캐시 가능, 단 TTL/무효화 정책 고려 -- POST `/shorten` (참고): 원본 URL → 단축 코드 생성(본 문서 비스코프) +- POST `/api/v1/urls` (참고): 원본 URL → 단축 코드 생성(본 문서 비스코프) ### 5. Data Storage - 테이블: `url_mapping` - - `code` (PK, 고정 길이 문자열) — 단축 코드, 기본 키 및 인덱스 + - `id` (PK, 고정 길이 문자열) — 단축 코드, 기본 키 및 인덱스 - `original_url` (text) + - `shortUrl` - `created_at`, `expires_at`(선택) - 인덱싱 - B-트리 인덱스(기본) 또는 해시 인덱스(정확 일치 최적화, PostgreSQL 등) diff --git a/src/main/java/com/example/bitly/controller/UrlController.java b/src/main/java/com/example/bitly/controller/UrlController.java index f233b1a..42f3407 100644 --- a/src/main/java/com/example/bitly/controller/UrlController.java +++ b/src/main/java/com/example/bitly/controller/UrlController.java @@ -1,4 +1,58 @@ package com.example.bitly.controller; + +import com.example.bitly.controller.dto.UrlCreateRequest; +import com.example.bitly.service.UrlService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "URL 단축 API", description = "URL을 단축하고, 단축된 URL을 원래 URL로 리디렉션하는 API입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/urls/") public class UrlController { + + private final UrlService urlService; + + @Operation(summary = "URL 단축 생성", description = "원본 URL을 받아 단축된 URL을 생성합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "단축 URL 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 URL 형식") + }) + @PostMapping() + public ResponseEntity createShortUrl(@RequestBody UrlCreateRequest request) { + String shortUrl = urlService.createShortUrl(request.getOriginalUrl()); + return ResponseEntity.status(HttpStatus.CREATED).body(shortUrl); + } + + @Operation(summary = "원본 URL로 리디렉션", description = "단축 URL을 통해 원본 URL로 리디렉션합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "302", description = "리디렉션 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 단축 URL") + }) + @GetMapping("/{shortUrl}") + public void redirect( + @Parameter(description = "단축 URL", required = true) @PathVariable String shortUrl, + HttpServletResponse response) { + String originalUrl = urlService.getOriginalUrl(shortUrl); + try { + response.sendRedirect(originalUrl); + } catch (IOException e) { + throw new RuntimeException(e); // Checked → Unchecked 변환 + } + } } diff --git a/src/main/java/com/example/bitly/controller/dto/UrlCreateRequest.java b/src/main/java/com/example/bitly/controller/dto/UrlCreateRequest.java new file mode 100644 index 0000000..a2f01eb --- /dev/null +++ b/src/main/java/com/example/bitly/controller/dto/UrlCreateRequest.java @@ -0,0 +1,10 @@ +package com.example.bitly.controller.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UrlCreateRequest { + private String originalUrl; +} diff --git a/src/main/java/com/example/bitly/entity/Url.java b/src/main/java/com/example/bitly/entity/Url.java index c9796b5..4ef2ef5 100644 --- a/src/main/java/com/example/bitly/entity/Url.java +++ b/src/main/java/com/example/bitly/entity/Url.java @@ -1,4 +1,33 @@ package com.example.bitly.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Url { -} + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, columnDefinition = "TEXT") + private String originalUrl; + + private String shortUrl; + + public Url(String originalUrl) { + this.originalUrl = originalUrl; + } + + public void setShortUrl(String shortUrl) { + this.shortUrl = shortUrl; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/bitly/repository/UrlRepository.java b/src/main/java/com/example/bitly/repository/UrlRepository.java index efc1c02..3b7f3b5 100644 --- a/src/main/java/com/example/bitly/repository/UrlRepository.java +++ b/src/main/java/com/example/bitly/repository/UrlRepository.java @@ -1,4 +1,12 @@ package com.example.bitly.repository; -public interface UrlRepository { -} +import com.example.bitly.entity.Url; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UrlRepository extends JpaRepository { + + Optional findByOriginalUrl(String originalUrl); + + Optional findByShortUrl(String shortUrl); +} \ No newline at end of file diff --git a/src/main/java/com/example/bitly/service/UrlService.java b/src/main/java/com/example/bitly/service/UrlService.java index b35f979..9363dd3 100644 --- a/src/main/java/com/example/bitly/service/UrlService.java +++ b/src/main/java/com/example/bitly/service/UrlService.java @@ -1,4 +1,48 @@ package com.example.bitly.service; +import com.example.bitly.entity.Url; +import com.example.bitly.repository.UrlRepository; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor public class UrlService { + + private final UrlRepository urlRepository; + private static final String BASE62_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final long ID_OFFSET = 1000000000L; + + @Transactional + public String createShortUrl(String originalUrl) { + // 이미 등록된 URL인지 확인 + return urlRepository.findByOriginalUrl(originalUrl) + .map(Url::getShortUrl) + .orElseGet(() -> { + Url newUrl = new Url(originalUrl); + urlRepository.save(newUrl); + String shortUrl = encode(newUrl.getId()); + newUrl.setShortUrl(shortUrl); + return shortUrl; + }); + } + + @Transactional(readOnly = true) + public String getOriginalUrl(String shortUrl) { + return urlRepository.findByShortUrl(shortUrl) + .map(Url::getOriginalUrl) + .orElseThrow(() -> new EntityNotFoundException("URL not found for short URL: " + shortUrl)); + } + + private String encode(long id) { + long targetId = id + ID_OFFSET; + StringBuilder sb = new StringBuilder(); + while (targetId > 0) { + sb.append(BASE62_CHARS.charAt((int) (targetId % 62))); + targetId /= 62; + } + return sb.reverse().toString(); + } }