From 763580f51da784b4622dafb104129d5f8ab2aa48 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Tue, 7 Jan 2025 23:30:26 +0900 Subject: [PATCH 01/29] first commit --- .gitattributes | 12 ++ .gitignore | 37 ++++ build.gradle | 46 +++++ gradle.properties | 4 + gradle/libs.versions.toml | 2 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 +++++++++++++++++++++++ gradlew.bat | 94 +++++++++ settings.gradle | 1 + 10 files changed, 454 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..f91f64602 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c2065bc26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..25fbae6aa --- /dev/null +++ b/build.gradle @@ -0,0 +1,46 @@ +plugins { + id 'java' // 모든 모듈에 공통적으로 적용 + id 'org.springframework.boot' version '3.4.1' apply false // 서브모듈에서 선택적으로 활성화 + id 'io.spring.dependency-management' version '1.1.7' apply false // 서브모듈에서 의존성 관리 +} + +group = 'project.redis' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +allprojects { + repositories { + mavenCentral() + } +} + +subprojects { + apply plugin: 'java' + apply plugin: 'io.spring.dependency-management' + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + dependencies { + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + } + + + tasks.named('test') { + useJUnitPlatform() // JUnit Platform 사용 + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..24a59763f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..4ac3234a6 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,2 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..cea7a793a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..f3b75f3b0 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..c58079dd4 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'redis_1st' From 4514acfedc494069ba3819ea496944b6033bb27e Mon Sep 17 00:00:00 2001 From: hongs429 Date: Wed, 8 Jan 2025 22:05:04 +0900 Subject: [PATCH 02/29] =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=EA=B3=BC=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 17 ++++++++-------- module-application/build.gradle | 30 ++++++++++++++++++++++++++++ module-infrastructure/build.gradle | 32 ++++++++++++++++++++++++++++++ module-presentation/build.gradle | 29 +++++++++++++++++++++++++++ settings.gradle | 4 ++++ 5 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 module-application/build.gradle create mode 100644 module-infrastructure/build.gradle create mode 100644 module-presentation/build.gradle diff --git a/build.gradle b/build.gradle index 25fbae6aa..1d75c56d8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,19 +1,20 @@ plugins { - id 'java' // 모든 모듈에 공통적으로 적용 - id 'org.springframework.boot' version '3.4.1' apply false // 서브모듈에서 선택적으로 활성화 - id 'io.spring.dependency-management' version '1.1.7' apply false // 서브모듈에서 의존성 관리 + id 'java' + id 'org.springframework.boot' version '3.4.1' apply false + id 'io.spring.dependency-management' version '1.1.7' apply false } group = 'project.redis' version = '0.0.1-SNAPSHOT' -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} allprojects { + java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + } + repositories { mavenCentral() } diff --git a/module-application/build.gradle b/module-application/build.gradle new file mode 100644 index 000000000..2271f64bd --- /dev/null +++ b/module-application/build.gradle @@ -0,0 +1,30 @@ +plugins { + id 'org.springframework.boot' +} + +jar { + enabled = true +} + +bootJar { + enabled = false +} + +bootRun { + enabled = false +} + + +group = 'project.redis.application' +version = '0.0.1-SNAPSHOT' + +dependencies { + implementation project(':module-infrastructure') + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle new file mode 100644 index 000000000..c9e5362f2 --- /dev/null +++ b/module-infrastructure/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'org.springframework.boot' +} + +jar { + enabled = true +} + +bootJar { + enabled = false +} + +bootRun { + enabled = false +} + + +group = 'project.redis.infrastructure' +version = '0.0.1-SNAPSHOT' + + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter' + runtimeOnly 'com.mysql:mysql-connector-j' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/module-presentation/build.gradle b/module-presentation/build.gradle new file mode 100644 index 000000000..8c16a36d2 --- /dev/null +++ b/module-presentation/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'org.springframework.boot' +} + +jar { + enabled = false +} + +bootJar { + enabled = true +} + +bootRun { + enabled = true +} + + +group = 'project.redis.presentation' +version = '0.0.1-SNAPSHOT' + +dependencies { + implementation project(':module-application') + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index c58079dd4..4412b1bb2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,5 @@ rootProject.name = 'redis_1st' +include 'module-application' +include 'module-presentation' +include 'module-infrastructure' + From c2b9f7437654171d1d4979e4223147c1b66f5566 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Wed, 8 Jan 2025 22:13:32 +0900 Subject: [PATCH 03/29] =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20&&=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=EC=9D=98=20=EA=B4=80=EA=B3=84=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 7 ++--- gradle/wrapper/gradle-wrapper.properties | 2 +- .../redis/application/domain/Cinema.java | 20 +++++++++++++ .../redis/application/domain/Genre.java | 19 ++++++++++++ .../redis/application/domain/Movie.java | 30 +++++++++++++++++++ .../domain/RatingClassification.java | 18 +++++++++++ .../redis/application/domain/Screening.java | 26 ++++++++++++++++ .../redis/application/domain/Seat.java | 21 +++++++++++++ 8 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 module-application/src/main/java/project/redis/application/domain/Cinema.java create mode 100644 module-application/src/main/java/project/redis/application/domain/Genre.java create mode 100644 module-application/src/main/java/project/redis/application/domain/Movie.java create mode 100644 module-application/src/main/java/project/redis/application/domain/RatingClassification.java create mode 100644 module-application/src/main/java/project/redis/application/domain/Screening.java create mode 100644 module-application/src/main/java/project/redis/application/domain/Seat.java diff --git a/gradle.properties b/gradle.properties index 24a59763f..0d78d9440 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,3 @@ -org.gradle.configuration-cache=true -org.gradle.parallel=true -org.gradle.caching=true - +#org.gradle.configuration-cache=true +#org.gradle.parallel=true +#org.gradle.caching=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cea7a793a..e2847c820 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/module-application/src/main/java/project/redis/application/domain/Cinema.java b/module-application/src/main/java/project/redis/application/domain/Cinema.java new file mode 100644 index 000000000..3a01961c6 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Cinema.java @@ -0,0 +1,20 @@ +package project.redis.application.domain; + + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Cinema { + UUID cinemaId; + String cinemaName; + + public static Cinema generateCinema(UUID cinemaId, String cinemaName) { + return new Cinema(cinemaId, cinemaName); + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/Genre.java b/module-application/src/main/java/project/redis/application/domain/Genre.java new file mode 100644 index 000000000..97b506cc5 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Genre.java @@ -0,0 +1,19 @@ +package project.redis.application.domain; + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Genre { + UUID genreId; + String genreName; + + public static Genre generateGenre(UUID genreId, String genreName) { + return new Genre(genreId, genreName); + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/Movie.java b/module-application/src/main/java/project/redis/application/domain/Movie.java new file mode 100644 index 000000000..1181be020 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Movie.java @@ -0,0 +1,30 @@ +package project.redis.application.domain; + + +import java.time.LocalDate; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Movie { + UUID movieId; + String title; + RatingClassification rating; + LocalDate releaseDate; + String thumbnailUrl; + int runningTime; + UUID genreId; + + public static Movie generateMovie( + UUID id, String title, + RatingClassification rating, LocalDate releaseDate, + String thumbnailUrl, int runningTime, UUID genreId + ) { + return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningTime, genreId); + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/RatingClassification.java b/module-application/src/main/java/project/redis/application/domain/RatingClassification.java new file mode 100644 index 000000000..46722a273 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/RatingClassification.java @@ -0,0 +1,18 @@ +package project.redis.application.domain; + +import lombok.Getter; + +@Getter +public enum RatingClassification { + ALL("전체관람가"), + TWELVE("12세 이상 관람가"), + FIFTEEN("15세 이상 관람가"), + NINETEEN("19세 이상 관림가"), + RESTRICT("제한상영가"); + + private final String description; + + RatingClassification(String description) { + this.description = description; + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/Screening.java b/module-application/src/main/java/project/redis/application/domain/Screening.java new file mode 100644 index 000000000..c53dee1a2 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Screening.java @@ -0,0 +1,26 @@ +package project.redis.application.domain; + +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Screening { + UUID screeningId; + LocalDateTime screenStartTime; + LocalDateTime screenEndTime; + UUID movieId; + UUID cinemaId; + + public static Screening generateScreening( + UUID screeningId, + LocalDateTime screenStartTime, LocalDateTime screenEndTime, + UUID movieId, UUID cinemaId) { + return new Screening(screeningId, screenStartTime, screenEndTime, movieId, cinemaId); + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/Seat.java b/module-application/src/main/java/project/redis/application/domain/Seat.java new file mode 100644 index 000000000..694a199fa --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Seat.java @@ -0,0 +1,21 @@ +package project.redis.application.domain; + + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Seat { + UUID seatId; + String seatNumber; + UUID cinemaId; + + public static Seat generateSeat(UUID seatId, String seatNumber, UUID cinemaId) { + return new Seat(seatId, seatNumber, cinemaId); + } +} From 10fb36b08a7d2a5984318aadc69d0414691d2415 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Thu, 9 Jan 2025 19:15:51 +0900 Subject: [PATCH 04/29] commitmm --- .gitignore | 2 + build.gradle | 41 +++++++++------ docker/docker-compose.yaml | 21 ++++++++ gradlew | 3 +- module-application/build.gradle | 12 ++--- .../application/movie/dto/MovieResponse.java | 38 ++++++++++++++ .../movie/port/inbound/MovieQueryUseCase.java | 9 ++++ .../movie/port/outbound/MovieQueryPort.java | 8 +++ .../movie/service/MovieQueryService.java | 22 ++++++++ module-domain/build.gradle | 8 +++ .../project/redis/domain/cinema}/Cinema.java | 5 +- .../project/redis/domain/genre}/Genre.java | 2 +- .../domain/infra/common/BaseJpaEntity.java | 22 ++++++++ .../infra/genre/entity/GenreJpaEntity.java | 31 ++++++++++++ .../infra/movie/entity/MovieJpaEntity.java | 50 +++++++++++++++++++ .../movie/inbound/MovieQueryAdapter.java | 20 ++++++++ .../movie/repository/MovieJpaRepository.java | 8 +++ .../redis/domain/movie/entity}/Movie.java | 10 ++-- .../movie/entity}/RatingClassification.java | 2 +- .../redis/domain/screening}/Screening.java | 2 +- .../java/project/redis/domain/seat}/Seat.java | 3 +- module-infrastructure/build.gradle | 7 +-- module-presentation/build.gradle | 9 ++-- .../presentation/TheaterApplication.java | 19 +++++++ .../movie/controller/MovieController.java | 26 ++++++++++ .../src/main/resources/application.yml | 21 ++++++++ settings.gradle | 1 + 27 files changed, 359 insertions(+), 43 deletions(-) create mode 100644 docker/docker-compose.yaml create mode 100644 module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java create mode 100644 module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java create mode 100644 module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java create mode 100644 module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java create mode 100644 module-domain/build.gradle rename {module-application/src/main/java/project/redis/application/domain => module-domain/src/main/java/project/redis/domain/cinema}/Cinema.java (89%) rename {module-application/src/main/java/project/redis/application/domain => module-domain/src/main/java/project/redis/domain/genre}/Genre.java (90%) create mode 100644 module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java create mode 100644 module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java create mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java create mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java create mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java rename {module-application/src/main/java/project/redis/application/domain => module-domain/src/main/java/project/redis/domain/movie/entity}/Movie.java (64%) rename {module-application/src/main/java/project/redis/application/domain => module-domain/src/main/java/project/redis/domain/movie/entity}/RatingClassification.java (89%) rename {module-application/src/main/java/project/redis/application/domain => module-domain/src/main/java/project/redis/domain/screening}/Screening.java (94%) rename {module-application/src/main/java/project/redis/application/domain => module-domain/src/main/java/project/redis/domain/seat}/Seat.java (90%) create mode 100644 module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java create mode 100644 module-presentation/src/main/resources/application.yml diff --git a/.gitignore b/.gitignore index c2065bc26..9d77ee124 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +docker/db/ \ No newline at end of file diff --git a/build.gradle b/build.gradle index 1d75c56d8..35ca4a98d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,29 +1,35 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.1' apply false - id 'io.spring.dependency-management' version '1.1.7' apply false + id 'io.spring.dependency-management' version '1.1.7' } group = 'project.redis' version = '0.0.1-SNAPSHOT' - -allprojects { - java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } - } - - repositories { - mavenCentral() +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) } } +//repositories { +// mavenCentral() +//} +//allprojects { +// repositories { +// mavenCentral() +// } +//} + subprojects { apply plugin: 'java' apply plugin: 'io.spring.dependency-management' + repositories { + mavenCentral() + } + configurations { compileOnly { extendsFrom annotationProcessor @@ -31,12 +37,12 @@ subprojects { } dependencies { - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' + compileOnly 'org.projectlombok:lombok:1.18.36' + annotationProcessor 'org.projectlombok:lombok:1.18.36' - testCompileOnly 'org.projectlombok:lombok' - testAnnotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok:1.18.36' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } @@ -45,3 +51,8 @@ subprojects { useJUnitPlatform() // JUnit Platform 사용 } } + + +jar { + enabled = false +} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 000000000..6bc0c09ce --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,21 @@ +version: '3' +services: + redis-movie: + image: mysql:9.1.0 + container_name: cinema-mysql + restart: always + ports: + - "3309:3306" + volumes: + - ./db/conf.d:/etc/mysql/conf.d + - ./db/data:/var/lib/mysql + - ./db/initdb.d:/docker-entrypoint-initdb.d + environment: + - TZ=Asia/Seoul + - MYSQL_ROOT_PASSWORD=1234 + - MYSQL_DATABASE=redis-movie + - MYSQL_USER=hongs + - MYSQL_PASSWORD=local1234 + command: > + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci \ No newline at end of file diff --git a/gradlew b/gradlew index f3b75f3b0..f5feea6d6 100755 --- a/gradlew +++ b/gradlew @@ -86,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/module-application/build.gradle b/module-application/build.gradle index 2271f64bd..2eb95f7ab 100644 --- a/module-application/build.gradle +++ b/module-application/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.springframework.boot' + id 'org.springframework.boot' version '3.4.1' } jar { @@ -19,12 +19,12 @@ group = 'project.redis.application' version = '0.0.1-SNAPSHOT' dependencies { - implementation project(':module-infrastructure') - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation project(':module-domain') +// implementation project(':module-infrastructure') implementation 'org.springframework.boot:spring-boot-starter' testImplementation 'org.springframework.boot:spring-boot-starter-test' } -test { - useJUnitPlatform() -} \ No newline at end of file +//test { +// useJUnitPlatform() +//} \ No newline at end of file diff --git a/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java b/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java new file mode 100644 index 000000000..2766eb59b --- /dev/null +++ b/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java @@ -0,0 +1,38 @@ +package project.redis.application.movie.dto; + + +import java.time.LocalDate; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class MovieResponse { + + private UUID movieId; + private String title; + private String rating; + private LocalDate releaseDate; + private String thumbnailUrl; + private int runningTimeMin; + private String genreName; + + public static MovieResponse of( + UUID movieId, String title, String rating, LocalDate releaseDate, + String thumbnailUrl, int runningTimeMin, String genreName) { + return MovieResponse.builder() + .movieId(movieId) + .title(title) + .rating(rating) + .releaseDate(releaseDate) + .thumbnailUrl(thumbnailUrl) + .runningTimeMin(runningTimeMin) + .genreName(genreName) + .build(); + } +} diff --git a/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java b/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java new file mode 100644 index 000000000..300e0207d --- /dev/null +++ b/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java @@ -0,0 +1,9 @@ +package project.redis.application.movie.port.inbound; + +import java.util.List; +import project.redis.application.movie.dto.MovieResponse; + +public interface MovieQueryUseCase { + + List getMovies(); +} diff --git a/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java new file mode 100644 index 000000000..089c355e5 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java @@ -0,0 +1,8 @@ +package project.redis.application.movie.port.outbound; + +import java.util.List; +import project.redis.domain.movie.entity.Movie; + +public interface MovieQueryPort { + List getMovies(); +} diff --git a/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java b/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java new file mode 100644 index 000000000..5e4b92f44 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java @@ -0,0 +1,22 @@ +package project.redis.application.movie.service; + + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.movie.dto.MovieResponse; +import project.redis.application.movie.port.inbound.MovieQueryUseCase; + +@Service +@RequiredArgsConstructor +public class MovieQueryService implements MovieQueryUseCase { + +// private final MovieQueryPort movieQueryPort; + + @Override + public List getMovies() { +// List movies = movieQueryPort.getMovies(); + + return List.of(); + } +} diff --git a/module-domain/build.gradle b/module-domain/build.gradle new file mode 100644 index 000000000..36d5b4389 --- /dev/null +++ b/module-domain/build.gradle @@ -0,0 +1,8 @@ +jar { + enabled = true +} + +group = 'project.redis.domain' +version = '0.0.1-SNAPSHOT' + + diff --git a/module-application/src/main/java/project/redis/application/domain/Cinema.java b/module-domain/src/main/java/project/redis/domain/cinema/Cinema.java similarity index 89% rename from module-application/src/main/java/project/redis/application/domain/Cinema.java rename to module-domain/src/main/java/project/redis/domain/cinema/Cinema.java index 3a01961c6..3282e6c6e 100644 --- a/module-application/src/main/java/project/redis/application/domain/Cinema.java +++ b/module-domain/src/main/java/project/redis/domain/cinema/Cinema.java @@ -1,5 +1,4 @@ -package project.redis.application.domain; - +package project.redis.domain.cinema; import java.util.UUID; import lombok.AccessLevel; @@ -17,4 +16,4 @@ public class Cinema { public static Cinema generateCinema(UUID cinemaId, String cinemaName) { return new Cinema(cinemaId, cinemaName); } -} +} \ No newline at end of file diff --git a/module-application/src/main/java/project/redis/application/domain/Genre.java b/module-domain/src/main/java/project/redis/domain/genre/Genre.java similarity index 90% rename from module-application/src/main/java/project/redis/application/domain/Genre.java rename to module-domain/src/main/java/project/redis/domain/genre/Genre.java index 97b506cc5..14fa4c517 100644 --- a/module-application/src/main/java/project/redis/application/domain/Genre.java +++ b/module-domain/src/main/java/project/redis/domain/genre/Genre.java @@ -1,4 +1,4 @@ -package project.redis.application.domain; +package project.redis.domain.genre; import java.util.UUID; import lombok.AccessLevel; diff --git a/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java new file mode 100644 index 000000000..f293d0b60 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java @@ -0,0 +1,22 @@ +//package project.redis.infrastructure.common; +// +// +//import jakarta.persistence.EntityListeners; +//import jakarta.persistence.MappedSuperclass; +//import java.time.LocalDateTime; +//import lombok.Getter; +//import org.springframework.data.annotation.CreatedDate; +//import org.springframework.data.annotation.LastModifiedDate; +//import org.springframework.data.jpa.domain.support.AuditingEntityListener; +// +//@Getter +//@EntityListeners(AuditingEntityListener.class) +//@MappedSuperclass +//public abstract class BaseJpaEntity { +// @CreatedDate +// private LocalDateTime createdAt; +// +// @LastModifiedDate +// private LocalDateTime updatedAt; +// +//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java new file mode 100644 index 000000000..f49aea7db --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java @@ -0,0 +1,31 @@ +//package project.redis.infrastructure.genre.entity; +// +//import jakarta.persistence.Column; +//import jakarta.persistence.Entity; +//import jakarta.persistence.GeneratedValue; +//import jakarta.persistence.Id; +//import jakarta.persistence.Table; +//import java.util.UUID; +//import lombok.AccessLevel; +//import lombok.AllArgsConstructor; +//import lombok.Builder; +//import lombok.Getter; +//import lombok.NoArgsConstructor; +//import project.redis.infrastructure.common.BaseJpaEntity; +// +// +//@Entity +//@Builder +//@Table(name = "movie") +//@Getter +//@AllArgsConstructor +//@NoArgsConstructor(access = AccessLevel.PROTECTED) +//public class GenreJpaEntity extends BaseJpaEntity { +// @Id +// @GeneratedValue(generator = "uuid") +// @Column(name = "genre_id", columnDefinition = "BINARY(16)") +// private UUID id; +// +// @Column(nullable = false) +// private String name; +//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java new file mode 100644 index 000000000..84c3685d2 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java @@ -0,0 +1,50 @@ +//package project.redis.infrastructure.movie.entity; +// +// +//import static jakarta.persistence.EnumType.STRING; +// +//import jakarta.persistence.Column; +//import jakarta.persistence.Entity; +//import jakarta.persistence.Enumerated; +//import jakarta.persistence.GeneratedValue; +//import jakarta.persistence.Id; +//import jakarta.persistence.Table; +//import java.time.LocalDate; +//import java.util.UUID; +//import lombok.AccessLevel; +//import lombok.AllArgsConstructor; +//import lombok.Builder; +//import lombok.Getter; +//import lombok.NoArgsConstructor; +//import project.redis.domain.movie.entity.RatingClassification; +//import project.redis.infrastructure.common.BaseJpaEntity; +// +//@Entity +//@Builder +//@Table(name = "movie") +//@Getter +//@AllArgsConstructor +//@NoArgsConstructor(access = AccessLevel.PROTECTED) +//public class MovieJpaEntity extends BaseJpaEntity { +// +// @Id +// @GeneratedValue(generator = "uuid") +// @Column(name = "movie_id", columnDefinition = "BINARY(16)") +// private UUID id; +// +// @Column(nullable = false) +// private String title; +// +// @Column(nullable = false) +// @Enumerated(value = STRING) +// private RatingClassification rating; +// +// @Column(nullable = false) +// private LocalDate releaseDate; +// +// @Column(columnDefinition = "TEXT") +// private String thumbnailUrl; +// +// @Column(nullable = false) +// private int runningMinTime; +//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java b/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java new file mode 100644 index 000000000..f04502ecc --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java @@ -0,0 +1,20 @@ +//package project.redis.infrastructure.movie.inbound; +// +//import java.util.List; +//import lombok.RequiredArgsConstructor; +//import org.springframework.stereotype.Component; +//import project.redis.application.movie.port.outbound.MovieQueryPort; +//import project.redis.domain.movie.entity.Movie; +//import project.redis.infrastructure.movie.repository.MovieJpaRepository; +// +//@Component +//@RequiredArgsConstructor +//public class MovieQueryAdapter implements MovieQueryPort { +// +// private final MovieJpaRepository movieJpaRepository; +// +// @Override +// public List getMovies() { +// return List.of(); +// } +//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java b/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java new file mode 100644 index 000000000..ad407f2e6 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java @@ -0,0 +1,8 @@ +//package project.redis.infrastructure.movie.repository; +// +//import java.util.UUID; +//import org.springframework.data.jpa.repository.JpaRepository; +//import project.redis.infrastructure.movie.entity.MovieJpaEntity; +// +//public interface MovieJpaRepository extends JpaRepository { +//} diff --git a/module-application/src/main/java/project/redis/application/domain/Movie.java b/module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java similarity index 64% rename from module-application/src/main/java/project/redis/application/domain/Movie.java rename to module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java index 1181be020..fb3cf58be 100644 --- a/module-application/src/main/java/project/redis/application/domain/Movie.java +++ b/module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java @@ -1,5 +1,4 @@ -package project.redis.application.domain; - +package project.redis.domain.movie.entity; import java.time.LocalDate; import java.util.UUID; @@ -17,14 +16,15 @@ public class Movie { RatingClassification rating; LocalDate releaseDate; String thumbnailUrl; - int runningTime; + int runningMinTime; + //TODO: 도메인에서 최소정보로 id 데이터만 가지고 있게되니, 결과적으로 디비를 한번 더 찔러야하는 상황이 생긴다... UUID genreId; public static Movie generateMovie( UUID id, String title, RatingClassification rating, LocalDate releaseDate, - String thumbnailUrl, int runningTime, UUID genreId + String thumbnailUrl, int runningMinTime, UUID genreId ) { - return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningTime, genreId); + return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningMinTime, genreId); } } diff --git a/module-application/src/main/java/project/redis/application/domain/RatingClassification.java b/module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java similarity index 89% rename from module-application/src/main/java/project/redis/application/domain/RatingClassification.java rename to module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java index 46722a273..cc01b80a3 100644 --- a/module-application/src/main/java/project/redis/application/domain/RatingClassification.java +++ b/module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java @@ -1,4 +1,4 @@ -package project.redis.application.domain; +package project.redis.domain.movie.entity; import lombok.Getter; diff --git a/module-application/src/main/java/project/redis/application/domain/Screening.java b/module-domain/src/main/java/project/redis/domain/screening/Screening.java similarity index 94% rename from module-application/src/main/java/project/redis/application/domain/Screening.java rename to module-domain/src/main/java/project/redis/domain/screening/Screening.java index c53dee1a2..8cc2bd247 100644 --- a/module-application/src/main/java/project/redis/application/domain/Screening.java +++ b/module-domain/src/main/java/project/redis/domain/screening/Screening.java @@ -1,4 +1,4 @@ -package project.redis.application.domain; +package project.redis.domain.screening; import java.time.LocalDateTime; import java.util.UUID; diff --git a/module-application/src/main/java/project/redis/application/domain/Seat.java b/module-domain/src/main/java/project/redis/domain/seat/Seat.java similarity index 90% rename from module-application/src/main/java/project/redis/application/domain/Seat.java rename to module-domain/src/main/java/project/redis/domain/seat/Seat.java index 694a199fa..9cd9dd7b1 100644 --- a/module-application/src/main/java/project/redis/application/domain/Seat.java +++ b/module-domain/src/main/java/project/redis/domain/seat/Seat.java @@ -1,5 +1,4 @@ -package project.redis.application.domain; - +package project.redis.domain.seat; import java.util.UUID; import lombok.AccessLevel; diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle index c9e5362f2..d1b5dbe6c 100644 --- a/module-infrastructure/build.gradle +++ b/module-infrastructure/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.springframework.boot' + id 'org.springframework.boot' version '3.4.1' } jar { @@ -20,13 +20,10 @@ version = '0.0.1-SNAPSHOT' dependencies { + implementation project(':module-domain') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.boot:spring-boot-starter-test' } - -test { - useJUnitPlatform() -} \ No newline at end of file diff --git a/module-presentation/build.gradle b/module-presentation/build.gradle index 8c16a36d2..ff7cbe7d5 100644 --- a/module-presentation/build.gradle +++ b/module-presentation/build.gradle @@ -22,8 +22,11 @@ dependencies { implementation project(':module-application') implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' } -test { - useJUnitPlatform() -} \ No newline at end of file +//test { +// useJUnitPlatform() +//} \ No newline at end of file diff --git a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java new file mode 100644 index 000000000..9f6ccb2a1 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java @@ -0,0 +1,19 @@ +package project.redis.presentation; + + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; + + +@SpringBootApplication +@ComponentScan(basePackages = { + "project.redis.application", + "project.redis.presentation", + "project.redis.infrastructure" +}) +public class TheaterApplication { + public static void main(String[] args) { + SpringApplication.run(TheaterApplication.class, args); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java b/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java new file mode 100644 index 000000000..a147f550c --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java @@ -0,0 +1,26 @@ +package project.redis.presentation.movie.controller; + + +import java.util.List; +import lombok.RequiredArgsConstructor; +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.RestController; + +import project.redis.application.movie.dto.MovieResponse; +import project.redis.application.movie.port.inbound.MovieQueryUseCase; + + +@RestController +@RequestMapping("/api/v1/movies") +@RequiredArgsConstructor +public class MovieController { + + public final MovieQueryUseCase movieQueryUseCase; + + @GetMapping + public ResponseEntity> getMovie() { + return ResponseEntity.ok(movieQueryUseCase.getMovies()); + } +} diff --git a/module-presentation/src/main/resources/application.yml b/module-presentation/src/main/resources/application.yml new file mode 100644 index 000000000..c34a9b9a3 --- /dev/null +++ b/module-presentation/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3309/redis-movie?useSSL=false&allowPublicKeyRetrieval=true + username: hongs + password: local1234 + jpa: + hibernate: + ddl-auto: create-drop + open-in-view: false + show-sql: true + +logging: + level: + org: + hibernate: + SQL: info + type: + descriptor: + sql: + info \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 4412b1bb2..085be3078 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,4 +2,5 @@ rootProject.name = 'redis_1st' include 'module-application' include 'module-presentation' include 'module-infrastructure' +include 'module-domain' From 24418d8615c69b417fac5e9f470c56f26cfdbccf Mon Sep 17 00:00:00 2001 From: hongs429 Date: Sun, 12 Jan 2025 04:46:47 +0900 Subject: [PATCH 05/29] =?UTF-8?q?commit=20=EB=82=B4=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module 구분: presentation, application, infrastructure, domain - 각 모듈이 독립적으로 작업가능하도록 SRP 적용 - Port-Adapter 패턴으로 명확한 의존관계 설정 - 프로잭트 설명은 루트의 README.md 에 기재 - 각 모듈의 기능과 설명은 README.md 에 명확하게 기재 - 급하게 하다보니 커밋을 나누지 못했습니다. 대신, 그만큼 자세하게 README.md에 기재했으니 참고 부탁드립니다 --- README.md | 50 ++++- build.gradle | 40 ++-- http/getScreenings.http | 12 ++ img.png | Bin 0 -> 177693 bytes module-application/README.md | 105 ++++++++++ module-application/build.gradle | 9 +- .../port/inbound/CinemaQueryUseCase.java | 9 + .../cinema/service/CinemaQueryService.java | 21 ++ .../application/movie/dto/MovieResponse.java | 38 ---- .../movie/port/inbound/MovieQueryUseCase.java | 4 +- .../movie/port/outbound/MovieQueryPort.java | 8 - .../movie/service/MovieQueryService.java | 11 +- .../port/inbound/ScreeningQueryUseCase.java | 9 + .../port/inbound/ScreeningsQueryParam.java | 14 ++ .../service/ScreeningQueryService.java | 23 +++ module-domain/README.md | 97 ++++++++++ module-domain/build.gradle | 3 - .../domain/infra/common/BaseJpaEntity.java | 22 --- .../infra/genre/entity/GenreJpaEntity.java | 31 --- .../infra/movie/entity/MovieJpaEntity.java | 50 ----- .../movie/inbound/MovieQueryAdapter.java | 20 -- .../movie/repository/MovieJpaRepository.java | 8 - .../domain/movie/{entity => }/Movie.java | 9 +- .../{entity => }/RatingClassification.java | 2 +- .../redis/domain/screening/Screening.java | 10 +- .../java/project/redis/domain/seat/Seat.java | 7 +- module-infrastructure/README.md | 106 +++++++++++ module-infrastructure/build.gradle | 5 +- .../infrastructure/ScreeningDataInit.java | 70 +++++++ .../cinema/entity/CinemaJpaEntity.java | 33 ++++ .../inbound/port/CinemaQueryAdapter.java | 25 +++ .../cinema/inbound/port/CinemaQueryPort.java | 9 + .../cinema/mapper/CinemaInfraMapper.java | 16 ++ .../repository/CinemaJpaRepository.java | 8 + .../common/config/JpaConfig.java | 18 ++ .../common/entity/BaseJpaEntity.java | 33 ++++ .../genre/entity/GenreJpaEntity.java | 33 ++++ .../genre/mapper/GenreInfraMapper.java | 14 ++ .../genre/repository/GenreJpaRepository.java | 8 + .../movie/entity/MovieJpaEntity.java | 60 ++++++ .../movie/inbound/MovieQueryAdapter.java | 26 +++ .../movie/inbound/port/MovieQueryPort.java | 8 + .../movie/mapper/MovieInfraMapper.java | 22 +++ .../movie/repository/MovieJpaRepository.java | 13 ++ .../screening/entity/ScreeningJpaEntity.java | 51 +++++ .../inbound/ScreeningQueryAdapter.java | 34 ++++ .../port/inbound/ScreeningQueryPort.java | 9 + .../mapper/ScreeningInfraMapper.java | 21 ++ .../repository/ScreeningJpaRepository.java | 20 ++ .../seat/entity/SeatJpaEntity.java | 41 ++++ .../infrastructure/ScreeningDataInitTest.java | 33 ++++ module-presentation/README.md | 100 ++++++++++ module-presentation/build.gradle | 10 +- .../presentation/TheaterApplication.java | 5 +- .../cinema/controller/CinemaController.java | 32 ++++ .../cinema/dto/response/CinemaResponse.java | 17 ++ .../cinema/mapper/CinemaApiMapper.java | 15 ++ .../movie/controller/MovieController.java | 13 +- .../movie/dto/response/MovieResponse.java | 24 +++ .../movie/mapper/MovieApiMapper.java | 18 ++ .../controller/ScreeningController.java | 35 ++++ .../dto/request/ScreeningsQueryRequest.java | 15 ++ .../response/GroupedScreeningResponse.java | 39 ++++ .../screening/mapper/ScreeningAppMapper.java | 52 +++++ .../{application.yml => application.yaml} | 15 +- .../db/migration/V1__CreateInitTable.sql | 75 ++++++++ .../resources/db/migration/V2__InitData.sql | 180 ++++++++++++++++++ 67 files changed, 1725 insertions(+), 248 deletions(-) create mode 100644 http/getScreenings.http create mode 100644 img.png create mode 100644 module-application/README.md create mode 100644 module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaQueryUseCase.java create mode 100644 module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java delete mode 100644 module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java delete mode 100644 module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java create mode 100644 module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java create mode 100644 module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java create mode 100644 module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java create mode 100644 module-domain/README.md delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java rename module-domain/src/main/java/project/redis/domain/movie/{entity => }/Movie.java (80%) rename module-domain/src/main/java/project/redis/domain/movie/{entity => }/RatingClassification.java (89%) create mode 100644 module-infrastructure/README.md create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/cinema/entity/CinemaJpaEntity.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryAdapter.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryPort.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/common/entity/BaseJpaEntity.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/genre/entity/GenreJpaEntity.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/genre/repository/GenreJpaRepository.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/MovieQueryAdapter.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/port/MovieQueryPort.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/movie/repository/MovieJpaRepository.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/ScreeningQueryAdapter.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/port/inbound/ScreeningQueryPort.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java create mode 100644 module-infrastructure/src/test/java/project/redis/infrastructure/ScreeningDataInitTest.java create mode 100644 module-presentation/README.md create mode 100644 module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/cinema/dto/response/CinemaResponse.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/cinema/mapper/CinemaApiMapper.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/movie/dto/response/MovieResponse.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/movie/mapper/MovieApiMapper.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/screening/dto/response/GroupedScreeningResponse.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/screening/mapper/ScreeningAppMapper.java rename module-presentation/src/main/resources/{application.yml => application.yaml} (62%) create mode 100644 module-presentation/src/main/resources/db/migration/V1__CreateInitTable.sql create mode 100644 module-presentation/src/main/resources/db/migration/V2__InitData.sql diff --git a/README.md b/README.md index 5fcc66b4d..9c140d2e5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,47 @@ -## [본 과정] 이커머스 핵심 프로세스 구현 -[단기 스킬업 Redis 교육 과정](https://hh-skillup.oopy.io/) 을 통해 상품 조회 및 주문 과정을 구현하며 현업에서 발생하는 문제를 Redis의 핵심 기술을 통해 해결합니다. -> Indexing, Caching을 통한 성능 개선 / 단계별 락 구현을 통한 동시성 이슈 해결 (낙관적/비관적 락, 분산락 등) +## Multi Module Design + +#### **의존관계** +- `presentation` → `application` → `infrastructure` +- `application` → `domain` +- `infrastructure` → `domain` + +#### **모듈별 역할** +- 각 모듈의 상세 설명은 해당 모듈 디렉토리 내 README.md 파일을 참고하십시오. + +--- + +## Table Design +- 테이블 구조는 아래의 다이어그램으로 대체됩니다. +- **![img.png](img.png)** + +--- + +## Architecture + +- **Port-Adapter 패턴 적용** + - 각 모듈 간 결합을 제거하여 독립성을 유지합니다. + - `Application` 계층은 비즈니스 로직만 담당하며, 외부 기술에 의존하지 않습니다. + - `Infrastructure` 계층은 외부 기술(JPA, DB, API 등)을 담당하며, Port를 통해 `Application`과 통신합니다. + +--- + +## API Design + +#### **`GET /api/v1/screenings`** +- 최신 영화별로 그룹핑하여 빠른 시간순으로 정렬된 상영 영화 목록을 반환합니다. +- 기본적으로 오늘부터 2일 이내 상영 영화 목록을 반환하며, 클라이언트 요청에 따라 기간을 조정할 수 있습니다. +- **HTTP 요청 예시**: + - IntelliJ Http Client `http/getScreenings.http` 참고. + +--- + +## 프로젝트 주요 특징 +- **모듈화된 설계**: 명확한 책임 분리를 통해 유지보수와 확장성을 높임. +- **API 유연성**: 다양한 클라이언트 요청 시나리오를 지원할 수 있는 유연한 파라미터 설계. +- **테이블 설계와 아키텍처**: 프로젝트 구조와 데이터베이스 설계를 통해 높은 일관성과 성능을 유지. + +--- + +## 데이터 관리 +- **flyway**로 형상 관리. Movie, Genre, Seat, Cinema 엔티티 생성 및 기본 데이터 생성 +- Screening(상영 영화 시간표)는 ``CommandLineRunner``를 구현하여 프로잭트 구동 시 생성 diff --git a/build.gradle b/build.gradle index 35ca4a98d..076191c51 100644 --- a/build.gradle +++ b/build.gradle @@ -1,33 +1,29 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.4.1' apply false id 'io.spring.dependency-management' version '1.1.7' } group = 'project.redis' version = '0.0.1-SNAPSHOT' -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + +repositories { + mavenCentral() } -//repositories { -// mavenCentral() -//} -//allprojects { -// repositories { -// mavenCentral() -// } -//} +dependencyManagement { + imports { + mavenBom 'org.springframework.boot:spring-boot-dependencies:3.4.1' + } +} subprojects { apply plugin: 'java' apply plugin: 'io.spring.dependency-management' - repositories { - mavenCentral() + java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } configurations { @@ -36,6 +32,11 @@ subprojects { } } + repositories { + mavenCentral() + } + + dependencies { compileOnly 'org.projectlombok:lombok:1.18.36' annotationProcessor 'org.projectlombok:lombok:1.18.36' @@ -43,16 +44,17 @@ subprojects { testCompileOnly 'org.projectlombok:lombok:1.18.36' testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +// testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } - tasks.named('test') { - useJUnitPlatform() // JUnit Platform 사용 + test { + useJUnitPlatform() } } jar { enabled = false -} +} \ No newline at end of file diff --git a/http/getScreenings.http b/http/getScreenings.http new file mode 100644 index 000000000..15eca66c5 --- /dev/null +++ b/http/getScreenings.http @@ -0,0 +1,12 @@ +### GET request to example server +GET https://examples.http-client.intellij.net/get + ?generated-in=IntelliJ IDEA + +### GET screenings (기본 2일) +GET http://localhost:8080/api/v1/screenings +Content-Type: application/json + + +### GET screenings (3일 이내 상영 영화 목록 가능) +GET http://localhost:8080/api/v1/screenings?maxScreeningDay=3 +Content-Type: application/json diff --git a/img.png b/img.png new file mode 100644 index 0000000000000000000000000000000000000000..6fec5a76b0bd1d40aacfcf95ad8099e9f409c3a5 GIT binary patch literal 177693 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 literal 0 HcmV?d00001 diff --git a/module-application/README.md b/module-application/README.md new file mode 100644 index 000000000..9dd3af66e --- /dev/null +++ b/module-application/README.md @@ -0,0 +1,105 @@ +## [Application Module] + +### 책임 + +1. **도메인 객체를 통해 Application 흐름 제어** + - 도메인 간 상호작용을 조율하며 비즈니스 로직의 흐름을 제어. + - 각 도메인 서비스와 협력하여 비즈니스 요구사항을 처리. + +2. **Infrastructure 모듈과의 분리** + - 데이터베이스와의 직접적인 통신은 Infrastructure 모듈에 위임. + - `ScreeningQueryPort`와 같은 인터페이스를 통해 Application 계층은 외부 기술에 독립적. + +3. **외부 기술과 무관한 설계** + - Infrastructure에 종속되지 않으며, 비즈니스 로직의 흐름에만 집중. + - 외부 기술 변경(JPA -> NoSQL 등)이 Application 계층에 영향을 미치지 않도록 설계. + +--- + +### 구조 + +- **Port-Adapter 패턴 적용**: + - Application 계층은 Port(인터페이스)를 정의하며, 실제 구현은 Infrastructure 모듈에서 Adapter로 처리. + - 예: `ScreeningQueryPort`를 통해 데이터 소스를 추상화. + +- **도메인 간 상호작용**: + - 비즈니스 로직은 도메인 객체와 도메인 서비스를 활용하여 처리. + - 데이터 변환 및 조율을 담당. + +--- + +### 주요 클래스 + +#### **`ScreeningQueryService`** +- 비즈니스 로직의 흐름을 제어하는 서비스 클래스. +- Port 인터페이스(`ScreeningQueryPort`)를 통해 Infrastructure 모듈과 통신. + +```java +@Service +@RequiredArgsConstructor +public class ScreeningQueryService implements ScreeningQueryUseCase { + + private final ScreeningQueryPort screeningQueryPort; + + @Override + public List getScreenings(ScreeningsQueryParam param) { + return screeningQueryPort.getScreenings(param.getMaxScreeningDay()); + } +} +``` + +--- + +#### **`ScreeningQueryUseCase`** +- Application 계층의 핵심 인터페이스. +- Presentation 계층이 이를 통해 비즈니스 로직에 접근. + +```java +public interface ScreeningQueryUseCase { + + List getScreenings(ScreeningsQueryParam param); + +} +``` + +--- + +#### **`ScreeningsQueryParam`** +- 비즈니스 로직 수행에 필요한 파라미터를 캡슐화한 클래스. + +```java +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningsQueryParam { + private int maxScreeningDay; +} +``` + +--- + +### 설계 원칙 + +1. **Port-Adapter 패턴 준수**: + - Port(인터페이스): `ScreeningQueryPort`. + - Adapter(구현체): Infrastructure 모듈에서 구현. + +2. **도메인 중심 설계**: + - 비즈니스 로직은 도메인 객체 및 도메인 서비스와 협력하여 처리. + - Application 계층은 비즈니스 흐름에 집중. + +3. **기술 독립성**: + - Application 계층은 외부 기술(JPA, DB 등)에 종속되지 않으며, 순수 Java 로직으로 구성. + +4. **확장성**: + - 새로운 데이터 소스나 비즈니스 요구사항이 추가되더라도 Port와 Adapter를 통해 확장 가능. + +--- + +### 장점 +- **유지보수성**: 외부 기술 변경 시 Application 계층에는 영향을 미치지 않음. +- **테스트 용이성**: Port를 Mocking하여 테스트 가능. +- **독립성**: 기술과 무관한 순수 비즈니스 로직 유지. + +--- diff --git a/module-application/build.gradle b/module-application/build.gradle index 2eb95f7ab..9d28f40a7 100644 --- a/module-application/build.gradle +++ b/module-application/build.gradle @@ -20,11 +20,6 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':module-domain') -// implementation project(':module-infrastructure') + implementation project(':module-infrastructure') implementation 'org.springframework.boot:spring-boot-starter' - testImplementation 'org.springframework.boot:spring-boot-starter-test' -} - -//test { -// useJUnitPlatform() -//} \ No newline at end of file +} \ No newline at end of file diff --git a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaQueryUseCase.java b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaQueryUseCase.java new file mode 100644 index 000000000..e0b1a7f5b --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaQueryUseCase.java @@ -0,0 +1,9 @@ +package project.redis.application.cinema.port.inbound; + +import java.util.List; +import project.redis.domain.cinema.Cinema; + +public interface CinemaQueryUseCase { + + List getCinemas(); +} diff --git a/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java b/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java new file mode 100644 index 000000000..a11533730 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java @@ -0,0 +1,21 @@ +package project.redis.application.cinema.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.cinema.port.inbound.CinemaQueryUseCase; +import project.redis.domain.cinema.Cinema; +import project.redis.infrastructure.cinema.inbound.port.CinemaQueryPort; + + +@Service +@RequiredArgsConstructor +public class CinemaQueryService implements CinemaQueryUseCase { + + private final CinemaQueryPort cinemaQueryPort; + + @Override + public List getCinemas() { + return cinemaQueryPort.getCinemas(); + } +} diff --git a/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java b/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java deleted file mode 100644 index 2766eb59b..000000000 --- a/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -package project.redis.application.movie.dto; - - -import java.time.LocalDate; -import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class MovieResponse { - - private UUID movieId; - private String title; - private String rating; - private LocalDate releaseDate; - private String thumbnailUrl; - private int runningTimeMin; - private String genreName; - - public static MovieResponse of( - UUID movieId, String title, String rating, LocalDate releaseDate, - String thumbnailUrl, int runningTimeMin, String genreName) { - return MovieResponse.builder() - .movieId(movieId) - .title(title) - .rating(rating) - .releaseDate(releaseDate) - .thumbnailUrl(thumbnailUrl) - .runningTimeMin(runningTimeMin) - .genreName(genreName) - .build(); - } -} diff --git a/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java b/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java index 300e0207d..e7dcc419b 100644 --- a/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java +++ b/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java @@ -1,9 +1,9 @@ package project.redis.application.movie.port.inbound; import java.util.List; -import project.redis.application.movie.dto.MovieResponse; +import project.redis.domain.movie.Movie; public interface MovieQueryUseCase { - List getMovies(); + List getMovies(); } diff --git a/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java deleted file mode 100644 index 089c355e5..000000000 --- a/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java +++ /dev/null @@ -1,8 +0,0 @@ -package project.redis.application.movie.port.outbound; - -import java.util.List; -import project.redis.domain.movie.entity.Movie; - -public interface MovieQueryPort { - List getMovies(); -} diff --git a/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java b/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java index 5e4b92f44..e860d2591 100644 --- a/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java +++ b/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java @@ -4,19 +4,18 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import project.redis.application.movie.dto.MovieResponse; import project.redis.application.movie.port.inbound.MovieQueryUseCase; +import project.redis.domain.movie.Movie; +import project.redis.infrastructure.movie.inbound.port.MovieQueryPort; @Service @RequiredArgsConstructor public class MovieQueryService implements MovieQueryUseCase { -// private final MovieQueryPort movieQueryPort; + private final MovieQueryPort movieQueryPort; @Override - public List getMovies() { -// List movies = movieQueryPort.getMovies(); - - return List.of(); + public List getMovies() { + return movieQueryPort.getMovies(); } } diff --git a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java new file mode 100644 index 000000000..e68a896ca --- /dev/null +++ b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java @@ -0,0 +1,9 @@ +package project.redis.application.screening.port.inbound; + +import java.util.List; +import project.redis.domain.screening.Screening; + +public interface ScreeningQueryUseCase { + + List getScreenings(ScreeningsQueryParam param); +} diff --git a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java new file mode 100644 index 000000000..47744e494 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java @@ -0,0 +1,14 @@ +package project.redis.application.screening.port.inbound; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningsQueryParam { + private int maxScreeningDay; +} diff --git a/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java new file mode 100644 index 000000000..43f29ae12 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java @@ -0,0 +1,23 @@ +package project.redis.application.screening.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.screening.port.inbound.ScreeningQueryUseCase; +import project.redis.application.screening.port.inbound.ScreeningsQueryParam; +import project.redis.domain.screening.Screening; +import project.redis.infrastructure.screening.inbound.port.inbound.ScreeningQueryPort; + + + +@Service +@RequiredArgsConstructor +public class ScreeningQueryService implements ScreeningQueryUseCase { + + private final ScreeningQueryPort screeningQueryPort; + + @Override + public List getScreenings(ScreeningsQueryParam param) { + return screeningQueryPort.getScreenings(param.getMaxScreeningDay()); + } +} diff --git a/module-domain/README.md b/module-domain/README.md new file mode 100644 index 000000000..b59dad747 --- /dev/null +++ b/module-domain/README.md @@ -0,0 +1,97 @@ +## [Domain Module] + +### 책임 + +1. **상호작용하는 객체 관리** + - 영화(Screening), 영화관(Cinema), 영화 장르(Genre) 등 도메인 간의 상호작용을 관리. + - 각 도메인은 데이터베이스 Entity와 분리된 **비즈니스 중심 객체**로 구성됩니다. + +2. **도메인 로직 담당** + - 각 도메인 클래스는 비즈니스 로직을 내포하며, 객체 간의 유효성 검사 및 데이터 조작을 처리합니다. + - 예: 상영 시간이 유효한지 확인하거나 영화와 상영관 간의 연관성을 검증. + +3. **도메인 간 로직 처리 (추후 도메인 서비스 도입 예정)** + - 특정 도메인에 국한되지 않는 로직은 별도의 Domain Service로 분리하여 관리할 예정. + - 예: 영화 상영 시간표 생성 로직. + +--- + +### 구조 + +- **변경 불가능한 객체**: + - 모든 도메인 객체는 불변성을 유지하여 안정성과 일관성을 확보합니다. + - 예: `@Value`, 생성자 기반 객체 생성. + +- **Entity와의 분리**: + - 도메인 객체는 데이터베이스와의 의존성을 가지지 않으며, 기술적인 구현 세부사항을 포함하지 않습니다. + - Entity 클래스는 Infrastructure 계층에 존재하며, 도메인 클래스와 별도로 관리됩니다. + +--- + +### 주요 클래스 + +#### **`Screening`** +- 영화 상영 정보를 나타내는 도메인 클래스. +- 영화(`Movie`), 상영관(`Cinema`)과 연관. + +```java +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Screening { + UUID screeningId; + LocalDateTime screenStartTime; + LocalDateTime screenEndTime; + Movie movie; + Cinema cinema; + + public static Screening generateScreening( + UUID screeningId, + LocalDateTime screenStartTime, LocalDateTime screenEndTime, + Movie movie, Cinema cinema) { + return new Screening(screeningId, screenStartTime, screenEndTime, movie, cinema); + } +} +``` + +--- + +#### **`Movie`** +- 영화 정보를 담는 도메인 클래스. + +```java +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Movie { + UUID movieId; + String title; + RatingClassification rating; + LocalDate releaseDate; + Genre genre; + + public static Movie generateMovie( + UUID movieId, String title, + RatingClassification rating, LocalDate releaseDate, + Genre genre) { + return new Movie(movieId, title, rating, releaseDate, genre); + } +} +``` + +--- + +#### **설계 원칙** +1. **불변 객체**: + - 모든 필드는 `final`로 선언하여 객체의 상태 변경을 방지. + - 객체 생성은 정적 팩토리 메서드(`generateScreening`, `generateMovie`)를 통해 관리. + +2. **Entity와 도메인의 분리**: + - 데이터베이스와 상호작용하는 JPA Entity는 Infrastructure 계층에 존재. + - 도메인은 순수 비즈니스 로직만 포함하여 데이터베이스 구현 변경 시 영향을 최소화. + +3. **확장성**: + - 도메인 클래스에 새로운 필드나 로직을 추가할 때, 기존 로직의 영향을 최소화. + - Domain Service를 통해 도메인 간 복잡한 로직 분리 가능. + +--- diff --git a/module-domain/build.gradle b/module-domain/build.gradle index 36d5b4389..ad220e67d 100644 --- a/module-domain/build.gradle +++ b/module-domain/build.gradle @@ -1,6 +1,3 @@ -jar { - enabled = true -} group = 'project.redis.domain' version = '0.0.1-SNAPSHOT' diff --git a/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java deleted file mode 100644 index f293d0b60..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java +++ /dev/null @@ -1,22 +0,0 @@ -//package project.redis.infrastructure.common; -// -// -//import jakarta.persistence.EntityListeners; -//import jakarta.persistence.MappedSuperclass; -//import java.time.LocalDateTime; -//import lombok.Getter; -//import org.springframework.data.annotation.CreatedDate; -//import org.springframework.data.annotation.LastModifiedDate; -//import org.springframework.data.jpa.domain.support.AuditingEntityListener; -// -//@Getter -//@EntityListeners(AuditingEntityListener.class) -//@MappedSuperclass -//public abstract class BaseJpaEntity { -// @CreatedDate -// private LocalDateTime createdAt; -// -// @LastModifiedDate -// private LocalDateTime updatedAt; -// -//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java deleted file mode 100644 index f49aea7db..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java +++ /dev/null @@ -1,31 +0,0 @@ -//package project.redis.infrastructure.genre.entity; -// -//import jakarta.persistence.Column; -//import jakarta.persistence.Entity; -//import jakarta.persistence.GeneratedValue; -//import jakarta.persistence.Id; -//import jakarta.persistence.Table; -//import java.util.UUID; -//import lombok.AccessLevel; -//import lombok.AllArgsConstructor; -//import lombok.Builder; -//import lombok.Getter; -//import lombok.NoArgsConstructor; -//import project.redis.infrastructure.common.BaseJpaEntity; -// -// -//@Entity -//@Builder -//@Table(name = "movie") -//@Getter -//@AllArgsConstructor -//@NoArgsConstructor(access = AccessLevel.PROTECTED) -//public class GenreJpaEntity extends BaseJpaEntity { -// @Id -// @GeneratedValue(generator = "uuid") -// @Column(name = "genre_id", columnDefinition = "BINARY(16)") -// private UUID id; -// -// @Column(nullable = false) -// private String name; -//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java deleted file mode 100644 index 84c3685d2..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java +++ /dev/null @@ -1,50 +0,0 @@ -//package project.redis.infrastructure.movie.entity; -// -// -//import static jakarta.persistence.EnumType.STRING; -// -//import jakarta.persistence.Column; -//import jakarta.persistence.Entity; -//import jakarta.persistence.Enumerated; -//import jakarta.persistence.GeneratedValue; -//import jakarta.persistence.Id; -//import jakarta.persistence.Table; -//import java.time.LocalDate; -//import java.util.UUID; -//import lombok.AccessLevel; -//import lombok.AllArgsConstructor; -//import lombok.Builder; -//import lombok.Getter; -//import lombok.NoArgsConstructor; -//import project.redis.domain.movie.entity.RatingClassification; -//import project.redis.infrastructure.common.BaseJpaEntity; -// -//@Entity -//@Builder -//@Table(name = "movie") -//@Getter -//@AllArgsConstructor -//@NoArgsConstructor(access = AccessLevel.PROTECTED) -//public class MovieJpaEntity extends BaseJpaEntity { -// -// @Id -// @GeneratedValue(generator = "uuid") -// @Column(name = "movie_id", columnDefinition = "BINARY(16)") -// private UUID id; -// -// @Column(nullable = false) -// private String title; -// -// @Column(nullable = false) -// @Enumerated(value = STRING) -// private RatingClassification rating; -// -// @Column(nullable = false) -// private LocalDate releaseDate; -// -// @Column(columnDefinition = "TEXT") -// private String thumbnailUrl; -// -// @Column(nullable = false) -// private int runningMinTime; -//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java b/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java deleted file mode 100644 index f04502ecc..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java +++ /dev/null @@ -1,20 +0,0 @@ -//package project.redis.infrastructure.movie.inbound; -// -//import java.util.List; -//import lombok.RequiredArgsConstructor; -//import org.springframework.stereotype.Component; -//import project.redis.application.movie.port.outbound.MovieQueryPort; -//import project.redis.domain.movie.entity.Movie; -//import project.redis.infrastructure.movie.repository.MovieJpaRepository; -// -//@Component -//@RequiredArgsConstructor -//public class MovieQueryAdapter implements MovieQueryPort { -// -// private final MovieJpaRepository movieJpaRepository; -// -// @Override -// public List getMovies() { -// return List.of(); -// } -//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java b/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java deleted file mode 100644 index ad407f2e6..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -//package project.redis.infrastructure.movie.repository; -// -//import java.util.UUID; -//import org.springframework.data.jpa.repository.JpaRepository; -//import project.redis.infrastructure.movie.entity.MovieJpaEntity; -// -//public interface MovieJpaRepository extends JpaRepository { -//} diff --git a/module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java b/module-domain/src/main/java/project/redis/domain/movie/Movie.java similarity index 80% rename from module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java rename to module-domain/src/main/java/project/redis/domain/movie/Movie.java index fb3cf58be..9f8fceec2 100644 --- a/module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java +++ b/module-domain/src/main/java/project/redis/domain/movie/Movie.java @@ -1,4 +1,4 @@ -package project.redis.domain.movie.entity; +package project.redis.domain.movie; import java.time.LocalDate; import java.util.UUID; @@ -6,6 +6,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Value; +import project.redis.domain.genre.Genre; @Getter @Value @@ -18,13 +19,13 @@ public class Movie { String thumbnailUrl; int runningMinTime; //TODO: 도메인에서 최소정보로 id 데이터만 가지고 있게되니, 결과적으로 디비를 한번 더 찔러야하는 상황이 생긴다... - UUID genreId; + Genre genre; public static Movie generateMovie( UUID id, String title, RatingClassification rating, LocalDate releaseDate, - String thumbnailUrl, int runningMinTime, UUID genreId + String thumbnailUrl, int runningMinTime, Genre genre ) { - return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningMinTime, genreId); + return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningMinTime, genre); } } diff --git a/module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java b/module-domain/src/main/java/project/redis/domain/movie/RatingClassification.java similarity index 89% rename from module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java rename to module-domain/src/main/java/project/redis/domain/movie/RatingClassification.java index cc01b80a3..daed4e418 100644 --- a/module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java +++ b/module-domain/src/main/java/project/redis/domain/movie/RatingClassification.java @@ -1,4 +1,4 @@ -package project.redis.domain.movie.entity; +package project.redis.domain.movie; import lombok.Getter; diff --git a/module-domain/src/main/java/project/redis/domain/screening/Screening.java b/module-domain/src/main/java/project/redis/domain/screening/Screening.java index 8cc2bd247..8f483b580 100644 --- a/module-domain/src/main/java/project/redis/domain/screening/Screening.java +++ b/module-domain/src/main/java/project/redis/domain/screening/Screening.java @@ -6,6 +6,8 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Value; +import project.redis.domain.cinema.Cinema; +import project.redis.domain.movie.Movie; @Getter @Value @@ -14,13 +16,13 @@ public class Screening { UUID screeningId; LocalDateTime screenStartTime; LocalDateTime screenEndTime; - UUID movieId; - UUID cinemaId; + Movie movie; + Cinema cinema; public static Screening generateScreening( UUID screeningId, LocalDateTime screenStartTime, LocalDateTime screenEndTime, - UUID movieId, UUID cinemaId) { - return new Screening(screeningId, screenStartTime, screenEndTime, movieId, cinemaId); + Movie movie, Cinema cinema) { + return new Screening(screeningId, screenStartTime, screenEndTime, movie, cinema); } } diff --git a/module-domain/src/main/java/project/redis/domain/seat/Seat.java b/module-domain/src/main/java/project/redis/domain/seat/Seat.java index 9cd9dd7b1..9fccfcc67 100644 --- a/module-domain/src/main/java/project/redis/domain/seat/Seat.java +++ b/module-domain/src/main/java/project/redis/domain/seat/Seat.java @@ -5,6 +5,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Value; +import project.redis.domain.cinema.Cinema; @Getter @Value @@ -12,9 +13,9 @@ public class Seat { UUID seatId; String seatNumber; - UUID cinemaId; + Cinema cinema; - public static Seat generateSeat(UUID seatId, String seatNumber, UUID cinemaId) { - return new Seat(seatId, seatNumber, cinemaId); + public static Seat generateSeat(UUID seatId, String seatNumber, Cinema cinema) { + return new Seat(seatId, seatNumber, cinema); } } diff --git a/module-infrastructure/README.md b/module-infrastructure/README.md new file mode 100644 index 000000000..036ae4f51 --- /dev/null +++ b/module-infrastructure/README.md @@ -0,0 +1,106 @@ +## [Infrastructure Module] + +### 책임 + +1. **외부 통신 및 기술 로직 담당** + - 데이터베이스, 외부 API, 메시지 큐 등 외부 시스템과의 통신을 처리. + - JPA, Flyway 등 구체적인 기술을 활용하여 데이터를 저장하고 조회. + +2. **Application 계층의 외부 의존성 제거** + - Domain 객체를 활용하여 외부 기술의 의존성을 철저히 제거. + - Application 계층은 Infrastructure 계층의 존재를 알지 못하며, Port-Adapter 패턴을 통해 간접적으로 의존. + +3. **데이터 변환 및 매핑** + - JPA Entity를 Domain 객체로 변환하여 Application 계층에 전달. + - Domain 객체에서 필요한 정보만 추출하여 비즈니스 로직에 활용 가능. + +--- + +### 구조 + +- **Port-Adapter 패턴 적용**: + - Application 계층에서 정의한 `ScreeningQueryPort` 인터페이스를 구현. + - 실제 기술 스택(JPA)을 사용하여 데이터를 처리. + +- **Domain과의 연결**: + - Infrastructure 계층에서 Domain 객체를 참조하여 데이터를 전달. + - 데이터베이스 Entity(`ScreeningJpaEntity`)를 Domain 객체(`Screening`)로 매핑. + +--- + +### 주요 클래스 + +#### **`ScreeningQueryAdapter`** +- Application 계층에서 정의한 `ScreeningQueryPort` 인터페이스를 구현. +- JPA를 사용하여 데이터를 조회하고, Domain 객체로 변환하여 반환. + +```java +@Transactional(readOnly = true) +@Component +@RequiredArgsConstructor +public class ScreeningQueryAdapter implements ScreeningQueryPort { + + private final ScreeningJpaRepository screeningJpaRepository; + + @Override + public List getScreenings(int maxScreeningDay) { + + LocalDate maxScreeningDate = LocalDate.now().plusDays(maxScreeningDay); + + List screenings + = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc(maxScreeningDate); + + return screenings.stream() + .map(ScreeningInfraMapper::toScreening) + .toList(); + } +} +``` + +--- + +#### **`ScreeningInfraMapper`** +- Entity와 Domain 객체 간의 변환을 처리. + +```java +public class ScreeningInfraMapper { + + public static Screening toScreening(ScreeningJpaEntity entity) { + return Screening.generateScreening( + entity.getScreeningId(), + entity.getScreeningStartTime(), + entity.getScreeningEndTime(), + MovieInfraMapper.toMovie(entity.getMovie()), + CinemaInfraMapper.toCinema(entity.getCinema()) + ); + } + +} +``` + +--- + +### 설계 원칙 + +1. **Port-Adapter 패턴 준수**: + - Port(`ScreeningQueryPort`)를 Application 계층에서 정의하고, Adapter(`ScreeningQueryAdapter`)를 통해 구현. + +2. **기술과 비즈니스의 분리**: + - JPA, Flyway와 같은 기술적인 구현은 Infrastructure 계층에서만 관리. + - Application 계층은 오직 Domain 객체만 활용. + +3. **확장성**: + - 데이터베이스가 변경되거나 다른 기술 스택(NoSQL, 외부 API 등)이 추가되더라도 Port-Adapter 패턴을 통해 쉽게 확장 가능. + +4. **Domain 중심 설계**: + - 데이터를 Domain 객체로 변환하여 Application 계층에 전달. + - 비즈니스 로직은 Application 계층에서 수행. + +--- + +### 장점 +- **유지보수성**: 외부 기술 변경(JPA -> 다른 ORM) 시에도 Application 계층은 수정이 필요 없음. +- **확장성**: 새로운 데이터 소스나 외부 API 추가 시 Port와 Adapter만 추가하면 됨. +- **독립성**: 비즈니스 로직과 기술 로직을 철저히 분리하여 각 계층의 역할을 명확히 함. + +--- diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle index d1b5dbe6c..f67c09ed7 100644 --- a/module-infrastructure/build.gradle +++ b/module-infrastructure/build.gradle @@ -14,7 +14,6 @@ bootRun { enabled = false } - group = 'project.redis.infrastructure' version = '0.0.1-SNAPSHOT' @@ -22,8 +21,8 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':module-domain') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' - testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java b/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java new file mode 100644 index 000000000..69ff8867e --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java @@ -0,0 +1,70 @@ +package project.redis.infrastructure; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; +import project.redis.infrastructure.movie.repository.MovieJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; + +@Component +@RequiredArgsConstructor +public class ScreeningDataInit implements CommandLineRunner { + + private final ScreeningJpaRepository screeningJpaRepository; + private final MovieJpaRepository movieJpaRepository; + private final CinemaJpaRepository cinemaJpaRepository; + + private static final Random RANDOM = new Random(); + + @Override + public void run(String... args) throws Exception { + List movies = movieJpaRepository.findAll(); + List cinemas = cinemaJpaRepository.findAll(); + + Stream.iterate(0, i -> i + 1) + .limit(500) + .parallel() + .map(index -> { + + MovieJpaEntity movieJpaEntity = movies.get(RANDOM.nextInt(movies.size())); + CinemaJpaEntity cinemaJpaEntity = cinemas.get(RANDOM.nextInt(cinemas.size())); + + LocalDateTime startTime = generateRandomStartTime(); + LocalDateTime endTime = startTime.plusMinutes(movieJpaEntity.getRunningMinTime()); + + return ScreeningJpaEntity.builder() + .screeningStartTime(startTime) + .screeningEndTime(endTime) + .movie(movieJpaEntity) + .cinema(cinemaJpaEntity) + .build(); + + }) + .forEach(screeningJpaRepository::save); + + } + + public LocalDateTime generateRandomStartTime() { + LocalDate today = LocalDate.now(); + LocalDate startDate = today.plusDays(1); + LocalDate endDate = today.plusDays(20); + + long randomDays = ThreadLocalRandom.current() + .nextLong(ChronoUnit.DAYS.between(startDate, endDate) + 1); + + return startDate + .plusDays(randomDays) + .atTime(new Random().nextInt(18), new Random().nextInt(60)); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/entity/CinemaJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/entity/CinemaJpaEntity.java new file mode 100644 index 000000000..dddb09469 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/entity/CinemaJpaEntity.java @@ -0,0 +1,33 @@ +package project.redis.infrastructure.cinema.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; + +@Entity +@Builder +@Table(name = "cinema") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CinemaJpaEntity extends BaseJpaEntity { + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "cinema_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private String cinemaName; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryAdapter.java new file mode 100644 index 000000000..e1ba05d69 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryAdapter.java @@ -0,0 +1,25 @@ +package project.redis.infrastructure.cinema.inbound.port; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.domain.cinema.Cinema; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.cinema.mapper.CinemaInfraMapper; +import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; + + +@Component +@RequiredArgsConstructor +public class CinemaQueryAdapter implements CinemaQueryPort { + + private final CinemaJpaRepository cinemaJpaRepository; + + @Override + public List getCinemas() { + List cinemas = cinemaJpaRepository.findAll(); + return cinemas.stream() + .map(CinemaInfraMapper::toCinema) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryPort.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryPort.java new file mode 100644 index 000000000..e70051a56 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryPort.java @@ -0,0 +1,9 @@ +package project.redis.infrastructure.cinema.inbound.port; + +import java.util.List; +import project.redis.domain.cinema.Cinema; + +public interface CinemaQueryPort { + + List getCinemas(); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java new file mode 100644 index 000000000..1e76781b9 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java @@ -0,0 +1,16 @@ +package project.redis.infrastructure.cinema.mapper; + + +import project.redis.domain.cinema.Cinema; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; + +public class CinemaInfraMapper { + + public static Cinema toCinema(CinemaJpaEntity cinema) { + return Cinema.generateCinema( + cinema.getId(), + cinema.getCinemaName() + ); + } + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java new file mode 100644 index 000000000..0d7f35479 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java @@ -0,0 +1,8 @@ +package project.redis.infrastructure.cinema.repository; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; + +public interface CinemaJpaRepository extends JpaRepository { +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java new file mode 100644 index 000000000..13661c86c --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java @@ -0,0 +1,18 @@ +package project.redis.infrastructure.common.config; + + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaAuditing +@EnableJpaRepositories(basePackages = { + "project.redis.infrastructure" +}) +@EntityScan(basePackages = { + "project.redis.infrastructure" +}) +public class JpaConfig { +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/entity/BaseJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/entity/BaseJpaEntity.java new file mode 100644 index 000000000..859c2b4e8 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/entity/BaseJpaEntity.java @@ -0,0 +1,33 @@ +package project.redis.infrastructure.common.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.Getter; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class BaseJpaEntity { + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + @CreatedBy + @Column(updatable = false, columnDefinition = "BINARY(16)") + private UUID createdBy; + + @LastModifiedBy + @Column(columnDefinition = "BINARY(16)") + private UUID updatedBy; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/entity/GenreJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/entity/GenreJpaEntity.java new file mode 100644 index 000000000..50592b666 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/entity/GenreJpaEntity.java @@ -0,0 +1,33 @@ +package project.redis.infrastructure.genre.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; + + +@Entity +@Builder +@Table(name = "genre") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GenreJpaEntity extends BaseJpaEntity { + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "genre_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private String genreName; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java new file mode 100644 index 000000000..a64c23647 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java @@ -0,0 +1,14 @@ +package project.redis.infrastructure.genre.mapper; + +import project.redis.domain.genre.Genre; +import project.redis.infrastructure.genre.entity.GenreJpaEntity; + +public class GenreInfraMapper { + + public static Genre toGenre(GenreJpaEntity genre) { + return Genre.generateGenre( + genre.getId(), + genre.getGenreName() + ); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/repository/GenreJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/repository/GenreJpaRepository.java new file mode 100644 index 000000000..3df16f7d9 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/repository/GenreJpaRepository.java @@ -0,0 +1,8 @@ +package project.redis.infrastructure.genre.repository; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.genre.entity.GenreJpaEntity; + +public interface GenreJpaRepository extends JpaRepository { +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java new file mode 100644 index 000000000..84121fc27 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java @@ -0,0 +1,60 @@ +package project.redis.infrastructure.movie.entity; + + +import static jakarta.persistence.EnumType.STRING; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.domain.movie.RatingClassification; +import project.redis.infrastructure.genre.entity.GenreJpaEntity; + +@Entity +@Builder +@Table(name = "movie") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MovieJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "movie_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + @Enumerated(value = STRING) + private RatingClassification rating; + + @Column(nullable = false) + private LocalDate releaseDate; + + @Column(columnDefinition = "TEXT") + private String thumbnailUrl; + + @Column(nullable = false) + private int runningMinTime; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "genre_id", columnDefinition = "BINARY(16)") + private GenreJpaEntity genre; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/MovieQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/MovieQueryAdapter.java new file mode 100644 index 000000000..d7f98af63 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/MovieQueryAdapter.java @@ -0,0 +1,26 @@ +package project.redis.infrastructure.movie.inbound; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.domain.movie.Movie; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; +import project.redis.infrastructure.movie.inbound.port.MovieQueryPort; +import project.redis.infrastructure.movie.mapper.MovieInfraMapper; +import project.redis.infrastructure.movie.repository.MovieJpaRepository; + +@Component +@RequiredArgsConstructor +public class MovieQueryAdapter implements MovieQueryPort { + + private final MovieJpaRepository movieJpaRepository; + + @Override + public List getMovies() { + List movies = movieJpaRepository.findAll(); + + return movies.stream() + .map(MovieInfraMapper::toMovie) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/port/MovieQueryPort.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/port/MovieQueryPort.java new file mode 100644 index 000000000..b1381ad34 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/port/MovieQueryPort.java @@ -0,0 +1,8 @@ +package project.redis.infrastructure.movie.inbound.port; + +import java.util.List; +import project.redis.domain.movie.Movie; + +public interface MovieQueryPort { + List getMovies(); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java new file mode 100644 index 000000000..c69756456 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java @@ -0,0 +1,22 @@ +package project.redis.infrastructure.movie.mapper; + + +import project.redis.domain.movie.Movie; +import project.redis.infrastructure.genre.mapper.GenreInfraMapper; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; + +public class MovieInfraMapper { + + public static Movie toMovie(MovieJpaEntity movie) { + return Movie.generateMovie( + movie.getId(), + movie.getTitle(), + movie.getRating(), + movie.getReleaseDate(), + movie.getThumbnailUrl(), + movie.getRunningMinTime(), + GenreInfraMapper.toGenre(movie.getGenre()) + ); + } + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/repository/MovieJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/repository/MovieJpaRepository.java new file mode 100644 index 000000000..3013f0d8f --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/repository/MovieJpaRepository.java @@ -0,0 +1,13 @@ +package project.redis.infrastructure.movie.repository; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; + +public interface MovieJpaRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"genre"}) + List findAll(); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java new file mode 100644 index 000000000..05194e3c9 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java @@ -0,0 +1,51 @@ +package project.redis.infrastructure.screening.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; + +@Entity +@Builder +@Table(name = "screening") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ScreeningJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "screening_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private LocalDateTime screeningStartTime; + + @Column(nullable = false) + private LocalDateTime screeningEndTime; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "movie_id", columnDefinition = "BINARY(16)") + private MovieJpaEntity movie; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cinema_id", columnDefinition = "BINARY(16)") + private CinemaJpaEntity cinema; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/ScreeningQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/ScreeningQueryAdapter.java new file mode 100644 index 000000000..6e6ae79c3 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/ScreeningQueryAdapter.java @@ -0,0 +1,34 @@ +package project.redis.infrastructure.screening.inbound; + +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import project.redis.domain.screening.Screening; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.inbound.port.inbound.ScreeningQueryPort; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; + + +@Transactional(readOnly = true) +@Component +@RequiredArgsConstructor +public class ScreeningQueryAdapter implements ScreeningQueryPort { + + private final ScreeningJpaRepository screeningJpaRepository; + + @Override + public List getScreenings(int maxScreeningDay) { + + LocalDate maxScreeningDate = LocalDate.now().plusDays(maxScreeningDay); + + List screenings + = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc(maxScreeningDate); + + return screenings.stream() + .map(ScreeningInfraMapper::toScreening) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/port/inbound/ScreeningQueryPort.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/port/inbound/ScreeningQueryPort.java new file mode 100644 index 000000000..ab9233196 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/port/inbound/ScreeningQueryPort.java @@ -0,0 +1,9 @@ +package project.redis.infrastructure.screening.inbound.port.inbound; + +import java.util.List; +import project.redis.domain.screening.Screening; + +public interface ScreeningQueryPort { + + List getScreenings(int maxScreeningDay); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java new file mode 100644 index 000000000..90b2b6da5 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java @@ -0,0 +1,21 @@ +package project.redis.infrastructure.screening.mapper; + + +import project.redis.domain.screening.Screening; +import project.redis.infrastructure.cinema.mapper.CinemaInfraMapper; +import project.redis.infrastructure.movie.mapper.MovieInfraMapper; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +public class ScreeningInfraMapper { + + public static Screening toScreening(ScreeningJpaEntity screening) { + return Screening.generateScreening( + screening.getId(), + screening.getScreeningStartTime(), + screening.getScreeningEndTime(), + MovieInfraMapper.toMovie(screening.getMovie()), + CinemaInfraMapper.toCinema(screening.getCinema()) + ); + } + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java new file mode 100644 index 000000000..2eef1c2a1 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java @@ -0,0 +1,20 @@ +package project.redis.infrastructure.screening.repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +public interface ScreeningJpaRepository extends JpaRepository { + + @Query("select s from ScreeningJpaEntity s " + + "left join fetch s.movie m " + + "left join fetch s.movie.genre g " + + "left join fetch s.cinema c " + + "where date(s.screeningStartTime) BETWEEN current_date AND :limit " + + "order by s.screeningStartTime asc, m.releaseDate desc") + List findAllOrderByReleaseDescAndScreenStartTimeAsc(@Param("limit") LocalDate limit); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java new file mode 100644 index 000000000..4d58794a4 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java @@ -0,0 +1,41 @@ +package project.redis.infrastructure.seat.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.common.entity.BaseJpaEntity; + +@Entity +@Builder +@Table(name = "seat") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SeatJpaEntity extends BaseJpaEntity { + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "seat_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private String seatNumber; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cinema_id", columnDefinition = "BINARY(16)") + private CinemaJpaEntity cinema; +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/ScreeningDataInitTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/ScreeningDataInitTest.java new file mode 100644 index 000000000..f844df8df --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/ScreeningDataInitTest.java @@ -0,0 +1,33 @@ +package project.redis.infrastructure; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; +import project.redis.infrastructure.movie.repository.MovieJpaRepository; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; + +@ExtendWith(MockitoExtension.class) +class ScreeningDataInitTest { + + @Mock + ScreeningJpaRepository screeningJpaRepository; + + @Mock + MovieJpaRepository movieJpaRepository; + + @Mock + CinemaJpaRepository cinemaJpaRepository; + + @InjectMocks + private ScreeningDataInit screeningDataInit; + + @Test + void testRandomStartTime() { + LocalDateTime startTime = screeningDataInit.generateRandomStartTime(); + System.out.println("startTime = " + startTime); + } +} \ No newline at end of file diff --git a/module-presentation/README.md b/module-presentation/README.md new file mode 100644 index 000000000..485fa71e7 --- /dev/null +++ b/module-presentation/README.md @@ -0,0 +1,100 @@ +## [Presentation Module] + +### 책임 + +1. **Client의 요청 처리** + - 클라이언트로부터 들어오는 요청을 처리합니다. + - 요청 데이터(`ScreeningsQueryRequest`)를 바탕으로 유효성을 검증하고 필요한 데이터를 추출합니다. + +2. **Application 모듈에 로직 위임** + - 요청 데이터를 기반으로 Application 모듈의 인터페이스(`ScreeningQueryUseCase`)를 호출합니다. + - 비즈니스 로직은 Application 모듈에서 처리하며, Presentation 모듈은 단순히 요청/응답에만 집중합니다. + +3. **적절한 응답 반환** + - Application 모듈에서 반환된 결과를 바탕으로 클라이언트가 원하는 응답 형식으로 변환합니다. + - 예: 영화 데이터를 `GroupedScreeningResponse`로 그룹화하여 반환합니다. + +### 구조 + +#### **Presentation과 Application 모듈의 분리** +- Presentation 모듈은 Application 모듈과 직접적으로 의존하지 않고, **Port-Adapter 패턴**을 통해 통신합니다. + - **Port**: `ScreeningQueryUseCase` 인터페이스로, Application 모듈에서 구현됩니다. + - **Adapter**: Application 모듈의 실제 구현체가 주입됩니다. + +#### **파라미터 설계** +- 클라이언트로부터 받은 요청(`ScreeningsQueryRequest`)은 Presentation 모듈 내에서 처리됩니다. +- Application 모듈과의 통신에는 별도의 파라미터 객체(`ScreeningsQueryParam`)를 사용하여 **Presentation의 변경이 Application에 영향을 미치지 않도록 설계**했습니다. + +### 주요 클래스 + +#### **`ScreeningController`** +- 클라이언트 요청을 처리하고 응답을 반환하는 API 컨트롤러. + +```java +@RestController +@RequestMapping("/api/v1/screenings") +@RequiredArgsConstructor +public class ScreeningController { + + private final ScreeningQueryUseCase screeningQueryUseCase; + + @GetMapping + public ResponseEntity> getScreenings(ScreeningsQueryRequest request) { + + List screenings = screeningQueryUseCase.getScreenings( + ScreeningsQueryParam.builder() + .maxScreeningDay(request.getMaxScreeningDay()) + .build() + ); + + return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); + } +} +``` + +#### **`ScreeningsQueryRequest`** +- 클라이언트로부터 전달된 요청 데이터를 캡슐화한 클래스. + +```java +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningsQueryRequest { + @Builder.Default + private int maxScreeningDay = 2; +} +``` + +#### **`GroupedScreeningResponse`** +- 영화별로 그룹화된 응답 데이터를 제공하는 DTO. + +```java +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class GroupedScreeningResponse { + private String movieId; + private String movieTitle; + private String rating; + private LocalDate releaseDate; + private String thumbnailUrl; + private int runningMinTime; + private String genreId; + private String genreName; + private List screenings; + + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ScreeningDetail { + private String screeningId; + private LocalDateTime screeningStartTime; + private LocalDateTime screeningEndTime; + private String cinemaId; + private String cinemaName; + } +} +``` \ No newline at end of file diff --git a/module-presentation/build.gradle b/module-presentation/build.gradle index ff7cbe7d5..221daec6e 100644 --- a/module-presentation/build.gradle +++ b/module-presentation/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.springframework.boot' + id 'org.springframework.boot' version '3.4.1' } jar { @@ -20,13 +20,7 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':module-application') + implementation project(':module-domain') implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' } - -//test { -// useJUnitPlatform() -//} \ No newline at end of file diff --git a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java index 9f6ccb2a1..a6e2f4a9e 100644 --- a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java +++ b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java @@ -3,15 +3,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.ComponentScan; -@SpringBootApplication -@ComponentScan(basePackages = { +@SpringBootApplication(scanBasePackages = { "project.redis.application", "project.redis.presentation", "project.redis.infrastructure" }) + public class TheaterApplication { public static void main(String[] args) { SpringApplication.run(TheaterApplication.class, args); diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java new file mode 100644 index 000000000..077379245 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java @@ -0,0 +1,32 @@ +package project.redis.presentation.cinema.controller; + + +import java.util.List; +import lombok.RequiredArgsConstructor; +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.RestController; +import project.redis.application.cinema.port.inbound.CinemaQueryUseCase; +import project.redis.domain.cinema.Cinema; +import project.redis.presentation.cinema.dto.response.CinemaResponse; +import project.redis.presentation.cinema.mapper.CinemaApiMapper; + +@RestController +@RequestMapping("/api/v1/cinemas") +@RequiredArgsConstructor +public class CinemaController { + + private final CinemaQueryUseCase cinemaQueryUseCase; + + @GetMapping + public ResponseEntity> getCinemas() { + List cinemas = cinemaQueryUseCase.getCinemas(); + + List result = cinemas.stream() + .map(CinemaApiMapper::toCinemaResponse) + .toList(); + + return ResponseEntity.ok(result); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/dto/response/CinemaResponse.java b/module-presentation/src/main/java/project/redis/presentation/cinema/dto/response/CinemaResponse.java new file mode 100644 index 000000000..d7de6b4fb --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/dto/response/CinemaResponse.java @@ -0,0 +1,17 @@ +package project.redis.presentation.cinema.dto.response; + + +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CinemaResponse { + private UUID cinemaId; + private String cinemaName; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/mapper/CinemaApiMapper.java b/module-presentation/src/main/java/project/redis/presentation/cinema/mapper/CinemaApiMapper.java new file mode 100644 index 000000000..0a673cf8f --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/mapper/CinemaApiMapper.java @@ -0,0 +1,15 @@ +package project.redis.presentation.cinema.mapper; + +import project.redis.domain.cinema.Cinema; +import project.redis.presentation.cinema.dto.response.CinemaResponse; + +public class CinemaApiMapper { + + public static CinemaResponse toCinemaResponse(Cinema cinema) { + return CinemaResponse.builder() + .cinemaId(cinema.getCinemaId()) + .cinemaName(cinema.getCinemaName()) + .build(); + } + +} diff --git a/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java b/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java index a147f550c..605175ed9 100644 --- a/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java +++ b/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java @@ -7,9 +7,10 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; - -import project.redis.application.movie.dto.MovieResponse; +import project.redis.presentation.movie.dto.response.MovieResponse; import project.redis.application.movie.port.inbound.MovieQueryUseCase; +import project.redis.domain.movie.Movie; +import project.redis.presentation.movie.mapper.MovieApiMapper; @RestController @@ -21,6 +22,12 @@ public class MovieController { @GetMapping public ResponseEntity> getMovie() { - return ResponseEntity.ok(movieQueryUseCase.getMovies()); + List movies = movieQueryUseCase.getMovies(); + + List result = movies.stream() + .map(MovieApiMapper::toMovieResponse) + .toList(); + + return ResponseEntity.ok(result); } } diff --git a/module-presentation/src/main/java/project/redis/presentation/movie/dto/response/MovieResponse.java b/module-presentation/src/main/java/project/redis/presentation/movie/dto/response/MovieResponse.java new file mode 100644 index 000000000..9710be6ec --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/movie/dto/response/MovieResponse.java @@ -0,0 +1,24 @@ +package project.redis.presentation.movie.dto.response; + + +import java.time.LocalDate; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class MovieResponse { + + private UUID movieId; + private String title; + private String rating; + private LocalDate releaseDate; + private String thumbnailUrl; + private int runningTimeMin; + private String genreName; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/movie/mapper/MovieApiMapper.java b/module-presentation/src/main/java/project/redis/presentation/movie/mapper/MovieApiMapper.java new file mode 100644 index 000000000..0b0d719f6 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/movie/mapper/MovieApiMapper.java @@ -0,0 +1,18 @@ +package project.redis.presentation.movie.mapper; + +import project.redis.domain.movie.Movie; +import project.redis.presentation.movie.dto.response.MovieResponse; + +public class MovieApiMapper { + + public static MovieResponse toMovieResponse(Movie movie) { + return MovieResponse.builder() + .movieId(movie.getMovieId()) + .title(movie.getTitle()) + .rating(movie.getRating().toString()) + .releaseDate(movie.getReleaseDate()) + .runningTimeMin(movie.getRunningMinTime()) + .genreName(movie.getGenre().getGenreName()) + .build(); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java b/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java new file mode 100644 index 000000000..c26c82eec --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java @@ -0,0 +1,35 @@ +package project.redis.presentation.screening.controller; + + +import java.util.List; +import lombok.RequiredArgsConstructor; +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.RestController; +import project.redis.application.screening.port.inbound.ScreeningQueryUseCase; +import project.redis.application.screening.port.inbound.ScreeningsQueryParam; +import project.redis.domain.screening.Screening; +import project.redis.presentation.screening.dto.request.ScreeningsQueryRequest; +import project.redis.presentation.screening.dto.response.GroupedScreeningResponse; +import project.redis.presentation.screening.mapper.ScreeningAppMapper; + +@RestController +@RequestMapping("/api/v1/screenings") +@RequiredArgsConstructor +public class ScreeningController { + + private final ScreeningQueryUseCase screeningQueryUseCase; + + @GetMapping + public ResponseEntity> getScreenings(ScreeningsQueryRequest request) { + + List screenings = screeningQueryUseCase.getScreenings( + ScreeningsQueryParam.builder() + .maxScreeningDay(request.getMaxScreeningDay()) + .build() + ); + + return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java b/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java new file mode 100644 index 000000000..e8a9a6be6 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java @@ -0,0 +1,15 @@ +package project.redis.presentation.screening.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningsQueryRequest { + @Builder.Default + private int maxScreeningDay = 2; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/dto/response/GroupedScreeningResponse.java b/module-presentation/src/main/java/project/redis/presentation/screening/dto/response/GroupedScreeningResponse.java new file mode 100644 index 000000000..5f4910af2 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/screening/dto/response/GroupedScreeningResponse.java @@ -0,0 +1,39 @@ +package project.redis.presentation.screening.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class GroupedScreeningResponse { + + private String movieId; + private String movieTitle; + private String rating; + private LocalDate releaseDate; + private String thumbnailUrl; + private int runningMinTime; + private String genreId; + private String genreName; + private List screenings; + + + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ScreeningDetail { + private String screeningId; + private LocalDateTime screeningStartTime; + private LocalDateTime screeningEndTime; + private String cinemaId; + private String cinemaName; + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/mapper/ScreeningAppMapper.java b/module-presentation/src/main/java/project/redis/presentation/screening/mapper/ScreeningAppMapper.java new file mode 100644 index 000000000..959b236d1 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/screening/mapper/ScreeningAppMapper.java @@ -0,0 +1,52 @@ +package project.redis.presentation.screening.mapper; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import project.redis.domain.movie.Movie; +import project.redis.domain.screening.Screening; +import project.redis.presentation.screening.dto.response.GroupedScreeningResponse; +import project.redis.presentation.screening.dto.response.GroupedScreeningResponse.ScreeningDetail; + +public class ScreeningAppMapper { + + public static List toGroupedScreeningResponse(List screenings) { + Map> groupedByMovie = screenings.stream() + .collect(Collectors.groupingBy(screening -> screening.getMovie().getMovieId())); + + return groupedByMovie.entrySet().stream() + .sorted(Comparator.comparing(entry -> + entry.getValue().get(0).getMovie().getReleaseDate(), + Comparator.reverseOrder()) + ) + .map(entry -> { + Movie movie = entry.getValue().get(0).getMovie(); + List screeningDetails = entry.getValue().stream() + .map(screening -> + ScreeningDetail.builder() + .screeningId(screening.getScreeningId().toString()) + .screeningStartTime(screening.getScreenStartTime()) + .screeningEndTime(screening.getScreenEndTime()) + .cinemaId(screening.getCinema().getCinemaId().toString()) + .cinemaName(screening.getCinema().getCinemaName()) + .build() + ) + .toList(); + + return GroupedScreeningResponse.builder() + .movieId(movie.getMovieId().toString()) + .movieTitle(movie.getTitle()) + .rating(movie.getRating().toString()) + .releaseDate(movie.getReleaseDate()) + .thumbnailUrl(movie.getThumbnailUrl()) + .runningMinTime(movie.getRunningMinTime()) + .genreId(movie.getGenre().getGenreId().toString()) + .genreName(movie.getGenre().getGenreName()) + .screenings(screeningDetails) + .build(); + }) + .toList(); + } +} diff --git a/module-presentation/src/main/resources/application.yml b/module-presentation/src/main/resources/application.yaml similarity index 62% rename from module-presentation/src/main/resources/application.yml rename to module-presentation/src/main/resources/application.yaml index c34a9b9a3..cd7148b9f 100644 --- a/module-presentation/src/main/resources/application.yml +++ b/module-presentation/src/main/resources/application.yaml @@ -4,11 +4,21 @@ spring: url: jdbc:mysql://localhost:3309/redis-movie?useSSL=false&allowPublicKeyRetrieval=true username: hongs password: local1234 + jpa: hibernate: - ddl-auto: create-drop + ddl-auto: validate open-in-view: false show-sql: true + properties: + hibernate: + format_sql: true + + flyway: + enabled: true + baseline-on-migrate: true + placeholder-replacement: false + locations: classpath:db/migration logging: level: @@ -18,4 +28,5 @@ logging: type: descriptor: sql: - info \ No newline at end of file + info + diff --git a/module-presentation/src/main/resources/db/migration/V1__CreateInitTable.sql b/module-presentation/src/main/resources/db/migration/V1__CreateInitTable.sql new file mode 100644 index 000000000..dff8960ba --- /dev/null +++ b/module-presentation/src/main/resources/db/migration/V1__CreateInitTable.sql @@ -0,0 +1,75 @@ +-- genre 테이블 생성 +create table genre +( + genre_id binary(16) not null primary key, + genre_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + +-- movie 테이블 생성 +create table movie +( + movie_id binary(16) not null primary key, + title varchar(255) not null, + rating varchar(255) not null, + release_date date not null, + thumbnail_url text, + running_min_time int not null, + genre_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_movie_genre foreign key (genre_id) references genre (genre_id) +) engine = innodb + charset = utf8mb4; + + +-- cinema 테이블 생성 +create table cinema +( + cinema_id binary(16) not null primary key, + cinema_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + + +-- screening 테이블 생성 +create table screening +( + screening_id binary(16) not null primary key, + screening_start_time datetime(6) not null, + screening_end_time datetime(6) not null, + movie_id binary(16) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_screening_movie foreign key (movie_id) references movie (movie_id), + constraint fk_screening_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; + + +-- seat 테이블 생성 +create table seat +( + seat_id binary(16) not null primary key, + seat_number varchar(255) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_seat_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; \ No newline at end of file diff --git a/module-presentation/src/main/resources/db/migration/V2__InitData.sql b/module-presentation/src/main/resources/db/migration/V2__InitData.sql new file mode 100644 index 000000000..ae31805dc --- /dev/null +++ b/module-presentation/src/main/resources/db/migration/V2__InitData.sql @@ -0,0 +1,180 @@ +insert into genre (genre_id, genre_name) +values (uuid_to_bin(uuid()), '액션'), + (uuid_to_bin(uuid()), '코미디'), + (uuid_to_bin(uuid()), '드라마'), + (uuid_to_bin(uuid()), '판타지'), + (uuid_to_bin(uuid()), '로맨스'); + +insert into cinema (cinema_id, cinema_name) +values (uuid_to_bin(uuid()), '스타라이트 상영관'), + (uuid_to_bin(uuid()), '드림씨어터'), + (uuid_to_bin(uuid()), '선셋 극장'), + (uuid_to_bin(uuid()), '루프탑 상영관'), + (uuid_to_bin(uuid()), '클래식 상영관'); + +insert into seat (seat_id, seat_number, cinema_id) +values + -- 루프탑 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + + -- 클래식 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '선셋 극장')); + + +insert into movie (movie_id, title, rating, release_date, thumbnail_url, running_min_time, genre_id) +values + -- 액션 장르 영화 + (uuid_to_bin(uuid()), '매드 맥스: 분노의 도로', 'NINETEEN', '2015-05-15', 'https://example.com/madmax.jpg', 120, + (select genre_id from genre where genre_name = '액션')), + (uuid_to_bin(uuid()), '다이하드', 'NINETEEN', '1988-07-20', 'https://example.com/diehard.jpg', 131, + (select genre_id from genre where genre_name = '액션')), + + -- 코미디 장르 영화 + (uuid_to_bin(uuid()), '슈퍼배드', 'TWELVE', '2010-07-09', 'https://example.com/despicableme.jpg', 95, + (select genre_id from genre where genre_name = '코미디')), + (uuid_to_bin(uuid()), '트루먼 쇼', 'TWELVE', '1998-06-05', 'https://example.com/trumanshow.jpg', 103, + (select genre_id from genre where genre_name = '코미디')), + + -- 드라마 장르 영화 + (uuid_to_bin(uuid()), '쇼생크 탈출', 'FIFTEEN', '1994-09-23', 'https://example.com/shawshank.jpg', 142, + (select genre_id from genre where genre_name = '드라마')), + (uuid_to_bin(uuid()), '포레스트 검프', 'TWELVE', '1994-07-06', 'https://example.com/forrestgump.jpg', 144, + (select genre_id from genre where genre_name = '드라마')), + + -- 판타지 장르 영화 + (uuid_to_bin(uuid()), '반지의 제왕: 반지 원정대', 'FIFTEEN', '2001-12-19', 'https://example.com/lotr.jpg', 178, + (select genre_id from genre where genre_name = '판타지')), + (uuid_to_bin(uuid()), '해리 포터와 마법사의 돌', 'TWELVE', '2001-11-16', 'https://example.com/harrypotter.jpg', 152, + (select genre_id from genre where genre_name = '판타지')), + + -- 로맨스 장르 영화 + (uuid_to_bin(uuid()), '타이타닉', 'FIFTEEN', '1997-12-19', 'https://example.com/titanic.jpg', 195, + (select genre_id from genre where genre_name = '로맨스')), + (uuid_to_bin(uuid()), '노트북', 'FIFTEEN', '2004-06-25', 'https://example.com/notebook.jpg', 123, + (select genre_id from genre where genre_name = '로맨스')); From 713e05b2c4f719a3724c6f3b5754f7b62ca978cc Mon Sep 17 00:00:00 2001 From: hongs429 Date: Thu, 9 Jan 2025 19:22:01 +0900 Subject: [PATCH 06/29] first commit --- .../redisproject1/RedisProject1Application.java | 13 +++++++++++++ src/main/resources/application.properties | 1 + .../RedisProject1ApplicationTests.java | 13 +++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 src/main/java/project/redis/redisproject1/RedisProject1Application.java create mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/project/redis/redisproject1/RedisProject1ApplicationTests.java diff --git a/src/main/java/project/redis/redisproject1/RedisProject1Application.java b/src/main/java/project/redis/redisproject1/RedisProject1Application.java new file mode 100644 index 000000000..e8b074e99 --- /dev/null +++ b/src/main/java/project/redis/redisproject1/RedisProject1Application.java @@ -0,0 +1,13 @@ +package project.redis.redisproject1; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class RedisProject1Application { + + public static void main(String[] args) { + SpringApplication.run(RedisProject1Application.class, args); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 000000000..a7a46819b --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=redis-project-1 diff --git a/src/test/java/project/redis/redisproject1/RedisProject1ApplicationTests.java b/src/test/java/project/redis/redisproject1/RedisProject1ApplicationTests.java new file mode 100644 index 000000000..7f6559044 --- /dev/null +++ b/src/test/java/project/redis/redisproject1/RedisProject1ApplicationTests.java @@ -0,0 +1,13 @@ +package project.redis.redisproject1; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class RedisProject1ApplicationTests { + + @Test + void contextLoads() { + } + +} From c7b8096143cb3dd57f66e839c39f1a0cbe7bbbd2 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Tue, 7 Jan 2025 23:30:26 +0900 Subject: [PATCH 07/29] first commit --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 0d78d9440..1fab15cd4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ #org.gradle.configuration-cache=true #org.gradle.parallel=true -#org.gradle.caching=true +#org.gradle.caching=true \ No newline at end of file From 5f0f369db4dc3f936f590fd5a18e6eb51f65f971 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Wed, 8 Jan 2025 22:13:32 +0900 Subject: [PATCH 08/29] =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20&&=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=EC=9D=98=20=EA=B4=80=EA=B3=84=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../redis/application/domain/Cinema.java | 20 +++++++++++++ .../redis/application/domain/Genre.java | 19 ++++++++++++ .../redis/application/domain/Movie.java | 30 +++++++++++++++++++ .../domain/RatingClassification.java | 18 +++++++++++ .../redis/application/domain/Screening.java | 26 ++++++++++++++++ .../redis/application/domain/Seat.java | 21 +++++++++++++ 6 files changed, 134 insertions(+) create mode 100644 module-application/src/main/java/project/redis/application/domain/Cinema.java create mode 100644 module-application/src/main/java/project/redis/application/domain/Genre.java create mode 100644 module-application/src/main/java/project/redis/application/domain/Movie.java create mode 100644 module-application/src/main/java/project/redis/application/domain/RatingClassification.java create mode 100644 module-application/src/main/java/project/redis/application/domain/Screening.java create mode 100644 module-application/src/main/java/project/redis/application/domain/Seat.java diff --git a/module-application/src/main/java/project/redis/application/domain/Cinema.java b/module-application/src/main/java/project/redis/application/domain/Cinema.java new file mode 100644 index 000000000..3a01961c6 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Cinema.java @@ -0,0 +1,20 @@ +package project.redis.application.domain; + + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Cinema { + UUID cinemaId; + String cinemaName; + + public static Cinema generateCinema(UUID cinemaId, String cinemaName) { + return new Cinema(cinemaId, cinemaName); + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/Genre.java b/module-application/src/main/java/project/redis/application/domain/Genre.java new file mode 100644 index 000000000..97b506cc5 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Genre.java @@ -0,0 +1,19 @@ +package project.redis.application.domain; + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Genre { + UUID genreId; + String genreName; + + public static Genre generateGenre(UUID genreId, String genreName) { + return new Genre(genreId, genreName); + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/Movie.java b/module-application/src/main/java/project/redis/application/domain/Movie.java new file mode 100644 index 000000000..1181be020 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Movie.java @@ -0,0 +1,30 @@ +package project.redis.application.domain; + + +import java.time.LocalDate; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Movie { + UUID movieId; + String title; + RatingClassification rating; + LocalDate releaseDate; + String thumbnailUrl; + int runningTime; + UUID genreId; + + public static Movie generateMovie( + UUID id, String title, + RatingClassification rating, LocalDate releaseDate, + String thumbnailUrl, int runningTime, UUID genreId + ) { + return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningTime, genreId); + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/RatingClassification.java b/module-application/src/main/java/project/redis/application/domain/RatingClassification.java new file mode 100644 index 000000000..46722a273 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/RatingClassification.java @@ -0,0 +1,18 @@ +package project.redis.application.domain; + +import lombok.Getter; + +@Getter +public enum RatingClassification { + ALL("전체관람가"), + TWELVE("12세 이상 관람가"), + FIFTEEN("15세 이상 관람가"), + NINETEEN("19세 이상 관림가"), + RESTRICT("제한상영가"); + + private final String description; + + RatingClassification(String description) { + this.description = description; + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/Screening.java b/module-application/src/main/java/project/redis/application/domain/Screening.java new file mode 100644 index 000000000..c53dee1a2 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Screening.java @@ -0,0 +1,26 @@ +package project.redis.application.domain; + +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Screening { + UUID screeningId; + LocalDateTime screenStartTime; + LocalDateTime screenEndTime; + UUID movieId; + UUID cinemaId; + + public static Screening generateScreening( + UUID screeningId, + LocalDateTime screenStartTime, LocalDateTime screenEndTime, + UUID movieId, UUID cinemaId) { + return new Screening(screeningId, screenStartTime, screenEndTime, movieId, cinemaId); + } +} diff --git a/module-application/src/main/java/project/redis/application/domain/Seat.java b/module-application/src/main/java/project/redis/application/domain/Seat.java new file mode 100644 index 000000000..694a199fa --- /dev/null +++ b/module-application/src/main/java/project/redis/application/domain/Seat.java @@ -0,0 +1,21 @@ +package project.redis.application.domain; + + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Seat { + UUID seatId; + String seatNumber; + UUID cinemaId; + + public static Seat generateSeat(UUID seatId, String seatNumber, UUID cinemaId) { + return new Seat(seatId, seatNumber, cinemaId); + } +} From 02e35555e1e5a6c9ed021f12b6ce9f5cb80f092a Mon Sep 17 00:00:00 2001 From: hongs429 Date: Thu, 9 Jan 2025 19:15:51 +0900 Subject: [PATCH 09/29] commitmm --- .../redis/application/domain/Cinema.java | 20 -------- .../redis/application/domain/Genre.java | 19 ------- .../redis/application/domain/Screening.java | 26 ---------- .../redis/application/domain/Seat.java | 21 -------- .../application/movie/dto/MovieResponse.java | 38 ++++++++++++++ .../movie/port/outbound/MovieQueryPort.java | 8 +++ .../domain/infra/common/BaseJpaEntity.java | 22 ++++++++ .../infra/genre/entity/GenreJpaEntity.java | 31 ++++++++++++ .../infra/movie/entity/MovieJpaEntity.java | 50 +++++++++++++++++++ .../movie/inbound/MovieQueryAdapter.java | 20 ++++++++ .../movie/repository/MovieJpaRepository.java | 8 +++ .../redis/domain/movie/entity}/Movie.java | 10 ++-- .../movie/entity}/RatingClassification.java | 2 +- .../src/main/resources/application.yml | 21 ++++++++ 14 files changed, 204 insertions(+), 92 deletions(-) delete mode 100644 module-application/src/main/java/project/redis/application/domain/Cinema.java delete mode 100644 module-application/src/main/java/project/redis/application/domain/Genre.java delete mode 100644 module-application/src/main/java/project/redis/application/domain/Screening.java delete mode 100644 module-application/src/main/java/project/redis/application/domain/Seat.java create mode 100644 module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java create mode 100644 module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java create mode 100644 module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java create mode 100644 module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java create mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java create mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java create mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java rename {module-application/src/main/java/project/redis/application/domain => module-domain/src/main/java/project/redis/domain/movie/entity}/Movie.java (64%) rename {module-application/src/main/java/project/redis/application/domain => module-domain/src/main/java/project/redis/domain/movie/entity}/RatingClassification.java (89%) create mode 100644 module-presentation/src/main/resources/application.yml diff --git a/module-application/src/main/java/project/redis/application/domain/Cinema.java b/module-application/src/main/java/project/redis/application/domain/Cinema.java deleted file mode 100644 index 3a01961c6..000000000 --- a/module-application/src/main/java/project/redis/application/domain/Cinema.java +++ /dev/null @@ -1,20 +0,0 @@ -package project.redis.application.domain; - - -import java.util.UUID; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Value; - -@Getter -@Value -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Cinema { - UUID cinemaId; - String cinemaName; - - public static Cinema generateCinema(UUID cinemaId, String cinemaName) { - return new Cinema(cinemaId, cinemaName); - } -} diff --git a/module-application/src/main/java/project/redis/application/domain/Genre.java b/module-application/src/main/java/project/redis/application/domain/Genre.java deleted file mode 100644 index 97b506cc5..000000000 --- a/module-application/src/main/java/project/redis/application/domain/Genre.java +++ /dev/null @@ -1,19 +0,0 @@ -package project.redis.application.domain; - -import java.util.UUID; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Value; - -@Getter -@Value -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Genre { - UUID genreId; - String genreName; - - public static Genre generateGenre(UUID genreId, String genreName) { - return new Genre(genreId, genreName); - } -} diff --git a/module-application/src/main/java/project/redis/application/domain/Screening.java b/module-application/src/main/java/project/redis/application/domain/Screening.java deleted file mode 100644 index c53dee1a2..000000000 --- a/module-application/src/main/java/project/redis/application/domain/Screening.java +++ /dev/null @@ -1,26 +0,0 @@ -package project.redis.application.domain; - -import java.time.LocalDateTime; -import java.util.UUID; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Value; - -@Getter -@Value -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Screening { - UUID screeningId; - LocalDateTime screenStartTime; - LocalDateTime screenEndTime; - UUID movieId; - UUID cinemaId; - - public static Screening generateScreening( - UUID screeningId, - LocalDateTime screenStartTime, LocalDateTime screenEndTime, - UUID movieId, UUID cinemaId) { - return new Screening(screeningId, screenStartTime, screenEndTime, movieId, cinemaId); - } -} diff --git a/module-application/src/main/java/project/redis/application/domain/Seat.java b/module-application/src/main/java/project/redis/application/domain/Seat.java deleted file mode 100644 index 694a199fa..000000000 --- a/module-application/src/main/java/project/redis/application/domain/Seat.java +++ /dev/null @@ -1,21 +0,0 @@ -package project.redis.application.domain; - - -import java.util.UUID; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Value; - -@Getter -@Value -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Seat { - UUID seatId; - String seatNumber; - UUID cinemaId; - - public static Seat generateSeat(UUID seatId, String seatNumber, UUID cinemaId) { - return new Seat(seatId, seatNumber, cinemaId); - } -} diff --git a/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java b/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java new file mode 100644 index 000000000..2766eb59b --- /dev/null +++ b/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java @@ -0,0 +1,38 @@ +package project.redis.application.movie.dto; + + +import java.time.LocalDate; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class MovieResponse { + + private UUID movieId; + private String title; + private String rating; + private LocalDate releaseDate; + private String thumbnailUrl; + private int runningTimeMin; + private String genreName; + + public static MovieResponse of( + UUID movieId, String title, String rating, LocalDate releaseDate, + String thumbnailUrl, int runningTimeMin, String genreName) { + return MovieResponse.builder() + .movieId(movieId) + .title(title) + .rating(rating) + .releaseDate(releaseDate) + .thumbnailUrl(thumbnailUrl) + .runningTimeMin(runningTimeMin) + .genreName(genreName) + .build(); + } +} diff --git a/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java new file mode 100644 index 000000000..089c355e5 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java @@ -0,0 +1,8 @@ +package project.redis.application.movie.port.outbound; + +import java.util.List; +import project.redis.domain.movie.entity.Movie; + +public interface MovieQueryPort { + List getMovies(); +} diff --git a/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java new file mode 100644 index 000000000..f293d0b60 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java @@ -0,0 +1,22 @@ +//package project.redis.infrastructure.common; +// +// +//import jakarta.persistence.EntityListeners; +//import jakarta.persistence.MappedSuperclass; +//import java.time.LocalDateTime; +//import lombok.Getter; +//import org.springframework.data.annotation.CreatedDate; +//import org.springframework.data.annotation.LastModifiedDate; +//import org.springframework.data.jpa.domain.support.AuditingEntityListener; +// +//@Getter +//@EntityListeners(AuditingEntityListener.class) +//@MappedSuperclass +//public abstract class BaseJpaEntity { +// @CreatedDate +// private LocalDateTime createdAt; +// +// @LastModifiedDate +// private LocalDateTime updatedAt; +// +//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java new file mode 100644 index 000000000..f49aea7db --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java @@ -0,0 +1,31 @@ +//package project.redis.infrastructure.genre.entity; +// +//import jakarta.persistence.Column; +//import jakarta.persistence.Entity; +//import jakarta.persistence.GeneratedValue; +//import jakarta.persistence.Id; +//import jakarta.persistence.Table; +//import java.util.UUID; +//import lombok.AccessLevel; +//import lombok.AllArgsConstructor; +//import lombok.Builder; +//import lombok.Getter; +//import lombok.NoArgsConstructor; +//import project.redis.infrastructure.common.BaseJpaEntity; +// +// +//@Entity +//@Builder +//@Table(name = "movie") +//@Getter +//@AllArgsConstructor +//@NoArgsConstructor(access = AccessLevel.PROTECTED) +//public class GenreJpaEntity extends BaseJpaEntity { +// @Id +// @GeneratedValue(generator = "uuid") +// @Column(name = "genre_id", columnDefinition = "BINARY(16)") +// private UUID id; +// +// @Column(nullable = false) +// private String name; +//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java new file mode 100644 index 000000000..84c3685d2 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java @@ -0,0 +1,50 @@ +//package project.redis.infrastructure.movie.entity; +// +// +//import static jakarta.persistence.EnumType.STRING; +// +//import jakarta.persistence.Column; +//import jakarta.persistence.Entity; +//import jakarta.persistence.Enumerated; +//import jakarta.persistence.GeneratedValue; +//import jakarta.persistence.Id; +//import jakarta.persistence.Table; +//import java.time.LocalDate; +//import java.util.UUID; +//import lombok.AccessLevel; +//import lombok.AllArgsConstructor; +//import lombok.Builder; +//import lombok.Getter; +//import lombok.NoArgsConstructor; +//import project.redis.domain.movie.entity.RatingClassification; +//import project.redis.infrastructure.common.BaseJpaEntity; +// +//@Entity +//@Builder +//@Table(name = "movie") +//@Getter +//@AllArgsConstructor +//@NoArgsConstructor(access = AccessLevel.PROTECTED) +//public class MovieJpaEntity extends BaseJpaEntity { +// +// @Id +// @GeneratedValue(generator = "uuid") +// @Column(name = "movie_id", columnDefinition = "BINARY(16)") +// private UUID id; +// +// @Column(nullable = false) +// private String title; +// +// @Column(nullable = false) +// @Enumerated(value = STRING) +// private RatingClassification rating; +// +// @Column(nullable = false) +// private LocalDate releaseDate; +// +// @Column(columnDefinition = "TEXT") +// private String thumbnailUrl; +// +// @Column(nullable = false) +// private int runningMinTime; +//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java b/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java new file mode 100644 index 000000000..f04502ecc --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java @@ -0,0 +1,20 @@ +//package project.redis.infrastructure.movie.inbound; +// +//import java.util.List; +//import lombok.RequiredArgsConstructor; +//import org.springframework.stereotype.Component; +//import project.redis.application.movie.port.outbound.MovieQueryPort; +//import project.redis.domain.movie.entity.Movie; +//import project.redis.infrastructure.movie.repository.MovieJpaRepository; +// +//@Component +//@RequiredArgsConstructor +//public class MovieQueryAdapter implements MovieQueryPort { +// +// private final MovieJpaRepository movieJpaRepository; +// +// @Override +// public List getMovies() { +// return List.of(); +// } +//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java b/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java new file mode 100644 index 000000000..ad407f2e6 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java @@ -0,0 +1,8 @@ +//package project.redis.infrastructure.movie.repository; +// +//import java.util.UUID; +//import org.springframework.data.jpa.repository.JpaRepository; +//import project.redis.infrastructure.movie.entity.MovieJpaEntity; +// +//public interface MovieJpaRepository extends JpaRepository { +//} diff --git a/module-application/src/main/java/project/redis/application/domain/Movie.java b/module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java similarity index 64% rename from module-application/src/main/java/project/redis/application/domain/Movie.java rename to module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java index 1181be020..fb3cf58be 100644 --- a/module-application/src/main/java/project/redis/application/domain/Movie.java +++ b/module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java @@ -1,5 +1,4 @@ -package project.redis.application.domain; - +package project.redis.domain.movie.entity; import java.time.LocalDate; import java.util.UUID; @@ -17,14 +16,15 @@ public class Movie { RatingClassification rating; LocalDate releaseDate; String thumbnailUrl; - int runningTime; + int runningMinTime; + //TODO: 도메인에서 최소정보로 id 데이터만 가지고 있게되니, 결과적으로 디비를 한번 더 찔러야하는 상황이 생긴다... UUID genreId; public static Movie generateMovie( UUID id, String title, RatingClassification rating, LocalDate releaseDate, - String thumbnailUrl, int runningTime, UUID genreId + String thumbnailUrl, int runningMinTime, UUID genreId ) { - return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningTime, genreId); + return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningMinTime, genreId); } } diff --git a/module-application/src/main/java/project/redis/application/domain/RatingClassification.java b/module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java similarity index 89% rename from module-application/src/main/java/project/redis/application/domain/RatingClassification.java rename to module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java index 46722a273..cc01b80a3 100644 --- a/module-application/src/main/java/project/redis/application/domain/RatingClassification.java +++ b/module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java @@ -1,4 +1,4 @@ -package project.redis.application.domain; +package project.redis.domain.movie.entity; import lombok.Getter; diff --git a/module-presentation/src/main/resources/application.yml b/module-presentation/src/main/resources/application.yml new file mode 100644 index 000000000..c34a9b9a3 --- /dev/null +++ b/module-presentation/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3309/redis-movie?useSSL=false&allowPublicKeyRetrieval=true + username: hongs + password: local1234 + jpa: + hibernate: + ddl-auto: create-drop + open-in-view: false + show-sql: true + +logging: + level: + org: + hibernate: + SQL: info + type: + descriptor: + sql: + info \ No newline at end of file From e40aaf91da7d0cffdf8e73c6b66b0bae1c240fa2 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Sun, 12 Jan 2025 04:46:47 +0900 Subject: [PATCH 10/29] =?UTF-8?q?commit=20=EB=82=B4=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module 구분: presentation, application, infrastructure, domain - 각 모듈이 독립적으로 작업가능하도록 SRP 적용 - Port-Adapter 패턴으로 명확한 의존관계 설정 - 프로잭트 설명은 루트의 README.md 에 기재 - 각 모듈의 기능과 설명은 README.md 에 명확하게 기재 - 급하게 하다보니 커밋을 나누지 못했습니다. 대신, 그만큼 자세하게 README.md에 기재했으니 참고 부탁드립니다 --- .../application/movie/dto/MovieResponse.java | 38 -------------- .../movie/port/outbound/MovieQueryPort.java | 8 --- .../domain/infra/common/BaseJpaEntity.java | 22 -------- .../infra/genre/entity/GenreJpaEntity.java | 31 ------------ .../infra/movie/entity/MovieJpaEntity.java | 50 ------------------- .../movie/inbound/MovieQueryAdapter.java | 20 -------- .../movie/repository/MovieJpaRepository.java | 8 --- .../redis/domain/movie/entity/Movie.java | 30 ----------- .../movie/entity/RatingClassification.java | 18 ------- .../src/main/resources/application.yml | 21 -------- 10 files changed, 246 deletions(-) delete mode 100644 module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java delete mode 100644 module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java delete mode 100644 module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java delete mode 100644 module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java delete mode 100644 module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java delete mode 100644 module-presentation/src/main/resources/application.yml diff --git a/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java b/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java deleted file mode 100644 index 2766eb59b..000000000 --- a/module-application/src/main/java/project/redis/application/movie/dto/MovieResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -package project.redis.application.movie.dto; - - -import java.time.LocalDate; -import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class MovieResponse { - - private UUID movieId; - private String title; - private String rating; - private LocalDate releaseDate; - private String thumbnailUrl; - private int runningTimeMin; - private String genreName; - - public static MovieResponse of( - UUID movieId, String title, String rating, LocalDate releaseDate, - String thumbnailUrl, int runningTimeMin, String genreName) { - return MovieResponse.builder() - .movieId(movieId) - .title(title) - .rating(rating) - .releaseDate(releaseDate) - .thumbnailUrl(thumbnailUrl) - .runningTimeMin(runningTimeMin) - .genreName(genreName) - .build(); - } -} diff --git a/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java deleted file mode 100644 index 089c355e5..000000000 --- a/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java +++ /dev/null @@ -1,8 +0,0 @@ -package project.redis.application.movie.port.outbound; - -import java.util.List; -import project.redis.domain.movie.entity.Movie; - -public interface MovieQueryPort { - List getMovies(); -} diff --git a/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java deleted file mode 100644 index f293d0b60..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/common/BaseJpaEntity.java +++ /dev/null @@ -1,22 +0,0 @@ -//package project.redis.infrastructure.common; -// -// -//import jakarta.persistence.EntityListeners; -//import jakarta.persistence.MappedSuperclass; -//import java.time.LocalDateTime; -//import lombok.Getter; -//import org.springframework.data.annotation.CreatedDate; -//import org.springframework.data.annotation.LastModifiedDate; -//import org.springframework.data.jpa.domain.support.AuditingEntityListener; -// -//@Getter -//@EntityListeners(AuditingEntityListener.class) -//@MappedSuperclass -//public abstract class BaseJpaEntity { -// @CreatedDate -// private LocalDateTime createdAt; -// -// @LastModifiedDate -// private LocalDateTime updatedAt; -// -//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java deleted file mode 100644 index f49aea7db..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/genre/entity/GenreJpaEntity.java +++ /dev/null @@ -1,31 +0,0 @@ -//package project.redis.infrastructure.genre.entity; -// -//import jakarta.persistence.Column; -//import jakarta.persistence.Entity; -//import jakarta.persistence.GeneratedValue; -//import jakarta.persistence.Id; -//import jakarta.persistence.Table; -//import java.util.UUID; -//import lombok.AccessLevel; -//import lombok.AllArgsConstructor; -//import lombok.Builder; -//import lombok.Getter; -//import lombok.NoArgsConstructor; -//import project.redis.infrastructure.common.BaseJpaEntity; -// -// -//@Entity -//@Builder -//@Table(name = "movie") -//@Getter -//@AllArgsConstructor -//@NoArgsConstructor(access = AccessLevel.PROTECTED) -//public class GenreJpaEntity extends BaseJpaEntity { -// @Id -// @GeneratedValue(generator = "uuid") -// @Column(name = "genre_id", columnDefinition = "BINARY(16)") -// private UUID id; -// -// @Column(nullable = false) -// private String name; -//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java b/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java deleted file mode 100644 index 84c3685d2..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/movie/entity/MovieJpaEntity.java +++ /dev/null @@ -1,50 +0,0 @@ -//package project.redis.infrastructure.movie.entity; -// -// -//import static jakarta.persistence.EnumType.STRING; -// -//import jakarta.persistence.Column; -//import jakarta.persistence.Entity; -//import jakarta.persistence.Enumerated; -//import jakarta.persistence.GeneratedValue; -//import jakarta.persistence.Id; -//import jakarta.persistence.Table; -//import java.time.LocalDate; -//import java.util.UUID; -//import lombok.AccessLevel; -//import lombok.AllArgsConstructor; -//import lombok.Builder; -//import lombok.Getter; -//import lombok.NoArgsConstructor; -//import project.redis.domain.movie.entity.RatingClassification; -//import project.redis.infrastructure.common.BaseJpaEntity; -// -//@Entity -//@Builder -//@Table(name = "movie") -//@Getter -//@AllArgsConstructor -//@NoArgsConstructor(access = AccessLevel.PROTECTED) -//public class MovieJpaEntity extends BaseJpaEntity { -// -// @Id -// @GeneratedValue(generator = "uuid") -// @Column(name = "movie_id", columnDefinition = "BINARY(16)") -// private UUID id; -// -// @Column(nullable = false) -// private String title; -// -// @Column(nullable = false) -// @Enumerated(value = STRING) -// private RatingClassification rating; -// -// @Column(nullable = false) -// private LocalDate releaseDate; -// -// @Column(columnDefinition = "TEXT") -// private String thumbnailUrl; -// -// @Column(nullable = false) -// private int runningMinTime; -//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java b/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java deleted file mode 100644 index f04502ecc..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/movie/inbound/MovieQueryAdapter.java +++ /dev/null @@ -1,20 +0,0 @@ -//package project.redis.infrastructure.movie.inbound; -// -//import java.util.List; -//import lombok.RequiredArgsConstructor; -//import org.springframework.stereotype.Component; -//import project.redis.application.movie.port.outbound.MovieQueryPort; -//import project.redis.domain.movie.entity.Movie; -//import project.redis.infrastructure.movie.repository.MovieJpaRepository; -// -//@Component -//@RequiredArgsConstructor -//public class MovieQueryAdapter implements MovieQueryPort { -// -// private final MovieJpaRepository movieJpaRepository; -// -// @Override -// public List getMovies() { -// return List.of(); -// } -//} diff --git a/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java b/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java deleted file mode 100644 index ad407f2e6..000000000 --- a/module-domain/src/main/java/project/redis/domain/infra/movie/repository/MovieJpaRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -//package project.redis.infrastructure.movie.repository; -// -//import java.util.UUID; -//import org.springframework.data.jpa.repository.JpaRepository; -//import project.redis.infrastructure.movie.entity.MovieJpaEntity; -// -//public interface MovieJpaRepository extends JpaRepository { -//} diff --git a/module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java b/module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java deleted file mode 100644 index fb3cf58be..000000000 --- a/module-domain/src/main/java/project/redis/domain/movie/entity/Movie.java +++ /dev/null @@ -1,30 +0,0 @@ -package project.redis.domain.movie.entity; - -import java.time.LocalDate; -import java.util.UUID; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Value; - -@Getter -@Value -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Movie { - UUID movieId; - String title; - RatingClassification rating; - LocalDate releaseDate; - String thumbnailUrl; - int runningMinTime; - //TODO: 도메인에서 최소정보로 id 데이터만 가지고 있게되니, 결과적으로 디비를 한번 더 찔러야하는 상황이 생긴다... - UUID genreId; - - public static Movie generateMovie( - UUID id, String title, - RatingClassification rating, LocalDate releaseDate, - String thumbnailUrl, int runningMinTime, UUID genreId - ) { - return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningMinTime, genreId); - } -} diff --git a/module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java b/module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java deleted file mode 100644 index cc01b80a3..000000000 --- a/module-domain/src/main/java/project/redis/domain/movie/entity/RatingClassification.java +++ /dev/null @@ -1,18 +0,0 @@ -package project.redis.domain.movie.entity; - -import lombok.Getter; - -@Getter -public enum RatingClassification { - ALL("전체관람가"), - TWELVE("12세 이상 관람가"), - FIFTEEN("15세 이상 관람가"), - NINETEEN("19세 이상 관림가"), - RESTRICT("제한상영가"); - - private final String description; - - RatingClassification(String description) { - this.description = description; - } -} diff --git a/module-presentation/src/main/resources/application.yml b/module-presentation/src/main/resources/application.yml deleted file mode 100644 index c34a9b9a3..000000000 --- a/module-presentation/src/main/resources/application.yml +++ /dev/null @@ -1,21 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://localhost:3309/redis-movie?useSSL=false&allowPublicKeyRetrieval=true - username: hongs - password: local1234 - jpa: - hibernate: - ddl-auto: create-drop - open-in-view: false - show-sql: true - -logging: - level: - org: - hibernate: - SQL: info - type: - descriptor: - sql: - info \ No newline at end of file From 146d9edb8658afe3609a830b855d61985746d026 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Sun, 12 Jan 2025 20:21:20 +0900 Subject: [PATCH 11/29] =?UTF-8?q?git=20=EC=A0=95=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../redisproject1/RedisProject1Application.java | 13 ------------- src/main/resources/application.properties | 1 - .../RedisProject1ApplicationTests.java | 13 ------------- 3 files changed, 27 deletions(-) delete mode 100644 src/main/java/project/redis/redisproject1/RedisProject1Application.java delete mode 100644 src/main/resources/application.properties delete mode 100644 src/test/java/project/redis/redisproject1/RedisProject1ApplicationTests.java diff --git a/src/main/java/project/redis/redisproject1/RedisProject1Application.java b/src/main/java/project/redis/redisproject1/RedisProject1Application.java deleted file mode 100644 index e8b074e99..000000000 --- a/src/main/java/project/redis/redisproject1/RedisProject1Application.java +++ /dev/null @@ -1,13 +0,0 @@ -package project.redis.redisproject1; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class RedisProject1Application { - - public static void main(String[] args) { - SpringApplication.run(RedisProject1Application.class, args); - } - -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index a7a46819b..000000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=redis-project-1 diff --git a/src/test/java/project/redis/redisproject1/RedisProject1ApplicationTests.java b/src/test/java/project/redis/redisproject1/RedisProject1ApplicationTests.java deleted file mode 100644 index 7f6559044..000000000 --- a/src/test/java/project/redis/redisproject1/RedisProject1ApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package project.redis.redisproject1; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class RedisProject1ApplicationTests { - - @Test - void contextLoads() { - } - -} From f8168858dd4d41a412ba86435f3aeadb854a8183 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Sun, 12 Jan 2025 20:57:32 +0900 Subject: [PATCH 12/29] feat(cinema) : create cinema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CinemaCommandAdapter 에 TODO 남김(exception 처리를 위한 공통 모듈 필요성) - 각 모듈 별로 독립적인 cinema 생성 코드 작성 --- .../port/inbound/CinemaCommandUseCase.java | 6 ++++ .../inbound/CinemaCreateCommandParam.java | 14 +++++++++ .../cinema/service/CinemaCommandService.java | 21 +++++++++++++ .../inbound/port/CinemaCommandAdapter.java | 31 +++++++++++++++++++ .../inbound/port/CinemaCommandPort.java | 6 ++++ .../repository/CinemaJpaRepository.java | 3 ++ .../cinema/controller/CinemaController.java | 19 ++++++++++++ .../dto/request/CinemaCreateRequest.java | 20 ++++++++++++ 8 files changed, 120 insertions(+) create mode 100644 module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCommandUseCase.java create mode 100644 module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java create mode 100644 module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/cinema/dto/request/CinemaCreateRequest.java diff --git a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCommandUseCase.java b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCommandUseCase.java new file mode 100644 index 000000000..190c682ca --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCommandUseCase.java @@ -0,0 +1,6 @@ +package project.redis.application.cinema.port.inbound; + +public interface CinemaCommandUseCase { + + void createCinema(CinemaCreateCommandParam param); +} diff --git a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java new file mode 100644 index 000000000..7bc856aa0 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java @@ -0,0 +1,14 @@ +package project.redis.application.cinema.port.inbound; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CinemaCreateCommandParam { + private String CinemaName; +} diff --git a/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java b/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java new file mode 100644 index 000000000..51d4d10d0 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java @@ -0,0 +1,21 @@ +package project.redis.application.cinema.service; + + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.cinema.port.inbound.CinemaCommandUseCase; +import project.redis.application.cinema.port.inbound.CinemaCreateCommandParam; +import project.redis.infrastructure.cinema.inbound.port.CinemaCommandPort; + + +@Service +@RequiredArgsConstructor +public class CinemaCommandService implements CinemaCommandUseCase { + + private final CinemaCommandPort cinemaCommandPort; + + @Override + public void createCinema(CinemaCreateCommandParam param) { + cinemaCommandPort.createCinema(param.getCinemaName()); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java new file mode 100644 index 000000000..af245b5fe --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java @@ -0,0 +1,31 @@ +package project.redis.infrastructure.cinema.inbound.port; + + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; + +@Component +@RequiredArgsConstructor +public class CinemaCommandAdapter implements CinemaCommandPort { + + private final CinemaJpaRepository cinemaJpaRepository; + + @Override + public void createCinema(String cinemaName) { + Optional cinemaOptional = cinemaJpaRepository.findByCinemaName(cinemaName); + + //TODO: 예외 처리를 담당하는 Exception, ExceptionHandler를 모아두는 모듈 필요 + if (cinemaOptional.isPresent()) { + throw new IllegalArgumentException("Cinema with name '" + cinemaName + "' already exists"); + } + + cinemaJpaRepository.save( + CinemaJpaEntity.builder() + .cinemaName(cinemaName) + .build() + ); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java new file mode 100644 index 000000000..f88f7cf5c --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java @@ -0,0 +1,6 @@ +package project.redis.infrastructure.cinema.inbound.port; + +public interface CinemaCommandPort { + + void createCinema(String cinemaName); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java index 0d7f35479..057b46452 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java @@ -1,8 +1,11 @@ package project.redis.infrastructure.cinema.repository; +import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; public interface CinemaJpaRepository extends JpaRepository { + + Optional findByCinemaName(String cinemaName); } diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java index 077379245..b643cf6fc 100644 --- a/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java @@ -1,14 +1,21 @@ package project.redis.presentation.cinema.controller; +import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import project.redis.application.cinema.port.inbound.CinemaCommandUseCase; +import project.redis.application.cinema.port.inbound.CinemaCreateCommandParam; import project.redis.application.cinema.port.inbound.CinemaQueryUseCase; import project.redis.domain.cinema.Cinema; +import project.redis.presentation.cinema.dto.request.CinemaCreateRequest; import project.redis.presentation.cinema.dto.response.CinemaResponse; import project.redis.presentation.cinema.mapper.CinemaApiMapper; @@ -18,6 +25,7 @@ public class CinemaController { private final CinemaQueryUseCase cinemaQueryUseCase; + private final CinemaCommandUseCase cinemaCommandUseCase; @GetMapping public ResponseEntity> getCinemas() { @@ -29,4 +37,15 @@ public ResponseEntity> getCinemas() { return ResponseEntity.ok(result); } + + @PostMapping + public ResponseEntity createCinema(@RequestBody @Valid CinemaCreateRequest request) { + cinemaCommandUseCase.createCinema( + CinemaCreateCommandParam.builder() + .CinemaName(request.getCinemaName()) + .build() + ); + + return ResponseEntity.status(HttpStatus.CREATED).build(); + } } diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/dto/request/CinemaCreateRequest.java b/module-presentation/src/main/java/project/redis/presentation/cinema/dto/request/CinemaCreateRequest.java new file mode 100644 index 000000000..db7ad33cd --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/dto/request/CinemaCreateRequest.java @@ -0,0 +1,20 @@ +package project.redis.presentation.cinema.dto.request; + + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CinemaCreateRequest { + + @NotNull + @NotBlank + private String cinemaName; +} From 9551e01d156ebb3fcfe7964fd0c87095a9bc75ea Mon Sep 17 00:00:00 2001 From: hongs429 Date: Mon, 13 Jan 2025 23:57:47 +0900 Subject: [PATCH 13/29] feat(cinema) : create cinema TC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 간단하게 application 테스트 코드 추가 --- .../service/CinemaCommandServiceTest.java | 59 +++++++++++++++++++ .../inbound/port/CinemaCommandAdapter.java | 2 +- .../inbound/port/CinemaCommandPort.java | 2 +- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java diff --git a/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java new file mode 100644 index 000000000..87785466b --- /dev/null +++ b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java @@ -0,0 +1,59 @@ +package project.redis.application.cinema.service; + + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import project.redis.application.cinema.port.inbound.CinemaCreateCommandParam; +import project.redis.infrastructure.cinema.inbound.port.CinemaCommandPort; + +@ExtendWith(MockitoExtension.class) +class CinemaCommandServiceTest { + + @Mock + CinemaCommandPort cinemaCommandPort; + + @InjectMocks + CinemaCommandService cinemaCommandService; + + + @DisplayName("상영관 생성 - 성공") + @Test + void testCreateCinema() { + String cinemaName = "cinema"; + CinemaCreateCommandParam param = CinemaCreateCommandParam.builder() + .CinemaName(cinemaName) + .build(); + + doNothing().when(cinemaCommandPort).createCinema(param.getCinemaName()); + + cinemaCommandService.createCinema(param); + + verify(cinemaCommandPort).createCinema(param.getCinemaName()); + } + + @DisplayName("상영관 생성 - 실패 - 이미 존재하는 상영관 이름") + @Test + void testCreateCinemaWithInvalidCinemaName() { + String cinemaName = "cinema"; + CinemaCreateCommandParam param = CinemaCreateCommandParam.builder() + .CinemaName(cinemaName) + .build(); + + doThrow(IllegalArgumentException.class) + .when(cinemaCommandPort).createCinema(param.getCinemaName()); + + Assertions.assertThrows(IllegalArgumentException.class, () -> cinemaCommandService.createCinema(param)); + + verify(cinemaCommandPort).createCinema(param.getCinemaName()); + } + +} \ No newline at end of file diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java index af245b5fe..3a539ec00 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java @@ -14,7 +14,7 @@ public class CinemaCommandAdapter implements CinemaCommandPort { private final CinemaJpaRepository cinemaJpaRepository; @Override - public void createCinema(String cinemaName) { + public void createCinema(String cinemaName) throws IllegalArgumentException { Optional cinemaOptional = cinemaJpaRepository.findByCinemaName(cinemaName); //TODO: 예외 처리를 담당하는 Exception, ExceptionHandler를 모아두는 모듈 필요 diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java index f88f7cf5c..b7658ba54 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java @@ -2,5 +2,5 @@ public interface CinemaCommandPort { - void createCinema(String cinemaName); + void createCinema(String cinemaName) throws IllegalArgumentException; } From 5c7d8b6178befdcf6ac25cd5f64c64273a46eebd Mon Sep 17 00:00:00 2001 From: hongs429 Date: Mon, 13 Jan 2025 23:58:51 +0900 Subject: [PATCH 14/29] fix(build.gradle) : update dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 각 모듈 별 테스트 환경 구축 --- build.gradle | 25 +++++++++++++++---------- module-application/build.gradle | 3 +++ module-domain/build.gradle | 12 ++++++++++++ module-infrastructure/build.gradle | 2 ++ module-presentation/build.gradle | 3 +++ 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/build.gradle b/build.gradle index 076191c51..29ac2bbdf 100644 --- a/build.gradle +++ b/build.gradle @@ -11,14 +11,9 @@ repositories { mavenCentral() } -dependencyManagement { - imports { - mavenBom 'org.springframework.boot:spring-boot-dependencies:3.4.1' - } -} - subprojects { apply plugin: 'java' + apply plugin: 'java-library' apply plugin: 'io.spring.dependency-management' java { @@ -26,6 +21,12 @@ subprojects { targetCompatibility = JavaVersion.VERSION_21 } + dependencyManagement { + imports { + mavenBom 'org.springframework.boot:spring-boot-dependencies:3.4.1' + } + } + configurations { compileOnly { extendsFrom annotationProcessor @@ -44,17 +45,21 @@ subprojects { testCompileOnly 'org.projectlombok:lombok:1.18.36' testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' - testImplementation 'org.springframework.boot:spring-boot-starter-test' -// testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } + tasks.withType(JavaCompile) { + options.encoding = 'UTF-8' + } - test { - useJUnitPlatform() + tasks { + test { + useJUnitPlatform() + } } } + jar { enabled = false } \ No newline at end of file diff --git a/module-application/build.gradle b/module-application/build.gradle index 9d28f40a7..4fcf7c336 100644 --- a/module-application/build.gradle +++ b/module-application/build.gradle @@ -22,4 +22,7 @@ dependencies { implementation project(':module-domain') implementation project(':module-infrastructure') implementation 'org.springframework.boot:spring-boot-starter' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } \ No newline at end of file diff --git a/module-domain/build.gradle b/module-domain/build.gradle index ad220e67d..724e0a6cb 100644 --- a/module-domain/build.gradle +++ b/module-domain/build.gradle @@ -1,5 +1,17 @@ +plugins { + id 'io.spring.dependency-management' version '1.1.7' +} group = 'project.redis.domain' version = '0.0.1-SNAPSHOT' +dependencies { + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-params") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + + testImplementation("org.assertj:assertj-core") + testImplementation("org.mockito:mockito-core") + testImplementation("org.mockito:mockito-junit-jupiter") +} diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle index f67c09ed7..47df7050d 100644 --- a/module-infrastructure/build.gradle +++ b/module-infrastructure/build.gradle @@ -25,4 +25,6 @@ dependencies { implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/module-presentation/build.gradle b/module-presentation/build.gradle index 221daec6e..da09ce1af 100644 --- a/module-presentation/build.gradle +++ b/module-presentation/build.gradle @@ -23,4 +23,7 @@ dependencies { implementation project(':module-domain') implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } From 2e0f48ce596549f9503d94dbc584c77a6ec2e99e Mon Sep 17 00:00:00 2001 From: hongs429 Date: Tue, 14 Jan 2025 00:06:07 +0900 Subject: [PATCH 15/29] feat(domain) : create domain validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최소한의 도메인 생성 시, 검증 로직 추가 --- .../redis/domain/screening/Screening.java | 6 +++ .../redis/domain/screening/ScreeningTest.java | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 module-domain/src/test/java/project/redis/domain/screening/ScreeningTest.java diff --git a/module-domain/src/main/java/project/redis/domain/screening/Screening.java b/module-domain/src/main/java/project/redis/domain/screening/Screening.java index 8f483b580..c1d18ae13 100644 --- a/module-domain/src/main/java/project/redis/domain/screening/Screening.java +++ b/module-domain/src/main/java/project/redis/domain/screening/Screening.java @@ -23,6 +23,12 @@ public static Screening generateScreening( UUID screeningId, LocalDateTime screenStartTime, LocalDateTime screenEndTime, Movie movie, Cinema cinema) { + assert screeningId != null; + assert screenStartTime != null; + assert screenEndTime != null; + assert movie != null; + assert cinema != null; + return new Screening(screeningId, screenStartTime, screenEndTime, movie, cinema); } } diff --git a/module-domain/src/test/java/project/redis/domain/screening/ScreeningTest.java b/module-domain/src/test/java/project/redis/domain/screening/ScreeningTest.java new file mode 100644 index 000000000..cc22f8f46 --- /dev/null +++ b/module-domain/src/test/java/project/redis/domain/screening/ScreeningTest.java @@ -0,0 +1,47 @@ +package project.redis.domain.screening; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import project.redis.domain.cinema.Cinema; +import project.redis.domain.movie.Movie; + +class ScreeningTest { + + + @Test + void testGenerateScreening() { + + Movie movieMock = Mockito.mock(Movie.class); + Cinema cinemaMock = Mockito.mock(Cinema.class); + UUID screeningId = UUID.randomUUID(); + LocalDateTime start = LocalDateTime.now(); + LocalDateTime end = start.plusMinutes(100); + + Screening result = Screening.generateScreening(screeningId, start, end, movieMock, cinemaMock); + + assertThat(result.getScreeningId()).isEqualTo(screeningId); + assertThat(result.getScreenStartTime()).isEqualTo(start); + assertThat(result.getScreenEndTime()).isEqualTo(end); + assertThat(result.getMovie()).isEqualTo(movieMock); + assertThat(result.getCinema()).isEqualTo(cinemaMock); + } + + @Test + void testGenerateScreeningException() { + + Movie movieMock = Mockito.mock(Movie.class); + UUID screeningId = UUID.randomUUID(); + LocalDateTime start = LocalDateTime.now(); + LocalDateTime end = start.plusMinutes(100); + + Assertions.assertThrows( + AssertionError.class, + ()-> Screening.generateScreening(screeningId, start, end, movieMock, null)); + } + +} \ No newline at end of file From 10b87392c33e62dfe5316cc23ec109d1a9c99a3c Mon Sep 17 00:00:00 2001 From: hongs429 Date: Tue, 14 Jan 2025 00:24:02 +0900 Subject: [PATCH 16/29] fix(jpa entity) : drop foreign keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이번 프로잭트의 취지에 맞도록 foreign key 참조는 전부 제거 - flyway 로 foreign key 제거 후 이력 관리 - application 레벨에서 키를 관리 - application 레벨에서 키를 관리함을 명시적으로 설정 - foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT) - 곁다리로 임시 데이터 생성 스크립트 주석 처리(지우긴 아쉬워) --- .../infrastructure/ScreeningDataInit.java | 140 +++++++++--------- .../movie/entity/MovieJpaEntity.java | 5 +- .../screening/entity/ScreeningJpaEntity.java | 8 +- .../seat/entity/SeatJpaEntity.java | 5 +- .../migration/V3__DropForeignKeyAllTables.sql | 4 + 5 files changed, 88 insertions(+), 74 deletions(-) create mode 100644 module-presentation/src/main/resources/db/migration/V3__DropForeignKeyAllTables.sql diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java b/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java index 69ff8867e..3034ad1c5 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java @@ -1,70 +1,70 @@ -package project.redis.infrastructure; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Random; -import java.util.concurrent.ThreadLocalRandom; -import java.util.stream.Stream; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.CommandLineRunner; -import org.springframework.stereotype.Component; -import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; -import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; -import project.redis.infrastructure.movie.entity.MovieJpaEntity; -import project.redis.infrastructure.movie.repository.MovieJpaRepository; -import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; -import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; - -@Component -@RequiredArgsConstructor -public class ScreeningDataInit implements CommandLineRunner { - - private final ScreeningJpaRepository screeningJpaRepository; - private final MovieJpaRepository movieJpaRepository; - private final CinemaJpaRepository cinemaJpaRepository; - - private static final Random RANDOM = new Random(); - - @Override - public void run(String... args) throws Exception { - List movies = movieJpaRepository.findAll(); - List cinemas = cinemaJpaRepository.findAll(); - - Stream.iterate(0, i -> i + 1) - .limit(500) - .parallel() - .map(index -> { - - MovieJpaEntity movieJpaEntity = movies.get(RANDOM.nextInt(movies.size())); - CinemaJpaEntity cinemaJpaEntity = cinemas.get(RANDOM.nextInt(cinemas.size())); - - LocalDateTime startTime = generateRandomStartTime(); - LocalDateTime endTime = startTime.plusMinutes(movieJpaEntity.getRunningMinTime()); - - return ScreeningJpaEntity.builder() - .screeningStartTime(startTime) - .screeningEndTime(endTime) - .movie(movieJpaEntity) - .cinema(cinemaJpaEntity) - .build(); - - }) - .forEach(screeningJpaRepository::save); - - } - - public LocalDateTime generateRandomStartTime() { - LocalDate today = LocalDate.now(); - LocalDate startDate = today.plusDays(1); - LocalDate endDate = today.plusDays(20); - - long randomDays = ThreadLocalRandom.current() - .nextLong(ChronoUnit.DAYS.between(startDate, endDate) + 1); - - return startDate - .plusDays(randomDays) - .atTime(new Random().nextInt(18), new Random().nextInt(60)); - } -} +//package project.redis.infrastructure; +// +//import java.time.LocalDate; +//import java.time.LocalDateTime; +//import java.time.temporal.ChronoUnit; +//import java.util.List; +//import java.util.Random; +//import java.util.concurrent.ThreadLocalRandom; +//import java.util.stream.Stream; +//import lombok.RequiredArgsConstructor; +//import org.springframework.boot.CommandLineRunner; +//import org.springframework.stereotype.Component; +//import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +//import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; +//import project.redis.infrastructure.movie.entity.MovieJpaEntity; +//import project.redis.infrastructure.movie.repository.MovieJpaRepository; +//import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +//import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; +// +//@Component +//@RequiredArgsConstructor +//public class ScreeningDataInit implements CommandLineRunner { +// +// private final ScreeningJpaRepository screeningJpaRepository; +// private final MovieJpaRepository movieJpaRepository; +// private final CinemaJpaRepository cinemaJpaRepository; +// +// private static final Random RANDOM = new Random(); +// +// @Override +// public void run(String... args) throws Exception { +// List movies = movieJpaRepository.findAll(); +// List cinemas = cinemaJpaRepository.findAll(); +// +// Stream.iterate(0, i -> i + 1) +// .limit(500) +// .parallel() +// .map(index -> { +// +// MovieJpaEntity movieJpaEntity = movies.get(RANDOM.nextInt(movies.size())); +// CinemaJpaEntity cinemaJpaEntity = cinemas.get(RANDOM.nextInt(cinemas.size())); +// +// LocalDateTime startTime = generateRandomStartTime(); +// LocalDateTime endTime = startTime.plusMinutes(movieJpaEntity.getRunningMinTime()); +// +// return ScreeningJpaEntity.builder() +// .screeningStartTime(startTime) +// .screeningEndTime(endTime) +// .movie(movieJpaEntity) +// .cinema(cinemaJpaEntity) +// .build(); +// +// }) +// .forEach(screeningJpaRepository::save); +// +// } +// +// public LocalDateTime generateRandomStartTime() { +// LocalDate today = LocalDate.now(); +// LocalDate startDate = today.plusDays(1); +// LocalDate endDate = today.plusDays(20); +// +// long randomDays = ThreadLocalRandom.current() +// .nextLong(ChronoUnit.DAYS.between(startDate, endDate) + 1); +// +// return startDate +// .plusDays(randomDays) +// .atTime(new Random().nextInt(18), new Random().nextInt(60)); +// } +//} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java index 84121fc27..f81b34fd1 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java @@ -4,9 +4,11 @@ import static jakarta.persistence.EnumType.STRING; import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; import jakarta.persistence.Entity; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -55,6 +57,7 @@ public class MovieJpaEntity extends BaseJpaEntity { private int runningMinTime; @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "genre_id", columnDefinition = "BINARY(16)") + @JoinColumn(name = "genre_id", columnDefinition = "BINARY(16)", + foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)) private GenreJpaEntity genre; } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java index 05194e3c9..3003bc50f 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java @@ -2,8 +2,10 @@ import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -42,10 +44,12 @@ public class ScreeningJpaEntity extends BaseJpaEntity { private LocalDateTime screeningEndTime; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "movie_id", columnDefinition = "BINARY(16)") + @JoinColumn(name = "movie_id", columnDefinition = "BINARY(16)", + foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)) private MovieJpaEntity movie; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "cinema_id", columnDefinition = "BINARY(16)") + @JoinColumn(name = "cinema_id", columnDefinition = "BINARY(16)", + foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)) private CinemaJpaEntity cinema; } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java index 4d58794a4..9839cc365 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java @@ -2,8 +2,10 @@ import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -36,6 +38,7 @@ public class SeatJpaEntity extends BaseJpaEntity { private String seatNumber; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "cinema_id", columnDefinition = "BINARY(16)") + @JoinColumn(name = "cinema_id", columnDefinition = "BINARY(16)", + foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)) private CinemaJpaEntity cinema; } diff --git a/module-presentation/src/main/resources/db/migration/V3__DropForeignKeyAllTables.sql b/module-presentation/src/main/resources/db/migration/V3__DropForeignKeyAllTables.sql new file mode 100644 index 000000000..b2e4cd447 --- /dev/null +++ b/module-presentation/src/main/resources/db/migration/V3__DropForeignKeyAllTables.sql @@ -0,0 +1,4 @@ +ALTER TABLE movie DROP FOREIGN KEY fk_movie_genre; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_movie; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_cinema; +ALTER TABLE seat DROP FOREIGN KEY fk_seat_cinema; \ No newline at end of file From 43c5ef253844989f406ab7e78f11fa767f4a23e4 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Tue, 14 Jan 2025 22:56:29 +0900 Subject: [PATCH 17/29] chore(load-testing): setup k6, InfluxDB, and Grafana with Docker Compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 간단한 테스트 실행. - 그라파나 대시보드 기본적인 것 간단하게 구성 - 아직은 k6의 메트릭에 대한 이해가 필요... --- .gitignore | 5 ++- Dockerfile | 15 +++++++++ docker/docker-compose.yaml | 60 ++++++++++++++++++++++++++++++++-- docker/k6/scripts/load_test.js | 17 ++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 Dockerfile create mode 100644 docker/k6/scripts/load_test.js diff --git a/.gitignore b/.gitignore index 9d77ee124..8f13becee 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ out/ ### VS Code ### .vscode/ -docker/db/ \ No newline at end of file +docker/db/ +docker/influxdb/ +docker/grafana/ +node_modules/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..2630e62d8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.22-alpine3.20 as builder +WORKDIR $GOPATH/src/go.k6.io/k6 +ADD docker . +RUN apk --no-cache add git +RUN go install go.k6.io/xk6/cmd/xk6@latest +RUN xk6 build --with github.com/grafana/xk6-output-influxdb --output /tmp/k6 + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates && \ + adduser -D -u 12345 -g 12345 k6 +COPY --from=builder /tmp/k6 /usr/bin/k6 + +USER 12345 +WORKDIR /home/k6 +ENTRYPOINT ["k6"] \ No newline at end of file diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 6bc0c09ce..abb93aa14 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,4 +1,4 @@ -version: '3' +version: '3.8' services: redis-movie: image: mysql:9.1.0 @@ -18,4 +18,60 @@ services: - MYSQL_PASSWORD=local1234 command: > --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci \ No newline at end of file + --collation-server=utf8mb4_unicode_ci + + + influxdb: + image: influxdb:2.7.5 + networks: + - monitoring + ports: + - "8086:8086" + volumes: + - ./influxdb/data:/var/lib/influxdb2 + - ./influxdb/config:/etc/influxdb2 + environment: + - DOCKER_INFLUXDB_INIT_MODE=setup + - DOCKER_INFLUXDB_INIT_USERNAME=hongs + - DOCKER_INFLUXDB_INIT_PASSWORD=local1234 + - DOCKER_INFLUXDB_INIT_ORG=hongs + - DOCKER_INFLUXDB_INIT_BUCKET=redis-cinema + - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=EsM8u2l07RkAdG8pLE0adgUtL2tXBrdWqVtQyb1rBv4MnRuza8UF4hNYlzzFakyF66Cw9WnRyGLepctBa5DBWQ== +# - DOCKER_INFLUXDB_INIT_RETENTION=1d # 데이터 보존 기간 (24시간) => default : 0 (infinite) + + k6: + image: k6-custom:latest + container_name: cinema-k6-load-test + restart: always + networks: + - monitoring + ports: + - "6565:6565" + environment: + - K6_OUT=xk6-influxdb=http://influxdb:8086 + - K6_INFLUXDB_ORGANIZATION=hongs + - K6_INFLUXDB_BUCKET=redis-cinema + - K6_INFLUXDB_INSECURE=true + - K6_INFLUXDB_TOKEN=EsM8u2l07RkAdG8pLE0adgUtL2tXBrdWqVtQyb1rBv4MnRuza8UF4hNYlzzFakyF66Cw9WnRyGLepctBa5DBWQ== + volumes: + - ./k6/scripts:/scripts + entrypoint: ["tail", "-f", "/dev/null"] # 컨테이너 실행 후 대기 상태 유지 + + + grafana: + image: grafana/grafana:8.2.6 + ports: + - "3000:3000" + networks: + - monitoring + environment: + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_BASIC_ENABLED=false + volumes: + - ./grafana/grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + +networks: + monitoring: + driver: bridge \ No newline at end of file diff --git a/docker/k6/scripts/load_test.js b/docker/k6/scripts/load_test.js new file mode 100644 index 000000000..19a570a4e --- /dev/null +++ b/docker/k6/scripts/load_test.js @@ -0,0 +1,17 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + vus: 5, + duration: '30s', + thresholds: { + 'http_reqs{expected_response:true}': ['rate>10'], + }, +}; + +export default function () { + check(http.get("http://host.docker.internal:8080/api/v1/screenings"), { + "status is 200": (r) => r.status == 200, + "protocol is HTTP/2": (r) => r.proto == "HTTP/2.0", + }); +} \ No newline at end of file From d2e69b79a9ba829272f3bd046a5a87e72c036e18 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Wed, 15 Jan 2025 23:21:27 +0900 Subject: [PATCH 18/29] =?UTF-8?q?refactor(build):=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EC=88=98=EC=A0=95=EC=9C=BC=EB=A1=9C=20cle?= =?UTF-8?q?an=20architecture=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모듈 간의 의존관계 - presentation -> application <- infrastructure (큰 흐름) - presentation -> domain : application의 응답을 도메인 모델로 받기 위함 - application -> domain : application 에서는 프로그램의 흐름을 제어하는 서비스로직 담당, 도메인의 로직(도메인 내부 메서드), 도메인 간의 상호 로직(추 후, domain service로 명명)은 해당 모듈에서 가지고 오도록 하여, applicaiton은 오로지 프로그램의 흐름을 제어하는 로직이 들어가도록 설정 - infrastructure -> domain : applicaton 모듈의 요청을 특정기술의 모델(ex> jpaEntity)이 아닌 domain 엔티티로 전달하기 위함 - presentation -> infrastructure : 단순히 infrastructure의 빈들을 application에서도 사용가능하도록 등록하기 위함. - 해당 구조에 맞게 클래스 파일의 이동 --- module-application/build.gradle | 1 - .../port/outbound}/CinemaCommandPort.java | 2 +- .../port/outbound}/CinemaQueryPort.java | 2 +- .../cinema/service/CinemaCommandService.java | 2 +- .../cinema/service/CinemaQueryService.java | 2 +- .../genre/port/inbound/GenreQueryUseCase.java | 9 +++++++ .../genre/port/outbound/GenreQueryPort.java | 9 +++++++ .../genre/service/GenreQueryService.java | 20 ++++++++++++++++ .../movie/port/outbound}/MovieQueryPort.java | 2 +- .../movie/service/MovieQueryService.java | 2 +- .../port/outbound}/ScreeningQueryPort.java | 2 +- .../service/ScreeningQueryService.java | 4 +++- .../service/CinemaCommandServiceTest.java | 2 +- module-infrastructure/build.gradle | 1 + .../CinemaCommandAdapter.java | 3 ++- .../port => adapter}/CinemaQueryAdapter.java | 3 ++- .../genre/adapter/GenreQueryAdapter.java | 24 +++++++++++++++++++ .../MovieQueryAdapter.java | 4 ++-- .../ScreeningQueryAdapter.java | 4 ++-- module-presentation/build.gradle | 1 + .../presentation/TheaterApplication.java | 2 +- 21 files changed, 84 insertions(+), 17 deletions(-) rename {module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port => module-application/src/main/java/project/redis/application/cinema/port/outbound}/CinemaCommandPort.java (66%) rename {module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port => module-application/src/main/java/project/redis/application/cinema/port/outbound}/CinemaQueryPort.java (70%) create mode 100644 module-application/src/main/java/project/redis/application/genre/port/inbound/GenreQueryUseCase.java create mode 100644 module-application/src/main/java/project/redis/application/genre/port/outbound/GenreQueryPort.java create mode 100644 module-application/src/main/java/project/redis/application/genre/service/GenreQueryService.java rename {module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/port => module-application/src/main/java/project/redis/application/movie/port/outbound}/MovieQueryPort.java (69%) rename {module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/port/inbound => module-application/src/main/java/project/redis/application/screening/port/outbound}/ScreeningQueryPort.java (71%) rename module-infrastructure/src/main/java/project/redis/infrastructure/cinema/{inbound/port => adapter}/CinemaCommandAdapter.java (89%) rename module-infrastructure/src/main/java/project/redis/infrastructure/cinema/{inbound/port => adapter}/CinemaQueryAdapter.java (86%) create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/genre/adapter/GenreQueryAdapter.java rename module-infrastructure/src/main/java/project/redis/infrastructure/movie/{inbound => adapter}/MovieQueryAdapter.java (85%) rename module-infrastructure/src/main/java/project/redis/infrastructure/screening/{inbound => adapter}/ScreeningQueryAdapter.java (88%) diff --git a/module-application/build.gradle b/module-application/build.gradle index 4fcf7c336..5f99f0e39 100644 --- a/module-application/build.gradle +++ b/module-application/build.gradle @@ -20,7 +20,6 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':module-domain') - implementation project(':module-infrastructure') implementation 'org.springframework.boot:spring-boot-starter' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java b/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaCommandPort.java similarity index 66% rename from module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java rename to module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaCommandPort.java index b7658ba54..e05a02c76 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandPort.java +++ b/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaCommandPort.java @@ -1,4 +1,4 @@ -package project.redis.infrastructure.cinema.inbound.port; +package project.redis.application.cinema.port.outbound; public interface CinemaCommandPort { diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryPort.java b/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaQueryPort.java similarity index 70% rename from module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryPort.java rename to module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaQueryPort.java index e70051a56..d9593bfb3 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryPort.java +++ b/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaQueryPort.java @@ -1,4 +1,4 @@ -package project.redis.infrastructure.cinema.inbound.port; +package project.redis.application.cinema.port.outbound; import java.util.List; import project.redis.domain.cinema.Cinema; diff --git a/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java b/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java index 51d4d10d0..8de6e63d1 100644 --- a/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java +++ b/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java @@ -5,7 +5,7 @@ import org.springframework.stereotype.Service; import project.redis.application.cinema.port.inbound.CinemaCommandUseCase; import project.redis.application.cinema.port.inbound.CinemaCreateCommandParam; -import project.redis.infrastructure.cinema.inbound.port.CinemaCommandPort; +import project.redis.application.cinema.port.outbound.CinemaCommandPort; @Service diff --git a/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java b/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java index a11533730..ee6fd43cb 100644 --- a/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java +++ b/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java @@ -4,8 +4,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import project.redis.application.cinema.port.inbound.CinemaQueryUseCase; +import project.redis.application.cinema.port.outbound.CinemaQueryPort; import project.redis.domain.cinema.Cinema; -import project.redis.infrastructure.cinema.inbound.port.CinemaQueryPort; @Service diff --git a/module-application/src/main/java/project/redis/application/genre/port/inbound/GenreQueryUseCase.java b/module-application/src/main/java/project/redis/application/genre/port/inbound/GenreQueryUseCase.java new file mode 100644 index 000000000..ad6835a59 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/genre/port/inbound/GenreQueryUseCase.java @@ -0,0 +1,9 @@ +package project.redis.application.genre.port.inbound; + +import java.util.List; +import project.redis.domain.genre.Genre; + +public interface GenreQueryUseCase { + + List getGenres(); +} diff --git a/module-application/src/main/java/project/redis/application/genre/port/outbound/GenreQueryPort.java b/module-application/src/main/java/project/redis/application/genre/port/outbound/GenreQueryPort.java new file mode 100644 index 000000000..f4d2096f4 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/genre/port/outbound/GenreQueryPort.java @@ -0,0 +1,9 @@ +package project.redis.application.genre.port.outbound; + +import java.util.List; +import project.redis.domain.genre.Genre; + +public interface GenreQueryPort { + + List findAllGenres(); +} diff --git a/module-application/src/main/java/project/redis/application/genre/service/GenreQueryService.java b/module-application/src/main/java/project/redis/application/genre/service/GenreQueryService.java new file mode 100644 index 000000000..f643e0eb7 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/genre/service/GenreQueryService.java @@ -0,0 +1,20 @@ +package project.redis.application.genre.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.genre.port.inbound.GenreQueryUseCase; +import project.redis.application.genre.port.outbound.GenreQueryPort; +import project.redis.domain.genre.Genre; + +@Service +@RequiredArgsConstructor +public class GenreQueryService implements GenreQueryUseCase { + + private final GenreQueryPort genreQueryPort; + + @Override + public List getGenres() { + return List.of(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/port/MovieQueryPort.java b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java similarity index 69% rename from module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/port/MovieQueryPort.java rename to module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java index b1381ad34..6dee94ebd 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/port/MovieQueryPort.java +++ b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java @@ -1,4 +1,4 @@ -package project.redis.infrastructure.movie.inbound.port; +package project.redis.application.movie.port.outbound; import java.util.List; import project.redis.domain.movie.Movie; diff --git a/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java b/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java index e860d2591..794faa489 100644 --- a/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java +++ b/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java @@ -5,8 +5,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import project.redis.application.movie.port.inbound.MovieQueryUseCase; +import project.redis.application.movie.port.outbound.MovieQueryPort; import project.redis.domain.movie.Movie; -import project.redis.infrastructure.movie.inbound.port.MovieQueryPort; @Service @RequiredArgsConstructor diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/port/inbound/ScreeningQueryPort.java b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java similarity index 71% rename from module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/port/inbound/ScreeningQueryPort.java rename to module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java index ab9233196..ec3101a4b 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/port/inbound/ScreeningQueryPort.java +++ b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java @@ -1,4 +1,4 @@ -package project.redis.infrastructure.screening.inbound.port.inbound; +package project.redis.application.screening.port.outbound; import java.util.List; import project.redis.domain.screening.Screening; diff --git a/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java index 43f29ae12..b6550495b 100644 --- a/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java +++ b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java @@ -5,8 +5,8 @@ import org.springframework.stereotype.Service; import project.redis.application.screening.port.inbound.ScreeningQueryUseCase; import project.redis.application.screening.port.inbound.ScreeningsQueryParam; +import project.redis.application.screening.port.outbound.ScreeningQueryPort; import project.redis.domain.screening.Screening; -import project.redis.infrastructure.screening.inbound.port.inbound.ScreeningQueryPort; @@ -14,8 +14,10 @@ @RequiredArgsConstructor public class ScreeningQueryService implements ScreeningQueryUseCase { + private final ScreeningQueryPort screeningQueryPort; + @Override public List getScreenings(ScreeningsQueryParam param) { return screeningQueryPort.getScreenings(param.getMaxScreeningDay()); diff --git a/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java index 87785466b..4ca80db2a 100644 --- a/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java +++ b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java @@ -13,7 +13,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import project.redis.application.cinema.port.inbound.CinemaCreateCommandParam; -import project.redis.infrastructure.cinema.inbound.port.CinemaCommandPort; +import project.redis.application.cinema.port.outbound.CinemaCommandPort; @ExtendWith(MockitoExtension.class) class CinemaCommandServiceTest { diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle index 47df7050d..ef706149d 100644 --- a/module-infrastructure/build.gradle +++ b/module-infrastructure/build.gradle @@ -20,6 +20,7 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':module-domain') + implementation project(':module-application') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.flywaydb:flyway-core' diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaCommandAdapter.java similarity index 89% rename from module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java rename to module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaCommandAdapter.java index 3a539ec00..4af938b32 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaCommandAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaCommandAdapter.java @@ -1,9 +1,10 @@ -package project.redis.infrastructure.cinema.inbound.port; +package project.redis.infrastructure.cinema.adapter; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import project.redis.application.cinema.port.outbound.CinemaCommandPort; import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaQueryAdapter.java similarity index 86% rename from module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryAdapter.java rename to module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaQueryAdapter.java index e1ba05d69..c0d9b163e 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/inbound/port/CinemaQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaQueryAdapter.java @@ -1,8 +1,9 @@ -package project.redis.infrastructure.cinema.inbound.port; +package project.redis.infrastructure.cinema.adapter; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import project.redis.application.cinema.port.outbound.CinemaQueryPort; import project.redis.domain.cinema.Cinema; import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; import project.redis.infrastructure.cinema.mapper.CinemaInfraMapper; diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/adapter/GenreQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/adapter/GenreQueryAdapter.java new file mode 100644 index 000000000..a20c694cc --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/adapter/GenreQueryAdapter.java @@ -0,0 +1,24 @@ +package project.redis.infrastructure.genre.adapter; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.application.genre.port.outbound.GenreQueryPort; +import project.redis.domain.genre.Genre; +import project.redis.infrastructure.genre.mapper.GenreInfraMapper; +import project.redis.infrastructure.genre.repository.GenreJpaRepository; + + +@Component +@RequiredArgsConstructor +public class GenreQueryAdapter implements GenreQueryPort { + + private final GenreJpaRepository genreJpaRepository; + + @Override + public List findAllGenres() { + return genreJpaRepository.findAll().stream() + .map(GenreInfraMapper::toGenre) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/MovieQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/adapter/MovieQueryAdapter.java similarity index 85% rename from module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/MovieQueryAdapter.java rename to module-infrastructure/src/main/java/project/redis/infrastructure/movie/adapter/MovieQueryAdapter.java index d7f98af63..09604168a 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/inbound/MovieQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/adapter/MovieQueryAdapter.java @@ -1,11 +1,11 @@ -package project.redis.infrastructure.movie.inbound; +package project.redis.infrastructure.movie.adapter; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import project.redis.domain.movie.Movie; import project.redis.infrastructure.movie.entity.MovieJpaEntity; -import project.redis.infrastructure.movie.inbound.port.MovieQueryPort; +import project.redis.application.movie.port.outbound.MovieQueryPort; import project.redis.infrastructure.movie.mapper.MovieInfraMapper; import project.redis.infrastructure.movie.repository.MovieJpaRepository; diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/ScreeningQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java similarity index 88% rename from module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/ScreeningQueryAdapter.java rename to module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java index 6e6ae79c3..adbce8150 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/inbound/ScreeningQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java @@ -1,4 +1,4 @@ -package project.redis.infrastructure.screening.inbound; +package project.redis.infrastructure.screening.adapter; import java.time.LocalDate; import java.util.List; @@ -7,7 +7,7 @@ import org.springframework.transaction.annotation.Transactional; import project.redis.domain.screening.Screening; import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; -import project.redis.infrastructure.screening.inbound.port.inbound.ScreeningQueryPort; +import project.redis.application.screening.port.outbound.ScreeningQueryPort; import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; diff --git a/module-presentation/build.gradle b/module-presentation/build.gradle index da09ce1af..4fd79b7c3 100644 --- a/module-presentation/build.gradle +++ b/module-presentation/build.gradle @@ -20,6 +20,7 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':module-application') + implementation project(':module-infrastructure') implementation project(':module-domain') implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java index a6e2f4a9e..03af43013 100644 --- a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java +++ b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java @@ -8,9 +8,9 @@ @SpringBootApplication(scanBasePackages = { "project.redis.application", "project.redis.presentation", + "project.redis.domain", "project.redis.infrastructure" }) - public class TheaterApplication { public static void main(String[] args) { SpringApplication.run(TheaterApplication.class, args); From 0144f9810eefafad76c2a3b924d1d45f82e4b24c Mon Sep 17 00:00:00 2001 From: hongs429 Date: Wed, 15 Jan 2025 23:23:12 +0900 Subject: [PATCH 19/29] create(test code): ScreeningControllerTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - controller 검증항목 : 알맞은 파라미터 바인딩이 되는가 && 결과를 응답에 맞게 만드는가 && 응답 코드는 일치하는가 --- .../controller/ScreeningControllerTest.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 module-presentation/src/test/java/project/redis/presentation/screening/controller/ScreeningControllerTest.java diff --git a/module-presentation/src/test/java/project/redis/presentation/screening/controller/ScreeningControllerTest.java b/module-presentation/src/test/java/project/redis/presentation/screening/controller/ScreeningControllerTest.java new file mode 100644 index 000000000..5aee0a95f --- /dev/null +++ b/module-presentation/src/test/java/project/redis/presentation/screening/controller/ScreeningControllerTest.java @@ -0,0 +1,115 @@ +package project.redis.presentation.screening.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import project.redis.application.screening.port.inbound.ScreeningQueryUseCase; +import project.redis.application.screening.port.inbound.ScreeningsQueryParam; +import project.redis.domain.cinema.Cinema; +import project.redis.domain.genre.Genre; +import project.redis.domain.movie.Movie; +import project.redis.domain.movie.RatingClassification; +import project.redis.domain.screening.Screening; + +@ExtendWith(MockitoExtension.class) +class ScreeningControllerTest { + + private MockMvc mockMvc; + + @Mock + private ScreeningQueryUseCase screeningQueryUseCase; + + @InjectMocks + private ScreeningController screeningController; + + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(screeningController) + .build(); + } + + + @DisplayName("상영 시간표 가져오기 - 성공") + @Test + void testGetScreenings() throws Exception { + Cinema cinema = Cinema.generateCinema(UUID.randomUUID(), "cinema"); + + UUID genreId = UUID.randomUUID(); + Genre genre = Genre.generateGenre(genreId, "액션"); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime end = now.plusMinutes(120); + UUID movieIdA = UUID.randomUUID(); + UUID movieIdB = UUID.randomUUID(); + LocalDate releaseDateNow = LocalDate.now(); + + Movie movieA = Movie.generateMovie( + movieIdA, "movieA", RatingClassification.ALL, + releaseDateNow, "thum", 120, genre); + + Movie movieB = Movie.generateMovie( + movieIdB, "movieB", RatingClassification.ALL, + releaseDateNow.plusDays(1), "thum", 120, genre); + + + Screening screening1 = Screening.generateScreening( + UUID.randomUUID(), now, now.plusMinutes(120), movieA, cinema); + + Screening screening2 = Screening.generateScreening( + UUID.randomUUID(), now.plusMinutes(120), now.plusMinutes(240), movieA, cinema); + + Screening screening3 = Screening.generateScreening( + UUID.randomUUID(), now.plusMinutes(240), now.plusMinutes(360), movieB, cinema); + + Screening screening4 = Screening.generateScreening( + UUID.randomUUID(), now.plusMinutes(360), now.plusMinutes(480), movieB, cinema); + + ArrayList screenings = new ArrayList<>(); + screenings.add(screening1); + screenings.add(screening2); + screenings.add(screening3); + screenings.add(screening4); + + when(screeningQueryUseCase.getScreenings(ScreeningsQueryParam.builder() + .maxScreeningDay(2) + .build())).thenReturn(screenings); + + ResultActions resultActions = mockMvc.perform( + get("/api/v1/screenings") + .contentType(MediaType.APPLICATION_JSON_VALUE) + ); + + resultActions + .andDo(print()) + .andExpect(status().isOk()) + .andExpect( + jsonPath("$[0].movieTitle") + .value(movieB.getTitle()) + ) + .andExpect( + jsonPath("$[0].screenings[0].screeningId") + .value(screening3.getScreeningId().toString()) + ); + } + + +} \ No newline at end of file From db609dd4ce83d0f42fdf85175d61c607d8b1ba8c Mon Sep 17 00:00:00 2001 From: hongs429 Date: Sat, 18 Jan 2025 21:40:28 +0900 Subject: [PATCH 20/29] =?UTF-8?q?feat(querydsl):=20querydsl=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Q클래스는 기본적으로 $buildDir/generated/sources/annotationProcessor를 따라감. 때문에 별도의 설정 필요없음. - 8.11.1 문법에 맞게 tasks.withType(JavaCompile) { => tasks.withType(JavaCompile).configureEach { 로 변경 --- build.gradle | 2 +- module-infrastructure/build.gradle | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 29ac2bbdf..6c144d1d5 100644 --- a/build.gradle +++ b/build.gradle @@ -47,7 +47,7 @@ subprojects { testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' } - tasks.withType(JavaCompile) { + tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle index ef706149d..d7bc01de2 100644 --- a/module-infrastructure/build.gradle +++ b/module-infrastructure/build.gradle @@ -26,6 +26,11 @@ dependencies { implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} +} \ No newline at end of file From 8a8f6bbd0a5cc923a0e4f0796b34f1633178c9d7 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Mon, 20 Jan 2025 00:40:03 +0900 Subject: [PATCH 21/29] =?UTF-8?q?feat(querydsl=20&&=20redis=20&&=20caffein?= =?UTF-8?q?e):=20querydsl=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 deserialization이 정상적으로 이루어지지 않음. --- .gitignore | 3 +- build.gradle | 3 +- docker/docker-compose.yaml | 6 + http/getScreenings.http | 17 ++- module-application/build.gradle | 1 + .../port/inbound/ScreeningQueryUseCase.java | 5 + .../port/inbound/ScreeningsQueryParam.java | 4 + .../port/outbound/ScreeningQueryFilter.java | 33 +++++ .../port/outbound/ScreeningQueryPort.java | 7 +- .../service/ScreeningQueryService.java | 35 +++++- module-common/build.gradle | 17 +++ .../project/redis/domain/cinema/Cinema.java | 2 + .../project/redis/domain/genre/Genre.java | 2 + .../project/redis/domain/movie/Movie.java | 2 + .../redis/domain/screening/Screening.java | 2 + .../redis/domain/screening/Screenings.java | 15 +++ .../redis/domain/cinema/CinemaTest.java | 37 ++++++ module-infrastructure/build.gradle | 27 ++++- .../cinema/entity/QCinemaJpaEntity.java | 53 ++++++++ .../common/entity/QBaseJpaEntity.java | 43 +++++++ .../genre/entity/QGenreJpaEntity.java | 53 ++++++++ .../movie/entity/QMovieJpaEntity.java | 75 ++++++++++++ .../screening/entity/QScreeningJpaEntity.java | 72 +++++++++++ .../seat/entity/QSeatJpaEntity.java | 67 ++++++++++ .../common/config/JpaConfig.java | 8 ++ .../common/config/LocalCacheConfig.java | 23 ++++ .../common/config/RedisConfig.java | 114 ++++++++++++++++++ .../adapter/ScreeningQueryAdapter.java | 55 ++++++++- .../repository/ScreeningJpaRepository.java | 2 +- .../ScreeningJpaRepositoryCustom.java | 16 +++ .../ScreeningJpaRepositoryCustomImpl.java | 63 ++++++++++ .../infrastructure/ScreeningDataInitTest.java | 33 ----- module-presentation/build.gradle | 2 + .../controller/ScreeningController.java | 32 ++++- .../dto/request/ScreeningsQueryRequest.java | 4 + .../src/main/resources/application.yaml | 14 +++ settings.gradle | 1 + 37 files changed, 899 insertions(+), 49 deletions(-) create mode 100644 module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryFilter.java create mode 100644 module-common/build.gradle create mode 100644 module-domain/src/main/java/project/redis/domain/screening/Screenings.java create mode 100644 module-domain/src/test/java/project/redis/domain/cinema/CinemaTest.java create mode 100644 module-infrastructure/src/main/generated/project/redis/infrastructure/cinema/entity/QCinemaJpaEntity.java create mode 100644 module-infrastructure/src/main/generated/project/redis/infrastructure/common/entity/QBaseJpaEntity.java create mode 100644 module-infrastructure/src/main/generated/project/redis/infrastructure/genre/entity/QGenreJpaEntity.java create mode 100644 module-infrastructure/src/main/generated/project/redis/infrastructure/movie/entity/QMovieJpaEntity.java create mode 100644 module-infrastructure/src/main/generated/project/redis/infrastructure/screening/entity/QScreeningJpaEntity.java create mode 100644 module-infrastructure/src/main/generated/project/redis/infrastructure/seat/entity/QSeatJpaEntity.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustom.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustomImpl.java delete mode 100644 module-infrastructure/src/test/java/project/redis/infrastructure/ScreeningDataInitTest.java diff --git a/.gitignore b/.gitignore index 8f13becee..228c3ef66 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ out/ docker/db/ docker/influxdb/ docker/grafana/ -node_modules/ \ No newline at end of file +node_modules/ +src/main/generated/ \ No newline at end of file diff --git a/build.gradle b/build.gradle index 6c144d1d5..396324c0e 100644 --- a/build.gradle +++ b/build.gradle @@ -41,7 +41,8 @@ subprojects { dependencies { compileOnly 'org.projectlombok:lombok:1.18.36' annotationProcessor 'org.projectlombok:lombok:1.18.36' - + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' + testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' testCompileOnly 'org.projectlombok:lombok:1.18.36' testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index abb93aa14..51256be07 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -20,6 +20,12 @@ services: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + redis: + image: redis + container_name: redis-container + ports: + - "6379:6379" + influxdb: image: influxdb:2.7.5 diff --git a/http/getScreenings.http b/http/getScreenings.http index 15eca66c5..dc7f3c6db 100644 --- a/http/getScreenings.http +++ b/http/getScreenings.http @@ -3,10 +3,25 @@ GET https://examples.http-client.intellij.net/get ?generated-in=IntelliJ IDEA ### GET screenings (기본 2일) -GET http://localhost:8080/api/v1/screenings +GET http://localhost:8080/api/v1/screenings?genreName=판타지 Content-Type: application/json ### GET screenings (3일 이내 상영 영화 목록 가능) GET http://localhost:8080/api/v1/screenings?maxScreeningDay=3 Content-Type: application/json + + +### GET screenings (기본 2일 + 판타지 장르) +GET http://localhost:8080/api/v1/screenings?maxScreeningDay=3&genreName=판타지 +Content-Type: application/json + + +### GET screenings (기본 2일 + 판타지 장르 + local cache) +GET http://localhost:8080/api/v2/screenings/local-caching?maxScreeningDay=3&genreName=판타지 +Content-Type: application/json + + +### GET screenings (기본 2일 + 판타지 장르 + redis) +GET http://localhost:8080/api/v3/screenings/redis?maxScreeningDay=3&genreName=판타지 +Content-Type: application/json \ No newline at end of file diff --git a/module-application/build.gradle b/module-application/build.gradle index 5f99f0e39..735319496 100644 --- a/module-application/build.gradle +++ b/module-application/build.gradle @@ -20,6 +20,7 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':module-domain') + implementation project(':module-common') implementation 'org.springframework.boot:spring-boot-starter' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java index e68a896ca..70a819277 100644 --- a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java +++ b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java @@ -6,4 +6,9 @@ public interface ScreeningQueryUseCase { List getScreenings(ScreeningsQueryParam param); + +// List getScreeningsLocalCache(ScreeningsQueryParam param); + + List getScreeningsRedis(ScreeningsQueryParam param); + } diff --git a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java index 47744e494..2269c4fa1 100644 --- a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java +++ b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java @@ -1,5 +1,7 @@ package project.redis.application.screening.port.inbound; +import java.util.ArrayList; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -11,4 +13,6 @@ @NoArgsConstructor public class ScreeningsQueryParam { private int maxScreeningDay; + private String movieName; + private String genreName; } diff --git a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryFilter.java b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryFilter.java new file mode 100644 index 000000000..fcb29023b --- /dev/null +++ b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryFilter.java @@ -0,0 +1,33 @@ +package project.redis.application.screening.port.outbound; + +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningQueryFilter { + private int maxScreeningDay; + private String movieName; + private String genreName; + + @Override + public int hashCode() { + return Objects.hash(maxScreeningDay, genreName, movieName); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ScreeningQueryFilter that = (ScreeningQueryFilter) o; + + return maxScreeningDay == that.maxScreeningDay && + Objects.equals(genreName, that.genreName) && + Objects.equals(movieName, that.movieName); + } +} diff --git a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java index ec3101a4b..b4ee1babf 100644 --- a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java +++ b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java @@ -2,8 +2,13 @@ import java.util.List; import project.redis.domain.screening.Screening; +import project.redis.domain.screening.Screenings; public interface ScreeningQueryPort { - List getScreenings(int maxScreeningDay); + List getScreenings(ScreeningQueryFilter filter); + + Screenings getScreeningsRedis(ScreeningQueryFilter filter); + +// List getScreeningsLocalCache(ScreeningQueryFilter filter); } diff --git a/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java index b6550495b..a91abdcc2 100644 --- a/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java +++ b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java @@ -5,9 +5,10 @@ import org.springframework.stereotype.Service; import project.redis.application.screening.port.inbound.ScreeningQueryUseCase; import project.redis.application.screening.port.inbound.ScreeningsQueryParam; +import project.redis.application.screening.port.outbound.ScreeningQueryFilter; import project.redis.application.screening.port.outbound.ScreeningQueryPort; import project.redis.domain.screening.Screening; - +import project.redis.domain.screening.Screenings; @Service @@ -20,6 +21,36 @@ public class ScreeningQueryService implements ScreeningQueryUseCase { @Override public List getScreenings(ScreeningsQueryParam param) { - return screeningQueryPort.getScreenings(param.getMaxScreeningDay()); + return screeningQueryPort.getScreenings( + ScreeningQueryFilter.builder() + .maxScreeningDay(param.getMaxScreeningDay()) + .genreName(param.getGenreName()) + .movieName(param.getMovieName()) + .build() + ); + } + +// @Override +// public List getScreeningsLocalCache(ScreeningsQueryParam param) { +// return screeningQueryPort.getScreeningsLocalCache( +// ScreeningQueryFilter.builder() +// .maxScreeningDay(param.getMaxScreeningDay()) +// .genreName(param.getGenreName()) +// .movieName(param.getMovieName()) +// .build() +// ); +// } + + @Override + public List getScreeningsRedis(ScreeningsQueryParam param) { + Screenings screenings = screeningQueryPort.getScreeningsRedis( + ScreeningQueryFilter.builder() + .maxScreeningDay(param.getMaxScreeningDay()) + .genreName(param.getGenreName()) + .movieName(param.getMovieName()) + .build() + ); + + return screenings.getScreenings(); } } diff --git a/module-common/build.gradle b/module-common/build.gradle new file mode 100644 index 000000000..5d825a2d9 --- /dev/null +++ b/module-common/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'project.redis.common' +version = '0.0.1-SNAPSHOT' + +dependencies { + + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-params") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + + testImplementation("org.assertj:assertj-core") + testImplementation("org.mockito:mockito-core") + testImplementation("org.mockito:mockito-junit-jupiter") +} \ No newline at end of file diff --git a/module-domain/src/main/java/project/redis/domain/cinema/Cinema.java b/module-domain/src/main/java/project/redis/domain/cinema/Cinema.java index 3282e6c6e..707e9e446 100644 --- a/module-domain/src/main/java/project/redis/domain/cinema/Cinema.java +++ b/module-domain/src/main/java/project/redis/domain/cinema/Cinema.java @@ -4,11 +4,13 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Value; @Getter @Value @AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) public class Cinema { UUID cinemaId; String cinemaName; diff --git a/module-domain/src/main/java/project/redis/domain/genre/Genre.java b/module-domain/src/main/java/project/redis/domain/genre/Genre.java index 14fa4c517..614ac5c92 100644 --- a/module-domain/src/main/java/project/redis/domain/genre/Genre.java +++ b/module-domain/src/main/java/project/redis/domain/genre/Genre.java @@ -4,11 +4,13 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Value; @Getter @Value @AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) public class Genre { UUID genreId; String genreName; diff --git a/module-domain/src/main/java/project/redis/domain/movie/Movie.java b/module-domain/src/main/java/project/redis/domain/movie/Movie.java index 9f8fceec2..b13a23749 100644 --- a/module-domain/src/main/java/project/redis/domain/movie/Movie.java +++ b/module-domain/src/main/java/project/redis/domain/movie/Movie.java @@ -5,12 +5,14 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Value; import project.redis.domain.genre.Genre; @Getter @Value @AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) public class Movie { UUID movieId; String title; diff --git a/module-domain/src/main/java/project/redis/domain/screening/Screening.java b/module-domain/src/main/java/project/redis/domain/screening/Screening.java index c1d18ae13..7963bc955 100644 --- a/module-domain/src/main/java/project/redis/domain/screening/Screening.java +++ b/module-domain/src/main/java/project/redis/domain/screening/Screening.java @@ -5,6 +5,7 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Value; import project.redis.domain.cinema.Cinema; import project.redis.domain.movie.Movie; @@ -12,6 +13,7 @@ @Getter @Value @AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) public class Screening { UUID screeningId; LocalDateTime screenStartTime; diff --git a/module-domain/src/main/java/project/redis/domain/screening/Screenings.java b/module-domain/src/main/java/project/redis/domain/screening/Screenings.java new file mode 100644 index 000000000..3f76cd431 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/screening/Screenings.java @@ -0,0 +1,15 @@ +package project.redis.domain.screening; + +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Screenings { + private List screenings = new ArrayList<>(); +} diff --git a/module-domain/src/test/java/project/redis/domain/cinema/CinemaTest.java b/module-domain/src/test/java/project/redis/domain/cinema/CinemaTest.java new file mode 100644 index 000000000..d10dd5c04 --- /dev/null +++ b/module-domain/src/test/java/project/redis/domain/cinema/CinemaTest.java @@ -0,0 +1,37 @@ +package project.redis.domain.cinema; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class CinemaTest { + + @Test + void testDeserialize() throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + + Cinema cinema = Cinema.generateCinema(UUID.randomUUID(), "cinema"); + String json = objectMapper.writeValueAsString(cinema); + Cinema result = objectMapper.readValue(json, Cinema.class); + + System.out.println("json = " + json); + assertThat(result).isEqualTo(cinema); + + } +} \ No newline at end of file diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle index d7bc01de2..005f7b236 100644 --- a/module-infrastructure/build.gradle +++ b/module-infrastructure/build.gradle @@ -20,17 +20,42 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':module-domain') + implementation project(':module-common') implementation project(':module-application') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + implementation 'com.querydsl:querydsl-apt:5.0.0' implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + implementation 'com.querydsl:querydsl-core:5.0.0' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} \ No newline at end of file +} + +def generatedQueryDSLDir = 'src/main/generated' +println(generatedQueryDSLDir) + +sourceSets { + main { + java { + srcDirs += [generatedQueryDSLDir] + } + } +} + +tasks.withType(JavaCompile).configureEach { + options.generatedSourceOutputDirectory.set(file(generatedQueryDSLDir)) +} + +clean { + delete file(generatedQueryDSLDir) +} diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/cinema/entity/QCinemaJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/cinema/entity/QCinemaJpaEntity.java new file mode 100644 index 000000000..fc4103754 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/cinema/entity/QCinemaJpaEntity.java @@ -0,0 +1,53 @@ +package project.redis.infrastructure.cinema.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QCinemaJpaEntity is a Querydsl query type for CinemaJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QCinemaJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = -1437267300L; + + public static final QCinemaJpaEntity cinemaJpaEntity = new QCinemaJpaEntity("cinemaJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + public final StringPath cinemaName = createString("cinemaName"); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QCinemaJpaEntity(String variable) { + super(CinemaJpaEntity.class, forVariable(variable)); + } + + public QCinemaJpaEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QCinemaJpaEntity(PathMetadata metadata) { + super(CinemaJpaEntity.class, metadata); + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/common/entity/QBaseJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/common/entity/QBaseJpaEntity.java new file mode 100644 index 000000000..9f6ed0429 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/common/entity/QBaseJpaEntity.java @@ -0,0 +1,43 @@ +package project.redis.infrastructure.common.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QBaseJpaEntity is a Querydsl query type for BaseJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") +public class QBaseJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = 608046018L; + + public static final QBaseJpaEntity baseJpaEntity = new QBaseJpaEntity("baseJpaEntity"); + + public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class); + + public final ComparablePath createdBy = createComparable("createdBy", java.util.UUID.class); + + public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.LocalDateTime.class); + + public final ComparablePath updatedBy = createComparable("updatedBy", java.util.UUID.class); + + public QBaseJpaEntity(String variable) { + super(BaseJpaEntity.class, forVariable(variable)); + } + + public QBaseJpaEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBaseJpaEntity(PathMetadata metadata) { + super(BaseJpaEntity.class, metadata); + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/genre/entity/QGenreJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/genre/entity/QGenreJpaEntity.java new file mode 100644 index 000000000..58b2154cd --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/genre/entity/QGenreJpaEntity.java @@ -0,0 +1,53 @@ +package project.redis.infrastructure.genre.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QGenreJpaEntity is a Querydsl query type for GenreJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QGenreJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = 515269496L; + + public static final QGenreJpaEntity genreJpaEntity = new QGenreJpaEntity("genreJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final StringPath genreName = createString("genreName"); + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QGenreJpaEntity(String variable) { + super(GenreJpaEntity.class, forVariable(variable)); + } + + public QGenreJpaEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QGenreJpaEntity(PathMetadata metadata) { + super(GenreJpaEntity.class, metadata); + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/movie/entity/QMovieJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/movie/entity/QMovieJpaEntity.java new file mode 100644 index 000000000..3076ff3c5 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/movie/entity/QMovieJpaEntity.java @@ -0,0 +1,75 @@ +package project.redis.infrastructure.movie.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QMovieJpaEntity is a Querydsl query type for MovieJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMovieJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = 1818379864L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QMovieJpaEntity movieJpaEntity = new QMovieJpaEntity("movieJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final project.redis.infrastructure.genre.entity.QGenreJpaEntity genre; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final EnumPath rating = createEnum("rating", project.redis.domain.movie.RatingClassification.class); + + public final DatePath releaseDate = createDate("releaseDate", java.time.LocalDate.class); + + public final NumberPath runningMinTime = createNumber("runningMinTime", Integer.class); + + public final StringPath thumbnailUrl = createString("thumbnailUrl"); + + public final StringPath title = createString("title"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QMovieJpaEntity(String variable) { + this(MovieJpaEntity.class, forVariable(variable), INITS); + } + + public QMovieJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QMovieJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QMovieJpaEntity(PathMetadata metadata, PathInits inits) { + this(MovieJpaEntity.class, metadata, inits); + } + + public QMovieJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.genre = inits.isInitialized("genre") ? new project.redis.infrastructure.genre.entity.QGenreJpaEntity(forProperty("genre")) : null; + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/screening/entity/QScreeningJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/screening/entity/QScreeningJpaEntity.java new file mode 100644 index 000000000..c23848ace --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/screening/entity/QScreeningJpaEntity.java @@ -0,0 +1,72 @@ +package project.redis.infrastructure.screening.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QScreeningJpaEntity is a Querydsl query type for ScreeningJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QScreeningJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = -456835560L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QScreeningJpaEntity screeningJpaEntity = new QScreeningJpaEntity("screeningJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + public final project.redis.infrastructure.cinema.entity.QCinemaJpaEntity cinema; + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final project.redis.infrastructure.movie.entity.QMovieJpaEntity movie; + + public final DateTimePath screeningEndTime = createDateTime("screeningEndTime", java.time.LocalDateTime.class); + + public final DateTimePath screeningStartTime = createDateTime("screeningStartTime", java.time.LocalDateTime.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QScreeningJpaEntity(String variable) { + this(ScreeningJpaEntity.class, forVariable(variable), INITS); + } + + public QScreeningJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QScreeningJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QScreeningJpaEntity(PathMetadata metadata, PathInits inits) { + this(ScreeningJpaEntity.class, metadata, inits); + } + + public QScreeningJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.cinema = inits.isInitialized("cinema") ? new project.redis.infrastructure.cinema.entity.QCinemaJpaEntity(forProperty("cinema")) : null; + this.movie = inits.isInitialized("movie") ? new project.redis.infrastructure.movie.entity.QMovieJpaEntity(forProperty("movie"), inits.get("movie")) : null; + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/seat/entity/QSeatJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/seat/entity/QSeatJpaEntity.java new file mode 100644 index 000000000..386aba085 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/seat/entity/QSeatJpaEntity.java @@ -0,0 +1,67 @@ +package project.redis.infrastructure.seat.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QSeatJpaEntity is a Querydsl query type for SeatJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QSeatJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = -344098188L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QSeatJpaEntity seatJpaEntity = new QSeatJpaEntity("seatJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + public final project.redis.infrastructure.cinema.entity.QCinemaJpaEntity cinema; + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final StringPath seatNumber = createString("seatNumber"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QSeatJpaEntity(String variable) { + this(SeatJpaEntity.class, forVariable(variable), INITS); + } + + public QSeatJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QSeatJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QSeatJpaEntity(PathMetadata metadata, PathInits inits) { + this(SeatJpaEntity.class, metadata, inits); + } + + public QSeatJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.cinema = inits.isInitialized("cinema") ? new project.redis.infrastructure.cinema.entity.QCinemaJpaEntity(forProperty("cinema")) : null; + } + +} + diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java index 13661c86c..9a06b066e 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java @@ -1,7 +1,10 @@ package project.redis.infrastructure.common.config; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @@ -15,4 +18,9 @@ "project.redis.infrastructure" }) public class JpaConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java new file mode 100644 index 000000000..ac8174c50 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java @@ -0,0 +1,23 @@ +package project.redis.infrastructure.common.config; + +//@EnableCaching +//@Configuration +//public class LocalCacheConfig { +// +// @Bean +// public Caffeine caffeineConfig() { +// return Caffeine.newBuilder() +// .expireAfterWrite(Duration.ofDays(1)) +// .initialCapacity(200) +// .maximumSize(500) +// .recordStats(); +// } +// +// +// @Bean("localCacheManager") +// public CacheManager localCacheManager() { +// CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); +// caffeineCacheManager.setCaffeine(caffeineConfig()); +// return caffeineCacheManager; +// } +//} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java new file mode 100644 index 000000000..c5e815f7d --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java @@ -0,0 +1,114 @@ +package project.redis.infrastructure.common.config; + + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.KeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import project.redis.application.screening.port.outbound.ScreeningQueryFilter; + +@Configuration +@EnableCaching +public class RedisConfig { + + @Value("${redis.host}") + private String host; + + @Value("${redis.port}") + private int port; + + public final static String REDIS_SCREENING = "redis-screening"; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration standaloneConfig + = new RedisStandaloneConfiguration(host, port); + + return new LettuceConnectionFactory(standaloneConfig); + } + + @Primary + @Bean("redisCacheManager") + public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { + + PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator + .builder() + .allowIfSubType("java.util.ArrayList") + .build(); + + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY +// LaissezFaireSubTypeValidator.instance, +// ObjectMapper.DefaultTyping.NON_FINAL, +// JsonTypeInfo.As.PROPERTY + ); + + + Map cacheConfigurations = new HashMap<>(); + cacheConfigurations.put(REDIS_SCREENING, RedisCacheConfiguration.defaultCacheConfig() + .prefixCacheNameWith(REDIS_SCREENING) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer(/*objectMapper*/) + ) + ) + .entryTtl(Duration.ofDays(1)) + ); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults( + RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(SerializationPair + .fromSerializer(new StringRedisSerializer())) + .serializeValuesWith( + SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer(/*objectMapper*/) + ) + ) + .entryTtl(Duration.ofHours(1)) + ) + .withInitialCacheConfigurations(cacheConfigurations) + .build(); + } + + @Bean("screeningKeyGenerator") + public KeyGenerator screeningKeyGenerator() { + return (target, method, params) -> { + ScreeningQueryFilter filter = (ScreeningQueryFilter) params[0]; + + String movieName = filter.getMovieName() != null ? filter.getMovieName() : "ALL"; + String genreName = filter.getGenreName() != null ? filter.getGenreName() : "ALL"; + return "maxDays:" + filter.getMaxScreeningDay() + + ":movie:" + movieName + + ":genre:" + genreName; + }; + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java index adbce8150..18af20f7e 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java @@ -1,15 +1,21 @@ package project.redis.infrastructure.screening.adapter; +import static project.redis.infrastructure.common.config.RedisConfig.REDIS_SCREENING; + import java.time.LocalDate; import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import project.redis.application.screening.port.outbound.ScreeningQueryFilter; +import project.redis.application.screening.port.outbound.ScreeningQueryPort; import project.redis.domain.screening.Screening; import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; -import project.redis.application.screening.port.outbound.ScreeningQueryPort; import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; +import project.redis.domain.screening.Screenings; @Transactional(readOnly = true) @@ -20,15 +26,52 @@ public class ScreeningQueryAdapter implements ScreeningQueryPort { private final ScreeningJpaRepository screeningJpaRepository; @Override - public List getScreenings(int maxScreeningDay) { + public List getScreenings(ScreeningQueryFilter filter) { - LocalDate maxScreeningDate = LocalDate.now().plusDays(maxScreeningDay); + LocalDate maxScreeningDate = LocalDate.now().plusDays(filter.getMaxScreeningDay()); - List screenings - = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc(maxScreeningDate); + List screeningsByFilter = screeningJpaRepository.findScreeningsByFilter( + maxScreeningDate, + filter.getGenreName(), + filter.getMovieName() + ); - return screenings.stream() + return screeningsByFilter.stream() .map(ScreeningInfraMapper::toScreening) .toList(); } + + @Override + @Cacheable(value = REDIS_SCREENING, cacheManager = "redisCacheManager", keyGenerator = "screeningKeyGenerator") + public Screenings getScreeningsRedis(ScreeningQueryFilter filter) { + LocalDate maxScreeningDate = LocalDate.now().plusDays(filter.getMaxScreeningDay()); + + List screeningsByFilter = screeningJpaRepository.findScreeningsByFilter( + maxScreeningDate, + filter.getGenreName(), + filter.getMovieName() + ); + + List screeningList = screeningsByFilter.stream() + .map(ScreeningInfraMapper::toScreening) + .collect(Collectors.toList()); + + return new Screenings(screeningList); + } + +// @Override +// @Cacheable(value = REDIS_SCREENING, cacheManager = "localCacheManager", keyGenerator = "screeningKeyGenerator") +// public List getScreeningsLocalCache(ScreeningQueryFilter filter) { +// LocalDate maxScreeningDate = LocalDate.now().plusDays(filter.getMaxScreeningDay()); +// +// List screeningsByFilter = screeningJpaRepository.findScreeningsByFilter( +// maxScreeningDate, +// filter.getGenreName(), +// filter.getMovieName() +// ); +// +// return screeningsByFilter.stream() +// .map(ScreeningInfraMapper::toScreening) +// .toList(); +// } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java index 2eef1c2a1..f659a86ab 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java @@ -8,7 +8,7 @@ import org.springframework.data.repository.query.Param; import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; -public interface ScreeningJpaRepository extends JpaRepository { +public interface ScreeningJpaRepository extends JpaRepository, ScreeningJpaRepositoryCustom { @Query("select s from ScreeningJpaEntity s " + "left join fetch s.movie m " diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustom.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustom.java new file mode 100644 index 000000000..51880325d --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustom.java @@ -0,0 +1,16 @@ +package project.redis.infrastructure.screening.repository; + +import java.time.LocalDate; +import java.util.List; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +public interface ScreeningJpaRepositoryCustom { + + List findScreeningsByFilter( + LocalDate maxScreeningDate, + String genreName, + String movieName + ); + +} + diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustomImpl.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustomImpl.java new file mode 100644 index 000000000..72f3a6239 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustomImpl.java @@ -0,0 +1,63 @@ +package project.redis.infrastructure.screening.repository; + +import static org.springframework.util.StringUtils.hasText; +import static project.redis.infrastructure.cinema.entity.QCinemaJpaEntity.cinemaJpaEntity; +import static project.redis.infrastructure.genre.entity.QGenreJpaEntity.genreJpaEntity; +import static project.redis.infrastructure.movie.entity.QMovieJpaEntity.movieJpaEntity; +import static project.redis.infrastructure.screening.entity.QScreeningJpaEntity.screeningJpaEntity; + +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +@Component +@RequiredArgsConstructor +public class ScreeningJpaRepositoryCustomImpl implements ScreeningJpaRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findScreeningsByFilter(LocalDate maxScreeningDate, String genreName, + String movieName) { + + List> orderSpecifiers = new ArrayList<>(); + orderSpecifiers.add(new OrderSpecifier<>(Order.ASC, screeningJpaEntity.screeningStartTime)); + orderSpecifiers.add(new OrderSpecifier<>(Order.DESC, screeningJpaEntity.movie.releaseDate)); + + return queryFactory.selectFrom(screeningJpaEntity) + .leftJoin(screeningJpaEntity.movie, movieJpaEntity).fetchJoin() + .leftJoin(screeningJpaEntity.movie.genre, genreJpaEntity).fetchJoin() + .leftJoin(screeningJpaEntity.cinema, cinemaJpaEntity).fetchJoin() + .where( + genreNameEq(genreName), + movieNameEq(movieName), + withInScreeningDay(maxScreeningDate) + ) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + } + + private BooleanExpression withInScreeningDay(LocalDate maxScreeningDate) { + LocalDate now = LocalDate.now(); + return screeningJpaEntity.screeningStartTime.between(now.atStartOfDay(), maxScreeningDate.atStartOfDay()); + } + + private BooleanExpression movieNameEq(String movieName) { + return hasText(movieName) + ? movieJpaEntity.title.eq(movieName) + : null; + } + + private BooleanExpression genreNameEq(String genreName) { + return hasText(genreName) + ? genreJpaEntity.genreName.eq(genreName) + : null; + } +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/ScreeningDataInitTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/ScreeningDataInitTest.java deleted file mode 100644 index f844df8df..000000000 --- a/module-infrastructure/src/test/java/project/redis/infrastructure/ScreeningDataInitTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package project.redis.infrastructure; - -import java.time.LocalDateTime; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; -import project.redis.infrastructure.movie.repository.MovieJpaRepository; -import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; - -@ExtendWith(MockitoExtension.class) -class ScreeningDataInitTest { - - @Mock - ScreeningJpaRepository screeningJpaRepository; - - @Mock - MovieJpaRepository movieJpaRepository; - - @Mock - CinemaJpaRepository cinemaJpaRepository; - - @InjectMocks - private ScreeningDataInit screeningDataInit; - - @Test - void testRandomStartTime() { - LocalDateTime startTime = screeningDataInit.generateRandomStartTime(); - System.out.println("startTime = " + startTime); - } -} \ No newline at end of file diff --git a/module-presentation/build.gradle b/module-presentation/build.gradle index 4fd79b7c3..33b994db0 100644 --- a/module-presentation/build.gradle +++ b/module-presentation/build.gradle @@ -22,8 +22,10 @@ dependencies { implementation project(':module-application') implementation project(':module-infrastructure') implementation project(':module-domain') + implementation project(':module-common') implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java b/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java index c26c82eec..cb27c10e0 100644 --- a/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java +++ b/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java @@ -15,18 +15,46 @@ import project.redis.presentation.screening.mapper.ScreeningAppMapper; @RestController -@RequestMapping("/api/v1/screenings") +@RequestMapping("/api") @RequiredArgsConstructor public class ScreeningController { private final ScreeningQueryUseCase screeningQueryUseCase; - @GetMapping + @GetMapping("/v1/screenings") public ResponseEntity> getScreenings(ScreeningsQueryRequest request) { List screenings = screeningQueryUseCase.getScreenings( ScreeningsQueryParam.builder() .maxScreeningDay(request.getMaxScreeningDay()) + .genreName(request.getGenreName()) + .movieName(request.getMovieName()) + .build() + ); + + return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); + } + +// @GetMapping("/v2/screenings/local-caching") +// public ResponseEntity> getScreeningsLocalCaching(ScreeningsQueryRequest request) { +// List screenings = screeningQueryUseCase.getScreenings( +// ScreeningsQueryParam.builder() +// .maxScreeningDay(request.getMaxScreeningDay()) +// .genreName(request.getGenreName()) +// .movieName(request.getMovieName()) +// .build() +// ); +// +// return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); +// } + + @GetMapping("/v3/screenings/redis") + public ResponseEntity> getScreeningsRedis(ScreeningsQueryRequest request) { + List screenings = screeningQueryUseCase.getScreeningsRedis( + ScreeningsQueryParam.builder() + .maxScreeningDay(request.getMaxScreeningDay()) + .genreName(request.getGenreName()) + .movieName(request.getMovieName()) .build() ); diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java b/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java index e8a9a6be6..2d523905b 100644 --- a/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java +++ b/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java @@ -1,5 +1,7 @@ package project.redis.presentation.screening.dto.request; +import java.util.ArrayList; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -12,4 +14,6 @@ public class ScreeningsQueryRequest { @Builder.Default private int maxScreeningDay = 2; + private String movieName; + private String genreName; } diff --git a/module-presentation/src/main/resources/application.yaml b/module-presentation/src/main/resources/application.yaml index cd7148b9f..fe58e2a50 100644 --- a/module-presentation/src/main/resources/application.yaml +++ b/module-presentation/src/main/resources/application.yaml @@ -23,6 +23,10 @@ spring: logging: level: org: + springframework: + cache: debug + data: + redis: debug hibernate: SQL: info type: @@ -30,3 +34,13 @@ logging: sql: info +redis: + host: localhost + port: 6379 + + +management: + endpoints: + web: + exposure: + include: "*" \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 085be3078..17ec3ef6e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,5 @@ include 'module-application' include 'module-presentation' include 'module-infrastructure' include 'module-domain' +include 'module-common' From 62852627e8be47d5aa4912e0dead6adc6f4f4b2c Mon Sep 17 00:00:00 2001 From: hongs429 Date: Tue, 21 Jan 2025 00:31:28 +0900 Subject: [PATCH 22/29] =?UTF-8?q?fix(cacheManager=20&&=20redis=20&&=20load?= =?UTF-8?q?ing=20test):=20cacheManager=20=EC=9D=B4=EC=8A=88=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20&&=20redis=20=ED=8F=AC=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20&&=20=EB=B6=80=ED=95=98=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=9E=91=EC=84=B1(lo?= =?UTF-8?q?ad=5Ftest.js)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리스트 데이터를 serialize 하는 경우, custom objectmapper를 사용하므로써 해결 - 명확한 이유는 모르겠으나, redis 포트 변경 후 정상 작동 - 부하테스트 확인 --- build.gradle | 6 ++- docker/docker-compose.yaml | 2 +- docker/k6/scripts/load_test.js | 22 +++++--- .../port/inbound/ScreeningQueryUseCase.java | 2 +- .../port/outbound/ScreeningQueryPort.java | 5 +- .../service/ScreeningQueryService.java | 24 ++++----- .../redis/domain/screening/Screenings.java | 15 ------ .../common/config/LocalCacheConfig.java | 50 +++++++++++-------- .../common/config/RedisConfig.java | 24 +++------ .../adapter/ScreeningQueryAdapter.java | 37 +++++++------- .../controller/ScreeningController.java | 24 ++++----- .../src/main/resources/application.yaml | 6 +-- 12 files changed, 101 insertions(+), 116 deletions(-) delete mode 100644 module-domain/src/main/java/project/redis/domain/screening/Screenings.java diff --git a/build.gradle b/build.gradle index 396324c0e..d3cc28acd 100644 --- a/build.gradle +++ b/build.gradle @@ -41,9 +41,11 @@ subprojects { dependencies { compileOnly 'org.projectlombok:lombok:1.18.36' annotationProcessor 'org.projectlombok:lombok:1.18.36' - implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' - testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' // Java Time 지원 + + testImplementation 'com.fasterxml.jackson.core:jackson-databind' testCompileOnly 'org.projectlombok:lombok:1.18.36' testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' } diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 51256be07..9e9496232 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -24,7 +24,7 @@ services: image: redis container_name: redis-container ports: - - "6379:6379" + - "6380:6379" influxdb: diff --git a/docker/k6/scripts/load_test.js b/docker/k6/scripts/load_test.js index 19a570a4e..5fa86fff0 100644 --- a/docker/k6/scripts/load_test.js +++ b/docker/k6/scripts/load_test.js @@ -2,16 +2,24 @@ import http from 'k6/http'; import { check } from 'k6'; export const options = { - vus: 5, - duration: '30s', + stages: [ + { duration: '1m', target: 10 }, // 1분 동안 10명의 VU + { duration: '2m', target: 50 }, // 2분 동안 50명의 VU 유지 + { duration: '1m', target: 0 }, // 1분 동안 VU 감소 + ], thresholds: { - 'http_reqs{expected_response:true}': ['rate>10'], + http_req_duration: ['p(95)<500'], // 95%의 요청이 500ms 이내여야 함 + 'http_reqs': ['rate>50'], // 초당 50개의 요청 이상 처리 }, }; export default function () { - check(http.get("http://host.docker.internal:8080/api/v1/screenings"), { - "status is 200": (r) => r.status == 200, - "protocol is HTTP/2": (r) => r.proto == "HTTP/2.0", + const url = 'http://host.docker.internal:8080/api/v1/screenings/redis'; // 테스트할 엔드포인트 + + const response = http.get(url); + + check(response, { + "status is 200": (r) => r.status === 200, + "response time is acceptable": (r) => r.timings.duration < 500, }); -} \ No newline at end of file +} diff --git a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java index 70a819277..d8118f4b8 100644 --- a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java +++ b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java @@ -7,7 +7,7 @@ public interface ScreeningQueryUseCase { List getScreenings(ScreeningsQueryParam param); -// List getScreeningsLocalCache(ScreeningsQueryParam param); + List getScreeningsLocalCache(ScreeningsQueryParam param); List getScreeningsRedis(ScreeningsQueryParam param); diff --git a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java index b4ee1babf..9eba9a723 100644 --- a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java +++ b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java @@ -2,13 +2,12 @@ import java.util.List; import project.redis.domain.screening.Screening; -import project.redis.domain.screening.Screenings; public interface ScreeningQueryPort { List getScreenings(ScreeningQueryFilter filter); - Screenings getScreeningsRedis(ScreeningQueryFilter filter); + List getScreeningsRedis(ScreeningQueryFilter filter); -// List getScreeningsLocalCache(ScreeningQueryFilter filter); + List getScreeningsLocalCache(ScreeningQueryFilter filter); } diff --git a/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java index a91abdcc2..a371ea450 100644 --- a/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java +++ b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java @@ -8,7 +8,6 @@ import project.redis.application.screening.port.outbound.ScreeningQueryFilter; import project.redis.application.screening.port.outbound.ScreeningQueryPort; import project.redis.domain.screening.Screening; -import project.redis.domain.screening.Screenings; @Service @@ -30,27 +29,26 @@ public List getScreenings(ScreeningsQueryParam param) { ); } -// @Override -// public List getScreeningsLocalCache(ScreeningsQueryParam param) { -// return screeningQueryPort.getScreeningsLocalCache( -// ScreeningQueryFilter.builder() -// .maxScreeningDay(param.getMaxScreeningDay()) -// .genreName(param.getGenreName()) -// .movieName(param.getMovieName()) -// .build() -// ); -// } @Override public List getScreeningsRedis(ScreeningsQueryParam param) { - Screenings screenings = screeningQueryPort.getScreeningsRedis( + return screeningQueryPort.getScreeningsRedis( ScreeningQueryFilter.builder() .maxScreeningDay(param.getMaxScreeningDay()) .genreName(param.getGenreName()) .movieName(param.getMovieName()) .build() ); + } - return screenings.getScreenings(); + @Override + public List getScreeningsLocalCache(ScreeningsQueryParam param) { + return screeningQueryPort.getScreeningsLocalCache( + ScreeningQueryFilter.builder() + .maxScreeningDay(param.getMaxScreeningDay()) + .genreName(param.getGenreName()) + .movieName(param.getMovieName()) + .build() + ); } } diff --git a/module-domain/src/main/java/project/redis/domain/screening/Screenings.java b/module-domain/src/main/java/project/redis/domain/screening/Screenings.java deleted file mode 100644 index 3f76cd431..000000000 --- a/module-domain/src/main/java/project/redis/domain/screening/Screenings.java +++ /dev/null @@ -1,15 +0,0 @@ -package project.redis.domain.screening; - -import java.util.ArrayList; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class Screenings { - private List screenings = new ArrayList<>(); -} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java index ac8174c50..a695512ef 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java @@ -1,23 +1,31 @@ package project.redis.infrastructure.common.config; -//@EnableCaching -//@Configuration -//public class LocalCacheConfig { -// -// @Bean -// public Caffeine caffeineConfig() { -// return Caffeine.newBuilder() -// .expireAfterWrite(Duration.ofDays(1)) -// .initialCapacity(200) -// .maximumSize(500) -// .recordStats(); -// } -// -// -// @Bean("localCacheManager") -// public CacheManager localCacheManager() { -// CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); -// caffeineCacheManager.setCaffeine(caffeineConfig()); -// return caffeineCacheManager; -// } -//} +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@EnableCaching +@Configuration +public class LocalCacheConfig { + + @Bean + public Caffeine caffeineConfig() { + return Caffeine.newBuilder() + .expireAfterWrite(Duration.ofDays(1)) + .initialCapacity(200) + .maximumSize(500) + .recordStats(); + } + + + @Bean("localCacheManager") + public CacheManager localCacheManager() { + CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); + caffeineCacheManager.setCaffeine(caffeineConfig()); + return caffeineCacheManager; + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java index c5e815f7d..9ca6cd173 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java @@ -5,8 +5,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; -import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.time.Duration; import java.util.HashMap; @@ -39,7 +38,7 @@ public class RedisConfig { @Value("${redis.port}") private int port; - public final static String REDIS_SCREENING = "redis-screening"; + public final static String REDIS_SCREENING = "SCREENING"; @Bean public RedisConnectionFactory redisConnectionFactory() { @@ -53,31 +52,25 @@ public RedisConnectionFactory redisConnectionFactory() { @Bean("redisCacheManager") public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { - PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator - .builder() - .allowIfSubType("java.util.ArrayList") - .build(); - ObjectMapper objectMapper = new ObjectMapper() .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY -// LaissezFaireSubTypeValidator.instance, -// ObjectMapper.DefaultTyping.NON_FINAL, -// JsonTypeInfo.As.PROPERTY + .activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY ); Map cacheConfigurations = new HashMap<>(); cacheConfigurations.put(REDIS_SCREENING, RedisCacheConfiguration.defaultCacheConfig() - .prefixCacheNameWith(REDIS_SCREENING) .serializeKeysWith( RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) ) .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer( - new GenericJackson2JsonRedisSerializer(/*objectMapper*/) + new GenericJackson2JsonRedisSerializer(objectMapper) ) ) .entryTtl(Duration.ofDays(1)) @@ -90,7 +83,7 @@ public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFact .fromSerializer(new StringRedisSerializer())) .serializeValuesWith( SerializationPair.fromSerializer( - new GenericJackson2JsonRedisSerializer(/*objectMapper*/) + new GenericJackson2JsonRedisSerializer(objectMapper) ) ) .entryTtl(Duration.ofHours(1)) @@ -103,7 +96,6 @@ public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFact public KeyGenerator screeningKeyGenerator() { return (target, method, params) -> { ScreeningQueryFilter filter = (ScreeningQueryFilter) params[0]; - String movieName = filter.getMovieName() != null ? filter.getMovieName() : "ALL"; String genreName = filter.getGenreName() != null ? filter.getGenreName() : "ALL"; return "maxDays:" + filter.getMaxScreeningDay() + diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java index 18af20f7e..16901d1bf 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java @@ -15,7 +15,6 @@ import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; -import project.redis.domain.screening.Screenings; @Transactional(readOnly = true) @@ -43,7 +42,7 @@ public List getScreenings(ScreeningQueryFilter filter) { @Override @Cacheable(value = REDIS_SCREENING, cacheManager = "redisCacheManager", keyGenerator = "screeningKeyGenerator") - public Screenings getScreeningsRedis(ScreeningQueryFilter filter) { + public List getScreeningsRedis(ScreeningQueryFilter filter) { LocalDate maxScreeningDate = LocalDate.now().plusDays(filter.getMaxScreeningDay()); List screeningsByFilter = screeningJpaRepository.findScreeningsByFilter( @@ -52,26 +51,24 @@ public Screenings getScreeningsRedis(ScreeningQueryFilter filter) { filter.getMovieName() ); - List screeningList = screeningsByFilter.stream() + return screeningsByFilter.stream() .map(ScreeningInfraMapper::toScreening) .collect(Collectors.toList()); - - return new Screenings(screeningList); } -// @Override -// @Cacheable(value = REDIS_SCREENING, cacheManager = "localCacheManager", keyGenerator = "screeningKeyGenerator") -// public List getScreeningsLocalCache(ScreeningQueryFilter filter) { -// LocalDate maxScreeningDate = LocalDate.now().plusDays(filter.getMaxScreeningDay()); -// -// List screeningsByFilter = screeningJpaRepository.findScreeningsByFilter( -// maxScreeningDate, -// filter.getGenreName(), -// filter.getMovieName() -// ); -// -// return screeningsByFilter.stream() -// .map(ScreeningInfraMapper::toScreening) -// .toList(); -// } + @Override + @Cacheable(value = REDIS_SCREENING, cacheManager = "localCacheManager", keyGenerator = "screeningKeyGenerator") + public List getScreeningsLocalCache(ScreeningQueryFilter filter) { + LocalDate maxScreeningDate = LocalDate.now().plusDays(filter.getMaxScreeningDay()); + + List screeningsByFilter = screeningJpaRepository.findScreeningsByFilter( + maxScreeningDate, + filter.getGenreName(), + filter.getMovieName() + ); + + return screeningsByFilter.stream() + .map(ScreeningInfraMapper::toScreening) + .toList(); + } } diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java b/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java index cb27c10e0..6f7b4309d 100644 --- a/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java +++ b/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java @@ -35,18 +35,18 @@ public ResponseEntity> getScreenings(ScreeningsQu return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); } -// @GetMapping("/v2/screenings/local-caching") -// public ResponseEntity> getScreeningsLocalCaching(ScreeningsQueryRequest request) { -// List screenings = screeningQueryUseCase.getScreenings( -// ScreeningsQueryParam.builder() -// .maxScreeningDay(request.getMaxScreeningDay()) -// .genreName(request.getGenreName()) -// .movieName(request.getMovieName()) -// .build() -// ); -// -// return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); -// } + @GetMapping("/v2/screenings/local-caching") + public ResponseEntity> getScreeningsLocalCaching(ScreeningsQueryRequest request) { + List screenings = screeningQueryUseCase.getScreenings( + ScreeningsQueryParam.builder() + .maxScreeningDay(request.getMaxScreeningDay()) + .genreName(request.getGenreName()) + .movieName(request.getMovieName()) + .build() + ); + + return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); + } @GetMapping("/v3/screenings/redis") public ResponseEntity> getScreeningsRedis(ScreeningsQueryRequest request) { diff --git a/module-presentation/src/main/resources/application.yaml b/module-presentation/src/main/resources/application.yaml index fe58e2a50..1c3292498 100644 --- a/module-presentation/src/main/resources/application.yaml +++ b/module-presentation/src/main/resources/application.yaml @@ -23,10 +23,6 @@ spring: logging: level: org: - springframework: - cache: debug - data: - redis: debug hibernate: SQL: info type: @@ -36,7 +32,7 @@ logging: redis: host: localhost - port: 6379 + port: 6380 management: From b3d8a1bf804ab1657eba561594326db311d0c15c Mon Sep 17 00:00:00 2001 From: hongs429 Date: Sun, 26 Jan 2025 06:43:19 +0900 Subject: [PATCH 23/29] =?UTF-8?q?feat(=EC=9E=85=EB=A0=A5=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=9C=A0=ED=9A=A8=EC=84=B1):=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=EB=A1=9C=20=EB=B0=9B=EC=9D=80=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EC=9D=98=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EC=B1=85=EC=9E=84=EC=9D=84=20?= =?UTF-8?q?application=EC=9D=98=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20&&=20=EA=B5=AD=EC=A0=9C?= =?UTF-8?q?=ED=99=94=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GlobalExceptionHandler 정의 - 국제화 도입 --- module-application/build.gradle | 1 + .../inbound/CinemaCreateCommandParam.java | 31 +++++++++----- .../service/CinemaCommandServiceTest.java | 8 +--- module-common/build.gradle | 1 + .../project/redis/common/SelfValidating.java | 25 +++++++++++ .../exception/DataInvalidException.java | 16 +++++++ .../redis/common/exception/ErrorCode.java | 18 ++++++++ .../presentation/TheaterApplication.java | 21 ++++++++++ .../cinema/controller/CinemaController.java | 12 +++--- .../cinema/exception/ErrorResponse.java | 12 ++++++ .../exception/GlobalExceptionHandler.java | 42 +++++++++++++++++++ .../src/main/resources/application.yaml | 4 ++ .../resources/i18n/messages_en.properties | 3 ++ .../resources/i18n/messages_ko.properties | 3 ++ 14 files changed, 173 insertions(+), 24 deletions(-) create mode 100644 module-common/src/main/java/project/redis/common/SelfValidating.java create mode 100644 module-common/src/main/java/project/redis/common/exception/DataInvalidException.java create mode 100644 module-common/src/main/java/project/redis/common/exception/ErrorCode.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java create mode 100644 module-presentation/src/main/resources/i18n/messages_en.properties create mode 100644 module-presentation/src/main/resources/i18n/messages_ko.properties diff --git a/module-application/build.gradle b/module-application/build.gradle index 735319496..393efc34d 100644 --- a/module-application/build.gradle +++ b/module-application/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation project(':module-domain') implementation project(':module-common') implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-validation' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java index 7bc856aa0..69985bc2b 100644 --- a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java +++ b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java @@ -1,14 +1,23 @@ package project.redis.application.cinema.port.inbound; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class CinemaCreateCommandParam { - private String CinemaName; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Value; +import project.redis.common.SelfValidating; + +@Getter +@Value +@EqualsAndHashCode(callSuper = false) +public class CinemaCreateCommandParam extends SelfValidating { + + @NotNull(message = "COMMON.ERROR.NOT_NULL") + @NotBlank(message = "COMMON.ERROR.NOT_BLANK") + String cinemaName; + + public CinemaCreateCommandParam(String cinemaName) { + this.cinemaName = cinemaName; + this.validate(); + } } diff --git a/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java index 4ca80db2a..55249051e 100644 --- a/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java +++ b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java @@ -29,9 +29,7 @@ class CinemaCommandServiceTest { @Test void testCreateCinema() { String cinemaName = "cinema"; - CinemaCreateCommandParam param = CinemaCreateCommandParam.builder() - .CinemaName(cinemaName) - .build(); + CinemaCreateCommandParam param = new CinemaCreateCommandParam(cinemaName); doNothing().when(cinemaCommandPort).createCinema(param.getCinemaName()); @@ -44,9 +42,7 @@ void testCreateCinema() { @Test void testCreateCinemaWithInvalidCinemaName() { String cinemaName = "cinema"; - CinemaCreateCommandParam param = CinemaCreateCommandParam.builder() - .CinemaName(cinemaName) - .build(); + CinemaCreateCommandParam param = new CinemaCreateCommandParam(cinemaName); doThrow(IllegalArgumentException.class) .when(cinemaCommandPort).createCinema(param.getCinemaName()); diff --git a/module-common/build.gradle b/module-common/build.gradle index 5d825a2d9..bd2fe950c 100644 --- a/module-common/build.gradle +++ b/module-common/build.gradle @@ -6,6 +6,7 @@ group = 'project.redis.common' version = '0.0.1-SNAPSHOT' dependencies { + implementation 'org.springframework.boot:spring-boot-starter-validation' testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-params") diff --git a/module-common/src/main/java/project/redis/common/SelfValidating.java b/module-common/src/main/java/project/redis/common/SelfValidating.java new file mode 100644 index 000000000..36e578e47 --- /dev/null +++ b/module-common/src/main/java/project/redis/common/SelfValidating.java @@ -0,0 +1,25 @@ +package project.redis.common; + +import static jakarta.validation.Validation.buildDefaultValidatorFactory; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; + +public abstract class SelfValidating { + private final Validator validator; + + public SelfValidating() { + ValidatorFactory factory = buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + protected void validate() { + Set> violations = validator.validate((T) this); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + } +} diff --git a/module-common/src/main/java/project/redis/common/exception/DataInvalidException.java b/module-common/src/main/java/project/redis/common/exception/DataInvalidException.java new file mode 100644 index 000000000..7e47a0ed8 --- /dev/null +++ b/module-common/src/main/java/project/redis/common/exception/DataInvalidException.java @@ -0,0 +1,16 @@ +package project.redis.common.exception; + + +import lombok.Getter; + +@Getter +public class DataInvalidException extends RuntimeException { + private final ErrorCode errorCode; + private final Object[] args; + + public DataInvalidException(ErrorCode errorCode, Object... args) { + super(errorCode.getMessageId()); + this.errorCode = errorCode; + this.args = args; + } +} diff --git a/module-common/src/main/java/project/redis/common/exception/ErrorCode.java b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java new file mode 100644 index 000000000..2428097c8 --- /dev/null +++ b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java @@ -0,0 +1,18 @@ +package project.redis.common.exception; + +import lombok.Getter; + +@Getter +public class ErrorCode { + + private String messageId; + + public ErrorCode(String messageId) { + this.messageId = messageId; + } + + public static ErrorCode NOT_FOUND = new ErrorCode("COMMON.ERROR.NOT_FOUND"); + public static ErrorCode NOT_NULL = new ErrorCode("COMMON.ERROR.NOT_NULL"); + public static ErrorCode NOT_BLANK = new ErrorCode("COMMON.ERROR.NOT_BLANK"); + +} diff --git a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java index 03af43013..8fafd0fa5 100644 --- a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java +++ b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java @@ -1,8 +1,14 @@ package project.redis.presentation; +import java.util.Locale; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; @SpringBootApplication(scanBasePackages = { @@ -15,4 +21,19 @@ public class TheaterApplication { public static void main(String[] args) { SpringApplication.run(TheaterApplication.class, args); } + + @Bean + public MessageSource messageSource() { + ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); + messageSource.setBasename("classpath:i18n/messages"); + messageSource.setDefaultEncoding("UTF-8"); + return messageSource; + } + + @Bean + public LocaleResolver localeResolver() { + AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver(); + localeResolver.setDefaultLocale(Locale.KOREA); + return localeResolver; + } } diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java index b643cf6fc..36798ce72 100644 --- a/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java @@ -1,7 +1,6 @@ package project.redis.presentation.cinema.controller; -import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -39,12 +38,11 @@ public ResponseEntity> getCinemas() { } @PostMapping - public ResponseEntity createCinema(@RequestBody @Valid CinemaCreateRequest request) { - cinemaCommandUseCase.createCinema( - CinemaCreateCommandParam.builder() - .CinemaName(request.getCinemaName()) - .build() - ); + public ResponseEntity createCinema(@RequestBody CinemaCreateRequest request) { + + CinemaCreateCommandParam command = new CinemaCreateCommandParam(request.getCinemaName()); + + cinemaCommandUseCase.createCinema(command); return ResponseEntity.status(HttpStatus.CREATED).build(); } diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java new file mode 100644 index 000000000..4cb762746 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java @@ -0,0 +1,12 @@ +package project.redis.presentation.cinema.exception; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ErrorResponse { + private String errorCode; + private String errorMessage; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..3bb1340b9 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java @@ -0,0 +1,42 @@ +package project.redis.presentation.cinema.exception; + + +import jakarta.validation.ConstraintViolationException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final MessageSource messageSource; + + @ExceptionHandler({ConstraintViolationException.class}) + public ResponseEntity>> handleConstraintViolationException( + ConstraintViolationException e, Locale locale) { + Map> errors = new HashMap<>(); + e.getConstraintViolations() + .forEach(constraintViolation -> { + String errorCode = constraintViolation.getMessageTemplate(); + String message = messageSource.getMessage(errorCode, null, locale); + + errors.computeIfAbsent( + constraintViolation.getPropertyPath().toString(), + key -> new ArrayList<>() + ).add(new ErrorResponse(errorCode, message)); + + }); + + System.out.println("errors = " + errors); + return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(errors); + } +} diff --git a/module-presentation/src/main/resources/application.yaml b/module-presentation/src/main/resources/application.yaml index 1c3292498..7f156978a 100644 --- a/module-presentation/src/main/resources/application.yaml +++ b/module-presentation/src/main/resources/application.yaml @@ -20,6 +20,10 @@ spring: placeholder-replacement: false locations: classpath:db/migration + messages: + basename: i18n/messages + + logging: level: org: diff --git a/module-presentation/src/main/resources/i18n/messages_en.properties b/module-presentation/src/main/resources/i18n/messages_en.properties new file mode 100644 index 000000000..3c1b1d0b9 --- /dev/null +++ b/module-presentation/src/main/resources/i18n/messages_en.properties @@ -0,0 +1,3 @@ +COMMON.ERROR.NOT_FOUND=Not found resource. +COMMON.ERROR.NOT_NULL=Not null this property. +COMMON.ERROR.NOT_BLANK=Not blank this property. \ No newline at end of file diff --git a/module-presentation/src/main/resources/i18n/messages_ko.properties b/module-presentation/src/main/resources/i18n/messages_ko.properties new file mode 100644 index 000000000..b14272fa2 --- /dev/null +++ b/module-presentation/src/main/resources/i18n/messages_ko.properties @@ -0,0 +1,3 @@ +COMMON.ERROR.NOT_FOUND=\uB9AC\uC18C\uC2A4\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +COMMON.ERROR.NOT_NULL=\uB110 \uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\uB2E4. +COMMON.ERROR.NOT_BLANK=\uBE48 \uAC12\uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\uB2E4. \ No newline at end of file From 4701c83a057ed54e07d0a22872e96f4a7c2ee5b1 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Mon, 27 Jan 2025 17:19:22 +0900 Subject: [PATCH 24/29] =?UTF-8?q?feat(Reservation):=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20?= =?UTF-8?q?&&=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인 로직, application 로직 분리 - Reservation 관련 도메인, jpa 엔티티 만들기 - Flyway로 DDL 반영 - 예외 정의 - 테스트 코드 작성 --- .../inbound/ReservationCommandUseCase.java | 6 + .../port/inbound/ReserveCommandParam.java | 33 ++++ .../port/outbound/ReservationCommandPort.java | 7 + .../port/outbound/ReservationQueryPort.java | 10 ++ .../service/ReservationCommandService.java | 66 ++++++++ .../port/outbound/ScreeningQueryPort.java | 3 + .../seat/port/outbound/SeatQueryPort.java | 10 ++ .../ReservationCommandServiceTest.java | 143 ++++++++++++++++++ .../redis/common/exception/ErrorCode.java | 19 ++- module-domain/build.gradle | 1 + .../redis/domain/reservation/Reservation.java | 45 ++++++ .../domain/reservation/ReservationSeat.java | 24 +++ .../redis/domain/screening/Screening.java | 5 + .../java/project/redis/domain/seat/Seat.java | 68 +++++++++ .../project/redis/domain/seat/SeatTest.java | 60 ++++++++ .../entity/QReservationJpaEntity.java | 69 +++++++++ .../entity/QReservationSeatJpaEntity.java | 68 +++++++++ .../cinema/mapper/CinemaInfraMapper.java | 7 + .../genre/mapper/GenreInfraMapper.java | 7 + .../movie/mapper/MovieInfraMapper.java | 12 ++ .../adapter/ReservationCommandAdapter.java | 38 +++++ .../adapter/ReservationQueryAdapter.java | 64 ++++++++ .../entity/ReservationJpaEntity.java | 46 ++++++ .../entity/ReservationSeatJpaEntity.java | 45 ++++++ .../mapper/ReservationInfraMapper.java | 18 +++ .../repository/ReservationJpaRepository.java | 18 +++ .../ReservationSeatJpaRepository.java | 13 ++ .../adapter/ScreeningQueryAdapter.java | 7 + .../mapper/ScreeningInfraMapper.java | 10 ++ .../repository/ScreeningJpaRepository.java | 11 ++ .../seat/adapter/SeatQueryAdapter.java | 25 +++ .../seat/mapper/SeatInfraMapper.java | 36 +++++ .../seat/respository/SeatJpaRepository.java | 12 ++ .../exception/GlobalExceptionHandler.java | 14 +- .../controller/ReservationController.java | 32 ++++ .../request/ReservationCommandRequest.java | 16 ++ .../src/main/resources/application.yaml | 3 + .../V4__CreateReservationRelatedTables.sql | 23 +++ .../resources/i18n/messages_en.properties | 12 +- .../resources/i18n/messages_ko.properties | 12 +- 40 files changed, 1114 insertions(+), 4 deletions(-) create mode 100644 module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java create mode 100644 module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java create mode 100644 module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java create mode 100644 module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java create mode 100644 module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java create mode 100644 module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java create mode 100644 module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java create mode 100644 module-domain/src/main/java/project/redis/domain/reservation/Reservation.java create mode 100644 module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java create mode 100644 module-domain/src/test/java/project/redis/domain/seat/SeatTest.java create mode 100644 module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java create mode 100644 module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java create mode 100644 module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java create mode 100644 module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql diff --git a/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java new file mode 100644 index 000000000..a4f923030 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java @@ -0,0 +1,6 @@ +package project.redis.application.reservation.port.inbound; + +public interface ReservationCommandUseCase { + + boolean reserve(ReserveCommandParam param); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java new file mode 100644 index 000000000..c0c0785c7 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java @@ -0,0 +1,33 @@ +package project.redis.application.reservation.port.inbound; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Value; +import project.redis.common.SelfValidating; + +@Getter +@Value +@EqualsAndHashCode(callSuper = false) +public class ReserveCommandParam extends SelfValidating { + + @NotNull + @Size(min = 1, max = 5) + List seatIds; + + @NotNull + UUID screeningId; + + @NotNull + String userName; + + public ReserveCommandParam(List seatIds, UUID screeningId, String userName) { + this.seatIds = seatIds; + this.screeningId = screeningId; + this.userName = userName; + validate(); + } +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java new file mode 100644 index 000000000..8424db0ef --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java @@ -0,0 +1,7 @@ +package project.redis.application.reservation.port.outbound; + +import project.redis.domain.reservation.Reservation; + +public interface ReservationCommandPort { + void reserve(Reservation reservation); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java new file mode 100644 index 000000000..7975253bb --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java @@ -0,0 +1,10 @@ +package project.redis.application.reservation.port.outbound; + +import java.util.List; +import java.util.UUID; +import project.redis.domain.reservation.Reservation; + +public interface ReservationQueryPort { + + List getReservations(String username, UUID screeningId); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java new file mode 100644 index 000000000..2c6fb812a --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java @@ -0,0 +1,66 @@ +package project.redis.application.reservation.service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import project.redis.application.reservation.port.inbound.ReservationCommandUseCase; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.application.reservation.port.outbound.ReservationCommandPort; +import project.redis.application.reservation.port.outbound.ReservationQueryPort; +import project.redis.application.screening.port.outbound.ScreeningQueryPort; +import project.redis.application.seat.port.outbound.SeatQueryPort; +import project.redis.common.exception.DataInvalidException; +import project.redis.common.exception.ErrorCode; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; + + +@Service +@RequiredArgsConstructor +public class ReservationCommandService implements ReservationCommandUseCase { + + private final SeatQueryPort seatQueryPort; + private final ReservationQueryPort reservationQueryPort; + private final ScreeningQueryPort screeningQueryPort; + private final ReservationCommandPort reservationCommandPort; + + @Override + public boolean reserve(ReserveCommandParam param) { + List seats = seatQueryPort.getSeats(param.getSeatIds()); + if (!Seat.isSeries(seats)) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + + List originReservations = reservationQueryPort.getReservations(param.getUserName(), + param.getScreeningId()); + + + List originSeats = new ArrayList<>(); + originReservations.forEach(reservation -> + originSeats.addAll(reservation.getSeats()) + ); + + Seat.isAvailable(originSeats, seats); + + Screening screening = !CollectionUtils.isEmpty(originReservations) + ? originReservations.getFirst().getScreening() + : screeningQueryPort.getScreening(param.getScreeningId()); + + if (!screening.isLaterScreening()) { + throw new DataInvalidException(ErrorCode.SCREENING_REQUIRED_LATER_NOW, param.getScreeningId()); + } + + + Reservation reservation + = Reservation.generateReservation( + null, LocalDateTime.now(), param.getUserName(), screening, seats); + + reservationCommandPort.reserve(reservation); + + return true; + } +} \ No newline at end of file diff --git a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java index 9eba9a723..442e93614 100644 --- a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java +++ b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java @@ -1,6 +1,7 @@ package project.redis.application.screening.port.outbound; import java.util.List; +import java.util.UUID; import project.redis.domain.screening.Screening; public interface ScreeningQueryPort { @@ -10,4 +11,6 @@ public interface ScreeningQueryPort { List getScreeningsRedis(ScreeningQueryFilter filter); List getScreeningsLocalCache(ScreeningQueryFilter filter); + + Screening getScreening(UUID screeningId); } diff --git a/module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java b/module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java new file mode 100644 index 000000000..f93e1101d --- /dev/null +++ b/module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java @@ -0,0 +1,10 @@ +package project.redis.application.seat.port.outbound; + +import java.util.List; +import java.util.UUID; +import project.redis.domain.seat.Seat; + +public interface SeatQueryPort { + + List getSeats(List seatIds); +} diff --git a/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java b/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java new file mode 100644 index 000000000..4c676c328 --- /dev/null +++ b/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java @@ -0,0 +1,143 @@ +package project.redis.application.reservation.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.application.reservation.port.outbound.ReservationQueryPort; +import project.redis.application.seat.port.outbound.SeatQueryPort; +import project.redis.common.exception.DataInvalidException; +import project.redis.domain.cinema.Cinema; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.seat.Seat; + +@ExtendWith(MockitoExtension.class) +class ReservationCommandServiceTest { + + @Mock + SeatQueryPort seatQueryPort; + + @Mock + ReservationQueryPort reservationQueryPort; + + @InjectMocks + ReservationCommandService reservationCommandService; + + @Test + void testSeriesSeatNoSeriesSeat() { + // Arrange + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + + List seats = List.of(seat1, seat2, seat3); + + List seatIds = seats.stream().map(Seat::getSeatId).toList(); + ReserveCommandParam param = new ReserveCommandParam(seatIds, + UUID.randomUUID(), "user"); + + when(seatQueryPort.getSeats(seatIds)).thenReturn(seats); + + Assertions.assertThatThrownBy(() -> reservationCommandService.reserve(param)); + + + } + + @Test + void testSeriesSeatInputModelNoValidate() { + // Arrange + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat4 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat5 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat6 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + + List seats = List.of(seat1, seat2, seat3, seat4, seat5, seat6); + + List seatIds = seats.stream().map(Seat::getSeatId).toList(); + + Assertions.assertThatThrownBy( + () -> new ReserveCommandParam(seatIds, UUID.randomUUID(), "user")); + } + + @Test + void testAlreadyReservedSeat() { + // given + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + UUID seat4Id = UUID.randomUUID(); + UUID seat2Id = UUID.randomUUID(); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(seat2Id, "A2", cinema); + + UUID screeningId = UUID.randomUUID(); + + Seat seat3 = Seat.generateSeat(seat2Id, "A2", cinema); + Seat seat4 = Seat.generateSeat(seat4Id, "A3", cinema); + List seats = List.of(seat3, seat4); + + List seatIds = seats.stream().map(Seat::getSeatId).toList(); + ReserveCommandParam param = new ReserveCommandParam(seatIds, + screeningId, "user"); + + Reservation reservation = Reservation.generateReservation( + null, null, null, null, List.of(seat1, seat2)); + + when(seatQueryPort.getSeats(List.of(seat2Id, seat4Id))).thenReturn(List.of(seat3, seat4)); + when(reservationQueryPort.getReservations(param.getUserName(), screeningId)).thenReturn(List.of(reservation)); + + + assertThrows(DataInvalidException.class, () -> reservationCommandService.reserve(param)); + } + + + + @Test + void testAlreadyReservationSeat5Exceed() { + // given + UUID cinemaId = UUID.randomUUID(); + UUID screeningId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A3", cinema); + Seat seat4 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat5 = Seat.generateSeat(UUID.randomUUID(), "A5", cinema); + + List alreadyReservedSeats = List.of(seat1, seat2, seat3, seat4, seat5); + + Seat seat6 = Seat.generateSeat(UUID.randomUUID(), "B5", cinema); + + ReserveCommandParam param = new ReserveCommandParam(List.of(seat6.getSeatId()), + screeningId, "user"); + + + Reservation reservation = Reservation.generateReservation( + null, null, null, null, alreadyReservedSeats); + + // when + when(seatQueryPort.getSeats(List.of(seat6.getSeatId()))).thenReturn(List.of(seat6)); + when(reservationQueryPort.getReservations(param.getUserName(), screeningId)).thenReturn(List.of(reservation)); + + + assertThrows(DataInvalidException.class, () -> reservationCommandService.reserve(param)); + } + +} \ No newline at end of file diff --git a/module-common/src/main/java/project/redis/common/exception/ErrorCode.java b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java index 2428097c8..0ccba2306 100644 --- a/module-common/src/main/java/project/redis/common/exception/ErrorCode.java +++ b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java @@ -5,7 +5,7 @@ @Getter public class ErrorCode { - private String messageId; + private final String messageId; public ErrorCode(String messageId) { this.messageId = messageId; @@ -15,4 +15,21 @@ public ErrorCode(String messageId) { public static ErrorCode NOT_NULL = new ErrorCode("COMMON.ERROR.NOT_NULL"); public static ErrorCode NOT_BLANK = new ErrorCode("COMMON.ERROR.NOT_BLANK"); + /* SEAT */ + public static ErrorCode SEAT_REQUIRED_SERIES = new ErrorCode( + "SEAT.ERROR.REQUIRED_SERIES" + ); + public static final ErrorCode SEAT_DUPLICATED = new ErrorCode( + "SEAT.ERROR.DUPLICATED" + ); + public static final ErrorCode SEAT_EXCEED_COUNT = new ErrorCode( + "SEAT.ERROR.EXCEED_COUNT" + ); + + /* SCREENING */ + public static ErrorCode SCREENING_REQUIRED_LATER_NOW = new ErrorCode( + "SCREENING.ERROR.REQUIRED_LATER_NOW" + ); + + } diff --git a/module-domain/build.gradle b/module-domain/build.gradle index 724e0a6cb..b0912b853 100644 --- a/module-domain/build.gradle +++ b/module-domain/build.gradle @@ -6,6 +6,7 @@ group = 'project.redis.domain' version = '0.0.1-SNAPSHOT' dependencies { + implementation project(':module-common') testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-params") diff --git a/module-domain/src/main/java/project/redis/domain/reservation/Reservation.java b/module-domain/src/main/java/project/redis/domain/reservation/Reservation.java new file mode 100644 index 000000000..aa27effe7 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/reservation/Reservation.java @@ -0,0 +1,45 @@ +package project.redis.domain.reservation; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class Reservation { + UUID reservationId; + LocalDateTime reservationTime; + String username; + Screening screening; + List seats; + + + public static Reservation generateReservation( + UUID reservationId, LocalDateTime reservationTime, + String username, + Screening screening, + List seats) { + return new Reservation(reservationId, reservationTime, username, screening, seats); + } + + public boolean isSeatAvailable(Seat seat) { + return !seats.contains(seat); + } + + public void addSeats(List newSeats) { + if (seats.size() + newSeats.size() > 5) { + throw new IllegalArgumentException("5개 이상 예약할 수 없습니다"); + } + seats.addAll(newSeats); + } + +} diff --git a/module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java b/module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java new file mode 100644 index 000000000..2f94e662d --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java @@ -0,0 +1,24 @@ +package project.redis.domain.reservation; + + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class ReservationSeat { + UUID reservationSeatId; + UUID reservationId; + UUID seatId; + + public static ReservationSeat generateReservationSeat( + UUID reservationSeatId, UUID reservationId, UUID seatId) { + return new ReservationSeat(reservationSeatId, reservationId, seatId); + } +} diff --git a/module-domain/src/main/java/project/redis/domain/screening/Screening.java b/module-domain/src/main/java/project/redis/domain/screening/Screening.java index 7963bc955..ec4687beb 100644 --- a/module-domain/src/main/java/project/redis/domain/screening/Screening.java +++ b/module-domain/src/main/java/project/redis/domain/screening/Screening.java @@ -33,4 +33,9 @@ public static Screening generateScreening( return new Screening(screeningId, screenStartTime, screenEndTime, movie, cinema); } + + public boolean isLaterScreening() { + assert screenStartTime != null; + return screenStartTime.isAfter(LocalDateTime.now()); + } } diff --git a/module-domain/src/main/java/project/redis/domain/seat/Seat.java b/module-domain/src/main/java/project/redis/domain/seat/Seat.java index 9fccfcc67..f8db10011 100644 --- a/module-domain/src/main/java/project/redis/domain/seat/Seat.java +++ b/module-domain/src/main/java/project/redis/domain/seat/Seat.java @@ -1,10 +1,14 @@ package project.redis.domain.seat; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Value; +import project.redis.common.exception.DataInvalidException; +import project.redis.common.exception.ErrorCode; import project.redis.domain.cinema.Cinema; @Getter @@ -18,4 +22,68 @@ public class Seat { public static Seat generateSeat(UUID seatId, String seatNumber, Cinema cinema) { return new Seat(seatId, seatNumber, cinema); } + + public char getColumn() { + return this.seatNumber.charAt(0); + } + + public char getRow() { + return this.seatNumber.charAt(1); + } + + public static boolean isAvailable(List originSeats, List targetSeats) { + List originSeatsIds = originSeats.stream().map(Seat::getSeatId).toList(); + + int sameSeatCount = originSeatsIds.stream() + .filter(seatId -> targetSeats.stream() + .anyMatch(seat -> seat.getSeatId() == seatId)) + .toList().size(); + + if (sameSeatCount > 0) { + throw new DataInvalidException(ErrorCode.SEAT_DUPLICATED); + } + + if (originSeats.size() == 5 || originSeats.size() + targetSeats.size() > 5) { + throw new DataInvalidException(ErrorCode.SEAT_EXCEED_COUNT, 5); + } + + List seats = new ArrayList<>(); + seats.addAll(originSeats); + seats.addAll(targetSeats); + + return isSeries(seats); + } + + public static boolean isSeries(List seats) { + if (seats == null || seats.isEmpty()) { + return false; + } + + char column = seats.getFirst().getColumn(); + + boolean isSameColumn = seats.stream() + .allMatch(seat -> seat.getColumn() == column); + + if (!isSameColumn) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + + List rows = seats.stream() + .map(Seat::getRow) + .map(String::valueOf) + .map(Integer::valueOf) + .sorted() + .toList(); + + for (int i = 0; i < rows.size() - 1; i++) { + if (i == rows.size() - 1) { + continue; + } + if (rows.get(i + 1) - rows.get(i) != 1) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + } + + return true; + } } diff --git a/module-domain/src/test/java/project/redis/domain/seat/SeatTest.java b/module-domain/src/test/java/project/redis/domain/seat/SeatTest.java new file mode 100644 index 000000000..7a7e572a1 --- /dev/null +++ b/module-domain/src/test/java/project/redis/domain/seat/SeatTest.java @@ -0,0 +1,60 @@ +package project.redis.domain.seat; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import project.redis.domain.cinema.Cinema; + +class SeatTest { + + @Test + void testIsSeries() { + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A3", cinema); + + List seats = List.of(seat1, seat2, seat3); + + boolean result = Seat.isSeries(seats); + + assertThat(result).isTrue(); + } + + @Test + void testIsSeriesNoEqualsColumn() { + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "B3", cinema); + + List seats = List.of(seat1, seat2, seat3); + + boolean result = Seat.isSeries(seats); + + assertThat(result).isFalse(); + } + + + @Test + void testIsSeriesNoSeriesRow() { + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + + List seats = List.of(seat1, seat2, seat3); + + boolean result = Seat.isSeries(seats); + + assertThat(result).isFalse(); + } +} \ No newline at end of file diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java new file mode 100644 index 000000000..573a2db43 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java @@ -0,0 +1,69 @@ +package project.redis.infrastructure.reservation.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QReservationJpaEntity is a Querydsl query type for ReservationJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReservationJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = 635228824L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QReservationJpaEntity reservationJpaEntity = new QReservationJpaEntity("reservationJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final DateTimePath reservationTime = createDateTime("reservationTime", java.time.LocalDateTime.class); + + public final project.redis.infrastructure.screening.entity.QScreeningJpaEntity screening; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public final StringPath username = createString("username"); + + public QReservationJpaEntity(String variable) { + this(ReservationJpaEntity.class, forVariable(variable), INITS); + } + + public QReservationJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QReservationJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QReservationJpaEntity(PathMetadata metadata, PathInits inits) { + this(ReservationJpaEntity.class, metadata, inits); + } + + public QReservationJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.screening = inits.isInitialized("screening") ? new project.redis.infrastructure.screening.entity.QScreeningJpaEntity(forProperty("screening"), inits.get("screening")) : null; + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java new file mode 100644 index 000000000..f8d474749 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java @@ -0,0 +1,68 @@ +package project.redis.infrastructure.reservation.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QReservationSeatJpaEntity is a Querydsl query type for ReservationSeatJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReservationSeatJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = -435679981L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QReservationSeatJpaEntity reservationSeatJpaEntity = new QReservationSeatJpaEntity("reservationSeatJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final QReservationJpaEntity reservation; + + public final project.redis.infrastructure.seat.entity.QSeatJpaEntity seat; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QReservationSeatJpaEntity(String variable) { + this(ReservationSeatJpaEntity.class, forVariable(variable), INITS); + } + + public QReservationSeatJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QReservationSeatJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QReservationSeatJpaEntity(PathMetadata metadata, PathInits inits) { + this(ReservationSeatJpaEntity.class, metadata, inits); + } + + public QReservationSeatJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.reservation = inits.isInitialized("reservation") ? new QReservationJpaEntity(forProperty("reservation"), inits.get("reservation")) : null; + this.seat = inits.isInitialized("seat") ? new project.redis.infrastructure.seat.entity.QSeatJpaEntity(forProperty("seat"), inits.get("seat")) : null; + } + +} + diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java index 1e76781b9..30b6212e3 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java @@ -13,4 +13,11 @@ public static Cinema toCinema(CinemaJpaEntity cinema) { ); } + public static CinemaJpaEntity toEntity(Cinema cinema) { + return CinemaJpaEntity.builder() + .id(cinema.getCinemaId()) + .cinemaName(cinema.getCinemaName()) + .build(); + } + } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java index a64c23647..9b11178c7 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java @@ -11,4 +11,11 @@ public static Genre toGenre(GenreJpaEntity genre) { genre.getGenreName() ); } + + public static GenreJpaEntity toEntity(Genre genre) { + return GenreJpaEntity.builder() + .id(genre.getGenreId()) + .genreName(genre.getGenreName()) + .build(); + } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java index c69756456..570427325 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java @@ -19,4 +19,16 @@ public static Movie toMovie(MovieJpaEntity movie) { ); } + public static MovieJpaEntity toEntity(Movie movie) { + return MovieJpaEntity.builder() + .id(movie.getMovieId()) + .title(movie.getTitle()) + .rating(movie.getRating()) + .releaseDate(movie.getReleaseDate()) + .thumbnailUrl(movie.getThumbnailUrl()) + .runningMinTime(movie.getRunningMinTime()) + .genre(GenreInfraMapper.toEntity(movie.getGenre())) + .build(); + } + } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java new file mode 100644 index 000000000..7bb311fa2 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java @@ -0,0 +1,38 @@ +package project.redis.infrastructure.reservation.adapter; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import project.redis.application.reservation.port.outbound.ReservationCommandPort; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.mapper.ReservationInfraMapper; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; + + +@Transactional +@Component +@RequiredArgsConstructor +public class ReservationCommandAdapter implements ReservationCommandPort { + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + + @Override + public void reserve(Reservation reservation) { + ReservationJpaEntity savedReservation = reservationJpaRepository.save(ReservationInfraMapper.toEntity(reservation)); + + for (Seat seat : reservation.getSeats()) { + ReservationSeatJpaEntity reservationSeat = ReservationSeatJpaEntity.builder() + .reservation(savedReservation) + .seat(SeatInfraMapper.toEntity(seat)) + .build(); + + reservationSeatJpaRepository.save(reservationSeat); + } + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java new file mode 100644 index 000000000..272002468 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java @@ -0,0 +1,64 @@ +package project.redis.infrastructure.reservation.adapter; + + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import project.redis.application.reservation.port.outbound.ReservationQueryPort; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReservationQueryAdapter implements ReservationQueryPort { + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + + @Override + public List getReservations(String username, UUID screeningId) { + List reservations + = reservationJpaRepository.findByUsernameAndScreeningId(username, screeningId); + + // 예약을 가지고 좌석 전부 가져오기 + + Map> reservationIdToEntitiesMap + = reservationSeatJpaRepository.findByReservationIn( + reservations).stream() + .collect(Collectors.groupingBy( + entity -> entity.getReservation().getId() + )); + + return reservations.stream() + .map(reservationJpaEntity -> { + List seats = reservationIdToEntitiesMap.get(reservationJpaEntity.getId()).stream() + .map(ReservationSeatJpaEntity::getSeat) + .map(SeatInfraMapper::toSeat) + .toList(); + + ScreeningJpaEntity screening = reservationJpaEntity.getScreening(); + + return Reservation.generateReservation( + reservationJpaEntity.getId(), + reservationJpaEntity.getReservationTime(), + reservationJpaEntity.getUsername(), + ScreeningInfraMapper.toScreening(screening), + seats + ); + + }) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java new file mode 100644 index 000000000..5613c241e --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java @@ -0,0 +1,46 @@ +package project.redis.infrastructure.reservation.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +@Entity +@Builder +@Table(name = "reservation") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReservationJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "reservation_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private LocalDateTime reservationTime; + + @Column(nullable = false) + private String username; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "screening_id") + private ScreeningJpaEntity screening; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java new file mode 100644 index 000000000..c6764a505 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java @@ -0,0 +1,45 @@ +package project.redis.infrastructure.reservation.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.infrastructure.seat.entity.SeatJpaEntity; + +@Entity +@Builder +@Table(name = "reservation_seat") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReservationSeatJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "reservation_seat_id", columnDefinition = "BINARY(16)") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private ReservationJpaEntity reservation; + + @ManyToOne + @JoinColumn(name = "seat_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private SeatJpaEntity seat; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java new file mode 100644 index 000000000..36106ba1c --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java @@ -0,0 +1,18 @@ +package project.redis.infrastructure.reservation.mapper; + +import project.redis.domain.reservation.Reservation; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; + +public class ReservationInfraMapper { + + public static ReservationJpaEntity toEntity(Reservation reservation) { + return ReservationJpaEntity.builder() + .id(reservation.getReservationId()) + .reservationTime(reservation.getReservationTime()) + .username(reservation.getUsername()) + .screening(ScreeningInfraMapper.toEntity(reservation.getScreening())) + .build(); + } + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java new file mode 100644 index 000000000..b6d76bd02 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java @@ -0,0 +1,18 @@ +package project.redis.infrastructure.reservation.repository; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; + +public interface ReservationJpaRepository extends JpaRepository { + + @EntityGraph(attributePaths = { + "screening", + "screening.movie", + "screening.movie.genre", + "screening.cinema" + }) + List findByUsernameAndScreeningId(String username, UUID screeningId); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java new file mode 100644 index 000000000..45ea153cb --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java @@ -0,0 +1,13 @@ +package project.redis.infrastructure.reservation.repository; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; + +public interface ReservationSeatJpaRepository extends JpaRepository { + + List findByReservationIn(List reservations); + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java index 16901d1bf..27b75f6e5 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java @@ -4,6 +4,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; @@ -71,4 +72,10 @@ public List getScreeningsLocalCache(ScreeningQueryFilter filter) { .map(ScreeningInfraMapper::toScreening) .toList(); } + + @Override + public Screening getScreening(UUID screeningId) { + ScreeningJpaEntity screeningEntity = screeningJpaRepository.findByIdOrThrow(screeningId); + return ScreeningInfraMapper.toScreening(screeningEntity); + } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java index 90b2b6da5..2027678df 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java @@ -18,4 +18,14 @@ public static Screening toScreening(ScreeningJpaEntity screening) { ); } + public static ScreeningJpaEntity toEntity(Screening screening) { + return ScreeningJpaEntity.builder() + .id(screening.getScreeningId()) + .screeningEndTime(screening.getScreenEndTime()) + .screeningStartTime(screening.getScreenStartTime()) + .movie(MovieInfraMapper.toEntity(screening.getMovie())) + .cinema(CinemaInfraMapper.toEntity(screening.getCinema())) + .build(); + } + } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java index f659a86ab..bffb40f34 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java @@ -2,10 +2,14 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import project.redis.common.exception.DataInvalidException; +import project.redis.common.exception.ErrorCode; import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; public interface ScreeningJpaRepository extends JpaRepository, ScreeningJpaRepositoryCustom { @@ -17,4 +21,11 @@ public interface ScreeningJpaRepository extends JpaRepository findAllOrderByReleaseDescAndScreenStartTimeAsc(@Param("limit") LocalDate limit); + + @EntityGraph(attributePaths = {"movie", "cinema", "movie.genre"}) + Optional findOneById(UUID screeningId); + + default ScreeningJpaEntity findByIdOrThrow(UUID id) { + return findOneById(id).orElseThrow(() -> new DataInvalidException(ErrorCode.NOT_FOUND, id)); + } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java new file mode 100644 index 000000000..ded648e8f --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java @@ -0,0 +1,25 @@ +package project.redis.infrastructure.seat.adapter; + +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.application.seat.port.outbound.SeatQueryPort; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; +import project.redis.infrastructure.seat.respository.SeatJpaRepository; + + +@Component +@RequiredArgsConstructor +public class SeatQueryAdapter implements SeatQueryPort { + + private final SeatJpaRepository seatJpaRepository; + + @Override + public List getSeats(List seatIds) { + return seatJpaRepository.findByIdIn(seatIds).stream() + .map(SeatInfraMapper::toSeatOnly) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java new file mode 100644 index 000000000..dd5d5c435 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java @@ -0,0 +1,36 @@ +package project.redis.infrastructure.seat.mapper; + +import project.redis.domain.cinema.Cinema; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.cinema.mapper.CinemaInfraMapper; +import project.redis.infrastructure.seat.entity.SeatJpaEntity; + +public class SeatInfraMapper { + + public static Seat toSeatOnly(SeatJpaEntity seatJpaEntity) { + return Seat.generateSeat( + seatJpaEntity.getId(), + seatJpaEntity.getSeatNumber(), + Cinema.generateCinema(seatJpaEntity.getCinema().getId(), null) + ); + } + + public static Seat toSeat(SeatJpaEntity seatJpaEntity) { + return Seat.generateSeat( + seatJpaEntity.getId(), + seatJpaEntity.getSeatNumber(), + Cinema.generateCinema( + seatJpaEntity.getCinema().getId(), + seatJpaEntity.getCinema().getCinemaName() + ) + ); + } + + public static SeatJpaEntity toEntity(Seat seat) { + return SeatJpaEntity.builder() + .id(seat.getSeatId()) + .cinema(CinemaInfraMapper.toEntity(seat.getCinema())) + .seatNumber(seat.getSeatNumber()) + .build(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java new file mode 100644 index 000000000..c1cc2dec9 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java @@ -0,0 +1,12 @@ +package project.redis.infrastructure.seat.respository; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.seat.entity.SeatJpaEntity; + +public interface SeatJpaRepository extends JpaRepository { + + List findByIdIn(Collection ids); +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java index 3bb1340b9..6619cd62b 100644 --- a/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import project.redis.common.exception.DataInvalidException; @RestControllerAdvice @RequiredArgsConstructor @@ -36,7 +37,18 @@ public ResponseEntity>> handleConstraintViolatio }); - System.out.println("errors = " + errors); return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(errors); } + + @ExceptionHandler(DataInvalidException.class) + public ResponseEntity handleDataInvalidException( + DataInvalidException e, Locale locale) { + String errorCode = e.getErrorCode().getMessageId(); + Object[] args = e.getArgs(); + String message = messageSource.getMessage(errorCode, args, locale); + + ErrorResponse errorResponse = new ErrorResponse(errorCode, message); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } } diff --git a/module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java b/module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java new file mode 100644 index 000000000..16e9957a9 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java @@ -0,0 +1,32 @@ +package project.redis.presentation.reservation.controller; + + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import project.redis.application.reservation.port.inbound.ReservationCommandUseCase; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.presentation.reservation.dto.request.ReservationCommandRequest; + +@RestController +@RequestMapping("/api/v1/reservations") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationCommandUseCase reservationCommandUseCase; + + @PostMapping + public ResponseEntity createReservation(@RequestBody ReservationCommandRequest request) { + + ReserveCommandParam param = new ReserveCommandParam(request.getSeatIds(), + request.getScreeningId(), request.getUsername()); + + reservationCommandUseCase.reserve(param); + + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java b/module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java new file mode 100644 index 000000000..20bc30f4b --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java @@ -0,0 +1,16 @@ +package project.redis.presentation.reservation.dto.request; + +import java.util.List; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ReservationCommandRequest { + private String username; + private UUID screeningId; + private List seatIds; +} diff --git a/module-presentation/src/main/resources/application.yaml b/module-presentation/src/main/resources/application.yaml index 7f156978a..f80206d62 100644 --- a/module-presentation/src/main/resources/application.yaml +++ b/module-presentation/src/main/resources/application.yaml @@ -13,6 +13,9 @@ spring: properties: hibernate: format_sql: true + jdbc: + batch_size: 100 + flyway: enabled: true diff --git a/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql b/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql new file mode 100644 index 000000000..87f247aac --- /dev/null +++ b/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql @@ -0,0 +1,23 @@ +create table reservation +( + reservation_id binary(16) not null primary key, + username varchar(255) not null, + screening_id binary(16) not null, + reservation_time datetime(6) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine=innodb charset=utf8mb4; + + +create table reservation_seat +( + reservation_seat_id binary(16) not null primary key, + reservation_id binary(16) not null, + seat_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine=innodb charset=utf8mb4; \ No newline at end of file diff --git a/module-presentation/src/main/resources/i18n/messages_en.properties b/module-presentation/src/main/resources/i18n/messages_en.properties index 3c1b1d0b9..1314f1da9 100644 --- a/module-presentation/src/main/resources/i18n/messages_en.properties +++ b/module-presentation/src/main/resources/i18n/messages_en.properties @@ -1,3 +1,13 @@ COMMON.ERROR.NOT_FOUND=Not found resource. COMMON.ERROR.NOT_NULL=Not null this property. -COMMON.ERROR.NOT_BLANK=Not blank this property. \ No newline at end of file +COMMON.ERROR.NOT_BLANK=Not blank this property. +# +# SEAT +# +SEAT.ERROR.REQUIRED_SERIES=Reservations can only be made for consecutive seats +SEAT.ERROR.DUPLICATED=Some of the seats have already been reserved. +SEAT.ERROR.EXCEED_COUNT=Seats can not be reserved over {0} count. +# +# SCREENING +# +SCREENING.ERROR.REQUIRED_LATER_NOW=This screening schedule has already passed. {0} \ No newline at end of file diff --git a/module-presentation/src/main/resources/i18n/messages_ko.properties b/module-presentation/src/main/resources/i18n/messages_ko.properties index b14272fa2..383e28c3b 100644 --- a/module-presentation/src/main/resources/i18n/messages_ko.properties +++ b/module-presentation/src/main/resources/i18n/messages_ko.properties @@ -1,3 +1,13 @@ COMMON.ERROR.NOT_FOUND=\uB9AC\uC18C\uC2A4\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. COMMON.ERROR.NOT_NULL=\uB110 \uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\uB2E4. -COMMON.ERROR.NOT_BLANK=\uBE48 \uAC12\uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\uB2E4. \ No newline at end of file +COMMON.ERROR.NOT_BLANK=\uBE48 \uAC12\uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\uB2E4. +# +# SEAT +# +SEAT.ERROR.REQUIRED_SERIES=\uC5F0\uC18D\uB41C \uC88C\uC11D\uC73C\uB85C\uB9CC \uC608\uC57D\uC774 \uAC00\uB2A5\uD569\uB2C8\uB2E4. +SEAT.ERROR.DUPLICATED=\uC77C\uBD80 \uC88C\uC11D\uC774 \uC774\uBBF8 \uC608\uC57D \uB418\uC5C8\uC2B5\uB2C8\uB2E4. +SEAT.ERROR.EXCEED_COUNT=\uC88C\uC11D\uC740 {0} \uAC1C \uC774\uC0C1 \uC608\uC57D\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +# +# SCREENING +# +SCREENING.ERROR.REQUIRED_LATER_NOW=\uC774\uBBF8 \uC9C0\uB09C \uC0C1\uC601\uC2DC\uAC04\uD45C \uC785\uB2C8\uB2E4. {0} \ No newline at end of file From b60bdae6df850c10f3fd8782811e6f7dcd8c629a Mon Sep 17 00:00:00 2001 From: hongs429 Date: Tue, 28 Jan 2025 17:08:38 +0900 Subject: [PATCH 25/29] =?UTF-8?q?fix(Reservation):=20=EC=9D=B4=EB=AF=B8=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8A=94=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=B4=EC=84=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/outbound/ReservationQueryPort.java | 2 +- .../service/ReservationCommandService.java | 37 ++++++++++++++----- .../ReservationCommandServiceTest.java | 8 ++-- .../redis/common/exception/ErrorCode.java | 3 ++ .../adapter/ReservationQueryAdapter.java | 5 +-- .../repository/ReservationJpaRepository.java | 8 ++++ .../resources/i18n/messages_en.properties | 1 + .../resources/i18n/messages_ko.properties | 1 + 8 files changed, 48 insertions(+), 17 deletions(-) diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java index 7975253bb..6e2f67729 100644 --- a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java @@ -6,5 +6,5 @@ public interface ReservationQueryPort { - List getReservations(String username, UUID screeningId); + List getReservations(UUID screeningId); } diff --git a/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java index 2c6fb812a..d454352db 100644 --- a/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java +++ b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java @@ -1,8 +1,9 @@ package project.redis.application.reservation.service; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; @@ -27,23 +28,42 @@ public class ReservationCommandService implements ReservationCommandUseCase { private final ReservationQueryPort reservationQueryPort; private final ScreeningQueryPort screeningQueryPort; private final ReservationCommandPort reservationCommandPort; - + /* + 적용 비지니스 규칙 + 1. 들어온 좌석은 연속된 좌석이어야 한다. + 2. 이미 예약이 존재하는 좌석은 예약이 불가능하다. + 3. 사용자의 예약은 모두 연속된 좌석이어야 한다. + 4. 사용자는 최대 해당 상영관에 대해서 5개까지 예약이 가능하다. + */ @Override public boolean reserve(ReserveCommandParam param) { + // 연속된 좌석인지 여부 List seats = seatQueryPort.getSeats(param.getSeatIds()); if (!Seat.isSeries(seats)) { throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); } - List originReservations = reservationQueryPort.getReservations(param.getUserName(), - param.getScreeningId()); + // 예약 가져오기 + List originReservations = reservationQueryPort.getReservations(param.getScreeningId()); + + List seatList = originReservations.stream() + .flatMap(reservation -> reservation.getSeats().stream()) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + // 이미 예약이 존재하는 좌석인지 검증 + boolean isAlreadyReservation = seatList.retainAll(param.getSeatIds()); - List originSeats = new ArrayList<>(); - originReservations.forEach(reservation -> - originSeats.addAll(reservation.getSeats()) - ); + if( isAlreadyReservation ) { + throw new DataInvalidException(ErrorCode.SEAT_ALREADY_RESERVED, seatList.toString()); + } + List originSeats = originReservations.stream() + .filter(reservation -> reservation.getUsername().equals(param.getUserName())) + .flatMap(reservation -> reservation.getSeats().stream()) + .toList(); + + // 이전 예약 + 현재 예약하려는 좌석의 연속성 검증 && 5개 이하의 예약 검증 Seat.isAvailable(originSeats, seats); Screening screening = !CollectionUtils.isEmpty(originReservations) @@ -54,7 +74,6 @@ public boolean reserve(ReserveCommandParam param) { throw new DataInvalidException(ErrorCode.SCREENING_REQUIRED_LATER_NOW, param.getScreeningId()); } - Reservation reservation = Reservation.generateReservation( null, LocalDateTime.now(), param.getUserName(), screening, seats); diff --git a/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java b/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java index 4c676c328..e3bd348bb 100644 --- a/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java +++ b/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; +import jakarta.validation.ConstraintViolationException; import java.util.List; import java.util.UUID; import org.assertj.core.api.Assertions; @@ -71,8 +72,7 @@ void testSeriesSeatInputModelNoValidate() { List seatIds = seats.stream().map(Seat::getSeatId).toList(); - Assertions.assertThatThrownBy( - () -> new ReserveCommandParam(seatIds, UUID.randomUUID(), "user")); + assertThrows(ConstraintViolationException.class, () -> new ReserveCommandParam(seatIds, UUID.randomUUID(), "user")); } @Test @@ -100,7 +100,7 @@ void testAlreadyReservedSeat() { null, null, null, null, List.of(seat1, seat2)); when(seatQueryPort.getSeats(List.of(seat2Id, seat4Id))).thenReturn(List.of(seat3, seat4)); - when(reservationQueryPort.getReservations(param.getUserName(), screeningId)).thenReturn(List.of(reservation)); + when(reservationQueryPort.getReservations(screeningId)).thenReturn(List.of(reservation)); assertThrows(DataInvalidException.class, () -> reservationCommandService.reserve(param)); @@ -134,7 +134,7 @@ void testAlreadyReservationSeat5Exceed() { // when when(seatQueryPort.getSeats(List.of(seat6.getSeatId()))).thenReturn(List.of(seat6)); - when(reservationQueryPort.getReservations(param.getUserName(), screeningId)).thenReturn(List.of(reservation)); + when(reservationQueryPort.getReservations(screeningId)).thenReturn(List.of(reservation)); assertThrows(DataInvalidException.class, () -> reservationCommandService.reserve(param)); diff --git a/module-common/src/main/java/project/redis/common/exception/ErrorCode.java b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java index 0ccba2306..22e3e92e4 100644 --- a/module-common/src/main/java/project/redis/common/exception/ErrorCode.java +++ b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java @@ -25,6 +25,9 @@ public ErrorCode(String messageId) { public static final ErrorCode SEAT_EXCEED_COUNT = new ErrorCode( "SEAT.ERROR.EXCEED_COUNT" ); + public static final ErrorCode SEAT_ALREADY_RESERVED = new ErrorCode( + "SEAT.ERROR.ALREADY_RESERVED" + ); /* SCREENING */ public static ErrorCode SCREENING_REQUIRED_LATER_NOW = new ErrorCode( diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java index 272002468..48c00ca43 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java @@ -28,12 +28,11 @@ public class ReservationQueryAdapter implements ReservationQueryPort { private final ReservationSeatJpaRepository reservationSeatJpaRepository; @Override - public List getReservations(String username, UUID screeningId) { + public List getReservations(UUID screeningId) { List reservations - = reservationJpaRepository.findByUsernameAndScreeningId(username, screeningId); + = reservationJpaRepository.findByScreeningId(screeningId); // 예약을 가지고 좌석 전부 가져오기 - Map> reservationIdToEntitiesMap = reservationSeatJpaRepository.findByReservationIn( reservations).stream() diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java index b6d76bd02..e8aa60bb7 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java @@ -15,4 +15,12 @@ public interface ReservationJpaRepository extends JpaRepository findByUsernameAndScreeningId(String username, UUID screeningId); + + @EntityGraph(attributePaths = { + "screening", + "screening.movie", + "screening.movie.genre", + "screening.cinema" + }) + List findByScreeningId(UUID screeningId); } diff --git a/module-presentation/src/main/resources/i18n/messages_en.properties b/module-presentation/src/main/resources/i18n/messages_en.properties index 1314f1da9..0bd50c5ef 100644 --- a/module-presentation/src/main/resources/i18n/messages_en.properties +++ b/module-presentation/src/main/resources/i18n/messages_en.properties @@ -7,6 +7,7 @@ COMMON.ERROR.NOT_BLANK=Not blank this property. SEAT.ERROR.REQUIRED_SERIES=Reservations can only be made for consecutive seats SEAT.ERROR.DUPLICATED=Some of the seats have already been reserved. SEAT.ERROR.EXCEED_COUNT=Seats can not be reserved over {0} count. +SEAT.ERROR.ALREADY_RESERVED=The seats already reserved exist. {0} # # SCREENING # diff --git a/module-presentation/src/main/resources/i18n/messages_ko.properties b/module-presentation/src/main/resources/i18n/messages_ko.properties index 383e28c3b..949f73083 100644 --- a/module-presentation/src/main/resources/i18n/messages_ko.properties +++ b/module-presentation/src/main/resources/i18n/messages_ko.properties @@ -7,6 +7,7 @@ COMMON.ERROR.NOT_BLANK=\uBE48 \uAC12\uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\ SEAT.ERROR.REQUIRED_SERIES=\uC5F0\uC18D\uB41C \uC88C\uC11D\uC73C\uB85C\uB9CC \uC608\uC57D\uC774 \uAC00\uB2A5\uD569\uB2C8\uB2E4. SEAT.ERROR.DUPLICATED=\uC77C\uBD80 \uC88C\uC11D\uC774 \uC774\uBBF8 \uC608\uC57D \uB418\uC5C8\uC2B5\uB2C8\uB2E4. SEAT.ERROR.EXCEED_COUNT=\uC88C\uC11D\uC740 {0} \uAC1C \uC774\uC0C1 \uC608\uC57D\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +SEAT.ERROR.ALREADY_RESERVED=\uC774\uBBF8 \uC608\uC57D\uB41C \uC88C\uC11D\uC774 \uC874\uC7AC\uD569\uB2C8\uB2E4. {0} # # SCREENING # From bb0ac7029a0faa0caa0eb39ce38f7f173255502c Mon Sep 17 00:00:00 2001 From: hongs429 Date: Sun, 2 Feb 2025 15:19:11 +0900 Subject: [PATCH 26/29] =?UTF-8?q?fix(=EB=8F=99=EC=8B=9C=EC=84=B1=20-=20uni?= =?UTF-8?q?que=20constraint):=20reservationSeat=EC=97=90=20screening=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20unique=20key=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 코드로 동시성 검증 - Infra 테스트 환경 구축( test container ) --- module-infrastructure/build.gradle | 10 + .../entity/QReservationSeatJpaEntity.java | 3 + .../common/config/RetryConfig.java | 28 +++ .../adapter/ReservationCommandAdapter.java | 2 + .../adapter/ReservationQueryAdapter.java | 31 +-- .../entity/ReservationJpaEntity.java | 4 +- .../entity/ReservationSeatJpaEntity.java | 13 +- .../repository/ReservationJpaRepository.java | 15 +- .../ReservationSeatJpaRepository.java | 7 +- .../seat/adapter/SeatQueryAdapter.java | 2 + .../seat/respository/SeatJpaRepository.java | 4 + .../infrastructure/TestConfiguration.java | 7 + .../ReservationCommandAdapterTest.java | 138 ++++++++++++++ .../reservation/util/ScreeningDataInit.java | 70 +++++++ .../util/TestContainerSupport.java | 44 +++++ .../src/test/resources/application.yaml | 6 + .../db/migration/V1__CreateInitTable.sql | 75 ++++++++ .../resources/db/migration/V2__InitData.sql | 180 ++++++++++++++++++ .../migration/V3__DropForeignKeyAllTables.sql | 4 + .../V4__CreateReservationRelatedTables.sql | 25 +++ .../V4__CreateReservationRelatedTables.sql | 4 +- 21 files changed, 638 insertions(+), 34 deletions(-) create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java create mode 100644 module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java create mode 100644 module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java create mode 100644 module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/ScreeningDataInit.java create mode 100644 module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/TestContainerSupport.java create mode 100644 module-infrastructure/src/test/resources/application.yaml create mode 100644 module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql create mode 100644 module-infrastructure/src/test/resources/db/migration/V2__InitData.sql create mode 100644 module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql create mode 100644 module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle index 005f7b236..ef1d21be5 100644 --- a/module-infrastructure/build.gradle +++ b/module-infrastructure/build.gradle @@ -33,6 +33,16 @@ dependencies { implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' implementation 'com.querydsl:querydsl-core:5.0.0' + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' + + // 테스트 컨테이너 + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' + testImplementation 'org.testcontainers:redis' + + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java index f8d474749..4933bfaab 100644 --- a/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java @@ -34,6 +34,8 @@ public class QReservationSeatJpaEntity extends EntityPathBase type, PathMetadata metadata, PathInits inits) { super(type, metadata, inits); this.reservation = inits.isInitialized("reservation") ? new QReservationJpaEntity(forProperty("reservation"), inits.get("reservation")) : null; + this.screening = inits.isInitialized("screening") ? new project.redis.infrastructure.screening.entity.QScreeningJpaEntity(forProperty("screening"), inits.get("screening")) : null; this.seat = inits.isInitialized("seat") ? new project.redis.infrastructure.seat.entity.QSeatJpaEntity(forProperty("seat"), inits.get("seat")) : null; } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java new file mode 100644 index 000000000..7ab284180 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java @@ -0,0 +1,28 @@ +package project.redis.infrastructure.common.config; + + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; + + +@Configuration +@EnableRetry +public class RetryConfig { + + @Bean("reservationSeatRetryTemplate") + public RetryTemplate reservationSeatRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); + + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(2)); + + FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy(); + fixedBackOffPolicy.setBackOffPeriod(50); + retryTemplate.setBackOffPolicy(fixedBackOffPolicy); + + return retryTemplate; + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java index 7bb311fa2..cedd091f7 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java @@ -11,6 +11,7 @@ import project.redis.infrastructure.reservation.mapper.ReservationInfraMapper; import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; import project.redis.infrastructure.seat.mapper.SeatInfraMapper; @@ -29,6 +30,7 @@ public void reserve(Reservation reservation) { for (Seat seat : reservation.getSeats()) { ReservationSeatJpaEntity reservationSeat = ReservationSeatJpaEntity.builder() .reservation(savedReservation) + .screening(ScreeningInfraMapper.toEntity(reservation.getScreening())) .seat(SeatInfraMapper.toEntity(seat)) .build(); diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java index 48c00ca43..9be8cb362 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; import project.redis.application.reservation.port.outbound.ReservationQueryPort; import project.redis.domain.reservation.Reservation; import project.redis.domain.seat.Seat; @@ -29,34 +30,34 @@ public class ReservationQueryAdapter implements ReservationQueryPort { @Override public List getReservations(UUID screeningId) { - List reservations - = reservationJpaRepository.findByScreeningId(screeningId); + List reservations = reservationJpaRepository.findAllByScreeningId(screeningId); - // 예약을 가지고 좌석 전부 가져오기 - Map> reservationIdToEntitiesMap - = reservationSeatJpaRepository.findByReservationIn( - reservations).stream() + if (CollectionUtils.isEmpty(reservations)) { + return List.of(); + } + + List reservationSeats = reservationSeatJpaRepository.findByScreeningId(screeningId); + + Map> reservationIdToEntityMap = reservationSeats.stream() .collect(Collectors.groupingBy( - entity -> entity.getReservation().getId() - )); + reservationSeatJpaEntity -> reservationSeatJpaEntity.getReservation().getId())); return reservations.stream() - .map(reservationJpaEntity -> { - List seats = reservationIdToEntitiesMap.get(reservationJpaEntity.getId()).stream() + .map(reservation -> { + List seats = reservationIdToEntityMap.get(reservation.getId()).stream() .map(ReservationSeatJpaEntity::getSeat) .map(SeatInfraMapper::toSeat) .toList(); - ScreeningJpaEntity screening = reservationJpaEntity.getScreening(); + ScreeningJpaEntity screening = reservation.getScreening(); return Reservation.generateReservation( - reservationJpaEntity.getId(), - reservationJpaEntity.getReservationTime(), - reservationJpaEntity.getUsername(), + reservation.getId(), + reservation.getReservationTime(), + reservation.getUsername(), ScreeningInfraMapper.toScreening(screening), seats ); - }) .toList(); } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java index 5613c241e..2c4a91fa6 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java @@ -2,8 +2,10 @@ import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -41,6 +43,6 @@ public class ReservationJpaEntity extends BaseJpaEntity { private String username; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "screening_id") + @JoinColumn(name = "screening_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private ScreeningJpaEntity screening; } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java index c6764a505..a4fdb79c8 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java @@ -11,6 +11,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.util.UUID; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -19,11 +20,17 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.UuidGenerator; import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; import project.redis.infrastructure.seat.entity.SeatJpaEntity; @Entity @Builder -@Table(name = "reservation_seat") +@Table(name = "reservation_seat", uniqueConstraints = { + @UniqueConstraint( + name = "UK_screening_seat", + columnNames = {"screening_id", "seat_id"} + ) +}) @Getter @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -39,6 +46,10 @@ public class ReservationSeatJpaEntity extends BaseJpaEntity { @JoinColumn(name = "reservation_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private ReservationJpaEntity reservation; + @ManyToOne + @JoinColumn(name = "screening_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private ScreeningJpaEntity screening; + @ManyToOne @JoinColumn(name = "seat_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private SeatJpaEntity seat; diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java index e8aa60bb7..dfae341b3 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java @@ -7,20 +7,11 @@ import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; public interface ReservationJpaRepository extends JpaRepository { - - @EntityGraph(attributePaths = { - "screening", - "screening.movie", - "screening.movie.genre", - "screening.cinema" - }) - List findByUsernameAndScreeningId(String username, UUID screeningId); - @EntityGraph(attributePaths = { "screening", "screening.movie", - "screening.movie.genre", - "screening.cinema" + "screening.cinema", + "screening.movie.genre" }) - List findByScreeningId(UUID screeningId); + List findAllByScreeningId(UUID screeningId); } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java index 45ea153cb..85c8a1f2e 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java @@ -2,12 +2,11 @@ import java.util.List; import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; -import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; public interface ReservationSeatJpaRepository extends JpaRepository { - - List findByReservationIn(List reservations); - + @EntityGraph(attributePaths = {"seat", "seat.cinema"}) + List findByScreeningId(UUID screeningId); } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java index ded648e8f..41d6cc1f2 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java @@ -4,12 +4,14 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import project.redis.application.seat.port.outbound.SeatQueryPort; import project.redis.domain.seat.Seat; import project.redis.infrastructure.seat.mapper.SeatInfraMapper; import project.redis.infrastructure.seat.respository.SeatJpaRepository; +@Transactional(readOnly = true) @Component @RequiredArgsConstructor public class SeatQueryAdapter implements SeatQueryPort { diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java index c1cc2dec9..6587377a3 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java @@ -3,10 +3,14 @@ import java.util.Collection; import java.util.List; import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import project.redis.infrastructure.seat.entity.SeatJpaEntity; public interface SeatJpaRepository extends JpaRepository { List findByIdIn(Collection ids); + + @EntityGraph(attributePaths = {"cinema"}) + List findByCinemaId(UUID cinemaId); } diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java b/module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java new file mode 100644 index 000000000..9b45dae3d --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java @@ -0,0 +1,7 @@ +package project.redis.infrastructure; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = {"project.redis.infrastructure"}) +public class TestConfiguration { +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java new file mode 100644 index 000000000..7cfffbc1d --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java @@ -0,0 +1,138 @@ +package project.redis.infrastructure.reservation.adapter; + +import static java.util.concurrent.Executors.newFixedThreadPool; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestConstructor; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.TestConfiguration; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.reservation.util.TestContainerSupport; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; +import project.redis.infrastructure.seat.respository.SeatJpaRepository; + + +@Slf4j +@SpringBootTest +@ContextConfiguration(classes = TestConfiguration.class) +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@RequiredArgsConstructor +class ReservationCommandAdapterTest extends TestContainerSupport { + + private final ReservationCommandAdapter reservationCommandAdapter; + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + private final SeatJpaRepository seatJpaRepository; + private final ScreeningJpaRepository screeningJpaRepository; + + + @Test + void testReserve() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seats = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .toList(); + + Reservation reservation = Reservation.generateReservation( + null, + LocalDateTime.now(), + "hongs", + screening, + seats); + + reservationCommandAdapter.reserve(reservation); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } + + + @Test + void testReserveConcurrencyTest() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seats = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .toList(); + + int threadCount = 10; + ExecutorService executorService = newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + int finalI = i; + executorService.execute(() -> { + try { + Reservation reservation = Reservation.generateReservation( + null, + LocalDateTime.now(), + "user-" + Thread.currentThread().getId(), + screening, + seats + ); + + reservationCommandAdapter.reserve(reservation); + } catch (Exception e) { + log.info("failed to reserve reservation {}", finalI); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } +} \ No newline at end of file diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/ScreeningDataInit.java b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/ScreeningDataInit.java new file mode 100644 index 000000000..42594b643 --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/ScreeningDataInit.java @@ -0,0 +1,70 @@ +package project.redis.infrastructure.reservation.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; +import project.redis.infrastructure.movie.repository.MovieJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; + +@Component +@RequiredArgsConstructor +public class ScreeningDataInit implements CommandLineRunner { + + private final ScreeningJpaRepository screeningJpaRepository; + private final MovieJpaRepository movieJpaRepository; + private final CinemaJpaRepository cinemaJpaRepository; + + private static final Random RANDOM = new Random(); + + @Override + public void run(String... args) throws Exception { + List movies = movieJpaRepository.findAll(); + List cinemas = cinemaJpaRepository.findAll(); + + Stream.iterate(0, i -> i + 1) + .limit(500) + .parallel() + .map(index -> { + + MovieJpaEntity movieJpaEntity = movies.get(RANDOM.nextInt(movies.size())); + CinemaJpaEntity cinemaJpaEntity = cinemas.get(RANDOM.nextInt(cinemas.size())); + + LocalDateTime startTime = generateRandomStartTime(); + LocalDateTime endTime = startTime.plusMinutes(movieJpaEntity.getRunningMinTime()); + + return ScreeningJpaEntity.builder() + .screeningStartTime(startTime) + .screeningEndTime(endTime) + .movie(movieJpaEntity) + .cinema(cinemaJpaEntity) + .build(); + + }) + .forEach(screeningJpaRepository::save); + + } + + public LocalDateTime generateRandomStartTime() { + LocalDate today = LocalDate.now(); + LocalDate startDate = today.plusDays(1); + LocalDate endDate = today.plusDays(20); + + long randomDays = ThreadLocalRandom.current() + .nextLong(ChronoUnit.DAYS.between(startDate, endDate) + 1); + + return startDate + .plusDays(randomDays) + .atTime(new Random().nextInt(18), new Random().nextInt(60)); + } +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/TestContainerSupport.java b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/TestContainerSupport.java new file mode 100644 index 000000000..a4cf1112d --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/TestContainerSupport.java @@ -0,0 +1,44 @@ +package project.redis.infrastructure.reservation.util; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; + + +public abstract class TestContainerSupport { + + @Container + public static final MySQLContainer mysqlContainer + = new MySQLContainer<>("mysql:9.1.0") + .withDatabaseName("db") + .withUsername("user") + .withPassword("1234") + .withCommand( + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_unicode_ci" + ); + + @Container + public static final GenericContainer redisContainer + = new GenericContainer<>("redis") + .withExposedPorts(6379); + + static { + mysqlContainer.start(); + redisContainer.start(); + } + + @DynamicPropertySource + static void setDatasourceProperties(DynamicPropertyRegistry registry) { + System.out.println("mysqlContainer = " + mysqlContainer.getJdbcUrl()); + registry.add("spring.datasource.url", ()-> mysqlContainer.getJdbcUrl() + "?useSSL=false&allowPublicKeyRetrieval=true"); + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName); + + registry.add("redis.host", redisContainer::getHost); + registry.add("redis.port", () -> redisContainer.getMappedPort(6379).toString()); + } +} diff --git a/module-infrastructure/src/test/resources/application.yaml b/module-infrastructure/src/test/resources/application.yaml new file mode 100644 index 000000000..e389ed929 --- /dev/null +++ b/module-infrastructure/src/test/resources/application.yaml @@ -0,0 +1,6 @@ +spring: + flyway: + enabled: true + baseline-on-migrate: true + placeholder-replacement: false + locations: classpath:db/migration diff --git a/module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql b/module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql new file mode 100644 index 000000000..dff8960ba --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql @@ -0,0 +1,75 @@ +-- genre 테이블 생성 +create table genre +( + genre_id binary(16) not null primary key, + genre_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + +-- movie 테이블 생성 +create table movie +( + movie_id binary(16) not null primary key, + title varchar(255) not null, + rating varchar(255) not null, + release_date date not null, + thumbnail_url text, + running_min_time int not null, + genre_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_movie_genre foreign key (genre_id) references genre (genre_id) +) engine = innodb + charset = utf8mb4; + + +-- cinema 테이블 생성 +create table cinema +( + cinema_id binary(16) not null primary key, + cinema_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + + +-- screening 테이블 생성 +create table screening +( + screening_id binary(16) not null primary key, + screening_start_time datetime(6) not null, + screening_end_time datetime(6) not null, + movie_id binary(16) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_screening_movie foreign key (movie_id) references movie (movie_id), + constraint fk_screening_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; + + +-- seat 테이블 생성 +create table seat +( + seat_id binary(16) not null primary key, + seat_number varchar(255) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_seat_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; \ No newline at end of file diff --git a/module-infrastructure/src/test/resources/db/migration/V2__InitData.sql b/module-infrastructure/src/test/resources/db/migration/V2__InitData.sql new file mode 100644 index 000000000..ae31805dc --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V2__InitData.sql @@ -0,0 +1,180 @@ +insert into genre (genre_id, genre_name) +values (uuid_to_bin(uuid()), '액션'), + (uuid_to_bin(uuid()), '코미디'), + (uuid_to_bin(uuid()), '드라마'), + (uuid_to_bin(uuid()), '판타지'), + (uuid_to_bin(uuid()), '로맨스'); + +insert into cinema (cinema_id, cinema_name) +values (uuid_to_bin(uuid()), '스타라이트 상영관'), + (uuid_to_bin(uuid()), '드림씨어터'), + (uuid_to_bin(uuid()), '선셋 극장'), + (uuid_to_bin(uuid()), '루프탑 상영관'), + (uuid_to_bin(uuid()), '클래식 상영관'); + +insert into seat (seat_id, seat_number, cinema_id) +values + -- 루프탑 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + + -- 클래식 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '선셋 극장')); + + +insert into movie (movie_id, title, rating, release_date, thumbnail_url, running_min_time, genre_id) +values + -- 액션 장르 영화 + (uuid_to_bin(uuid()), '매드 맥스: 분노의 도로', 'NINETEEN', '2015-05-15', 'https://example.com/madmax.jpg', 120, + (select genre_id from genre where genre_name = '액션')), + (uuid_to_bin(uuid()), '다이하드', 'NINETEEN', '1988-07-20', 'https://example.com/diehard.jpg', 131, + (select genre_id from genre where genre_name = '액션')), + + -- 코미디 장르 영화 + (uuid_to_bin(uuid()), '슈퍼배드', 'TWELVE', '2010-07-09', 'https://example.com/despicableme.jpg', 95, + (select genre_id from genre where genre_name = '코미디')), + (uuid_to_bin(uuid()), '트루먼 쇼', 'TWELVE', '1998-06-05', 'https://example.com/trumanshow.jpg', 103, + (select genre_id from genre where genre_name = '코미디')), + + -- 드라마 장르 영화 + (uuid_to_bin(uuid()), '쇼생크 탈출', 'FIFTEEN', '1994-09-23', 'https://example.com/shawshank.jpg', 142, + (select genre_id from genre where genre_name = '드라마')), + (uuid_to_bin(uuid()), '포레스트 검프', 'TWELVE', '1994-07-06', 'https://example.com/forrestgump.jpg', 144, + (select genre_id from genre where genre_name = '드라마')), + + -- 판타지 장르 영화 + (uuid_to_bin(uuid()), '반지의 제왕: 반지 원정대', 'FIFTEEN', '2001-12-19', 'https://example.com/lotr.jpg', 178, + (select genre_id from genre where genre_name = '판타지')), + (uuid_to_bin(uuid()), '해리 포터와 마법사의 돌', 'TWELVE', '2001-11-16', 'https://example.com/harrypotter.jpg', 152, + (select genre_id from genre where genre_name = '판타지')), + + -- 로맨스 장르 영화 + (uuid_to_bin(uuid()), '타이타닉', 'FIFTEEN', '1997-12-19', 'https://example.com/titanic.jpg', 195, + (select genre_id from genre where genre_name = '로맨스')), + (uuid_to_bin(uuid()), '노트북', 'FIFTEEN', '2004-06-25', 'https://example.com/notebook.jpg', 123, + (select genre_id from genre where genre_name = '로맨스')); diff --git a/module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql b/module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql new file mode 100644 index 000000000..b2e4cd447 --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql @@ -0,0 +1,4 @@ +ALTER TABLE movie DROP FOREIGN KEY fk_movie_genre; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_movie; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_cinema; +ALTER TABLE seat DROP FOREIGN KEY fk_seat_cinema; \ No newline at end of file diff --git a/module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql b/module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql new file mode 100644 index 000000000..7decc80c5 --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql @@ -0,0 +1,25 @@ +create table reservation +( + reservation_id binary(16) not null primary key, + username varchar(255) not null, + screening_id binary(16) not null, + reservation_time datetime(6) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine=innodb charset=utf8mb4; + + +create table reservation_seat +( + reservation_seat_id binary(16) not null primary key, + reservation_id binary(16) not null, + screening_id binary(16) not null, + seat_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint UK_screening_seat unique (screening_id, seat_id) +) engine=innodb charset=utf8mb4; \ No newline at end of file diff --git a/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql b/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql index 87f247aac..7decc80c5 100644 --- a/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql +++ b/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql @@ -15,9 +15,11 @@ create table reservation_seat ( reservation_seat_id binary(16) not null primary key, reservation_id binary(16) not null, + screening_id binary(16) not null, seat_id binary(16) not null, created_at datetime(6) null, updated_at datetime(6) null, created_by binary(16) null, - updated_by binary(16) null + updated_by binary(16) null, + constraint UK_screening_seat unique (screening_id, seat_id) ) engine=innodb charset=utf8mb4; \ No newline at end of file From 465514aaff7857f6ffa5d825ac8865cf94f82835 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Mon, 3 Feb 2025 23:39:40 +0900 Subject: [PATCH 27/29] =?UTF-8?q?fix(=EB=8F=99=EC=8B=9C=EC=84=B1=20-=20red?= =?UTF-8?q?isson):=20screening-seat=20=EC=A1=B0=ED=95=A9=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=9D=BD=EC=9D=84=20=EA=B1=B8=EC=96=B4=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - redission 세팅 - 테스트 코드로 동시성 검증 --- .../port/outbound/ReservationLockPort.java | 13 ++ .../service/ReservationCommandService.java | 25 +++- module-infrastructure/build.gradle | 2 + .../common/config/RedisConfig.java | 10 ++ .../adapter/ReservationLockAdapter.java | 68 +++++++++ .../IntegrationTestConfiguration.java | 7 + ...ervationCommandServiceIntegrationTest.java | 137 ++++++++++++++++++ .../ReservationCommandAdapterTest.java | 11 +- .../util/ScreeningDataInit.java | 2 +- .../util/TestContainerSupport.java | 2 +- 10 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java create mode 100644 module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java create mode 100644 module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java create mode 100644 module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java rename module-infrastructure/src/test/java/project/redis/infrastructure/{reservation => }/util/ScreeningDataInit.java (98%) rename module-infrastructure/src/test/java/project/redis/infrastructure/{reservation => }/util/TestContainerSupport.java (96%) diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java new file mode 100644 index 000000000..3c223ceb4 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java @@ -0,0 +1,13 @@ +package project.redis.application.reservation.port.outbound; + +import java.util.List; + +public interface ReservationLockPort { + boolean tryLock(String lockKey, long waitTimeMils, long releaseTimeMils); + + boolean tryScreeningSeatLock(List lockKeys, long waitTimeMils, long releaseTimeMils); + + void releaseLock(String lockKey); + + void releaseMultiLock(List lockKeys); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java index d454352db..694502eb9 100644 --- a/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java +++ b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java @@ -5,11 +5,13 @@ import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import project.redis.application.reservation.port.inbound.ReservationCommandUseCase; import project.redis.application.reservation.port.inbound.ReserveCommandParam; import project.redis.application.reservation.port.outbound.ReservationCommandPort; +import project.redis.application.reservation.port.outbound.ReservationLockPort; import project.redis.application.reservation.port.outbound.ReservationQueryPort; import project.redis.application.screening.port.outbound.ScreeningQueryPort; import project.redis.application.seat.port.outbound.SeatQueryPort; @@ -20,6 +22,7 @@ import project.redis.domain.seat.Seat; +@Slf4j @Service @RequiredArgsConstructor public class ReservationCommandService implements ReservationCommandUseCase { @@ -28,6 +31,9 @@ public class ReservationCommandService implements ReservationCommandUseCase { private final ReservationQueryPort reservationQueryPort; private final ScreeningQueryPort screeningQueryPort; private final ReservationCommandPort reservationCommandPort; + + private final ReservationLockPort reservationLockPort; + /* 적용 비지니스 규칙 1. 들어온 좌석은 연속된 좌석이어야 한다. @@ -37,6 +43,17 @@ public class ReservationCommandService implements ReservationCommandUseCase { */ @Override public boolean reserve(ReserveCommandParam param) { + List seatIds = param.getSeatIds().stream().map(String::valueOf).toList(); + boolean lock = reservationLockPort.tryScreeningSeatLock( + makeLockKey(param.getScreeningId().toString(), seatIds), + 20, + 1000); + + if (!lock) { + log.info("locking screening for seat {} failed", param.getScreeningId()); + throw new DataInvalidException(ErrorCode.SEAT_ALREADY_RESERVED, param.getSeatIds().toString()); + } + // 연속된 좌석인지 여부 List seats = seatQueryPort.getSeats(param.getSeatIds()); if (!Seat.isSeries(seats)) { @@ -79,7 +96,13 @@ public boolean reserve(ReserveCommandParam param) { null, LocalDateTime.now(), param.getUserName(), screening, seats); reservationCommandPort.reserve(reservation); - + reservationLockPort.releaseMultiLock(makeLockKey(param.getScreeningId().toString(), seatIds)); return true; } + + private List makeLockKey(String screeningId, List list) { + return list.stream() + .map(seatId -> "reservation-lock:" + screeningId + ":" + seatId) + .toList(); + } } \ No newline at end of file diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle index ef1d21be5..cbdadcb34 100644 --- a/module-infrastructure/build.gradle +++ b/module-infrastructure/build.gradle @@ -42,6 +42,8 @@ dependencies { testImplementation 'org.testcontainers:mysql' testImplementation 'org.testcontainers:redis' + // redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.44.0' annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java index 9ca6cd173..3dcb74a51 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java @@ -10,6 +10,9 @@ import java.time.Duration; import java.util.HashMap; import java.util.Map; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; @@ -103,4 +106,11 @@ public KeyGenerator screeningKeyGenerator() { ":genre:" + genreName; }; } + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress("redis://" + host + ":" + port); + return Redisson.create(config); + } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java new file mode 100644 index 000000000..3baa287c7 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java @@ -0,0 +1,68 @@ +package project.redis.infrastructure.reservation.adapter; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; +import project.redis.application.reservation.port.outbound.ReservationLockPort; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReservationLockAdapter implements ReservationLockPort { + + private final RedissonClient redissonClient; + + @Override + public boolean tryLock(String lockKey, long waitTimeMils, long releaseTimeMils) { + RLock lock = redissonClient.getLock(lockKey); + try { + return lock.tryLock(waitTimeMils, releaseTimeMils, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public boolean tryScreeningSeatLock(List lockKeys, long waitTimeMils, long releaseTimeMils) { + RLock[] locks = lockKeys.stream() + .map(redissonClient::getLock) + .toArray(RLock[]::new); + + RLock multiLock = redissonClient.getMultiLock(locks); + + try { + log.info("Trying to acquire multi-lock for keys: {}", lockKeys); + boolean acquired = multiLock.tryLock(waitTimeMils, releaseTimeMils, TimeUnit.MILLISECONDS); + log.info("Multi-lock acquired: {}", acquired); + return acquired; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public void releaseLock(String lockKey) { + RLock lock = redissonClient.getLock(lockKey); + if (lock.isHeldByCurrentThread()) { + log.info("Release lock ...{}", lockKey); + lock.unlock(); + } + } + + @Override + public void releaseMultiLock(List lockKeys) { + lockKeys.forEach(lockKey -> { + RLock lock = redissonClient.getLock(lockKey); + if (lock.isHeldByCurrentThread()) { + log.info("Release lock ...{}", lockKey); + lock.unlock(); + } + }); + } +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java b/module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java new file mode 100644 index 000000000..ac50f3074 --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java @@ -0,0 +1,7 @@ +package project.redis.infrastructure; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = {"project.redis.infrastructure", "project.redis.application"}) +public class IntegrationTestConfiguration { +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java new file mode 100644 index 000000000..8df31211c --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java @@ -0,0 +1,137 @@ +package project.redis.infrastructure.integration.reservation; + + +import static java.util.concurrent.Executors.newFixedThreadPool; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestConstructor; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.application.reservation.service.ReservationCommandService; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.IntegrationTestConfiguration; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; +import project.redis.infrastructure.seat.respository.SeatJpaRepository; +import project.redis.infrastructure.util.TestContainerSupport; + +@Slf4j +@SpringBootTest +@ContextConfiguration(classes = IntegrationTestConfiguration.class) +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@RequiredArgsConstructor +public class ReservationCommandServiceIntegrationTest extends TestContainerSupport { + + private final ReservationCommandService reservationCommandService; + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + private final ScreeningJpaRepository screeningJpaRepository; + private final SeatJpaRepository seatJpaRepository; + + @DisplayName("상영 예약 동시성 테스트") + @Test + void testReservationConcurrency() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seatIds = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + + int threadCount = 10; + ExecutorService executorService = newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + int finalI = i; + executorService.execute(() -> { + try { + + ReserveCommandParam param = new ReserveCommandParam(seatIds, screening.getScreeningId(), + "user-" + Thread.currentThread().getId()); + reservationCommandService.reserve(param); + + } catch (Exception e) { + log.info("failed to reserve reservation {}", finalI); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } + + + @DisplayName("상영 예약 테스트") + @Test + void testReservation() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seatIds = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + + ReserveCommandParam param = new ReserveCommandParam(seatIds, screening.getScreeningId(), + "user-" + Thread.currentThread().getId()); + reservationCommandService.reserve(param); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java index 7cfffbc1d..df3be794d 100644 --- a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java @@ -10,6 +10,7 @@ import java.util.concurrent.ExecutorService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ContextConfiguration; @@ -22,12 +23,12 @@ import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; -import project.redis.infrastructure.reservation.util.TestContainerSupport; import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; import project.redis.infrastructure.seat.mapper.SeatInfraMapper; import project.redis.infrastructure.seat.respository.SeatJpaRepository; +import project.redis.infrastructure.util.TestContainerSupport; @Slf4j @@ -43,9 +44,14 @@ class ReservationCommandAdapterTest extends TestContainerSupport { private final SeatJpaRepository seatJpaRepository; private final ScreeningJpaRepository screeningJpaRepository; + @AfterEach + void tearDown() { + reservationSeatJpaRepository.deleteAll(); + reservationJpaRepository.deleteAll(); + } @Test - void testReserve() throws InterruptedException { + void testReserve() { LocalDate limitDay = LocalDate.now().plusDays(2); List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( limitDay); @@ -80,7 +86,6 @@ void testReserve() throws InterruptedException { assertThat(reservationSeats.size()).isEqualTo(5); } - @Test void testReserveConcurrencyTest() throws InterruptedException { LocalDate limitDay = LocalDate.now().plusDays(2); diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/ScreeningDataInit.java b/module-infrastructure/src/test/java/project/redis/infrastructure/util/ScreeningDataInit.java similarity index 98% rename from module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/ScreeningDataInit.java rename to module-infrastructure/src/test/java/project/redis/infrastructure/util/ScreeningDataInit.java index 42594b643..7a5b93279 100644 --- a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/ScreeningDataInit.java +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/util/ScreeningDataInit.java @@ -1,4 +1,4 @@ -package project.redis.infrastructure.reservation.util; +package project.redis.infrastructure.util; import java.time.LocalDate; import java.time.LocalDateTime; diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/TestContainerSupport.java b/module-infrastructure/src/test/java/project/redis/infrastructure/util/TestContainerSupport.java similarity index 96% rename from module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/TestContainerSupport.java rename to module-infrastructure/src/test/java/project/redis/infrastructure/util/TestContainerSupport.java index a4cf1112d..e0a7ec1fb 100644 --- a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/util/TestContainerSupport.java +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/util/TestContainerSupport.java @@ -1,4 +1,4 @@ -package project.redis.infrastructure.reservation.util; +package project.redis.infrastructure.util; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; From 002f15f0b1652ffa9b9b8c8ccad04df737116a94 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Mon, 3 Feb 2025 23:52:28 +0900 Subject: [PATCH 28/29] =?UTF-8?q?fix(lock=20=ED=95=B4=EC=A0=9C=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90):=20try~finally=EB=A1=9C=20=EB=9D=BD=EC=9D=84=20?= =?UTF-8?q?=ED=9A=8D=EB=93=9D=ED=95=9C=20=EC=9A=94=EC=B2=AD=EC=9D=B4=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=EC=9D=84=20=EC=8B=A4=ED=8C=A8=ED=95=9C=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=EC=97=90=20=EB=8C=80=ED=95=B4=20?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B3=EC=97=90=EC=84=9C=20=EB=9D=BD=EC=9D=84=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReservationCommandService.java | 89 ++++++++++--------- 1 file changed, 46 insertions(+), 43 deletions(-) diff --git a/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java index 694502eb9..dcc3fd99e 100644 --- a/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java +++ b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java @@ -54,50 +54,53 @@ public boolean reserve(ReserveCommandParam param) { throw new DataInvalidException(ErrorCode.SEAT_ALREADY_RESERVED, param.getSeatIds().toString()); } - // 연속된 좌석인지 여부 - List seats = seatQueryPort.getSeats(param.getSeatIds()); - if (!Seat.isSeries(seats)) { - throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + try { + // 연속된 좌석인지 여부 + List seats = seatQueryPort.getSeats(param.getSeatIds()); + if (!Seat.isSeries(seats)) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + + // 예약 가져오기 + List originReservations = reservationQueryPort.getReservations(param.getScreeningId()); + + List seatList = originReservations.stream() + .flatMap(reservation -> reservation.getSeats().stream()) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + + // 이미 예약이 존재하는 좌석인지 검증 + boolean isAlreadyReservation = seatList.retainAll(param.getSeatIds()); + + if( isAlreadyReservation ) { + throw new DataInvalidException(ErrorCode.SEAT_ALREADY_RESERVED, seatList.toString()); + } + + List originSeats = originReservations.stream() + .filter(reservation -> reservation.getUsername().equals(param.getUserName())) + .flatMap(reservation -> reservation.getSeats().stream()) + .toList(); + + // 이전 예약 + 현재 예약하려는 좌석의 연속성 검증 && 5개 이하의 예약 검증 + Seat.isAvailable(originSeats, seats); + + Screening screening = !CollectionUtils.isEmpty(originReservations) + ? originReservations.getFirst().getScreening() + : screeningQueryPort.getScreening(param.getScreeningId()); + + if (!screening.isLaterScreening()) { + throw new DataInvalidException(ErrorCode.SCREENING_REQUIRED_LATER_NOW, param.getScreeningId()); + } + + Reservation reservation + = Reservation.generateReservation( + null, LocalDateTime.now(), param.getUserName(), screening, seats); + + reservationCommandPort.reserve(reservation); + return true; + } finally { + reservationLockPort.releaseMultiLock(makeLockKey(param.getScreeningId().toString(), seatIds)); } - - // 예약 가져오기 - List originReservations = reservationQueryPort.getReservations(param.getScreeningId()); - - List seatList = originReservations.stream() - .flatMap(reservation -> reservation.getSeats().stream()) - .map(Seat::getSeatId) - .collect(Collectors.toList()); - - // 이미 예약이 존재하는 좌석인지 검증 - boolean isAlreadyReservation = seatList.retainAll(param.getSeatIds()); - - if( isAlreadyReservation ) { - throw new DataInvalidException(ErrorCode.SEAT_ALREADY_RESERVED, seatList.toString()); - } - - List originSeats = originReservations.stream() - .filter(reservation -> reservation.getUsername().equals(param.getUserName())) - .flatMap(reservation -> reservation.getSeats().stream()) - .toList(); - - // 이전 예약 + 현재 예약하려는 좌석의 연속성 검증 && 5개 이하의 예약 검증 - Seat.isAvailable(originSeats, seats); - - Screening screening = !CollectionUtils.isEmpty(originReservations) - ? originReservations.getFirst().getScreening() - : screeningQueryPort.getScreening(param.getScreeningId()); - - if (!screening.isLaterScreening()) { - throw new DataInvalidException(ErrorCode.SCREENING_REQUIRED_LATER_NOW, param.getScreeningId()); - } - - Reservation reservation - = Reservation.generateReservation( - null, LocalDateTime.now(), param.getUserName(), screening, seats); - - reservationCommandPort.reserve(reservation); - reservationLockPort.releaseMultiLock(makeLockKey(param.getScreeningId().toString(), seatIds)); - return true; } private List makeLockKey(String screeningId, List list) { From 03091b1c678378ccc2b1ebf7259ab6309f9e3dc0 Mon Sep 17 00:00:00 2001 From: hongs429 Date: Tue, 4 Feb 2025 00:24:05 +0900 Subject: [PATCH 29/29] =?UTF-8?q?fix(Test=20=EC=9D=BC=EA=B4=80=EC=84=B1):?= =?UTF-8?q?=20=EA=B0=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=80=EC=84=B1=20=EC=9C=A0=EC=A7=80=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20@AfterEach=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReservationCommandServiceIntegrationTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java index 8df31211c..2333ab9a0 100644 --- a/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java @@ -13,6 +13,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -48,6 +49,12 @@ public class ReservationCommandServiceIntegrationTest extends TestContainerSuppo private final ScreeningJpaRepository screeningJpaRepository; private final SeatJpaRepository seatJpaRepository; + @AfterEach + void tearDown() { + reservationSeatJpaRepository.deleteAll(); + reservationJpaRepository.deleteAll(); + } + @DisplayName("상영 예약 동시성 테스트") @Test void testReservationConcurrency() throws InterruptedException {