From fc9c06def2002f2096a495daa9965de883118ea9 Mon Sep 17 00:00:00 2001 From: Minseong Park <52368015+pminsung12@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:28:41 +0900 Subject: [PATCH] =?UTF-8?q?[No-jira]=20=EB=82=99=EA=B4=80=EB=9D=BD?= =?UTF-8?q?=EA=B3=BC=20Spring=20Retry=EB=A5=BC=20=ED=86=B5=ED=95=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=A0=9C=EC=96=B4=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 리뷰 엔티티와 도메인에 @version 컬럼 추가 * feat: 리뷰 엔티티에 likesCount 증감 메서드 추가 * feat: 리뷰 레포지토리에 낙관락 기반으로 좋아요 수 업데이트 구현 * feat: 리뷰엔티티 version 컬럼 디폴트 0L 설정 * feat: 리뷰 좋아요 토글 메서드에 낙관락 및 재시도 로직 구현 * test: 멀티스레드 낙관락 정합성 테스트 * feat: setter로 hibernate 변경 감지 가능하도록 변경 * feat: spring retry 적용헤 재시도 로직 구현 --- .../review/like/ReviewLikeController.java | 4 +- .../application/ReviewLikeServiceTest.java | 248 +++++++++--------- .../src/test/resources/application-test.yml | 6 +- .../sql/review-like-service-data.sql | 183 ++++++++++--- .../exception/review/ReviewException.java | 4 +- .../depromeet/spot/domain/review/Review.java | 8 +- .../jpa/review/entity/ReviewEntity.java | 13 +- .../repository/ReviewJpaRepository.java | 8 + .../repository/ReviewRepositoryImpl.java | 28 +- .../like/ReviewLikeRepositoryImpl.java | 7 +- usecase/build.gradle.kts | 5 + .../port/in/review/UpdateReviewUsecase.java | 2 - .../port/out/review/ReviewLikeRepository.java | 4 +- .../port/out/review/ReviewRepository.java | 5 +- .../service/review/UpdateReviewService.java | 5 - .../review/like/ReviewLikeService.java | 50 ++-- versions.properties | 4 + 17 files changed, 381 insertions(+), 203 deletions(-) diff --git a/application/src/main/java/org/depromeet/spot/application/review/like/ReviewLikeController.java b/application/src/main/java/org/depromeet/spot/application/review/like/ReviewLikeController.java index b3ffae05..2132015b 100644 --- a/application/src/main/java/org/depromeet/spot/application/review/like/ReviewLikeController.java +++ b/application/src/main/java/org/depromeet/spot/application/review/like/ReviewLikeController.java @@ -31,10 +31,10 @@ public class ReviewLikeController { @ResponseStatus(HttpStatus.OK) @Operation(summary = "특정 리뷰에 공감한다. 만약 이전에 공감했던 리뷰라면, 공감을 취소한다.") @PostMapping("/{reviewId}/like") - public void toggleLike( + public boolean toggleLike( @PathVariable @Positive @NotNull final Long reviewId, @Parameter(hidden = true) Long memberId) { - boolean result = reviewLikeUsecase.toggleLike(memberId, reviewId); + return reviewLikeUsecase.toggleLike(memberId, reviewId); // if (result) { // // 리뷰 공감 추이 이벤트 발생 // applicationEventPublisher.publishEvent( diff --git a/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java b/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java index 260f872a..19353d23 100644 --- a/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java +++ b/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java @@ -1,119 +1,129 @@ -// package org.depromeet.spot.application; -// -// import static org.junit.jupiter.api.Assertions.assertEquals; -// -// import java.util.concurrent.CountDownLatch; -// import java.util.concurrent.ExecutorService; -// import java.util.concurrent.Executors; -// import java.util.concurrent.atomic.AtomicLong; -// -// import org.depromeet.spot.domain.member.Level; -// import org.depromeet.spot.domain.member.Member; -// import org.depromeet.spot.domain.member.enums.MemberRole; -// import org.depromeet.spot.domain.member.enums.SnsProvider; -// import org.depromeet.spot.domain.review.Review; -// import org.depromeet.spot.usecase.port.in.review.ReadReviewUsecase; -// import org.depromeet.spot.usecase.port.out.member.LevelRepository; -// import org.depromeet.spot.usecase.port.out.member.MemberRepository; -// import org.depromeet.spot.usecase.service.review.like.ReviewLikeService; -// import org.junit.jupiter.api.BeforeEach; -// import org.junit.jupiter.api.Test; -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -// import org.springframework.boot.test.context.SpringBootTest; -// import org.springframework.test.context.ActiveProfiles; -// import org.springframework.test.context.TestPropertySource; -// import org.springframework.test.context.jdbc.Sql; -// import org.springframework.test.context.jdbc.Sql.ExecutionPhase; -// import org.springframework.test.context.jdbc.SqlGroup; -// import org.springframework.transaction.annotation.Transactional; -// import org.testcontainers.junit.jupiter.Testcontainers; -// -// import lombok.extern.slf4j.Slf4j; -// -// @Slf4j -// @SpringBootTest -// @Testcontainers -// @ActiveProfiles("test") -// @TestPropertySource("classpath:application-test.yml") -// @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -// @SqlGroup({ -// @Sql( -// value = "/sql/delete-data-after-review-like.sql", -// executionPhase = ExecutionPhase.AFTER_TEST_METHOD), -// @Sql( -// value = "/sql/review-like-service-data.sql", -// executionPhase = ExecutionPhase.BEFORE_TEST_METHOD), -// }) -// class ReviewLikeServiceTest { -// -// @Autowired private ReviewLikeService reviewLikeService; -// -// @Autowired private ReadReviewUsecase readReviewUsecase; -// -// @Autowired private MemberRepository memberRepository; -// -// @Autowired private LevelRepository levelRepository; -// -// private static final int NUMBER_OF_THREAD = 100; -// -// @BeforeEach -// @Transactional -// void init() { -// Level level = levelRepository.findByValue(0); -// AtomicLong memberIdGenerator = new AtomicLong(1); -// -// for (int i = 0; i < NUMBER_OF_THREAD; i++) { -// long memberId = memberIdGenerator.getAndIncrement(); -// memberRepository.save( -// Member.builder() -// .id(memberId) -// .snsProvider(SnsProvider.KAKAO) -// .teamId(1L) -// .role(MemberRole.ROLE_ADMIN) -// .idToken("idToken" + memberId) -// .nickname(String.valueOf(memberId)) -// .phoneNumber(String.valueOf(memberId)) -// .email("email" + memberId) -// .build(), -// level); -// } -// } -// -// @Test -// void 멀티_스레드_환경에서_리뷰_공감_수를_정상적으로_증가시킬_수_있다() throws InterruptedException { -// // given -// final long reviewId = 1L; -// AtomicLong memberIdGenerator = new AtomicLong(1); -// final ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_THREAD); -// final CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREAD); -// -// // when -// for (int i = 0; i < NUMBER_OF_THREAD; i++) { -// long memberId = memberIdGenerator.getAndIncrement(); -// executorService.execute( -// () -> { -// try { -// reviewLikeService.toggleLike(memberId, reviewId); -// System.out.println( -// "Thread " + Thread.currentThread().getId() + " - 성공"); -// } catch (Throwable e) { -// System.out.println( -// "Thread " -// + Thread.currentThread().getId() -// + " - 실패" -// + e.getClass().getName()); -// e.printStackTrace(); -// } finally { -// latch.countDown(); -// } -// }); -// } -// latch.await(); -// executorService.shutdown(); -// -// // then -// Review review = readReviewUsecase.findById(reviewId); -// assertEquals(100, review.getLikesCount()); -// } -// } +package org.depromeet.spot.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ConcurrentModificationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.depromeet.spot.domain.member.Level; +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.MemberRole; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.domain.review.Review; +import org.depromeet.spot.usecase.port.out.member.LevelRepository; +import org.depromeet.spot.usecase.port.out.member.MemberRepository; +import org.depromeet.spot.usecase.port.out.review.ReviewRepository; +import org.depromeet.spot.usecase.service.review.like.ReviewLikeService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; +import org.springframework.test.context.jdbc.SqlGroup; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.junit.jupiter.Testcontainers; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +@Testcontainers +@ActiveProfiles("test") +@TestPropertySource("classpath:application-test.yml") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@SqlGroup({ + @Sql( + value = "/sql/delete-data-after-review-like.sql", + executionPhase = ExecutionPhase.AFTER_TEST_METHOD), + @Sql( + value = "/sql/review-like-service-data.sql", + executionPhase = ExecutionPhase.BEFORE_TEST_METHOD), +}) +class ReviewLikeServiceTest { + + @Autowired private ReviewLikeService reviewLikeService; + @Autowired private ReviewRepository reviewRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private LevelRepository levelRepository; + @Autowired private TransactionTemplate transactionTemplate; + + private static final int NUMBER_OF_THREADS = 100; + + @BeforeEach + void init() { + transactionTemplate.execute( + status -> { + Level level = levelRepository.findByValue(0); + AtomicLong memberIdGenerator = new AtomicLong(1); + + for (int i = 0; i < NUMBER_OF_THREADS; i++) { + long memberId = memberIdGenerator.getAndIncrement(); + memberRepository.save( + Member.builder() + .id(memberId) + .snsProvider(SnsProvider.KAKAO) + .teamId(1L) + .role(MemberRole.ROLE_ADMIN) + .idToken("idToken" + memberId) + .nickname(String.valueOf(memberId)) + .phoneNumber(String.valueOf(memberId)) + .email("email" + memberId) + .build(), + level); + } + return null; + }); + } + + @Test + void 멀티_스레드_환경에서_리뷰_공감_수를_정상적으로_증가시킬_수_있다() throws InterruptedException { + // given + final long reviewId = 1L; + AtomicLong memberIdGenerator = new AtomicLong(1); + final ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_THREADS); + final CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREADS); + final AtomicInteger retryCount = new AtomicInteger(0); + final AtomicInteger successCount = new AtomicInteger(0); + final AtomicInteger failCount = new AtomicInteger(0); + + // when + for (int i = 0; i < NUMBER_OF_THREADS; i++) { + long memberId = memberIdGenerator.getAndIncrement(); + executorService.execute( + () -> { + try { + reviewLikeService.toggleLike(memberId, reviewId); + successCount.incrementAndGet(); + } catch (ObjectOptimisticLockingFailureException e) { + retryCount.incrementAndGet(); + } catch (ConcurrentModificationException e) { + failCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // then + Review review = + transactionTemplate.execute( + status -> { + return reviewRepository.findReviewByIdWithLock(reviewId); + }); + + assertEquals(successCount.get(), review.getLikesCount(), "좋아요 수가 성공한 요청 수와 일치해야 함"); + } +} diff --git a/application/src/test/resources/application-test.yml b/application/src/test/resources/application-test.yml index 5de5335f..cadfea33 100644 --- a/application/src/test/resources/application-test.yml +++ b/application/src/test/resources/application-test.yml @@ -34,9 +34,13 @@ spring: jpa: database: mysql hibernate: - ddl-auto: create + ddl-auto: create-drop + properties: + hibernate: + format_sql: true database-platform: org.hibernate.dialect.MySQL8Dialect defer-datasource-initialization: true + jwt: secret: ${JWT_SECRETKEY} diff --git a/application/src/test/resources/sql/review-like-service-data.sql b/application/src/test/resources/sql/review-like-service-data.sql index 18de5f2c..e1b6a2c8 100644 --- a/application/src/test/resources/sql/review-like-service-data.sql +++ b/application/src/test/resources/sql/review-like-service-data.sql @@ -1,41 +1,146 @@ --- levels +-- levels 테이블 생성 +CREATE TABLE IF NOT EXISTS levels ( + id BIGINT NOT NULL PRIMARY KEY, + value INT NOT NULL, + title VARCHAR(255) NOT NULL, + mascot_image_url VARCHAR(255), + created_at TIMESTAMP, + updated_at TIMESTAMP, + deleted_at TIMESTAMP +); + +-- stadiums 테이블 생성 +CREATE TABLE IF NOT EXISTS stadiums ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + main_image VARCHAR(255), + seating_chart_image VARCHAR(255), + labeled_seating_chart_image VARCHAR(255), + is_active BOOLEAN, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- baseball_teams 테이블 생성 +CREATE TABLE IF NOT EXISTS baseball_teams ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + alias VARCHAR(255), + logo VARCHAR(255), + label_font_color VARCHAR(255), + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- sections 테이블 생성 +CREATE TABLE IF NOT EXISTS sections ( + id BIGINT NOT NULL PRIMARY KEY, + stadium_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + alias VARCHAR(255), + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- blocks 테이블 생성 +CREATE TABLE IF NOT EXISTS blocks ( + id BIGINT NOT NULL PRIMARY KEY, + stadium_id BIGINT NOT NULL, + section_id BIGINT NOT NULL, + code VARCHAR(255) NOT NULL, + max_rows INT, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- block_rows 테이블 생성 +CREATE TABLE IF NOT EXISTS block_rows ( + id BIGINT NOT NULL PRIMARY KEY, + block_id BIGINT NOT NULL, + number INT NOT NULL, + max_seats INT, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- seats 테이블 생성 +CREATE TABLE IF NOT EXISTS seats ( + id BIGINT NOT NULL PRIMARY KEY, + stadium_id BIGINT NOT NULL, + section_id BIGINT NOT NULL, + block_id BIGINT NOT NULL, + row_id BIGINT NOT NULL, + seat_number INT NOT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- reviews 테이블 생성 +CREATE TABLE IF NOT EXISTS reviews ( + id BIGINT NOT NULL PRIMARY KEY, + member_id BIGINT NOT NULL, + stadium_id BIGINT NOT NULL, + section_id BIGINT NOT NULL, + block_id BIGINT NOT NULL, + row_id BIGINT, + seat_id BIGINT, + date_time TIMESTAMP NOT NULL, + content VARCHAR(300), + likes_count INT DEFAULT 0, + scraps_count INT DEFAULT 0, + review_type VARCHAR(20), + version BIGINT DEFAULT 0, + deleted_at TIMESTAMP, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- review_likes 테이블 생성 +CREATE TABLE IF NOT EXISTS review_likes ( + id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT, + member_id BIGINT NOT NULL, + review_id BIGINT NOT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP, + deleted_at TIMESTAMP, + UNIQUE KEY uk_review_likes (member_id, review_id) +); + +-- levels 데이터 삽입 INSERT INTO levels (id, value, title, mascot_image_url, created_at, updated_at, deleted_at) -VALUES (1, 0, '직관 꿈나무', null, null, null, null), - (2, 1, '직관 첫 걸음', null, null, null, null), - (3, 2, '경기장 탐험가', null, null, null, null), - (4, 3, '직관의 여유', null, null, null, null), - (5, 4, '응원 단장', null, null, null, null), - (6, 5, '야구장 VIP', null, null, null, null), - (7, 6, '전설의 직관러', null, null, null, null); - --- Stadiums -INSERT INTO stadiums (id, name, main_image, seating_chart_image, labeled_seating_chart_image, - is_active) -VALUES (1, '잠실 야구 경기장', 'main_image_a.jpg', 'seating_chart_a.jpg', 'labeled_seating_chart_a.jpg', - 1); - --- Baseball Teams -INSERT INTO baseball_teams (id, name, alias, logo, label_font_color) -VALUES (1, 'Team A', 'A', 'logo_a.png', '#FFFFFF'); - --- Stadium Sections -INSERT INTO sections (id, stadium_id, name, alias) -VALUES (1, 1, '오렌지석', '응원석'); - --- Block -INSERT INTO blocks (id, stadium_id, section_id, code, max_rows) -VALUES (1, 1, 1, "207", 3); - --- Row -INSERT INTO block_rows (id, block_id, number, max_seats) -VALUES (1, 1, 1, 3); - --- Seats -INSERT INTO seats (id, stadium_id, section_id, block_id, row_id, seat_number) -VALUES (1, 1, 1, 1, 1, 1); - --- reviews -INSERT INTO reviews (id, member_id, stadium_id, section_id, block_id, row_id, seat_id, date_time, content, likes_count, scraps_count, review_type) +VALUES (1, 0, '직관 꿈나무', null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, null), + (2, 1, '직관 첫 걸음', null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, null), + (3, 2, '경기장 탐험가', null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, null), + (4, 3, '직관의 여유', null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, null), + (5, 4, '응원 단장', null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, null), + (6, 5, '야구장 VIP', null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, null), + (7, 6, '전설의 직관러', null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, null); + +-- stadiums 데이터 삽입 +INSERT INTO stadiums (id, name, main_image, seating_chart_image, labeled_seating_chart_image, is_active, created_at, updated_at) +VALUES (1, '잠실 야구 경기장', 'main_image_a.jpg', 'seating_chart_a.jpg', 'labeled_seating_chart_a.jpg', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- baseball_teams 데이터 삽입 +INSERT INTO baseball_teams (id, name, alias, logo, label_font_color, created_at, updated_at) +VALUES (1, 'Team A', 'A', 'logo_a.png', '#FFFFFF', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- sections 데이터 삽입 +INSERT INTO sections (id, stadium_id, name, alias, created_at, updated_at) +VALUES (1, 1, '오렌지석', '응원석', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- blocks 데이터 삽입 +INSERT INTO blocks (id, stadium_id, section_id, code, max_rows, created_at, updated_at) +VALUES (1, 1, 1, '207', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- block_rows 데이터 삽입 +INSERT INTO block_rows (id, block_id, number, max_seats, created_at, updated_at) +VALUES (1, 1, 1, 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- seats 데이터 삽입 +INSERT INTO seats (id, stadium_id, section_id, block_id, row_id, seat_number, created_at, updated_at) +VALUES (1, 1, 1, 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- reviews 데이터 삽입 +INSERT INTO reviews (id, member_id, stadium_id, section_id, block_id, row_id, seat_id, date_time, content, likes_count, scraps_count, review_type, version, created_at, updated_at) VALUES - (1, 1, 1, 1, 1, 1, 1, '2023-06-01 19:00:00', '좋은 경기였습니다!', 0, 0, 'VIEW'), - (2, 1, 1, 1, 1, 1, 1, '2023-06-01 19:00:00', '좋은 경기였습니다!', 0, 0, 'VIEW'); \ No newline at end of file + (1, 1, 1, 1, 1, 1, 1, '2023-06-01 19:00:00', '좋은 경기였습니다!', 0, 0, 'VIEW', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); \ No newline at end of file diff --git a/common/src/main/java/org/depromeet/spot/common/exception/review/ReviewException.java b/common/src/main/java/org/depromeet/spot/common/exception/review/ReviewException.java index 6a2f147d..4194965b 100644 --- a/common/src/main/java/org/depromeet/spot/common/exception/review/ReviewException.java +++ b/common/src/main/java/org/depromeet/spot/common/exception/review/ReviewException.java @@ -12,8 +12,8 @@ public ReviewNotFoundException() { super(ReviewErrorCode.REVIEW_NOT_FOUND); } - public ReviewNotFoundException(String s) { - super(ReviewErrorCode.REVIEW_NOT_FOUND.appended(s)); + public ReviewNotFoundException(Long s) { + super(ReviewErrorCode.REVIEW_NOT_FOUND.appended("요청한 리뷰를 찾을 수 없습니다." + s)); } } diff --git a/domain/src/main/java/org/depromeet/spot/domain/review/Review.java b/domain/src/main/java/org/depromeet/spot/domain/review/Review.java index 72b09683..75d5833a 100644 --- a/domain/src/main/java/org/depromeet/spot/domain/review/Review.java +++ b/domain/src/main/java/org/depromeet/spot/domain/review/Review.java @@ -49,6 +49,7 @@ public enum SortCriteria { private int likesCount; private int scrapsCount; private final ReviewType reviewType; + private Long version; public static final int DEFAULT_LIKE_COUNT = 0; public static final int DEFAULT_SCRAPS_COUNT = 0; @@ -71,7 +72,8 @@ public Review( List keywords, int likesCount, int scrapsCount, - ReviewType reviewType) { + ReviewType reviewType, + Long version) { if (likesCount < 0) { throw new InvalidReviewLikesException(); } @@ -91,6 +93,7 @@ public Review( this.likesCount = likesCount; this.scrapsCount = scrapsCount; this.reviewType = reviewType; + this.version = version; } public void addKeyword(ReviewKeyword keyword) { @@ -167,6 +170,7 @@ public Review withLimitedImages(int limit) { this.keywords, this.likesCount, this.scrapsCount, - this.reviewType); + this.reviewType, + this.version); } } diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java index 6e97237e..fc47f70b 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java @@ -17,6 +17,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.Version; import org.depromeet.spot.domain.review.Review; import org.depromeet.spot.domain.review.Review.ReviewType; @@ -31,16 +32,20 @@ import org.depromeet.spot.infrastructure.jpa.stadium.entity.StadiumEntity; import org.hibernate.annotations.BatchSize; import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicUpdate; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Table(name = "reviews") @NoArgsConstructor @AllArgsConstructor +@DynamicUpdate @Getter +@Setter public class ReviewEntity extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @@ -109,6 +114,11 @@ public class ReviewEntity extends BaseEntity { @Column(name = "review_type", nullable = false) private ReviewType reviewType; + @Version + @ColumnDefault("0L") + @Column(name = "version") + private Long version; + public static ReviewEntity from(Review review) { SeatEntity seatEntity = review.getSeat() != null ? SeatEntity.withSeat(review.getSeat()) : null; @@ -129,7 +139,8 @@ public static ReviewEntity from(Review review) { new ArrayList<>(), review.getLikesCount(), review.getScrapsCount(), - review.getReviewType()); + review.getReviewType(), + review.getVersion()); entity.setId(review.getId()); // ID 설정 추가 diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/ReviewJpaRepository.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/ReviewJpaRepository.java index d1bc03f9..5858d92d 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/ReviewJpaRepository.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/ReviewJpaRepository.java @@ -2,12 +2,16 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; + +import jakarta.persistence.LockModeType; import org.depromeet.spot.domain.review.Review.ReviewType; import org.depromeet.spot.domain.review.ReviewCount; import org.depromeet.spot.domain.review.ReviewYearMonth; import org.depromeet.spot.infrastructure.jpa.review.entity.ReviewEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -15,6 +19,10 @@ public interface ReviewJpaRepository extends JpaRepository { long countByMemberIdAndDeletedAtIsNull(Long memberId); + @Lock(LockModeType.OPTIMISTIC) + @Query("Select r FROM ReviewEntity r WHERE r.id = :id") + Optional findByIdWithOptimistic(@Param("id") Long id); + @Query( "SELECT new org.depromeet.spot.domain.review.ReviewYearMonth(YEAR(r.dateTime), MONTH(r.dateTime)) " + "FROM ReviewEntity r WHERE r.member.id = :memberId " diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/ReviewRepositoryImpl.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/ReviewRepositoryImpl.java index 0b5ca275..d3806015 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/ReviewRepositoryImpl.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/ReviewRepositoryImpl.java @@ -3,6 +3,8 @@ import java.time.LocalDateTime; import java.util.List; +import jakarta.transaction.Transactional; + import org.depromeet.spot.common.exception.review.ReviewException.ReviewNotFoundException; import org.depromeet.spot.domain.review.Review; import org.depromeet.spot.domain.review.Review.ReviewType; @@ -26,8 +28,26 @@ public class ReviewRepositoryImpl implements ReviewRepository { private final ReviewCustomRepository reviewCustomRepository; @Override - public void updateLikesCount(Long reviewId, int likesCount) { - reviewJpaRepository.updateLikesCount(reviewId, likesCount); + public Review findReviewByIdWithLock(Long id) { + return reviewJpaRepository + .findByIdWithOptimistic(id) + .orElseThrow(() -> new ReviewNotFoundException(id)) + .toDomain(); + } + + @Override + @Transactional + public void updateLikesCount(Long reviewId, boolean isLiking) { + ReviewEntity entity = + reviewJpaRepository + .findByIdWithOptimistic(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(reviewId)); + + if (isLiking) { + entity.setLikesCount(entity.getLikesCount() + 1); + } else { + entity.setLikesCount(entity.getLikesCount() - 1); + } } @Override @@ -45,9 +65,7 @@ public Review save(Review review) { @Override public Review findById(Long id) { ReviewEntity entity = - reviewJpaRepository - .findById(id) - .orElseThrow(() -> new ReviewNotFoundException("요청한 리뷰를 찾을 수 없습니다." + id)); + reviewJpaRepository.findById(id).orElseThrow(() -> new ReviewNotFoundException(id)); return entity.toDomain(); } diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/like/ReviewLikeRepositoryImpl.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/like/ReviewLikeRepositoryImpl.java index a0df4858..174601ee 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/like/ReviewLikeRepositoryImpl.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/repository/like/ReviewLikeRepositoryImpl.java @@ -18,7 +18,7 @@ public class ReviewLikeRepositoryImpl implements ReviewLikeRepository { private final ReviewLikeJpaRepository reviewLikeJpaRepository; @Override - public boolean existsBy(final long memberId, final long reviewId) { + public boolean existsBy(final Long memberId, final Long reviewId) { return reviewLikeJpaRepository.existsByMemberIdAndReviewId(memberId, reviewId); } @@ -28,14 +28,13 @@ public long countByReview(final long reviewId) { } @Override - public void deleteBy(final long memberId, final long reviewId) { + public void deleteBy(final Long memberId, final Long reviewId) { reviewLikeJpaRepository.deleteByMemberIdAndReviewId(memberId, reviewId); } @Override public void save(ReviewLike like) { - ReviewLikeEntity entity = ReviewLikeEntity.from(like); - reviewLikeJpaRepository.save(entity); + reviewLikeJpaRepository.save(ReviewLikeEntity.from(like)); } @Override diff --git a/usecase/build.gradle.kts b/usecase/build.gradle.kts index 8aac069f..e02a3f38 100644 --- a/usecase/build.gradle.kts +++ b/usecase/build.gradle.kts @@ -9,6 +9,11 @@ dependencies { because("@Transactional을 위해 추가") } + // spring retry + implementation("org.springframework.retry:spring-retry") + implementation("org.springframework:spring-aspects") + + // Mixpanel implementation("com.mixpanel:mixpanel-java:_") diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/UpdateReviewUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/UpdateReviewUsecase.java index 91fdb8b6..24733d90 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/UpdateReviewUsecase.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/UpdateReviewUsecase.java @@ -11,8 +11,6 @@ public interface UpdateReviewUsecase { UpdateReviewResult updateReview(Long memberId, Long reviewId, UpdateReviewCommand command); - void updateLikesCount(Review review); - void updateScrapsCount(Review review); @Builder diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/review/ReviewLikeRepository.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/review/ReviewLikeRepository.java index 41cf1dac..9558082e 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/review/ReviewLikeRepository.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/review/ReviewLikeRepository.java @@ -7,11 +7,11 @@ public interface ReviewLikeRepository { - boolean existsBy(long memberId, long reviewId); + boolean existsBy(Long memberId, Long reviewId); long countByReview(long reviewId); - void deleteBy(long memberId, long reviewId); + void deleteBy(Long memberId, Long reviewId); void save(ReviewLike like); diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/review/ReviewRepository.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/review/ReviewRepository.java index c3c2e177..d197c172 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/review/ReviewRepository.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/review/ReviewRepository.java @@ -10,7 +10,10 @@ import org.depromeet.spot.usecase.port.in.review.ReadReviewUsecase.LocationInfo; public interface ReviewRepository { - void updateLikesCount(Long reviewId, int likesCount); + + Review findReviewByIdWithLock(Long id); + + void updateLikesCount(Long reviewId, boolean isLiking); void updateScrapsCount(Long reviewId, int likesCount); diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java index d4a51644..ee1150b6 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java @@ -57,11 +57,6 @@ public UpdateReviewResult updateReview( return new UpdateReviewResult(savedReview); } - @Override - public void updateLikesCount(Review review) { - reviewRepository.updateLikesCount(review.getId(), review.getLikesCount()); - } - @Override public void updateScrapsCount(Review review) { reviewRepository.updateScrapsCount(review.getId(), review.getScrapsCount()); diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java index 6ecbf536..2ef2dec6 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java @@ -1,18 +1,25 @@ package org.depromeet.spot.usecase.service.review.like; -import org.depromeet.spot.common.annotation.DistributedLock; -import org.depromeet.spot.domain.review.Review; +import java.util.ConcurrentModificationException; + import org.depromeet.spot.domain.review.like.ReviewLike; import org.depromeet.spot.usecase.port.in.review.ReadReviewUsecase; import org.depromeet.spot.usecase.port.in.review.UpdateReviewUsecase; import org.depromeet.spot.usecase.port.in.review.like.ReviewLikeUsecase; import org.depromeet.spot.usecase.port.out.review.ReviewLikeRepository; +import org.depromeet.spot.usecase.port.out.review.ReviewRepository; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.Builder; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @Builder @Transactional @@ -22,31 +29,38 @@ public class ReviewLikeService implements ReviewLikeUsecase { private final ReadReviewUsecase readReviewUsecase; private final UpdateReviewUsecase updateReviewUsecase; private final ReviewLikeRepository reviewLikeRepository; + private final ReviewRepository reviewRepository; @Override - @DistributedLock(key = "#reviewId") + @Transactional + @Retryable( + retryFor = ObjectOptimisticLockingFailureException.class, + maxAttempts = 4, + backoff = @Backoff(delay = 100)) public boolean toggleLike(final Long memberId, final long reviewId) { - Review review = readReviewUsecase.findById(reviewId); + boolean exists = reviewLikeRepository.existsBy(memberId, reviewId); - if (reviewLikeRepository.existsBy(memberId, reviewId)) { - cancelLike(memberId, reviewId, review); + if (exists) { + reviewLikeRepository.deleteBy(memberId, reviewId); + reviewRepository.updateLikesCount(reviewId, false); return false; } - addLike(memberId, reviewId, review); + ReviewLike like = ReviewLike.builder().memberId(memberId).reviewId(reviewId).build(); + reviewLikeRepository.save(like); + reviewRepository.updateLikesCount(reviewId, true); return true; } - public void cancelLike(final long memberId, final long reviewId, Review review) { - reviewLikeRepository.deleteBy(memberId, reviewId); - review.cancelLike(); - updateReviewUsecase.updateLikesCount(review); - } - - public void addLike(final long memberId, final long reviewId, Review review) { - ReviewLike like = ReviewLike.builder().memberId(memberId).reviewId(reviewId).build(); - reviewLikeRepository.save(like); - review.addLike(); - updateReviewUsecase.updateLikesCount(review); + // 예외 처리를 위한 Recovery 메소드 + @Recover + public boolean recoverToggleLike( + ObjectOptimisticLockingFailureException e, Long memberId, long reviewId) { + log.error( + "Failed to toggle like after 3 attempts for memberId: {} and reviewId: {}", + memberId, + reviewId, + e); + throw new ConcurrentModificationException("좋아요 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."); } } diff --git a/versions.properties b/versions.properties index b820f57b..f700da05 100644 --- a/versions.properties +++ b/versions.properties @@ -72,3 +72,7 @@ version.org.testcontainers..mysql=1.20.1 version.org.testcontainers..jdbc=1.20.1 version.io.awspring.cloud..spring-cloud-starter-aws-secrets-manager-config=2.4.4 + +versions.org.springframework.retry..spring-retry=2.0.3 + +versions.org.springframework..spring-aspects=6.1.3