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: redis 캐시 정책을 추가 #151

Closed
wants to merge 14 commits into from
Closed
2 changes: 1 addition & 1 deletion backend-config
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,6 @@ tasks.register('copyYml') {
}

build {
dependsOn copyDocument
mustRunAfter copyDocument
dependsOn copyYml
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.thirdparty.ticketing.domain.seat;

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

import lombok.Getter;

@Getter
public class RedisSeat {

private Long seatId;
private Long memberId; // 예약한 유저 정보 -> null이면 아직 없음
private SeatStatus seatStatus; // 예약 상태 -> 예약 가능, 예약 불가능

public RedisSeat(Long seatId, Long memberId, SeatStatus seatStatus) {
this.seatId = seatId;
this.memberId = memberId;
this.seatStatus = seatStatus;
}

public void checkSelectable() {
if (this.seatStatus != SeatStatus.SELECTABLE) {
throw new TicketingException(ErrorCode.NOT_SELECTABLE_SEAT);
}
}

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

this.memberId = member.getMemberId();
this.seatStatus = SeatStatus.SELECTED;
}

public boolean isAssignedByMember(Member loginMember) {
return loginMember.getMemberId().equals(this.memberId)
&& 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 void markAsSelected() {
this.seatStatus = SeatStatus.SELECTED;
}

public void releaseSeat(Member loginMember) {
if (!isAssignedByMember(loginMember)) {
throw new TicketingException(ErrorCode.NOT_SELECTABLE_SEAT);
}

this.memberId = null;
this.seatStatus = SeatStatus.SELECTABLE;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.thirdparty.ticketing.domain.seat.repository;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

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

import com.thirdparty.ticketing.domain.common.ErrorCode;
import com.thirdparty.ticketing.domain.common.TicketingException;
import com.thirdparty.ticketing.domain.seat.RedisSeat;
import com.thirdparty.ticketing.domain.seat.SeatStatus;

import lombok.extern.slf4j.Slf4j;

@Repository
@Slf4j
public class LettuceSeatRepository {
private static final String SEAT_DATA_KEY = "seat-data-";
private final HashOperations<String, String, String> seatData;

public LettuceSeatRepository(StringRedisTemplate redisTemplate) {
this.seatData = redisTemplate.opsForHash();
}

public Optional<RedisSeat> findBySeatId(Long seatId) {
Map<String, String> seatMap = seatData.entries(getSeatDataKey(seatId));

if (seatMap.isEmpty()) {
throw new TicketingException(ErrorCode.NOT_FOUND_SEAT);
}

RedisSeat redisSeat =
new RedisSeat(
Long.valueOf(seatMap.get("seatId")),
seatMap.get("memberId") != null
? Long.valueOf(seatMap.get("memberId"))
: null,
SeatStatus.valueOf(seatMap.get("seatStatus")));

return Optional.of(redisSeat);
}

public void selectSeat(RedisSeat redisSeat) {
update(redisSeat);
log.info("Seat {} selected by member {}", redisSeat.getSeatId(), redisSeat.getMemberId());
}

public void update(RedisSeat redisSeat) {
// 값 있는지 체크 안하고 하면 side effect 발생
Map<String, String> seatMap = new HashMap<>();
seatMap.put("seatId", redisSeat.getSeatId().toString());
if (redisSeat.getMemberId() != null) {
seatMap.put("memberId", redisSeat.getMemberId().toString());
}
seatMap.put("seatStatus", redisSeat.getSeatStatus().toString());

seatData.putAll(getSeatDataKey(redisSeat.getSeatId()), seatMap);
}

private String getSeatDataKey(Long seatId) {
return SEAT_DATA_KEY + seatId;
}
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,7 @@
package com.thirdparty.ticketing.domain.ticket.service;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

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.seat.Seat;
import com.thirdparty.ticketing.domain.seat.repository.SeatRepository;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class ReservationManager {
private final SeatRepository seatRepository;

@Transactional
public void releaseSeat(Member loginMember, long seatId) {
Seat seat =
seatRepository
.findById(seatId)
.orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_SEAT));
seat.releaseSeat(loginMember);
}
public interface ReservationManager {
void releaseSeat(Member loginMember, long seatId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.thirdparty.ticketing.domain.ticket.service;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

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.seat.Seat;
import com.thirdparty.ticketing.domain.seat.repository.SeatRepository;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class ReservationManagerTransactionalImpl implements ReservationManager {
private final SeatRepository seatRepository;

@Transactional
public void releaseSeat(Member loginMember, long seatId) {
Seat seat =
seatRepository
.findById(seatId)
.orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_SEAT));
seat.releaseSeat(loginMember);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.thirdparty.ticketing.domain.ticket.service;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.transaction.annotation.Transactional;

import com.thirdparty.ticketing.domain.common.ErrorCode;
import com.thirdparty.ticketing.domain.common.EventPublisher;
import com.thirdparty.ticketing.domain.common.TicketingException;
import com.thirdparty.ticketing.domain.member.Member;
import com.thirdparty.ticketing.domain.member.repository.MemberRepository;
import com.thirdparty.ticketing.domain.payment.PaymentProcessor;
import com.thirdparty.ticketing.domain.payment.dto.PaymentRequest;
import com.thirdparty.ticketing.domain.seat.RedisSeat;
import com.thirdparty.ticketing.domain.seat.Seat;
import com.thirdparty.ticketing.domain.seat.repository.LettuceSeatRepository;
import com.thirdparty.ticketing.domain.seat.repository.SeatRepository;
import com.thirdparty.ticketing.domain.ticket.Ticket;
import com.thirdparty.ticketing.domain.ticket.dto.event.PaymentEvent;
import com.thirdparty.ticketing.domain.ticket.dto.event.SeatEvent;
import com.thirdparty.ticketing.domain.ticket.dto.request.SeatSelectionRequest;
import com.thirdparty.ticketing.domain.ticket.dto.request.TicketPaymentRequest;
import com.thirdparty.ticketing.domain.ticket.repository.TicketRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
public class ReservationRedisService implements ReservationService {

private final MemberRepository memberRepository;
private final EventPublisher eventPublisher;
private final PaymentProcessor paymentProcessor;
private final LettuceSeatRepository lettuceSeatRepository;
private final SeatRepository seatRepository;
private final TicketRepository ticketRepository;
private final StringRedisTemplate redisTemplate;

@Override
@Transactional
public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) {
Long seatId = seatSelectionRequest.getSeatId();

RedisSeat redisSeat =
lettuceSeatRepository
.findBySeatId(seatId)
.orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_SEAT));

Member member =
memberRepository
.findByEmail(memberEmail)
.orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_MEMBER));

redisSeat.assignByMember(member);
lettuceSeatRepository.selectSeat(redisSeat);
// event publish
eventPublisher.publish(new SeatEvent(memberEmail, seatId, SeatEvent.EventType.SELECT));
Map<String, String> map = new HashMap<>();
map.put("occupy", seatId.toString());
redisTemplate.opsForStream().add("seat-stream", map);
}

@Override
@Transactional
public void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest) {
Long seatId = ticketPaymentRequest.getSeatId();
RedisSeat redisSeat =
lettuceSeatRepository
.findBySeatId(seatId)
.orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_SEAT));

Seat seat =
seatRepository
.findById(seatId)
.orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_SEAT));

Member loginMember =
memberRepository
.findByEmail(memberEmail)
.orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_MEMBER));

processPayment(redisSeat, loginMember);

Ticket ticket =
Ticket.builder()
.ticketSerialNumber(UUID.randomUUID())
.seat(Seat.builder().seatId(redisSeat.getSeatId()).build())
.member(loginMember)
.build();
try {
// 이벤트 기반으로 하는게 제일 이상적이지만... 일단은 이렇게 -> 강제로 정합성을 통일함.
redisSeat.markAsPaid();
seat.assignByMember(loginMember);
seat.markAsPaid();
ticketRepository.save(ticket);
seatRepository.save(seat);
} catch (Exception e) {
log.error("Failed to save ticket: {}", e.getMessage());
redisSeat.markAsSelected();
throw new TicketingException(ErrorCode.PAYMENT_FAILED);
} finally {
lettuceSeatRepository.update(redisSeat);
}
}

@Override
public void releaseSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) {
RedisSeat redisSeat =
lettuceSeatRepository
.findBySeatId(seatSelectionRequest.getSeatId())
.orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_SEAT));

Member loginMember =
memberRepository
.findByEmail(memberEmail)
.orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_MEMBER));

redisSeat.releaseSeat(loginMember);

lettuceSeatRepository.update(redisSeat);
}

private void processPayment(RedisSeat seat, Member loginMember) {
if (!seat.isAssignedByMember(loginMember)) {
throw new TicketingException(ErrorCode.NOT_SELECTABLE_SEAT);
}
paymentProcessor.processPayment(new PaymentRequest());
PaymentEvent paymentEvent = new PaymentEvent(loginMember.getEmail());
eventPublisher.publish(paymentEvent);
}
}
Loading
Loading