diff --git a/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java b/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java index 56ffb232..56a1a0b4 100644 --- a/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java +++ b/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java @@ -210,10 +210,17 @@ private List getLinkedUsers(User author, List userIds, Feed fe .toList(); } + /** + * 피드에 공감을 추가 + * 비관적 락(PESSIMISTIC_WRITE)을 사용하여 동시성 문제를 해결 + * @see leets.leenk.domain.feed.domain.repository.FeedRepository#findByIdWithPessimisticLock + * @see leets.leenk.domain.user.domain.repository.UserRepository#findByIdWithPessimisticLock + */ @Transactional public void reactToFeed(long userId, long feedId, ReactionRequest request) { + Feed feed = feedGetService.findByIdWithLock(feedId); // !락 순서 중요! User user = userGetService.findById(userId); - Feed feed = feedGetService.findById(feedId); + validateReaction(feed, user); Reaction reaction = reactionGetService.findByFeedAndUser(feed, user) @@ -225,6 +232,7 @@ public void reactToFeed(long userId, long feedId, ReactionRequest request) { long previousReactionCount = reaction.getReactionCount(); + // Feed를 가져올 때 Fetch Join으로 작성자를 함께 가져와 락이 함께 걸리므로 별도의 락 필요 없음. feedUpdateService.updateTotalReaction(feed, reaction, feed.getUser(), request.reactionCount()); feedNotificationUsecase.saveFirstReactionNotification(reaction); diff --git a/src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java b/src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java index 7af872aa..a22d665f 100644 --- a/src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java +++ b/src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java @@ -1,11 +1,15 @@ package leets.leenk.domain.feed.domain.repository; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import leets.leenk.domain.feed.domain.entity.Feed; import leets.leenk.domain.user.domain.entity.User; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; 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.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; @@ -23,6 +27,16 @@ public interface FeedRepository extends JpaRepository { @Query("SELECT f FROM Feed f JOIN FETCH f.user WHERE f.deletedAt IS NULL AND f.id = :id") Optional findByDeletedAtIsNullAndId(Long id); + /** + * 비관적 락을 사용하여 피드 조회 (동시성 제어용) + * 공감하기 등 동시 수정이 발생할 수 있는 경우 사용 + * feed.user를 함께 가져오기 때문에 해당 유저에도 락이 함께 걸리니 주의 + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT f FROM Feed f JOIN FETCH f.user WHERE f.deletedAt IS NULL AND f.id = :id") + Optional findByIdWithPessimisticLock(@Param("id") Long id); + /** * 현재 피드보다 최신인 피드 조회 (이전 피드) * createDate > currentCreateDate 조건으로 더 최근 피드를 ASC 정렬로 조회 diff --git a/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java b/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java index 82de5787..7194c427 100644 --- a/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java +++ b/src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java @@ -1,11 +1,13 @@ package leets.leenk.domain.feed.domain.service; +import jakarta.persistence.PessimisticLockException; import leets.leenk.domain.feed.application.exception.FeedNotFoundException; import leets.leenk.domain.feed.domain.entity.Feed; import leets.leenk.domain.feed.domain.repository.FeedRepository; import leets.leenk.domain.feed.domain.service.dto.FeedNavigationResult; import leets.leenk.domain.user.domain.entity.User; import leets.leenk.domain.user.domain.entity.UserBlock; +import leets.leenk.global.common.exception.ResourceLockedException; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -26,6 +28,19 @@ public Feed findById(long feedId) { .orElseThrow(FeedNotFoundException::new); } + /** + * 비관적 락을 사용하여 피드 조회 + * 동시 수정이 발생할 수 있는 경우 (공감하기 등) 사용 + */ + public Feed findByIdWithLock(long feedId) { + try { + return feedRepository.findByIdWithPessimisticLock(feedId) + .orElseThrow(FeedNotFoundException::new); + } catch (PessimisticLockException e) { + throw new ResourceLockedException(); + } + } + public Slice findAll(Pageable pageable, List blockedUser) { List blockedUserIds = blockedUser.stream() .map(UserBlock::getBlocked) diff --git a/src/main/java/leets/leenk/domain/user/domain/repository/UserRepository.java b/src/main/java/leets/leenk/domain/user/domain/repository/UserRepository.java index 91aea96a..825d5c73 100644 --- a/src/main/java/leets/leenk/domain/user/domain/repository/UserRepository.java +++ b/src/main/java/leets/leenk/domain/user/domain/repository/UserRepository.java @@ -1,10 +1,14 @@ package leets.leenk.domain.user.domain.repository; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import leets.leenk.domain.user.domain.entity.User; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; 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.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; import java.time.LocalDate; @@ -14,6 +18,15 @@ public interface UserRepository extends JpaRepository { + /** + * 비관적 락을 사용하여 유저 조회 + * 동시 수정이 발생할 수 있는 경우 (공감하기 등) 사용 + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT u FROM User u WHERE u.id = :userId AND u.leaveDate IS NULL AND u.deleteDate IS NULL") + Optional findByIdWithPessimisticLock(@Param("userId") long userId); + Optional findByIdAndLeaveDateIsNullAndDeleteDateIsNull(long userId); List findAllByIdInAndLeaveDateIsNullAndDeleteDateIsNull(List userIds); diff --git a/src/main/java/leets/leenk/domain/user/domain/service/user/UserGetService.java b/src/main/java/leets/leenk/domain/user/domain/service/user/UserGetService.java index bd6bc737..8356d974 100644 --- a/src/main/java/leets/leenk/domain/user/domain/service/user/UserGetService.java +++ b/src/main/java/leets/leenk/domain/user/domain/service/user/UserGetService.java @@ -23,6 +23,15 @@ public User findById(long userId) { .orElseThrow(UserNotFoundException::new); } + /** + * 비관적 락을 사용하여 유저 조회 + * 동시 수정이 발생할 수 있는 경우 (공감하기 등) 사용 + */ + public User findByIdWithLock(long userId) { + return userRepository.findByIdWithPessimisticLock(userId) + .orElseThrow(UserNotFoundException::new); + } + public Optional existById(long userId) { return userRepository.findById(userId); } diff --git a/src/main/java/leets/leenk/global/common/exception/ErrorCode.java b/src/main/java/leets/leenk/global/common/exception/ErrorCode.java index b886e2d9..7a4bf2ff 100644 --- a/src/main/java/leets/leenk/global/common/exception/ErrorCode.java +++ b/src/main/java/leets/leenk/global/common/exception/ErrorCode.java @@ -10,6 +10,7 @@ public enum ErrorCode implements ErrorCodeInterface { // 3000번대: 서버 에러 INTERNAL_SERVER_ERROR(3001, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다."), JSON_PROCESSING(3002, HttpStatus.INTERNAL_SERVER_ERROR, "JSON 처리 중 문제가 발생했습니다."), + RESOURCE_LOCKED(3003, HttpStatus.CONFLICT, "다른 사용자가 처리 중입니다. 잠시 후 다시 시도해주세요."), // 4000번대: 클라이언트 요청 에러 INVALID_ARGUMENT(4001, HttpStatus.BAD_REQUEST, "잘못된 인자입니다."), diff --git a/src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java b/src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java new file mode 100644 index 00000000..83d6b8a9 --- /dev/null +++ b/src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java @@ -0,0 +1,7 @@ +package leets.leenk.global.common.exception; + +public class ResourceLockedException extends BaseException{ + public ResourceLockedException() { + super(ErrorCode.RESOURCE_LOCKED); + } +} diff --git a/src/test/java/leets/leenk/config/MysqlTestConfig.java b/src/test/java/leets/leenk/config/MysqlTestConfig.java deleted file mode 100644 index 53c3e730..00000000 --- a/src/test/java/leets/leenk/config/MysqlTestConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package leets.leenk.config; - -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.context.annotation.Bean; -import org.testcontainers.containers.MySQLContainer; - -@TestConfiguration -public class MysqlTestConfig { - - private static final String MYSQL_IMAGE = "mysql:8.0.41"; - - @Bean - @ServiceConnection - public MySQLContainer mysqlContainer() { - return new MySQLContainer<>(MYSQL_IMAGE) - .withDatabaseName("testdb"); - } -} diff --git a/src/test/java/leets/leenk/config/TestContainersTest.java b/src/test/java/leets/leenk/config/TestContainersTest.java deleted file mode 100644 index 39d81606..00000000 --- a/src/test/java/leets/leenk/config/TestContainersTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package leets.leenk.config; - -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.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; -import org.testcontainers.containers.MySQLContainer; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@Import(MysqlTestConfig.class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@ActiveProfiles("test") -class TestContainersTest { - - @Autowired - private MySQLContainer mysqlContainer; - - - @Test - void MySQL_컨테이너_정상_동작_테스트() { - // MySQL 컨테이너 테스트 - assertThat(mysqlContainer).isNotNull(); - assertThat(mysqlContainer.isRunning()).isTrue(); - assertThat(mysqlContainer.getDatabaseName()).isEqualTo("testdb"); - - System.out.println("MySQL Container JDBC URL: " + mysqlContainer.getJdbcUrl()); - } -} diff --git a/src/test/java/leets/leenk/domain/feed/application/FeedUsecaseTest.java b/src/test/java/leets/leenk/domain/feed/application/FeedUsecaseTest.java index d7968aad..99e83b9a 100644 --- a/src/test/java/leets/leenk/domain/feed/application/FeedUsecaseTest.java +++ b/src/test/java/leets/leenk/domain/feed/application/FeedUsecaseTest.java @@ -356,8 +356,8 @@ void reactToFeed1() { User me = UserTestFixture.createUser(userId, "me"); Feed myFeed = FeedTestFixture.createFeed(feedId, me); + given(feedGetService.findByIdWithLock(feedId)).willReturn(myFeed); given(userGetService.findById(userId)).willReturn(me); - given(feedGetService.findById(feedId)).willReturn(myFeed); ReactionRequest request = new ReactionRequest(1L); @@ -380,8 +380,8 @@ void reactToFeed2() { User author = UserTestFixture.createUser(2L, "author"); Feed feed = FeedTestFixture.createFeed(feedId, author); + given(feedGetService.findByIdWithLock(feedId)).willReturn(feed); given(userGetService.findById(userId)).willReturn(me); - given(feedGetService.findById(feedId)).willReturn(feed); Reaction reaction = ReactionTestFixture.createReaction(feed, me, 4); given(reactionGetService.findByFeedAndUser(feed, me)).willReturn(Optional.of(reaction)); @@ -408,8 +408,8 @@ void reactToFeed3() { User author = UserTestFixture.createUser(2L, "author"); Feed feed = FeedTestFixture.createFeed(feedId, author); + given(feedGetService.findByIdWithLock(feedId)).willReturn(feed); given(userGetService.findById(userId)).willReturn(me); - given(feedGetService.findById(feedId)).willReturn(feed); given(reactionGetService.findByFeedAndUser(feed, me)).willReturn(Optional.empty()); diff --git a/src/test/kotlin/leets/leenk/config/MongoTestConfig.kt b/src/test/kotlin/leets/leenk/config/MongoTestConfig.kt new file mode 100644 index 00000000..21184d97 --- /dev/null +++ b/src/test/kotlin/leets/leenk/config/MongoTestConfig.kt @@ -0,0 +1,17 @@ +package leets.leenk.config + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.context.annotation.Bean +import org.testcontainers.containers.MongoDBContainer + +private const val MONGO_IMAGE: String = "mongo:7.0" + +@TestConfiguration +class MongoTestConfig { + @Bean + @ServiceConnection + fun mongoContainer(): MongoDBContainer { + return MongoDBContainer(MONGO_IMAGE) + } +} diff --git a/src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt b/src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt new file mode 100644 index 00000000..fbf15a1a --- /dev/null +++ b/src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt @@ -0,0 +1,18 @@ +package leets.leenk.config + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.context.annotation.Bean +import org.testcontainers.containers.MySQLContainer + +private const val MYSQL_IMAGE: String = "mysql:8.0.41" + +@TestConfiguration +class MysqlTestConfig { + @Bean + @ServiceConnection + fun mysqlContainer(): MySQLContainer<*> { + return MySQLContainer(MYSQL_IMAGE) + .withDatabaseName("testdb") + } +} diff --git a/src/test/kotlin/leets/leenk/config/TestContainersTest.kt b/src/test/kotlin/leets/leenk/config/TestContainersTest.kt new file mode 100644 index 00000000..08eb08f8 --- /dev/null +++ b/src/test/kotlin/leets/leenk/config/TestContainersTest.kt @@ -0,0 +1,35 @@ +package leets.leenk.config + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.testcontainers.containers.MongoDBContainer +import org.testcontainers.containers.MySQLContainer + +@SpringBootTest +@Import(MysqlTestConfig::class, MongoTestConfig::class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class TestContainersTest( + private val mysqlContainer: MySQLContainer<*>, + private val mongoContainer: MongoDBContainer +) : StringSpec({ + + "MySQL 컨테이너가 정상적으로 실행되어야 한다" { + mysqlContainer shouldNotBe null + mysqlContainer.isRunning shouldBe true + mysqlContainer.databaseName shouldBe "testdb" + + println("MySQL Container JDBC URL: ${mysqlContainer.jdbcUrl}") + } + + "MongoDB 컨테이너가 정상적으로 실행되어야 한다" { + mongoContainer shouldNotBe null + mongoContainer.isRunning shouldBe true + + println("MongoDB Container Connection String: ${mongoContainer.connectionString}") + println("MongoDB Container Replica Set URL: ${mongoContainer.replicaSetUrl}") + } +}) diff --git a/src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt b/src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt new file mode 100644 index 00000000..dfc2dbb4 --- /dev/null +++ b/src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt @@ -0,0 +1,27 @@ +package leets.leenk.domain.feed.application.domain.service + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import jakarta.persistence.PessimisticLockException +import leets.leenk.domain.feed.domain.repository.FeedRepository +import leets.leenk.domain.feed.domain.service.FeedGetService +import leets.leenk.global.common.exception.ResourceLockedException + +class FeedGetServiceTest : DescribeSpec({ + val feedRepository = mockk() + val feedGetService = FeedGetService(feedRepository) + + describe("피드 리액션 기능") { + context("동시 요청 시 락 타임아웃 발생 시") { + it("DB에서 락 예외가 발생하면 커스텀 예외로 변환해야 한다") { + every { feedRepository.findByIdWithPessimisticLock(any()) } throws PessimisticLockException() + + shouldThrow { + feedGetService.findByIdWithLock(1L) + } + } + } + } +}) diff --git a/src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt b/src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt new file mode 100644 index 00000000..a4594310 --- /dev/null +++ b/src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt @@ -0,0 +1,222 @@ +package leets.leenk.domain.feed.application.usecase + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import leets.leenk.config.MongoTestConfig +import leets.leenk.config.MysqlTestConfig +import leets.leenk.domain.feed.application.dto.request.ReactionRequest +import leets.leenk.domain.feed.domain.repository.FeedRepository +import leets.leenk.domain.feed.domain.repository.ReactionRepository +import leets.leenk.domain.feed.test.fixture.FeedTestFixture +import leets.leenk.domain.user.domain.repository.UserRepository +import leets.leenk.domain.user.test.fixture.UserTestFixture +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger + +private const val CONCURRENT_THREAD_COUNT = 10 +private const val ATTEMPT_COUNT = 5 + +private const val FEED_AUTHOR_ID_100 = 100L +private const val FEED_AUTHOR_ID_200 = 200L +private const val FEED_AUTHOR_ID_300 = 300L + +private const val USER_ID_201 = 201L +private const val USER_ID_BASE_300 = 300L + +@SpringBootTest +@Import(MysqlTestConfig::class, MongoTestConfig::class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class FeedUsecaseIntegrationTest( + private val feedUsecase: FeedUsecase, + private val userRepository: UserRepository, + private val feedRepository: FeedRepository, + private val reactionRepository: ReactionRepository +) : BehaviorSpec({ + + afterEach { + reactionRepository.deleteAll() + feedRepository.deleteAll() + userRepository.deleteAll() + } + + /** + * 여러 사용자가 동시에 같은 피드에 공감할 때 비관적 락으로 동시성을 제어하는지 검증 + * 비관적 락이 없으면 데드락이 발생할 수 있음 + */ + Given("여러 사용자가 동시에 같은 피드에 공감하는 경우") { + val feedAuthor = userRepository.save( + UserTestFixture.createUser(id = FEED_AUTHOR_ID_100, name = "피드작성자") + ) + + val feed = feedRepository.save( + FeedTestFixture.createFeed(user = feedAuthor, description = "동시성 테스트용 피드") + ) + + val users = (1..CONCURRENT_THREAD_COUNT).map { i -> + userRepository.save( + UserTestFixture.createUser(id = i.toLong(), name = "사용자$i") + ) + } + + When("비관적 락을 사용하여 동시에 공감을 추가하면") { + val (successCount, failureCount) = executeConcurrentReactions( + threadCount = CONCURRENT_THREAD_COUNT, + reactions = users.map { user -> + { feedUsecase.reactToFeed(user.id!!, feed.id!!, ReactionRequest(1L)) } + } + ) + + Then("모든 요청이 성공하고 리액션 수가 정확해야 한다") { + successCount shouldBe CONCURRENT_THREAD_COUNT + failureCount shouldBe 0 + + val reactions = reactionRepository.findAllByFeed(feed) + reactions.size shouldBe CONCURRENT_THREAD_COUNT + + // 영속성 컨텍스트에서 최신 데이터 조회 + val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get() + updatedFeedAuthor.totalReactionCount shouldBe CONCURRENT_THREAD_COUNT.toLong() + + val updatedFeed = feedRepository.findById(feed.id!!).get() + updatedFeed.totalReactionCount shouldBe CONCURRENT_THREAD_COUNT.toLong() + } + } + } + + /** + * 동일 사용자가 여러 번 공감할 때 reactionCount가 정확하게 증가하는지 검증 + * 피드에 먼저 락을 걸어 유니크 제약 조건 위반(동시 INSERT)을 방지 + */ + Given("동일 사용자가 동시에 여러 번 공감하는 경우") { + val feedAuthor = userRepository.save( + UserTestFixture.createUser(id = FEED_AUTHOR_ID_200, name = "피드작성자") + ) + + val user = userRepository.save( + UserTestFixture.createUser(id = USER_ID_201, name = "공감사용자") + ) + + val feed = feedRepository.save( + FeedTestFixture.createFeed(user = feedAuthor, description = "동일 사용자 동시성 테스트용 피드") + ) + + When("비관적 락을 사용하여 동시에 여러 번 공감을 추가하면") { + val (successCount, failureCount) = executeConcurrentReactions( + threadCount = ATTEMPT_COUNT, + reactions = List(ATTEMPT_COUNT) { + { feedUsecase.reactToFeed(user.id!!, feed.id!!, ReactionRequest(1L)) } + } + ) + + Then("모든 요청이 성공하고 리액션 카운트가 정확해야 한다") { + successCount shouldBe ATTEMPT_COUNT + failureCount shouldBe 0 + + val reaction = reactionRepository.findByFeedAndUser(feed, user) + reaction.isPresent shouldBe true + reaction.get().reactionCount shouldBe ATTEMPT_COUNT.toLong() + + // 영속성 컨텍스트에서 최신 데이터 조회 + val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get() + updatedFeedAuthor.totalReactionCount shouldBe ATTEMPT_COUNT.toLong() + + val updatedFeed = feedRepository.findById(feed.id!!).get() + updatedFeed.totalReactionCount shouldBe ATTEMPT_COUNT.toLong() + } + } + } + + /** + * 동일 작성자의 여러 피드에 여러 사용자가 동시에 공감할 때 데드락이 발생하지 않는지 검증 + * 작성자에 락이 걸리지 않은 경우 데드락이 발생할 수 있음 + */ + Given("다른 사용자가 동일한 사용자의 다른 피드에 동시에 공감하는 경우 (고강도 테스트)") { + val feedAuthor = userRepository.saveAndFlush( + UserTestFixture.createUser(id = FEED_AUTHOR_ID_300, name = "피드 작성자") + ) + + val feed1 = feedRepository.saveAndFlush( + FeedTestFixture.createFeed(user = feedAuthor) + ) + + val feed2 = feedRepository.saveAndFlush( + FeedTestFixture.createFeed(user = feedAuthor) + ) + + val users = (1..CONCURRENT_THREAD_COUNT).map { i -> + userRepository.saveAndFlush( + UserTestFixture.createUser(id = USER_ID_BASE_300 + i, name = "사용자$i") + ) + } + + When("비관적 락 환경에서 동시에 요청을 보낼 때") { + val (successCount, failureCount) = executeConcurrentReactions( + threadCount = CONCURRENT_THREAD_COUNT, + reactions = users.mapIndexed { index, user -> + // 짝수/홀수 인덱스로 피드를 나누어 동일 작성자의 서로 다른 피드로 트랜잭션 분산 + val targetFeedId = if (index % 2 == 0) feed1.id!! else feed2.id!! + { feedUsecase.reactToFeed(user.id!!, targetFeedId, ReactionRequest(1L)) } + } + ) + + Then("데드락 없이 모든 요청이 성공해야 한다") { + successCount shouldBe CONCURRENT_THREAD_COUNT + failureCount shouldBe 0 + + val reactions1 = reactionRepository.findAllByFeed(feed1) + val reactions2 = reactionRepository.findAllByFeed(feed2) + + reactions1.size shouldBe (CONCURRENT_THREAD_COUNT / 2 + CONCURRENT_THREAD_COUNT % 2) + reactions2.size shouldBe CONCURRENT_THREAD_COUNT / 2 + + // 영속성 컨텍스트에서 최신 데이터 조회 + val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get() + updatedFeedAuthor.totalReactionCount shouldBe CONCURRENT_THREAD_COUNT.toLong() + + val updatedFeed1 = feedRepository.findById(feed1.id!!).get() + val updatedFeed2 = feedRepository.findById(feed2.id!!).get() + (updatedFeed1.totalReactionCount + updatedFeed2.totalReactionCount) shouldBe CONCURRENT_THREAD_COUNT.toLong() + } + } + } +}) + +/** + * 동시성 테스트를 위한 헬퍼 함수 + * + * @param threadCount 스레드 풀 크기 + * @param reactions 실행할 작업 목록 + * @return Pair<성공 횟수, 실패 횟수> + */ +private fun executeConcurrentReactions( + threadCount: Int, + reactions: List<() -> Unit> +): Pair { + val executor = Executors.newFixedThreadPool(threadCount) + val latch = CountDownLatch(reactions.size) + val successCount = AtomicInteger(0) + val failureCount = AtomicInteger(0) + + reactions.forEachIndexed { index, reaction -> + executor.submit { + try { + reaction() + successCount.incrementAndGet() + } catch (e: Exception) { + println("스레드 $index 실패: ${e.message}") + failureCount.incrementAndGet() + } finally { + latch.countDown() + } + } + } + + latch.await() + executor.shutdown() + + return successCount.get() to failureCount.get() +} diff --git a/src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt b/src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt new file mode 100644 index 00000000..955b7fe2 --- /dev/null +++ b/src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt @@ -0,0 +1,20 @@ +package leets.leenk.domain.feed.test.fixture + +import leets.leenk.domain.feed.domain.entity.Feed +import leets.leenk.domain.user.domain.entity.User + +class FeedTestFixture { + companion object { + fun createFeed( + user: User, + description: String = "테스트 피드", + totalReactionCount: Long = 0L + ): Feed { + return Feed.builder() + .user(user) + .description(description) + .totalReactionCount(totalReactionCount) + .build() + } + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index f613e859..e2847f14 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,4 +1,15 @@ spring: + autoconfigure: + exclude: + # AWS 관련 AutoConfiguration 제외 + - io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration + - io.awspring.cloud.autoconfigure.sqs.SqsAutoConfiguration + - io.awspring.cloud.autoconfigure.core.AwsAutoConfiguration + - io.awspring.cloud.autoconfigure.core.CredentialsProviderAutoConfiguration + - io.awspring.cloud.autoconfigure.core.RegionProviderAutoConfiguration + data: + mongodb: + # TestContainers @ServiceConnection이 자동으로 설정 jpa: hibernate: ddl-auto: create-drop @@ -6,4 +17,58 @@ spring: properties: hibernate: format_sql: true - dialect: org.hibernate.dialect.MySQL8Dialect \ No newline at end of file + dialect: org.hibernate.dialect.MySQL8Dialect + cloud: + aws: + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + +notion: + token: test-notion-token + feedback_database_id: test-feedback-id + report_database_id: test-report-id + report_database_leenk_id: test-report-leenk-id + version: test-version + +slack: + webhook_url: https://hooks.slack.com/test + +token: + access_token: test-access-token + refresh_token: test-refresh-token + password: test-password + +auth: + oauth2: + client-id: test-client-id + client-secret: test-client-secret + oauth-iss: https://test.oauth.com + oauth-server-token-uri: https://test.oauth.com/token + oauth-jwk-set-uri: https://test.oauth.com/jwks + oauth-server-user-info-uri: https://test.oauth.com/userinfo + kakao: + kakao_grant_type: test + kakao_grant_type_name: test + apple: + apple_grant_type: test + apple_grant_type_name: test + +cloud: + aws: + s3: + bucket: test-bucket + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + +myapp: + sqs: + queue_name: test-queue + queue_url: https://sqs.ap-northeast-2.amazonaws.com/test/test-queue + resize_queue_url: https://sqs.ap-northeast-2.amazonaws.com/test/test-resize-queue + messageDelaySecs: 0 diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..027b4e36 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: test \ No newline at end of file