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
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,41 @@
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) {
UrlResponse urlResponse = urlService.createShortUrl(shortUrlCreateRequest, httpServletRequest.getRemoteAddr());

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,13 @@
package com.programmers.urlshortener.url.domain;

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

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

public abstract String getShortUrl(int num);
}
80 changes: 80 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,80 @@
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.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

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 lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@DynamicInsert
@DynamicUpdate
@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


@Column(unique = true)
private String shortUrl;

@Column(nullable = false)
private String ip;

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

@Column(nullable = false)
@ColumnDefault("0")
private Long viewCount;

@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 = algorithm.getShortUrl(id.intValue());
}

public void increaseCount() {
viewCount += 1;
}
}
Loading