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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,16 @@
- 관측(로그/메트릭/트레이싱)은 엣지/오리진 모두에서 수집

### 4. APIs (스케치)
- GET `/{code}`: 코드로 리다이렉트 수행
- GET `/api/v1/urls/{shortUrl}`: 코드로 리다이렉트 수행
- 301/302 Location: `<original_url>`
- 캐시 제어 헤더: 엣지 캐시 가능, 단 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 등)
Expand Down
54 changes: 54 additions & 0 deletions src/main/java/com/example/bitly/controller/UrlController.java
Original file line number Diff line number Diff line change
@@ -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<String> 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 변환
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.bitly.controller.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class UrlCreateRequest {
private String originalUrl;
}
31 changes: 30 additions & 1 deletion src/main/java/com/example/bitly/entity/Url.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 10 additions & 2 deletions src/main/java/com/example/bitly/repository/UrlRepository.java
Original file line number Diff line number Diff line change
@@ -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<Url, Long> {

Optional<Url> findByOriginalUrl(String originalUrl);

Optional<Url> findByShortUrl(String shortUrl);
}
44 changes: 44 additions & 0 deletions src/main/java/com/example/bitly/service/UrlService.java
Original file line number Diff line number Diff line change
@@ -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();
}
}