프로그래밍/넥스터즈

[Nexters] 넥스터즈 (URL 단축팀) 정기활동 1주차 기록 - URL 단축 알고리즘 구현 및 코드 리팩토링

Jay Tech 2019. 2. 24. 13:16
반응형

난 시간이 이렇게 빨리 갈 줄 몰랐다. 넥스터즈의 겨울 정기활동 2개월이 끝이 났다. 결과는 감사하게도 우수팀 선정!  매 주 세션 후 기록을 하려고 했지만, 너무 바빠서 기록할 틈이 없었다. 결론 적으로는 우리팀은 무사히 런칭을 마쳤다. 팀원들에게 너무 고맙다.


여태 했던 것들을 까먹을 까봐 적어 놓는다. 


일단 우리 팀은 URL을 단축 하는 서비스를 개발하는 팀이다. 팀 구성은 (서버3, 디자인2, 프런트엔드3) 이렇게 되었다. 나는 서버 개발을 맡았다. URL을 단축 한다는 것은 큰 의미가 있다. 일반인들이 보기에는 저걸 굳이 줄여야할 필요가 있나라고 생각을 한다. 하지만 알게 모르게 그들도 일상속에서 단축 URL을 사용한다. 


일례로, 가장 큰 서비스인 bit.ly가 있다. 원래 google의 단축 서비스가 있었지만 지금은 종료 되었다. 유튜브, 트위터도 가지고 있다. 도대체 무엇때문에 URL을 줄이는 것일까.

예를들어, 트위터는 Post의 글자수 제한이 있다. 그렇게 되면 엄청 긴 URL이 Post글자수를 만땅 채우고 내가 원하는 본문은 작성도 다 못할 것이다. 그렇기 때문에 최대한 URL을 줄이는 것이다.

또한 카톡방의 엄청 긴 URL을 본적이 있을 것이다. 



이렇게 길게 나오게 되면 보는 사람도 기분이 매우 언짢고 내가 만약에 블로그에 포스팅을 하게 되면 본문을 읽다가 매우 긴 URL에 으악 놀라면서 지저분한 게시글이라고 생각할 수 있다. 


두 번째로 Branding의 효과가 있다. bitly 서비스에는 본인 이름의 URL을 만들어 준다. 그렇게되면 홍보를 할때 URL 자체만 보아도 이름이 들어가 있기 때문에 브랜드 효과를 볼 수 있다. 

우리의 서비스는 도메인 자체가 동아리 이름인 넥스터즈가 들어가 있기 때문에 홍보 효과가 크다. 그래서 이 프로젝트는 가치가 있는 것이다. 


본론으로 들어가 이 프로젝트는 URL을 단축시켜 짧게 만들어주고 이에 따른 통계(추후 설명)를 보여준다. 먼저 깃허브 organization에 프로젝트를 등록 시켰다. 그리고 회의를 통해서 프로젝트의 공통 환경을 정했다. 


프레임워크는 Spring Boot 2, 테스트 프레임워크 JUnit5, 자바 빌드 도구는 Maven, CI는 젠킨스, 저장소는 Github, 버전 관리는 Git, 서버환경은 CentOS, 데이터베이스는 MySQL, 그리고 슬랙 (헐 지금 쓰면서 생각났는데 슬랙 왜 안썼지... 이메일자동알림과 카톡방을 주로 사용했던 것 같다... 다음에 쓰는 걸로!) 일단 대표적인 툴은 이정도 이다. 상세한 내용은 추후에 설명한다.


버전 관리는 Git을 사용했다. 처음 Git을 공부하고나서는 항상 master에 때려 박았다. 하지만 이번 기회에 GitFlow 전략을 조금이나마 알 수 있었다. 그리고 일전에 한 master에 때려 박는 방법이 얼마나 위험하고 안 좋은 방법인지 알게 되었다.


[GitFlow란]

깃 플로우는 일종의 방법론이다. 공식 홈페이지 (bitbucket)의 내용에 따르면 깃 플로우는 단지 깃의 workflow의 추상적인 아이디어라고 표현되어 있다. 이것은 어떤 브랜치를 만들고 어떻게 머지를 하게 되는지에 대한 내용이다. gitflow를 설치를 해도 되지만, 브랜치 이름만 지켜도 이 방법론을 쓸 수 있다.

우리는 주 개발은 Develop 브랜치, 기능 개발은 Feauture를 따서 Feature 브랜치에 개발을 하기로 하였다. 


출처 : https://es.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow


우리는 딱 이런 식으로 하기로 하였다. 추가로는 릴리즈 브랜치, 핫 픽스 브랜치 등이 있다.



초기 Pom세팅이다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.1.RELEASE</version>
        <relativePath/<!-- lookup parent from repository -->
    </parent>
    <groupId>me.nexters</groupId>
    <artifactId>chop</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>chop</name>
    <description>shorten url for nexters</description>
 
    <properties>
        <java.version>1.11</java.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>
 
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
 
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>junit</groupId>
                    <artifactId>junit</artifactId>
                </exclusion>
            </exclusions>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.restdocs</groupId>
            <artifactId>spring-restdocs-mockmvc</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- Provide JUnit 5 API -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- and the engine for surefire and failsafe -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
 
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
 
</project>
 
cs



기존에 제공되는 spring boot starter test에는 기본적으로 Junit4가 들어있다. 그것을 Exclude 해주고 Junit5를 집어넣었다. 나중에 몇 개 더 추가하긴 했다. (mokito 등) 하면서 기록했어야 되는데 끝나고 history보면서 기록하니까 매우 불편하고 빼먹는것이 있을 것 같다... 앞으론 하면서 기록하자.





각자의 할일을 TODO로 깃 허브 이슈에 올려서 작업을 하였다. 나의 1주차 TODO 목록이다. URL 단축 알고리즘 구현이 메인 작업이었다. 


맨 처음 생각했던 것은 길어진 URL을 해싱 으로 줄여보자라는 방법 이었다. 해싱 참고 : https://pjh3749.tistory.com/222?category=761512


긴 문자열을 MD5로 줄여보았다. 하지만 줄인 결과는 매우 길었다. URL을 단축하려면 최대한! 짧은 문자가 나와야 한다. 기존의 생각한 것은 한 5~7자리 정도 였다. 예를들어 nexters.me/qweqwe 이런식으로 말이다. 하지만 MD5는 그 이상으로 넘어갈 뿐더러 해싱 후 최상단 5자리를 짜르자니 중복이 생길것 같았다. 참고로 sha 계열은 더 길어진다.


고민 결과 일련의 정수를 인코딩하는 방법을 생각했다. 인코딩은 62진법을 사용했다. 62진법은 이메일에 binary content를 사용하기 위해 고안되었다. 




딱 이 테이블이 우리 프로젝트에 들어 맞는 테이블이었다. 사실 64진법의 표이지만 실제로는 제외할 문자들이있다. = equal 기호와 / 슬래시 기호이다. 저 equal기호는 추가로 나와 있고 + 플러스기호도 제외 하였다. equal기호는 빈 bit를 채우기 위해서 사용되는 것이데 우리는 URL표현을 위해서 저들은 제외를 하였다. 슬래시는 디렉토리로 인식을 하게 되고 equal기호는 쿼리 파라미터로 인식하기 때문이다. 아주 간단히 말하자면 0부터9까지 알파벳 소문자,대문자 이렇게 합하면 총 62개의 character가 나오게 된다.


[참고]

URL을 더 짧게하려면 당연히 쓰이는 character를 늘리면 된다. 키보드 특수기호까지 다 이용할 수 있다. 하지만, 여기서 사람들의 사용성과 심미성을 고려해야한다. 62진법이 maximum인 이유를 설명해본다.


1. 모바일에서 링크를 직접 입력하는 경우 english 자판에서 특수문자로 넘어가려면 자판 슬라이딩을 해야한다. 사용자가 불편함을 느낄 것이다.

2. 육성으로 링크를 전달하는 경우, "야, nexters.me에 소문자 에이, 대문자 비" 이런식으로 편하게 공유할 수 있다. (쓰잘데기 없어보이지만, 육성공유 은근 한다)

3. 무엇보다 보기에 깔끔하다.


이 테이블과 내가 generating한 정수와 매핑작업을 할 것이다. 테이블 매핑을 해야 하므로 필요한 연산은 모듈러 연산이다.


예를들어 120이란 숫자를 인코딩 해보자. 62로 나눈 나머지를 계산한다. 나머지를 계산한 후 해당 나머지를 저 위의 테이블에 매핑시킨다. 그리고 몫이 남는다면 계속 그 값으로 나머지 연산을 수행하여 테이블에 매핑 시킨다.



딱 그 base62 인코딩 로직이다. base62String은 그냥 설정파일에 등록을 해놓고 (위의 62진법 문자열) 들어오는 넘버를 해싱해서 문자열을 만드는 과정이다. (복기해보니 저기서 수정할 부분이보인다. 저 테이블을 매번 생성할 필요가 없네... static으로 올려야 될 듯 하다)


그렇다면 저 inputNumber가 고유한 값이면 절대 겹치지 않는 짧은 문자열이 생성이 된다. 아주아주 고유하다. 하지만 저 inputNumber가 관건이다. 저게 무조건, 무슨 일이 있어도 고유한 값이 넘어가야 한다. 


이 방식을 썼을 때 우리가 처음 생각한 경우의 수는 단축 문자열의 길이가 5정도로 생각을 했다. 그럼 62 * 62 * 62 * 62* 62 즉 62의 5승가지의 경우가 생긴다. 그럼 총 9억개의 URL이 생성될 수 있다. 이 정도면 충분하다고 판단하였다. 글을 작성하는 현 시점에 제일 큰 서비스인 bilty는 총 400억개의 url이 단축되었다. 그럼 우리 목표 치 9억개면 견줄만하지 않을까? 

우리 서비스가 부족해서 6자리로 생각을 한다면 9억 곱하기 62개가 된다. 그럼얼마지.. 550개인가? 여튼 그건 그렇고 저 inputNumber를 어디서 가져올까.


랜덤 Number 생성을 생각했다. 그럼 매우 위험하다. 초기에는 겹치지 않겠지만 날이 갈수록 같은 넘버가 생성될 가능성이 있기 때문에 충돌이 매우 많이 일어날 것이다. 그러면 저 안정적인 고유한 값을 어디서 가져올까 고민하다가 데이터베이스를 이용하기로 했다. 데이터베이스의 칼럼인 id 값을 가져오기로 한 것이다.



우리 데이터베이스의 URL 테이블의 auto increment 항목인 id 값이다. 이 아이디 값을 가져와서 고유한 번호로 사용한다. 당연히 겹치지 않는다. (디비서버를 여러 대 두었을 때 동시성문제는 현재 논외이다.) 데이터베이스에서 현재 id 값을 채번하여 +1을 해주어서 그 숫자를 base62로 인코딩 한 후 문자열을 생성해서 단축 url을 제공한다. 복잡해 보이지만 별거 없다.



일단 테이블은 이렇게 생겼다. 줄이려는 긴 url은 origin_url로 들어가고 생성된 단축 url은 short_url에 들어가게 된다.


그리고 이미 있는 긴 URL이라면 데이터베이스에 존재하는 줄여진 짧은 URL을 반환한다. 즉, 긴 URL도 고유 값으로 간주한다. 이 부분은 통계를 내기 위함이다. 너도나도 같은 주소를 줄였는데 다른 단축 URL이 나오게 된다면 전체 통계를 내기 매우 비효율적이기 때문이다.


개선 전 로직이다. 


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional
    public Url save(UrlRequestDto dto) {
        int hashNumber = findMaxIdFromDatabase();
        String originUrl = dto.getOriginUrl();
 
        Url maybeUrl = shortenRepository.findUrlByOriginUrl(originUrl);
 
        if (maybeUrl != null) {
            return maybeUrl;
        }
 
        Url url = Url.builder()
                .originUrl(originUrl)
                .shortUrl(base62Encode(hashNumber))
                .build();
 
        return shortenRepository.save(url);
    }
cs


1
2
3
4
    private int findMaxIdFromDatabase() {
        return (int)(shortenRepository.getMaxId() + 1);
    }
 
cs


한 함수당 최대한 하나의 기능만 하도록 분리하였지만 개선할 점이 있어 3주차에 수정을 했다. 먼저번 코드는 위에 보는 것처럼 일단 디비에서 id를 채번한다. 그리고 나서 URL이 이미 줄여져서 데이터베이스에 있는지 검사한다. 그리고 나서 인코딩 후 저장하게 된다. 이 로직의 문제점은 이미 데이터베이스에 줄이려는 URL이 있다면 디비 id를 채번을 하지 않아도 된다. 그렇다면 매우 비효율적인 로직이다.


개선 후 모습이다. 함수를 더 쪼개서 관심사를 분리했다.







처음에 shorten 함수를 탄다. 그리고 있는지 없는지 검사를 하게 된다. 존재하게 되면 그 값을 리턴해주고 없으면 저장해야 되므로 saveUrl함수를 타게한다. null 처리는 Optional을 사용하여 더 간결하게 하였다. 훨씬 간결해졌다. 존재하는 URL이면 shorten함수 단에서 끝나게 된다. 그리고 나서 URL을 저장할 때는 무조건 데이터베이스 채번을 해야 하기 때문에 hashNumber에 채번한 id값을 넣어주었다. (지금 또 생각해보면 findMaxIdFromDatabase 함수내에서 플러스1을 해주어야 될게 아니라 saveUrl함수에서 플러스 1을 해주는 것이 나아보인다. 함수의 이름에서 본 것처럼 디비의 맥스 id를 찾는다 라는 이름인데 그 안에서 +1을 해주어서 반환하게 되면 이름과 맞지 않기 때문이다.)

그리고 URL 모델은 빌더 패턴을 이용해서 넘겼다. 생성자 방식보다 값을 넣는게 아주명확하고 실수할 확률이 없기 때문에 빌더패턴을 선호한다. 


이렇게 단축 URL이 저장되게 된다. 


지금까지 1주차 아이디어 논의와 차 후에 리팩토링한 부분이었다. 앞으론 미리미리 기록좀 하자.

반응형