Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.7'
id "jacoco"
}

group = 'com.movie'
Expand Down Expand Up @@ -29,6 +30,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.43.0'
implementation 'com.google.guava:guava:33.4.0-jre'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand All @@ -39,4 +43,85 @@ dependencies {

tasks.named('test') {
useJUnitPlatform()
finalizedBy 'jacocoTestReport'
}

// jacoco 정보
jacoco {
toolVersion = "0.8.11"
layout.buildDirectory.dir("reports/jacoco")
}

// jacoco Report 생성
jacocoTestReport {
dependsOn test // test 종속성 추가

reports {
xml.required = true
csv.required = false
html.required = true
}

def QDomainList = []
for (qPattern in '**/QA'..'**/QZ') { // QClass 대응
QDomainList.add(qPattern + '*')
}

afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/dto/**',
'**/event/**',
'**/*InitData*',
'**/*Application*',
'**/exception/**',
'**/service/alarm/**',
'**/aop/**',
'**/config/**',
'**/MemberRole*'
] + QDomainList)
}))
}

finalizedBy 'jacocoTestCoverageVerification' // jacocoTestReport 태스크가 끝난 후 실행
}

// jacoco Test 유효성 확인
jacocoTestCoverageVerification {
def QDomainList = []
for (qPattern in '*.QA'..'*.QZ') { // QClass 대응
QDomainList.add(qPattern + '*')
}

violationRules {
rule {
enabled = true // 규칙 활성화 여부
element = 'CLASS' // 커버리지를 체크할 단위 설정

// 코드 커버리지를 측정할 때 사용되는 지표
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.30
}

limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.30
}

excludes = [
'**.dto.**',
'**.event.**',
'**.*InitData*',
'**.*Application*',
'**.exception.**',
'**.service.alarm.**',
'**.aop.**',
'**.config.**',
'**.MemberRole*'
] + QDomainList
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package com.movie.app.controller;
import java.util.List;

import com.google.common.util.concurrent.RateLimiter;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import com.movie.app.domain.Movie;
import com.movie.app.domain.MovieRepository;
import com.movie.app.domain.MovieRequestDto;
import com.movie.app.domain.TicketingRequestDto;
import com.movie.app.service.MovieService;

import jakarta.annotation.PostConstruct;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;

Expand All @@ -15,27 +21,46 @@
@RestController
public class MovieRestController {

private final MovieRepository movieRepository;
private final MovieService movieService;
private static RateLimiter moviesRateLimiter;
private static RateLimiter ticketingLimiter;

@PostConstruct
public void init() {
moviesRateLimiter = RateLimiter.create(0.8);//50permits/60sec = 0.8permits/1sec
ticketingLimiter = RateLimiter.create(0.003);//1permits/5min = 0.003permits/1sec
}

@GetMapping("/api/movies")
public List<Movie> getMovies(
public ResponseEntity<List<Movie>> getMovies(
@Size(max = 100, message = "title length should not exceed 100 characters")
@RequestParam(required=false) String title,
@RequestParam(required=false) String genre) {

if(!moviesRateLimiter.tryAcquire()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}

if(genre != null) {
return movieRepository.findByGenre(genre);
return ResponseEntity.ok(movieService.getMoviesByGenre(genre));
} else if (title != null) {
return movieRepository.findByTitle(title);
return ResponseEntity.ok(movieService.getMoviesByTitle(title));
} else {
return movieRepository.findAll();
return ResponseEntity.ok(movieService.getMoviesAll());
}
}

@PostMapping("/api/movies")
public Movie postMovies(@RequestBody MovieRequestDto requestDto) {
Movie movie = new Movie(requestDto);
movieRepository.save(movie);
return movie;
return movieService.postMovie(requestDto);
}

@PutMapping("/api/ticketing/{id}")
public ResponseEntity<Movie> ticketingMovie(@PathVariable Long id, @RequestBody TicketingRequestDto requestDto) {
if(!ticketingLimiter.tryAcquire()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}

return ResponseEntity.ok(movieService.ticketing(id ,requestDto));
}
}
25 changes: 23 additions & 2 deletions app/src/main/java/com/movie/app/domain/Movie.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.movie.app.domain;

import java.time.LocalTime;
import java.util.List;

import jakarta.persistence.Column;
Expand All @@ -14,6 +15,9 @@
@NoArgsConstructor
@Entity(name="Movie")
public class Movie extends Timestamped{

private static final int MAX_SEATS = 25;

@GeneratedValue(strategy = GenerationType.AUTO)
@Id
private Long id;
Expand All @@ -40,7 +44,14 @@ public class Movie extends Timestamped{
private String theater;

@Column
private List<String> screeningSchedule;
private LocalTime screenStartTime;

@Column
private LocalTime screenEndTime;

@Column
private Boolean[] seats = new Boolean[MAX_SEATS];
Comment on lines +52 to +53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 이 부분 테스트 해보셨을까요?
boolean 배열이 디비에 저장되기 위해선 적절한 변환 과정이 선언되어야 할듯 합니다.



public Movie(MovieRequestDto requestDto) {
this.title = requestDto.getTitle();
Expand All @@ -50,6 +61,16 @@ public Movie(MovieRequestDto requestDto) {
this.runningTime = requestDto.getRunningTime();
this.genre = requestDto.getGenre();
this.theater = requestDto.getTheater();
this.screeningSchedule = requestDto.getScreeningSchedule();
}

public void updateSeats(List<Integer> wantedSeats) {
if(this.seats == null) {
this.seats = new Boolean[MAX_SEATS];
}

for (int i=0; i<wantedSeats.size(); i++) {
int val= wantedSeats.get(i);
this.seats[val]=true;
}
}
}
10 changes: 10 additions & 0 deletions app/src/main/java/com/movie/app/domain/TicketingRequestDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.movie.app.domain;

import java.util.List;

import lombok.Getter;

@Getter
public class TicketingRequestDto {
private List<Integer> seats;
}
9 changes: 9 additions & 0 deletions app/src/main/java/com/movie/app/domain/Timestamped.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;

import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
Expand All @@ -17,9 +22,13 @@
@EntityListeners(AuditingEntityListener.class)
public class Timestamped {

@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@CreatedDate
private LocalDateTime createdAt;

@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@LastModifiedDate
private LocalDateTime modifiedAt;

Expand Down
43 changes: 41 additions & 2 deletions app/src/main/java/com/movie/app/service/MovieService.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package com.movie.app.service;

import java.util.List;
import java.util.concurrent.TimeUnit;

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.movie.app.domain.Movie;
import com.movie.app.domain.MovieRepository;
import com.movie.app.domain.MovieRequestDto;
import com.movie.app.domain.TicketingRequestDto;

import lombok.RequiredArgsConstructor;

Expand All @@ -15,6 +21,7 @@
public class MovieService {

private final MovieRepository movieRepository;
private final RedissonClient redissonClient;

@Cacheable(value = "Movies", key = "#title", cacheManager = "contentCacheManager")
public List<Movie> getMoviesByTitle(String title) {
Expand All @@ -26,9 +33,41 @@ public List<Movie> getMoviesByGenre(String genre) {
return movieRepository.findByGenre(genre);
}

@Cacheable(value = "Movies", key = "all", cacheManager = "contentCacheManager")
public List<Movie> getMoviesByGenre() {
@Cacheable(value = "Movies", key = "'all'", cacheManager = "contentCacheManager")
public List<Movie> getMoviesAll() {
return movieRepository.findAll();
}

public Movie postMovie(MovieRequestDto requestDto) {
Movie movie = new Movie(requestDto);
movieRepository.save(movie);
return movie;
}

@Transactional
public Movie ticketing(Long id, TicketingRequestDto requestDto) {
Movie movie = movieRepository.findById(id).orElseThrow(
() -> new NullPointerException("There is no id at DB.")
);

if(movie==null) {
return movie;
}
Comment on lines +49 to +55

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Movie movie = movieRepository.findById(id).orElseThrow(
() -> new NullPointerException("There is no id at DB.")
);
if(movie==null) {
return movie;
}
Movie movie = movieRepository.findById(id).orElseThrow(
() -> new NullPointerException("There is no id at DB.")
);

movie에 대해 orElseThrow를 하셨다면 movie가 null일 수는 없겠네요!
if 조건문은 없어도 되지 않을까요?ㅎㅎ


RLock lock = redissonClient.getLock(id.toString());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

영화 전체에 대한 잠금이 걸려있네요!
영화 - 상영관 - 좌석 (5x5)
좌석은 상영관 별로 25개씩 있고, 상영관은 여러개 존재할 수 있어요.
잠금을 여러 개로 나눈다면 사용자가 덜 기다릴 수도 있지 않을까요?

try {
boolean acquireLock = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!acquireLock) {
System.out.println("Lock get fail");
return movie;
}
Comment on lines +60 to +63

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예약이 실패됐다 라는 내용이 사용자에게 전달되려면 어떻게 해야 할까요?

movie.updateSeats(requestDto.getSeats());
} catch (InterruptedException e) {
} finally {
lock.unlock();
}

return movie;
}

}
9 changes: 4 additions & 5 deletions app/src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
spring.datasource.url=jdbc:mysql://mysql:3306/redis1st
spring.datasource.username=ID
spring.datasource.password=PASSWORD
spring.datasource.url=jdbc:mysql://localhost:3306/redis1st
spring.datasource.username=redis1st
spring.datasource.password=password
spring.application.name=app

spring.jpa.hibernate.ddl-auto= update
spring.jpa.properties.hibernate.dialect= org.hibernate.dialect.MySQLDialect
spring.jpa.defer-datasource-initialization=true

spring.sql.init.mode=always

spring.data.redis.host=redis
spring.data.redis.host=localhost
spring.data.redis.port=6379

spring.docker.compose.enabled=true
1 change: 1 addition & 0 deletions app/src/test/java/com/movie/app/AppApplicationTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class AppApplicationTests {

@Test
void contextLoads() {
System.out.println("Hello Test!!!!!!!!!!");
}

}