Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
package project.flipnote.bookmark.entity;

import lombok.extern.slf4j.Slf4j;
import project.flipnote.common.model.event.BookmarkEventTargetType;

@Slf4j
public enum BookmarkTargetType {
CARD_SET
CARD_SET;

public BookmarkEventTargetType toEventType() {
try {
return BookmarkEventTargetType.valueOf(this.name());
} catch (IllegalArgumentException e) {
log.error("Failed to map BookmarkTargetType '{}' to BookmarkEventTargetType", this.name(), e);
throw new IllegalStateException(
"Invalid mapping from BookmarkTargetType to BookmarkEventTargetType: " + this.name(),
e
);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package project.flipnote.bookmark.repository;

import java.util.List;
import java.util.Optional;
import java.util.Set;

Expand All @@ -17,5 +18,7 @@ public interface BookmarkRepository extends JpaRepository<Bookmark, Long> {

Page<Bookmark> findAllByTargetTypeAndUserId(BookmarkTargetType targetType, Long userId, Pageable pageable);

int deleteByTargetTypeAndUserIdAndTargetIdIn(BookmarkTargetType targetType, Long userId, Set<Long> targetIds);
List<Bookmark> findAllByTargetTypeAndUserIdAndTargetIdIn(
BookmarkTargetType targetType, Long userId, Set<Long> targetIds
);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package project.flipnote.bookmark.service;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
Expand All @@ -20,6 +22,9 @@
import project.flipnote.bookmark.repository.BookmarkRepository;
import project.flipnote.cardset.service.CardSetService;
import project.flipnote.common.exception.BizException;
import project.flipnote.common.model.event.BookmarkAddedEvent;
import project.flipnote.common.model.event.BookmarkRemovedEvent;
import project.flipnote.common.model.event.BulkBookmarkRemovedEvent;
import project.flipnote.common.model.response.IdResponse;
import project.flipnote.common.model.response.PagingResponse;

Expand All @@ -32,6 +37,7 @@ public class BookmarkService {
private final BookmarkRepository bookmarkRepository;
private final BookmarkTargetFetchService<BookmarkTargetResponse> bookmarkTargetFetchService;
private final CardSetService cardSetService;
private final ApplicationEventPublisher eventPublisher;

/**
* 즐겨찾기 추가
Expand Down Expand Up @@ -59,6 +65,8 @@ public IdResponse addBookmark(Long userId, BookmarkTargetType targetType, Long t
throw new BizException(BookmarkErrorCode.BOOKMARK_ALREADY_EXISTS);
}

eventPublisher.publishEvent(new BookmarkAddedEvent(targetType.toEventType(), targetId, userId));

return IdResponse.from(bookmark.getId());
}

Expand All @@ -78,6 +86,8 @@ public IdResponse deleteBookmark(Long userId, BookmarkTargetType targetType, Lon

bookmarkRepository.delete(bookmark);

eventPublisher.publishEvent(new BookmarkRemovedEvent(targetType.toEventType(), targetId, userId));

return IdResponse.from(bookmark.getId());
}

Expand Down Expand Up @@ -128,6 +138,18 @@ public void removePrivateCardSetBookmarks(Long groupId, Long userId) {
return;
}

bookmarkRepository.deleteByTargetTypeAndUserIdAndTargetIdIn(BookmarkTargetType.CARD_SET, userId, privateCardSetIds);
BookmarkTargetType targetType = BookmarkTargetType.CARD_SET;
List<Bookmark> bookmarks
= bookmarkRepository.findAllByTargetTypeAndUserIdAndTargetIdIn(targetType, userId, privateCardSetIds);
if (bookmarks.isEmpty()) {
return;
}

bookmarkRepository.deleteAll(bookmarks);

List<Long> targetIds = bookmarks.stream()
.map(Bookmark::getTargetId)
.toList();
eventPublisher.publishEvent(new BulkBookmarkRemovedEvent(targetType.toEventType(), targetIds, userId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ public class CardSetMetadata {
@Column(nullable = false)
private int likeCount;

@Column(nullable = false)
private int bookmarkCount;

@Builder
public CardSetMetadata(Long id) {
this.id = id;
this.likeCount = 0;
this.bookmarkCount = 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package project.flipnote.cardset.listener;

import org.springframework.dao.DataAccessException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.cardset.service.CardSetService;
import project.flipnote.common.model.event.BookmarkEventTargetType;
import project.flipnote.common.model.event.BulkBookmarkRemovedEvent;

@Slf4j
@RequiredArgsConstructor
@Component
public class BulkCardSetBookmarkRemovedEventHandler {

private final CardSetService cardSetService;

@Async
@Retryable(
maxAttempts = 3,
retryFor = DataAccessException.class,
backoff = @Backoff(delay = 2000, multiplier = 2)
)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleBookmarkRemovedEvent(BulkBookmarkRemovedEvent event) {
if (event.targetType() != BookmarkEventTargetType.CARD_SET) {
return;
}

cardSetService.decrementBookmarkCount(event.targetIds());
}

@Recover
public void recover(Exception ex, BulkBookmarkRemovedEvent event) {
log.error(
"카드셋 즐겨찾기 벌크 삭제 처리 중 예외 발생 : targetType={}, userId={}, targetIds={}",
event.targetType(), event.userId(), event.targetIds(), ex
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package project.flipnote.cardset.listener;

import org.springframework.dao.DataAccessException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.cardset.service.CardSetService;
import project.flipnote.common.model.event.BookmarkAddedEvent;
import project.flipnote.common.model.event.BookmarkEventTargetType;

@Slf4j
@RequiredArgsConstructor
@Component
public class CardSetBookmarkAddedEventHandler {

private final CardSetService cardSetService;

@Async
@Retryable(
maxAttempts = 3,
retryFor = DataAccessException.class,
backoff = @Backoff(delay = 2000, multiplier = 2)
)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleBookmarkAddedEvent(BookmarkAddedEvent event) {
if (event.targetType() != BookmarkEventTargetType.CARD_SET) {
return;
}

cardSetService.incrementBookmarkCount(event.targetId());
}

@Recover
public void recover(Exception ex, BookmarkAddedEvent event) {
log.error(
"카드셋 즐겨찾기 추가 처리 중 예외 발생 : targetType={}, targetId={}, userId={}",
event.targetType(), event.targetId(), event.userId(), ex
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package project.flipnote.cardset.listener;

import org.springframework.dao.DataAccessException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.cardset.service.CardSetService;
import project.flipnote.common.model.event.BookmarkEventTargetType;
import project.flipnote.common.model.event.BookmarkRemovedEvent;

@Slf4j
@RequiredArgsConstructor
@Component
public class CardSetBookmarkRemovedEventHandler {

private final CardSetService cardSetService;

@Async
@Retryable(
maxAttempts = 3,
retryFor = DataAccessException.class,
backoff = @Backoff(delay = 2000, multiplier = 2)
)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleBookmarkRemovedEvent(BookmarkRemovedEvent event) {
if (event.targetType() != BookmarkEventTargetType.CARD_SET) {
return;
}

cardSetService.decrementBookmarkCount(event.targetId());
}

@Recover
public void recover(Exception ex, BookmarkRemovedEvent event) {
log.error(
"카드셋 즐겨찾기 삭제 처리 중 예외 발생 : targetType={}, targetId={}, userId={}",
event.targetType(), event.targetId(), event.userId(), ex
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import java.util.stream.Collectors;

public enum CardSetSortField {
ID, LIKE;
ID, LIKE, BOOKMARK;

public static Set<String> getFieldNames() {
return Arrays.stream(values())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package project.flipnote.cardset.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -20,4 +22,24 @@ public interface CardSetMetadataRepository extends JpaRepository<CardSetMetadata
WHERE m.id = :cardSetId
""")
int decrementLikeCount(@Param("cardSetId") Long cardSetId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE CardSetMetadata m SET m.bookmarkCount = m.bookmarkCount + 1 WHERE m.id = :cardSetId")
int incrementBookmarkCount(@Param("cardSetId") Long cardSetId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE CardSetMetadata m
SET m.bookmarkCount = CASE WHEN m.bookmarkCount > 0 THEN m.bookmarkCount - 1 ELSE 0 END
WHERE m.id = :cardSetId
""")
int decrementBookmarkCount(@Param("cardSetId") Long cardSetId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE CardSetMetadata m
SET m.bookmarkCount = CASE WHEN m.bookmarkCount > 0 THEN m.bookmarkCount - 1 ELSE 0 END
WHERE m.id IN :cardSetIds
""")
int decrementBookmarkCount(@Param("cardSetIds") List<Long> cardSetIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ public Page<CardSet> searchByNameContainingAndCategory(
if (sortField == CardSetSortField.LIKE) {
orders.add(toOrderSpecifier(cardSetMetadata.likeCount, order));
useMetadata = true;
} else if (sortField == CardSetSortField.BOOKMARK) {
orders.add(toOrderSpecifier(cardSetMetadata.bookmarkCount, order));
useMetadata = true;
Comment on lines +61 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

INNER JOIN으로 인한 결과 누락·total 불일치 가능성 — LEFT JOIN 권장

메타데이터가 없는 카드셋이 존재하면 BOOKMARK/LIKE 정렬 시 결과에서 누락되고(total는 전체 기준) 페이지네이션 불일치가 발생할 수 있습니다. LEFT JOIN으로 바꾸어 안전하게 정렬하세요.

-		if (useMetadata) {
-			selectQuery.join(cardSetMetadata).on(cardSet.id.eq(cardSetMetadata.id));
-		}
+		if (useMetadata) {
+			selectQuery.leftJoin(cardSetMetadata).on(cardSet.id.eq(cardSetMetadata.id));
+		}

보완 사항:

  • LEFT JOIN 시 null 정렬이 앞서는 DB에서는 nulls last 처리(가능하면 Querydsl NullHandling)도 고려해 주세요.
  • INNER JOIN을 고집해야 한다면 count 쿼리에도 동일 조인을 반영해 일관성을 맞춰야 합니다.

Also applies to: 79-81, 89-95

🤖 Prompt for AI Agents
In
src/main/java/project/flipnote/cardset/repository/CardSetRepositoryCustomImpl.java
around lines 61-63 (and similarly at 79-81 and 89-95), the current INNER JOIN on
cardSetMetadata causes card sets without metadata to be dropped and pagination
totals to mismatch; change those joins to LEFT JOIN so card sets with null
metadata are preserved, and ensure sorting handles nulls (e.g., apply Querydsl
nullsLast/nullsFirst handling so null metadata sorts as expected); if you must
keep INNER JOIN instead, apply the same INNER JOIN to the count query to keep
totals consistent.

} else {
orders.add(toOrderSpecifier(cardSet.id, order));
hasIdSort = true;
Expand All @@ -74,7 +77,7 @@ public Page<CardSet> searchByNameContainingAndCategory(
.where(buildCardSetSearchFilterConditions(name, category));

if (useMetadata) {
selectQuery.join(cardSetMetadata).on(cardSet.id.eq(cardSetMetadata.id));
selectQuery.leftJoin(cardSetMetadata).on(cardSet.id.eq(cardSetMetadata.id));
}

List<CardSet> content = selectQuery
Expand Down
39 changes: 36 additions & 3 deletions src/main/java/project/flipnote/cardset/service/CardSetService.java
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public CreateCardSetResponse createCardSet(Long groupId, AuthPrinciple authPrinc
public PagingResponse<CardSetSummaryResponse> getCardSets(CardSetSearchRequest req) {
// TODO: Projection 튜닝 필요
Page<CardSet> cardSetPage = cardSetRepository.searchByNameContainingAndCategory(
req.getKeyword(), Category.from(req.getCategory()), req.getPageRequest()
req.getKeyword(), Category.from(req.getCategory()), req.getPageRequest()
);

Page<CardSetSummaryResponse> res = cardSetPage.map(CardSetSummaryResponse::from);
Expand Down Expand Up @@ -223,7 +223,7 @@ public List<CardSetSummaryResponse> getCardSetsByIds(Set<Long> targetIds) {
* 사용자가 특정 카드셋에 접근할 수 있는지 여부를 확인
*
* @param cardSetId 확인할 카드셋의 ID
* @param userId 접근 권한을 확인할 사용자의 ID
* @param userId 접근 권한을 확인할 사용자의 ID
* @return 접근 가능 여부
* @author 윤정환
*/
Expand All @@ -237,7 +237,7 @@ public boolean isCardSetViewable(Long cardSetId, Long userId) {
* 카드셋 ID 목록에 해당하는 카드셋 목록 조회
*
* @param targetIds 조회할 카드셋 ID 목록
* @param userId 카드셋 목록을 조회하는 회원 ID
* @param userId 카드셋 목록을 조회하는 회원 ID
* @return 조회된 카드셋 목록
* @author 윤정환
*/
Expand All @@ -260,4 +260,37 @@ public List<CardSetSummaryResponse> findViewableCardSetsByIds(Set<Long> targetId
public Set<Long> findPrivateCardSetIds(Long groupId) {
return cardSetRepository.findPrivateIdsByGroupId(groupId);
}

/**
* 카드셋 즐겨찾기 수를 1 증가
*
* @param cardSetId 즐겨찾기 수를 증가시킬 카드셋 ID
* @author 윤정환
*/
@Transactional
public void incrementBookmarkCount(Long cardSetId) {
cardSetMetadataRepository.incrementBookmarkCount(cardSetId);
}

/**
* 카드셋 즐겨찾기 수를 1 감소
*
* @param cardSetId 즐겨찾기 수를 감소시킬 카드셋 ID
* @author 윤정환
*/
@Transactional
public void decrementBookmarkCount(Long cardSetId) {
cardSetMetadataRepository.decrementBookmarkCount(cardSetId);
}

/**
* 여러 카드셋 즐겨찾기 수를 1 감소
*
* @param cardSetIds 즐겨찾기 수를 감소시킬 카드셋 ID 목록
* @author 윤정환
*/
@Transactional
public void decrementBookmarkCount(List<Long> cardSetIds) {
cardSetMetadataRepository.decrementBookmarkCount(cardSetIds);
}
}
Loading
Loading