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] 할인 쿠폰 분산락 적용 #140

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6d27560
[config] redis yaml 설정 추가
june-777 Aug 22, 2024
03bae45
[config] Redis Redisson gradle 의존성 추가
june-777 Aug 22, 2024
ca7aad1
[feat] AOP 를 적용하여 분산락 구현
june-777 Aug 22, 2024
5b9092e
[feat] AOP 에서 트랜젝션을 시작하기 위한 컴포넌트 구현
june-777 Aug 22, 2024
12ed14c
[feat] 분산락 애노테이션 구현
june-777 Aug 22, 2024
e5d9130
[feat] Spring EPL Parser 구현
june-777 Aug 22, 2024
7fbafc7
[feat] Redis yaml 설정값을 담은 VO 구현
june-777 Aug 22, 2024
7fffab5
[feat] 분산락을 적용한 할인 쿠폰 발급 기능 구현
june-777 Aug 22, 2024
c6a11d8
[feat] 비관적락 없이 아이디를 기반으로 할인 쿠폰을 찾는 기능 구현
june-777 Aug 22, 2024
9a48ec0
[feat] DistributedLockAop 수정
kimhyun5u Aug 23, 2024
57bbc8e
[refactor] 분산락을 해제하는 로직을 메서드 분리 및 예외 핸들링 추가
june-777 Aug 23, 2024
78558da
[test] AOP 기반 Redisson 분산락 기능 단위 테스트
june-777 Aug 23, 2024
2606771
[style] @Disabled 테스트 애노테이션 추가
june-777 Aug 23, 2024
dc46b00
[fix] 분산락을 사용하는 쿠폰 발급은 비관락을 사용하지 않도록 수정
june-777 Aug 23, 2024
c301bc7
[refactor] 분산락을 적용한 할인 쿠폰 발급 메서드를 호출하도록 변경
june-777 Aug 24, 2024
976d4e2
[fix] 분산락을 적용한 할인 쿠폰 발급 메서드를 모킹 하도록 테스트 코드 수정
june-777 Aug 24, 2024
673e907
[fix] 테스트 컨테이너 세팅 환경 테스트 @Disabled 임시 처리
june-777 Aug 24, 2024
955d59a
Merge remote-tracking branch 'origin/main' into feature/132_june-777-…
june-777 Aug 27, 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
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ dependencies {

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis:3.3.3'

implementation 'org.redisson:redisson-spring-boot-starter:3.34.1' // Redis Redisson

// https://mvnrepository.com/artifact/com.github.codemonstur/embedded-redis
implementation 'com.github.codemonstur:embedded-redis:1.4.3'
implementation 'com.github.codemonstur:embedded-redis:1.4.3'

//QueryDsl
// QueryDsl
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ public interface CouponRepository extends JpaRepository<Coupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :id")
Optional<Coupon> findByIdWithPessimisticLock(Long id);

@Query("SELECT c FROM Coupon c WHERE c.id = :id")
Optional<Coupon> findById(Long id);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package camp.woowak.lab.coupon.service;

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

import camp.woowak.lab.coupon.domain.Coupon;
import camp.woowak.lab.coupon.domain.CouponIssuance;
Expand All @@ -12,7 +13,7 @@
import camp.woowak.lab.coupon.service.command.IssueCouponCommand;
import camp.woowak.lab.customer.domain.Customer;
import camp.woowak.lab.customer.repository.CustomerRepository;
import jakarta.transaction.Transactional;
import camp.woowak.lab.infra.aop.DistributedLock;

@Service
public class IssueCouponService {
Expand Down Expand Up @@ -54,4 +55,26 @@ public Long issueCoupon(IssueCouponCommand cmd) {
// coupon issuance 저장
return couponIssuanceRepository.save(newCouponIssuance).getId();
}

@DistributedLock(key = "#cmd.couponId()", leaseTime = 30L)
public Long issueCouponWithDistributionLock(IssueCouponCommand cmd) {
// customer 조회
Customer targetCustomer = customerRepository.findById(cmd.customerId())
.orElseThrow(() -> new InvalidICreationIssuanceException("customer not found"));

// coupon 조회
Coupon targetCoupon = couponRepository.findById(cmd.couponId())
.orElseThrow(() -> new InvalidICreationIssuanceException("coupon not found"));

// coupon 수량 확인
if (!targetCoupon.hasAvailableQuantity()) {
throw new InsufficientCouponQuantityException("quantity of coupon is insufficient");
}

// coupon issuance 생성
CouponIssuance newCouponIssuance = new CouponIssuance(targetCoupon, targetCustomer);

// coupon issuance 저장
return couponIssuanceRepository.save(newCouponIssuance).getId();
}
}
15 changes: 15 additions & 0 deletions src/main/java/camp/woowak/lab/infra/aop/AopForTransaction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package camp.woowak.lab.infra.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Component
public class AopForTransaction {

@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
20 changes: 20 additions & 0 deletions src/main/java/camp/woowak/lab/infra/aop/CustomSpringELParser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package camp.woowak.lab.infra.aop;

import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class CustomSpringELParser {
private CustomSpringELParser() {
}

public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();

for (int parmeterIdx = 0; parmeterIdx < parameterNames.length; parmeterIdx++) {
context.setVariable(parameterNames[parmeterIdx], args[parmeterIdx]);
}

return spelExpressionParser.parseExpression(key).getValue(context, Object.class);
}
}
33 changes: 33 additions & 0 deletions src/main/java/camp/woowak/lab/infra/aop/DistributedLock.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package camp.woowak.lab.infra.aop;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

/**
* @return 분산락의 키 이름
*/
String key();

/**
* @return 분산락 단위 시간
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;

/**
* @return 분산락 대기 시간
*/
long waitTime() default 5L;

/**
* @return 분산락을 점유한 스레드가 정해진 시간이 지나면 락을 해제
*/
long leaseTime() default 5L;

}
65 changes: 65 additions & 0 deletions src/main/java/camp/woowak/lab/infra/aop/DistributedLockAop.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package camp.woowak.lab.infra.aop;

import java.lang.reflect.Method;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

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

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

private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;

@Around("@annotation(camp.woowak.lab.infra.aop.DistributedLock)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature)joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(),
joinPoint.getArgs(),
distributedLock.key());
RLock rLock = redissonClient.getLock(key);

try {
boolean locked = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(),
distributedLock.timeUnit());
if (!locked) {
log.warn("Failed to acquire lock for method {} with key {}", method.getName(), key);
throw new IllegalStateException("Unable to acquire lock");
}
log.info("Acquired lock for method {} with key {}", method.getName(), key);
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) { // rLock.tryLock
log.warn("Interrupted while trying to acquire lock for method {} with key {}", method.getName(), key);
throw e;
} finally {
releaseLock(rLock, method, key);
}
}

private void releaseLock(RLock rLock, Method method, String key) {
try {
if (rLock.isHeldByCurrentThread()) {
rLock.unlock();
log.info("Released lock for method {} with key {}", method.getName(), key);
}
} catch (IllegalMonitorStateException e) {
log.warn("Failed to release lock for method {} with key {}", method.getName(), key, e);
throw e;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package camp.woowak.lab.infra.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfiguration {

private static final Logger log = LoggerFactory.getLogger(RedissonConfiguration.class);
@Value("${spring.data.redis.host}")
private String redisHost;

@Value("${spring.data.redis.port}")
private int redisPort;

private static final String REDISSON_HOST_PREFIX = "redis://";

@Bean
public RedissonClient redissonClient() {
log.info("Creating redisson config client start");
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
RedissonClient redissonClient = Redisson.create(config);
log.info("Creating redisson config client end");
return redissonClient;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public IssueCouponResponse issueCoupon(@AuthenticationPrincipal LoginCustomer lo
@PathVariable Long couponId) {
IssueCouponCommand cmd = new IssueCouponCommand(loginCustomer.getId(), couponId);

issueCouponService.issueCoupon(cmd);
issueCouponService.issueCouponWithDistributionLock(cmd);

return new IssueCouponResponse();
}
Expand Down
8 changes: 7 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ spring:
properties:
hibernate:
format_sql: true
show_sql: true
show_sql: true

data:
redis:
host: localhost
port: 6379
password:
Empty file.
Loading