Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2dd9e30
init: 프ë¡로젝트 초기 ì설정
CheatIsKey Apr 10, 2026
e9eb01e
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 10, 2026
ff6f98c
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 10, 2026
b77a2f2
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 11, 2026
b3a33ee
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 12, 2026
08bc5c8
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 13, 2026
2c02bbe
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 14, 2026
21755ca
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 15, 2026
12be03f
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 15, 2026
33edb5d
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 15, 2026
134f2a3
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 15, 2026
5b25401
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 16, 2026
5199462
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 16, 2026
0f586e4
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 17, 2026
ba879ef
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 17, 2026
8a95286
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 17, 2026
483bc52
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 17, 2026
e232379
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 19, 2026
67a3f77
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 20, 2026
dbbb767
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 20, 2026
2de4325
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 21, 2026
af7712f
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 21, 2026
7b8ffa0
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 21, 2026
b16d03c
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 21, 2026
aa34b0e
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 21, 2026
016e9a2
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 21, 2026
9409ce8
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 22, 2026
dccbc69
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 23, 2026
6337b02
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 23, 2026
55ca628
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 23, 2026
983d756
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 23, 2026
7131980
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 23, 2026
e9b8173
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 24, 2026
f2af60f
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 24, 2026
a8a68b6
Merge branch 'develop' of https://github.com/Allday-Project/Allday-Pr…
CheatIsKey Apr 24, 2026
f21da32
Feat: 채팅 WebSocket Redis Pub/Sub 연동 및 구조 개선
CheatIsKey Apr 24, 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
@@ -1,9 +1,16 @@
package jpa.basic.alldayprojectcommerce.common.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import jpa.basic.alldayprojectcommerce.domain.chat.redis.ChatRedisSubscriber;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
Expand All @@ -23,4 +30,62 @@ public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factor

return template;
}

/**
* 채팅 Pub/Sub용 RedisTemplate (JSON 직렬화)
*
* ChatMessageResponse 객체를 JSON으로 직렬화하여 Redis 채널에 발행
* StringRedisSerializer로는 객체 직렬화 불가 -> Jackson 기반 JSON 직렬화 사용
*/
@Bean
public RedisTemplate<String, Object> chatRedisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);

/**
* JavaTimeModule 등록한 ObjectMapper 설정
* LocalDateTime 등 Java 8 날짜 타입 직렬화 지원
*/
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 타입 정보 포함 - 역직렬화 시 올바른 클래스로 복원
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);

GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(objectMapper);

StringRedisSerializer keySerializer = new StringRedisSerializer();
template.setKeySerializer(keySerializer);
template.setValueSerializer(serializer);
template.setHashKeySerializer(keySerializer);
template.setHashValueSerializer(serializer);

return template;
}

/**
* Redis Pub/Sub 메시지 리스터 컨테이너
*
* PatternTopic("chat:room:*")
* "chat:room:1", "chat:room:2" 등 모든 채팅방 채널을 단일 리스너로 처리
*/
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory redisConnectionFactory,
ChatRedisSubscriber chatRedisSubscriber) {

RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);

// "chat:room:*" - 모든 채팅방 채널을 단일 리스너로 처리
container.addMessageListener(chatRedisSubscriber,
new PatternTopic("chat:room:*")
);

return container;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) {
log.info("[STOMP] 연결 성공 userId: {}, role: {}", userId, role);
}

/**
* SUBSCRIBE 시점 - 구독 경로 권한 검증
*
* /sub/chat/{roomId} 구독 시 해당 방에 접근 권한이 있는지 확인
* 지금은 인증된 유저만 구독 가능하도록 1차 방어
*/
if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
if (!(accessor.getUser() instanceof StompPrincipal)) {
log.warn("[STOMP] 인증되지 않은 구독 시도 차단");
throw new CustomException(ErrorCode.CHAT_UNAUTHORIZED);
}
}

return message;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub"); // 구독 경로 prefix
registry.setApplicationDestinationPrefixes("/pub"); // 발행 경로 prefix
registry.enableSimpleBroker("/sub", "/queue"); // 구독 경로 prefix
registry.setApplicationDestinationPrefixes("/pub"); // 발행 경로 prefix
registry.setUserDestinationPrefix("/user"); // 개인 세션 라우팅 prefix
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package jpa.basic.alldayprojectcommerce.common.exception;

import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.web.bind.annotation.ControllerAdvice;

@Slf4j
@ControllerAdvice
public class WebSocketExceptionHandler {

/**
* WebSocket 전용 전역 예외 처리기
*
* 에러 응답 경로: /user/queue/errors
* 예외 발생한 당사자 세션에만 전송
* 클라이언트: SUBSCRIBE /user/queue/errors 구독 필요
*/
@MessageExceptionHandler(CustomException.class)
@SendToUser("/queue/errors")
public ChatErrorResponse handleCustomException(CustomException e) {
log.warn("[WebSocket] 비즈니스 예외 - {}: {}", e.getErrorCode().getCode(), e.getErrorCode().getMessage());

return new ChatErrorResponse(
e.getErrorCode().getCode(),
e.getErrorCode().getMessage()
);
}

@MessageExceptionHandler(Exception.class)
@SendToUser("/queue/errors")
public ChatErrorResponse handleUnexpectedException(Exception e) {
log.error("[WebSocket] 예상치 못한 예외", e);

return new ChatErrorResponse("SYS001", "서버 내부 오류가 발생했습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package jpa.basic.alldayprojectcommerce.domain.chat.controller;

import jakarta.validation.Valid;
import jpa.basic.alldayprojectcommerce.common.config.StompPrincipal;
import jpa.basic.alldayprojectcommerce.common.exception.CustomException;
import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode;
import jpa.basic.alldayprojectcommerce.domain.chat.dto.request.ChatMessageRequest;
import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatMessageResponse;
import jpa.basic.alldayprojectcommerce.domain.chat.redis.ChatRedisPublisher;
import jpa.basic.alldayprojectcommerce.domain.chat.service.ChatMessageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.stereotype.Controller;

import java.security.Principal;

@Slf4j
@Controller
@RequiredArgsConstructor
public class ChatWebSocketController {

private final ChatMessageService chatMessageService;
private final ChatRedisPublisher chatRedisPublisher;

/**
* 클라이언트 -> 서버: 메시지 전송
*
* chatRedisPublisher.publish(roomId, response)
* 1. Redis 채널에 발행
* 2. 모든 서버의 ChatRedisSubscriber가 수신
* 3. 각 서버가 자기 WebSocket 구독자에게 브로드캐스트
* 4. 서버 A/B 어디 접속한 구독자든 메시지 수신 가능
*/
@MessageMapping("/chat/{roomId}")
public void sendMessage(
@DestinationVariable Long roomId,
@Valid ChatMessageRequest request,
Principal principal) {

if (!(principal instanceof StompPrincipal stompUser)) {
log.warn("[WebSocket] Principal이 StompPrincipal이 아님 - 비정상");
throw new CustomException(ErrorCode.CHAT_UNAUTHORIZED);
}

Long senderId = stompUser.getUserId();
String role = stompUser.getRole();

/**
* 메시지 저장 + 권한 검증
*
* 검증 실패 시 클라이언트에 STOMP ERROR 프레임 전송
*/
ChatMessageResponse response =
chatMessageService.saveMessage(roomId, senderId, role, request);

/**
* Redis 채널에 발행
*
* 모든 서버의 구독자에게 브로드캐스트
*/

chatRedisPublisher.publish(roomId, response);

log.debug("[WebSocket] 메시지 발행 roomId: {}, messageId: {}",
roomId, response.id());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package jpa.basic.alldayprojectcommerce.domain.chat.dto.response;

public record ChatErrorResponse(
String code, String message
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package jpa.basic.alldayprojectcommerce.domain.chat.redis;

import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatMessageResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class ChatRedisPublisher {

private final RedisTemplate<String, Object> chatRedisTemplate;

/**
* Redis 채널에 채팅 메시지 발행
*
* 채널명: "chat:room:{roomId}"
* 모든 서버의 ChatRedisSubscriber가 이 채널을 구독 중
*/
public void publish(Long roomId, ChatMessageResponse message) {
String channel = "chat:room:" + roomId;
chatRedisTemplate.convertAndSend(channel, message);
log.debug("[Redis Pub] channel: {}, messageId: {}", channel, message.id());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package jpa.basic.alldayprojectcommerce.domain.chat.redis;

import com.fasterxml.jackson.databind.ObjectMapper;
import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatMessageResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.Nullable;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class ChatRedisSubscriber implements MessageListener {

private final SimpMessagingTemplate simpMessagingTemplate;
private final ObjectMapper objectMapper;

/**
* Redis 채널 메시지 수신 -> WebSocket 브로드캐스트
*
* 흐름:
* 1. 어떤 서버에서든 "chat:room:{roomId}" 채널에 발행
* 2. 모든 서버의 이 메서드가 호출됨
* 3. 각 서버는 자신이 보유한 WebSocket 구독자에게 브로드캐스트
*
* "chat:room:".length() = 10 이후 문자열이 roomId
*/
@Override
public void onMessage(Message message, @Nullable byte[] pattern) {
try {
String channel = new String(message.getChannel());
String roomId = channel.substring("chat:room:".length());

ChatMessageResponse response = objectMapper.readValue(
message.getBody(), ChatMessageResponse.class);

// 구독자 전체에게 브로드캐스트
simpMessagingTemplate.convertAndSend("/sub/chat/" + roomId, response);

log.debug("[Redis Sub] roomId: {}, messageId: {}", roomId, response.id());

} catch (Exception e) {
log.error("[Redis Sub] 메시지 처리 실패", e);
}
}
}
Loading