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` 에 위치
+
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",