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

Feature: 락을 활용하여 동시성 문제가 일어나지 않는 예매 기능을 구현한다. #65

Merged
merged 61 commits into from
Aug 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
13ce9ed
feat: CacheTicketService selectSeat 구현
mirageoasis Aug 15, 2024
b44b83d
feat: 자리 선점 기능 구현
mirageoasis Aug 15, 2024
22c0c20
feat: 테스트 작성
mirageoasis Aug 15, 2024
9fc858f
feat: 레디스 락 작성 완료
mirageoasis Aug 15, 2024
a9b72ee
fix: 시간 수정
mirageoasis Aug 15, 2024
d0239c9
feature: 티켓 구매 기능 임시구현
mirageoasis Aug 15, 2024
de2de15
feature: 티켓 구매 기능 임시구현
mirageoasis Aug 15, 2024
40c534a
feat: 낙관적 락을 사용하여 좌석 선택 기능 구현
seminchoi Aug 15, 2024
bc08c30
refactor: 티켓 예매 관련 인터페이스 수정
seminchoi Aug 15, 2024
fcc6b05
feat: 좌석 상태를 조회할 때 전략을 선택할 수 있는 전략 패턴 인터페이스 구현
seminchoi Aug 15, 2024
ad8c59a
feat: 좌석 상태를 조회할 때 낙관락, 비관락을 사용할 수 있는 전략 패턴 구현
seminchoi Aug 15, 2024
08d2c15
fix: ErrorCode 컴파일 오류 수정
seminchoi Aug 15, 2024
373ced9
feat: 티켓 예매시 데이터 정합성이 유지되도록 DB Locking 적용
seminchoi Aug 15, 2024
8690ee1
refactor: DTO final 필드 설정
seminchoi Aug 15, 2024
d95e69b
feat: 티켓 예매 테스트를 위한 sql 파일 생성
seminchoi Aug 15, 2024
73f1506
test: 티켓 예매 시 좌석 선택할 때 동시성 테스트 코드 구현
seminchoi Aug 15, 2024
dd93c3f
test: 티켓 예매 시 결제 시도할 때 동시성 테스트 코드 구현
seminchoi Aug 15, 2024
88e0e60
refactor: 코드 포맷팅
seminchoi Aug 15, 2024
ae5561d
refactor: 구조 변경
seminchoi Aug 16, 2024
d9c993b
refactor: 예외 사용 컨벤션 통일
seminchoi Aug 16, 2024
fe26e55
test: 구조 변경사항 테스트에 적용
seminchoi Aug 16, 2024
cc652a9
feat: 좌석 상태에 관련된 에러 코드 추가
seminchoi Aug 16, 2024
37bb02d
refactor: 좌석, 좌석 상태, 좌석에 할당된 회원간에 서로 메세지를 통해서 통신할 수 있도록 구조 수정
seminchoi Aug 16, 2024
a731d54
refactor: 객체간 메세지를 보내도록 수정한 코드 적용
seminchoi Aug 16, 2024
230fa84
feat: reservation-test.sql 쿼리 수정
seminchoi Aug 16, 2024
b33ab71
refactor: 코드 리포맷팅
seminchoi Aug 16, 2024
e9e5d7a
feat: ErrorCode 추가
seminchoi Aug 16, 2024
6c53a65
test: 동작에 맞게 테스트 수정
mirageoasis Aug 17, 2024
72bc6bc
fix: 락 시간 수정
mirageoasis Aug 17, 2024
04c9bcb
feature: Seat 엔티티에 수정 로그 추가
mirageoasis Aug 17, 2024
1bf5fce
refactor: Redisson 클래스 오타 수정
mirageoasis Aug 17, 2024
b03df9b
feat: 이벤트 발행기 정의
lass9436 Aug 16, 2024
fb14d36
feat: 사용자 진행 가능 여부 메서드 정의
lass9436 Aug 16, 2024
0786031
feat: 사용자 대기열 삽입 메서드 정의
lass9436 Aug 16, 2024
cb4054f
feat: 사용자 남은 순번 조회 메서드 정의
lass9436 Aug 16, 2024
3d9b59c
feat: 사용자 작업 가능 공간 이동 메서드 정의
lass9436 Aug 16, 2024
279cef3
refactor: WaitingMember, WaitingAspect 패키지 이동
lass9436 Aug 16, 2024
4e861bd
refactor: WaitingController 패키지 이동
lass9436 Aug 16, 2024
46d2dfd
feat: 대기열 정보 관리 인터페이스 정의
lass9436 Aug 16, 2024
5142ff0
refactor: 코드 포맷 정리
lass9436 Aug 16, 2024
5e28f33
setting: 서브모듈 배포환경 설정 파일 구성
seminchoi Aug 17, 2024
c60b832
feat: 서비스 계층 구조 변경 완료에 따라 컨트롤러 기능 재구현
seminchoi Aug 17, 2024
02db7e8
refactor: 임시로 생성한 클래스 네이밍 수정
seminchoi Aug 17, 2024
dc00439
refactor: 패키지 구조 변경 및 올바르지 않은 네이밍 수정
seminchoi Aug 17, 2024
01737b3
feat: 낙관락, 비관락에서 사용하는 proxy 서비스 구현
seminchoi Aug 17, 2024
15175cd
feat: 좌석 기본 조회 시 락을 사용하지 않도록 명시
seminchoi Aug 17, 2024
9a82b18
feat: 기본으로 사용할 락 전략에 @Primary 어노테이션 사용
seminchoi Aug 17, 2024
aa8cae8
test: 티켓팅 테스트 클래스 테스트 컨테이너 적용
seminchoi Aug 17, 2024
831f914
refactor: 코드 포맷팅 수정
seminchoi Aug 17, 2024
d2e0129
test: 예매 테스트에 낙관락과 비관락 테스트를 모두 적용
seminchoi Aug 17, 2024
0e90107
feat: 낙관락에서 발생하는 예외 정확하게 catch하도록 수정
seminchoi Aug 17, 2024
d16f80e
refactor: 더 이상 사용하지 않는 레디스 config 클래스 제거
hseong3243 Aug 16, 2024
f9579e9
feat: 사용자의 작업 가능 여부 레디스 조회 기능 구현
hseong3243 Aug 16, 2024
a1478ae
test: 사용자의 작업 가능 여부 레디스 조회 기능 검증
hseong3243 Aug 16, 2024
3021635
test: 더 이상 사용하지 않는 테스트 클래스 비활성화
hseong3243 Aug 16, 2024
edcd860
refactor: 코드 포맷 정렬
hseong3243 Aug 17, 2024
2422c93
refactor: 대기열 시스템(`waitingsystem`) 패키지 구조 개선
hseong3243 Aug 17, 2024
986d5cf
refactor: `WaitingRoom` 책임 분리
hseong3243 Aug 17, 2024
ece1057
refactor: `RunningRoom` 책임 분리
hseong3243 Aug 17, 2024
2ee9070
refactor: 코드 포맷 정렬
hseong3243 Aug 17, 2024
7254139
refactor: 개선된 구조에 맞게 import를 변경
hseong3243 Aug 17, 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 All @@ -51,7 +58,8 @@ public enum ErrorCode {
Waiting Error
*/
WAITING_WRITE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "W500-1", "대기열 쓰기에 실패했습니다."),
WAITING_READ_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "W500-2", "대기열 읽기에 실패했습니다.");
WAITING_READ_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "W500-2", "대기열 읽기에 실패했습니다."),
;

ErrorCode(HttpStatus httpStatus, String code, String message) {
this.httpStatus = httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.thirdparty.ticketing.domain.common;

public interface Event {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.thirdparty.ticketing.domain.common;

public interface EventPublisher {

void publish(Event event);
}
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);
}
}
33 changes: 33 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 All @@ -11,13 +13,15 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Entity
@Table(name = "seat")
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Slf4j
public class Seat extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand All @@ -43,6 +47,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 +57,31 @@ 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);
}
log.info("seat occupied by {}", member.getEmail());
this.member = member;
this.seatStatus = SeatStatus.SELECTED;
}

public void markAsPendingPayment() {
seminchoi marked this conversation as resolved.
Show resolved Hide resolved
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;
seminchoi marked this conversation as resolved.
Show resolved Hide resolved
}

public boolean isPaid() {
return this == PAID;
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
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;
import com.thirdparty.ticketing.domain.zone.Zone;

import io.lettuce.core.dynamic.annotation.Param;

@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.NONE)
Optional<Seat> findById(@Param("seatId") Long seatId);

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

@Query("SELECT s FROM Seat as s WHERE s.id = :seatId")
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Seat> findByIdWithPessimistic(@Param("seatId") Long seatId);
}
Original file line number Diff line number Diff line change
@@ -1,45 +1,50 @@
package com.thirdparty.ticketing.domain.ticket.controller;

import java.util.List;

import jakarta.validation.Valid;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

import com.thirdparty.ticketing.domain.ItemResult;
import com.thirdparty.ticketing.domain.common.LoginMember;
import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest;
import com.thirdparty.ticketing.domain.ticket.dto.TicketElement;
import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest;
import com.thirdparty.ticketing.domain.ticket.service.ReservationService;
import com.thirdparty.ticketing.domain.ticket.service.TicketService;

import lombok.RequiredArgsConstructor;

@RestController("/api")
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class TicketController {
private final TicketService ticketService;
private final ReservationService reservationService;

@GetMapping("/members/tickets")
public ResponseEntity<ItemResult<TicketElement>> selectMyTickets(
@LoginMember String memberEmail) {
ItemResult<TicketElement> tickets = ticketService.selectMyTicket(memberEmail);
ItemResult<TicketElement> tickets = ItemResult.of(List.of());
ticketService.selectMyTicket(memberEmail);
return ResponseEntity.ok().body(tickets);
}

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

@PostMapping("/tickets")
public ResponseEntity<Void> payTicket(
public ResponseEntity<Void> reservationTicket(
@LoginMember String memberEmail,
@RequestBody @Valid TicketPaymentRequest ticketPaymentRequest) {
ticketService.reservationTicket(ticketPaymentRequest);
reservationService.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;
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.thirdparty.ticketing.domain.ticket.service;

import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest;
import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest;

public interface ReservationService {
void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest);

void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest);
}
Loading
Loading