Skip to content

Conversation

@momuzzi
Copy link
Member

@momuzzi momuzzi commented Oct 8, 2025

⚒️develop의 최신 커밋을 pull 받았나요?

#️⃣ 작업 내용

어떤 기능을 구현했나요?
기존 기능에서 어떤 점이 달라졌나요?
자세한 로직이 필요하다면 함께 적어주세요!
코드에 대한 설명이라면, 코맨트를 통해서 어떤 부분이 어떤 코드인지 설명해주세요!

채팅 구현

Redis 사용 전략

  1. 웹소켓 연결, 채팅방 참여 정보를 redis에서 관리
  • 소켓 연결 시 (sessionId, clientId) 매핑
  • 채팅방 stomp 구독 시 (sessionId, chatRoomId) 매핑
  • 채팅방 stomp 구독 시 (sessionId, clientId) 매핑
  • 채팅방 stomp 구독 시 chatRoomId Set에 clientId 추가 (푸시 알림 전송 대상 필터로 사용)
  1. Member의 nickname, persona를 redis에 캐싱 TTL 1시간
  • 사용자가 기본정보 수정 시 redis 캐싱
  • 채팅 전송 or 채팅 조회 시 Look Aside Cache 전략으로 처리
  1. Redis Pub/Sub 활용
  • 해당 서버 프로세스 내 한명이라도 특정 채팅방에 접속 중이라면, 서버 프로세스는 Redis의 Topic chatroom:{chatrooId} 를 구독
  • 한명도 접속 중이지 않는 채팅방에 대한 Topic은 구독 x
  • 단일 서버에서 필요 없긴합니다

수정된 람다 코드(send_each() 추가) (배포되면 변경 예정)

import json
import time
import boto3
import firebase_admin as admin
from firebase_admin import credentials, messaging

# Firebase Admin 초기화
cred = credentials.Certificate('fire-base-key.json')
admin.initialize_app(cred)

# DLQ SQS 클라이언트 설정
sqs = boto3.client('sqs')
DLQ_URL = ''

TRY_CNT = 2
RETRY_DELAY = 1

INVALID_ERROR_KEYWORDS = [
    "The registration token is not a valid FCM registration token",
    "InvalidRegistration",
    "NotRegistered"
]

def lambda_handler(event, context):
    retry_payloads = []
    invalid_payloads = []

    # SQS에서 받은 메시지 단위 반복
    for record in event['Records']:
        payload_list = json.loads(record['body'])

        # 채팅용 대량 메시지는 send_each 사용
        if len(payload_list) > 1:
            result = attempt_send_batch(payload_list, 1)
        else:
            result = attempt_send_list(payload_list, 1)

        retry_payloads.extend(result['retry'])
        invalid_payloads.extend(result['invalid'])

    # 실패시 재시도
    for round_num in range(2, TRY_CNT + 1):
        if not retry_payloads:
            break

        time.sleep(RETRY_DELAY * (round_num - 1))

        if len(retry_payloads) > 1:
            result = attempt_send_batch(retry_payloads, round_num)
        else:
            result = attempt_send_list(retry_payloads, round_num)

        retry_payloads = result['retry']
        invalid_payloads.extend(result['invalid'])

    # DLQ 전송
    if invalid_payloads:
        invalid_tokens_payloads = [{"deviceToken": p["deviceToken"]} for p in invalid_payloads]
        body = json.dumps({"failedPayloads": invalid_tokens_payloads})
        sqs.send_message(QueueUrl=DLQ_URL, MessageBody=body)
        print(f"🚨 DLQ 전송 완료 ({len(invalid_payloads)}건)")

    return {
        'statusCode': 200,
        'body': f"알림 처리 완료 (DLQ {len(invalid_payloads)}건)"
    }

def attempt_send_list(payload_list, round_num):
    retry = []
    invalid = []

    for payload in payload_list:
        try:
            send_notification(payload)
            print(f"✅ 성공 (시도 {round_num}): {payload['deviceToken']}")
        except Exception as e:
            error_msg = str(e)
            print(f"❌ 실패 (시도 {round_num}): {payload['deviceToken']}, 이유: {error_msg}")

            if any(keyword in error_msg for keyword in INVALID_ERROR_KEYWORDS):
                invalid.append(payload)
            else:
                retry.append(payload)

    return {'retry': retry, 'invalid': invalid}

def attempt_send_batch(payload_list, round_num):
    retry = []
    invalid = []

    # FCM 메시지 변환
    messages = []
    for payload in payload_list:
        data = {}
        if payload.get('memberId'): data['memberId'] = payload['memberId']
        if payload.get('chatRoomId'): data['chatRoomId'] = payload['chatRoomId']
        if payload.get('roomId'): data['roomId'] = payload['roomId']
        if payload.get('actionType'): data['actionType'] = payload['actionType']

        messages.append(messaging.Message(
            notification=messaging.Notification(
                title=payload['title'],
                body=payload['body']
            ),
            token=payload['deviceToken'],
            data=data
        ))

    # FCM에 한번에 전송 (최대 500개)
    batch_response = messaging.send_each(messages)

    # 결과 처리
    for i, resp in enumerate(batch_response.responses):
        payload = payload_list[i]
        if resp.success:
            print(f"✅ 성공 (시도 {round_num}): {payload['deviceToken']}")
        else:
            error_msg = str(resp.exception)
            print(f"❌ 실패 (시도 {round_num}): {payload['deviceToken']}, 이유: {error_msg}")
            if any(keyword in error_msg for keyword in INVALID_ERROR_KEYWORDS):
                invalid.append(payload)
            else:
                retry.append(payload)

    return {'retry': retry, 'invalid': invalid}


def send_notification(payload):
    data = {}
    if payload.get('memberId'): data['memberId'] = payload['memberId']
    if payload.get('chatRoomId'): data['chatRoomId'] = payload['chatRoomId']
    if payload.get('messageRoomId'): data['messageRoomId'] = payload['messageRoomId']
    if payload.get('roomId'): data['roomId'] = payload['roomId']
    if payload.get('actionType'): data['actionType'] = payload['actionType']

    message = messaging.Message(
        notification=messaging.Notification(
            title=payload['title'],
            body=payload['body']
        ),
        token=payload['deviceToken'],
        data=data
    )
    return messaging.send(message)
  • nginx
image 클라이언트의 WebSocket 연결 요청을 백엔드로 전달하기 위해 location /ws 블록을 추가했습니다.

동작 확인

기능을 실행했을 때 정상 동작하는지 여부를 확인하고 스샷을 올려주세요


  1. 채팅방 목록 조회
image
  1. 채팅 내역없는 채팅방 입장
image
  1. 채팅 내역 있는 채팅방 입장
image image
  1. 가장 마지막 채팅의 chatId와 createdAt으로 이전 채팅 조회
image
  1. 알림 관련
    채팅방에 5명 존재
image

채팅방에 현재 2명 접속
image
image

한명이 채팅을 보내면 나머지 3명에게 알림 전송
image

미접속 사용자 중 한명이 알림 수신 off
image

2명에게 알림 전송
image


  1. 사용자가 닉네임 or 페르소나 수정

수정 전
image

수정 후
image


  1. member2가 채팅 전송
image

7-2 member2가 전송한 채팅이 @Valid 제약에 걸림
image


  1. 없는 채팅방을 stomp subscribe 하려는 경우
image

8-2 stomp subscribe 시 사용자 조회가 안되는 경우
image

8-3 다른 대학 기숙사 채팅방을 stomp subscribe 하려는 경우
image

💬 리뷰 요구사항(선택)

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요
고민사항도 적어주세요.

Summary by CodeRabbit

  • 신기능

    • 실시간 채팅(웹소켓) 전송, 채팅방 목록/입장, 이전 메시지 조회(페이지네이션)
    • 채팅 푸시: 오프라인 대상 배치 알림 전송 및 SQS 연동
  • 개선

    • WebSocket 인증·오류 피드백 및 세션·구독 관리 강화
    • 멤버 캐시 도입으로 채팅 표시 성능 향상
    • 채팅 알림 전송 신뢰성(재시도·배치) 보완
  • 작업

    • 웹소켓, MongoDB, 재시도·AOP 등 런타임 의존성 및 트랜잭션/직렬화 설정 추가

@momuzzi momuzzi self-assigned this Oct 8, 2025
@momuzzi momuzzi added the enhancement New feature or request label Oct 8, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 20

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/cozymate/cozymate_server/domain/notificationlog/enums/NotificationType.java (1)

302-314: NotificationLogCreateDTO에 chatRoomId 필드 및 getter 추가 후 ARRIVE_CHAT 로직 수정

  • src/main/java/com/cozymate/cozymate_server/domain/notificationlog/dto/NotificationLogCreateDTO.java: private Long chatRoomId 추가, 생성자·@builder·@Getter에 반영
  • src/main/java/com/cozymate/cozymate_server/domain/notificationlog/enums/NotificationType.java (ARRIVE_CHAT): generateNotificationLog에서 createDTO.getChatRoomId() 사용하도록 변경
🧹 Nitpick comments (23)
src/main/java/com/cozymate/cozymate_server/domain/chat/dto/redis/ChatPubDTO.java (1)

13-14: createdAt 시간 차이에 대한 문서화 개선

Line 13의 주석에서 "DB에 저장될 createdAt와 약간의 오차 있음"이라고 명시되어 있습니다. 이 시간 차이가:

  1. Redis Pub 시점과 MongoDB 저장 시점의 차이인지
  2. 비즈니스 로직상 허용 가능한 수준인지
  3. 클라이언트에 영향을 미치는지

명확히 문서화하는 것이 좋습니다. 또한 Line 14의 "pub sequence는 0 고정" 주석도 이유를 추가하면 유지보수에 도움이 됩니다.

src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoomMember.java (1)

28-32: 외래 키 제약 조건 추가를 고려하세요.

chatRoommember 필드에 @JoinColumn을 추가하여 외래 키 이름과 제약 조건을 명시하는 것이 좋습니다. 특히 nullable = false를 설정하여 데이터 무결성을 보장할 수 있습니다.

예시:

 @ManyToOne(fetch = FetchType.LAZY)
+@JoinColumn(name = "chat_room_id", nullable = false)
 private ChatRoom chatRoom;

 @ManyToOne(fetch = FetchType.LAZY)
+@JoinColumn(name = "member_id", nullable = false)
 private Member member;
src/main/java/com/cozymate/cozymate_server/global/redispubsub/event/StompSubEvent.java (1)

3-5: 필드 검증 추가를 고려하세요.

chatRoomId가 null이거나 유효하지 않은 값일 경우를 방지하기 위해 @NotNull 또는 생성자에서 검증을 추가하는 것을 권장합니다. 이벤트 처리 중 NPE를 방지할 수 있습니다.

예시:

import jakarta.validation.constraints.NotNull;

public record StompSubEvent(
    @NotNull Long chatRoomId
) {
}
src/main/java/com/cozymate/cozymate_server/domain/member/converter/MemberConverter.java (1)

96-108: 하드코딩 제거 및 도메인 의미 확인 필요

  • "(알수없음)" 문자열, persona=1은 매직값입니다. 의미를 명확히 하기 위해 상수로 추출해 주세요.
  • persona=1이 “탈퇴/미상”을 의미하는 공식 값인지 확인이 필요합니다. 아니라면 캐시에서 null 허용 또는 전용 sentinel을 고려해 주세요.
  • 입력값(nickname/persona)에 대한 유효성(예: 1~16 범위) 보강 또는 상위 레이어에서 보장해 주세요.

적용 예(해당 메서드 내부 치환):

-            .nickname("(알수없음)")
-            .persona(1)
+            .nickname(UNKNOWN_NICKNAME)
+            .persona(UNKNOWN_PERSONA)

클래스 상단에 상수 추가(예시):

private static final String UNKNOWN_NICKNAME = "(알수없음)";
private static final int UNKNOWN_PERSONA = 1; // 도메인 의미 확인 필요
src/main/java/com/cozymate/cozymate_server/global/redispubsub/config/RedisPubSubConfig.java (2)

18-21: MessageListenerAdapter 기본 메서드 지정 확인

Spring Data Redis 기본값은 "handleMessage"입니다. RedisPubSubListener가 onMessage(Message, byte[])만 구현했다면 호출 불일치로 런타임 문제 가능성이 있습니다. 명시 지정 또는 어댑터 미사용(직접 MessageListener 등록) 중 하나를 권장합니다.

-        return new MessageListenerAdapter(redisPubSubListener);
+        return new MessageListenerAdapter(redisPubSubListener, "onMessage");

24-28: 컨테이너에 리스너/토픽 등록 경로 확인 및 에러 핸들러 권장

현재 컨테이너는 커넥션만 설정합니다. 런타임에 RedisSubscriberManager 등에서 addMessageListener(adapter, topic)을 호출하는지 확인 부탁드립니다. 또한, 리스너 예외가 삼켜지지 않도록 ErrorHandler 설정을 권장합니다.

예시:

container.setErrorHandler(t -> /* 로깅/모니터링 */ );
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1)

10-23: 조회 성능을 위한 복합 인덱스 제안(chatRoomId, createdAt, sequence)

findChatsInRange 패턴에 맞춰 복합 인덱스를 추가하면 스캔을 줄일 수 있습니다.

+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
@@
-@Document("chat")
+@CompoundIndexes({
+    @CompoundIndex(name = "chat_room_time_seq",
+        def = "{'chatRoomId': 1, 'createdAt': 1, 'sequence': 1}")
+})
+@Document("chat")
 public class Chat {
src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepositoryService.java (2)

16-18: 저장 결과 반환 고려

식별자/생성시간 등 후속 처리에 유용하므로 저장 결과를 반환하는 편이 일반적입니다.

-    public void saveChat(Chat chat) {
-        chatRepository.save(chat);
-    }
+    public Chat saveChat(Chat chat) {
+        return chatRepository.save(chat);
+    }

20-24: PageRequest 대신 Pageable 수용으로 API 유연성 향상

서비스 계층에서 PageRequest에 고정될 필요가 없습니다. Pageable을 받으면 호출자 선택지를 넓힐 수 있습니다.

-    public List<Chat> getChatListByRange(Long chatRoomId, LocalDateTime enterTime,
-        LocalDateTime lastChatTime, Long sequence, PageRequest pageRequest) {
+    public List<Chat> getChatListByRange(Long chatRoomId, LocalDateTime enterTime,
+        LocalDateTime lastChatTime, Long sequence, Pageable pageable) {
-        return chatRepository.findChatsInRange(chatRoomId, enterTime, lastChatTime, sequence,
-            pageRequest);
+        return chatRepository.findChatsInRange(chatRoomId, enterTime, lastChatTime, sequence,
+            pageable);
     }
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (1)

391-401: 배치 구간 계산 단순화(가독성)

end 계산을 Math.min으로 단순화하면 가독성이 좋아집니다.

-                    for (int start = 0; start < fcmSqsMessageList.size(); start += BATCH_SIZE) {
-                        int end = start + BATCH_SIZE;
-
-                        if (end > fcmSqsMessageList.size()) {
-                            end = fcmSqsMessageList.size();
-                        }
-
-                        batchList.add(fcmSqsMessageList.subList(start, end));
-                    }
+                    for (int start = 0; start < fcmSqsMessageList.size(); start += BATCH_SIZE) {
+                        int end = Math.min(start + BATCH_SIZE, fcmSqsMessageList.size());
+                        batchList.add(fcmSqsMessageList.subList(start, end));
+                    }
src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepository.java (1)

12-26: 조회 정렬 보장 및 색인 권고

  • 현재 쿼리는 정렬이 명시되지 않아 페이지네이션 시 순서가 비결정적일 수 있습니다(특히 동일 createdAt에서 sequence로 2차 정렬 필요).
  • 정렬을 쿼리에 명시하고, 복합 인덱스를 추가하는 것을 권장합니다.
-    @Query("{"+
+    @Query(value = "{"+
         "'chatRoomId': ?0," +
         "$or: [" +
         "{ 'createdAt': { $gt: ?1, $lt: ?2 } }," +
         "{ 'createdAt': ?2, 'sequence': { $lt: ?3 } }" +
         "]" +
-        "}")
+        "}",
+        sort = "{ 'createdAt': 1, 'sequence': 1 }")
     List<Chat> findChatsInRange(

운영 조언:

  • Mongo에 { chatRoomId: 1, createdAt: 1, sequence: 1 } 복합 인덱스를 추가해 주세요. 대화방별 범위 조회/정렬에 필수입니다.
src/main/java/com/cozymate/cozymate_server/domain/chat/controller/ChatController.java (2)

35-40: Swagger 문서용 더미 엔드포인트의 목적을 명확히 하세요.

이 엔드포인트가 Swagger 문서 생성을 위한 더미 엔드포인트임을 코드에 명시하는 것이 좋습니다. 실제 WebSocket을 통해 메시지를 보내야 함을 개발자가 명확히 인지할 수 있도록 주석이나 설명을 보강하세요.

+    // Note: 실제 채팅 전송은 WebSocket STOMP를 통해 /pub/chats로 전송해야 합니다.
+    // 이 엔드포인트는 Swagger 문서 생성을 위한 참고용입니다.
     @Operation(summary = "[베로] 채팅 전송 (docs)", description = "WebSocket STOMP로 채팅 전송")
     @GetMapping("/pub/chats")

42-51: 페이지네이션 파라미터 검증을 고려하세요.

lastChatTimesequence가 필수 파라미터이지만, null이거나 잘못된 값(예: 음수 sequence)에 대한 검증이 없습니다. 서비스 레이어에서 검증하더라도 컨트롤러 레벨에서 기본적인 검증을 추가하는 것을 권장합니다.

     @GetMapping("/chats/chatrooms/{chatRoomId}")
     public ResponseEntity<ApiResponse<ChatListResponseDTO>> getChatList(
         @AuthenticationPrincipal MemberDetails memberDetails, @PathVariable Long chatRoomId,
-        @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastChatTime,
-        @RequestParam Long sequence) {
+        @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) @NotNull LocalDateTime lastChatTime,
+        @RequestParam @NotNull @Min(0) Long sequence) {
src/main/java/com/cozymate/cozymate_server/domain/chatroom/controller/ChatRoomController.java (1)

39-48: 채팅방 입장 엔드포인트의 HTTP 메서드를 재고하세요.

채팅방 입장이 멤버십을 생성하거나 상태를 변경하는 경우 POST가 적절하지만, 단순히 최근 채팅을 조회하는 것이라면 GET이 더 의미적으로 맞을 수 있습니다. enterChatRoom의 부수 효과(멤버 추가, 참가자 수 증가 등)를 확인하세요.

부수 효과가 없다면 GET으로 변경을 고려하세요:

-    @PostMapping("/{chatRoomId}")
+    @GetMapping("/{chatRoomId}/enter")
     public ResponseEntity<ApiResponse<ChatListResponseDTO>> enterChatRoom(
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketHandshakeInterceptor.java (1)

39-45: JWT 검증 예외 처리를 구체화하세요.

Line 41에서 모든 예외를 포괄적으로 처리하고 있습니다. JWT 검증 실패의 구체적인 원인(만료, 서명 오류 등)을 구분하여 로깅하면 디버깅에 도움이 됩니다.

             try {
                 jwtUtil.validateToken(jwt);
-            } catch (Exception e) {
-                log.info("WebSocketHandShake 전 jwt 인증 실패");
+            } catch (io.jsonwebtoken.ExpiredJwtException e) {
+                log.info("WebSocketHandShake 전 jwt 만료: {}", e.getMessage());
+                response.setStatusCode(HttpStatus.UNAUTHORIZED);
+                return false;
+            } catch (io.jsonwebtoken.JwtException e) {
+                log.info("WebSocketHandShake 전 jwt 검증 실패: {}", e.getMessage());
+                response.setStatusCode(HttpStatus.UNAUTHORIZED);
+                return false;
+            } catch (Exception e) {
+                log.error("WebSocketHandShake 전 예상치 못한 오류", e);
                 response.setStatusCode(HttpStatus.UNAUTHORIZED);
                 return false;
             }
src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (1)

34-34: LocalDateTime.now() 사용이 테스트 가능성을 저해합니다.

변환기에서 LocalDateTime.now()를 직접 호출하면 단위 테스트 시 시간 제어가 어렵습니다. 시간을 파라미터로 받거나, Clock을 주입하는 것을 권장합니다.

-    public static ChatPubDTO toChatPubDTO(CreateChatRequestDTO createChatRequestDTO,
-        MemberCachingDTO memberCachingDTO) {
+    public static ChatPubDTO toChatPubDTO(CreateChatRequestDTO createChatRequestDTO,
+        MemberCachingDTO memberCachingDTO, LocalDateTime createdAt) {
         return ChatPubDTO.builder()
             .chatRoomId(createChatRequestDTO.chatRoomId())
             .persona(memberCachingDTO.persona())
             .memberId(createChatRequestDTO.memberId())
             .nickname(memberCachingDTO.nickname())
             .content(createChatRequestDTO.content())
-            .createdAt(LocalDateTime.now())
+            .createdAt(createdAt)
             .sequence(0l)
             .build();
     }
src/main/java/com/cozymate/cozymate_server/domain/sqs/service/SQSMessageCreator.java (1)

245-246: 메서드 시그니처 변경이 불필요하게 줄바꿈되었습니다.

Line 245-246에서 메서드 파라미터가 변경되면서 불필요하게 줄바꿈된 것으로 보입니다. 일관성을 위해 다른 메서드와 동일한 스타일을 유지하세요.

     private SQSMessageResult getMessageResultWithMessageRoomId(List<Fcm> fcmList, String content,
-        NotificationType notificationType, NotificationLog notificationLog,
-        MessageRoom messageRoom) {
+        NotificationType notificationType, NotificationLog notificationLog, MessageRoom messageRoom) {
src/main/java/com/cozymate/cozymate_server/domain/chatroom/repository/ChatRoomRepositoryService.java (2)

11-15: 트랜잭션 경계 추가 제안 (읽기/쓰기 분리).

Repository 래핑 서비스이므로 기본 readOnly 트랜잭션을 클래스에 부여하고, 쓰기 메서드에서만 readOnly 해제하는 편이 안전합니다.

다음 변경을 권장합니다:

 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;

 @Component
 @RequiredArgsConstructor
+@Transactional(readOnly = true)
 public class ChatRoomRepositoryService {

40-42: 쓰기 메서드에 @transactional 적용 및 반환형 개선 제안.

  • save/delete 계열은 명시적으로 트랜잭션을 시작하는 것이 안전합니다.
  • save 메서드는 저장된 엔티티를 반환하면 호출자가 id/상태를 활용하기 좋습니다.

적용 예:

-    public void saveChatRoomMember(ChatRoomMember chatRoomMember) {
-        chatRoomMemberRepository.save(chatRoomMember);
-    }
+    @Transactional
+    public ChatRoomMember saveChatRoomMember(ChatRoomMember chatRoomMember) {
+        return chatRoomMemberRepository.save(chatRoomMember);
+    }

-    public void deleteAllChatRoomMemberByMemberId(Long memberId) {
-        chatRoomMemberRepository.deleteAllByMemberId(memberId);
-    }
+    @Transactional
+    public void deleteAllChatRoomMemberByMemberId(Long memberId) {
+        chatRoomMemberRepository.deleteAllByMemberId(memberId);
+    }

Also applies to: 48-50

src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamConsumer.java (2)

31-33: 동시성 안전한 Map 사용 권장.

여러 스레드(스케줄러/초기화)에서 접근 가능성이 있으므로 ConcurrentHashMap으로 교체하세요.

-    private final Map<Long, Subscription> dbSubscriptions = new HashMap<>();
+    private final Map<Long, Subscription> dbSubscriptions = new java.util.concurrent.ConcurrentHashMap<>();

75-79: 중복 구독 방지 가드 추가.

이미 구독 중인 chatRoomId에 대해 중복 등록을 피하세요.

-        // 3. 서버 메모리 내에서 Subscription 관리
-        dbSubscriptions.put(chatRoomId, dbSub);
+        // 3. 서버 메모리 내에서 Subscription 관리 (중복 구독 방지)
+        if (dbSubscriptions.putIfAbsent(chatRoomId, dbSub) != null) {
+            log.debug("ChatRoom {} 이미 구독 중입니다. 중복 구독을 건너뜁니다.", chatRoomId);
+            dbSub.cancel();
+            return;
+        }
src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (1)

202-220: Map 조회 중복 최소화(가독성/할당 감소).

동일 키에 대해 두 번 getOrDefault 호출하면서 기본 DTO를 매번 생성합니다. 한 번만 조회하고 로컬 변수로 재사용하세요.

-        List<ChatResponseDTO> chatResponseDTOList = chatList.stream()
-            .map(chat -> ChatConverter.toChatResponseDTO(chat,
-                cachingMemberMap.getOrDefault(chat.getMemberId(),
-                    MemberConverter.toWithdrawMemberCachingDTO()).nickname(),
-                cachingMemberMap.getOrDefault(chat.getMemberId(),
-                    MemberConverter.toWithdrawMemberCachingDTO()).persona()))
-            .toList();
+        final var defaultMember = MemberConverter.toWithdrawMemberCachingDTO();
+        List<ChatResponseDTO> chatResponseDTOList = chatList.stream()
+            .map(chat -> {
+                MemberCachingDTO cached = cachingMemberMap.getOrDefault(chat.getMemberId(), defaultMember);
+                return ChatConverter.toChatResponseDTO(chat, cached.nickname(), cached.persona());
+            })
+            .toList();
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamService.java (1)

243-254: Listener 컨테이너 스레드/옵션 하드코딩 — 프로퍼티화 권장.

pollTimeout, batchSize, executor 스레드 수(현재 2)는 트래픽/방 수에 따라 튜닝 필요합니다. 설정값으로 외부화하거나 Executor 빈 주입을 권장합니다.

예시: @value로 thread 수/properties 주입 또는 TaskExecutor 빈을 주입받아 사용.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 032932e and 44eec4a.

📒 Files selected for processing (58)
  • build.gradle (1 hunks)
  • config (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/CozymateServerApplication.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/controller/ChatController.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/dto/redis/ChatPubDTO.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/dto/redis/ChatStreamDTO.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/dto/request/CreateChatRequestDTO.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/dto/response/ChatListResponseDTO.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/dto/response/ChatResponseDTO.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepository.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepositoryService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamConsumer.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoom.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoomMember.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chatroom/controller/ChatRoomController.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chatroom/converter/ChatRoomConverter.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chatroom/dto/response/ChatRoomResponseDTO.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chatroom/repository/ChatRoomMemberRepository.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chatroom/repository/ChatRoomRepository.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chatroom/repository/ChatRoomRepositoryService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chatroom/service/ChatRoomService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/fcm/event/SentChatEvent.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/fcm/event/converter/EventConverter.java (2 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (6 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/member/controller/MemberController.java (4 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/member/converter/MemberConverter.java (2 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/member/dto/MemberCachingDTO.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/member/repository/MemberRepositoryService.java (2 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberCacheService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberWithdrawService.java (3 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/notificationlog/enums/NotificationType.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/sqs/dto/FcmSQSMessage.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/sqs/service/SQSMessageCreator.java (3 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/config/JpaConfig.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/config/MongoConfig.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/config/ObjectMapperConfig.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/config/SecurityConfig.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/config/SshRedisConfig.java (3 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPubSubListener.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPublisher.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisSubscriberManager.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/config/RedisPubSubConfig.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/event/StompDisconnectEvent.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/event/StompSubEvent.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/response/code/status/ErrorStatus.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/response/exception/ExceptionAdvice.java (2 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/response/exception/WebSocketException.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/scheduler/RedisStreamScheduler.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/websocket/StompErrorHandler.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/websocket/StompInterceptor.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketHandshakeInterceptor.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/websocket/config/WebSocketConfig.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/websocket/repository/WebSocketSessionRepository.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (28)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (3)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (1)
  • Slf4j (38-221)
src/main/java/com/cozymate/cozymate_server/domain/sqs/service/SQSMessageSender.java (1)
  • Slf4j (14-42)
src/main/java/com/cozymate/cozymate_server/domain/sqs/service/SQSMessageCreator.java (1)
  • Component (18-306)
src/main/java/com/cozymate/cozymate_server/domain/chat/dto/response/ChatListResponseDTO.java (1)
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1)
  • Builder (10-23)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoomMember.java (2)
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1)
  • Builder (10-23)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoom.java (1)
  • Getter (18-41)
src/main/java/com/cozymate/cozymate_server/global/websocket/StompErrorHandler.java (1)
src/main/java/com/cozymate/cozymate_server/global/websocket/config/WebSocketConfig.java (1)
  • Configuration (14-49)
src/main/java/com/cozymate/cozymate_server/domain/chat/dto/response/ChatResponseDTO.java (1)
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1)
  • Builder (10-23)
src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisSubscriberManager.java (2)
src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPubSubListener.java (1)
  • Component (14-34)
src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPublisher.java (1)
  • Component (8-17)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/service/ChatRoomService.java (2)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/converter/ChatRoomConverter.java (1)
  • ChatRoomConverter (8-25)
src/main/java/com/cozymate/cozymate_server/global/response/exception/ExceptionAdvice.java (1)
  • Slf4j (32-229)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/controller/ChatRoomController.java (1)
src/main/java/com/cozymate/cozymate_server/domain/chat/controller/ChatController.java (1)
  • RestController (24-52)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/repository/ChatRoomRepositoryService.java (2)
src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepositoryService.java (1)
  • Component (10-25)
src/main/java/com/cozymate/cozymate_server/domain/messageroom/repository/MessageRoomRepositoryService.java (1)
  • Component (15-46)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java (3)
src/main/java/com/cozymate/cozymate_server/global/websocket/StompInterceptor.java (1)
  • Slf4j (23-82)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketHandshakeInterceptor.java (1)
  • Slf4j (21-63)
src/main/java/com/cozymate/cozymate_server/global/websocket/repository/WebSocketSessionRepository.java (1)
  • Slf4j (12-112)
src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPubSubListener.java (1)
src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPublisher.java (1)
  • Component (8-17)
src/main/java/com/cozymate/cozymate_server/domain/notificationlog/enums/NotificationType.java (1)
src/main/java/com/cozymate/cozymate_server/domain/notificationlog/converter/NotificationLogConverter.java (1)
  • NotificationLogConverter (11-48)
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (2)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoom.java (1)
  • Getter (18-41)
src/main/java/com/cozymate/cozymate_server/domain/member/Member.java (1)
  • Getter (35-103)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/SentChatEvent.java (1)
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1)
  • Builder (10-23)
src/main/java/com/cozymate/cozymate_server/domain/chat/dto/redis/ChatStreamDTO.java (1)
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1)
  • Builder (10-23)
src/main/java/com/cozymate/cozymate_server/global/websocket/StompInterceptor.java (3)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java (1)
  • Slf4j (12-29)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketHandshakeInterceptor.java (1)
  • Slf4j (21-63)
src/main/java/com/cozymate/cozymate_server/global/websocket/repository/WebSocketSessionRepository.java (1)
  • Slf4j (12-112)
src/main/java/com/cozymate/cozymate_server/domain/chat/dto/redis/ChatPubDTO.java (1)
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1)
  • Builder (10-23)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamService.java (3)
src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (1)
  • ChatConverter (13-58)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (1)
  • Slf4j (38-221)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamConsumer.java (1)
  • Slf4j (26-111)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamConsumer.java (3)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (1)
  • Slf4j (38-221)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamService.java (1)
  • Slf4j (45-300)
src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepositoryService.java (1)
  • Component (10-25)
src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPublisher.java (1)
src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPubSubListener.java (1)
  • Component (14-34)
src/main/java/com/cozymate/cozymate_server/global/websocket/repository/WebSocketSessionRepository.java (3)
src/main/java/com/cozymate/cozymate_server/global/websocket/StompInterceptor.java (1)
  • Slf4j (23-82)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java (1)
  • Slf4j (12-29)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketHandshakeInterceptor.java (1)
  • Slf4j (21-63)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoom.java (3)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoomMember.java (1)
  • Getter (17-39)
src/main/java/com/cozymate/cozymate_server/domain/university/University.java (1)
  • Getter (18-47)
src/main/java/com/cozymate/cozymate_server/global/utils/BaseTimeEntity.java (1)
  • Getter (12-29)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (6)
src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (1)
  • ChatConverter (13-58)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/converter/EventConverter.java (1)
  • EventConverter (18-98)
src/main/java/com/cozymate/cozymate_server/domain/member/converter/MemberConverter.java (1)
  • MemberConverter (16-117)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamConsumer.java (1)
  • Slf4j (26-111)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamService.java (1)
  • Slf4j (45-300)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (1)
  • Slf4j (47-411)
src/main/java/com/cozymate/cozymate_server/global/websocket/config/WebSocketConfig.java (1)
src/main/java/com/cozymate/cozymate_server/global/websocket/StompErrorHandler.java (1)
  • Configuration (16-45)
src/main/java/com/cozymate/cozymate_server/domain/chat/controller/ChatController.java (1)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/controller/ChatRoomController.java (1)
  • RestController (23-58)
src/main/java/com/cozymate/cozymate_server/domain/member/controller/MemberController.java (1)
src/main/java/com/cozymate/cozymate_server/domain/member/converter/MemberConverter.java (1)
  • MemberConverter (16-117)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketHandshakeInterceptor.java (3)
src/main/java/com/cozymate/cozymate_server/global/websocket/StompInterceptor.java (1)
  • Slf4j (23-82)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java (1)
  • Slf4j (12-29)
src/main/java/com/cozymate/cozymate_server/global/websocket/repository/WebSocketSessionRepository.java (1)
  • Slf4j (12-112)
src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberCacheService.java (3)
src/main/java/com/cozymate/cozymate_server/domain/member/converter/MemberConverter.java (1)
  • MemberConverter (16-117)
src/main/java/com/cozymate/cozymate_server/domain/member/controller/MemberController.java (1)
  • RequiredArgsConstructor (40-166)
src/main/java/com/cozymate/cozymate_server/domain/member/repository/MemberRepositoryService.java (1)
  • Service (13-68)
🔇 Additional comments (27)
config (1)

1-1: 서브모듈 커밋 갱신 확인 완료

새 커밋 포인터로의 업데이트 외 추가 우려 사항 없습니다.

build.gradle (1)

95-104: LGTM!

채팅 기능 구현에 필요한 의존성이 적절하게 추가되었습니다:

  • WebSocket: STOMP 기반 실시간 채팅
  • MongoDB: 채팅 메시지 영구 저장
  • Spring Retry & Aspects: 재시도 로직 및 AOP 지원

모든 의존성은 Spring Boot BOM에 의해 버전이 관리됩니다.

src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoomMember.java (1)

28-32: LGTM!

@ManyToOne(fetch = FetchType.LAZY) 설정이 적절하게 적용되어 N+1 문제를 방지합니다. 지연 로딩은 채팅방 멤버 조회 시 불필요한 연관 엔터티 로딩을 피할 수 있습니다.

src/main/java/com/cozymate/cozymate_server/global/redispubsub/event/StompSubEvent.java (1)

3-5: LGTM!

단순하고 명확한 이벤트 레코드 구조입니다. chatRoomId만을 전달하는 목적에 적합합니다.

src/main/java/com/cozymate/cozymate_server/global/config/JpaConfig.java (1)

12-16: 다중 트랜잭션 매니저 환경에서 명시적 지정을 검토하세요.
@Primary JpaTransactionManager가 기본값이므로, MongoDB 작업을 수행하는 서비스나 리포지토리에서 @Transactional(transactionManager = "mongoTransactionManager")가 제대로 지정되어 있는지 직접 확인해주세요.

src/main/java/com/cozymate/cozymate_server/CozymateServerApplication.java (1)

7-14: 승인 완료

@EnableRetry가 추가되었으며, ChatRoomService.enterChatRoom 메서드에 적용된 @Retryable(retryFor=ObjectOptimisticLockingFailureException.class, maxAttempts=10, backoff=@Backoff(delay=100, multiplier=1.5, maxDelay=1000)) 설정이 적절히 구성되었습니다.

src/main/java/com/cozymate/cozymate_server/global/config/SecurityConfig.java (1)

98-100: WebSocket 인증/인가 처리 확인됨

WebSocketHandshakeInterceptor에서 JWT 검증 및 clientId 설정을, StompInterceptor에서 CONNECT·SUBSCRIBE 시 권한 검증을 수행하고 있어 /ws/**permitAll로 설정해도 안전합니다.

src/main/java/com/cozymate/cozymate_server/global/redispubsub/event/StompDisconnectEvent.java (1)

3-5: LGTM!

WebSocket 연결 해제 이벤트를 위한 간결하고 명확한 레코드 구조입니다.

src/main/java/com/cozymate/cozymate_server/domain/member/dto/MemberCachingDTO.java (1)

5-9: LGTM!

멤버 캐싱을 위한 DTO 구조가 적절하며, Builder 패턴을 통해 명확한 인스턴스 생성이 가능합니다.

src/main/java/com/cozymate/cozymate_server/domain/sqs/dto/FcmSQSMessage.java (1)

15-16: LGTM!

채팅방 ID 필드 추가가 기존 구조와 일관성 있게 구현되었습니다.

src/main/java/com/cozymate/cozymate_server/domain/fcm/event/converter/EventConverter.java (1)

91-97: LGTM!

CreateChatRequestDTO에서 SentChatEvent로의 변환 로직이 명확하며, 파일 내 다른 변환 메서드들과 일관된 패턴을 따르고 있습니다.

src/main/java/com/cozymate/cozymate_server/domain/chatroom/dto/response/ChatRoomResponseDTO.java (1)

5-10: LGTM!

채팅방 응답 DTO 구조가 명확하며, 필요한 정보들이 적절하게 포함되어 있습니다.

src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPubSubListener.java (1)

30-32: LGTM!

역직렬화된 메시지를 WebSocket으로 브로드캐스트하는 로직이 적절하며, ApiResponse로 래핑하여 일관된 응답 형식을 제공합니다.

src/main/java/com/cozymate/cozymate_server/global/config/ObjectMapperConfig.java (1)

12-19: LGTM!

ObjectMapper 설정이 적절합니다. JavaTimeModule 등록과 타임스탬프 비활성화를 통해 Java 8 날짜/시간 타입의 일관된 JSON 직렬화를 보장합니다.

src/main/java/com/cozymate/cozymate_server/domain/fcm/event/SentChatEvent.java (1)

5-10: LGTM!

채팅 전송 이벤트 구조가 명확하며, Chat 엔터티의 핵심 필드들과 일관성 있게 정의되었습니다.

src/main/java/com/cozymate/cozymate_server/global/response/code/status/ErrorStatus.java (1)

225-231: 채팅 도메인 에러 추가 LGTM

기존 패턴과 일관됩니다.

src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoom.java (1)

35-36: 낙관적 잠금을 통한 동시성 제어가 잘 구현되었습니다.

@Version 어노테이션을 사용하여 참가자 수 업데이트 시 발생할 수 있는 동시성 문제를 효과적으로 방지하고 있습니다.

src/main/java/com/cozymate/cozymate_server/domain/chat/controller/ChatController.java (1)

30-33: 입력 검증이 적절히 적용되었습니다.

@Valid 어노테이션을 통해 CreateChatRequestDTO의 입력 검증을 수행하는 것은 좋은 방법입니다. WebSocket 메시지에서도 유효하지 않은 데이터가 처리되지 않도록 보장합니다.

src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisSubscriberManager.java (2)

28-47: 동시성 처리가 적절히 구현되었습니다.

ConcurrentHashMapcompute 메서드를 사용하여 원자적으로 활성 사용자 수를 관리하고 Redis 구독을 처리하는 것은 좋은 접근입니다. 멀티스레드 환경에서 안전하게 동작합니다.


62-65: 불필요한 null 체크 제거
ConcurrentHashMapcomputeIfPresent 로직상 topicMap.remove(chatRoomId) 가 null을 반환할 수 없으므로

if (Objects.isNull(channelTopic)) { … }

분기는 삭제하고 곧바로 container.removeMessageListener(messageListenerAdapter, channelTopic) 만 호출하세요.

Likely an incorrect or invalid review comment.

src/main/java/com/cozymate/cozymate_server/global/websocket/StompInterceptor.java (2)

71-81: 채팅방 구독 권한 검증이 잘 구현되었습니다.

회원이 자신의 대학교 채팅방만 구독할 수 있도록 검증하는 로직은 보안상 중요하며 올바르게 구현되어 있습니다. 적절한 예외 메시지도 제공합니다.


38-42: clientId의 null 안전성을 확인하세요.

sessionAttributes.get("clientId")가 null을 반환할 수 있습니다. WebSocketHandshakeInterceptor에서 설정하지만, 핸드셰이크를 우회하거나 실패한 경우 null일 수 있습니다.

Line 39, 47, 56, 65에서 clientId를 사용하기 전에 null 체크를 추가하는 것을 고려하세요:

         Map<String, Object> sessionAttributes = accessor.getSessionAttributes();
         String clientId = (String) sessionAttributes.get("clientId");
+        
+        if (clientId == null) {
+            log.warn("[StompInterceptor] clientId가 세션에 없습니다.");
+            throw new MessageDeliveryException("인증되지 않은 연결");
+        }
src/main/java/com/cozymate/cozymate_server/domain/chatroom/controller/ChatRoomController.java (1)

31-37: 채팅방 목록 조회 구현이 깔끔합니다.

인증된 회원의 대학교에 속한 채팅방만 조회하는 로직이 잘 분리되어 있으며, 표준 Spring MVC 패턴을 따릅니다.

src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketHandshakeInterceptor.java (1)

28-56: WebSocket 핸드셰이크 시 JWT 인증이 올바르게 구현되었습니다.

JWT 토큰을 검증하고 유효하지 않은 경우 연결을 거부하는 로직이 적절히 구현되어 있습니다. clientId를 세션 속성에 저장하여 이후 STOMP 인터셉터에서 사용할 수 있도록 한 것도 좋습니다.

src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (2)

13-58: 변환 로직이 명확하고 일관성 있게 구현되었습니다.

빌더 패턴을 사용한 변환 메서드들이 읽기 쉽고 유지보수하기 좋게 구현되어 있습니다.


26-37: toChatPubDTO의 sequence 0L 고정은 의도된 동작입니다
ChatPubDTO 정의에서 // pub sequence는 0 고정 주석이 명시되어 있어 변경이 불필요합니다.

src/main/java/com/cozymate/cozymate_server/domain/sqs/service/SQSMessageCreator.java (1)

138-151: 채팅 알림을 위한 다중 수신자 처리가 잘 구현되었습니다.

채팅방의 여러 회원에게 알림을 보내기 위해 List<Member>를 받아 각 회원별로 SQSMessageResult를 생성하는 접근 방식이 적절합니다.

Comment on lines +6 to +12
public record CreateChatRequestDTO(
Long memberId,
@NotBlank(message = "채팅 내용이 공백일 수 없습니다.")
@Size(max = 300, message = "300자 이하로 입력해주세요.")
String content,
Long chatRoomId
) {
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

memberIdchatRoomId에 null 검증을 추가하세요.

content 필드는 적절한 검증이 있지만, memberIdchatRoomId에는 null 검증이 없습니다. 이 필드들이 필수인 경우, 다운스트림에서 NPE가 발생할 수 있습니다.

다음 diff를 적용하여 null 검증을 추가하세요:

+import jakarta.validation.constraints.NotNull;
+
 public record CreateChatRequestDTO(
+    @NotNull(message = "회원 ID는 필수입니다.")
     Long memberId,
     @NotBlank(message = "채팅 내용이 공백일 수 없습니다.")
     @Size(max = 300, message = "300자 이하로 입력해주세요.")
     String content,
+    @NotNull(message = "채팅방 ID는 필수입니다.")
     Long chatRoomId
 ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public record CreateChatRequestDTO(
Long memberId,
@NotBlank(message = "채팅 내용이 공백일 수 없습니다.")
@Size(max = 300, message = "300자 이하로 입력해주세요.")
String content,
Long chatRoomId
) {
import jakarta.validation.constraints.NotNull;
public record CreateChatRequestDTO(
@NotNull(message = "회원 ID는 필수입니다.")
Long memberId,
@NotBlank(message = "채팅 내용이 공백일 수 없습니다.")
@Size(max = 300, message = "300자 이하로 입력해주세요.")
String content,
@NotNull(message = "채팅방 ID는 필수입니다.")
Long chatRoomId
) {
// ...
}
🤖 Prompt for AI Agents
In
src/main/java/com/cozymate/cozymate_server/domain/chat/dto/request/CreateChatRequestDTO.java
around lines 6 to 12, memberId and chatRoomId currently lack null validation
which can cause NPEs; add @NotNull to both fields (with clear message like "필수
항목입니다.") so validation fails fast, and ensure you import
javax.validation.constraints.NotNull (or jakarta.validation.constraints.NotNull)
alongside the existing annotations.

Comment on lines +39 to +68
String script = """
local clientId = redis.call('HGET', KEYS[1], ARGV[1])
if clientId then
redis.call('SADD', KEYS[2], clientId)
redis.call('HSET', KEYS[3], ARGV[1], ARGV[2])
redis.call('HSET', KEYS[4], ARGV[1], clientId)
end
""";

String key1 = CONNECT_KEY;
String key2 = SUBSCRIBERS_CHATROOM_KEY_PREFIX + chatRoomId;
String key3 = SUBSCRIBE_CHATROOM_KEY;
String key4 = SUBSCRIBE_CLIENT_KEY;

redisTemplate.execute(
(RedisCallback<Void>) conn -> {
redisTemplate.getStringSerializer().deserialize(
conn.eval(
script.getBytes(),
ReturnType.STATUS,
4,
key1.getBytes(), key2.getBytes(), key3.getBytes(), key4.getBytes(),
sessionId.getBytes(), chatRoomId.getBytes()
)
);

return null;
}
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

세션 이동 시 기존 구독자가 정리되지 않습니다

현재 Lua 스크립트가 동일 세션으로 다른 채팅방을 다시 SUBSCRIBE할 때, 이전 채팅방의 집합에서 clientId를 제거하지 않습니다. 그래서 SUBSCRIBERS:chatRoomId:<이전방>에 clientId가 남아 있어 실제로는 구독하지 않는 방에도 메시지가 전달되는 등 잘못된 온라인 사용자 계산이 발생합니다. 이전 chatRoomId를 조회해서 다르면 SREM으로 정리하도록 스크립트를 보강해 주세요.

-        String script = """
-            local clientId = redis.call('HGET', KEYS[1], ARGV[1])
-            
-            if clientId then
-                redis.call('SADD', KEYS[2], clientId)
-                redis.call('HSET', KEYS[3], ARGV[1], ARGV[2])
-                redis.call('HSET', KEYS[4], ARGV[1], clientId)
-            end
-        """;
+        String script = """
+            local clientId = redis.call('HGET', KEYS[1], ARGV[1])
+            local previousChatRoomId = redis.call('HGET', KEYS[3], ARGV[1])
+            local chatRoomId = ARGV[2]
+
+            if clientId then
+                if previousChatRoomId and previousChatRoomId ~= chatRoomId then
+                    redis.call('SREM', KEYS[2] .. previousChatRoomId, clientId)
+                end
+                redis.call('SADD', KEYS[2] .. chatRoomId, clientId)
+                redis.call('HSET', KEYS[3], ARGV[1], chatRoomId)
+                redis.call('HSET', KEYS[4], ARGV[1], clientId)
+            end
+        """;
-        String key2 = SUBSCRIBERS_CHATROOM_KEY_PREFIX + chatRoomId;
+        String key2 = SUBSCRIBERS_CHATROOM_KEY_PREFIX;

Comment on lines +23 to +44
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage,
Throwable ex) {
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setLeaveMutable(true);

ApiResponse<Object> apiResponse = ApiResponse.onFailure(
ErrorStatus._BAD_REQUEST.getCode(), ex.getMessage(), null);

return errorMessage(accessor, apiResponse);
}

private Message<byte[]> errorMessage(StompHeaderAccessor accessor,
ApiResponse<Object> apiResponse) {
try {
String errorMessage = objectMapper.writeValueAsString(apiResponse);
return MessageBuilder.createMessage(errorMessage.getBytes(StandardCharsets.UTF_8),
accessor.getMessageHeaders());
} catch (JsonProcessingException e) {
return MessageBuilder.createMessage(EMPTY_PAYLOAD, accessor.getMessageHeaders());
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

STOMP ERROR 페이로드 위생 및 헤더 보강

  • 클라이언트로 예외 메시지(raw ex.getMessage())를 그대로 노출하면 민감 정보가 새어 나갈 수 있습니다. WebSocketException만 상세, 그 외는 일반 메시지로 축소를 권장합니다.
  • content-type 헤더를 JSON으로 명시하면 클라이언트 처리 일관성이 좋아집니다.

예시 수정:

+import org.springframework.util.MimeTypeUtils;
@@
-        ApiResponse<Object> apiResponse = ApiResponse.onFailure(
-            ErrorStatus._BAD_REQUEST.getCode(), ex.getMessage(), null);
+        String msg = (ex instanceof WebSocketException)
+            ? ex.getMessage()
+            : ErrorStatus._BAD_REQUEST.getMessage();
+        ApiResponse<Object> apiResponse = ApiResponse.onFailure(
+            ErrorStatus._BAD_REQUEST.getCode(), msg, null);
@@
-    private Message<byte[]> errorMessage(StompHeaderAccessor accessor,
+    private Message<byte[]> errorMessage(StompHeaderAccessor accessor,
         ApiResponse<Object> apiResponse) {
         try {
+            accessor.setContentType(MimeTypeUtils.APPLICATION_JSON);
             String errorMessage = objectMapper.writeValueAsString(apiResponse);
             return MessageBuilder.createMessage(errorMessage.getBytes(StandardCharsets.UTF_8),
                 accessor.getMessageHeaders());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage,
Throwable ex) {
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setLeaveMutable(true);
ApiResponse<Object> apiResponse = ApiResponse.onFailure(
ErrorStatus._BAD_REQUEST.getCode(), ex.getMessage(), null);
return errorMessage(accessor, apiResponse);
}
private Message<byte[]> errorMessage(StompHeaderAccessor accessor,
ApiResponse<Object> apiResponse) {
try {
String errorMessage = objectMapper.writeValueAsString(apiResponse);
return MessageBuilder.createMessage(errorMessage.getBytes(StandardCharsets.UTF_8),
accessor.getMessageHeaders());
} catch (JsonProcessingException e) {
return MessageBuilder.createMessage(EMPTY_PAYLOAD, accessor.getMessageHeaders());
}
}
import org.springframework.util.MimeTypeUtils;
@@
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage,
Throwable ex) {
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setLeaveMutable(true);
-
- ApiResponse<Object> apiResponse = ApiResponse.onFailure(
String msg = (ex instanceof WebSocketException)
? ex.getMessage()
: ErrorStatus._BAD_REQUEST.getMessage();
ApiResponse<Object> apiResponse = ApiResponse.onFailure(
ErrorStatus._BAD_REQUEST.getCode(), msg, null);
return errorMessage(accessor, apiResponse);
}
@@
private Message<byte[]> errorMessage(StompHeaderAccessor accessor,
ApiResponse<Object> apiResponse) {
try {
accessor.setContentType(MimeTypeUtils.APPLICATION_JSON);
String errorMessage = objectMapper.writeValueAsString(apiResponse);
return MessageBuilder.createMessage(errorMessage.getBytes(StandardCharsets.UTF_8),
accessor.getMessageHeaders());

Comment on lines 23 to 27
String chatRoomId = webSocketSessionRepository.deleteChatRoomSubscribingMembers(sessionId);
webSocketSessionRepository.deleteSessionAndClientId(sessionId);

eventPublisher.publishEvent(new StompDisconnectEvent(Long.parseLong(chatRoomId)));
log.info("[WebSocketEventListener] 세션 종료 처리: {}", sessionId);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

세션 미구독 시 NPE 발생 가능

Line 23에서 deleteChatRoomSubscribingMembers가 null을 반환할 수 있는데, Line 26에서 즉시 Long.parseLong(chatRoomId)를 호출하면 세션이 구독을 완료하지 못한 상태로 끊길 때 NullPointerException이 발생합니다. 연결 직후 곧바로 끊거나 다른 토픽만 구독한 세션의 정리 시 서비스 전체가 깨집니다. chatRoomId 존재 여부와 숫자 여부를 확인한 뒤 이벤트를 발행하도록 방어 로직을 추가해주세요.

-        String chatRoomId = webSocketSessionRepository.deleteChatRoomSubscribingMembers(sessionId);
+        String chatRoomId = webSocketSessionRepository.deleteChatRoomSubscribingMembers(sessionId);
         webSocketSessionRepository.deleteSessionAndClientId(sessionId);

-        eventPublisher.publishEvent(new StompDisconnectEvent(Long.parseLong(chatRoomId)));
-        log.info("[WebSocketEventListener] 세션 종료 처리: {}", sessionId);
+        if (chatRoomId != null && !chatRoomId.isBlank()) {
+            try {
+                eventPublisher.publishEvent(new StompDisconnectEvent(Long.parseLong(chatRoomId)));
+            } catch (NumberFormatException ex) {
+                log.warn("[WebSocketEventListener] 숫자가 아닌 chatRoomId가 반환되었습니다. sessionId={}, chatRoomId={}", sessionId, chatRoomId, ex);
+            }
+        }
+        log.info("[WebSocketEventListener] 세션 종료 처리: {}", sessionId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
String chatRoomId = webSocketSessionRepository.deleteChatRoomSubscribingMembers(sessionId);
webSocketSessionRepository.deleteSessionAndClientId(sessionId);
eventPublisher.publishEvent(new StompDisconnectEvent(Long.parseLong(chatRoomId)));
log.info("[WebSocketEventListener] 세션 종료 처리: {}", sessionId);
String chatRoomId = webSocketSessionRepository.deleteChatRoomSubscribingMembers(sessionId);
webSocketSessionRepository.deleteSessionAndClientId(sessionId);
if (chatRoomId != null && !chatRoomId.isBlank()) {
try {
eventPublisher.publishEvent(new StompDisconnectEvent(Long.parseLong(chatRoomId)));
} catch (NumberFormatException ex) {
log.warn(
"[WebSocketEventListener] 숫자가 아닌 chatRoomId가 반환되었습니다. sessionId={}, chatRoomId={}",
sessionId, chatRoomId, ex
);
}
}
log.info("[WebSocketEventListener] 세션 종료 처리: {}", sessionId);
🤖 Prompt for AI Agents
In
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java
around lines 23-27, the returned chatRoomId from
deleteChatRoomSubscribingMembers can be null or non-numeric causing
NPE/NumberFormatException when Long.parseLong is called; add a defensive check:
after calling deleteChatRoomSubscribingMembers and deleteSessionAndClientId,
verify chatRoomId is not null/empty and matches a numeric pattern (or catch
NumberFormatException) before calling eventPublisher.publishEvent(new
StompDisconnectEvent(...)); if invalid, skip publishing and log a debug/info
message indicating no chat-room event is emitted for that session.

Copy link
Member Author

@momuzzi momuzzi left a comment

Choose a reason for hiding this comment

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

처음에 redis stream을 사용해서 구현했었는데, 이것저것 고려할게 많아서 관련 코드 전부 제외시켰습니다.
좀 더 공부해서 다음에 다시 시도해 보는 걸로..

@Slf4j
@Repository
@RequiredArgsConstructor
public class WebSocketSessionRepository {
Copy link
Member Author

Choose a reason for hiding this comment

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

웹소켓 연결 관련한 데이터를 redis에 저장, 삭제하는 클래스입니다

Comment on lines +35 to +56
String authHeader = httpServletRequest.getHeader(JwtUtil.HEADER_ATTRIBUTE_NAME_AUTHORIZATION);

if (Objects.nonNull(authHeader) && authHeader.startsWith(JwtUtil.TOKEN_PREFIX)) {
String jwt = authHeader.substring(JwtUtil.TOKEN_PREFIX.length());
try {
jwtUtil.validateToken(jwt);
} catch (Exception e) {
log.info("WebSocketHandShake 전 jwt 인증 실패");
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false;
}

String clientId = jwtUtil.extractUserName(jwt);
attributes.put("clientId", clientId); // StompInterceptor에서 꺼내어 사용 예정
} else {
log.info("WebSocketHandShake 전 헤더에 jwt 없음");
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false;
}

return true;
}
Copy link
Member Author

Choose a reason for hiding this comment

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

http -> ws 업그레이드 시 jwt로 검증하고 있습니다.
여기서 jwt에서 꺼낸 clientId 넘겨서 stomp CONNECT부터 DISCONNECT까지 redis에 세션id랑 clientId랑 매핑해서 사용

Comment on lines 71 to 81
private void validateCanSubscribe(String clientId, String chatRoomId) {
Member member = memberRepository.findByClientId(clientId)
.orElseThrow(() -> new MessageDeliveryException("토픽 Sub중, member not found"));

ChatRoom chatRoom = chatRoomRepository.findById(Long.parseLong(chatRoomId))
.orElseThrow(() -> new MessageDeliveryException("sub하려는 채팅방이 존재하지 않음"));

if (!member.getUniversity().getId().equals(chatRoom.getUniversity().getId())) {
throw new MessageDeliveryException("다른 대학의 채팅방 sub 불가");
}
}
Copy link
Member Author

Choose a reason for hiding this comment

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

여기서 발생하는 예외는 StompErrorHandler에서 잡아서 ERROR 프레임을 클라이언트에 전송합니다

Comment on lines +36 to +48
// 클라이언트는 "/user/queue/errors" 경로를 필수 구독
@MessageExceptionHandler
@SendToUser(destinations = "/queue/errors")
public ApiResponse<Void> webSocketException(WebSocketException e) {
return ApiResponse.onFailure(e.getCode().getReasonHttpStatus().getCode(),
e.getCode().getMessage(), null);
}

// 클라이언트는 "/user/queue/errors" 경로를 필수 구독
@MessageExceptionHandler
@SendToUser(destinations = "/queue/errors")
public ApiResponse<Map<String, String>> handleValidationException(
org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException e) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Stomp SEND 프레임으로 전송되어 @MessageMapping(@ReqeustMapping과 유사)에서 발생하는 예외에 대해 잡아주는 부분입니다

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisSubscriberManager {
Copy link
Member Author

Choose a reason for hiding this comment

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

서버에서 Redis의 Topic 구독을 관리하는 클래스입니다.
해당 서버에서 한명이라도 채팅방에 접속해있다면 서버는 chatroom:{채팅방id} topic을 구독, 0명이라면 구독 x

import org.springframework.orm.jpa.JpaTransactionManager;

@Configuration
public class JpaConfig {
Copy link
Member Author

Choose a reason for hiding this comment

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

Mongo트랜잭션매니저 빈 등록하면서 기존 @transactional이 트랜잭션매니저를 못찾아서 @primary로 등록해주었습니다


@Service
@RequiredArgsConstructor
public class MemberCacheService {
Copy link
Member Author

Choose a reason for hiding this comment

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

사용자의 nickname과 persona를 캐싱해서 사용하도록 했습니다. TTL 1시간으로 잡아뒀어요

Copy link
Contributor

Choose a reason for hiding this comment

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

사용자 정보를 캐싱하는 건 채팅방 때문인거 같은데, dto하나가 만료된 상황에 여러명이 동시에 조회하면 다들 miss나는 건가요? 추후에 개선하면 좋을거 같아요. 한 번 미스나서 배치하는 동안에는 락거는 방향으로

Comment on lines +46 to +51
@Transactional
@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 10,
backoff = @Backoff(delay = 100, multiplier = 1.5, maxDelay = 1000)
)
Copy link
Member Author

Choose a reason for hiding this comment

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

채팅방에 참여 인원 요구 사항이 한번이라도 거쳐간 사람에 대해서만 단 한번 +1을 하는 구조라서, 최초의 채팅방 입장때만 1증가해주면 되고, 충돌이 잘 안일어 날 것이라 생각해서 낙관적 락을 걸고, 충돌 시 재시도 하도록 했습니다

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (1)

357-366: 🔴 발신자 본인에게 푸시 알림이 발송되는 중대 결함

이전 리뷰에서 지적된 문제가 여전히 수정되지 않았습니다. 현재 notSubscribing 리스트에 발신자(sender)가 포함되어 있어, 본인이 보낸 메시지에 대해 본인에게 푸시 알림이 발송됩니다.

아래 diff를 적용하여 발신자를 제외한 대상에게만 푸시를 발송하도록 수정해주세요:

         List<Member> notSubscribing = chatRoomMembers.stream()
             .map(ChatRoomMember::getMember)
             .filter(m -> !subscribingMembers.contains(m.getClientId()))
             .toList();
 
         Member sender = memberRepositoryService.getMemberByIdOrThrow(sentChatEvent.memberId());
+        
+        // 발신자는 푸시 대상에서 제외
+        List<Member> targets = notSubscribing.stream()
+            .filter(m -> !m.getId().equals(sender.getId()))
+            .toList();
 
         List<SQSMessageResult> results = sqsMessageCreator.createWithChatRoomId(
-            sender, notSubscribing, sentChatEvent.content(), sentChatEvent.chatRoomId(),
+            sender, targets, sentChatEvent.content(), sentChatEvent.chatRoomId(),
             NotificationType.ARRIVE_CHAT);
🧹 Nitpick comments (2)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (2)

372-377: 주석을 메서드 상단으로 이동하거나 한글로 작성 권장

테스트 결과 및 배치 크기 산정 근거를 담은 주석이 로직 중간에 위치해 있습니다. 이러한 설명은 메서드 JavaDoc이나 메서드 시작 부분에 위치하는 것이 가독성에 더 좋습니다. 또한 코드베이스의 다른 주석들과 일관성을 위해 한글로 작성하는 것을 고려해주세요.


379-393: 배치 처리 로직을 헬퍼 메서드로 추출 권장

배치 분할 로직이 다소 복잡하여 가독성을 저해하고 있습니다. 재사용성과 테스트 용이성을 고려하여 별도의 private 메서드로 추출하는 것을 권장합니다.

예시:

private <T> List<List<T>> partitionList(List<T> list, int batchSize) {
    List<List<T>> batches = new ArrayList<>();
    for (int i = 0; i < list.size(); i += batchSize) {
        int end = Math.min(i + batchSize, list.size());
        batches.add(list.subList(i, end));
    }
    return batches;
}

그런 다음 호출 부분을 간소화:

-            if (fcmSqsMessageList.size() > BATCH_SIZE) {
-                List<List<FcmSQSMessage>> batchList = new ArrayList<>();
-                for (int start = 0; start < fcmSqsMessageList.size(); start += BATCH_SIZE) {
-                    int end = start + BATCH_SIZE;
-
-                    if (end > fcmSqsMessageList.size()) {
-                        end = fcmSqsMessageList.size();
-                    }
-
-                    batchList.add(fcmSqsMessageList.subList(start, end));
-                }
-
-                batchList.forEach(
-                    fcmSQSMessages -> sqsMessageSender.sendMessage(fcmSQSMessages)
-                );
+            if (fcmSqsMessageList.size() > BATCH_SIZE) {
+                List<List<FcmSQSMessage>> batches = partitionList(fcmSqsMessageList, BATCH_SIZE);
+                batches.forEach(sqsMessageSender::sendMessage);
             } else {
                 sqsMessageSender.sendMessage(fcmSqsMessageList);
             }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 44eec4a and 2a7a802.

📒 Files selected for processing (1)
  • src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (6 hunks)
🔇 Additional comments (1)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (1)

345-346: @eventlistener 사용이 적절합니다
ChatService.sendChat에 @Transactional이 없어 @TransactionalEventListener로는 이벤트가 실행되지 않습니다.

Likely an incorrect or invalid review comment.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (1)

356-366: 발신자 제외 필터 추가 필요

현재 notSubscribing 리스트에 발신자가 포함되어 있어 자기 자신에게 푸시가 발송될 수 있습니다. 이전 리뷰에서 이미 지적된 사항으로, 아래와 같이 발신자를 제외한 targets를 생성해 사용해 주세요.

 List<Member> notSubscribing = chatRoomMembers.stream()
     .map(ChatRoomMember::getMember)
     .filter(m -> !subscribingMembers.contains(m.getClientId()))
     .toList();

 Member sender = memberRepositoryService.getMemberByIdOrThrow(sentChatEvent.memberId());
+List<Member> targets = notSubscribing.stream()
+    .filter(m -> !m.getId().equals(sender.getId()))
+    .toList();

-List<SQSMessageResult> results = sqsMessageCreator.createWithChatRoomId(
-    sender, notSubscribing, sentChatEvent.content(), sentChatEvent.chatRoomId(),
+List<SQSMessageResult> results = sqsMessageCreator.createWithChatRoomId(
+    sender, targets, sentChatEvent.content(), sentChatEvent.chatRoomId(),
     NotificationType.ARRIVE_CHAT);
🧹 Nitpick comments (1)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (1)

377-387: 배치 전송 로직 확인 완료 및 로깅 개선 제안

배치 전송 로직은 올바르게 구현되어 있습니다. 빈 리스트, 크기 초과, 일반 케이스를 모두 적절히 처리하고 있습니다.

이전 리뷰에서 언급된 ObjectMapper 직렬화 관련 이슈는 현재 코드에 해당 로직이 없어 적용되지 않습니다.

다만, 배치 전송 시 디버깅을 위한 로깅을 추가하면 운영 중 문제 추적에 도움이 될 수 있습니다.

 if (!fcmSqsMessageList.isEmpty()) {
     if (fcmSqsMessageList.size() > BATCH_SIZE) {
+        log.info("채팅 알림 배치 전송 시작: 총 {} 개 메시지, {} 개 배치", 
+            fcmSqsMessageList.size(), (fcmSqsMessageList.size() + BATCH_SIZE - 1) / BATCH_SIZE);
         for (int start = 0; start < fcmSqsMessageList.size(); start += BATCH_SIZE) {
             int end = Math.min(start + BATCH_SIZE, fcmSqsMessageList.size());
             sqsMessageSender.sendMessage(fcmSqsMessageList.subList(start, end));
+            log.debug("배치 전송 완료: {}-{}", start, end - 1);
         }
     } else {
         sqsMessageSender.sendMessage(fcmSqsMessageList);
+        log.debug("채팅 알림 전송 완료: {} 개 메시지", fcmSqsMessageList.size());
     }
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2a7a802 and 843721f.

📒 Files selected for processing (1)
  • src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (6 hunks)
🔇 Additional comments (3)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (3)

3-7: LGTM!

새로운 채팅 알림 흐름에 필요한 의존성이 올바르게 추가되었습니다.

Also applies to: 52-54


344-349: @eventlistener 사용 의도 확인 필요

다른 알림 핸들러들은 @TransactionalEventListener를 사용하지만, 이 메서드는 @EventListener를 사용합니다. 이는 트랜잭션 커밋 후가 아닌 이벤트 발행 즉시 실행됩니다.

Redis Streams 기반 채팅 아키텍처에서는 의도된 동작일 수 있으나, 채팅 메시지가 아직 영속화되지 않은 상태에서 알림이 발송될 가능성을 검토해 주세요.


367-369: 알림 로그 영속화 누락 확인 필요

다른 알림 핸들러들과 달리 이 메서드는 NotificationLog를 저장하지 않습니다. SQSMessageResultnotificationLog()가 포함되어 있으나 이를 영속화하는 로직이 없습니다.

채팅 알림의 높은 발생 빈도를 고려한 의도적인 설계인지 확인해 주세요. 만약 의도적이지 않다면, 아래와 같이 로그 저장 로직을 추가하는 것을 권장합니다.

 List<FcmSQSMessage> fcmSqsMessageList = results.stream()
     .flatMap(r -> r.fcmSQSMessageList().stream())
     .toList();

+// NotificationLog 저장
+results.forEach(r -> 
+    notificationLogRepositoryService.createNotificationLog(r.notificationLog()));

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java (1)

28-31: Long.parseLong 호출 시 NumberFormatException 방어 처리 고려

현재는 StompInterceptorvalidateCanSubscribe에서 chatRoomId 형식을 검증하므로 실제로는 문제가 없지만, 방어적 프로그래밍 차원에서 NumberFormatException 처리를 추가하는 것을 권장합니다.

다음과 같이 try-catch를 추가할 수 있습니다:

 if (Objects.nonNull(chatRoomId)) {
-    eventPublisher.publishEvent(new StompDisconnectEvent(Long.parseLong(chatRoomId)));
+    try {
+        eventPublisher.publishEvent(new StompDisconnectEvent(Long.parseLong(chatRoomId)));
+    } catch (NumberFormatException ex) {
+        log.warn("[WebSocketEventListener] chatRoomId 파싱 실패: sessionId={}, chatRoomId={}", sessionId, chatRoomId, ex);
+    }
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0b8bf44 and fa0dac7.

📒 Files selected for processing (2)
  • src/main/java/com/cozymate/cozymate_server/global/websocket/StompInterceptor.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/com/cozymate/cozymate_server/global/websocket/StompInterceptor.java
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java (2)
src/main/java/com/cozymate/cozymate_server/global/websocket/StompInterceptor.java (1)
  • Slf4j (23-81)
src/main/java/com/cozymate/cozymate_server/global/websocket/repository/WebSocketSessionRepository.java (1)
  • Slf4j (12-112)
🔇 Additional comments (2)
src/main/java/com/cozymate/cozymate_server/global/websocket/WebSocketEventListener.java (2)

1-16: 클래스 구조 및 의존성 주입이 적절합니다.

Spring의 이벤트 리스너 패턴과 Lombok을 활용한 의존성 주입이 명확하고 간결하게 구현되어 있습니다.


28-31: 이전 리뷰의 NPE 문제가 정확히 해결되었습니다.

Objects.nonNull(chatRoomId) 체크를 통해 SUBSCRIBE 프레임 검증 실패 시 chatRoomId가 null인 경우를 안전하게 처리하고 있습니다. 주석으로 null이 발생하는 상황도 명확히 설명되어 있어 좋습니다.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (3)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamService.java (3)

144-144: 로그 메시지의 오타를 수정하세요.

"CousumerGroup"을 "ConsumerGroup"으로 수정해야 합니다.

-        log.info("CousumerGroup : {}, ack 성공 수 : {}", consumerGroupName, acknowledge);
+        log.info("ConsumerGroup : {}, ack 성공 수 : {}", consumerGroupName, acknowledge);

154-154: 로그 메시지의 오타를 수정하세요.

"CousumerGroup"을 "ConsumerGroup"으로 수정해야 합니다.

-        log.info("CousumerGroup : {}, ack 성공 수 : {}", consumerGroupName, acknowledge);
+        log.info("ConsumerGroup : {}, ack 성공 수 : {}", consumerGroupName, acknowledge);

259-274: Redis 커넥션 리소스 누수를 수정하세요.

getConnection()으로 얻은 커넥션을 닫지 않아 리소스 누수가 발생합니다. trimStreamBeforeOldestPendingMessage 메서드에서는 try-with-resources를 올바르게 사용하고 있으나, 여기서는 누락되었습니다.

다음과 같이 try-with-resources로 감싸주세요:

         // Stream 존재 하지 않으면, MKSTREAM 옵션을 통해 만들고, ConsumerGroup 또한 생성한다
         if (!redisTemplate.hasKey(streamKey)) {
-            RedisAsyncCommands commands = (RedisAsyncCommands) redisTemplate
-                .getConnectionFactory()
-                .getConnection()
-                .getNativeConnection();
-
-            CommandArgs<String, String> args = new CommandArgs<>(StringCodec.UTF8)
-                .add(CommandKeyword.CREATE)
-                .add(streamKey)
-                .add(consumerGroupName)
-                .add("0")
-                .add("MKSTREAM");
-
-            commands.dispatch(CommandType.XGROUP, new StatusOutput(StringCodec.UTF8), args);
+            try (RedisConnection connection = redisTemplate.getConnectionFactory().getConnection()) {
+                @SuppressWarnings("unchecked")
+                RedisAsyncCommands<String, String> commands =
+                    (RedisAsyncCommands<String, String>) connection.getNativeConnection();
+
+                CommandArgs<String, String> args = new CommandArgs<>(StringCodec.UTF8)
+                    .add(CommandKeyword.CREATE)
+                    .add(streamKey)
+                    .add(consumerGroupName)
+                    .add("0")
+                    .add("MKSTREAM");
+
+                commands.dispatch(CommandType.XGROUP, new StatusOutput<>(StringCodec.UTF8), args);
+            }
         } else { // Stream 존재 시
🧹 Nitpick comments (5)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamService.java (5)

112-129: 매직 넘버를 상수로 추출하세요.

메서드명과 코드에 하드코딩된 21은 매직 넘버입니다. 향후 값 변경 시 메서드명도 함께 바꿔야 하는 불편함이 있습니다.

다음과 같이 개선할 수 있습니다:

+    private static final int INITIAL_CHAT_LOAD_SIZE = 21;
+
     /**
-     * Stream에서 가장 최신 21개 채팅 데이터 조회 (방 입장 시 조회용)
+     * Stream에서 가장 최신 N개 채팅 데이터 조회 (방 입장 시 조회용)
      */
-    public List<Chat> getRecent21ChatList(Long chatRoomId) {
+    public List<Chat> getRecentChatList(Long chatRoomId) {
         List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream()
             .reverseRange(generateChatroomStreamKey(chatRoomId), Range.unbounded(),
-                Limit.limit().count(21));
+                Limit.limit().count(INITIAL_CHAT_LOAD_SIZE));
 
         log.info("redis에서 조회된 방 입장시 데이터 수 : {}", records.size());

Note: ChatService.java에서 이 메서드를 호출하는 부분도 함께 업데이트해야 합니다.


160-174: SCAN 카운트를 상수로 추출하세요.

하드코딩된 50은 매직 넘버입니다. 향후 조정이 필요할 수 있으므로 상수로 관리하는 것이 좋습니다.

+    private static final int STREAM_SCAN_COUNT = 50;
+
     public List<String> getAllChatRoomStreamKeyList() {
-        ScanOptions options = ScanOptions.scanOptions().match("STREAM:chatroom:*").count(50)
+        ScanOptions options = ScanOptions.scanOptions().match("STREAM:chatroom:*").count(STREAM_SCAN_COUNT)
             .build();

185-188: 트림 보존 개수를 상수로 추출하세요.

펜딩 메시지가 없을 때 남기는 50개는 매직 넘버입니다. 클래스 상단에 상수로 선언하면 가독성과 유지보수성이 향상됩니다.

+    private static final int DEFAULT_TRIM_RETAIN_COUNT = 50;
+
     // 펜딩 메시지가 없는 경우, 최신 50개만 남겨둔다
     if (pendingSummary.getTotalPendingMessages() == 0) {
-        redisTemplate.opsForStream().trim(streamKey, 50);
+        redisTemplate.opsForStream().trim(streamKey, DEFAULT_TRIM_RETAIN_COUNT);
         return;
     }

241-252: 설정 값들을 외부화하거나 상수로 추출하세요.

배치 크기(10), 스레드 풀 크기(2), 폴링 타임아웃(20ms)이 하드코딩되어 있습니다. 운영 환경에 따라 조정이 필요할 수 있으므로 application.yml에서 주입받거나 최소한 클래스 상수로 관리하는 것이 좋습니다.

+    @Value("${redis.stream.batch-size:10}")
+    private int streamBatchSize;
+    
+    @Value("${redis.stream.thread-pool-size:2}")
+    private int streamThreadPoolSize;
+    
+    @Value("${redis.stream.poll-timeout-ms:20}")
+    private int streamPollTimeoutMs;
+
     public StreamMessageListenerContainer createStreamMessageListenerContainer() {
         return StreamMessageListenerContainer.create(
             redisTemplate.getConnectionFactory(),
             StreamMessageListenerContainer
                 .StreamMessageListenerContainerOptions.builder()
-                .batchSize(10)
+                .batchSize(streamBatchSize)
-                .executor(Executors.newFixedThreadPool(2))
+                .executor(Executors.newFixedThreadPool(streamThreadPoolSize))
                 .hashKeySerializer(new StringRedisSerializer())
                 .hashValueSerializer(new StringRedisSerializer())
-                .pollTimeout(Duration.ofMillis(20))
+                .pollTimeout(Duration.ofMillis(streamPollTimeoutMs))
                 .build()
         );
     }

285-297: Stream API를 사용하여 간결하게 작성하세요.

수동 반복문 대신 Stream API를 사용하면 코드가 더 간결하고 읽기 쉬워집니다.

     private boolean existStreamConsumerGroup(String streamKey, String consumerGroupName) {
-        Iterator<XInfoGroup> iterator = redisTemplate.opsForStream().groups(streamKey).stream()
-            .iterator();
-
-        while (iterator.hasNext()) {
-            StreamInfo.XInfoGroup xInfoGroup = iterator.next();
-            if (xInfoGroup.groupName().equals(consumerGroupName)) {
-                return true;
-            }
-        }
-
-        return false;
+        return redisTemplate.opsForStream().groups(streamKey).stream()
+            .anyMatch(group -> group.groupName().equals(consumerGroupName));
     }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fef55a2 and 9728400.

📒 Files selected for processing (1)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamService.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamService.java (4)
src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (1)
  • ChatConverter (13-58)
src/main/java/com/cozymate/cozymate_server/global/scheduler/RedisStreamScheduler.java (1)
  • Slf4j (16-91)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (1)
  • Slf4j (38-221)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/redis/ChatStreamConsumer.java (1)
  • Slf4j (26-106)

Comment on lines +33 to +36
private int participantNum;

@Version
private long version;
Copy link
Member Author

Choose a reason for hiding this comment

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

채팅방 참여 인원 반정규화 + 낙관적 락으로 설계했습니다.

@cozy-mate cozy-mate deleted a comment from coderabbitai bot Oct 9, 2025
@coderabbitai
Copy link

coderabbitai bot commented Oct 9, 2025

Walkthrough

실시간 채팅 기능을 위한 인프라·도메인·서비스를 대규모로 추가합니다. STOMP/WebSocket 엔드포인트와 핸드셰이크·인터셉터·에러핸들러, WebSocket 세션/구독을 Redis로 관리하는 저장소와 구독 관리자, Redis Pub/Sub 퍼블리셔·리스너가 도입되었습니다. 채팅 메시지는 MongoDB 문서로 저장되며 채팅방과 참가자는 JPA 엔티티 및 리포지토리로 관리됩니다. 멤버 캐시(Redis)·FCM/SQS 알림 생성 흐름·Spring Retry 설정(@EnableRetry)과 관련 구성(ObjectMapper, MongoTransactionManager, JPA 트랜잭션 등)도 추가되었습니다.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client as WS Client
  participant Hand as WebSocketHandshakeInterceptor
  participant WS as STOMP /ws
  participant SI as StompInterceptor
  participant RepoS as WebSocketSessionRepository
  participant CC as ChatController
  participant CS as ChatService
  participant ChatRepo as ChatRepositoryService
  participant MCache as MemberCacheService
  participant RedisPub as RedisPublisher
  participant Evt as ApplicationEventPublisher

  rect rgba(220,240,255,0.12)
    Client->>Hand: HTTP Upgrade /ws (Authorization)
    Hand->>Client: validate JWT, set clientId
    Client->>WS: CONNECT / SUBSCRIBE / SEND
    WS->>SI: inbound message
    SI->>RepoS: saveSessionAndClientId / saveChatRoomSubscribingMembers
    SI-->>Evt: publish StompSubEvent (chatRoomId)
    WS->>CC: @MessageMapping("/chats") SEND(dto)
    CC->>CS: sendChat(dto)
    CS->>ChatRepo: saveChat(document)
    CS->>MCache: findMemberCachingDTO(memberId)
    alt cache miss
      MCache->>CS: fetch members & save to cache
    end
    CS->>RedisPub: publishToChat(ChatPubDTO)
    CS-->>Evt: publish SentChatEvent
  end
Loading
sequenceDiagram
  autonumber
  participant Redis as Redis Channel "chatroom:{id}"
  participant L as RedisPubSubListener
  participant SMT as SimpMessagingTemplate
  participant Sub as Subscribed Clients

  Redis-->>L: message (ChatPubDTO JSON)
  L->>L: deserialize(ObjectMapper)
  L->>SMT: convertAndSend("/topic/chatrooms/{id}", ApiResponse)
  SMT-->>Sub: STOMP MESSAGE (payload)
Loading
sequenceDiagram
  autonumber
  participant Evt as ApplicationEventPublisher
  participant NEL as NotificationEventListener
  participant Repo as WebSocketSessionRepository
  participant CRM as ChatRoomMemberRepository
  participant MRS as MemberRepositoryService
  participant SQC as SQSMessageCreator
  participant SQS as SQS

  Evt-->>NEL: SentChatEvent(chatRoomId, senderId, content)
  NEL->>Repo: getSubscribingMembersInChatRoom(chatRoomId)
  NEL->>CRM: findFetchMemberByChatRoomId(chatRoomId) (opt-in)
  NEL->>MRS: getMemberByIdOrSocketThrow(senderId)
  NEL->>SQC: createWithChatRoomId(sender, recipients, content, chatRoomId, ARRIVE_CHAT)
  loop batched send
    SQC-->>NEL: List<SQSMessageResult>
    NEL->>SQS: send(batch)
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed 제목 “COZY-665 feat : 채팅 기능 구현”은 PR의 주요 변경 사항인 채팅 기능 구현을 간결하고 명확하게 요약하며 불필요한 정보 없이 주요 의도를 잘 전달하고 있습니다.
Description Check ✅ Passed PR 설명은 템플릿의 구조를 충실히 따르며 작업 내용과 Redis 사용 전략, 동작 확인 스크린샷, 리뷰 요구사항 섹션을 상세히 채워 변경 사항과 검증 과정을 명확히 전달하고 있습니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/COZY-665

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (1)

355-364: 발신자 제외 필터 누락

현재 notSubscribing 컬렉션에 발신자가 그대로 포함되어 자기 자신에게 푸시가 전달됩니다. 지난 리뷰 지적과 동일한 버그이므로 sender를 조회한 뒤 명시적으로 제외해 주세요.

-        List<Member> notSubscribing = chatRoomMembers.stream()
-            .map(ChatRoomMember::getMember)
-            .filter(m -> !subscribingMembers.contains(m.getClientId()))
-            .toList();
-
-        Member sender = memberRepositoryService.getMemberByIdOrThrow(sentChatEvent.memberId());
+        Member sender = memberRepositoryService.getMemberByIdOrThrow(sentChatEvent.memberId());
+
+        List<Member> notSubscribing = chatRoomMembers.stream()
+            .map(ChatRoomMember::getMember)
+            .filter(m -> !subscribingMembers.contains(m.getClientId()))
+            .filter(m -> !m.getId().equals(sender.getId()))
+            .toList();
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1)

10-22: Mongo 역직렬화를 위한 기본 생성자 필요.

이전 리뷰에서 지적된 것처럼, MongoDB가 문서를 역직렬화할 때 문제를 방지하려면 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 추가해야 합니다.

🧹 Nitpick comments (4)
src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (2)

12-12: 유틸리티 클래스 패턴 적용 권장.

정적 메서드만 포함된 유틸리티 클래스는 final로 선언하고 private 생성자를 추가하여 인스턴스화를 방지하는 것이 좋습니다.

다음과 같이 수정하세요:

-public class ChatConverter {
+public final class ChatConverter {
+
+    private ChatConverter() {
+        throw new UnsupportedOperationException("Utility class");
+    }

14-21: 박싱/언박싱 제거 및 타임존 일관성 확보

  • chatRoomId는 이미 Long 타입이므로 Long.valueOf(createChatRequestDTO.chatRoomId()) 대신 createChatRequestDTO.chatRoomId()를 직접 사용하세요.
  • LocalDateTime.now()는 시스템 기본 타임존을 사용하므로, UTC 기준 시각 또는 주입된 Clock/OffsetDateTime 사용을 고려하세요.
src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (1)

44-63: 트랜잭션 범위 내 외부 DB 조회 확인 필요.

Line 56에서 getMemberCachingDTO를 호출하는데, 이 메서드는 캐시 미스 시 멤버 DB(아마도 JPA)를 조회합니다. MongoDB 트랜잭션 내에서 다른 데이터 소스를 조회하는 것은 성능 문제를 일으킬 수 있습니다.

멤버 정보 조회를 트랜잭션 범위 밖으로 이동하거나, MongoDB에 채팅을 저장하기 전에 미리 조회하는 것을 고려하세요.

다음과 같이 리팩토링하는 것을 고려하세요:

 @Transactional(transactionManager = "mongoTransactionManager")
 public void sendChat(CreateChatRequestDTO createChatRequestDTO) {
     if (!chatRoomRepositoryService.existsChatRoomMemberByChatRoomIdAndMemberId(
         createChatRequestDTO.chatRoomId(), createChatRequestDTO.memberId())) {
         throw new WebSocketException(ErrorStatus._CHATROOMMEMBER_NOT_FOUND);
     }
 
+    // 트랜잭션 전에 멤버 캐싱 정보 조회
+    MemberCachingDTO memberCachingDTO = getMemberCachingDTO(createChatRequestDTO);
+
     // 채팅 저장
     Chat chat = ChatConverter.toDocument(createChatRequestDTO);
     chatRepositoryService.saveChat(chat);
 
-    // sender의 nickname, persona 조회
-    MemberCachingDTO memberCachingDTO = getMemberCachingDTO(createChatRequestDTO);
-
     // 트랜잭션 커밋 후 redis topic에 pub
     eventPublisher.publishEvent(ChatConverter.toChatPubDTO(chat, memberCachingDTO));
 
     // 푸시 알림
     eventPublisher.publishEvent(EventConverter.toSentChatEvent(createChatRequestDTO));
 }
src/main/java/com/cozymate/cozymate_server/domain/chat/controller/ChatController.java (1)

36-39: GET 메서드에 @RequestBody 사용 재검토

GET 메서드에 @RequestBody를 사용하는 것은 HTTP 표준에 부합하지 않아 일부 클라이언트나 프록시가 본문을 무시할 수 있습니다. 문서화용이라면 POST로 변경하거나 Swagger에서 해당 엔드포인트를 제외하세요.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9728400 and d0e7b5f.

📒 Files selected for processing (10)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/controller/ChatController.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/dto/redis/ChatPubDTO.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/dto/response/ChatResponseDTO.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepository.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepositoryService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (6 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPublisher.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/main/java/com/cozymate/cozymate_server/domain/chat/dto/redis/ChatPubDTO.java
  • src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepositoryService.java
  • src/main/java/com/cozymate/cozymate_server/domain/chat/repository/ChatRepository.java
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisPublisher.java
🧰 Additional context used
🧬 Code graph analysis (5)
src/main/java/com/cozymate/cozymate_server/domain/chat/dto/response/ChatResponseDTO.java (1)
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (1)
  • Builder (10-22)
src/main/java/com/cozymate/cozymate_server/domain/chat/Chat.java (3)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/ChatRoom.java (1)
  • Getter (18-41)
src/main/java/com/cozymate/cozymate_server/domain/member/Member.java (1)
  • Getter (35-103)
src/main/java/com/cozymate/cozymate_server/global/utils/BaseTimeEntity.java (1)
  • Getter (12-29)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (4)
src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (1)
  • ChatConverter (12-53)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/converter/EventConverter.java (1)
  • EventConverter (18-98)
src/main/java/com/cozymate/cozymate_server/domain/member/converter/MemberConverter.java (1)
  • MemberConverter (16-117)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (1)
  • Slf4j (42-388)
src/main/java/com/cozymate/cozymate_server/domain/fcm/event/listener/NotificationEventListener.java (3)
src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (1)
  • Slf4j (31-137)
src/main/java/com/cozymate/cozymate_server/domain/sqs/service/SQSMessageSender.java (1)
  • Slf4j (14-42)
src/main/java/com/cozymate/cozymate_server/domain/sqs/service/SQSMessageCreator.java (1)
  • Component (18-306)
src/main/java/com/cozymate/cozymate_server/domain/chat/controller/ChatController.java (2)
src/main/java/com/cozymate/cozymate_server/domain/member/controller/MemberController.java (1)
  • RequiredArgsConstructor (40-166)
src/main/java/com/cozymate/cozymate_server/domain/chatroom/controller/ChatRoomController.java (1)
  • RestController (23-58)
🔇 Additional comments (11)
src/main/java/com/cozymate/cozymate_server/domain/chat/dto/response/ChatResponseDTO.java (1)

6-17: LGTM!

레코드 정의가 명확하고, @builder 어노테이션이 적절하게 적용되어 있습니다. 모든 필드가 채팅 응답에 필요한 정보를 포함하고 있습니다.

src/main/java/com/cozymate/cozymate_server/domain/chat/controller/ChatController.java (2)

30-33: LGTM!

WebSocket 메시지 핸들러가 적절하게 구현되어 있으며, 요청 DTO의 유효성 검증도 포함되어 있습니다.


42-52: LGTM!

이전 채팅 조회 엔드포인트가 적절하게 구현되어 있으며, 날짜 형식 검증과 인증 처리가 올바르게 적용되어 있습니다.

src/main/java/com/cozymate/cozymate_server/domain/chat/converter/ChatConverter.java (3)

23-32: LGTM!

Chat 엔티티와 멤버 캐싱 DTO를 ChatPubDTO로 변환하는 로직이 명확하고 정확합니다.


34-44: LGTM!

Chat 엔티티를 ChatResponseDTO로 변환하는 로직이 적절하게 구현되어 있습니다.


46-52: LGTM!

ChatListResponseDTO 생성 로직이 간결하고 명확합니다.

src/main/java/com/cozymate/cozymate_server/domain/chat/service/ChatService.java (5)

65-73: LGTM!

최근 채팅 목록 조회 로직이 간결하고 명확하게 구현되어 있습니다.


75-86: LGTM!

이전 채팅 조회 로직이 적절하게 구현되어 있으며, 채팅방 멤버십 검증도 포함되어 있습니다.


88-103: LGTM!

멤버 캐싱 조회 로직이 표준적인 look-aside 캐시 패턴으로 잘 구현되어 있습니다.


105-108: LGTM!

페이지네이션 요청 생성 로직이 정확하며, PAGE_SIZE + 1 패턴을 사용해 hasNext 판단을 올바르게 지원합니다.


110-136: LGTM!

응답 변환 로직이 효율적이고 정확하게 구현되어 있습니다:

  • 페이지네이션 처리가 올바름
  • 멤버 캐시 데이터를 배치로 조회하여 N+1 문제 방지
  • 탈퇴한 멤버에 대한 폴백 처리 포함

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatRoomService {
Copy link
Member Author

Choose a reason for hiding this comment

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

ChatRoom, ChatRooMember 관련 클래스를 각각 만들기엔 ChatRoomMember 쪽 양이 적기도하고, ChatRoomMember 관련해서는 방 알림 끄기가 전부인데 ChatRoomService에 있어도 될 것 같아서, ChatRoomXXX(Service, RepositoryService, Converter) 클래스에 합쳐서 구현했습니다

@genius00hwan
Copy link
Contributor

Mongo Atlas정보 Notion에 부탁드려요

Copy link
Contributor

@genius00hwan genius00hwan left a comment

Choose a reason for hiding this comment

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

걱정되는 사항 남겼습니다 아직 보류 드릴게요.
코드가 많아서 PR에 전체 맥락을 설명해주는 글이 있으면 좋을 거 같아요.


@Service
@RequiredArgsConstructor
public class MemberCacheService {
Copy link
Contributor

Choose a reason for hiding this comment

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

사용자 정보를 캐싱하는 건 채팅방 때문인거 같은데, dto하나가 만료된 상황에 여러명이 동시에 조회하면 다들 miss나는 건가요? 추후에 개선하면 좋을거 같아요. 한 번 미스나서 배치하는 동안에는 락거는 방향으로

try {
chatPubDTO = objectMapper.readValue(message.getBody(), ChatPubDTO.class);
} catch (IOException e) {
throw new RuntimeException(e);
Copy link
Contributor

Choose a reason for hiding this comment

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

로그 찍고 재시도 큐 같은데다 박는 구조가 어떨까요.

throw new RuntimeException(e);
}

simpMessagingTemplate.convertAndSend(
Copy link
Contributor

Choose a reason for hiding this comment

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

여기서 바로 호출하면 레디스 뻑나면 스레드 고갈 날 수도 있는 건가요? 전용 쓰레드풀로 Execute 시키는게 어떤지요

Copy link
Member Author

Choose a reason for hiding this comment

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

simpMessagingTemplate.convertAndSend() 작업을 하는 스레드 풀을 따로 관리하자는 말씀이신걸가요??

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberService.java (2)

130-136: 캐시 업데이트 패턴이 올바르게 구현되었습니다.

회원 정보 수정 후 캐시를 업데이트하는 로직이 적절하게 구현되었습니다:

  • Try-catch로 캐시 실패 시에도 메인 로직이 중단되지 않도록 방어적으로 처리
  • 실패 시 로그를 남겨 추적 가능성 확보
  • TTL(1시간)이 있어 일시적인 불일치가 자동으로 해소됨

선택적 개선사항: 트랜잭션 커밋 후 캐시를 업데이트하면(예: @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 사용) 트랜잭션 실패 시 stale cache를 완전히 방지할 수 있습니다. 하지만 현재 구현도 캐시 작업이 메서드 마지막에 위치하고 TTL이 있어 충분히 안전합니다.


152-158: 캐시 삭제 로직이 올바르게 구현되었습니다.

회원 탈퇴 후 캐시를 삭제하는 로직이 update 메서드와 일관된 패턴으로 적절하게 구현되었습니다:

  • Try-catch로 캐시 실패 시에도 탈퇴 프로세스가 중단되지 않도록 처리
  • 탈퇴 시 캐시 삭제는 올바른 접근 방식 (탈퇴 마커 저장 대신)

Update 메서드와 동일하게, 트랜잭션 커밋 후 캐시 작업을 수행하는 것을 선택적으로 고려할 수 있지만, 현재 구현도 충분히 안전합니다.

src/main/java/com/cozymate/cozymate_server/global/redispubsub/config/RedisPubSubConfig.java (1)

34-48: 스레드 풀 설정 재검토 및 추가 옵션 고려를 권장합니다.

현재 설정(core=2, max=3, queue=200)은 소규모 트래픽에는 적합하지만, 채팅 시스템의 예상 부하에 따라 부족할 수 있습니다. 또한 CallerRunsPolicy는 큐가 가득 찰 경우 Redis 구독 스레드가 직접 메시지를 처리하게 되어 구독 처리가 블로킹될 위험이 있습니다.

다음 사항을 검토해 주세요:

  1. 스레드 풀 크기: 동시 활성 채팅방 수와 메시지 처리량을 고려하여 조정
  2. Rejection Policy: AbortPolicy + DLQ 또는 모니터링 강화 검토
  3. 추가 옵션: 이전 리뷰 코멘트에서 언급된 대로, RedisMessageListenerContainer에 구독 관리용 별도 스레드 풀(setSubscriptionExecutor) 설정을 고려

예시:

@Bean
public Executor redisListenerExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);  // 증가 고려
    executor.setMaxPoolSize(10);  // 증가 고려
    executor.setQueueCapacity(500);  // 증가 고려
    // rejection policy는 요구사항에 따라 선택
    executor.setRejectedExecutionHandler(new CallerRunsPolicy());
    // ... 나머지 설정
    return executor;
}

// 선택사항: 구독 관리용 별도 executor
@Bean
public Executor redisSubscriptionExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2);
    executor.setThreadNamePrefix("redis-subscription-");
    executor.initialize();
    return executor;
}

@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
    // ...
    container.setSubscriptionExecutor(redisSubscriptionExecutor());
    // ...
}

Based on past review comments

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6581979 and ada70ce.

📒 Files selected for processing (4)
  • src/main/java/com/cozymate/cozymate_server/domain/member/controller/MemberController.java (0 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberService.java (3 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisSubscriberManager.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/config/RedisPubSubConfig.java (1 hunks)
💤 Files with no reviewable changes (1)
  • src/main/java/com/cozymate/cozymate_server/domain/member/controller/MemberController.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/com/cozymate/cozymate_server/global/redispubsub/RedisSubscriberManager.java
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberService.java (1)
src/main/java/com/cozymate/cozymate_server/domain/member/converter/MemberConverter.java (1)
  • MemberConverter (16-117)
🔇 Additional comments (1)
src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberService.java (1)

42-42: LGTM! 의존성 주입이 올바르게 추가되었습니다.

MemberCacheService 의존성이 표준 Spring DI 패턴에 따라 올바르게 추가되었습니다.

Comment on lines +26 to +32
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
container.setTaskExecutor(redisListenerExecutor());
return container;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

에러 핸들러 추가를 권장합니다.

RedisMessageListenerContainer에 명시적인 에러 핸들러가 설정되어 있지 않습니다. 메시지 처리 중 예외 발생 시 로그만 남기고 메시지가 유실될 수 있으며, 에러 패턴 파악이나 알림 연동이 어렵습니다.

다음 설정을 추가하여 에러 처리 전략을 명시하는 것을 권장합니다:

 @Bean
 public RedisMessageListenerContainer redisMessageListenerContainer() {
     RedisMessageListenerContainer container = new RedisMessageListenerContainer();
     container.setConnectionFactory(redisConnectionFactory);
     container.setTaskExecutor(redisListenerExecutor());
+    container.setErrorHandler(t -> {
+        log.error("Redis Pub/Sub message processing failed", t);
+        // 필요시 알림 전송, 메트릭 수집 등 추가
+    });
     return container;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
container.setTaskExecutor(redisListenerExecutor());
return container;
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
container.setTaskExecutor(redisListenerExecutor());
container.setErrorHandler(t -> {
log.error("Redis Pub/Sub message processing failed", t);
// 필요시 알림 전송, 메트릭 수집 등 추가
});
return container;
}
🤖 Prompt for AI Agents
In
src/main/java/com/cozymate/cozymate_server/global/redispubsub/config/RedisPubSubConfig.java
around lines 26 to 32, the RedisMessageListenerContainer is created without an
explicit error handler; update the bean to set a custom ErrorHandler (or provide
a lambda implementing ErrorHandler) that logs exceptions with contextual details
and forwards critical failures to your monitoring/alerting system, and
optionally implements simple retry/backoff or delegates to a retrying task
executor; ensure the error handler is injected or created as a bean and wired
into container.setErrorHandler(...) so exceptions during message processing are
captured and handled instead of being silently lost.

Copy link
Contributor

@genius00hwan genius00hwan left a comment

Choose a reason for hiding this comment

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

수고하셨습니다. 근데 어떤 문제가 있을지 몰라서 채팅만큼은 개발서버에 올리고 테스트 진행후에 배포해도 될것같다는 생각이..

Copy link
Member

@eple0329 eple0329 left a comment

Choose a reason for hiding this comment

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

너무 길어요 ㅠ
보다가 끝이 없는 코드 끝에 지쳐... 일단 쭉 보다 서비스, 엔티티, 레포 위주로 확인했습니당.
고생하셨어요... 문제생겨도 다른 도메인에 영향을 크게 주진 않을거같네요

Comment on lines +13 to +22
@Document("chat")
public class Chat {

@Id
private String id; // MongoDB ObjectId
private Long chatRoomId; // 채팅방 ID
private Long memberId; // 메시지 보낸 사람
private String content; // 메시지 내용
private LocalDateTime createdAt; // 생성 시간
}
Copy link
Member

Choose a reason for hiding this comment

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

roomId랑 createdAt으로 검색하니까, 인덱스 걸어두는게 어떨까용

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants