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

Lock으로 사용자 기준 특정 기능 동시 실행 막기 #112

Merged
merged 1 commit into from
Nov 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.ConcurrentModificationException;

@RestControllerAdvice(basePackages = "com.ayucoupon")
public class CouponExceptionHandler {

Expand All @@ -27,7 +29,13 @@ public ResponseEntity<String> handle(IllegalStateException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(e.getMessage());
}


@ExceptionHandler(ConcurrentModificationException.class)
public ResponseEntity<String> handle(ConcurrentModificationException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(e.getMessage());
}

@ExceptionHandler(BaseCustomException.class)
public ResponseEntity<String> handle(BaseCustomException e) {
return ResponseEntity.status(e.getStatus())
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/com/ayucoupon/common/lock/RunnerLock.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.ayucoupon.common.lock;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class RunnerLock {

private final Lock lock;
private int count;

public RunnerLock() {
this.lock = new ReentrantLock(true);
this.count = 0;
}

public boolean tryLock() {
return lock.tryLock();
}

public boolean tryLock(Long time, TimeUnit unit) throws InterruptedException {
return lock.tryLock(time, unit);
}

public void unLock() {
lock.unlock();
}

public int increase() {
return ++count;
}

public int decrease() {
return --count;
}

}
47 changes: 47 additions & 0 deletions src/main/java/com/ayucoupon/common/lock/UserExclusiveRunner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.ayucoupon.common.lock;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;

import java.time.Duration;
import java.util.ConcurrentModificationException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

@Component
@Slf4j
public class UserExclusiveRunner {

private final ConcurrentMap<Long, RunnerLock> map = new ConcurrentHashMap<>();

public <T> T call(Long userId, Duration tryLockTimeout, Supplier<T> f) {
Assert.notNull(tryLockTimeout, "tryLockTimeout must not be null");

RunnerLock lock = map.computeIfAbsent(userId, k -> new RunnerLock());
lock.increase();
try {
log.debug("Method \"{}\" tried to get lock of \"{}\"", f.getClass().getSimpleName(), this.getClass().getSimpleName());
if (lock.tryLock(tryLockTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
log.debug("User {} get lock of \"{}\"", userId, this.getClass().getSimpleName());
try {
return f.get();
} finally {
int count = lock.decrease();
if (count <= 0) {
map.remove(userId, lock);
}
log.debug("User {} release lock of \"{}\"", userId, this.getClass().getSimpleName());
lock.unLock();
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug("User {} fail to get Lock of \"{}\"", userId, this.getClass().getSimpleName());
throw new ConcurrentModificationException("동시에 같은 요청을 보낼 수 없습니다.");
}

}
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
package com.ayucoupon.usercoupon.service.issue;

import com.ayucoupon.common.lock.UserExclusiveRunner;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;

@Service
@RequiredArgsConstructor
@Transactional
public class IssueUserCouponService {

private final IssueUserCouponModule issueUserCouponModule;
private final IssueValidator issueValidator;
private final UserExclusiveRunner userExclusiveRunner;

public Long issue(IssueUserCouponCommand command) {
issueValidator.validate(command);
return issueUserCouponModule.issue(command);
return userExclusiveRunner.call(command.userId(),
Duration.ofSeconds(30),
() -> {
issueValidator.validate(command);
return issueUserCouponModule.issue(command);
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,39 @@ public void issueCouponForInvalidCoupon() {
.hasMessage("발급 요청된 쿠폰이 존재하지 않습니다.");
}

@DisplayName("동일한 사용자가 동시에 쿠폰 발급 요청을 보낼 수 없다.")
@Test
public void multiIssueCouponOfSameUser() throws InterruptedException {
//given
Long couponId = 1L;
Long userId = 1L;
long issueCount = 5L;
AtomicInteger duplicatedCouponExceptionCount = new AtomicInteger();

int couponLeftQuantity = (int) issueCount;
int numberOfThreads = (int) issueCount;

ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(couponLeftQuantity);

//when
IssueUserCouponCommand command = new IssueUserCouponCommand(userId, couponId);
for (int i = 1; i <= issueCount; i++) {
CompletableFuture
.runAsync(() -> issueCouponService.issue(command), service)
.whenComplete((result, error) -> {
if (error != null && error.getCause() instanceof DuplicatedCouponException)
duplicatedCouponExceptionCount.incrementAndGet();
latch.countDown();
});
}
latch.await();

// then
assertThat(duplicatedCouponExceptionCount.get()).isEqualTo(issueCount - 1);
}


@DisplayName("쿠폰을 발급하면, 쿠폰 재고가 감소한다.")
@Test
public void pessimisticLockIssueCouponTest() throws InterruptedException {
Expand Down