Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e407e9c
refactor: 영화 검색 조건을 DTO로 캡슐화해 확장 가능하도록 개선
zhdiddl Jan 21, 2025
d34a68b
refactor: 요청 값에 대한 validation 조건 개선
zhdiddl Jan 21, 2025
82ce364
feat: 인덱스 생성 쿼리를 DDL에 추가
zhdiddl Jan 23, 2025
b6c91f5
refactor: 동적 정렬 로직에 순서 유지 및 필드 제한 적용
zhdiddl Jan 23, 2025
2e43f50
chore: 복합 인덱스 적용 후 성능 테스트 결과 README 반영
zhdiddl Jan 23, 2025
7afcc1d
chore: 주석 및 설정 수정으로 가독성 개선
zhdiddl Jan 23, 2025
df23690
chore: README 수정
zhdiddl Jan 24, 2025
c441a56
feat: 영화 예약 API에 필요한 엔티티 추가
zhdiddl Jan 24, 2025
56b1505
refactor: 좌석 번호 값 객체 리팩토링
zhdiddl Jan 24, 2025
78ec165
feat: 예약 생성 시 검증에 필요한 Validation 추가
zhdiddl Jan 25, 2025
2e50fc0
feat: 저장소 포트 및 어댑터 추가
zhdiddl Jan 25, 2025
ad7edce
feat: 예약 생성 요청 및 응답 DTO 추가
zhdiddl Jan 25, 2025
54be6e6
refactor: 예약 생성 Validation 로직 개선
zhdiddl Jan 25, 2025
660f36b
refactor: Validation 포트 및 어댑터 추가로 서비스 간 참고 제거
zhdiddl Jan 25, 2025
4f7ef63
feat: 예약 생성 서비스 포트 및 구현체 추가
zhdiddl Jan 25, 2025
51f364c
fix: 예약 리포지토리 인터페이스 및 어댑터 수정
zhdiddl Jan 25, 2025
90f9eec
style: 코드 포맷팅 수정
zhdiddl Jan 25, 2025
7991a01
feat: 엔티티 추가에 따른 DDL 업데이트
zhdiddl Jan 25, 2025
3d95f66
refactor: 클라이언트 요청 값을 컨트롤러에서 검증하도록 책임 분리
zhdiddl Jan 25, 2025
b1d4dfe
fix: CustomException 메시지 처리 로직 수정
zhdiddl Jan 25, 2025
9556ab8
test: 회원 더미 데이터 생성 및 테스트 파일 추가
zhdiddl Jan 25, 2025
8f803bb
feat: 예약 완료 시 메시지 발송 기능 추가
zhdiddl Jan 25, 2025
8019bcd
refactor: 불필요한 필드 및 클래스 제거
zhdiddl Jan 25, 2025
b36f91c
test: 동시성 테스트 작성 및 관련 메서드 추가
zhdiddl Jan 25, 2025
7b3029f
refactor: 불필요한 코드 제거 및 가독성 개선
zhdiddl Jan 26, 2025
db53d5e
feat: 예약 서비스에 Pessimistic Lock 적용으로 동시성 해결
zhdiddl Jan 26, 2025
106e92b
refactor: 예약 생성 메서드 책임을 세부 메서드로 분리
zhdiddl Jan 26, 2025
ee48288
refactor: 데이터베이스 구조 리팩토링
zhdiddl Jan 27, 2025
93c14b7
refactor: 변경된 구조 기반으로 리포지토리 및 DTO 수정
zhdiddl Jan 27, 2025
5351541
refactor: 변경된 구조 기반으로 DDL 파일 수정
zhdiddl Jan 27, 2025
b9672af
feat: 예약 서비스에 Optimistic Lock 적용으로 동시성 해결
zhdiddl Jan 27, 2025
5a29de5
test: 동시성 테스트 작성 및 메서드명 수정
zhdiddl Jan 27, 2025
b77c6fb
feat: AOP 기반 분산 락 적용하여 동시성 제어
zhdiddl Jan 27, 2025
3382fb3
feat: 함수형 분산 락 적용하여 동시성 제어
zhdiddl Jan 27, 2025
cb2d0f2
fix: 트랜잭션 처리 및 분산 락 동작 개선
zhdiddl Jan 30, 2025
66b4601
refactor: 코드 가독성 개선 및 불필요한 메서드 제거
zhdiddl Jan 30, 2025
f82330d
feat: 예외 핸들러 및 예외 코드 추가
zhdiddl Jan 30, 2025
37b606f
refactor: 분산 락 예외 처리 및 해제 로직 개선
zhdiddl Jan 30, 2025
f35814a
refactor: 불필요한 메서드 호출 제거
zhdiddl Jan 30, 2025
e4e03cf
chore: Redis 타임아웃 및 재시도 설정 추가
zhdiddl Jan 30, 2025
be09082
chore: 빌드 설정 및 주석 수정
zhdiddl Jan 31, 2025
06aff06
chore: Redis 설정 추가
zhdiddl Feb 1, 2025
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
216 changes: 135 additions & 81 deletions README.md

Large diffs are not rendered by default.

Binary file modified doc/img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified doc/img_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified doc/img_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/img_4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/img_aop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed img.png
Binary file not shown.
Binary file removed img_1.png
Binary file not shown.
3 changes: 3 additions & 0 deletions movie-application/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation ("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")

// Redis AOP 기반 분산 락 구현시 애노테이션 사용을 위해 추가
implementation 'org.redisson:redisson-spring-boot-starter:3.22.0'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.application.dto.request;

import com.example.domain.model.valueObject.Genre;

public record MovieSearchCriteria(
String title,
Genre genre
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.application.dto.request;

import java.util.List;

public record ReservationRequestDto(
Long screeningId,
Long memberId,
List<Long> seatIds
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.example.application.dto.response;

import com.example.domain.model.entity.Reservation;
import java.time.LocalTime;
import java.util.List;

public record ReservationResponseDto(
String memberName,
String movieTitle,
String theaterName,
LocalTime screeningStartTime,
List<String> reservedSeats
) {
public static ReservationResponseDto fromEntity(Reservation reservation) {
return new ReservationResponseDto(
reservation.getMember().getName(),
reservation.getScreening().getMovie().getTitle(),
reservation.getScreening().getTheater().getName(),
reservation.getScreening().getStartTime(),
reservation.getReservedSeats().stream()
.map(rs -> rs.getScreeningSeat().getSeat().getSeatNumber().toString())
.toList()
);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class GlobalExceptionHandler {
public ResponseEntity<ErrorResponse> handleCustomException(CustomException ex) {
ErrorResponse errorResponse = new ErrorResponse(
ex.getErrorCode().name(),
ex.getErrorCode().getMessage(),
ex.getCustomMessage(),
ex.getErrorCode().getStatusCode()
);
return ResponseEntity.status(ex.getErrorCode().getStatusCode()).body(errorResponse);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.application.lock;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
long waitTime() default 2_000; // 적당한 대기 시간을 고려해 잠금 시간의 2배 정도로 설정
long leaseTime() default 1_000; // 지연을 고려해 트랜잭션 평균 실행 시간의 2배 정도로 설정
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.example.application.lock;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {

private final RedissonClient redissonClient;

@Around("@annotation(distributedLock)")
public Object applyLock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
String lockKey = distributedLock.key();
long waitTime = distributedLock.waitTime();
long leaseTime = distributedLock.leaseTime();

RLock lock = redissonClient.getLock(lockKey);

boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
if (!acquired) {
log.warn("[Distributed Lock] 락 획득 실패: {}", lockKey);
throw new RuntimeException("Seat is already reserved by another transaction.");
}

try {
log.info("[Distributed Lock] 락 획득 성공: {}", lockKey);
return joinPoint.proceed();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("[Distributed Lock] 락 해제 완료: {}", lockKey);
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.example.application.port.in;

public interface MessageServicePort {
void send(String message);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.example.application.port.in;

import com.example.application.dto.request.MovieRequestDto;
import com.example.application.dto.request.MovieSearchCriteria;
import com.example.application.dto.request.ScreeningRequestDto;
import com.example.application.dto.response.MovieResponseDto;
import com.example.domain.model.valueObject.Genre;
import java.util.List;

public interface MovieServicePort {
List<MovieResponseDto> findMovies(String title, Genre genre);
List<MovieResponseDto> findMovies(MovieSearchCriteria movieSearchCriteria);
void createMovie(MovieRequestDto movieRequestDto);
void addScreeningToMovie(Long movieId, ScreeningRequestDto screeningRequestDto);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.application.port.in;

import com.example.application.dto.request.ReservationRequestDto;
import com.example.application.dto.response.ReservationResponseDto;

public interface ReservationServicePort {
ReservationResponseDto create(ReservationRequestDto reservationRequestDto);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.application.port.out;

import com.example.domain.model.entity.Member;
import java.util.Optional;

public interface MemberRepositoryPort {
Optional<Member> findById(Long id);
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
package com.example.application.port.out;

import com.example.application.dto.request.MovieSearchCriteria;
import com.example.domain.model.entity.Movie;
import com.example.domain.model.valueObject.Genre;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Sort;

/**
* Represents a port for accessing movie data from a persistence layer.
* This interface provides an abstraction that allows the application layer
* to interact with various repository implementations
* (e.g., databases, external APIs).
*/
public interface MovieRepositoryPort {
List<Movie> findBy(String title, Genre genre, Sort sort);
List<Movie> findBy(MovieSearchCriteria movieSearchCriteria, Sort sort);
void save(Movie movie);
Optional<Movie> findById(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.application.port.out;

import com.example.domain.model.entity.Member;
import com.example.domain.model.entity.Reservation;
import com.example.domain.model.entity.Screening;
import java.util.Optional;

public interface ReservationRepositoryPort {
void save(Reservation reservation);
Optional<Reservation> findById(Long id);
void deleteAll();
int countByScreeningAndMember(Screening screening, Member member);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.application.port.out;

import com.example.domain.model.entity.ScreeningSeat;
import com.example.domain.model.entity.Seat;
import java.util.List;

public interface ReservationValidationPort {
void validateMaxSeatsPerScreening(int existingReservations, int newSeatCount);
void validateSeatsAreConsecutive(List<Seat> seats);
void validateSeatsExist(List<Long> requestedSeatIds, List<ScreeningSeat> foundSeats);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.application.port.out;

import com.example.domain.model.entity.ReservedSeat;
import java.util.List;

public interface ReservedSeatRepositoryPort {
void saveAll(List<ReservedSeat> reservedSeats);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package com.example.application.port.out;

import com.example.domain.model.entity.Screening;
import java.util.List;
import java.util.Optional;

/**
* Represents a port for accessing movie data from a persistence layer.
* This interface provides an abstraction that allows the application layer
* to interact with various repository implementations
* (e.g., databases, external APIs).
*/
public interface ScreeningRepositoryPort {
void save(Screening screening);
Optional<Screening> findById(Long id);
List<Screening> findAll();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.application.port.out;

import com.example.domain.model.entity.Screening;
import com.example.domain.model.entity.ScreeningSeat;
import java.util.List;

public interface ScreeningSeatRepositoryPort {
List<ScreeningSeat> findByScreeningAndSeatIds(Screening screening, List<Long> seatIds);
void save(ScreeningSeat screeningSeat);
long count();
void resetAllReservations();
long countReservedSeats(Long screeningId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.application.port.out;

import com.example.domain.model.entity.Seat;
import java.util.List;

public interface SeatRepositoryPort {
List<Seat> findAll();
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@
import com.example.domain.model.entity.Theater;
import java.util.Optional;

/**
* Represents a port for accessing movie data from a persistence layer.
* This interface provides an abstraction that allows the application layer
* to interact with various repository implementations
* (e.g., databases, external APIs).
*/
public interface TheaterRepositoryPort {
Optional<Theater> findById(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.example.application.service;

import com.example.application.port.in.MessageServicePort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class MessageService implements MessageServicePort {

@Override
public void send(String message) {
try {
log.info("[MessageService] 전송 시작: {}", message);
Thread.sleep(500); // 메시지 전송에 걸리는 시간 가정
log.info("[MessageService] 전송 완료: {}", message);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 현재 스레드가 인터럽트 상태임을 유지하도록 설정
log.error("[MessageService] 전송 실패!", e);
}
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.application.service;

import com.example.application.dto.request.MovieRequestDto;
import com.example.application.dto.request.MovieSearchCriteria;
import com.example.application.dto.request.ScreeningRequestDto;
import com.example.application.dto.response.MovieResponseDto;
import com.example.application.port.in.MovieServicePort;
Expand All @@ -12,7 +13,6 @@
import com.example.domain.model.entity.Movie;
import com.example.domain.model.entity.Screening;
import com.example.domain.model.entity.Theater;
import com.example.domain.model.valueObject.Genre;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
Expand All @@ -27,12 +27,13 @@ public class MovieService implements MovieServicePort {
private final ScreeningRepositoryPort screeningRepositoryPort;
private final TheaterRepositoryPort theaterRepositoryPort;

@Cacheable(value = "movies", key = "#title + '-' + #genre")
@Cacheable(value = "movies", key = "#movieSearchCriteria.title + '-' + #movieSearchCriteria.genre")
@Override
public List<MovieResponseDto> findMovies(String title, Genre genre) {
public List<MovieResponseDto> findMovies(MovieSearchCriteria movieSearchCriteria) {
Sort sort = Sort.by("releaseDate").descending();

return movieRepositoryPort.findBy(title, genre, sort).stream()
return movieRepositoryPort.findBy(movieSearchCriteria, sort
).stream()
.map(MovieResponseDto::fromEntity)
.toList();
}
Expand Down
Loading