Skip to content

Commit 3287ce8

Browse files
authored
Merge pull request #83 from DorumDorum/feat/#79/discord-시스템-알림-연동-및-coderabbitai-설정
[FEAT] Discord 시스템 알림 연동 및 CodeRabbit AI 설정
2 parents 7bbf833 + 4558295 commit 3287ce8

20 files changed

+1495
-47
lines changed

.coderabbit.yml

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
language: "ko-KR"
2+
3+
tone_instructions: |
4+
존댓말을 사용해주세요. 코드 리뷰는 친절하고 건설적인 어조로 작성해주세요.
5+
문제점을 지적할 때는 이유와 개선 방향을 함께 제시해주세요.
6+
7+
reviews:
8+
auto_review:
9+
enabled: true
10+
drafts: false
11+
profile: "assertive"
12+
request_changes_workflow: false
13+
high_level_summary: true
14+
poem: false
15+
review_status: true
16+
collapse_walkthrough: false
17+
path_filters:
18+
- "!**/Q*.java"
19+
- "!**/*MapperImpl.java"
20+
- "!**/generated/**"
21+
- "!**/.claude/**"
22+
- "!**/build/**"
23+
- "!**/*.md"
24+
25+
path_instructions:
26+
- path: "BE/src/main/java/**/ui/**"
27+
instructions: |
28+
- Controller 네이밍: `{동사}{도메인}Controller` (예: `CreateRoomController`)
29+
- Controller에 `@Transactional` 금지. 트랜잭션은 UseCase 계층에서 처리.
30+
- 응답은 반드시 `BaseResponse.onSuccess(data)` 또는 `BaseResponse.onSuccess()` 사용.
31+
- 인증이 필요한 엔드포인트는 `@CurrentUser` 파라미터 존재 여부 확인.
32+
- 입력값 검증에 `@Valid` 적용 여부 확인.
33+
34+
- path: "BE/src/main/java/**/application/usecase/**"
35+
instructions: |
36+
- UseCase 네이밍: `{동사}{도메인}UseCase` (예: `CreateRoomUseCase`)
37+
- 메서드명은 `execute()` 고정. 단, 이벤트 리스너 역할을 겸하는 경우 `handle()` 허용.
38+
- 트랜잭션은 UseCase 단위에서 `@Transactional` 적용.
39+
- Cross-domain 의존 시 이벤트 발행 방식 사용. 직접 도메인 서비스 호출 지양.
40+
- `@TransactionalEventListener(phase = BEFORE_COMMIT)` 사용 시 부모 트랜잭션과 원자성 보장 여부 확인.
41+
42+
- path: "BE/src/main/java/**/domain/**"
43+
instructions: |
44+
- Service 네이밍: `{도메인}Service` (예: `RoomService`)
45+
- 도메인 레이어는 다른 도메인에 직접 의존하면 안 됨. 이벤트/인터페이스로 분리.
46+
- 엔티티 상태 변경은 도메인 메서드로 캡슐화. setter 직접 호출 지양.
47+
48+
- path: "BE/src/main/java/**/application/dto/**"
49+
instructions: |
50+
- DTO는 `record` 타입 사용.
51+
- 응답 DTO 이름: `{도메인}{동작}Response` (예: `RoomDetailResponse`)
52+
- 요청 DTO 이름: `{동작}{도메인}Request` (예: `CreateRoomRequest`)
53+
54+
- path: "BE/src/main/java/**/infra/**"
55+
instructions: |
56+
- infra 레이어는 domain 인터페이스 구현체.
57+
- domain 레이어의 Repository 인터페이스를 구현할 때는 `Impl` 접미사 사용.
58+
- QueryDSL 사용 시 Q클래스는 자동생성이므로 직접 편집 금지.
59+
60+
- path: "BE/src/main/java/**/global/exception/**"
61+
instructions: |
62+
- 에러 발생 시 `throw new RestApiException({Domain}ErrorStatus.XXX)` 형식만 사용.
63+
- `RuntimeException` 직접 throw 금지.
64+
- ErrorStatus enum은 해당 도메인 패키지의 `code/status/` 하위에 위치.
65+
66+
- path: "BE/src/main/java/**/global/alert/**"
67+
instructions: |
68+
- Discord 알림은 `SystemAlertPublisher.publish(...)` 통해서만 발행.
69+
- CRITICAL: 즉각 대응 필요한 서비스 장애. ERROR: 기능 실패. WARN: 주의 필요. INFO: 참고 정보.
70+
71+
- path: "BE/src/test/**"
72+
instructions: |
73+
- 테스트는 성공/실패/경계값 케이스 모두 작성.
74+
- UseCase 단위 테스트: `src/test/java/.../unit/usecase/`
75+
- Service 단위 테스트: `src/test/java/.../unit/service/`
76+
- `@Transactional` 롤백 사용 시 의도 명시.
77+
- Mock 남용 지양. 도메인 로직 테스트는 실제 객체 사용 권장.
78+
79+
chat:
80+
auto_reply: true

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111
import com.project.dorumdorum.domain.user.domain.entity.User;
1212
import com.project.dorumdorum.domain.user.domain.service.UserService;
1313
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
1415
import org.springframework.messaging.simp.SimpMessagingTemplate;
1516
import org.springframework.stereotype.Component;
16-
import org.springframework.transaction.annotation.Propagation;
17-
import org.springframework.transaction.annotation.Transactional;
1817
import org.springframework.transaction.event.TransactionPhase;
1918
import org.springframework.transaction.event.TransactionalEventListener;
2019

20+
@Slf4j
2121
@Component
2222
@RequiredArgsConstructor
2323
public class RoommateKickedEventListener {
@@ -31,9 +31,12 @@ public class RoommateKickedEventListener {
3131
/**
3232
* 룸메이트 강퇴(RoommateKickedEvent) → 채팅방에서 퇴장 처리
3333
* 발행: KickRoommateUseCase (room 도메인 담당자)
34+
*
35+
* BEFORE_COMMIT: 부모 트랜잭션(KickRoommateUseCase)에 참여하여 방 강퇴 + 채팅방 퇴장을 하나의 트랜잭션으로 처리.
36+
* 채팅방 퇴장 실패 시 방 강퇴도 롤백되어 데이터 정합성 보장.
37+
* WebSocket 브로드캐스트는 트랜잭션 외부 작업이므로 실패가 TX에 영향을 주지 않도록 독립 처리.
3438
*/
35-
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
36-
@Transactional(propagation = Propagation.REQUIRES_NEW)
39+
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
3740
public void handle(RoommateKickedEvent event) {
3841
chatRoomService.findByRoomNo(event.roomNo()).ifPresent(chatRoom -> {
3942
if (chatRoomMemberService.isMember(chatRoom, event.kickedUserNo())) {
@@ -48,8 +51,16 @@ public void handle(RoommateKickedEvent event) {
4851
ChatMessageResponse response = new ChatMessageResponse(
4952
message.getMessageNo(), chatRoom.getChatRoomNo(),
5053
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt());
51-
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), response);
54+
broadcastSafely(chatRoom, response);
5255
}
5356
});
5457
}
58+
59+
private void broadcastSafely(ChatRoom chatRoom, ChatMessageResponse response) {
60+
try {
61+
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), response);
62+
} catch (Exception e) {
63+
log.warn("[Chat] WebSocket 브로드캐스트 실패. chatRoomNo={}", chatRoom.getChatRoomNo(), e);
64+
}
65+
}
5566
}

src/main/java/com/project/dorumdorum/domain/chat/application/usecase/CreateChatRoomUseCase.java

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
import com.project.dorumdorum.domain.chat.domain.service.ChatRoomService;
66
import com.project.dorumdorum.domain.room.application.event.RoomConfirmedEvent;
77
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.dao.DataIntegrityViolationException;
810
import org.springframework.stereotype.Service;
9-
import org.springframework.transaction.annotation.Propagation;
10-
import org.springframework.transaction.annotation.Transactional;
1111
import org.springframework.transaction.event.TransactionPhase;
1212
import org.springframework.transaction.event.TransactionalEventListener;
1313

14+
@Slf4j
1415
@Service
1516
@RequiredArgsConstructor
1617
public class CreateChatRoomUseCase {
@@ -22,15 +23,38 @@ public class CreateChatRoomUseCase {
2223
* 방 전원 확정 완료(RoomConfirmedEvent) 수신 → 채팅방 생성 및 전원 입장
2324
* - 채팅방 없으면 생성 후 미입장 멤버 전원 입장
2425
* - 채팅방 있으면 아직 미입장인 멤버만 추가 (RoommateAcceptedEvent로 일부 이미 입장했을 수 있음)
26+
*
27+
* BEFORE_COMMIT: 부모 트랜잭션(ConfirmRoomAssignmentUseCase)에 참여.
28+
* 채팅방 생성 실패 시 방 확정도 롤백 → 원자성 보장.
29+
* DataIntegrityViolationException: 동시 요청으로 인한 중복 생성/입장 시 재조회로 처리.
2530
*/
26-
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
27-
@Transactional(propagation = Propagation.REQUIRES_NEW)
31+
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
2832
public void handle(RoomConfirmedEvent event) {
29-
ChatRoom chatRoom = chatRoomService.findByRoomNo(event.roomNo())
30-
.orElseGet(() -> chatRoomService.create(event.roomNo()));
33+
ChatRoom chatRoom = findOrCreate(event.roomNo());
3134

3235
event.allMemberUserNos().stream()
3336
.filter(userNo -> !chatRoomMemberService.isMember(chatRoom, userNo))
34-
.forEach(userNo -> chatRoomMemberService.join(chatRoom, userNo));
37+
.forEach(userNo -> joinSafely(chatRoom, userNo));
38+
}
39+
40+
private ChatRoom findOrCreate(String roomNo) {
41+
return chatRoomService.findByRoomNo(roomNo).orElseGet(() -> {
42+
try {
43+
return chatRoomService.create(roomNo);
44+
} catch (DataIntegrityViolationException e) {
45+
log.debug("[Chat] 채팅방 중복 생성 (동시 요청). roomNo={}", roomNo);
46+
return chatRoomService.findByRoomNo(roomNo)
47+
.orElseThrow(() -> e);
48+
}
49+
});
50+
}
51+
52+
private void joinSafely(ChatRoom chatRoom, String userNo) {
53+
try {
54+
chatRoomMemberService.join(chatRoom, userNo);
55+
} catch (DataIntegrityViolationException e) {
56+
log.debug("[Chat] 채팅방 중복 입장 (동시 요청). chatRoomNo={}, userNo={}",
57+
chatRoom.getChatRoomNo(), userNo);
58+
}
3559
}
3660
}

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

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@
1111
import com.project.dorumdorum.domain.user.domain.entity.User;
1212
import com.project.dorumdorum.domain.user.domain.service.UserService;
1313
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.dao.DataIntegrityViolationException;
1416
import org.springframework.messaging.simp.SimpMessagingTemplate;
1517
import org.springframework.stereotype.Service;
16-
import org.springframework.transaction.annotation.Propagation;
17-
import org.springframework.transaction.annotation.Transactional;
1818
import org.springframework.transaction.event.TransactionPhase;
1919
import org.springframework.transaction.event.TransactionalEventListener;
2020

21+
@Slf4j
2122
@Service
2223
@RequiredArgsConstructor
2324
public class JoinChatRoomUseCase {
@@ -29,23 +30,37 @@ public class JoinChatRoomUseCase {
2930
private final SimpMessagingTemplate messagingTemplate;
3031

3132
/**
32-
* 룸메이트 승인 이벤트 처리
33-
* - 그룹 채팅방이 없으면 생성하고 방장을 먼저 입장
34-
* - 승인된 사용자를 중복 없이 입장 처리
35-
* - 입장 시스템 메시지를 저장하고 실시간 전송
33+
* 방장이 룸메이트 승인(RoommateAcceptedEvent) → 채팅방 입장
34+
* - 채팅방 없으면 생성 후 방장 + 신규 멤버 입장
35+
* - 채팅방 있으면 신규 멤버만 입장 (중복 방지)
36+
* - 입장 시스템 메시지 저장
37+
*
38+
* BEFORE_COMMIT: 부모 트랜잭션(DecideApplicationRequestUseCase.approve)에 참여.
39+
* 채팅방 입장 실패 시 룸메이트 승인도 롤백 → 원자성 보장.
40+
* WebSocket 브로드캐스트는 트랜잭션 외부 작업이므로 실패가 TX에 영향을 주지 않도록 독립 처리.
3641
*/
37-
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
38-
@Transactional(propagation = Propagation.REQUIRES_NEW)
42+
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
3943
public void handle(RoommateAcceptedEvent event) {
4044
ChatRoom chatRoom = chatRoomService.findByRoomNo(event.roomNo())
4145
.orElseGet(() -> {
4246
ChatRoom created = chatRoomService.create(event.roomNo());
43-
chatRoomMemberService.join(created, event.hostUserNo());
47+
try {
48+
chatRoomMemberService.join(created, event.hostUserNo());
49+
} catch (DataIntegrityViolationException e) {
50+
log.debug("[Chat] 방장 중복 입장 (동시 요청). roomNo={}, hostUserNo={}",
51+
event.roomNo(), event.hostUserNo());
52+
}
4453
return created;
4554
});
4655

4756
if (!chatRoomMemberService.isMember(chatRoom, event.acceptedUserNo())) {
48-
chatRoomMemberService.join(chatRoom, event.acceptedUserNo());
57+
try {
58+
chatRoomMemberService.join(chatRoom, event.acceptedUserNo());
59+
} catch (DataIntegrityViolationException e) {
60+
log.debug("[Chat] 신규 멤버 중복 입장 (동시 요청). roomNo={}, userNo={}",
61+
event.roomNo(), event.acceptedUserNo());
62+
return;
63+
}
4964
User accepted = userService.findById(event.acceptedUserNo());
5065
String displayName = (accepted.getNickname() != null && !accepted.getNickname().isBlank())
5166
? accepted.getNickname() : accepted.getName();
@@ -54,7 +69,15 @@ public void handle(RoommateAcceptedEvent event) {
5469
ChatMessageResponse response = new ChatMessageResponse(
5570
message.getMessageNo(), chatRoom.getChatRoomNo(),
5671
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt());
72+
broadcastSafely(chatRoom, response);
73+
}
74+
}
75+
76+
private void broadcastSafely(ChatRoom chatRoom, ChatMessageResponse response) {
77+
try {
5778
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), response);
79+
} catch (Exception e) {
80+
log.warn("[Chat] WebSocket 브로드캐스트 실패. chatRoomNo={}", chatRoom.getChatRoomNo(), e);
5881
}
5982
}
6083
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.project.dorumdorum.global.alert;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.data.redis.core.StringRedisTemplate;
5+
import org.springframework.stereotype.Service;
6+
7+
import java.time.Duration;
8+
9+
/**
10+
* 동일한 알림이 짧은 시간 내에 반복 발송되는 것을 방지하는 서비스.
11+
* Redis TTL 기반으로 중복 여부를 판단한다.
12+
*/
13+
@Service
14+
@RequiredArgsConstructor
15+
public class AlertDeduplicationService {
16+
17+
private static final String KEY_PREFIX = "alert:dedup:";
18+
19+
private final StringRedisTemplate redisTemplate;
20+
21+
/**
22+
* 해당 알림이 중복인지 확인하고, 중복이 아니면 TTL을 설정한다.
23+
*
24+
* @return true이면 중복 — 발송 건너뜀. false이면 신규 — 발송 진행.
25+
*/
26+
public boolean isDuplicate(SystemAlert alert) {
27+
String key = buildKey(alert);
28+
Duration ttl = resolveTtl(alert.severity());
29+
Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, "1", ttl);
30+
return !Boolean.TRUE.equals(isNew);
31+
}
32+
33+
private String buildKey(SystemAlert alert) {
34+
return KEY_PREFIX + alert.severity() + ":" + Math.abs(alert.title().hashCode());
35+
}
36+
37+
private Duration resolveTtl(AlertSeverity severity) {
38+
return switch (severity) {
39+
case CRITICAL -> Duration.ofSeconds(60);
40+
case ERROR -> Duration.ofSeconds(300);
41+
case WARN -> Duration.ofSeconds(900);
42+
case INFO -> Duration.ofSeconds(1800);
43+
};
44+
}
45+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.project.dorumdorum.global.alert;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.context.event.ContextClosedEvent;
5+
import org.springframework.context.event.EventListener;
6+
import org.springframework.boot.context.event.ApplicationReadyEvent;
7+
import org.springframework.stereotype.Component;
8+
9+
@Component
10+
@RequiredArgsConstructor
11+
public class ApplicationLifecycleAlertListener {
12+
13+
private final SystemAlertPublisher systemAlertPublisher;
14+
15+
@EventListener(ApplicationReadyEvent.class)
16+
public void onApplicationReady() {
17+
systemAlertPublisher.publish(
18+
AlertSeverity.INFO,
19+
AlertType.DEPLOYMENT,
20+
"[배포] 서버 시작 완료",
21+
"dorumdorum 서버가 정상 시작되었습니다."
22+
);
23+
}
24+
25+
@EventListener(ContextClosedEvent.class)
26+
public void onContextClosed() {
27+
systemAlertPublisher.publish(
28+
AlertSeverity.WARN,
29+
AlertType.DEPLOYMENT,
30+
"[배포] 서버 종료",
31+
"dorumdorum 서버가 종료되고 있습니다."
32+
);
33+
}
34+
}

src/main/java/com/project/dorumdorum/global/alert/DiscordAlertEventListener.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.project.dorumdorum.global.alert;
22

33
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
45
import org.springframework.scheduling.annotation.Async;
56
import org.springframework.stereotype.Component;
67
import org.springframework.transaction.event.TransactionPhase;
78
import org.springframework.transaction.event.TransactionalEventListener;
89

10+
@Slf4j
911
@Component
1012
@RequiredArgsConstructor
1113
public class DiscordAlertEventListener {
@@ -18,6 +20,10 @@ public class DiscordAlertEventListener {
1820
fallbackExecution = true
1921
)
2022
public void handle(SystemAlertEvent event) {
21-
discordAlertSender.send(event.alert());
23+
try {
24+
discordAlertSender.send(event.alert());
25+
} catch (Exception e) {
26+
log.error("[Alert] Discord 전송 실패 (Async). title={}", event.alert().title(), e);
27+
}
2228
}
2329
}

0 commit comments

Comments
 (0)