Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Setting: 배포 환경을 세팅한다. #77

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
feec12c
feat: 낙관적 락을 사용하여 좌석 선택 기능 구현
seminchoi Aug 15, 2024
9d4a03c
refactor: 티켓 예매 관련 인터페이스 수정
seminchoi Aug 15, 2024
246af13
feat: 좌석 상태를 조회할 때 전략을 선택할 수 있는 전략 패턴 인터페이스 구현
seminchoi Aug 15, 2024
6adf6f1
feat: 좌석 상태를 조회할 때 낙관락, 비관락을 사용할 수 있는 전략 패턴 구현
seminchoi Aug 15, 2024
5c72253
fix: ErrorCode 컴파일 오류 수정
seminchoi Aug 15, 2024
20a320d
feat: 티켓 예매시 데이터 정합성이 유지되도록 DB Locking 적용
seminchoi Aug 15, 2024
57c8720
refactor: DTO final 필드 설정
seminchoi Aug 15, 2024
d7ef2c3
feat: 티켓 예매 테스트를 위한 sql 파일 생성
seminchoi Aug 15, 2024
8b00e9f
test: 티켓 예매 시 좌석 선택할 때 동시성 테스트 코드 구현
seminchoi Aug 15, 2024
e8ad160
test: 티켓 예매 시 결제 시도할 때 동시성 테스트 코드 구현
seminchoi Aug 15, 2024
b337f71
refactor: 코드 포맷팅
seminchoi Aug 15, 2024
a334e12
feat: 좌석 상태에 관련된 에러 코드 추가
seminchoi Aug 16, 2024
446f042
refactor: 좌석, 좌석 상태, 좌석에 할당된 회원간에 서로 메세지를 통해서 통신할 수 있도록 구조 수정
seminchoi Aug 16, 2024
a5c0e79
refactor: 객체간 메세지를 보내도록 수정한 코드 적용
seminchoi Aug 16, 2024
2e19429
feat: reservation-test.sql 쿼리 수정
seminchoi Aug 16, 2024
1e01b87
feat: CacheTicketService selectSeat 구현
mirageoasis Aug 15, 2024
1045def
feat: 자리 선점 기능 구현
mirageoasis Aug 15, 2024
7eb8c8a
feat: 테스트 작성
mirageoasis Aug 15, 2024
b141a46
feat: 레디스 락 작성 완료
mirageoasis Aug 15, 2024
f315001
fix: 시간 수정
mirageoasis Aug 15, 2024
2be4877
feature: 티켓 구매 기능 임시구현
mirageoasis Aug 15, 2024
f818ca3
feature: 티켓 구매 기능 임시구현
mirageoasis Aug 15, 2024
1b57129
setting: 서브모듈 배포환경 설정 파일 구성
seminchoi Aug 16, 2024
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
2 changes: 1 addition & 1 deletion backend-config
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ public enum ErrorCode {
*/
NOT_FOUND_SEAT_GRADE(HttpStatus.NOT_FOUND, "SG404-1", "존재하지 않는 구역입니다."),

/*
Seat Error
*/
NOT_FOUND_SEAT(HttpStatus.NOT_FOUND, "S404-1", "존재하지 않는 좌석입니다."),
NOT_SELECTABLE_SEAT(HttpStatus.FORBIDDEN, "S403-1", "이미 선택된 좌석입니다."),
INVALID_SEAT_STATUS(HttpStatus.FORBIDDEN, "S403-2", "요청을 수행할 수 없는 좌석의 상태입니다."),

/*
Payment Error
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.thirdparty.ticketing.domain.common;

import java.time.Duration;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class LettuceRepository {
private final StringRedisTemplate redisTemplate;

// 1. lock을 생성
// 2. 60초가 유지되는 key는 자리번호 value는 유저 id를 생성
public Boolean seatLock(String key) {
return redisTemplate.opsForValue().setIfAbsent(key, "lock", Duration.ofMinutes(1));
}

public void unlock(String string) {
redisTemplate.delete(string);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.thirdparty.ticketing.domain.member;

import java.time.ZonedDateTime;
import java.util.Objects;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand Down Expand Up @@ -53,4 +54,17 @@ public Member(String email, String password, MemberRole memberRole, ZonedDateTim
this.password = password;
this.memberRole = memberRole;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Member member = (Member) o;
return Objects.equals(memberId, member.memberId);
}

@Override
public int hashCode() {
return Objects.hashCode(memberId);
}
}
30 changes: 30 additions & 0 deletions src/main/java/com/thirdparty/ticketing/domain/seat/Seat.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import jakarta.persistence.*;

import com.thirdparty.ticketing.domain.BaseEntity;
import com.thirdparty.ticketing.domain.common.ErrorCode;
import com.thirdparty.ticketing.domain.common.TicketingException;
import com.thirdparty.ticketing.domain.member.Member;
import com.thirdparty.ticketing.domain.zone.Zone;

Expand Down Expand Up @@ -43,6 +45,8 @@ public class Seat extends BaseEntity {
@Column(length = 16, nullable = false)
private SeatStatus seatStatus = SeatStatus.SELECTABLE;

@Version private Long version;

public Seat(String seatCode, SeatStatus seatStatus) {
this.seatCode = seatCode;
this.seatStatus = seatStatus;
Expand All @@ -51,4 +55,30 @@ public Seat(String seatCode, SeatStatus seatStatus) {
public boolean isSelectable() {
return seatStatus.isSelectable();
}

public void assignByMember(Member member) {
if (!isSelectable()) {
throw new TicketingException(ErrorCode.NOT_SELECTABLE_SEAT);
}
this.member = member;
this.seatStatus = SeatStatus.SELECTED;
}

public void markAsPendingPayment() {
if (!seatStatus.isSelected()) {
throw new TicketingException(ErrorCode.INVALID_SEAT_STATUS);
}
this.seatStatus = SeatStatus.PENDING_PAYMENT;
}

public void markAsPaid() {
if (!seatStatus.isPendingPayment()) {
throw new TicketingException(ErrorCode.INVALID_SEAT_STATUS);
}
this.seatStatus = SeatStatus.PAID;
}

public boolean isAssignedByMember(Member loginMember) {
return loginMember.equals(member);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,16 @@ public enum SeatStatus {
public boolean isSelectable() {
return this == SELECTABLE;
}

public boolean isSelected() {
return this == SELECTED;
}

public boolean isPendingPayment() {
return this == PENDING_PAYMENT;
}

public boolean isPaid() {
return this == PAID;
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package com.thirdparty.ticketing.domain.seat.repository;

import java.util.List;
import java.util.Optional;

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.stereotype.Repository;

import com.thirdparty.ticketing.domain.seat.Seat;
Expand All @@ -11,4 +16,12 @@
@Repository
public interface SeatRepository extends JpaRepository<Seat, Long> {
List<Seat> findByZone(Zone zone);

@Query("SELECT s FROM Seat as s WHERE s.id = :seatId")
@Lock(LockModeType.OPTIMISTIC)
Optional<Seat> findByIdWithOptimistic(Long seatId);

@Query("SELECT s FROM Seat as s WHERE s.id = :seatId")
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Seat> findByIdWithPessimistic(Long seatId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.validation.Valid;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -15,13 +16,15 @@
import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest;
import com.thirdparty.ticketing.domain.ticket.service.TicketService;

import lombok.RequiredArgsConstructor;

@RestController("/api")
@RequiredArgsConstructor
public class TicketController {

private final TicketService ticketService;

public TicketController(@Qualifier("lettuceCacheTicketService") TicketService ticketService) {
this.ticketService = ticketService;
}

@GetMapping("/members/tickets")
public ResponseEntity<ItemResult<TicketElement>> selectMyTickets(
@LoginMember String memberEmail) {
Expand All @@ -31,15 +34,17 @@ public ResponseEntity<ItemResult<TicketElement>> selectMyTickets(

@PostMapping("/seats/select")
public ResponseEntity<Void> selectSeat(
@LoginMember String memberEmail,
@RequestBody @Valid SeatSelectionRequest seatSelectionRequest) {
ticketService.selectSeat(seatSelectionRequest);
ticketService.selectSeat(memberEmail, seatSelectionRequest);
return ResponseEntity.ok().build();
}

@PostMapping("/tickets")
public ResponseEntity<Void> payTicket(
@LoginMember String memberEmail,
@RequestBody @Valid TicketPaymentRequest ticketPaymentRequest) {
ticketService.reservationTicket(ticketPaymentRequest);
ticketService.reservationTicket(memberEmail, ticketPaymentRequest);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
public class SeatSelectionRequest {
@NotNull(message = "좌석 ID를 요청하지 않았습니다.")
@Min(value = 1, message = "좌석 ID는 1 이상이어야 합니다.")
private Long seatId;
private final Long seatId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
public class TicketPaymentRequest {
@NotNull(message = "좌석 ID를 요청하지 않았습니다.")
@Min(value = 1, message = "좌석 ID는 1 이상이어야 합니다.")
private Long seatId;
private final Long seatId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.thirdparty.ticketing.domain.ticket.policy;

import java.util.Optional;

import com.thirdparty.ticketing.domain.seat.Seat;

public interface LockSeatStrategy {
Optional<Seat> getSeatWithLock(Long seatId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.thirdparty.ticketing.domain.ticket.policy;

import java.util.Optional;

import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import com.thirdparty.ticketing.domain.seat.Seat;
import com.thirdparty.ticketing.domain.seat.repository.SeatRepository;

import lombok.RequiredArgsConstructor;

@Primary
@Component
@RequiredArgsConstructor
public class OptimisticLockSeatStrategy implements LockSeatStrategy {
private final SeatRepository seatRepository;

@Override
public Optional<Seat> getSeatWithLock(Long seatId) {
return seatRepository.findByIdWithOptimistic(seatId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.thirdparty.ticketing.domain.ticket.policy;

import java.util.Optional;

import org.springframework.stereotype.Component;

import com.thirdparty.ticketing.domain.seat.Seat;
import com.thirdparty.ticketing.domain.seat.repository.SeatRepository;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class PessimisticLockSeatStrategy implements LockSeatStrategy {
private final SeatRepository seatRepository;

@Override
public Optional<Seat> getSeatWithLock(Long seatId) {
return seatRepository.findByIdWithPessimistic(seatId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ public CacheTicketService(
}

@Override
public void selectSeat(SeatSelectionRequest seatSelectionRequest) {}
public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) {}

@Override
public void reservationTicket(TicketPaymentRequest ticketPaymentRequest) {}
public void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.thirdparty.ticketing.domain.ticket.service;

import org.springframework.stereotype.Service;

import com.thirdparty.ticketing.domain.common.LettuceRepository;
import com.thirdparty.ticketing.domain.member.repository.MemberRepository;
import com.thirdparty.ticketing.domain.payment.PaymentProcessor;
import com.thirdparty.ticketing.domain.seat.repository.SeatRepository;
import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest;
import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest;
import com.thirdparty.ticketing.domain.ticket.repository.TicketRepository;

@Service
public class LettuceCacheTicketService extends TicketService {
private final CacheTicketService cacheTicketService;
private final LettuceRepository lettuceRepository;

public LettuceCacheTicketService(
MemberRepository memberRepository,
TicketRepository ticketRepository,
SeatRepository seatRepository,
PaymentProcessor paymentProcessor,
CacheTicketService cacheTicketService,
LettuceRepository lettuceRepository) {
super(memberRepository, ticketRepository, seatRepository, paymentProcessor);
this.cacheTicketService = cacheTicketService;
this.lettuceRepository = lettuceRepository;
}

@Override
public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) {
int limit = 5;
try {
while (limit > 0
&& !lettuceRepository.seatLock(seatSelectionRequest.getSeatId().toString())) {
limit -= 1;
Thread.sleep(300);
}

if (limit > 0) {
cacheTicketService.selectSeat(memberEmail, seatSelectionRequest);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lettuceRepository.unlock(seatSelectionRequest.getSeatId().toString());
}
}

@Override
public void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest) {
int limit = 5;
try {
while (limit > 0
&& !lettuceRepository.seatLock(ticketPaymentRequest.getSeatId().toString())) {
limit -= 1;
Thread.sleep(300);
}

if (limit > 0) {
cacheTicketService.reservationTicket(memberEmail, ticketPaymentRequest);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lettuceRepository.unlock(ticketPaymentRequest.getSeatId().toString());
}
}
}
Loading
Loading