Skip to content

Commit ffd1f8c

Browse files
authored
test: 동시요청 비관적 락 적용, 미적용 최대참가자 초과 테스트 작성 (#116)
* test: 동시요청 락 미적용 최대참가자 초과 테스트 작성 * test: 동시요청 비관적 락 적용 최대참가자 초과 테스트 작성 * style: 기타 코드스타일 적용 누락 수정
1 parent 42f03b6 commit ffd1f8c

File tree

11 files changed

+290
-17
lines changed

11 files changed

+290
-17
lines changed

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ dependencies {
4343
testAnnotationProcessor 'org.projectlombok:lombok'
4444
testImplementation 'org.springframework.boot:spring-boot-starter-test'
4545
testImplementation 'org.springframework.security:spring-security-test'
46+
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
47+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
4648
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
4749
}
4850

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,57 @@ public LessonApplicationResponseDto applyToLesson(Long lessonId, Long userId) {
195195
}
196196
}
197197

198+
@Transactional
199+
public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long userId) {
200+
Lesson lesson = adminLessonService.findLessonByIdWithLock(lessonId); // 락적용 find 메서드
201+
202+
User user = userService.getUserById(userId);
203+
204+
if (lesson.getLessonLeader().equals(userId)) {
205+
throw new BusinessException(ErrorCode.LESSON_CREATOR_CANNOT_APPLY);
206+
}
207+
208+
boolean alreadyParticipated = lessonParticipantRepository.existsByLessonIdAndUserId(lessonId, userId);
209+
boolean alreadyApplied = lessonApplicationRepository.existsByLessonIdAndUserId(lessonId, userId);
210+
if (alreadyParticipated || alreadyApplied) {
211+
throw new BusinessException(ErrorCode.ALREADY_APPLIED);
212+
}
213+
214+
if (lesson.getStatus() != LessonStatus.RECRUITING) {
215+
throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE);
216+
}
217+
218+
if (lesson.getOpenRun()) {
219+
LessonParticipant participant = LessonParticipant.builder()
220+
.lesson(lesson)
221+
.user(user)
222+
.build();
223+
lessonParticipantRepository.save(participant);
224+
lesson.incrementParticipantCount(); // 비관적 락 덕분에 안전하게 증가
225+
226+
return LessonApplyMapper.toLessonApplicationResponseDto(
227+
lesson.getId(),
228+
user.getId(),
229+
ApplicationStatus.APPROVED,
230+
participant.getJoinAt()
231+
);
232+
} else {
233+
LessonApplication application = LessonApplication.builder()
234+
.lesson(lesson)
235+
.user(user)
236+
.build();
237+
lessonApplicationRepository.save(application);
238+
239+
return LessonApplyMapper.toLessonApplicationResponseDto(
240+
lesson.getId(),
241+
user.getId(),
242+
ApplicationStatus.PENDING,
243+
application.getCreatedAt()
244+
);
245+
}
246+
}
247+
248+
198249
@Transactional
199250
public void cancelLessonApplication(Long lessonId, Long userId) {
200251
// 레슨 조회

src/main/java/com/threestar/trainus/domain/lesson/teacher/controller/LocationCsvController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@ public ResponseEntity<Boolean> checkExists(
4040
boolean exists = locationCsvService.checkLocation(city, district, dong, ri);
4141
return ResponseEntity.ok(exists);
4242
}
43-
}
43+
}

src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/Location.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@ public Location(String city, String district, String dong, String ri) {
3131
this.dong = dong;
3232
this.ri = ri;
3333
}
34-
}
34+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
package com.threestar.trainus.domain.lesson.teacher.repository;
22

3+
import org.springframework.data.jpa.repository.Modifying;
4+
import org.springframework.data.jpa.repository.Query;
35
import org.springframework.data.repository.CrudRepository;
46

57
import com.threestar.trainus.domain.lesson.teacher.entity.LessonParticipant;
68

9+
import jakarta.transaction.Transactional;
10+
711
public interface LessonParticipantRepository extends CrudRepository<LessonParticipant, Long> {
812

913
boolean existsByLessonIdAndUserId(Long lessonId, Long userId);
14+
15+
long countByLessonId(Long lessonId);
16+
17+
@Modifying
18+
@Transactional
19+
@Query("DELETE FROM LessonParticipant lp WHERE lp.lesson.id = :lessonId")
20+
void deleteByLessonId(Long lessonId);
1021
}

src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
import org.springframework.data.domain.Page;
77
import org.springframework.data.domain.Pageable;
88
import org.springframework.data.jpa.repository.JpaRepository;
9+
import org.springframework.data.jpa.repository.Lock;
910
import org.springframework.data.jpa.repository.Query;
1011
import org.springframework.data.repository.query.Param;
1112

1213
import com.threestar.trainus.domain.lesson.teacher.entity.Category;
1314
import com.threestar.trainus.domain.lesson.teacher.entity.Lesson;
1415
import com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus;
1516

17+
import jakarta.persistence.LockModeType;
18+
1619
public interface LessonRepository extends JpaRepository<Lesson, Long> {
1720
//삭제되지 않은 레슨만 조회
1821
Optional<Lesson> findByIdAndDeletedAtIsNull(Long lessonId);
@@ -83,4 +86,8 @@ Page<Lesson> findBySearchConditions(
8386
@Param("search") String search,
8487
Pageable pageable
8588
);
89+
90+
@Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 적용
91+
@Query("SELECT l FROM Lesson l WHERE l.id = :lessonId")
92+
Optional<Lesson> findByIdWithLock(@Param("lessonId") Long lessonId);
8693
}

src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LocationRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ public interface LocationRepository extends JpaRepository<Location, Long> {
88
boolean existsByCityAndDistrictAndDongAndRi(String city, String district, String dong, String ri);
99

1010
boolean existsByCityAndDistrictAndDong(String city, String district, String dong);
11-
}
11+
}

src/main/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonService.java

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -336,27 +336,32 @@ public Lesson findLessonById(Long lessonId) {
336336
.orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND));
337337
}
338338

339+
public Lesson findLessonByIdWithLock(Long lessonId) {
340+
return lessonRepository.findByIdWithLock(lessonId)
341+
.orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND));
342+
}
339343
//레슨 application조회 및 검증
344+
340345
public LessonApplication findApplicationById(Long applicationId) {
341346
return lessonApplicationRepository.findById(applicationId)
342347
.orElseThrow(() -> new BusinessException(ErrorCode.LESSON_APPLICATION_NOT_FOUND));
343348
}
344-
345349
//강사 권한 검증
350+
346351
public void validateIsYourLesson(Lesson lesson, Long userId) {
347352
if (!lesson.getLessonLeader().equals(userId)) {
348353
throw new BusinessException(ErrorCode.LESSON_ACCESS_FORBIDDEN);
349354
}
350355
}
351-
352356
//레슨 삭제 여부 검증
357+
353358
public void validateLessonNotDeleted(Lesson lesson) {
354359
if (lesson.isDeleted()) {
355360
throw new BusinessException(ErrorCode.LESSON_NOT_FOUND);
356361
}
357362
}
358-
359363
//레슨 접근 권한 검증 -> 올린사람(강사)가 맞는지 체크
364+
360365
private Lesson validateLessonAccess(Long lessonId, Long userId) {
361366
// User 존재 확인
362367
User user = userService.getUserById(userId);
@@ -372,8 +377,8 @@ private Lesson validateLessonAccess(Long lessonId, Long userId) {
372377

373378
return lesson;
374379
}
375-
376380
//레슨 상태 검증
381+
377382
private ApplicationStatus validateStatus(String status) {
378383
if ("ALL".equals(status)) {
379384
return null; // ALL인 경우 null 반환해서 필터링 안함
@@ -385,17 +390,17 @@ private ApplicationStatus validateStatus(String status) {
385390
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS);
386391
}
387392
}
388-
389393
//레슨 신청 상태 검증
394+
390395
private LessonStatus validateLessonStatus(String status) {
391396
try {
392397
return LessonStatus.valueOf(status);
393398
} catch (IllegalArgumentException e) {
394399
throw new BusinessException(ErrorCode.INVALID_LESSON_STATUS);
395400
}
396401
}
397-
398402
//참가자가 있을때 수정 검증
403+
399404
private void validatePeopleInLessonUpdate(Lesson lesson, LessonUpdateRequestDto requestDto) {
400405
// 카테고리 변경 불가
401406
if (!lesson.getCategory().equals(requestDto.category())) {
@@ -428,8 +433,8 @@ private void validatePeopleInLessonUpdate(Lesson lesson, LessonUpdateRequestDto
428433
throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION);
429434
}
430435
}
431-
432436
//레슨 이미지 업데이트
437+
433438
private List<LessonImage> updateLessonImages(Lesson lesson, List<String> imageUrls) {
434439
// 기존 이미지 삭제
435440
List<LessonImage> existingImages = lessonImageRepository.findByLesson(lesson);
@@ -449,14 +454,14 @@ private List<LessonImage> updateLessonImages(Lesson lesson, List<String> imageUr
449454

450455
return lessonImageRepository.saveAll(newImages);
451456
}
452-
453457
//기본 정보 수정
458+
454459
private void updateBasicInfo(Lesson lesson, LessonUpdateRequestDto requestDto) {
455460
lesson.updateLessonName(requestDto.lessonName());
456461
lesson.updateDescription(requestDto.description());
457462
}
458-
459463
//제한되어 있는 필드 수정
464+
460465
private void updateRestrictedFields(Lesson lesson, LessonUpdateRequestDto requestDto, Long userId, Long lessonId) {
461466
// 시간 관련 검증
462467
if (requestDto.hasTimeChanges()) {
@@ -483,8 +488,8 @@ private void updateRestrictedFields(Lesson lesson, LessonUpdateRequestDto reques
483488
lesson.updateLocation(requestDto.city(), requestDto.district(), requestDto.dong());
484489
lesson.updateAddressDetail(requestDto.addressDetail());
485490
}
486-
487491
//레슨 이미지 수정
492+
488493
private List<LessonImage> updateLessonImagesIfNeeded(Lesson lesson, List<String> newImageUrls) {
489494
if (newImageUrls != null) {
490495
// 기존 이미지 삭제

src/main/java/com/threestar/trainus/domain/lesson/teacher/service/LocationCsvService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public void processCsv(MultipartFile file) {
3131
List<Location> locations = new ArrayList<>();
3232

3333
try (Reader reader = new BufferedReader(new InputStreamReader(file.getInputStream()));
34-
CSVReader csvReader = new CSVReaderBuilder(reader).withSkipLines(1).build()) {
34+
CSVReader csvReader = new CSVReaderBuilder(reader).withSkipLines(1).build()) {
3535

3636
String[] row;
3737
while ((row = csvReader.readNext()) != null) {

src/main/java/com/threestar/trainus/global/config/SwaggerConfig.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
import org.springframework.context.annotation.Bean;
44
import org.springframework.context.annotation.Configuration;
55

6+
import io.swagger.v3.oas.models.Components;
67
import io.swagger.v3.oas.models.OpenAPI;
78
import io.swagger.v3.oas.models.info.Info;
89
import io.swagger.v3.oas.models.security.SecurityRequirement;
910
import io.swagger.v3.oas.models.security.SecurityScheme;
10-
import io.swagger.v3.oas.models.Components;
1111

1212
@Configuration
1313
public class SwaggerConfig {
1414
@Bean
15-
public OpenAPI openAPI() {
15+
public OpenAPI openApi() {
1616
return new OpenAPI()
1717
.addSecurityItem(new SecurityRequirement().addList("session"))
1818
.components(new Components()
@@ -31,4 +31,4 @@ private Info apiInfo() {
3131
.description("운동 메이트 매칭 플랫폼의 API 명세서") // API에 대한 설명
3232
.version("1.0.0"); // API의 버전
3333
}
34-
}
34+
}

0 commit comments

Comments
 (0)