diff --git a/build.gradle b/build.gradle index db04ad14..ca43328a 100644 --- a/build.gradle +++ b/build.gradle @@ -24,11 +24,31 @@ repositories { maven { url 'https://jitpack.io' } } +// QueryDSL +def generatedDir = "$buildDir/generated/querydsl" + +sourceSets { + main.java.srcDirs += [generatedDir] +} + +tasks.withType(JavaCompile) { + options.getGeneratedSourceOutputDirectory().set(file(generatedDir)) +} + +clean.doLast { + file(generatedDir).deleteDir() +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-batch' + // QueryDSL Dependencies + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' // 채팅 웹 소켓 implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' @@ -42,7 +62,7 @@ dependencies { //Flyway implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' - + implementation 'com.fasterxml.jackson.core:jackson-databind' testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1' compileOnly 'org.projectlombok:lombok' diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/team/buddyya/common/config/QueryDslConfig.java b/src/main/java/com/team/buddyya/common/config/QueryDslConfig.java new file mode 100644 index 00000000..79f97dd5 --- /dev/null +++ b/src/main/java/com/team/buddyya/common/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package com.team.buddyya.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/team/buddyya/feed/controller/FeedController.java b/src/main/java/com/team/buddyya/feed/controller/FeedController.java index 5eefcf4a..091fc64e 100644 --- a/src/main/java/com/team/buddyya/feed/controller/FeedController.java +++ b/src/main/java/com/team/buddyya/feed/controller/FeedController.java @@ -2,6 +2,7 @@ import com.team.buddyya.auth.domain.CustomUserDetails; import com.team.buddyya.feed.dto.request.feed.FeedCreateRequest; +import com.team.buddyya.feed.dto.request.feed.FeedCursorListRequest; import com.team.buddyya.feed.dto.request.feed.FeedListRequest; import com.team.buddyya.feed.dto.request.feed.FeedUpdateRequest; import com.team.buddyya.feed.dto.response.BookmarkResponse; @@ -45,6 +46,17 @@ public ResponseEntity getFeeds(@AuthenticationPrincipal Custom return ResponseEntity.ok(response); } + @GetMapping("/cursor") + public ResponseEntity getFeedsByCursor( + @AuthenticationPrincipal CustomUserDetails userDetails, + @ModelAttribute FeedCursorListRequest request) { + FeedListResponse response = feedService.getFeedsByCursor( + userDetails.getStudentInfo(), + request + ); + return ResponseEntity.ok(response); + } + @GetMapping("/popular") public ResponseEntity getPopularFeeds( @AuthenticationPrincipal CustomUserDetails userDetails, diff --git a/src/main/java/com/team/buddyya/feed/dto/request/feed/FeedCursorListRequest.java b/src/main/java/com/team/buddyya/feed/dto/request/feed/FeedCursorListRequest.java new file mode 100644 index 00000000..faab077b --- /dev/null +++ b/src/main/java/com/team/buddyya/feed/dto/request/feed/FeedCursorListRequest.java @@ -0,0 +1,10 @@ +package com.team.buddyya.feed.dto.request.feed; + +public record FeedCursorListRequest( + String university, + String category, + String keyword, + Long lastId, + Integer pageSize +) { +} diff --git a/src/main/java/com/team/buddyya/feed/dto/response/feed/FeedListResponse.java b/src/main/java/com/team/buddyya/feed/dto/response/feed/FeedListResponse.java index 13111c8e..01c5d4bc 100644 --- a/src/main/java/com/team/buddyya/feed/dto/response/feed/FeedListResponse.java +++ b/src/main/java/com/team/buddyya/feed/dto/response/feed/FeedListResponse.java @@ -3,6 +3,7 @@ import com.team.buddyya.feed.domain.Feed; import java.util.List; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; public record FeedListResponse( List feeds, @@ -19,4 +20,13 @@ public static FeedListResponse from(List feeds, Page feedInf feedInfo.hasNext() ); } + + public static FeedListResponse from(List feeds, Slice sliceInfo) { + return new FeedListResponse( + feeds, + -1, + -1, + sliceInfo.hasNext() + ); + } } diff --git a/src/main/java/com/team/buddyya/feed/repository/FeedLikeRepository.java b/src/main/java/com/team/buddyya/feed/repository/FeedLikeRepository.java index bbd18729..c20f1c79 100644 --- a/src/main/java/com/team/buddyya/feed/repository/FeedLikeRepository.java +++ b/src/main/java/com/team/buddyya/feed/repository/FeedLikeRepository.java @@ -21,4 +21,6 @@ public interface FeedLikeRepository extends JpaRepository { @Query("SELECT fl.feed.id FROM FeedLike fl WHERE fl.student.id = :studentId AND fl.feed.id IN :feedIds") Set findFeedIdsByStudentIdAndFeedIdsIn(@Param("studentId") Long studentId, @Param("feedIds") List feedIds); + + Long countByFeed(Feed feed); } diff --git a/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java b/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java index 2d2e3ea4..cb2ab258 100644 --- a/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java +++ b/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java @@ -4,16 +4,20 @@ import com.team.buddyya.feed.domain.Feed; import com.team.buddyya.student.domain.Student; import com.team.buddyya.student.domain.University; +import jakarta.persistence.LockModeType; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; 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; @Repository -public interface FeedRepository extends JpaRepository { +public interface FeedRepository extends JpaRepository, FeedRepositoryCustom { @Override @EntityGraph(attributePaths = { @@ -23,6 +27,10 @@ public interface FeedRepository extends JpaRepository { }) Optional findById(Long id); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Feed f where f.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + @EntityGraph(attributePaths = { "category", "university", diff --git a/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryCustom.java b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryCustom.java new file mode 100644 index 00000000..b1b11d28 --- /dev/null +++ b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.team.buddyya.feed.repository; + +import com.team.buddyya.feed.domain.Feed; +import com.team.buddyya.feed.dto.request.feed.FeedCursorListRequest; +import org.springframework.data.domain.Slice; + +public interface FeedRepositoryCustom { + + Slice findFeedsByCursor(FeedCursorListRequest request, Feed lastFeed); +} diff --git a/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java new file mode 100644 index 00000000..e0b30b09 --- /dev/null +++ b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java @@ -0,0 +1,75 @@ +package com.team.buddyya.feed.repository; + +import static com.team.buddyya.feed.domain.QFeed.feed; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.team.buddyya.feed.domain.Feed; +import com.team.buddyya.feed.dto.request.feed.FeedCursorListRequest; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +@RequiredArgsConstructor +public class FeedRepositoryImpl implements FeedRepositoryCustom { + + private static final int DEFAULT_PAGE_SIZE = 10; + private final JPAQueryFactory queryFactory; + + @Override + public Slice findFeedsByCursor(FeedCursorListRequest request, Feed lastFeed) { + int pageSize = (request.pageSize() != null && request.pageSize() > 0) ? request.pageSize() : DEFAULT_PAGE_SIZE; + List feeds = queryFactory + .selectFrom(feed) + .where( + cursorCondition(lastFeed), + eqUniversity(request.university()), + eqCategory(request.category()) + ) + .orderBy(feed.pinned.desc(), feed.createdDate.desc(), feed.id.desc()) + .limit(pageSize + 1) + .fetch(); + return toSlice(feeds, pageSize); + } + + private BooleanExpression cursorCondition(Feed lastFeed) { + if (lastFeed == null) { + return null; + } + BooleanExpression pinnedExpression = feed.pinned; + boolean lastPinned = lastFeed.isPinned(); + LocalDateTime lastCreatedAt = lastFeed.getCreatedDate(); + Long lastId = lastFeed.getId(); + return pinnedExpression.lt(lastPinned) + .or(pinnedExpression.eq(lastPinned) + .and(feed.createdDate.lt(lastCreatedAt))) + .or(pinnedExpression.eq(lastPinned) + .and(feed.createdDate.eq(lastCreatedAt)) + .and(feed.id.lt(lastId))); + } + + private BooleanExpression eqUniversity(String university) { + if (university == null || university.isBlank()) { + return null; + } + return feed.university.universityName.eq(university); + } + + private BooleanExpression eqCategory(String category) { + if (category == null || category.isBlank()) { + return null; + } + return feed.category.name.eq(category); + } + + private Slice toSlice(List feeds, int pageSize) { + boolean hasNext = feeds.size() > pageSize; + if (hasNext) { + feeds.remove(pageSize); + } + return new SliceImpl<>(feeds, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/src/main/java/com/team/buddyya/feed/service/BookmarkService.java b/src/main/java/com/team/buddyya/feed/service/BookmarkService.java index c7439e0d..98bb8efb 100644 --- a/src/main/java/com/team/buddyya/feed/service/BookmarkService.java +++ b/src/main/java/com/team/buddyya/feed/service/BookmarkService.java @@ -24,19 +24,17 @@ public class BookmarkService { private final FindStudentService findStudentService; private final FeedRepository feedRepository; - @Transactional(readOnly = true) - boolean existsByStudentAndFeed(Student student, Feed feed) { + private boolean existsByStudentAndFeed(Student student, Feed feed) { return bookmarkRepository.existsByStudentAndFeed(student, feed); } - @Transactional(readOnly = true) - Bookmark findByStudentAndFeed(Student student, Feed feed) { + private Bookmark findByStudentAndFeed(Student student, Feed feed) { return bookmarkRepository.findByStudentAndFeed(student, feed) .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_BOOKMARKED)); } public BookmarkResponse toggleBookmark(StudentInfo studentInfo, Long feedId) { - Feed feed = feedRepository.findById(feedId) + Feed feed = feedRepository.findByIdForUpdate(feedId) .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_FOUND)); Student student = findStudentService.findByStudentId(studentInfo.id()); boolean isBookmarked = existsByStudentAndFeed(student, feed); diff --git a/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java b/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java index 39e0f1f4..cdcef476 100644 --- a/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java +++ b/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java @@ -24,19 +24,17 @@ public class FeedLikeService { private final FindStudentService findStudentService; private final FeedRepository feedRepository; - @Transactional(readOnly = true) - boolean existsByStudentAndFeed(Student student, Feed feed) { + private boolean existsByStudentAndFeed(Student student, Feed feed) { return feedLikeRepository.existsByStudentAndFeed(student, feed); } - @Transactional(readOnly = true) - FeedLike findLikeByStudentAndFeed(Student student, Feed feed) { + private FeedLike findLikeByStudentAndFeed(Student student, Feed feed) { return feedLikeRepository.findByStudentAndFeed(student, feed) .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_LIKED)); } public LikeResponse toggleLike(StudentInfo studentInfo, Long feedId) { - Feed feed = feedRepository.findById(feedId) + Feed feed = feedRepository.findByIdForUpdate(feedId) .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_FOUND)); Student student = findStudentService.findByStudentId(studentInfo.id()); boolean isLiked = existsByStudentAndFeed(student, feed); diff --git a/src/main/java/com/team/buddyya/feed/service/FeedService.java b/src/main/java/com/team/buddyya/feed/service/FeedService.java index 9a7aafb8..af604c25 100644 --- a/src/main/java/com/team/buddyya/feed/service/FeedService.java +++ b/src/main/java/com/team/buddyya/feed/service/FeedService.java @@ -8,6 +8,7 @@ import com.team.buddyya.feed.domain.FeedUserAction; import com.team.buddyya.feed.dto.projection.FeedAuthorInfo; import com.team.buddyya.feed.dto.request.feed.FeedCreateRequest; +import com.team.buddyya.feed.dto.request.feed.FeedCursorListRequest; import com.team.buddyya.feed.dto.request.feed.FeedListRequest; import com.team.buddyya.feed.dto.request.feed.FeedUpdateRequest; import com.team.buddyya.feed.dto.response.feed.FeedListResponse; @@ -38,6 +39,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -85,39 +87,30 @@ public FeedListResponse getFeeds(StudentInfo studentInfo, Pageable pageable, Fee Page feeds = (request.keyword() == null || request.keyword().isBlank()) ? getFeedsByUniversityAndCategory(request, pageable) : getFeedsByKeyword(student, request.keyword(), pageable); + return createFeedListResponse(feeds, studentInfo); + } - Set blockedStudentIds = blockRepository.findBlockedStudentIdByBlockerId(studentInfo.id()); - List feedIds = feeds.getContent().stream() - .map(Feed::getId) - .toList(); - Set likedFeedIds = feedLikeRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Set bookmarkedFeedIds = bookmarkRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Map authorInfoMap = getFeedAuthorInfoMap(feeds.getContent()); - List response = filterBlockedFeeds(feeds.getContent(), blockedStudentIds, student, likedFeedIds, - bookmarkedFeedIds, authorInfoMap); - return FeedListResponse.from(response, feeds); + @Transactional(readOnly = true) + public FeedListResponse getFeedsByCursor(StudentInfo studentInfo, FeedCursorListRequest request) { + final Feed lastFeed = request.lastId() != null ? + feedRepository.findById(request.lastId()) + .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_FOUND)) + : null; + Slice feedSlice = feedRepository.findFeedsByCursor(request, lastFeed); + return createFeedListResponse(feedSlice, studentInfo); } + @Transactional(readOnly = true) public FeedListResponse getPopularFeeds( StudentInfo studentInfo, Pageable pageable, FeedListRequest request ) { - Student student = findStudentByStudentId(studentInfo.id()); University university = findUniversityByUniversityName(request.university()); Page feeds = feedRepository.findByLikeCountGreaterThanEqualAndUniversity(LIKE_COUNT_THRESHOLD, university, pageable); - Set blockedStudentIds = blockRepository.findBlockedStudentIdByBlockerId(studentInfo.id()); - List feedIds = feeds.getContent().stream() - .map(Feed::getId) - .toList(); - Set likedFeedIds = feedLikeRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Set bookmarkedFeedIds = bookmarkRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Map authorInfoMap = getFeedAuthorInfoMap(feeds.getContent()); - List response = filterBlockedFeeds(feeds.getContent(), blockedStudentIds, student, likedFeedIds, - bookmarkedFeedIds, authorInfoMap); - return FeedListResponse.from(response, feeds); + return createFeedListResponse(feeds, studentInfo); } @Transactional(readOnly = true) @@ -152,19 +145,42 @@ public FeedListResponse getBookmarkFeed(StudentInfo studentInfo, Pageable pageab Student student = findStudentByStudentId(studentInfo.id()); Page bookmarks = bookmarkRepository.findAllByStudent(student, pageable); Page feeds = bookmarks.map(Bookmark::getFeed); - Set blockedStudentIds = blockRepository.findBlockedStudentIdByBlockerId(studentInfo.id()); - List feedIds = feeds.getContent().stream() - .map(Feed::getId) - .toList(); + return createFeedListResponse(feeds, studentInfo); + } + + private FeedListResponse createFeedListResponse(Page feedPage, StudentInfo studentInfo) { + List feedResponses = buildFeedResponses(feedPage.getContent(), studentInfo); + return FeedListResponse.from(feedResponses, feedPage); + } + + private FeedListResponse createFeedListResponse(Slice feedSlice, StudentInfo studentInfo) { + List feedResponses = buildFeedResponses(feedSlice.getContent(), studentInfo); + return FeedListResponse.from(feedResponses, feedSlice); + } + private List buildFeedResponses(List feeds, StudentInfo studentInfo) { + if (feeds.isEmpty()) { + return List.of(); + } + Student student = findStudentByStudentId(studentInfo.id()); + Set blockedStudentIds = blockRepository.findBlockedStudentIdByBlockerId(studentInfo.id()); + List feedIds = feeds.stream().map(Feed::getId).toList(); Set likedFeedIds = feedLikeRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); Set bookmarkedFeedIds = bookmarkRepository.findFeedIdsByStudentIdAndFeedIdsIn(studentInfo.id(), feedIds); - Map authorInfoMap = getFeedAuthorInfoMap(feeds.getContent()); - List response = filterBlockedFeeds(feeds.getContent(), blockedStudentIds, student, likedFeedIds, - bookmarkedFeedIds, authorInfoMap); - return FeedListResponse.from(response, feeds); + Map authorInfoMap = getFeedAuthorInfoMap(feeds); + return feeds.stream() + .filter(feed -> !blockedStudentIds.contains(feed.getStudent().getId())) + .map(feed -> { + boolean isFeedOwner = feed.isFeedOwner(student.getId()); + boolean isLiked = likedFeedIds.contains(feed.getId()); + boolean isBookmarked = bookmarkedFeedIds.contains(feed.getId()); + FeedUserAction userAction = FeedUserAction.from(isFeedOwner, isLiked, isBookmarked); + FeedAuthorInfo authorInfo = authorInfoMap.get(feed.getStudent().getId()); + return FeedResponse.from(feed, userAction, authorInfo); + }) + .toList(); } - + @Transactional(readOnly = true) Page getFeedsByKeyword(Student student, String keyword, Pageable pageable) { University openUniversity = findUniversityByUniversityName("all"); @@ -232,23 +248,6 @@ public void togglePin(StudentInfo studentInfo, Long feedId) { feed.togglePin(); } - private List filterBlockedFeeds(List feeds, Set blockedStudentIds, - Student currentStudent, Set likedFeedIds, - Set bookmarkedFeedIds, - Map authorInfoMap) { - return feeds.stream() - .filter(feed -> !blockedStudentIds.contains(feed.getStudent().getId())) - .map(feed -> { - boolean isFeedOwner = feed.isFeedOwner(currentStudent.getId()); - boolean isLiked = likedFeedIds.contains(feed.getId()); - boolean isBookmarked = bookmarkedFeedIds.contains(feed.getId()); - FeedUserAction userAction = FeedUserAction.from(isFeedOwner, isLiked, isBookmarked); - FeedAuthorInfo authorInfo = authorInfoMap.get(feed.getStudent().getId()); - return FeedResponse.from(feed, userAction, authorInfo); - }) - .toList(); - } - private Map getFeedAuthorInfoMap(List feeds) { Set studentIds = feeds.stream() .map(feed -> feed.getStudent().getId()) diff --git a/src/main/resources/db/migration/V32__Add_feed_index.sql b/src/main/resources/db/migration/V32__Add_feed_index.sql new file mode 100644 index 00000000..f708f251 --- /dev/null +++ b/src/main/resources/db/migration/V32__Add_feed_index.sql @@ -0,0 +1 @@ +CREATE INDEX idx_feed_pagination ON feed (university_id, category_id, pinned DESC, created_date DESC, id DESC); diff --git a/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java new file mode 100644 index 00000000..39812bb7 --- /dev/null +++ b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java @@ -0,0 +1,289 @@ +package com.team.buddyya.feed.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.team.buddyya.auth.domain.StudentInfo; +import com.team.buddyya.certification.service.EmailSendService; +import com.team.buddyya.certification.service.MessageSendService; +import com.team.buddyya.common.config.S3Config; +import com.team.buddyya.common.service.S3UploadService; +import com.team.buddyya.feed.domain.Category; +import com.team.buddyya.feed.domain.Feed; +import com.team.buddyya.feed.repository.CategoryRepository; +import com.team.buddyya.feed.repository.FeedLikeRepository; +import com.team.buddyya.feed.repository.FeedRepository; +import com.team.buddyya.notification.service.NotificationService; +import com.team.buddyya.student.domain.Gender; +import com.team.buddyya.student.domain.Role; +import com.team.buddyya.student.domain.Student; +import com.team.buddyya.student.domain.University; +import com.team.buddyya.student.repository.StudentRepository; +import com.team.buddyya.student.repository.UniversityRepository; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class FeedLikeServiceConcurrencyTest { + + @MockBean + private NotificationService notificationService; + + @MockBean + private EmailSendService emailSendService; + + @MockBean + private MessageSendService messageSendService; + + @MockBean + private S3UploadService s3UploadService; + + @MockBean + private S3Config s3Config; + + @Autowired + private FeedLikeService feedLikeService; + @Autowired + private FeedRepository feedRepository; + @Autowired + private FeedLikeRepository feedLikeRepository; + @Autowired + private StudentRepository studentRepository; + @Autowired + private UniversityRepository universityRepository; + @Autowired + private CategoryRepository categoryRepository; + + private Feed savedFeed; + private List likerStudents; + + @BeforeEach + void setUp() { + feedLikeRepository.deleteAllInBatch(); + feedRepository.deleteAllInBatch(); + studentRepository.deleteAllInBatch(); + categoryRepository.deleteAllInBatch(); + universityRepository.deleteAllInBatch(); + + University testUniversity = universityRepository.save(new University("sejong")); + Category testCategory = categoryRepository.save(new Category("자유게시판")); + + Student feedOwner = createAndSaveStudent("01000000000", "피드주인", testUniversity); + savedFeed = feedRepository.save(Feed.builder() + .student(feedOwner) + .title("동시성 테스트 피드") + .content("내용입니다.") + .university(testUniversity) + .category(testCategory) + .isProfileVisible(true) + .build()); + + likerStudents = IntStream.range(1, 101) + .mapToObj(i -> createAndSaveStudent( + String.format("010%08d", i), + "user" + i, + testUniversity)) + .collect(Collectors.toList()); + } + + @Test + void 동시에_좋아요를_누르면_갱신손실이_발생한다() throws InterruptedException { + // given + int userCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(userCount); + + // when + for (Student liker : likerStudents) { + executorService.submit(() -> { + try { + StudentInfo studentInfo = new StudentInfo(liker.getId(), liker.getRole(), true); + feedLikeService.toggleLike(studentInfo, savedFeed.getId()); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + // then + long actualLikeRows = feedLikeRepository.countByFeed(savedFeed); + Feed finalFeed = feedRepository.findById(savedFeed.getId()).orElseThrow(); + int finalLikeCount = finalFeed.getLikeCount(); + + System.out.println("=============================================="); + System.out.println("실제 생성된 FeedLike 레코드 수: " + actualLikeRows); + System.out.println("Feed 엔티티에 기록된 최종 likeCount: " + finalLikeCount); + System.out.println("=============================================="); + + assertThat(actualLikeRows).isEqualTo(userCount); + assertThat(finalLikeCount).isNotEqualTo(userCount); + } + + @Test + void 트랜잭션_격리_수준을_SERIALIZABLE로_설정시_동시성_문제가_해결된다() throws InterruptedException { + // given + int userCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(userCount); + + // when + for (Student liker : likerStudents) { + executorService.submit(() -> { + try { + StudentInfo studentInfo = new StudentInfo(liker.getId(), liker.getRole(), true); + feedLikeService.toggleLike(studentInfo, savedFeed.getId()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + // then + long actualLikeRows = feedLikeRepository.countByFeed(savedFeed); + Feed finalFeed = feedRepository.findById(savedFeed.getId()).orElseThrow(); + int finalLikeCount = finalFeed.getLikeCount(); + + System.out.println("=============================================="); + System.out.println("[SERIALIZABLE] 실제 생성된 FeedLike 레코드 수: " + actualLikeRows); + System.out.println("[SERIALIZABLE] Feed 엔티티에 기록된 최종 likeCount: " + finalLikeCount); + System.out.println("=============================================="); + + assertThat(actualLikeRows).isEqualTo(userCount); + assertThat(finalLikeCount).isEqualTo(userCount); + } + + @Test + void synchronized_키워드_적용시_동시성_문제가_해결된다() throws InterruptedException { + // given + int userCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(userCount); + + // when + for (Student liker : likerStudents) { + executorService.submit(() -> { + try { + StudentInfo studentInfo = new StudentInfo(liker.getId(), liker.getRole(), true); + feedLikeService.toggleLike(studentInfo, savedFeed.getId()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + // then + long actualLikeRows = feedLikeRepository.countByFeed(savedFeed); + Feed finalFeed = feedRepository.findById(savedFeed.getId()).orElseThrow(); + int finalLikeCount = finalFeed.getLikeCount(); + + System.out.println("=============================================="); + System.out.println("[synchronized] 실제 생성된 FeedLike 레코드 수: " + actualLikeRows); + System.out.println("[synchronized] Feed 엔티티에 기록된 최종 likeCount: " + finalLikeCount); + System.out.println("=============================================="); + + assertThat(actualLikeRows).isEqualTo(userCount); + assertThat(finalLikeCount).isEqualTo(userCount); + } + + @Test + void 낙관적_락_적용시_쓰기_충돌이_발생하면_예외가_발생한다() throws InterruptedException { + // given + int userCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(userCount); + + // when + for (Student liker : likerStudents) { + executorService.submit(() -> { + try { + StudentInfo studentInfo = new StudentInfo(liker.getId(), liker.getRole(), true); + feedLikeService.toggleLike(studentInfo, savedFeed.getId()); + } catch (Exception e) { + System.out.println("충돌 감지: " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + // then + long actualLikeRows = feedLikeRepository.countByFeed(savedFeed); + Feed finalFeed = feedRepository.findById(savedFeed.getId()).orElseThrow(); + int finalLikeCount = finalFeed.getLikeCount(); + + System.out.println("=============================================="); + System.out.println("[Optimistic Lock] 실제 생성된 FeedLike 레코드 수: " + actualLikeRows); + System.out.println("[Optimistic Lock] Feed 엔티티에 기록된 최종 likeCount: " + finalLikeCount); + System.out.println("=============================================="); + + assertThat(actualLikeRows).isNotEqualTo(userCount); + assertThat(finalLikeCount).isNotEqualTo(userCount); + assertThat(actualLikeRows).isEqualTo(finalLikeCount); + } + + @Test + void 비관적_락_적용시_동시성_문제가_해결된다() throws InterruptedException { + // given + int userCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(userCount); + + // when + for (Student liker : likerStudents) { + executorService.submit(() -> { + try { + StudentInfo studentInfo = new StudentInfo(liker.getId(), liker.getRole(), true); + feedLikeService.toggleLike(studentInfo, savedFeed.getId()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + // then + long actualLikeRows = feedLikeRepository.countByFeed(savedFeed); + Feed finalFeed = feedRepository.findById(savedFeed.getId()).orElseThrow(); + int finalLikeCount = finalFeed.getLikeCount(); + + System.out.println("=============================================="); + System.out.println("[Pessimistic Lock] 실제 생성된 FeedLike 레코드 수: " + actualLikeRows); + System.out.println("[Pessimistic Lock] Feed 엔티티에 기록된 최종 likeCount: " + finalLikeCount); + System.out.println("=============================================="); + + assertThat(actualLikeRows).isEqualTo(userCount); + assertThat(finalLikeCount).isEqualTo(userCount); + } + + private Student createAndSaveStudent(String phoneNumber, String name, University university) { + Student student = Student.builder() + .phoneNumber(phoneNumber) + .name(name) + .university(university) + .country("South Korea") + .isKorean(true) + .role(Role.STUDENT) + .gender(Gender.MALE) + .characterProfileImage("default.png") + .build(); + return studentRepository.save(student); + } +}