diff --git a/README.md b/README.md index 6e489bd84..df2c78dab 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ >- MySQL >- Docker Compose >- IntelliJ Http Request +>- K6 +>- Junit5 +>- Jacoco +>- Google guava ratelimiter (단일 서버) +>- Redisson (분산 애플리케이션) **Clean Code 작성 요구 사항** >- 가독성 (클래스, 변수, 메서드 이름) @@ -546,6 +551,8 @@ Querydsl로 변경하여 한 번에 조회할 수 있도록 변경함. > 동시성 제어를 위해 *좌석 점유와 결제 이벤트를 분리*한 것으로 보임. => 궁금한 점 : 보통 예매 시스템에서 좌석을 누르는 순간 바로 점유 여부를 체크하는지 아니면 좌석을 누른 후 선택하기 버튼을 추가로 눌렀을 때 점유 여부를 체크하는지? +> 상황에 따라 다르게 적용하지만 유저 입장에서는 빠르게 점유 여부를 체크할 수 있는 편이 좋음. +> 다만, 빠르게 점유 여부를 체크할수록 그만큼 요청이 많아지므로 리소스가 많이 들게 됨. 회사의 리소스 가용여부에 따라 효율적 판단 요구됨. ### Pessimistic Lock (비관적 락) - 트랜잭션이 시작될 때 해당 행을 잠금 처리하고, 다른 트랜잭션이 해당 데이터를 읽거나 수정하지 못하도록 막는 방식. @@ -567,6 +574,27 @@ Querydsl로 변경하여 한 번에 조회할 수 있도록 변경함. > waitTime 을 5초로 설정한 것은 사용자가 너무 길게 느껴지지 않는 시간이면서 서버 처리시간에 여유를 주었고, > leaseTime 보다 살짝 길게 설정하여 데이터 정합성이 유지될 수 있도록 하였음. +--- + +## [3주차] 피드백 후 수정사항 +1. ✅ 예외 처리는 try-catch보다는 Exception Handler에서 처리하는 것이 책임 구분이 명확해짐. +2. ✅ DTO에 Setter와 같은 과도한 어노테이션 적용을 지양하고 해당 객체에 적절한 책임을 줄 것 + - 절차지향적인 방법이 아닌 객체지향적 방법으로 개선할 것 +3. ✅ 반복문에서 size() 메서드를 호출하는 것은 성능상 좋지 않음. +4. ✅ for문이나 배열의 인덱스 조회와 같은 부분은 가독성에 좋지 않고 런타임시 예외가 발생할 수 있음. +5. ✅ 락을 획득하지 않은 요청도 모두 Transactional을 적용받고 있으므로 함수형 락을 사용하는 이점을 살리기 위해 분리할 것 +6. ✅ 클라이언트에서 전달받은 user 값에 대한 검증 추가 + + +--- +# [4주차] 서버 안정성을 높이기 위한 RateLimit 구현 +## Google Guava Ratelimiter +- 조회API 테스트 시 429 에러가 51번째부터 발생해야 하는데, 두번째 요청부터 훨씬 빠르게 발생함. +- 예매API 테스트 429 반환 성공. + +## Redisson +- 조회API, 예매API 테스트 모두 429 반환 성공. + diff --git a/cinema-adapter/build.gradle b/cinema-adapter/build.gradle index d21355569..f6fa1c0c1 100644 --- a/cinema-adapter/build.gradle +++ b/cinema-adapter/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' id 'io.spring.dependency-management' // 루트의 dependency-management 상속 + id 'jacoco' } group = 'com.cinema' @@ -10,6 +11,7 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation project(':cinema-application') implementation project(':cinema-core') + implementation project(':cinema-common') implementation project(':cinema-infra') implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -21,8 +23,36 @@ dependencies { // Spring Retry implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework.boot:spring-boot-starter-aop' // AOP 활성화 필요 + + // Google Guava RateLimiter + implementation 'com.google.guava:guava:32.0.0-jre' + + // Redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.21.0' } tasks.named('bootJar') { mainClass = 'com.cinema.CinemaApplication' +} + +tasks.jacocoTestReport { + dependsOn test // test 실행 후 보고서 생성 + reports { + xml.required = true + csv.required = false + html.destination file("${buildDir}/jacocoHtml") + } +} + +tasks.jacocoTestCoverageVerification { + violationRules { + rule { + element = 'CLASS' + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.60 // 최소 60% 커버리지 필요 + } + } + } } \ No newline at end of file diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java b/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java new file mode 100644 index 000000000..28a707eca --- /dev/null +++ b/cinema-adapter/src/main/java/com/cinema/adapter/config/RateLimiterConfig.java @@ -0,0 +1,36 @@ +package com.cinema.adapter.config; + +import com.google.common.util.concurrent.RateLimiter; +import org.redisson.api.RRateLimiter; +import org.redisson.api.RateIntervalUnit; +import org.redisson.api.RateType; +import org.redisson.api.RedissonClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RateLimiterConfig { + + // Google Guava + /*@Bean + public RateLimiter rateLimiter() { + return RateLimiter.create(50.0 / 60.0); // 1분당 50회 + }*/ + + + // Redisson + private static final String RATE_LIMIT_KEY = "rate_limit:requests"; + + private final RedissonClient redissonClient; + + public RateLimiterConfig(RedissonClient redissonClient) { + this.redissonClient = redissonClient; + } + + @Bean + public RRateLimiter rateLimiter() { + RRateLimiter rateLimiter = redissonClient.getRateLimiter(RATE_LIMIT_KEY); + rateLimiter.trySetRate(RateType.OVERALL, 50, 1, RateIntervalUnit.MINUTES); // 1분당 50회 + return rateLimiter; + } +} diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/config/WebConfig.java b/cinema-adapter/src/main/java/com/cinema/adapter/config/WebConfig.java index 82b8a4c13..43deeea71 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/config/WebConfig.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/config/WebConfig.java @@ -1,16 +1,22 @@ package com.cinema.adapter.config; +import com.cinema.adapter.interceptor.RateLimitingInterceptor; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.nio.charset.StandardCharsets; import java.util.List; @Configuration +@RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { + private final RateLimitingInterceptor rateLimitingInterceptor; + @Override public void configureMessageConverters(List> converters) { for (HttpMessageConverter converter : converters) { @@ -19,4 +25,10 @@ public void configureMessageConverters(List> converters) } } } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitingInterceptor) + .addPathPatterns("/api/v1/movies/**", "/api/v1/tickets/**"); + } } \ No newline at end of file diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java b/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java index e2d687177..074b7b278 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/controller/MovieController.java @@ -2,8 +2,8 @@ import com.cinema.application.dto.MovieRequestDTO; import com.cinema.application.dto.MovieResponseDTO; -import com.cinema.application.dto.TicketRequestDTO; import com.cinema.application.service.MovieService; +import com.cinema.common.response.ApiResponseDTO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -22,16 +22,18 @@ public class MovieController { * */ // TODO : 상영일자가 추가되는 경우, bookable 쿼리파라미터를 추가하여 상영가능한 상태의 영화만 조회할 수 있도록 확장 가능 @GetMapping - public List getMovieScreenings(@Valid @RequestBody MovieRequestDTO movieRequestDTO) { - return movieService.getMovieScreenings(movieRequestDTO); + public ResponseEntity>> getMovieScreenings(@Valid @RequestBody MovieRequestDTO movieRequestDTO) { + List movieList = movieService.getMovieScreenings(movieRequestDTO); + return ResponseEntity.ok(ApiResponseDTO.success(movieList, "영화 목록 조회 성공")); } /** * 영화별 상영시간표 캐시 삭제 */ @GetMapping("/evictRedisCache") - public void evictRedisCache() { + public ResponseEntity> evictRedisCache() { movieService.evictShowingMovieCache(); + return ResponseEntity.ok(ApiResponseDTO.success(null, "캐시 삭제 완료")); } } diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java b/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java index d1c40decf..40ad32c7a 100644 --- a/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java +++ b/cinema-adapter/src/main/java/com/cinema/adapter/controller/TicketController.java @@ -1,17 +1,13 @@ package com.cinema.adapter.controller; -import com.cinema.application.dto.MovieRequestDTO; -import com.cinema.application.dto.MovieResponseDTO; import com.cinema.application.dto.TicketRequestDTO; -import com.cinema.application.service.MovieService; import com.cinema.application.service.TicketService; +import com.cinema.common.response.ApiResponseDTO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/tickets") @@ -22,15 +18,9 @@ public class TicketController { * 예매 * */ @PostMapping - public ResponseEntity bookTickets(@Valid @RequestBody TicketRequestDTO ticketRequestDTO ) { - try { - ticketService.bookTickets(ticketRequestDTO); - return ResponseEntity.ok("예매가 성공적으로 완료되었습니다."); - } catch (Exception e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } + public ResponseEntity> bookTickets(@Valid @RequestBody TicketRequestDTO ticketRequestDTO) { + ticketService.bookTickets(ticketRequestDTO); + return ResponseEntity.ok(ApiResponseDTO.success(null, "예매 성공")); } - - } diff --git a/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java b/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java new file mode 100644 index 000000000..82922f959 --- /dev/null +++ b/cinema-adapter/src/main/java/com/cinema/adapter/interceptor/RateLimitingInterceptor.java @@ -0,0 +1,82 @@ +package com.cinema.adapter.interceptor; + +import com.google.common.util.concurrent.RateLimiter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RRateLimiter; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class RateLimitingInterceptor implements HandlerInterceptor { + +// private final RateLimiter rateLimiter; // Google Guava + private final RRateLimiter rateLimiter; // Redisson + + private final Map requestCounts = new ConcurrentHashMap<>(); + private final Map blockedIps = new ConcurrentHashMap<>(); + + private static final int MAX_REQUESTS = 50; // 1분 내 최대 50회 + private static final long BLOCK_TIME_MS = 60 * 60 * 1000; // 1시간 차단 + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String clientIp = request.getRemoteAddr(); + long currentTime = System.currentTimeMillis(); + + // 1. 조회 API 제한 - 차단된 IP인지 확인 + if (blockedIps.containsKey(clientIp)) { + long blockedTime = blockedIps.get(clientIp); + if (currentTime - blockedTime < BLOCK_TIME_MS) { + String unblockTime = Instant.ofEpochMilli(blockedTime + BLOCK_TIME_MS) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.getWriter().write("해당 IP는 1시간 동안 요청이 차단되었습니다. 차단 해제 시각: " + unblockTime); + return false; + } else { + blockedIps.remove(clientIp); + requestCounts.remove(clientIp); + } + } + + // 2. 조회 API 제한 - 1분 내 50회 + if (request.getRequestURI().startsWith("/api/v1/movies") && request.getMethod().equalsIgnoreCase("GET")) { + requestCounts.put(clientIp, requestCounts.getOrDefault(clientIp, 0) + 1); + if (requestCounts.get(clientIp) > MAX_REQUESTS) { + blockedIps.put(clientIp, currentTime); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.getWriter().write("너무 많은 요청으로 해당 IP는 요청이 차단되었습니다."); + return false; + } + + // 실시간 요청 속도 제한 적용 (RateLimiter) + // Google Guava + /*if (!rateLimiter.tryAcquire()) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("현재 요청량이 너무 많습니다. 잠시 후 다시 시도해주세요."); + return false; + }*/ + + // Redisson + if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("현재 요청량이 너무 많습니다. 잠시 후 다시 시도해주세요."); + return false; + } + } + + return true; + } +} diff --git a/cinema-adapter/src/test/java/com/cinema/PessimisticLockTest.java b/cinema-adapter/src/test/java/com/cinema/PessimisticLockTest.java deleted file mode 100644 index e1af1a4ca..000000000 --- a/cinema-adapter/src/test/java/com/cinema/PessimisticLockTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.cinema; - -import com.cinema.application.service.TicketService; -import com.cinema.infra.repository.TicketRepository; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -@Transactional -public class PessimisticLockTest { - - @Autowired - private TicketRepository ticketRepository; - - @Autowired - private TicketService ticketService; - - @Test - void testPessimisticLock() throws InterruptedException { - } -} diff --git a/cinema-adapter/src/test/java/com/cinema/adapter/controller/MovieControllerTest.java b/cinema-adapter/src/test/java/com/cinema/adapter/controller/MovieControllerTest.java new file mode 100644 index 000000000..1d96c2f60 --- /dev/null +++ b/cinema-adapter/src/test/java/com/cinema/adapter/controller/MovieControllerTest.java @@ -0,0 +1,66 @@ +package com.cinema.adapter.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class MovieControllerTest { + + @LocalServerPort + private int port; + + private String baseUrl; + private RestTemplate restTemplate; + + @BeforeEach + void setUp() { + baseUrl = "http://localhost:" + port + "/api/v1/movies"; + restTemplate = new RestTemplate(); + } + + @Test + void testRateLimitForMovies() throws InterruptedException { + int requestCount = 51; + int delayBetweenRequestsMs = 100; // 각 요청 사이의 대기 시간 (100ms) + + ExecutorService executorService = Executors.newFixedThreadPool(10); + int[] rateLimitExceededCount = {0}; // 429 응답 개수 카운트 + + for (int i = 0; i < requestCount; i++) { + executorService.submit(() -> { + try { + ResponseEntity response = restTemplate.getForEntity(baseUrl, String.class); + System.out.println("Response: " + response.getStatusCode()); + } catch (HttpClientErrorException.TooManyRequests e) { + System.out.println("429 Too Many Requests received."); + synchronized (rateLimitExceededCount) { + rateLimitExceededCount[0]++; + } + } + + try { + Thread.sleep(delayBetweenRequestsMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + executorService.shutdown(); + executorService.awaitTermination(1, TimeUnit.MINUTES); + + // 429 응답이 하나라도 나와야 테스트 통과 + assertThat(rateLimitExceededCount[0]).isGreaterThanOrEqualTo(1); + } +} diff --git a/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java b/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java new file mode 100644 index 000000000..27c9dd14b --- /dev/null +++ b/cinema-adapter/src/test/java/com/cinema/adapter/controller/TicketControllerTest.java @@ -0,0 +1,59 @@ +package com.cinema.adapter.controller; + +import com.cinema.application.dto.TicketRequestDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class TicketControllerTest { + + @LocalServerPort + private int port; + + private String baseUrl; + private RestTemplate restTemplate; + + @BeforeEach + void setUp() { + baseUrl = "http://localhost:" + port + "/api/v1/tickets"; + restTemplate = new RestTemplate(); + } + + @Test + void testRateLimitForTickets() { + Long userId = 1L; + Long screeningId = 3L; + + HttpHeaders headers = new HttpHeaders(); + + // 첫 번째 요청: 정상 예매 + TicketRequestDTO requestDTO = new TicketRequestDTO(screeningId, userId, List.of("E1")); + HttpEntity request = new HttpEntity<>(requestDTO, headers); + ResponseEntity firstResponse = restTemplate.exchange(baseUrl, HttpMethod.POST, request, String.class); + assertThat(firstResponse.getStatusCodeValue()).isEqualTo(200); + + // 두 번째 요청: 5분 제한 적용되어야 함 (429 반환 예상) + TicketRequestDTO requestDTO2 = new TicketRequestDTO(screeningId, userId, List.of("E2")); + HttpEntity request2 = new HttpEntity<>(requestDTO2, headers); + + try { + restTemplate.exchange(baseUrl, HttpMethod.POST, request2, String.class); + System.out.println("test failed!!!"); + } catch (HttpClientErrorException.TooManyRequests e) { + System.out.println("429 Too Many Requests received as expected."); + assertThat(e.getStatusCode().value()).isEqualTo(429); + } + } +} diff --git a/cinema-adapter/src/test/resources/application-test.yml b/cinema-adapter/src/test/resources/application-test.yml new file mode 100644 index 000000000..6c615c0bc --- /dev/null +++ b/cinema-adapter/src/test/resources/application-test.yml @@ -0,0 +1,43 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3307/cinema?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + username: cinema_user + password: cinema_password + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: create-drop + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + format_sql: true + show-sql: true + data: + redis: + host: localhost + # host: redis-container + port: 6379 + # password: cinema_password + serializer: jackson + cache: + type: redis + http: + encoding: + charset: UTF-8 + enabled: true + force: true + jackson: + serialization: + fail-on-empty-beans: false + +# 상영시간표별 최대 예매 가능 수 +max-count: + theater-bookable: 5 + +logging: + level: + org.springframework.transaction: DEBUG + org.hibernate.SQL: DEBUG diff --git a/cinema-application/build.gradle b/cinema-application/build.gradle index eb5ec611d..0d6c69126 100644 --- a/cinema-application/build.gradle +++ b/cinema-application/build.gradle @@ -29,4 +29,7 @@ dependencies { // Redisson implementation 'org.redisson:redisson-spring-boot-starter:3.21.0' + + // Google Guava RateLimiter + implementation 'com.google.guava:guava:32.0.0-jre' } \ No newline at end of file diff --git a/cinema-application/src/main/java/com/cinema/application/dto/MovieRequestDTO.java b/cinema-application/src/main/java/com/cinema/application/dto/MovieRequestDTO.java index dd4b19254..b8cb1abcf 100644 --- a/cinema-application/src/main/java/com/cinema/application/dto/MovieRequestDTO.java +++ b/cinema-application/src/main/java/com/cinema/application/dto/MovieRequestDTO.java @@ -2,10 +2,8 @@ import jakarta.validation.constraints.*; import lombok.Getter; -import lombok.Setter; @Getter -@Setter public class MovieRequestDTO { @Size(max=100, message="제목은 100자 이하로 입력해야 합니다.") @Pattern(regexp = "^[a-zA-Z0-9가-힣]*$", message = "특수문자는 허용되지 않습니다.") diff --git a/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java b/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java index 2fa1f3f74..53c3a1862 100644 --- a/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java +++ b/cinema-application/src/main/java/com/cinema/application/dto/TicketRequestDTO.java @@ -1,13 +1,13 @@ package com.cinema.application.dto; import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.Setter; import java.util.List; @Getter -@Setter +@AllArgsConstructor public class TicketRequestDTO { @NotNull private Long screeningId; diff --git a/cinema-application/src/main/java/com/cinema/application/service/MovieService.java b/cinema-application/src/main/java/com/cinema/application/service/MovieService.java index 60cedb575..9d7f9969e 100644 --- a/cinema-application/src/main/java/com/cinema/application/service/MovieService.java +++ b/cinema-application/src/main/java/com/cinema/application/service/MovieService.java @@ -2,14 +2,11 @@ import com.cinema.application.dto.MovieRequestDTO; import com.cinema.application.dto.MovieResponseDTO; -import com.cinema.application.dto.TicketRequestDTO; import com.cinema.common.enums.GenreCode; import com.cinema.common.enums.GradeCode; -import com.cinema.core.domain.Ticket; import com.cinema.infra.dto.MovieScreeningData; import com.cinema.infra.dto.ScreeningData; import com.cinema.infra.repository.MovieRepository; -import com.cinema.infra.repository.SeatRepository; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; @@ -23,12 +20,11 @@ @Service public class MovieService { private final MovieRepository movieRepository; - private final SeatRepository seatRepository; /** * 영화별 상영시간표 조회 * */ - // TODO : 캐시 전략 변경해볼 것! + // TODO : 캐시 전략 변경해볼 것! (전체 데이터 캐싱 후 필터링하는 방식?) @Cacheable( value = "movieScreenings", key = "(#title ?: '').concat('_').concat(#genreCd ?: '')", diff --git a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java index 035f78c2f..f20cbd0cf 100644 --- a/cinema-application/src/main/java/com/cinema/application/service/TicketService.java +++ b/cinema-application/src/main/java/com/cinema/application/service/TicketService.java @@ -2,27 +2,34 @@ import com.cinema.application.dto.TicketRequestDTO; import com.cinema.common.enums.SeatNameCode; -import com.cinema.core.annotation.DistributedLock; +import com.cinema.common.exception.TooManyRequestsException; +import com.cinema.common.response.ApiResponseDTO; import com.cinema.core.domain.Ticket; import com.cinema.core.domain.TicketSeat; import com.cinema.infra.lock.DistributedLockUtil; import com.cinema.infra.repository.SeatRepository; import com.cinema.infra.repository.TicketRepository; import com.cinema.infra.repository.TicketSeatRepository; -import jakarta.persistence.PessimisticLockException; -import jakarta.transaction.Transactional; +import com.cinema.infra.repository.UserRepository; +import com.google.common.util.concurrent.RateLimiter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; +import org.redisson.api.RRateLimiter; +import org.redisson.api.RateIntervalUnit; +import org.redisson.api.RateType; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Value; -import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Iterator; import java.util.List; -import java.util.concurrent.TimeUnit; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @Service @@ -32,136 +39,191 @@ public class TicketService { private final TicketRepository ticketRepository; private final TicketSeatRepository ticketSeatRepository; private final SeatRepository seatRepository; - private final RedissonClient redissonClient; + private final UserRepository userRepository; + private final DistributedLockUtil lockUtil; + // Google Guava + private final Map reservationRateLimiters = new ConcurrentHashMap<>(); + private static final double RESERVATION_RATE = 1.0 / 300; // 5분에 1회 제한 + + // Redisson + private final RedissonClient redissonClient; + private static final String RESERVATION_LIMIT_PREFIX = "rate_limit:reservation:"; + @Value("${max-count.theater-bookable}") private int maxTheaterBookableCnt; /** * 예매하기 * */ - // FIXME : AOP Distibuted Lock -// @DistributedLock(key = "lock:screening:#{#ticketRequestDTO.screeningId}") - - // FIXME : Optimistic Lock - @Retryable(value = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) - @Transactional + @Retryable(value = OptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) public void bookTickets(TicketRequestDTO ticketRequestDTO) { - // FIXME : 명령형 Distibuted Lock -// String lockKey = "lock:screening:" + ticketRequestDTO.getScreeningId(); -// RLock lock = redissonClient.getLock(lockKey); + Long userId = ticketRequestDTO.getUserId(); + Long screeningId = ticketRequestDTO.getScreeningId(); - // FIXME : 함수형 Distibuted Lock - String lockKey = "lock:screening:" + ticketRequestDTO.getScreeningId(); + if (userId == null || screeningId == null) { + throw new NoSuchElementException("유효하지 않은 요청 데이터입니다."); + } -// try { - // FIXME : 명령형 Distibuted Lock - // tyrLock(waitTime 락 대기 시간, leaseTime 락 유지 시간, 락 획득 실패 시) -// if (!lock.tryLock(5, 3, TimeUnit.SECONDS)) { -// throw new IllegalStateException("동일 좌석이 이미 예약 중입니다. 나중에 다시 시도해 주세요."); -// } - - // FIXME : 함수형 Distibuted Lock - lockUtil.executeWithLock(lockKey, 5, 3, () -> { - List seatNameEnums = ticketRequestDTO.getSeatNames().stream() - .map(SeatNameCode::fromString) - .collect(Collectors.toList()); + // RateLimit 적용 + // 예매 가능 여부 확인 + // 1) Google Guava + /* + String reservationKey = userId + "-" + screeningId; - if (seatNameEnums == null || seatNameEnums.isEmpty()) { - throw new NullPointerException("좌석 정보가 없습니다."); - } + if (reservationRateLimiters.containsKey(reservationKey) && + !reservationRateLimiters.get(reservationKey).tryAcquire()) { + throw new TooManyRequestsException("해당 상영 일정에 대해 5분 내에 다시 예매할 수 없습니다."); + }*/ - // 좌석 예약 가능 여부 확인 - if (!this.areSeatsBookable(ticketRequestDTO.getScreeningId(), seatNameEnums)) { - throw new IllegalStateException("선택한 좌석 중 예약이 불가능한 좌석이 있습니다."); - } + // 2) Redisson + String reservationKey = RESERVATION_LIMIT_PREFIX + userId + ":" + screeningId; + RRateLimiter rateLimiter = redissonClient.getRateLimiter(reservationKey); - // 좌석 연속 여부 확인 (동일한 라인인지) - if (!this.areSeatsConsecutive(seatNameEnums)) { - throw new IllegalStateException("좌석은 동일한 행이면서 연속적이어야 합니다."); - } + if (rateLimiter.isExists() && !rateLimiter.tryAcquire()) { + throw new TooManyRequestsException("해당 상영 일정에 대해 5분 내에 다시 예매할 수 없습니다."); + } - // 상영시간표별 최대 5개 좌석 예약 가능 - if (!this.isBookingExceed(ticketRequestDTO, seatNameEnums.size())) { - throw new IllegalStateException("상영시간표당 예매 가능 좌석 수를 초과하였습니다."); - } + List seatNameEnums = ticketRequestDTO.getSeatNames().stream() + .map(SeatNameCode::fromString) + .collect(Collectors.toList()); + + this.ticketsValidCheck(ticketRequestDTO, seatNameEnums); + + // lock을 획득한 요청에 대해서만 Transactional 적용하기 위해 분리 + String lockKey = "lock:screening:" + ticketRequestDTO.getScreeningId(); + boolean bookingSuccess = lockUtil.executeWithLock(lockKey, 5, 3, () -> + bookTicketsWithTransaction(ticketRequestDTO, seatNameEnums) + ); + + // 예매 성공 시에만 5분 제한 적용 + if (bookingSuccess) { + // 1) Google Guava + /*reservationRateLimiters.computeIfAbsent(reservationKey, key -> RateLimiter.create(RESERVATION_RATE)); + reservationRateLimiters.get(reservationKey).tryAcquire();*/ + + // 2) Redisson + rateLimiter.trySetRate(RateType.PER_CLIENT, 1, 5, RateIntervalUnit.MINUTES); // 5분에 1회 제한 + rateLimiter.tryAcquire(); + log.info("예매 성공 - {} 제한 적용 (5분)", reservationKey); + } else { + log.error("예매 실패 - {} 제한 적용되지 않음"); + } + } + /** + * 예매 정보 저장 + * */ + @Transactional + public boolean bookTicketsWithTransaction(TicketRequestDTO ticketRequestDTO, List seatNameEnums) { + try { // 예매 저장 - Ticket ticket = new Ticket(); - ticket.setUserId(ticketRequestDTO.getUserId()); - ticket.setScreeningId(ticketRequestDTO.getScreeningId()); + Ticket ticket = Ticket.builder() + .userId(ticketRequestDTO.getUserId()) + .screeningId(ticketRequestDTO.getScreeningId()) + .build(); Ticket savedTicket = ticketRepository.save(ticket); + ticketRepository.flush(); // 예매 좌석 저장 List ticketSeats = seatNameEnums.stream() .map(seat -> { Long seatId = seatRepository.findSeatIdByScreeningIdAndSeatNameCd( ticketRequestDTO.getScreeningId(), seat.name() - ).orElseThrow(() -> new IllegalArgumentException("좌석 정보를 찾을 수 없습니다: " + seat.name())); + ).orElseThrow(() -> new NoSuchElementException("좌석 정보를 찾을 수 없습니다. 상영시간표ID : " + ticketRequestDTO.getScreeningId() + ", 좌석명 : " + seat.name())); return new TicketSeat(savedTicket.getTicketId(), seatId); }) .collect(Collectors.toList()); ticketSeatRepository.saveAll(ticketSeats); + ticketSeatRepository.flush(); + return true; + } catch (Exception e) { + log.error("예매 정보 저장 실패: {}", e.getMessage()); + return false; + } + } - return null; - }); - - // FIXME : Pessimistic Lock -// } catch (PessimisticLockException e) { - - // FIXME : Optimistic Lock -// } catch (ObjectOptimisticLockingFailureException e) { -// log.error("좌석 예약 중 락 충돌 발생: screeningId={}, seatNameEnums={}", -// ticketRequestDTO.getScreeningId(), -// seatNameEnums); -// throw new IllegalStateException("동일 좌석이 이미 예약 중입니다. 나중에 다시 시도해 주세요."); -// } - - // FIXME : 명령형 Distibuted Lock -// } catch (InterruptedException e) { -// throw new RuntimeException("예매 처리 중 문제가 발생했습니다."); -// } finally { -// // 락 해제 -// if (lock.isHeldByCurrentThread()) { -// lock.unlock(); -// } -// } + /** + * 유효성 검증 + * */ + private void ticketsValidCheck(TicketRequestDTO ticketRequestDTO, List seatNameEnums) { + // 사용자 확인 + if (!this.isUserExists(ticketRequestDTO.getUserId())) { + throw new NoSuchElementException("사용자 정보가 없습니다. ID : " + ticketRequestDTO.getUserId()); + } + + // 좌석 예약 가능 여부 확인 + if (!this.areSeatsBookable(ticketRequestDTO.getScreeningId(), seatNameEnums)) { + throw new IllegalStateException("선택한 좌석 중 예약이 불가능한 좌석이 있습니다."); + } + + // 좌석 연속 여부 확인 (동일한 라인인지) + if (!this.areSeatsConsecutive(seatNameEnums)) { + throw new IllegalStateException("좌석은 동일한 행이면서 연속적이어야 합니다."); + } + + // 상영시간표별 최대 5개 좌석 예약 가능 + if (!this.isBookingExceed(ticketRequestDTO, seatNameEnums.size())) { + throw new IllegalStateException("상영시간표당 예매 가능 좌석 수를 초과하였습니다."); + } } /** - * 좌석 연속 여부 확인 (동일한 라인인지) + * 사용자 확인 + * */ + private boolean isUserExists(Long userId) { + return userRepository.existsById(userId); + } + + /** + * 좌석 연속 여부 확인 * */ private boolean areSeatsConsecutive(List seatNameEnums) { if (seatNameEnums == null || seatNameEnums.isEmpty()) { + throw new NullPointerException("좌석 정보가 없습니다."); + } + + Iterator seatIterator = getIntegerIterator(seatNameEnums); + + if (!seatIterator.hasNext()) { return false; } - // 좌석명 코드에서 첫 글자를 추출 - char firstRow = seatNameEnums.get(0).name().charAt(0); - List seatNumbers = seatNameEnums.stream() - .map(seat -> { - String seatName = seat.name(); - // 첫 번째 글자가 동일한지 확인 - if (seatName.charAt(0) != firstRow) { - throw new IllegalArgumentException("좌석은 동일한 행에 있어야 합니다: " + seatName); - } - // 두 번째 글자부터 숫자 추출 - return Integer.parseInt(seatName.substring(1)); - }) - .sorted() - .collect(Collectors.toList()); + int prev = seatIterator.next(); // 첫 번째 좌석 번호 - for (int i = 1; i < seatNumbers.size(); i++) { - if (seatNumbers.get(i) - seatNumbers.get(i - 1) != 1) { - return false; + while (seatIterator.hasNext()) { + int current = seatIterator.next(); + if (prev + 1 != current) { + throw new IllegalArgumentException("좌석은 연속적으로만 선택할 수 있습니다."); } + prev = current; } + return true; } + /** + * 좌석 동일행 확인 + * */ + private static Iterator getIntegerIterator(List seatNameEnums) { + String firstRow = seatNameEnums.get(0).name().substring(0, 1); + + Iterator seatIterator = seatNameEnums.stream() + .map(SeatNameCode::name) + .peek(seatName -> { + if (!seatName.startsWith(firstRow)) { + throw new IllegalArgumentException("좌석은 동일한 행에 있어야 합니다."); + } + }) + .map(seatName -> Integer.parseInt(seatName.substring(1))) + .sorted() + .iterator(); + return seatIterator; + } + /** * 좌석 예매 가능 여부 확인 * */ @@ -170,7 +232,7 @@ private boolean areSeatsBookable(Long screeningId, List seatNameEn for (SeatNameCode seat : seatNameEnums) { Long seatId = seatRepository.findSeatIdByScreeningIdAndSeatNameCd(screeningId, seat.name()) - .orElseThrow(() -> new IllegalArgumentException("좌석 정보를 찾을 수 없습니다: " + seat.name())); + .orElseThrow(() -> new NoSuchElementException("좌석 정보를 찾을 수 없습니다. 상영시간표ID : " + screeningId + ", 좌석명 : " + seat.name())); if (bookedSeats.contains(seatId)) { return false; diff --git a/cinema-common/build.gradle b/cinema-common/build.gradle index a23bf2069..b5a5d0a22 100644 --- a/cinema-common/build.gradle +++ b/cinema-common/build.gradle @@ -9,4 +9,5 @@ version = '0.0.1-SNAPSHOT' dependencies { implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' } \ No newline at end of file diff --git a/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java b/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java index ef6f0e734..cb8711ee4 100644 --- a/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java +++ b/cinema-common/src/main/java/com/cinema/common/exception/GlobalExceptionHandler.java @@ -1,24 +1,56 @@ -/* package com.cinema.common.exception; +import com.cinema.common.response.ApiResponseDTO; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; -import java.util.HashMap; -import java.util.Map; - +import java.util.NoSuchElementException; @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { - Map errors = new HashMap<>(); - ex.getBindingResult().getFieldErrors().forEach(error -> - errors.put(error.getField(), error.getDefaultMessage()) - ); - return ResponseEntity.badRequest().body(errors); + + @ExceptionHandler(NullPointerException.class) + public ResponseEntity> handleNullPointerException(NullPointerException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponseDTO.error(HttpStatus.BAD_REQUEST, 40001, "필수 데이터가 누락되었습니다. " + ex.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponseDTO.error(HttpStatus.BAD_REQUEST, 40002, "잘못된 요청: " + ex.getMessage())); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalStateException(IllegalStateException ex) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ApiResponseDTO.error(HttpStatus.CONFLICT, 409, "요청 처리 중 문제가 발생했습니다: " + ex.getMessage())); + } + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity> handleNoSuchElementException(NoSuchElementException ex) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponseDTO.error(HttpStatus.NOT_FOUND, 404, "요청한 데이터를 찾을 수 없습니다. " + ex.getMessage())); + } + + @ExceptionHandler(TooManyRequestsException.class) + public ResponseEntity> handleTooManyRequestsException(TooManyRequestsException ex) { + return ResponseEntity + .status(HttpStatus.TOO_MANY_REQUESTS) + .body(ApiResponseDTO.error(HttpStatus.TOO_MANY_REQUESTS, 429, "요청량을 초과했습니다.")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneralException(Exception ex) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponseDTO.error(HttpStatus.INTERNAL_SERVER_ERROR, 500, "서버 내부 오류 발생: " + ex.getMessage())); } -} -*/ +} \ No newline at end of file diff --git a/cinema-common/src/main/java/com/cinema/common/exception/TooManyRequestsException.java b/cinema-common/src/main/java/com/cinema/common/exception/TooManyRequestsException.java new file mode 100644 index 000000000..c16350f03 --- /dev/null +++ b/cinema-common/src/main/java/com/cinema/common/exception/TooManyRequestsException.java @@ -0,0 +1,13 @@ +package com.cinema.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class TooManyRequestsException extends RuntimeException { + private final HttpStatus status = HttpStatus.TOO_MANY_REQUESTS; + + public TooManyRequestsException(String message) { + super(message); + } +} diff --git a/cinema-common/src/main/java/com/cinema/common/response/ApiResponseDTO.java b/cinema-common/src/main/java/com/cinema/common/response/ApiResponseDTO.java new file mode 100644 index 000000000..2c0944a0e --- /dev/null +++ b/cinema-common/src/main/java/com/cinema/common/response/ApiResponseDTO.java @@ -0,0 +1,27 @@ +package com.cinema.common.response; + +import org.springframework.http.HttpStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 공통 API Response + * */ + +@Getter +@AllArgsConstructor +public class ApiResponseDTO { + private HttpStatus status; + private int customCode; + private String message; + private T data; + + public static ApiResponseDTO success(T data, String message) { + return new ApiResponseDTO<>(HttpStatus.OK, 200, message, data); + } + + public static ApiResponseDTO error(HttpStatus status, int customCode, String message) { + return new ApiResponseDTO<>(status, customCode, message, null); + } +} + diff --git a/cinema-core/src/main/java/com/cinema/core/domain/Screening.java b/cinema-core/src/main/java/com/cinema/core/domain/Screening.java index 2c3529795..33efa69ad 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/Screening.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/Screening.java @@ -2,7 +2,6 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; import java.time.LocalDate; @@ -10,7 +9,6 @@ @Entity @Getter -@Setter @Table(name = "screening") public class Screening extends BaseEntity implements Serializable { private static final long serialVersionUID = -1116415509867579126L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/Seat.java b/cinema-core/src/main/java/com/cinema/core/domain/Seat.java index 0866bd89e..d25b7ba2e 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/Seat.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/Seat.java @@ -2,13 +2,11 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; @Entity @Getter -@Setter @Table(name = "seat") public class Seat extends BaseEntity implements Serializable { private static final long serialVersionUID = -8004503224579450742L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/Theater.java b/cinema-core/src/main/java/com/cinema/core/domain/Theater.java index d87fb220f..5bd640894 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/Theater.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/Theater.java @@ -2,13 +2,11 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; @Entity @Getter -@Setter @Table(name = "theater") public class Theater extends BaseEntity implements Serializable { private static final long serialVersionUID = 8678295794324077135L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/Ticket.java b/cinema-core/src/main/java/com/cinema/core/domain/Ticket.java index 6a32230a1..c4cd90f4b 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/Ticket.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/Ticket.java @@ -1,15 +1,16 @@ package com.cinema.core.domain; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; import java.io.Serializable; @Entity -@Getter -@Setter @Table(name = "ticket") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor public class Ticket extends BaseEntity implements Serializable { private static final long serialVersionUID = -5368501456583801672L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/TicketSeat.java b/cinema-core/src/main/java/com/cinema/core/domain/TicketSeat.java index 5ed5f258e..a3b83f2a6 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/TicketSeat.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/TicketSeat.java @@ -2,13 +2,11 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; @Entity @Getter -@Setter @Table(name = "ticket_seat") public class TicketSeat extends BaseEntity implements Serializable { private static final long serialVersionUID = -9104093970044754227L; diff --git a/cinema-core/src/main/java/com/cinema/core/domain/User.java b/cinema-core/src/main/java/com/cinema/core/domain/User.java index fe5834aa7..2eeae6ee9 100644 --- a/cinema-core/src/main/java/com/cinema/core/domain/User.java +++ b/cinema-core/src/main/java/com/cinema/core/domain/User.java @@ -2,14 +2,12 @@ import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import java.io.Serializable; import java.time.LocalDate; @Entity @Getter -@Setter @Table(name = "user") public class User extends BaseEntity implements Serializable { private static final long serialVersionUID = 3213156872610256916L; diff --git a/cinema-infra/src/main/java/com/cinema/infra/dto/MovieScreeningData.java b/cinema-infra/src/main/java/com/cinema/infra/dto/MovieScreeningData.java index 3b346dce9..d525e1781 100644 --- a/cinema-infra/src/main/java/com/cinema/infra/dto/MovieScreeningData.java +++ b/cinema-infra/src/main/java/com/cinema/infra/dto/MovieScreeningData.java @@ -6,7 +6,6 @@ import java.time.LocalTime; @Getter -@Setter @AllArgsConstructor @NoArgsConstructor public class MovieScreeningData { diff --git a/cinema-infra/src/main/java/com/cinema/infra/dto/ScreeningData.java b/cinema-infra/src/main/java/com/cinema/infra/dto/ScreeningData.java index 8aa08f8a5..56db8a695 100644 --- a/cinema-infra/src/main/java/com/cinema/infra/dto/ScreeningData.java +++ b/cinema-infra/src/main/java/com/cinema/infra/dto/ScreeningData.java @@ -3,12 +3,10 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import java.time.LocalTime; @Getter -@Setter @AllArgsConstructor @NoArgsConstructor public class ScreeningData { diff --git a/cinema-infra/src/main/java/com/cinema/infra/repository/UserRepository.java b/cinema-infra/src/main/java/com/cinema/infra/repository/UserRepository.java new file mode 100644 index 000000000..0369e29a4 --- /dev/null +++ b/cinema-infra/src/main/java/com/cinema/infra/repository/UserRepository.java @@ -0,0 +1,9 @@ +package com.cinema.infra.repository; + +import com.cinema.core.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { +} diff --git a/compose.yaml b/compose.yaml index 876ddc452..c9f89296c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,11 +12,11 @@ services: TZ: Asia/Seoul volumes: - db_data:/var/lib/mysql -# - ./ddl_no_index.sql:/docker-entrypoint-initdb.d/ddl_no_index.sql:ro - - ./ddl.sql:/docker-entrypoint-initdb.d/ddl.sql:ro - - ./dummy_data.sql:/docker-entrypoint-initdb.d/dummy_data.sql:ro - - ./dummy_data_movie.sql:/docker-entrypoint-initdb.d/dummy_data_movie.sql:ro - - ./dummy_data_screening.sql:/docker-entrypoint-initdb.d/dummy_data_screening.sql:ro +# - ./docs/data/ddl_no_index.sql:/docker-entrypoint-initdb.d/ddl_no_index.sql:ro + - ./docs/data/ddl.sql:/docker-entrypoint-initdb.d/ddl.sql:ro + - ./docs/data/dummy_data.sql:/docker-entrypoint-initdb.d/dummy_data.sql:ro + - ./docs/data/dummy_data_movie.sql:/docker-entrypoint-initdb.d/dummy_data_movie.sql:ro + - ./docs/data/dummy_data_screening.sql:/docker-entrypoint-initdb.d/dummy_data_screening.sql:ro command: [ "--character-set-server=utf8mb4", # MySQL 서버 기본 문자 집합을 utf8mb4로 설정 "--collation-server=utf8mb4_general_ci", # MySQL 서버 기본 정렬 규칙을 utf8mb4_general_ci로 설정 diff --git a/ddl.sql b/docs/data/ddl.sql similarity index 100% rename from ddl.sql rename to docs/data/ddl.sql diff --git a/ddl_no_index.sql b/docs/data/ddl_no_index.sql similarity index 100% rename from ddl_no_index.sql rename to docs/data/ddl_no_index.sql diff --git a/dummy_data.sql b/docs/data/dummy_data.sql similarity index 100% rename from dummy_data.sql rename to docs/data/dummy_data.sql diff --git a/dummy_data_movie.sql b/docs/data/dummy_data_movie.sql similarity index 100% rename from dummy_data_movie.sql rename to docs/data/dummy_data_movie.sql diff --git a/dummy_data_screening.sql b/docs/data/dummy_data_screening.sql similarity index 100% rename from dummy_data_screening.sql rename to docs/data/dummy_data_screening.sql