text/Java

short url

hoonzii 2024. 6. 14. 10:46
반응형

인스타로 시간 열심히 녹이던 중, 재밌는 게시물을 봤다. 취업할 때 회사에서 이런 과제테스트를

낸다고 하던데…

 

 

이걸보고 심심하던 차 한번 구현해보고 싶어졌다.

아래에 텍스트로 다시 정리해 보자면

 

  • 과제 테스트 요구사항(예시)
    1. URL bitly과 같은 단축 URL 서비스를 만들어야 합니다.
    2. 단축된 URL 내의 키(key)는 8글자로 생성되어야 합니다. '단축된 URL의 키'는 ‘https://bit.ly/3onGwak’에서 경로(path)에 해당하는 '3 onGwak'를 의미합니다. bitly에서는 7글자의 키를 사용합니다.
    3. 키 생성 알고리즘은 자유롭게 구현하시면 됩니다.
    4. 단축된 URL로 사용자가 요청하면 원래의 URL로 리다이렉트 되어야 합니다.
    5. 원래의 URL내로 다시 단축 URL을 생성해도 항상 새로운 단축 URL이 생성되어야 합니다. 이때 기존에 생성되었던 단축 URL도 여전히 동작해야 합니다.
    6. 단축된 URL - 원본 URL 로 리다이렉트 될 때마다 카운트가 증가되어야 하고, 해당 정보들 확인할 수 있는 API가 있어야 합니다.
    7. 데이터베이스 없이 컬렉션을 활용하여 데이터를 저장해야 합니다.
    8. 기능이 정상 동작하는 것을 확인할 수 있는 적절한 테스트 코드가 있어야 합니다.
    9. (선택) 해당 서비스를 사용할 수 있는 UI 페이지를 구현해 주세요.

 

위 요구사항을 보고 필요한 구현사항을 생각나는 대로 다시 정리해 보자면...

1. 사용자가 url 전달 → 내 웹 url + 키 반환 api 필요 (짧은 url 반환 API)

2. 키 생성 알고리즘 (base62 * 8자리)

3. 전달된 url : key 형식의 자료구조 필요 → map으로

4. 키 형식의 url 호출 시 전달된 url로 리다이렉트

5. 전달된 url로 리다이렉트시 count 해줄 자료구조 필요 → map으로

6. 리다이렉트 시 count += 1인데, 이걸 확인할 수 있는 api 필요(short url 입력 시 연결된 long url, redirect count 횟수 확인)

 

하나씩 구현해 보자.

 

우선, 사용자가 url 전달 → 내 웹서비스 url + 키 반환 api

KeyService라는 클래스는 url을 받으면, short url을 바꿔주는 역할을 한다.

buildUrl이라는 요청이 올 경우, {원본 url, 짧은 url, 생성시각}의 정보를 넘겨준다.

(KeyService가 구현되지도 않았는데 우선 api부터…ㅋㅋㅋ)

@RestController
public class UrlAPI {
	
	@Autowired
	KeyService KeyService;
	
	@GetMapping("/buildUrl")
	public Object buildShortUrl(@RequestParam String url) {
		JSONObject result = new JSONObject();
		result.put("orgUrl", url);
		result.put("shortUrl", "localhost:8081/"+KeyService.key(url));
		result.put("createdAt", LocalDateTime.now());
		return result;
	}
}

 

우선 이렇게 만들어 두고 짧은 url을 만들어내는 KeyService라는 클래스를 구현해야 한다.

 

키 생성 알고리즘의 경우, 찾아보니 base62 형식으로 사용한다고들 한다.
(base64에서 url에 사용 못하는 기호(/,+) 2개를 제거)

 

base64의 encoding은 8bit를 6bit(2^6=64)로 바뀐 뒤 해당 bit값으로 base64 문자를 바꾸게 되는데,
아무리 생각해도 그렇게 구현하면 과제에서 원하는 8자리로 바꿀 수가 없다.

다른 블로그 글을 참고해 보면,
DB에 저장한 뒤 AUTO_INCREMENT 값 → code로 바꾸고, 모자란 자릿수는 “=” 값으로 padding 처리하던데...

요구사항에서 DB를 사용하는 게 아니라 메모리 위에서 컬렉션으로 구현해야 했기에 패스했다.

(물론 하려면 할 수야 있겠지… url 생성요청 시마다 count값을 주면 될 거 같다.)

 

내가 선택한 건 랜덤 생성이다. 만든 알고리즘은 아래와 같다.

 

키(짧은 url) 생성 알고리즘

- url에 사용할 수 있는 a-zA-Z0-9 문자열로 생성 (26+26+10 ⇒ 62자 aka base62)

- 문자열 → byte배열로 변경 및 각 byte 숫자를 더해 하나의 int값으로 변환

- 해당 int값에 랜덤 한 숫자 더함 ( rand.hashCode 함수 사용) → 매번 다른 key 값 생성

- 10진수 int값 → 62진수 변환 및 랜덤한 문자열로 반환

- 이때, 8자리보다 많다면 자르기, 8자리보다 작다면 랜덤한 base62 값을 padding으로 추가

public class KeyService {
	
	private Map<String, String> urlMap = new HashMap<String, String>();
	private String base62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

	public void key(String url) {
		String result = "";
		
		Random rand = new Random();
		byte[] urlBytes = url.getBytes();
		StringBuilder sb = new StringBuilder();
		int sum = rand.hashCode();
		for(int i = 0; i < urlBytes.length; i++) {
			sum += urlBytes[i];
		}
		
		while(sum > 1) {
			sb.append(base62.charAt(sum % 62));
			sum /= 62;
		}
		
		if(sb.length() > 8) result = sb.substring(0, 8);
		else if(sb.length() < 8) {
			while(sb.length() < 8) {
				sb.append(base62.charAt(rand.nextInt(62)));
			}
			result = sb.toString();
		}else {
			result = sb.toString();
		}
		
		urlMap.put(result, url);
		System.out.println(url+" -> "+result);
		return result;
	}
	
	public static void main(String[] args) {
		KeyService ks = new KeyService();
		ks.key("https://www.naver.com/");
		ks.key("https://www.daum.com/");
		ks.key("https://stackoverflow.com/questions/742013/how-do-i-create-a-url-shortener");
	}
}

 

KeyService만을 돌려서 확인해 본 결과는 아래와 같다.

 

 

    다시 API로 돌아가보면 
@RestController
public class UrlAPI {
	
	@Autowired
	KeyService KeyService;
	
	@GetMapping("/buildUrl")
	public Object buildShortUrl(@RequestParam String url) {
		JSONObject result = new JSONObject();
		result.put("orgUrl", url);
		result.put("shortUrl", "localhost:8081/"+KeyService.key(url));
		result.put("createdAt", LocalDateTime.now());
		return result;
	}
}

 

이제 해당 API 호출 시 결과는 이렇게 나오게 된다.

 

 

키 형식의 url 호출시 전달된 url로 리다이렉트 구현은

 

생성된 Key와 원본 url을 관계를 저장하는 map을 하나 만들어주고 생성 시 map에 저장해 주게끔 

KeyService urlMap에 저장해 준다. key가 붙은 url요청이 올 경우 원본 url을 반환하는 함수도 KeyService에 구현해 준다.

public String shortToLong(String key) {
    String shortUrl = key;
    String longUrl = urlMap.get(key);

    return longUrl;
}

 

구현 이후, 

“/+shortUrl” 형식 요청일 경우, 원 url 반환하는 함수를 통해 redirect

키 형식의 url 호출 시 전달된 url로 리다이렉트 → redirect GetMapping

@Controller
public class PageController {
	
	@Autowired
	KeyService KeyService;
	
	@GetMapping("/{shortUrl}")
	public String redirectUrl(@PathVariable("shortUrl") String sh_url) {
		if(sh_url != null)  
			return "redirect:"+KeyService.shortToLong(sh_url);
		return null;
	}
}

 

 

 

또한
단축된 URL - 원본 URL로 리다이렉트 될 때마다 카운트가 증가되어야 하고, 해당 정보들 확인할 수 있는 API가 있어야 합니다.

위 요구 사항을 맞추기 위한 전달된 url로 리다이렉트시 count 해줄 자료구조가 필요하므로

// keyService.java에 추가

// 추가된 부분
private Map<String, Integer> shortUrlCallCount = new HashMap<String, Integer>();
private Map<String, Integer> longUrlCallCount = new HashMap<String, Integer>();

// 추가된 부분
public Map<String, Object> getRedirectCount(String sh_url) {
	Map<String, Object> map = new HashMap<String, Object>();
	map.put("orgUrl", urlMap.get(sh_url));
	map.put("shortUrl", "localhost:8081/"+sh_url);
	map.put("redirect_count", shortUrlCallCount.get(sh_url));
	return map;
}

public String shortToLong(String key) {
	String shortUrl = key;
	String longUrl = urlMap.get(key);
	// 추가된 부분
	shortUrlCallCount.put(shortUrl, shortUrlCallCount.getOrDefault(shortUrl, 0)+1);
	longUrlCallCount.put(longUrl, longUrlCallCount.getOrDefault(longUrl, 0)+1);
	
	return longUrl;
}

 

 

shortCallCount라는 map <String, Integer> 형 변수를 선언해 주고, /{shortUrl} 요청이 들어왔을 때

요청 count 수를 올려준다.

 

getRedirectCount는 요청 count 수를 확인할 때 사용하는 함수로, Key는 뭔지, 연결된 원본 url은 어떤 건지, 얼마나 호출됐는지 확인할 수 있게 구성했다.

 

해당 함수를 사용하는 API도 구성한다.

@RestController
public class UrlAPI {
	
	@Autowired
	KeyService KeyService;
	
	@GetMapping("/getUrlInfo")
	public Object getUrlInfo(@RequestParam String url) {
		url = url.replace("localhost:8081/", "");
		JSONObject result = new JSONObject();
		result.put("result", KeyService.getRedirectCount(url));
		return result;
	}
}

 

이로써, 리다이렉트 시 count += 1인데, 이걸 확인할 수 있는 api 역시 구현됐다.

위와 같이 Key가 생성되고 짧은 url로 두 번 호출 후, API로 확인하면 아래와 같이 반환된다.

 

 

이쯤에서 전체 패키지 구조는 아래와 같다.

 

요구사항 8번을 확인해 보면

기능이 정상 동작하는 것을 확인할 수 있는 적절한 테스트 코드가 있어야 합니다.

라고 적혀있는데, 사실 만들면서 되는지 안되는지를 확인하면서 만들었기 때문에
(service의 경우 main을 구성, controller의 경우 브라우저로 get 요청등)

테스트 코드 작성은 어떻게 해야 할지 모르겠어서 대충 구현했다.

@SpringBootTest
class KeyServiceTest {
	
	@Autowired
	KeyService KeyService;
	
	@Test
	@DisplayName("short url 생성 및 저장 확인 테스트")
	void createTest() {
		// given
		String testUrl = "www.naver.com";
		
		// when
		String shortUrl = KeyService.key(testUrl);
		
		// then
		assertEquals(testUrl, KeyService.shortToLong(shortUrl));
	}

}

 

testURl로 key를 생성하고, 해당 key의 원본 url이 testURL과 같은지만 확인했다.

 

9번 선택사항으로 해당 api를 제공할 수 있는 화면을 구현만 하면 1~9번까지의 요구사항을 만족시킬 수 있다.

 

화면 부분은 template 폴더에 대애충 home.html을 만들어 주고… 

 

화면 구성은 어려우니, chat gpt로 구성…!

 

구성된 전체 html 은 아래와 같다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>URL Shortener</title>
    <style>
        body {
            background-color: #98FB98; /* 초록색 파스텔 톤 */
            color: #000000; /* 검정색 글자 */
            font-family: Helvetica, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
        }
        .container {
            text-align: center;
        }
        h1 {
            font-weight: bold;
        }
        input {
            width: 300px;
            padding: 10px;
            margin: 10px 0;
            border: 1px solid #000000;
            border-radius: 5px;
        }
        a {
            display: block;
            margin-top: 10px;
            color: #000000;
            text-decoration: none;
            font-weight: bold;
        }
        a:hover {
            text-decoration: underline;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Shorten URL</h1>
        <input type="text" id="longUrl" placeholder="Enter long URL here">
        <a id="shortUrl" href="#">Short URL will appear here</a>
    </div>

    <script>
        // JavaScript 코드로 long URL 입력받고 short URL 반환하는 로직을 추가할 수 있습니다.
        // 예를 들어, long URL을 입력받고 서버로 요청을 보내서 short URL을 받는 로직 등을 구현할 수 있습니다.
    </script>
</body>
</html>

 

 

해당 화면은 “/”에 getMapping 해준다.

@Controller
public class PageController {
	
	@Autowired
	KeyService KeyService;
	
	@GetMapping("/{shortUrl}")
	public String redirectUrl(@PathVariable("shortUrl") String sh_url) {
		if(sh_url != null)  
			return "redirect:"+KeyService.shortToLong(sh_url);
		return null;
	}
	
	@GetMapping("/")
	public String index() {
		return "home";
	}
	
}

 

 

 

화면상에 long url 부분에 입력 후 엔터를 치면 buildUrl API을 호출할 수 있게 jquery+javascript 코드를 구성해 준다.

<script src="https://code.jquery.com/jquery-3.7.1.slim.min.js" integrity="sha256-kmHvs0B+OpCW5GVHUNjv9rOmY0IvSIRcf7zGUDTDQM8=" crossorigin="anonymous"></script>
  
<script>
    // JavaScript 코드로 long URL 입력받고 short URL 반환하는 로직을 추가할 수 있습니다.
    // 예를 들어, long URL을 입력받고 서버로 요청을 보내서 short URL을 받는 로직 등을 구현할 수 있습니다.
    $(document).ready(function() {
        $("#longUrl").off("keyup").on("keyup", function(key) {
            if(key.keyCode == 13){
                let requestUrl = $(this).val();
                $.ajax({
                    method : "GET",
                    url : "/buildUrl?url="+requestUrl,
                    success : function(data) {
                        let shortUrl = data.shortUrl;
                        if(!shortUrl.includes("http"))
                            shortUrl = "http://"+shortUrl;
                        $("#shortUrl").attr("href", shortUrl);
                        $("#shortUrl").text(shortUrl);
                    }
                });
            }
        });
    });
</script>

 

 

결과는 아래와 같다.

 

 

 

다 하고 느낀 건,
키 생성 알고리즘의 경우 아무것도 모른 상태로 구현하려면 빡세다는 점...

이걸 면접장에서 구현하려면 난 탈락이라는 점...

 

다른 블로그 글들을 참고하다 보니,

다들 DB에 저장해서 사용하던데 다음에 DB에 연결하는 버전으로 구현... 해봐야겠다.

반응형