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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.redisson:redisson-spring-boot-starter:3.27.2'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public ResponseEntity<BaseResponse<CreateUserCouponResponseDto>> createUserCoupo
@PathVariable Long couponId,
@LoginUser Long userId
) {
CreateUserCouponResponseDto dto = couponService.createUserCoupon(userId, couponId);
CreateUserCouponResponseDto dto = couponService.createUserCouponWithDistributedLock(userId, couponId);

return BaseResponse.ok("쿠폰 발급 완료", dto, HttpStatus.CREATED);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository;
import com.threestar.trainus.domain.user.entity.User;
import com.threestar.trainus.domain.user.service.UserService;
import com.threestar.trainus.global.annotation.RedissonLock;
import com.threestar.trainus.global.annotation.DistributedLock;
import com.threestar.trainus.global.exception.domain.ErrorCode;
import com.threestar.trainus.global.exception.handler.BusinessException;

Expand All @@ -35,8 +35,80 @@ public class CouponService {
private final UserService userService;

@Transactional
@RedissonLock(value = "#couponId")
public CreateUserCouponResponseDto createUserCoupon(Long userId, Long couponId) {
public CreateUserCouponResponseDto createUserCouponWithoutLock(Long userId, Long couponId) {
User user = userService.getUserById(userId);
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND));
// 쿠폰 발급 종료시각이 지났으면 예외처리
if (LocalDateTime.now().isAfter(coupon.getCloseAt())) {
throw new BusinessException(ErrorCode.COUPON_EXPIRED);
}
//중복 발급 방지
boolean alreadyIssued = userCouponRepository.existsByUserIdAndCouponId(userId, couponId);
if (alreadyIssued) {
throw new BusinessException(ErrorCode.COUPON_ALREADY_ISSUED);
}

//쿠폰 오픈 시간 전이라면 예외 처리(모든 쿠폰 공통)
if (LocalDateTime.now().isBefore(coupon.getOpenAt())) {
throw new BusinessException(ErrorCode.COUPON_NOT_YET_OPEN);
}

if (coupon.getCategory() == CouponCategory.OPEN_RUN) {
//선착순 쿠폰 발급 시 수량이 소진되면 예외처리
if (coupon.getQuantity() <= 0) {
throw new BusinessException(ErrorCode.COUPON_BE_EXHAUSTED);
}
coupon.decreaseQuantity();
}

LocalDateTime expirationDate = coupon.getExpirationDate();

UserCoupon userCoupon = new UserCoupon(user, coupon, expirationDate);
userCouponRepository.save(userCoupon);

return UserCouponMapper.toCreateUserCouponResponseDto(userCoupon);
}

@Transactional
public CreateUserCouponResponseDto createUserCouponWithPessimisticLock(Long userId, Long couponId) {
User user = userService.getUserById(userId);
Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId)
.orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND));
// 쿠폰 발급 종료시각이 지났으면 예외처리
if (LocalDateTime.now().isAfter(coupon.getCloseAt())) {
throw new BusinessException(ErrorCode.COUPON_EXPIRED);
}
//중복 발급 방지
boolean alreadyIssued = userCouponRepository.existsByUserIdAndCouponId(userId, couponId);
if (alreadyIssued) {
throw new BusinessException(ErrorCode.COUPON_ALREADY_ISSUED);
}

//쿠폰 오픈 시간 전이라면 예외 처리(모든 쿠폰 공통)
if (LocalDateTime.now().isBefore(coupon.getOpenAt())) {
throw new BusinessException(ErrorCode.COUPON_NOT_YET_OPEN);
}

if (coupon.getCategory() == CouponCategory.OPEN_RUN) {
//선착순 쿠폰 발급 시 수량이 소진되면 예외처리
if (coupon.getQuantity() <= 0) {
throw new BusinessException(ErrorCode.COUPON_BE_EXHAUSTED);
}
coupon.decreaseQuantity();
}

LocalDateTime expirationDate = coupon.getExpirationDate();

UserCoupon userCoupon = new UserCoupon(user, coupon, expirationDate);
userCouponRepository.save(userCoupon);

return UserCouponMapper.toCreateUserCouponResponseDto(userCoupon);
}

@Transactional
@DistributedLock(key = "'coupon:' + #couponId")
public CreateUserCouponResponseDto createUserCouponWithDistributedLock(Long userId, Long couponId) {
User user = userService.getUserById(userId);
// Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId)
// .orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> createLessonAp
@PathVariable Long lessonId,
@LoginUser Long userId
) {
LessonApplicationResponseDto response = studentLessonService.applyToLesson(lessonId, userId);
// 잔여 좌석 확인 메서드
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithDistributedLock(lessonId, userId);
return BaseResponse.ok("레슨 신청 완료", response, HttpStatus.OK);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import com.threestar.trainus.domain.user.service.UserService;
import com.threestar.trainus.global.exception.domain.ErrorCode;
import com.threestar.trainus.global.exception.handler.BusinessException;
import com.threestar.trainus.global.annotation.DistributedLock;
import com.threestar.trainus.global.utils.PageLimitCalculator;

import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -148,7 +149,7 @@ public LessonDetailResponseDto getLessonDetail(Long lessonId) {
}

@Transactional
public LessonApplicationResponseDto applyToLesson(Long lessonId, Long userId) {
public LessonApplicationResponseDto applyToLessonWithoutLock(Long lessonId, Long userId) {
// 레슨 조회
Lesson lesson = adminLessonService.findLessonById(lessonId);

Expand Down Expand Up @@ -212,26 +213,109 @@ public LessonApplicationResponseDto applyToLesson(Long lessonId, Long userId) {
}

@Transactional
public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long userId) {
Lesson lesson = adminLessonService.findLessonByIdWithLock(lessonId); // 락적용 find 메서드
@DistributedLock(key = "'lesson_apply:' + #lessonId")
public LessonApplicationResponseDto applyToLessonWithDistributedLock(Long lessonId, Long userId) {
// 레슨 조회
Lesson lesson = adminLessonService.findLessonById(lessonId);

// 유저 조회
User user = userService.getUserById(userId);

// 개설자 신청 불가 체크
if (lesson.getLessonLeader().equals(userId)) {
throw new BusinessException(ErrorCode.LESSON_CREATOR_CANNOT_APPLY);
}

// 중복 체크
boolean alreadyParticipated = lessonParticipantRepository.existsByLessonIdAndUserId(lessonId, userId);
boolean alreadyApplied = lessonApplicationRepository.existsByLessonIdAndUserId(lessonId, userId);
if (alreadyParticipated || alreadyApplied) {
throw new BusinessException(ErrorCode.ALREADY_APPLIED);
}

// 레슨 상태 체크
if (lesson.getStatus() != LessonStatus.RECRUITING) {
throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE);
}

// 선착순 여부에 따라 저장 처리 분기
if (lesson.getOpenRun()) {
// 락 내부에서 정원 체크
if (lesson.getParticipantCount() >= lesson.getMaxParticipants()) {
throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE);
}
// 신청 시간 체크
if (java.time.LocalDateTime.now().isBefore(lesson.getOpenTime())) {
throw new BusinessException(ErrorCode.LESSON_NOT_YET_OPEN);
}

LessonParticipant participant = LessonParticipant.builder()
.lesson(lesson)
.user(user)
.build();
lessonParticipantRepository.save(participant);
lesson.incrementParticipantCount();

return LessonApplyMapper.toLessonApplicationResponseDto(
lesson.getId(),
user.getId(),
ApplicationStatus.APPROVED,
participant.getJoinAt()
);
} else {
// 신청만 등록
LessonApplication application = LessonApplication.builder()
.lesson(lesson)
.user(user)
.build();
lessonApplicationRepository.save(application);

return LessonApplyMapper.toLessonApplicationResponseDto(
lesson.getId(),
user.getId(),
ApplicationStatus.PENDING,
application.getCreatedAt()
);
}
}

@Transactional
public LessonApplicationResponseDto applyToLessonWithPessimisticLock(Long lessonId, Long userId) {
// 레슨 조회 (비관적 락)
Lesson lesson = lessonRepository.findByIdWithLock(lessonId)
.orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND));

// 유저 조회
User user = userService.getUserById(userId);

// 개설자 신청 불가 체크
if (lesson.getLessonLeader().equals(userId)) {
throw new BusinessException(ErrorCode.LESSON_CREATOR_CANNOT_APPLY);
}

// 중복 체크
boolean alreadyParticipated = lessonParticipantRepository.existsByLessonIdAndUserId(lessonId, userId);
boolean alreadyApplied = lessonApplicationRepository.existsByLessonIdAndUserId(lessonId, userId);
if (alreadyParticipated || alreadyApplied) {
throw new BusinessException(ErrorCode.ALREADY_APPLIED);
}

// 레슨 상태 체크
if (lesson.getStatus() != LessonStatus.RECRUITING) {
throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE);
}

// 선착순 여부에 따라 저장 처리 분기
if (lesson.getOpenRun()) {
// 락 내부에서 정원 체크
if (lesson.getParticipantCount() >= lesson.getMaxParticipants()) {
throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE);
}
// 신청 시간 체크
if (java.time.LocalDateTime.now().isBefore(lesson.getOpenTime())) {
throw new BusinessException(ErrorCode.LESSON_NOT_YET_OPEN);
}

LessonParticipant participant = LessonParticipant.builder()
.lesson(lesson)
.user(user)
Expand All @@ -246,6 +330,7 @@ public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long us
participant.getJoinAt()
);
} else {
// 신청만 등록
LessonApplication application = LessonApplication.builder()
.lesson(lesson)
.user(user)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,71 @@ public class TestConcurrencyController {
private final StudentLessonService studentLessonService;
private final TestUserService testUserService;

@PostMapping("/coupons/{couponId}")
@Operation(summary = "쿠폰 발급 동시성 테스트", description = "쿠폰을 발급받는 테스트 API")
public ResponseEntity<BaseResponse<CreateUserCouponResponseDto>> issueCouponForTest(
// 쿠폰 동시성 테스트
@PostMapping("/coupons/{couponId}/no-lock")
@Operation(summary = "쿠폰 발급 동시성 테스트 (락 미적용)", description = "쿠폰을 발급받는 테스트 API (락 미적용)")
public ResponseEntity<BaseResponse<CreateUserCouponResponseDto>> issueCouponNoLock(
@PathVariable Long couponId,
@RequestBody TestRequestDto testRequestDto
) {
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
CreateUserCouponResponseDto responseDto = couponService.createUserCoupon(user.getId(), couponId);
return BaseResponse.ok("쿠폰 발급 완료", responseDto, HttpStatus.CREATED);
CreateUserCouponResponseDto responseDto = couponService.createUserCouponWithoutLock(user.getId(), couponId);
return BaseResponse.ok("쿠폰 발급 완료 (락 미적용)", responseDto, HttpStatus.CREATED);
}

@PostMapping("/lessons/{lessonId}/application")
@Operation(summary = "레슨 신청 동시성 테스트", description = "레슨을 신청하는 테스트 API")
public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> applyToLessonForTest(
@PostMapping("/coupons/{couponId}/pessimistic-lock")
@Operation(summary = "쿠폰 발급 동시성 테스트 (비관적 락)", description = "쿠폰을 발급받는 테스트 API (비관적 락)")
public ResponseEntity<BaseResponse<CreateUserCouponResponseDto>> issueCouponPessimisticLock(
@PathVariable Long couponId,
@RequestBody TestRequestDto testRequestDto
) {
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
CreateUserCouponResponseDto responseDto = couponService.createUserCouponWithPessimisticLock(user.getId(), couponId);
return BaseResponse.ok("쿠폰 발급 완료 (비관적 락)", responseDto, HttpStatus.CREATED);
}

@PostMapping("/coupons/{couponId}/distributed-lock")
@Operation(summary = "쿠폰 발급 동시성 테스트 (분산 락)", description = "쿠폰을 발급받는 테스트 API (분산 락)")
public ResponseEntity<BaseResponse<CreateUserCouponResponseDto>> issueCouponDistributedLock(
@PathVariable Long couponId,
@RequestBody TestRequestDto testRequestDto
) {
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
CreateUserCouponResponseDto responseDto = couponService.createUserCouponWithDistributedLock(user.getId(), couponId);
return BaseResponse.ok("쿠폰 발급 완료 (분산 락)", responseDto, HttpStatus.CREATED);
}

// 레슨 신청 동시성 테스트
@PostMapping("/lessons/{lessonId}/application/no-lock")
@Operation(summary = "레슨 신청 동시성 테스트 (락 미적용)", description = "레슨을 신청하는 테스트 API (락 미적용)")
public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> applyToLessonNoLock(
@PathVariable Long lessonId,
@RequestBody TestRequestDto testRequestDto
) {
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithoutLock(lessonId, user.getId());
return BaseResponse.ok("레슨 신청 완료 (락 미적용)", response, HttpStatus.OK);
}

@PostMapping("/lessons/{lessonId}/application/pessimistic-lock")
@Operation(summary = "레슨 신청 동시성 테스트 (비관적 락)", description = "레슨을 신청하는 테스트 API (비관적 락)")
public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> applyToLessonPessimisticLock(
@PathVariable Long lessonId,
@RequestBody TestRequestDto testRequestDto
) {
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithPessimisticLock(lessonId, user.getId());
return BaseResponse.ok("레슨 신청 완료 (비관적 락)", response, HttpStatus.OK);
}

@PostMapping("/lessons/{lessonId}/application/distributed-lock")
@Operation(summary = "레슨 신청 동시성 테스트 (분산 락)", description = "레슨을 신청하는 테스트 API (분산 락)")
public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> applyToLessonDistributedLock(
@PathVariable Long lessonId,
@RequestBody TestRequestDto testRequestDto
) {
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithLock(lessonId, user.getId());
return BaseResponse.ok("레슨 신청 완료", response, HttpStatus.OK);
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithDistributedLock(lessonId, user.getId());
return BaseResponse.ok("레슨 신청 완료 (분산 락)", response, HttpStatus.OK);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.threestar.trainus.global.annotation;

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 {
String key();
long waitTime() default 20L;
long leaseTime() default 3L;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}

This file was deleted.

Loading