Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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(
String type,
String roomNo,
String chatRoomNo
) {
public static NotificationMessage roomDeleted(String roomNo, String chatRoomNo) {
return new NotificationMessage("ROOM_DELETED", roomNo, chatRoomNo);
}

public static NotificationMessage kicked(String roomNo, String chatRoomNo) {
return new NotificationMessage("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,76 @@
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 lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class RoomDeletedEventListener {

private final ChatRoomService chatRoomService;
private final ChatRoomMemberService chatRoomMemberService;
private final ChatMessageService chatMessageService;
private final SimpMessagingTemplate messagingTemplate;

/**
* 방 삭제(RoomDeletedEvent) → GROUP·DIRECT 채팅방 전체 삭제 처리
* 발행: DeleteRoomUseCase (room 도메인 담당자)
*
* BEFORE_COMMIT: 부모 트랜잭션(DeleteRoomUseCase)에 참여하여 방 삭제 + 채팅방 삭제를 하나의 트랜잭션으로 처리.
* WebSocket 브로드캐스트는 트랜잭션 외부 작업이므로 실패가 TX에 영향을 주지 않도록 독립 처리.
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(RoomDeletedEvent event) {
List<ChatRoom> chatRooms = chatRoomService.findAllByRoomNo(event.roomNo());
for (ChatRoom chatRoom : chatRooms) {
notifyAndClean(chatRoom, event.roomNo());
}
}

private void notifyAndClean(ChatRoom chatRoom, String roomNo) {
NotificationMessage notification = NotificationMessage.roomDeleted(roomNo, chatRoom.getChatRoomNo());

// 채팅방 열려 있는 유저에게 브로드캐스트 (/topic/)
broadcastDeletionSafely(chatRoom, notification);

// DIRECT 채팅방은 지원자가 다른 화면에 있어도 받을 수 있도록 개인 큐로 추가 전송 (/queue/)
if (ChatRoomType.DIRECT.equals(chatRoom.getChatRoomType())
&& chatRoom.getApplicantUserNo() != null) {
notifyUserSafely(chatRoom.getApplicantUserNo(), notification);
}

chatMessageService.deleteAllByChatRoom(chatRoom.getChatRoomNo());
chatRoomMemberService.deleteAllByChatRoom(chatRoom);
chatRoomService.deleteByChatRoomNo(chatRoom.getChatRoomNo());
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

삭제 알림은 커밋 이후에 보내도록 분리해 주세요.

Line 48과 Line 53에서 WebSocket 알림을 먼저 보낸 뒤 Line 56-58의 DB 삭제가 실패하면, 트랜잭션은 롤백되지만 이미 발송된 “방 삭제” 알림은 되돌릴 수 없습니다. 채팅 데이터 정리는 BEFORE_COMMIT에 두더라도, 사용자 알림은 삭제 대상 chatRoomNo/수신자 정보를 별도 이벤트나 outbox에 담아 AFTER_COMMIT에서 전송하도록 분리하는 편이 안전합니다.

🤖 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/RoomDeletedEventListener.java`
around lines 47 - 58, The current RoomDeletedEventListener sends WebSocket
notifications (broadcastDeletionSafely and notifyUserSafely) before performing
DB deletes (chatMessageService.deleteAllByChatRoom,
chatRoomMemberService.deleteAllByChatRoom, chatRoomService.deleteByChatRoomNo),
which can leak notifications on rollback; change this so DB deletes execute
inside the transaction and notification sending runs only AFTER commit.
Concretely, stop calling broadcastDeletionSafely and notifyUserSafely directly
in RoomDeletedEventListener; instead capture the minimal notification payload
(chatRoomNo, recipientUserNo(s)) and either (a) register a
TransactionSynchronization.afterCommit callback to dispatch those payloads, or
(b) publish a new ChatRoomDeletedNotificationEvent/outbox entry from
RoomDeletedEventListener and handle sending in an AFTER_COMMIT listener (e.g.,
ChatRoomDeletedNotificationHandler) that calls
broadcastDeletionSafely/notifyUserSafely. Ensure
ChatRoomDeletedNotificationEvent contains chatRoomNo and recipient IDs and that
only the after-commit handler invokes the WebSocket methods.

}

private void broadcastDeletionSafely(ChatRoom chatRoom, NotificationMessage notification) {
try {
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), notification);
} catch (Exception e) {
log.warn("[Chat] 방 삭제 WebSocket 브로드캐스트 실패. chatRoomNo={}", chatRoom.getChatRoomNo(), e);
}
}

private void notifyUserSafely(String userNo, NotificationMessage notification) {
try {
messagingTemplate.convertAndSendToUser(userNo, "/queue/notification", notification);
} catch (Exception e) {
log.warn("[Chat] 개인 알림 전송 실패. userNo={}", userNo, e);
}
}
}
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 Down Expand Up @@ -41,25 +42,29 @@ public class RoommateKickedEventListener {
*/
@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 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 response = new ChatMessageResponse(
message.getMessageNo(), chatRoom.getChatRoomNo(),
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount());
broadcastSafely(chatRoom, response);
notifyUserSafely(event.kickedUserNo(),
NotificationMessage.kicked(event.roomNo(), chatRoom.getChatRoomNo()));
}
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 | 🟡 Minor

BEFORE_COMMIT 단계에서의 WebSocket 알림 순서에 대한 검토 요청드립니다.

현재 processKick(...)은 BEFORE_COMMIT 단계에서 DB 변경(decreaseUnreadCount, leave, 시스템 메시지 save)과 브로드캐스트/개인 알림 전송을 함께 수행하고 있습니다. broadcastSafely/notifyUserSafely가 예외를 삼켜 트랜잭션에 영향은 주지 않지만, 반대 방향의 문제가 남습니다:

  • 알림은 이미 나갔는데 이후 동일 트랜잭션(부모 KickRoommateUseCase)의 다른 작업이 실패해 롤백되면, 강퇴당하지 않은 사용자에게 "강퇴됨" 알림이 전달된 상태가 됩니다. 시스템 메시지(ChatMessage)도 구독자들은 이미 받은 상태인데 DB에는 존재하지 않게 됩니다.

DB 정합성은 BEFORE_COMMIT에 유지하되, 외부로 나가는 브로드캐스트/개인 알림만 AFTER_COMMIT 시점으로 분리하는 것이 더 안전해 보입니다. 예: processKick을 "상태 변경 + 페이로드 준비"와 "외부 전송"으로 쪼개고 후자를 TransactionSynchronizationManager.registerSynchronization(... afterCommit ...) 또는 별도 AFTER_COMMIT 리스너로 실행하는 방식이요. 의도적으로 BEFORE_COMMIT에서 같이 내보내는 설계라면, 클래스 주석에 "커밋 실패 시 오발송 가능" 트레이드오프도 함께 명시해 주시면 좋겠습니다.

🤖 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/RoommateKickedEventListener.java`
around lines 43 - 68, The listener RoommateKickedEventListener currently
performs DB state changes (chatMessageService.decreaseUnreadCount,
chatRoomMemberService.leave, chatMessageService.save) and sends external
notifications (broadcastSafely, notifyUserSafely) inside a
`@TransactionalEventListener`(phase = TransactionPhase.BEFORE_COMMIT) via
processKick(...), which can cause out-of-sync external notifications if the
transaction later rolls back; split processKick into two parts: (1) perform and
persist state changes and assemble the payload (message and ChatMessageResponse)
during BEFORE_COMMIT, and (2) defer the external sends (calls to broadcastSafely
and notifyUserSafely) to AFTER_COMMIT using
TransactionSynchronizationManager.registerSynchronization(... afterCommit ...)
or move sending into a separate `@TransactionalEventListener`(phase =
AFTER_COMMIT) that receives the prepared payload, ensuring only committed
changes trigger broadcasts; alternatively, document the intentional trade-off in
the class javadoc if you choose to keep current behavior.


private void broadcastSafely(ChatRoom chatRoom, ChatMessageResponse response) {
Expand All @@ -69,4 +74,12 @@ private void broadcastSafely(ChatRoom chatRoom, ChatMessageResponse response) {
log.warn("[Chat] WebSocket 브로드캐스트 실패. chatRoomNo={}", chatRoom.getChatRoomNo(), e);
}
}

private void notifyUserSafely(String userNo, NotificationMessage notification) {
try {
messagingTemplate.convertAndSendToUser(userNo, "/queue/notification", notification);
} catch (Exception e) {
log.warn("[Chat] 개인 알림 전송 실패. userNo={}", userNo, e);
}
}
}
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
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ public RoomRule findByRoomNo(String roomNo) {
return roomRuleRepository.findByRoomNo(roomNo)
.orElseThrow(() -> new RestApiException(_NOT_FOUND));
}

public void deleteByRoomNo(String roomNo) {
roomRuleRepository.deleteByRoomNo(roomNo);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.project.dorumdorum.domain.room.application.event;

/**
* 방 삭제 → 채팅방 삭제 트리거
* 발행 책임: DeleteRoomUseCase (room 도메인 담당자)
*/
public record RoomDeletedEvent(
String roomNo
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.project.dorumdorum.domain.room.application.usecase;

import com.project.dorumdorum.domain.checklist.domain.service.RoomRuleService;
import com.project.dorumdorum.domain.room.application.event.RoomDeletedEvent;
import com.project.dorumdorum.domain.room.domain.entity.Room;
import com.project.dorumdorum.domain.room.domain.repository.RoomLikeRepository;
import com.project.dorumdorum.domain.room.domain.service.RoomRequestService;
import com.project.dorumdorum.domain.room.domain.service.RoomService;
import com.project.dorumdorum.domain.roommate.domain.service.RoommateService;
import com.project.dorumdorum.global.exception.RestApiException;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.CANNOT_DELETE_COMPLETED_ROOM;
import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.NO_PERMISSION_ON_ROOM;
import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.ROOM_HAS_MEMBERS;

@Service
@Transactional
@RequiredArgsConstructor
public class DeleteRoomUseCase {

private final RoomService roomService;
private final RoommateService roommateService;
private final RoomRequestService roomRequestService;
private final RoomRuleService roomRuleService;
private final RoomLikeRepository roomLikeRepository;
private final ApplicationEventPublisher eventPublisher;

/**
* 방 삭제
* - 방장 권한 검증
* - 방장 외 룸메이트 존재 시 삭제 불가
* - RoomRequest, RoomLike cascade 삭제 후 방 소프트 삭제
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

Javadoc에 ‘RoomRequest, RoomLike cascade 삭제 후 방 소프트 삭제’라고 되어 있지만, 실제 구현은 room.delete()로 소프트 삭제를 먼저 수행한 뒤 연관 데이터 삭제를 진행합니다. 문서와 구현 순서가 불일치하므로 Javadoc을 실제 동작에 맞게 수정해 주세요(또는 구현 순서를 의도대로 조정).

Suggested change
* - RoomRequest, RoomLike cascade 삭제 소프트 삭제
* - 소프트 삭제 방장 퇴실 처리 flush 수행
* - RoomRequest, RoomRule, RoomLike 연관 데이터 삭제

Copilot uses AI. Check for mistakes.
* - 채팅방 삭제 이벤트 발행
*/
public void execute(String requesterNo, String roomNo) {
Room room = roomService.findByIdForUpdate(roomNo);

if (!room.isHost(requesterNo)) {
throw new RestApiException(NO_PERMISSION_ON_ROOM);
}

if (room.isCompleted()) {
throw new RestApiException(CANNOT_DELETE_COMPLETED_ROOM);
}

if (room.getCurrentMateCount() > 1) {
throw new RestApiException(ROOM_HAS_MEMBERS);
}

room.delete();
roommateService.leaveRoom(requesterNo, roomNo);
Comment on lines +51 to +56
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
# Description: Roommate 존재 여부를 실제 row 기준으로 확인할 수 있는 서비스/리포지토리 메서드가 있는지 점검합니다.
rg -n -C3 'count.*Room|exists.*Room|find.*Roommate|currentMateCount|getCurrentMateCount|RoommateService|RoommateRepository' src/main/java src/test/java

Repository: DorumDorum/BE

Length of output: 50370


🏁 Script executed:

cat src/main/java/com/project/dorumdorum/domain/roommate/domain/repository/RoommateRepository.java

Repository: DorumDorum/BE

Length of output: 1310


🏁 Script executed:

cat src/main/java/com/project/dorumdorum/domain/roommate/domain/service/RoommateService.java

Repository: DorumDorum/BE

Length of output: 2945


🏁 Script executed:

cat src/main/java/com/project/dorumdorum/domain/room/application/usecase/DeleteRoomUseCase.java

Repository: DorumDorum/BE

Length of output: 2596


방장 외 룸메이트 존재 여부를 실제 Roommate 데이터 기준으로 강화해 주세요.

라인 50에서 currentMateCount만 믿고 삭제 가능 여부를 판단하고 있는데, 인원수 동기화 버그가 이 PR의 대상인 만큼 카운트가 실제 Roommate row와 어긋난 경우 비방장 룸메이트가 남은 채 방이 삭제될 수 있습니다.

라인 50의 검증 조건을 보강해서 roommateService.findByRoom(room)으로 실제 Roommate 레코드를 조회한 후, 방장을 제외한 다른 룸메이트가 없는지 확인하는 로직을 추가해 주세요. 혹은 RoommateService에 "방장 외 룸메이트 존재 여부"를 직접 확인하는 전용 쿼리 메서드를 추가하는 것도 좋은 방식입니다.

🤖 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/room/application/usecase/DeleteRoomUseCase.java`
around lines 50 - 55, The deletion currently trusts room.getCurrentMateCount()
which can be out of sync; update DeleteRoomUseCase to verify using actual
Roommate rows by calling roommateService.findByRoom(room) (or add a new
roommateService.existsNonOwnerInRoom(room) method) and throw
RestApiException(ROOM_HAS_MEMBERS) if any roommate other than the owner is
returned, only then call room.delete() and
roommateService.leaveRoom(requesterNo, roomNo).

roomService.flush();
Comment on lines +55 to +57
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

PR 설명에서는 room.delete() 직후 roomService.flush()로 deletedAt을 먼저 DB에 반영한 뒤 leaveRoom()을 수행하는 것으로 되어 있는데, 현재 코드는 leaveRoom() 이후에 flush()가 호출됩니다. 의도한 트랜잭션 정합성/재진입 차단 목적이 flush 선행을 전제한 것이라면 구현 순서를 맞추고, 반대로 현재 순서가 의도라면 PR 설명(및 관련 주석)을 현재 동작에 맞게 업데이트해 주세요.

Copilot uses AI. Check for mistakes.

roomRequestService.deleteAllByRoom(room);
roomRuleService.deleteByRoomNo(roomNo);
roomLikeRepository.deleteAllByRoom(room);

eventPublisher.publishEvent(new RoomDeletedEvent(roomNo));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public void execute(String requesterNo, String roomNo, String kickedUserNo) {

roommateService.leaveRoom(kickedUserNo, roomNo);
room.minusCurrentMate();
roomService.flush();

eventPublisher.publishEvent(new RoommateKickedEvent(roomNo, kickedUserNo));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@
import com.project.dorumdorum.domain.room.domain.entity.Room;
import com.project.dorumdorum.domain.room.domain.entity.RoomLike;
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;

public interface RoomLikeRepository extends JpaRepository<RoomLike, String> {

boolean existsByUserNoAndRoom(String userNo, Room room);

void deleteByUserNoAndRoom(String userNo, Room room);

// N+1 DELETE 문제 — @Modifying JPQL 벌크 DELETE로 교체
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM RoomLike l WHERE l.room = :room")
void deleteAllByRoom(@Param("room") Room room);
}

Loading
Loading