diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..18c4d800 --- /dev/null +++ b/.gitignore @@ -0,0 +1,359 @@ +# Created by https://www.toptal.com/developers/gitignore/api/windows,macos,git,java,maven,eclipse,intellij,gradle,netbeans,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,macos,git,java,maven,eclipse,intellij,gradle,netbeans,visualstudiocode + +### Eclipse ### +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders + +# External tool builders +.externalToolBuilders/ + +# Locally stored "Eclipse launch configurations" +*.launch + +# PyDev specific (Python IDE for Eclipse) +*.pydevproject + +# CDT-specific (C/C++ Development Tooling) +.cproject + +# CDT- autotools +.autotools + +# Java annotation processor (APT) +.factorypath + +# PDT-specific (PHP Development Tools) +.buildpath + +# sbteclipse plugin +.target + +# Tern plugin +.tern-project + +# TeXlipse plugin +.texlipse + +# STS (Spring Tool Suite) +.springBeans + +# Code Recommenders +.recommenders/ + +# Annotation Processing +.apt_generated/ +.apt_generated_test/ + +# Scala IDE specific (Scala & Java development for Eclipse) +.cache-main +.scala_dependencies +.worksheet + +# Uncomment this line if you wish to ignore the project description file. +# Typically, this file would be tracked if it contains build/dependency configurations: +#.project + +### Eclipse Patch ### +# Spring Boot Tooling +.sts4-cache/ + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +/src/main/generated + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/ +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Maven ### +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# https://github.com/takari/maven-wrapper#usage-without-binary-jar +.mvn/wrapper/maven-wrapper.jar + +# Eclipse m2e generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### NetBeans ### +**/nbproject/private/ +**/nbproject/Makefile-*.mk +**/nbproject/Package-*.bash +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +# JDT-specific (Eclipse Java Development Tools) + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/windows,macos,git,java,maven,eclipse,intellij,gradle,netbeans,visualstudiocode \ No newline at end of file diff --git a/README.md b/README.md index 5c097d95..0e2af664 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,39 @@ # Redis_project -커머스의 핵심 프로세스인 상품 조회 및 주문 과정에서 발생할 수 있는 동시성 이슈 해결 및 성능 개선을 경험하고, 안정성을 높이기 위한 방법을 배웁니다. \ No newline at end of file +## ERD +![img.png](docs/img.png) + +## Multi Module 구조 +Layered Architecture 패턴을 적용하여 설계. + +``` +├── api +│ └── com.example.api +│ └── controller +│ └── common # GlobalExceptionHandler +│ └── dto +│ └── application # SpringBootApplication +│ +├── domain +│ └── com.example.domain +│ └── common # 락과 AOP 로직 +│ └── config # Async 설정 +│ └── dto +│ └── service +│ +├── common +│ └── com.example.common +│ └── config +│ └── exception +│ +├── infra # MySQL, Redis 등 외부 서비스 연동 + └── com.example.infra + └── config + └── entity + └── enums + └── repository +``` + + + + diff --git a/api/build.gradle b/api/build.gradle index faecf2bb..a9a2dd51 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -1,5 +1,6 @@ dependencies { implementation project(':domain') + implementation project(':common') implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' } \ No newline at end of file diff --git a/api/src/main/java/com/example/ApiApplication.java b/api/src/main/java/com/example/ApiApplication.java index b0145019..70c188c8 100644 --- a/api/src/main/java/com/example/ApiApplication.java +++ b/api/src/main/java/com/example/ApiApplication.java @@ -2,10 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication -@EnableAsync public class ApiApplication { public static void main(String[] args) { diff --git a/api/src/main/java/com/example/MovieController.java b/api/src/main/java/com/example/MovieController.java deleted file mode 100644 index 4569dce7..00000000 --- a/api/src/main/java/com/example/MovieController.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example; - -import com.example.dto.MovieScreeningResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import java.util.List; - -@RestController -@RequiredArgsConstructor -public class MovieController { - - private final MovieService movieService; - - @GetMapping("/movies") - public List getMovies( - @RequestParam(required = false) String title, - @RequestParam(required = false) String genre) { - return movieService.getMoviesWithScreenings(title, genre); - } -} diff --git a/api/src/main/java/com/example/common/GlobalExceptionHandler.java b/api/src/main/java/com/example/common/GlobalExceptionHandler.java new file mode 100644 index 00000000..96090f32 --- /dev/null +++ b/api/src/main/java/com/example/common/GlobalExceptionHandler.java @@ -0,0 +1,32 @@ +package com.example.common; + +import com.example.dto.response.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(BindException.class) + public ApiResponse bindException(BindException e){ + return ApiResponse.of( + HttpStatus.BAD_REQUEST, + e.getBindingResult().getAllErrors().get(0).getDefaultMessage(), + null + ); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ApiResponse handleIllegalArgumentException(IllegalArgumentException e) { + return ApiResponse.of( + HttpStatus.BAD_REQUEST, + e.getMessage(), + null + ); + } +} diff --git a/api/src/main/java/com/example/controller/MovieController.java b/api/src/main/java/com/example/controller/MovieController.java new file mode 100644 index 00000000..39407253 --- /dev/null +++ b/api/src/main/java/com/example/controller/MovieController.java @@ -0,0 +1,31 @@ +package com.example.controller; + +import com.example.service.MovieService; +import com.example.dto.response.ApiResponse; +import com.example.dto.response.MovieScreeningResponse; +import com.example.dto.response.MovieScreeningServiceResponse; +import com.example.dto.response.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class MovieController { + + private final MovieService movieService; + + @GetMapping("/movies") + public ApiResponse> getMovies( + @RequestParam(required = false) String title, + @RequestParam(required = false) String genre, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + PageResponse serviceResult = + movieService.getMoviesWithScreenings(title, genre, page, size); + + return ApiResponse.ok(PageResponse.from(serviceResult, MovieScreeningResponse::from)); + } +} \ No newline at end of file diff --git a/api/src/main/java/com/example/ReservationController.java b/api/src/main/java/com/example/controller/ReservationController.java similarity index 54% rename from api/src/main/java/com/example/ReservationController.java rename to api/src/main/java/com/example/controller/ReservationController.java index 18ec5918..e08a1888 100644 --- a/api/src/main/java/com/example/ReservationController.java +++ b/api/src/main/java/com/example/controller/ReservationController.java @@ -1,6 +1,9 @@ -package com.example; +package com.example.controller; +import com.example.service.ReservationService; import com.example.dto.request.ReservationRequest; +import com.example.dto.response.ApiResponse; +import com.example.dto.response.ReservationResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; @@ -14,7 +17,7 @@ public class ReservationController { private final ReservationService reservationService; @PostMapping("/reservations") - public String reserveSeats(@Valid @RequestBody ReservationRequest request){ - return reservationService.reserveSeats(request.toServiceRequest()); + public ApiResponse reserveSeats(@Valid @RequestBody ReservationRequest request){ + return ApiResponse.ok(ReservationResponse.from(reservationService.reserveSeats(request.toServiceRequest()))); } -} +} \ No newline at end of file diff --git a/api/src/main/java/com/example/dto/request/ReservationRequest.java b/api/src/main/java/com/example/dto/request/ReservationRequest.java index d646efae..19a06eb1 100644 --- a/api/src/main/java/com/example/dto/request/ReservationRequest.java +++ b/api/src/main/java/com/example/dto/request/ReservationRequest.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; +import lombok.Builder; import lombok.Getter; import java.util.List; @@ -11,7 +12,7 @@ public class ReservationRequest { @NotNull(message = "회원 ID는 필수입니다.") @Positive(message = "회원 ID는 양수여야 합니다.") - private Long memberId; + private Long userId; @NotNull(message = "상영 시간표 ID는 필수입니다.") @Positive(message = "상영 시간표 ID는 양수여야 합니다.") @@ -23,11 +24,25 @@ public class ReservationRequest { @Positive(message = "좌석 ID는 양수여야 합니다.") Long> seatIds; + @Builder + private ReservationRequest(Long userId, Long screeningId, List seatIds){ + this.userId = userId; + this.screeningId = screeningId; + this.seatIds = seatIds; + } + public ReservationServiceRequest toServiceRequest() { return ReservationServiceRequest.builder() - .memberId(memberId) + .userId(userId) + .screeningId(screeningId) + .seatIds(seatIds) + .build(); + } + public ReservationRequest of(Long userId, Long screeningId, List seatIds) { + return ReservationRequest.builder() + .userId(userId) .screeningId(screeningId) .seatIds(seatIds) .build(); } -} +} \ No newline at end of file diff --git a/api/src/main/java/com/example/dto/response/ApiResponse.java b/api/src/main/java/com/example/dto/response/ApiResponse.java new file mode 100644 index 00000000..772f71c5 --- /dev/null +++ b/api/src/main/java/com/example/dto/response/ApiResponse.java @@ -0,0 +1,31 @@ +package com.example.dto.response; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class ApiResponse { + private int code; + private HttpStatus status; + private String message; + private T data; + + public ApiResponse(HttpStatus status, String message, T data){ + this.code = status.value(); + this.status = status; + this.message = message; + this.data = data; + } + + public static ApiResponse of(HttpStatus httpStatus, String message, T data){ + return new ApiResponse<>(httpStatus, message, data); + } + + public static ApiResponse of(HttpStatus httpStatus, T data){ + return of(httpStatus, httpStatus.name(), data); + } + + public static ApiResponse ok(T data) { + return of(HttpStatus.OK, data); + } +} diff --git a/api/src/main/java/com/example/dto/response/MovieScreeningResponse.java b/api/src/main/java/com/example/dto/response/MovieScreeningResponse.java new file mode 100644 index 00000000..67c8eb6c --- /dev/null +++ b/api/src/main/java/com/example/dto/response/MovieScreeningResponse.java @@ -0,0 +1,31 @@ +package com.example.dto.response; + +import lombok.Builder; +import lombok.Getter; +import java.time.LocalDate; +import java.util.List; + +@Getter +@Builder +public class MovieScreeningResponse { + + private final String title; + private final String rating; + private final LocalDate releaseDate; + private final String thumbnailImage; + private final int runningTime; + private final String genre; + private final List screeningResponses; + + public static MovieScreeningResponse from(MovieScreeningServiceResponse serviceDto) { + return MovieScreeningResponse.builder() + .title(serviceDto.getTitle()) + .rating(serviceDto.getRating()) + .releaseDate(serviceDto.getReleaseDate()) + .thumbnailImage(serviceDto.getThumbnailImage()) + .runningTime(serviceDto.getRunningTime()) + .genre(serviceDto.getGenre()) + .screeningResponses(ScreeningResponse.from(serviceDto.getScreeningServiceResponses())) + .build(); + } +} diff --git a/api/src/main/java/com/example/dto/response/ReservationResponse.java b/api/src/main/java/com/example/dto/response/ReservationResponse.java new file mode 100644 index 00000000..87eeb682 --- /dev/null +++ b/api/src/main/java/com/example/dto/response/ReservationResponse.java @@ -0,0 +1,21 @@ +package com.example.dto.response; + +import lombok.Builder; +import lombok.Getter; +import java.util.List; + +@Getter +@Builder +public class ReservationResponse { + private final Long userId; + private final Long screeningId; + private final List reservedSeatIds; + + public static ReservationResponse from(ReservationServiceResponse serviceDto){ + return ReservationResponse.builder() + .userId(serviceDto.getUserId()) + .screeningId(serviceDto.getScreeningId()) + .reservedSeatIds(serviceDto.getReservedSeatIds()) + .build(); + } +} \ No newline at end of file diff --git a/api/src/main/java/com/example/dto/response/ScreeningResponse.java b/api/src/main/java/com/example/dto/response/ScreeningResponse.java new file mode 100644 index 00000000..6acec68b --- /dev/null +++ b/api/src/main/java/com/example/dto/response/ScreeningResponse.java @@ -0,0 +1,35 @@ +package com.example.dto.response; + +import lombok.Builder; +import lombok.Getter; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +public class ScreeningResponse { + + private final Long screeningId; + private final LocalDate date; + private final LocalDateTime startedAt; + private final LocalDateTime endedAt; + private final String theaterName; + + public static ScreeningResponse from(ScreeningServiceResponse serviceDto) { + return ScreeningResponse.builder() + .screeningId(serviceDto.getScreeningId()) + .date(serviceDto.getDate()) + .startedAt(serviceDto.getStartedAt()) + .endedAt(serviceDto.getEndedAt()) + .theaterName(serviceDto.getTheaterName()) + .build(); + } + + public static List from(List serviceDtos) { + return serviceDtos.stream() + .map(ScreeningResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/api/src/test/java/com/example/config/IntegrationControllerSupport.java b/api/src/test/java/com/example/config/IntegrationControllerSupport.java new file mode 100644 index 00000000..b35bf9ff --- /dev/null +++ b/api/src/test/java/com/example/config/IntegrationControllerSupport.java @@ -0,0 +1,32 @@ +package com.example.config; + +import com.example.service.MovieService; +import com.example.service.ReservationService; +import com.example.controller.MovieController; +import com.example.controller.ReservationController; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@ActiveProfiles("test") +@WebMvcTest({ + MovieController.class, + ReservationController.class +}) +public abstract class IntegrationControllerSupport { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @MockitoBean + protected MovieService movieService; + + @MockitoBean + protected ReservationService reservationService; +} diff --git a/api/src/test/java/com/example/controller/MovieControllerTest.java b/api/src/test/java/com/example/controller/MovieControllerTest.java new file mode 100644 index 00000000..8d49e709 --- /dev/null +++ b/api/src/test/java/com/example/controller/MovieControllerTest.java @@ -0,0 +1,69 @@ +package com.example.controller; + +import com.example.config.IntegrationControllerSupport; +import com.example.dto.response.MovieScreeningServiceResponse; +import com.example.dto.response.PageResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import java.time.LocalDate; +import java.util.List; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; + + +class MovieControllerTest extends IntegrationControllerSupport { + + @Nested + @DisplayName("상영중인 영화 조회") + class getMovies { + + @Test + @DisplayName("영화 목록을 정상적으로 조회한다") + void getMovies_success() throws Exception { + // given + MovieScreeningServiceResponse movie1 = MovieScreeningServiceResponse.builder() + .title("movie1") + .rating("R_19") + .releaseDate(LocalDate.of(2014, 11, 7)) + .thumbnailImage("movie1.jpg") + .runningTime(169) + .genre("SF") + .screeningServiceResponses(List.of()) + .build(); + + PageImpl page = new PageImpl<>( + List.of(movie1), + PageRequest.of(0, 10), + 1 + ); + + PageResponse serviceResponse = PageResponse.of(List.of(movie1), page); + + given(movieService.getMoviesWithScreenings(any(), any(), anyInt(), anyInt())) + .willReturn(serviceResponse); + + // when & then + mockMvc.perform(get("/movies") + .param("title", "movie1") + .param("genre", "SF") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.content[0].title").value("movie1")) + .andExpect(jsonPath("$.data.content[0].rating").value("R_19")) + .andExpect(jsonPath("$.data.content[0].releaseDate").value("2014-11-07")) + .andExpect(jsonPath("$.data.content[0].thumbnailImage").value("movie1.jpg")) + .andExpect(jsonPath("$.data.content[0].runningTime").value(169)) + .andExpect(jsonPath("$.data.content[0].genre").value("SF")); + } + } +} \ No newline at end of file diff --git a/api/src/test/java/com/example/controller/ReservationControllerTest.java b/api/src/test/java/com/example/controller/ReservationControllerTest.java new file mode 100644 index 00000000..e0f4bd72 --- /dev/null +++ b/api/src/test/java/com/example/controller/ReservationControllerTest.java @@ -0,0 +1,112 @@ +package com.example.controller; + +import com.example.config.IntegrationControllerSupport; +import com.example.dto.request.ReservationRequest; +import com.example.dto.response.ReservationServiceResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.MediaType; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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; + +class ReservationControllerTest extends IntegrationControllerSupport { + + @Nested + @DisplayName("좌석 예약") + class reserveSeats { + + @Test + @DisplayName("좌석 예약 요청이 들어오면 정상적으로 처리하고 응답한다") + void reserveSeats_success() throws Exception { + // given + ReservationRequest request = ReservationRequest.builder() + .userId(1L) + .screeningId(1L) + .seatIds(List.of(1L, 2L)) + .build(); + + ReservationServiceResponse mockResponse = ReservationServiceResponse.builder() + .userId(1L) + .screeningId(1L) + .reservedSeatIds(List.of(1L, 2L)) + .build(); + + given(reservationService.reserveSeats(any())).willReturn(mockResponse); + + // when & then + mockMvc.perform( + post("/reservations") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.userId").value(1L)) + .andExpect(jsonPath("$.data.screeningId").value(1L)) + .andExpect(jsonPath("$.data.reservedSeatIds[0]").value(1L)) + .andExpect(jsonPath("$.data.reservedSeatIds[1]").value(2L)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "{index}: userId={0}, screeningId={1}, seatIds={2}") + @MethodSource("invalidRequests") + @DisplayName("유효하지 않은 좌석 예약 요청은 400 Bad Request를 반환한다") + void reserveSeats_fail_invalidInputs(Long userId, Long screeningId, List seatIds) throws Exception { + // given + ReservationRequest request = ReservationRequest.builder() + .userId(userId) + .screeningId(screeningId) + .seatIds(seatIds) + .build(); + + // when & then + mockMvc.perform( + post("/reservations") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.status").value("BAD_REQUEST")); + } + + static Stream invalidRequests() { + return Stream.of( + // userId null + Arguments.of(null, 1L, Arrays.asList(1L)), + // userId < 0 + Arguments.of(-1L, 1L, Arrays.asList(1L)), + + // screeningId null + Arguments.of(1L, null, Arrays.asList(1L)), + // screeningId < 0 + Arguments.of(1L, -1L, Arrays.asList(1L)), + + // seatIds null + Arguments.of(1L, 1L, null), + // seatIds empty + Arguments.of(1L, 1L, Arrays.asList()), + // seatIds > 5 + Arguments.of(1L, 1L, Arrays.asList(1L, 2L, 3L, 4L, 5L, 6L)), + // seatIds with null + Arguments.of(1L, 1L, Arrays.asList(1L, null)), + // seatIds < 0 + Arguments.of(1L, 1L, Arrays.asList(-1L)) + ); + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index c8ecd346..365abe21 100644 --- a/build.gradle +++ b/build.gradle @@ -4,21 +4,34 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } -bootJar.enabled = false +bootJar { + enabled = false // 루트 프로젝트에서 실행 가능한 JAR을 비활성화 +} -// 모든 하위 모듈들에 이 설정을 적용. +// 모든 하위 모듈들에 이 설정을 적용 subprojects { - group 'com.example' - version '0.0.1-SNAPSHOT' - sourceCompatibility = '17' - apply plugin: 'java' apply plugin: 'java-library' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' - bootJar.enabled = false // 기본적으로 실행 가능한 JAR을 비활성화 - jar.enabled = true // 기본적으로 라이브러리 JAR 활성화 + group = 'com.example' + version = '0.0.1-SNAPSHOT' + + // 새로운 방식으로 Java 버전 설정 + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + } + + // 기본적으로 실행 가능한 JAR을 비활성화하고, 라이브러리 JAR을 활성화 + bootJar { + enabled = false + } + jar { + enabled = true + } configurations { compileOnly { @@ -27,28 +40,35 @@ subprojects { } repositories { - mavenCentral() + mavenCentral() // 의존성 관리 } - java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } - } - - dependencies { // 모든 하위 모듈에 추가 될 의존성 목록. + dependencies { implementation 'org.springframework.boot:spring-boot-starter' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'com.h2database:h2' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' } test { - useJUnitPlatform() + useJUnitPlatform() // JUnit5 사용 설정 } } +allprojects { + tasks.withType(JavaCompile) { + options.encoding = 'UTF-8' + options.compilerArgs << "-parameters" + } +} + +// API 모듈에 대해 설정을 오버라이드 project(':api') { - bootJar.enabled = true // API 모듈만 실행 가능한 JAR 생성 - jar.enabled = false -} \ No newline at end of file + bootJar { + enabled = true // API 모듈만 실행 가능한 JAR 생성 + } + jar { + enabled = false + } +} diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 00000000..0dbc265a --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-json' + + // infra 모듈 : RedisCacheConfig에서 사용 + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' +} \ No newline at end of file diff --git a/common/src/main/java/com/example/config/JacksonConfig.java b/common/src/main/java/com/example/config/JacksonConfig.java new file mode 100644 index 00000000..1016e04d --- /dev/null +++ b/common/src/main/java/com/example/config/JacksonConfig.java @@ -0,0 +1,20 @@ +package com.example.config; + +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import java.time.format.DateTimeFormatter; + +@Configuration +public class JacksonConfig { + + private static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm"; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern(DATETIME_FORMAT); + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() { + return builder -> builder + .serializers(new LocalDateTimeSerializer(FORMATTER)); + } +} \ No newline at end of file diff --git a/db/init/ddl.sql b/db/init/ddl.sql index 00b61b57..b30012f5 100644 --- a/db/init/ddl.sql +++ b/db/init/ddl.sql @@ -1,5 +1,6 @@ CREATE TABLE users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(20), created_at DATETIME, created_by BIGINT, updated_at DATETIME, @@ -19,9 +20,9 @@ CREATE TABLE movies ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(30), rating VARCHAR(20), - released_at DATETIME, + released_date DATE, thumbnail_image VARCHAR(50), - running_time INT, + running_time_min INT, genre VARCHAR(20), created_at DATETIME, created_by BIGINT, @@ -44,8 +45,8 @@ CREATE TABLE screenings ( CREATE TABLE screening_seats ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - `row` int, - `col` int, + seat_row int, + seat_col int, theater_id BIGINT NOT NULL, created_at DATETIME, created_by BIGINT, @@ -55,9 +56,11 @@ CREATE TABLE screening_seats ( CREATE TABLE reservations ( id BIGINT AUTO_INCREMENT PRIMARY KEY, + version BIGINT DEFAULT 0, screening_seat_id BIGINT NOT NULL, + is_reserved BOOLEAN NOT NULL DEFAULT false, screening_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, + user_id BIGINT, created_at DATETIME, created_by BIGINT, updated_at DATETIME, diff --git a/db/init/dummy.sql b/db/init/dummy.sql index 12df6960..f631fc78 100644 --- a/db/init/dummy.sql +++ b/db/init/dummy.sql @@ -12,9 +12,8 @@ FROM ( ) t LIMIT 100; - -- MOVIES (10000 rows) -INSERT INTO movies (title, rating, released_at, thumbnail_image, running_time, genre, created_at, created_by, updated_at, updated_by) +INSERT INTO movies (title, rating, released_date, thumbnail_image, running_time_min, genre, created_at, created_by, updated_at, updated_by) SELECT CONCAT('Movie_', n), CASE FLOOR(RAND() * 3) @@ -22,7 +21,7 @@ SELECT WHEN 1 THEN 'R_15' ELSE 'R_19' END, - DATE_ADD('2010-01-01', INTERVAL FLOOR(RAND() * 5843) DAY), + CAST(DATE_ADD('2010-01-01', INTERVAL FLOOR(RAND() * 5843) DAY) AS DATE), CONCAT('movie_', n, '.jpg'), FLOOR(RAND() * 60 + 90), CASE FLOOR(RAND() * 12) @@ -52,43 +51,30 @@ FROM ( ) t LIMIT 10000; - --- SCREENINGS (100000 rows) - started_at < ended_at 보장 +-- SCREENINGS (50000 rows) INSERT INTO screenings (date, started_at, ended_at, movie_id, theater_id, created_at, created_by, updated_at, updated_by) SELECT - DATE_ADD('2024-01-01', INTERVAL rand_day DAY) AS rand_date, - start_dt, - end_dt, - FLOOR(RAND() * 10000) + 1, - FLOOR(RAND() * 100) + 1, + DATE_ADD(m.released_date, INTERVAL FLOOR(RAND(m.id + s.n) * 100) DAY), + TIMESTAMP(DATE_ADD(m.released_date, INTERVAL FLOOR(RAND(m.id + s.n) * 100) DAY), SEC_TO_TIME(rand_sec)), + TIMESTAMP(DATE_ADD(m.released_date, INTERVAL FLOOR(RAND(m.id + s.n) * 100) DAY), SEC_TO_TIME(LEAST(rand_sec + duration_sec, 86399))), + m.id, + FLOOR(RAND(m.id + s.n) * 100) + 1, NOW(), 1, NOW(), 1 -FROM ( +FROM movies m +JOIN ( + SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 +) s -- 🎯 각 영화당 5개의 상영 정보 +JOIN ( SELECT - rand_day, - TIMESTAMP(DATE_ADD('2024-01-01', INTERVAL rand_day DAY), SEC_TO_TIME(rand_sec)) AS start_dt, - TIMESTAMP(DATE_ADD('2024-01-01', INTERVAL rand_day DAY), SEC_TO_TIME(LEAST(rand_sec + duration_sec, 86399))) AS end_dt - FROM ( - SELECT - @rownum := @rownum + 1 AS rownum, - FLOOR(RAND(@rownum) * 365) AS rand_day, - FLOOR(RAND(@rownum + 1) * 79200) AS rand_sec, - FLOOR(RAND(@rownum + 2) * 10800) + 1 AS duration_sec -- 최소 1초 이상 보장 - FROM ( - SELECT 1 FROM - (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10) a, - (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10) b, - (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10) c, - (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10) d, - (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10) e, - (SELECT @rownum := 0) r - ) base - ) derived -) final -LIMIT 100000; - + @row := @row + 1, + FLOOR(RAND(@row) * 79200) AS rand_sec, + FLOOR(RAND(@row + 1) * 10800) + 1 AS duration_sec + FROM (SELECT 1 FROM dual LIMIT 10000) rand_gen, (SELECT @row := 0) r +) r_gen +LIMIT 50000; -- SCREENING_SEATS (100 theaters * 25 seats = 2500 rows) -INSERT INTO screening_seats (`row`, `col`, theater_id, created_at, created_by, updated_at, updated_by) +INSERT INTO screening_seats (seat_row, seat_col, theater_id, created_at, created_by, updated_at, updated_by) SELECT r.r + 1 AS seat_row, c.c + 1 AS seat_col, @@ -103,10 +89,9 @@ JOIN ( ) AS r ORDER BY t.id, seat_row, seat_col; - -- USERS (100 rows) -INSERT INTO users (created_at, created_by, updated_at, updated_by) -SELECT NOW(), 1, NOW(), 1 +INSERT INTO users (name, created_at, created_by, updated_at, updated_by) +SELECT CONCAT('user_', num), NOW(), 1, NOW(), 1 FROM ( SELECT a.N + b.N * 10 + 1 AS num FROM ( @@ -119,4 +104,18 @@ FROM ( ) b ORDER BY num LIMIT 100 -) AS numbers; \ No newline at end of file +) AS numbers; + +-- RESERVATIONS (10 rows) +INSERT INTO reservations (is_reserved, screening_seat_id, screening_id, user_id) +VALUES + (false, 1, 1, null), + (false, 2, 1, null), + (false, 3, 1, null), + (false, 4, 1, null), + (false, 5, 1, null), + (false, 6, 1, null), + (false, 7, 1, null), + (false, 8, 1, null), + (false, 9, 1, null), + (false, 10, 1, null); \ No newline at end of file diff --git a/docs/img.png b/docs/img.png new file mode 100644 index 00000000..35384491 Binary files /dev/null and b/docs/img.png differ diff --git a/domain/build.gradle b/domain/build.gradle index 13a2edf8..1222d690 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -1,5 +1,5 @@ dependencies { implementation project(':infra') - implementation('org.springframework.boot:spring-boot-starter-data-jpa') - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2' + implementation project(':common') + api 'org.springframework.boot:spring-boot-starter-data-jpa' } \ No newline at end of file diff --git a/domain/src/main/java/com/example/MovieService.java b/domain/src/main/java/com/example/MovieService.java deleted file mode 100644 index 32562417..00000000 --- a/domain/src/main/java/com/example/MovieService.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.example; - -import com.example.dto.MovieScreeningResponse; -import com.example.entity.Movie; -import com.example.entity.Screening; -import com.example.enums.Genre; -import com.example.repository.MovieRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import java.util.*; -import java.util.stream.Collectors; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional -public class MovieService { - - private final MovieRepository movieRepository; - - @Cacheable( - value = "movies", - key = "#genre", - condition = "#genre != null and #title == null", cacheManager = "contentCacheManager" - ) - public List getMoviesWithScreenings(String title, String genre) { - Genre genreEnum = genre != null ? Genre.valueOf(genre.toUpperCase()) : null; - List movies = movieRepository.searchMoviesWithScreenings(title, genreEnum); - return movies.stream() - .flatMap(movie -> movie.getScreenings().stream() - .collect(Collectors.groupingBy(Screening::getTheater)) - .entrySet().stream() - .map(entry -> MovieScreeningResponse.from(movie, entry.getKey(), entry.getValue())) - ) - .sorted(Comparator.comparing(response -> response.getScreeningTimes().get(0))) - .collect(Collectors.toList()); - } -} diff --git a/domain/src/main/java/com/example/ReservationEventHandler.java b/domain/src/main/java/com/example/ReservationEventHandler.java deleted file mode 100644 index 2ae763c1..00000000 --- a/domain/src/main/java/com/example/ReservationEventHandler.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.example; - -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class ReservationEventHandler { - - private final MessageService messageService; - - @Async - public void sendReservationCompleteMessage(Long userId, int seatCount) { - String message = "회원 " + userId + "님, 좌석 " + seatCount + "개 예약이 완료되었습니다."; - messageService.send(message); - } -} diff --git a/domain/src/main/java/com/example/ReservationService.java b/domain/src/main/java/com/example/ReservationService.java deleted file mode 100644 index d7388877..00000000 --- a/domain/src/main/java/com/example/ReservationService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.example; - -import com.example.dto.request.ReservationServiceRequest; -import com.example.entity.Reservation; -import com.example.entity.Screening; -import com.example.entity.ScreeningSeat; -import com.example.entity.User; -import com.example.repository.ReservationRepository; -import com.example.repository.ScreeningRepository; -import com.example.repository.ScreeningSeatRepository; -import com.example.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -@Transactional -public class ReservationService { - - private final ReservationValidator reservationValidator; - private final ReservationRepository reservationRepository; - private final UserRepository userRepository; - private final ScreeningRepository screeningRepository; - private final ScreeningSeatRepository screeningSeatRepository; - private final ReservationEventHandler reservationEventHandler; - - public String reserveSeats(ReservationServiceRequest request){ - - reservationValidator.validate(request); - - User user = userRepository.findById(request.getMemberId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); - Screening screening = screeningRepository.findById(request.getScreeningId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 상영 시간표입니다.")); - - List requestedSeats = screeningSeatRepository.findByIdInWithLock(request.getSeatIds()); - - List newReservations = requestedSeats.stream() - .map(seat -> Reservation.builder() - .screeningSeat(seat) - .screening(screening) - .user(user) - .build()) - .collect(Collectors.toList()); - - reservationRepository.saveAll(newReservations); - reservationEventHandler.sendReservationCompleteMessage(user.getId(), newReservations.size()); - - return "총 " + newReservations.size() + "개의 좌석이 예약되었습니다."; - } -} diff --git a/domain/src/main/java/com/example/common/DistributedMultiLock.java b/domain/src/main/java/com/example/common/DistributedMultiLock.java new file mode 100644 index 00000000..f5db79c3 --- /dev/null +++ b/domain/src/main/java/com/example/common/DistributedMultiLock.java @@ -0,0 +1,16 @@ +package com.example.common; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedMultiLock { + String expression(); + long waitTime() default 2000L; // milliseconds + long leaseTime() default 1500L; // milliseconds + TimeUnit timeUnit() default TimeUnit.MILLISECONDS; +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/common/DistributedMultiLockAspect.java b/domain/src/main/java/com/example/common/DistributedMultiLockAspect.java new file mode 100644 index 00000000..2f92f3bf --- /dev/null +++ b/domain/src/main/java/com/example/common/DistributedMultiLockAspect.java @@ -0,0 +1,85 @@ +package com.example.common; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.RedissonMultiLock; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; +import java.lang.reflect.Method; +import java.util.List; + +@Aspect +@Component +@RequiredArgsConstructor +public class DistributedMultiLockAspect { + + private final RedissonClient redissonClient; + private final ExpressionParser parser = new SpelExpressionParser(); + private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer(); + + @Around("@annotation(distributedMultiLock)") + public Object lock(ProceedingJoinPoint joinPoint, DistributedMultiLock distributedMultiLock) throws Throwable { + List keys = getLockKeys(joinPoint, distributedMultiLock.expression()); + + List locks = keys.stream().map(redissonClient::getLock).toList(); + RLock multiLock = new RedissonMultiLock(locks.toArray(new RLock[0])); + + boolean acquired = multiLock.tryLock( + distributedMultiLock.waitTime(), + distributedMultiLock.leaseTime(), + distributedMultiLock.timeUnit() + ); + + if (!acquired) { + throw new IllegalStateException("좌석 락 획득 실패: " + keys); + } + + System.out.println("스레드: " + Thread.currentThread().getName() + ", 락 키: " + keys); + + try { + return joinPoint.proceed(); + } finally { + try { + multiLock.unlock(); + } catch (Exception e) { + System.err.println("락 해제 중 예외 발생: " + e.getMessage()); + e.printStackTrace(); + } + } + } + + private List getLockKeys(ProceedingJoinPoint joinPoint, String expression) { + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + Method method = methodSignature.getMethod(); + + EvaluationContext context = new StandardEvaluationContext(); + String[] paramNames = nameDiscoverer.getParameterNames(method); + Object[] args = joinPoint.getArgs(); + + if (paramNames != null) { + for (int i = 0; i < paramNames.length; i++) { + context.setVariable(paramNames[i], args[i]); + } + } + + List evaluatedKeys = parser.parseExpression(expression).getValue(context, List.class); + + if (evaluatedKeys == null || evaluatedKeys.isEmpty()) { + throw new IllegalStateException("분산락 키 생성 실패: expression=" + expression); + } + return evaluatedKeys.stream() + .map(k -> "lock:" + k) + .sorted() + .toList(); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/common/LockTemplate.java b/domain/src/main/java/com/example/common/LockTemplate.java new file mode 100644 index 00000000..190e6bb1 --- /dev/null +++ b/domain/src/main/java/com/example/common/LockTemplate.java @@ -0,0 +1,47 @@ +package com.example.common; + +import lombok.RequiredArgsConstructor; +import org.redisson.RedissonMultiLock; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class LockTemplate { + + private final RedissonClient redissonClient; + + public void executeMultiLock(List rawKeys, Runnable criticalSection) { + List locks = rawKeys.stream() + .map(key -> redissonClient.getLock("lock:" + key)) // prefix 포함 + .sorted((a, b) -> a.getName().compareTo(b.getName())) // 데드락 방지 + .toList(); + + RLock multiLock = new RedissonMultiLock(locks.toArray(new RLock[0])); + + boolean acquired = false; + try { + acquired = multiLock.tryLock(2000, 1500, TimeUnit.MILLISECONDS); // waitTime, leaseTime + if (!acquired) { + throw new IllegalStateException("락 획득 실패: " + rawKeys); + } + + criticalSection.run(); // 💡 핵심 로직 실행 + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("락 획득 중 인터럽트 발생", e); + } finally { + if (acquired) { + try { + multiLock.unlock(); + } catch (Exception e) { + System.err.println("락 해제 중 예외 발생: " + e.getMessage()); + } + } + } + } +} diff --git a/domain/src/main/java/com/example/config/AsyncConfig.java b/domain/src/main/java/com/example/config/AsyncConfig.java new file mode 100644 index 00000000..7de16382 --- /dev/null +++ b/domain/src/main/java/com/example/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package com.example.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { +} diff --git a/domain/src/main/java/com/example/dto/MovieScreeningResponse.java b/domain/src/main/java/com/example/dto/MovieScreeningResponse.java deleted file mode 100644 index 642bd4e5..00000000 --- a/domain/src/main/java/com/example/dto/MovieScreeningResponse.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.example.dto; - -import com.example.entity.Movie; -import com.example.entity.Screening; -import com.example.entity.Theater; -import com.fasterxml.jackson.annotation.JsonFormat; -import lombok.Builder; -import lombok.Getter; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Comparator; -import java.util.List; -import java.util.stream.Collectors; - -@Builder -@Getter -public class MovieScreeningResponse { - - private String title; - private String rating; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime releaseDate; - private String thumbnailImage; - private int runningTime; - private String genre; - private String theaterName; - private List screeningTimes; - - public static MovieScreeningResponse from(Movie movie, Theater theater, List screenings) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - - List sortedScreeningTimes = screenings.stream() - .sorted(Comparator.comparing(Screening::getStartedAt)) - .map(screening -> { - LocalDateTime startTime = screening.getStartedAt(); - LocalDateTime endTime = screening.getEndedAt(); - return startTime.format(formatter) + " : " + endTime.format(formatter); - }) - .collect(Collectors.toList()); - - return MovieScreeningResponse.builder() - .title(movie.getTitle()) - .rating(movie.getRating().getValue()) - .releaseDate(movie.getReleasedAt()) - .thumbnailImage(movie.getThumbnailImage()) - .runningTime(movie.getRunningTime()) - .genre(movie.getGenre().getValue()) - .theaterName(theater.getName()) // 극장명 정확히 매핑 - .screeningTimes(sortedScreeningTimes) - .build(); - } -} diff --git a/domain/src/main/java/com/example/dto/ScreeningResponse.java b/domain/src/main/java/com/example/dto/ScreeningResponse.java deleted file mode 100644 index d87a196b..00000000 --- a/domain/src/main/java/com/example/dto/ScreeningResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.dto; - -import com.example.entity.Screening; -import lombok.Builder; -import lombok.Getter; -import java.time.format.DateTimeFormatter; - -@Builder -@Getter -public class ScreeningResponse { - - private String startTime; - private String endTime; - - public static ScreeningResponse of(Screening screening) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - - return ScreeningResponse.builder() - .startTime(screening.getStartedAt().format(formatter)) - .endTime(screening.getEndedAt().format(formatter)) - .build(); - } -} diff --git a/domain/src/main/java/com/example/dto/request/ReservationCompletedEvent.java b/domain/src/main/java/com/example/dto/request/ReservationCompletedEvent.java new file mode 100644 index 00000000..d5f49da6 --- /dev/null +++ b/domain/src/main/java/com/example/dto/request/ReservationCompletedEvent.java @@ -0,0 +1,18 @@ +package com.example.dto.request; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ReservationCompletedEvent { + private String userName; + private int seatCount; + + public static ReservationCompletedEvent of(String name, int seatCount){ + return ReservationCompletedEvent.builder() + .userName(name) + .seatCount(seatCount) + .build(); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/dto/request/ReservationServiceRequest.java b/domain/src/main/java/com/example/dto/request/ReservationServiceRequest.java index d121a42d..7b7db229 100644 --- a/domain/src/main/java/com/example/dto/request/ReservationServiceRequest.java +++ b/domain/src/main/java/com/example/dto/request/ReservationServiceRequest.java @@ -2,20 +2,20 @@ import lombok.Builder; import lombok.Getter; - import java.util.List; @Getter +@Builder public class ReservationServiceRequest { - private Long memberId; + private Long userId; private Long screeningId; private List seatIds; - @Builder - public ReservationServiceRequest(Long memberId, Long screeningId, List seatIds) { - this.memberId = memberId; - this.screeningId = screeningId; - this.seatIds = seatIds; + public List toLockKeys() { + return seatIds.stream() + .map(seatId -> screeningId + ":" + seatId) + .sorted() + .toList(); } -} +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/dto/response/MovieScreeningServiceResponse.java b/domain/src/main/java/com/example/dto/response/MovieScreeningServiceResponse.java new file mode 100644 index 00000000..b53d4585 --- /dev/null +++ b/domain/src/main/java/com/example/dto/response/MovieScreeningServiceResponse.java @@ -0,0 +1,45 @@ +package com.example.dto.response; + +import com.example.entity.Movie; +import com.example.entity.Screening; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MovieScreeningServiceResponse { + + private String title; + private String rating; + private LocalDate releaseDate; + private String thumbnailImage; + private int runningTime; + private String genre; + private List screeningServiceResponses; + + public static MovieScreeningServiceResponse from(Movie movie) { + List screeningServiceRespons = movie.getScreenings().stream() + .filter(screening -> !screening.getDate().isBefore(movie.getReleasedDate())) + .sorted(Comparator.comparing(Screening::getStartedAt)) + .map(ScreeningServiceResponse::from) + .collect(Collectors.toList()); + + return MovieScreeningServiceResponse.builder() + .title(movie.getTitle()) + .rating(movie.getRating().name()) + .releaseDate(movie.getReleasedDate()) + .thumbnailImage(movie.getThumbnailImage()) + .runningTime(movie.getRunningTimeMin()) + .genre(movie.getGenre().name()) + .screeningServiceResponses(screeningServiceRespons) + .build(); + } +} diff --git a/domain/src/main/java/com/example/dto/response/PageResponse.java b/domain/src/main/java/com/example/dto/response/PageResponse.java new file mode 100644 index 00000000..d25c457c --- /dev/null +++ b/domain/src/main/java/com/example/dto/response/PageResponse.java @@ -0,0 +1,55 @@ +package com.example.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PageResponse { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean hasNext; + @JsonProperty("last") + private boolean isLast; + + + public static PageResponse of(List content, Page page) { + return PageResponse.builder() + .content(content) + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .hasNext(page.hasNext()) + .isLast(page.isLast()) + .build(); + } + + public static PageResponse from(PageResponse serviceResponse, Function mapper) { + List converted = serviceResponse.getContent().stream() + .map(mapper) + .collect(Collectors.toList()); + + return PageResponse.builder() + .content(converted) + .page(serviceResponse.getPage()) + .size(serviceResponse.getSize()) + .totalElements(serviceResponse.getTotalElements()) + .totalPages(serviceResponse.getTotalPages()) + .hasNext(serviceResponse.isHasNext()) + .isLast(serviceResponse.isLast()) + .build(); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/dto/response/ReservationServiceResponse.java b/domain/src/main/java/com/example/dto/response/ReservationServiceResponse.java new file mode 100644 index 00000000..470e412e --- /dev/null +++ b/domain/src/main/java/com/example/dto/response/ReservationServiceResponse.java @@ -0,0 +1,21 @@ +package com.example.dto.response; + +import lombok.Builder; +import lombok.Getter; +import java.util.List; + +@Getter +@Builder +public class ReservationServiceResponse { + private final Long userId; + private final Long screeningId; + private final List reservedSeatIds; + + public static ReservationServiceResponse from(ReservationServiceResponse serviceDto){ + return ReservationServiceResponse.builder() + .userId(serviceDto.userId) + .screeningId(serviceDto.screeningId) + .reservedSeatIds(serviceDto.reservedSeatIds) + .build(); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/dto/response/ScreeningServiceResponse.java b/domain/src/main/java/com/example/dto/response/ScreeningServiceResponse.java new file mode 100644 index 00000000..ec7985e3 --- /dev/null +++ b/domain/src/main/java/com/example/dto/response/ScreeningServiceResponse.java @@ -0,0 +1,32 @@ +package com.example.dto.response; + +import com.example.entity.Screening; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ScreeningServiceResponse { + + private Long screeningId; + private LocalDate date; + private LocalDateTime startedAt; + private LocalDateTime endedAt; + private String theaterName; + + public static ScreeningServiceResponse from(Screening screening) { + return ScreeningServiceResponse.builder() + .screeningId(screening.getId()) + .date(screening.getDate()) + .startedAt(screening.getStartedAt()) + .endedAt(screening.getEndedAt()) + .theaterName(screening.getTheater().getName()) + .build(); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/MessageService.java b/domain/src/main/java/com/example/service/MessageService.java similarity index 93% rename from domain/src/main/java/com/example/MessageService.java rename to domain/src/main/java/com/example/service/MessageService.java index e7114412..72490027 100644 --- a/domain/src/main/java/com/example/MessageService.java +++ b/domain/src/main/java/com/example/service/MessageService.java @@ -1,4 +1,4 @@ -package com.example; +package com.example.service; import org.springframework.stereotype.Service; diff --git a/domain/src/main/java/com/example/service/MovieService.java b/domain/src/main/java/com/example/service/MovieService.java new file mode 100644 index 00000000..279d4542 --- /dev/null +++ b/domain/src/main/java/com/example/service/MovieService.java @@ -0,0 +1,47 @@ +package com.example.service; + +import com.example.dto.response.MovieScreeningServiceResponse; +import com.example.dto.response.PageResponse; +import com.example.entity.Movie; +import com.example.enums.Genre; +import com.example.repository.MovieRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import java.util.*; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MovieService { + + private final MovieRepository movieRepository; + + @Cacheable( + value = "movies", + key = "#genre != null ? #genre + '_page_' + #page : 'all_page_' + #page", + condition = "(#genre != null and #title == null and #page >= 0 and #page < 2) " + + "|| (#genre == null and #title == null and #page >= 0 and #page < 2)", + cacheManager = "contentCacheManager" + ) + @Transactional(readOnly = true) + public PageResponse getMoviesWithScreenings(String title, String genre, int page, int size) { + + Genre genreEnum = genre != null ? Genre.valueOf(genre.toUpperCase()) : null; + + Page moviePage = movieRepository.searchMoviesWithScreenings( + title, + genreEnum, + PageRequest.of(page, size) + ); + + List content = moviePage.getContent().stream() + .map(MovieScreeningServiceResponse::from) + .toList(); + + return PageResponse.of(content, moviePage); + } +} diff --git a/domain/src/main/java/com/example/service/ReservationEventHandler.java b/domain/src/main/java/com/example/service/ReservationEventHandler.java new file mode 100644 index 00000000..59950c69 --- /dev/null +++ b/domain/src/main/java/com/example/service/ReservationEventHandler.java @@ -0,0 +1,22 @@ +package com.example.service; + +import com.example.dto.request.ReservationCompletedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class ReservationEventHandler { + + private final MessageService messageService; + + @Async + @TransactionalEventListener + public void handle(ReservationCompletedEvent event) { + String message = "회원 %s님, 좌석 %d개 예약이 완료되었습니다." + .formatted(event.getUserName(), event.getSeatCount()); + messageService.send(message); + } +} diff --git a/domain/src/main/java/com/example/service/ReservationLockHandler.java b/domain/src/main/java/com/example/service/ReservationLockHandler.java new file mode 100644 index 00000000..3cae82a3 --- /dev/null +++ b/domain/src/main/java/com/example/service/ReservationLockHandler.java @@ -0,0 +1,54 @@ +package com.example.service; + +import com.example.common.DistributedMultiLock; +import com.example.common.LockTemplate; +import com.example.dto.request.ReservationServiceRequest; +import com.example.entity.Reservation; +import com.example.entity.User; +import com.example.repository.ReservationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ReservationLockHandler { + + private final ReservationRepository reservationRepository; + private final LockTemplate lockTemplate; + + @DistributedMultiLock(expression = "#request.toLockKeys()") + public void handleWithAspectLock(ReservationServiceRequest request, User user) { + List reservationsToUpdate = reservationRepository + .findByScreeningIdAndScreeningSeatIdInWithLock(request.getScreeningId(), request.getSeatIds()); + + if (reservationsToUpdate.size() != request.getSeatIds().size()) { + throw new IllegalStateException("요청한 좌석에 대한 Reservation 데이터가 일부 누락되었습니다."); + } + + if (reservationsToUpdate.stream().anyMatch(Reservation::isReserved)) { + throw new IllegalStateException("이미 예약된 좌석이 포함되어 있습니다."); + } + + reservationsToUpdate.forEach(reservation -> reservation.reserve(user)); + } + + public void handleWithTemplateLock(ReservationServiceRequest request, User user) { + List lockKeys = request.toLockKeys(); // ex) ["1:5", "1:6", "1:7"] + + lockTemplate.executeMultiLock(lockKeys, () -> { + List reservationsToUpdate = reservationRepository + .findByScreeningIdAndScreeningSeatIdInWithLock(request.getScreeningId(), request.getSeatIds()); + + if (reservationsToUpdate.size() != request.getSeatIds().size()) { + throw new IllegalStateException("요청한 좌석에 대한 Reservation 데이터가 일부 누락되었습니다."); + } + + if (reservationsToUpdate.stream().anyMatch(Reservation::isReserved)) { + throw new IllegalStateException("이미 예약된 좌석이 포함되어 있습니다."); + } + + reservationsToUpdate.forEach(reservation -> reservation.reserve(user)); + }); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/service/ReservationService.java b/domain/src/main/java/com/example/service/ReservationService.java new file mode 100644 index 00000000..041532ae --- /dev/null +++ b/domain/src/main/java/com/example/service/ReservationService.java @@ -0,0 +1,40 @@ +package com.example.service; + +import com.example.dto.request.ReservationServiceRequest; +import com.example.dto.response.ReservationServiceResponse; +import com.example.entity.User; +import com.example.dto.request.ReservationCompletedEvent; +import com.example.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ReservationService { + + private final ReservationValidator reservationValidator; + private final ReservationLockHandler reservationLockHandler; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + public ReservationServiceResponse reserveSeats(ReservationServiceRequest request){ + + reservationValidator.validate(request); + + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + + reservationLockHandler.handleWithAspectLock(request, user); + + eventPublisher.publishEvent(ReservationCompletedEvent.of(user.getName(), request.getSeatIds().size())); + + return ReservationServiceResponse.builder() + .userId(user.getId()) + .screeningId(request.getScreeningId()) + .reservedSeatIds(request.getSeatIds()) + .build(); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/ReservationValidator.java b/domain/src/main/java/com/example/service/ReservationValidator.java similarity index 79% rename from domain/src/main/java/com/example/ReservationValidator.java rename to domain/src/main/java/com/example/service/ReservationValidator.java index c813a76f..8029bbb7 100644 --- a/domain/src/main/java/com/example/ReservationValidator.java +++ b/domain/src/main/java/com/example/service/ReservationValidator.java @@ -1,7 +1,6 @@ -package com.example; +package com.example.service; import com.example.dto.request.ReservationServiceRequest; -import com.example.entity.Reservation; import com.example.entity.ScreeningSeat; import com.example.repository.ReservationRepository; import com.example.repository.ScreeningSeatRepository; @@ -20,7 +19,7 @@ public class ReservationValidator { public void validate(ReservationServiceRequest request){ // 1. 해당 상영 시간표에서 이미 예약한 좌석 수 확인 - int alreadyReservedCount = reservationRepository.countByUserIdAndScreeningId(request.getMemberId(), request.getScreeningId()); + int alreadyReservedCount = reservationRepository.countByUserIdAndScreeningId(request.getUserId(), request.getScreeningId()); int requestedCount = request.getSeatIds().size(); if (alreadyReservedCount + requestedCount > 5) { @@ -51,13 +50,5 @@ public void validate(ReservationServiceRequest request){ throw new IllegalArgumentException("좌석은 같은 행에서 인접해야 합니다."); } } - - // 5. 해당 좌석이 이미 예약되었는지 확인 - List existingReservations = reservationRepository - .findByScreeningIdAndScreeningSeatIdIn(request.getScreeningId(), request.getSeatIds()); - - if (!existingReservations.isEmpty()) { - throw new IllegalArgumentException("이미 예약된 좌석이 포함되어 있습니다."); - } } -} +} \ No newline at end of file diff --git a/domain/src/test/java/com/example/TestApplication.java b/domain/src/test/java/com/example/TestApplication.java new file mode 100644 index 00000000..0e490f19 --- /dev/null +++ b/domain/src/test/java/com/example/TestApplication.java @@ -0,0 +1,9 @@ +package com.example; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootApplication +public class TestApplication { + public void contextLoads(){} +} \ No newline at end of file diff --git a/domain/src/test/java/com/example/config/IntegrationServiceTest.java b/domain/src/test/java/com/example/config/IntegrationServiceTest.java new file mode 100644 index 00000000..68bb48ae --- /dev/null +++ b/domain/src/test/java/com/example/config/IntegrationServiceTest.java @@ -0,0 +1,46 @@ +package com.example.config; + +import com.example.repository.*; +import com.example.service.MovieService; +import com.example.service.ReservationService; +import com.example.service.ReservationValidator; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +public abstract class IntegrationServiceTest { + + @Autowired + protected MovieService movieService; + + @Autowired + protected MovieRepository movieRepository; + + @Autowired + protected TheaterRepository theaterRepository; + + @Autowired + protected ReservationValidator reservationValidator; + + @Autowired + protected ScreeningRepository screeningRepository; + + @Autowired + protected ReservationService reservationService; + + @Autowired + protected ReservationRepository reservationRepository; + + @Autowired + protected ScreeningSeatRepository screeningSeatRepository; + + @Autowired + protected UserRepository userRepository; + + @PersistenceContext + protected EntityManager em; +} diff --git a/domain/src/test/java/com/example/service/MovieServiceTest.java b/domain/src/test/java/com/example/service/MovieServiceTest.java new file mode 100644 index 00000000..e06acae0 --- /dev/null +++ b/domain/src/test/java/com/example/service/MovieServiceTest.java @@ -0,0 +1,86 @@ +package com.example.service; + +import com.example.config.IntegrationServiceTest; +import com.example.dto.response.MovieScreeningServiceResponse; +import com.example.dto.response.PageResponse; +import com.example.dto.response.ScreeningServiceResponse; +import com.example.entity.Movie; +import com.example.entity.Screening; +import com.example.entity.Theater; +import com.example.enums.Genre; +import com.example.enums.Rating; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; +import static com.example.enums.Genre.DRAMA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +@Transactional +class MovieServiceTest extends IntegrationServiceTest { + + @Nested + @DisplayName("상영중인 영화 조회") + class getMoviesWithScreenings_success { + + @Test + @DisplayName("영화 제목과 장르로 조회하면 해당 영화와 관련된 상영 정보가 반환된다") + void getMoviesWithScreenings() { + // given + Movie movie = saveMovie("movie1", DRAMA); + Theater theater = saveTheater("theater1"); + Screening screening = saveScreening(movie, theater, LocalDate.now().plusDays(1)); + em.clear(); + + // when + PageResponse result = + movieService.getMoviesWithScreenings("movie1", "drama", 0, 10); + + // then + MovieScreeningServiceResponse response = result.getContent().get(0); + assertThat(response.getTitle()).isEqualTo("movie1"); + assertThat(response.getGenre()).isEqualTo("DRAMA"); + + assertThat(response.getScreeningServiceResponses()) + .hasSize(1) + .extracting( + ScreeningServiceResponse::getDate, + ScreeningServiceResponse::getTheaterName + ) + .containsExactly( + tuple(LocalDate.now().plusDays(1), "theater1") + ); + } + } + + + private Movie saveMovie(String title, Genre genre) { + return movieRepository.save(Movie.builder() + .title(title) + .rating(Rating.R_12) + .releasedDate(LocalDate.of(2000, 1, 1)) + .thumbnailImage("movie1-thumbnail.jpg") + .runningTimeMin(120) + .genre(genre) + .build()); + } + + private Theater saveTheater(String name) { + return theaterRepository.save(Theater.builder() + .name(name) + .build()); + } + + private Screening saveScreening(Movie movie, Theater theater, LocalDate localDate) { + return screeningRepository.save(Screening.builder() + .date(localDate) + .startedAt(LocalDateTime.of(2026, 1, 1, 14, 0)) + .endedAt(LocalDateTime.of(2026, 1, 1, 16, 0)) + .movie(movie) + .theater(theater) + .build()); + } +} \ No newline at end of file diff --git a/domain/src/test/java/com/example/service/ReservationConcurrencyTest.java b/domain/src/test/java/com/example/service/ReservationConcurrencyTest.java new file mode 100644 index 00000000..18d1b477 --- /dev/null +++ b/domain/src/test/java/com/example/service/ReservationConcurrencyTest.java @@ -0,0 +1,149 @@ +package com.example.service; + +import com.example.config.IntegrationServiceTest; +import com.example.dto.request.ReservationServiceRequest; +import com.example.entity.*; +import com.example.enums.Genre; +import com.example.enums.Rating; +import org.junit.jupiter.api.*; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ReservationConcurrencyTest extends IntegrationServiceTest { + + @MockitoBean + private ReservationValidator reservationValidator; + + @MockitoBean + private ApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + } + + @AfterEach + void tearDown() { + reservationRepository.deleteAllInBatch(); + screeningSeatRepository.deleteAllInBatch(); + screeningRepository.deleteAllInBatch(); + theaterRepository.deleteAllInBatch(); + movieRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @Nested + @DisplayName("영화 좌석 예약") + class reserveSeats { + + @Test + @DisplayName("여러 사용자가 동시에 같은 좌석을 예매하면 오직 한 명만 성공해야 한다") + void reserveSeats_success_concurrency() throws Exception { + // given + Movie movie = saveMovie(); + Theater theater = saveTheater(); + Screening screening = saveScreening(movie, theater); + List seats = saveScreeningSeats(theater, 1); + List reservations = saveReservationsForSeats(screening, seats); + + List seatIds = seats.stream() + .map(ScreeningSeat::getId) + .toList(); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + User user = saveUser(); + + executor.submit(() -> { + try { + ReservationServiceRequest request = ReservationServiceRequest.builder() + .userId(user.getId()) + .screeningId(screening.getId()) + .seatIds(seatIds) + .build(); + + reservationService.reserveSeats(request); + successCount.incrementAndGet(); + } catch (Exception e) { + System.out.println("예외 발생: " + e.getClass() + " / " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + // then + assertEquals(1, successCount.get(), "동시에 예약 시 단 한 명만 성공해야 합니다."); + } + } + + + private User saveUser() { + return userRepository.save(User.builder() + .name("user1") + .build()); + } + + private Movie saveMovie() { + return movieRepository.save(Movie.builder() + .title("movie1") + .rating(Rating.R_12) + .releasedDate(LocalDate.of(2024, 1, 1)) + .thumbnailImage("movie1-thumbnail.jpg") + .runningTimeMin(120) + .genre(Genre.DRAMA) + .build()); + } + + private Theater saveTheater() { + return theaterRepository.save(Theater.builder() + .name("theater1") + .build()); + } + + private Screening saveScreening(Movie movie, Theater theater) { + return screeningRepository.save(Screening.builder() + .date(LocalDate.of(2024, 1, 1)) + .startedAt(LocalDateTime.of(2024, 1, 1, 14, 0)) + .endedAt(LocalDateTime.of(2024, 1, 1, 16, 0)) + .movie(movie) + .theater(theater) + .build()); + } + + private List saveScreeningSeats(Theater theater, int count) { + return IntStream.range(0, count) + .mapToObj(i -> ScreeningSeat.builder() + .row(1) + .col(i + 1) + .theater(theater) + .build()) + .map(screeningSeatRepository::save) + .toList(); + } + + private List saveReservationsForSeats(Screening screening, List seats) { + return seats.stream() + .map(seat -> Reservation.builder() + .screening(screening) + .screeningSeat(seat) + .isReserved(false) + .build()) + .map(reservationRepository::save) + .toList(); + } +} \ No newline at end of file diff --git a/domain/src/test/java/com/example/service/ReservationServiceTest.java b/domain/src/test/java/com/example/service/ReservationServiceTest.java new file mode 100644 index 00000000..b6649644 --- /dev/null +++ b/domain/src/test/java/com/example/service/ReservationServiceTest.java @@ -0,0 +1,120 @@ +package com.example.service; + +import com.example.config.IntegrationServiceTest; +import com.example.dto.request.ReservationServiceRequest; +import com.example.dto.response.ReservationServiceResponse; +import com.example.entity.*; +import com.example.enums.Genre; +import com.example.enums.Rating; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class ReservationServiceTest extends IntegrationServiceTest { + + @MockitoBean + private ReservationValidator reservationValidator; + + @MockitoBean + private ApplicationEventPublisher eventPublisher; + + @Nested + @DisplayName("영화 좌석 예약") + class reserveSeats { + + @Test + @DisplayName("정상적인 요청으로 좌석 예약에 성공한다") + void reserveSeats_success() { + // given + User user = saveUser(); + Theater theater = saveTheater(); + Movie movie = saveMovie(); + Screening screening = saveScreening(movie, theater); + List seats = saveScreeningSeats(theater, 1); + saveReservationsForSeats(screening, seats); + + List seatIds = seats.stream() + .map(ScreeningSeat::getId) + .toList(); + + ReservationServiceRequest request = ReservationServiceRequest.builder() + .userId(user.getId()) + .screeningId(screening.getId()) + .seatIds(seatIds) + .build(); + + // when + ReservationServiceResponse response = reservationService.reserveSeats(request); + + // then + assertThat(response.getUserId()).isEqualTo(user.getId()); + assertThat(response.getScreeningId()).isEqualTo(screening.getId()); + assertThat(response.getReservedSeatIds()) + .containsExactlyInAnyOrderElementsOf(seatIds); + } + + private User saveUser() { + return userRepository.save(User.builder() + .name("user1") + .build()); + } + + private Movie saveMovie() { + return movieRepository.save(Movie.builder() + .title("movie1") + .rating(Rating.R_12) + .releasedDate(LocalDate.of(2024, 1, 1)) + .thumbnailImage("movie1-thumbnail.jpg") + .runningTimeMin(120) + .genre(Genre.DRAMA) + .build()); + } + + private Theater saveTheater() { + return theaterRepository.save(Theater.builder() + .name("theater1") + .build()); + } + + private Screening saveScreening(Movie movie, Theater theater) { + return screeningRepository.save(Screening.builder() + .date(LocalDate.of(2024, 1, 1)) + .startedAt(LocalDateTime.of(2024, 1, 1, 14, 0)) + .endedAt(LocalDateTime.of(2024, 1, 1, 16, 0)) + .movie(movie) + .theater(theater) + .build()); + } + + private List saveScreeningSeats(Theater theater, int count) { + return IntStream.range(0, count) + .mapToObj(i -> ScreeningSeat.builder() + .row(1) + .col(i + 1) + .theater(theater) + .build()) + .map(screeningSeatRepository::save) + .toList(); + } + + private List saveReservationsForSeats(Screening screening, List seats) { + return seats.stream() + .map(seat -> Reservation.builder() + .screening(screening) + .screeningSeat(seat) + .isReserved(false) + .build()) + .map(reservationRepository::save) + .toList(); + } + } +} diff --git a/domain/src/test/java/com/example/service/ReservationValidatorTest.java b/domain/src/test/java/com/example/service/ReservationValidatorTest.java new file mode 100644 index 00000000..5e4c35af --- /dev/null +++ b/domain/src/test/java/com/example/service/ReservationValidatorTest.java @@ -0,0 +1,211 @@ +package com.example.service; + +import com.example.config.IntegrationServiceTest; +import com.example.dto.request.ReservationServiceRequest; +import com.example.entity.*; +import com.example.enums.Genre; +import com.example.enums.Rating; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; +import static org.junit.jupiter.api.Assertions.*; + +@Transactional +class ReservationValidatorTest extends IntegrationServiceTest { + + @Nested + @DisplayName("ReservationValidator") + class validate{ + + @Test + @DisplayName("모든 조건을 만족하면 예외 없이 통과한다") + void validate_success() { + // given + User user = saveUser(); + Movie movie = saveMovie(); + Theater theater = saveTheater(); + Screening screening = saveScreening(movie, theater); + List seats = saveScreeningSeats(screening.getTheater(), 6); + + List alreadyReservedSeats = seats.subList(0, 4); + saveReservationsForSeats(screening, alreadyReservedSeats, user); + + ScreeningSeat sixthSeat = seats.get(4); + ReservationServiceRequest request = ReservationServiceRequest.builder() + .userId(user.getId()) + .screeningId(screening.getId()) + .seatIds(List.of(sixthSeat.getId())) + .build(); + + // when + then + assertDoesNotThrow(() -> reservationValidator.validate(request)); + } + + + @Test + @DisplayName("1인당 5좌석 초과 요청 시 예외가 발생한다.") + void validate_fail_exceedMaxSeats() { + // given + User user = saveUser(); + Movie movie = saveMovie(); + Theater theater = saveTheater(); + Screening screening = saveScreening(movie, theater); + List seats = saveScreeningSeats(screening.getTheater(), 6); + + List alreadyReservedSeats = seats.subList(0, 5); + saveReservationsForSeats(screening, alreadyReservedSeats, user); + + ScreeningSeat sixthSeat = seats.get(5); + ReservationServiceRequest request = ReservationServiceRequest.builder() + .userId(user.getId()) + .screeningId(screening.getId()) + .seatIds(List.of(sixthSeat.getId())) + .build(); + + // when & then + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + reservationValidator.validate(request); + }); + assertEquals("1인당 최대 5좌석까지만 예매할 수 있습니다.", exception.getMessage()); + } + + @Test + @DisplayName("요청한 좌석이 유효하지 않다면 예외가 발생한다.") + void validate_fail_wrongSeats() { + // given + User user = saveUser(); + Movie movie = saveMovie(); + Theater theater = saveTheater(); + Screening screening = saveScreening(movie, theater); + + ReservationServiceRequest request = ReservationServiceRequest.builder() + .userId(user.getId()) + .screeningId(screening.getId()) + .seatIds(List.of(1L)) + .build(); + + // when & then + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + reservationValidator.validate(request); + }); + assertEquals("유효하지 않은 좌석이 포함되어 있습니다.", exception.getMessage()); + } + + @Test + @DisplayName("다른 row의 좌석을 요청하면 예외가 발생한다.") + void validate_fail_differentRow() { + // given + User user = saveUser(); + Movie movie = saveMovie(); + Theater theater = saveTheater(); + Screening screening = saveScreening(movie, theater); + ScreeningSeat seat1 = saveScreeningSeat(theater, 1, 1); + ScreeningSeat seat2 = saveScreeningSeat(theater, 2, 1); + + ReservationServiceRequest request = ReservationServiceRequest.builder() + .userId(user.getId()) + .screeningId(screening.getId()) + .seatIds(List.of(seat1.getId(), seat2.getId())) + .build(); + + // when & then + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + reservationValidator.validate(request); + }); + assertEquals("좌석은 같은 행(row)이어야 합니다.", exception.getMessage()); + } + + @Test + @DisplayName("좌석이 인접하지 않으면 예외 발생") + void validate_fail_notAdjacent() { + // given + User user = saveUser(); + Movie movie = saveMovie(); + Theater theater = saveTheater(); + Screening screening = saveScreening(movie, theater); + ScreeningSeat seat1 = saveScreeningSeat(theater, 1, 1); + ScreeningSeat seat2 = saveScreeningSeat(theater, 1, 3); + + ReservationServiceRequest request = ReservationServiceRequest.builder() + .userId(user.getId()) + .screeningId(screening.getId()) + .seatIds(List.of(seat1.getId(), seat2.getId())) + .build(); + + // when & then + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + reservationValidator.validate(request); + }); + assertEquals("좌석은 같은 행에서 인접해야 합니다.", exception.getMessage()); + } + } + + private User saveUser() { + return userRepository.save(User.builder() + .name("user1") + .build()); + } + + private Movie saveMovie() { + return movieRepository.save(Movie.builder() + .title("movie1") + .rating(Rating.R_12) + .releasedDate(LocalDate.of(2024, 1, 1)) + .thumbnailImage("movie1-thumbnail.jpg") + .runningTimeMin(120) + .genre(Genre.DRAMA) + .build()); + } + + private Theater saveTheater() { + return theaterRepository.save(Theater.builder() + .name("theater1") + .build()); + } + + private Screening saveScreening(Movie movie, Theater theater) { + return screeningRepository.save(Screening.builder() + .date(LocalDate.of(2024, 1, 1)) + .startedAt(LocalDateTime.of(2024, 1, 1, 14, 0)) + .endedAt(LocalDateTime.of(2024, 1, 1, 16, 0)) + .movie(movie) + .theater(theater) + .build()); + } + + private List saveScreeningSeats(Theater theater, int count) { + return IntStream.range(0, count) + .mapToObj(i -> ScreeningSeat.builder() + .row(1) + .col(i + 1) + .theater(theater) + .build()) + .map(screeningSeatRepository::save) + .toList(); + } + + private ScreeningSeat saveScreeningSeat(Theater theater, int row, int col) { + return screeningSeatRepository.save(ScreeningSeat.builder() + .theater(theater) + .row(row) + .col(col) + .build()); + } + + private List saveReservationsForSeats(Screening screening, List seats, User user) { + return seats.stream() + .map(seat -> Reservation.builder() + .screening(screening) + .screeningSeat(seat) + .isReserved(true) + .user(user) + .build()) + .map(reservationRepository::save) + .toList(); + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9355b415 --- /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.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 00000000..f5feea6d --- /dev/null +++ b/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/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..9d21a218 --- /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/infra/build.gradle b/infra/build.gradle index 39e2e8bf..20c64bb7 100644 --- a/infra/build.gradle +++ b/infra/build.gradle @@ -1,10 +1,13 @@ dependencies { + implementation project(':common') implementation('org.springframework.boot:spring-boot-starter-data-jpa') implementation 'mysql:mysql-connector-java:8.0.33' runtimeOnly 'com.mysql:mysql-connector-j' - runtimeOnly 'com.h2database:h2' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2' + runtimeOnly 'com.h2database:h2' + + // domain 모듈 : 분산락에서 사용 + api 'org.redisson:redisson:3.22.0' implementation 'org.hibernate:hibernate-core:6.6.11.Final' // QueryDsl diff --git a/infra/src/main/java/com/example/config/RedisCacheConfig.java b/infra/src/main/java/com/example/config/RedisCacheConfig.java index 20be7cbb..1d555cbb 100644 --- a/infra/src/main/java/com/example/config/RedisCacheConfig.java +++ b/infra/src/main/java/com/example/config/RedisCacheConfig.java @@ -1,6 +1,8 @@ package com.example.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; @@ -12,7 +14,6 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; - import java.time.Duration; @Configuration @@ -21,11 +22,16 @@ public class RedisCacheConfig { @Bean public CacheManager contentCacheManager(RedisConnectionFactory cf) { + PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class) + .build(); + ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); + objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL); RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() - .entryTtl(Duration.ofMinutes(3)) // 캐시 TTL 3분 + .entryTtl(Duration.ofDays(1)) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))); diff --git a/infra/src/main/java/com/example/config/RedissonConfig.java b/infra/src/main/java/com/example/config/RedissonConfig.java new file mode 100644 index 00000000..5a4dd538 --- /dev/null +++ b/infra/src/main/java/com/example/config/RedissonConfig.java @@ -0,0 +1,28 @@ +package com.example.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Value("${spring.redis.host}") + private String host; + + @Value("${spring.redis.port}") + private int port; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + String redisUrl = String.format("redis://%s:%d", host, port); + config.useSingleServer() + .setAddress(redisUrl); + + return Redisson.create(config); + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/example/entity/Movie.java b/infra/src/main/java/com/example/entity/Movie.java index 3afe4539..84fe04a6 100644 --- a/infra/src/main/java/com/example/entity/Movie.java +++ b/infra/src/main/java/com/example/entity/Movie.java @@ -7,7 +7,8 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import org.hibernate.annotations.BatchSize; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -29,28 +30,29 @@ public class Movie extends BaseEntity { private Rating rating; @Column - private LocalDateTime releasedAt; + private LocalDate releasedDate; @Column(length = 50) private String thumbnailImage; @Column - private int runningTime; + private int runningTimeMin; @Column(length = 20) @Enumerated(EnumType.STRING) private Genre genre; @OneToMany(mappedBy = "movie", fetch = FetchType.LAZY) + @BatchSize(size = 100) private List screenings = new ArrayList<>(); @Builder - private Movie(String title, Rating rating, LocalDateTime releasedAt, String thumbnailImage, int runningTime, Genre genre) { + private Movie(String title, Rating rating, LocalDate releasedDate, String thumbnailImage, int runningTimeMin, Genre genre) { this.title = title; this.rating = rating; - this.releasedAt = releasedAt; + this.releasedDate = releasedDate; this.thumbnailImage = thumbnailImage; - this.runningTime = runningTime; + this.runningTimeMin = runningTimeMin; this.genre = genre; } } \ No newline at end of file diff --git a/infra/src/main/java/com/example/entity/Reservation.java b/infra/src/main/java/com/example/entity/Reservation.java index beea7a32..3feeb0a8 100644 --- a/infra/src/main/java/com/example/entity/Reservation.java +++ b/infra/src/main/java/com/example/entity/Reservation.java @@ -1,8 +1,8 @@ package com.example.entity; -import com.fasterxml.jackson.databind.ser.Serializers; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,6 +16,11 @@ public class Reservation extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Version + private Long version; + + private boolean isReserved = false; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "screening_seat_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private ScreeningSeat screeningSeat; @@ -25,6 +30,19 @@ public class Reservation extends BaseEntity { private Screening screening; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + @JoinColumn(name = "user_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private User user; + + @Builder + private Reservation(boolean isReserved,ScreeningSeat screeningSeat, Screening screening, User user) { + this.isReserved = isReserved; + this.screeningSeat = screeningSeat; + this.screening = screening; + this.user = user; + } + + public void reserve(User user) { + this.user = user; + this.isReserved = true; + } } diff --git a/infra/src/main/java/com/example/entity/Screening.java b/infra/src/main/java/com/example/entity/Screening.java index 5c4ac532..91a4f860 100644 --- a/infra/src/main/java/com/example/entity/Screening.java +++ b/infra/src/main/java/com/example/entity/Screening.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDate; @@ -34,4 +35,12 @@ public class Screening extends BaseEntity { @JoinColumn(name = "theater_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private Theater theater; + @Builder + private Screening(LocalDate date, LocalDateTime startedAt, LocalDateTime endedAt, Movie movie, Theater theater) { + this.date = date; + this.startedAt = startedAt; + this.endedAt = endedAt; + this.movie = movie; + this.theater = theater; + } } diff --git a/infra/src/main/java/com/example/entity/ScreeningSeat.java b/infra/src/main/java/com/example/entity/ScreeningSeat.java index 162e0f8d..b0a444cd 100644 --- a/infra/src/main/java/com/example/entity/ScreeningSeat.java +++ b/infra/src/main/java/com/example/entity/ScreeningSeat.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -15,13 +16,20 @@ public class ScreeningSeat extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column + @Column(name = "seat_row") private int row; - @Column + @Column(name = "seat_col") private int col; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "theater_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private Theater theater; + + @Builder + private ScreeningSeat(int row, int col, Theater theater) { + this.row = row; + this.col = col; + this.theater = theater; + } } diff --git a/infra/src/main/java/com/example/entity/Theater.java b/infra/src/main/java/com/example/entity/Theater.java index efd66ee3..9e78874e 100644 --- a/infra/src/main/java/com/example/entity/Theater.java +++ b/infra/src/main/java/com/example/entity/Theater.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -17,4 +18,9 @@ public class Theater extends BaseEntity { @Column(length = 30) private String name; + + @Builder + private Theater(String name) { + this.name = name; + } } diff --git a/infra/src/main/java/com/example/entity/User.java b/infra/src/main/java/com/example/entity/User.java index ef107733..f2b46eaf 100644 --- a/infra/src/main/java/com/example/entity/User.java +++ b/infra/src/main/java/com/example/entity/User.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -15,4 +16,11 @@ public class User extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(length = 20) + private String name; + + @Builder + private User(String name) { + this.name = name; + } } diff --git a/infra/src/main/java/com/example/repository/MovieRepositoryCustom.java b/infra/src/main/java/com/example/repository/MovieRepositoryCustom.java index fa7b71cd..6511e1fd 100644 --- a/infra/src/main/java/com/example/repository/MovieRepositoryCustom.java +++ b/infra/src/main/java/com/example/repository/MovieRepositoryCustom.java @@ -2,9 +2,10 @@ import com.example.entity.Movie; import com.example.enums.Genre; -import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface MovieRepositoryCustom { - List searchMoviesWithScreenings(String title, Genre genre); + Page searchMoviesWithScreenings(String title, Genre genre, Pageable pageable); } diff --git a/infra/src/main/java/com/example/repository/MovieRepositoryImpl.java b/infra/src/main/java/com/example/repository/MovieRepositoryImpl.java index 3558d3b5..5038a783 100644 --- a/infra/src/main/java/com/example/repository/MovieRepositoryImpl.java +++ b/infra/src/main/java/com/example/repository/MovieRepositoryImpl.java @@ -5,13 +5,13 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import static com.example.entity.QMovie.movie; -import static com.example.entity.QScreening.screening; -import static com.example.entity.QTheater.theater; -import static com.querydsl.core.types.dsl.Expressions.booleanTemplate; @Repository @RequiredArgsConstructor @@ -20,23 +20,34 @@ public class MovieRepositoryImpl implements MovieRepositoryCustom { private final JPAQueryFactory jpaQueryFactory; @Override - public List searchMoviesWithScreenings(String title, Genre genre) { - return jpaQueryFactory + public Page searchMoviesWithScreenings(String title, Genre genre, Pageable pageable) { + + List movies = jpaQueryFactory .selectFrom(movie) - .leftJoin(movie.screenings, screening).fetchJoin() - .leftJoin(screening.theater, theater).fetchJoin() .where( - filterByTitleFTS(title), - filterByGenre(genre), - movie.releasedAt.before(LocalDateTime.now()) + filterByTitle(title), + filterByGenre(genre) ) + .orderBy(movie.releasedDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) .fetch(); + + // 페이징을 위한 전체 개수 조회 + long total = jpaQueryFactory + .select(movie.count()) + .from(movie) + .where( + filterByTitle(title), + filterByGenre(genre), + movie.releasedDate.before(LocalDate.now()) + ) + .fetchOne(); + return new PageImpl<>(movies, pageable, total); } - private BooleanExpression filterByTitleFTS(String title) { - return (title != null && !title.isEmpty()) ? - booleanTemplate("function('match_against', {0}, {1}) > 0", movie.title, title) : - null; + private BooleanExpression filterByTitle(String title) { + return (title != null && !title.isBlank()) ? movie.title.startsWith(title) : null; } private BooleanExpression filterByGenre(Genre genre) { diff --git a/infra/src/main/java/com/example/repository/ReservationRepository.java b/infra/src/main/java/com/example/repository/ReservationRepository.java index f82da29a..b5d2d3f2 100644 --- a/infra/src/main/java/com/example/repository/ReservationRepository.java +++ b/infra/src/main/java/com/example/repository/ReservationRepository.java @@ -1,12 +1,18 @@ package com.example.repository; import com.example.entity.Reservation; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface ReservationRepository extends JpaRepository { int countByUserIdAndScreeningId(Long userId, Long screeningId); - List findByScreeningIdAndScreeningSeatIdIn(Long screeningId, List seatIds); -} + @Lock(LockModeType.OPTIMISTIC) + @Query("SELECT r FROM Reservation r WHERE r.screening.id = :screeningId AND r.screeningSeat.id IN :seatIds") + List findByScreeningIdAndScreeningSeatIdInWithLock(@Param("screeningId") Long screeningId, @Param("seatIds") List seatIds); +} \ No newline at end of file diff --git a/infra/src/main/java/com/example/repository/ScreeningRepository.java b/infra/src/main/java/com/example/repository/ScreeningRepository.java new file mode 100644 index 00000000..6169a1d8 --- /dev/null +++ b/infra/src/main/java/com/example/repository/ScreeningRepository.java @@ -0,0 +1,7 @@ +package com.example.repository; + +import com.example.entity.Screening; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScreeningRepository extends JpaRepository { +} diff --git a/infra/src/main/java/com/example/repository/ScreeningSeatRepository.java b/infra/src/main/java/com/example/repository/ScreeningSeatRepository.java index d3d92638..f5e76890 100644 --- a/infra/src/main/java/com/example/repository/ScreeningSeatRepository.java +++ b/infra/src/main/java/com/example/repository/ScreeningSeatRepository.java @@ -2,15 +2,6 @@ import com.example.entity.ScreeningSeat; import org.springframework.data.jpa.repository.JpaRepository; -import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import java.util.List; public interface ScreeningSeatRepository extends JpaRepository { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT s FROM ScreeningSeat s WHERE s.id IN :seatIds") - List findByIdInWithLock(@Param("seatIds") List seatIds); } diff --git a/infra/src/main/java/com/example/repository/TheaterRepository.java b/infra/src/main/java/com/example/repository/TheaterRepository.java new file mode 100644 index 00000000..b301d2aa --- /dev/null +++ b/infra/src/main/java/com/example/repository/TheaterRepository.java @@ -0,0 +1,7 @@ +package com.example.repository; + +import com.example.entity.Theater; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TheaterRepository extends JpaRepository { +} diff --git a/infra/src/main/java/com/example/repository/UserRepository.java b/infra/src/main/java/com/example/repository/UserRepository.java new file mode 100644 index 00000000..94e261f3 --- /dev/null +++ b/infra/src/main/java/com/example/repository/UserRepository.java @@ -0,0 +1,7 @@ +package com.example.repository; + +import com.example.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} diff --git a/infra/src/main/resources/application-test.yml b/infra/src/main/resources/application-test.yml new file mode 100644 index 00000000..61d000f5 --- /dev/null +++ b/infra/src/main/resources/application-test.yml @@ -0,0 +1,16 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/infra/src/main/resources/application.yml b/infra/src/main/resources/application.yml new file mode 100644 index 00000000..cfc74be2 --- /dev/null +++ b/infra/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3307/cinema_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: 1234 + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + defer-datasource-initialization: true + hibernate: + ddl-auto: none # ✅ 기존 데이터 유지 + show-sql: true # ✅ SQL 로그 콘솔 출력 활성화 + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + function_contributor: com.example.config.MySQLCustomDialect + format_sql: true # ✅ SQL을 보기 좋게 포맷팅 + + redis: + host: 127.0.0.1 + port: 6380 \ No newline at end of file diff --git a/infra/src/test/java/com/example/TestApplication.java b/infra/src/test/java/com/example/TestApplication.java new file mode 100644 index 00000000..99cd1697 --- /dev/null +++ b/infra/src/test/java/com/example/TestApplication.java @@ -0,0 +1,9 @@ +package com.example; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootApplication +public class TestApplication { + public void contextLoads(){} +} diff --git a/infra/src/test/java/com/example/repository/IntegrationRepositoryTest.java b/infra/src/test/java/com/example/repository/IntegrationRepositoryTest.java new file mode 100644 index 00000000..ef79aab6 --- /dev/null +++ b/infra/src/test/java/com/example/repository/IntegrationRepositoryTest.java @@ -0,0 +1,28 @@ +package com.example.repository; + + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@DataJpaTest +@Import(TestJpaConfig.class) +public abstract class IntegrationRepositoryTest { + + @Autowired + protected ReservationRepository reservationRepository; + @Autowired + protected UserRepository userRepository; + @Autowired + protected MovieRepository movieRepository; + @Autowired + protected TheaterRepository theaterRepository; + @Autowired + protected ScreeningRepository screeningRepository; + @Autowired + protected ScreeningSeatRepository screeningSeatRepository; + @Autowired + protected MovieRepositoryImpl movieRepositoryImpl; +} diff --git a/infra/src/test/java/com/example/repository/MovieRepositoryCustomTest.java b/infra/src/test/java/com/example/repository/MovieRepositoryCustomTest.java new file mode 100644 index 00000000..dd302bff --- /dev/null +++ b/infra/src/test/java/com/example/repository/MovieRepositoryCustomTest.java @@ -0,0 +1,99 @@ +package com.example.repository; + +import com.example.entity.Movie; +import com.example.enums.Genre; +import com.example.enums.Rating; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import static com.example.enums.Genre.DRAMA; +import static com.example.enums.Genre.SF; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@Transactional +class MovieRepositoryCustomTest extends IntegrationRepositoryTest { + + @Nested + @DisplayName("searchMoviesWithScreenings 메서드") + class SearchMoviesWithScreenings { + + @Test + @DisplayName("제목과 장르로 영화를 검색하고 페이징 처리한다") + void success_with_title_genre() { + saveMovie("movie1", SF); + saveMovie("movie2", DRAMA); + saveMovie("movie3", DRAMA); + + // when + Page result = movieRepositoryImpl.searchMoviesWithScreenings( + "movie", // title startsWith "A" + DRAMA, // genre filter + PageRequest.of(0, 10) + ); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()) + .extracting(Movie::getTitle, Movie::getGenre) + .containsExactly( + tuple("movie2", DRAMA), + tuple("movie3", DRAMA) + ); + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @Test + @DisplayName("제목과 장르가 없을 때 전체 영화가 검색된다") + void success_nothing() { + // given + saveMovie("movie1", SF); + saveMovie("movie2", DRAMA); + saveMovie("movie3", DRAMA); + + // when + Page result = movieRepositoryImpl.searchMoviesWithScreenings( + null, + null, + PageRequest.of(0, 10) + ); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent()) + .extracting(Movie::getTitle, Movie::getGenre) + .containsExactly( + tuple("movie1", SF), + tuple("movie2", DRAMA), + tuple("movie3", DRAMA) + ); + assertThat(result.getTotalElements()).isEqualTo(3); + } + } + + private Movie saveMovie(String title, Genre genre) { + return movieRepository.save(Movie.builder() + .title(title) + .rating(Rating.R_12) + .releasedDate(LocalDate.of(2000, 1, 1)) + .thumbnailImage("movie1-thumbnail.jpg") + .runningTimeMin(120) + .genre(genre) + .build()); + } + + private Movie saveMovie(String title, Genre genre, LocalDate date) { + return movieRepository.save(Movie.builder() + .title(title) + .rating(Rating.R_12) + .releasedDate(date) + .thumbnailImage("movie1-thumbnail.jpg") + .runningTimeMin(120) + .genre(genre) + .build()); + } +} \ No newline at end of file diff --git a/infra/src/test/java/com/example/repository/ReservationRepositoryTest.java b/infra/src/test/java/com/example/repository/ReservationRepositoryTest.java new file mode 100644 index 00000000..335973a1 --- /dev/null +++ b/infra/src/test/java/com/example/repository/ReservationRepositoryTest.java @@ -0,0 +1,116 @@ +package com.example.repository; + +import com.example.entity.*; +import com.example.enums.Genre; +import com.example.enums.Rating; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class ReservationRepositoryTest extends IntegrationRepositoryTest { + + @Nested + @DisplayName("countByUserIdAndScreeningId 메서드") + class CountByUserIdAndScreeningId { + + @Test + @DisplayName("특정 사용자의 상영 회차 예약 횟수를 조회한다") + void success() { + // given + User user = saveUser(); + Movie movie = saveMovie(); + Theater theater = saveTheater(); + Screening screening = saveScreening(movie, theater); + ScreeningSeat screeningSeat = saveScreeningSeat(screening, theater); + saveReservation(screeningSeat, screening, user); + + // when + long count = reservationRepository.countByUserIdAndScreeningId(user.getId(), screening.getId()); + + // then + assertThat(count).isEqualTo(1); + } + } + + @Nested + @DisplayName("findByScreeningIdAndScreeningSeatIdInWithLock 메서드") + class FindByScreeningIdAndScreeningSeatIdInWithLock { + + @Test + @DisplayName("screeningId와 seatIds로 예약 정보를 조회한다. (version 컬럼 포함)") + void success() { + // given + Movie movie = saveMovie(); + Theater theater = saveTheater(); + Screening screening = saveScreening(movie, theater); + ScreeningSeat screeningSeat = saveScreeningSeat(screening, theater); + Reservation reservation = saveReservation(screeningSeat, screening, null); + + // when + List reservations = reservationRepository.findByScreeningIdAndScreeningSeatIdInWithLock( + screening.getId(), + List.of(screeningSeat.getId()) + ); + + // then + assertThat(reservations).hasSize(1); + assertThat(reservations.get(0).getId()).isEqualTo(reservation.getId()); + assertThat(reservations.get(0).getVersion()).isEqualTo(0); + } + } + + private User saveUser() { + return userRepository.save(User.builder() + .name("user1") + .build()); + } + + private Movie saveMovie() { + return movieRepository.save(Movie.builder() + .title("movie1") + .rating(Rating.R_12) + .releasedDate(LocalDate.of(2024, 1, 1)) + .thumbnailImage("movie1-thumbnail.jpg") + .runningTimeMin(120) + .genre(Genre.DRAMA) + .build()); + } + + private Theater saveTheater() { + return theaterRepository.save(Theater.builder() + .name("theater1") + .build()); + } + + private Screening saveScreening(Movie movie, Theater theater) { + return screeningRepository.save(Screening.builder() + .date(LocalDate.of(2024, 1, 1)) + .startedAt(LocalDateTime.of(2024, 1, 1, 14, 0)) + .endedAt(LocalDateTime.of(2024, 1, 1, 16, 0)) + .movie(movie) + .theater(theater) + .build()); + } + + private ScreeningSeat saveScreeningSeat(Screening screening, Theater theater) { + return screeningSeatRepository.save(ScreeningSeat.builder() + .row(1) + .col(1) + .theater(theater) + .build()); + } + + private Reservation saveReservation(ScreeningSeat screeningSeat, Screening screening, User user) { + return reservationRepository.save(Reservation.builder() + .screeningSeat(screeningSeat) + .screening(screening) + .user(user) + .build()); + } +} \ No newline at end of file diff --git a/infra/src/test/java/com/example/repository/TestJpaConfig.java b/infra/src/test/java/com/example/repository/TestJpaConfig.java new file mode 100644 index 00000000..7302e62f --- /dev/null +++ b/infra/src/test/java/com/example/repository/TestJpaConfig.java @@ -0,0 +1,19 @@ +package com.example.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TestJpaConfig { + + @PersistenceContext + public EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} \ No newline at end of file diff --git a/k6/script.js b/k6/script.js new file mode 100644 index 00000000..fdcbd1dd --- /dev/null +++ b/k6/script.js @@ -0,0 +1,35 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export let options = { + vus: 30, // 100명의 동시 사용자 + duration: '30m', // 짧은 시간에 집중 부하 +}; + +export default function () { + const url = 'http://host.docker.internal:8080/reservations'; + + const userId = __VU; // __VU는 각 가상 사용자에게 고유한 ID를 부여함 (1~100) + + const payload = JSON.stringify({ + memberId: userId, + screeningId: 1, + seatIds: [1, 2, 3], // 모든 유저가 동일한 좌석을 요청함 + }); + + const params = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + const res = http.post(url, payload, params); + + check(res, { + 'status is 200 or 409': (r) => r.status === 200 || r.status === 409, + 'reservation success or conflict': (r) => + r.status === 200 || r.status === 409, + }); + + sleep(1); // 사용자별 Think Time +} diff --git a/settings.gradle b/settings.gradle index 11f3531f..c8fbfd51 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,4 +2,5 @@ rootProject.name = 'cinema' include 'api' include 'domain' include 'infra' +include 'common'