diff --git a/src/main/java/org/swyp/linkit/domain/chat/redis/RedisChatSubscriber.java b/src/main/java/org/swyp/linkit/domain/chat/redis/RedisChatSubscriber.java index c9467189..2721edfa 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/redis/RedisChatSubscriber.java +++ b/src/main/java/org/swyp/linkit/domain/chat/redis/RedisChatSubscriber.java @@ -15,7 +15,7 @@ public class RedisChatSubscriber implements MessageListener { private final SimpMessagingTemplate messagingTemplate; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; @Override public void onMessage(Message message, byte[] pattern) { diff --git a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java index 005fa918..4f1672b6 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java +++ b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java @@ -37,7 +37,7 @@ public class ChatService { private final UserRepository userRepository; private final StringRedisTemplate redisTemplate; private final NotificationService notificationService; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; private static final String CHAT_CHANNEL_PREFIX = "chat:room:"; @@ -189,8 +189,9 @@ public void deleteMessages(Long roomId, Long userId, List messageIds) { * Redis Pub/Sub을 통해 메시지 발행 */ public void publishToRedis(ChatMessage message) { + Long roomId = message.getChatRoom().getId(); ChatPayloadResponseDto payload = ChatPayloadResponseDto.builder() - .roomId(message.getChatRoom().getId()) + .roomId(roomId) .messageId(message.getId()) .senderId(message.getSenderId()) .senderRole(message.getSenderRole().name()) @@ -203,11 +204,12 @@ public void publishToRedis(ChatMessage message) { try { String json = objectMapper.writeValueAsString(payload); - String channel = CHAT_CHANNEL_PREFIX + message.getChatRoom().getId(); + String channel = CHAT_CHANNEL_PREFIX + roomId; redisTemplate.convertAndSend(channel, json); log.info("Redis 메시지 발행: channel={}, messageId={}", channel, message.getId()); } catch (JsonProcessingException e) { - log.error("ChatPayload 직렬화 실패", e); + log.error("채팅 메시지 직렬화 실패: roomId={}, messageId={}", roomId, message.getId(), e); + throw new ChatPublishFailedException(roomId); } } @@ -228,7 +230,8 @@ public void publishReadEvent(Long roomId, Long userId, Long lastReadMessageId) { redisTemplate.convertAndSend(channel, json); log.info("읽음 이벤트 발행: channel={}, readerId={}", channel, userId); } catch (JsonProcessingException e) { - log.error("읽음 이벤트 직렬화 실패", e); + log.error("읽음 이벤트 직렬화 실패: roomId={}, userId={}", roomId, userId, e); + throw new ChatPublishFailedException(roomId); } } diff --git a/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationMessageDto.java b/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationMessageDto.java index 1a134104..55254614 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationMessageDto.java +++ b/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationMessageDto.java @@ -15,7 +15,7 @@ * WebSocket으로 전송되는 알림 메시지 DTO */ @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder(access = AccessLevel.PRIVATE) public class NotificationMessageDto { diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java index 7963a0ff..068d8a95 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java @@ -7,6 +7,8 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.swyp.linkit.domain.notification.dto.NotificationDto; import org.swyp.linkit.domain.notification.dto.NotificationMessageDto; import org.swyp.linkit.domain.notification.dto.response.ChatRoomUnreadCountResponseDto; @@ -20,6 +22,7 @@ import org.swyp.linkit.global.error.exception.NotificationAccessDeniedException; import org.swyp.linkit.global.error.exception.NotificationAlreadyReadException; import org.swyp.linkit.global.error.exception.NotificationNotFoundException; +import org.swyp.linkit.global.error.exception.NotificationPublishFailedException; import org.swyp.linkit.global.error.exception.UserNotFoundException; import java.time.LocalDateTime; @@ -260,18 +263,33 @@ private User findUserById(Long userId) { } /** - * Redis Pub/Sub을 통해 알림 발행 + * Redis Pub/Sub을 통해 알림 발행 (트랜잭션 커밋 이후 발행) */ private void publishNotificationToRedis(Notification notification, String senderNickname, String message) { NotificationMessageDto payload = NotificationMessageDto.from(notification, senderNickname, message); + String channel = NOTIFICATION_CHANNEL_PREFIX + notification.getReceiver().getId(); + + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + doPublish(channel, payload, notification.getId()); + } + }); + } else { + doPublish(channel, payload, notification.getId()); + } + } + private void doPublish(String channel, NotificationMessageDto payload, Long notificationId) { try { String json = objectMapper.writeValueAsString(payload); - String channel = NOTIFICATION_CHANNEL_PREFIX + notification.getReceiver().getId(); redisTemplate.convertAndSend(channel, json); - log.info("Redis 알림 발행: channel={}, notificationId={}", channel, notification.getId()); + log.info("Redis 알림 발행: channel={}, notificationId={}", channel, notificationId); } catch (JsonProcessingException e) { - log.error("알림 직렬화 실패", e); + // afterCommit() 내부에서는 예외를 throw해도 Spring이 억제하므로 로그로 대체 + NotificationPublishFailedException ex = new NotificationPublishFailedException(notificationId); + log.error("[{}] {}", ex.getErrorCode().getCode(), ex.getMessage(), e); } } @@ -283,7 +301,7 @@ private String generateNotificationMessage(NotificationType type, String senderN case REQUEST_RECEIVED -> senderNickname + "님이 스킬 교환을 요청했습니다."; case REQUEST_SENT -> senderNickname + "님에게 스킬 교환 요청을 보냈습니다."; case REQUEST_STATUS_CHANGED -> "스킬 교환 요청 상태가 변경되었습니다."; - case CHAT_MESSAGE -> senderNickname + "님에게 메시지 요청이 왔습니다."; + case CHAT_MESSAGE -> senderNickname + "님이 메시지를 보냈습니다."; }; } } \ No newline at end of file diff --git a/src/main/java/org/swyp/linkit/global/error/ErrorCode.java b/src/main/java/org/swyp/linkit/global/error/ErrorCode.java index 5466542c..1bc6027d 100644 --- a/src/main/java/org/swyp/linkit/global/error/ErrorCode.java +++ b/src/main/java/org/swyp/linkit/global/error/ErrorCode.java @@ -135,6 +135,9 @@ public enum ErrorCode implements BaseErrorCode { @ExplainError("멘토와 멘티가 동일한 사용자인 경우 발생합니다.") CHAT_SAME_USER(HttpStatus.BAD_REQUEST, "CH006", "멘토와 멘티는 서로 다른 사용자여야 합니다."), + @ExplainError("Redis Pub/Sub을 통한 채팅 메시지 발행에 실패한 경우 발생합니다.") + CHAT_PUBLISH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CH007", "채팅 메시지 발행에 실패했습니다."), + // 크레딧 @ExplainError("사용자의 크레딧 정보가 존재하지 않는 경우 발생합니다.") NOT_FOUND_CREDIT(HttpStatus.NOT_FOUND, "CR001", "크레딧 정보를 찾을 수 없습니다."), @@ -216,6 +219,9 @@ public enum ErrorCode implements BaseErrorCode { @ExplainError("지원하지 않는 알림 타입을 요청한 경우 발생합니다.") INVALID_NOTIFICATION_TYPE(HttpStatus.BAD_REQUEST, "N004", "유효하지 않은 알림 타입입니다."), + @ExplainError("Redis Pub/Sub을 통한 알림 발행에 실패한 경우 발생합니다.") + NOTIFICATION_PUBLISH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "N005", "알림 발행에 실패했습니다."), + // 채팅 파일 업로드 @ExplainError("채팅 파일 크기가 10MB를 초과하는 경우 발생합니다.") CHAT_FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "CF001", "파일 용량은 최대 10MB까지 업로드할 수 있습니다."), diff --git a/src/main/java/org/swyp/linkit/global/error/exception/ChatPublishFailedException.java b/src/main/java/org/swyp/linkit/global/error/exception/ChatPublishFailedException.java new file mode 100644 index 00000000..c5a8e993 --- /dev/null +++ b/src/main/java/org/swyp/linkit/global/error/exception/ChatPublishFailedException.java @@ -0,0 +1,16 @@ +package org.swyp.linkit.global.error.exception; + +import org.swyp.linkit.global.error.ErrorCode; +import org.swyp.linkit.global.error.exception.base.BusinessException; + +public class ChatPublishFailedException extends BusinessException { + + public ChatPublishFailedException() { + super(ErrorCode.CHAT_PUBLISH_FAILED); + } + + public ChatPublishFailedException(Long roomId) { + super(ErrorCode.CHAT_PUBLISH_FAILED, + "채팅 메시지 발행에 실패했습니다. roomId=" + roomId); + } +} diff --git a/src/main/java/org/swyp/linkit/global/error/exception/NotificationPublishFailedException.java b/src/main/java/org/swyp/linkit/global/error/exception/NotificationPublishFailedException.java new file mode 100644 index 00000000..49df93b1 --- /dev/null +++ b/src/main/java/org/swyp/linkit/global/error/exception/NotificationPublishFailedException.java @@ -0,0 +1,16 @@ +package org.swyp.linkit.global.error.exception; + +import org.swyp.linkit.global.error.ErrorCode; +import org.swyp.linkit.global.error.exception.base.BusinessException; + +public class NotificationPublishFailedException extends BusinessException { + + public NotificationPublishFailedException() { + super(ErrorCode.NOTIFICATION_PUBLISH_FAILED); + } + + public NotificationPublishFailedException(Long notificationId) { + super(ErrorCode.NOTIFICATION_PUBLISH_FAILED, + "알림 발행에 실패했습니다. notificationId=" + notificationId); + } +} diff --git a/src/main/java/org/swyp/linkit/global/swagger/docs/ChatExceptionDocs.java b/src/main/java/org/swyp/linkit/global/swagger/docs/ChatExceptionDocs.java index 75f175e8..ee577d1b 100644 --- a/src/main/java/org/swyp/linkit/global/swagger/docs/ChatExceptionDocs.java +++ b/src/main/java/org/swyp/linkit/global/swagger/docs/ChatExceptionDocs.java @@ -59,4 +59,11 @@ public BaseErrorCode getErrorCode() { return ErrorCode.CHAT_SAME_USER; } } + + public static class ChatPublishFailedException implements SwaggerExampleExceptions { + @Override + public BaseErrorCode getErrorCode() { + return ErrorCode.CHAT_PUBLISH_FAILED; + } + } } diff --git a/src/main/java/org/swyp/linkit/global/swagger/docs/NotificationExceptionDocs.java b/src/main/java/org/swyp/linkit/global/swagger/docs/NotificationExceptionDocs.java index 397782b7..63db9eb2 100644 --- a/src/main/java/org/swyp/linkit/global/swagger/docs/NotificationExceptionDocs.java +++ b/src/main/java/org/swyp/linkit/global/swagger/docs/NotificationExceptionDocs.java @@ -45,4 +45,11 @@ public BaseErrorCode getErrorCode() { return ErrorCode.INVALID_NOTIFICATION_TYPE; } } + + public static class NotificationPublishFailedException implements SwaggerExampleExceptions { + @Override + public BaseErrorCode getErrorCode() { + return ErrorCode.NOTIFICATION_PUBLISH_FAILED; + } + } }