diff --git a/backend/src/main/java/shook/shook/song/application/killingpart/KillingPartLikeService.java b/backend/src/main/java/shook/shook/song/application/killingpart/KillingPartLikeService.java index b8e589293..73cac9d31 100644 --- a/backend/src/main/java/shook/shook/song/application/killingpart/KillingPartLikeService.java +++ b/backend/src/main/java/shook/shook/song/application/killingpart/KillingPartLikeService.java @@ -30,11 +30,9 @@ public void updateLikeStatus( final KillingPartLikeRequest request ) { final Member member = memberRepository.findById(memberId) - .orElseThrow( - () -> new MemberException.MemberNotExistException( - Map.of("MemberId", String.valueOf(memberId)) - ) - ); + .orElseThrow(() -> new MemberException.MemberNotExistException( + Map.of("MemberId", String.valueOf(memberId)) + )); final KillingPart killingPart = killingPartRepository.findById(killingPartId) .orElseThrow(() -> new KillingPartException.PartNotExistException( @@ -53,20 +51,25 @@ private void create(final KillingPart killingPart, final Member member) { return; } - final KillingPartLike likeOnKillingPart = - likeRepository.findByKillingPartAndMember(killingPart, member) - .orElseGet(() -> createNewLike(killingPart, member)); - - killingPart.like(likeOnKillingPart); + final KillingPartLike likeOnKillingPart = likeRepository.findByKillingPartAndMember(killingPart, member) + .orElseGet(() -> createNewLike(killingPart, member)); + if (likeOnKillingPart.isDeleted()) { + likeRepository.pressLike(likeOnKillingPart.getId()); + killingPartRepository.increaseLikeCount(killingPart.getId()); + } } private KillingPartLike createNewLike(final KillingPart killingPart, final Member member) { final KillingPartLike like = new KillingPartLike(killingPart, member); + return likeRepository.save(like); } private void delete(final KillingPart killingPart, final Member member) { killingPart.findLikeByMember(member) - .ifPresent(killingPart::unlike); + .ifPresent(likeOnKillingPart -> { + likeRepository.cancelLike(likeOnKillingPart.getId()); + killingPartRepository.decreaseLikeCount(killingPart.getId()); + }); } } diff --git a/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartLikeRepository.java b/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartLikeRepository.java index 686a398ae..23ff65c08 100644 --- a/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartLikeRepository.java +++ b/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartLikeRepository.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -32,4 +33,12 @@ List findLikedKillingPartAndSongByMe + "FROM KillingPartLike kp_like " + "WHERE kp_like.member=:member and kp_like.isDeleted=false") List findLikedKillingPartIdsByMember(@Param("member") final Member member); + + @Query("update KillingPartLike kp_like set kp_like.isDeleted = false where kp_like.id = :id") + @Modifying(clearAutomatically = true, flushAutomatically = true) + void pressLike(@Param("id") final Long killingPartLikeId); + + @Query("update KillingPartLike kp_like set kp_like.isDeleted = true where kp_like.id = :id") + @Modifying(clearAutomatically = true, flushAutomatically = true) + void cancelLike(@Param("id") final Long killingPartLikeId); } diff --git a/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartRepository.java b/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartRepository.java index 1e9f2babd..99679d467 100644 --- a/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartRepository.java +++ b/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartRepository.java @@ -2,6 +2,9 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import shook.shook.song.domain.Song; import shook.shook.song.domain.killingpart.KillingPart; @@ -10,4 +13,12 @@ public interface KillingPartRepository extends JpaRepository { List findAllBySong(final Song song); + + @Query("update KillingPart kp set kp.likeCount = kp.likeCount + 1 where kp.id = :id") + @Modifying(clearAutomatically = true, flushAutomatically = true) + void increaseLikeCount(@Param("id") final Long killingPartLikeId); + + @Query("update KillingPart kp set kp.likeCount = kp.likeCount - 1 where kp.id = :id") + @Modifying(clearAutomatically = true, flushAutomatically = true) + void decreaseLikeCount(@Param("id") final Long killingPartLikeId); } diff --git a/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeConcurrencyTest.java b/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeConcurrencyTest.java new file mode 100644 index 000000000..2070e9e4a --- /dev/null +++ b/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeConcurrencyTest.java @@ -0,0 +1,134 @@ +package shook.shook.song.application.killingpart; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import shook.shook.member.domain.Member; +import shook.shook.member.domain.repository.MemberRepository; +import shook.shook.song.application.killingpart.dto.KillingPartLikeRequest; +import shook.shook.song.domain.killingpart.KillingPart; +import shook.shook.song.domain.killingpart.repository.KillingPartLikeRepository; +import shook.shook.song.domain.killingpart.repository.KillingPartRepository; + +@Sql("classpath:/killingpart/initialize_killing_part_song.sql") +@SpringBootTest +class KillingPartLikeConcurrencyTest { + + private static KillingPart SAVED_KILLING_PART; + private static Member SAVED_MEMBER; + + @Autowired + private KillingPartRepository killingPartRepository; + + @Autowired + private KillingPartLikeRepository killingPartLikeRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + private KillingPartLikeService likeService; + private TransactionTemplate transactionTemplate; + + @BeforeEach + void setUp() { + SAVED_KILLING_PART = killingPartRepository.findById(1L).get(); + SAVED_MEMBER = memberRepository.findById(1L).get(); + likeService = new KillingPartLikeService(killingPartRepository, memberRepository, killingPartLikeRepository); + transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + @DisplayName("두 사용자가 동시에 좋아요를 누르면 좋아요 개수가 2 증가한다.") + @Test + void likeByMultiplePeople() throws InterruptedException { + // given + final Member first = SAVED_MEMBER; + final Member second = memberRepository.save(new Member("second@gmail.com", "second")); + + // when + ExecutorService executorService = Executors.newFixedThreadPool(2); + + CountDownLatch latch = new CountDownLatch(2); + final KillingPartLikeRequest request = new KillingPartLikeRequest(true); + + executorService.execute(() -> + transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), request); + latch.countDown(); + return null; + })) + ); + executorService.execute(() -> + transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), second.getId(), request); + latch.countDown(); + return null; + })) + ); + latch.await(); + Thread.sleep(1000); + + // then + final KillingPart killingPart = killingPartRepository.findById(SAVED_KILLING_PART.getId()).get(); + assertThat(killingPart.getLikeCount()).isEqualTo(2); + } + + @Disabled("UPDATE + 1 사용 시 한 사용자의 동시에 도착하는 좋아요 요청 동시성 문제 발생") + @DisplayName("한 사용자가 좋아요, 취소, 좋아요를 누르면 좋아요 개수가 1 증가한다.") + @Test + void likeByOnePersonMultipleTimes() throws InterruptedException { + // given + final Member first = SAVED_MEMBER; + + // when + ExecutorService executorService = Executors.newFixedThreadPool(3); + + CountDownLatch latch = new CountDownLatch(3); + final KillingPartLikeRequest likeRequest = new KillingPartLikeRequest(true); + final KillingPartLikeRequest unlikeRequest = new KillingPartLikeRequest(false); + + executorService.execute(() -> + transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), likeRequest); + latch.countDown(); + return null; + })) + ); + executorService.execute(() -> + transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), unlikeRequest); + latch.countDown(); + return null; + })) + ); + executorService.execute(() -> + transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), likeRequest); + latch.countDown(); + return null; + })) + ); + + latch.await(); + Thread.sleep(1000); + + // then + final KillingPart killingPart = killingPartRepository.findById(SAVED_KILLING_PART.getId()).get(); + assertThat(killingPart.getLikeCount()).isEqualTo(1); // 예상: 2, 결과: 1 + } +} diff --git a/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartLikeRepositoryTest.java b/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartLikeRepositoryTest.java index 27e7be08a..9d77808f9 100644 --- a/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartLikeRepositoryTest.java +++ b/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartLikeRepositoryTest.java @@ -192,4 +192,41 @@ void findLikedKillingPartIdsByMember() { SECOND_SAVED_KILLING_PART.getId() ); } + + @DisplayName("좋아요 데이터를 삭제되지 않은 상태로 변경한다. (좋아요를 누른다.)") + @Test + void pressLike() { + // given + final KillingPartLike killingPartLike = new KillingPartLike(FIRST_SAVED_KILLING_PART, SAVED_MEMBER); + killingPartLikeRepository.save(killingPartLike); + + // when + killingPartLikeRepository.pressLike(killingPartLike.getId()); + + // then + final Optional foundLike = killingPartLikeRepository.findById(killingPartLike.getId()); + + assertThat(foundLike).isPresent() + .get() + .hasFieldOrPropertyWithValue("isDeleted", false); + } + + @DisplayName("좋아요 데이터를 삭제된 상태로 변경한다. (좋아요를 취소한다.)") + @Test + void cancelLike() { + // given + final KillingPartLike killingPartLike = new KillingPartLike(FIRST_SAVED_KILLING_PART, SAVED_MEMBER); + killingPartLikeRepository.save(killingPartLike); + killingPartLikeRepository.pressLike(killingPartLike.getId()); + + // when + killingPartLikeRepository.cancelLike(killingPartLike.getId()); + + // then + final Optional foundLike = killingPartLikeRepository.findById(killingPartLike.getId()); + + assertThat(foundLike).isPresent() + .get() + .hasFieldOrPropertyWithValue("isDeleted", true); + } } diff --git a/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartRepositoryTest.java b/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartRepositoryTest.java index 21a214fa1..19299528b 100644 --- a/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartRepositoryTest.java +++ b/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartRepositoryTest.java @@ -109,4 +109,41 @@ void findAllBySong() { List.of(FIRST_KILLING_PART, SECOND_KILLING_PART, THIRD_KILLING_PART) ); } + + @DisplayName("한 킬링파트에 UPDATE + 1로 좋아요 수를 증가시킨다.") + @Test + void increaseLikeCount() { + // given + killingPartRepository.saveAll(KILLING_PARTS.getKillingParts()); + final KillingPart killingPart = killingPartRepository.findById(FIRST_KILLING_PART.getId()).get(); + final int initialLikeCount = killingPart.getLikeCount(); + + // when + saveAndClearEntityManager(); + killingPartRepository.increaseLikeCount(killingPart.getId()); + + // then + final KillingPart foundKillingPart = killingPartRepository.findById(killingPart.getId()).get(); + + assertThat(foundKillingPart.getLikeCount()).isEqualTo(initialLikeCount + 1); + } + + @DisplayName("한 킬링파트에 UPDATE - 1로 좋아요 수를 감소시킨다.") + @Test + void decreaseLikeCount() { + // given + killingPartRepository.saveAll(KILLING_PARTS.getKillingParts()); + killingPartRepository.increaseLikeCount(FIRST_KILLING_PART.getId()); + final KillingPart killingPart = killingPartRepository.findById(FIRST_KILLING_PART.getId()).get(); + final int initialLikeCount = killingPart.getLikeCount(); + + // when + saveAndClearEntityManager(); + killingPartRepository.decreaseLikeCount(killingPart.getId()); + + // then + final KillingPart foundKillingPart = killingPartRepository.findById(killingPart.getId()).get(); + + assertThat(foundKillingPart.getLikeCount()).isEqualTo(initialLikeCount - 1); + } }