본문 바로가기
Stove Dev Camp

Stove Dev Camp Project#1 Url Shortner

by AndoneKwon 2020. 12. 22.

- Stove Dev Camp 란?

스마일 게이트의 법인 중에서 플랫폼 사업을 하는 Stove에서 진행하는 인턴십 프로그램이다.

 

이전까지는 스마일게이트 서버캠프라는 이름의 단순 캠프로 진행되었지만 이번에는 Stove 법인에서 맞추어 취업연계까지 되는 전환형 인턴

으로 새롭게 진행하고 있다.

 

지난 겨울때, 온라인으로 진행되었던 서버캠프에 이어서 이번에도 운이 좋게도 캠프에 참여하게 되었다.

 

- Project #1 Url Shortner

서버캠프는 교육을 시키며 본격적인 팀 프로젝트가 들어가기 전에 개인에게 프로젝트를 주고 해결하게 한다.

2가지의 프로젝트를 주는데 이번 프로젝트는 Url을 줄여주는 Url Shortner를 구현해보는 과제이다.

 

Url shortner란?

bitly의 홈페이지 이미지

말그대로 URL을 그대로 다른사람들한테 URL을 그대로 공유하려고 하면 주소가 너무 길어지니 이것을 단축시켜주는 서비스이다.

예를들어

www.smilegate.com/ko/main/main.asp

 

라는 주소를 공유하고자 한다고 치자.

이때에 이것을 저 위에 홈페이지에 요청한다면

https://bit.ly/34BvcxQ

와 같은 짧게 생성된 URL이 생성된다.

 

일반적으로 이러한 URL을 줄이는 방식에서는 Base62 라는 방식을 이용하여 하게 되는데 Base64 방식이 있는데 굳이 Base62를 사용하는 이유는 Url을 생성할 때에 .과 / 두문자가 들어가면 Url을 해석할 때에 문제가 생길 수 있기 때문이다.(부정확 할 수도 있다.)

 

이것을 어떻게 구현 해야할지 많은..고민을 했는데 결론 적으로 같은 URL일때 같은 Shortner URL이 나와야 하기 때문에 DB를 적극적으로 사용하기로 하였다.

 

스프링부트로 구현하였는데 폴더 구조는 아래와 같다.

기본적인 기능의 시퀀스 순서는 사용자의 URL을 디비에 저장하여 그 인덱스 값을 리턴받아서 그 인덱스를 인덱싱 하는 방식으로 구현하였다. 단, 여기서 최근에 사용한 URL은 다시 참조될 확률이 높기 때문에 캐시서버를 이용해서 LRU방식으로 최근에 조회한 URL의 Expire Time을 갱신시켜준다.

 

각 세부 코드를 확인해보겠다.(클라이언트는 귀찮으니까 생략한다..)

 

RedisRepositoryConfig.java

package com.example.demo.conf;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {
    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;
    @Value("${spring.redis.password}")
    private String password;

    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setHostName(host);
        //configuration.setPassword(password);
        configuration.setPort(port);

        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(configuration);
        return lettuceConnectionFactory;
    }

    @Bean
    public RedisTemplate<String,String> redisTemplateString(){
        RedisTemplate<String,String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }


}

redisConnection을 하는 객체를 Factory 패턴으로 생성을 한다. 

여기서, Netty 기반의 Lettuce를 이용하여 Redis를 연결한다.

 

그 후 RedisTemplate를 설정해서 redis에 값을 저장하거나 불러올때 사용한다.

 

다음은 데이터베이스에 저장되는 Entity들의 정보이다.

 

User.java

package com.example.demo.domain.login;

import lombok.*;
import org.hibernate.annotations.ColumnDefault;

import javax.persistence.*;

@Getter
@Setter
@Entity
@Table(name = "Users")
@NoArgsConstructor
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @NonNull
    @Column(name = "u_id")
    private String uid;
    @NonNull
    private String password;
    @NonNull
    private String salt;

    //0 : 일반 사용자 1 : 관리자
    @NonNull
    @ColumnDefault("0")
    private int status;

    @NonNull
    @ColumnDefault("0")
    private int verified;

    @Builder
    User(String uid,String password,String salt){
        this.uid=uid;
        this.password=password;
        this.salt=salt;
    }
}

 

UserRepository

package com.example.demo.domain.login;

import com.sun.istack.Nullable;
import lombok.NonNull;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User,Long> {
    @Nullable
    User findFirstByUid(String uid);
}

Url

package com.example.demo.domain.url;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import org.hibernate.annotations.ColumnDefault;

import javax.persistence.*;

@Table(name="URLS")
@Getter
@NoArgsConstructor
@Entity
public class Url {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    @NonNull
    private String originalUrl;

    @Builder
    Url(String originalUrl){
        this.originalUrl=originalUrl;
    }

}

 

UrlRepository.java

package com.example.demo.domain.url;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import org.hibernate.annotations.ColumnDefault;

import javax.persistence.*;

@Table(name="URLS")
@Getter
@NoArgsConstructor
@Entity
public class Url {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    @NonNull
    private String originalUrl;

    @Builder
    Url(String originalUrl){
        this.originalUrl=originalUrl;
    }

}

UrlHistory.java

package com.example.demo.domain.url;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;

import javax.persistence.*;

@Table(name="URLHISTORY")
@Getter
@NoArgsConstructor
@Entity
public class UrlHistroy {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    @NonNull
    private long uid;

    @NonNull
    private String originalUrl;

    @NonNull
    private String shortUrl;

    @Builder
    UrlHistroy(long uid, String originalUrl,String shortUrl){
        this.uid=uid;
        this.originalUrl=originalUrl;
        this.shortUrl=shortUrl;
    }
}

UrlHistoryRepository

package com.example.demo.domain.url;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface UrlHistoryRepository extends JpaRepository<UrlHistroy,Long> {
    List<UrlHistroy> findAllByUid(long uid);
}

 

위 내용은 사실 딱히 어려울게 없다..

 

이제 핵심 코어 부분인 Service파트이다.

 

LoginService.java

package com.example.demo.service;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTCreationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.demo.domain.login.User;
import com.example.demo.domain.login.UserRepository;
import com.example.demo.domain.url.UrlHistroy;
import com.example.demo.web.dto.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.stereotype.Service;

import javax.persistence.criteria.CriteriaBuilder;
import java.util.Calendar;
import java.util.Date;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {
    Logger loggerFactory = LoggerFactory.getLogger(this.getClass());
    private final UserRepository userRepository;
    private final UrlShroterService urlShroterService;
    private final RedisTemplate redisTemplate;

    @Value("${jwtsecret}")
    private String secret;

    public MyInfoResponseDto getMyInfo(String token){
        DecodedJWT decodedJWT = JWT.decode(token);
        long id = decodedJWT.getClaim("id").asLong();
        String uid = decodedJWT.getClaim("uid").asString();
        List<UrlHistroy> histroysList = urlShroterService.getHistroy(id);
        return MyInfoResponseDto.builder().histroys(histroysList).uid(uid).build();
    }

    public LoginResponseDto login(LoginRequestDto loginRequestDto){
        try {
            Algorithm algorithm = Algorithm.HMAC256(secret);
            User user=userRepository.findFirstByUid(loginRequestDto.getUid());
            if(user==null)
                return LoginResponseDto.builder().code(201).message("Id 또는 비밀번호를 확인해주세요.").build();
            else if(!BCrypt.checkpw(loginRequestDto.getPassword()+user.getSalt(),user.getPassword())){
                return LoginResponseDto.builder().code(202).message("Id 또는 비밀번호를 확인해주세요.").build();
            }
            else{
                String token = JWT.create().withClaim("id",user.getId())
                        .withClaim("status",user.getStatus())
                        .withClaim("uid",user.getUid())
                        .withIssuer("kwon")
                        .withExpiresAt(makeTime(0))
                        .sign(algorithm);

                String refreshToken = JWT.create()
                        .withIssuer("kwon")
                        .withExpiresAt(makeTime(1))
                        .sign(algorithm);
                redisTemplate.setKeySerializer(new StringRedisSerializer());
                redisTemplate.setValueSerializer(new StringRedisSerializer());
                redisTemplate.opsForValue().set(Long.toString(user.getId()),refreshToken);

                return LoginResponseDto.builder().code(200).message("success").token(token).refreshToken(refreshToken).build();
            }
        }catch (JWTCreationException e){
            loggerFactory.error("Class {}",this.getClass(),e);
            return LoginResponseDto.builder().code(203).message("Create Error").build();
        }
    }

    public JoinResponseDto join(JoinRequestDto joinRequestDto){
        try{
            String salt = BCrypt.gensalt(8);
            String hashedPassword = BCrypt.hashpw(joinRequestDto.getPassword()+salt, BCrypt.gensalt());

            userRepository.save(User.builder().uid(joinRequestDto.getUid()).password(hashedPassword).salt(salt).build());
            return JoinResponseDto.builder().code(200).message("success").build();
        }catch (Exception e){
            loggerFactory.error("Class {}",getClass(),e);
            return JoinResponseDto.builder().code(201).message("join error").build();
        }
    }

    private Date makeTime(int timezone){
        Calendar cal = Calendar.getInstance();
        Date expire_date = new Date();
        cal.setTime(expire_date);
        if(timezone==0)
            cal.add(Calendar.MINUTE,40);
        else
            cal.add(Calendar.DATE,1);
        expire_date=cal.getTime();
        return expire_date;
    }
}

기존에 검색했던 내용들을 다시 불러오기 위해서 로그인을 추가 구현하였다.

이때 주목할 점은 JWT토큰을 이용한 방식을 이용해서 로그인을 구현하였다.

 

이제 진짜 핵심중의 핵심 UrlShortnerService이다.

 

UrlShortnerService.java

package com.example.demo.service;

import com.auth0.jwt.JWT;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.demo.domain.url.Url;
import com.example.demo.domain.url.UrlHistoryRepository;
import com.example.demo.domain.url.UrlRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Service;
import com.example.demo.domain.url.UrlHistroy;
import com.example.demo.web.dto.UrlShorterRequestDto;

import java.util.List;
import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class UrlShroterService {
    final private UrlRepository urlRepository;
    final private UrlHistoryRepository urlHistoryRepository;
    final private RedisTemplate redisTemplate;
    private String BASE62 ="ABCDEFGHIJKLMNOPQRSTUVWSYZabcdefghijklmnopqrstuvwxyz0123456789";

    public String encode(long idx){
        StringBuffer sf = new StringBuffer();
        if(idx==0){
            sf.append("A");
        }
        while (idx>0){
            sf.append(BASE62.charAt((int) (idx%62)));
            idx/=62;
        }
        return sf.toString();
    }//Base62 Encoding

    public long decode(String code){
        long sum = 0;
        long pow = 1;
        for(int i=0;i<code.length();i++){
            sum+=BASE62.indexOf(code.charAt(i));
        }
        return sum;
    }//Base62 Decoding

    public void saveToRedis(String url,long id){
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.opsForValue().set(url,Long.toString(id));
        redisTemplate.expire(url,1, TimeUnit.DAYS);
    }//Save to Redis

    public void makeHistory(long uid,String originUrl, String shortUrl){
        urlHistoryRepository.save(UrlHistroy.builder().uid(uid).originalUrl(originUrl).shortUrl(shortUrl).build());
    }

    public long getUid(String token){
        DecodedJWT decodedJWT = JWT.decode(token);
        Long uid = decodedJWT.getClaim("id").asLong();
        return uid;
    }

    public String makeShorter(UrlShorterRequestDto urlShorterRequestDto,String token){
        String url = urlShorterRequestDto.getUrl();
        long uid=0;
        if(token!=null){
            uid=getUid(token);
        }
        Long id=null;
        String shortUrl;
        if(redisTemplate.opsForValue().get(url)!=null)
            id=Long.parseLong(redisTemplate.opsForValue().get(url).toString());

        if(id!=null){
            id = Long.parseLong(redisTemplate.opsForValue().get(url).toString());
            shortUrl = encode(id);
            if(token!=null)
                makeHistory(uid,url,shortUrl);
            return shortUrl;
        }//레디스에 존재하면


        Url findUrl = urlRepository.findFirstByOriginalUrl(url);//기존에 생성한 주소면 재사용

        if(findUrl==null){
            id=urlRepository.save(urlShorterRequestDto.toEntity()).getId();//기존에 생성한 Url이 아니면 DB에 저장 후 id 리턴
            shortUrl = encode(id);
            saveToRedis(url,id);
            if(token!=null)
                makeHistory(uid,url,shortUrl);
            return shortUrl;
        }else{
            shortUrl = encode(findUrl.getId());
            saveToRedis(url,findUrl.getId());
            if(token!=null)
                makeHistory(uid,url,shortUrl);
            return shortUrl;
        }
    }

    public String getOrigin(String url){
        long idx = decode(url);
        Url originalUrl=urlRepository.findFirstById(idx);
        if(originalUrl==null){
            return "No Url";
        }else{
            return originalUrl.getOriginalUrl();
        }
    }

    public List<UrlHistroy> getHistroy(long uid){
        return urlHistoryRepository.findAllByUid(uid);
    }
}

디코드와 인코딩을 함수로 따로 분리하여서 만약 사용자가 URL을 축소 요청을 보내면 인코딩을 해주고

사용자가 원래 Url을 리턴하기를 원한다면 디코딩을 하여서 DB에서 오리지널 URL을 찾아서 리턴해준다.

 

이전에 Redis에 URL있는지 체크하고 있으면 그 URL을 리턴해준다.

Base62 인코딩 방식은 사실 크게 어려운게 없으니.. 생략한다.

 

'Stove Dev Camp' 카테고리의 다른 글

SCTP 프로토콜(기존 프로토콜 TCP, UDP)(1)  (0) 2021.01.25
WebRTC Data Channel  (0) 2021.01.18
WebRTC란? (STUN과 TURN 서버의 이해) (2)  (3) 2020.12.29
WebRTC란? (1)  (0) 2020.12.22
Project #2 Authorization  (0) 2020.12.22