Skip to content

Commit 0aad27d

Browse files
committed
feat: 락 테스트 API 케이스 분리 및 메서드명 재정립
1 parent cd9f132 commit 0aad27d

File tree

6 files changed

+204
-19
lines changed

6 files changed

+204
-19
lines changed

src/main/java/com/threestar/trainus/domain/coupon/user/controller/CouponController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public ResponseEntity<BaseResponse<CreateUserCouponResponseDto>> createUserCoupo
3535
@PathVariable Long couponId,
3636
@LoginUser Long userId
3737
) {
38-
CreateUserCouponResponseDto dto = couponService.createUserCoupon(userId, couponId);
38+
CreateUserCouponResponseDto dto = couponService.createUserCouponWithDistributedLock(userId, couponId);
3939

4040
return BaseResponse.ok("쿠폰 발급 완료", dto, HttpStatus.CREATED);
4141
}

src/main/java/com/threestar/trainus/domain/coupon/user/service/CouponService.java

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,81 @@ public class CouponService {
3434
private final UserCouponRepository userCouponRepository;
3535
private final UserService userService;
3636

37+
@Transactional
38+
public CreateUserCouponResponseDto createUserCouponWithoutLock(Long userId, Long couponId) {
39+
User user = userService.getUserById(userId);
40+
Coupon coupon = couponRepository.findById(couponId)
41+
.orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND));
42+
// 쿠폰 발급 종료시각이 지났으면 예외처리
43+
if (LocalDateTime.now().isAfter(coupon.getCloseAt())) {
44+
throw new BusinessException(ErrorCode.COUPON_EXPIRED);
45+
}
46+
//중복 발급 방지
47+
boolean alreadyIssued = userCouponRepository.existsByUserIdAndCouponId(userId, couponId);
48+
if (alreadyIssued) {
49+
throw new BusinessException(ErrorCode.COUPON_ALREADY_ISSUED);
50+
}
51+
52+
//쿠폰 오픈 시간 전이라면 예외 처리(모든 쿠폰 공통)
53+
if (LocalDateTime.now().isBefore(coupon.getOpenAt())) {
54+
throw new BusinessException(ErrorCode.COUPON_NOT_YET_OPEN);
55+
}
56+
57+
if (coupon.getCategory() == CouponCategory.OPEN_RUN) {
58+
//선착순 쿠폰 발급 시 수량이 소진되면 예외처리
59+
if (coupon.getQuantity() <= 0) {
60+
throw new BusinessException(ErrorCode.COUPON_BE_EXHAUSTED);
61+
}
62+
coupon.decreaseQuantity();
63+
}
64+
65+
LocalDateTime expirationDate = coupon.getExpirationDate();
66+
67+
UserCoupon userCoupon = new UserCoupon(user, coupon, expirationDate);
68+
userCouponRepository.save(userCoupon);
69+
70+
return UserCouponMapper.toCreateUserCouponResponseDto(userCoupon);
71+
}
72+
73+
@Transactional
74+
public CreateUserCouponResponseDto createUserCouponWithPessimisticLock(Long userId, Long couponId) {
75+
User user = userService.getUserById(userId);
76+
Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId)
77+
.orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND));
78+
// 쿠폰 발급 종료시각이 지났으면 예외처리
79+
if (LocalDateTime.now().isAfter(coupon.getCloseAt())) {
80+
throw new BusinessException(ErrorCode.COUPON_EXPIRED);
81+
}
82+
//중복 발급 방지
83+
boolean alreadyIssued = userCouponRepository.existsByUserIdAndCouponId(userId, couponId);
84+
if (alreadyIssued) {
85+
throw new BusinessException(ErrorCode.COUPON_ALREADY_ISSUED);
86+
}
87+
88+
//쿠폰 오픈 시간 전이라면 예외 처리(모든 쿠폰 공통)
89+
if (LocalDateTime.now().isBefore(coupon.getOpenAt())) {
90+
throw new BusinessException(ErrorCode.COUPON_NOT_YET_OPEN);
91+
}
92+
93+
if (coupon.getCategory() == CouponCategory.OPEN_RUN) {
94+
//선착순 쿠폰 발급 시 수량이 소진되면 예외처리
95+
if (coupon.getQuantity() <= 0) {
96+
throw new BusinessException(ErrorCode.COUPON_BE_EXHAUSTED);
97+
}
98+
coupon.decreaseQuantity();
99+
}
100+
101+
LocalDateTime expirationDate = coupon.getExpirationDate();
102+
103+
UserCoupon userCoupon = new UserCoupon(user, coupon, expirationDate);
104+
userCouponRepository.save(userCoupon);
105+
106+
return UserCouponMapper.toCreateUserCouponResponseDto(userCoupon);
107+
}
108+
37109
@Transactional
38110
@DistributedLock(key = "'coupon:' + #couponId")
39-
public CreateUserCouponResponseDto createUserCoupon(Long userId, Long couponId) {
111+
public CreateUserCouponResponseDto createUserCouponWithDistributedLock(Long userId, Long couponId) {
40112
User user = userService.getUserById(userId);
41113
// Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId)
42114
// .orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND));

src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> createLessonAp
8181
@LoginUser Long userId
8282
) {
8383
// 잔여 좌석 확인 메서드
84-
LessonApplicationResponseDto response = studentLessonService.applyToLesson(lessonId, userId);
84+
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithDistributedLock(lessonId, userId);
8585
return BaseResponse.ok("레슨 신청 완료", response, HttpStatus.OK);
8686
}
8787

src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ public LessonDetailResponseDto getLessonDetail(Long lessonId) {
149149
}
150150

151151
@Transactional
152-
public LessonApplicationResponseDto applyToLesson(Long lessonId, Long userId) {
152+
public LessonApplicationResponseDto applyToLessonWithoutLock(Long lessonId, Long userId) {
153153
// 레슨 조회
154154
Lesson lesson = adminLessonService.findLessonById(lessonId);
155155

@@ -214,7 +214,7 @@ public LessonApplicationResponseDto applyToLesson(Long lessonId, Long userId) {
214214

215215
@Transactional
216216
@DistributedLock(key = "'lesson_apply:' + #lessonId")
217-
public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long userId) {
217+
public LessonApplicationResponseDto applyToLessonWithDistributedLock(Long lessonId, Long userId) {
218218
// 레슨 조회
219219
Lesson lesson = adminLessonService.findLessonById(lessonId);
220220

@@ -279,6 +279,73 @@ public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long us
279279
}
280280
}
281281

282+
@Transactional
283+
public LessonApplicationResponseDto applyToLessonWithPessimisticLock(Long lessonId, Long userId) {
284+
// 레슨 조회 (비관적 락)
285+
Lesson lesson = lessonRepository.findByIdWithLock(lessonId)
286+
.orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND));
287+
288+
// 유저 조회
289+
User user = userService.getUserById(userId);
290+
291+
// 개설자 신청 불가 체크
292+
if (lesson.getLessonLeader().equals(userId)) {
293+
throw new BusinessException(ErrorCode.LESSON_CREATOR_CANNOT_APPLY);
294+
}
295+
296+
// 중복 체크
297+
boolean alreadyParticipated = lessonParticipantRepository.existsByLessonIdAndUserId(lessonId, userId);
298+
boolean alreadyApplied = lessonApplicationRepository.existsByLessonIdAndUserId(lessonId, userId);
299+
if (alreadyParticipated || alreadyApplied) {
300+
throw new BusinessException(ErrorCode.ALREADY_APPLIED);
301+
}
302+
303+
// 레슨 상태 체크
304+
if (lesson.getStatus() != LessonStatus.RECRUITING) {
305+
throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE);
306+
}
307+
308+
// 선착순 여부에 따라 저장 처리 분기
309+
if (lesson.getOpenRun()) {
310+
// 락 내부에서 정원 체크
311+
if (lesson.getParticipantCount() >= lesson.getMaxParticipants()) {
312+
throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE);
313+
}
314+
// 신청 시간 체크
315+
if (java.time.LocalDateTime.now().isBefore(lesson.getOpenTime())) {
316+
throw new BusinessException(ErrorCode.LESSON_NOT_YET_OPEN);
317+
}
318+
319+
LessonParticipant participant = LessonParticipant.builder()
320+
.lesson(lesson)
321+
.user(user)
322+
.build();
323+
lessonParticipantRepository.save(participant);
324+
lesson.incrementParticipantCount();
325+
326+
return LessonApplyMapper.toLessonApplicationResponseDto(
327+
lesson.getId(),
328+
user.getId(),
329+
ApplicationStatus.APPROVED,
330+
participant.getJoinAt()
331+
);
332+
} else {
333+
// 신청만 등록
334+
LessonApplication application = LessonApplication.builder()
335+
.lesson(lesson)
336+
.user(user)
337+
.build();
338+
lessonApplicationRepository.save(application);
339+
340+
return LessonApplyMapper.toLessonApplicationResponseDto(
341+
lesson.getId(),
342+
user.getId(),
343+
ApplicationStatus.PENDING,
344+
application.getCreatedAt()
345+
);
346+
}
347+
}
348+
282349
@Transactional
283350
public void cancelLessonApplication(Long lessonId, Long userId) {
284351
// 레슨 조회

src/main/java/com/threestar/trainus/domain/test/controller/TestConcurrencyController.java

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,25 +27,71 @@ public class TestConcurrencyController {
2727
private final StudentLessonService studentLessonService;
2828
private final TestUserService testUserService;
2929

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

41-
@PostMapping("/lessons/{lessonId}/application")
42-
@Operation(summary = "레슨 신청 동시성 테스트", description = "레슨을 신청하는 테스트 API")
43-
public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> applyToLessonForTest(
42+
@PostMapping("/coupons/{couponId}/pessimistic-lock")
43+
@Operation(summary = "쿠폰 발급 동시성 테스트 (비관적 락)", description = "쿠폰을 발급받는 테스트 API (비관적 락)")
44+
public ResponseEntity<BaseResponse<CreateUserCouponResponseDto>> issueCouponPessimisticLock(
45+
@PathVariable Long couponId,
46+
@RequestBody TestRequestDto testRequestDto
47+
) {
48+
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
49+
CreateUserCouponResponseDto responseDto = couponService.createUserCouponWithPessimisticLock(user.getId(), couponId);
50+
return BaseResponse.ok("쿠폰 발급 완료 (비관적 락)", responseDto, HttpStatus.CREATED);
51+
}
52+
53+
@PostMapping("/coupons/{couponId}/distributed-lock")
54+
@Operation(summary = "쿠폰 발급 동시성 테스트 (분산 락)", description = "쿠폰을 발급받는 테스트 API (분산 락)")
55+
public ResponseEntity<BaseResponse<CreateUserCouponResponseDto>> issueCouponDistributedLock(
56+
@PathVariable Long couponId,
57+
@RequestBody TestRequestDto testRequestDto
58+
) {
59+
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
60+
CreateUserCouponResponseDto responseDto = couponService.createUserCouponWithDistributedLock(user.getId(), couponId);
61+
return BaseResponse.ok("쿠폰 발급 완료 (분산 락)", responseDto, HttpStatus.CREATED);
62+
}
63+
64+
// 레슨 신청 동시성 테스트
65+
@PostMapping("/lessons/{lessonId}/application/no-lock")
66+
@Operation(summary = "레슨 신청 동시성 테스트 (락 미적용)", description = "레슨을 신청하는 테스트 API (락 미적용)")
67+
public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> applyToLessonNoLock(
68+
@PathVariable Long lessonId,
69+
@RequestBody TestRequestDto testRequestDto
70+
) {
71+
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
72+
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithoutLock(lessonId, user.getId());
73+
return BaseResponse.ok("레슨 신청 완료 (락 미적용)", response, HttpStatus.OK);
74+
}
75+
76+
@PostMapping("/lessons/{lessonId}/application/pessimistic-lock")
77+
@Operation(summary = "레슨 신청 동시성 테스트 (비관적 락)", description = "레슨을 신청하는 테스트 API (비관적 락)")
78+
public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> applyToLessonPessimisticLock(
79+
@PathVariable Long lessonId,
80+
@RequestBody TestRequestDto testRequestDto
81+
) {
82+
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
83+
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithPessimisticLock(lessonId, user.getId());
84+
return BaseResponse.ok("레슨 신청 완료 (비관적 락)", response, HttpStatus.OK);
85+
}
86+
87+
@PostMapping("/lessons/{lessonId}/application/distributed-lock")
88+
@Operation(summary = "레슨 신청 동시성 테스트 (분산 락)", description = "레슨을 신청하는 테스트 API (분산 락)")
89+
public ResponseEntity<BaseResponse<LessonApplicationResponseDto>> applyToLessonDistributedLock(
4490
@PathVariable Long lessonId,
4591
@RequestBody TestRequestDto testRequestDto
4692
) {
4793
User user = testUserService.findOrCreateUser(testRequestDto.getUserId());
48-
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithLock(lessonId, user.getId());
49-
return BaseResponse.ok("레슨 신청 완료", response, HttpStatus.OK);
94+
LessonApplicationResponseDto response = studentLessonService.applyToLessonWithDistributedLock(lessonId, user.getId());
95+
return BaseResponse.ok("레슨 신청 완료 (분산 락)", response, HttpStatus.OK);
5096
}
5197
}

src/test/java/com/threestar/trainus/domain/lesson/student/LessonApplyLockTest.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ void tearDown() {
116116
try {
117117
Lesson updatedLesson = lessonRepository.findById(lessonId).orElseThrow();
118118
User user = userRepository.findByEmail(email).orElseThrow();
119-
lessonService.applyToLesson(lessonId, user.getId());
119+
lessonService.applyToLessonWithoutLock(lessonId, user.getId());
120120
log.info("신청 성공 - email: {} | participantCount: {}", email, updatedLesson.getParticipantCount());
121121
successCount.incrementAndGet();
122122
} catch (Exception e) {
@@ -147,8 +147,8 @@ void tearDown() {
147147
}
148148

149149
@Test
150-
@DisplayName("동시 요청 시 - 적용: 인원 수 초과 없이 정상 처리")
151-
void applyToLessonWithLock_동시요청_락적용_최대참가자수를초과하지않음() throws InterruptedException {
150+
@DisplayName("동시 요청 시 - 비관락 적용: 인원 수 초과 없이 정상 처리")
151+
void applyToLessonWithLock_동시요청_비관락적용_최대참가자수를초과하지않음() throws InterruptedException {
152152
ExecutorService executor = Executors.newFixedThreadPool(100); // 병렬 쓰레드 수 조절
153153
CountDownLatch latch = new CountDownLatch(CONCURRENT_USERS);
154154

@@ -165,7 +165,7 @@ void tearDown() {
165165
try {
166166
Lesson updatedLesson = lessonRepository.findById(lessonId).orElseThrow();
167167
User user = userRepository.findByEmail(email).orElseThrow();
168-
lessonService.applyToLessonWithLock(lessonId, user.getId());
168+
lessonService.applyToLessonWithPessimisticLock(lessonId, user.getId());
169169
log.info("신청 성공 - email: {} | participantCount: {}", email, updatedLesson.getParticipantCount());
170170
successCount.incrementAndGet();
171171
} catch (Exception e) {

0 commit comments

Comments
 (0)