diff --git a/backend/turip-app/src/main/java/turip/common/configuration/AsyncConfiguration.java b/backend/turip-app/src/main/java/turip/common/configuration/AsyncConfiguration.java new file mode 100644 index 000000000..a95bbc3de --- /dev/null +++ b/backend/turip-app/src/main/java/turip/common/configuration/AsyncConfiguration.java @@ -0,0 +1,35 @@ +package turip.common.configuration; + +import java.util.concurrent.Executor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Slf4j +@EnableAsync +@Configuration +public class AsyncConfiguration { + + @Bean(name = "sseEventExecutor") + public Executor sseEventExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("SSE-EVT-"); + + executor.setRejectedExecutionHandler((r, executorInstance) -> log.warn( + "[SSE-ThreadPool] 이벤트 전송 거부됨 - Thread pool 포화 상태 (현재 활성 스레드: {}, 잔여 큐 용량: {})", + executorInstance.getActiveCount(), + executorInstance.getQueue().remainingCapacity() + ) + ); + + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + + return executor; + } +} diff --git a/backend/turip-app/src/main/java/turip/common/exception/ErrorTag.java b/backend/turip-app/src/main/java/turip/common/exception/ErrorTag.java index 792187a46..5beee3e90 100644 --- a/backend/turip-app/src/main/java/turip/common/exception/ErrorTag.java +++ b/backend/turip-app/src/main/java/turip/common/exception/ErrorTag.java @@ -52,6 +52,7 @@ public enum ErrorTag { FAVORITE_PLACE_IN_FOLDER_CONFLICT("해당 폴더에 이미 찜한 장소입니다."), LOGIN_ID_CONFLICT("중복 아이디가 존재합니다."), CONTENT_SAVE_CONFLICT("이미 등록된 컨텐츠입니다."), + FAVORITE_FOLDER_ALREADY_JOINED("해당 공유 찜 폴더에 이미 참여한 멤버입니다."), // 500 Internal Server Error INTERNAL_SERVER_ERROR("서버에서 예기치 못한 에러가 발생했습니다."), diff --git a/backend/turip-app/src/main/java/turip/favorite/controller/FavoritePlaceController.java b/backend/turip-app/src/main/java/turip/favorite/controller/FavoritePlaceController.java index 730de5a05..1b24af104 100644 --- a/backend/turip-app/src/main/java/turip/favorite/controller/FavoritePlaceController.java +++ b/backend/turip-app/src/main/java/turip/favorite/controller/FavoritePlaceController.java @@ -31,8 +31,6 @@ import turip.favorite.controller.dto.response.FavoriteFolderWithFavoriteStatusResponse.FavoritePlacesWithPlaceDetailResponse; import turip.favorite.controller.dto.response.FavoritePlaceCountResponse; import turip.favorite.service.FavoritePlaceService; -import turip.favorite.stream.service.ActionType; -import turip.favorite.stream.service.FavoriteFolderStreamService; @RestController @RequiredArgsConstructor @@ -41,7 +39,6 @@ public class FavoritePlaceController { private final FavoritePlaceService favoritePlaceService; - private final FavoriteFolderStreamService favoriteFolderStreamService; @Operation( summary = "튜립 장소 추가 api", @@ -184,7 +181,6 @@ public ResponseEntity create( @RequestParam("placeId") Long placeId ) { FavoritePlaceResponse response = favoritePlaceService.create(account, favoriteFolderId, placeId); - favoriteFolderStreamService.sendFolderUpdateEvents(response.favoriteFolderId(), ActionType.PLACE_ADDED); return ResponseEntity.created(URI.create("/api/v1/turips/places/" + response.id())) .body(response); } diff --git a/backend/turip-app/src/main/java/turip/favorite/stream/service/ActionType.java b/backend/turip-app/src/main/java/turip/favorite/domain/event/ActionType.java similarity index 56% rename from backend/turip-app/src/main/java/turip/favorite/stream/service/ActionType.java rename to backend/turip-app/src/main/java/turip/favorite/domain/event/ActionType.java index a9bd1d4aa..7e9e0c5b7 100644 --- a/backend/turip-app/src/main/java/turip/favorite/stream/service/ActionType.java +++ b/backend/turip-app/src/main/java/turip/favorite/domain/event/ActionType.java @@ -1,11 +1,16 @@ -package turip.favorite.stream.service; +package turip.favorite.domain.event; public enum ActionType { // FavoritePlace 관련 PLACE_REORDERED, PLACE_ADDED, PLACE_DELETED, - NAME_CHANGED, + + // FavoriteFolder 관련 + FOLDER_NAME_CHANGED, + FOLDER_DELETED, + FOLDER_PLACE_CHANGED, + // Member 관련 MEMBER_JOINED, MEMBER_EXITED, diff --git a/backend/turip-app/src/main/java/turip/favorite/domain/event/FavoriteFolderUpdateEvent.java b/backend/turip-app/src/main/java/turip/favorite/domain/event/FavoriteFolderUpdateEvent.java new file mode 100644 index 000000000..725d97bf7 --- /dev/null +++ b/backend/turip-app/src/main/java/turip/favorite/domain/event/FavoriteFolderUpdateEvent.java @@ -0,0 +1,10 @@ +package turip.favorite.domain.event; + +public record FavoriteFolderUpdateEvent( + Long favoriteFolderId, + ActionType actionType +) { + public static FavoriteFolderUpdateEvent of(Long favoriteFolderId, ActionType actionType) { + return new FavoriteFolderUpdateEvent(favoriteFolderId, actionType); + } +} diff --git a/backend/turip-app/src/main/java/turip/favorite/repository/FavoriteFolderAccountRepository.java b/backend/turip-app/src/main/java/turip/favorite/repository/FavoriteFolderAccountRepository.java index e99ebd0fb..6f7b3c4c4 100644 --- a/backend/turip-app/src/main/java/turip/favorite/repository/FavoriteFolderAccountRepository.java +++ b/backend/turip-app/src/main/java/turip/favorite/repository/FavoriteFolderAccountRepository.java @@ -26,18 +26,13 @@ public interface FavoriteFolderAccountRepository extends JpaRepository countByFavoriteFolderIdsIn(List folderIds); - boolean existsByFavoriteFolderAndAccountAndAccountRole(FavoriteFolder favoriteFolder, Account account, - AccountRole accountRole); - - boolean existsByFavoriteFolderAndAccount(FavoriteFolder favoriteFolder, Account account); - Optional findByFavoriteFolderAndAccount(FavoriteFolder favoriteFolder, Account account); - @Query("SELECT m FROM FavoriteFolderAccount ffa " + - "JOIN ffa.account a " + - "JOIN Member m ON m.account.id = a.id " + - "WHERE ffa.favoriteFolder = :favoriteFolder") - List findMembersByFavoriteFolder(@Param("favoriteFolder") FavoriteFolder favoriteFolder); + @Query("SELECT m FROM Member m " + + "JOIN FETCH m.account a " + + "JOIN FavoriteFolderAccount ffa ON ffa.account.id = a.id " + + "WHERE ffa.favoriteFolder.id = :favoriteFolderId") + List findMembersByFavoriteFolderId(@Param("favoriteFolderId") Long favoriteFolderId); @Modifying @Query("UPDATE FavoriteFolderAccount ffa " + @@ -45,4 +40,10 @@ boolean existsByFavoriteFolderAndAccountAndAccountRole(FavoriteFolder favoriteFo "WHERE ffa.account = :oldAccount") void updateAccount(@Param("oldAccount") Account oldAccount, @Param("newAccount") Account newAccount); + boolean existsByFavoriteFolderAndAccountAndAccountRole(FavoriteFolder favoriteFolder, Account account, + AccountRole accountRole); + + boolean existsByFavoriteFolderAndAccount(FavoriteFolder favoriteFolder, Account account); + + boolean existsByFavoriteFolderIdAndAccount(Long favoriteFolderId, Account account); } diff --git a/backend/turip-app/src/main/java/turip/favorite/service/FavoriteFolderAccountService.java b/backend/turip-app/src/main/java/turip/favorite/service/FavoriteFolderAccountService.java index 0503bd5da..014abdc75 100644 --- a/backend/turip-app/src/main/java/turip/favorite/service/FavoriteFolderAccountService.java +++ b/backend/turip-app/src/main/java/turip/favorite/service/FavoriteFolderAccountService.java @@ -48,7 +48,14 @@ public void validateOwnership(Account account, FavoriteFolder favoriteFolder) { } public void validateMembership(Account account, FavoriteFolder favoriteFolder) { - if (!isFolderMember(account, favoriteFolder)) { + validateMembership(account, favoriteFolder.getId()); + } + + public void validateMembership(Account account, Long favoriteFolderId) { + boolean isMember = favoriteFolderAccountRepository.existsByFavoriteFolderIdAndAccount(favoriteFolderId, + account); + + if (!isMember) { throw new ForbiddenException(ErrorTag.FORBIDDEN); } } @@ -57,8 +64,8 @@ public boolean isFolderMember(Account account, FavoriteFolder favoriteFolder) { return favoriteFolderAccountRepository.existsByFavoriteFolderAndAccount(favoriteFolder, account); } - public List findMembersByFavoriteFolder(FavoriteFolder favoriteFolder) { - return favoriteFolderAccountRepository.findMembersByFavoriteFolder(favoriteFolder); + public List findMembersByFavoriteFolder(Long favoriteFolderId) { + return favoriteFolderAccountRepository.findMembersByFavoriteFolderId(favoriteFolderId); } public int countByFavoriteFolder(FavoriteFolder favoriteFolder) { diff --git a/backend/turip-app/src/main/java/turip/favorite/service/FavoriteFolderService.java b/backend/turip-app/src/main/java/turip/favorite/service/FavoriteFolderService.java index 4af93c00f..18137beb7 100644 --- a/backend/turip-app/src/main/java/turip/favorite/service/FavoriteFolderService.java +++ b/backend/turip-app/src/main/java/turip/favorite/service/FavoriteFolderService.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import turip.account.domain.Account; @@ -27,6 +28,8 @@ import turip.favorite.domain.AccountRole; import turip.favorite.domain.FavoriteFolder; import turip.favorite.domain.FavoriteFolderAccount; +import turip.favorite.domain.event.ActionType; +import turip.favorite.domain.event.FavoriteFolderUpdateEvent; import turip.favorite.repository.FavoriteFolderRepository; import turip.favorite.repository.FavoritePlaceRepository; import turip.favorite.repository.dto.FavoriteFolderItemCountResult; @@ -42,6 +45,7 @@ public class FavoriteFolderService { private final FavoritePlaceRepository favoritePlaceRepository; private final PlaceRepository placeRepository; private final FavoriteFolderAccountService favoriteFolderAccountService; + private final ApplicationEventPublisher eventPublisher; private final InvitationTokenProvider invitationTokenProvider; @Transactional @@ -67,11 +71,34 @@ public FavoriteFolderJoinResponse joinMember(Long favoriteFolderId, Member membe FavoriteFolder favoriteFolder = getById(favoriteFolderId); validateShareAndCustomFolder(favoriteFolder); + boolean isAlreadyJoined = favoriteFolderAccountService.isFolderMember(member.getAccount(), favoriteFolder); + if (isAlreadyJoined) { + throw new ConflictException(ErrorTag.FAVORITE_FOLDER_ALREADY_JOINED); + } + FavoriteFolderAccount favoriteFolderAccount = favoriteFolderAccountService.findOrCreate(favoriteFolder, member.getAccount()); + eventPublisher.publishEvent(FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.MEMBER_JOINED)); + return FavoriteFolderJoinResponse.from(favoriteFolderAccount); } + @Transactional + public FolderInvitationTokenResponse createInvitationToken(Member member, Long favoriteFolderId) { + FavoriteFolder favoriteFolder = favoriteFolderRepository.findById(favoriteFolderId) + .orElseThrow(() -> new NotFoundException(ErrorTag.FAVORITE_FOLDER_NOT_FOUND)); + + favoriteFolderAccountService.validateMembership(member.getAccount(), favoriteFolder); + + if (!favoriteFolder.isShared()) { + favoriteFolder.convertToSharedFolder(); + } + + String invitationToken = invitationTokenProvider.generateToken(member.getAccount().getId(), favoriteFolderId); + + return FolderInvitationTokenResponse.from(invitationToken); + } + public FavoriteFoldersDetailResponse findAllByAccount(Account account) { List folders = favoriteFolderRepository.findAllByAccountOrderByFavoriteFolderAccountIdAsc( account); @@ -95,20 +122,19 @@ public FavoriteFoldersDetailResponse findAllByAccount(Account account) { return FavoriteFoldersDetailResponse.from(favoriteFoldersWithPlaceCount); } - @Transactional - public FolderInvitationTokenResponse createInvitationToken(Member member, Long favoriteFolderId) { - FavoriteFolder favoriteFolder = favoriteFolderRepository.findById(favoriteFolderId) - .orElseThrow(() -> new NotFoundException(ErrorTag.FAVORITE_FOLDER_NOT_FOUND)); + public FolderInvitationDetailResponse getInvitationDetails(String token, Account account) { + Long favoriteFolderId = invitationTokenProvider.getClaimOfName(token, "fid", Long.class); - favoriteFolderAccountService.validateMembership(member.getAccount(), favoriteFolder); + FavoriteFolder favoriteFolder = getById(favoriteFolderId); - if (!favoriteFolder.isShared()) { - favoriteFolder.convertToSharedFolder(); - } + boolean alreadyJoined = favoriteFolderAccountService.isFolderMember(account, favoriteFolder); - String invitationToken = invitationTokenProvider.generateToken(member.getAccount().getId(), favoriteFolderId); + return FolderInvitationDetailResponse.of(favoriteFolderId, alreadyJoined); + } - return FolderInvitationTokenResponse.from(invitationToken); + public FavoriteFolder getById(Long favoriteFolderId) { + return favoriteFolderRepository.findById(favoriteFolderId) + .orElseThrow(() -> new NotFoundException(ErrorTag.FAVORITE_FOLDER_NOT_FOUND)); } public FavoriteFoldersWithFavoriteStatusResponse findAllWithFavoriteStatusByAccountId(Account account, @@ -142,28 +168,12 @@ public FavoriteFolderDetailResponse findById(Long favoriteFolderId, Account acco } public FavoriteFolderMembersResponse findMembersById(Long favoriteFolderId, Account account) { - FavoriteFolder favoriteFolder = getById(favoriteFolderId); - favoriteFolderAccountService.validateMembership(account, favoriteFolder); - - List members = favoriteFolderAccountService.findMembersByFavoriteFolder(favoriteFolder); + validateFolderExists(favoriteFolderId); + favoriteFolderAccountService.validateMembership(account, favoriteFolderId); + List members = favoriteFolderAccountService.findMembersByFavoriteFolder(favoriteFolderId); return FavoriteFolderMembersResponse.of(members); } - public FavoriteFolder getById(Long favoriteFolderId) { - return favoriteFolderRepository.findById(favoriteFolderId) - .orElseThrow(() -> new NotFoundException(ErrorTag.FAVORITE_FOLDER_NOT_FOUND)); - } - - public FolderInvitationDetailResponse getInvitationDetails(String token, Account account) { - Long favoriteFolderId = invitationTokenProvider.getClaimOfName(token, "fid", Long.class); - - FavoriteFolder favoriteFolder = getById(favoriteFolderId); - - boolean alreadyJoined = favoriteFolderAccountService.isFolderMember(account, favoriteFolder); - - return FolderInvitationDetailResponse.of(favoriteFolderId, alreadyJoined); - } - @Transactional public FavoriteFolderResponse updateName(Account account, Long favoriteFolderId, FavoriteFolderNameRequest request) { @@ -177,6 +187,8 @@ public FavoriteFolderResponse updateName(Account account, Long favoriteFolderId, validateDuplicatedName(newName, account); favoriteFolder.rename(newName); + eventPublisher.publishEvent(FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.FOLDER_NAME_CHANGED)); + return FavoriteFolderResponse.of(favoriteFolder, account); } @@ -185,6 +197,7 @@ public void remove(Account account, Long favoriteFolderId) { FavoriteFolder favoriteFolder = getById(favoriteFolderId); validateRemovableFolder(account, favoriteFolder); removeFavoriteFolderWithFavoritePlaces(favoriteFolderId, favoriteFolder); + eventPublisher.publishEvent(FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.FOLDER_DELETED)); } @Transactional @@ -192,16 +205,29 @@ public FavoriteFolderExitResponse exitFolder(Account account, Long favoriteFolde FavoriteFolder favoriteFolder = getByIdWithLock(favoriteFolderId); validateShareAndCustomFolder(favoriteFolder); favoriteFolderAccountService.validateMembership(account, favoriteFolder); + favoriteFolderAccountService.deleteByFavoriteFolderAndAccount(favoriteFolder, account); - boolean isDeleted = false; int remainingMemberCount = favoriteFolderAccountService.countByFavoriteFolder(favoriteFolder); if (remainingMemberCount == 0) { removeFavoriteFolderWithFavoritePlaces(favoriteFolderId, favoriteFolder); - isDeleted = true; + eventPublisher.publishEvent(FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.FOLDER_DELETED)); + return FavoriteFolderExitResponse.of(true); + } + + eventPublisher.publishEvent(FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.MEMBER_EXITED)); + return FavoriteFolderExitResponse.of(false); + } + + public void validateFolderExists(Long favoriteFolderId) { + if (!favoriteFolderRepository.existsById(favoriteFolderId)) { + throw new NotFoundException(ErrorTag.FAVORITE_FOLDER_NOT_FOUND); } + } - return FavoriteFolderExitResponse.of(isDeleted); + public void validateFolderMembership(Long favoriteFolderId, Member member) { + FavoriteFolder favoriteFolder = getById(favoriteFolderId); + favoriteFolderAccountService.validateMembership(member.getAccount(), favoriteFolder); } private void validateRemovableFolder(Account account, FavoriteFolder favoriteFolder) { diff --git a/backend/turip-app/src/main/java/turip/favorite/service/FavoritePlaceService.java b/backend/turip-app/src/main/java/turip/favorite/service/FavoritePlaceService.java index 9ae11a5d3..278bf2158 100644 --- a/backend/turip-app/src/main/java/turip/favorite/service/FavoritePlaceService.java +++ b/backend/turip-app/src/main/java/turip/favorite/service/FavoritePlaceService.java @@ -2,8 +2,10 @@ import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import turip.account.domain.Account; @@ -18,6 +20,8 @@ import turip.favorite.controller.dto.response.FavoritePlaceCountResponse; import turip.favorite.domain.FavoriteFolder; import turip.favorite.domain.FavoritePlace; +import turip.favorite.domain.event.ActionType; +import turip.favorite.domain.event.FavoriteFolderUpdateEvent; import turip.favorite.repository.FavoriteFolderRepository; import turip.favorite.repository.FavoritePlaceRepository; import turip.place.domain.Place; @@ -31,6 +35,7 @@ public class FavoritePlaceService { private final FavoriteFolderRepository favoriteFolderRepository; private final PlaceRepository placeRepository; private final FavoriteFolderAccountService favoriteFolderAccountService; + private final ApplicationEventPublisher eventPublisher; @Transactional public FavoritePlaceResponse create(Account account, Long favoriteFolderId, Long placeId) { @@ -46,26 +51,30 @@ public FavoritePlaceResponse create(Account account, Long favoriteFolderId, Long FavoritePlace favoritePlace = new FavoritePlace(favoriteFolder, place, maxOrder + 1); FavoritePlace savedFavoritePlace = favoritePlaceRepository.save(favoritePlace); + eventPublisher.publishEvent(FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.PLACE_ADDED)); + return FavoritePlaceResponse.from(savedFavoritePlace); } @Transactional public List updateFavoriteFolders(Account account, List favoriteFolderIds, - Long placeId - ) { - List requestFavoriteFolderIds = favoriteFolderIds.stream().distinct().toList(); - List favoriteFolders = favoriteFolderRepository.findAllById(requestFavoriteFolderIds); - validateMultiFolder(account, favoriteFolders, requestFavoriteFolderIds); + Long placeId) { + List requestIds = favoriteFolderIds.stream().distinct().toList(); + List requestFolders = favoriteFolderRepository.findAllById(requestIds); + validateMultiFolder(account, requestFolders, requestIds); Place place = getPlaceById(placeId); - List existingFavoritePlaces = favoritePlaceRepository.findAllByPlaceAndAccount(place, account); + List existingPlaces = favoritePlaceRepository.findAllByPlaceAndAccount(place, account); + + Set affectedFolderIds = calculateAffectedFolderIds(existingPlaces, requestIds); - deleteRemovedFavoritePlaces(existingFavoritePlaces, requestFavoriteFolderIds); - List createdFavoritePlaces = createFavoritePlaces(place, existingFavoritePlaces, favoriteFolders, - requestFavoriteFolderIds); + deleteRemovedFavoritePlaces(existingPlaces, requestIds); + List createdPlaces = createFavoritePlaces(place, existingPlaces, requestFolders, requestIds); - return convertToResultResponse(existingFavoritePlaces, createdFavoritePlaces, requestFavoriteFolderIds); + publishFolderUpdateEvents(affectedFolderIds); + + return convertToResultResponse(existingPlaces, createdPlaces, requestIds); } public FavoritePlacesWithPlaceDetailResponse findAllByFolder(Long favoriteFolderId) { @@ -102,6 +111,8 @@ public void updatePlaceOrder(Account account, Long favoriteFolderId, validateFavoritePlaceBelongsToFolder(favoritePlace, favoriteFolder); favoritePlace.updateFavoriteOrder(index + 1); } + + eventPublisher.publishEvent(FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.PLACE_REORDERED)); } @Transactional @@ -113,6 +124,8 @@ public void remove(Account account, Long favoriteFolderId, Long placeId) { FavoritePlace favoritePlace = getByFavoriteFolderAndPlace(favoriteFolder, place); favoritePlaceRepository.delete(favoritePlace); + + eventPublisher.publishEvent(FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.PLACE_DELETED)); } private FavoriteFolder getFavoriteFolderById(Long favoriteFolderId) { @@ -130,6 +143,20 @@ private FavoritePlace getFavoritePlaceById(Long favoritePlaceId) { .orElseThrow(() -> new NotFoundException(ErrorTag.FAVORITE_PLACE_NOT_FOUND)); } + private Set calculateAffectedFolderIds(List existingPlaces, List requestIds) { + Set ids = existingPlaces.stream() + .map(fp -> fp.getFavoriteFolder().getId()) + .collect(Collectors.toSet()); + ids.addAll(requestIds); + return ids; + } + + private void publishFolderUpdateEvents(Set folderIds) { + folderIds.forEach(folderId -> + eventPublisher.publishEvent(new FavoriteFolderUpdateEvent(folderId, ActionType.FOLDER_PLACE_CHANGED)) + ); + } + private void deleteRemovedFavoritePlaces(List existingFavoritePlaces, List requestFavoriteFolderIds) { List removedFavoritePlaces = existingFavoritePlaces.stream() diff --git a/backend/turip-app/src/main/java/turip/favorite/stream/controller/dto/response/FolderUpdateStreamResponse.java b/backend/turip-app/src/main/java/turip/favorite/stream/controller/dto/response/FolderUpdateStreamResponse.java index acc45516d..7af8383cd 100644 --- a/backend/turip-app/src/main/java/turip/favorite/stream/controller/dto/response/FolderUpdateStreamResponse.java +++ b/backend/turip-app/src/main/java/turip/favorite/stream/controller/dto/response/FolderUpdateStreamResponse.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.time.LocalDateTime; -import turip.favorite.stream.service.ActionType; +import turip.favorite.domain.event.ActionType; public record FolderUpdateStreamResponse( @JsonProperty("turipId") Long favoriteFolderId, diff --git a/backend/turip-app/src/main/java/turip/favorite/stream/controller/dto/response/MemberUpdateStreamResponse.java b/backend/turip-app/src/main/java/turip/favorite/stream/controller/dto/response/MemberUpdateStreamResponse.java new file mode 100644 index 000000000..117a0807c --- /dev/null +++ b/backend/turip-app/src/main/java/turip/favorite/stream/controller/dto/response/MemberUpdateStreamResponse.java @@ -0,0 +1,30 @@ +package turip.favorite.stream.controller.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDateTime; +import java.util.List; +import turip.account.domain.Member; +import turip.favorite.domain.event.ActionType; + +public record MemberUpdateStreamResponse( + @JsonProperty("turipId") Long favoriteFolderId, + @JsonProperty("action") ActionType actionType, + int memberCount, + List members, + LocalDateTime timestamp +) { + public static MemberUpdateStreamResponse of(Long favoriteFolderId, ActionType actionType, List members) { + return new MemberUpdateStreamResponse( + favoriteFolderId, + actionType, + members.size(), + members.stream() + .map(member -> new MemberNicknameResponse(member.getAccount().getNickname())) + .toList(), + LocalDateTime.now() + ); + } + + public record MemberNicknameResponse(String nickname) { + } +} diff --git a/backend/turip-app/src/main/java/turip/favorite/stream/service/FavoriteFolderEventListener.java b/backend/turip-app/src/main/java/turip/favorite/stream/service/FavoriteFolderEventListener.java new file mode 100644 index 000000000..3f1ac8f8c --- /dev/null +++ b/backend/turip-app/src/main/java/turip/favorite/stream/service/FavoriteFolderEventListener.java @@ -0,0 +1,35 @@ +package turip.favorite.stream.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import turip.favorite.domain.event.ActionType; +import turip.favorite.domain.event.FavoriteFolderUpdateEvent; + +@Component +@RequiredArgsConstructor +public class FavoriteFolderEventListener { + + private final FavoriteFolderStreamService favoriteFolderStreamService; + + @Async("sseEventExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleFavoriteFolderUpdateEvent(FavoriteFolderUpdateEvent event) { + Long folderId = event.favoriteFolderId(); + ActionType action = event.actionType(); + + if (action == ActionType.MEMBER_JOINED || action == ActionType.MEMBER_EXITED) { + favoriteFolderStreamService.sendMemberUpdateEvents(folderId, action); + return; + } + + if (action == ActionType.FOLDER_DELETED) { + favoriteFolderStreamService.closeAllEmittersForFolder(folderId); + return; + } + + favoriteFolderStreamService.sendFolderUpdateEvents(folderId, action); + } +} diff --git a/backend/turip-app/src/main/java/turip/favorite/stream/service/FavoriteFolderStreamService.java b/backend/turip-app/src/main/java/turip/favorite/stream/service/FavoriteFolderStreamService.java index 88a191cbc..d235f12fd 100644 --- a/backend/turip-app/src/main/java/turip/favorite/stream/service/FavoriteFolderStreamService.java +++ b/backend/turip-app/src/main/java/turip/favorite/stream/service/FavoriteFolderStreamService.java @@ -1,5 +1,8 @@ package turip.favorite.stream.service; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; @@ -14,12 +17,13 @@ import turip.account.domain.Member; import turip.common.exception.ErrorTag; import turip.common.exception.custom.InternalServerException; -import turip.favorite.domain.FavoriteFolder; +import turip.favorite.domain.event.ActionType; import turip.favorite.service.FavoriteFolderAccountService; import turip.favorite.service.FavoriteFolderService; import turip.favorite.stream.controller.dto.response.ConnectStreamResponse; import turip.favorite.stream.controller.dto.response.FolderUpdateStreamResponse; import turip.favorite.stream.controller.dto.response.HeartbeatStreamResponse; +import turip.favorite.stream.controller.dto.response.MemberUpdateStreamResponse; @Slf4j @Service @@ -31,14 +35,13 @@ public class FavoriteFolderStreamService { private final Map> emitters = new ConcurrentHashMap<>(); private final Map> heartbeatSchedules = new ConcurrentHashMap<>(); private final ScheduledExecutorService scheduler; - @Value("${sse.heartbeat.interval:30}") - private Long heartbeatInterval; - private final FavoriteFolderService favoriteFolderService; private final FavoriteFolderAccountService favoriteFolderAccountService; + @Value("${sse.heartbeat.interval:30}") + private Long heartbeatInterval; public SseEmitter createEmitter(Long favoriteFolderId, Member member) { - validateIfMemberJoiningFavoriteFolder(favoriteFolderId, member); + favoriteFolderService.validateFolderMembership(favoriteFolderId, member); SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT); String emitterKey = getEmitterKey(favoriteFolderId, member.getId()); @@ -50,20 +53,51 @@ public SseEmitter createEmitter(Long favoriteFolderId, Member member) { } public void sendFolderUpdateEvents(Long favoriteFolderId, ActionType actionType) { - Map folderEmitters = emitters.get(favoriteFolderId); - if (folderEmitters == null || folderEmitters.isEmpty()) { - log.info(SSE_LOG_PREFIX + "폴더에 연결된 사용자 없음, folderId: {}", favoriteFolderId); - return; - } + Map folderEmitters = validateAndGetFolderEmitters( + favoriteFolderId); + FolderUpdateStreamResponse response = FolderUpdateStreamResponse.of(favoriteFolderId, actionType); + log.info(SSE_LOG_PREFIX + "폴더 업데이트 이벤트 전송 시작, folderId: {}, 연결된 사용자 수: {}", favoriteFolderId, folderEmitters.size()); - folderEmitters.values() - .forEach(emitter -> sendFolderUpdateEvent(favoriteFolderId, actionType, emitter)); + + new ArrayList<>(folderEmitters.values()).forEach(emitter -> + sendFolderUpdateEvent(favoriteFolderId, response, emitter) + ); + } + + public void sendMemberUpdateEvents(Long favoriteFolderId, ActionType actionType) { + Map folderEmitters = validateAndGetFolderEmitters( + favoriteFolderId); + List members = favoriteFolderAccountService.findMembersByFavoriteFolder(favoriteFolderId); + MemberUpdateStreamResponse response = MemberUpdateStreamResponse.of(favoriteFolderId, actionType, members); + + log.info(SSE_LOG_PREFIX + "멤버 업데이트 이벤트 전송 시작, folderId: {}, action: {}", favoriteFolderId, actionType); + + new ArrayList<>(folderEmitters.values()).forEach(emitter -> + sendMemberUpdateEvent(favoriteFolderId, response, emitter) + ); } - private void validateIfMemberJoiningFavoriteFolder(Long favoriteFolderId, Member member) { - FavoriteFolder favoriteFolder = favoriteFolderService.getById(favoriteFolderId); - favoriteFolderAccountService.validateMembership(member.getAccount(), favoriteFolder); + public void closeAllEmittersForFolder(Long favoriteFolderId) { + Map folderEmitters = emitters.remove(favoriteFolderId); + if (folderEmitters != null) { + new ArrayList<>(folderEmitters.values()).forEach(emitter -> { + try { + emitter.complete(); + } catch (Exception e) { + log.warn(SSE_LOG_PREFIX + "Emitter close 실패", e); + } + }); + } + } + + private Map validateAndGetFolderEmitters(Long favoriteFolderId) { + Map folderEmitters = emitters.get(favoriteFolderId); + if (folderEmitters == null || folderEmitters.isEmpty()) { + log.info(SSE_LOG_PREFIX + "폴더에 연결된 사용자 없음, folderId: {}", favoriteFolderId); + return Collections.emptyMap(); + } + return folderEmitters; } private String getEmitterKey(Long favoriteFolderId, Long memberId) { @@ -119,10 +153,8 @@ private void sendConnectEvent(Long favoriteFolderId, SseEmitter emitter, String } } - private void sendFolderUpdateEvent(Long favoriteFolderId, ActionType actionType, SseEmitter emitter) { + private void sendFolderUpdateEvent(Long favoriteFolderId, FolderUpdateStreamResponse response, SseEmitter emitter) { try { - FolderUpdateStreamResponse response = FolderUpdateStreamResponse.of(favoriteFolderId, actionType); - SseEventBuilder event = SseEmitter.event() .id(String.valueOf(System.currentTimeMillis())) .name(StreamEventType.FOLDER_UPDATE.getName()) @@ -139,6 +171,24 @@ private void sendFolderUpdateEvent(Long favoriteFolderId, ActionType actionType, } } + private void sendMemberUpdateEvent(Long favoriteFolderId, MemberUpdateStreamResponse response, SseEmitter emitter) { + try { + SseEventBuilder event = SseEmitter.event() + .id(String.valueOf(System.currentTimeMillis())) + .name(StreamEventType.MEMBER_UPDATE.getName()) + .data(response); + + emitter.send(event); + } catch (Exception e) { + try { + log.error(SSE_LOG_PREFIX + "멤버 업데이트 전송 실패, folderId: {}", favoriteFolderId, e); + emitter.completeWithError(e); + } catch (Exception completeException) { + log.error(SSE_LOG_PREFIX + "completeWithError 실패, folderId: {}", favoriteFolderId, completeException); + } + } + } + private void removeEmitter(Long favoriteFolderId, String emitterKey) { emitters.computeIfPresent(favoriteFolderId, (key, folderEmitters) -> { folderEmitters.remove(emitterKey); @@ -150,10 +200,6 @@ private void removeEmitter(Long favoriteFolderId, String emitterKey) { }); } - private void sendMemberUpdateEvent() { - // TODO: 구현 필요 - } - private void startHeartbeat(String emitterKey, SseEmitter emitter) { ScheduledFuture scheduledFuture = scheduler.scheduleAtFixedRate( () -> sendHeartbeatEvent(emitter), diff --git a/backend/turip-app/src/test/java/turip/favorite/repository/FavoriteFolderAccountRepositoryTest.java b/backend/turip-app/src/test/java/turip/favorite/repository/FavoriteFolderAccountRepositoryTest.java index 4f72e33f4..0d5e67923 100644 --- a/backend/turip-app/src/test/java/turip/favorite/repository/FavoriteFolderAccountRepositoryTest.java +++ b/backend/turip-app/src/test/java/turip/favorite/repository/FavoriteFolderAccountRepositoryTest.java @@ -55,7 +55,7 @@ void findMembersByFavoriteFolder1() { favoriteFolderAccountRepository.save(new FavoriteFolderAccount(folder, account2, AccountRole.MEMBER)); // when - List members = favoriteFolderAccountRepository.findMembersByFavoriteFolder(folder); + List members = favoriteFolderAccountRepository.findMembersByFavoriteFolderId(folder.getId()); // then assertThat(members).hasSize(2); @@ -70,7 +70,7 @@ void findMembersByFavoriteFolder2() { FavoriteFolder folder = favoriteFolderRepository.save(FavoriteFolder.customFolderOf("빈 폴더")); // when - List members = favoriteFolderAccountRepository.findMembersByFavoriteFolder(folder); + List members = favoriteFolderAccountRepository.findMembersByFavoriteFolderId(folder.getId()); // then assertThat(members).isEmpty(); diff --git a/backend/turip-app/src/test/java/turip/favorite/service/FavoriteFolderServiceTest.java b/backend/turip-app/src/test/java/turip/favorite/service/FavoriteFolderServiceTest.java index 26647cf4a..bdf4c12b3 100644 --- a/backend/turip-app/src/test/java/turip/favorite/service/FavoriteFolderServiceTest.java +++ b/backend/turip-app/src/test/java/turip/favorite/service/FavoriteFolderServiceTest.java @@ -19,6 +19,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import turip.account.domain.Account; import turip.account.domain.Member; import turip.account.domain.Role; @@ -39,6 +40,8 @@ import turip.favorite.domain.AccountRole; import turip.favorite.domain.FavoriteFolder; import turip.favorite.domain.FavoriteFolderAccount; +import turip.favorite.domain.event.ActionType; +import turip.favorite.domain.event.FavoriteFolderUpdateEvent; import turip.favorite.repository.FavoriteFolderRepository; import turip.favorite.repository.FavoritePlaceRepository; import turip.favorite.repository.dto.FavoriteFolderItemCountResult; @@ -67,6 +70,9 @@ class FavoriteFolderServiceTest { @Mock private PlaceRepository placeRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock private InvitationTokenProvider invitationTokenProvider; @@ -252,7 +258,7 @@ void findAllWithFavoriteStatusByDeviceId2() { @Nested class UpdateName { - @DisplayName("찜 폴더의 이름을 변경할 수 있다") + @DisplayName("찜 폴더의 이름을 변경하고 실시간 알림 이벤트를 발행한다") @Test void updateName1() { // given @@ -276,7 +282,9 @@ void updateName1() { () -> assertThat(response.id()).isEqualTo(folderId), () -> assertThat(response.name()).isEqualTo(newName), () -> assertThat(response.accountId()).isEqualTo(accountId), - () -> assertThat(response.isDefault()).isFalse() + () -> assertThat(response.isDefault()).isFalse(), + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(folderId, ActionType.FOLDER_NAME_CHANGED)) ); } @@ -400,25 +408,26 @@ void findMembersById1() { // given Long turipId = 1L; Account account = AccountFixture.createUser(); - FavoriteFolder favoriteFolder = FavoriteFolderFixture.createCustomFolderWithId(turipId, "함께 튜립"); Account memberAccount1 = new Account(2L, Role.USER, "계정1"); Account memberAccount2 = new Account(3L, Role.USER, "계정2"); Member member1 = new Member(1L, memberAccount1, "test1@example.com", false); Member member2 = new Member(2L, memberAccount2, "test2@example.com", false); - given(favoriteFolderRepository.findById(turipId)) - .willReturn(Optional.of(favoriteFolder)); - given(favoriteFolderAccountService.findMembersByFavoriteFolder(favoriteFolder)) + given(favoriteFolderRepository.existsById(turipId)) + .willReturn(true); + given(favoriteFolderAccountService.findMembersByFavoriteFolder(turipId)) .willReturn(List.of(member1, member2)); // when var response = favoriteFolderService.findMembersById(turipId, account); // then - assertThat(response.members()).hasSize(2); - assertThat(response.members().get(0).nickname()).isEqualTo("계정1"); - assertThat(response.members().get(1).nickname()).isEqualTo("계정2"); + assertAll( + () -> assertThat(response.members()).hasSize(2), + () -> assertThat(response.members().get(0).nickname()).isEqualTo("계정1"), + () -> assertThat(response.members().get(1).nickname()).isEqualTo("계정2") + ); } @DisplayName("찜폴더가 존재하지 않는 경우 NotFoundException을 발생시킨다") @@ -428,8 +437,8 @@ void findMembersById2() { Long nonExistentTuripId = 999L; Account account = AccountFixture.createUser(); - given(favoriteFolderRepository.findById(nonExistentTuripId)) - .willReturn(Optional.empty()); + given(favoriteFolderRepository.existsById(nonExistentTuripId)) + .willReturn(false); // when & then assertThatThrownBy(() -> favoriteFolderService.findMembersById(nonExistentTuripId, account)) @@ -443,12 +452,11 @@ void findMembersById3() { // given Long turipId = 1L; Account nonMemberAccount = AccountFixture.createUser(); - FavoriteFolder favoriteFolder = FavoriteFolderFixture.createCustomFolderWithId(turipId, "비공개 튜립"); - given(favoriteFolderRepository.findById(turipId)) - .willReturn(Optional.of(favoriteFolder)); + given(favoriteFolderRepository.existsById(turipId)) + .willReturn(true); willThrow(new ForbiddenException(ErrorTag.FORBIDDEN)) - .given(favoriteFolderAccountService).validateMembership(nonMemberAccount, favoriteFolder); + .given(favoriteFolderAccountService).validateMembership(nonMemberAccount, turipId); // when & then assertThatThrownBy(() -> favoriteFolderService.findMembersById(turipId, nonMemberAccount)) @@ -461,7 +469,7 @@ void findMembersById3() { @Nested class JoinFavoriteFolder { - @DisplayName("공유 찜폴더에 참여할 수 있다") + @DisplayName("공유 찜폴더에 참여하고 멤버 참여 이벤트를 발행한다") @Test void joinFavoriteFolder1() { // given @@ -478,6 +486,7 @@ void joinFavoriteFolder1() { given(favoriteFolderRepository.findById(turipId)) .willReturn(Optional.of(favoriteFolder)); + given(favoriteFolderAccountService.isFolderMember(account, favoriteFolder)).willReturn(false); given(favoriteFolderAccountService.findOrCreate(favoriteFolder, account)) .willReturn(favoriteFolderAccount); @@ -489,10 +498,32 @@ void joinFavoriteFolder1() { () -> assertThat(response.id()).isEqualTo(favoriteFolderAccountId), () -> assertThat(response.favoriteFolderId()).isEqualTo(turipId), () -> assertThat(response.isShared()).isTrue(), - () -> assertThat(response.accountId()).isEqualTo(accountId) + () -> assertThat(response.accountId()).isEqualTo(accountId), + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(turipId, ActionType.MEMBER_JOINED)) ); } + @DisplayName("이미 참여한 공유 찜폴더에 참여요청시 409 Conflict를 발생시킨다") + @Test + void joinFavoriteFolder_alreadyJoined() { + // given + Long turipId = 1L; + Member member = MemberFixture.createMember(); + Account account = member.getAccount(); + FavoriteFolder favoriteFolder = FavoriteFolderFixture.createCustomFolderWithId(turipId, "함께 튜립"); + favoriteFolder.convertToSharedFolder(); + + given(favoriteFolderRepository.findById(turipId)) + .willReturn(Optional.of(favoriteFolder)); + given(favoriteFolderAccountService.isFolderMember(account, favoriteFolder)).willReturn(true); + + // when & then + assertThatThrownBy(() -> favoriteFolderService.joinMember(turipId, member)) + .isInstanceOf(ConflictException.class) + .hasMessage(ErrorTag.FAVORITE_FOLDER_ALREADY_JOINED.getMessage()); + } + @DisplayName("찜폴더가 존재하지 않는 경우 NotFoundException을 발생시킨다") @Test void joinFavoriteFolder2() { @@ -549,7 +580,7 @@ void joinFavoriteFolder4() { @Nested class Remove { - @DisplayName("장소 찜 폴더를 삭제할 수 있다") + @DisplayName("장소 찜 폴더를 삭제하고 폴더 삭제 이벤트를 발행한다") @Test void remove1() { // given @@ -567,8 +598,12 @@ void remove1() { favoriteFolderService.remove(member, folderId); // then - verify(favoritePlaceRepository).deleteAllByFavoriteFolder(favoriteFolder); - verify(favoriteFolderRepository).deleteById(folderId); + assertAll( + () -> verify(favoritePlaceRepository).deleteAllByFavoriteFolder(favoriteFolder), + () -> verify(favoriteFolderRepository).deleteById(folderId), + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(folderId, ActionType.FOLDER_DELETED)) + ); } @DisplayName("favoriteFolderId에 대한 회원이 존재하지 않는 경우 NotFoundException을 발생시킨다") @@ -653,7 +688,7 @@ void remove6() { @Nested class ExitFolder { - @DisplayName("공유 찜폴더에서 나갈 수 있다 (마지막 참여자가 아닌 경우)") + @DisplayName("마지막 참여자가 아닌 경우, 참여 정보만 삭제하고 멤버 탈퇴 이벤트를 발행한다") @Test void exitFolder1() { // given @@ -672,11 +707,16 @@ void exitFolder1() { FavoriteFolderExitResponse response = favoriteFolderService.exitFolder(account, folderId); // then - assertThat(response.isDeleted()).isFalse(); - verify(favoriteFolderAccountService).deleteByFavoriteFolderAndAccount(favoriteFolder, account); + assertAll( + () -> assertThat(response.isDeleted()).isFalse(), + () -> verify(favoriteFolderAccountService).deleteByFavoriteFolderAndAccount(favoriteFolder, + account), + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(folderId, ActionType.MEMBER_EXITED)) + ); } - @DisplayName("공유 찜폴더에서 나갈 수 있다 (마지막 참여자인 경우 폴더 삭제)") + @DisplayName("마지막 참여자인 경우, 폴더를 삭제하고 폴더 삭제 이벤트를 발행한다") @Test void exitFolder2() { // given @@ -695,10 +735,15 @@ void exitFolder2() { FavoriteFolderExitResponse response = favoriteFolderService.exitFolder(account, folderId); // then - assertThat(response.isDeleted()).isTrue(); - verify(favoriteFolderAccountService).deleteByFavoriteFolderAndAccount(favoriteFolder, account); - verify(favoritePlaceRepository).deleteAllByFavoriteFolder(favoriteFolder); - verify(favoriteFolderRepository).deleteById(folderId); + assertAll( + () -> assertThat(response.isDeleted()).isTrue(), + () -> verify(favoriteFolderAccountService).deleteByFavoriteFolderAndAccount(favoriteFolder, + account), + () -> verify(favoritePlaceRepository).deleteAllByFavoriteFolder(favoriteFolder), + () -> verify(favoriteFolderRepository).deleteById(folderId), + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(folderId, ActionType.FOLDER_DELETED)) + ); } @DisplayName("찜폴더가 존재하지 않는 경우 NotFoundException을 발생시킨다") diff --git a/backend/turip-app/src/test/java/turip/favorite/service/FavoritePlaceServiceTest.java b/backend/turip-app/src/test/java/turip/favorite/service/FavoritePlaceServiceTest.java index d0c314d49..7ec2514dc 100644 --- a/backend/turip-app/src/test/java/turip/favorite/service/FavoritePlaceServiceTest.java +++ b/backend/turip-app/src/test/java/turip/favorite/service/FavoritePlaceServiceTest.java @@ -4,8 +4,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; import java.util.List; import java.util.Optional; @@ -16,6 +18,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import turip.account.domain.Account; import turip.account.domain.Role; import turip.common.exception.ErrorTag; @@ -29,6 +32,8 @@ import turip.favorite.controller.dto.response.FavoritePlaceCountResponse; import turip.favorite.domain.FavoriteFolder; import turip.favorite.domain.FavoritePlace; +import turip.favorite.domain.event.ActionType; +import turip.favorite.domain.event.FavoriteFolderUpdateEvent; import turip.favorite.repository.FavoriteFolderRepository; import turip.favorite.repository.FavoritePlaceRepository; import turip.place.domain.Place; @@ -54,11 +59,14 @@ class FavoritePlaceServiceTest { @Mock private FavoriteFolderAccountService favoriteFolderAccountService; + @Mock + private ApplicationEventPublisher eventPublisher; + @DisplayName("장소 찜 생성 테스트") @Nested class Create { - @DisplayName("장소 찜을 생성할 수 있다") + @DisplayName("장소 찜을 생성하고 알림 이벤트를 발행한다") @Test void create1() { // given @@ -86,7 +94,9 @@ void create1() { assertAll( () -> assertThat(response.id()).isEqualTo(favoriteFolderId), () -> assertThat(response.favoriteFolderId()).isEqualTo(favoriteFolderId), - () -> assertThat(response.placeId()).isEqualTo(placeId) + () -> assertThat(response.placeId()).isEqualTo(placeId), + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.PLACE_ADDED)) ); } @@ -247,12 +257,13 @@ void countByAccount1() { @Nested class UpdatePlaceOrder { - @DisplayName("장소 찜 순서를 변경할 수 있다") + @DisplayName("장소 찜 순서를 변경하고 알림 이벤트를 발행한다") @Test void updatePlaceOrder1() { // given Account account = AccountFixture.createUser(); - FavoriteFolder favoriteFolder = FavoriteFolderFixture.createDefaultFolder(); + Long favoriteFolderId = 1L; + FavoriteFolder favoriteFolder = FavoriteFolderFixture.createCustomFolderWithId(favoriteFolderId, "폴더"); FavoritePlace firstFavoritePlace = new FavoritePlace(1L, favoriteFolder, null, 1); FavoritePlace secondFavoritePlace = new FavoritePlace(2L, favoriteFolder, null, 2); given(favoriteFolderRepository.findById(favoriteFolder.getId())) @@ -269,7 +280,9 @@ void updatePlaceOrder1() { // then assertAll( () -> assertThat(firstFavoritePlace.getFavoriteOrder()).isEqualTo(2), - () -> assertThat(secondFavoritePlace.getFavoriteOrder()).isEqualTo(1) + () -> assertThat(secondFavoritePlace.getFavoriteOrder()).isEqualTo(1), + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.PLACE_REORDERED)) ); } @@ -321,11 +334,48 @@ void updatePlaceOrder3() { } } + @DisplayName("여러 폴더의 장소 업데이트 테스트") + @Nested + class UpdateFavoriteFolders { + + @Test + @DisplayName("여러 폴더의 장소를 업데이트하면 영향을 받은 모든 폴더에 이벤트를 발행한다") + void updateFavoriteFolders_Success() { + // given + Account account = AccountFixture.createUser(); + Long placeId = 1L; + Place place = new Place(placeId, "장소", "url", "주소", 1, 1); + + // 기존 폴더: 1번 / 요청 폴더: 2번 -> 영향을 받은 폴더: 1, 2번 + Long existingFolderId = 1L; + Long requestFolderId = 2L; + FavoriteFolder existingFolder = FavoriteFolderFixture.createCustomFolderWithId(existingFolderId, "기존"); + FavoriteFolder requestFolder = FavoriteFolderFixture.createCustomFolderWithId(requestFolderId, "새요청"); + + FavoritePlace existingPlace = new FavoritePlace(10L, existingFolder, place, 1); + + given(placeRepository.findById(placeId)).willReturn(Optional.of(place)); + given(favoriteFolderRepository.findAllById(any())).willReturn(List.of(requestFolder)); + given(favoritePlaceRepository.findAllByPlaceAndAccount(place, account)).willReturn(List.of(existingPlace)); + + // when + favoritePlaceService.updateFavoriteFolders(account, List.of(requestFolderId), placeId); + + // then + assertAll( + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(existingFolderId, ActionType.FOLDER_PLACE_CHANGED)), + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(requestFolderId, ActionType.FOLDER_PLACE_CHANGED)) + ); + } + } + @DisplayName("장소 찜 삭제 테스트") @Nested class Remove { - @DisplayName("장소 찜을 삭제할 수 있다") + @DisplayName("장소 찜을 삭제하고 알림 이벤트를 발행한다") @Test void remove1() { // given @@ -345,7 +395,11 @@ void remove1() { .willReturn(Optional.of(favoritePlace)); // when & then - assertDoesNotThrow(() -> favoritePlaceService.remove(account, favoriteFolderId, placeId)); + assertAll( + () -> assertDoesNotThrow(() -> favoritePlaceService.remove(account, favoriteFolderId, placeId)), + () -> verify(eventPublisher).publishEvent( + FavoriteFolderUpdateEvent.of(favoriteFolderId, ActionType.PLACE_DELETED)) + ); } @DisplayName("폴더 소유자의 기기id와 요청자의 기기id가 같지 않은 경우 ForbiddenException을 발생시킨다") diff --git a/backend/turip-app/src/test/java/turip/favorite/stream/service/FavoriteFolderStreamServiceTest.java b/backend/turip-app/src/test/java/turip/favorite/stream/service/FavoriteFolderStreamServiceTest.java index df7aa6650..e309194e8 100644 --- a/backend/turip-app/src/test/java/turip/favorite/stream/service/FavoriteFolderStreamServiceTest.java +++ b/backend/turip-app/src/test/java/turip/favorite/stream/service/FavoriteFolderStreamServiceTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; @@ -31,11 +30,9 @@ import turip.common.exception.ErrorTag; import turip.common.exception.custom.ForbiddenException; import turip.common.exception.custom.NotFoundException; -import turip.favorite.domain.FavoriteFolder; -import turip.favorite.service.FavoriteFolderAccountService; +import turip.favorite.domain.event.ActionType; import turip.favorite.service.FavoriteFolderService; import turip.util.fixture.AccountFixture; -import turip.util.fixture.FavoriteFolderFixture; import turip.util.fixture.MemberFixture; @ExtendWith(MockitoExtension.class) @@ -47,9 +44,6 @@ class FavoriteFolderStreamServiceTest { @Mock private FavoriteFolderService favoriteFolderService; - @Mock - private FavoriteFolderAccountService favoriteFolderAccountService; - @Mock private ScheduledExecutorService scheduler; @@ -65,8 +59,9 @@ void createEmitter1() { Account account = AccountFixture.createUser(); Member member = MemberFixture.createCustomMember(account, "test@example.com", false); - given(favoriteFolderService.getById(folderId)) - .willThrow(new NotFoundException(ErrorTag.FAVORITE_FOLDER_NOT_FOUND)); + willThrow(new NotFoundException(ErrorTag.FAVORITE_FOLDER_NOT_FOUND)) + .given(favoriteFolderService) + .validateFolderMembership(folderId, member); // when & then assertThatThrownBy(() -> favoriteFolderStreamService.createEmitter(folderId, member)) @@ -81,11 +76,10 @@ void createEmitter2() { Long folderId = 1L; Account account = AccountFixture.createUser(); Member member = MemberFixture.createCustomMember(account, "test@example.com", false); - FavoriteFolder favoriteFolder = FavoriteFolderFixture.createCustomFolderWithId(folderId, "테스트 폴더"); - given(favoriteFolderService.getById(folderId)).willReturn(favoriteFolder); willThrow(new ForbiddenException(ErrorTag.FORBIDDEN)) - .given(favoriteFolderAccountService).validateMembership(account, favoriteFolder); + .given(favoriteFolderService) + .validateFolderMembership(folderId, member); // when & then assertThatThrownBy(() -> favoriteFolderStreamService.createEmitter(folderId, member))