Skip to content

Commit

Permalink
[feat] : 분산락 활용해 동시성 이슈 처리 (#150)
Browse files Browse the repository at this point in the history
* [fix] : dev 브랜치와의 충돌 해결

* [refactor] : 불필요한 로직 제거

* [feat] : redisson 설정 추가

* [feat] : 분산락 어노테이션 구현

* [feat] : 분산락 AOP 관련 트랜잭션 설정 및 key 파싱 클래스 추가

* [feat] : 입찰 등록 로직에 분산락 어노테이션 적용

* [test] : 동시성 테스트 로직 추가

* [test] : ObjectProvider 사용에 따른 입찰 등록 테스트 수정

* [feat] : 기본 생성자 private 설정

* [feat] : 대기 시간 동안 획득하지 못할 시 예외 반환

* [feat] : 동시성 제어 로직 수정

* [test] : 동시성 제어 로직 수정 반영

* [test] : 불필요한 코드 제거

* [refactor] : BiddingMapper 파라미터 수정

* [refactor] : 입찰자 검증 로직 생성자로 이동

* [feat] : 등록 메서드 내에서 호출하던 별도의 동시성 제어 메서드 삭제 후 통합

* [test] : 등록 메서드 내에서 호출하던 별도의 동시성 제어 메서드 삭제 후 통합 반영

* [test] : 동시성 테스트 리팩토링

* [style] : 코드 리포멧팅
  • Loading branch information
hyun2371 authored Mar 24, 2024
1 parent 07c254d commit 5bb10f7
Show file tree
Hide file tree
Showing 15 changed files with 310 additions and 50 deletions.
3 changes: 3 additions & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

//redisson
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.25.1'

// testFixtures 의존성
testFixturesImplementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
testFixturesImplementation 'org.springframework:spring-tx:6.1.1'
Expand Down
7 changes: 7 additions & 0 deletions core/src/main/java/dev/handsup/auction/domain/Auction.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import dev.handsup.auction.domain.product.ProductStatus;
import dev.handsup.auction.domain.product.product_category.ProductCategory;
import dev.handsup.auction.exception.AuctionErrorCode;
import dev.handsup.bidding.exception.BiddingErrorCode;
import dev.handsup.comment.exception.CommentErrorCode;
import dev.handsup.common.entity.TimeBaseEntity;
import dev.handsup.common.exception.ValidationException;
Expand Down Expand Up @@ -219,4 +220,10 @@ public void validateIfSeller(User user) {
throw new ValidationException(NOT_AUCTION_SELLER);
}
}

public void validateNotSeller(User user) {
if (getSeller().equals(user)) {
throw new ValidationException(BiddingErrorCode.NOT_ALLOW_SELF_BIDDING);
}
}
}
3 changes: 3 additions & 0 deletions core/src/main/java/dev/handsup/bidding/domain/Bidding.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public class Bidding extends TimeBaseEntity {

@Builder
private Bidding(int biddingPrice, Auction auction, User bidder) {
auction.validateNotSeller(bidder);
this.biddingPrice = biddingPrice;
this.auction = auction;
this.bidder = bidder;
Expand All @@ -62,6 +63,7 @@ private Bidding(int biddingPrice, Auction auction, User bidder) {

// 테스트용
public Bidding(Long id, int biddingPrice, Auction auction, User bidder, TradingStatus tradingStatus) {
auction.validateNotSeller(bidder);
this.id = id;
this.biddingPrice = biddingPrice;
this.auction = auction;
Expand All @@ -71,6 +73,7 @@ public Bidding(Long id, int biddingPrice, Auction auction, User bidder, TradingS

// 테스트용
public Bidding(int biddingPrice, Auction auction, User bidder, TradingStatus tradingStatus) {
auction.validateNotSeller(bidder);
this.biddingPrice = biddingPrice;
this.auction = auction;
this.bidder = bidder;
Expand Down
5 changes: 2 additions & 3 deletions core/src/main/java/dev/handsup/bidding/dto/BiddingMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import dev.handsup.auction.domain.Auction;
import dev.handsup.bidding.domain.Bidding;
import dev.handsup.bidding.dto.request.RegisterBiddingRequest;
import dev.handsup.bidding.dto.response.BiddingResponse;
import dev.handsup.user.domain.User;
import lombok.NoArgsConstructor;
Expand All @@ -25,9 +24,9 @@ public static BiddingResponse toBiddingResponse(Bidding bidding) {
);
}

public static Bidding toBidding(RegisterBiddingRequest request, Auction auction, User bidder) {
public static Bidding toBidding(int biddingPrice, Auction auction, User bidder) {
return Bidding.of(
request.biddingPrice(),
biddingPrice,
auction,
bidder
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ public interface BiddingRepository extends JpaRepository<Bidding, Long> {

Slice<Bidding> findByBidderAndAuction_StatusOrderByAuction_CreatedAtDesc(
User bidder, AuctionStatus auctionStatus, Pageable pageable);

}
66 changes: 33 additions & 33 deletions core/src/main/java/dev/handsup/bidding/service/BiddingService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import org.springframework.transaction.annotation.Transactional;

import dev.handsup.auction.domain.Auction;
import dev.handsup.auction.service.AuctionService;
import dev.handsup.auction.exception.AuctionErrorCode;
import dev.handsup.auction.repository.auction.AuctionRepository;
import dev.handsup.bidding.domain.Bidding;
import dev.handsup.bidding.dto.BiddingMapper;
import dev.handsup.bidding.dto.request.RegisterBiddingRequest;
Expand All @@ -18,6 +19,7 @@
import dev.handsup.common.dto.PageResponse;
import dev.handsup.common.exception.NotFoundException;
import dev.handsup.common.exception.ValidationException;
import dev.handsup.common.redisson.DistributeLock;
import dev.handsup.notification.domain.NotificationType;
import dev.handsup.notification.service.FCMService;
import dev.handsup.user.domain.User;
Expand All @@ -29,43 +31,20 @@ public class BiddingService {

private final BiddingRepository biddingRepository;
private final BiddingQueryRepository biddingQueryRepository;
private final AuctionService auctionService;
private final AuctionRepository auctionRepository;
private final FCMService fcmService;

private void validateBiddingPrice(int biddingPrice, Auction auction) {
Integer maxBiddingPrice = biddingRepository.findMaxBiddingPriceByAuctionId(auction.getId());

if (maxBiddingPrice == null) {
// 입찰 내역이 없는 경우, 최소 입찰가부터 입찰 가능
if (biddingPrice < auction.getInitPrice()) {
throw new ValidationException(BiddingErrorCode.BIDDING_PRICE_LESS_THAN_INIT_PRICE);
}
} else {
// 최고 입찰가보다 1000원 이상일 때만 입찰 가능
if (biddingPrice < (maxBiddingPrice + 1000)) {
throw new ValidationException(BiddingErrorCode.BIDDING_PRICE_NOT_HIGH_ENOUGH);
}
}
}

@Transactional
@DistributeLock(key = "'auction_' + #auctionId") // auctionId 값을 추출하여 락 키로 사용
public BiddingResponse registerBidding(RegisterBiddingRequest request, Long auctionId, User bidder) {
Auction auction = auctionService.getAuctionById(auctionId);
Auction auction = getAuctionById(auctionId);

if (auction.getSeller().equals(bidder)) {
throw new ValidationException(BiddingErrorCode.NOT_ALLOW_SELF_BIDDING);
}

validateBiddingPrice(request.biddingPrice(), auction);

Bidding savedBidding = biddingRepository.save(
BiddingMapper.toBidding(request, auction, bidder)
);
validateBiddingPrice(request.biddingPrice(), auction); // 경매 입찰 최고가보다 입찰가 높은지 확인
auction.updateCurrentBiddingPrice(request.biddingPrice()); // 경매 입찰 최고가 갱신
auction.increaseBiddingCount(); // 경매 입찰 수 + 1
Bidding bidding = BiddingMapper.toBidding(request.biddingPrice(), auction, bidder);

auction.updateCurrentBiddingPrice(savedBidding.getBiddingPrice());
auction.increaseBiddingCount();

return BiddingMapper.toBiddingResponse(savedBidding);
return BiddingMapper.toBiddingResponse(biddingRepository.save(bidding));
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -109,11 +88,32 @@ public BiddingResponse cancelTrading(Long biddingId, User user) {
return BiddingMapper.toBiddingResponse(bidding);
}

public Bidding findBiddingById(Long biddingId) {
public void validateBiddingPrice(int biddingPrice, Auction auction) {
Integer maxBiddingPrice = biddingRepository.findMaxBiddingPriceByAuctionId(auction.getId());

if (maxBiddingPrice == null) {
// 입찰 내역이 없는 경우, 최소 입찰가부터 입찰 가능
if (biddingPrice < auction.getInitPrice()) {
throw new ValidationException(BiddingErrorCode.BIDDING_PRICE_LESS_THAN_INIT_PRICE);
}
} else {
// 최고 입찰가보다 1000원 이상일 때만 입찰 가능
if (biddingPrice < (maxBiddingPrice + 1000)) {
throw new ValidationException(BiddingErrorCode.BIDDING_PRICE_NOT_HIGH_ENOUGH);
}
}
}

private Bidding findBiddingById(Long biddingId) {
return biddingRepository.findById(biddingId)
.orElseThrow(() -> new NotFoundException(BiddingErrorCode.NOT_FOUND_BIDDING));
}

private Auction getAuctionById(Long auctionId) {
return auctionRepository.findById(auctionId)
.orElseThrow(() -> new NotFoundException(AuctionErrorCode.NOT_FOUND_AUCTION));
}

private void sendMessage(User seller, Bidding bidding, NotificationType completedPurchaseTrading) {
fcmService.sendMessage(
seller.getEmail(),
Expand Down
26 changes: 26 additions & 0 deletions core/src/main/java/dev/handsup/common/config/RedissonConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dev.handsup.common.config;

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

@Configuration
public class RedissonConfig {
private static final String REDISSON_HOST_PREFIX = "redis://";
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;

@Bean
public RedissonClient redissonClient() {
RedissonClient redisson = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
redisson = Redisson.create(config);
return redisson;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package dev.handsup.common.redisson;

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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package dev.handsup.common.redisson;

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

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class CustomSpringELParser {

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

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

return parser.parseExpression(key).getValue(context, Object.class);
}
}
20 changes: 20 additions & 0 deletions core/src/main/java/dev/handsup/common/redisson/DistributeLock.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dev.handsup.common.redisson;

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;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface DistributeLock {

String key(); // 락 이름

TimeUnit timeUnit() default TimeUnit.SECONDS; // 시간 단위

long waitTime() default 5L; // 대기 시간

long leaseTime() default 3L; // 임대 시간
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package dev.handsup.common.redisson;

import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

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 dev.handsup.common.exception.ValidationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

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

private static final String REDISSON_KEY_PREFIX = "RLOCK_";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;

@Around("@annotation(dev.handsup.common.redisson.DistributeLock)")
public Object concurrencyLock(final ProceedingJoinPoint joinPoint) throws Throwable {

MethodSignature signature = (MethodSignature)joinPoint.getSignature();
Method method = signature.getMethod();
DistributeLock annotation = method.getAnnotation(DistributeLock.class);

final long waitTime = annotation.waitTime();
final long leaseTime = annotation.leaseTime();
final TimeUnit unit = annotation.timeUnit();
final String lockName = annotation.key();

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

try {
boolean hasLock = rLock.tryLock(waitTime, leaseTime, unit);

if (!hasLock) {
throw new ValidationException(LockErrorCode.FAILED_TO_GET_LOCK);
}

log.info("get lock success {}", key);
// 실제 수행 로직
return aopForTransaction.proceed(joinPoint);

} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.info("get lock fail {}", key);
throw new InterruptedException();
} finally {
rLock.unlock();
}
}
}
15 changes: 15 additions & 0 deletions core/src/main/java/dev/handsup/common/redisson/LockErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package dev.handsup.common.redisson;

import dev.handsup.common.exception.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum LockErrorCode implements ErrorCode {

FAILED_TO_GET_LOCK("주어진 시간 동안 락을 획득하는데 실패했습니다.", "rl_001");

private final String message;
private final String code;
}
Loading

0 comments on commit 5bb10f7

Please sign in to comment.