From b43e0c1c44caed006b5ebd0d20319a983c5da04b Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Sat, 13 Sep 2025 21:11:12 +0900 Subject: [PATCH 01/14] =?UTF-8?q?chore:=20queryDSL=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=84=A4=EC=B9=98=20=EB=B0=8F=20config=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 22 ++++++++++++++++++- gradlew | 0 .../buddyya/common/config/QueryDslConfig.java | 19 ++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) mode change 100644 => 100755 gradlew create mode 100644 src/main/java/com/team/buddyya/common/config/QueryDslConfig.java 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); + } +} From fe4168a04b2ae44bb2308366ae44026b860add3a Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Sat, 13 Sep 2025 21:14:38 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=94=BC=EB=93=9C=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20DTO=20=EA=B5=AC=ED=98=84=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/buddyya/feed/controller/FeedController.java | 12 ++++++++++++ .../feed/dto/request/feed/FeedCursorListRequest.java | 10 ++++++++++ .../feed/dto/response/feed/FeedListResponse.java | 10 ++++++++++ 3 files changed, 32 insertions(+) create mode 100644 src/main/java/com/team/buddyya/feed/dto/request/feed/FeedCursorListRequest.java 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..0627db26 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, + sliceInfo.getNumber(), + -1, + sliceInfo.hasNext() + ); + } } From 93600a58f414fab65edbd6e89fc1297e50fe0633 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Sat, 13 Sep 2025 21:20:05 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20Rep?= =?UTF-8?q?ository=20=EC=83=9D=EC=84=B1=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/buddyya/feed/repository/FeedRepository.java | 2 +- .../buddyya/feed/repository/FeedRepositoryCustom.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/team/buddyya/feed/repository/FeedRepositoryCustom.java 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..a0b08546 100644 --- a/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java +++ b/src/main/java/com/team/buddyya/feed/repository/FeedRepository.java @@ -13,7 +13,7 @@ import org.springframework.stereotype.Repository; @Repository -public interface FeedRepository extends JpaRepository { +public interface FeedRepository extends JpaRepository, FeedRepositoryCustom { @Override @EntityGraph(attributePaths = { 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); +} From 0afc49705ae7776f436388c2c69fde5375acb188 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Sat, 13 Sep 2025 21:52:54 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EB=8F=99=EC=A0=81=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=ED=94=BC=EB=93=9C=20=EC=B0=BE=EA=B8=B0=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/repository/FeedRepositoryImpl.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java 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..82c97066 --- /dev/null +++ b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java @@ -0,0 +1,84 @@ +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()), + containsKeyword(request.keyword()) + ) + .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.coalesce(false); + 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() || university.equalsIgnoreCase("all")) { + 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 BooleanExpression containsKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return feed.title.containsIgnoreCase(keyword) + .or(feed.content.containsIgnoreCase(keyword)); + } + + 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); + } +} From a804c939e033fc406615b6e80ce1f2c7b577953b Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Sat, 13 Sep 2025 22:08:06 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=94=BC=EB=93=9C=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../buddyya/feed/service/FeedService.java | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) 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..b3a1ad30 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,17 +145,7 @@ 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(); - - 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) @@ -232,6 +215,28 @@ public void togglePin(StudentInfo studentInfo, Long feedId) { feed.togglePin(); } + private FeedListResponse createFeedListResponse(Slice feedSlice, StudentInfo studentInfo) { + List feeds = feedSlice.getContent(); + Student student = findStudentByStudentId(studentInfo.id()); + if (feeds.isEmpty()) { + return FeedListResponse.from(List.of(), feedSlice); + } + 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); + List response = filterBlockedFeeds( + feeds, + blockedStudentIds, + student, + likedFeedIds, + bookmarkedFeedIds, + authorInfoMap + ); + return FeedListResponse.from(response, feedSlice); + } + private List filterBlockedFeeds(List feeds, Set blockedStudentIds, Student currentStudent, Set likedFeedIds, Set bookmarkedFeedIds, From e61e40e52426520a5a9c0602696804d4ab3e9c2e Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Sat, 13 Sep 2025 22:26:14 +0900 Subject: [PATCH 06/14] =?UTF-8?q?refactor:=20N=20+=201=20=EB=B2=8C?= =?UTF-8?q?=ED=81=AC=20=EB=A1=9C=EB=94=A9=EC=97=90=EC=84=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EB=90=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EB=B6=84=EB=A6=AC=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/feed/FeedListResponse.java | 2 +- .../buddyya/feed/service/FeedService.java | 72 +++++++++---------- 2 files changed, 34 insertions(+), 40 deletions(-) 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 0627db26..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 @@ -24,7 +24,7 @@ public static FeedListResponse from(List feeds, Page feedInf public static FeedListResponse from(List feeds, Slice sliceInfo) { return new FeedListResponse( feeds, - sliceInfo.getNumber(), + -1, -1, sliceInfo.hasNext() ); 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 b3a1ad30..af604c25 100644 --- a/src/main/java/com/team/buddyya/feed/service/FeedService.java +++ b/src/main/java/com/team/buddyya/feed/service/FeedService.java @@ -148,6 +148,39 @@ public FeedListResponse getBookmarkFeed(StudentInfo studentInfo, Pageable pageab 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); + 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"); @@ -215,45 +248,6 @@ public void togglePin(StudentInfo studentInfo, Long feedId) { feed.togglePin(); } - private FeedListResponse createFeedListResponse(Slice feedSlice, StudentInfo studentInfo) { - List feeds = feedSlice.getContent(); - Student student = findStudentByStudentId(studentInfo.id()); - if (feeds.isEmpty()) { - return FeedListResponse.from(List.of(), feedSlice); - } - 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); - List response = filterBlockedFeeds( - feeds, - blockedStudentIds, - student, - likedFeedIds, - bookmarkedFeedIds, - authorInfoMap - ); - return FeedListResponse.from(response, feedSlice); - } - - 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()) From ee64f0730976eba41c8a8bcb84fb9e6301af6e23 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Sun, 14 Sep 2025 01:35:22 +0900 Subject: [PATCH 07/14] =?UTF-8?q?chore:=20flyway=20index=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EB=AC=B8=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/repository/FeedRepositoryImpl.java | 15 +++------------ .../db/migration/V32__Add_feed_index.sql | 1 + 2 files changed, 4 insertions(+), 12 deletions(-) create mode 100644 src/main/resources/db/migration/V32__Add_feed_index.sql diff --git a/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java index 82c97066..e0b30b09 100644 --- a/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java +++ b/src/main/java/com/team/buddyya/feed/repository/FeedRepositoryImpl.java @@ -27,8 +27,7 @@ public Slice findFeedsByCursor(FeedCursorListRequest request, Feed lastFee .where( cursorCondition(lastFeed), eqUniversity(request.university()), - eqCategory(request.category()), - containsKeyword(request.keyword()) + eqCategory(request.category()) ) .orderBy(feed.pinned.desc(), feed.createdDate.desc(), feed.id.desc()) .limit(pageSize + 1) @@ -40,7 +39,7 @@ private BooleanExpression cursorCondition(Feed lastFeed) { if (lastFeed == null) { return null; } - BooleanExpression pinnedExpression = feed.pinned.coalesce(false); + BooleanExpression pinnedExpression = feed.pinned; boolean lastPinned = lastFeed.isPinned(); LocalDateTime lastCreatedAt = lastFeed.getCreatedDate(); Long lastId = lastFeed.getId(); @@ -53,7 +52,7 @@ private BooleanExpression cursorCondition(Feed lastFeed) { } private BooleanExpression eqUniversity(String university) { - if (university == null || university.isBlank() || university.equalsIgnoreCase("all")) { + if (university == null || university.isBlank()) { return null; } return feed.university.universityName.eq(university); @@ -66,14 +65,6 @@ private BooleanExpression eqCategory(String category) { return feed.category.name.eq(category); } - private BooleanExpression containsKeyword(String keyword) { - if (keyword == null || keyword.isBlank()) { - return null; - } - return feed.title.containsIgnoreCase(keyword) - .or(feed.content.containsIgnoreCase(keyword)); - } - private Slice toSlice(List feeds, int pageSize) { boolean hasNext = feeds.size() > pageSize; if (hasNext) { 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); From f90b277e12032756c5b1bcb7b2a2e49358e58891 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Tue, 16 Sep 2025 10:29:34 +0900 Subject: [PATCH 08/14] =?UTF-8?q?test:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=99=98=EA=B2=BD=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95=20(#372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/repository/FeedLikeRepository.java | 2 + .../FeedLikeServiceConcurrencyTest.java | 146 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java 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/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..4c80247a --- /dev/null +++ b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java @@ -0,0 +1,146 @@ +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); + } + + 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); + } +} From aae87b5ee0273961871c6f7aaae88e11371a1d83 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Tue, 16 Sep 2025 10:33:43 +0900 Subject: [PATCH 09/14] =?UTF-8?q?test:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EA=B2=A9=EB=A6=AC=20=EC=88=98=EC=A4=80=EC=9D=84=20?= =?UTF-8?q?=EC=B5=9C=EA=B3=A0=EB=A1=9C=20=ED=96=88=EC=9D=84=20=EB=95=8C=20?= =?UTF-8?q?=EB=8D=B0=EB=93=9C=EB=9D=BD=20=EB=B0=9C=EC=83=9D=20(#372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../buddyya/feed/service/FeedLikeService.java | 2 ++ .../FeedLikeServiceConcurrencyTest.java | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+) 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..545846e3 100644 --- a/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java +++ b/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @Service @@ -35,6 +36,7 @@ FeedLike findLikeByStudentAndFeed(Student student, Feed feed) { .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_LIKED)); } + @Transactional(isolation = Isolation.SERIALIZABLE) public LikeResponse toggleLike(StudentInfo studentInfo, Long feedId) { Feed feed = feedRepository.findById(feedId) .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_FOUND)); diff --git a/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java index 4c80247a..7d231846 100644 --- a/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java +++ b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java @@ -130,6 +130,41 @@ void setUp() { 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); + } + private Student createAndSaveStudent(String phoneNumber, String name, University university) { Student student = Student.builder() .phoneNumber(phoneNumber) From d26064f58e2d454c23f95ccbc2ed3afe91d47f57 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Tue, 16 Sep 2025 10:37:53 +0900 Subject: [PATCH 10/14] =?UTF-8?q?test:=20synchronized=20=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=95=B4=EA=B2=B0=20(#372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../buddyya/feed/service/FeedLikeService.java | 4 +-- .../FeedLikeServiceConcurrencyTest.java | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) 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 545846e3..2c03006e 100644 --- a/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java +++ b/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java @@ -13,7 +13,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @Service @@ -36,8 +35,7 @@ FeedLike findLikeByStudentAndFeed(Student student, Feed feed) { .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_LIKED)); } - @Transactional(isolation = Isolation.SERIALIZABLE) - public LikeResponse toggleLike(StudentInfo studentInfo, Long feedId) { + public synchronized LikeResponse toggleLike(StudentInfo studentInfo, Long feedId) { Feed feed = feedRepository.findById(feedId) .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_FOUND)); Student student = findStudentService.findByStudentId(studentInfo.id()); diff --git a/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java index 7d231846..19b16a0b 100644 --- a/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java +++ b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java @@ -165,6 +165,41 @@ void setUp() { 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); + } + private Student createAndSaveStudent(String phoneNumber, String name, University university) { Student student = Student.builder() .phoneNumber(phoneNumber) From 41606c5bb01b0d9065c1adba36eba48bb22f24d7 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Tue, 16 Sep 2025 10:53:54 +0900 Subject: [PATCH 11/14] =?UTF-8?q?test:=20=EB=82=99=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EB=9D=BD=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?,=20=EB=A1=A4=EB=B0=B1=20=ED=99=95=EC=9D=B8=20(#372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/team/buddyya/feed/domain/Feed.java | 4 ++ .../buddyya/feed/service/FeedLikeService.java | 2 +- .../FeedLikeServiceConcurrencyTest.java | 38 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/team/buddyya/feed/domain/Feed.java b/src/main/java/com/team/buddyya/feed/domain/Feed.java index da0e4f52..bf2a28d3 100644 --- a/src/main/java/com/team/buddyya/feed/domain/Feed.java +++ b/src/main/java/com/team/buddyya/feed/domain/Feed.java @@ -16,6 +16,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.Version; import java.util.ArrayList; import java.util.List; import lombok.Builder; @@ -33,6 +34,9 @@ public class Feed extends BaseTime { @GeneratedValue(strategy = IDENTITY) private Long id; + @Version + private Long version; + @Column(length = 255, nullable = false) private String title; 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 2c03006e..39e0f1f4 100644 --- a/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java +++ b/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java @@ -35,7 +35,7 @@ FeedLike findLikeByStudentAndFeed(Student student, Feed feed) { .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_LIKED)); } - public synchronized LikeResponse toggleLike(StudentInfo studentInfo, Long feedId) { + public LikeResponse toggleLike(StudentInfo studentInfo, Long feedId) { Feed feed = feedRepository.findById(feedId) .orElseThrow(() -> new FeedException(FeedExceptionType.FEED_NOT_FOUND)); Student student = findStudentService.findByStudentId(studentInfo.id()); diff --git a/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java index 19b16a0b..bc82e7b1 100644 --- a/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java +++ b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java @@ -200,6 +200,44 @@ void setUp() { 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); + } + private Student createAndSaveStudent(String phoneNumber, String name, University university) { Student student = Student.builder() .phoneNumber(phoneNumber) From 6b014b582b86173b8985663e3f0606e41ec749d3 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Tue, 16 Sep 2025 11:01:15 +0900 Subject: [PATCH 12/14] =?UTF-8?q?test:=20=EB=B9=84=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EB=9D=BD=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/team/buddyya/feed/domain/Feed.java | 4 --- .../feed/repository/FeedRepository.java | 8 +++++ .../buddyya/feed/service/FeedLikeService.java | 2 +- .../FeedLikeServiceConcurrencyTest.java | 35 +++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/team/buddyya/feed/domain/Feed.java b/src/main/java/com/team/buddyya/feed/domain/Feed.java index bf2a28d3..da0e4f52 100644 --- a/src/main/java/com/team/buddyya/feed/domain/Feed.java +++ b/src/main/java/com/team/buddyya/feed/domain/Feed.java @@ -16,7 +16,6 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import jakarta.persistence.Version; import java.util.ArrayList; import java.util.List; import lombok.Builder; @@ -34,9 +33,6 @@ public class Feed extends BaseTime { @GeneratedValue(strategy = IDENTITY) private Long id; - @Version - private Long version; - @Column(length = 255, nullable = false) private String title; 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 a0b08546..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,12 +4,16 @@ 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 @@ -23,6 +27,10 @@ public interface FeedRepository extends JpaRepository, FeedRepositor }) 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/service/FeedLikeService.java b/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java index 39e0f1f4..a24c8ed8 100644 --- a/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java +++ b/src/main/java/com/team/buddyya/feed/service/FeedLikeService.java @@ -36,7 +36,7 @@ FeedLike findLikeByStudentAndFeed(Student student, Feed feed) { } 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/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java index bc82e7b1..39812bb7 100644 --- a/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java +++ b/src/test/java/com/team/buddyya/feed/service/FeedLikeServiceConcurrencyTest.java @@ -238,6 +238,41 @@ void setUp() { 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) From ca9a5a7ce3f5b2037eca662f889e5969866badf4 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Tue, 16 Sep 2025 11:04:58 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refcator:=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=84=ED=8C=8C=20?= =?UTF-8?q?=EB=81=8A=EC=96=B4=EB=82=B4=EA=B8=B0=20(#372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/team/buddyya/feed/service/FeedLikeService.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 a24c8ed8..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,13 +24,11 @@ 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)); } From 6ab6766407d64ab66c153ba20183c2e7d7105199 Mon Sep 17 00:00:00 2001 From: mangsuyo Date: Tue, 16 Sep 2025 11:20:15 +0900 Subject: [PATCH 14/14] =?UTF-8?q?refacotr:=20=EB=B6=81=EB=A7=88=ED=81=AC?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=95=B4=EA=B2=B0=20(#372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/team/buddyya/feed/service/BookmarkService.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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);