Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[4기 황창현] URL Shortener 과제 제출 #40

Open
wants to merge 28 commits into
base: changhyeonh
Choose a base branch
from
Open
Changes from 21 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e94b801
feat: 입력 폼 페이지 구현
Hchanghyeon Oct 1, 2023
5219f38
build: validation 의존성 추가
Hchanghyeon Oct 1, 2023
443a96a
feat: 분리되어있던 페이지 통합, 프론트엔드 구현
Hchanghyeon Oct 3, 2023
910b678
build: JPA, H2 Database 사용을 위한 yml파일 설정
Hchanghyeon Oct 3, 2023
b2e7a79
chore: 페이지를 통합하면서 필요 없는 페이지 삭제
Hchanghyeon Oct 3, 2023
ab6fdfe
feat: Url 도메인 구현
Hchanghyeon Oct 3, 2023
6233638
feat: JpaAudting 기능 추가
Hchanghyeon Oct 3, 2023
3fde736
feat: 10진수를 Base62로 변환하는 컨버터 생성
Hchanghyeon Oct 3, 2023
9a77c4f
feat: URL을 검증하는 Validator 구현
Hchanghyeon Oct 3, 2023
5b17cb3
feat: Url Repository 생성 및 Lock과 기본 조회 로직 구현
Hchanghyeon Oct 3, 2023
df14a51
feat: Url Service 로직 구현
Hchanghyeon Oct 3, 2023
0ff6b6b
feat: Url Controller 로직 구현
Hchanghyeon Oct 3, 2023
74b2b14
feat: WebConfig로 ViewController 설정
Hchanghyeon Oct 3, 2023
8a92e3a
feat: 예외 처리 구현
Hchanghyeon Oct 3, 2023
5210f20
chore: 불필요한 테스트 코드 삭제
Hchanghyeon Oct 3, 2023
1246112
test: Repository, Domain 테스트 구현
Hchanghyeon Oct 3, 2023
14f5a61
style: class import
Hchanghyeon Oct 3, 2023
3554631
feat: Url 도메인 null 예외처리
Hchanghyeon Oct 3, 2023
b4828f1
style: 개행 처리
Hchanghyeon Oct 3, 2023
88552d9
style: 페이지에서 MD5 알고리즘 삭제
Hchanghyeon Oct 3, 2023
413974e
style: 개행 삭제
Hchanghyeon Oct 3, 2023
c24d8ca
refactor: IP DTO에서 받지 않도록 변경
Hchanghyeon Oct 11, 2023
dd539b5
refactor: unique로 변경
Hchanghyeon Oct 11, 2023
de6991e
style: ViewCount로 이름 변경
Hchanghyeon Oct 11, 2023
1ced81b
refactor: 분기문이 아닌 Enum에서 처리
Hchanghyeon Oct 11, 2023
4e45131
refactor: 빈 생성자 삭제
Hchanghyeon Oct 11, 2023
55b4a31
refactor: 비관적 락 적용 삭제
Hchanghyeon Oct 11, 2023
9fd79f2
refactor: column default 값 설정
Hchanghyeon Oct 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
Original file line number Diff line number Diff line change
@@ -2,12 +2,13 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class SpringbootUrlShortenerApplication {

public static void main(String[] args) {
SpringApplication.run(SpringbootUrlShortenerApplication.class, args);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.programmers.urlshortener.common.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("index");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.programmers.urlshortener.common.converter;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Base62Converter {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

원본 URL의 값을 DB에 저장하기 때문에 굳이 디코딩할 필요가 없다고 생각되어 만들지 않았습니다.


private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int BASE = ALPHABET.length();

public static String encode(int value) {
StringBuilder sb = new StringBuilder();

while (value != 0) {
sb.append(ALPHABET.charAt(value % BASE));
value /= BASE;
}

return sb.reverse().toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.programmers.urlshortener.common.exception;

import org.springframework.http.HttpStatus;

import lombok.Getter;

@Getter
public class BusinessException extends RuntimeException {

private HttpStatus status;
private String message;

protected BusinessException(ExceptionRule exceptionRule) {
super(exceptionRule.getMessage());
this.status = exceptionRule.getStatus();
this.message = exceptionRule.getMessage();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.programmers.urlshortener.common.exception;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {

private int statusCode;
private String message;

@Builder
private ErrorResponse(int statusCode, String message) {
this.statusCode = statusCode;
this.message = message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.programmers.urlshortener.common.exception;

import org.springframework.http.HttpStatus;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ExceptionRule {

SHORTENURL_NOT_EXIST(HttpStatus.NOT_FOUND, "해당 URL은 단축된 URL 목록에서 찾을 수 없습니다."),
NOT_FOUND(HttpStatus.NOT_FOUND, "요청하신 URL은 없는 URL입니다."),
BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다. 입력 값을 다시 확인해주세요."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에서 알 수 없는 오류가 발생했습니다. 관리자에게 문의해주세요."),
URL_NOT_SAVED(HttpStatus.INTERNAL_SERVER_ERROR, "해당 URL은 데이터베이스에 저장된 URL이 아닙니다."),
;

private final HttpStatus status;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.programmers.urlshortener.common.exception;

import static com.programmers.urlshortener.common.exception.ExceptionRule.*;

import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
List<FieldError> errors = e.getBindingResult()
.getAllErrors()
.stream()
.map(FieldError.class::cast)
.toList();

log.error("[에러]: {}", e.getMessage(), e);

errors.forEach(
error -> log.error("메시지: {} / 원인: {} : {}", error.getDefaultMessage(), error.getField(),
error.getRejectedValue()));

ErrorResponse errorResponse = ErrorResponse.builder()
.statusCode(BAD_REQUEST.getStatus().value())
.message(BAD_REQUEST.getMessage())
.build();

return ResponseEntity.status(BAD_REQUEST.getStatus()).body(errorResponse);
}

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.error("[에러]: {}", e.getMessage(), e);

ErrorResponse errorResponse = ErrorResponse.builder()
.statusCode(e.getStatus().value())
.message(e.getMessage())
.build();

return ResponseEntity.status(e.getStatus()).body(errorResponse);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("[에러]: {}", e.getMessage(), e);

ErrorResponse errorResponse = ErrorResponse.builder()
.statusCode(INTERNAL_SERVER_ERROR.getStatus().value())
.message(INTERNAL_SERVER_ERROR.getMessage())
.build();

return ResponseEntity.status(INTERNAL_SERVER_ERROR.getStatus()).body(errorResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.programmers.urlshortener.common.exception;

public class UrlException extends BusinessException {

public UrlException(ExceptionRule exceptionRule) {
super(exceptionRule);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.programmers.urlshortener.common.validator;

import static com.programmers.urlshortener.common.exception.ExceptionRule.*;

import java.util.regex.Pattern;

import com.programmers.urlshortener.common.exception.UrlException;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class UrlValidator {

private static final Pattern URL_PATTERN = Pattern.compile(
"https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)");

public static void validateUrlPattern(String url) {
if (!URL_PATTERN.matcher(url).matches()) {
throw new UrlException(BAD_REQUEST);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.programmers.urlshortener.url.controller;

import java.io.IOException;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import com.programmers.urlshortener.url.service.UrlService;

import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class RedirectController {

private final UrlService urlService;

@GetMapping("/{shortUrl}")
public void redirectOriginUrl(@PathVariable String shortUrl, HttpServletResponse response) throws IOException {
String originalUrl = urlService.findOriginalUrlByShortUrl(shortUrl);
response.sendRedirect(originalUrl);
}
Comment on lines +20 to +24
Copy link

@hanjo8813 hanjo8813 Oct 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요렇게 하면 http status code도 바뀌나요?? (모름)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네! 저도 직접 설정해줘야하나 싶었는데 302 Redirect 정상적으로 바뀌더라구요!!! 👍

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.programmers.urlshortener.url.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.programmers.urlshortener.url.dto.request.ShortUrlCreateRequest;
import com.programmers.urlshortener.url.dto.response.UrlResponse;
import com.programmers.urlshortener.url.service.UrlService;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/urls")
public class UrlController {

private final UrlService urlService;

@PostMapping
public ResponseEntity<UrlResponse> createShortUrl(@Valid @RequestBody ShortUrlCreateRequest shortUrlCreateRequest,
HttpServletRequest httpServletRequest) {
shortUrlCreateRequest.updateIp(httpServletRequest.getRemoteAddr());
UrlResponse urlResponse = urlService.createShortUrl(shortUrlCreateRequest);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request dto에 굳이 추가하지 않고 service 파라미터에 ip를 추가해주는건 어떨까요?
ip는 사용자가 직접 입력하는 값이 아니라서 dto 내에 포함되니 조금 어색한것 같아요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하신 대로 클라이언트가 직접 입력하는 값이 아니라서 어색한 것 같습니다! 서비스 파라미터에 값을 추가하고 toEntity로 변경될 때 매개변수로 넣어서 엔티티 변환할 수 있도록 처리했습니다!

감사합니다!!

반영: c24d8ca


return ResponseEntity.status(HttpStatus.CREATED).body(urlResponse);
}

@GetMapping("/{shortUrl}")
public ResponseEntity<UrlResponse> getShortUrlInfo(@PathVariable String shortUrl) {
UrlResponse urlResponse = urlService.findUrlByShortUrl(shortUrl);

return ResponseEntity.ok(urlResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.programmers.urlshortener.url.domain;

public enum Algorithm {
BASE62
}
81 changes: 81 additions & 0 deletions src/main/java/com/programmers/urlshortener/url/domain/Url.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.programmers.urlshortener.url.domain;

import static com.programmers.urlshortener.common.exception.ExceptionRule.*;
import static com.programmers.urlshortener.common.validator.UrlValidator.*;

import java.time.LocalDateTime;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import com.programmers.urlshortener.common.converter.Base62Converter;
import com.programmers.urlshortener.common.exception.UrlException;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Url {

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

@Enumerated(EnumType.STRING)
private Algorithm algorithm;

@Column(nullable = false)
private String originalUrl;
Comment on lines +44 to +45

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unique 설정이 필요할것 같아요~~
unique 제약조건 걸었을때의 index에 대해서도 알아보세요!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가로 url 길이 제한이 20인것 같으니 길이 설정도 해주면 좋겠죠?!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 재원님께서 말씀하신 것 처럼 unique 설정을 해서 동일한 originalURL로는 만들 수 없게 하려했으나 다른 단축 서비스들을 보니 똑같은 originalURL에 대해서 만들 수 있게 되어있더라구요! 그래서 originalURL에는 따로 unique를 걸지 않았습니다!

근데 생각해보니 shortURL은 단축된 URL이니 겹치면 안될 것 같네요! 아무리 id값으로 변환이 된다고해도 혹시 모를 위험을 위해 shortURL에 unique를 걸어두도록 하겠습니다!

Unique는 Real MySQL에서도 봤던 내용이었는데 조금 가물가물해서 다시 찾아보았습니다!

유니크 인덱스와 유니크하지 않은 일반 보조 인덱스는 사실 크게 다르지 않고 읽기는 성능이 비슷하지만 쓰기 성능에서는 유니크 한지 아닌지 체크하는 과정이 필요하여 보조 인덱스보다 더 느리다고 하네요.

사실 현재 이 프로젝트에서는 쓰기의 성능보다는 읽기의 성능이 더 중요하긴 하지만 쓰기의 양도 적은 편은 아니라고 생각합니다. 그래서 어차피 shortURL의 경우 고유한 ID값으로 변환되어 저장되니 고유하다고 가정하고 unique를 걸지 않을 수도 있을 것 같네요! 하지만 이런 방법은 안전한 방법은 아닌 것 같아서 조금 느리더라도 unique를 걸어서 처리하겠습니다!

반영: dd539b5

private String shortUrl;

@Column(nullable = false)
private String ip;

@CreatedDate
@Column(columnDefinition = "TIMESTAMP", nullable = false, updatable = false)
private LocalDateTime createdAt;

@Column(nullable = false)
private Long count;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

딱 봤을 때 어떤 count인지 알기가 어렵네요
view나 viewCount와 같이 명확한 네이밍을 써주면 좋을것 같습니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

너무 비지니스 로직이 단순해서 하나 밖에 없다고 생각하고 했었는데 지금보니 정말로,, 명확하지 않은 것 같습니다. 피드백 감사드립니다!! 🙇🙇

반영: de6991e


@Builder
public Url(String originalUrl, Algorithm algorithm, String ip) {
validateUrlPattern(originalUrl);
this.algorithm = algorithm;
this.originalUrl = originalUrl;
this.ip = ip;
}

public void convertToShortUrl() {
if (id == null) {
throw new UrlException(URL_NOT_SAVED);
}

this.shortUrl = switch (algorithm) {
case BASE62 -> Base62Converter.encode(id.intValue());
};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용자의 입력 값으로 받는 알고리즘 종류에 따라 switch문으로 인코딩 된 값을 반환하는 것으로 했는데 괜찮은 로직인지 궁금합니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enum 값에 인코딩 함수(람다)를 넣어주면 분기문 없이 해결할 수 있을것 같네요~

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉 말씀하신 방법을 처음 알았습니다! Enum 활용에 대해서 조금 더 공부해보아야겠네요.

말씀하신 코드가 아래와 같은 방식이 맞나요?

public enum Algorithm {
    BASE62 {
        public String getShortUrl(int num) {
            return Base62Converter.encode(num);
        }
    }
    public abstract String getShortUrl(int num);
}

// Url.class
public void convertToShortUrl() {
    if (id == null) {
        throw new UrlException(URL_NOT_SAVED);
    }

    this.shortUrl = algorithm.getShortUrl(id.intValue());
}

반영: 1ced81b

}

public void increaseCount() {
count += 1;
}

@PrePersist
public void prePersist() {
this.count = 0L;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 컬럼의 경우 jpa의 영속성 컨텍스트 생명주기를 활용하는것보다 컬럼 default 값을 지정해주는게 더 명확할 것 같습니다.
(default값이 절대 변하지 않을것 같은 경우)

관련해서 영주님 엔티티를 참고해보세요!
이것도 트러블 슈팅이 있긴 합니다 ㅋㅋ
https://github.com/kylekim2123/url-shortener-back/pull/13/files#diff-dc7cc552e2ff3b2310c98b4ce6916768c03c6cf3e4feca558c57c7d0be4b0510

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

처음에 default값으로 지정했었는데 계속 null로 처리되서 뭐가 문제일까하다가 생명주기 활용해서 처리했었는데 @DynamicInsert @DynamicUpdate라는 것이 있었군요..?! 이것도 모르고 계속 null로 나와서 오래 헤맸었는데 그래도 이렇게 알게되어 너무너무 기쁩니다.

근데 생명주기로 처리했을 때는 영속성 컨텍스트에 0이 반영되어 response에 viewCount가 0으로 반환되어 나갈 수 있지만 위와 같이 어노테이션을 이용해서 처리할 경우 반환 값을 0으로 처리할 수는 없는 것 같네요 ㅠㅠ

반영: 9fd79f2

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 경우 그냥 long 타입을 쓰면 모든 문제가 해결될것 같긴하네요~~!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉 진짜 그러네요! 감사합니다 ㅎㅎ

}
Loading