diff --git a/README.md b/README.md index 5c097d95..f2e5b447 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ -# Redis_project +## 4주차 -커머스의 핵심 프로세스인 상품 조회 및 주문 과정에서 발생할 수 있는 동시성 이슈 해결 및 성능 개선을 경험하고, 안정성을 높이기 위한 방법을 배웁니다. \ No newline at end of file +#### Jacoco Test Report + +`build/jacocoTestReportHtml` 에 위치
+![Jacoco 커버리지 리포트](docs/images/jacoco.png) diff --git a/build.gradle b/build.gradle index c814892b..103494e3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,53 +1,112 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.4.3' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.4.3' + id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' } group = 'com.cinema' version = '1.0.0' allprojects { - bootJar{ - enabled = false - } + repositories { + mavenCentral() + } - jar{ - enabled = true - } + bootJar { + enabled = false + } + + jar { + enabled = true + } } subprojects { - apply plugin: 'java' - apply plugin: 'org.springframework.boot' - apply plugin: 'io.spring.dependency-management' - - java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } - } - - repositories { - mavenCentral() - } - - dependencies { - implementation 'org.springframework.boot:spring-boot-starter' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - } - - test { - useJUnitPlatform() - } - - bootJar{ - enabled = false - } - - jar{ - enabled = true - } + apply plugin: 'java' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + apply plugin: 'jacoco' + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + } + + dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + } + + test { + useJUnitPlatform() + } + + bootJar { + enabled = false + } + + jar { + enabled = true + } +} + +jacoco { + toolVersion = '0.8.12' } +// 커버리지 결과를 리포트로 생성하는 작업 +tasks.register('jacocoRootTestReport', JacocoReport) { + dependsOn subprojects.collect { + it.tasks.named('test') + } + + // 서브프로젝트의 .exec 파일을 포함 + executionData.from(fileTree(dir: '.', include: '**/build/jacoco/*.exec')) + executionData.from(fileTree(dir: '.', include: '**/**/build/jacoco/*.exec')) + + reports { + xml.required = true + html.required = true + csv.required = false + html.outputLocation = file('build/jacocoTestReportHtml') + xml.outputLocation = file('build/reports/jacoco/test/jacocoTestReport.xml') + } + + classDirectories.setFrom(files(subprojects.collect { + // 서브 프로젝트들의 컴파일된 클래스 파일이 저장되는 디렉토리 + it.sourceSets.main.output + })) + + finalizedBy 'jacocoTestVerification' +} + +// 커버리지 측정을 위한 조건을 명시하는 task +tasks.register('jacocoTestVerification') { + dependsOn 'jacocoRootTestReport' // 리포트 생성 후 실행 + + doLast { + // 커버리지 검증 로직 추가 + def coverageRules = [ + [ + enabled: true, + element: 'CLASS', + limits : [ + [counter: 'LINE', value: 'COVEREDRATIO', minimum: 0.0], + [counter: 'METHOD', value: 'COVEREDRATIO', minimum: 0.0] + ] + ] + ] + } +} + +test.dependsOn 'jacocoRootTestReport' + +tasks.withType(Test).configureEach { + useJUnitPlatform() + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(17) + } +} diff --git a/cinema-app-api/build.gradle b/cinema-app-api/build.gradle index edfda324..a553e6b2 100644 --- a/cinema-app-api/build.gradle +++ b/cinema-app-api/build.gradle @@ -4,6 +4,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + + implementation 'org.springframework.boot:spring-boot-starter-data-redis:3.4.0' + implementation 'org.springframework.boot:spring-boot-starter-aop' } bootJar { diff --git a/cinema-app-api/src/main/java/com/cinema/api/config/RateLimitScriptConfig.java b/cinema-app-api/src/main/java/com/cinema/api/config/RateLimitScriptConfig.java new file mode 100644 index 00000000..5e94dfa2 --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/config/RateLimitScriptConfig.java @@ -0,0 +1,25 @@ +package com.cinema.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +@Configuration +public class RateLimitScriptConfig { + @Bean(name = "searchRateLimitScript") + public DefaultRedisScript searchRateLimit() { + DefaultRedisScript redisScript = new DefaultRedisScript<>(); + redisScript.setLocation(new ClassPathResource("scripts/rate-limit-search.lua")); + redisScript.setResultType(Long.class); + return redisScript; + } + + @Bean(name = "reservationRateLimitScript") + public DefaultRedisScript reservationRateLimit() { + DefaultRedisScript redisScript = new DefaultRedisScript<>(); + redisScript.setLocation(new ClassPathResource("scripts/rate-limit-reservation.lua")); + redisScript.setResultType(Long.class); + return redisScript; + } +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/config/RedisConfig.java b/cinema-app-api/src/main/java/com/cinema/api/config/RedisConfig.java new file mode 100644 index 00000000..ea5276f1 --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/config/RedisConfig.java @@ -0,0 +1,22 @@ +package com.cinema.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } + +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ApiErrorCode.java b/cinema-app-api/src/main/java/com/cinema/api/support/ApiErrorCode.java deleted file mode 100644 index 69f31673..00000000 --- a/cinema-app-api/src/main/java/com/cinema/api/support/ApiErrorCode.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.cinema.api.support; - -import org.springframework.http.HttpStatus; - -public enum ApiErrorCode { - ILLEGAL_ARGUMENT_ERROR(HttpStatus.BAD_REQUEST, "CLIENT-01", "잘못된 요청입니다. 요청 내용을 다시 확인해주세요."), - NOT_FOUND(HttpStatus.NOT_FOUND, "CLIENT-02", "요청한 리소스를 찾을 수 없습니다."), - INVALID_ARGUMENT_ERROR(HttpStatus.BAD_REQUEST, "CLIENT-03", "올바르지 않은 요청입니다. 요청 내용을 다시 확인해주세요."), - INVALID_FORMAT_ERROR(HttpStatus.BAD_REQUEST, "CLIENT-04", "올바르지 않은 포맷입니다."), - INVALID_TYPE_ERROR(HttpStatus.BAD_REQUEST, "CLIENT-05", "올바르지 않은 타입입니다."), - INVALID_HTTP_METHOD(HttpStatus.METHOD_NOT_ALLOWED, "CLIENT-06", "잘못된 Http Method 요청입니다."), - - // 인증 및 권한 관련 에러 - AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH-01", "사용자 인증에 실패했습니다."), - ACCESS_DENIED(HttpStatus.FORBIDDEN, "AUTH-02", "접근이 거부되었습니다. 이 리소스에 대한 권한이 없습니다."), - INSUFFICIENT_PERMISSIONS(HttpStatus.FORBIDDEN, "AUTH-03", "작업을 수행할 권한이 부족합니다."), - LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH-04", "로그인에 실패했습니다."), - - // 토큰 관련 에러 - ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH-05", "엑세스 토큰의 유효기간이 만료되었습니다."), - REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH-06", "리프레시 토큰의 유효기간이 만료되었습니다."), - INVALID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "AUTH-07", "잘못된 토큰 형식입니다."), - INVALID_TOKEN_SIGNATURE(HttpStatus.BAD_REQUEST, "AUTH-08", "토큰의 서명이 일치하지 않습니다."), - UNSUPPORTED_TOKEN(HttpStatus.BAD_REQUEST, "AUTH-09", "토큰의 특정 헤더나 클레임이 지원되지 않습니다."), - ACCESS_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH-10", "쿠키에 엑세스 토큰이 없습니다."), - REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH-11", "쿠키에 리프레시 토큰이 없습니다."), - JWT_VALIDATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH-12", "JWT 토큰 만료 검사중 알 수 없는 서버 오류 발생"); - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - ApiErrorCode(HttpStatus httpStatus, String code, String message) { - this.httpStatus = httpStatus; - this.code = code; - this.message = message; - } - - public HttpStatus getHttpStatus() { - return httpStatus; - } - - public String getCode() { - return code; - } - - public String getMessage() { - return message; - } -} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ApiException.java b/cinema-app-api/src/main/java/com/cinema/api/support/ApiException.java deleted file mode 100644 index 35af4fdf..00000000 --- a/cinema-app-api/src/main/java/com/cinema/api/support/ApiException.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cinema.api.support; - -public class ApiException extends RuntimeException { - private final ApiErrorCode errorCode; - - public ApiException(ApiErrorCode errorCode) { - this.errorCode = errorCode; - } - - public ApiErrorCode getErrorCode() { - return errorCode; - } -} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ErrorResponse.java b/cinema-app-api/src/main/java/com/cinema/api/support/ErrorResponse.java deleted file mode 100644 index a946d154..00000000 --- a/cinema-app-api/src/main/java/com/cinema/api/support/ErrorResponse.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.cinema.api.support; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; - -/** - * @param errors 실패 시 에러 목록 List - */ -@JsonPropertyOrder({"code", "message", "errors"}) -public record ErrorResponse( - String code, - String message, - @JsonInclude(Include.NON_EMPTY) - T errors -) { - public ErrorResponse(String code, String message, T errors) { - this.code = code; - this.message = message; - this.errors = errors; - } - - public static ErrorResponse from(String code, String message) { - return new ErrorResponse<>(code, message, null); - } - - public static ErrorResponse from(String code, String message, T errors) { - return new ErrorResponse<>(code, message, errors); - } -} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/SystemErrorCode.java b/cinema-app-api/src/main/java/com/cinema/api/support/SystemErrorCode.java deleted file mode 100644 index 7a5bd27a..00000000 --- a/cinema-app-api/src/main/java/com/cinema/api/support/SystemErrorCode.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.cinema.api.support; - -import org.springframework.http.HttpStatus; - -public enum SystemErrorCode { - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SYSTEM-01", "서버 내부 오류가 발생했습니다. 다시 시도해주세요."), - SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "SYS-02", "현재 서비스 이용이 불가능합니다. 나중에 다시 시도해주세요."), - GATEWAY_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "SYS-03", "요청 시간이 초과되었습니다. 다시 시도해주세요."), - ; - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - SystemErrorCode(HttpStatus httpStatus, String code, String message) { - this.httpStatus = httpStatus; - this.code = code; - this.message = message; - } - - public HttpStatus getHttpStatus() { - return httpStatus; - } - - public String getCode() { - return code; - } - - public String getMessage() { - return message; - } -} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ValidationError.java b/cinema-app-api/src/main/java/com/cinema/api/support/ValidationError.java deleted file mode 100644 index 9c4d7394..00000000 --- a/cinema-app-api/src/main/java/com/cinema/api/support/ValidationError.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.cinema.api.support; - -public record ValidationError( - String field, - String message -) { -} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/exception/ApiErrorCode.java b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ApiErrorCode.java new file mode 100644 index 00000000..fd0ed60b --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ApiErrorCode.java @@ -0,0 +1,50 @@ +package com.cinema.api.support.exception; + +import org.springframework.http.HttpStatus; + +public enum ApiErrorCode { + ILLEGAL_ARGUMENT_ERROR(HttpStatus.BAD_REQUEST, "CLIENT-01", "잘못된 요청입니다. 요청 내용을 다시 확인해주세요."), + NOT_FOUND(HttpStatus.NOT_FOUND, "CLIENT-02", "요청한 리소스를 찾을 수 없습니다."), + INVALID_ARGUMENT_ERROR(HttpStatus.BAD_REQUEST, "CLIENT-03", "올바르지 않은 요청입니다. 요청 내용을 다시 확인해주세요."), + INVALID_FORMAT_ERROR(HttpStatus.BAD_REQUEST, "CLIENT-04", "올바르지 않은 포맷입니다."), + INVALID_TYPE_ERROR(HttpStatus.BAD_REQUEST, "CLIENT-05", "올바르지 않은 타입입니다."), + INVALID_HTTP_METHOD(HttpStatus.METHOD_NOT_ALLOWED, "CLIENT-06", "잘못된 Http Method 요청입니다."), + + // 인증 및 권한 관련 에러 + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH-01", "사용자 인증에 실패했습니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "AUTH-02", "접근이 거부되었습니다. 이 리소스에 대한 권한이 없습니다."), + INSUFFICIENT_PERMISSIONS(HttpStatus.FORBIDDEN, "AUTH-03", "작업을 수행할 권한이 부족합니다."), + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH-04", "로그인에 실패했습니다."), + + // 토큰 관련 에러 + ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH-05", "엑세스 토큰의 유효기간이 만료되었습니다."), + REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH-06", "리프레시 토큰의 유효기간이 만료되었습니다."), + INVALID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "AUTH-07", "잘못된 토큰 형식입니다."), + INVALID_TOKEN_SIGNATURE(HttpStatus.BAD_REQUEST, "AUTH-08", "토큰의 서명이 일치하지 않습니다."), + UNSUPPORTED_TOKEN(HttpStatus.BAD_REQUEST, "AUTH-09", "토큰의 특정 헤더나 클레임이 지원되지 않습니다."), + ACCESS_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH-10", "쿠키에 엑세스 토큰이 없습니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH-11", "쿠키에 리프레시 토큰이 없습니다."), + JWT_VALIDATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH-12", "JWT 토큰 만료 검사중 알 수 없는 서버 오류 발생"); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + ApiErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/exception/ApiException.java b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ApiException.java new file mode 100644 index 00000000..e5f1fc53 --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ApiException.java @@ -0,0 +1,13 @@ +package com.cinema.api.support.exception; + +public class ApiException extends RuntimeException { + private final ApiErrorCode errorCode; + + public ApiException(ApiErrorCode errorCode) { + this.errorCode = errorCode; + } + + public ApiErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/exception/ErrorResponse.java b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ErrorResponse.java new file mode 100644 index 00000000..49076599 --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ErrorResponse.java @@ -0,0 +1,30 @@ +package com.cinema.api.support.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +/** + * @param errors 실패 시 에러 목록 List + */ +@JsonPropertyOrder({"code", "message", "errors"}) +public record ErrorResponse( + String code, + String message, + @JsonInclude(Include.NON_EMPTY) + T errors +) { + public ErrorResponse(String code, String message, T errors) { + this.code = code; + this.message = message; + this.errors = errors; + } + + public static ErrorResponse from(String code, String message) { + return new ErrorResponse<>(code, message, null); + } + + public static ErrorResponse from(String code, String message, T errors) { + return new ErrorResponse<>(code, message, errors); + } +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ExceptionAdvice.java b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ExceptionAdvice.java similarity index 98% rename from cinema-app-api/src/main/java/com/cinema/api/support/ExceptionAdvice.java rename to cinema-app-api/src/main/java/com/cinema/api/support/exception/ExceptionAdvice.java index a95fecae..4c8a6966 100644 --- a/cinema-app-api/src/main/java/com/cinema/api/support/ExceptionAdvice.java +++ b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ExceptionAdvice.java @@ -1,4 +1,4 @@ -package com.cinema.api.support; +package com.cinema.api.support.exception; import com.cinema.core.support.exception.CoreErrorCode; import com.cinema.core.support.exception.CoreErrorStatus; @@ -35,6 +35,7 @@ public ResponseEntity handleCoreException(CoreException e) { case FORBIDDEN -> HttpStatus.FORBIDDEN; case NOT_FOUND -> HttpStatus.NOT_FOUND; case CONFLICT -> HttpStatus.CONFLICT; + case TOO_MANY_REQUEST -> HttpStatus.TOO_MANY_REQUESTS; }; log.error("[CoreException] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage()); diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/exception/SystemErrorCode.java b/cinema-app-api/src/main/java/com/cinema/api/support/exception/SystemErrorCode.java new file mode 100644 index 00000000..361a0330 --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/exception/SystemErrorCode.java @@ -0,0 +1,32 @@ +package com.cinema.api.support.exception; + +import org.springframework.http.HttpStatus; + +public enum SystemErrorCode { + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SYSTEM-01", "서버 내부 오류가 발생했습니다. 다시 시도해주세요."), + SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "SYS-02", "현재 서비스 이용이 불가능합니다. 나중에 다시 시도해주세요."), + GATEWAY_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "SYS-03", "요청 시간이 초과되었습니다. 다시 시도해주세요."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + SystemErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/exception/ValidationError.java b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ValidationError.java new file mode 100644 index 00000000..a7656f80 --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/exception/ValidationError.java @@ -0,0 +1,7 @@ +package com.cinema.api.support.exception; + +public record ValidationError( + String field, + String message +) { +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimit.java b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimit.java new file mode 100644 index 00000000..a7282e58 --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimit.java @@ -0,0 +1,44 @@ +package com.cinema.api.support.ratelimit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimit { + + /** + * 조회, 예약 등 제한 유형 + */ + RateLimitType type(); + + /** + * 최대 호출 가능 횟수 + */ + int limit(); + + /** + * 제한 시간 + */ + int ttl(); + + /** + * 제한 시간 단위 + */ + TimeUnit ttlTimeUnit() default TimeUnit.SECONDS; + + /** + * 금지 시간 + */ + int banTime() default 0; + + /** + * 금지 시간 단위 + */ + TimeUnit banTimeUnit() default TimeUnit.SECONDS; + + // ip, url은 따로 처리 +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimitAop.java b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimitAop.java new file mode 100644 index 00000000..507dea4e --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimitAop.java @@ -0,0 +1,57 @@ +package com.cinema.api.support.ratelimit; + +import com.cinema.core.support.exception.CoreErrorCode; +import com.cinema.core.support.exception.CoreException; +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.List; + +@Aspect +@Component +public class RateLimitAop { + private final List strategies; + private final HttpServletRequest request; + + public RateLimitAop(List strategyList, HttpServletRequest request) { + this.strategies = strategyList; + this.request = request; + } + + @Around("@annotation(com.cinema.api.support.ratelimit.RateLimit)") + public Object preHandleRateLimit(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + RateLimit rateLimit = method.getAnnotation(RateLimit.class); + + RateLimitType type = rateLimit.type(); + if (type != RateLimitType.SEARCH) return joinPoint.proceed(); + RateLimitStrategy strategy = findStrategy(type); + + boolean allowed = strategy.isAllowed(rateLimit, getRequestUrl(), getClientIp()); + if (!allowed) { + throw new CoreException(CoreErrorCode.TOO_MANY_REQUEST); + } + return joinPoint.proceed(); + } + + private String getClientIp() { + return request.getRemoteAddr(); // 프록시 서버는 고려 X + } + + private String getRequestUrl() { + return request.getRequestURI(); + } + + private RateLimitStrategy findStrategy(RateLimitType type) { + return strategies.stream() + .filter(s -> s.supports(type)) + .findFirst() + .orElseThrow(() -> new CoreException(CoreErrorCode.RATE_LIMIT_TYPE_NOT_FOUND)); + } +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimitStrategy.java b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimitStrategy.java new file mode 100644 index 00000000..0c5f149a --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimitStrategy.java @@ -0,0 +1,10 @@ +package com.cinema.api.support.ratelimit; + +import org.springframework.stereotype.Component; + +@Component +public interface RateLimitStrategy { + boolean isAllowed(RateLimit rateLimit, String url, String ip); + + boolean supports(RateLimitType type); +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimitType.java b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimitType.java new file mode 100644 index 00000000..a8ed873f --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/RateLimitType.java @@ -0,0 +1,6 @@ +package com.cinema.api.support.ratelimit; + +public enum RateLimitType { + SEARCH, + RESERVATION +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/ReservationRateLimitStrategy.java b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/ReservationRateLimitStrategy.java new file mode 100644 index 00000000..1114d9dc --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/ReservationRateLimitStrategy.java @@ -0,0 +1,44 @@ +package com.cinema.api.support.ratelimit; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +@Component +public class ReservationRateLimitStrategy implements RateLimitStrategy { + private final static String REQUEST_KEY = "request_ip"; + private final StringRedisTemplate redisTemplate; + private final DefaultRedisScript redisScript; + + public ReservationRateLimitStrategy(StringRedisTemplate redisTemplate, + @Qualifier("reservationRateLimitScript") DefaultRedisScript redisScript) { + this.redisTemplate = redisTemplate; + this.redisScript = redisScript; + } + + @Override + public boolean isAllowed(RateLimit rateLimit, String url, String ip) { + String requestKey = generateRequestKey(url, ip); + Long result = redisTemplate.execute( + redisScript, + Arrays.asList(requestKey), + String.valueOf(rateLimit.limit()), + TimeUnit.SECONDS.convert(rateLimit.ttl(), rateLimit.ttlTimeUnit()) + ); + + return result != null && result != -1; + } + + @Override + public boolean supports(RateLimitType type) { + return type == RateLimitType.RESERVATION; + } + + private String generateRequestKey(String url, String ip) { + return String.format("%s:%s:%s", REQUEST_KEY, url, ip); + } +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/SearchRateLimitStrategy.java b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/SearchRateLimitStrategy.java new file mode 100644 index 00000000..563709e7 --- /dev/null +++ b/cinema-app-api/src/main/java/com/cinema/api/support/ratelimit/SearchRateLimitStrategy.java @@ -0,0 +1,54 @@ +package com.cinema.api.support.ratelimit; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +@Component +public class SearchRateLimitStrategy implements RateLimitStrategy { + private final static String REQUEST_KEY = "request_ip"; + private final static String BAN_KEY = "banned_ip"; + private final RedisTemplate redisTemplate; + private final DefaultRedisScript redisScript; + + public SearchRateLimitStrategy( + RedisTemplate redisTemplate, + @Qualifier("searchRateLimitScript") DefaultRedisScript redisScript) { + this.redisTemplate = redisTemplate; + this.redisScript = redisScript; + } + + @Override + public boolean isAllowed(RateLimit rateLimit, String url, String ip) { + String requestKey = generateRequestKey(url, ip); + String banKey = generateBanKey(url, ip); + + Long result = redisTemplate.execute( + redisScript, + Arrays.asList(requestKey, banKey), + rateLimit.limit(), + TimeUnit.SECONDS.convert(rateLimit.ttl(), rateLimit.ttlTimeUnit()), + TimeUnit.SECONDS.convert(rateLimit.banTime(), rateLimit.banTimeUnit()) + ); + // System.out.println(result); + + return result != null && result != -1; + } + + @Override + public boolean supports(RateLimitType type) { + return type == RateLimitType.SEARCH; + } + + private String generateRequestKey(String url, String ip) { + return String.format("%s:%s:%s", REQUEST_KEY, url, ip); + } + + private String generateBanKey(String url, String ip) { + return String.format("%s:%s:%s", BAN_KEY, url, ip); + } +} diff --git a/cinema-app-api/src/main/java/com/cinema/api/v1/schedule/ScheduleController.java b/cinema-app-api/src/main/java/com/cinema/api/v1/schedule/ScheduleController.java index 00eb808d..eb507dc9 100644 --- a/cinema-app-api/src/main/java/com/cinema/api/v1/schedule/ScheduleController.java +++ b/cinema-app-api/src/main/java/com/cinema/api/v1/schedule/ScheduleController.java @@ -1,40 +1,50 @@ package com.cinema.api.v1.schedule; -import java.util.List; - +import com.cinema.api.support.ratelimit.RateLimit; +import com.cinema.api.support.ratelimit.RateLimitType; +import com.cinema.api.v1.schedule.dto.MovieWithSchedule; +import com.cinema.core.domains.movie.Genre; +import com.cinema.core.domains.schedule.Schedule; +import com.cinema.core.domains.schedule.ScheduleService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.cinema.api.v1.schedule.dto.MovieWithSchedule; -import com.cinema.core.domains.movie.Genre; -import com.cinema.core.domains.schedule.Schedule; -import com.cinema.core.domains.schedule.ScheduleService; +import java.util.List; +import java.util.concurrent.TimeUnit; @RestController @RequestMapping("/v1/schedule") public class ScheduleController { - private final ScheduleService scheduleService; + private final ScheduleService scheduleService; - public ScheduleController(ScheduleService scheduleService) { - this.scheduleService = scheduleService; - } + public ScheduleController(ScheduleService scheduleService) { + this.scheduleService = scheduleService; + } - @GetMapping() - public ResponseEntity> getOngoingSchedule( - @RequestParam(required = false) String title, - @RequestParam(required = false) Genre genre - ) { - isParamsValid(title, genre); - List response = scheduleService.getOngoingSchedule(title, genre); - return ResponseEntity.ok(MovieWithSchedule.from(response)); - } + @RateLimit( + type = RateLimitType.SEARCH, + limit = 50, + ttl = 1, + ttlTimeUnit = TimeUnit.MINUTES, + banTime = 1, + banTimeUnit = TimeUnit.HOURS + ) + @GetMapping() + public ResponseEntity> getOngoingSchedule( + @RequestParam(required = false) String title, + @RequestParam(required = false) Genre genre + ) { + isParamsValid(title, genre); + List response = scheduleService.getOngoingSchedule(title, genre); + return ResponseEntity.ok(MovieWithSchedule.from(response)); + } - private void isParamsValid(String title, Genre genre) { - if (title != null && title.length() > 255) { - throw new IllegalArgumentException("영화 제목은 255자를 넘길 수 없습니다."); - } - } + private void isParamsValid(String title, Genre genre) { + if (title != null && title.length() > 255) { + throw new IllegalArgumentException("영화 제목은 255자를 넘길 수 없습니다."); + } + } } diff --git a/cinema-app-api/src/main/resources/application.properties b/cinema-app-api/src/main/resources/application.properties deleted file mode 100644 index 6e8e92cb..00000000 --- a/cinema-app-api/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=cinema-app-api diff --git a/cinema-app-api/src/main/resources/application.yml b/cinema-app-api/src/main/resources/application.yml new file mode 100644 index 00000000..94afbf87 --- /dev/null +++ b/cinema-app-api/src/main/resources/application.yml @@ -0,0 +1,5 @@ +spring: + profiles: + include: + - core + - rds diff --git a/cinema-app-api/src/main/resources/scripts/rate-limit-reservation.lua b/cinema-app-api/src/main/resources/scripts/rate-limit-reservation.lua new file mode 100644 index 00000000..f372cf60 --- /dev/null +++ b/cinema-app-api/src/main/resources/scripts/rate-limit-reservation.lua @@ -0,0 +1,12 @@ +local key = KEYS[1] -- rate_limit:URL:IP +local limit = tonumber(ARGV[1]) -- 1회 제한 +local expire_time = tonumber(ARGV[2]) -- 300초 (5분) + +-- 5분 내에 요청한 이력이 있으면 return -1 +if redis.call('EXISTS', key) == 1 then + return -1 +end + +-- 요청 내역 저장 +redis.call('SETEX', key, expire_time, '1') +return 1 diff --git a/cinema-app-api/src/main/resources/scripts/rate-limit-search.lua b/cinema-app-api/src/main/resources/scripts/rate-limit-search.lua new file mode 100644 index 00000000..d67dd8db --- /dev/null +++ b/cinema-app-api/src/main/resources/scripts/rate-limit-search.lua @@ -0,0 +1,24 @@ +local key = KEYS[1] -- "request_ip:요청URL:127.0.0.1" +local ban_key = KEYS[2] -- "banned_ip:요청URL:127.0.0.1" +local limit = tonumber(ARGV[1]) -- 50회 제한 +local expire_time = tonumber(ARGV[2]) -- 제한 시간 : 60초 (1분) +local ban_duration = tonumber(ARGV[3]) -- 차단 시간 : 3600초 (1시간) + +-- 이미 차단되어 있으면 return -1 +if redis.call('EXISTS', ban_key) == 1 then + return -1 +end + +local current = redis.call('INCR', key) + +if current == 1 then + redis.call('EXPIRE', key, expire_time) +end + +if current > limit then + redis.call('SETEX', ban_key, ban_duration, '1') + return -1 +end + +return current + diff --git a/cinema-core/build.gradle b/cinema-core/build.gradle index ca383065..a864b87f 100644 --- a/cinema-core/build.gradle +++ b/cinema-core/build.gradle @@ -2,7 +2,7 @@ dependencies { implementation 'org.springframework:spring-context' implementation 'org.springframework:spring-tx' - implementation 'org.redisson:redisson-spring-boot-starter:3.18.0' + implementation 'org.redisson:redisson-spring-data-34:3.45.1' implementation 'org.springframework.boot:spring-boot-starter-aop' testRuntimeOnly 'com.h2database:h2' diff --git a/cinema-core/src/main/java/com/cinema/core/config/RedissonConfig.java b/cinema-core/src/main/java/com/cinema/core/config/RedissonConfig.java index 8361cf0d..7c087b8d 100644 --- a/cinema-core/src/main/java/com/cinema/core/config/RedissonConfig.java +++ b/cinema-core/src/main/java/com/cinema/core/config/RedissonConfig.java @@ -3,16 +3,17 @@ import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class RedissonConfig { - //@Value("${spring.data.redis.host}") - private String redisHost = "localhost"; + @Value("${spring.data.redis.host}") + private String redisHost; // = "localhost"; - // @Value("${spring.data.redis.port}") - private int redisPort = 6377; + @Value("${spring.data.redis.port}") + private int redisPort; //= 6377; private static final String REDISSON_HOST_PREFIX = "redis://"; diff --git a/cinema-core/src/main/java/com/cinema/core/domains/ticketing/TicketService.java b/cinema-core/src/main/java/com/cinema/core/domains/ticketing/TicketService.java index 1015a6af..18abe3b3 100644 --- a/cinema-core/src/main/java/com/cinema/core/domains/ticketing/TicketService.java +++ b/cinema-core/src/main/java/com/cinema/core/domains/ticketing/TicketService.java @@ -65,9 +65,10 @@ public void createTicketing(CreateTicketingCommand command) { .map(Seat::seatId) .toList(); String lockName = "lock:schedule:" + command.scheduleId().toString(); + // FIXME : AOP 분산락 //validateSeatBookable(command.scheduleId(), seatIds, lockName); - // 예약 및 ticket 생성 + // FIXME : 예약 및 ticket 생성 (낙관락, 비관락, AOP 분산락) //reservationRepository.reserve(command.userId(), command.scheduleId(), seatIds); //ticketRepository.save(command.userId(), command.scheduleId(), seats); diff --git a/cinema-core/src/main/java/com/cinema/core/support/exception/CoreErrorCode.java b/cinema-core/src/main/java/com/cinema/core/support/exception/CoreErrorCode.java index da4230db..be3d95b4 100644 --- a/cinema-core/src/main/java/com/cinema/core/support/exception/CoreErrorCode.java +++ b/cinema-core/src/main/java/com/cinema/core/support/exception/CoreErrorCode.java @@ -13,8 +13,10 @@ public enum CoreErrorCode { SEAT_NOT_IN_SEQUENCE(CoreErrorStatus.FORBIDDEN, "SEAT-03", "연속된 좌석이 아닙니다."), SEAT_NOT_FOUND(CoreErrorStatus.NOT_FOUND, "SEAT-04", "해당 상영관에 존재하지 않는 좌석입니다."), - // - ; + // rate limit + TOO_MANY_REQUEST(CoreErrorStatus.TOO_MANY_REQUEST, "RATE_LIMIT-01", "요청이 너무 많습니다. 나중에 다시 시도해주세요."), + RATE_LIMIT_TYPE_NOT_FOUND(CoreErrorStatus.NOT_FOUND, "RATE_LIMIT-02", "존재하지 않는 rate limit 전략입니다."); + private final CoreErrorStatus httpStatus; private final String code; diff --git a/cinema-core/src/main/java/com/cinema/core/support/exception/CoreErrorStatus.java b/cinema-core/src/main/java/com/cinema/core/support/exception/CoreErrorStatus.java index 9b772604..94709284 100644 --- a/cinema-core/src/main/java/com/cinema/core/support/exception/CoreErrorStatus.java +++ b/cinema-core/src/main/java/com/cinema/core/support/exception/CoreErrorStatus.java @@ -4,5 +4,6 @@ public enum CoreErrorStatus { BAD_REQUEST, FORBIDDEN, NOT_FOUND, - CONFLICT + CONFLICT, + TOO_MANY_REQUEST } diff --git a/cinema-core/src/main/java/com/cinema/core/support/aop/AopForTransaction.java b/cinema-core/src/main/java/com/cinema/core/support/lock/AopForTransaction.java similarity index 93% rename from cinema-core/src/main/java/com/cinema/core/support/aop/AopForTransaction.java rename to cinema-core/src/main/java/com/cinema/core/support/lock/AopForTransaction.java index 7f93c5e2..a4e389a3 100644 --- a/cinema-core/src/main/java/com/cinema/core/support/aop/AopForTransaction.java +++ b/cinema-core/src/main/java/com/cinema/core/support/lock/AopForTransaction.java @@ -1,4 +1,4 @@ -package com.cinema.core.support.aop; +package com.cinema.core.support.lock; import org.aspectj.lang.ProceedingJoinPoint; import org.springframework.stereotype.Component; diff --git a/cinema-core/src/main/java/com/cinema/core/support/aop/CustomSpringELParser.java b/cinema-core/src/main/java/com/cinema/core/support/lock/CustomSpringELParser.java similarity index 95% rename from cinema-core/src/main/java/com/cinema/core/support/aop/CustomSpringELParser.java rename to cinema-core/src/main/java/com/cinema/core/support/lock/CustomSpringELParser.java index 1bf8ec7f..fdd47ca0 100644 --- a/cinema-core/src/main/java/com/cinema/core/support/aop/CustomSpringELParser.java +++ b/cinema-core/src/main/java/com/cinema/core/support/lock/CustomSpringELParser.java @@ -1,4 +1,4 @@ -package com.cinema.core.support.aop; +package com.cinema.core.support.lock; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; diff --git a/cinema-core/src/main/java/com/cinema/core/support/aop/DistributedLockAop.java b/cinema-core/src/main/java/com/cinema/core/support/lock/DistributedLockAop.java similarity index 96% rename from cinema-core/src/main/java/com/cinema/core/support/aop/DistributedLockAop.java rename to cinema-core/src/main/java/com/cinema/core/support/lock/DistributedLockAop.java index 50ce895b..d37c1cb8 100644 --- a/cinema-core/src/main/java/com/cinema/core/support/aop/DistributedLockAop.java +++ b/cinema-core/src/main/java/com/cinema/core/support/lock/DistributedLockAop.java @@ -1,6 +1,5 @@ -package com.cinema.core.support.aop; +package com.cinema.core.support.lock; -import com.cinema.core.support.lock.DistributedLock; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; diff --git a/cinema-domain-rds/src/main/java/com/cinema/rds/domains/schedule/ScheduleCoreRepository.java b/cinema-domain-rds/src/main/java/com/cinema/rds/domains/schedule/ScheduleCoreRepository.java index 58708e69..41c30758 100644 --- a/cinema-domain-rds/src/main/java/com/cinema/rds/domains/schedule/ScheduleCoreRepository.java +++ b/cinema-domain-rds/src/main/java/com/cinema/rds/domains/schedule/ScheduleCoreRepository.java @@ -1,12 +1,5 @@ package com.cinema.rds.domains.schedule; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import org.springframework.stereotype.Repository; - import com.cinema.core.domains.movie.Genre; import com.cinema.core.domains.schedule.Schedule; import com.cinema.core.domains.schedule.ScheduleRepository; @@ -14,60 +7,67 @@ import com.cinema.rds.domains.screen.QScreenEntity; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; @Repository public class ScheduleCoreRepository implements ScheduleRepository { - private final ScheduleJpaRepository scheduleJpaRepository; - private final JPAQueryFactory queryFactory; - private final QScheduleEntity schedule = QScheduleEntity.scheduleEntity; - private final QMovieEntity movie = QMovieEntity.movieEntity; - private final QScreenEntity screen = QScreenEntity.screenEntity; + private final ScheduleJpaRepository scheduleJpaRepository; + private final JPAQueryFactory queryFactory; + private final QScheduleEntity schedule = QScheduleEntity.scheduleEntity; + private final QMovieEntity movie = QMovieEntity.movieEntity; + private final QScreenEntity screen = QScreenEntity.screenEntity; - public ScheduleCoreRepository(ScheduleJpaRepository scheduleJpaRepository, JPAQueryFactory queryFactory) { - this.scheduleJpaRepository = scheduleJpaRepository; - this.queryFactory = queryFactory; - } + public ScheduleCoreRepository(ScheduleJpaRepository scheduleJpaRepository, JPAQueryFactory queryFactory) { + this.scheduleJpaRepository = scheduleJpaRepository; + this.queryFactory = queryFactory; + } - /** - * 가장 최근 개봉한 영화 순으로 정렬한다. - * 시간 시간이 빠른 것부터 정렬된다. - * @return - */ - @Override - public List getSchedule(String title, Genre genre) { - LocalDateTime currentDate = LocalDate.now() - .atStartOfDay(); + /** + * 가장 최근 개봉한 영화 순으로 정렬한다. + * 시간 시간이 빠른 것부터 정렬된다. + * + * @return + */ + @Override + public List getSchedule(String title, Genre genre) { + LocalDateTime currentDate = LocalDate.of(2025, 1, 1) //LocalDate.now() + .atStartOfDay(); - List scheduleEntities = queryFactory.selectFrom(schedule) - .join(schedule.movie, movie) - .fetchJoin() - .join(schedule.screen, screen) - .fetchJoin() - .where(schedule.startAt.goe(currentDate), titleContains(title), genreEq(genre)) - .orderBy(movie.releasedAt.desc(), schedule.startAt.asc()) - .fetch(); + List scheduleEntities = queryFactory.selectFrom(schedule) + .join(schedule.movie, movie) + .fetchJoin() + .join(schedule.screen, screen) + .fetchJoin() + .where(schedule.startAt.goe(currentDate), titleContains(title), genreEq(genre)) + .orderBy(movie.releasedAt.desc(), schedule.startAt.asc()) + .fetch(); - return scheduleEntities.stream() - .map(ScheduleEntity::toSchedule) - .toList(); - } + return scheduleEntities.stream() + .map(ScheduleEntity::toSchedule) + .toList(); + } - @Override - public Optional findById(Long id) { - return scheduleJpaRepository.findById(id) - .map(ScheduleEntity::toSchedule); - } + @Override + public Optional findById(Long id) { + return scheduleJpaRepository.findById(id) + .map(ScheduleEntity::toSchedule); + } - private BooleanExpression titleContains(String title) { - return (title == null || title.isBlank()) ? null : movie.title.contains(title); - } + private BooleanExpression titleContains(String title) { + return (title == null || title.isBlank()) ? null : movie.title.contains(title); + } - private BooleanExpression titleEq(String title) { - return (title == null || title.isBlank()) ? null : movie.title.eq(title); - } + private BooleanExpression titleEq(String title) { + return (title == null || title.isBlank()) ? null : movie.title.eq(title); + } - private BooleanExpression genreEq(Genre genre) { - return genre == null ? null : movie.genre.eq(genre); - } + private BooleanExpression genreEq(Genre genre) { + return genre == null ? null : movie.genre.eq(genre); + } } diff --git a/cinema-domain-rds/src/main/resources/application.yml b/cinema-domain-rds/src/main/resources/application-rds.yml similarity index 100% rename from cinema-domain-rds/src/main/resources/application.yml rename to cinema-domain-rds/src/main/resources/application-rds.yml diff --git a/docker/redis_data/dump.rdb b/docker/redis_data/dump.rdb index c70e5b7b..882ebe02 100644 Binary files a/docker/redis_data/dump.rdb and b/docker/redis_data/dump.rdb differ diff --git a/docs/images/jacoco.png b/docs/images/jacoco.png new file mode 100644 index 00000000..71a1675d Binary files /dev/null and b/docs/images/jacoco.png differ diff --git a/http/ticketing.http b/http/ticketing.http index 7e913a20..32412dd1 100644 --- a/http/ticketing.http +++ b/http/ticketing.http @@ -4,7 +4,7 @@ Content-Type: application/json { "userId": 1, - "scheduleId": 10, + "scheduleId": 2, "seats": [ "A1", "A2",