Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
94b4cd7
chore: FRONTEND_PROD_URL, BACKEND_URL, BACKEND_PROD_URL Value 추가
chuuminggg Jan 21, 2026
1284857
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Jan 23, 2026
fc5ff71
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Jan 26, 2026
2d51667
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Jan 28, 2026
b963c9f
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Feb 5, 2026
77bdf45
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Feb 5, 2026
96d87fc
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Feb 12, 2026
031791a
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Feb 13, 2026
ae81b27
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Mar 3, 2026
14350bc
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Mar 11, 2026
5efc1b1
refactor : message 필드 추가
chuuminggg Apr 4, 2026
3c71556
refactor : REST API 응답에 message 포함
chuuminggg Apr 4, 2026
c0c0345
refactor : 채팅 메시지 전송 시 알림 발송
chuuminggg Apr 4, 2026
972f5af
Merge branch 'main' into feature/notification/message
chuuminggg Apr 4, 2026
761d298
refactor : null 체크 변경
chuuminggg Apr 4, 2026
bf43b7e
Merge branch 'feature/notification/message' of https://github.com/SWY…
chuuminggg Apr 4, 2026
a310cb4
refactor : 중복 발송 상태 수정
chuuminggg Apr 4, 2026
720feff
fix : RedisConfig에 알림 리스너 등록
chuuminggg Apr 6, 2026
a3642c4
fix : 이미지 메시지 전송 시 ChatMessage.content NPE 발생 문제 해결
chuuminggg Apr 6, 2026
e3ed193
Merge branch 'main' of https://github.com/SWYP-Link-it/backend into f…
chuuminggg Apr 6, 2026
8c31e08
refactor : 닉네임 처리 로직 개선 및 시스템/알 수 없음 구분 (resolveNickname()으로 null/bla…
chuuminggg Apr 6, 2026
a054172
fix : Redis에서 받은 JSON 역직렬화 오류로 알림 미수신 이슈 수정
chuuminggg Apr 7, 2026
ba2771a
fix : 의미없는 ObjectMapper를 RequiredArgsConstructor로 Spring Bean 주입 방식으로 변경
chuuminggg Apr 7, 2026
de0c260
fix : Redis publish 시 트랜잭션 커밋 이후 실행하도록 적용 (트랜잭션이 롤백되는 경우에도 알림 발행하는 이슈…
chuuminggg Apr 7, 2026
e037390
refactor : ErrorCode 추가 (N005, CH007)
chuuminggg Apr 7, 2026
ffe4c6a
feat : 새 예외 클래스 생성 (N005 : NotificationPublishFailedException, CH007 …
chuuminggg Apr 7, 2026
daf7fb9
refactor : 신규 exception Swagger Docs 추가
chuuminggg Apr 7, 2026
94183f2
Merge branch 'main' of https://github.com/SWYP-Link-it/backend into f…
chuuminggg Apr 7, 2026
c36084b
fix : 미커밋된 예외 클래스 파일 추가 (NotificationPublishFailedException, ChatPubl…
chuuminggg Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:";

Expand Down Expand Up @@ -189,8 +189,9 @@ public void deleteMessages(Long roomId, Long userId, List<Long> 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())
Expand All @@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

ChatPublishFailedException을 던져 STOMP ERROR 프레임을 전달하고자 하는 의도는 확인되나, afterCommit 패턴을 적용할 경우 트랜잭션 완료 후 예외가 발생하므로 클라이언트에게 에러 전달이 어려울 수 있습니다. 프로젝트 규칙에 따라, 트랜잭션 롤백과 관련된 복잡한 예외 처리나 상태 업데이트(FAILED 등) 대신, 스케줄러를 통한 재시도 메커니즘을 활용하여 처리를 단순화하는 것을 권장합니다.

References
  1. 트랜잭션 메소드에서 예외 발생 시 'FAILED' 상태 업데이트나 복잡한 예외 처리가 어렵다면, 이를 생략하고 스케줄러를 통한 재시도 메커니즘을 사용하는 것이 권장됩니다.

}
Comment on lines 205 to 213
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

PR 설명에 언급된 'Transactional Outbox 패턴'이 채팅 메시지 발행에는 적용되지 않았습니다. 현재 구현 방식은 redisTemplate.convertAndSend가 트랜잭션 커밋 여부와 상관없이 즉시 실행되므로, 이후 트랜잭션이 롤백되더라도 Redis 메시지는 이미 발행되어 데이터 정합성이 깨질 수 있습니다. NotificationServiceImpl과 동일하게 TransactionSynchronizationManager를 사용하여 커밋 이후에 발행되도록 개선이 필요합니다. 다만, 이 경우 afterCommit 내부에서 발생하는 예외는 호출자에게 전달되지 않으므로 로그 처리가 권장됩니다.

}

Expand All @@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

publishReadEvent 메소드 역시 publishToRedis와 마찬가지로 Transactional Outbox 패턴이 적용되지 않았습니다. 읽음 처리 트랜잭션이 성공적으로 커밋된 후에만 Redis 이벤트가 발행되도록 수정하여 데이터 일관성을 보장해 주세요.

}
Comment on lines 232 to 235
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

읽음 이벤트 발행 시에도 Transactional Outbox 패턴이 적용되지 않았습니다. 메시지 발행과 마찬가지로 트랜잭션 성공 시에만 Redis 이벤트가 전달되도록 afterCommit 동기화 로직을 적용하는 것을 권장합니다.

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* WebSocket으로 전송되는 알림 메시지 DTO
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder(access = AccessLevel.PRIVATE)
public class NotificationMessageDto {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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 + "님이 메시지를 보냈습니다.";
};
}
}
6 changes: 6 additions & 0 deletions src/main/java/org/swyp/linkit/global/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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", "크레딧 정보를 찾을 수 없습니다."),
Expand Down Expand Up @@ -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까지 업로드할 수 있습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Loading