Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
0e618d2
feat: Room 집계 μƒνƒœ λ³€κ²½ κ²½λ‘œμ— 비관적 락 적용
KoungQ Mar 27, 2026
3256169
chore: deprecated μˆ˜μ •
KoungQ Mar 27, 2026
e34dc4e
docs: λΉ„μ¦ˆλ‹ˆμŠ€ 흐름 주석 μΆ”κ°€
KoungQ Mar 27, 2026
2db3032
feat: λ£Έλ©”μ΄νŠΈ 단일 λ°© μ†Œμ† μ œμ•½ μΆ”κ°€
KoungQ Mar 30, 2026
b11cc34
feat: λ£Έλ©”μ΄νŠΈ 단일 λ°© μ†Œμ† μ œμ•½ μΆ”κ°€
KoungQ Mar 30, 2026
0791127
feat: νšŒμ›κ°€μž… 정보 쀑볡 μ œμ•½ μΆ”κ°€
KoungQ Mar 30, 2026
ea05a30
feat: μ±„νŒ… 읽음 처리 λ™μ‹œμ„± μ œμ–΄ μΆ”κ°€
KoungQ Mar 30, 2026
e6b5233
feat: μ±„νŒ…λ°© 쀑볡 생성 μ œμ•½ μΆ”κ°€
KoungQ Mar 30, 2026
f4e43af
chore: validate 기반 μŠ€ν‚€λ§ˆ μ΄ˆκΈ°ν™” μ„€μ • μΆ”κ°€
KoungQ Mar 30, 2026
cfc04e5
feat: λ°© 지원 μš”μ²­ 쀑볡 μ œμ•½ μΆ”κ°€
KoungQ Mar 30, 2026
98aaf72
feat: μ’‹μ•„μš”μ™€ λ””λ°”μ΄μŠ€ 등둝 λ™μ‹œμ„± 처리 보완
KoungQ Mar 30, 2026
0b9ea3f
feat: Discord μ•Œλ¦Ό 쀑볡 λ°©μ§€ 및 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 라이프사이클 μ•Œλ¦Ό μΆ”κ°€
ydking0911 Mar 30, 2026
5479489
chore: 개발 도ꡬ μ„€μ • μ΅œμ ν™” 및 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ›Œλ°μ—… 둜직 정ꡐ화
ydking0911 Mar 30, 2026
c1b09e6
test: μ±„νŒ… μœ μŠ€μΌ€μ΄μŠ€ 및 이벀트 λ¦¬μŠ€λ„ˆ ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€ κ°•ν™”
ydking0911 Mar 30, 2026
f9049b5
chore: AI μ½”λ“œ 리뷰λ₯Ό μœ„ν•œ CodeRabbit μ•„ν‚€ν…μ²˜ κ°€μ΄λ“œλΌμΈ μΆ”κ°€
ydking0911 Mar 30, 2026
321c2e0
feat: λ°© 정원 λ³€κ²½κ³Ό 퇴μž₯ 처리 μ •ν•©μ„± 보완
KoungQ Mar 30, 2026
f9d139e
feat: μ±„νŒ…λ°© ꡬ독 기반 μ‚¬μš©μž presence μƒνƒœ 반영
KoungQ Mar 30, 2026
b526924
test: ν…ŒμŠ€νŠΈ μ½”λ“œ μˆ˜μ •
KoungQ Mar 30, 2026
4d1ee4d
feat: Discord μ‹œμŠ€ν…œ μ•Œλ¦Ό 연동 및 μ•±νŽœλ” ν…ŒμŠ€νŠΈ μΆ”κ°€
ydking0911 Mar 30, 2026
abc5ad9
test: ν…ŒμŠ€νŠΈ μ½”λ“œ μˆ˜μ •
KoungQ Mar 31, 2026
c48ea59
test: schema.sql 싀행을 test ν”„λ‘œν•„λ‘œ μ œν•œ
KoungQ Mar 31, 2026
846bffc
feat: λ°© 지원 승인 μ‹œ roomRequest의 roomNo 일치 μ—¬λΆ€ 검증 μΆ”κ°€
KoungQ Mar 31, 2026
680c66d
refactor: DataIntegrityViolationException 처리λ₯Ό μ „μ—­ μ˜ˆμ™Έ ν•Έλ“€λŸ¬λ‘œ 톡합
KoungQ Mar 31, 2026
d99a61b
test: ν…ŒμŠ€νŠΈ μˆ˜μ •
KoungQ Mar 31, 2026
7bbf833
Merge pull request #82 from DorumDorum/feat/#81/λ™μ‹œμ„±-μ œμ–΄-보강-및-μƒνƒœ-μ •ν•©μ„±-κ°œμ„ 
KoungQ Mar 31, 2026
3ded803
fix: application update
ydking0911 Mar 31, 2026
4558295
Merge branch 'develop' into feat/#79/discord-μ‹œμŠ€ν…œ-μ•Œλ¦Ό-연동-및-coderabbitai-μ„€μ •
ydking0911 Mar 31, 2026
3287ce8
Merge pull request #83 from DorumDorum/feat/#79/discord-μ‹œμŠ€ν…œ-μ•Œλ¦Ό-연동-및-c…
ydking0911 Mar 31, 2026
dbc99b7
feat: 남은 인원 수 μ •λ ¬ κΈ°μ€€ νŽ˜μ΄μ§• κΈ°λŠ₯ μΆ”κ°€
KoungQ Apr 1, 2026
9226b88
test: skip chat integration tests when Docker is unavailable
ydking0911 Apr 1, 2026
ae15127
fix: adjust chat kick flow and unit coverage
ydking0911 Apr 1, 2026
2dca8ae
fix: align chat leave unread sync and enforce docker tests
ydking0911 Apr 1, 2026
9172399
chore: adjust dev profile and ignore mac artefacts
ydking0911 Apr 2, 2026
56e33ce
feat: broadcast chat read receipts and include unread count
ydking0911 Apr 2, 2026
9f250fa
fix: make leaveRoom JPA flush
ydking0911 Apr 2, 2026
86869e7
test: test annotation update
ydking0911 Apr 2, 2026
37dbf0b
Merge pull request #87 from DorumDorum/fix/#86/κ·Έλ£Ή-μ±„νŒ…-멀버-퇴μž₯μΆ”λ°©-μ‹œ-미읽음-μΉ΄μš΄β€¦
ydking0911 Apr 2, 2026
f6f0257
feat: 체크리슀트 λ°© 검색에 JOIN LATERAL 적용
KoungQ Apr 3, 2026
845745c
feat: remaining κΈ°μ€€ λ°© 검색 μ»€μ„œμ™€ μ •λ ¬ κΈ°μ€€ μ •ν•©μ„± 반영
KoungQ Apr 3, 2026
b985c3e
chore: 쿼리 νŠœλ‹ κ΄€λ ¨ 폴더 gitignore μΆ”κ°€
KoungQ Apr 3, 2026
ce7da30
test: λ°© 검색 μ΅œμ ν™” λ°˜μ˜ν•œ ν…ŒμŠ€νŠΈ μˆ˜μ •
KoungQ Apr 3, 2026
a8cf277
Merge branch 'develop' into feat/#85/체크리슀트-기반-λ°©-필터링-쑰회-쿼리-μ΅œμ ν™”
KoungQ Apr 3, 2026
2c0f3c0
Merge pull request #88 from DorumDorum/feat/#85/체크리슀트-기반-λ°©-필터링-쑰회-쿼리-μ΅œμ ν™”
KoungQ Apr 3, 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
80 changes: 80 additions & 0 deletions .coderabbit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
language: "ko-KR"

tone_instructions: |
μ‘΄λŒ“λ§μ„ μ‚¬μš©ν•΄μ£Όμ„Έμš”. μ½”λ“œ λ¦¬λ·°λŠ” μΉœμ ˆν•˜κ³  건섀적인 μ–΄μ‘°λ‘œ μž‘μ„±ν•΄μ£Όμ„Έμš”.
λ¬Έμ œμ μ„ 지적할 λ•ŒλŠ” μ΄μœ μ™€ κ°œμ„  λ°©ν–₯을 ν•¨κ»˜ μ œμ‹œν•΄μ£Όμ„Έμš”.

reviews:
auto_review:
enabled: true
drafts: false
profile: "assertive"
request_changes_workflow: false
high_level_summary: true
poem: false
review_status: true
collapse_walkthrough: false
path_filters:
- "!**/Q*.java"
- "!**/*MapperImpl.java"
- "!**/generated/**"
- "!**/.claude/**"
- "!**/build/**"
- "!**/*.md"

path_instructions:
- path: "BE/src/main/java/**/ui/**"
instructions: |
- Controller 넀이밍: `{동사}{도메인}Controller` (예: `CreateRoomController`)
- Controller에 `@Transactional` κΈˆμ§€. νŠΈλžœμž­μ…˜μ€ UseCase κ³„μΈ΅μ—μ„œ 처리.
- 응닡은 λ°˜λ“œμ‹œ `BaseResponse.onSuccess(data)` λ˜λŠ” `BaseResponse.onSuccess()` μ‚¬μš©.
- 인증이 ν•„μš”ν•œ μ—”λ“œν¬μΈνŠΈλŠ” `@CurrentUser` νŒŒλΌλ―Έν„° 쑴재 μ—¬λΆ€ 확인.
- μž…λ ₯κ°’ 검증에 `@Valid` 적용 μ—¬λΆ€ 확인.

- path: "BE/src/main/java/**/application/usecase/**"
instructions: |
- UseCase 넀이밍: `{동사}{도메인}UseCase` (예: `CreateRoomUseCase`)
- λ©”μ„œλ“œλͺ…은 `execute()` κ³ μ •. 단, 이벀트 λ¦¬μŠ€λ„ˆ 역할을 κ²Έν•˜λŠ” 경우 `handle()` ν—ˆμš©.
- νŠΈλžœμž­μ…˜μ€ UseCase λ‹¨μœ„μ—μ„œ `@Transactional` 적용.
- Cross-domain 의쑴 μ‹œ 이벀트 λ°œν–‰ 방식 μ‚¬μš©. 직접 도메인 μ„œλΉ„μŠ€ 호좜 μ§€μ–‘.
- `@TransactionalEventListener(phase = BEFORE_COMMIT)` μ‚¬μš© μ‹œ λΆ€λͺ¨ νŠΈλžœμž­μ…˜κ³Ό μ›μžμ„± 보μž₯ μ—¬λΆ€ 확인.

- path: "BE/src/main/java/**/domain/**"
instructions: |
- Service 넀이밍: `{도메인}Service` (예: `RoomService`)
- 도메인 λ ˆμ΄μ–΄λŠ” λ‹€λ₯Έ 도메인에 직접 μ˜μ‘΄ν•˜λ©΄ μ•ˆ 됨. 이벀트/μΈν„°νŽ˜μ΄μŠ€λ‘œ 뢄리.
- μ—”ν‹°ν‹° μƒνƒœ 변경은 도메인 λ©”μ„œλ“œλ‘œ μΊ‘μŠν™”. setter 직접 호좜 μ§€μ–‘.

- path: "BE/src/main/java/**/application/dto/**"
instructions: |
- DTOλŠ” `record` νƒ€μž… μ‚¬μš©.
- 응닡 DTO 이름: `{도메인}{λ™μž‘}Response` (예: `RoomDetailResponse`)
- μš”μ²­ DTO 이름: `{λ™μž‘}{도메인}Request` (예: `CreateRoomRequest`)

- path: "BE/src/main/java/**/infra/**"
instructions: |
- infra λ ˆμ΄μ–΄λŠ” domain μΈν„°νŽ˜μ΄μŠ€ κ΅¬ν˜„μ²΄.
- domain λ ˆμ΄μ–΄μ˜ Repository μΈν„°νŽ˜μ΄μŠ€λ₯Ό κ΅¬ν˜„ν•  λ•ŒλŠ” `Impl` 접미사 μ‚¬μš©.
- QueryDSL μ‚¬μš© μ‹œ Qν΄λž˜μŠ€λŠ” μžλ™μƒμ„±μ΄λ―€λ‘œ 직접 νŽΈμ§‘ κΈˆμ§€.

- path: "BE/src/main/java/**/global/exception/**"
instructions: |
- μ—λŸ¬ λ°œμƒ μ‹œ `throw new RestApiException({Domain}ErrorStatus.XXX)` ν˜•μ‹λ§Œ μ‚¬μš©.
- `RuntimeException` 직접 throw κΈˆμ§€.
- ErrorStatus enum은 ν•΄λ‹Ή 도메인 νŒ¨ν‚€μ§€μ˜ `code/status/` ν•˜μœ„μ— μœ„μΉ˜.

- path: "BE/src/main/java/**/global/alert/**"
instructions: |
- Discord μ•Œλ¦Όμ€ `SystemAlertPublisher.publish(...)` ν†΅ν•΄μ„œλ§Œ λ°œν–‰.
- CRITICAL: 즉각 λŒ€μ‘ ν•„μš”ν•œ μ„œλΉ„μŠ€ μž₯μ• . ERROR: κΈ°λŠ₯ μ‹€νŒ¨. WARN: 주의 ν•„μš”. INFO: μ°Έκ³  정보.

- path: "BE/src/test/**"
instructions: |
- ν…ŒμŠ€νŠΈλŠ” 성곡/μ‹€νŒ¨/경계값 μΌ€μ΄μŠ€ λͺ¨λ‘ μž‘μ„±.
- UseCase λ‹¨μœ„ ν…ŒμŠ€νŠΈ: `src/test/java/.../unit/usecase/`
- Service λ‹¨μœ„ ν…ŒμŠ€νŠΈ: `src/test/java/.../unit/service/`
- `@Transactional` λ‘€λ°± μ‚¬μš© μ‹œ μ˜λ„ λͺ…μ‹œ.
- Mock λ‚¨μš© μ§€μ–‘. 도메인 둜직 ν…ŒμŠ€νŠΈλŠ” μ‹€μ œ 객체 μ‚¬μš© ꢌμž₯.

chat:
auto_reply: true
5 changes: 5 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ jobs:
- name: Setup Gradle cache
uses: gradle/actions/setup-gradle@v4

- name: Ensure Docker is available (Testcontainers)
run: |
echo ">>> [CI] Checking Docker daemon..."
docker info

- name: Run tests
run: |
echo ">>> [CI] Running tests..."
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,8 @@ pinpoint-agent/tools/
.claude

### env ###
.env
.env
/analysis/

# macOS artefacts
.DS_Store
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ public class LoadCalendarEventsUseCase {
private final CalendarEventService calendarEventService;
private final CalendarEventMapper calendarEventMapper;

/**
* κΈ°κ°„ λ‚΄ μΊ˜λ¦°λ” 일정 쑰회
* - μ‹œμž‘μΌ~μ’…λ£ŒμΌ λ²”μœ„μ˜ 일정을 쑰회
* - 응닡 DTO λͺ©λ‘μœΌλ‘œ λ³€ν™˜ν•΄ λ°˜ν™˜
*/
public List<CalendarEventResponse> execute(LocalDate startDate, LocalDate endDate) {
List<CalendarEvent> events = calendarEventService.loadBetween(startDate, endDate);
return calendarEventMapper.toResponseList(events);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ public record ChatMessageResponse(
String senderNickname,
String content,
String messageType,
LocalDateTime sentAt
LocalDateTime sentAt,
int unreadCount
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.project.dorumdorum.domain.chat.application.dto.response;

import java.time.LocalDateTime;

public record ChatReadReceiptResponse(
String chatRoomNo,
String readerUserNo,
LocalDateTime readAt
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.project.dorumdorum.domain.chat.application.dto.response.ChatMessageResponse;
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;
import com.project.dorumdorum.domain.chat.domain.entity.MessageType;
import com.project.dorumdorum.domain.chat.domain.service.ChatMessageService;
import com.project.dorumdorum.domain.chat.domain.service.ChatRoomMemberService;
Expand All @@ -11,13 +12,15 @@
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.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.time.LocalDateTime;

@Slf4j
@Component
@RequiredArgsConstructor
public class RoommateKickedEventListener {
Expand All @@ -31,25 +34,39 @@ public class RoommateKickedEventListener {
/**
* λ£Έλ©”μ΄νŠΈ 강퇴(RoommateKickedEvent) β†’ μ±„νŒ…λ°©μ—μ„œ 퇴μž₯ 처리
* λ°œν–‰: KickRoommateUseCase (room 도메인 λ‹΄λ‹Ήμž)
*
* BEFORE_COMMIT: λΆ€λͺ¨ νŠΈλžœμž­μ…˜(KickRoommateUseCase)에 μ°Έμ—¬ν•˜μ—¬ λ°© 강퇴 + μ±„νŒ…λ°© 퇴μž₯을 ν•˜λ‚˜μ˜ νŠΈλžœμž­μ…˜μœΌλ‘œ 처리.
* μ±„νŒ…λ°© 퇴μž₯ μ‹€νŒ¨ μ‹œ λ°© 강퇴도 λ‘€λ°±λ˜μ–΄ 데이터 μ •ν•©μ„± 보μž₯.
* WebSocket λΈŒλ‘œλ“œμΊμŠ€νŠΈλŠ” νŠΈλžœμž­μ…˜ μ™ΈλΆ€ μž‘μ—…μ΄λ―€λ‘œ μ‹€νŒ¨κ°€ TX에 영ν–₯을 μ£Όμ§€ μ•Šλ„λ‘ 독립 처리.
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(RoommateKickedEvent event) {
Comment on lines +42 to 43
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:

cat -n src/main/java/com/project/dorumdorum/domain/chat/application/event/RoommateKickedEventListener.java

Repository: DorumDorum/BE

Length of output: 3864


🏁 Script executed:

rg -n "TransactionalEventListener|broadcastSafely|convertAndSend" --type java src/main/java/com/project/dorumdorum/domain/chat/

Repository: DorumDorum/BE

Length of output: 2896


🏁 Script executed:

rg -B5 -A5 "TransactionSynchronizationManager|afterCommit" --type java src/main/java/com/project/dorumdorum/ | head -50

Repository: DorumDorum/BE

Length of output: 39


🏁 Script executed:

cat -n src/main/java/com/project/dorumdorum/domain/chat/application/usecase/CreateDirectChatRoomUseCase.java

Repository: DorumDorum/BE

Length of output: 1612


🏁 Script executed:

rg -n "TransactionSynchronizationManager" --type java src/

Repository: DorumDorum/BE

Length of output: 39


🏁 Script executed:

cat -n src/main/java/com/project/dorumdorum/domain/chat/application/usecase/JoinChatRoomUseCase.java | head -85

Repository: DorumDorum/BE

Length of output: 4724


Move the WebSocket send to afterCommit.

broadcastSafely(...) is invoked from a BEFORE_COMMIT listener, so a later flush/commit failure can roll back the DB changes after clients already received the frame. Keep the DB work in this transaction, but register the broadcast for afterCommit using TransactionSynchronizationManager.registerSynchronization().

Suggested fix
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
 ...
-                broadcastSafely(chatRoom, response);
+                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                    `@Override`
+                    public void afterCommit() {
+                        broadcastSafely(chatRoom, response);
+                    }
+                });
 ...

Also applies to: 54-64

πŸ€– 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 39 - 40, The WebSocket broadcast is currently sent inside
RoommateKickedEventListener.handle() as a BEFORE_COMMIT listener (invoking
broadcastSafely(...)), which can send frames before the transaction actually
commits; change the listener to perform only transactional DB work and move the
broadcast logic into an after-commit callback by calling
TransactionSynchronizationManager.registerSynchronization(...) and registering a
TransactionSynchronization that calls broadcastSafely(...) in afterCommit;
update both the handle method (and the similar block at lines 54-64) to register
the synchronization instead of sending the frame directly so broadcasts occur
only after successful commit.

chatRoomService.findByRoomNo(event.roomNo()).ifPresent(chatRoom -> {
if (chatRoomMemberService.isMember(chatRoom, event.kickedUserNo())) {
chatRoomMemberService.leave(
chatRoomMemberService.findByChatRoomAndUserNo(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());
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), response);
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount());
broadcastSafely(chatRoom, response);
}
});
}

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
import com.project.dorumdorum.domain.chat.domain.service.ChatRoomService;
import com.project.dorumdorum.domain.room.application.event.RoomConfirmedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Service
@RequiredArgsConstructor
public class CreateChatRoomUseCase {
Expand All @@ -22,15 +23,38 @@ public class CreateChatRoomUseCase {
* λ°© 전원 ν™•μ • μ™„λ£Œ(RoomConfirmedEvent) μˆ˜μ‹  β†’ μ±„νŒ…λ°© 생성 및 전원 μž…μž₯
* - μ±„νŒ…λ°© μ—†μœΌλ©΄ 생성 ν›„ λ―Έμž…μž₯ 멀버 전원 μž…μž₯
* - μ±„νŒ…λ°© 있으면 아직 λ―Έμž…μž₯인 λ©€λ²„λ§Œ μΆ”κ°€ (RoommateAcceptedEvent둜 일뢀 이미 μž…μž₯ν–ˆμ„ 수 있음)
*
* BEFORE_COMMIT: λΆ€λͺ¨ νŠΈλžœμž­μ…˜(ConfirmRoomAssignmentUseCase)에 μ°Έμ—¬.
* μ±„νŒ…λ°© 생성 μ‹€νŒ¨ μ‹œ λ°© 확정도 λ‘€λ°± β†’ μ›μžμ„± 보μž₯.
* DataIntegrityViolationException: λ™μ‹œ μš”μ²­μœΌλ‘œ μΈν•œ 쀑볡 생성/μž…μž₯ μ‹œ 재쑰회둜 처리.
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(RoomConfirmedEvent event) {
ChatRoom chatRoom = chatRoomService.findByRoomNo(event.roomNo())
.orElseGet(() -> chatRoomService.create(event.roomNo()));
ChatRoom chatRoom = findOrCreate(event.roomNo());

event.allMemberUserNos().stream()
.filter(userNo -> !chatRoomMemberService.isMember(chatRoom, userNo))
.forEach(userNo -> chatRoomMemberService.join(chatRoom, userNo));
.forEach(userNo -> joinSafely(chatRoom, userNo));
}

private ChatRoom findOrCreate(String roomNo) {
return chatRoomService.findByRoomNo(roomNo).orElseGet(() -> {
try {
return chatRoomService.create(roomNo);
} catch (DataIntegrityViolationException e) {
log.debug("[Chat] μ±„νŒ…λ°© 쀑볡 생성 (λ™μ‹œ μš”μ²­). roomNo={}", roomNo);
return chatRoomService.findByRoomNo(roomNo)
.orElseThrow(() -> e);
}
});
}

private void joinSafely(ChatRoom chatRoom, String userNo) {
try {
chatRoomMemberService.join(chatRoom, userNo);
} catch (DataIntegrityViolationException e) {
log.debug("[Chat] μ±„νŒ…λ°© 쀑볡 μž…μž₯ (λ™μ‹œ μš”μ²­). chatRoomNo={}, userNo={}",
chatRoom.getChatRoomNo(), userNo);
}
Comment on lines +52 to +58
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:

# First, let's find the file mentioned in the review
find . -name "CreateChatRoomUseCase.java" -type f

Repository: DorumDorum/BE

Length of output: 154


🏁 Script executed:

# Also explore the general structure
find . -name "*.java" -path "*/domain/chat/*" -type f | head -20

Repository: DorumDorum/BE

Length of output: 2001


🏁 Script executed:

# Search for ChatRoomMemberService to understand available methods
find . -name "*ChatRoomMemberService*" -type f

Repository: DorumDorum/BE

Length of output: 244


🏁 Script executed:

# Read the CreateChatRoomUseCase.java file to verify the code
wc -l ./src/main/java/com/project/dorumdorum/domain/chat/application/usecase/CreateChatRoomUseCase.java

Repository: DorumDorum/BE

Length of output: 157


🏁 Script executed:

# Read lines around 52-58 to see the code in question
sed -n '45,65p' ./src/main/java/com/project/dorumdorum/domain/chat/application/usecase/CreateChatRoomUseCase.java

Repository: DorumDorum/BE

Length of output: 609


🏁 Script executed:

# Read the ChatRoomMemberService to see available methods
cat ./src/main/java/com/project/dorumdorum/domain/chat/domain/service/ChatRoomMemberService.java

Repository: DorumDorum/BE

Length of output: 2880


🏁 Script executed:

# Check for other usages of joinSafely to understand context
rg "joinSafely" -A 2 -B 2

Repository: DorumDorum/BE

Length of output: 1362


🏁 Script executed:

# Check for other direct calls to chatRoomMemberService.join() that might have similar issues
rg "chatRoomMemberService\.join" -A 2 -B 2

Repository: DorumDorum/BE

Length of output: 4402


Only ignore verified duplicate-member inserts.

The joinSafely() method catches DataIntegrityViolationException without verifying that the member was actually created. A race condition exists: between the pre-check !isMember() and the actual join() call, another thread can insert the member, causing this thread's insert to fail with a constraint violation. The exception is then silently suppressed, but the member creation never happens. Additionally, DataIntegrityViolationException can be thrown for other constraint violations beyond duplicate members, so this pattern masks unrelated failures.

Add a membership verification in the catch block to ensure the join actually succeeded before suppressing the exception:

Fix
     private void joinSafely(ChatRoom chatRoom, String userNo) {
         try {
             chatRoomMemberService.join(chatRoom, userNo);
         } catch (DataIntegrityViolationException e) {
+            if (!chatRoomMemberService.isMember(chatRoom, userNo)) {
+                throw e;
+            }
             log.debug("[Chat] μ±„νŒ…λ°© 쀑볡 μž…μž₯ (λ™μ‹œ μš”μ²­). chatRoomNo={}, userNo={}",
                     chatRoom.getChatRoomNo(), userNo);
         }
     }
πŸ€– 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/usecase/CreateChatRoomUseCase.java`
around lines 52 - 58, The catch in joinSafely currently swallows any
DataIntegrityViolationException; change it to verify the member exists before
suppressing: inside the catch for DataIntegrityViolationException (in
CreateChatRoomUseCase.joinSafely) call the membership check on
chatRoomMemberService (e.g., chatRoomMemberService.isMember or equivalent using
chatRoom.getChatRoomNo() and userNo) and only suppress/log the exception if that
check returns true (duplicate insert). If the check shows the member is still
absent, rethrow the exception (or wrap and throw a runtime/DataAccessException)
so unrelated constraint violations are not masked; keep the existing debug log
for the duplicate case.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ public class GetChatRoomMembersUseCase {
private final RoommateService roommateService;
private final UserService userService;

/**
* μ±„νŒ…λ°© 멀버 λͺ©λ‘ 쑰회
* - μš”μ²­ μ‚¬μš©μžκ°€ μ±„νŒ…λ°© 멀버인지 검증
* - 멀버별 λ‹‰λ„€μž„κ³Ό λ°©μž₯ μ—¬λΆ€λ₯Ό 쑰회
* - λ°©μž₯이 λ¨Όμ € 보이도둝 μ •λ ¬ν•΄ λ°˜ν™˜
*/
@Transactional(readOnly = true)
public List<ChatRoomMemberResponse> execute(String chatRoomNo, String requestingUserNo) {
ChatRoom chatRoom = chatRoomService.findByChatRoomNo(chatRoomNo);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ public class GetOrCreateDirectChatRoomUseCase {

/**
* μ§€μ›μž-λ°©μž₯ 1:1 μ±„νŒ…λ°© 쑰회 λ˜λŠ” 생성
* - 이미 μ‘΄μž¬ν•˜λ©΄ κΈ°μ‘΄ μ±„νŒ…λ°© λ°˜ν™˜
* - μ—†μœΌλ©΄ 생성 ν›„ λ°˜ν™˜
* - ν˜ΈμΆœμžλŠ” λ°©μž₯ λ˜λŠ” μ§€μ›μžμ—¬μ•Ό 함
* - ν˜ΈμΆœμžκ°€ λ°©μž₯ λ˜λŠ” μ§€μ›μžμΈμ§€ 검증
* - κΈ°μ‘΄ μ±„νŒ…λ°©μ΄ 있으면 κ·ΈλŒ€λ‘œ λ°˜ν™˜
* - μ—†μœΌλ©΄ 생성 ν›„ μ–‘μͺ½ μ‚¬μš©μžλ₯Ό μž…μž₯μ‹œμΌœ λ°˜ν™˜
*/
public String execute(String callerUserNo, String roomNo, String applicantUserNo) {
Room room = roomService.findById(roomNo);
Expand All @@ -46,7 +46,10 @@ public String execute(String callerUserNo, String roomNo, String applicantUserNo
}

/**
* 이벀트 λ¦¬μŠ€λ„ˆμ—μ„œ μ‚¬μš© β€” 인증 없이 μ±„νŒ…λ°© 생성 (λ°© 지원 μ‹œ μžλ™ 생성)
* μ§€μ›μž-λ°©μž₯ 1:1 μ±„νŒ…λ°© 선생성
* - 이벀트 λ¦¬μŠ€λ„ˆμ—μ„œ 인증 없이 호좜
* - κΈ°μ‘΄ μ±„νŒ…λ°©μ΄ 있으면 κ·ΈλŒ€λ‘œ λ°˜ν™˜
* - μ—†μœΌλ©΄ 생성 ν›„ μ–‘μͺ½ μ‚¬μš©μžλ₯Ό μž…μž₯μ‹œμΌœ λ°˜ν™˜
*/
public String createIfAbsent(String roomNo, String applicantUserNo, String hostUserNo) {
return chatRoomService.findDirectChatRoom(roomNo, applicantUserNo)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,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.dao.DataIntegrityViolationException;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Service
@RequiredArgsConstructor
public class JoinChatRoomUseCase {
Expand All @@ -33,28 +34,50 @@ public class JoinChatRoomUseCase {
* - μ±„νŒ…λ°© μ—†μœΌλ©΄ 생성 ν›„ λ°©μž₯ + μ‹ κ·œ 멀버 μž…μž₯
* - μ±„νŒ…λ°© 있으면 μ‹ κ·œ λ©€λ²„λ§Œ μž…μž₯ (쀑볡 λ°©μ§€)
* - μž…μž₯ μ‹œμŠ€ν…œ λ©”μ‹œμ§€ μ €μž₯
*
* BEFORE_COMMIT: λΆ€λͺ¨ νŠΈλžœμž­μ…˜(DecideApplicationRequestUseCase.approve)에 μ°Έμ—¬.
* μ±„νŒ…λ°© μž…μž₯ μ‹€νŒ¨ μ‹œ λ£Έλ©”μ΄νŠΈ μŠΉμΈλ„ λ‘€λ°± β†’ μ›μžμ„± 보μž₯.
* WebSocket λΈŒλ‘œλ“œμΊμŠ€νŠΈλŠ” νŠΈλžœμž­μ…˜ μ™ΈλΆ€ μž‘μ—…μ΄λ―€λ‘œ μ‹€νŒ¨κ°€ TX에 영ν–₯을 μ£Όμ§€ μ•Šλ„λ‘ 독립 처리.
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
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:

find . -type f -name "JoinChatRoomUseCase.java" 2>/dev/null

Repository: DorumDorum/BE

Length of output: 152


🏁 Script executed:

cat -n ./src/main/java/com/project/dorumdorum/domain/chat/application/usecase/JoinChatRoomUseCase.java

Repository: DorumDorum/BE

Length of output: 4724


🏁 Script executed:

rg "convertAndSend" --type java -B 3 -A 1

Repository: DorumDorum/BE

Length of output: 8674


🏁 Script executed:

cat -n ./src/main/java/com/project/dorumdorum/domain/chat/application/event/RoommateKickedEventListener.java

Repository: DorumDorum/BE

Length of output: 3864


🏁 Script executed:

cat -n ./src/main/java/com/project/dorumdorum/domain/chat/application/usecase/SendGroupChatMessageUseCase.java | head -60

Repository: DorumDorum/BE

Length of output: 3402


🏁 Script executed:

cat -n ./src/main/java/com/project/dorumdorum/domain/chat/application/usecase/SendGroupChatMessageUseCase.java | tail -20

Repository: DorumDorum/BE

Length of output: 912


Defer the WebSocket send to afterCommit phase.

Both JoinChatRoomUseCase and RoommateKickedEventListener run in BEFORE_COMMIT, allowing convertAndSend() to publish notifications that could roll back with the transaction. Database writes belong here, but the WebSocket broadcast must execute only after the commit succeeds. Otherwise, subscribers are notified of changes that don't exist in the database.

Use TransactionSynchronizationManager.registerSynchronization() with an afterCommit() callback to defer the broadcast:

Possible fix
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
 ...
-            broadcastSafely(chatRoom, response);
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                `@Override`
+                public void afterCommit() {
+                    broadcastSafely(chatRoom, response);
+                }
+            });
 ...
πŸ€– 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/usecase/JoinChatRoomUseCase.java`
at line 42, The WebSocket broadcast (convertAndSend()) is currently triggered in
BEFORE_COMMIT (e.g. in JoinChatRoomUseCase and RoommateKickedEventListener) and
can be rolled back; change the notification to run only after the transaction
commits by registering a synchronization via
TransactionSynchronizationManager.registerSynchronization(...). Specifically,
keep your DB write logic in the existing BEFORE_COMMIT handler but replace the
direct convertAndSend() call with a TransactionSynchronization that implements
afterCommit() (or overrides afterCommit() on a TransactionSynchronization
implementation) and performs convertAndSend() there so broadcasts only run after
successful commit.

public void handle(RoommateAcceptedEvent event) {
ChatRoom chatRoom = chatRoomService.findByRoomNo(event.roomNo())
.orElseGet(() -> {
ChatRoom created = chatRoomService.create(event.roomNo());
chatRoomMemberService.join(created, event.hostUserNo());
try {
chatRoomMemberService.join(created, event.hostUserNo());
} catch (DataIntegrityViolationException e) {
log.debug("[Chat] λ°©μž₯ 쀑볡 μž…μž₯ (λ™μ‹œ μš”μ²­). roomNo={}, hostUserNo={}",
event.roomNo(), event.hostUserNo());
}
return created;
});

if (!chatRoomMemberService.isMember(chatRoom, event.acceptedUserNo())) {
chatRoomMemberService.join(chatRoom, event.acceptedUserNo());
try {
chatRoomMemberService.join(chatRoom, event.acceptedUserNo());
} catch (DataIntegrityViolationException e) {
log.debug("[Chat] μ‹ κ·œ 멀버 쀑볡 μž…μž₯ (λ™μ‹œ μš”μ²­). roomNo={}, userNo={}",
event.roomNo(), event.acceptedUserNo());
return;
}
Comment on lines +47 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -type f -name "JoinChatRoomUseCase.java"

Repository: DorumDorum/BE

Length of output: 227


🏁 Script executed:

cat -n src/main/java/com/project/dorumdorum/domain/chat/application/usecase/JoinChatRoomUseCase.java

Repository: DorumDorum/BE

Length of output: 4724


🏁 Script executed:

fd -type f -name "*ChatRoomMember*" | head -20

Repository: DorumDorum/BE

Length of output: 227


🏁 Script executed:

rg "DataIntegrityViolationException" -A 5 -B 5 src/

Repository: DorumDorum/BE

Length of output: 43750


Re-check membership after exception to confirm duplicate, not other constraint failure.

Lines 49 and 59 catch DataIntegrityViolationException without verifying the failure reason. This exception can be raised by any constraint violation (foreign keys, other unique constraints, etc.), not just duplicate-member inserts. If a non-duplicate constraint fails, the code silently continues while the user is not actually joined, causing data inconsistency. Add a membership check after the catch block to confirm the user was already a member before suppressing the exception; if still missing, re-throw it.

Suggested fix
                 try {
                     chatRoomMemberService.join(created, event.hostUserNo());
                 } catch (DataIntegrityViolationException e) {
+                    if (!chatRoomMemberService.isMember(created, event.hostUserNo())) {
+                        throw e;
+                    }
                     log.debug("[Chat] λ°©μž₯ 쀑볡 μž…μž₯ (λ™μ‹œ μš”μ²­). roomNo={}, hostUserNo={}",
                             event.roomNo(), event.hostUserNo());
                 }
 ...
             try {
                 chatRoomMemberService.join(chatRoom, event.acceptedUserNo());
             } catch (DataIntegrityViolationException e) {
+                if (!chatRoomMemberService.isMember(chatRoom, event.acceptedUserNo())) {
+                    throw e;
+                }
                 log.debug("[Chat] μ‹ κ·œ 멀버 쀑볡 μž…μž₯ (λ™μ‹œ μš”μ²­). roomNo={}, userNo={}",
                         event.roomNo(), event.acceptedUserNo());
                 return;
             }
πŸ€– 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/usecase/JoinChatRoomUseCase.java`
around lines 47 - 63, The catch blocks in JoinChatRoomUseCase that swallow
DataIntegrityViolationException need to re-check membership before suppressing
the error: after catching the exception from chatRoomMemberService.join (both
the host join using created/event.hostUserNo() and the accepted user join using
chatRoom/event.acceptedUserNo()), call chatRoomMemberService.isMember(...) to
verify the user actually exists in the room; if isMember returns true, keep the
debug log and continue, otherwise re-throw the caught
DataIntegrityViolationException so non-duplicate constraint failures surface
instead of silently failing.

User accepted = userService.findById(event.acceptedUserNo());
String displayName = (accepted.getNickname() != null && !accepted.getNickname().isBlank())
? accepted.getNickname() : accepted.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());
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount());
broadcastSafely(chatRoom, response);
}
}

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);
}
}
}
Loading
Loading