diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..a4de0b4d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md index 5fcc66b4d..3dbcf9413 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,33 @@ -## [본 과정] 이커머스 핵심 프로세스 구현 -[단기 스킬업 Redis 교육 과정](https://hh-skillup.oopy.io/) 을 통해 상품 조회 및 주문 과정을 구현하며 현업에서 발생하는 문제를 Redis의 핵심 기술을 통해 해결합니다. -> Indexing, Caching을 통한 성능 개선 / 단계별 락 구현을 통한 동시성 이슈 해결 (낙관적/비관적 락, 분산락 등) +# JM 항해 시네마소개 + +## 프로젝트 소개 +- 해당과정은 [단기 스킬업 Redis 교육 과정](https://hh-skillup.oopy.io/) 을 하면서 진행한 프로젝트이다. +- 영화목록을 조회할수 있는 서비스 프로젝트 +- 향후 진행하면서 README파일은 계속 업데이트 + +## API +### Get +|#|API|내용|비고| +|---|------|---|---| +|1|/api/movies|서버가 가지고 있는 영화목록 리턴| | +|1|/api/movies/title?title=|특정 title의 영화 정보를 리턴| | +|1|/api/movies/genre?genre=|특정 genre의 영화목록을 리턴| | + +### Post +|#|API|내용|비고| +|---|------|---|---| +|1|/api/movies|서버에 영화목록을 입력| | + +## Architecture + - 해당과제는 Controller, Service, domain으로 나누어져있는 Layered Architecture를 기본으로한다. + - 구현을 진행하면서, 필요시 Layered Architecture를 기반으로 Module을 분리예정 +image + +## TABLE +- 현재 DB에 저장되는 Data는 아래와 같다. +- 구현 진행하면서, 필요시 변경예정 +image + +## TODO +- 메인페이지 추가 구현 +- /api/movie, /api/movie/title, /api/movie/genre API 합치기 diff --git a/app/.gitattributes b/app/.gitattributes new file mode 100644 index 000000000..8af972cde --- /dev/null +++ b/app/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..03a129c20 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,40 @@ +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/ + +### etc ### +volumes/ \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..8e3b3e574 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,127 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.1' + id 'io.spring.dependency-management' version '1.1.7' + id "jacoco" +} + +group = 'com.movie' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.43.0' + implementation 'com.google.guava:guava:33.4.0-jre' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-docker-compose' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() + finalizedBy 'jacocoTestReport' +} + +// jacoco 정보 +jacoco { + toolVersion = "0.8.11" + layout.buildDirectory.dir("reports/jacoco") +} + +// jacoco Report 생성 +jacocoTestReport { + dependsOn test // test 종속성 추가 + + reports { + xml.required = true + csv.required = false + html.required = true + } + + def QDomainList = [] + for (qPattern in '**/QA'..'**/QZ') { // QClass 대응 + QDomainList.add(qPattern + '*') + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + '**/dto/**', + '**/event/**', + '**/*InitData*', + '**/*Application*', + '**/exception/**', + '**/service/alarm/**', + '**/aop/**', + '**/config/**', + '**/MemberRole*' + ] + QDomainList) + })) + } + + finalizedBy 'jacocoTestCoverageVerification' // jacocoTestReport 태스크가 끝난 후 실행 +} + +// jacoco Test 유효성 확인 +jacocoTestCoverageVerification { + def QDomainList = [] + for (qPattern in '*.QA'..'*.QZ') { // QClass 대응 + QDomainList.add(qPattern + '*') + } + + violationRules { + rule { + enabled = true // 규칙 활성화 여부 + element = 'CLASS' // 커버리지를 체크할 단위 설정 + + // 코드 커버리지를 측정할 때 사용되는 지표 + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.30 + } + + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.30 + } + + excludes = [ + '**.dto.**', + '**.event.**', + '**.*InitData*', + '**.*Application*', + '**.exception.**', + '**.service.alarm.**', + '**.aop.**', + '**.config.**', + '**.MemberRole*' + ] + QDomainList + } + } +} \ No newline at end of file diff --git a/app/compose.yaml b/app/compose.yaml new file mode 100644 index 000000000..d6cdf06ef --- /dev/null +++ b/app/compose.yaml @@ -0,0 +1,24 @@ +services: + mysql: + image: 'mysql:latest' + environment: + MYSQL_DATABASE: redis1st + MYSQL_PASSWORD: PASSWORD + MYSQL_ROOT_PASSWORD: PASSWORD + MYSQL_USER: redis1st + volumes: + - ./volumes/db/mysql/data:/var/lib/mysql + - ./volumes/db/mysql/init:/docker-entrypoint-initdb.d + ports: + - '3306:3306' + redis: + image: redis:latest + labels: + - "name=redis" + - "mode=standalone" + command: redis-server /usr/local/conf/redis.conf + volumes: + - ./volumes/db/redis/data:/data + - ./volumes/db/redis/conf/redis.conf:/usr/local/conf/redis.conf + ports: + - '6379:6379' diff --git a/app/gradle/wrapper/gradle-wrapper.jar b/app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..a4b76b953 Binary files /dev/null and b/app/gradle/wrapper/gradle-wrapper.jar differ diff --git a/app/gradle/wrapper/gradle-wrapper.properties b/app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..e2847c820 --- /dev/null +++ b/app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/app/gradlew b/app/gradlew new file mode 100755 index 000000000..f5feea6d6 --- /dev/null +++ b/app/gradlew @@ -0,0 +1,252 @@ +#!/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 +' "$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/app/gradlew.bat b/app/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/app/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/app/settings.gradle b/app/settings.gradle new file mode 100644 index 000000000..0cdaa80e4 --- /dev/null +++ b/app/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'app' diff --git a/app/src/main/java/com/movie/app/AppApplication.java b/app/src/main/java/com/movie/app/AppApplication.java new file mode 100644 index 000000000..fa9e2282b --- /dev/null +++ b/app/src/main/java/com/movie/app/AppApplication.java @@ -0,0 +1,17 @@ +package com.movie.app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableCaching +@EnableJpaAuditing +@SpringBootApplication +public class AppApplication { + + public static void main(String[] args) { + SpringApplication.run(AppApplication.class, args); + } + +} diff --git a/app/src/main/java/com/movie/app/config/RedisCacheConfig.java b/app/src/main/java/com/movie/app/config/RedisCacheConfig.java new file mode 100644 index 000000000..184fee80f --- /dev/null +++ b/app/src/main/java/com/movie/app/config/RedisCacheConfig.java @@ -0,0 +1,29 @@ +package com.movie.app.config; + +import java.time.Duration; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableCaching +public class RedisCacheConfig { + + @Bean + public CacheManager contentCacheManager(RedisConnectionFactory cf) { + RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) // Value Serializer 변경 + .entryTtl(Duration.ofMinutes(3L)); // 캐시 수명 30분 + + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf).cacheDefaults(redisCacheConfiguration).build(); + } +} diff --git a/app/src/main/java/com/movie/app/controller/MovieRestController.java b/app/src/main/java/com/movie/app/controller/MovieRestController.java new file mode 100644 index 000000000..94a654976 --- /dev/null +++ b/app/src/main/java/com/movie/app/controller/MovieRestController.java @@ -0,0 +1,66 @@ +package com.movie.app.controller; +import java.util.List; + +import com.google.common.util.concurrent.RateLimiter; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import com.movie.app.domain.Movie; +import com.movie.app.domain.MovieRequestDto; +import com.movie.app.domain.TicketingRequestDto; +import com.movie.app.service.MovieService; + +import jakarta.annotation.PostConstruct; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; + + +@RequiredArgsConstructor +@RestController +public class MovieRestController { + + private final MovieService movieService; + private static RateLimiter moviesRateLimiter; + private static RateLimiter ticketingLimiter; + + @PostConstruct + public void init() { + moviesRateLimiter = RateLimiter.create(0.8);//50permits/60sec = 0.8permits/1sec + ticketingLimiter = RateLimiter.create(0.003);//1permits/5min = 0.003permits/1sec + } + + @GetMapping("/api/movies") + public ResponseEntity> getMovies( + @Size(max = 100, message = "title length should not exceed 100 characters") + @RequestParam(required=false) String title, + @RequestParam(required=false) String genre) { + + if(!moviesRateLimiter.tryAcquire()) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); + } + + if(genre != null) { + return ResponseEntity.ok(movieService.getMoviesByGenre(genre)); + } else if (title != null) { + return ResponseEntity.ok(movieService.getMoviesByTitle(title)); + } else { + return ResponseEntity.ok(movieService.getMoviesAll()); + } + } + + @PostMapping("/api/movies") + public Movie postMovies(@RequestBody MovieRequestDto requestDto) { + return movieService.postMovie(requestDto); + } + + @PutMapping("/api/ticketing/{id}") + public ResponseEntity ticketingMovie(@PathVariable Long id, @RequestBody TicketingRequestDto requestDto) { + if(!ticketingLimiter.tryAcquire()) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); + } + + return ResponseEntity.ok(movieService.ticketing(id ,requestDto)); + } +} diff --git a/app/src/main/java/com/movie/app/domain/Movie.java b/app/src/main/java/com/movie/app/domain/Movie.java new file mode 100644 index 000000000..3f1cdb5ac --- /dev/null +++ b/app/src/main/java/com/movie/app/domain/Movie.java @@ -0,0 +1,76 @@ +package com.movie.app.domain; + +import java.time.LocalTime; +import java.util.List; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Entity(name="Movie") +public class Movie extends Timestamped{ + + private static final int MAX_SEATS = 25; + + @GeneratedValue(strategy = GenerationType.AUTO) + @Id + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String rating; + + @Column(nullable = false) + private String releaseDate; + + @Column(nullable = false) + private String thumbnailImage; + + @Column(nullable = false) + private String runningTime; + + @Column(nullable = false) + private String genre; + + @Column(nullable = false) + private String theater; + + @Column + private LocalTime screenStartTime; + + @Column + private LocalTime screenEndTime; + + @Column + private Boolean[] seats = new Boolean[MAX_SEATS]; + + + public Movie(MovieRequestDto requestDto) { + this.title = requestDto.getTitle(); + this.rating = requestDto.getRating(); + this.releaseDate = requestDto.getReleaseDate(); + this.thumbnailImage = requestDto.getThumbnailImage(); + this.runningTime = requestDto.getRunningTime(); + this.genre = requestDto.getGenre(); + this.theater = requestDto.getTheater(); + } + + public void updateSeats(List wantedSeats) { + if(this.seats == null) { + this.seats = new Boolean[MAX_SEATS]; + } + + for (int i=0; i{ + List findByTitle(String title); + List findByGenre(String genre); +} diff --git a/app/src/main/java/com/movie/app/domain/MovieRequestDto.java b/app/src/main/java/com/movie/app/domain/MovieRequestDto.java new file mode 100644 index 000000000..a01e1de12 --- /dev/null +++ b/app/src/main/java/com/movie/app/domain/MovieRequestDto.java @@ -0,0 +1,17 @@ +package com.movie.app.domain; + +import java.util.List; + +import lombok.Getter; + +@Getter +public class MovieRequestDto { + private String title; + private String rating; + private String releaseDate; + private String thumbnailImage; + private String runningTime; + private String genre; + private String theater; + private List screeningSchedule; +} diff --git a/app/src/main/java/com/movie/app/domain/TicketingRequestDto.java b/app/src/main/java/com/movie/app/domain/TicketingRequestDto.java new file mode 100644 index 000000000..3def536a7 --- /dev/null +++ b/app/src/main/java/com/movie/app/domain/TicketingRequestDto.java @@ -0,0 +1,10 @@ +package com.movie.app.domain; + +import java.util.List; + +import lombok.Getter; + +@Getter +public class TicketingRequestDto { + private List seats; +} diff --git a/app/src/main/java/com/movie/app/domain/Timestamped.java b/app/src/main/java/com/movie/app/domain/Timestamped.java new file mode 100644 index 000000000..b1306a6ef --- /dev/null +++ b/app/src/main/java/com/movie/app/domain/Timestamped.java @@ -0,0 +1,40 @@ +package com.movie.app.domain; + +import java.time.LocalDateTime; + +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; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class Timestamped { + + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + @CreatedDate + private LocalDateTime createdAt; + + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + @LastModifiedDate + private LocalDateTime modifiedAt; + + @CreatedBy + private Long createdId; + + @LastModifiedBy + private Long modifiedId; +} diff --git a/app/src/main/java/com/movie/app/service/MovieService.java b/app/src/main/java/com/movie/app/service/MovieService.java new file mode 100644 index 000000000..2d72f6dc1 --- /dev/null +++ b/app/src/main/java/com/movie/app/service/MovieService.java @@ -0,0 +1,73 @@ +package com.movie.app.service; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.movie.app.domain.Movie; +import com.movie.app.domain.MovieRepository; +import com.movie.app.domain.MovieRequestDto; +import com.movie.app.domain.TicketingRequestDto; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class MovieService { + + private final MovieRepository movieRepository; + private final RedissonClient redissonClient; + + @Cacheable(value = "Movies", key = "#title", cacheManager = "contentCacheManager") + public List getMoviesByTitle(String title) { + return movieRepository.findByTitle(title); + } + + @Cacheable(value = "Movies", key = "#genre", cacheManager = "contentCacheManager") + public List getMoviesByGenre(String genre) { + return movieRepository.findByGenre(genre); + } + + @Cacheable(value = "Movies", key = "'all'", cacheManager = "contentCacheManager") + public List getMoviesAll() { + return movieRepository.findAll(); + } + + public Movie postMovie(MovieRequestDto requestDto) { + Movie movie = new Movie(requestDto); + movieRepository.save(movie); + return movie; + } + + @Transactional + public Movie ticketing(Long id, TicketingRequestDto requestDto) { + Movie movie = movieRepository.findById(id).orElseThrow( + () -> new NullPointerException("There is no id at DB.") + ); + + if(movie==null) { + return movie; + } + + RLock lock = redissonClient.getLock(id.toString()); + try { + boolean acquireLock = lock.tryLock(10, 1, TimeUnit.SECONDS); + if (!acquireLock) { + System.out.println("Lock get fail"); + return movie; + } + movie.updateSeats(requestDto.getSeats()); + } catch (InterruptedException e) { + } finally { + lock.unlock(); + } + + return movie; + } + +} diff --git a/app/src/main/resources/application.properties b/app/src/main/resources/application.properties new file mode 100644 index 000000000..606875365 --- /dev/null +++ b/app/src/main/resources/application.properties @@ -0,0 +1,14 @@ +spring.datasource.url=jdbc:mysql://localhost:3306/redis1st +spring.datasource.username=redis1st +spring.datasource.password=password +spring.application.name=app + +spring.jpa.properties.hibernate.dialect= org.hibernate.dialect.MySQLDialect +spring.jpa.defer-datasource-initialization=true + +spring.sql.init.mode=always + +spring.data.redis.host=localhost +spring.data.redis.port=6379 + +spring.docker.compose.enabled=true \ No newline at end of file diff --git a/app/src/main/resources/static/basic.js b/app/src/main/resources/static/basic.js new file mode 100644 index 000000000..172d7cc3f --- /dev/null +++ b/app/src/main/resources/static/basic.js @@ -0,0 +1,107 @@ +let myGenre = "all" + +$(document).ready(function () { + + $('#query').on('keypress', function (e) { + if (e.key == 'Enter') { + execSearch(); + } + }); + + $('.nav div.nav-action').on('click', function () { + showGenre('action') + }) + + $('.nav div.nav-romance').on('click', function () { + showGenre('romance') + }) + + $('.nav div.nav-horror').on('click', function () { + showGenre('horror') + }) + + $('.nav div.nav-SF').on('click', function () { + showGenre('SF') + }) + + + showMovies(); +}) + +/////////////////////////////////////////////////////////////////////////////////////////// +function showMovies() { + $.ajax({ + type: 'GET', + url: '/api/movies', + success: function(response) { + $('#search-result-box').empty(); + + for(let i=0; i +
+ +
+
+
타이틀: ${movie.title}
+
영상물 등급: ${movie.rating}
+
개봉일: ${movie.releaseDate}
+
이미지: ${movie.thumbnailImage}
+
러닝 타임(분): ${movie.runningTime}
+
영화 장르: : ${movie.genre}
+
상영관 이름: : ${movie.theater}
+
+ `; +} + +function execSearch() { + $('#search-result-box').empty(); + + let query = $('#query').val(); + if (query =='') { + alert("검색어를 입력해주세요."); + $('#query').focus(); + showMovies(); + } + + $.ajax({ + type: 'GET', + url: `/api/movies?title=${query}`, + success: function(response) { + $('#search-result-box').empty(); + + for(let i=0; i + + + + + + + + + JM 항해 시네마 + + +
+ JM 항해 시네마 +
+ +
+
+ +
+
+
+
+ +
+
+
영화 이름
+
영상물 등급
+
개봉일
+
러닝 타임(분)
+
영화 장르
+
상영관 이름
+
+
+
+
+ + \ No newline at end of file diff --git a/app/src/main/resources/static/style.css b/app/src/main/resources/static/style.css new file mode 100644 index 000000000..866a18365 --- /dev/null +++ b/app/src/main/resources/static/style.css @@ -0,0 +1,273 @@ + +body { + margin: 0px; +} + +#search-result-box { + margin-top: 15px; +} + +.search-itemDto { + width: 530px; + display: flex; + flex-direction: row; + align-content: center; + justify-content: space-around; +} + +.search-itemDto-left img { + width: 159px; + height: 159px; +} + +.search-itemDto-center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-evenly; +} + +.search-itemDto-center div { + width: 280px; + height: 23px; + font-size: 18px; + font-weight: normal; + font-stretch: normal; + font-style: normal; + line-height: 1.3; + letter-spacing: -0.9px; + text-align: left; + color: #343a40; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.search-itemDto-center div.price { + height: 27px; + font-size: 27px; + font-weight: 600; + font-stretch: normal; + font-style: normal; + line-height: 1; + letter-spacing: -0.54px; + text-align: left; + color: #E8344E; +} + +.search-itemDto-center span.unit { + width: 17px; + height: 18px; + font-size: 18px; + font-weight: 500; + font-stretch: normal; + font-style: normal; + line-height: 1; + letter-spacing: -0.9px; + text-align: center; + color: #000000; +} + +.search-itemDto-right { + display: inline-block; + height: 100%; + vertical-align: middle +} + +.search-itemDto-right img { + height: 25px; + width: 25px; + vertical-align: middle; + margin-top: 60px; + cursor: pointer; +} + +input#query { + padding: 15px; + width: 526px; + border-radius: 2px; + background-color: #e9ecef; + border: none; + + background-image: url('images/icon-search.png'); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 20px 20px; +} + +input#query::placeholder { + padding: 15px; +} + +button { + color: white; + border-radius: 4px; + border-radius: none; +} + +.popup-container { + display: none; + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; +} + +.popup-container.active { + display: flex; +} + +.popup { + padding: 20px; + box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.3); + position: relative; + width: 370px; + height: 190px; + border-radius: 11px; + background-color: #ffffff; +} + +.popup h1 { + margin: 0px; + font-size: 22px; + font-weight: 500; + font-stretch: normal; + font-style: normal; + line-height: 1; + letter-spacing: -1.1px; + color: #000000; +} + +.popup input { + width: 330px; + height: 39px; + border-radius: 2px; + border: solid 1.1px #dee2e6; + margin-right: 9px; + margin-bottom: 10px; + padding-left: 10px; +} + +.popup button.close { + position: absolute; + top: 15px; + right: 15px; + color: #adb5bd; + background-color: #fff; + font-size: 19px; + border: none; +} + +.popup button.cta { + width: 369.1px; + height: 43.9px; + border-radius: 2px; + background-color: #15aabf; + border: none; +} + +#search-area, #see-area { + width: 530px; + margin: auto; +} + +.nav { + width: 530px; + margin: 30px auto; + display: flex; + align-items: center; + justify-content: space-around; +} + +.nav div { + cursor: pointer; +} + +.nav div.active { + font-weight: 700; +} + +.header { + background-color: #15aabf; + color: white; + text-align: center; + padding: 50px; + font-size: 45px; + font-weight: bold; +} + +#product-container { + grid-template-columns: 100px 50px 100px; + grid-template-rows: 80px auto 80px; + column-gap: 10px; + row-gap: 15px; +} + +.product-card { + width: 300px; + margin: auto; + cursor: pointer; +} + +.product-card .card-header { + width: 300px; +} + +.product-card .card-header img { + width: 300px; +} + +.product-card .card-body { + margin-top: 15px; +} + +.product-card .card-body .title { + font-size: 15px; + font-weight: normal; + font-stretch: normal; + font-style: normal; + line-height: 1; + letter-spacing: -0.75px; + text-align: left; + color: #343a40; + margin-bottom: 10px; +} + +.product-card .card-body .lprice { + font-size: 15.8px; + font-weight: normal; + font-stretch: normal; + font-style: normal; + line-height: 1; + letter-spacing: -0.79px; + color: #000000; + margin-bottom: 10px; +} + +.product-card .card-body .lprice span { + font-size: 21.4px; + font-weight: 600; + font-stretch: normal; + font-style: normal; + line-height: 1; + letter-spacing: -0.43px; + text-align: left; + color: #E8344E; +} + +.product-card .card-body .isgood { + margin-top: 10px; + padding: 10px 20px; + color: white; + border-radius: 2.6px; + background-color: #ff8787; + width: 42px; +} + +.none { + display: none; +} \ No newline at end of file diff --git a/app/src/test/java/com/movie/app/AppApplicationTests.java b/app/src/test/java/com/movie/app/AppApplicationTests.java new file mode 100644 index 000000000..3b2f35d61 --- /dev/null +++ b/app/src/test/java/com/movie/app/AppApplicationTests.java @@ -0,0 +1,14 @@ +package com.movie.app; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AppApplicationTests { + + @Test + void contextLoads() { + System.out.println("Hello Test!!!!!!!!!!"); + } + +} diff --git a/document/perfomanceResult_after_250119 b/document/perfomanceResult_after_250119 new file mode 100644 index 000000000..fa39431b0 --- /dev/null +++ b/document/perfomanceResult_after_250119 @@ -0,0 +1,36 @@ +⚙ parkjaemin@bagjaemin-ui-MacBookAir  ~/workspace/coding/redis_1st/tools/k6   main ±  k6 run script.ts + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + execution: local + script: script.ts + output: - + + scenarios: (100.00%) 1 scenario, 10 max VUs, 5m30s max duration (incl. graceful stop): + * default: 10 looping VUs for 5m0s (gracefulStop: 30s) + + + data_received..................: 1.1 GB 3.7 MB/s + data_sent......................: 350 MB 1.2 MB/s + http_req_blocked...............: avg=2.7µs min=0s med=1µs max=9.12ms p(90)=2µs p(95)=3µs + http_req_connecting............: avg=1.2µs min=0s med=0s max=9.08ms p(90)=0s p(95)=0s + http_req_duration..............: avg=870.64µs min=369µs med=795µs max=50.91ms p(90)=1.15ms p(95)=1.32ms + { expected_response:true }...: avg=870.64µs min=369µs med=795µs max=50.91ms p(90)=1.15ms p(95)=1.32ms + http_req_failed................: 0.00% 0 out of 3370000 + http_req_receiving.............: avg=23.05µs min=-39000ns med=16µs max=27.43ms p(90)=45µs p(95)=59µs + http_req_sending...............: avg=3.22µs min=-36000ns med=2µs max=2.45ms p(90)=5µs p(95)=7µs + http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting...............: avg=844.36µs min=358µs med=770µs max=50.19ms p(90)=1.12ms p(95)=1.29ms + http_reqs......................: 3370000 11232.224713/s + iteration_duration.............: avg=89.01ms min=67.14ms med=85.25ms max=673.79ms p(90)=104.39ms p(95)=111.23ms + iterations.....................: 33700 112.322247/s + vus............................: 10 min=10 max=10 + vus_max........................: 10 min=10 max=10 + + +running (5m00.0s), 00/10 VUs, 33700 complete and 0 interrupted iterations +default ✓ [======================================] 10 VUs 5m0s \ No newline at end of file diff --git a/document/perfomanceResult_before_250119.txt b/document/perfomanceResult_before_250119.txt new file mode 100644 index 000000000..481e41e6d --- /dev/null +++ b/document/perfomanceResult_before_250119.txt @@ -0,0 +1,36 @@ +⚙ parkjaemin@bagjaemin-ui-MacBookAir  ~/workspace/coding/redis_1st/tools/k6   main ±  k6 run script.ts + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + execution: local + script: script.ts + output: - + + scenarios: (100.00%) 1 scenario, 10 max VUs, 5m30s max duration (incl. graceful stop): + * default: 10 looping VUs for 5m0s (gracefulStop: 30s) + + + data_received..................: 583 MB 1.9 MB/s + data_sent......................: 186 MB 620 kB/s + http_req_blocked...............: avg=4µs min=0s med=2µs max=5.25ms p(90)=3µs p(95)=4µs + http_req_connecting............: avg=1.69µs min=0s med=0s max=2.74ms p(90)=0s p(95)=0s + http_req_duration..............: avg=1.64ms min=569µs med=1.54ms max=108.95ms p(90)=2.14ms p(95)=2.45ms + { expected_response:true }...: avg=1.64ms min=569µs med=1.54ms max=108.95ms p(90)=2.14ms p(95)=2.45ms + http_req_failed................: 0.00% 0 out of 1791100 + http_req_receiving.............: avg=32.95µs min=4µs med=24µs max=17.75ms p(90)=61µs p(95)=80µs + http_req_sending...............: avg=5.36µs min=1µs med=5µs max=8.17ms p(90)=9µs p(95)=11µs + http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting...............: avg=1.6ms min=556µs med=1.5ms max=107.12ms p(90)=2.1ms p(95)=2.4ms + http_reqs......................: 1791100 5968.084002/s + iteration_duration.............: avg=167.52ms min=124.42ms med=161.7ms max=550.56ms p(90)=190.82ms p(95)=208.4ms + iterations.....................: 17911 59.68084/s + vus............................: 10 min=10 max=10 + vus_max........................: 10 min=10 max=10 + + +running (5m00.1s), 00/10 VUs, 17911 complete and 0 interrupted iterations +default ✓ [======================================] 10 VUs 5m0s \ No newline at end of file diff --git a/document/perfomance_test_with_redis.md b/document/perfomance_test_with_redis.md new file mode 100644 index 000000000..dcc4a43a3 --- /dev/null +++ b/document/perfomance_test_with_redis.md @@ -0,0 +1,118 @@ +# 용량 시험 +- 이 page는 본 app의 redis 추가 전 후의 용량을 비교한 문서이다. +- k6로 용량 측정함 + +## Test Case +- title 조회 API를 이용하여 용량을 측정 +- 5분 * 10명 * 100개 title을 조회 +- 사용한 k6스크립트 +```ts +import http from 'k6/http'; + +import {sleep} from "k6" + +export let options = { + vus: 10, + duration: '5m' +} + +const BASE_URL = 'http://localhost:8080/api/search?title=title' + +export default function() { + + let getUrl = BASE_URL + for (let titleid=1; titleid<=100; titleid++) { + http.get(getUrl+titleid); + // console.log(getUrl+titleid); + } + +} +``` + + +## redis 도입 이전 +``` + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + execution: local + script: script.ts + output: - + + scenarios: (100.00%) 1 scenario, 10 max VUs, 5m30s max duration (incl. graceful stop): + * default: 10 looping VUs for 5m0s (gracefulStop: 30s) + + + data_received..................: 583 MB 1.9 MB/s + data_sent......................: 186 MB 620 kB/s + http_req_blocked...............: avg=4µs min=0s med=2µs max=5.25ms p(90)=3µs p(95)=4µs + http_req_connecting............: avg=1.69µs min=0s med=0s max=2.74ms p(90)=0s p(95)=0s + http_req_duration..............: avg=1.64ms min=569µs med=1.54ms max=108.95ms p(90)=2.14ms p(95)=2.45ms + { expected_response:true }...: avg=1.64ms min=569µs med=1.54ms max=108.95ms p(90)=2.14ms p(95)=2.45ms + http_req_failed................: 0.00% 0 out of 1791100 + http_req_receiving.............: avg=32.95µs min=4µs med=24µs max=17.75ms p(90)=61µs p(95)=80µs + http_req_sending...............: avg=5.36µs min=1µs med=5µs max=8.17ms p(90)=9µs p(95)=11µs + http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting...............: avg=1.6ms min=556µs med=1.5ms max=107.12ms p(90)=2.1ms p(95)=2.4ms + http_reqs......................: 1791100 5968.084002/s + iteration_duration.............: avg=167.52ms min=124.42ms med=161.7ms max=550.56ms p(90)=190.82ms p(95)=208.4ms + iterations.....................: 17911 59.68084/s + vus............................: 10 min=10 max=10 + vus_max........................: 10 min=10 max=10 + + +running (5m00.1s), 00/10 VUs, 17911 complete and 0 interrupted iterations +default ✓ [======================================] 10 VUs 5m0s +``` + +## redis cache 도입 이후 +``` + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + execution: local + script: script.ts + output: - + + scenarios: (100.00%) 1 scenario, 10 max VUs, 5m30s max duration (incl. graceful stop): + * default: 10 looping VUs for 5m0s (gracefulStop: 30s) + + + data_received..................: 1.1 GB 3.7 MB/s + data_sent......................: 350 MB 1.2 MB/s + http_req_blocked...............: avg=2.7µs min=0s med=1µs max=9.12ms p(90)=2µs p(95)=3µs + http_req_connecting............: avg=1.2µs min=0s med=0s max=9.08ms p(90)=0s p(95)=0s + http_req_duration..............: avg=870.64µs min=369µs med=795µs max=50.91ms p(90)=1.15ms p(95)=1.32ms + { expected_response:true }...: avg=870.64µs min=369µs med=795µs max=50.91ms p(90)=1.15ms p(95)=1.32ms + http_req_failed................: 0.00% 0 out of 3370000 + http_req_receiving.............: avg=23.05µs min=-39000ns med=16µs max=27.43ms p(90)=45µs p(95)=59µs + http_req_sending...............: avg=3.22µs min=-36000ns med=2µs max=2.45ms p(90)=5µs p(95)=7µs + http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting...............: avg=844.36µs min=358µs med=770µs max=50.19ms p(90)=1.12ms p(95)=1.29ms + http_reqs......................: 3370000 11232.224713/s + iteration_duration.............: avg=89.01ms min=67.14ms med=85.25ms max=673.79ms p(90)=104.39ms p(95)=111.23ms + iterations.....................: 33700 112.322247/s + vus............................: 10 min=10 max=10 + vus_max........................: 10 min=10 max=10 + + +running (5m00.0s), 00/10 VUs, 33700 complete and 0 interrupted iterations +default ✓ [======================================] 10 VUs 5m0s +``` + +## 결론 +- 동일 시나리오세ㅓ redis cache 도입후 data_received 가 2배 증가 +- 이전 +``` +data_received..................: 583 MB 1.9 MB/s +``` +- 이후 +``` +data_received..................: 1.1 GB 3.7 MB/s +``` diff --git a/document/scenario.md b/document/scenario.md new file mode 100644 index 000000000..132cd1d7e --- /dev/null +++ b/document/scenario.md @@ -0,0 +1,11 @@ +# 시나리오 + +## 사전과제 +- https://teamsparta.notion.site/Redis-15a2dc3ef51480ecada7c97409ddc120 +- https://teamsparta.notion.site/1602dc3ef5148010afa6e243bed07546 + +## 1주차 +- https://teamsparta.notion.site/1-1732dc3ef51481bf8337e861c98c4e25 + +## 2주차 +- https://teamsparta.notion.site/2-1732dc3ef5148172a8fac96047fd51c5 diff --git a/tools/k6/script.ts b/tools/k6/script.ts new file mode 100644 index 000000000..7e9d0b9d2 --- /dev/null +++ b/tools/k6/script.ts @@ -0,0 +1,20 @@ +import http from 'k6/http'; + +import {sleep} from "k6" + +export let options = { + vus: 10, + duration: '5m' +} + +const BASE_URL = 'http://localhost:8080/api/search?title=title' + +export default function() { + + let getUrl = BASE_URL + for (let titleid=1; titleid<=100; titleid++) { + http.get(getUrl+titleid); + // console.log(getUrl+titleid); + } + +} diff --git a/tools/mysql/init.sql b/tools/mysql/init.sql new file mode 100644 index 000000000..5dc9384dc --- /dev/null +++ b/tools/mysql/init.sql @@ -0,0 +1,126 @@ +-- For performance test +DROP DATABASE IF EXISTS 'redis1st'; + +CREATE DATABASE 'redis1st'; + +USE 'redis1st'; + +DROP TABLE IF EXISTS "movie" + +CREATE TABLE `movie` ( + `id` bigint NOT NULL, + `created_at` datetime(6) DEFAULT NULL, + `modified_at` datetime(6) DEFAULT NULL, + `genre` varchar(255) NOT NULL, + `rating` varchar(255) NOT NULL, + `release_date` varchar(255) NOT NULL, + `running_time` varchar(255) NOT NULL, + `screening_schedule` varbinary(255) DEFAULT NULL, + `theater` varchar(255) NOT NULL, + `thumbnail_image` varchar(255) NOT NULL, + `title` varchar(255) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + + +INSERT INTO movie(id, created_at, modified_at, genre, rating, release_date, running_time, screening_schedule, theater, thumbnail_image, title) +VALUES ('1',NULL,NULL,'action','전체이용가','1월','60분',NULL,'theater1','이미지 링크1','title1'), +('2',NULL,NULL,'romance','15세이용가','2월','60분',NULL,'theater2','이미지 링크2','title2'), +('3',NULL,NULL,'horror','18세이용가','3월','60분',NULL,'theater3','이미지 링크3','title3'), +('4',NULL,NULL,'SF','전체이용가','4월','60분',NULL,'theater4','이미지 링크4','title4'), +('5',NULL,NULL,'action','15세이용가','5월','60분',NULL,'theater5','이미지 링크5','title5'), +('6',NULL,NULL,'romance','18세이용가','6월','60분',NULL,'theater6','이미지 링크6','title6'), +('7',NULL,NULL,'horror','전체이용가','7월','60분',NULL,'theater7','이미지 링크7','title7'), +('8',NULL,NULL,'SF','15세이용가','8월','60분',NULL,'theater8','이미지 링크8','title8'), +('9',NULL,NULL,'action','18세이용가','9월','60분',NULL,'theater9','이미지 링크9','title9'), +('10',NULL,NULL,'romance','전체이용가','10월','60분',NULL,'theater10','이미지 링크10','title10'), +('11',NULL,NULL,'horror','15세이용가','11월','60분',NULL,'theater11','이미지 링크11','title11'), +('12',NULL,NULL,'SF','18세이용가','12월','60분',NULL,'theater12','이미지 링크12','title12'), +('13',NULL,NULL,'action','전체이용가','1월','60분',NULL,'theater13','이미지 링크13','title13'), +('14',NULL,NULL,'romance','15세이용가','2월','60분',NULL,'theater14','이미지 링크14','title14'), +('15',NULL,NULL,'horror','18세이용가','3월','60분',NULL,'theater15','이미지 링크15','title15'), +('16',NULL,NULL,'SF','전체이용가','4월','60분',NULL,'theater16','이미지 링크16','title16'), +('17',NULL,NULL,'action','15세이용가','5월','60분',NULL,'theater17','이미지 링크17','title17'), +('18',NULL,NULL,'romance','18세이용가','6월','60분',NULL,'theater18','이미지 링크18','title18'), +('19',NULL,NULL,'horror','전체이용가','7월','60분',NULL,'theater19','이미지 링크19','title19'), +('20',NULL,NULL,'SF','15세이용가','8월','60분',NULL,'theater20','이미지 링크20','title20'), +('21',NULL,NULL,'action','18세이용가','9월','60분',NULL,'theater21','이미지 링크21','title21'), +('22',NULL,NULL,'romance','전체이용가','10월','60분',NULL,'theater22','이미지 링크22','title22'), +('23',NULL,NULL,'horror','15세이용가','11월','60분',NULL,'theater23','이미지 링크23','title23'), +('24',NULL,NULL,'SF','18세이용가','12월','60분',NULL,'theater24','이미지 링크24','title24'), +('25',NULL,NULL,'action','전체이용가','1월','60분',NULL,'theater25','이미지 링크25','title25'), +('26',NULL,NULL,'romance','15세이용가','2월','60분',NULL,'theater26','이미지 링크26','title26'), +('27',NULL,NULL,'horror','18세이용가','3월','60분',NULL,'theater27','이미지 링크27','title27'), +('28',NULL,NULL,'SF','전체이용가','4월','60분',NULL,'theater28','이미지 링크28','title28'), +('29',NULL,NULL,'action','15세이용가','5월','60분',NULL,'theater29','이미지 링크29','title29'), +('30',NULL,NULL,'romance','18세이용가','6월','60분',NULL,'theater30','이미지 링크30','title30'), +('31',NULL,NULL,'horror','전체이용가','7월','60분',NULL,'theater31','이미지 링크31','title31'), +('32',NULL,NULL,'SF','15세이용가','8월','60분',NULL,'theater32','이미지 링크32','title32'), +('33',NULL,NULL,'action','18세이용가','9월','60분',NULL,'theater33','이미지 링크33','title33'), +('34',NULL,NULL,'romance','전체이용가','10월','60분',NULL,'theater34','이미지 링크34','title34'), +('35',NULL,NULL,'horror','15세이용가','11월','60분',NULL,'theater35','이미지 링크35','title35'), +('36',NULL,NULL,'SF','18세이용가','12월','60분',NULL,'theater36','이미지 링크36','title36'), +('37',NULL,NULL,'action','전체이용가','1월','60분',NULL,'theater37','이미지 링크37','title37'), +('38',NULL,NULL,'romance','15세이용가','2월','60분',NULL,'theater38','이미지 링크38','title38'), +('39',NULL,NULL,'horror','18세이용가','3월','60분',NULL,'theater39','이미지 링크39','title39'), +('40',NULL,NULL,'SF','전체이용가','4월','60분',NULL,'theater40','이미지 링크40','title40'), +('41',NULL,NULL,'action','15세이용가','5월','60분',NULL,'theater41','이미지 링크41','title41'), +('42',NULL,NULL,'romance','18세이용가','6월','60분',NULL,'theater42','이미지 링크42','title42'), +('43',NULL,NULL,'horror','전체이용가','7월','60분',NULL,'theater43','이미지 링크43','title43'), +('44',NULL,NULL,'SF','15세이용가','8월','60분',NULL,'theater44','이미지 링크44','title44'), +('45',NULL,NULL,'action','18세이용가','9월','60분',NULL,'theater45','이미지 링크45','title45'), +('46',NULL,NULL,'romance','전체이용가','10월','60분',NULL,'theater46','이미지 링크46','title46'), +('47',NULL,NULL,'horror','15세이용가','11월','60분',NULL,'theater47','이미지 링크47','title47'), +('48',NULL,NULL,'SF','18세이용가','12월','60분',NULL,'theater48','이미지 링크48','title48'), +('49',NULL,NULL,'action','전체이용가','1월','60분',NULL,'theater49','이미지 링크49','title49'), +('50',NULL,NULL,'romance','15세이용가','2월','60분',NULL,'theater50','이미지 링크50','title50'), +('51',NULL,NULL,'horror','18세이용가','3월','60분',NULL,'theater51','이미지 링크51','title51'), +('52',NULL,NULL,'SF','전체이용가','4월','60분',NULL,'theater52','이미지 링크52','title52'), +('53',NULL,NULL,'action','15세이용가','5월','60분',NULL,'theater53','이미지 링크53','title53'), +('54',NULL,NULL,'romance','18세이용가','6월','60분',NULL,'theater54','이미지 링크54','title54'), +('55',NULL,NULL,'horror','전체이용가','7월','60분',NULL,'theater55','이미지 링크55','title55'), +('56',NULL,NULL,'SF','15세이용가','8월','60분',NULL,'theater56','이미지 링크56','title56'), +('57',NULL,NULL,'action','18세이용가','9월','60분',NULL,'theater57','이미지 링크57','title57'), +('58',NULL,NULL,'romance','전체이용가','10월','60분',NULL,'theater58','이미지 링크58','title58'), +('59',NULL,NULL,'horror','15세이용가','11월','60분',NULL,'theater59','이미지 링크59','title59'), +('60',NULL,NULL,'SF','18세이용가','12월','60분',NULL,'theater60','이미지 링크60','title60'), +('61',NULL,NULL,'action','전체이용가','1월','60분',NULL,'theater61','이미지 링크61','title61'), +('62',NULL,NULL,'romance','15세이용가','2월','60분',NULL,'theater62','이미지 링크62','title62'), +('63',NULL,NULL,'horror','18세이용가','3월','60분',NULL,'theater63','이미지 링크63','title63'), +('64',NULL,NULL,'SF','전체이용가','4월','60분',NULL,'theater64','이미지 링크64','title64'), +('65',NULL,NULL,'action','15세이용가','5월','60분',NULL,'theater65','이미지 링크65','title65'), +('66',NULL,NULL,'romance','18세이용가','6월','60분',NULL,'theater66','이미지 링크66','title66'), +('67',NULL,NULL,'horror','전체이용가','7월','60분',NULL,'theater67','이미지 링크67','title67'), +('68',NULL,NULL,'SF','15세이용가','8월','60분',NULL,'theater68','이미지 링크68','title68'), +('69',NULL,NULL,'action','18세이용가','9월','60분',NULL,'theater69','이미지 링크69','title69'), +('70',NULL,NULL,'romance','전체이용가','10월','60분',NULL,'theater70','이미지 링크70','title70'), +('71',NULL,NULL,'horror','15세이용가','11월','60분',NULL,'theater71','이미지 링크71','title71'), +('72',NULL,NULL,'SF','18세이용가','12월','60분',NULL,'theater72','이미지 링크72','title72'), +('73',NULL,NULL,'action','전체이용가','1월','60분',NULL,'theater73','이미지 링크73','title73'), +('74',NULL,NULL,'romance','15세이용가','2월','60분',NULL,'theater74','이미지 링크74','title74'), +('75',NULL,NULL,'horror','18세이용가','3월','60분',NULL,'theater75','이미지 링크75','title75'), +('76',NULL,NULL,'SF','전체이용가','4월','60분',NULL,'theater76','이미지 링크76','title76'), +('77',NULL,NULL,'action','15세이용가','5월','60분',NULL,'theater77','이미지 링크77','title77'), +('78',NULL,NULL,'romance','18세이용가','6월','60분',NULL,'theater78','이미지 링크78','title78'), +('79',NULL,NULL,'horror','전체이용가','7월','60분',NULL,'theater79','이미지 링크79','title79'), +('80',NULL,NULL,'SF','15세이용가','8월','60분',NULL,'theater80','이미지 링크80','title80'), +('81',NULL,NULL,'action','18세이용가','9월','60분',NULL,'theater81','이미지 링크81','title81'), +('82',NULL,NULL,'romance','전체이용가','10월','60분',NULL,'theater82','이미지 링크82','title82'), +('83',NULL,NULL,'horror','15세이용가','11월','60분',NULL,'theater83','이미지 링크83','title83'), +('84',NULL,NULL,'SF','18세이용가','12월','60분',NULL,'theater84','이미지 링크84','title84'), +('85',NULL,NULL,'action','전체이용가','1월','60분',NULL,'theater85','이미지 링크85','title85'), +('86',NULL,NULL,'romance','15세이용가','2월','60분',NULL,'theater86','이미지 링크86','title86'), +('87',NULL,NULL,'horror','18세이용가','3월','60분',NULL,'theater87','이미지 링크87','title87'), +('88',NULL,NULL,'SF','전체이용가','4월','60분',NULL,'theater88','이미지 링크88','title88'), +('89',NULL,NULL,'action','15세이용가','5월','60분',NULL,'theater89','이미지 링크89','title89'), +('90',NULL,NULL,'romance','18세이용가','6월','60분',NULL,'theater90','이미지 링크90','title90'), +('91',NULL,NULL,'horror','전체이용가','7월','60분',NULL,'theater91','이미지 링크91','title91'), +('92',NULL,NULL,'SF','15세이용가','8월','60분',NULL,'theater92','이미지 링크92','title92'), +('93',NULL,NULL,'action','18세이용가','9월','60분',NULL,'theater93','이미지 링크93','title93'), +('94',NULL,NULL,'romance','전체이용가','10월','60분',NULL,'theater94','이미지 링크94','title94'), +('95',NULL,NULL,'horror','15세이용가','11월','60분',NULL,'theater95','이미지 링크95','title95'), +('96',NULL,NULL,'SF','18세이용가','12월','60분',NULL,'theater96','이미지 링크96','title96'), +('97',NULL,NULL,'action','전체이용가','1월','60분',NULL,'theater97','이미지 링크97','title97'), +('98',NULL,NULL,'romance','15세이용가','2월','60분',NULL,'theater98','이미지 링크98','title98'), +('99',NULL,NULL,'horror','18세이용가','3월','60분',NULL,'theater99','이미지 링크99','title99'), +('100',NULL,NULL,'SF','전체이용가','4월','60분',NULL,'theater100','이미지 링크100','title100');