tiny url 개발

tiny url 개발

short url 이라고도 부른다


Tiny URL


1. 개요

tiny url은 https://www.google.com/search?q=tiny+url&oq=tiny+url&sourceid=chrome&ie=UTF-8 라는 긴 링크를 https://tinyurl.com/2k4dvsw7 와 같이 짧게 단축시키는 것을 의미한다.

왜 사용하는지에 대한 의견은 여러가지가 있고 정답은 없을것이나, 우리 회사의 경우엔 SMS나 카카오톡에 넣기에는 너무 긴 링크들이 있어서 사용하게 되었다.


2. 구현

1. 길이는 어느정도가 좋을까?

마법의 숫자 7로 유명한 밀러의 법칙에 근거해 일단 최대 7자리 정도의 길이로 만들어볼 예정이다.

2. 어떻게 만들까?

단축된후의 URL이 최대 길이 7의 문자열이므로 이 7자리의 문자열로 최대한 많은 URL을 매핑할 수 있어야 한다.

일단 단축되기전의 원본 URL과 단축된 URL을 매핑하기 위해 이미 사용중인 MySQL을 활용할것이다.

ID 타입을 bigint로 하면 너무 크기 때문에 int로 한다. (대부분의 회사에서는 하나의 테이블에 1억개의 record가 insert되는 일조차 거의 없을것이므로 21억개를 저장할 수 있는 int도 이미 충분히 많다!)

URL을 데이터베이스에 저장하면 정수형 ID가 생성될 것인데, 이 정수형 ID를 그대로 단축 URL로 사용한다고 가정하면, 7자리의 문자열밖에 사용할 수 없으므로 9,999,999개의 URL만 매핑할수 있다.

하루에도 최소 수십 수백개의 단축 URL이 생성되어야 할 것이고, MySQL의 int형 타입의 최대값은 2,147,483,647이므로 매핑할 수 있는 URL의 수가 최대 9,999,999개라면 너무 적다고 생각된다.

그렇다면 컴퓨터과학에서 배운 진법을 활용해볼 수 있다.

3. 16진수

16진수는 0~9, a~f로 총 16개의 문자로 이루어지며 각 자릿수 하나가 2^4 = 16개의 값을 표현할 수 있다.

우선 DB에서 반환받은 ID를 16진수로 변환한다고 가정하면 7자리의 16진수를 사용할 수 있게 되므로, 매핑할 수 있는 최대 URL의 수는 16^7 = 268,435,456개가 된다.

9,999,999개에 비하면 아주 많아지나 MySQL의 int형 최대값인 2,147,483,647에 비하면 아직 턱없이 적다.

4. 64진수

그렇다면 64진수를 사용해볼 수 있다.

64진수는 0~9, a~z, A~Z, +, / 로 총 64개의 문자로 이루어지며 각 자릿수 하나가 2^6 = 64개의 값을 표현할 수 있다.

7자리의 64진수를 사용한다면 64^7 = 4,398,046,511,104개. 약, 4조 4천억개의 URL을 매핑할 수 있다. 천문학적인 수의 URL을 매핑할 수 있으나 아직 문제가 있다.

  1. 2,147,483,647개에 비해 너무 많은 URL을 매핑할 수 있어 괴리가 크다.
  2. 우리가 만드려는것은 결국 URL인데 64진수는 URL에서 사용하기 어려운(예약어) +, / 가 포함되어 있다.

5. 62진수

그렇다면 62진수를 사용해볼 수 있다.

62진수는 모든 구성 문자가 URL에서 아무문제 없이 사용할 수 있는 0~9, a~z, A~Z 총 62개의 문자로 이루어진다.

7자리의 62진수를 사용한다면 62^7 = 3,521,614,606,208개. 약, 3조 5천억개의 URL을 매핑할 수 있다. 아직도 수가 너무 많으므로 자릿수를 7개가 아닌 6개정도로 제한해보자.

그렇다면 6자리의 62진수 문자열을 사용하므로 62^6 = 56,800,235,584개. 약, 568억개의 URL을 매핑할 수 있다. MySQL int형 ID의 최대값인 2,147,483,647개와도 괴리가 너무 크지 않다.


3. 구현

image

1. 62진수 변환

우선 Base62 클래스를 작성해보자. 이 경우엔 테스트 케이스가 명확하므로 테스트 코드를 우선 작성한다.

class Base62Test {
    @ParameterizedTest
    @CsvSource({"0, 0", "1, 1", "10, a", "100, 1C", "1000, g8", "10000, 2Bi"})
    void encode(int number, String expected) {
        // given
        Base62 base62 = new Base62();

        // when
        String base62String = base62.encode(number);

        // then
        assertThat(base62String).isEqualTo(expected);
    }

    @ParameterizedTest
    @ValueSource(ints = {0, 1, 10, 100, 1000, 10000})
    void decode(int number) {
        // given
        Base62 base62 = new Base62();
        String base62String = base62.encode(number);

        // when
        int decoded = base62.decode(base62String);

        // then
        assertThat(decoded).isEqualTo(number);
    }

    @ParameterizedTest
    @ValueSource(strings = {";", "/", "?", ":", "@", "=", "&"})
    void isBase62(String specialCharacter) {
        assertThat(new Base62().isBase62(specialCharacter)).isFalse();
    }
}

이 테스트가 통과하도록 Base62 encode & decode 함수를 작성하면 대략 다음과같이 구현할 수 있다.

import java.util.Map;
import java.util.HashMap;

final class Base62 {
    private final int base;
    private final String base62Characters;
    private final Map<Character, Integer> lookupTable;

    Base62() {
        this.base = 62;
        this.base62Characters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
        this.lookupTable = base62Characters
                .chars()
                .collect(HashMap::new, (map, value) -> map.put((char) value, map.size()), HashMap::putAll);
    }

    String encode(int decimal) {
        if (decimal < 0) {
            throw new IllegalArgumentException("Decimal must be greater than or equal to 0 !");
        }

        StringBuilder sb = new StringBuilder();
        while (decimal >= 0) {
            sb.append(base62Characters.charAt(decimal % base));
            decimal /= base;
            if (decimal == 0) break;
        }
        return sb.reverse().toString();
    }

    int decode(String base62String) {
        if (!isBase62(base62String)) {
            throw new IllegalArgumentException("Invalid base62 string !");
        }

        int result = 0;
        for (int i = 0; i < base62String.length(); i++) {
            result = result * base + lookupTable.get(base62String.charAt(i));
        }
        return result;
    }

    boolean isBase62(String base62) {
        if (base62 == null || base62.isBlank()) {
            return false;
        }

        for (char c : base62.toCharArray()) {
            if (!lookupTable.containsKey(c)) {
                return false;
            }
        }

        return true;
    }
}

2. DB 테이블 작성

tinyurl 테이블을 작성한다.

CREATE TABLE tinyurl (
    id INT AUTO_INCREMENT PRIMARY KEY,
    long_url VARCHAR(500) NOT NULL UNIQUE,
    expired_at DATETIME NOT NULL,
);

3. TinyUrlExchangeService 작성

일부 loop문에 대한 최적화를 위해 아주 작은 사이즈의 in-memory cache를 적용해볼것이다. 알고리즘은 LRU로 선택했다.

만료일시의 경우 일단 간단하게 DB layer에서 3개월정도로 설정할 예정이다.

구현 코드는 다음과 같다.

import java.util.regex.Pattern;

import org.springframework.stereotype.Component;
import org.springframework.util.ConcurrentLruCache;

/**
 * The TinyUrlExchangeService class is responsible for generating and retrieving shortened URLs.
 */
@Component
final class TinyUrlExchangeService {
    /**
     * The Base62 class provides encoding and decoding functionality for converting numbers to and from base 62.
     * Base 62 uses digits 0-9, lowercase letters a-z, and uppercase letters A-Z.
     */
    private final Base62 base62;

    /**
     * String pattern of 6 characters or fewer consisting of 0-9, a-z, A-Z
     */
    private final Pattern base62Pattern;

    /**
     * The repository that stores and retrieves long URLs.
     */
    private final TinyUrlRepository repository;

    /**
     * A cache that stores the long URL ID.
     */
    private final ConcurrentLruCache<String, Integer> cache;

    public TinyUrlExchangeService(TinyUrlRepository repository) {
        this.base62 = new Base62();
        this.base62Pattern = Pattern.compile("[0-9a-zA-Z]{1,6}");
        this.repository = repository;
        this.cache = new ConcurrentLruCache<>(
                50, longUrl -> repository.getId(longUrl).orElseGet(() -> repository.save(longUrl)));
    }

    /**
     * Retrieves the tiny URI associated with a given long URL.
     *
     * @param longUrl the long URL
     * @return the tiny URI
     * @throws IllegalArgumentException if the long URL is null or empty
     */
    public String tinyUri(String longUrl) {
        if (longUrl == null || longUrl.isBlank()) {
            throw new IllegalArgumentException("Long URL must not be null or empty !");
        }

        int id = cache.get(longUrl);
        return base62.encode(id);
    }

    /**
     * Retrieves the long URL associated with a given tiny URI.
     *
     * @param tinyUri the tiny URI
     * @return the long URL
     * @throws IllegalArgumentException if the tiny URL is null or empty, or if it does not match the base62 pattern
     * @throws UnsupportedOperationException if the long URL cannot be found
     */
    public String getLongUrl(String tinyUri) {
        if (tinyUri == null || tinyUri.isBlank()) {
            throw new IllegalArgumentException("Tiny URL must not be null !");
        }

        if (!base62Pattern.matcher(tinyUri).matches()) {
            throw new IllegalArgumentException(STR."`\{tinyUri}` is invalid tiny URL !");
        }

        int id = base62.decode(tinyUri);
        return repository
                .getById(id)
                .orElseThrow(() -> new UnsupportedOperationException(STR."`\{tinyUri}` is not a valid tiny URL !"));
    }
}

4. TinyUrlService 작성

생성한 Tiny URI와 커스텀 도메인을 결합해 Tiny URL을 만든다. 최종적으로는 이 클래스가 외부에 노출되어야 하므로 public class가 된다.

/**
 * The TinyUrlService class is responsible for generating tiny URLs based on the provided destinations and parameters.
 */
@Service
@RequiredArgsConstructor
public final class TinyUrlService {
    private final NotificationProperties props;
    private final TinyUrlExchangeService exchange;
    private final DestinationDesignator destinationDesignator;

    /**
     * Generates a tiny URL for a given destination and parameters.
     *
     * @param dst the destination application for which the tiny URL needs to be generated
     * @param parameters a map of parameters to include in the tiny URL
     * @return the generated tiny URL
     * @throws IllegalArgumentException if the long URL is null or empty
     */
    public String generate(Path dst, Map<String, String> parameters) {
        String longUrl = destinationDesignator.designate(dst, parameters); // long url 을 만드는 부분. 알아서 구현해보자.
        String tinyUri = exchange.tinyUri(longUrl);
        return STR."\{props.shortHost()}/shorts/\{tinyUri}";
    }

    /**
     * Retrieves the long URL associated with a given tiny URI.
     * @param tinyUri the tiny URI
     * @return the long URL
     * @throws IllegalArgumentException if the tiny URL is null or empty, or if it does not match the base62 pattern
     * @throws UnsupportedOperationException if the long URL cannot be found 
     */
    public String getLongUrl(String tinyUri) {
        return exchange.getLongUrl(tinyUri);
    }
}

5. TinyUrlController 작성

마지막으로 Tiny URL을 해석하고 클라이언트를 리디렉션시켜줄 컨트롤러 작성한다.

@RestController
@RequiredArgsConstructor
class TinyUrlController {
    private final TinyUrlService tinyUrlService;

    @GetMapping("/shorts/{tinyUri}")
    RedirectView getLongUrl(@PathVariable String tinyUri) {
        String longUrl = tinyUrlService.getLongUrl(tinyUri);
        return new RedirectView(longUrl);
    }
}

© 2022. All rights reserved.