[F-Lab 모각코 챌린지 15일차] URL 단축 서비스 API

부추·2023년 6월 15일
0

F-Lab 모각코 챌린지

목록 보기
15/66

TIL

  1. 기능
  2. 개발한 URL 단축 서비스의 Base62 인코딩 과정
  3. 리다이렉팅 처리
  4. 그 외 신경쓴 사항?



0. 기능

0-1) 단축할 URL 등록 기능

POST : /url

body의 "url" 항목에 단축하고자 하는 URL을 문자열로 등록 response의 "encodedUrl"이 축약된 URL입니다.

이미 축약되어 프로그램에 존재하는 URL의 경우, 409 에러를 반환합니다.


0-2) URL 조회 기능

GET : /url/{단축된 URL}

"단축된 URL" 경로에 단축된 URL을 입력하여 원래 URL 조회
"decodedUrl" 항목 확인

만약 존재하지 않는 URL일 경우, 404 에러를 반환합니다.


0-3) 리다이렉팅 기능

GET : /{단축된 URL}

단축된 URL로 요청할경우, 바로 원래 URL경로로 리다이렉트됩니다.

존재하지 않는 URL일 경우, 404 에러를 반환합니다.

0-4) 저장된 단축 URL 전체 조회 기능

GET : /url

"size" 항목에 저장된 url 기록의 개수, "urls" 항목에 저장된 각각의 url이 존재합니다.


0-5) 단축 URL 요청 횟수 조회 기능

GET : /check/{단축된 URL}

"requestedNumber" 항목에 단축 URL을 통해 해당 URL에 접근한 횟수가 존재합니다.




1. Why Base62?

URL 단축 서비스 API를 제작하기 위해, 사용자가 요청한 URL에 대해 URL을 단축하는 기능을 제공해야 했다. 이게 "보안"이 목적이라기보단 "단축"이 목적이었기 때문에, 뭔가 encryption을 한다기보단 shortening/encoding을 한다는 것에 집중해야 했다.

# 해싱 (Hashing)

encoding에 고려할 수 있는 옵션은 몇 가지가 있었다.

과제를 받자마자 가장 처음 생각했던 Hashing. Hashing이란 임의의 길이의 데이터를 고정된 길이의 데이터로 변환시키는 과정이다. 해싱의 특징은,

  1. 단방향성 : 특정 해싱 알고리즘을 통해 해시화된 해시 값은 다시 원래 값으로 돌려보낼 수 없다.
  2. 해시 충돌 : Java에서 hash 관련 객체들(HashSet, HashMap 등)을 다룰 때 함꼐 알아두어야할 문제인데, 엄청나게 넓은 범위의 정의역 값을 일정한 길이의 해시 치역 값으로 줄이다보니 나타나는 현상이다. 원본 값이 다른데 해시 값이 같아 해시 값으로 원본이 구분이 되지 않는 현상이다. 해시 충돌이 잘 일어나지 않는가? 가 좋은 해시 함수를 판단하는 기준이 되기도 한다.
  3. 고정된 길이 : 해시 함수는 고정된 길이의 결과값을 반환한다.

해시 함수는 같은 입력에 대해선 항상 같은 결과값을 반환하고, 입력 값이 아주 조금 바뀌어도 전혀 다른 결과를 반환하기 때문에 데이터의 무결성 검사에 주로 쓰인다.

아무튼 encoding의 기능만 잘 한다면, URL 단축 기능을 제공하는데 해시 함수를 쓰는 것도 나쁘지 않을 것 같다. 일반적으로 쓰이는 해시 함수로 www.naver.com을 해싱하여 결과값을 살펴봤다.
음~~,,, 길다.

앞서 말했듯, 이 서비스는 URL을 단축 하는데 그 목적이 있다. 내가 이름을 들어본 적이 있던 MD나 SHA 군의 해시 함수들을 보면 기본적으로 30자가 넘어가고 SHA family의 경우 답이 없다. "당신의 단축된 URL입니다!" 랍시고 71ac45d6a5574bc1cf48c041627fb5606b6072599199e27e0374276caa4c3b1d를 제공하는 서비스는 두 번 다시 이용하고 싶지 않을 것 같다.

탈락!


# Base64 인코딩 (or Base62)

다음으로 Base64이다. 일반적인 Base64 인코딩에 사용되는 charset은 [a-z][A-Z][0-9][+][/]이다. URL에 사용하기 위해 +/-_로 대체해 사용할 수 있다. 혹은, 아예 -_ 역시 삭제하고 Base62 인코딩을 사용할 수도 있다! binary 기반 데이터를 다루는 것이 목적이 아니므로 이쪽 종류의 인코딩을 사용한다면 아마 Base62 인코딩을 사용하게 될 듯?

"www.naver.com"을 일반적인 Base64로 인코딩해보자.
으음~~,,역시 길다. 그리고 Base64 인코딩은 24비트(ASCII 기준 3글자, 결과로 4개의 인코딩 문자열)씩 끊은 문자열에 대해 인코딩을 진행한 후, 남는 공간에 = 문자를 패딩으로 사용하는데, 이것 역시 사용자에게 좋은 경험이 아니다.

일반적으로 Base64 인코딩을 사용하는 이유는, binary 데이터를 text 기반으로 다룰 수 있기 때문이다. 그리고 해시 함수와 달리 Base64로 인코딩된 데이터는 그대로 디코딩 할 수도 있다(양방향성). 원본 값과 해시 값을 모두 저장해야하는 해시에 비해 base64를 이용하면 디비 등의 저장소에 인코딩 값만 보관해도 나중에 디코딩을 쉽게 할 수 있으니 자원을 절약할 수도 있다. 그래 자원 절약 좋다. 그래서, 축약은?


사실 지금까지 우리는 엉뚱한 시도를 해왔다고 할 수 있다. "축약"이 아닌 "인코딩" 방법을 찾아왔던 것이다.


# 원본 URL은 DB에 저장된다 -> DB값?

전략을 바꿔보자. URL 문자열 대신 다른 것을 인코딩에 이용하는 것이다.

축약 서비스를 이용하는 고객이 등록한 URL은 서비스의 DB에 저장될 것이다. DB에 저장되는 값은 대부분.. PK를 갖는다. 혹은 꼭 PK가 아니더라도 해당 레코드에 특화된 index 등의 값을 가질 수도 있다. 이것은 URL처럼 "길이를 가진 문자열"이 아니라 int 등의 primitive type으로 둘 수 있다.

그 index 값을 인코딩하면 어떨까? 원본 문자열이 아니라 primitive 타입을 인코딩하면 축약이 될 수 있지 않을까?

숫자를 각각 숫자의 문자열, 그리고 Decimal 자체일 때 Base64로 인코딩해보았다.

원본값base64 encoding값
'123456'MTIzNDU2
1234567omA
'999999999'OTk5OTk5OTk5OTk5
9999999997Ke/

위 표를 보면, String 값에 비해 decimal값을 base64로 인코딩하면 훨씬! 효율적인 결과가 나오는 것을 알 수 있다.

실제로 Base64 대신, 특수문자가 없는 Base62를 url index 인코딩에 사용하면 1. 단축 2. 저장된 URL의 구분 모두 가능해지지 않을까? Base62를 이용해 5글자로 단축된 URL을 제공한다면 가용한 namespace는 62^5, 약 91억개다. 실로! reasonable하다.


# 인코딩

URL에 대한 데이터는 과제 명세에 나와있던대로 ArrayList에 담았다.

private final ArrayList<String> urls = new ArrayList<>();

ArrayList에 저장된 각 URL 레코드 각각은 해당 리스트에서 index 값을 가질 것이다. 단축된 URL로서 사용자에게 제공될 값은 이 index를 Base62로 인코딩한 값이다.

  • 정수 타입의 인덱스를 받아 Base62로 인코딩한 문자열을 반환하는 메소드 encodeBase62()
  • Base62로 인코딩된 문자열을 받아 정수 타입의 인덱스로 반환하는 메소드 decodeBase62()

를 각각 작성했다. 비즈니스 로직의 핵심을 담당하는 코드라고 볼 수 있다!

private static final String TABLE = 
		"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

// int index -> String encodedValue
private String encodeBase62(int index) {
    StringBuilder res = new StringBuilder();
    do {
        res.append(TABLE.charAt(index % 62));
        index /= 62;
    } while (index % 62 > 0);

    return res.toString();
}

// String encodedValue -> int decodedIndex
private int decodeBase62(final String encodedUrl) {
    int index = 0;
    int pow = 1;
    for (char s : encodedUrl.toCharArray()) {
        index += TABLE.indexOf(s) * pow;
        pow *= 62;
    }
    return index;
}

그리고 실제로 url값을 받아 ArrayList에 추가하고 index를 반환하는 메소드 addUrl()이다.

// ArrayList url 추가하고 index return
public int addUrl(final String url) {
    urls.add(url);
    return urls.size()-1;
}

두 메소드를 다같이 이용해서, encode 완료된 URL을 반환하는 메소드 encodeUrl()을 작성했다.

public String encodeUrl(final String url) {
    return encodeBase62(urlData.addUrl(url));
}

위 로직들은 @Service레이어에 작성했다. @Controller에서 실제로 @PostMapping을 통해 Response entity까지 내려주는 부분은 아래다.

@PostMapping("/url")
public EncodingUrl.Response encodeUrl(@Valid @RequestBody 
							final EncodingUrl.Request request) {
    return EncodingUrl.Response.builder()
            .encodedUrl(urlService.encodeUrl(request.getUrl()))
            .originalUrl(request.getUrl())
            .build();
}

# 디코딩

이제 실제로 축약+인코딩된 값을 줬을 때, 해당 값을 디코딩 하여 index를 얻고 리스트에서 원본 URL을 가져오는 과정이다.

가장 먼저 URL이 저장된 리스트와 관련된 몇 가지 메소드를 작성했다.

public String getUrlOfIndex(int index) {
    return urls.get(index);
}
public int sizeOfUrls() {
    return urls.size();
}

그리고 인코딩된 URL 기반으로 디코딩된 index를 구하고, 리스트에서 해당 index에 존재하는 URL을 가져오게 했다.

public String decodeUrl(final String encodedUrl) {
    int decodedIndex = decodeBase62(encodedUrl);
    validateIndex(decodedIndex);
    return urlData.getUrlOfIndex(decodedIndex);
}

private void validateIndex(final int index) {
    if (index < 0 || index >= urlData.sizeOfUrls()) {
        throw new UrlException(UrlExceptionCode.NOT_FOUND_URL);
    }
}

validateIndex()로 index가 유효한 값인지 검사했다. decodeUrl()을 호출하면 실제로 리스트에 저장된 레코드가 반환될 것이다.(validation이 통과한다면)




2. 리다이렉팅

다음은 주로 사용하는 URL 단축 서비스 bit.ly에서 HTTP 요청 및 응답 메세지를 캡쳐한 모양이다.

GET /Ac6FG6 HTTP/1.1
Accept: text/html, application/xhtml+xml, */*
Accept-Language: ko-KR
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)
Accept-Encoding: gzip, deflate
Host: bit.ly
Connection: Keep-Alive
Cookie: _bit=4fde5237-00126-06293-421cf10a

HTTP/1.1 301 Moved
Server: nginx
Date: Tue, 26 Jun 2012 17:13:49 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Cache-control: private; max-age=90
Location: http://nachbaur.com/blog/core-graphics-isnt-scary-honest
MIME-Version: 1.0
Content-Length: 148

보면 알겠지만, 응답 메세지에 별다른 body가 없다. 다만 status code가 301(3xx : 해당 페이지는 옮겨졌으므로 그곳으로 리다이렉트할 것을 의미), 그에 따른 Location 항목이 리다이렉트할 URL으로 지정된 것을 알 수 있다.


리다이렉트를 위해 레퍼런스를 찾아봤는데, 기능 구현을 위한 여러 가지 방법이 있는듯 했다. String 형식의 redirect:를 사용하거나, HttpServletResponse에 대해 sendRedirect()메소드를 호출하거나 하는 방식으로 말이다.

나는 RedirectView 객체를 사용하는 방법을 택했다.

@GetMapping("/{encodedUrl}")
public RedirectView redirect(@PathVariable("encodedUrl") 
								final String encodedUrl) {
    return new RedirectView(urlService.getRedirectUrl(encodedUrl));
}
  • 일단 "RedirectView"라는 문구 자체를 사용한다는 점에서 아, 이 메소드는 리다이렉팅을 하는 기능을 갖고 있구나! 라고 이해하기 쉽다는 점
  • 리다이렉팅과 관련해서 Spring 프레임워크에서 제공하는 기능을 사용할 수 있다는 점

때문이다.

getRedirectUrl()은 아래와 같이 구현했다.

public String getRedirectUrl(final String encodedUrl) {
    int decodedIndex = decodeBase62(encodedUrl);
    validateIndex(decodedIndex);
    urlData.increaseReqNumOfIndex(decodedIndex);
    return "http://" + decodeUrl(encodedUrl);
}

별다른거 없이 단순히 디코딩 -> 유효성 검사 -> http:// 붙여서 절대경로의 url 생성 과정이 일어났다.


주요 기능은 이정도인데, Spring Boot Application을 실행한 후 localhost:8080에 실제로 Postman을 이용해서 몇 가지 URL 데이터를 넣어보고 리다이렉팅이 잘 일어나는지 실험해보자. (테스트 코드를 작성하는게 제일 좋지만.. 테스트코드 짜다가 밤샐 실력의 나에겐 너무 허들이 높다)

일단 Post 요청 몇 번으로 단축할 URL을 만들었다.
그리고 @GetMapping("/{encodedUrl}")을 확인하기 위해 각각 localhost:8080/의 뒤에 a,b,c,d로 접근해보았다. 크롬 개발자도구의 network 탭에서 HTTP 메세지를 확인해봤다.

Request URL: http://localhost:8080/c
Request Method: GET
Status Code: 302

Response header의 Location 항목도 원하는대로 잘 뽑혔다!

Location: http://buchu-doodle.tistory.com/

참고로 화면상에도 잘 나온다. 굳굳




3. 그 외 신경쓴 사항?

3-1) Optional : 요청 횟수 저장 및 조회

Optional으로 말씀하셨지만, 요청횟수 저장 및 조회 기능을 구현하기 위해 기존에 URL을 저장한 urls ArrayList 밑에 requestNum을 추가했다.

private final ArrayList<Integer> requestNum = new ArrayList<>();

새로운 URL이 들어올 때 addUrl() 메소드를 이용했는데, 같은 index의 requestNum에 0을 해준다. 이 리스트에 담긴 숫자는 요청 횟수를 의미한다. 처음 등록했을 땐 요청횟수가 0이므로, 0으로 초기화한다.

public int addUrl(final String url) {
    urls.add(url);
    requestNum.add(0);
    return urls.size()-1;
}

그리고 /{encodedUrl} 요청의 encodedUrl를 decode한 URL을 반환하기 위해 getRedirectUrl()을 호출할 때, 같은 index의 requestNum 항목 값을 올린다.

public String getRedirectUrl(final String encodedUrl) {
    int decodedIndex = decodeBase62(encodedUrl);
    validateIndex(decodedIndex);
    urlData.increaseReqNumOfIndex(decodedIndex);
    return "http://" + decodeUrl(encodedUrl);
}

increaseReqNumOfIndex() 메소드가 그 부분이다!

public void increaseReqNumOfIndex(int index) {
    requestNum.set(index, requestNum.get(index)+1);
}

해당하는 requestNum@GetMapping("/check/{encodedUrl}")에서 확인할 수 있도록 했다.

// controller
@GetMapping("/check/{encodedUrl}")
public ReqNumResponse getRequestedNumber(@PathVariable("encodedUrl") 
											final String encodedUrl) {
    return ReqNumResponse.builder()
            .encodedUrl(encodedUrl)
            .originalUrl(urlService.decodeUrl(encodedUrl))
            .requestedNumber(urlService.getRequestedNumOfUrl(encodedUrl))
            .build();
}

// service
public int getRequestedNumOfUrl(final String encodedUrl) {
    int decodedIndex = decodeBase62(encodedUrl);
    validateIndex(decodedIndex);
    return urlData.getReqNumOfIndex(decodedIndex);
}

validateIndex()를 상기하자면, decode된 index 범위를 검사함으로써 encodedURl이 현재 url 정보를 담고있는 리스트에 존재하는지 여부를 확인한다.


3-2) 프론트가 생겼을 때 활용 가능한가?

으음~ 모르겠다. 나름 DTO를 사용한답시고 사용했는데, 프론트와 협업 비스무리 한 것도 해본 적도 없고 (냅다 thymeleaf+bootstrap으로 화면을 지 혼자 구성해온 여성) 뭘 고려해야할지 배운적도 없어서 T T

DTO 관련 사항은 설명할 것도 없어서 넘어가기로 하고 Exception handling에 관해 간단히 훑자.

일단 프로젝트에서 사용할 Exception 객체를 만들었다. Unchecked Exception으로 만들것이기 때문에 RuntimeException을 상속받도록 했다.

@Getter
public class UrlException extends RuntimeException{
    private final UrlExceptionCode urlExceptionCode;
    private final HttpStatus statusCode;

    public UrlException(UrlExceptionCode code) {
        super(code.getMessage());
        this.urlExceptionCode = code;
        this.statusCode = code.getStatus();
    }
}

사용되는 UrlExceptionCode는 아래와 같은 enum 타입이다.

@RequiredArgsConstructor
@Getter
public enum UrlExceptionCode {
    INVALID_URL("유효한 URL 형식이 아닙니다!", HttpStatus.BAD_REQUEST),
    EMPTY_DATA("URL 데이터가 비어선 안됩니다!", HttpStatus.BAD_REQUEST),
    INVALID_DATA("유효한 데이터가 아닙니다!",HttpStatus.BAD_REQUEST),
    NOT_FOUND_URL("해당하는 URL은 존재하지 않습니다!", HttpStatus.NOT_FOUND),
    ALREADY_EXISTS("해당 URL은 이미 저희 서비스에 등록된 URL입니다!", HttpStatus.CONFLICT),
    INTERNAL_ERROR("서버 내에서 오류가 발생했습니다!",HttpStatus.INTERNAL_SERVER_ERROR);

    private final String message;
    private final HttpStatus status;
}

프로젝트 내에서 던져지는 에러는 아래 형식이다. validation fail 했을 때 각 상황에 맞는 에러코드를 생성자로 넣었다.

private void validateRegisteringUrl(final String registeringUrl) {
    if (registeringUrl.isEmpty()) {
        throw new UrlException(UrlExceptionCode.EMPTY_DATA);
    }
    if (!registeringUrl.contains(".")
            || urlData.isReserved(registeringUrl)) {
        throw new UrlException(UrlExceptionCode.INVALID_URL);
    }
    if (urlData.contains(registeringUrl)) {
        throw new UrlException(UrlExceptionCode.ALREADY_EXISTS);
    }
}

그러면.. 아래와 같은 @ExceptionHandler 메소드가 호출된다.

@RestControllerAdvice
public class UrlExceptionHandler {
    @ExceptionHandler(UrlException.class)
    public ResponseEntity<UrlExceptionResponse> handleUrlException(UrlException e) {
        UrlExceptionResponse response = UrlExceptionResponse.builder()
                .code(String.valueOf(e.getUrlExceptionCode()))
                .message(e.getMessage()).build();
        return ResponseEntity.status(e.getStatusCode())
                .body(response);
    }
}

@RestControllerAdvice 어노테이션과 함께 사용된 것을 확인하자. ResponseEntity 객체를 이용해서 body에 넣을 json 데이터와 status를 직접 설정해서 반환했다. UrlExceptionResponse는 예외 발생시 응답으로 내려줄 에러 DTO다.

@Builder
@Getter
public class UrlExceptionResponse {
    private final String code;
    private final String message;
}

간단하게 codemessage 정보.. 를 담도록 했다..




REFERENCE

https://mangkyu.tistory.com/204
https://metalkin.tistory.com/53
https://metalkin.tistory.com/50

profile
부추튀김인지 부추전일지 모를 정도로 빠싹한 부추전을 먹을래

0개의 댓글