From b480f7626052d42915a10d011af8240a33569ffc Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 25 May 2026 23:37:18 +0900 Subject: [PATCH 01/16] =?UTF-8?q?improve:=20=EB=AA=A8=EC=9E=84=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/party/events/PartyDeletedEvent.java | 17 +++++++++++++++++ .../party/service/PartyCommandServiceImpl.java | 2 ++ .../party/service/PartyCommandServiceTest.java | 2 ++ 3 files changed, 21 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/party/events/PartyDeletedEvent.java diff --git a/src/main/java/umc/cockple/demo/domain/party/events/PartyDeletedEvent.java b/src/main/java/umc/cockple/demo/domain/party/events/PartyDeletedEvent.java new file mode 100644 index 000000000..3a7436dbc --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/party/events/PartyDeletedEvent.java @@ -0,0 +1,17 @@ +package umc.cockple.demo.domain.party.events; + +import java.time.LocalDateTime; + +public record PartyDeletedEvent( + Long partyId, + Long deletedByMemberId, + LocalDateTime occurredAt +) { + public static PartyDeletedEvent deleted(Long partyId, Long deletedByMemberId) { + return new PartyDeletedEvent( + partyId, + deletedByMemberId, + LocalDateTime.now() + ); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java index 9616b587a..6c1a684ea 100644 --- a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java @@ -22,6 +22,7 @@ import umc.cockple.demo.domain.party.enums.PartyStatus; import umc.cockple.demo.domain.party.enums.RequestAction; import umc.cockple.demo.domain.party.enums.RequestStatus; +import umc.cockple.demo.domain.party.events.PartyDeletedEvent; import umc.cockple.demo.domain.party.events.PartyMemberJoinedEvent; import umc.cockple.demo.domain.party.exception.PartyErrorCode; import umc.cockple.demo.domain.party.exception.PartyException; @@ -112,6 +113,7 @@ public void deleteParty(Long partyId, Long memberId) { //Party 엔티티의 상태를 INACTIVE로 변경 party.delete(); + applicationEventPublisher.publishEvent(PartyDeletedEvent.deleted(partyId, memberId)); createNotification(member, partyId, NotificationTarget.PARTY_DELETE); diff --git a/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java index e90511e4c..dd750c071 100644 --- a/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java @@ -29,6 +29,7 @@ import umc.cockple.demo.domain.party.enums.PartyStatus; import umc.cockple.demo.domain.party.enums.RequestAction; import umc.cockple.demo.domain.party.enums.RequestStatus; +import umc.cockple.demo.domain.party.events.PartyDeletedEvent; import umc.cockple.demo.domain.party.events.PartyMemberJoinedEvent; import umc.cockple.demo.domain.party.exception.PartyErrorCode; import umc.cockple.demo.domain.party.exception.PartyException; @@ -739,6 +740,7 @@ void success_deleteParty() { // then assertThat(party.getStatus()).isEqualTo(PartyStatus.INACTIVE); + verify(applicationEventPublisher).publishEvent(any(PartyDeletedEvent.class)); } @Test From 22851eef27a4e3ddd9d907db7e0079f33672f181 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 25 May 2026 23:46:05 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20PartyDeletedEvent=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=EB=84=88=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PartyDeletedChatCleanupListener.java | 22 ++++++++++++ .../domain/chat/service/ChatRoomService.java | 4 +++ .../PartyDeletedChatCleanupListenerTest.java | 35 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java create mode 100644 src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java b/src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java new file mode 100644 index 000000000..32daa35dd --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java @@ -0,0 +1,22 @@ +package umc.cockple.demo.domain.chat.events; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.service.ChatRoomService; +import umc.cockple.demo.domain.party.events.PartyDeletedEvent; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PartyDeletedChatCleanupListener { + + private final ChatRoomService chatRoomService; + + @EventListener + public void handlePartyDeleted(PartyDeletedEvent event) { + log.info("모임 삭제 이벤트 처리 - partyId: {}, deletedBy: {}", event.partyId(), event.deletedByMemberId()); + chatRoomService.deletePartyChatRoom(event.partyId()); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java index 0e7d452f2..986fa368b 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java @@ -51,6 +51,10 @@ public void leavePartyChatRoom(Long partyId, Long memberId) { log.info("[모임 채팅방 퇴장 완료] - chatRoomId: {}", chatRoom.getId()); } + public void deletePartyChatRoom(Long partyId) { + log.info("[모임 채팅방 삭제 진입] - partyId: {}", partyId); + } + private ChatRoom findChatRoomByPartyIdOrThrow(Long partyId) { return chatRoomRepository.findByPartyId(partyId) .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); diff --git a/src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java b/src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java new file mode 100644 index 000000000..3fc41f3d3 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java @@ -0,0 +1,35 @@ +package umc.cockple.demo.domain.chat.events; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.service.ChatRoomService; +import umc.cockple.demo.domain.party.events.PartyDeletedEvent; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PartyDeletedChatCleanupListener") +class PartyDeletedChatCleanupListenerTest { + + @InjectMocks + private PartyDeletedChatCleanupListener listener; + + @Mock + private ChatRoomService chatRoomService; + + @Test + @DisplayName("모임 삭제 이벤트를 받으면 채팅방 삭제 서비스에 위임한다") + void handlePartyDeleted_delegatesToChatRoomService() { + Long partyId = 1L; + Long deletedByMemberId = 10L; + PartyDeletedEvent event = PartyDeletedEvent.deleted(partyId, deletedByMemberId); + + listener.handlePartyDeleted(event); + + verify(chatRoomService).deletePartyChatRoom(partyId); + } +} From d9040491591cdc50cab06e380b306a879add0bbf Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 25 May 2026 23:53:26 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=EC=8B=A4=EC=A0=9C=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EA=B4=80=EB=A0=A8=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MessageReadStatusRepository.java | 7 ++ .../domain/chat/service/ChatRoomService.java | 30 +++++- .../ChatListSubscriptionService.java | 9 ++ .../websocket/RedisSubscriptionService.java | 9 ++ .../chat/service/ChatRoomServiceTest.java | 96 +++++++++++++++++++ 5 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java index 9392be1ea..961ecba40 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java @@ -10,6 +10,13 @@ public interface MessageReadStatusRepository extends JpaRepository { + @Modifying + @Query(""" + DELETE FROM MessageReadStatus mrs + WHERE mrs.chatRoomId = :chatRoomId + """) + int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId); + @Modifying @Query(""" UPDATE MessageReadStatus mrs diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java index 986fa368b..b6d952768 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java @@ -10,9 +10,15 @@ import umc.cockple.demo.domain.chat.exception.ChatException; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; +import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; +import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.party.domain.Party; +import java.util.Optional; + @Service @Transactional @RequiredArgsConstructor @@ -21,6 +27,10 @@ public class ChatRoomService { private final ChatRoomRepository chatRoomRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; + private final MessageReadStatusRepository messageReadStatusRepository; + private final ChatRoomListCacheService chatRoomListCacheService; + private final RedisSubscriptionService redisSubscriptionService; + private final ChatListSubscriptionService chatListSubscriptionService; public void createPartyChatRoom(Party party, Member owner) { log.info("[모임 채팅방 생성 시작] - partyId: {}", party.getId()); @@ -52,7 +62,25 @@ public void leavePartyChatRoom(Long partyId, Long memberId) { } public void deletePartyChatRoom(Long partyId) { - log.info("[모임 채팅방 삭제 진입] - partyId: {}", partyId); + log.info("[모임 채팅방 삭제 시작] - partyId: {}", partyId); + + Optional chatRoomOptional = chatRoomRepository.findByPartyId(partyId); + + if (chatRoomOptional.isEmpty()) { + log.warn("[모임 채팅방 삭제 스킵] - partyId: {}, 채팅방이 존재하지 않습니다.", partyId); + return; + } + + ChatRoom chatRoom = chatRoomOptional.get(); + Long chatRoomId = chatRoom.getId(); + + messageReadStatusRepository.deleteByChatRoomId(chatRoomId); + chatRoomListCacheService.evictLastMessage(chatRoomId); + redisSubscriptionService.clearRoomSubscribers(chatRoomId); + chatListSubscriptionService.clearChatListSubscribers(chatRoomId); + chatRoomRepository.delete(chatRoom); + + log.info("[모임 채팅방 삭제 완료] - partyId: {}, chatRoomId: {}", partyId, chatRoomId); } private ChatRoom findChatRoomByPartyIdOrThrow(Long partyId) { diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java index b20bf3da8..492cb6152 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java @@ -61,4 +61,13 @@ public Set getChatListSubscribers(Long chatRoomId) { return Set.of(); } } + + public void clearChatListSubscribers(Long chatRoomId) { + try { + stringRedisTemplate.delete(CHAT_LIST_SUBSCRIBERS + chatRoomId); + log.info("채팅방 목록 구독 키 삭제 - 채팅방: {}", chatRoomId); + } catch (Exception e) { + log.error("채팅방 목록 구독 키 삭제 실패 - 채팅방: {}", chatRoomId, e); + } + } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java index 4a7bffd70..19862020a 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java @@ -76,4 +76,13 @@ public Set getSubscribers(Long chatRoomId) { return Set.of(); } } + + public void clearRoomSubscribers(Long chatRoomId) { + try { + stringRedisTemplate.delete(CHAT_ROOM_SUBSCRIBERS + chatRoomId); + log.info("Redis 구독 키 삭제 - 채팅방: {}", chatRoomId); + } catch (Exception e) { + log.error("Redis 구독 키 삭제 실패 - 채팅방: {}", chatRoomId, e); + } + } } diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java new file mode 100644 index 000000000..3c83641db --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java @@ -0,0 +1,96 @@ +package umc.cockple.demo.domain.chat.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; +import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; +import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.util.Optional; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomService 단위 테스트") +class ChatRoomServiceTest { + + @InjectMocks + private ChatRoomService chatRoomService; + + @Mock + private ChatRoomRepository chatRoomRepository; + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + @Mock + private MessageReadStatusRepository messageReadStatusRepository; + @Mock + private ChatRoomListCacheService chatRoomListCacheService; + @Mock + private RedisSubscriptionService redisSubscriptionService; + @Mock + private ChatListSubscriptionService chatListSubscriptionService; + + @Nested + @DisplayName("deletePartyChatRoom") + class DeletePartyChatRoom { + + @Test + @DisplayName("성공 - 채팅방이 있으면 읽음 상태, 캐시/구독 정리 후 채팅방을 삭제한다") + void success_deletePartyChatRoom() { + Long partyId = 1L; + Long chatRoomId = 2L; + + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("테스트 모임", 10L, PartyFixture.createPartyAddr("서울", "강남")) + ); + ReflectionTestUtils.setField(chatRoom, "id", chatRoomId); + + given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.of(chatRoom)); + + chatRoomService.deletePartyChatRoom(partyId); + + var inOrder = inOrder( + messageReadStatusRepository, + chatRoomListCacheService, + redisSubscriptionService, + chatListSubscriptionService, + chatRoomRepository + ); + inOrder.verify(messageReadStatusRepository).deleteByChatRoomId(chatRoomId); + inOrder.verify(chatRoomListCacheService).evictLastMessage(chatRoomId); + inOrder.verify(redisSubscriptionService).clearRoomSubscribers(chatRoomId); + inOrder.verify(chatListSubscriptionService).clearChatListSubscribers(chatRoomId); + inOrder.verify(chatRoomRepository).delete(chatRoom); + } + + @Test + @DisplayName("성공 - 채팅방이 없으면 아무것도 삭제하지 않고 종료한다") + void success_deletePartyChatRoom_whenRoomMissing() { + Long partyId = 1L; + given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.empty()); + + chatRoomService.deletePartyChatRoom(partyId); + + verify(messageReadStatusRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(chatRoomListCacheService, never()).evictLastMessage(org.mockito.ArgumentMatchers.anyLong()); + verify(redisSubscriptionService, never()).clearRoomSubscribers(org.mockito.ArgumentMatchers.anyLong()); + verify(chatListSubscriptionService, never()).clearChatListSubscribers(org.mockito.ArgumentMatchers.anyLong()); + verify(chatRoomRepository, never()).delete(org.mockito.ArgumentMatchers.any(ChatRoom.class)); + } + } +} From 08eb713f2b8e3311d45e54e4cda92e7a18746ba6 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Tue, 26 May 2026 00:27:45 +0900 Subject: [PATCH 04/16] =?UTF-8?q?test:=20=EB=AA=A8=EC=9E=84=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=95=ED=99=94=20-=20=EC=B1=84=ED=8C=85=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/PartyIntegrationTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java index 25fe51839..ab016e42e 100644 --- a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java @@ -1,19 +1,25 @@ package umc.cockple.demo.domain.party.integration; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import umc.cockple.demo.domain.chat.domain.ChatRoom; import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.domain.MessageReadStatus; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; import umc.cockple.demo.domain.exercise.domain.Exercise; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.member.domain.Member; @@ -76,11 +82,19 @@ class PartyIntegrationTest extends IntegrationTestBase { @Autowired ChatRoomMemberRepository chatRoomMemberRepository; @Autowired + ChatMessageRepository chatMessageRepository; + @Autowired + MessageReadStatusRepository messageReadStatusRepository; + @Autowired PartyJoinRequestRepository partyJoinRequestRepository; @Autowired PartyInvitationRepository partyInvitationRepository; @Autowired ObjectMapper objectMapper; + @PersistenceContext + EntityManager entityManager; + @Autowired + JdbcTemplate jdbcTemplate; private Member manager; private Member normalMember; @@ -786,15 +800,40 @@ class DeleteParty { void success_deleteParty() throws Exception { // given SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + ChatRoom partyChatRoom = chatRoomRepository.findByPartyId(party.getId()).orElseThrow(); + Long chatRoomId = partyChatRoom.getId(); + + jdbcTemplate.update(""" + INSERT INTO chat_message (created_at, updated_at, chat_room_id, sender_id, content, type, is_deleted) + VALUES (NOW(6), NOW(6), ?, ?, ?, ?, ?) + """, + chatRoomId, manager.getId(), "삭제 전 메시지", "TEXT", false + ); + Long chatMessageId = jdbcTemplate.queryForObject( + "SELECT MAX(id) FROM chat_message WHERE chat_room_id = ?", + Long.class, + chatRoomId + ); + messageReadStatusRepository.save(MessageReadStatus.createRead(chatMessageId, manager.getId(), chatRoomId)); + messageReadStatusRepository.save(MessageReadStatus.createUnread(chatMessageId, normalMember.getId(), chatRoomId)); + entityManager.flush(); + entityManager.clear(); // when & then mockMvc.perform(patch("/api/parties/{partyId}/status", party.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("COMMON200")); + entityManager.flush(); + entityManager.clear(); // 검증 Party deletedParty = partyRepository.findById(party.getId()).orElseThrow(); assertThat(deletedParty.getStatus()).isEqualTo(PartyStatus.INACTIVE); + assertThat(chatRoomRepository.findByPartyId(party.getId())).isEmpty(); + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)).isEmpty(); + assertThat(chatMessageRepository.countByChatRoomId(chatRoomId)).isZero(); + assertThat(messageReadStatusRepository.findAll()) + .noneMatch(readStatus -> readStatus.getChatRoomId().equals(chatRoomId)); } @Test From 00346091d58cf2585a9a8f0f3b108b04da6115fa Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Tue, 26 May 2026 00:27:57 +0900 Subject: [PATCH 05/16] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=EC=9D=84=20bulk=20updat?= =?UTF-8?q?e=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/repository/ChatFileRepository.java | 10 ++++++++++ .../repository/ChatMessageRepository.java | 8 ++++++++ .../repository/ChatRoomMemberRepository.java | 8 +++++++- .../chat/repository/ChatRoomRepository.java | 10 +++++++++- .../domain/chat/service/ChatRoomService.java | 9 ++++++++- .../chat/service/ChatRoomServiceTest.java | 19 +++++++++++++++++-- 6 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java index a1113c845..72301863c 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java @@ -1,7 +1,17 @@ package umc.cockple.demo.domain.chat.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.chat.domain.ChatMessageFile; public interface ChatFileRepository extends JpaRepository { + + @Modifying + @Query(""" + DELETE FROM ChatMessageFile cmf + WHERE cmf.chatMessage.chatRoom.id = :chatRoomId + """) + int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java index 4b14a0235..f3254b23d 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.chat.domain.ChatMessage; @@ -10,6 +11,13 @@ public interface ChatMessageRepository extends JpaRepository { + @Modifying + @Query(""" + DELETE FROM ChatMessage cm + WHERE cm.chatRoom.id = :chatRoomId + """) + int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId); + ChatMessage findTop1ByChatRoom_IdOrderByCreatedAtDesc(Long chatRoomId); @Query(""" diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java index 14884030a..baa4169ba 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java @@ -12,6 +12,13 @@ public interface ChatRoomMemberRepository extends JpaRepository { + @Modifying + @Query(""" + DELETE FROM ChatRoomMember crm + WHERE crm.chatRoom.id = :chatRoomId + """) + int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId); + // 채팅방 내 참여자 수 @Query("SELECT COUNT(c) FROM ChatRoomMember c WHERE c.chatRoom.id = :chatRoomId") int countByChatRoomId(@Param("chatRoomId") Long chatRoomId); @@ -84,4 +91,3 @@ AND counterPart.chatRoom.id IN ( """) List findDirectChatCounterParts(@Param("memberId") Long memberId); } - diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomRepository.java index 1d9bd3c24..ceb5ac567 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.chat.domain.ChatRoom; @@ -11,6 +12,13 @@ public interface ChatRoomRepository extends JpaRepository { + @Modifying + @Query(""" + DELETE FROM ChatRoom cr + WHERE cr.id = :chatRoomId + """) + int deleteRoomById(@Param("chatRoomId") Long chatRoomId); + @Query(""" SELECT cr FROM ChatRoom cr JOIN cr.chatRoomMembers crm @@ -103,4 +111,4 @@ Slice searchDirectChatRoomsByName( WHERE cr.id = :roomId """) Optional findChatRoomWithPartyById(@Param("roomId") Long roomId); -} \ No newline at end of file +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java index b6d952768..f8bac907a 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java @@ -8,6 +8,8 @@ import umc.cockple.demo.domain.chat.domain.ChatRoomMember; import umc.cockple.demo.domain.chat.exception.ChatErrorCode; import umc.cockple.demo.domain.chat.exception.ChatException; +import umc.cockple.demo.domain.chat.repository.ChatFileRepository; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; @@ -26,6 +28,8 @@ public class ChatRoomService { private final ChatRoomRepository chatRoomRepository; + private final ChatFileRepository chatFileRepository; + private final ChatMessageRepository chatMessageRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; private final MessageReadStatusRepository messageReadStatusRepository; private final ChatRoomListCacheService chatRoomListCacheService; @@ -78,7 +82,10 @@ public void deletePartyChatRoom(Long partyId) { chatRoomListCacheService.evictLastMessage(chatRoomId); redisSubscriptionService.clearRoomSubscribers(chatRoomId); chatListSubscriptionService.clearChatListSubscribers(chatRoomId); - chatRoomRepository.delete(chatRoom); + chatFileRepository.deleteByChatRoomId(chatRoomId); + chatMessageRepository.deleteByChatRoomId(chatRoomId); + chatRoomMemberRepository.deleteByChatRoomId(chatRoomId); + chatRoomRepository.deleteRoomById(chatRoomId); log.info("[모임 채팅방 삭제 완료] - partyId: {}, chatRoomId: {}", partyId, chatRoomId); } diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java index 3c83641db..f026bf919 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java @@ -9,6 +9,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.repository.ChatFileRepository; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; @@ -35,6 +37,10 @@ class ChatRoomServiceTest { @Mock private ChatRoomRepository chatRoomRepository; @Mock + private ChatFileRepository chatFileRepository; + @Mock + private ChatMessageRepository chatMessageRepository; + @Mock private ChatRoomMemberRepository chatRoomMemberRepository; @Mock private MessageReadStatusRepository messageReadStatusRepository; @@ -69,13 +75,19 @@ void success_deletePartyChatRoom() { chatRoomListCacheService, redisSubscriptionService, chatListSubscriptionService, + chatFileRepository, + chatMessageRepository, + chatRoomMemberRepository, chatRoomRepository ); inOrder.verify(messageReadStatusRepository).deleteByChatRoomId(chatRoomId); inOrder.verify(chatRoomListCacheService).evictLastMessage(chatRoomId); inOrder.verify(redisSubscriptionService).clearRoomSubscribers(chatRoomId); inOrder.verify(chatListSubscriptionService).clearChatListSubscribers(chatRoomId); - inOrder.verify(chatRoomRepository).delete(chatRoom); + inOrder.verify(chatFileRepository).deleteByChatRoomId(chatRoomId); + inOrder.verify(chatMessageRepository).deleteByChatRoomId(chatRoomId); + inOrder.verify(chatRoomMemberRepository).deleteByChatRoomId(chatRoomId); + inOrder.verify(chatRoomRepository).deleteRoomById(chatRoomId); } @Test @@ -90,7 +102,10 @@ void success_deletePartyChatRoom_whenRoomMissing() { verify(chatRoomListCacheService, never()).evictLastMessage(org.mockito.ArgumentMatchers.anyLong()); verify(redisSubscriptionService, never()).clearRoomSubscribers(org.mockito.ArgumentMatchers.anyLong()); verify(chatListSubscriptionService, never()).clearChatListSubscribers(org.mockito.ArgumentMatchers.anyLong()); - verify(chatRoomRepository, never()).delete(org.mockito.ArgumentMatchers.any(ChatRoom.class)); + verify(chatFileRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(chatMessageRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(chatRoomMemberRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(chatRoomRepository, never()).deleteRoomById(org.mockito.ArgumentMatchers.anyLong()); } } } From 132f875ebfd1683d62b0a29ddc173c995a43beb6 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 13:47:28 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20GCS=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=B4=20Domain=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ObjectStorageDeleteOutbox.java | 48 +++++++++++++++ .../enums/ObjectStorageDeleteSourceType.java | 5 ++ .../ObjectStorageDeleteOutboxRepository.java | 7 +++ .../ObjectStorageDeleteOutboxService.java | 45 ++++++++++++++ ...0__create_object_storage_delete_outbox.sql | 13 ++++ .../ObjectStorageDeleteOutboxServiceTest.java | 59 +++++++++++++++++++ 6 files changed, 177 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java create mode 100644 src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java create mode 100644 src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java create mode 100644 src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java create mode 100644 src/main/resources/db/migration/V2026.05.27.13.30__create_object_storage_delete_outbox.sql create mode 100644 src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java diff --git a/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java b/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java new file mode 100644 index 000000000..4e8d4d846 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java @@ -0,0 +1,48 @@ +package umc.cockple.demo.domain.file.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.global.common.BaseEntity; + +@Entity +@Table(name = "object_storage_delete_outbox") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ObjectStorageDeleteOutbox extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "object_key", nullable = false, length = 512) + private String objectKey; + + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false, length = 50) + private ObjectStorageDeleteSourceType sourceType; + + @Column(name = "source_id") + private Long sourceId; + + public static ObjectStorageDeleteOutbox pending(String objectKey, ObjectStorageDeleteSourceType sourceType, Long sourceId) { + return ObjectStorageDeleteOutbox.builder() + .objectKey(objectKey) + .sourceType(sourceType) + .sourceId(sourceId) + .build(); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java new file mode 100644 index 000000000..b37a75dd9 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java @@ -0,0 +1,5 @@ +package umc.cockple.demo.domain.file.enums; + +public enum ObjectStorageDeleteSourceType { + PARTY_CHAT_ROOM +} diff --git a/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java b/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java new file mode 100644 index 000000000..2077d3a04 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java @@ -0,0 +1,7 @@ +package umc.cockple.demo.domain.file.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; + +public interface ObjectStorageDeleteOutboxRepository extends JpaRepository { +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java new file mode 100644 index 000000000..7273517c3 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java @@ -0,0 +1,45 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.util.Collection; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ObjectStorageDeleteOutboxService { + + private final ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + + @Transactional + public void enqueuePartyChatFiles(Long chatRoomId, Collection objectKeys) { + if (objectKeys == null || objectKeys.isEmpty()) { + return; + } + + List outboxes = objectKeys.stream() + .filter(StringUtils::hasText) + .distinct() + .map(objectKey -> ObjectStorageDeleteOutbox.pending( + objectKey, + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + chatRoomId + )) + .toList(); + + if (outboxes.isEmpty()) { + return; + } + + objectStorageDeleteOutboxRepository.saveAll(outboxes); + log.info("Object storage 삭제 outbox 등록 - chatRoomId: {}, 파일 수: {}", chatRoomId, outboxes.size()); + } +} diff --git a/src/main/resources/db/migration/V2026.05.27.13.30__create_object_storage_delete_outbox.sql b/src/main/resources/db/migration/V2026.05.27.13.30__create_object_storage_delete_outbox.sql new file mode 100644 index 000000000..ef13bf4c2 --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.27.13.30__create_object_storage_delete_outbox.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS object_storage_delete_outbox +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + object_key VARCHAR(512) NOT NULL, + source_type VARCHAR(50) NOT NULL, + source_id BIGINT, + PRIMARY KEY (id), + INDEX idx_object_storage_delete_outbox_source (source_type, source_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java new file mode 100644 index 000000000..1098cb665 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java @@ -0,0 +1,59 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ObjectStorageDeleteOutboxService 단위 테스트") +class ObjectStorageDeleteOutboxServiceTest { + + @InjectMocks + private ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; + + @Mock + private ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + + @Test + @DisplayName("채팅 object key를 중복 제거 후 PARTY_CHAT_ROOM outbox로 등록한다") + void enqueuePartyChatFiles_savesDistinctObjectKeys() { + Long chatRoomId = 10L; + List objectKeys = List.of("chat/a.jpg", "chat/a.jpg", "", "chat/b.jpg"); + + objectStorageDeleteOutboxService.enqueuePartyChatFiles(chatRoomId, objectKeys); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(objectStorageDeleteOutboxRepository).saveAll(captor.capture()); + + List savedOutboxes = captor.getValue(); + assertThat(savedOutboxes) + .extracting(ObjectStorageDeleteOutbox::getObjectKey) + .containsExactly("chat/a.jpg", "chat/b.jpg"); + assertThat(savedOutboxes) + .allSatisfy(outbox -> { + assertThat(outbox.getSourceType()).isEqualTo(ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM); + assertThat(outbox.getSourceId()).isEqualTo(chatRoomId); + }); + } + + @Test + @DisplayName("등록할 object key가 없으면 저장하지 않는다") + void enqueuePartyChatFiles_skipsWhenNoObjectKeys() { + objectStorageDeleteOutboxService.enqueuePartyChatFiles(10L, List.of("", " ")); + + verify(objectStorageDeleteOutboxRepository, never()).saveAll(org.mockito.ArgumentMatchers.any()); + } +} From b43edf6df9ffc81ce69d98f71d503c4aa17f5ca2 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 13:51:37 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20ObjectStorage=20file=EC=9D=84?= =?UTF-8?q?=20Outbox=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20=EB=84=A3?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/repository/ChatFileRepository.java | 8 ++++++++ .../domain/chat/service/ChatRoomService.java | 6 ++++++ .../domain/chat/service/ChatRoomServiceTest.java | 16 +++++++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java index 72301863c..001c86601 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java @@ -6,8 +6,16 @@ import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.chat.domain.ChatMessageFile; +import java.util.List; + public interface ChatFileRepository extends JpaRepository { + @Query(""" + SELECT cmf.fileKey FROM ChatMessageFile cmf + WHERE cmf.chatMessage.chatRoom.id = :chatRoomId + """) + List findObjectKeysByChatRoomId(@Param("chatRoomId") Long chatRoomId); + @Modifying @Query(""" DELETE FROM ChatMessageFile cmf diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java index f8bac907a..92186d68b 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java @@ -16,9 +16,11 @@ import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; +import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.party.domain.Party; +import java.util.List; import java.util.Optional; @Service @@ -35,6 +37,7 @@ public class ChatRoomService { private final ChatRoomListCacheService chatRoomListCacheService; private final RedisSubscriptionService redisSubscriptionService; private final ChatListSubscriptionService chatListSubscriptionService; + private final ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; public void createPartyChatRoom(Party party, Member owner) { log.info("[모임 채팅방 생성 시작] - partyId: {}", party.getId()); @@ -77,6 +80,9 @@ public void deletePartyChatRoom(Long partyId) { ChatRoom chatRoom = chatRoomOptional.get(); Long chatRoomId = chatRoom.getId(); + List objectKeys = chatFileRepository.findObjectKeysByChatRoomId(chatRoomId); + + objectStorageDeleteOutboxService.enqueuePartyChatFiles(chatRoomId, objectKeys); messageReadStatusRepository.deleteByChatRoomId(chatRoomId); chatRoomListCacheService.evictLastMessage(chatRoomId); diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java index f026bf919..00bfabbbb 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java @@ -17,9 +17,11 @@ import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; +import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService; import umc.cockple.demo.support.fixture.ChatFixture; import umc.cockple.demo.support.fixture.PartyFixture; +import java.util.List; import java.util.Optional; import static org.mockito.BDDMockito.given; @@ -50,6 +52,8 @@ class ChatRoomServiceTest { private RedisSubscriptionService redisSubscriptionService; @Mock private ChatListSubscriptionService chatListSubscriptionService; + @Mock + private ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; @Nested @DisplayName("deletePartyChatRoom") @@ -65,21 +69,26 @@ void success_deletePartyChatRoom() { PartyFixture.createParty("테스트 모임", 10L, PartyFixture.createPartyAddr("서울", "강남")) ); ReflectionTestUtils.setField(chatRoom, "id", chatRoomId); + List objectKeys = List.of("chat/a.jpg", "chat/b.jpg"); given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.of(chatRoom)); + given(chatFileRepository.findObjectKeysByChatRoomId(chatRoomId)).willReturn(objectKeys); chatRoomService.deletePartyChatRoom(partyId); var inOrder = inOrder( + chatFileRepository, + objectStorageDeleteOutboxService, messageReadStatusRepository, chatRoomListCacheService, redisSubscriptionService, chatListSubscriptionService, - chatFileRepository, chatMessageRepository, chatRoomMemberRepository, chatRoomRepository ); + inOrder.verify(chatFileRepository).findObjectKeysByChatRoomId(chatRoomId); + inOrder.verify(objectStorageDeleteOutboxService).enqueuePartyChatFiles(chatRoomId, objectKeys); inOrder.verify(messageReadStatusRepository).deleteByChatRoomId(chatRoomId); inOrder.verify(chatRoomListCacheService).evictLastMessage(chatRoomId); inOrder.verify(redisSubscriptionService).clearRoomSubscribers(chatRoomId); @@ -102,6 +111,11 @@ void success_deletePartyChatRoom_whenRoomMissing() { verify(chatRoomListCacheService, never()).evictLastMessage(org.mockito.ArgumentMatchers.anyLong()); verify(redisSubscriptionService, never()).clearRoomSubscribers(org.mockito.ArgumentMatchers.anyLong()); verify(chatListSubscriptionService, never()).clearChatListSubscribers(org.mockito.ArgumentMatchers.anyLong()); + verify(chatFileRepository, never()).findObjectKeysByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(objectStorageDeleteOutboxService, never()).enqueuePartyChatFiles( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any() + ); verify(chatFileRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); verify(chatMessageRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); verify(chatRoomMemberRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); From 95e185447156f4eca3dc51429044a7f470a60a48 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 13:58:37 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20GCS=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=84=B8=EC=84=9C=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ObjectStorageDeleteOutbox.java | 40 +++++++ .../file/enums/ObjectStorageDeleteStatus.java | 7 ++ .../ObjectStorageDeleteOutboxRepository.java | 11 ++ .../file/service/GcsObjectStorageClient.java | 16 +++ .../file/service/ObjectStorageClient.java | 6 + .../ObjectStorageDeleteOutboxProcessor.java | 66 +++++++++++ ...status_to_object_storage_delete_outbox.sql | 6 + .../service/GcsObjectStorageClientTest.java | 29 +++++ ...bjectStorageDeleteOutboxProcessorTest.java | 104 ++++++++++++++++++ .../ObjectStorageDeleteOutboxServiceTest.java | 3 + 10 files changed, 288 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java create mode 100644 src/main/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClient.java create mode 100644 src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageClient.java create mode 100644 src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java create mode 100644 src/main/resources/db/migration/V2026.05.27.13.40__add_status_to_object_storage_delete_outbox.sql create mode 100644 src/test/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClientTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java diff --git a/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java b/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java index 4e8d4d846..e6933a371 100644 --- a/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java +++ b/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java @@ -14,8 +14,11 @@ import lombok.Getter; import lombok.NoArgsConstructor; import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; import umc.cockple.demo.global.common.BaseEntity; +import java.time.LocalDateTime; + @Entity @Table(name = "object_storage_delete_outbox") @Getter @@ -24,6 +27,8 @@ @Builder public class ObjectStorageDeleteOutbox extends BaseEntity { + private static final int LAST_ERROR_MAX_LENGTH = 2000; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -38,11 +43,46 @@ public class ObjectStorageDeleteOutbox extends BaseEntity { @Column(name = "source_id") private Long sourceId; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ObjectStorageDeleteStatus status; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "last_error", length = LAST_ERROR_MAX_LENGTH) + private String lastError; + + @Column(name = "last_attempted_at") + private LocalDateTime lastAttemptedAt; + public static ObjectStorageDeleteOutbox pending(String objectKey, ObjectStorageDeleteSourceType sourceType, Long sourceId) { return ObjectStorageDeleteOutbox.builder() .objectKey(objectKey) .sourceType(sourceType) .sourceId(sourceId) + .status(ObjectStorageDeleteStatus.PENDING) + .retryCount(0) .build(); } + + public void markDone() { + this.status = ObjectStorageDeleteStatus.DONE; + this.lastError = null; + this.lastAttemptedAt = LocalDateTime.now(); + } + + public void markFailed(String errorMessage) { + this.status = ObjectStorageDeleteStatus.FAILED; + this.retryCount++; + this.lastError = truncate(errorMessage); + this.lastAttemptedAt = LocalDateTime.now(); + } + + private String truncate(String value) { + if (value == null || value.length() <= LAST_ERROR_MAX_LENGTH) { + return value; + } + return value.substring(0, LAST_ERROR_MAX_LENGTH); + } } diff --git a/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java new file mode 100644 index 000000000..3f0b9b99d --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java @@ -0,0 +1,7 @@ +package umc.cockple.demo.domain.file.enums; + +public enum ObjectStorageDeleteStatus { + PENDING, + DONE, + FAILED +} diff --git a/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java b/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java index 2077d3a04..f855dcb0b 100644 --- a/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java +++ b/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java @@ -1,7 +1,18 @@ package umc.cockple.demo.domain.file.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Pageable; import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; + +import java.util.Collection; +import java.util.List; public interface ObjectStorageDeleteOutboxRepository extends JpaRepository { + + List findByStatusInAndRetryCountLessThanOrderByCreatedAtAsc( + Collection statuses, + int retryCount, + Pageable pageable + ); } diff --git a/src/main/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClient.java b/src/main/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClient.java new file mode 100644 index 000000000..00aa4ba9e --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClient.java @@ -0,0 +1,16 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class GcsObjectStorageClient implements ObjectStorageClient { + + private final FileService fileService; + + @Override + public void delete(String objectKey) { + fileService.delete(objectKey); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageClient.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageClient.java new file mode 100644 index 000000000..422bffd01 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageClient.java @@ -0,0 +1,6 @@ +package umc.cockple.demo.domain.file.service; + +public interface ObjectStorageClient { + + void delete(String objectKey); +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java new file mode 100644 index 000000000..3d5a949fc --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java @@ -0,0 +1,66 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ObjectStorageDeleteOutboxProcessor { + + private final ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + private final ObjectStorageClient objectStorageClient; + + @Value("${cockple.object-storage-delete-outbox.batch-size:50}") + private int batchSize; + + @Value("${cockple.object-storage-delete-outbox.max-retry-count:5}") + private int maxRetryCount; + + @Transactional + public int processPendingBatch() { + List outboxes = objectStorageDeleteOutboxRepository + .findByStatusInAndRetryCountLessThanOrderByCreatedAtAsc( + List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED), + maxRetryCount, + PageRequest.of(0, batchSize) + ); + + outboxes.forEach(this::process); + return outboxes.size(); + } + + @Transactional + public void processOne(Long outboxId) { + ObjectStorageDeleteOutbox outbox = objectStorageDeleteOutboxRepository.findById(outboxId) + .orElseThrow(() -> new IllegalArgumentException("Object storage 삭제 outbox를 찾을 수 없습니다. id=" + outboxId)); + + process(outbox); + } + + private void process(ObjectStorageDeleteOutbox outbox) { + try { + objectStorageClient.delete(outbox.getObjectKey()); + outbox.markDone(); + log.info("Object storage 삭제 outbox 처리 완료 - outboxId: {}, objectKey: {}", outbox.getId(), outbox.getObjectKey()); + } catch (Exception e) { + outbox.markFailed(e.getMessage()); + log.warn( + "Object storage 삭제 outbox 처리 실패 - outboxId: {}, objectKey: {}, retryCount: {}", + outbox.getId(), + outbox.getObjectKey(), + outbox.getRetryCount(), + e + ); + } + } +} diff --git a/src/main/resources/db/migration/V2026.05.27.13.40__add_status_to_object_storage_delete_outbox.sql b/src/main/resources/db/migration/V2026.05.27.13.40__add_status_to_object_storage_delete_outbox.sql new file mode 100644 index 000000000..01ced2a49 --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.27.13.40__add_status_to_object_storage_delete_outbox.sql @@ -0,0 +1,6 @@ +ALTER TABLE object_storage_delete_outbox + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN last_error VARCHAR(2000), + ADD COLUMN last_attempted_at DATETIME(6), + ADD INDEX idx_object_storage_delete_outbox_retry (status, retry_count, created_at); diff --git a/src/test/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClientTest.java b/src/test/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClientTest.java new file mode 100644 index 000000000..0ac0f8ea5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClientTest.java @@ -0,0 +1,29 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GcsObjectStorageClient 단위 테스트") +class GcsObjectStorageClientTest { + + @InjectMocks + private GcsObjectStorageClient gcsObjectStorageClient; + + @Mock + private FileService fileService; + + @Test + @DisplayName("objectKey 삭제를 FileService에 위임한다") + void delete_delegatesToFileService() { + gcsObjectStorageClient.delete("chat/file.jpg"); + + verify(fileService).delete("chat/file.jpg"); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java new file mode 100644 index 000000000..e85695f7b --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java @@ -0,0 +1,104 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ObjectStorageDeleteOutboxProcessor 단위 테스트") +class ObjectStorageDeleteOutboxProcessorTest { + + @InjectMocks + private ObjectStorageDeleteOutboxProcessor objectStorageDeleteOutboxProcessor; + + @Mock + private ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + @Mock + private ObjectStorageClient objectStorageClient; + + @Test + @DisplayName("PENDING/FAILED outbox를 batchSize만큼 조회해 처리한다") + void processPendingBatch_deletesRetryTargets() { + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "batchSize", 2); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "maxRetryCount", 5); + ObjectStorageDeleteOutbox first = ObjectStorageDeleteOutbox.pending( + "chat/first.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + ObjectStorageDeleteOutbox second = ObjectStorageDeleteOutbox.pending( + "chat/second.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + given(objectStorageDeleteOutboxRepository.findByStatusInAndRetryCountLessThanOrderByCreatedAtAsc( + List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED), + 5, + PageRequest.of(0, 2) + )).willReturn(List.of(first, second)); + + int processedCount = objectStorageDeleteOutboxProcessor.processPendingBatch(); + + assertThat(processedCount).isEqualTo(2); + verify(objectStorageClient).delete("chat/first.jpg"); + verify(objectStorageClient).delete("chat/second.jpg"); + assertThat(first.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); + assertThat(second.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); + } + + @Test + @DisplayName("삭제에 성공하면 outbox를 DONE으로 표시한다") + void processOne_marksDoneWhenDeleteSucceeds() { + ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( + "chat/file.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + given(objectStorageDeleteOutboxRepository.findById(1L)).willReturn(Optional.of(outbox)); + + objectStorageDeleteOutboxProcessor.processOne(1L); + + verify(objectStorageClient).delete("chat/file.jpg"); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); + assertThat(outbox.getLastError()).isNull(); + assertThat(outbox.getLastAttemptedAt()).isNotNull(); + } + + @Test + @DisplayName("삭제에 실패하면 실패 사유와 retryCount를 남긴다") + void processOne_marksFailedWhenDeleteFails() { + ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( + "chat/file.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + given(objectStorageDeleteOutboxRepository.findById(1L)).willReturn(Optional.of(outbox)); + willThrow(new RuntimeException("delete failed")) + .given(objectStorageClient) + .delete("chat/file.jpg"); + + objectStorageDeleteOutboxProcessor.processOne(1L); + + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.FAILED); + assertThat(outbox.getRetryCount()).isEqualTo(1); + assertThat(outbox.getLastError()).isEqualTo("delete failed"); + assertThat(outbox.getLastAttemptedAt()).isNotNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java index 1098cb665..1d1d67ce1 100644 --- a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java @@ -9,6 +9,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; import java.util.List; @@ -46,6 +47,8 @@ void enqueuePartyChatFiles_savesDistinctObjectKeys() { .allSatisfy(outbox -> { assertThat(outbox.getSourceType()).isEqualTo(ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM); assertThat(outbox.getSourceId()).isEqualTo(chatRoomId); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.PENDING); + assertThat(outbox.getRetryCount()).isZero(); }); } From ae36500730f194da8a3f159041b6cc6053779f5a Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 14:03:27 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20ObjectStorage=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/umc/cockple/demo/Application.java | 2 ++ .../ObjectStorageDeleteOutboxScheduler.java | 32 +++++++++++++++++++ ...bjectStorageDeleteOutboxSchedulerTest.java | 29 +++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxScheduler.java create mode 100644 src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxSchedulerTest.java diff --git a/src/main/java/umc/cockple/demo/Application.java b/src/main/java/umc/cockple/demo/Application.java index 934d856b5..5fbaf64e1 100644 --- a/src/main/java/umc/cockple/demo/Application.java +++ b/src/main/java/umc/cockple/demo/Application.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing @EnableCaching +@EnableScheduling public class Application { public static void main(String[] args) { diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxScheduler.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxScheduler.java new file mode 100644 index 000000000..d603ee711 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxScheduler.java @@ -0,0 +1,32 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty( + prefix = "cockple.object-storage-delete-outbox.scheduler", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class ObjectStorageDeleteOutboxScheduler { + + private final ObjectStorageDeleteOutboxProcessor objectStorageDeleteOutboxProcessor; + + @Scheduled( + initialDelayString = "${cockple.object-storage-delete-outbox.scheduler.initial-delay-ms:10000}", + fixedDelayString = "${cockple.object-storage-delete-outbox.scheduler.fixed-delay-ms:60000}" + ) + public void processPendingDeletes() { + int processedCount = objectStorageDeleteOutboxProcessor.processPendingBatch(); + if (processedCount > 0) { + log.info("Object storage 삭제 outbox 배치 처리 완료 - 처리 수: {}", processedCount); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxSchedulerTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxSchedulerTest.java new file mode 100644 index 000000000..86757b952 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxSchedulerTest.java @@ -0,0 +1,29 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ObjectStorageDeleteOutboxScheduler 단위 테스트") +class ObjectStorageDeleteOutboxSchedulerTest { + + @InjectMocks + private ObjectStorageDeleteOutboxScheduler objectStorageDeleteOutboxScheduler; + + @Mock + private ObjectStorageDeleteOutboxProcessor objectStorageDeleteOutboxProcessor; + + @Test + @DisplayName("스케줄 실행 시 pending outbox 처리를 위임한다") + void processPendingDeletes_delegatesToProcessor() { + objectStorageDeleteOutboxScheduler.processPendingDeletes(); + + verify(objectStorageDeleteOutboxProcessor).processPendingBatch(); + } +} From 9338fddabf73583a9e2fb14baf55b827e7a7110e Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 14:03:37 +0900 Subject: [PATCH 10/16] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8A=94=20=EC=8A=A4=EC=BC=80=EC=A5=B4?= =?UTF-8?q?=EB=9F=AC=20=EC=95=88=20=EB=8F=8C=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-integrationtest.yml | 5 +++++ src/test/resources/application.yml | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/test/resources/application-integrationtest.yml b/src/test/resources/application-integrationtest.yml index befc63ec4..bf6c2af19 100644 --- a/src/test/resources/application-integrationtest.yml +++ b/src/test/resources/application-integrationtest.yml @@ -27,6 +27,11 @@ spring: gcs: bucket: test-bucket +cockple: + object-storage-delete-outbox: + scheduler: + enabled: false + kakao: client-id: test-client-id client-secret: test-client-secret diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 570edddd1..e055d686f 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -24,4 +24,9 @@ spring: use_sql_comments: true gcs: - bucket: test-bucket \ No newline at end of file + bucket: test-bucket + +cockple: + object-storage-delete-outbox: + scheduler: + enabled: false From 68b34ea339046de044c7b2622ddbaab7be67f9a6 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 14:16:47 +0900 Subject: [PATCH 11/16] =?UTF-8?q?test:=20GCS=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ectStorageDeleteOutboxIntegrationTest.java | 146 ++++++++++++++++++ .../integration/PartyIntegrationTest.java | 27 ++++ 2 files changed, 173 insertions(+) create mode 100644 src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java diff --git a/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java new file mode 100644 index 000000000..918080bc5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java @@ -0,0 +1,146 @@ +package umc.cockple.demo.domain.file.integration; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.repository.ChatFileRepository; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; +import umc.cockple.demo.domain.file.service.ObjectStorageClient; +import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxProcessor; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.domain.party.service.PartyCommandService; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; + +@Transactional +@Import(ObjectStorageDeleteOutboxIntegrationTest.TestObjectStorageConfig.class) +@DisplayName("ObjectStorageDeleteOutbox 통합 테스트") +class ObjectStorageDeleteOutboxIntegrationTest extends IntegrationTestBase { + + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemberPartyRepository memberPartyRepository; + @Autowired + private PartyAddrRepository partyAddrRepository; + @Autowired + private PartyRepository partyRepository; + @Autowired + private ChatRoomRepository chatRoomRepository; + @Autowired + private ChatRoomMemberRepository chatRoomMemberRepository; + @Autowired + private ChatMessageRepository chatMessageRepository; + @Autowired + private ChatFileRepository chatFileRepository; + @Autowired + private ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + @Autowired + private ObjectStorageDeleteOutboxProcessor objectStorageDeleteOutboxProcessor; + @Autowired + private ObjectStorageClient objectStorageClient; + @Autowired + private PartyCommandService partyCommandService; + + @PersistenceContext + private EntityManager entityManager; + + private Member owner; + private Party party; + private ChatRoom chatRoom; + + @BeforeEach + void setUp() { + reset(objectStorageClient); + + owner = memberRepository.save(MemberFixture.createMember("매니저", Gender.MALE, Level.A, 2001L)); + PartyAddr partyAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울", "강남")); + party = partyRepository.save(PartyFixture.createParty("삭제 테스트 모임", owner.getId(), partyAddr)); + memberPartyRepository.save(MemberParty.createOwner(owner, party)); + + chatRoom = chatRoomRepository.save(ChatRoom.createPartyChatRoom(party)); + chatRoomMemberRepository.save(ChatRoomMember.create(chatRoom, owner)); + } + + @Test + @DisplayName("모임 삭제 시 채팅 파일 object key를 outbox에 보존하고 processor가 삭제 완료 처리한다") + void deleteParty_enqueuesObjectKey_thenProcessorMarksDone() { + String objectKey = "chat/delete-target.jpg"; + ChatMessage message = chatMessageRepository.save(ChatFixture.createTextMessage(chatRoom, owner, "첨부 메시지")); + ChatMessageFile file = ChatFixture.createChatMessageFile(message, objectKey, 0, "delete-target.jpg"); + message.getChatMessageFiles().add(file); + chatMessageRepository.save(message); + entityManager.flush(); + entityManager.clear(); + + partyCommandService.deleteParty(party.getId(), owner.getId()); + entityManager.flush(); + entityManager.clear(); + + List outboxes = objectStorageDeleteOutboxRepository.findAll(); + assertThat(outboxes).hasSize(1); + ObjectStorageDeleteOutbox outbox = outboxes.get(0); + assertThat(outbox.getObjectKey()).isEqualTo(objectKey); + assertThat(outbox.getSourceType()).isEqualTo(ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM); + assertThat(outbox.getSourceId()).isEqualTo(chatRoom.getId()); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.PENDING); + assertThat(chatFileRepository.findObjectKeysByChatRoomId(chatRoom.getId())).isEmpty(); + + objectStorageDeleteOutboxProcessor.processOne(outbox.getId()); + entityManager.flush(); + entityManager.clear(); + + verify(objectStorageClient).delete(objectKey); + ObjectStorageDeleteOutbox processedOutbox = objectStorageDeleteOutboxRepository.findById(outbox.getId()).orElseThrow(); + assertThat(processedOutbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); + assertThat(processedOutbox.getLastError()).isNull(); + assertThat(processedOutbox.getLastAttemptedAt()).isNotNull(); + } + + @TestConfiguration(proxyBeanMethods = false) + static class TestObjectStorageConfig { + + @Bean + @Primary + ObjectStorageClient objectStorageClient() { + return mock(ObjectStorageClient.class); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java index ab016e42e..c7bb68d1b 100644 --- a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java @@ -13,15 +13,21 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; import umc.cockple.demo.domain.chat.domain.ChatRoom; import umc.cockple.demo.domain.chat.domain.ChatRoomMember; import umc.cockple.demo.domain.chat.domain.MessageReadStatus; +import umc.cockple.demo.domain.chat.repository.ChatFileRepository; import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; import umc.cockple.demo.domain.exercise.domain.Exercise; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; import umc.cockple.demo.domain.member.domain.MemberParty; @@ -46,6 +52,7 @@ import umc.cockple.demo.global.enums.Role; import umc.cockple.demo.support.IntegrationTestBase; import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.ChatFixture; import umc.cockple.demo.support.fixture.ExerciseFixture; import umc.cockple.demo.support.fixture.MemberFixture; import umc.cockple.demo.support.fixture.PartyFixture; @@ -86,6 +93,10 @@ class PartyIntegrationTest extends IntegrationTestBase { @Autowired MessageReadStatusRepository messageReadStatusRepository; @Autowired + ChatFileRepository chatFileRepository; + @Autowired + ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + @Autowired PartyJoinRequestRepository partyJoinRequestRepository; @Autowired PartyInvitationRepository partyInvitationRepository; @@ -814,6 +825,14 @@ INSERT INTO chat_message (created_at, updated_at, chat_room_id, sender_id, conte Long.class, chatRoomId ); + String objectKey = "chat/delete-party-file.jpg"; + ChatMessageFile chatMessageFile = ChatFixture.createChatMessageFile( + chatMessageRepository.findById(chatMessageId).orElseThrow(), + objectKey, + 0, + "delete-party-file.jpg" + ); + chatFileRepository.save(chatMessageFile); messageReadStatusRepository.save(MessageReadStatus.createRead(chatMessageId, manager.getId(), chatRoomId)); messageReadStatusRepository.save(MessageReadStatus.createUnread(chatMessageId, normalMember.getId(), chatRoomId)); entityManager.flush(); @@ -832,8 +851,16 @@ INSERT INTO chat_message (created_at, updated_at, chat_room_id, sender_id, conte assertThat(chatRoomRepository.findByPartyId(party.getId())).isEmpty(); assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)).isEmpty(); assertThat(chatMessageRepository.countByChatRoomId(chatRoomId)).isZero(); + assertThat(chatFileRepository.findObjectKeysByChatRoomId(chatRoomId)).isEmpty(); assertThat(messageReadStatusRepository.findAll()) .noneMatch(readStatus -> readStatus.getChatRoomId().equals(chatRoomId)); + List outboxes = objectStorageDeleteOutboxRepository.findAll(); + assertThat(outboxes).hasSize(1); + ObjectStorageDeleteOutbox outbox = outboxes.get(0); + assertThat(outbox.getObjectKey()).isEqualTo(objectKey); + assertThat(outbox.getSourceType()).isEqualTo(ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM); + assertThat(outbox.getSourceId()).isEqualTo(chatRoomId); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.PENDING); } @Test From 62c9b0f4bc5d0f4ac3437458237aa08f94b0714a Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 14:23:30 +0900 Subject: [PATCH 12/16] =?UTF-8?q?refactor:=20Redis=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=8A=94=20TTL=EC=9D=B4=20=EC=9E=88=EC=9C=BC?= =?UTF-8?q?=EB=AF=80=EB=A1=9C=20=EC=A0=95=EC=83=81=20=EC=9E=91=EB=8F=99=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=EC=9D=84=20=EB=B0=A9=ED=95=B4=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/service/ChatRoomService.java | 17 +++++++- .../ChatListSubscriptionService.java | 6 +-- .../websocket/RedisSubscriptionService.java | 6 +-- .../chat/service/ChatRoomServiceTest.java | 38 ++++++++++++++-- .../ChatListSubscriptionServiceTest.java | 43 +++++++++++++++++++ .../RedisSubscriptionServiceTest.java | 43 +++++++++++++++++++ 6 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java index 92186d68b..5c5d56796 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java @@ -86,8 +86,7 @@ public void deletePartyChatRoom(Long partyId) { messageReadStatusRepository.deleteByChatRoomId(chatRoomId); chatRoomListCacheService.evictLastMessage(chatRoomId); - redisSubscriptionService.clearRoomSubscribers(chatRoomId); - chatListSubscriptionService.clearChatListSubscribers(chatRoomId); + tryClearRedisState(chatRoomId); chatFileRepository.deleteByChatRoomId(chatRoomId); chatMessageRepository.deleteByChatRoomId(chatRoomId); chatRoomMemberRepository.deleteByChatRoomId(chatRoomId); @@ -100,4 +99,18 @@ private ChatRoom findChatRoomByPartyIdOrThrow(Long partyId) { return chatRoomRepository.findByPartyId(partyId) .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); } + + private void tryClearRedisState(Long chatRoomId) { + try { + redisSubscriptionService.tryClearRoomSubscribers(chatRoomId); + } catch (Exception e) { + log.warn("[모임 채팅방 삭제] Redis 채팅방 구독 상태 best-effort 정리 실패 - chatRoomId: {}", chatRoomId, e); + } + + try { + chatListSubscriptionService.tryClearChatListSubscribers(chatRoomId); + } catch (Exception e) { + log.warn("[모임 채팅방 삭제] Redis 채팅 목록 구독 상태 best-effort 정리 실패 - chatRoomId: {}", chatRoomId, e); + } + } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java index 492cb6152..db14e3630 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java @@ -62,12 +62,12 @@ public Set getChatListSubscribers(Long chatRoomId) { } } - public void clearChatListSubscribers(Long chatRoomId) { + public void tryClearChatListSubscribers(Long chatRoomId) { try { stringRedisTemplate.delete(CHAT_LIST_SUBSCRIBERS + chatRoomId); - log.info("채팅방 목록 구독 키 삭제 - 채팅방: {}", chatRoomId); + log.info("채팅방 목록 구독 키 best-effort 삭제 완료 - 채팅방: {}", chatRoomId); } catch (Exception e) { - log.error("채팅방 목록 구독 키 삭제 실패 - 채팅방: {}", chatRoomId, e); + log.warn("채팅방 목록 구독 키 best-effort 삭제 실패 - 채팅방: {}, TTL 만료를 기다립니다.", chatRoomId, e); } } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java index 19862020a..139ea094f 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java @@ -77,12 +77,12 @@ public Set getSubscribers(Long chatRoomId) { } } - public void clearRoomSubscribers(Long chatRoomId) { + public void tryClearRoomSubscribers(Long chatRoomId) { try { stringRedisTemplate.delete(CHAT_ROOM_SUBSCRIBERS + chatRoomId); - log.info("Redis 구독 키 삭제 - 채팅방: {}", chatRoomId); + log.info("Redis 구독 키 best-effort 삭제 완료 - 채팅방: {}", chatRoomId); } catch (Exception e) { - log.error("Redis 구독 키 삭제 실패 - 채팅방: {}", chatRoomId, e); + log.warn("Redis 구독 키 best-effort 삭제 실패 - 채팅방: {}, TTL 만료를 기다립니다.", chatRoomId, e); } } } diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java index 00bfabbbb..ef57e1f72 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java @@ -25,6 +25,7 @@ import java.util.Optional; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -91,14 +92,43 @@ void success_deletePartyChatRoom() { inOrder.verify(objectStorageDeleteOutboxService).enqueuePartyChatFiles(chatRoomId, objectKeys); inOrder.verify(messageReadStatusRepository).deleteByChatRoomId(chatRoomId); inOrder.verify(chatRoomListCacheService).evictLastMessage(chatRoomId); - inOrder.verify(redisSubscriptionService).clearRoomSubscribers(chatRoomId); - inOrder.verify(chatListSubscriptionService).clearChatListSubscribers(chatRoomId); + inOrder.verify(redisSubscriptionService).tryClearRoomSubscribers(chatRoomId); + inOrder.verify(chatListSubscriptionService).tryClearChatListSubscribers(chatRoomId); inOrder.verify(chatFileRepository).deleteByChatRoomId(chatRoomId); inOrder.verify(chatMessageRepository).deleteByChatRoomId(chatRoomId); inOrder.verify(chatRoomMemberRepository).deleteByChatRoomId(chatRoomId); inOrder.verify(chatRoomRepository).deleteRoomById(chatRoomId); } + @Test + @DisplayName("성공 - Redis best-effort 정리 실패는 채팅방 삭제 흐름을 막지 않는다") + void success_deletePartyChatRoom_whenRedisCleanupFails() { + Long partyId = 1L; + Long chatRoomId = 2L; + + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("테스트 모임", 10L, PartyFixture.createPartyAddr("서울", "강남")) + ); + ReflectionTestUtils.setField(chatRoom, "id", chatRoomId); + List objectKeys = List.of("chat/a.jpg"); + + given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.of(chatRoom)); + given(chatFileRepository.findObjectKeysByChatRoomId(chatRoomId)).willReturn(objectKeys); + willThrow(new RuntimeException("redis down")) + .given(redisSubscriptionService) + .tryClearRoomSubscribers(chatRoomId); + willThrow(new RuntimeException("redis down")) + .given(chatListSubscriptionService) + .tryClearChatListSubscribers(chatRoomId); + + chatRoomService.deletePartyChatRoom(partyId); + + verify(chatFileRepository).deleteByChatRoomId(chatRoomId); + verify(chatMessageRepository).deleteByChatRoomId(chatRoomId); + verify(chatRoomMemberRepository).deleteByChatRoomId(chatRoomId); + verify(chatRoomRepository).deleteRoomById(chatRoomId); + } + @Test @DisplayName("성공 - 채팅방이 없으면 아무것도 삭제하지 않고 종료한다") void success_deletePartyChatRoom_whenRoomMissing() { @@ -109,8 +139,8 @@ void success_deletePartyChatRoom_whenRoomMissing() { verify(messageReadStatusRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); verify(chatRoomListCacheService, never()).evictLastMessage(org.mockito.ArgumentMatchers.anyLong()); - verify(redisSubscriptionService, never()).clearRoomSubscribers(org.mockito.ArgumentMatchers.anyLong()); - verify(chatListSubscriptionService, never()).clearChatListSubscribers(org.mockito.ArgumentMatchers.anyLong()); + verify(redisSubscriptionService, never()).tryClearRoomSubscribers(org.mockito.ArgumentMatchers.anyLong()); + verify(chatListSubscriptionService, never()).tryClearChatListSubscribers(org.mockito.ArgumentMatchers.anyLong()); verify(chatFileRepository, never()).findObjectKeysByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); verify(objectStorageDeleteOutboxService, never()).enqueuePartyChatFiles( org.mockito.ArgumentMatchers.anyLong(), diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java new file mode 100644 index 000000000..5646a7321 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java @@ -0,0 +1,43 @@ +package umc.cockple.demo.domain.chat.service.websocket; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatListSubscriptionService 단위 테스트") +class ChatListSubscriptionServiceTest { + + @InjectMocks + private ChatListSubscriptionService chatListSubscriptionService; + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Test + @DisplayName("채팅 목록 구독 키는 best-effort로 삭제한다") + void tryClearChatListSubscribers_deletesKey() { + chatListSubscriptionService.tryClearChatListSubscribers(10L); + + verify(stringRedisTemplate).delete("chatlist:subscribers:10"); + } + + @Test + @DisplayName("채팅 목록 구독 키 삭제 실패는 TTL 만료에 맡기고 예외를 전파하지 않는다") + void tryClearChatListSubscribers_doesNotThrowWhenRedisFails() { + willThrow(new RuntimeException("redis down")) + .given(stringRedisTemplate) + .delete("chatlist:subscribers:10"); + + assertThatCode(() -> chatListSubscriptionService.tryClearChatListSubscribers(10L)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java new file mode 100644 index 000000000..c38192af5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java @@ -0,0 +1,43 @@ +package umc.cockple.demo.domain.chat.service.websocket; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisSubscriptionService 단위 테스트") +class RedisSubscriptionServiceTest { + + @InjectMocks + private RedisSubscriptionService redisSubscriptionService; + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Test + @DisplayName("채팅방 구독 키는 best-effort로 삭제한다") + void tryClearRoomSubscribers_deletesKey() { + redisSubscriptionService.tryClearRoomSubscribers(10L); + + verify(stringRedisTemplate).delete("chatroom:subscribers:10"); + } + + @Test + @DisplayName("채팅방 구독 키 삭제 실패는 TTL 만료에 맡기고 예외를 전파하지 않는다") + void tryClearRoomSubscribers_doesNotThrowWhenRedisFails() { + willThrow(new RuntimeException("redis down")) + .given(stringRedisTemplate) + .delete("chatroom:subscribers:10"); + + assertThatCode(() -> redisSubscriptionService.tryClearRoomSubscribers(10L)) + .doesNotThrowAnyException(); + } +} From 4673942bb2355db6cf63a949af2be463311fcfcf Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 14:41:07 +0900 Subject: [PATCH 13/16] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=EC=B6=94=EC=83=81=ED=99=94=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/service/ChatRoomService.java | 17 ++--------- .../chat/service/ChatRoomServiceTest.java | 30 ------------------- 2 files changed, 2 insertions(+), 45 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java index 5c5d56796..170212673 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java @@ -86,7 +86,8 @@ public void deletePartyChatRoom(Long partyId) { messageReadStatusRepository.deleteByChatRoomId(chatRoomId); chatRoomListCacheService.evictLastMessage(chatRoomId); - tryClearRedisState(chatRoomId); + redisSubscriptionService.tryClearRoomSubscribers(chatRoomId); + chatListSubscriptionService.tryClearChatListSubscribers(chatRoomId); chatFileRepository.deleteByChatRoomId(chatRoomId); chatMessageRepository.deleteByChatRoomId(chatRoomId); chatRoomMemberRepository.deleteByChatRoomId(chatRoomId); @@ -99,18 +100,4 @@ private ChatRoom findChatRoomByPartyIdOrThrow(Long partyId) { return chatRoomRepository.findByPartyId(partyId) .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); } - - private void tryClearRedisState(Long chatRoomId) { - try { - redisSubscriptionService.tryClearRoomSubscribers(chatRoomId); - } catch (Exception e) { - log.warn("[모임 채팅방 삭제] Redis 채팅방 구독 상태 best-effort 정리 실패 - chatRoomId: {}", chatRoomId, e); - } - - try { - chatListSubscriptionService.tryClearChatListSubscribers(chatRoomId); - } catch (Exception e) { - log.warn("[모임 채팅방 삭제] Redis 채팅 목록 구독 상태 best-effort 정리 실패 - chatRoomId: {}", chatRoomId, e); - } - } } diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java index ef57e1f72..0ad5b0981 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java @@ -25,7 +25,6 @@ import java.util.Optional; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -100,35 +99,6 @@ void success_deletePartyChatRoom() { inOrder.verify(chatRoomRepository).deleteRoomById(chatRoomId); } - @Test - @DisplayName("성공 - Redis best-effort 정리 실패는 채팅방 삭제 흐름을 막지 않는다") - void success_deletePartyChatRoom_whenRedisCleanupFails() { - Long partyId = 1L; - Long chatRoomId = 2L; - - ChatRoom chatRoom = ChatFixture.createPartyChatRoom( - PartyFixture.createParty("테스트 모임", 10L, PartyFixture.createPartyAddr("서울", "강남")) - ); - ReflectionTestUtils.setField(chatRoom, "id", chatRoomId); - List objectKeys = List.of("chat/a.jpg"); - - given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.of(chatRoom)); - given(chatFileRepository.findObjectKeysByChatRoomId(chatRoomId)).willReturn(objectKeys); - willThrow(new RuntimeException("redis down")) - .given(redisSubscriptionService) - .tryClearRoomSubscribers(chatRoomId); - willThrow(new RuntimeException("redis down")) - .given(chatListSubscriptionService) - .tryClearChatListSubscribers(chatRoomId); - - chatRoomService.deletePartyChatRoom(partyId); - - verify(chatFileRepository).deleteByChatRoomId(chatRoomId); - verify(chatMessageRepository).deleteByChatRoomId(chatRoomId); - verify(chatRoomMemberRepository).deleteByChatRoomId(chatRoomId); - verify(chatRoomRepository).deleteRoomById(chatRoomId); - } - @Test @DisplayName("성공 - 채팅방이 없으면 아무것도 삭제하지 않고 종료한다") void success_deletePartyChatRoom_whenRoomMissing() { From 64bfbac335607f720c128480b168453c184fe604 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 14:44:21 +0900 Subject: [PATCH 14/16] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20sql=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/PartyIntegrationTest.java | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java index c7bb68d1b..778b930d2 100644 --- a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java @@ -9,10 +9,10 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.domain.ChatMessage; import umc.cockple.demo.domain.chat.domain.ChatMessageFile; import umc.cockple.demo.domain.chat.domain.ChatRoom; import umc.cockple.demo.domain.chat.domain.ChatRoomMember; @@ -104,8 +104,6 @@ class PartyIntegrationTest extends IntegrationTestBase { ObjectMapper objectMapper; @PersistenceContext EntityManager entityManager; - @Autowired - JdbcTemplate jdbcTemplate; private Member manager; private Member normalMember; @@ -814,20 +812,13 @@ void success_deleteParty() throws Exception { ChatRoom partyChatRoom = chatRoomRepository.findByPartyId(party.getId()).orElseThrow(); Long chatRoomId = partyChatRoom.getId(); - jdbcTemplate.update(""" - INSERT INTO chat_message (created_at, updated_at, chat_room_id, sender_id, content, type, is_deleted) - VALUES (NOW(6), NOW(6), ?, ?, ?, ?, ?) - """, - chatRoomId, manager.getId(), "삭제 전 메시지", "TEXT", false - ); - Long chatMessageId = jdbcTemplate.queryForObject( - "SELECT MAX(id) FROM chat_message WHERE chat_room_id = ?", - Long.class, - chatRoomId + ChatMessage chatMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(partyChatRoom, manager, "삭제 전 메시지") ); + Long chatMessageId = chatMessage.getId(); String objectKey = "chat/delete-party-file.jpg"; ChatMessageFile chatMessageFile = ChatFixture.createChatMessageFile( - chatMessageRepository.findById(chatMessageId).orElseThrow(), + chatMessage, objectKey, 0, "delete-party-file.jpg" From 6e9f3f7ed7b74d2e4134b48f91331bd1f8b69ad3 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 14:58:55 +0900 Subject: [PATCH 15/16] =?UTF-8?q?refactor:=20redis=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=8A=94=20after-commit=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events/ChatRoomRedisCleanupEvent.java | 9 +++ .../events/ChatRoomRedisCleanupListener.java | 39 +++++++++ .../domain/chat/service/ChatRoomService.java | 13 +-- .../ChatRoomRedisCleanupListenerTest.java | 79 +++++++++++++++++++ .../chat/service/ChatRoomServiceTest.java | 30 +++---- 5 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupEvent.java create mode 100644 src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java create mode 100644 src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupEvent.java b/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupEvent.java new file mode 100644 index 000000000..db38a19ad --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupEvent.java @@ -0,0 +1,9 @@ +package umc.cockple.demo.domain.chat.events; + +public record ChatRoomRedisCleanupEvent( + Long chatRoomId +) { + public static ChatRoomRedisCleanupEvent of(Long chatRoomId) { + return new ChatRoomRedisCleanupEvent(chatRoomId); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java b/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java new file mode 100644 index 000000000..a3f881d49 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java @@ -0,0 +1,39 @@ +package umc.cockple.demo.domain.chat.events; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; +import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; +import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatRoomRedisCleanupListener { + + private final ChatRoomListCacheService chatRoomListCacheService; + private final RedisSubscriptionService redisSubscriptionService; + private final ChatListSubscriptionService chatListSubscriptionService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void handleChatRoomRedisCleanup(ChatRoomRedisCleanupEvent event) { + Long chatRoomId = event.chatRoomId(); + log.info("[채팅방 Redis 정리 시작] - chatRoomId: {}", chatRoomId); + + try { + chatRoomListCacheService.evictLastMessage(chatRoomId); + } catch (Exception e) { + log.warn("[채팅방 Redis 정리] 마지막 메시지 캐시 best-effort 삭제 실패 - chatRoomId: {}", chatRoomId, e); + } + + redisSubscriptionService.tryClearRoomSubscribers(chatRoomId); + chatListSubscriptionService.tryClearChatListSubscribers(chatRoomId); + + log.info("[채팅방 Redis 정리 완료] - chatRoomId: {}", chatRoomId); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java index 170212673..81abbdd8e 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java @@ -2,10 +2,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import umc.cockple.demo.domain.chat.domain.ChatRoom; import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.events.ChatRoomRedisCleanupEvent; import umc.cockple.demo.domain.chat.exception.ChatErrorCode; import umc.cockple.demo.domain.chat.exception.ChatException; import umc.cockple.demo.domain.chat.repository.ChatFileRepository; @@ -13,9 +15,6 @@ import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; -import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; -import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; -import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.party.domain.Party; @@ -34,10 +33,8 @@ public class ChatRoomService { private final ChatMessageRepository chatMessageRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; private final MessageReadStatusRepository messageReadStatusRepository; - private final ChatRoomListCacheService chatRoomListCacheService; - private final RedisSubscriptionService redisSubscriptionService; - private final ChatListSubscriptionService chatListSubscriptionService; private final ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; + private final ApplicationEventPublisher applicationEventPublisher; public void createPartyChatRoom(Party party, Member owner) { log.info("[모임 채팅방 생성 시작] - partyId: {}", party.getId()); @@ -85,13 +82,11 @@ public void deletePartyChatRoom(Long partyId) { objectStorageDeleteOutboxService.enqueuePartyChatFiles(chatRoomId, objectKeys); messageReadStatusRepository.deleteByChatRoomId(chatRoomId); - chatRoomListCacheService.evictLastMessage(chatRoomId); - redisSubscriptionService.tryClearRoomSubscribers(chatRoomId); - chatListSubscriptionService.tryClearChatListSubscribers(chatRoomId); chatFileRepository.deleteByChatRoomId(chatRoomId); chatMessageRepository.deleteByChatRoomId(chatRoomId); chatRoomMemberRepository.deleteByChatRoomId(chatRoomId); chatRoomRepository.deleteRoomById(chatRoomId); + applicationEventPublisher.publishEvent(ChatRoomRedisCleanupEvent.of(chatRoomId)); log.info("[모임 채팅방 삭제 완료] - partyId: {}, chatRoomId: {}", partyId, chatRoomId); } diff --git a/src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java b/src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java new file mode 100644 index 000000000..b0ae82c4c --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java @@ -0,0 +1,79 @@ +package umc.cockple.demo.domain.chat.events; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; +import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; +import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomRedisCleanupListener") +class ChatRoomRedisCleanupListenerTest { + + @InjectMocks + private ChatRoomRedisCleanupListener listener; + + @Mock + private ChatRoomListCacheService chatRoomListCacheService; + @Mock + private RedisSubscriptionService redisSubscriptionService; + @Mock + private ChatListSubscriptionService chatListSubscriptionService; + + @Test + @DisplayName("채팅방 Redis 정리 이벤트는 커밋 이후 비동기로 처리되도록 설정한다") + void handleChatRoomRedisCleanup_runsAfterCommit() throws NoSuchMethodException { + Method method = ChatRoomRedisCleanupListener.class.getDeclaredMethod( + "handleChatRoomRedisCleanup", + ChatRoomRedisCleanupEvent.class + ); + + TransactionalEventListener annotation = method.getAnnotation(TransactionalEventListener.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); + assertThat(method.getAnnotation(Async.class)).isNotNull(); + } + + @Test + @DisplayName("채팅방 Redis 정리 이벤트를 받으면 마지막 메시지 캐시와 구독 키를 삭제한다") + void handleChatRoomRedisCleanup_clearsCacheAndSubscriptionKeys() { + Long chatRoomId = 1L; + ChatRoomRedisCleanupEvent event = ChatRoomRedisCleanupEvent.of(chatRoomId); + + listener.handleChatRoomRedisCleanup(event); + + var inOrder = inOrder(chatRoomListCacheService, redisSubscriptionService, chatListSubscriptionService); + inOrder.verify(chatRoomListCacheService).evictLastMessage(chatRoomId); + inOrder.verify(redisSubscriptionService).tryClearRoomSubscribers(chatRoomId); + inOrder.verify(chatListSubscriptionService).tryClearChatListSubscribers(chatRoomId); + } + + @Test + @DisplayName("마지막 메시지 캐시 삭제가 실패해도 구독 키 정리를 계속한다") + void handleChatRoomRedisCleanup_continuesWhenCacheEvictFails() { + Long chatRoomId = 1L; + willThrow(new RuntimeException("redis cache down")) + .given(chatRoomListCacheService) + .evictLastMessage(chatRoomId); + + listener.handleChatRoomRedisCleanup(ChatRoomRedisCleanupEvent.of(chatRoomId)); + + verify(redisSubscriptionService).tryClearRoomSubscribers(chatRoomId); + verify(chatListSubscriptionService).tryClearChatListSubscribers(chatRoomId); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java index 0ad5b0981..76c281a44 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java @@ -7,16 +7,15 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.events.ChatRoomRedisCleanupEvent; import umc.cockple.demo.domain.chat.repository.ChatFileRepository; import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; -import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; -import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; -import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService; import umc.cockple.demo.support.fixture.ChatFixture; import umc.cockple.demo.support.fixture.PartyFixture; @@ -25,6 +24,7 @@ import java.util.Optional; import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -47,20 +47,16 @@ class ChatRoomServiceTest { @Mock private MessageReadStatusRepository messageReadStatusRepository; @Mock - private ChatRoomListCacheService chatRoomListCacheService; - @Mock - private RedisSubscriptionService redisSubscriptionService; - @Mock - private ChatListSubscriptionService chatListSubscriptionService; - @Mock private ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; + @Mock + private ApplicationEventPublisher applicationEventPublisher; @Nested @DisplayName("deletePartyChatRoom") class DeletePartyChatRoom { @Test - @DisplayName("성공 - 채팅방이 있으면 읽음 상태, 캐시/구독 정리 후 채팅방을 삭제한다") + @DisplayName("성공 - 채팅방이 있으면 읽음 상태, 파일, 메시지, 멤버, 방을 삭제하고 Redis 정리 이벤트를 발행한다") void success_deletePartyChatRoom() { Long partyId = 1L; Long chatRoomId = 2L; @@ -80,23 +76,19 @@ void success_deletePartyChatRoom() { chatFileRepository, objectStorageDeleteOutboxService, messageReadStatusRepository, - chatRoomListCacheService, - redisSubscriptionService, - chatListSubscriptionService, chatMessageRepository, chatRoomMemberRepository, - chatRoomRepository + chatRoomRepository, + applicationEventPublisher ); inOrder.verify(chatFileRepository).findObjectKeysByChatRoomId(chatRoomId); inOrder.verify(objectStorageDeleteOutboxService).enqueuePartyChatFiles(chatRoomId, objectKeys); inOrder.verify(messageReadStatusRepository).deleteByChatRoomId(chatRoomId); - inOrder.verify(chatRoomListCacheService).evictLastMessage(chatRoomId); - inOrder.verify(redisSubscriptionService).tryClearRoomSubscribers(chatRoomId); - inOrder.verify(chatListSubscriptionService).tryClearChatListSubscribers(chatRoomId); inOrder.verify(chatFileRepository).deleteByChatRoomId(chatRoomId); inOrder.verify(chatMessageRepository).deleteByChatRoomId(chatRoomId); inOrder.verify(chatRoomMemberRepository).deleteByChatRoomId(chatRoomId); inOrder.verify(chatRoomRepository).deleteRoomById(chatRoomId); + inOrder.verify(applicationEventPublisher).publishEvent(new ChatRoomRedisCleanupEvent(chatRoomId)); } @Test @@ -108,9 +100,6 @@ void success_deletePartyChatRoom_whenRoomMissing() { chatRoomService.deletePartyChatRoom(partyId); verify(messageReadStatusRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); - verify(chatRoomListCacheService, never()).evictLastMessage(org.mockito.ArgumentMatchers.anyLong()); - verify(redisSubscriptionService, never()).tryClearRoomSubscribers(org.mockito.ArgumentMatchers.anyLong()); - verify(chatListSubscriptionService, never()).tryClearChatListSubscribers(org.mockito.ArgumentMatchers.anyLong()); verify(chatFileRepository, never()).findObjectKeysByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); verify(objectStorageDeleteOutboxService, never()).enqueuePartyChatFiles( org.mockito.ArgumentMatchers.anyLong(), @@ -120,6 +109,7 @@ void success_deletePartyChatRoom_whenRoomMissing() { verify(chatMessageRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); verify(chatRoomMemberRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); verify(chatRoomRepository, never()).deleteRoomById(org.mockito.ArgumentMatchers.anyLong()); + verify(applicationEventPublisher, never()).publishEvent(any(ChatRoomRedisCleanupEvent.class)); } } } From d4a95758c7fee669691f20327cc188dd878ff631 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Wed, 27 May 2026 15:16:43 +0900 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20GCS=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=EA=B0=80=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=97=90=20=EB=8F=84=EB=8A=94=20=EC=83=81=ED=99=A9?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EB=A0=A4=ED=95=98=EC=97=AC=20DB=20Claim?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ObjectStorageDeleteOutbox.java | 5 + .../file/enums/ObjectStorageDeleteStatus.java | 9 +- .../ObjectStorageDeleteOutboxRepository.java | 65 ++++++- .../ClaimedObjectStorageDeleteOutbox.java | 8 + ...ObjectStorageDeleteOutboxClaimService.java | 80 +++++++++ .../ObjectStorageDeleteOutboxProcessor.java | 67 ++++--- ..._claim_to_object_storage_delete_outbox.sql | 3 + ...ectStorageDeleteOutboxIntegrationTest.java | 3 +- ...ctStorageDeleteOutboxClaimServiceTest.java | 163 ++++++++++++++++++ ...bjectStorageDeleteOutboxProcessorTest.java | 96 ++++++----- 10 files changed, 428 insertions(+), 71 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/file/service/ClaimedObjectStorageDeleteOutbox.java create mode 100644 src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimService.java create mode 100644 src/main/resources/db/migration/V2026.05.27.14.10__add_claim_to_object_storage_delete_outbox.sql create mode 100644 src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimServiceTest.java diff --git a/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java b/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java index e6933a371..88260e610 100644 --- a/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java +++ b/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java @@ -56,6 +56,9 @@ public class ObjectStorageDeleteOutbox extends BaseEntity { @Column(name = "last_attempted_at") private LocalDateTime lastAttemptedAt; + @Column(name = "claim_token", length = 36) + private String claimToken; + public static ObjectStorageDeleteOutbox pending(String objectKey, ObjectStorageDeleteSourceType sourceType, Long sourceId) { return ObjectStorageDeleteOutbox.builder() .objectKey(objectKey) @@ -70,6 +73,7 @@ public void markDone() { this.status = ObjectStorageDeleteStatus.DONE; this.lastError = null; this.lastAttemptedAt = LocalDateTime.now(); + this.claimToken = null; } public void markFailed(String errorMessage) { @@ -77,6 +81,7 @@ public void markFailed(String errorMessage) { this.retryCount++; this.lastError = truncate(errorMessage); this.lastAttemptedAt = LocalDateTime.now(); + this.claimToken = null; } private String truncate(String value) { diff --git a/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java index 3f0b9b99d..e0f6ead61 100644 --- a/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java +++ b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java @@ -1,7 +1,14 @@ package umc.cockple.demo.domain.file.enums; +import java.util.List; + public enum ObjectStorageDeleteStatus { PENDING, + PROCESSING, DONE, - FAILED + FAILED; + + public static List retryableStatuses() { + return List.of(PENDING, FAILED); + } } diff --git a/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java b/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java index f855dcb0b..4d5ac268a 100644 --- a/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java +++ b/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java @@ -1,18 +1,75 @@ package umc.cockple.demo.domain.file.repository; -import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; +import java.util.Optional; public interface ObjectStorageDeleteOutboxRepository extends JpaRepository { - List findByStatusInAndRetryCountLessThanOrderByCreatedAtAsc( - Collection statuses, - int retryCount, + @Query(""" + SELECT outbox.id FROM ObjectStorageDeleteOutbox outbox + WHERE outbox.retryCount < :maxRetryCount + AND ( + outbox.status IN :retryableStatuses + OR ( + outbox.status = :processingStatus + AND ( + outbox.lastAttemptedAt IS NULL + OR outbox.lastAttemptedAt < :processingTimeoutBefore + ) + ) + ) + ORDER BY outbox.createdAt ASC + """) + List findClaimCandidateIds( + @Param("retryableStatuses") Collection retryableStatuses, + @Param("processingStatus") ObjectStorageDeleteStatus processingStatus, + @Param("maxRetryCount") int maxRetryCount, + @Param("processingTimeoutBefore") LocalDateTime processingTimeoutBefore, Pageable pageable ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE ObjectStorageDeleteOutbox outbox + SET outbox.status = :processingStatus, + outbox.lastAttemptedAt = :claimedAt, + outbox.claimToken = :claimToken + WHERE outbox.id = :outboxId + AND outbox.retryCount < :maxRetryCount + AND ( + outbox.status IN :retryableStatuses + OR ( + outbox.status = :processingStatus + AND ( + outbox.lastAttemptedAt IS NULL + OR outbox.lastAttemptedAt < :processingTimeoutBefore + ) + ) + ) + """) + int claimForProcessing( + @Param("outboxId") Long outboxId, + @Param("retryableStatuses") Collection retryableStatuses, + @Param("processingStatus") ObjectStorageDeleteStatus processingStatus, + @Param("maxRetryCount") int maxRetryCount, + @Param("processingTimeoutBefore") LocalDateTime processingTimeoutBefore, + @Param("claimedAt") LocalDateTime claimedAt, + @Param("claimToken") String claimToken + ); + + Optional findByIdAndStatusAndClaimToken( + Long id, + ObjectStorageDeleteStatus status, + String claimToken + ); } diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ClaimedObjectStorageDeleteOutbox.java b/src/main/java/umc/cockple/demo/domain/file/service/ClaimedObjectStorageDeleteOutbox.java new file mode 100644 index 000000000..95b1e806c --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ClaimedObjectStorageDeleteOutbox.java @@ -0,0 +1,8 @@ +package umc.cockple.demo.domain.file.service; + +record ClaimedObjectStorageDeleteOutbox( + Long id, + String objectKey, + String claimToken +) { +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimService.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimService.java new file mode 100644 index 000000000..bfd2f48e9 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimService.java @@ -0,0 +1,80 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ObjectStorageDeleteOutboxClaimService { + + private final ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + + @Transactional + public Optional claim( + Long outboxId, + int maxRetryCount, + LocalDateTime processingTimeoutBefore + ) { + String claimToken = UUID.randomUUID().toString(); + int updatedCount = objectStorageDeleteOutboxRepository.claimForProcessing( + outboxId, + ObjectStorageDeleteStatus.retryableStatuses(), + ObjectStorageDeleteStatus.PROCESSING, + maxRetryCount, + processingTimeoutBefore, + LocalDateTime.now(), + claimToken + ); + + if (updatedCount == 0) { + if (!objectStorageDeleteOutboxRepository.existsById(outboxId)) { + throw new IllegalArgumentException("Object storage 삭제 outbox를 찾을 수 없습니다. id=" + outboxId); + } + return Optional.empty(); + } + + return objectStorageDeleteOutboxRepository + .findByIdAndStatusAndClaimToken(outboxId, ObjectStorageDeleteStatus.PROCESSING, claimToken) + .map(outbox -> new ClaimedObjectStorageDeleteOutbox( + outbox.getId(), + outbox.getObjectKey(), + claimToken + )); + } + + @Transactional + public boolean markDone(ClaimedObjectStorageDeleteOutbox claimedOutbox) { + return findClaimedOutbox(claimedOutbox) + .map(outbox -> { + outbox.markDone(); + return true; + }) + .orElse(false); + } + + @Transactional + public boolean markFailed(ClaimedObjectStorageDeleteOutbox claimedOutbox, String errorMessage) { + return findClaimedOutbox(claimedOutbox) + .map(outbox -> { + outbox.markFailed(errorMessage); + return true; + }) + .orElse(false); + } + + private Optional findClaimedOutbox(ClaimedObjectStorageDeleteOutbox claimedOutbox) { + return objectStorageDeleteOutboxRepository.findByIdAndStatusAndClaimToken( + claimedOutbox.id(), + ObjectStorageDeleteStatus.PROCESSING, + claimedOutbox.claimToken() + ); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java index 3d5a949fc..f989aa263 100644 --- a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java @@ -5,12 +5,12 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -18,6 +18,7 @@ public class ObjectStorageDeleteOutboxProcessor { private final ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + private final ObjectStorageDeleteOutboxClaimService objectStorageDeleteOutboxClaimService; private final ObjectStorageClient objectStorageClient; @Value("${cockple.object-storage-delete-outbox.batch-size:50}") @@ -26,41 +27,57 @@ public class ObjectStorageDeleteOutboxProcessor { @Value("${cockple.object-storage-delete-outbox.max-retry-count:5}") private int maxRetryCount; - @Transactional + @Value("${cockple.object-storage-delete-outbox.processing-timeout-minutes:10}") + private long processingTimeoutMinutes; + public int processPendingBatch() { - List outboxes = objectStorageDeleteOutboxRepository - .findByStatusInAndRetryCountLessThanOrderByCreatedAtAsc( - List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED), + LocalDateTime processingTimeoutBefore = LocalDateTime.now().minusMinutes(processingTimeoutMinutes); + List outboxIds = objectStorageDeleteOutboxRepository + .findClaimCandidateIds( + ObjectStorageDeleteStatus.retryableStatuses(), + ObjectStorageDeleteStatus.PROCESSING, maxRetryCount, + processingTimeoutBefore, PageRequest.of(0, batchSize) ); - outboxes.forEach(this::process); - return outboxes.size(); + int processedCount = 0; + for (Long outboxId : outboxIds) { + if (processOne(outboxId)) { + processedCount++; + } + } + return processedCount; } - @Transactional - public void processOne(Long outboxId) { - ObjectStorageDeleteOutbox outbox = objectStorageDeleteOutboxRepository.findById(outboxId) - .orElseThrow(() -> new IllegalArgumentException("Object storage 삭제 outbox를 찾을 수 없습니다. id=" + outboxId)); + public boolean processOne(Long outboxId) { + LocalDateTime processingTimeoutBefore = LocalDateTime.now().minusMinutes(processingTimeoutMinutes); + Optional claimedOutbox = objectStorageDeleteOutboxClaimService.claim( + outboxId, + maxRetryCount, + processingTimeoutBefore + ); - process(outbox); + return claimedOutbox + .map(this::process) + .orElse(false); } - private void process(ObjectStorageDeleteOutbox outbox) { + private boolean process(ClaimedObjectStorageDeleteOutbox outbox) { try { - objectStorageClient.delete(outbox.getObjectKey()); - outbox.markDone(); - log.info("Object storage 삭제 outbox 처리 완료 - outboxId: {}, objectKey: {}", outbox.getId(), outbox.getObjectKey()); + objectStorageClient.delete(outbox.objectKey()); + if (objectStorageDeleteOutboxClaimService.markDone(outbox)) { + log.info("Object storage 삭제 outbox 처리 완료 - outboxId: {}, objectKey: {}", outbox.id(), outbox.objectKey()); + } else { + log.warn("Object storage 삭제 outbox 처리 완료 후 상태 변경 스킵 - outboxId: {}, objectKey: {}", outbox.id(), outbox.objectKey()); + } } catch (Exception e) { - outbox.markFailed(e.getMessage()); - log.warn( - "Object storage 삭제 outbox 처리 실패 - outboxId: {}, objectKey: {}, retryCount: {}", - outbox.getId(), - outbox.getObjectKey(), - outbox.getRetryCount(), - e - ); + if (objectStorageDeleteOutboxClaimService.markFailed(outbox, e.getMessage())) { + log.warn("Object storage 삭제 outbox 처리 실패 - outboxId: {}, objectKey: {}", outbox.id(), outbox.objectKey(), e); + } else { + log.warn("Object storage 삭제 outbox 처리 실패 후 상태 변경 스킵 - outboxId: {}, objectKey: {}", outbox.id(), outbox.objectKey(), e); + } } + return true; } } diff --git a/src/main/resources/db/migration/V2026.05.27.14.10__add_claim_to_object_storage_delete_outbox.sql b/src/main/resources/db/migration/V2026.05.27.14.10__add_claim_to_object_storage_delete_outbox.sql new file mode 100644 index 000000000..f0764b22a --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.27.14.10__add_claim_to_object_storage_delete_outbox.sql @@ -0,0 +1,3 @@ +ALTER TABLE object_storage_delete_outbox + ADD COLUMN claim_token VARCHAR(36), + ADD INDEX idx_object_storage_delete_outbox_claim (status, retry_count, last_attempted_at, created_at); diff --git a/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java index 918080bc5..3f9b3ed09 100644 --- a/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java @@ -123,10 +123,11 @@ void deleteParty_enqueuesObjectKey_thenProcessorMarksDone() { assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.PENDING); assertThat(chatFileRepository.findObjectKeysByChatRoomId(chatRoom.getId())).isEmpty(); - objectStorageDeleteOutboxProcessor.processOne(outbox.getId()); + int processedCount = objectStorageDeleteOutboxProcessor.processPendingBatch(); entityManager.flush(); entityManager.clear(); + assertThat(processedCount).isEqualTo(1); verify(objectStorageClient).delete(objectKey); ObjectStorageDeleteOutbox processedOutbox = objectStorageDeleteOutboxRepository.findById(outbox.getId()).orElseThrow(); assertThat(processedOutbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimServiceTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimServiceTest.java new file mode 100644 index 000000000..b02bd8fae --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimServiceTest.java @@ -0,0 +1,163 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ObjectStorageDeleteOutboxClaimService 단위 테스트") +class ObjectStorageDeleteOutboxClaimServiceTest { + + @InjectMocks + private ObjectStorageDeleteOutboxClaimService objectStorageDeleteOutboxClaimService; + + @Mock + private ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + + @Test + @DisplayName("claim 성공 시 PROCESSING으로 선점한 outbox 정보를 반환한다") + void claim_returnsClaimedOutboxWhenUpdateSucceeds() { + Long outboxId = 1L; + ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( + "chat/file.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + ReflectionTestUtils.setField(outbox, "id", outboxId); + given(objectStorageDeleteOutboxRepository.claimForProcessing( + eq(outboxId), + eq(List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED)), + eq(ObjectStorageDeleteStatus.PROCESSING), + eq(5), + any(LocalDateTime.class), + any(LocalDateTime.class), + any(String.class) + )).willReturn(1); + given(objectStorageDeleteOutboxRepository.findByIdAndStatusAndClaimToken( + eq(outboxId), + eq(ObjectStorageDeleteStatus.PROCESSING), + any(String.class) + )).willReturn(Optional.of(outbox)); + + Optional claimedOutbox = objectStorageDeleteOutboxClaimService.claim( + outboxId, + 5, + LocalDateTime.now().minusMinutes(10) + ); + + assertThat(claimedOutbox).isPresent(); + assertThat(claimedOutbox.get().id()).isEqualTo(outboxId); + assertThat(claimedOutbox.get().objectKey()).isEqualTo("chat/file.jpg"); + assertThat(claimedOutbox.get().claimToken()).isNotBlank(); + } + + @Test + @DisplayName("이미 다른 worker가 선점한 outbox는 빈 결과를 반환한다") + void claim_returnsEmptyWhenUpdateSkipped() { + Long outboxId = 1L; + given(objectStorageDeleteOutboxRepository.claimForProcessing( + eq(outboxId), + eq(List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED)), + eq(ObjectStorageDeleteStatus.PROCESSING), + eq(5), + any(LocalDateTime.class), + any(LocalDateTime.class), + any(String.class) + )).willReturn(0); + given(objectStorageDeleteOutboxRepository.existsById(outboxId)).willReturn(true); + + Optional claimedOutbox = objectStorageDeleteOutboxClaimService.claim( + outboxId, + 5, + LocalDateTime.now().minusMinutes(10) + ); + + assertThat(claimedOutbox).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 outbox claim은 예외를 반환한다") + void claim_throwsWhenOutboxMissing() { + Long outboxId = 1L; + given(objectStorageDeleteOutboxRepository.claimForProcessing( + eq(outboxId), + eq(List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED)), + eq(ObjectStorageDeleteStatus.PROCESSING), + eq(5), + any(LocalDateTime.class), + any(LocalDateTime.class), + any(String.class) + )).willReturn(0); + given(objectStorageDeleteOutboxRepository.existsById(outboxId)).willReturn(false); + + assertThatThrownBy(() -> objectStorageDeleteOutboxClaimService.claim( + outboxId, + 5, + LocalDateTime.now().minusMinutes(10) + )).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("claim token이 일치하는 PROCESSING outbox만 DONE으로 변경한다") + void markDone_marksOnlyClaimedOutbox() { + ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( + "chat/file.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + ClaimedObjectStorageDeleteOutbox claimedOutbox = new ClaimedObjectStorageDeleteOutbox(1L, "chat/file.jpg", "token"); + given(objectStorageDeleteOutboxRepository.findByIdAndStatusAndClaimToken( + 1L, + ObjectStorageDeleteStatus.PROCESSING, + "token" + )).willReturn(Optional.of(outbox)); + + boolean marked = objectStorageDeleteOutboxClaimService.markDone(claimedOutbox); + + assertThat(marked).isTrue(); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); + assertThat(outbox.getClaimToken()).isNull(); + } + + @Test + @DisplayName("claim token이 일치하는 PROCESSING outbox만 FAILED로 변경한다") + void markFailed_marksOnlyClaimedOutbox() { + ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( + "chat/file.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + ClaimedObjectStorageDeleteOutbox claimedOutbox = new ClaimedObjectStorageDeleteOutbox(1L, "chat/file.jpg", "token"); + given(objectStorageDeleteOutboxRepository.findByIdAndStatusAndClaimToken( + 1L, + ObjectStorageDeleteStatus.PROCESSING, + "token" + )).willReturn(Optional.of(outbox)); + + boolean marked = objectStorageDeleteOutboxClaimService.markFailed(claimedOutbox, "delete failed"); + + assertThat(marked).isTrue(); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.FAILED); + assertThat(outbox.getRetryCount()).isEqualTo(1); + assertThat(outbox.getLastError()).isEqualTo("delete failed"); + assertThat(outbox.getClaimToken()).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java index e85695f7b..faaefd1e1 100644 --- a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java @@ -8,17 +8,19 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; -import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; -import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -31,6 +33,8 @@ class ObjectStorageDeleteOutboxProcessorTest { @Mock private ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; @Mock + private ObjectStorageDeleteOutboxClaimService objectStorageDeleteOutboxClaimService; + @Mock private ObjectStorageClient objectStorageClient; @Test @@ -38,67 +42,79 @@ class ObjectStorageDeleteOutboxProcessorTest { void processPendingBatch_deletesRetryTargets() { ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "batchSize", 2); ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "maxRetryCount", 5); - ObjectStorageDeleteOutbox first = ObjectStorageDeleteOutbox.pending( - "chat/first.jpg", - ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, - 10L - ); - ObjectStorageDeleteOutbox second = ObjectStorageDeleteOutbox.pending( - "chat/second.jpg", - ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, - 10L - ); - given(objectStorageDeleteOutboxRepository.findByStatusInAndRetryCountLessThanOrderByCreatedAtAsc( - List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED), - 5, - PageRequest.of(0, 2) - )).willReturn(List.of(first, second)); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "processingTimeoutMinutes", 10L); + ClaimedObjectStorageDeleteOutbox first = new ClaimedObjectStorageDeleteOutbox(1L, "chat/first.jpg", "token-1"); + ClaimedObjectStorageDeleteOutbox second = new ClaimedObjectStorageDeleteOutbox(2L, "chat/second.jpg", "token-2"); + given(objectStorageDeleteOutboxRepository.findClaimCandidateIds( + eq(List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED)), + eq(ObjectStorageDeleteStatus.PROCESSING), + eq(5), + any(LocalDateTime.class), + eq(PageRequest.of(0, 2)) + )).willReturn(List.of(1L, 2L)); + given(objectStorageDeleteOutboxClaimService.claim(eq(1L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.of(first)); + given(objectStorageDeleteOutboxClaimService.claim(eq(2L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.of(second)); + given(objectStorageDeleteOutboxClaimService.markDone(first)).willReturn(true); + given(objectStorageDeleteOutboxClaimService.markDone(second)).willReturn(true); int processedCount = objectStorageDeleteOutboxProcessor.processPendingBatch(); assertThat(processedCount).isEqualTo(2); verify(objectStorageClient).delete("chat/first.jpg"); verify(objectStorageClient).delete("chat/second.jpg"); - assertThat(first.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); - assertThat(second.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); + verify(objectStorageDeleteOutboxClaimService).markDone(first); + verify(objectStorageDeleteOutboxClaimService).markDone(second); } @Test @DisplayName("삭제에 성공하면 outbox를 DONE으로 표시한다") void processOne_marksDoneWhenDeleteSucceeds() { - ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( - "chat/file.jpg", - ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, - 10L - ); - given(objectStorageDeleteOutboxRepository.findById(1L)).willReturn(Optional.of(outbox)); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "maxRetryCount", 5); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "processingTimeoutMinutes", 10L); + ClaimedObjectStorageDeleteOutbox outbox = new ClaimedObjectStorageDeleteOutbox(1L, "chat/file.jpg", "token"); + given(objectStorageDeleteOutboxClaimService.claim(eq(1L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.of(outbox)); + given(objectStorageDeleteOutboxClaimService.markDone(outbox)).willReturn(true); - objectStorageDeleteOutboxProcessor.processOne(1L); + boolean processed = objectStorageDeleteOutboxProcessor.processOne(1L); + assertThat(processed).isTrue(); verify(objectStorageClient).delete("chat/file.jpg"); - assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); - assertThat(outbox.getLastError()).isNull(); - assertThat(outbox.getLastAttemptedAt()).isNotNull(); + verify(objectStorageDeleteOutboxClaimService).markDone(outbox); } @Test @DisplayName("삭제에 실패하면 실패 사유와 retryCount를 남긴다") void processOne_marksFailedWhenDeleteFails() { - ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( - "chat/file.jpg", - ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, - 10L - ); - given(objectStorageDeleteOutboxRepository.findById(1L)).willReturn(Optional.of(outbox)); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "maxRetryCount", 5); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "processingTimeoutMinutes", 10L); + ClaimedObjectStorageDeleteOutbox outbox = new ClaimedObjectStorageDeleteOutbox(1L, "chat/file.jpg", "token"); + given(objectStorageDeleteOutboxClaimService.claim(eq(1L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.of(outbox)); willThrow(new RuntimeException("delete failed")) .given(objectStorageClient) .delete("chat/file.jpg"); + given(objectStorageDeleteOutboxClaimService.markFailed(outbox, "delete failed")).willReturn(true); + + boolean processed = objectStorageDeleteOutboxProcessor.processOne(1L); + + assertThat(processed).isTrue(); + verify(objectStorageDeleteOutboxClaimService).markFailed(outbox, "delete failed"); + } + + @Test + @DisplayName("다른 worker가 먼저 claim한 outbox는 처리하지 않는다") + void processOne_skipsWhenClaimFails() { + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "maxRetryCount", 5); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "processingTimeoutMinutes", 10L); + given(objectStorageDeleteOutboxClaimService.claim(eq(1L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.empty()); - objectStorageDeleteOutboxProcessor.processOne(1L); + boolean processed = objectStorageDeleteOutboxProcessor.processOne(1L); - assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.FAILED); - assertThat(outbox.getRetryCount()).isEqualTo(1); - assertThat(outbox.getLastError()).isEqualTo("delete failed"); - assertThat(outbox.getLastAttemptedAt()).isNotNull(); + assertThat(processed).isFalse(); + verify(objectStorageClient, never()).delete(any()); } }