티스토리 뷰
[ 1. 기획 ]
📍 단축 URL 알고리즘
- BASE64
참고 : 단축 URL 알고리즘으로 BASE64 알고리즘을 사용한 이유
📍 단축 URL 서비스 원리
- 단축 : 입력받은 URL을 DB에 저장 후, url의 id를 BASE64 알고리즘을 통해 encode 시킨다.
💬 url 자체를 encode해버리면 encode한 값의 자리수가 상당히 길어지기 때문에 url의 unique한 값인 id를 단축하기로 했다! - redirect : "localhost:8080/redirect/{encoded-value}"의 pathVariable로 들어온 encoded-value를 BASE64 알고리즘을 통해 url의 id로 decode 시킨 후, findById()를 통해 DB의 origin-url를 얻고 해당 origin-url로 redirect 시킨다.
📍 선택한 기술
- 백엔드 : Java / Springboot / MySQL / JPA
- 프론트엔드 : HTML + CSS / Javascript
[ 2. 개발 ]
📍 Controller
기본 url과 단축url로 GET 요청을 처리하는 HomeController, url 단축 POST 요청을 처리하는 UrlShortenerController 로 나누어 개발했다.
Trouble 1. 원하는 뷰가 반환되지 않는다!
@GetMapping
public String getHome() {
return "home";
}
원래 코드는 위와 같았다. 자동적으로 home.html을 찾을거라 생각했는데 home.html이 보이지 않길래 혹시 resources 찾는 기본 경로에 resources 폴더가 설정되지 않은건가? 생각했는데 아래 캡쳐처럼 resources 폴더는 포함돼있었다.
그래서 다른 방법으로 뷰를 반환할 수 없을까 검색해보다가 아래처럼 직접 ModelAndView를 반환하는 방법을 선택했더니 성공...!
@GetMapping
public ModelAndView getHome() {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("home");
return modelAndView;
}
지금 생각해보니 resources 폴더는 설정돼있었지만, resources/templates 폴더는 설정돼있지 않아 발생한 오류같다.
(classpath에 내가 원하는 폴더를 추가하여 자동으로 지정 view를 찾게 하는 방법은 여기있다.)
Trouble 2. 리다이렉트가 되지 않는다!
Springboot에서 redirect하는 방법을 검색해보고 이 글을 보았다.
원래는 첨부한 글의 첫번째 방법을 사용하려고 했는데 실패! 위에 언급한 것과 비슷한 이유일 것 같다.
그래서 나는 네번째 방법 httpServletResponse.sendRedirect() 를 통해 redirect 하는 방법을 사용했다.
@GetMapping("/{encoded-url}")
public void redirect(@PathVariable(value = "encoded-url") String encodedUrl, HttpServletResponse httpServletResponse)
throws IOException {
String originUrl = urlShortenerService.getOriginUrl(encodedUrl);
httpServletResponse.sendRedirect("http://" + originUrl);
}
String으로 반환할 뷰를 찾는 방법은 모두 실패했지만, 어쨌거나 controller에서 String을 반환해주면 자동으로 view를 찾아 반환해주는 게 신기하다. (controller 에서 "redirect:"가 붙은 String을 return하면 spring에서 redirect 시켜주는 것일까? 어떻게 이렇게 간단하게 redirect를 시켜준단말이지?! 등등...)
🔜추후 redirect 시키는 과정을 자세히 알아보고 포스팅할 예정(가능하면 저 위 포스팅의 네 가지 방법 모두?)
📍 Service
@Service
@RequiredArgsConstructor
public class UrlShortenerService {
private final BASE62Utils base62Utils;
private final UrlShortenerRepository urlShortenerRepository;
public ShortenUrlResponse getShortenUrl(ShortenUrlRequest request) {
Optional<ShortenUrl> optional = urlShortenerRepository.findByOriginUrl(request.getOriginUrl());
if (optional.isPresent()) {
return new ShortenUrlResponse(
request.getOriginUrl(),
base62Utils.encode(optional.orElseThrow(() -> new RuntimeException()).getId()));
}
ShortenUrl shortenUrl = urlShortenerRepository.save(new ShortenUrl(request.getOriginUrl()));
return new ShortenUrlResponse(request.getOriginUrl(), base62Utils.encode(shortenUrl.getId()));
}
public String getOriginUrl(String encodedUrl) {
Optional<ShortenUrl> optional = urlShortenerRepository.findById(base62Utils.decode(encodedUrl));
if (optional.isEmpty()) {
throw new RuntimeException();
}
return optional.orElseThrow(() -> new RuntimeException()).getOriginUrl();
}
}
사용자가 단축을 요청해온 origin-url이 db에 있는지 확인 후 있으면 있는 url을 encoding하여 반환하고, 없으면 db에 저장한 후 encode하여 반환하는 getShortenUrl() 메서드,
encode된 shorten-url을 decode하여 db에 있는 origin-url을 반환하는 getShortenUrl() 메서드로 구성하였다.
Trouble 1. setter를 통해 영속화된 url entity를 수정하려 했는데 실패!
원래는 id값만 있는 shortenUrl entity로 save()를 통해 영속화시킨 후, setter로 shortenUrl을 수정해 변경감지를 통해서 entity의 값을 수정하려고 했다.
하지만 shortenUrl은 null값인 상태로 변경되지 않길래 로직을 위와 같이 전면 수정하였다.
shortenUrl entity의 필드값으로 shortenUrl을 빼고 util 클래스에 decode하는 과정을 추가하였다.
(단축요청이 오면 db 조회가 아닌 encode 과정을 통해 얻은 url을 반환하고, origin-url 조회 요청이 오면 decode 과정을 통해 얻은 id로 조회하는 방법으로 바꾼 것이다.)
나중에 발견한건데... service단에 @Transactional을 달아주지 않아 flush가 되지 않았던 것...!
사소한 실수로 찾은 차선책이었지만 코드리뷰로 "DB 메모리 공간효율을 높일 수 있고 실제로 decode 연산하는 시간이 오래 걸리진 않을 것 같아서 좋다고 생각합니다!" 라고 오히려 굿아이디어라는 평을 들었다.(오호라?!)
📍 BASE64Utils
파라미터로 받은 shorten-url의 id를 BASE64 알고리즘을 통해 encode하는 encode() 메서드와 파라미터로 받은 encode값을 decode하여 shorten-url의 id를 반환하는 decode() 메서드로 구성하였다.
encode하고 decode하는 과정은 아무래도 서비스 로직과는 상관이 없는 독립된 부분인 것 같아 utils라고 네이밍하여 util성 클래스임을 명시했다.
그리고 완전한 url을 위해 encode값 앞에 추가할 "http://localhost:8080/"을 HEAD, encode할 때 필요한 char배열을 BASE62라고 static final 필드로 두었는데 @Value를 통해 yml 설정값을 주입했다.
❕ yml, properties 파일을 이용하는 이유
나는 코드 자체에 포트 정보 등을 코드에 노출시키지 않고, 좀 더 확장성 있는 코드를 위해서 필요한 줄 알았는데 더 실무적(?)인 이유가 있었다.
코드 배포 후 서버를 내리지 않고도 수정할 수 있다는 점!
자바 코드 자체에 설정정보를 정의하면, 배포 후엔 이미 compile되었기 때문에 설정정보를 수정하려면 수정 후 다시 compile해야한다. 하지만 yml, properties 파일은 설정정보에 대해 따로 매칭, 치환(?) 작업이 진행되므로 yml, properties 파일만 수정하면 된다.
📍 View (home.html)
참고했던 다른 분의 코드를 통해 뷰를 완성했다.
사실은 백엔드 부분 하는데에도 시간이 꽤 걸려 원래 하려고 했던 thymeleaf는 알아보지 못했고, 급하게 바로 적용할 수 있는 코드를 찾았다. thymeleaf는 다음에 꼭...
어떤 함수인지 자세히 알아보고 하진 않았고 대강 유추하고 console.log를 통해 확인해가면서 적용했다.
[ 3. 코드 리뷰 ]
- home.html로 리다이렉트하는 부분
- "WebMvcConfigurer 의 addViewControllers 사용해보세요!" 라는 커맨트를 받았는데 둘이 어떤 차이가 있는지는 모르겠다. 🔜WebConfig에 대해 알아볼 것
- @Validated 부분
- @Validated를 사용해보려다가 dto에 실수로 @Validated를 써넣었다.
근데 이 부분을 차치하고도 나는 환장할 코드를 짰음... - 처음에는 단순히 유효성 검증 그룹을 커스터마이징하기위해서 @Validated를 사용해보려고 했는데 아예 사용방법(?)을 잘못 안 것 같다.
- 나는 Controller의 RequestBody에 @Validated를 사용했는데(그렇게 하면 @Validated로 동작할 줄 알았음)
Controller의 RequestBody 유효성 검증은 ArgumentResolver가 "@Valid"로 시작하는 어노테이션을 잡아 처리하기에 JSR 표준의 @Valid로 동작한 것이었다. → 에러도 MethodArgumentNotValidException로 잡힘 - Controller에서 @Validated를 사용하려면 유효성 검증 그룹을 지정해줘서 사용했어야 했다. 아니면 Service class 단에 @Validated를 지정해주고 검증할 파라미터에 @Valid를 달아주었거나...?
- 하지만 나는 클래스마다 검증할 필드들이 달라지는 것도 아니고, service에서 잡아야할 필요도 없기 때문에 @Validated를 사용하지 않고 그냥 @Valid로 바꿨다.
- 다만 아직 잘 모르겠는건...
- 만약 Controller에서 클래스단에 @Validated를 주고 RequestBody에 @Valid를 줬다면 @Valid로 동작했을지, @Validated로 동작했을까 잘 모르겠다.
(정리를 안 해서 확실하지는 않지만 시도해봤는데 @Valid로 동작했던 거 같음. 왜 @Valid로 동작하냐고...!!) - 그리고 @Service 단에서 유효성 검증을 해줘야하는 상황이 뭐가 있을지 모르겠다.
음.. 그니까 @Validated의 유효성 그룹 지정 기능 말고, AOP로 인터셉트해야할 필요가 왜 있는건지 궁금하다.
- 만약 Controller에서 클래스단에 @Validated를 주고 RequestBody에 @Valid를 줬다면 @Valid로 동작했을지, @Validated로 동작했을까 잘 모르겠다.
- (전체적인 @Valid, @Validated 사용은 해당 글을 참고했음.)
- @Validated를 사용해보려다가 dto에 실수로 @Validated를 써넣었다.
- RequestDto 부분
- 내 RequestDto 에 생성자가 없었는데 "RequestDto에 왜 생성자가 없나?" 라는 커맨트를 받았는데 나는 RequestDto에 생성자가 없는지도, 왜 생성자가 없는데 실행이 됐는지도 몰랐다 하하
- RequestDto는 역직렬화만 필요할 것 같아 '역직렬화할 때 기본 생성자가 없어도 되는지' 를 검색해봤다.
근데... 진짜 많이 찾아봤는데 당최 그 이유를 알 수가 없다. 가장 가까운 이유가 이 글에 있는 것 같은데 이 글에서 말하는 '@JsonProperty, @JsonAutoDetect 등을 사용한 Property 기반 클래스이거나, 생성자가 위임된 경우라면 생성자는 필요하지 않다.' 이 결론에 내 request dto는 해당하지 않는다..! (소름...!) - 결국 그 이유는 찾지 못했지만, 시간이 나면 이 분처럼 🔜직접 디버깅 해가면서 찾아봐야할 것 같다.
- 🔜dto의 직렬화/역직렬화 더 자세히 알아볼 것
- (이 외에도 참고했던 글)
- https://da-nyee.github.io/posts/woowacourse-why-the-default-constructor-is-needed/
- https://pomo0703.tistory.com/109
- https://tecoble.techcourse.co.kr/post/2021-05-11-requestbody-modelattribute/
- ResponseDto 부분
- entity 자체의 필드가 거의 없어 내가 만든 ResponseDto의 필드가 모두 entity와 같은 상황이 생겨서 문득 "dto를 따로 만든 의미가 있나" 궁금해져서 친구한테 물어봤다.
- 친구曰 : entity와 관계없이 dto는 서비스와 controller 계층 사이에서 DB를 보호하기 위해 필요하다 생각해요!
- 나는 필드값이 같으면 그게 그거 아닌가라고 생각했는데 친구 코드를 확인해보니 response는 private final로 변경이 불가능하도록 막는 등 분명히 entity와의 차이가 있었다.
흐음~ 인터레스팅~
접근제어자 어려워도 다음부터는 나도 신경을 좀 써야겠다...!
[ 4. 리팩토링할 것들]
- 단축된 URL에 대한 요청 수(cnt) 정보저장
- yml 민감정보 숨기기(env 파일 이용)
- BASE62Utils 수정
- encoded 값이 7자 이상이 되도록 알고리즘 수정
- url data에 대한 만료기능