Skip to content
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 @@ -8,17 +8,14 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
import org.hibernate.type.SqlTypes;

@Entity
@Table(name = "coupons")
Expand All @@ -36,11 +33,11 @@ public class Coupon {
@Column(name = "coupon_name", nullable = false, length = 100)
private String couponName;

@Column(name = "description", length = 255)
@Column(name = "description")
private String description;

@Enumerated(EnumType.STRING)
@Column(name = "discount_type", nullable = false, columnDefinition = "coupon_discount_type")
@Column(name = "discount_type", nullable = false)
private CouponDiscountType discountType;

@Column(name = "discount_value", nullable = false)
Expand Down Expand Up @@ -68,7 +65,7 @@ public class Coupon {
private LocalDateTime validUntil;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, columnDefinition = "coupon_status")
@Column(name = "status", nullable = false)
private CouponStatus status;

@CreationTimestamp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public class UserCoupon {
private LocalDateTime usedAt;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, columnDefinition = "user_coupon_status")
@Column(name = "status", nullable = false)
private UserCouponStatus status;

@Version
Expand Down
3 changes: 1 addition & 2 deletions src/main/java/com/rentify/rentify_api/post/entity/Post.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.ArrayList;
Expand Down Expand Up @@ -49,7 +48,7 @@ public class Post {
@JoinColumn(name = "user_id", nullable = false)
private User user;

@OneToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public RentalResponse createRental(Long userId, RentalRequest request) {
.orElseThrow(UserNotFoundException::new);

// 게시글 조회
Post post = postRepository.findById(request.getPostId())
Post post = postRepository.findByIdWithPessimisticLock(request.getPostId())
.orElseThrow(() -> new NotFoundException("게시글을 찾을 수 없습니다."));

// 본인의 게시글 여부 확인
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package com.rentify.rentify_api.coupon.service;

import static org.assertj.core.api.Assertions.assertThat;

import com.rentify.rentify_api.common.filter.JwtAuthenticationFilter;
import com.rentify.rentify_api.common.jwt.JwtTokenProvider;
import com.rentify.rentify_api.coupon.entity.Coupon;
import com.rentify.rentify_api.coupon.entity.CouponDiscountType;
import com.rentify.rentify_api.coupon.entity.CouponStatus;
import com.rentify.rentify_api.coupon.repository.CouponRepository;
import com.rentify.rentify_api.coupon.repository.UserCouponRepository;
import com.rentify.rentify_api.user.entity.User;
import com.rentify.rentify_api.user.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@SpringBootTest
class CouponConcurrencyTest {

@Autowired
CouponService couponService;

@Autowired
CouponRepository couponRepository;

@Autowired
UserCouponRepository userCouponRepository;

@Autowired
UserRepository userRepository;

@MockitoBean
JwtTokenProvider jwtTokenProvider;

@MockitoBean
JavaMailSender mailSender;

@MockitoBean
JwtAuthenticationFilter jwtAuthenticationFilter;

private Coupon coupon;
private List<User> users;

@BeforeEach
void setUp() {
coupon = couponRepository.save(Coupon.builder()
.couponName("선착순 테스트 쿠폰")
.description("동시성 테스트용 쿠폰")
.discountValue(1000)
.discountType(CouponDiscountType.FIXED)
.maxDiscountAmount(1000)
.minOrderAmount(5000)
.totalQuantity((short) 10)
.issuedQuantity((short) 0)
.perUserLimit((short) 1)
.validFrom(LocalDateTime.now().minusDays(1))
.validUntil(LocalDateTime.now().plusDays(30))
.status(CouponStatus.ACTIVE)
.build());

users = new ArrayList<>();
for (int i = 0; i < 20; i++) {
users.add(userRepository.save(User.builder()
.name("유저" + i)
.email("coupon-test-" + i + "@test.com")
.password("password")
.build())
);
}
}

@AfterEach
void tearDown() {
userCouponRepository.deleteAll();
couponRepository.deleteAll();
userRepository.deleteAll();
}

@Test
@DisplayName("수량 10개 쿠폰에 20명이 동시 요청 시 정확히 10명만 발급 성공")
void concurrency_coupon_issue_should_limit_to_total_quantity() throws InterruptedException {
// given
int threadCount = 20;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch readyLatch = new CountDownLatch(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);

AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();

// when
for (int i = 0; i < threadCount; i++) {
final Long userId = users.get(i).getId();
executorService.execute(() -> {
readyLatch.countDown();
try {
startLatch.await();
couponService.issueCoupon(userId, coupon.getId());
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
doneLatch.countDown();
}
});
}

readyLatch.await();
startLatch.countDown();
doneLatch.await();

// then
long issuedCount = userCouponRepository.count();
Coupon updatedCoupon = couponRepository.findById(coupon.getId()).orElseThrow();

System.out.println("성공: " + successCount.get());
System.out.println("실패: " + failCount.get());
System.out.println("실제 발급 수: " + issuedCount);
System.out.println("쿠폰 issuedQuantity: " + updatedCoupon.getIssuedQuantity());

assertThat(successCount.get()).isEqualTo(10);
assertThat(failCount.get()).isEqualTo(10);
assertThat(issuedCount).isEqualTo(10);
assertThat(updatedCoupon.getIssuedQuantity()).isEqualTo((short) 10);
assertThat(updatedCoupon.getStatus()).isEqualTo(CouponStatus.INACTIVE);

executorService.shutdown();
}

@Test
@DisplayName("같은 유저가 동시에 여러 번 요청해도 perUserLimit 만큼만 발급됨")
void concurrency_same_user_should_respect_per_user_limit() throws InterruptedException {
// given
int threadCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch readyLatch = new CountDownLatch(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);

Long userId = users.get(0).getId();
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();

for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
readyLatch.countDown();
try {
startLatch.await();
couponService.issueCoupon(userId, coupon.getId());
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
doneLatch.countDown();
}
});
}

readyLatch.await();
startLatch.countDown();
doneLatch.await();

long userCouponCount = userCouponRepository.countByUserIdAndCouponId(userId,
coupon.getId());

System.out.println("같은 유저 - 성공: " + successCount.get());
System.out.println("같은 유저 - 실패: " + failCount.get());
System.out.println("같은 유저 발급 수: " + userCouponCount);

assertThat(successCount.get()).isEqualTo(1);
assertThat(userCouponCount).isEqualTo(1);

executorService.shutdown();
}
}
Loading