From a0b242076c8769b03bac7e146fb09f5ba828d1c0 Mon Sep 17 00:00:00 2001 From: Shanate Date: Wed, 8 Jan 2025 09:56:00 +0900 Subject: [PATCH 1/7] feat: add Optimistic Lock - entity,domain,mapper --- .../src/main/java/com/peauty/domain/review/Review.java | 1 + .../main/java/com/peauty/persistence/review/ReviewEntity.java | 3 +++ .../main/java/com/peauty/persistence/review/ReviewMapper.java | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/peauty-domain/src/main/java/com/peauty/domain/review/Review.java b/peauty-domain/src/main/java/com/peauty/domain/review/Review.java index 9a888509..1e4cad02 100644 --- a/peauty-domain/src/main/java/com/peauty/domain/review/Review.java +++ b/peauty-domain/src/main/java/com/peauty/domain/review/Review.java @@ -14,6 +14,7 @@ public class Review { private final ID id; // 리뷰 ID + @Getter private Integer version; // Optimistic Lock @Getter private final BiddingThread.ID threadId; // 입찰 스레드 ID @Getter private ReviewRating reviewRating; // 별점 @Getter private String contentDetail; // 상세리뷰 diff --git a/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewEntity.java b/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewEntity.java index 0a0bef04..d4357192 100644 --- a/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewEntity.java +++ b/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewEntity.java @@ -20,6 +20,9 @@ public class ReviewEntity extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Version + private Integer version; + @Column(name = "bidding_thread_id", nullable = false) private Long biddingThreadId; diff --git a/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewMapper.java b/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewMapper.java index 3e090649..28bb6431 100644 --- a/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewMapper.java +++ b/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewMapper.java @@ -22,6 +22,7 @@ public static ReviewEntity toReviewEntity(Review domain){ .reviewRating(domain.getReviewRating()) .contentDetail(domain.getContentDetail()) .contentGeneral(domain.getContentGenerals()) + .version(domain.getVersion()) .build(); } @@ -50,6 +51,7 @@ public static Review toReviewDomain(ReviewEntity entity, List .reviewImages(reviewImages) // TODO: null이 아닌데 null 체크를 해야 하는 것이 매우 이상. .reviewCreatedAt(entity.getCreatedAt() != null ? LocalDate.from(entity.getCreatedAt()) : null) + .version(entity.getVersion()) .build(); } @@ -83,8 +85,6 @@ public static Review toReviewDomain( .toList()) .reviewRating(reviewEntity.getReviewRating()) .build(); - - } } From 8192e66dc21b576fa3d3daf1c9ffb4770e374937 Mon Sep 17 00:00:00 2001 From: Shanate Date: Wed, 8 Jan 2025 11:04:01 +0900 Subject: [PATCH 2/7] feat: To use OptimisticLockException in Test --- peauty-domain/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/peauty-domain/build.gradle b/peauty-domain/build.gradle index 1cf8e098..b33e5abf 100644 --- a/peauty-domain/build.gradle +++ b/peauty-domain/build.gradle @@ -11,6 +11,8 @@ dependencies { testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' testImplementation 'org.mockito:mockito-core' testImplementation 'org.assertj:assertj-core' + + implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0' } test { From af065fb2d2600e121de74e4886a519ab8b9873cb Mon Sep 17 00:00:00 2001 From: Shanate Date: Tue, 14 Jan 2025 11:44:24 +0900 Subject: [PATCH 3/7] refactor: Domain -> Persistence(DB) --- peauty-domain/build.gradle | 1 - .../src/main/java/com/peauty/domain/review/Review.java | 1 - .../main/java/com/peauty/persistence/review/ReviewMapper.java | 2 -- 3 files changed, 4 deletions(-) diff --git a/peauty-domain/build.gradle b/peauty-domain/build.gradle index b33e5abf..b3f4c0f0 100644 --- a/peauty-domain/build.gradle +++ b/peauty-domain/build.gradle @@ -12,7 +12,6 @@ dependencies { testImplementation 'org.mockito:mockito-core' testImplementation 'org.assertj:assertj-core' - implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0' } test { diff --git a/peauty-domain/src/main/java/com/peauty/domain/review/Review.java b/peauty-domain/src/main/java/com/peauty/domain/review/Review.java index 1e4cad02..9a888509 100644 --- a/peauty-domain/src/main/java/com/peauty/domain/review/Review.java +++ b/peauty-domain/src/main/java/com/peauty/domain/review/Review.java @@ -14,7 +14,6 @@ public class Review { private final ID id; // 리뷰 ID - @Getter private Integer version; // Optimistic Lock @Getter private final BiddingThread.ID threadId; // 입찰 스레드 ID @Getter private ReviewRating reviewRating; // 별점 @Getter private String contentDetail; // 상세리뷰 diff --git a/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewMapper.java b/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewMapper.java index 28bb6431..fda3c0ed 100644 --- a/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewMapper.java +++ b/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewMapper.java @@ -22,7 +22,6 @@ public static ReviewEntity toReviewEntity(Review domain){ .reviewRating(domain.getReviewRating()) .contentDetail(domain.getContentDetail()) .contentGeneral(domain.getContentGenerals()) - .version(domain.getVersion()) .build(); } @@ -51,7 +50,6 @@ public static Review toReviewDomain(ReviewEntity entity, List .reviewImages(reviewImages) // TODO: null이 아닌데 null 체크를 해야 하는 것이 매우 이상. .reviewCreatedAt(entity.getCreatedAt() != null ? LocalDate.from(entity.getCreatedAt()) : null) - .version(entity.getVersion()) .build(); } From 01491273d95a9b19bed88f36b1cb0561b65dc5e4 Mon Sep 17 00:00:00 2001 From: Shanate Date: Tue, 14 Jan 2025 15:02:19 +0900 Subject: [PATCH 4/7] feat: create ReviewServiceFacade class --- .../business/review/ReviewServiceFacade.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 peauty-customer-api/src/main/java/com/peauty/customer/business/review/ReviewServiceFacade.java diff --git a/peauty-customer-api/src/main/java/com/peauty/customer/business/review/ReviewServiceFacade.java b/peauty-customer-api/src/main/java/com/peauty/customer/business/review/ReviewServiceFacade.java new file mode 100644 index 00000000..a5c16840 --- /dev/null +++ b/peauty-customer-api/src/main/java/com/peauty/customer/business/review/ReviewServiceFacade.java @@ -0,0 +1,36 @@ +package com.peauty.customer.business.review; + +import com.peauty.customer.business.review.dto.RegisterReviewCommand; +import com.peauty.customer.business.review.dto.RegisterReviewResult; +import com.peauty.domain.exception.PeautyException; +import com.peauty.domain.response.PeautyResponseCode; +import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReviewServiceFacade { + + private final ReviewService reviewService; + + @Transactional + public RegisterReviewResult registerReviewWithRetry( + Long userId, + Long puppyId, + Long processId, + Long threadId, + RegisterReviewCommand command + ) throws InterruptedException { + + while (true) { + try { + return reviewService.registerReview(userId, puppyId, processId, threadId, command); + } catch (ObjectOptimisticLockingFailureException e) { + Thread.sleep(30); + + } + } + } +} \ No newline at end of file From 0040211a34657088feb4479a1f740714eb7b28a5 Mon Sep 17 00:00:00 2001 From: Shanate Date: Tue, 14 Jan 2025 20:06:11 +0900 Subject: [PATCH 5/7] feat: Add Column Version in WorkspaceEntity --- .../peauty/persistence/designer/workspace/WorkspaceEntity.java | 3 +++ .../main/java/com/peauty/persistence/review/ReviewEntity.java | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/peauty-persistence/src/main/java/com/peauty/persistence/designer/workspace/WorkspaceEntity.java b/peauty-persistence/src/main/java/com/peauty/persistence/designer/workspace/WorkspaceEntity.java index c8f77e13..ca49bd15 100644 --- a/peauty-persistence/src/main/java/com/peauty/persistence/designer/workspace/WorkspaceEntity.java +++ b/peauty-persistence/src/main/java/com/peauty/persistence/designer/workspace/WorkspaceEntity.java @@ -69,4 +69,7 @@ public class WorkspaceEntity extends BaseTimeEntity { @Column(name = "phone_number", nullable = false) private String phoneNumber; + @Version + private Integer version; + } diff --git a/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewEntity.java b/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewEntity.java index d4357192..0a0bef04 100644 --- a/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewEntity.java +++ b/peauty-persistence/src/main/java/com/peauty/persistence/review/ReviewEntity.java @@ -20,9 +20,6 @@ public class ReviewEntity extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Version - private Integer version; - @Column(name = "bidding_thread_id", nullable = false) private Long biddingThreadId; From 01cf49bb9718d1454d73ea139f35c971543b44a0 Mon Sep 17 00:00:00 2001 From: Shanate Date: Wed, 15 Jan 2025 14:35:36 +0900 Subject: [PATCH 6/7] refactor: remove ReviewServiceFacade --- .../business/review/ReviewServiceFacade.java | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 peauty-customer-api/src/main/java/com/peauty/customer/business/review/ReviewServiceFacade.java diff --git a/peauty-customer-api/src/main/java/com/peauty/customer/business/review/ReviewServiceFacade.java b/peauty-customer-api/src/main/java/com/peauty/customer/business/review/ReviewServiceFacade.java deleted file mode 100644 index a5c16840..00000000 --- a/peauty-customer-api/src/main/java/com/peauty/customer/business/review/ReviewServiceFacade.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.peauty.customer.business.review; - -import com.peauty.customer.business.review.dto.RegisterReviewCommand; -import com.peauty.customer.business.review.dto.RegisterReviewResult; -import com.peauty.domain.exception.PeautyException; -import com.peauty.domain.response.PeautyResponseCode; -import lombok.RequiredArgsConstructor; -import org.springframework.orm.ObjectOptimisticLockingFailureException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class ReviewServiceFacade { - - private final ReviewService reviewService; - - @Transactional - public RegisterReviewResult registerReviewWithRetry( - Long userId, - Long puppyId, - Long processId, - Long threadId, - RegisterReviewCommand command - ) throws InterruptedException { - - while (true) { - try { - return reviewService.registerReview(userId, puppyId, processId, threadId, command); - } catch (ObjectOptimisticLockingFailureException e) { - Thread.sleep(30); - - } - } - } -} \ No newline at end of file From c3a17ca795763a5e694cbfbcfebac167e1a609d2 Mon Sep 17 00:00:00 2001 From: Shanate Date: Wed, 15 Jan 2025 15:37:01 +0900 Subject: [PATCH 7/7] feat: Resolve Concurrency Issue for Logic --- .../workspace/WorkspaceAdapter.java | 41 +++++++++++++------ .../domain/response/PeautyResponseCode.java | 1 + .../workspace/WorkspaceRepository.java | 6 +++ 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/peauty-customer-api/src/main/java/com/peauty/customer/implementaion/workspace/WorkspaceAdapter.java b/peauty-customer-api/src/main/java/com/peauty/customer/implementaion/workspace/WorkspaceAdapter.java index a04710bf..e969e06a 100644 --- a/peauty-customer-api/src/main/java/com/peauty/customer/implementaion/workspace/WorkspaceAdapter.java +++ b/peauty-customer-api/src/main/java/com/peauty/customer/implementaion/workspace/WorkspaceAdapter.java @@ -18,7 +18,9 @@ import com.peauty.persistence.designer.workspace.WorkspaceEntity; import com.peauty.persistence.designer.workspace.WorkspaceRepository; import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -100,18 +102,33 @@ public Workspace findByDesignerId(Long userId) { } @Override - public Workspace registerReviewStats(Long designerId, ReviewRating newRating) { - WorkspaceEntity workspaceEntity = workspaceRepository.getByDesignerId(designerId) - .orElseThrow(() -> new PeautyException(PeautyResponseCode.NOT_EXIST_WORKSPACE)); - List bannerImageEntities = bannerImageRepository.findByWorkspaceId(workspaceEntity.getId()); - Workspace workspace = WorkspaceMapper.toDomain(workspaceEntity, bannerImageEntities); - // 리뷰 작성 로직 - workspace.registerReviewStats(newRating); - // 엔티티 변환 후 저장 - workspaceEntity = WorkspaceMapper.toEntity(workspace, designerId); - workspaceRepository.save(workspaceEntity); - - return workspace; + @Transactional + public Workspace registerReviewStats(Long designerId, ReviewRating newRating +// TODO: InterruptedException을 WorkspacePort에서 Workspace registerReviewStats(Long designerId, ReviewRating newRating) throws InterruptedException; 이렇게 걸어주는 것이 맞을까? +// 아니면 내부 try-catch에서 InterruptedException을 거는게 맞을까? + ) { + while (true) { + try { + WorkspaceEntity workspaceEntity = workspaceRepository.findByDesignerIdWithOptimisticLock(designerId) // Workspace 엔티티에서 id를 호출해올 때, 버전을 확인 + .orElseThrow(() -> new PeautyException(PeautyResponseCode.NOT_EXIST_WORKSPACE)); + List bannerImageEntities = bannerImageRepository.findByWorkspaceId(workspaceEntity.getId()); + Workspace workspace = WorkspaceMapper.toDomain(workspaceEntity, bannerImageEntities); + // 리뷰 작성 로직 + workspace.registerReviewStats(newRating); + // 엔티티 변환 후 저장 + workspaceEntity = WorkspaceMapper.toEntity(workspace, designerId); + workspaceRepository.save(workspaceEntity); + + return workspace; + } catch (ObjectOptimisticLockingFailureException e) { + try { + Thread.sleep(10); // 10ms 대기 // 저장 작업을 수행하는 하나의 실행 단위(Thread) + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); // 현재 스레드의 인터럽트 상태 복구 + throw new PeautyException(PeautyResponseCode.INTERNAL_SERVER_MAINTENANCE); + } + } + } } public Workspace updateReviewStats(Long designerId, ReviewRating oldRating, ReviewRating newRating) { diff --git a/peauty-domain/src/main/java/com/peauty/domain/response/PeautyResponseCode.java b/peauty-domain/src/main/java/com/peauty/domain/response/PeautyResponseCode.java index fed8dec9..c18b4d4f 100644 --- a/peauty-domain/src/main/java/com/peauty/domain/response/PeautyResponseCode.java +++ b/peauty-domain/src/main/java/com/peauty/domain/response/PeautyResponseCode.java @@ -51,6 +51,7 @@ public enum PeautyResponseCode { INVALID_BIRTHDATE("1229", "Invalid Your Puppy Birthday", "현재 날짜보다 이후를 선택했습니다."), CONTAINS_NON_EXISTING_DESIGNERS("1230", "Designer Not Found", "존재하지 않는 디자이너가 포함되어있습니다."), NOT_FOUND_REVIEWER_WRITTEN_REVIEW("1231", "Not found reviewer written review", "해당 리뷰를 작성한 사용자를 찾을 수 없습니다."), + INTERNAL_SERVER_MAINTENANCE("1232", "Performing Internal Server Maintenance. Try Later", "내부 서버 점검 중이니, 잠시 후 다시 등록해주세요."), // 비딩 관련 (1300 ~ 1350) WRONG_BIDDING_PROCESS_STEP_DESCRIPTION("1300", "Wrong Bidding Process Step Description", "잘못된 입찰 프로세스입니다."), diff --git a/peauty-persistence/src/main/java/com/peauty/persistence/designer/workspace/WorkspaceRepository.java b/peauty-persistence/src/main/java/com/peauty/persistence/designer/workspace/WorkspaceRepository.java index fe6008e5..9850f0f8 100644 --- a/peauty-persistence/src/main/java/com/peauty/persistence/designer/workspace/WorkspaceRepository.java +++ b/peauty-persistence/src/main/java/com/peauty/persistence/designer/workspace/WorkspaceRepository.java @@ -1,6 +1,8 @@ package com.peauty.persistence.designer.workspace; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -21,4 +23,8 @@ @Query("SELECT w FROM WorkspaceEntity w WHERE w.address LIKE CONCAT(:baseAddress, '%')") List findByBaseAddress(@Param("baseAddress") String baseAddress); + @Lock(LockModeType.OPTIMISTIC) + @Query("SELECT w FROM WorkspaceEntity w WHERE w.designerId = :designerId") + Optional findByDesignerIdWithOptimisticLock(@Param("designerId") Long designerId); + }