├── settings.gradle ├── .DS_Store ├── src ├── .DS_Store ├── main │ ├── .DS_Store │ ├── resources │ │ ├── .DS_Store │ │ ├── static │ │ │ ├── favicon.ico │ │ │ ├── images │ │ │ │ └── logo.png │ │ │ └── css │ │ │ │ └── custom.css │ │ ├── db-init-scripts │ │ │ ├── schema.sql │ │ │ └── data.sql │ │ ├── templates │ │ │ ├── index.html │ │ │ ├── login.html │ │ │ └── add-user.html │ │ ├── logback-spring.xml │ │ └── application.yaml │ └── java │ │ └── com │ │ └── example │ │ └── urlshortener │ │ ├── domain │ │ ├── url │ │ │ ├── repository │ │ │ │ ├── interfaces │ │ │ │ │ └── ShortUrlOnly.java │ │ │ │ ├── UrlClickRepository.java │ │ │ │ └── ShortenedUrlRepository.java │ │ │ ├── exception │ │ │ │ └── UrlNotFoundException.java │ │ │ ├── controller │ │ │ │ ├── request │ │ │ │ │ └── CreateShortUrlRequest.java │ │ │ │ ├── response │ │ │ │ │ └── ShortUrlResponse.java │ │ │ │ └── UrlController.java │ │ │ ├── dto │ │ │ │ └── ShortenedUrlDto.java │ │ │ ├── entity │ │ │ │ ├── ShortenedUrl.java │ │ │ │ └── UrlClick.java │ │ │ └── service │ │ │ │ └── UrlService.java │ │ ├── user │ │ │ ├── repository │ │ │ │ └── UserRepository.java │ │ │ ├── controller │ │ │ │ ├── LoginController.java │ │ │ │ └── RegistrationController.java │ │ │ ├── dto │ │ │ │ ├── UserDto.java │ │ │ │ └── ApplicationUserDto.java │ │ │ ├── entity │ │ │ │ └── ApplicationUser.java │ │ │ └── service │ │ │ │ ├── UserService.java │ │ │ │ ├── CustomUserDetailsService.java │ │ │ │ └── LoginAttemptService.java │ │ └── dev │ │ │ └── controller │ │ │ └── PingController.java │ │ ├── common │ │ ├── exception │ │ │ ├── ForbiddenException.java │ │ │ ├── BadRequestException.java │ │ │ ├── ConflictException.java │ │ │ ├── NotFoundException.java │ │ │ ├── UnauthorizedException.java │ │ │ ├── dto │ │ │ │ └── ExceptionDto.java │ │ │ └── ApiException.java │ │ ├── enums │ │ │ └── ErrorCode.java │ │ ├── annotation │ │ │ ├── validator │ │ │ │ └── HttpsValidator.java │ │ │ └── Https.java │ │ ├── dto │ │ │ └── Response.java │ │ ├── configuration │ │ │ ├── WebMvcConfiguration.java │ │ │ ├── QueryDslConfiguration.java │ │ │ ├── GlobalExceptionHandler.java │ │ │ ├── SecurityConfiguration.java │ │ │ └── SwaggerConfiguration.java │ │ ├── utils │ │ │ └── RandomStringUtil.java │ │ └── security │ │ │ ├── listener │ │ │ ├── AuthenticationSuccessEventListener.java │ │ │ └── AuthenticationFailureEventListener.java │ │ │ └── handler │ │ │ └── CustomAuthenticationFailureHandler.java │ │ └── UrlShortenerApplication.java └── test │ └── java │ └── com │ └── example │ └── urlshortener │ ├── UrlShortenerApplicationTests.java │ └── ch3 │ ├── Ch3Test5.java │ ├── Ch3Test8.java │ ├── Ch3Test2.java │ ├── Ch3Test3.java │ ├── Ch3Test4.java │ └── Ch3Test7.java ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .github ├── pull_request_template.md └── ISSUE_TEMPLATE │ └── gdsc의-봄-이슈-템플릿.md ├── README.md ├── .gitignore ├── gradlew.bat ├── log └── gdsc.log ├── study └── chapter1.md ├── gradlew └── [3주차] 프로퍼티 /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'url-shortener' 2 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gdsc-ssu/2024-spring-of-gdsc/HEAD/.DS_Store -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gdsc-ssu/2024-spring-of-gdsc/HEAD/src/.DS_Store -------------------------------------------------------------------------------- /src/main/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gdsc-ssu/2024-spring-of-gdsc/HEAD/src/main/.DS_Store -------------------------------------------------------------------------------- /src/main/resources/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gdsc-ssu/2024-spring-of-gdsc/HEAD/src/main/resources/.DS_Store -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gdsc-ssu/2024-spring-of-gdsc/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gdsc-ssu/2024-spring-of-gdsc/HEAD/src/main/resources/static/favicon.ico -------------------------------------------------------------------------------- /src/main/resources/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gdsc-ssu/2024-spring-of-gdsc/HEAD/src/main/resources/static/images/logo.png -------------------------------------------------------------------------------- /src/main/resources/static/css/custom.css: -------------------------------------------------------------------------------- 1 | .icon-color { 2 | background-color: black; 3 | } 4 | 5 | .brand-text { 6 | font-size: x-large; 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/repository/interfaces/ShortUrlOnly.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.repository.interfaces; 2 | 3 | public interface ShortUrlOnly { 4 | String getShortUrl(); 5 | } -------------------------------------------------------------------------------- /src/test/java/com/example/urlshortener/UrlShortenerApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener; 2 | 3 | import org.springframework.boot.test.context.SpringBootTest; 4 | 5 | @SpringBootTest 6 | class UrlShortenerApplicationTests { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/exception/ForbiddenException.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.exception; 2 | 3 | import static org.springframework.http.HttpStatus.FORBIDDEN; 4 | 5 | public class ForbiddenException extends ApiException { 6 | public ForbiddenException(String message) { 7 | super(FORBIDDEN, message); 8 | } 9 | } -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Issue 2 | 3 | - Resolves # 4 | 5 | ## Description 6 | 7 | - 8 | 9 | ## Check List 10 | 11 | - [ ] PR 제목을 커밋 규칙에 맞게 작성 12 | - [ ] PR에 해당되는 Issue를 연결 완료 13 | - [ ] 적절한 라벨 설정 14 | - [ ] 작업한 사람 모두를 Assign 15 | - [ ] 작업한 팀에게 Code Review 요청 (Reviewer 등록) 16 | - [ ] `main` 브랜치의 최신 상태를 반영하고 있는지 확인 17 | 18 | ## Screenshot 19 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/exception/BadRequestException.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.exception; 2 | 3 | import static org.springframework.http.HttpStatus.BAD_REQUEST; 4 | 5 | public class BadRequestException extends ApiException { 6 | public BadRequestException(String message) { 7 | super(BAD_REQUEST, message); 8 | } 9 | } -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/exception/ConflictException.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.exception; 2 | 3 | import static org.springframework.http.HttpStatus.CONFLICT; 4 | 5 | public class ConflictException extends ApiException { 6 | public ConflictException(String message) { 7 | super(CONFLICT, message); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/exception/NotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.exception; 2 | 3 | import static org.springframework.http.HttpStatus.NOT_FOUND; 4 | 5 | public class NotFoundException extends ApiException { 6 | public NotFoundException(String message) { 7 | super(NOT_FOUND, message); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/exception/UnauthorizedException.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.exception; 2 | 3 | import static org.springframework.http.HttpStatus.UNAUTHORIZED; 4 | 5 | public class UnauthorizedException extends ApiException { 6 | public UnauthorizedException(String message) { 7 | super(UNAUTHORIZED, message); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/gdsc의-봄-이슈-템플릿.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: GDSC의 봄 이슈 템플릿 3 | about: 해당 템플릿을 사용해서 이슈를 만들어주세요 4 | title: "[n주차/1] 제목입니다" 5 | labels: '' 6 | assignees: HwanGonJang 7 | 8 | --- 9 | 10 | ### [n주차/1] 제목입니다. 11 | #### 담장자 12 | @HwanGonJang 13 | #### 날짜 14 | - 2024-03-n 15 | #### 진도 16 | - 해당 이슈에서 공부할 진도1 17 | - 해당 이슈에서 공부할 진도2 18 | - 해당 이슈에서 공부할 진도3 19 | #### 기타 자료 20 | - 아무거나 필요한거 21 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/repository/UrlClickRepository.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.repository; 2 | 3 | import com.example.urlshortener.domain.url.entity.UrlClick; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | public interface UrlClickRepository extends JpaRepository { 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/enums/ErrorCode.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.enums; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | 7 | @Getter 8 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 9 | public enum ErrorCode { 10 | 11 | BAD_REQUEST(2001, "잘못된 요청입니다."); 12 | 13 | private final int code; 14 | private final String message; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/exception/UrlNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.exception; 2 | 3 | import com.example.urlshortener.common.exception.BadRequestException; 4 | 5 | public class UrlNotFoundException extends BadRequestException { 6 | 7 | private static final String MESSAGE = "존재하지 않는 URL입니다."; 8 | 9 | public UrlNotFoundException() { 10 | super(MESSAGE); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/user/repository/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.user.repository; 2 | 3 | import com.example.urlshortener.domain.user.entity.ApplicationUser; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | public interface UserRepository extends JpaRepository { 9 | ApplicationUser findByUsername(String username); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/controller/request/CreateShortUrlRequest.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.controller.request; 2 | 3 | import com.example.urlshortener.common.annotation.Https; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @AllArgsConstructor 10 | @NoArgsConstructor 11 | public class CreateShortUrlRequest{ 12 | // url은 https가 적용되어야 합니다. 13 | @Https 14 | private String url; 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/exception/dto/ExceptionDto.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.exception.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | @AllArgsConstructor 8 | public class ExceptionDto { 9 | private int status; 10 | private String error; 11 | private String message; 12 | 13 | public static ExceptionDto of(int status, String error, String message) { 14 | return new ExceptionDto(status, error, message); 15 | } 16 | } -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/annotation/validator/HttpsValidator.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.annotation.validator; 2 | 3 | import com.example.urlshortener.common.annotation.Https; 4 | import jakarta.validation.ConstraintValidator; 5 | import jakarta.validation.ConstraintValidatorContext; 6 | 7 | public class HttpsValidator implements ConstraintValidator { 8 | @Override 9 | public boolean isValid(String value, ConstraintValidatorContext context) { 10 | return value.startsWith("https"); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GDSC의 봄 2 | GDSC Soongsil 스프링부트 스터디를 위한 레포지토리입니다. 3 | 4 | 자바 스프링 부트로 간단한 URL 단축기 프로젝트를 만들어봅니다. 5 | - Swagger 문서 6 | - http://localhost:8080/swagger-ui/index.html 7 | - H2 데이터베이스 8 | - http://localhost:8080/h2-console 9 | 10 | ## 프로젝트 구조 11 | 추가 예정 12 | 13 | ## PR 규칙 14 | - [x] PR 제목을 커밋 규칙에 맞게 작성 15 | - [x] PR에 해당되는 Issue를 연결 완료 16 | - [x] 적절한 라벨 설정 17 | - [x] 작업한 사람 모두를 Assign 18 | - [x] 작업한 팀에게 Code Review 요청 (Reviewer 등록) 19 | - [x] `main` 브랜치의 최신 상태를 반영하고 있는지 확인 20 | 21 | PR 제목은 다음과 같은 형식으로 작성합니다. 22 | - [n주차/이름]{Feat, Fix, Docs, Style, Refactor, Test, Chore}/제목 23 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/dto/Response.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | @AllArgsConstructor 8 | public class Response { 9 | private int code; 10 | private String message; 11 | private T data; 12 | 13 | public static Response of(int code, String message, T data) { 14 | return new Response<>(code, message, data); 15 | } 16 | 17 | public static Response data(T data) { 18 | return new Response<>(0, "", data); 19 | } 20 | } -------------------------------------------------------------------------------- /src/main/resources/db-init-scripts/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE shortened_url ( 2 | id BIGINT AUTO_INCREMENT PRIMARY KEY, 3 | short_url VARCHAR(2048) NOT NULL, 4 | origin_url VARCHAR(2048) NOT NULL, 5 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL 6 | ); 7 | 8 | CREATE TABLE url_click ( 9 | id BIGINT AUTO_INCREMENT PRIMARY KEY, 10 | shortened_url_id BIGINT NOT NULL, 11 | clicks BIGINT DEFAULT 0 NOT NULL, 12 | click_date DATE NOT NULL, 13 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 14 | FOREIGN KEY (shortened_url_id) REFERENCES shortened_url(id) 15 | ); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | README.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/exception/ApiException.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.server.ResponseStatusException; 5 | 6 | public abstract class ApiException extends ResponseStatusException { 7 | 8 | private final String message; 9 | 10 | public ApiException(final HttpStatus status, final String message) { 11 | super(status, message); 12 | this.message = message; 13 | } 14 | 15 | @Override 16 | public String getMessage() { 17 | return this.message; 18 | } 19 | } -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/configuration/WebMvcConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.configuration; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.web.servlet.config.annotation.CorsRegistry; 5 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 6 | 7 | @Configuration 8 | public class WebMvcConfiguration implements WebMvcConfigurer { 9 | 10 | @Override 11 | public void addCorsMappings(CorsRegistry registry) { 12 | registry.addMapping("/**") 13 | .allowedOrigins("*") 14 | .allowedMethods("*"); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/configuration/QueryDslConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.configuration; 2 | 3 | import com.querydsl.jpa.impl.JPAQueryFactory; 4 | import jakarta.persistence.EntityManager; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | @Configuration 10 | public class QueryDslConfiguration { 11 | 12 | @Autowired 13 | private EntityManager em; 14 | @Bean 15 | public JPAQueryFactory jpaQueryFactory(){ 16 | return new JPAQueryFactory(em); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/resources/db-init-scripts/data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO shortened_url (short_url, origin_url, created_at) VALUES 2 | ('http://short.url/abc', 'http://example.com/page1', '2024-04-01 10:00:00'), 3 | ('http://short.url/def', 'http://example.com/page2', '2024-04-02 12:00:00'), 4 | ('http://short.url/ghi', 'http://example.com/page3', '2024-04-03 14:00:00'), 5 | ('http://short.url/jkl', 'http://example.com/page4', '2024-04-04 16:00:00'), 6 | ('http://short.url/mno', 'http://example.com/page5', '2024-04-05 18:00:00'); 7 | 8 | INSERT INTO url_click (shortened_url_id, clicks, click_date, created_at) VALUES 9 | (1, 10, '2024-04-01', '2024-04-01 10:00:00'), 10 | (1, 40, '2024-04-02', '2024-04-02 10:00:00'), 11 | (1, 25, '2024-04-03', '2024-04-03 10:00:00'); -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/annotation/Https.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.annotation; 2 | 3 | import com.example.urlshortener.common.annotation.validator.HttpsValidator; 4 | import jakarta.validation.Constraint; 5 | import jakarta.validation.Payload; 6 | 7 | import java.lang.annotation.ElementType; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.RetentionPolicy; 10 | import java.lang.annotation.Target; 11 | 12 | @Target({ElementType.METHOD, ElementType.FIELD}) 13 | @Retention(RetentionPolicy.RUNTIME) 14 | @Constraint(validatedBy = HttpsValidator.class) 15 | public @interface Https { 16 | String message() default "도메인에 https가 적용되어야 합니다."; 17 | Class[] groups() default {}; 18 | Class[] payload() default {}; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/UrlShortenerApplication.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | 7 | import java.time.LocalDateTime; 8 | 9 | @SpringBootApplication 10 | @Slf4j 11 | public class UrlShortenerApplication { 12 | 13 | // Lombok 사용 x 14 | // private static final Logger log = LoggerFactory.getLogger(UrlShortenerApplication.class); 15 | 16 | public static void main(String[] args) { 17 | SpringApplication.run(UrlShortenerApplication.class, args); 18 | 19 | // Slf4j logger로 로깅하기 20 | var now = LocalDateTime.now().toString(); 21 | log.info("Application started at {}", now); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/utils/RandomStringUtil.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.utils; 2 | 3 | import java.security.SecureRandom; 4 | import java.util.Random; 5 | 6 | public class RandomStringUtil { 7 | 8 | private static final String ALLOWED_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789-"; 9 | 10 | public static String generateRandomString(int length) { 11 | Random random = new SecureRandom(); 12 | StringBuilder sb = new StringBuilder(length); 13 | for (int i = 0; i < length; i++) { 14 | int randomIndex = random.nextInt(ALLOWED_CHARACTERS.length()); 15 | char randomChar = ALLOWED_CHARACTERS.charAt(randomIndex); 16 | sb.append(randomChar); 17 | } 18 | return sb.toString(); 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/main/resources/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Course Tracker 7 | 8 | 9 | 10 | 11 | 12 | 13 | OK 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/dto/ShortenedUrlDto.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.dto; 2 | 3 | import com.example.urlshortener.domain.url.entity.ShortenedUrl; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Getter; 7 | 8 | import java.time.LocalDateTime; 9 | 10 | @Getter 11 | @AllArgsConstructor 12 | @Builder 13 | public class ShortenedUrlDto { 14 | 15 | private Long id; 16 | private String shortUrl; 17 | private String originUrl; 18 | private LocalDateTime createdAt; 19 | 20 | public static ShortenedUrlDto from(ShortenedUrl shortenedUrl) { 21 | return ShortenedUrlDto.builder() 22 | .id(shortenedUrl.getId()) 23 | .shortUrl(shortenedUrl.getShortUrl()) 24 | .originUrl(shortenedUrl.getOriginUrl()) 25 | .createdAt(shortenedUrl.getCreatedAt()) 26 | .build(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/user/controller/LoginController.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.user.controller; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.ui.Model; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | 7 | @Controller 8 | public class LoginController { 9 | @GetMapping("/index") 10 | public String index() { 11 | return "index"; 12 | } 13 | 14 | @GetMapping("/login") 15 | public String login() { 16 | return "login"; 17 | } 18 | 19 | @GetMapping("/login-error") 20 | public String loginError(Model model) { 21 | model.addAttribute("loginError", true); 22 | return "login"; 23 | } 24 | 25 | @GetMapping("/login-locked") 26 | public String loginLocked(Model model) { 27 | model.addAttribute("loginLocked", true); 28 | return "login"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/controller/response/ShortUrlResponse.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.controller.response; 2 | 3 | import com.example.urlshortener.domain.url.dto.ShortenedUrlDto; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Getter; 7 | 8 | import java.time.LocalDateTime; 9 | 10 | import static lombok.AccessLevel.PRIVATE; 11 | 12 | @Getter 13 | @AllArgsConstructor 14 | @Builder(access = PRIVATE) 15 | public class ShortUrlResponse { 16 | private String shortUrl; 17 | private String originUrl; 18 | private LocalDateTime createdAt; 19 | 20 | public static ShortUrlResponse from(ShortenedUrlDto shortenedUrl) { 21 | return ShortUrlResponse.builder() 22 | .shortUrl(shortenedUrl.getShortUrl()) 23 | .originUrl(shortenedUrl.getOriginUrl()) 24 | .createdAt(shortenedUrl.getCreatedAt()) 25 | .build(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/user/dto/UserDto.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.user.dto; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import jakarta.validation.constraints.NotEmpty; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | @Data 10 | @AllArgsConstructor 11 | @NoArgsConstructor 12 | public class UserDto { 13 | @NotEmpty(message = "Enter your firstname") 14 | private String firstName; 15 | 16 | @NotEmpty(message = "Enter your lastname") 17 | private String lastName; 18 | 19 | @NotEmpty(message = "Enter a username") 20 | private String username; 21 | 22 | @NotEmpty(message = "Enter an email") 23 | @Email(message = "Email is not valid") 24 | private String email; 25 | 26 | @NotEmpty(message = "Enter a password") 27 | private String password; 28 | 29 | @NotEmpty(message = "Confirm your password") 30 | private String confirmPassword; 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/user/entity/ApplicationUser.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.user.entity; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.Builder; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | 9 | @Getter 10 | @Setter 11 | @NoArgsConstructor 12 | @Entity 13 | @Table(name = "users") 14 | public class ApplicationUser { 15 | @Id 16 | @GeneratedValue(strategy = GenerationType.IDENTITY) 17 | private Long id; 18 | 19 | private String firstName; 20 | private String lastName; 21 | private String username; 22 | private String email; 23 | private String password; 24 | 25 | @Builder 26 | public ApplicationUser(String firstName, String lastName, String username, String email, String password) { 27 | this.firstName = firstName; 28 | this.lastName = lastName; 29 | this.username = username; 30 | this.email = email; 31 | this.password = password; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/dev/controller/PingController.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.dev.controller; 2 | 3 | import com.example.urlshortener.common.dto.Response; 4 | import io.swagger.v3.oas.annotations.Operation; 5 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 6 | import io.swagger.v3.oas.annotations.tags.Tag; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | @RestController 13 | @RequiredArgsConstructor 14 | @RequestMapping("/dev") 15 | @Tag(name = "🖥️ 개발 전용", description = "개발 전용 API") 16 | public class PingController { 17 | @Operation( 18 | summary = "Ping 테스트", 19 | responses = @ApiResponse(responseCode = "200", description = "pong을 반환합니다.") 20 | ) 21 | @GetMapping("ping") 22 | public Response ping() { 23 | return Response.data("pong"); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/security/listener/AuthenticationSuccessEventListener.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.security.listener; 2 | 3 | import com.example.urlshortener.domain.user.service.LoginAttemptService; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.context.ApplicationListener; 6 | import org.springframework.security.authentication.event.AuthenticationSuccessEvent; 7 | import org.springframework.security.core.userdetails.User; 8 | import org.springframework.stereotype.Service; 9 | 10 | @Service 11 | @RequiredArgsConstructor 12 | public class AuthenticationSuccessEventListener implements ApplicationListener { 13 | private final LoginAttemptService loginAttemptService; 14 | 15 | @Override 16 | public void onApplicationEvent(AuthenticationSuccessEvent authenticationSuccessEvent) { 17 | User user = (User) authenticationSuccessEvent.getAuthentication().getPrincipal(); 18 | loginAttemptService.loginSuccess(user.getUsername()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/security/listener/AuthenticationFailureEventListener.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.security.listener; 2 | 3 | import com.example.urlshortener.domain.user.service.LoginAttemptService; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.context.ApplicationListener; 6 | import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent; 7 | import org.springframework.stereotype.Service; 8 | 9 | @Service 10 | @RequiredArgsConstructor 11 | public class AuthenticationFailureEventListener implements ApplicationListener { 12 | private final LoginAttemptService loginAttemptService; 13 | 14 | @Override 15 | public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent authenticationFailureBadCredentialsEvent) { 16 | String username = (String) authenticationFailureBadCredentialsEvent.getAuthentication().getPrincipal(); 17 | loginAttemptService.loginFailed(username); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/user/controller/RegistrationController.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.user.controller; 2 | 3 | import com.example.urlshortener.domain.user.dto.UserDto; 4 | import com.example.urlshortener.domain.user.service.UserService; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Controller; 7 | import org.springframework.validation.BindingResult; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.ModelAttribute; 10 | import org.springframework.web.bind.annotation.PostMapping; 11 | 12 | @Controller 13 | @RequiredArgsConstructor 14 | public class RegistrationController { 15 | private final UserService userService; 16 | 17 | @GetMapping("/adduser") 18 | public String register(@ModelAttribute("user") UserDto user) { 19 | return "add-user"; 20 | } 21 | 22 | @PostMapping("/adduser") 23 | public String loginError(@ModelAttribute("user") UserDto userDto, BindingResult result) { 24 | if (result.hasErrors()) 25 | return "add-user"; 26 | 27 | userService.createUser(userDto); 28 | return "redirect:adduser?success"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/configuration/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.configuration; 2 | 3 | import com.example.urlshortener.common.exception.ApiException; 4 | import com.example.urlshortener.common.exception.dto.ExceptionDto; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.web.bind.annotation.ExceptionHandler; 8 | import org.springframework.web.bind.annotation.RestControllerAdvice; 9 | 10 | @RestControllerAdvice 11 | public class GlobalExceptionHandler { 12 | 13 | @ExceptionHandler(Exception.class) 14 | protected ResponseEntity handleException(final Exception e) { 15 | e.printStackTrace(); 16 | return ResponseEntity 17 | .status(HttpStatus.INTERNAL_SERVER_ERROR) 18 | .body(ExceptionDto.of(500, "INTERNAL_SERVER_ERROR", e.getMessage())); 19 | } 20 | 21 | @ExceptionHandler(ApiException.class) 22 | protected ResponseEntity handleApiException(final ApiException e) { 23 | e.printStackTrace(); 24 | return ResponseEntity 25 | .status(e.getStatusCode()) 26 | .body(ExceptionDto.of(e.getStatusCode().value(), e.getStatusCode().toString(), e.getMessage())); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/user/service/UserService.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.user.service; 2 | 3 | import com.example.urlshortener.domain.user.dto.UserDto; 4 | import com.example.urlshortener.domain.user.entity.ApplicationUser; 5 | import com.example.urlshortener.domain.user.repository.UserRepository; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.security.crypto.password.PasswordEncoder; 8 | import org.springframework.stereotype.Service; 9 | 10 | @Service 11 | @RequiredArgsConstructor 12 | public class UserService { 13 | private final UserRepository userRepository; 14 | private final PasswordEncoder passwordEncoder; 15 | 16 | public void createUser(UserDto userDto) { 17 | ApplicationUser user = new ApplicationUser(); 18 | user.setFirstName(userDto.getFirstName()); 19 | user.setLastName(userDto.getLastName()); 20 | user.setUsername(userDto.getUsername()); 21 | user.setEmail(userDto.getEmail()); 22 | user.setPassword(passwordEncoder.encode(userDto.getPassword())); 23 | 24 | userRepository.save(user); 25 | } 26 | 27 | public void save(ApplicationUser user) { 28 | userRepository.save(user); 29 | } 30 | 31 | public ApplicationUser findByUsername(String username) { 32 | return userRepository.findByUsername(username); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/security/handler/CustomAuthenticationFailureHandler.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.security.handler; 2 | 3 | import jakarta.servlet.ServletException; 4 | import jakarta.servlet.http.HttpServletRequest; 5 | import jakarta.servlet.http.HttpServletResponse; 6 | import org.springframework.security.authentication.LockedException; 7 | import org.springframework.security.core.AuthenticationException; 8 | import org.springframework.security.web.DefaultRedirectStrategy; 9 | import org.springframework.security.web.authentication.AuthenticationFailureHandler; 10 | import org.springframework.stereotype.Service; 11 | 12 | import java.io.IOException; 13 | 14 | @Service 15 | public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { 16 | private final DefaultRedirectStrategy defaultRedirectStrategy = new DefaultRedirectStrategy(); 17 | 18 | @Override 19 | public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { 20 | if (exception.getCause() instanceof LockedException) { 21 | defaultRedirectStrategy.sendRedirect(request, response, "/login-locked"); 22 | return; 23 | } 24 | 25 | defaultRedirectStrategy.sendRedirect(request, response, "/login-error"); 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/entity/ShortenedUrl.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.entity; 2 | 3 | import jakarta.persistence.*; 4 | import jakarta.validation.constraints.NotBlank; 5 | import lombok.Builder; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | import lombok.Setter; 9 | 10 | import java.time.LocalDateTime; 11 | import java.util.HashSet; 12 | import java.util.Set; 13 | 14 | @Getter 15 | @Setter 16 | @NoArgsConstructor 17 | @Entity 18 | @Table(name = "shortened_url") 19 | public class ShortenedUrl { 20 | @Id 21 | @GeneratedValue(strategy = GenerationType.IDENTITY) 22 | private Long id; 23 | 24 | @NotBlank 25 | @Column(name = "short_url", length = 2048, nullable = false) 26 | private String shortUrl; 27 | 28 | @NotBlank 29 | @Column(name = "origin_url", length = 2048, nullable = false) 30 | private String originUrl; 31 | 32 | @OneToMany(mappedBy = "shortenedUrl", fetch = FetchType.LAZY) 33 | private Set urlClicks = new HashSet<>(); 34 | 35 | // TODO: BaseEntity로 설정하기 36 | @Column(name = "created_at", nullable = false) 37 | private LocalDateTime createdAt; 38 | 39 | @Builder 40 | public ShortenedUrl(String shortUrl, String originUrl, LocalDateTime createdAt) { 41 | this.shortUrl = shortUrl; 42 | this.originUrl = originUrl; 43 | this.createdAt = createdAt; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/entity/UrlClick.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.entity; 2 | 3 | import jakarta.persistence.*; 4 | import jakarta.validation.constraints.NotNull; 5 | import lombok.Builder; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | import lombok.Setter; 9 | 10 | import java.time.LocalDate; 11 | import java.time.LocalDateTime; 12 | 13 | @Getter 14 | @Setter 15 | @NoArgsConstructor 16 | @Entity 17 | @Table(name = "url_click") 18 | public class UrlClick { 19 | @Id 20 | @GeneratedValue(strategy = GenerationType.IDENTITY) 21 | private Long id; 22 | 23 | @ManyToOne(fetch = FetchType.LAZY) 24 | @JoinColumn(name = "shortened_url_id") 25 | private ShortenedUrl shortenedUrl; 26 | 27 | @NotNull 28 | @Column(name = "clicks", length = 128, nullable = false) 29 | private Long clicks; 30 | 31 | @NotNull 32 | @Column(name = "click_date", nullable = false) 33 | private LocalDate clickDate; 34 | 35 | // TODO: BaseEntity로 설정하기 36 | @Column(name = "created_at", nullable = false) 37 | private LocalDateTime createdAt; 38 | 39 | @Builder 40 | public UrlClick(Long clicks, ShortenedUrl shortenedUrl, LocalDate clickDate, LocalDateTime createdAt) { 41 | this.shortenedUrl = shortenedUrl; 42 | this.clickDate = clickDate; 43 | this.clicks = clicks; 44 | this.createdAt = createdAt; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/user/dto/ApplicationUserDto.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.user.dto; 2 | 3 | import com.example.urlshortener.domain.user.entity.ApplicationUser; 4 | import jakarta.validation.constraints.Email; 5 | import jakarta.validation.constraints.NotEmpty; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | 10 | @Getter 11 | @AllArgsConstructor 12 | @Builder 13 | public class ApplicationUserDto { 14 | @NotEmpty(message = "Enter your firstname") 15 | private String firstName; 16 | 17 | @NotEmpty(message = "Enter your lastname") 18 | private String lastName; 19 | 20 | @NotEmpty(message = "Enter a username") 21 | private String username; 22 | 23 | @NotEmpty(message = "Enter an email") 24 | @Email(message = "Email is not valid") 25 | private String email; 26 | 27 | @NotEmpty(message = "Enter a password") 28 | private String password; 29 | 30 | public static ApplicationUserDto from(ApplicationUser applicationUser) { 31 | return ApplicationUserDto.builder() 32 | .firstName(applicationUser.getFirstName()) 33 | .lastName(applicationUser.getLastName()) 34 | .username(applicationUser.getUsername()) 35 | .email(applicationUser.getEmail()) 36 | .password(applicationUser.getPassword()) 37 | .build(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/test/java/com/example/urlshortener/ch3/Ch3Test5.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.ch3; 2 | 3 | import com.example.urlshortener.domain.url.entity.ShortenedUrl; 4 | import com.example.urlshortener.domain.url.repository.ShortenedUrlRepository; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | 9 | import java.time.LocalDateTime; 10 | 11 | import static org.assertj.core.api.Assertions.assertThat; 12 | 13 | // 3.5 14 | @SpringBootTest 15 | class Ch3Test5 { 16 | 17 | @Autowired 18 | private ShortenedUrlRepository shortenedUrlRepository; 19 | 20 | @Test 21 | public void givenShortenedUrlsWhenLoadShortenedUrlsWithQueryThenExpectCorrectShortenedUrlsDetails() { 22 | ShortenedUrl shortenedUrl = new ShortenedUrl("abc", "http://example.com/page1", LocalDateTime.now()); 23 | ShortenedUrl savedShortenedUrl = shortenedUrlRepository.save(shortenedUrl); 24 | 25 | assertThat(shortenedUrlRepository.findByShortUrlWithQuery(savedShortenedUrl.getShortUrl()).get().getOriginUrl()).isEqualTo("http://example.com/page1"); 26 | 27 | shortenedUrlRepository.updateOriginUrlByShortUrl("http://example.com/page2", savedShortenedUrl.getShortUrl()); 28 | assertThat(shortenedUrlRepository.findByShortUrlWithQuery(savedShortenedUrl.getShortUrl()).get().getOriginUrl()).isEqualTo("http://example.com/page2"); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/user/service/CustomUserDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.user.service; 2 | 3 | import com.example.urlshortener.domain.user.entity.ApplicationUser; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.security.authentication.LockedException; 6 | import org.springframework.security.core.userdetails.User; 7 | import org.springframework.security.core.userdetails.UserDetails; 8 | import org.springframework.security.core.userdetails.UserDetailsService; 9 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 10 | import org.springframework.stereotype.Service; 11 | 12 | import java.util.ArrayList; 13 | 14 | @Service 15 | @RequiredArgsConstructor 16 | public class CustomUserDetailsService implements UserDetailsService { 17 | private final UserService userService; 18 | private final LoginAttemptService loginAttemptService; 19 | 20 | @Override 21 | public UserDetails loadUserByUsername(String username) { 22 | if(loginAttemptService.isBlocked(username)) { 23 | throw new LockedException("User Account is Locked"); 24 | } 25 | 26 | ApplicationUser applicationUser = userService.findByUsername(username); 27 | if(applicationUser == null) { 28 | throw new UsernameNotFoundException("User with username " + username + " not found"); 29 | } 30 | 31 | return new User(applicationUser.getUsername(), applicationUser.getPassword(), new ArrayList<>()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/com/example/urlshortener/ch3/Ch3Test8.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.ch3; 2 | 3 | import com.example.urlshortener.domain.url.entity.ShortenedUrl; 4 | import com.example.urlshortener.domain.url.entity.UrlClick; 5 | import com.example.urlshortener.domain.url.repository.ShortenedUrlRepository; 6 | import com.example.urlshortener.domain.url.repository.UrlClickRepository; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.context.SpringBootTest; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import static org.assertj.core.api.Assertions.assertThat; 13 | 14 | // 3.8 15 | @SpringBootTest 16 | class Ch3Test8 { 17 | 18 | @Autowired 19 | private ShortenedUrlRepository shortenedUrlRepository; 20 | 21 | @Autowired 22 | private UrlClickRepository urlClickRepository; 23 | 24 | // sql.init.data-locations 가 data.sql로 설정되어야 함 25 | // 영속성 컨텍스트(FetchType.LAZY)로 @Transactional 적용 26 | @Test 27 | @Transactional 28 | public void givenUrlClicksCreatedWhenLoadUrlClicksThenExpectCorrectUrlClicksDetails() { 29 | ShortenedUrl shortenedUrl = shortenedUrlRepository.findById(1L).get(); 30 | 31 | assertThat(shortenedUrl.getUrlClicks().size()).isEqualTo(3); 32 | 33 | Long totalClicks = shortenedUrl.getUrlClicks().stream() 34 | .mapToLong(UrlClick::getClicks) 35 | .sum(); 36 | 37 | assertThat(totalClicks).isEqualTo(75L); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | # H2 Database ?? 3 | datasource: 4 | driver-class-name: org.h2.Driver 5 | url: 'jdbc:h2:mem:test' # H2 DB (In-Memory Mode) 6 | # url: 'jdbc:h2:~/test' # H2 DB (Embedded Mode) 7 | username: root # H2 DB ID 8 | password: 1234 # H2 DB PW 9 | 10 | # H2 Console 11 | h2: 12 | console: 13 | enabled: true 14 | path: /h2-console 15 | 16 | # JPA 17 | jpa: 18 | database-platform: org.hibernate.dialect.H2Dialect 19 | hibernate: 20 | ddl-auto: create # (none, create, create-drop, update, validate) 21 | properties: 22 | hibernate: 23 | dialect: org.hibernate.dialect.H2Dialect # Dialect: 데이터베이스에 대한 특정 SQL 문법과 데이터 타입 매핑에 대한 규칙을 제공 24 | format_sql: true # SQL 쿼리를 포맷팅하여 출력할지 여부 설정 25 | show_sql: true # SQL 쿼리를 출력할지 여부 설정 26 | 27 | # TODO: 발표 후 ddl-auto: create 로 변경하기(보통 ddl-auto로 사용함) 28 | # sql: 29 | # init: 30 | # mode: always # ddl-auto: none, H2는 필요없음(어차피 사라지기 때문) 31 | # schema-locations: classpath:db-init-scripts/schema.sql 32 | # data-locations: classpath:db-init-scripts/data.sql 33 | 34 | #logging: 35 | # pattern: 36 | # # 로깅 패턴 설정 37 | # console: "%clr(%d{dd-MM-yyyy HH:mm:ss.SSS}){yellow} %clr(${PID:- }){green} %magenta([%thread]) %highlight([%-5level]) %clr(%-40.40logger{39}){cyan} %msg%n" 38 | # level: 39 | # # 로깅 레벨 설정 40 | # root: info 41 | # file: 42 | # name: log/gdsc.log 43 | # path: /Users/gdsc/Documents/Java/SpringBoot/url-shortener/logs 44 | 45 | server: 46 | # 서버 안전하게 종료하기 47 | shutdown: graceful -------------------------------------------------------------------------------- /src/test/java/com/example/urlshortener/ch3/Ch3Test2.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.ch3; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | import javax.sql.DataSource; 8 | import java.sql.PreparedStatement; 9 | import java.sql.ResultSet; 10 | import java.sql.SQLException; 11 | 12 | import static org.assertj.core.api.Assertions.assertThat; 13 | 14 | // 3.2 15 | @SpringBootTest 16 | class Ch3Test2 { 17 | 18 | @Autowired 19 | private DataSource datasource; 20 | 21 | // Datasource 확인 22 | @Test 23 | public void giveDatasourceAvailableWhenAccessDetailsThenExpectDetails() throws SQLException { 24 | assertThat(datasource.getClass().getName()).isEqualTo("com.zaxxer.hikari.HikariDataSource"); 25 | assertThat(datasource.getConnection().getMetaData().getDatabaseProductName()).isEqualTo("H2"); 26 | } 27 | 28 | // sql.init.data-locations 가 data.sql로 설정되어야 함 29 | @Test 30 | public void whenCountAllCoursesThenExpectFiveCourses() throws SQLException { 31 | ResultSet rs = null; 32 | int noOfShortenedUrls = 0; 33 | 34 | try(PreparedStatement ps = datasource.getConnection().prepareStatement("SELECT COUNT(1) FROM shortened_url")) { 35 | rs = ps.executeQuery(); 36 | if(rs.next()) { 37 | noOfShortenedUrls = rs.getInt(1); 38 | } 39 | assertThat(noOfShortenedUrls).isEqualTo(5); 40 | } finally { 41 | if(rs != null) { 42 | rs.close(); 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/repository/ShortenedUrlRepository.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.repository; 2 | 3 | import com.example.urlshortener.domain.url.entity.ShortenedUrl; 4 | import com.example.urlshortener.domain.url.repository.interfaces.ShortUrlOnly; 5 | import jakarta.transaction.Transactional; 6 | import org.springframework.data.domain.Page; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.jpa.repository.JpaRepository; 9 | import org.springframework.data.jpa.repository.Modifying; 10 | import org.springframework.data.jpa.repository.Query; 11 | import org.springframework.data.repository.query.Param; 12 | import org.springframework.stereotype.Repository; 13 | 14 | import java.util.Optional; 15 | 16 | @Repository 17 | public interface ShortenedUrlRepository extends JpaRepository { 18 | 19 | Optional findByOriginUrl(String longUrl); 20 | 21 | Optional findByShortUrl(String shortUrl); 22 | 23 | boolean existsByShortUrl(String shortUrl); 24 | 25 | long countByOriginUrl(String longUrl); 26 | 27 | Page findAll(Pageable pageable); 28 | 29 | // TODO: @Query 사용은 지양하기 30 | @Query("SELECT s FROM ShortenedUrl s WHERE s.shortUrl = :shortUrl") 31 | Optional findByShortUrlWithQuery(@Param("shortUrl") String shortUrl); 32 | 33 | @Modifying 34 | @Transactional 35 | @Query("UPDATE ShortenedUrl s SET s.originUrl = :originUrl WHERE s.shortUrl = :shortUrl") 36 | int updateOriginUrlByShortUrl(@Param("originUrl") String originUrl, @Param("shortUrl") String shortUrl); 37 | 38 | Iterable findShortenedUrlByOriginUrl(String originUrl); 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/user/service/LoginAttemptService.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.user.service; 2 | 3 | import com.google.common.cache.CacheBuilder; 4 | import com.google.common.cache.CacheLoader; 5 | import com.google.common.cache.LoadingCache; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.util.concurrent.ExecutionException; 9 | import java.util.concurrent.TimeUnit; 10 | 11 | @Service 12 | public class LoginAttemptService { 13 | private static final int MAX_ATTEMPTS_COUNT = 3; 14 | private final LoadingCache loginAttemptCache; 15 | 16 | public LoginAttemptService() { 17 | loginAttemptCache = CacheBuilder.newBuilder() 18 | .expireAfterWrite(1, TimeUnit.DAYS) 19 | .build(new CacheLoader() { 20 | @Override 21 | public Integer load(String key) { 22 | return 0; 23 | } 24 | }); 25 | } 26 | 27 | public void loginSuccess(String username) { 28 | loginAttemptCache.invalidate(username); 29 | } 30 | 31 | public void loginFailed(String username) { 32 | int failedAttemptCounter = 0; 33 | try { 34 | failedAttemptCounter = loginAttemptCache.get(username); 35 | } catch (ExecutionException e) { 36 | // No need to do anything, counter is already 0 37 | } 38 | failedAttemptCounter++; 39 | loginAttemptCache.put(username, failedAttemptCounter); 40 | } 41 | 42 | public boolean isBlocked(String username) { 43 | try { 44 | return loginAttemptCache.get(username) >= MAX_ATTEMPTS_COUNT; 45 | } catch (ExecutionException e) { 46 | return false; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/service/UrlService.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.service; 2 | 3 | import com.example.urlshortener.common.utils.RandomStringUtil; 4 | import com.example.urlshortener.domain.url.dto.ShortenedUrlDto; 5 | import com.example.urlshortener.domain.url.entity.ShortenedUrl; 6 | import com.example.urlshortener.domain.url.exception.UrlNotFoundException; 7 | import com.example.urlshortener.domain.url.repository.ShortenedUrlRepository; 8 | import jakarta.transaction.Transactional; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.stereotype.Service; 11 | 12 | import java.time.LocalDateTime; 13 | 14 | @Service 15 | @RequiredArgsConstructor 16 | public class UrlService { 17 | private final ShortenedUrlRepository shortenedUrlRepository; 18 | 19 | @Transactional 20 | public ShortenedUrlDto createShortUrl(String url) { 21 | ShortenedUrl existingShortenedUrl = shortenedUrlRepository.findByOriginUrl(url).orElse(null); 22 | 23 | if (existingShortenedUrl != null) { 24 | return ShortenedUrlDto.from(existingShortenedUrl); 25 | } 26 | 27 | String shortUrl = RandomStringUtil.generateRandomString(8); 28 | 29 | ShortenedUrl shortenedUrl = new ShortenedUrl(shortUrl, url, LocalDateTime.now()); 30 | shortenedUrl = shortenedUrlRepository.save(shortenedUrl); 31 | 32 | return ShortenedUrlDto.from(shortenedUrl); 33 | } 34 | 35 | public ShortenedUrlDto getShortUrl(String shortId) { 36 | ShortenedUrl shortenedUrl = shortenedUrlRepository.findByShortUrl(shortId) 37 | .orElseThrow(UrlNotFoundException::new); 38 | 39 | return new ShortenedUrlDto(shortenedUrl.getId(), shortenedUrl.getShortUrl(), shortenedUrl.getOriginUrl(), shortenedUrl.getCreatedAt()); 40 | } 41 | 42 | public String getOriginUrl(String shortId) { 43 | ShortenedUrl shortenedUrl = shortenedUrlRepository.findByShortUrl(shortId) 44 | .orElseThrow(UrlNotFoundException::new); 45 | 46 | return shortenedUrl.getOriginUrl(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/test/java/com/example/urlshortener/ch3/Ch3Test3.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.ch3; 2 | 3 | import com.example.urlshortener.domain.url.entity.ShortenedUrl; 4 | import com.example.urlshortener.domain.url.repository.ShortenedUrlRepository; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | 9 | import java.time.LocalDateTime; 10 | 11 | import static org.assertj.core.api.Assertions.assertThat; 12 | 13 | // 3.3 14 | @SpringBootTest 15 | class Ch3Test3 { 16 | 17 | @Autowired 18 | private ShortenedUrlRepository shortenedUrlRepository; 19 | 20 | @Test 21 | public void givenCreateShortenedUrlWhenLoadTheShortenedUrlThenExpectSameShortenedUrl() { 22 | ShortenedUrl shortenedUrl = new ShortenedUrl("abc", "http://example.com/page1", LocalDateTime.now()); 23 | ShortenedUrl savedShortenedUrl = shortenedUrlRepository.save(shortenedUrl); 24 | 25 | // TODO: 실제로는 이렇게 사용하면 안됨 with Transactional 26 | assertThat(shortenedUrlRepository.findById(savedShortenedUrl.getId()).get().getId()).isEqualTo(savedShortenedUrl.getId()); 27 | } 28 | 29 | @Test 30 | public void givenUpdateShortenedUrlWhenLoadTheShortenedUrlThenExpectUpdatedShortenedUrl() { 31 | ShortenedUrl shortenedUrl = new ShortenedUrl("abc", "http://example.com/page1", LocalDateTime.now()); 32 | shortenedUrlRepository.save(shortenedUrl); 33 | 34 | shortenedUrl.setOriginUrl("http://example.com/page2"); 35 | // TODO: 영속성 컨텍스트 / 엔티티 생명주기 이야기 36 | ShortenedUrl updatedShortenedUrl = shortenedUrlRepository.save(shortenedUrl); 37 | 38 | assertThat(shortenedUrlRepository.findById(updatedShortenedUrl.getId()).get().getOriginUrl()).isEqualTo("http://example.com/page2"); 39 | } 40 | 41 | @Test 42 | public void givenDeleteShortenedUrlWhenLoadTheShortenedUrlThenExpectNoShortenedUrl() { 43 | ShortenedUrl shortenedUrl = new ShortenedUrl("abc", "http://example.com/page1", LocalDateTime.now()); 44 | ShortenedUrl savedShortenedUrl = shortenedUrlRepository.save(shortenedUrl); 45 | 46 | assertThat(shortenedUrlRepository.findById(savedShortenedUrl.getId()).get().getId()).isEqualTo(savedShortenedUrl.getId()); 47 | 48 | shortenedUrlRepository.delete(savedShortenedUrl); 49 | assertThat(shortenedUrlRepository.findById(savedShortenedUrl.getId()).isPresent()).isFalse(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/configuration/SecurityConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.configuration; 2 | 3 | import com.example.urlshortener.common.security.handler.CustomAuthenticationFailureHandler; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 8 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 9 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 10 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 11 | import org.springframework.security.crypto.password.PasswordEncoder; 12 | import org.springframework.security.web.SecurityFilterChain; 13 | import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter; 14 | 15 | @Configuration 16 | @EnableWebSecurity 17 | @RequiredArgsConstructor 18 | public class SecurityConfiguration { 19 | private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler; 20 | 21 | @Bean 22 | public PasswordEncoder passwordEncoder() { 23 | return new BCryptPasswordEncoder(); 24 | } 25 | 26 | @Bean 27 | public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 28 | http.csrf(AbstractHttpConfigurer::disable); 29 | http 30 | .cors(cors -> {}); 31 | // H2 DB 헤더 옵션 32 | http 33 | .headers(headers -> 34 | headers.addHeaderWriter(new XFrameOptionsHeaderWriter( 35 | XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN) 36 | ) 37 | ); 38 | // Chapter 6.1 모든 요청에 HTTPS 적용하기 39 | // http 40 | // .requiresChannel( 41 | // requiresChannel -> requiresChannel.anyRequest().requiresSecure() 42 | // ); 43 | http 44 | .authorizeHttpRequests( 45 | authorize -> authorize 46 | // 로그인 보안 관련 엔드포인트는 모두 접근 허용 + 편의를 위해 나머지도 접근 허용 47 | .requestMatchers("/adduser", "/login", "/login-error", "/login-locked").permitAll() 48 | .requestMatchers("/webjars/**", "/static/css/**", "/h2-console/**", "/images/**").permitAll() 49 | .anyRequest().permitAll() 50 | // .anyRequest().authenticated() 51 | ); 52 | http 53 | .formLogin( 54 | formLogin -> formLogin 55 | .loginPage("/login") 56 | .defaultSuccessUrl("/index", true).permitAll() 57 | .failureHandler(customAuthenticationFailureHandler) 58 | ); 59 | 60 | return http.build(); 61 | } 62 | } -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/domain/url/controller/UrlController.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.domain.url.controller; 2 | 3 | import com.example.urlshortener.common.dto.Response; 4 | import com.example.urlshortener.domain.url.controller.request.CreateShortUrlRequest; 5 | import com.example.urlshortener.domain.url.controller.response.ShortUrlResponse; 6 | import com.example.urlshortener.domain.url.dto.ShortenedUrlDto; 7 | import com.example.urlshortener.domain.url.service.UrlService; 8 | import io.swagger.v3.oas.annotations.Operation; 9 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 10 | import io.swagger.v3.oas.annotations.tags.Tag; 11 | import jakarta.validation.Valid; 12 | import jakarta.validation.constraints.NotBlank; 13 | import lombok.RequiredArgsConstructor; 14 | import org.springframework.web.bind.annotation.*; 15 | import org.springframework.web.servlet.view.RedirectView; 16 | 17 | @RequiredArgsConstructor 18 | @RestController 19 | @RequestMapping("/short-links") 20 | @Tag(name = "🔗 URL 단축기", description = "URL 단축기 API") 21 | public class UrlController { 22 | 23 | private final UrlService urlService; 24 | 25 | @Operation( 26 | summary = "URL 단축하기", 27 | responses = { 28 | @ApiResponse(responseCode = "200", description = "OK"), 29 | @ApiResponse(responseCode = "500", description = "INTERNAL_SERVER_ERROR") 30 | } 31 | ) 32 | @PostMapping 33 | public Response createShortUrl(@Valid @RequestBody CreateShortUrlRequest request) { 34 | ShortenedUrlDto shortenedUrl = urlService.createShortUrl(request.getUrl()); 35 | return Response.data(ShortUrlResponse.from(shortenedUrl)); 36 | } 37 | 38 | @Operation( 39 | summary = "단축 URL 조회하기", 40 | responses = { 41 | @ApiResponse(responseCode = "200", description = "OK"), 42 | @ApiResponse(responseCode = "404", description = "URL_NOT_FOUND"), 43 | @ApiResponse(responseCode = "500", description = "INTERNAL_SERVER_ERROR") 44 | } 45 | ) 46 | @GetMapping("/{short_id}") 47 | public Response getShortUrl(@NotBlank @PathVariable("short_id") String shortId) { 48 | ShortenedUrlDto shortenedUrl = urlService.getShortUrl(shortId); 49 | return Response.data(ShortUrlResponse.from(shortenedUrl)); 50 | } 51 | 52 | @Operation( 53 | summary = "Short URL 리디렉션", 54 | responses = { 55 | @ApiResponse(responseCode = "302", description = "FOUND"), 56 | @ApiResponse(responseCode = "500", description = "INTERNAL_SERVER_ERROR") 57 | } 58 | ) 59 | @GetMapping("/r/{short_id}") 60 | public RedirectView redirectShortUrl(@NotBlank @PathVariable("short_id") String shortId) { 61 | String originUrl = urlService.getOriginUrl(shortId); 62 | RedirectView redirectView = new RedirectView(); 63 | redirectView.setUrl(originUrl); 64 | return redirectView; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/java/com/example/urlshortener/ch3/Ch3Test4.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.ch3; 2 | 3 | import com.example.urlshortener.domain.url.entity.ShortenedUrl; 4 | import com.example.urlshortener.domain.url.repository.ShortenedUrlRepository; 5 | import org.assertj.core.api.Condition; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.data.domain.PageRequest; 10 | import org.springframework.data.domain.Pageable; 11 | import org.springframework.data.domain.Sort; 12 | 13 | import java.time.LocalDateTime; 14 | import java.util.List; 15 | 16 | import static org.assertj.core.api.Assertions.assertThat; 17 | 18 | // 3.4 19 | @SpringBootTest 20 | class Ch3Test4 { 21 | 22 | @Autowired 23 | private ShortenedUrlRepository shortenedUrlRepository; 24 | 25 | @Test 26 | public void givenCreateShortenedUrlWhenLoadTheShortenedUrlThenExpectSameShortenedUrl() { 27 | ShortenedUrl shortenedUrl = new ShortenedUrl("abc", "http://example.com/page1", LocalDateTime.now()); 28 | ShortenedUrl savedShortenedUrl = shortenedUrlRepository.save(shortenedUrl); 29 | 30 | assertThat(shortenedUrlRepository.findByOriginUrl(savedShortenedUrl.getOriginUrl()).get().getId()).isEqualTo(savedShortenedUrl.getId()); 31 | assertThat(shortenedUrlRepository.existsByShortUrl(savedShortenedUrl.getShortUrl())).isTrue(); 32 | assertThat(shortenedUrlRepository.countByOriginUrl(savedShortenedUrl.getOriginUrl())).isEqualTo(1); 33 | } 34 | 35 | @Test 36 | public void givenDataAvailableWhenSortsFirstPageThenGetSortedData() { 37 | saveMockedShortenedUrls(); 38 | 39 | Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Order.desc("shortUrl"))); 40 | 41 | Condition sortedFirstCourseCondition = new Condition() { 42 | @Override 43 | public boolean matches(ShortenedUrl shortenedUrl) { 44 | return shortenedUrl.getId() == 5L && shortenedUrl.getShortUrl().equals("mno"); 45 | } 46 | }; 47 | 48 | // short_url 역순 정렬 검증 49 | assertThat(shortenedUrlRepository.findAll(pageable).getContent()).first().has(sortedFirstCourseCondition); 50 | } 51 | 52 | private void saveMockedShortenedUrls() { 53 | List shortenedUrls = List.of( 54 | new ShortenedUrl("abc", "http://example.com/page1", LocalDateTime.parse("2024-04-01T10:00:00")), 55 | new ShortenedUrl("def", "http://example.com/page2", LocalDateTime.parse("2024-04-02T12:00:00")), 56 | new ShortenedUrl("ghi", "http://example.com/page3", LocalDateTime.parse("2024-04-03T14:00:00")), 57 | new ShortenedUrl("jkl", "http://example.com/page4", LocalDateTime.parse("2024-04-04T16:00:00")), 58 | new ShortenedUrl("mno", "http://example.com/page5", LocalDateTime.parse("2024-04-05T18:00:00")) 59 | ); 60 | 61 | shortenedUrlRepository.saveAll(shortenedUrls); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/test/java/com/example/urlshortener/ch3/Ch3Test7.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.ch3; 2 | 3 | import com.example.urlshortener.domain.url.entity.QShortenedUrl; 4 | import com.example.urlshortener.domain.url.entity.ShortenedUrl; 5 | import com.example.urlshortener.domain.url.repository.ShortenedUrlRepository; 6 | import com.example.urlshortener.domain.url.repository.interfaces.ShortUrlOnly; 7 | import com.querydsl.jpa.impl.JPAQueryFactory; 8 | import org.junit.jupiter.api.Test; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.context.SpringBootTest; 11 | 12 | import java.time.LocalDateTime; 13 | import java.util.List; 14 | 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | 17 | // 3.7 18 | @SpringBootTest 19 | class Ch3Test7 { 20 | 21 | @Autowired 22 | private ShortenedUrlRepository shortenedUrlRepository; 23 | 24 | @Autowired 25 | private JPAQueryFactory jpaQueryFactory; 26 | 27 | @Test 28 | public void givenShortenedUrlsCreatedWhenLoadShortenedUrlsWithQueryDslThenExpectCorrectShortenedUrlDetails() { 29 | saveMockedShortenedUrls(); 30 | 31 | QShortenedUrl shortenedUrl = QShortenedUrl.shortenedUrl; 32 | List shortenedUrls = jpaQueryFactory 33 | .selectFrom(shortenedUrl) 34 | .where(shortenedUrl.originUrl.contains("http")) 35 | .fetch(); 36 | assertThat(shortenedUrls.size()).isEqualTo(5); 37 | 38 | List shortenedUrls2 = jpaQueryFactory 39 | .selectFrom(shortenedUrl) 40 | .where(shortenedUrl.originUrl.contains("http").and(shortenedUrl.originUrl.contains("page"))) 41 | .orderBy(shortenedUrl.shortUrl.desc()) 42 | .fetch(); 43 | 44 | assertThat(shortenedUrls2.size()).isEqualTo(5); 45 | assertThat(shortenedUrls2.get(0).getOriginUrl()).isEqualTo("http://example.com/page5"); 46 | 47 | // OrderSpecifier 테스트는 생략합니다. 48 | } 49 | 50 | @Test 51 | public void givenAShortenedUrlAvailableWhenGetShortenedUrlByNameThenGetShortenedUrlDescription() { 52 | // saveMockedShortenedUrls(); 53 | 54 | Iterable result = shortenedUrlRepository.findShortenedUrlByOriginUrl("http://example.com/page1"); 55 | 56 | assertThat(result).extracting("shortUrl").contains("abc"); 57 | } 58 | 59 | private void saveMockedShortenedUrls() { 60 | List shortenedUrls = List.of( 61 | new ShortenedUrl("abc", "http://example.com/page1", LocalDateTime.parse("2024-04-01T10:00:00")), 62 | new ShortenedUrl("def", "http://example.com/page2", LocalDateTime.parse("2024-04-02T12:00:00")), 63 | new ShortenedUrl("ghi", "http://example.com/page3", LocalDateTime.parse("2024-04-03T14:00:00")), 64 | new ShortenedUrl("jkl", "http://example.com/page4", LocalDateTime.parse("2024-04-04T16:00:00")), 65 | new ShortenedUrl("mno", "http://example.com/page5", LocalDateTime.parse("2024-04-05T18:00:00")) 66 | ); 67 | 68 | shortenedUrlRepository.saveAll(shortenedUrls); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/main/java/com/example/urlshortener/common/configuration/SwaggerConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.example.urlshortener.common.configuration; 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 4 | import io.swagger.v3.oas.models.Components; 5 | import io.swagger.v3.oas.models.ExternalDocumentation; 6 | import io.swagger.v3.oas.models.OpenAPI; 7 | import io.swagger.v3.oas.models.info.Info; 8 | import io.swagger.v3.oas.models.media.Schema; 9 | import io.swagger.v3.oas.models.security.SecurityRequirement; 10 | import io.swagger.v3.oas.models.security.SecurityScheme; 11 | import io.swagger.v3.oas.models.servers.Server; 12 | import org.springdoc.core.utils.SpringDocUtils; 13 | import org.springframework.context.annotation.Bean; 14 | import org.springframework.context.annotation.Configuration; 15 | 16 | import java.awt.*; 17 | import java.time.LocalDate; 18 | import java.time.LocalDateTime; 19 | import java.time.LocalTime; 20 | import java.time.format.DateTimeFormatter; 21 | import java.util.Collections; 22 | 23 | @Configuration 24 | @OpenAPIDefinition 25 | public class SwaggerConfiguration { 26 | private final SecurityScheme securityScheme = new SecurityScheme() 27 | .type(SecurityScheme.Type.HTTP) 28 | .scheme("bearer") 29 | .bearerFormat("JWT") 30 | .in(SecurityScheme.In.HEADER) 31 | .name("Authorization"); 32 | 33 | { 34 | SpringDocUtils.getConfig().replaceWithSchema(Color.class, 35 | new Schema() 36 | .type("string") 37 | .format("color") 38 | .example("#FFFFFFFF")); 39 | 40 | SpringDocUtils.getConfig().replaceWithSchema(LocalDateTime.class, 41 | new Schema() 42 | .type("string") 43 | .format("date-time") 44 | .example(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))); 45 | 46 | SpringDocUtils.getConfig().replaceWithSchema(LocalDate.class, 47 | new Schema() 48 | .type("string") 49 | .format("date") 50 | .example(LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE))); 51 | 52 | SpringDocUtils.getConfig().replaceWithSchema(LocalTime.class, 53 | new Schema() 54 | .type("string") 55 | .format("time") 56 | .example(LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")))); 57 | } 58 | 59 | @Bean 60 | public OpenAPI openApi() { 61 | String description = "GDSC Soongsil 스프링부트 스터디를 위한 Swagger 문서입니다."; 62 | String securityRequirementName = "bearerAuth"; 63 | return new OpenAPI() 64 | .servers(Collections.singletonList(new Server().url("/"))) 65 | .security(Collections.singletonList(new SecurityRequirement().addList(securityRequirementName))) 66 | .components(new Components().addSecuritySchemes(securityRequirementName, securityScheme)) 67 | .info(new Info() 68 | .title("URL Shortener API") 69 | .description(description) 70 | .version("0.0.1") 71 | ) 72 | .externalDocs(new ExternalDocumentation().description("URL Shortener API")); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/resources/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Login 7 | 8 | 9 | 10 | 11 | 12 | 13 | 37 |
38 |
39 |
40 |
41 |
Login
42 |
43 |
44 |
45 |
46 |
47 |
48 |
Wrong Username or Password
49 |
Account has been verified. Please sign-in
50 |
Account is not yet active. Please check your email
51 |
Account locked due to multiple incorrect login attempts.
52 | 53 |
54 |
55 | 56 | 57 |
58 |
59 | 60 | 61 |
62 |
63 | 64 | Register 65 |
66 |
67 |
68 |
69 |
70 |
71 | 72 | 73 | -------------------------------------------------------------------------------- /src/main/resources/templates/add-user.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Regsiter User 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 33 |
34 | 35 |
36 |
37 |
38 |
Register User
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | Registration completed. You can now login 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 |
81 |
82 | 83 |

Already have an account? Sign in

84 |
85 |
86 |
87 |
88 |
89 |
90 | 91 | 92 | -------------------------------------------------------------------------------- /log/gdsc.log: -------------------------------------------------------------------------------- 1 | 2024-03-29T15:16:32.593+09:00 INFO 28960 --- [main] c.e.u.UrlShortenerApplication : Starting UrlShortenerApplication using Java 17.0.5 with PID 28960 (/Users/ggona/Documents/GitHub/study/url-shortener/out/production/classes started by ggona in /Users/ggona/Documents/GitHub/study/url-shortener) 2 | 2024-03-29T15:16:32.594+09:00 INFO 28960 --- [main] c.e.u.UrlShortenerApplication : No active profile set, falling back to 1 default profile: "default" 3 | 2024-03-29T15:16:33.125+09:00 INFO 28960 --- [main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode. 4 | 2024-03-29T15:16:33.156+09:00 INFO 28960 --- [main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 26 ms. Found 1 JPA repository interface. 5 | 2024-03-29T15:16:33.448+09:00 INFO 28960 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http) 6 | 2024-03-29T15:16:33.455+09:00 INFO 28960 --- [main] o.apache.catalina.core.StandardService : Starting service [Tomcat] 7 | 2024-03-29T15:16:33.455+09:00 INFO 28960 --- [main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.18] 8 | 2024-03-29T15:16:33.486+09:00 INFO 28960 --- [main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext 9 | 2024-03-29T15:16:33.486+09:00 INFO 28960 --- [main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 870 ms 10 | 2024-03-29T15:16:33.502+09:00 INFO 28960 --- [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting... 11 | 2024-03-29T15:16:33.602+09:00 INFO 28960 --- [main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:test user=ROOT 12 | 2024-03-29T15:16:33.603+09:00 INFO 28960 --- [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed. 13 | 2024-03-29T15:16:33.609+09:00 INFO 28960 --- [main] o.s.b.a.h2.H2ConsoleAutoConfiguration : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:test' 14 | 2024-03-29T15:16:33.689+09:00 INFO 28960 --- [main] o.hibernate.jpa.internal.util.LogHelper : HHH000204: Processing PersistenceUnitInfo [name: default] 15 | 2024-03-29T15:16:33.724+09:00 INFO 28960 --- [main] org.hibernate.Version : HHH000412: Hibernate ORM core version 6.4.1.Final 16 | 2024-03-29T15:16:33.744+09:00 INFO 28960 --- [main] o.h.c.internal.RegionFactoryInitiator : HHH000026: Second-level cache disabled 17 | 2024-03-29T15:16:33.898+09:00 INFO 28960 --- [main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer 18 | 2024-03-29T15:16:33.933+09:00 WARN 28960 --- [main] org.hibernate.orm.deprecation : HHH90000025: H2Dialect does not need to be specified explicitly using 'hibernate.dialect' (remove the property setting and it will be selected by default) 19 | 2024-03-29T15:16:34.402+09:00 INFO 28960 --- [main] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) 20 | 2024-03-29T15:16:34.418+09:00 INFO 28960 --- [main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default' 21 | 2024-03-29T15:16:34.668+09:00 WARN 28960 --- [main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning 22 | 2024-03-29T15:16:34.679+09:00 WARN 28960 --- [main] .s.s.UserDetailsServiceAutoConfiguration : 23 | 24 | Using generated security password: dbe432b6-ca1f-485c-bd30-b883d805f6df 25 | 26 | This generated password is for development use only. Your security configuration must be updated before running your application in production. 27 | 28 | 2024-03-29T15:16:35.088+09:00 INFO 28960 --- [main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@7842260f, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@7cb4d4ee, org.springframework.security.web.context.SecurityContextHolderFilter@34dda1b4, org.springframework.security.web.header.HeaderWriterFilter@3c4231e5, org.springframework.web.filter.CorsFilter@44ec6637, org.springframework.security.web.authentication.logout.LogoutFilter@701c413, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@5cb4ba80, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@978475b, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@5a0b550a, org.springframework.security.web.access.ExceptionTranslationFilter@5ae22651, org.springframework.security.web.access.intercept.AuthorizationFilter@7a085d02] 29 | 2024-03-29T15:16:35.261+09:00 INFO 28960 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '' 30 | 2024-03-29T15:16:35.271+09:00 INFO 28960 --- [main] c.e.u.UrlShortenerApplication : Started UrlShortenerApplication in 2.956 seconds (process running for 3.495) 31 | 2024-03-29T15:16:35.274+09:00 INFO 28960 --- [main] c.e.u.UrlShortenerApplication : Application started at 2024-03-29T15:16:35.274112 32 | 2024-03-29T15:37:12.905+09:00 INFO 28960 --- [SpringApplicationShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default' 33 | 2024-03-29T15:37:12.914+09:00 INFO 28960 --- [SpringApplicationShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated... 34 | 2024-03-29T15:37:12.919+09:00 INFO 28960 --- [SpringApplicationShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed. 35 | -------------------------------------------------------------------------------- /study/chapter1.md: -------------------------------------------------------------------------------- 1 | 2 | # 1.1 스프링 부트 소개 3 | ## 1.1.1 왜 스프링 부트인가 4 | 스프링부트 = 스프링 프레임워크의 오픈소스 확장 기능 5 | 현장에서 쓸 수 있을 정도의 독립 실행형 애플리케이션 설정을 알아서 해줌 6 | - 생산성을 높혀준다고 이해하였음. 스프링보다 더 기능이 추가 된 것~ 7 | - 개발자가 보다 **비즈니스 로직**에만 집중 할 수 있게끔 도와준다 8 | ## 1.1.2 스프링 부트는 무엇인가 9 | 스프링 - 개발자 사이에 존재하는 계층![[스크린샷 2024-03-25 오후 4.51.12.png]] 10 | 11 | - 2014년 출시. 오래되지 않은 프레임워크 12 | - 개발자가 보다 비즈니스 로직에만 집중 할 수 있게끔 도와준다. 13 | ## 1.1.3 스프링 부트 핵심 기능 14 | - 빠른 시동 15 | - 자동 구성 16 | - 미리 정의된 방식 17 | - 독립 실행형 18 | - 실제 서비스 환경에 사용 가능 19 | 20 | ## 1.1.4 스프링 부트 컴포넌트 21 | 22 | > - 컴포넌트란? 23 | > 여러 개의 프로그램 함수들을 모아 하나의 특정한 기능을 수행할 수 있도록 구성한 작은 기능적 단위 24 | > 재사용이 가능한 각각의 독립된 모듈 25 | 26 | 27 | 28 | - 스프링부트 컴포넌트 종류![[스크린샷 2024-03-25 오후 5.02.14.png]] 29 | - spring-boot: 스프링부트 기본 컴포넌트 30 | - spring-boot-autoconfigure: 빈 생성 31 | - spring-boot-starter: 의존 관계 기술서 32 | - spring-boot-CLI: dependency를 메이븐이나 그레이들에서 안하고 그루비 코드를 컴파일할 수 있는 개발자 친화적 명령형 도구 라는데 난 잘 모르겠음 33 | - spring-boot-actuator: 스프링부트 애플리케이션을 모니터링하고 감시할 수 있는 액추에이터 34 | - spring-boot-actuator-autoconfigure: 35 | - spring-boot-test 36 | - spring-boot-test-autoconfigure: 37 | - spring-boot-loader: 38 | - spring-boot-devtools: 39 | 40 | 41 | # 1.2 코드 예제 42 | ## 1.2.1 ~~메이븐~~ vs. 그레이들 43 | 44 | 메이븐 45 | ![[Pasted image 20240325171739.png]] 46 | 47 | 그레이들 48 | ![[Pasted image 20240325171844.png]] 49 | 50 | 51 | > 왜 메이븐에서 그레이들로 갈아탈까? 52 | > 53 | > 그레이들이 가독성이 좋기 때문! 54 | > 55 | > 메이븐은 XML 형식인데, 계층 구조를 타고 타고 depth가 깊어지는 형식으로 가는 문서 형식이라서 가독성이 좋지 않다. 56 | 57 | ## 1.2.2 자바 vs. 코틀린 58 | 59 | 자바 코틀린 혼용이 한 프로젝트 안에서 가능하다고는 하나, 빈 생성이 안되어서 하나의 컨트롤러 단위로 혼용이 된다고 알고 있다. 60 | 61 | ~~코틀린 언어에 대한 수요가 많다면 스터디 개설을 고려해보겠다. 나는 코틀린을 좋아한다. 62 | 63 | ## 1.2.3 데이터베이스 지원 64 | 65 | - H2 인메모리 SQL 데이터베이스 66 | - MySQL이나 PostgreSQL을 사용해본 경험이 있음 67 | 68 | ## 1.2.4 롬복 69 | 어노테이션을 통해 POJO 클래스를 정의할 수 있게 도와준다. 70 | 71 | > POJO(Plain Old Java Object)란? 72 | > 즉, **Java로 생성하는 순수한 객체** 73 | > 74 | >토비 왈 **"진정한 POJO란 객체지향적인 원리에 충실하면서, 환경과 기술에 종속되지 않고 필요에 따라 재활용될 수 있는 방식으로 설계된 오브젝트이다"** 75 | 76 | ```java 77 | public class UserDTO { 78 | private String userName; 79 | private String userId; 80 | private String userPassword; 81 | 82 | public String getUserName() { return userName; } 83 | 84 | public void setUserName(String userName) { 85 | this.userName = userName; 86 | } 87 | 88 | public String getUserId() { return userId; } 89 | 90 | public void setUserId(String userId) { 91 | this.userId = userId; 92 | } 93 | 94 | public String getUserPassword() { 95 | return userPassword; 96 | } 97 | 98 | public void setUserPassword(String userPassword) { 99 | this.userPassword = userPassword; 100 | } 101 | } 102 | 103 | ``` 104 | 105 | 게터 세터를 다 구현한 모습이다. 하지만, 롬복 어노테이션을 통해 짧게 줄일 수 있다. (사견이지만, 이게 다 자바에 데이터 클래스가 없어서 발생한 일이 아닐까.. 코틀린에는 데이터 클래스가 있는데!) 106 | 107 | 그래서 롬복은? POJO 객체의 생성자, getter, setter, toString을 어노테이션 하나만 추가하면 자동으로 추가해준다!! 와아 108 | ![[스크린샷 2024-03-25 오후 7.33.59.png]] 109 | 110 | **@Data** 111 | ```java 112 | package com.example.demo.dto; 113 | 114 | 115 | public class Member { 116 | 117 | private Integer id; 118 | private String name; 119 | private Integer age; 120 | private String dept; 121 | 122 | public Member() { 123 | super(); // TODO Auto-generated constructor stub } 124 | 125 | public Integer getId() { return id; } 126 | 127 | public void setId(Integer id) { this.id = id; } 128 | 129 | public String getName() { return name; } 130 | 131 | public void setName(String name) { this.name = name; } 132 | 133 | public Integer getAge() { return age; } 134 | 135 | public void setAge(Integer age) { this.age = age; } 136 | 137 | public String getDept() { return dept; } 138 | 139 | public void setDept(String dept) { this.dept = dept; } @Override 140 | 141 | public String toString() { return "Member [id=" + id + ", name=" + name + ", age=" + age + ", dept=" + dept + "]"; } } 142 | ``` 143 | 144 | 145 | @Data를 붙이면 게터세터, 기본 생성자, toString이 붙는다. 146 | ```java 147 | package com.example.demo.dto; 148 | 149 | import lombok.Data; 150 | @Data 151 | public class Member { 152 | private Integer id; 153 | private String name; 154 | private Integer age; 155 | private String dept; 156 | } 157 | ``` 158 | 159 | **@AllArgsConstructor** 160 | @Data로는 파라미터를 가지지 않은 기본 생성자를 만든다. 161 | 하지만 모든 변수를 파라미터로 받는 생성자가 필요할 수 있다. 162 | 이 때에 @AllArgsConstructor을 사용한다. 163 | (단, 이 경우 @Data가 자동 생성한 기본 생성자는 사라진다. 164 |  양쪽을 모두 사용하고 싶다면 @NoArgsConstructor와 @AllArgsConstructor을 함께 사용해야한다.) 165 | 166 | ```java 167 | package com.example.demo.dto; 168 | 169 | import lombok.AllArgsConstructor; 170 | import lombok.Data; 171 | 172 | @Data 173 | @AllArgsConstructor 174 | public class Member { 175 | private Integer id; 176 | private String name; 177 | private Integer age; 178 | private String dept; 179 | } 180 | ``` 181 | 182 | 183 | >POJO가 아닌 것 184 | > 185 | >순수 자바가 아니라 WindowAdapter를 상속 받기 때문에, WindowAdapter가 바뀌면 안에 내용도 바뀜. 종속적이다. 186 | ```java 187 | public class WinExam extends WindowAdapter { 188 | @Override public void windowClosing(WindowEvent e) { 189 | System.exit(0); 190 | } 191 | } 192 | ``` 193 | 194 | 195 | 롬복으로 생성된 거나 직접 구현해서 작성된거나 작동은 동일하게 된다. 196 | 197 | 롬복은 서드파티인데, 서드파티를 사용하고 싶지 않다면 자바 레코드도 있다고 한다. 198 | 199 | 200 | 201 | 202 | # 1.3 스프링 부트 시작하기 203 | 204 | ## 1.3.1 첫 번째 스프링 부트 프로젝트 205 | 206 | https://start.spring.io/ 207 | 208 | 209 | ## 1.3.2 ~~스프링 부트 프로젝트 구조~~(메이븐 제외, 18 페이지 부터 정리) 210 | 211 | - 메인 클래스![[스크린샷 2024-03-25 오후 5.39.50.png]] 212 | 213 | 1. main() 214 | 별다른 war, ear 파일을 만들 필요 없이, main 메소드만 실행한다면 로컬에서 웹 애플리케이션을 돌려볼 수 있다. 215 | 기본적으로 서블릿 기반 웹 애플리케이션은 아파치 톰캣이나 제티와 같은 서블릿 컨테이너 위에서만 실행할 수 있다. 216 | 217 | 원래대로 라면 톰캣 서버 위에 우리가 만든 웹 애플리케이션을 올려야 작동하지만, 스프링부트와 함께라면! 이야기는 달라진다. 218 | 219 | - 요약 220 | main()을 통해 스프링부트 애플리케이션을 실행하면 스프링부트가 내장된 기본 서블릿 컨테이너인 톰캣 시작하고, 그 위에서 우리의 웹 애플리케이션을 실행한다. 221 | 2. @SpringBootAppLication 사용 222 | 아래의 3 어노테이션을 하나로 줄여 사용할 수 있다. 223 | 1. @EnableAutoConfiguration: jar 파일을 바탕으로 애플리케이션을 자동으로 구성해주는 스프링부트 자동 구성 기능을 활성화 합니다. 224 | 2. @ComponentScan: 애플리케이션에 있는 스프링 컴포넌트(빈, 컴포넌트 등)를 탐색하고 그것들을 자바 빈으로서 스프링으로 관리한다. 라이프 사이클도 따르며, 루트에서 시작해서 하위 패키지까지 모두 탐색한다. 225 | >빈이란? 226 | > 227 | >빈(Bean)은 스프링 컨테이너에 의해 관리되는 재사용 가능한 소프트웨어 컴포넌트이다. 즉, 스프링 컨테이너가 관리하는 자바 객체를 뜻하며, 하나 이상의 빈(Bean)을 관리한다. 228 | > 229 | >**빈은 인스턴스화된 객체를 의미하며, 스프링 컨테이너에 등록된 객체를 스프링 빈이라고 한다.** 230 | >@Bean 어노테이션을 통해 메서드로부터 반환된 객체를 스프링 컨테이너에 등록한다. 빈은 클래스의 등록 정보, Getter/Setter 메서드를 포함하며, 컨테이너에 사용되는 설정 메타데이터로 생성된다. 231 | 232 | 3. @SpringBootConfiguration: 스프링부트 애플리케이션 설정을 담당하는 클래스에 이 어노테이션을 붙임. -> 이 어노테이션 때문에 메인 클래스가 반드시 Root에 위치해야함. 그래야 루트부터 시작해서 다른 스프링 어노테이션이 붙어있는 컴포넌트들을 찾아서 로딩할 수가 있다. 233 | 234 | 3. SpringApplication클래스의 역할 235 | run()이 실행 될 때 수행되는 작업들 236 | 1. 클래스 패스에 있는 라이브러리를 기준으로 ApplicationContext 클래스 인스턴스를 생성한다. 237 | 2. CommandLinePropertySource를 등록해서 명령행 인자를 스프링 프로퍼티로 읽어 들인다. 238 | 3. 1에서 생성한 ApplicationContext를 통해 모든 싱글톤 빈을 로딩한다. 239 | 4. 애플리케이션에 설정된 ApplicationRunners와 commandRunner를 실행한다. 240 | 241 | >ApplicationContext를 싱글톤으로 만들어야한다. 242 | 싱글톤은 하나의 클래스에 오직 하나의 인스턴스만 가지는 패턴이고, 싱글톤을 하는 이유는, 간단히 말해서 객체를 생성할 때 비용이 들기 때문에, 그 비용을 줄이기 위해 하나의 인스턴스를 가지고 돌려 쓰려고 한다. 그러기 위해서 의존성 주입을 한다. 243 | 244 | SpringApplication클래스는 클래스 패스에 있는 jar 의존 관계를 바탕으로 ApplicationContext 인스턴스를 생성한다. 245 | 246 | 스프링 부트 웹 애플리케이션은 서블릿/리액티브 타입 중에 하나다. 247 | 스프링 클래스 패스에 있는 클래스를 바탕으로 어떤 타입인지 유추할 수 잇다. 248 | 249 | ApplicationContext를 로딩할 때 250 | - 서블릿 기반 일 때 AnnotationConfigServletWebServerApplicationContext 클래스 인스턴스 생성 251 | - 리액티브 일 때 AnnotationConfigReactiveWebServerApplicationContext 클래스 인스턴스 생성 252 | - 둘 다 아니면, AnnotationConfigApplicationContex 클래스 인스턴스 생성 253 | 254 | SpringApplication클래스안에 있는 main()은 정적 메서드이다. run()이 유용하긴 하지만, 개발자의 의도하는 바가 특별히 있다면, SpringApplication클래스 인스턴스를 직접 생성해서 동작되게 할 수도 있다. 255 | ![[스크린샷 2024-03-25 오후 7.30.04.png]] 256 | 직접 인스턴스를 생성하고, 리액티브 타입으로 설정하는 코드이다. 257 | 258 | 259 | - 애플리케이션 정보 관리 260 | src/main/resources 경로에 application.yml을 올린다. 261 | 262 | 주로 어떤 포트를 쓰는지, base url은 뭔지, jwt 키값, 토큰 유효 시간 등을 담아놓는다. 263 | 264 | ![[스크린샷 2024-03-25 오후 7.31.07.png]] 265 | 애플리케이션 설정을 담는데, 보통 gitinore해서 사용하거나, 특정 변수만 환경변수로 빼서 레포지토리에 올려서 사용할 수가 있다. 266 | 267 | yml에 있는 내용을 변경하고서 팀원들과 공유하지 않는다면, 왜 안되? 상황이 발생할 수 있다. 빌드가 안되기 때문! 268 | ## 1.3.3 실행 가능한 JAR 파일 269 | 270 | 서버 배포를 할 때 올리는게 jar 파일이다. 271 | 272 | java -jar 명령어를 통해 애플리케이션 실행을 할 수 있다. 273 | 274 | ## 1.3.4 JAR 파일 구조 275 | 276 | ![[스크린샷 2024-03-25 오후 7.41.39.png]] 277 | - META-INF: 매니패스트가 있다. 그 안에는 주요 파라미터인 MainClass와 StartClass가 들어있다. 278 | - 스프링부트 로더 컴포넌트: 실행가능한 파일을 로딩하는데 사용하는 로더의 구현체 279 | - BOOT-INF/classes: 컴파일된 모든 클래스 280 | - BOOT-INF/lib: 의존성 281 | 282 | 283 | ## 1.3.5 스프링 부트 애플리케이션 종료 284 | 285 | 286 | jar 파일을 프롬프트에서 종료하면 종료된다. 287 | 혹은 해당 프로세스 종료 288 | 289 | 안전 종료 설정 (스프링부트 2.3.0부터 도입 ) 290 | ``` 291 | server.shutdown=graceful //기본 값은 immediate 즉시 종료 292 | spring.lifecycle.timeout-per-shutdown-phase=1m //기본값은 30s 293 | 294 | ``` 295 | 296 | 297 | 298 | # 1.4 스프링 부트 기타 개념 299 | 300 | 생산성을 높이기 위해 여러 도구가 존재한다. 301 | ## 1.4.1 스프링 부트 스타트업 이벤트 302 | ## 1.4.2 스프링 부트 애플리케이션 이벤트 감지 303 | ## 1.4.3 커스텀 스프링 부트 스타터 304 | ## 1.4.4 커스텀 자동 구성 305 | ## 1.4.5 실패 분석기 306 | ## 1.4.6 스프링 부트 액추에이터 307 | ## 1.4.7 스프링 부트 개발자 도구 -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /[3주차] 프로퍼티: -------------------------------------------------------------------------------- 1 | ## 2장 학습 내용 2 | 3 | 8 | 9 | ## 2.1 애플리케이션 설정 관리 10 | 11 | `배포할 환경이 달라지면 설정 정보는 달라지지만, 애플리케이션 소스 코드는 거의 달라지지 않는다.` 12 | 13 | 스프링 부트는 `프로퍼티 파일, yaml 파일, 환경 변수, 명령행 인자` 등 여러 가지 방법으로 설정 정보를 외부화해서 소스 코드 변경 없이 환경마다 다르게 적용 14 | 15 | **스프링 부트에서 애플리케이션 설정 정보를 지정하는 다양한 방법에 대해 살펴본다.** 16 | 17 | ## 2.1.1 SpringApplication 클래스 사용 18 | 19 | https://github.com/spring-boot-in-practice/repo/tree/main/ch02/spring-application : 소스 코드 20 | 21 | ### **SpringApplication 클래스 :** 애플리케이션 설정 정보를 정의할 수 있다. 22 | 23 | - 설정 정보를 Properties나 Map에 넣어서 setDefaultProperties() 메서드를 호출 시 설정 정보가 애플리케이션에 적용된다. 24 | - 상황에 따라서 클래스파일에 설정 파일이 없을 때 예외를 던지는 등의 행위를 추가할 수 있다. 25 | 26 | ## 2.1.2 @PropertySource 사용 27 | 28 | ### 프로퍼티 파일의 위치를 @PropertySource 애너테이션을 사용해서 지정할 수 있다. 29 | 30 | 예) `@PropertySource("classpath:dbConfig-properties")` 스프링 환경 설정 클래스에 지정 31 | 32 | → 그 뒤, src/main/resources 디렉터리에 있는 파일은 실행가능한 JAR로 패키징된 후 클래스패스에 위치하게 되므로 이 디렉터리에 dbConfig.properties 파일을 작성 33 | 34 | - 스프링이 제공하는 환경 인스턴스 주입 후 [dbConfig.properties](http://dbConfig.properties) 파일에 지정된 설정 정보를 읽을 수 있다. 35 | 36 | ```java 37 | DbConfiguration dbConfiguration = applicationContext.getBean(DbConfiguration.class); 38 | log.info(dbConfiguration.toString()); 39 | ``` 40 | 41 | - 자바 8 이후로는 동일한 애너테이션을 여러 번 사용할 수 있으므로, 여러 프로퍼티 파일을 지정 후 사용 가능 42 | 43 | ## 2.1.3 환경 설정 파일 44 | 45 | ### `application.properties` //이니셜라이저로 생성시 기본적으로 함께 생성 46 | 47 | **key = value 형식** 48 | 49 | ``` 50 | server.port = 8081 51 | spring.datasource.username=sebin 52 | spring.datasource.password=hwangoni 53 | ``` 54 | 55 | ### `application.yml` //주로 사용 (YAML 문법 익숙 시) 56 | 57 | **들여쓰기 “매우” 중요** 58 | 59 | ``` 60 | server: 61 | port:8081 62 | spring : 63 | datasource: 64 | user: sebin 65 | password: hwangoni 66 | ``` 67 | 68 | 1. 파일에 명시된 설정 프로퍼티 정보는 스프링의 Environment 객체에 로딩 69 | 2. 애플리케이션 클래스에서 Environment 인스턴스에 접근해서 설정 정보 읽기 가능 70 | 3. @Value 애너테이션을 통해 접근 가능 71 | 72 | ### **스프링 부트가 읽는 application.properties / application.yml 파일 위치** 73 | 74 | 1. 클래스패스 루트 75 | 2. 클래스패스 /config 패키지 76 | 3. 현재 디렉터리 77 | 4. 현재 디렉터리 /config 디렉터리 78 | 5. /config 디렉터리의 바로 하위 디렉터리 79 | 80 | ### **spring.config.loaction 프로퍼티** 81 | 82 | - 사용 시 다른 위치에 있는 설정 파일 읽기 가능 83 | - 상대 경로, 절대 경로 명시 가능 84 | 85 | **번외) 설정 파일이 없을 시 애플리케이션 실행하기** 86 | 87 | spring.config.location 프로퍼티값 앞에 `optional` 접두어 붙어주기 88 | 89 | ``` 90 | java -jar target/config-data-file-0.0.1-SNAPSHOT.jar --spring.config.location=optional:data 91 | /sbip1.yml 92 | ``` 93 | 94 | ### 명령행 인자 95 | 96 | 스프링 부트 애플리케이션을 JAR 파일로 만든 후에 애플리케이션 실행 시 명령행 인자로 설정 정보를 지정 가능 97 | 98 | ### 프로파일(profile)별 설정 정보 99 | 100 | - 프로파일별로 프로퍼티 파일을 다르게 지정해서 사용 가능 101 | - 애플리케이션 설정의 일부를 분리해서 환경별로 다르게 적용 102 | 103 | ex) `application- dev.properties, application-test.properties` 104 | 105 | **설정 파일 로딩 순서** 106 | 107 | 1. 애플리케이션 JAR 파일 **안에** 패키징 되는 **application.properties( 또는yml)** 파일 108 | 2. 애플리케이션 JAR 파일 **안에** 패키징 되는 **application-{profile}.properties(또는yml)**파일 109 | 3. 애플리케이션 JAR 파일 **밖에서** 패키징 되는 **application.properties( 또는yml)** 파일 110 | 4. 애플리케이션 JAR 파일 **밖에서** 패키징 되는 **application-{profile}.properties(또는yml)**파일 111 | 112 | ## 2.1.4 운영 체제 환경 변수 113 | 114 | 118 | 119 | ``` 120 | app.timeout =${APP _TIMEOUT} 121 | ``` 122 | 123 | **윈도우** 124 | 125 | ``` 126 | set = 127 | ``` 128 | 129 | **리눅스** 130 | 131 | ``` 132 | export = 133 | ``` 134 | 135 | ⚠️이 방식으로 지정한 환경 변수는 해당 명령행 터미널 세션에서만 유효 136 | 137 | **아래 로그를 통해 확인할 수 있다.** 138 | 139 | ```java 140 | log. info("Configured application timeout value: " + env.getProperty ("app. timeout")); 141 | ``` 142 | 143 | 기본 설정 값을 application.properties 파일에 작성하는 것이 스프링 부트의 공통관례 144 | 145 | → 필요하다면 환경 변수로부터 읽은 값으로 덮어쓸 수 있다. 146 | 147 | ### 동일한 프로퍼티가 여러 곳에 존재할 때의 우선순위 148 | 149 | 1. SpringAppilcation 150 | 2. @PropertySource 151 | 3. 설정정보파일(application.properties) 152 | 4. 운영체제환경변수 153 | 5. 명령행인자 (가장 높은 우선순위) 154 | 155 | ### 2.1절 정리 : 스프링 부트 애플리케이션에서 설정 정보를 지정하는 방법 156 | 157 | - 빌트인 프로퍼티 (스프링 부트에서 제공) 158 | - 다양한 기능에 대한 굉장히 많은 빌트인 프로퍼티 제공 159 | - HTTP 포트 지정 : server.port 160 | - 커스텀 프로퍼티 161 | 162 | ## 2.2 @ConfigurationProperties로 커스텀 프로퍼티 만들기 163 | 164 | `커스텀 프로퍼티 만들고 사용하는 방법` 165 | 166 | → 필요한 만큼 얼마든지 만들어 사용할 수 있으며, 커스텀 프로퍼티가 런타임에 로딩되는 것을 스프링 부트가 보장 167 | 168 | 2.1절에서는 프로퍼티는 스프링의 Environment 인스턴스에 바인딩 되고, Environment 인스턴스를 주입받으면 프로퍼티값 읽고 사용 169 | 170 | → 단점 존재 171 | 172 | - 프로퍼티 값의 타입 안전성( type-safety)이 보장되지 않으며 이로 인해 런타임 에러가 발생 할 수 있다. 예를 들어 URL이나 이메일 주소를 프로퍼티로 사용할 때 유효성 검증(validation)을 수행할 수 없다. 173 | - 프로퍼티 값을 일정한 단위로 묶어서 읽을 수 없고, @Value 애너테이션이나 스프링의 Environment 인스턴스를 사용해서 하나하나 개별적으로만 읽을 수 있다. 174 | 175 | ⇒ 스프링 부트에서 프로퍼티의 타입 안정성을 보장하고 유효성을 검증하는 방법 사용 176 | 177 | ## 2.2.1 기법: @ConfigurationProperties를 사용한 커스텀 프로퍼티 정의 178 | 179 | 186 | 187 | **프로퍼티 정보를 담을 수 있는 클래스** 188 | 189 | ```java 190 | package com.manning.sbip.choz.configurationproperties; 191 | import java.util.List; 192 | import org.springframework.boot.context. properties.ConfigurationProperties; 193 | import org.springframework.boot.context. properties.Constructorinding; 194 | 195 | @ConstructorBinding 196 | @ConfigurationProperties("app.sbip.ct"') 197 | public class ApProperties { 198 | //name, ip, port 프로퍼티 이름 정의 199 | private final String name; 200 | private final String ip; 201 | private final int port; 202 | //보안 관련 프로 퍼티 정적 클래스 Security 203 | private final Security security; 204 | 205 | public String getName (){ 206 | return name; 207 | } 208 | 209 | public String getIp() { 210 | return ip; 211 | } 212 | 213 | public int getPort() { 214 | return port; 215 | } 216 | public Security getSecurity(){ 217 | return security; 218 | } 219 | 220 | public AppProperties(String name, String ip, int port, Security security) { 221 | this.name = name; 222 | this.ip = ip; 223 | this.port = port; 224 | this.security = security; 225 | } 226 | 227 | @Override 228 | public String toString() { 229 | return "Approperties{" + 230 | "name='" + name + ' \ ' + 231 | ",ip='" +ip +'\'' + 232 | ", port='" + port + ' \'' + 233 | ", security=" + security + 234 | '}'; 235 | } 236 | 237 | public static class Security { 238 | 239 | private boolean enabled; 240 | 241 | private final String token; 242 | 243 | private final List roles; 244 | 245 | public Security(boolean enabled, String token, List roles) { 246 | this.enabled = enabled; 247 | this.token = token; 248 | this.roles = roles; 249 | } 250 | 251 | public boolean isEnabled(){ 252 | return enabled; 253 | } 254 | 255 | public String getToken(){ 256 | return token; 257 | } 258 | 259 | public List getRoles() { 260 | return roles; 261 | } 262 | 263 | @Override 264 | public String toString(){ 265 | return "Security{" + 266 | "enabled=" + enabled + 267 | ",token= '" +token +'\'' + 268 | ", roles=" + roles+ 269 | '}'; 270 | 271 | } 272 | } 273 | } 274 | ``` 275 | 276 | **프로퍼티 정보를 담을 수 있는 클래스를 사용해서 프로퍼티를 읽는 클래스 (AppService)** 277 | 278 | ```java 279 | package com.manning.sbip.ch02; 280 | 281 | //import 문 생략 282 | @Service 283 | public class AppService { 284 | 285 | private final Approperties approperties; 286 | 287 | @Autowired 288 | public AppService(Approperties approperties) { 289 | this.appProperties = appProperties; 290 | } 291 | 292 | public AppProperties getAppProperties() { 293 | return this.appProperties; 294 | } 295 | } 296 | ``` 297 | 298 | 스프링 부트가 application.properties에 있는 프로퍼티를 읽어서 유효성 검증을 수행하고 AppProperties객체에 프로퍼티값을 넣어준다. 299 | 300 | **서비스 클래스를 사용해서 AppProperties 객체에 접근하여 프로퍼티 값을 사용하는 스프링 부트 애플리케이션** 301 | 302 | ```java 303 | package com.manning.sbip.ch82; 304 | //import 문 생략 305 | @SpringBootApplication 306 | @EnableConfigurationProperties(Approperties.class) 307 | //@ConfigurationProperties 애너테이션이 붙어있는 클래스를 스프링 컨테이너에 등록 308 | //단점 : @ConfigurationProperties 애너테이션이 붙어 있는 클래스를 자동 탐색해서 등록해주는 것이 아니라 직접 명시해줘야 한다. 309 | 310 | public class SpringbootAppDemoApplication { 311 | 312 | private static final Logger log = 313 | LoggerFactory.getLogger(SpringBootAppDemoApplication.class); 314 | 315 | public static void main (String[] args){ 316 | ConfigurablepplicationContext applicationContext = 317 | SpringApplication.run(SpringBootAppDemoApplication.class, args); 318 | 319 | AppService appService= 320 | applicationContext.getBean(AppService.class); 321 | 322 | log.info(appService. getAppProperties).toString); 323 | } 324 | } 325 | ``` 326 | 327 | **@EnableConfigurationProperties 대신에 @ConfigurationPropertiesScan 애너테이션을 사용 시** 328 | 329 | → 지정 패키지 하위에 있는 @ConfigurationProperties가 붙어 있는 클래스를 모두 탐색해서 스프링 컨테이너에 등록해준다. 330 | 331 | → @Component 같은 애터네이션이 붙은 클래스가 아니라 @ConfigurationProperties가 붙어 있는 클래스만 탐색해서 등록 332 | 333 | ### @ConfigurationProperties 334 | 335 | - 설정 정보를 외부화 336 | - 타임 안정성 확보 337 | - 구조화된 방식으로 구조화된 방식으로 관리 338 | - 클래스에 붙일 수 있다. 339 | - @Configuration 클래스안에서 빈을 생성하는 @Bean 메서드에도 붙일 수 있다. 340 | 341 | **프로퍼티 값을 클래스에 바인딩하는 작업** 342 | 343 | - 세터 메스드로 수행 가능 344 | - 생성자 바인딩 방식으로 수행 가능 345 | 346 | ### @ConstructorBinding 347 | 348 | - POJO 클래스에 사용 시 생성자를 통해 프로퍼티 정보값이 설정 349 | - 클래스에 붙일 수 있다. 350 | - 생성자에 붙일 수 있다. 351 | - 생성자 바인딩 대신에 세터 메서드를 사용하는 세터 바인딩 방식으로 프로퍼티값을 설정 가능 352 | - 설정 정보 클래스에 불변성을 보장하기 위해 세터 메서드를 추가하지 말고 @ConstructorBinding 애너테이션 사용 → 프로퍼티값이 생성자를 통해 POJO 샛체에 설정된 후에 설정값이 변경될 수 없다. 353 | 354 | ## 2.3 스프링 부트 애플리케이션 시작 시 코드 실행 355 | 356 | 스프링 부트 애플리케이션을 시작할 때 **특정 코드를 실행**해야 할 때 357 | 예) 358 | → 애플리케이션 초기화가 완료되기 전에 데이터베이스 초기화 스크립트 실행 359 | → 외부 REST 서비스를 호출해서 데이터 가져오기 360 | 361 | ⇒ `CommandLineRunner`와 `ApplicationRunner` 인터페이스를 구현해서 빈으로 등록해두면 스프링 부트 애플리케이션 초기화 완료 직전에 run() 메서드가 실행된다. 362 | (두 인터페이스는 매우 유사하다) 363 | 364 | ### 2.3.1 기법 : 스프링 부트 애플리케이션 시작 시 CommandLineRunner로 코드 실행 365 | 366 | 374 | 375 | ### 1) 스프링 부트 메인 클래스가 CommandLineRunner 인터페이스를 구현 376 | 377 | CommandLineRunner 구현체를 한 개만 정의할 수 있고, 실행 순서를 지정할 수 없다는 점에서 제한적이다. 378 | 379 | ```java 380 | package com.manning.sbip.ch02; 381 | //import문 생략 382 | 383 | @SpringBootApplication 384 | public class CourseTrackerApplication implements CommandLineRunner { 385 | 386 | protected final Log logger = LogFactory getLog(getClass()); 387 | 388 | public static void main(String[] args) { 389 | SpringApplication.run(CourseTrackerApplication.class,args); 390 | } 391 | 392 | @Override 393 | public void run(String ... args) throws Exception { //run() 메서드를 구현해서 콘솔에 로그 출력 394 | logger.info("CourseTrackerApplication CommandLineRunner has executed"); 395 | } 396 | ``` 397 | 398 | ### 2) CommandLineRunner 구현체에 @Bean을 붙여서 빈으로 정의 399 | 400 | 상황에 맞게 유연하게 사용할 수 있고 실행 순서도 지정 가능 401 | 402 | ```java 403 | package com.manning.sbip.ch02; 404 | 405 | //import 문 생략 406 | 407 | @SpringBootApplication 408 | public class CourseTrackerApplication implements CommandLineRunner { 409 | 410 | protected final Logger logger= LoggerFactory.getLogger(getClass()); 411 | 412 | public static void main(String[] args) { 413 | SpringApplication.run(CourseTrackerApplication.class, args); 414 | } 415 | 416 | @Bean //애플리케이션이 시작되면 빈이 로딩되면서 콘솔에 로그를 출력 417 | //장점 : CommandLineRunner 인터페이스를 구현할 필요가 없다 418 | public CommandLineRunner commandLineRunner(){ 419 | return args -> { 420 | logger.info("CommandLineRunner executed as a bean definition with " + args. 421 | length + " arguments"); 422 | for (int i= 0; i < args.length ; i++) { 423 | logger.info("Argument: " + args[i]); 424 | } 425 | }; 426 | } 427 | } 428 | ``` 429 | 430 | --- 431 | 432 | ComandLineRunner 구현체를 스프링 부트 메인 클래스에 작성 433 | 434 | @Component 애너테이션이 붙어 있는 별도의 클래스에 작성 가능 435 | 436 | → 스프링 부트 메인 클래스가 ComandLineRunner 관련 코드로 인해 복잡해지는 부작용을 막을 수 있다. 437 | 438 | --- 439 | 440 | ### @Bean과 @Component 441 | 442 | **공통점** : 스프링에 의해 빈으로 등록된다. 443 | 444 | **@Bean** : 빈으로 등록하고자 하는 클래스의 소스 코드에 직접 접근할 수 없을 때는 해당 클래스의 인스턴스를 반환하는 메서드를 작성하고 @Bean 애너테이션을 붙여서 빈으로 등록 445 | 446 | **@Component** : 빈으로 등록하고자 하는 클래스의 소스 코드에 직접 접근할 수 있을 때는 @Component 애너테이션을 붙이면 빈으로 등록 447 | 448 | --- 449 | 450 | ### 3) CommandLineRunner 구현체에 @Component를 붙여서 스프링 컴포넌트로 정의 451 | 452 | 상황에 맞게 유연하게 사용할 수 있고 실행 순서도 지정 가능 453 | 454 | CommandLineRunner 구현체를 별도의 클래스로 작성할 수 있어 더 나은 코드를 작성 가능 455 | 456 | ```java 457 | package com.manning.sbip.chez.commandline; 458 | // import 문 생략 459 | 460 | @Order (1) // 컴포넌트의 순서 정의 지정 숫자가 낮을 수록 우선순위가 높다. 461 | @Component 462 | public class MyCommandLineRunner implements CommandLineRunner { 463 | 464 | protected final Logger logger = LoggerFactory.getLogger(getClass()); 465 | 466 | @Override 467 | public void run(String...args) throws Exception { 468 | logger. info("MyCommandLineRunner executed as a Spring Component"); 469 | } 470 | } 471 | //애플리케이션을 실행하면 스프링 부트 컴포넌트 탐색 기능을 통해 472 | //MyCommadLineRunner 컴포넌트 클래스의 인스턴스가 생성되고 빈으로 등록되며 로그가 콘솔에 표시 473 | ``` 474 | 475 | **CommandLineRunner** 구현체는 여러 개를 등록할 수 있으며 **@Order** 애너테이션으로 실행 순서를 정한다. 476 | 477 | ### CommandLineRunner 478 | 479 | - 애플리케이션 초기화를 위해 여러 작업을 수행해야 할 때 편리하게 사용 480 | - args 파라미터에 접근 가능 481 | 482 | → 외부에서 파라미터값을 다르게 지정하면서 원하는 대로 CommandLineRunner 구현체 제어 가능 483 | 484 | - 스프링의 의존 관계 주입으로 빈을 주입받아 사용할 수 있다. 485 | 486 | → CommandLineRunner 구현체는 스프링 부트 애플리케이션이 빈 등록을 포함한 초기화 과정 수행을 거의 다 마친 뒤에 실행 487 | 488 | → 어떤 빈이든 주입받아 사용 가능 489 | --------------------------------------------------------------------------------