Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
92b4722
feat(chat): add user queue notifications for room events
ydking0911 Apr 18, 2026
9a72c6f
feat(room): add room deletion use case and cleanup flow
ydking0911 Apr 18, 2026
5e8d971
feat(chat): clean up chat rooms when a room is deleted
ydking0911 Apr 18, 2026
d99c0e9
fix: room.delete() 순서 조정 및 flush() 추가로 방 삭제 정합성 보장
ydking0911 Apr 18, 2026
20e4356
fix: KickRoommateUseCase에 flush() 추가로 강퇴 후 이벤트 리스너 가시성 보장
ydking0911 Apr 18, 2026
a6ff163
refactor: RoommateKickedEventListener 이중 조회 제거 및 processKick() 분리
ydking0911 Apr 18, 2026
678453a
feat: WebSocket 개인 알림 라우팅을 위한 JwtPrincipalHandshakeHandler 추가
ydking0911 Apr 18, 2026
b7cf5a8
fix: ChatMessageRepository @Modifying에 flushAutomatically 추가
ydking0911 Apr 18, 2026
62aeb59
refactor: RoomDeletedEventListener 채팅방 삭제를 벌크 JPQL로 교체
ydking0911 Apr 18, 2026
9a668dc
test: 방 삭제/강퇴 영속성 통합 테스트 추가
ydking0911 Apr 18, 2026
66a1704
fix: CI 통합 테스트에서 User 엔티티 직접 저장 제거
ydking0911 Apr 18, 2026
579bcd0
feat(chat): decouple websocket notifications from transaction
ydking0911 Apr 19, 2026
1c0583e
chore(room): tighten delete-room checks and add chat_room index
ydking0911 Apr 19, 2026
9d8afeb
test(chat): cover websocket notification event flow
ydking0911 Apr 19, 2026
5ce9ead
fix(chat): send room deletion notifications via user queue
ydking0911 Apr 19, 2026
db34317
fix: event 전달 순서 조정, test 코드 수정
ydking0911 Apr 20, 2026
b408a75
fix: deleted_at NULL 조건 처리 수정
ydking0911 Apr 20, 2026
47affd1
fix: persist checklist and room rule updates
ydking0911 Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.project.dorumdorum.domain.chat.application.dto.response;

public record NotificationMessage(
NotificationType type,
String roomNo,
String chatRoomNo
) {
public static NotificationMessage roomDeleted(String roomNo, String chatRoomNo) {
return new NotificationMessage(NotificationType.ROOM_DELETED, roomNo, chatRoomNo);
}

public static NotificationMessage kicked(String roomNo, String chatRoomNo) {
return new NotificationMessage(NotificationType.KICKED_FROM_ROOM, roomNo, chatRoomNo);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.project.dorumdorum.domain.chat.application.dto.response;

import com.fasterxml.jackson.annotation.JsonValue;

public enum NotificationType {
ROOM_DELETED("ROOM_DELETED"),
KICKED_FROM_ROOM("KICKED_FROM_ROOM");

private final String value;

NotificationType(String value) {
this.value = value;
}

@JsonValue
public String getValue() {
return value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.project.dorumdorum.domain.chat.application.event;

import java.util.List;

public record ChatWebSocketNotificationEvent(
List<BroadcastTask> broadcasts,
List<UserNotifyTask> userNotifications
) {
public record BroadcastTask(String chatRoomNo, Object payload) {}

public record UserNotifyTask(String userNo, Object payload) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.project.dorumdorum.domain.chat.application.event;

import com.project.dorumdorum.domain.chat.infra.websocket.ChatWebSocketSendService;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class ChatWebSocketNotificationEventListener {

private final ChatWebSocketSendService chatWebSocketSendService;

@Async("notificationExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = false)
public void handle(ChatWebSocketNotificationEvent event) {
event.broadcasts().forEach(task ->
chatWebSocketSendService.broadcast(task.chatRoomNo(), task.payload()));
event.userNotifications().forEach(task ->
chatWebSocketSendService.notifyUser(task.userNo(), task.payload()));
}
Comment on lines +16 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

부분 실패 시 후속 태스크가 누락될 수 있는 점 확인 부탁드립니다.

broadcasts/userNotifications를 순차적으로 forEach로 돌리는 중, ChatWebSocketSendService.broadcast(...)@Retryable 3회 시도를 모두 소진해 최종 예외를 던지면 @Recover가 호출되어 정상 복구되므로 보통은 문제가 없습니다. 다만 recoverSend(...)의 시그니처가 매개변수 순서와 정확히 매칭되지 않아 복구가 실패하는 경우(또는 @EnableRetry 미설정 등)에는 예외가 forEach 바깥으로 전파되어 나머지 태스크 전송이 모두 누락될 수 있습니다.

방어적으로 각 태스크 전송을 try/catch로 감싸서 개별 실패가 후속 태스크에 영향을 주지 않도록 하면 조금 더 견고해집니다.

♻️ 제안 수정안
-        event.broadcasts().forEach(task ->
-                chatWebSocketSendService.broadcast(task.chatRoomNo(), task.payload()));
-        event.userNotifications().forEach(task ->
-                chatWebSocketSendService.notifyUser(task.userNo(), task.payload()));
+        event.broadcasts().forEach(task -> {
+            try {
+                chatWebSocketSendService.broadcast(task.chatRoomNo(), task.payload());
+            } catch (Exception e) {
+                log.warn("[Chat] broadcast 전파 실패. chatRoomNo={}", task.chatRoomNo(), e);
+            }
+        });
+        event.userNotifications().forEach(task -> {
+            try {
+                chatWebSocketSendService.notifyUser(task.userNo(), task.payload());
+            } catch (Exception e) {
+                log.warn("[Chat] notifyUser 전파 실패. userNo={}", task.userNo(), e);
+            }
+        });

(로깅을 위해 @Slf4j도 함께 추가해주세요.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/project/dorumdorum/domain/chat/application/event/ChatWebSocketNotificationEventListener.java`
around lines 16 - 23, The handle method in
ChatWebSocketNotificationEventListener should defend against one task's
exception aborting the rest: add `@Slf4j` to the class and wrap each broadcast and
notifyUser invocation inside its own try/catch so exceptions from
chatWebSocketSendService.broadcast(...) or .notifyUser(...) are caught, logged
(include the task identifiers and exception), and swallowed so the loop
continues; also verify recoverSend signature and `@EnableRetry` configuration
remain correct but do not rely on `@Retryable` recovery to prevent skipping
subsequent tasks.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.project.dorumdorum.domain.chat.application.event;

import com.project.dorumdorum.domain.chat.application.dto.response.NotificationMessage;
import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom;
import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomType;
import com.project.dorumdorum.domain.chat.domain.service.ChatMessageService;
import com.project.dorumdorum.domain.chat.domain.service.ChatRoomMemberService;
import com.project.dorumdorum.domain.chat.domain.service.ChatRoomService;
import com.project.dorumdorum.domain.room.application.event.RoomDeletedEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.util.ArrayList;
import java.util.List;

@Component
@RequiredArgsConstructor
public class RoomDeletedEventListener {

private final ChatRoomService chatRoomService;
private final ChatRoomMemberService chatRoomMemberService;
private final ChatMessageService chatMessageService;
private final ApplicationEventPublisher eventPublisher;

/**
* 방 삭제(RoomDeletedEvent) → GROUP·DIRECT 채팅방 전체 삭제 처리
* 발행: DeleteRoomUseCase (room 도메인 담당자)
*
* BEFORE_COMMIT: 부모 트랜잭션(DeleteRoomUseCase)에 참여하여 방 삭제 + 채팅방 삭제를 하나의 트랜잭션으로 처리.
* WebSocket 알림 페이로드를 수집한 뒤 ChatWebSocketNotificationEvent로 발행 →
* AFTER_COMMIT에서 비동기 재처리(exponential backoff)로 전송.
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(RoomDeletedEvent event) {
List<ChatRoom> chatRooms = chatRoomService.findAllByRoomNo(event.roomNo());

List<ChatWebSocketNotificationEvent.BroadcastTask> broadcasts = new ArrayList<>();
List<ChatWebSocketNotificationEvent.UserNotifyTask> userNotifications = new ArrayList<>();

for (ChatRoom chatRoom : chatRooms) {
NotificationMessage notification = NotificationMessage.roomDeleted(event.roomNo(), chatRoom.getChatRoomNo());
broadcasts.add(new ChatWebSocketNotificationEvent.BroadcastTask(chatRoom.getChatRoomNo(), notification));

Comment on lines +42 to +43
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

RoomDeletedEventListener가 /topic/chat-room/{chatRoomNo}에 NotificationMessage를 브로드캐스트하고 있는데, 기존 코드 경로(예: SendGroupChatMessageUseCase/JoinChatRoomUseCase/LeaveChatRoomUseCase)는 동일 destination에 ChatMessageResponse만 전송하고 있습니다. 동일 destination에서 서로 다른 payload 스키마를 섞으면 클라이언트 역직렬화/핸들링이 깨질 가능성이 커서, (1) 별도 destination(/topic/notification 등)으로 분리하거나 (2) 공통 envelope(type 필드 포함)로 통일하거나 (3) 삭제 알림도 SYSTEM ChatMessageResponse 형태로 맞추는 쪽을 권장합니다.

Copilot uses AI. Check for mistakes.
if (ChatRoomType.DIRECT.equals(chatRoom.getChatRoomType())
&& chatRoom.getApplicantUserNo() != null) {
userNotifications.add(new ChatWebSocketNotificationEvent.UserNotifyTask(
chatRoom.getApplicantUserNo(), notification));
}

chatMessageService.deleteAllByChatRoom(chatRoom.getChatRoomNo());
chatRoomMemberService.deleteAllByChatRoom(chatRoom);
chatRoomService.deleteByChatRoomNo(chatRoom.getChatRoomNo());
}

if (!broadcasts.isEmpty() || !userNotifications.isEmpty()) {
eventPublisher.publishEvent(new ChatWebSocketNotificationEvent(broadcasts, userNotifications));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.project.dorumdorum.domain.chat.application.event;

import com.project.dorumdorum.domain.chat.application.dto.response.ChatMessageResponse;
import com.project.dorumdorum.domain.chat.application.dto.response.NotificationMessage;
import com.project.dorumdorum.domain.chat.domain.entity.ChatMessage;
import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom;
import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomMember;
Expand All @@ -12,15 +13,14 @@
import com.project.dorumdorum.domain.user.domain.entity.User;
import com.project.dorumdorum.domain.user.domain.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.time.LocalDateTime;
import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class RoommateKickedEventListener {
Expand All @@ -29,44 +29,46 @@ public class RoommateKickedEventListener {
private final ChatRoomMemberService chatRoomMemberService;
private final ChatMessageService chatMessageService;
private final UserService userService;
private final SimpMessagingTemplate messagingTemplate;
private final ApplicationEventPublisher eventPublisher;

/**
* 룸메이트 강퇴(RoommateKickedEvent) → 채팅방에서 퇴장 처리
* 발행: KickRoommateUseCase (room 도메인 담당자)
*
* BEFORE_COMMIT: 부모 트랜잭션(KickRoommateUseCase)에 참여하여 방 강퇴 + 채팅방 퇴장을 하나의 트랜잭션으로 처리.
* 채팅방 퇴장 실패 시 방 강퇴도 롤백되어 데이터 정합성 보장.
* WebSocket 브로드캐스트는 트랜잭션 외부 작업이므로 실패가 TX에 영향을 주지 않도록 독립 처리.
* WebSocket 알림 페이로드를 수집한 뒤 ChatWebSocketNotificationEvent로 발행 →
* AFTER_COMMIT에서 비동기 재처리(exponential backoff)로 전송.
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(RoommateKickedEvent event) {
chatRoomService.findByRoomNo(event.roomNo()).ifPresent(chatRoom -> {
if (chatRoomMemberService.isMember(chatRoom, event.kickedUserNo())) {
ChatRoomMember member = chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, event.kickedUserNo());
LocalDateTime fromTime = member.getLastReadAt() != null
? member.getLastReadAt()
: member.getJoinedAt();
chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, event.kickedUserNo());
chatRoomMemberService.leave(member);
User kicked = userService.findById(event.kickedUserNo());
String displayName = (kicked.getNickname() != null && !kicked.getNickname().isBlank())
? kicked.getNickname() : kicked.getName();
String content = displayName + "가 퇴장했습니다.";
ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0);
ChatMessageResponse response = new ChatMessageResponse(
message.getMessageNo(), chatRoom.getChatRoomNo(),
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount());
broadcastSafely(chatRoom, response);
}
});
chatRoomService.findByRoomNo(event.roomNo()).ifPresent(chatRoom ->
chatRoomMemberService.findOptionalByChatRoomAndUserNo(chatRoom, event.kickedUserNo())
.ifPresent(member -> processKick(chatRoom, member, event))
);
}

private void broadcastSafely(ChatRoom chatRoom, ChatMessageResponse response) {
try {
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), response);
} catch (Exception e) {
log.warn("[Chat] WebSocket 브로드캐스트 실패. chatRoomNo={}", chatRoom.getChatRoomNo(), e);
}
private void processKick(ChatRoom chatRoom, ChatRoomMember member, RoommateKickedEvent event) {
LocalDateTime fromTime = member.getLastReadAt() != null
? member.getLastReadAt()
: member.getJoinedAt();
chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, event.kickedUserNo());
chatRoomMemberService.leave(member);

User kicked = userService.findById(event.kickedUserNo());
String displayName = (kicked.getNickname() != null && !kicked.getNickname().isBlank())
? kicked.getNickname() : kicked.getName();
String content = displayName + "님이 퇴장했습니다.";
ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

ChatMessageResponse broadcastPayload = new ChatMessageResponse(
message.getMessageNo(), chatRoom.getChatRoomNo(),
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount());
NotificationMessage notificationPayload = NotificationMessage.kicked(event.roomNo(), chatRoom.getChatRoomNo());

eventPublisher.publishEvent(new ChatWebSocketNotificationEvent(
List.of(new ChatWebSocketNotificationEvent.BroadcastTask(chatRoom.getChatRoomNo(), broadcastPayload)),
List.of(new ChatWebSocketNotificationEvent.UserNotifyTask(event.kickedUserNo(), notificationPayload))
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
@Entity
@Table(
indexes = {
@Index(name = "idx_chat_room_last_message_at", columnList = "last_message_at")
@Index(name = "idx_chat_room_last_message_at", columnList = "last_message_at"),
@Index(name = "idx_chat_room_room_no", columnList = "room_no")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

public interface ChatMessageRepository extends JpaRepository<ChatMessage, String>, ChatMessageQueryRepository {

@Modifying(clearAutomatically = true)
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE ChatMessage m SET m.unreadCount = m.unreadCount - 1 " +
"WHERE m.chatRoom.chatRoomNo = :chatRoomNo " +
"AND m.createdAt > :fromTime " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

Expand Down Expand Up @@ -34,4 +35,8 @@ Optional<ChatRoomMember> findByChatRoomNoAndUserNoForUpdate(
long countByChatRoom(ChatRoom chatRoom);

boolean existsByChatRoom_ChatRoomNoAndUserNo(String chatRoomNo, String userNo);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM ChatRoomMember m WHERE m.chatRoom = :chatRoom")
void deleteAllByChatRoom(@Param("chatRoom") ChatRoom chatRoom);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom;
import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomType;
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 java.util.List;
import java.util.Optional;

public interface ChatRoomRepository extends JpaRepository<ChatRoom, String>, ChatRoomQueryRepository {

Optional<ChatRoom> findByRoomNoAndChatRoomType(String roomNo, ChatRoomType chatRoomType);
List<ChatRoom> findAllByRoomNo(String roomNo);

boolean existsByRoomNo(String roomNo);

Expand All @@ -17,4 +22,8 @@ boolean existsByRoomNoAndChatRoomTypeAndApplicantUserNo(

Optional<ChatRoom> findByRoomNoAndChatRoomTypeAndApplicantUserNo(
String roomNo, ChatRoomType chatRoomType, String applicantUserNo);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM ChatRoom c WHERE c.chatRoomNo = :chatRoomNo")
void deleteByChatRoomNo(@Param("chatRoomNo") String chatRoomNo);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import static com.project.dorumdorum.global.exception.code.status.ChatErrorStatus.*;

Expand All @@ -34,6 +35,10 @@ public ChatRoomMember findByChatRoomAndUserNo(ChatRoom chatRoom, String userNo)
.orElseThrow(() -> new RestApiException(CHAT_ROOM_MEMBER_NOT_FOUND));
}

public Optional<ChatRoomMember> findOptionalByChatRoomAndUserNo(ChatRoom chatRoom, String userNo) {
return chatRoomMemberRepository.findByChatRoomAndUserNo(chatRoom, userNo);
}

public ChatRoomMember findByChatRoomNoAndUserNoForUpdate(String chatRoomNo, String userNo) {
return chatRoomMemberRepository.findByChatRoomNoAndUserNoForUpdate(chatRoomNo, userNo)
.orElseThrow(() -> new RestApiException(CHAT_ROOM_MEMBER_NOT_FOUND));
Expand Down Expand Up @@ -65,6 +70,10 @@ public void leave(ChatRoomMember member) {
chatRoomMemberRepository.delete(member);
}

public void deleteAllByChatRoom(ChatRoom chatRoom) {
chatRoomMemberRepository.deleteAllByChatRoom(chatRoom);
}

public void updateLastReadAt(ChatRoomMember member, LocalDateTime readAt) {
member.updateLastReadAt(readAt);
chatRoomMemberRepository.save(member);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ public void delete(ChatRoom chatRoom) {
chatRoomRepository.delete(chatRoom);
}

public void deleteByChatRoomNo(String chatRoomNo) {
chatRoomRepository.deleteByChatRoomNo(chatRoomNo);
}

public List<ChatRoom> findAllByRoomNo(String roomNo) {
return chatRoomRepository.findAllByRoomNo(roomNo);
}

public List<ChatRoomSummary> findMyChatRooms(String userNo) {
return chatRoomRepository.findMyChatRooms(userNo);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.project.dorumdorum.domain.chat.infra.websocket;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatWebSocketSendService {

private final SimpMessagingTemplate messagingTemplate;

@Retryable(retryFor = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
public void broadcast(String chatRoomNo, Object payload) {
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoomNo, payload);
}

@Retryable(retryFor = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
public void notifyUser(String userNo, Object payload) {
messagingTemplate.convertAndSendToUser(userNo, "/queue/notification", payload);
}

// broadcast / notifyUser 모두 (String, Object) 시그니처이므로 단일 recover로 처리
@Recover
public void recoverSend(Exception e, String recipient, Object payload) {
log.warn("[Chat] WebSocket 알림 최종 실패. recipient={}", recipient, e);
}
Comment on lines +18 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# `@EnableRetry` 설정 여부 확인
rg -nP '@EnableRetry\b' --type=java

Repository: DorumDorum/BE

Length of output: 136


🏁 Script executed:

# DorumdorumApplication.java에서 `@EnableRetry` 설정 확인
cat -n src/main/java/com/project/dorumdorum/DorumdorumApplication.java | head -30

Repository: DorumDorum/BE

Length of output: 983


🏁 Script executed:

# ChatWebSocketSendService.java 전체 내용 확인
cat -n src/main/java/com/project/dorumdorum/domain/chat/infra/websocket/ChatWebSocketSendService.java

Repository: DorumDorum/BE

Length of output: 1613


🏁 Script executed:

# Spring Retry가 지원하는 예외 타입과 messaging 관련 예외 검색
rg -i 'messageconversion|messagedelivery|exception' --type=java src/main/java/com/project/dorumdorum/domain/chat/ -A 2

Repository: DorumDorum/BE

Length of output: 13024


재시도 정책 범위와 @Recover 메서드 매칭 방식을 개선해 보세요.

다행히 @EnableRetry는 DorumdorumApplication에 이미 설정되어 있어서 @Retryable/@Recover가 정상 동작할 거예요. 그 대신 다른 두 가지 개선 사항을 제안드립니다:

  1. 재시도 정책 범위 최적화: retryFor = Exception.class로 모든 예외를 재시도하고 있는데, 직렬화 오류(MessageConversionException)나 IllegalArgumentException 같은 비즈니스 예외는 재시도해도 성공할 수 없어요. 네트워크 계층 예외(MessageDeliveryException 등)만 선별하거나 noRetryFor에 비재시도 예외를 명시하면 불필요한 재시도 대기(최대 3초)를 줄일 수 있습니다.

  2. @Recover 메서드의 명확성: 현재 recoverSend(Exception, String, Object) 시그니처가 두 메서드와 잘 매칭되지만, 향후 다른 시그니처의 @Retryable 메서드가 추가될 때 @Recover 매칭이 모호해질 수 있어요. 각 @Retryable 메서드마다 명시적인 @Recover 메서드를 두거나 메서드명으로 직접 지정하면(recover 속성) 더 안전할 거 같아요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/project/dorumdorum/domain/chat/infra/websocket/ChatWebSocketSendService.java`
around lines 18 - 32, The retry scope is too broad and the single generic
`@Recover` may become ambiguous; for methods broadcast and notifyUser narrow
retries to messaging delivery/network exceptions (e.g., retryFor =
MessageDeliveryException.class or MessagingException.class) and exclude
non-retriable errors with noRetryFor = {MessageConversionException.class,
IllegalArgumentException.class}, and replace the single recoverSend with two
explicit recover methods (e.g., recoverBroadcast(Exception e, String chatRoomNo,
Object payload) and recoverNotify(Exception e, String userNo, Object payload))
so each `@Retryable` (broadcast, notifyUser) has a matching `@Recover` signature.

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.project.dorumdorum.domain.checklist.domain.entity.RoomRule;
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;

Expand All @@ -12,4 +13,7 @@ public interface RoomRuleRepository extends JpaRepository<RoomRule, String> {
@Query("select rr from RoomRule rr where rr.room.roomNo = :roomNo")
Optional<RoomRule> findByRoomNo(@Param("roomNo") String roomNo);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM RoomRule rr WHERE rr.room.roomNo = :roomNo")
void deleteByRoomNo(@Param("roomNo") String roomNo);
}
Loading
Loading