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

[FEAT] 쿠폰 API 구현 - Redis Spin Lock, Pub/sub Lock #22

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
71d72f9
fix : 수량 감소 로직
kcsc2217 Sep 25, 2024
c51e884
feat : 쿠폰 전역 상수
kcsc2217 Sep 25, 2024
fcf7b50
feat : 쿠폰 커맨드 서비스 인터페이스
kcsc2217 Sep 25, 2024
adff588
feat : 이벤트 리스너를 통한 쿠폰 추가
kcsc2217 Sep 25, 2024
b75fe81
feat : 쿠폰 발급 서비스 로직
kcsc2217 Sep 25, 2024
0a0a0fd
feat : 쿠폰 전역 상수 추가
kcsc2217 Sep 25, 2024
e1ca4da
feat : 해당 멤버가 쿠폰 받았는지 boolean
kcsc2217 Sep 25, 2024
f76c4a4
feat : 해당 멤버가 쿠폰 받았는지 검증
kcsc2217 Sep 25, 2024
3fd0f0c
fix : 쿠폰 생성 반환값 변경
kcsc2217 Sep 25, 2024
c636f2f
fix : 쿠폰 생성 반환값 변경
kcsc2217 Sep 25, 2024
6cbe4a4
test : 쿠폰 생성 테스트 코드
kcsc2217 Sep 25, 2024
b82262c
fix : 파라미터 long으로 수정
kcsc2217 Sep 25, 2024
ef4bdb6
feat : 유저 쿠폰 api
kcsc2217 Sep 25, 2024
66c289d
feat : lettuce를 사용한 동시성 처리
kcsc2217 Sep 26, 2024
0651218
fix : catch Exception catch 변경
kcsc2217 Sep 26, 2024
2241d2e
fix : 요구사항 변경에 따른 컬럼 추가
kcsc2217 Sep 26, 2024
f77fa0e
feat: lockRedisson 구현
kcsc2217 Sep 26, 2024
a5c69dd
test: lockRedisson 테스트 코드
kcsc2217 Sep 26, 2024
99d35b9
fix: init 쿠폰 클래스 삭제 및 동시성 처리 구현 된 쿠폰 생성으로 변경
kcsc2217 Sep 26, 2024
db66b76
feat: 쿠폰발급 api 와 쿠폰 생성 api
kcsc2217 Sep 26, 2024
0488474
fix: 쿠폰 삭제 메서드 삭제
kcsc2217 Sep 26, 2024
61a0bab
fix: 코드리뷰 반영
kcsc2217 Sep 26, 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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

public class SessionConst {
public static final String LOGIN_USER = "loginUser";

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
package com.example.book_your_seat.common.constants;

public final class Constants {

public static final String NOT_VALIDATION = "로그인은 필수 입니다";
}
19 changes: 19 additions & 0 deletions src/main/java/com/example/book_your_seat/coupon/CouponConst.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.example.book_your_seat.coupon;

public final class CouponConst {

public static final String STOCK_ZERO = "수량이 부족합니다";

public static final String NOTFOUND_COUPON = "쿠폰을 찾을수 없습니다";

public static final String VALIDATION_COUPON = "이미 쿠폰을 받으셨습니다";

public static final String COUPON_MESSAGE = "축하합니다 쿠폰에 당첨되었습니다";

public static final String TIME_OUT = "redisson getLock timeout";

public static final String QUANTITY_COUPON = "수량을 입력해주세요";
public static final String DISCOUNT_COUPON = "할인률을 입력해주세요";

private CouponConst() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.example.book_your_seat.coupon.controller;

import com.example.book_your_seat.coupon.controller.Dto.CouponRequest;
import com.example.book_your_seat.coupon.controller.Dto.CouponResponse;
import com.example.book_your_seat.coupon.service.CouponCommandService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/coupon")
public class CouponController {

private final CouponCommandService couponCommandService;


@PostMapping
public ResponseEntity<CouponResponse> addCoupon(@RequestBody @Valid CouponRequest couponRequest) {
CouponResponse couponResponse = couponCommandService.saveCoupon(couponRequest);

return new ResponseEntity<>(couponResponse, HttpStatus.CREATED);
Copy link
Collaborator

Choose a reason for hiding this comment

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

P5
return ResponseEntity.status(HttpStatus.CREATED)
.body(response);
저는 메소드 체이닝을 제공해주는 경우에는 메소드 체이닝을 이용하는 편이긴 합니당

}



}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.example.book_your_seat.coupon.controller.Dto;

import com.example.book_your_seat.coupon.domain.Coupon;
import com.example.book_your_seat.coupon.domain.DiscountRate;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotNull;

import java.time.LocalDateTime;

import static com.example.book_your_seat.coupon.CouponConst.*;

public record CouponRequest (

@NotNull(message = QUANTITY_COUPON)
Integer amount,

@NotNull(message = DISCOUNT_COUPON)
DiscountRate discountRate,

@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime dateTime

){

public static Coupon to(CouponRequest couponRequest){
return new Coupon(couponRequest.amount, couponRequest.discountRate, couponRequest.dateTime);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.example.book_your_seat.coupon.controller.Dto;

import com.example.book_your_seat.coupon.domain.Coupon;
import lombok.Getter;

@Getter
public class CouponResponse {

private Long id;

public CouponResponse(Long id) {
this.id = id;
}

public static CouponResponse fromDto(Coupon coupon) {
return new CouponResponse(coupon.getId());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.book_your_seat.coupon.controller.Dto;

import com.example.book_your_seat.coupon.domain.Coupon;
import com.example.book_your_seat.coupon.domain.DiscountRate;
import lombok.Getter;

@Getter
public class UserCouponResponse {

private String message;

private DiscountRate discountRate;

public UserCouponResponse(Coupon coupon, String message) {
this.message = message;
this.discountRate = coupon.getDiscountRate();
}

public static UserCouponResponse fromCoupon(Coupon coupon, String message) {
return new UserCouponResponse(coupon, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.example.book_your_seat.coupon.controller;

import com.example.book_your_seat.coupon.controller.Dto.UserCouponResponse;
import com.example.book_your_seat.coupon.service.facade.LockCouponRedissonFacade;
import com.example.book_your_seat.user.controller.dto.UserResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import static com.example.book_your_seat.common.SessionConst.LOGIN_USER;
import static com.example.book_your_seat.common.constants.Constants.NOT_VALIDATION;

@RestController
@RequiredArgsConstructor
@RequestMapping("/usersCoupon")
public class UserCouponController {
private final LockCouponRedissonFacade lockCouponRedissonFacade;

@PostMapping
public ResponseEntity<UserCouponResponse> getCoupon(@RequestParam Long couponId, HttpServletRequest request) {

HttpSession session = request.getSession(false);

Long userId = sessionMember(session);

UserCouponResponse couponResponse = lockCouponRedissonFacade.useCoupon(userId, couponId);

return new ResponseEntity<>(couponResponse, HttpStatus.OK);
}

private static Long sessionMember(HttpSession session) {
if(session == null) {
throw new IllegalArgumentException(NOT_VALIDATION);
}
Comment on lines +38 to +40
Copy link
Collaborator

Choose a reason for hiding this comment

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

저는 session == null 을 체크하는 걸 까먹었네요! 나중에 추가하겠습니다!

UserResponse userResponse = (UserResponse) session.getAttribute(LOGIN_USER);

return userResponse.userId();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import static com.example.book_your_seat.coupon.CouponConst.STOCK_ZERO;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand All @@ -33,12 +37,25 @@ public class Coupon extends BaseEntity {
@OneToMany(mappedBy = "coupon", cascade = CascadeType.ALL)
private final List<UserCoupon> userCoupons = new ArrayList<>();

public Coupon(int amount, DiscountRate discountRate) {
private LocalDateTime expired;
Comment on lines 38 to +40
Copy link
Collaborator

@AnTaeho AnTaeho Sep 26, 2024

Choose a reason for hiding this comment

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

P4
변수명이 expiredAt으로 하면 좋을 것 같습니다!


public Coupon(int amount, DiscountRate discountRate, LocalDateTime expired) {
this.amount = amount;
this.discountRate = discountRate;
this.expired = expired;
}

public void addUserCoupon(UserCoupon userCoupon) {
this.userCoupons.add(userCoupon);
}

public void removeCoupon(int quantity){
if(amount - quantity < 0){
throw new IllegalArgumentException(STOCK_ZERO);
Copy link
Collaborator

Choose a reason for hiding this comment

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

P5
if (amount < quantity) 가 더 가독성이 좋을 것 같습니당

}

amount -= quantity;
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ public class UserCoupon extends BaseEntity {
@JoinColumn(name = "coupon_id")
private Coupon coupon;

private boolean useStatus;

public UserCoupon(User user, Coupon coupon) {
this.user = user;
this.coupon = coupon;
this.coupon.removeCoupon(1);
Copy link
Collaborator

Choose a reason for hiding this comment

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

P5
이것도 좋은 방법인거 같긴한데
서비스 계층에서 coupon으로 감소해주는 메서드를 만들어주는 것을 만들어 주면 좋을 것 같습니다!

user.adduserCoupon(this);
coupon.addUserCoupon(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
import org.springframework.data.jpa.repository.JpaRepository;

public interface CouponRepository extends JpaRepository<Coupon, Long> {

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserCouponRepository extends JpaRepository<UserCoupon, Long> {

boolean existsByUserIdAndCouponId(Long userId, Long couponId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.book_your_seat.coupon.repository.redis;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;

@Component
public class RedisLockRepository {

private RedisTemplate<String, String> redisTemplate;

public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}

public Boolean lock(Long key){

return redisTemplate
.opsForValue()
.setIfAbsent(key.toString(), "lock", Duration.ofMillis(3000));
}

public Boolean unlock(Long key){
return redisTemplate.delete(key.toString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.example.book_your_seat.coupon.service;

import com.example.book_your_seat.coupon.controller.Dto.CouponRequest;
import com.example.book_your_seat.coupon.controller.Dto.CouponResponse;
import com.example.book_your_seat.coupon.controller.Dto.UserCouponResponse;

public interface CouponCommandService {

UserCouponResponse useCoupon(Long userId, Long couponId);



CouponResponse saveCoupon(CouponRequest couponRequest);



}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.example.book_your_seat.coupon.service;

import com.example.book_your_seat.coupon.controller.Dto.CouponRequest;
import com.example.book_your_seat.coupon.controller.Dto.CouponResponse;
import com.example.book_your_seat.coupon.controller.Dto.UserCouponResponse;
import com.example.book_your_seat.coupon.domain.Coupon;
import com.example.book_your_seat.coupon.domain.UserCoupon;
import com.example.book_your_seat.coupon.repository.CouponRepository;
import com.example.book_your_seat.coupon.repository.UserCouponRepository;
import com.example.book_your_seat.user.domain.User;
import com.example.book_your_seat.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static com.example.book_your_seat.coupon.CouponConst.*;
import static com.example.book_your_seat.user.UserConst.NOTFOUND_USER;


@Service
@RequiredArgsConstructor
@Slf4j
public class CouponCommandServiceImpl implements CouponCommandService{

private final CouponRepository couponRepository;
private final UserCouponRepository userCouponRepository;
private final UserRepository userRepository;

@Override
public UserCouponResponse useCoupon(Long userId, Long couponId) {
validationUserCoupon(userId, couponId);

User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException(NOTFOUND_USER));
Coupon coupon = couponRepository.findById(couponId).orElseThrow(() -> new IllegalArgumentException(NOTFOUND_COUPON)); //쿠폰 찾기

Copy link
Collaborator

Choose a reason for hiding this comment

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

P4
코드가 길어지는 경우에는 엔터로 끊어주는 게 가독성이 좋은 것 같습니당

userCouponRepository.save( new UserCoupon(user, coupon)); // 쿠폰 발급

return UserCouponResponse.fromCoupon(coupon, COUPON_MESSAGE);
}


@Override
public CouponResponse saveCoupon(CouponRequest couponRequest) {

Coupon coupon = CouponRequest.to(couponRequest);

couponRepository.save(coupon);

return CouponResponse.fromDto(coupon);
}


public void validationUserCoupon(Long userId, Long couponId) {
if(userCouponRepository.existsByUserIdAndCouponId(userId, couponId)) {
throw new IllegalArgumentException(VALIDATION_COUPON);
}
}

}
Loading
Loading