From aeab863860205fa77ba3cf036c6ac852e95b4a05 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 27 Feb 2026 14:17:06 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20WebSocket=20STOMP=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?-=20JWT=20=EC=9D=B8=EC=A6=9D=20=EC=9D=B8=ED=84=B0=EC=85=89?= =?UTF-8?q?=ED=84=B0=20=EB=B0=8F=20=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + build.gradle | 1 + .../websocket/StompChannelInterceptor.java | 73 +++++++++++++++++++ .../auth/security/DevSecurityConfig.java | 3 + .../global/auth/security/SecurityConfig.java | 3 +- .../global/common/config/WebSocketConfig.java | 50 +++++++++++++ 6 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/websocket/StompChannelInterceptor.java create mode 100644 src/main/java/app/dearobjet/backend/global/common/config/WebSocketConfig.java diff --git a/.gitignore b/.gitignore index f18eb38..4964ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ out/ .env src/main/resources/logback.xml + +# AI tools +.claude/ +.agents/ diff --git a/build.gradle b/build.gradle index 338a7a7..abf0181 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-websocket' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'com.h2database:h2' diff --git a/src/main/java/app/dearobjet/backend/domain/chat/websocket/StompChannelInterceptor.java b/src/main/java/app/dearobjet/backend/domain/chat/websocket/StompChannelInterceptor.java new file mode 100644 index 0000000..3669f51 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/websocket/StompChannelInterceptor.java @@ -0,0 +1,73 @@ +package app.dearobjet.backend.domain.chat.websocket; + +import app.dearobjet.backend.global.auth.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * STOMP 메시지 인터셉터 + * WebSocket 연결 시 JWT 토큰 검증 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class StompChannelInterceptor implements ChannelInterceptor { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtProvider jwtProvider; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor == null) { + return message; + } + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + // CONNECT 시 JWT 토큰 검증 + String authHeader = accessor.getFirstNativeHeader(AUTHORIZATION_HEADER); + + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { + log.warn("WebSocket connection attempt without valid Authorization header"); + throw new IllegalArgumentException("Missing or invalid Authorization header"); + } + + String token = authHeader.substring(BEARER_PREFIX.length()); + + if (!jwtProvider.validateToken(token)) { + log.warn("WebSocket connection attempt with invalid JWT token"); + throw new IllegalArgumentException("Invalid JWT token"); + } + + Long userId = jwtProvider.getUserId(token); + String role = jwtProvider.getRole(token).name(); + + // Principal 설정 (인증 정보) + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority("ROLE_" + role)) + ); + + accessor.setUser(authentication); + log.info("WebSocket CONNECT - userId: {}, role: {}", userId, role); + } + + return message; + } +} diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/DevSecurityConfig.java b/src/main/java/app/dearobjet/backend/global/auth/security/DevSecurityConfig.java index 97d4911..52f0722 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/security/DevSecurityConfig.java +++ b/src/main/java/app/dearobjet/backend/global/auth/security/DevSecurityConfig.java @@ -16,6 +16,9 @@ public SecurityFilterChain devFilterChain(HttpSecurity http) throws Exception { .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .anyRequest().permitAll() + ) + .oauth2Login(oauth2 -> oauth2 + .defaultSuccessUrl("/", true) ); return http.build(); diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java index a12eb47..97e98aa 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java +++ b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java @@ -46,7 +46,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/oauth/**", "/oauth2/authorization/**", "/login/oauth2/**", - "/error" + "/error", + "/ws/**" ).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/app/dearobjet/backend/global/common/config/WebSocketConfig.java b/src/main/java/app/dearobjet/backend/global/common/config/WebSocketConfig.java new file mode 100644 index 0000000..1dd3d77 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/common/config/WebSocketConfig.java @@ -0,0 +1,50 @@ +package app.dearobjet.backend.global.common.config; + +import app.dearobjet.backend.domain.chat.websocket.StompChannelInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +/** + * WebSocket STOMP 설정 + * 스케일 아웃 환경에서는 Redis Pub/Sub을 통해 메시지 브로드캐스트 + */ +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompChannelInterceptor stompChannelInterceptor; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // STOMP 접속 엔드포인트 + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*") + .withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 메시지 구독(수신) prefix (서버 -> 클라이언트) + // /topic: 1:N 브로드캐스트 (채팅방 메시지) + // /queue: 1:1 개인 메시지 (알림 등) + registry.enableSimpleBroker("/topic", "/queue"); + + // 메시지 발행(송신) prefix (클라이언트 -> 서버) + registry.setApplicationDestinationPrefixes("/app"); + + // 사용자별 개인 메시지 prefix + registry.setUserDestinationPrefix("/user"); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // JWT 인증 인터셉터 등록 + registration.interceptors(stompChannelInterceptor); + } +} From 7ab3830049d5bb3e15ce4bb70902f6d7dc412c57 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 27 Feb 2026 15:02:51 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20=EC=B1=84=ED=8C=85=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B3=B5=ED=86=B5=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20-=20Redis=20=ED=85=9C=ED=94=8C=EB=A6=BF,?= =?UTF-8?q?=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=A7=81,=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../dearobjet/backend/BackendApplication.java | 2 + .../backend/global/config/RedisConfig.java | 57 +++++++++++++++++++ .../backend/global/exception/ErrorCode.java | 1 + 3 files changed, 60 insertions(+) diff --git a/src/main/java/app/dearobjet/backend/BackendApplication.java b/src/main/java/app/dearobjet/backend/BackendApplication.java index 197aefe..d4c5623 100644 --- a/src/main/java/app/dearobjet/backend/BackendApplication.java +++ b/src/main/java/app/dearobjet/backend/BackendApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class BackendApplication { public static void main(String[] args) { diff --git a/src/main/java/app/dearobjet/backend/global/config/RedisConfig.java b/src/main/java/app/dearobjet/backend/global/config/RedisConfig.java index d0aef45..e7c9cce 100644 --- a/src/main/java/app/dearobjet/backend/global/config/RedisConfig.java +++ b/src/main/java/app/dearobjet/backend/global/config/RedisConfig.java @@ -1,14 +1,34 @@ package app.dearobjet.backend.global.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { + /** + * 채팅용 ObjectMapper (LocalDateTime 직렬화 지원) + */ + @Bean + public ObjectMapper chatObjectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return objectMapper; + } + /** + * 기존 String-String RedisTemplate (RefreshToken 저장용) + */ + @Primary @Bean public RedisTemplate redisTemplate( RedisConnectionFactory connectionFactory) { @@ -23,4 +43,41 @@ public RedisTemplate redisTemplate( return template; } + + /** + * 채팅용 RedisTemplate (JSON 직렬화) + * 채팅 메시지, 세션 정보 등 객체 저장용 + */ + @Bean + public RedisTemplate chatRedisTemplate( + RedisConnectionFactory connectionFactory, + ObjectMapper chatObjectMapper) { + + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + GenericJackson2JsonRedisSerializer jsonSerializer = + new GenericJackson2JsonRedisSerializer(chatObjectMapper); + + template.setKeySerializer(stringSerializer); + template.setValueSerializer(jsonSerializer); + template.setHashKeySerializer(stringSerializer); + template.setHashValueSerializer(jsonSerializer); + + return template; + } + + /** + * Redis Pub/Sub 메시지 리스너 컨테이너 + * 스케일 아웃 환경에서 다중 서버 간 메시지 브로드캐스트용 + */ + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory connectionFactory) { + + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + return container; + } } diff --git a/src/main/java/app/dearobjet/backend/global/exception/ErrorCode.java b/src/main/java/app/dearobjet/backend/global/exception/ErrorCode.java index 04bad87..01d0888 100644 --- a/src/main/java/app/dearobjet/backend/global/exception/ErrorCode.java +++ b/src/main/java/app/dearobjet/backend/global/exception/ErrorCode.java @@ -29,6 +29,7 @@ public enum ErrorCode { CANNOT_CHAT_WITH_SELF(HttpStatus.BAD_REQUEST, "CH003", "자기 자신과는 채팅할 수 없습니다"), INVALID_CHAT_PARTICIPANTS(HttpStatus.BAD_REQUEST, "CH004", "유효하지 않은 참여자입니다"), GROUP_CHAT_MIN_PARTICIPANTS(HttpStatus.BAD_REQUEST, "CH005", "그룹 채팅은 최소 3명 이상이어야 합니다"), + CHAT_MESSAGE_PUBLISH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CH006", "메시지 발행에 실패했습니다"), // Auth (A0XX) INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A001", "유효하지 않은 토큰입니다"), From bf52ab5ef5eb4f884091381372d62e230f0b0960 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 27 Feb 2026 15:03:13 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20Redis=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20Pub/Sub=20=EB=B8=8C=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EC=BA=90=EC=8A=A4=ED=8A=B8,=20Presence,=20=EC=84=B8=EC=85=98,?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../domain/chat/dto/RedisChatMessageDto.java | 86 +++++++++ .../domain/chat/dto/UserPresenceDto.java | 46 +++++ .../service/redis/ChatMessagePublisher.java | 69 +++++++ .../service/redis/ChatMessageSubscriber.java | 95 ++++++++++ .../redis/MessageCacheRedisService.java | 150 +++++++++++++++ .../service/redis/PresenceRedisService.java | 166 +++++++++++++++++ .../service/redis/SessionRedisService.java | 146 +++++++++++++++ .../redis/UnreadCountRedisService.java | 174 ++++++++++++++++++ 8 files changed, 932 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/dto/RedisChatMessageDto.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/dto/UserPresenceDto.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/service/redis/ChatMessagePublisher.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/service/redis/ChatMessageSubscriber.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/service/redis/MessageCacheRedisService.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/service/redis/PresenceRedisService.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/service/redis/SessionRedisService.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/service/redis/UnreadCountRedisService.java diff --git a/src/main/java/app/dearobjet/backend/domain/chat/dto/RedisChatMessageDto.java b/src/main/java/app/dearobjet/backend/domain/chat/dto/RedisChatMessageDto.java new file mode 100644 index 0000000..03f9183 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/dto/RedisChatMessageDto.java @@ -0,0 +1,86 @@ +package app.dearobjet.backend.domain.chat.dto; + +import app.dearobjet.backend.domain.chat.entity.MessageType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Redis Pub/Sub을 통해 전달되는 채팅 메시지 + * 스케일 아웃 환경에서 다중 서버 간 메시지 브로드캐스트용 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RedisChatMessageDto { + + /** + * 메시지 이벤트 타입 + */ + public enum EventType { + MESSAGE, // 일반 메시지 + TYPING, // 타이핑 중 + READ, // 읽음 처리 + JOIN, // 채팅방 입장 + LEAVE // 채팅방 퇴장 + } + + private EventType eventType; + private String roomId; + private Long messageId; + private Long senderId; + private String senderName; + private String senderProfileImage; + private String content; + private MessageType messageType; + private LocalDateTime timestamp; + + /** + * 일반 메시지 생성 + */ + public static RedisChatMessageDto ofMessage(String roomId, Long messageId, Long senderId, + String senderName, String senderProfileImage, + String content, + MessageType messageType, LocalDateTime timestamp) { + return RedisChatMessageDto.builder() + .eventType(EventType.MESSAGE) + .roomId(roomId) + .messageId(messageId) + .senderId(senderId) + .senderName(senderName) + .senderProfileImage(senderProfileImage) + .content(content) + .messageType(messageType) + .timestamp(timestamp) + .build(); + } + + /** + * 타이핑 이벤트 생성 + */ + public static RedisChatMessageDto ofTyping(String roomId, Long senderId, String senderName) { + return RedisChatMessageDto.builder() + .eventType(EventType.TYPING) + .roomId(roomId) + .senderId(senderId) + .senderName(senderName) + .timestamp(LocalDateTime.now()) + .build(); + } + + /** + * 읽음 이벤트 생성 + */ + public static RedisChatMessageDto ofRead(String roomId, Long senderId, LocalDateTime readAt) { + return RedisChatMessageDto.builder() + .eventType(EventType.READ) + .roomId(roomId) + .senderId(senderId) + .timestamp(readAt) + .build(); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/dto/UserPresenceDto.java b/src/main/java/app/dearobjet/backend/domain/chat/dto/UserPresenceDto.java new file mode 100644 index 0000000..b86ed2f --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/dto/UserPresenceDto.java @@ -0,0 +1,46 @@ +package app.dearobjet.backend.domain.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자 온라인 상태 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserPresenceDto { + + public enum Status { + ONLINE, + AWAY, + OFFLINE + } + + private Long userId; + private Status status; + private LocalDateTime lastSeenAt; + private String activeRoomId; + + public static UserPresenceDto online(Long userId, String activeRoomId) { + return UserPresenceDto.builder() + .userId(userId) + .status(Status.ONLINE) + .lastSeenAt(LocalDateTime.now()) + .activeRoomId(activeRoomId) + .build(); + } + + public static UserPresenceDto offline(Long userId) { + return UserPresenceDto.builder() + .userId(userId) + .status(Status.OFFLINE) + .lastSeenAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/service/redis/ChatMessagePublisher.java b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/ChatMessagePublisher.java new file mode 100644 index 0000000..666b74d --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/ChatMessagePublisher.java @@ -0,0 +1,69 @@ +package app.dearobjet.backend.domain.chat.service.redis; + +import app.dearobjet.backend.domain.chat.dto.RedisChatMessageDto; +import app.dearobjet.backend.global.exception.BusinessException; +import app.dearobjet.backend.global.exception.ErrorCode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +/** + * Redis Pub/Sub 메시지 발행 서비스 + * 스케일 아웃 환경에서 다중 서버 간 메시지 브로드캐스트 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatMessagePublisher { + + private static final String CHANNEL_PREFIX = "chat:room:"; + private static final String USER_CHANNEL_PREFIX = "chat:user:"; + + private final RedisTemplate redisTemplate; + private final ObjectMapper chatObjectMapper; + + /** + * 채팅방에 메시지 발행 + * + * @param message 발행할 메시지 + */ + public void publishToRoom(RedisChatMessageDto message) { + String channel = CHANNEL_PREFIX + message.getRoomId(); + publish(channel, message); + } + + /** + * 특정 사용자에게 메시지 발행 (개인 알림용) + * + * @param userId 대상 사용자 ID + * @param message 발행할 메시지 + */ + public void publishToUser(Long userId, RedisChatMessageDto message) { + String channel = USER_CHANNEL_PREFIX + userId; + publish(channel, message); + } + + /** + * 채팅방의 모든 참여자에게 메시지 발행 + * + * @param roomId 채팅방 ID + * @param message 발행할 메시지 + */ + public void broadcastToRoom(String roomId, RedisChatMessageDto message) { + publishToRoom(message); + } + + private void publish(String channel, RedisChatMessageDto message) { + try { + String jsonMessage = chatObjectMapper.writeValueAsString(message); + redisTemplate.convertAndSend(channel, jsonMessage); + log.debug("Published message to channel {}: {}", channel, message.getEventType()); + } catch (JsonProcessingException e) { + log.error("Failed to serialize message for channel {}", channel, e); + throw new BusinessException(ErrorCode.CHAT_MESSAGE_PUBLISH_FAILED, e); + } + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/service/redis/ChatMessageSubscriber.java b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/ChatMessageSubscriber.java new file mode 100644 index 0000000..2021fe6 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/ChatMessageSubscriber.java @@ -0,0 +1,95 @@ +package app.dearobjet.backend.domain.chat.service.redis; + +import app.dearobjet.backend.domain.chat.dto.ChatMessageResponse; +import app.dearobjet.backend.domain.chat.dto.RedisChatMessageDto; +import app.dearobjet.backend.domain.chat.dto.ReadReceiptDto; +import app.dearobjet.backend.domain.chat.dto.TypingIndicatorDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +/** + * Redis Pub/Sub 메시지 구독 서비스 + * 다른 서버에서 발행한 메시지를 수신하여 로컬 WebSocket 클라이언트에게 전달 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatMessageSubscriber implements MessageListener { + + private static final String ROOM_PATTERN = "chat:room:*"; + private static final String USER_PATTERN = "chat:user:*"; + + private final RedisMessageListenerContainer listenerContainer; + private final SimpMessagingTemplate messagingTemplate; + private final ObjectMapper chatObjectMapper; + + @PostConstruct + public void init() { + // 채팅방 메시지 패턴 구독 + listenerContainer.addMessageListener(this, new PatternTopic(ROOM_PATTERN)); + // 개인 메시지 패턴 구독 + listenerContainer.addMessageListener(this, new PatternTopic(USER_PATTERN)); + log.info("Subscribed to Redis channels: {}, {}", ROOM_PATTERN, USER_PATTERN); + } + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + String channel = new String(message.getChannel()); + String body = new String(message.getBody()); + + RedisChatMessageDto redisMessage = chatObjectMapper.readValue(body, RedisChatMessageDto.class); + log.debug("Received message from channel {}: {}", channel, redisMessage.getEventType()); + + handleMessage(channel, redisMessage); + } catch (Exception e) { + log.error("Failed to process Redis message", e); + } + } + + private void handleMessage(String channel, RedisChatMessageDto message) { + String roomId = message.getRoomId(); + String destination = "/topic/chat/" + roomId; + + switch (message.getEventType()) { + case MESSAGE -> { + // 채팅 메시지를 WebSocket으로 전달 + ChatMessageResponse dto = ChatMessageResponse.builder() + .id(message.getMessageId()) + .roomId(message.getRoomId()) + .senderId(message.getSenderId()) + .senderName(message.getSenderName()) + .senderProfileImage(message.getSenderProfileImage()) + .content(message.getContent()) + .messageType(message.getMessageType()) + .createdAt(message.getTimestamp()) + .build(); + messagingTemplate.convertAndSend(destination, dto); + } + case TYPING -> { + // 타이핑 상태 전달 + TypingIndicatorDto dto = TypingIndicatorDto.of( + roomId, message.getSenderId(), message.getSenderName(), true); + messagingTemplate.convertAndSend(destination + "/typing", dto); + } + case READ -> { + // 읽음 처리 전달 + ReadReceiptDto dto = ReadReceiptDto.of( + roomId, message.getSenderId(), message.getTimestamp()); + messagingTemplate.convertAndSend(destination + "/read", dto); + } + case JOIN, LEAVE -> { + // 입장/퇴장 알림 전달 + messagingTemplate.convertAndSend(destination + "/presence", message); + } + } + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/service/redis/MessageCacheRedisService.java b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/MessageCacheRedisService.java new file mode 100644 index 0000000..607c9a8 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/MessageCacheRedisService.java @@ -0,0 +1,150 @@ +package app.dearobjet.backend.domain.chat.service.redis; + +import app.dearobjet.backend.domain.chat.dto.ChatMessageResponse; +import app.dearobjet.backend.domain.chat.entity.ChatMessage; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; + +/** + * Redis 기반 최근 메시지 캐싱 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MessageCacheRedisService { + + private static final String MESSAGES_KEY_PREFIX = "messages:room:"; + private static final int MAX_CACHED_MESSAGES = 50; + private static final Duration CACHE_TTL = Duration.ofHours(1); + + private final RedisTemplate redisTemplate; + private final ObjectMapper chatObjectMapper; + + /** + * 최근 메시지 목록에 메시지 추가 + * + * @param message 추가할 메시지 + */ + public void addRecentMessage(ChatMessage message, Long currentUserId) { + String key = buildKey(message.getRoomId()); + + try { + ChatMessageResponse dto = ChatMessageResponse.from(message, currentUserId); + String json = chatObjectMapper.writeValueAsString(dto); + + // 리스트 앞에 추가 (최신 메시지가 앞에) + redisTemplate.opsForList().leftPush(key, json); + // 최대 개수 유지 + redisTemplate.opsForList().trim(key, 0, MAX_CACHED_MESSAGES - 1); + // TTL 갱신 + redisTemplate.expire(key, CACHE_TTL); + + log.debug("Added message to cache for room {}", message.getRoomId()); + } catch (JsonProcessingException e) { + log.error("Failed to serialize message for caching", e); + } + } + + /** + * 채팅방의 최근 메시지 목록 조회 + * + * @param roomId 채팅방 ID + * @param limit 조회할 개수 + * @return 메시지 목록 (최신순) + */ + public List getRecentMessages(String roomId, int limit) { + String key = buildKey(roomId); + + try { + List jsonMessages = redisTemplate.opsForList().range(key, 0, limit - 1); + if (jsonMessages == null || jsonMessages.isEmpty()) { + return Collections.emptyList(); + } + + return jsonMessages.stream() + .map(this::deserializeMessage) + .filter(dto -> dto != null) + .toList(); + } catch (Exception e) { + log.error("Failed to get recent messages from cache for room {}", roomId, e); + return Collections.emptyList(); + } + } + + /** + * 캐시 존재 여부 확인 + * + * @param roomId 채팅방 ID + * @return true - 캐시 존재, false - 캐시 없음 + */ + public boolean hasCache(String roomId) { + String key = buildKey(roomId); + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + /** + * 캐시 무효화 + * + * @param roomId 채팅방 ID + */ + public void invalidateCache(String roomId) { + String key = buildKey(roomId); + redisTemplate.delete(key); + log.debug("Invalidated message cache for room {}", roomId); + } + + /** + * 캐시 워밍업: DB에서 조회한 메시지로 캐시 초기화 + * + * @param roomId 채팅방 ID + * @param messages 메시지 목록 (최신순) + * @param currentUserId 현재 사용자 ID + */ + public void warmUp(String roomId, List messages, Long currentUserId) { + if (messages.isEmpty()) { + return; + } + + String key = buildKey(roomId); + + // 기존 캐시 삭제 + redisTemplate.delete(key); + + // 메시지 추가 (역순으로 추가해야 최신이 앞에 위치) + List reversed = new java.util.ArrayList<>(messages); + java.util.Collections.reverse(reversed); + for (ChatMessage message : reversed) { + try { + ChatMessageResponse dto = ChatMessageResponse.from(message, currentUserId); + String json = chatObjectMapper.writeValueAsString(dto); + redisTemplate.opsForList().leftPush(key, json); + } catch (JsonProcessingException e) { + log.error("Failed to serialize message during warmup", e); + } + } + + redisTemplate.expire(key, CACHE_TTL); + log.debug("Warmed up message cache for room {} with {} messages", roomId, messages.size()); + } + + private String buildKey(String roomId) { + return MESSAGES_KEY_PREFIX + roomId + ":recent"; + } + + private ChatMessageResponse deserializeMessage(String json) { + try { + return chatObjectMapper.readValue(json, ChatMessageResponse.class); + } catch (JsonProcessingException e) { + log.error("Failed to deserialize message from cache", e); + return null; + } + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/service/redis/PresenceRedisService.java b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/PresenceRedisService.java new file mode 100644 index 0000000..911d218 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/PresenceRedisService.java @@ -0,0 +1,166 @@ +package app.dearobjet.backend.domain.chat.service.redis; + +import app.dearobjet.backend.domain.chat.dto.UserPresenceDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Redis 기반 사용자 온라인 상태 관리 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PresenceRedisService { + + private static final String PRESENCE_KEY_PREFIX = "presence:"; + private static final String ROOM_PRESENCE_KEY_PREFIX = "presence:room:"; + private static final Duration PRESENCE_TTL = Duration.ofMinutes(5); + + private final RedisTemplate redisTemplate; + + /** + * 사용자 온라인 상태 설정 + * + * @param userId 사용자 ID + * @param status 상태 + */ + public void setPresence(Long userId, UserPresenceDto.Status status) { + String key = PRESENCE_KEY_PREFIX + userId; + redisTemplate.opsForValue().set(key, status.name(), PRESENCE_TTL); + log.debug("Set presence for user {}: {}", userId, status); + } + + /** + * 사용자 온라인 상태 조회 + * + * @param userId 사용자 ID + * @return 상태 (없으면 OFFLINE) + */ + public UserPresenceDto.Status getPresence(Long userId) { + String key = PRESENCE_KEY_PREFIX + userId; + String value = redisTemplate.opsForValue().get(key); + + if (value == null) { + return UserPresenceDto.Status.OFFLINE; + } + + try { + return UserPresenceDto.Status.valueOf(value); + } catch (IllegalArgumentException e) { + return UserPresenceDto.Status.OFFLINE; + } + } + + /** + * 사용자 온라인 상태 갱신 (heartbeat) + * + * @param userId 사용자 ID + */ + public void refreshPresence(Long userId) { + String key = PRESENCE_KEY_PREFIX + userId; + if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { + redisTemplate.expire(key, PRESENCE_TTL); + } + } + + /** + * 사용자 오프라인 처리 + * + * @param userId 사용자 ID + */ + public void setOffline(Long userId) { + String key = PRESENCE_KEY_PREFIX + userId; + redisTemplate.delete(key); + log.debug("User {} is now offline", userId); + } + + /** + * 채팅방에 사용자 입장 기록 + * + * @param roomId 채팅방 ID + * @param userId 사용자 ID + */ + public void joinRoom(String roomId, Long userId) { + String key = ROOM_PRESENCE_KEY_PREFIX + roomId; + double score = Instant.now().toEpochMilli(); + redisTemplate.opsForZSet().add(key, userId.toString(), score); + log.debug("User {} joined room {}", userId, roomId); + } + + /** + * 채팅방에서 사용자 퇴장 기록 + * + * @param roomId 채팅방 ID + * @param userId 사용자 ID + */ + public void leaveRoom(String roomId, Long userId) { + String key = ROOM_PRESENCE_KEY_PREFIX + roomId; + redisTemplate.opsForZSet().remove(key, userId.toString()); + log.debug("User {} left room {}", userId, roomId); + } + + /** + * 채팅방의 온라인 사용자 목록 조회 + * + * @param roomId 채팅방 ID + * @return 온라인 사용자 ID 목록 + */ + public Set getOnlineUsersInRoom(String roomId) { + String key = ROOM_PRESENCE_KEY_PREFIX + roomId; + Set members = redisTemplate.opsForZSet().range(key, 0, -1); + + if (members == null) { + return Set.of(); + } + + // 5분 이상 활동 없는 사용자 제거 + long cutoffTime = Instant.now().minusMillis(PRESENCE_TTL.toMillis()).toEpochMilli(); + redisTemplate.opsForZSet().removeRangeByScore(key, 0, cutoffTime); + + return members.stream() + .map(Long::parseLong) + .collect(Collectors.toSet()); + } + + /** + * 채팅방 내 사용자 활동 시간 갱신 + * + * @param roomId 채팅방 ID + * @param userId 사용자 ID + */ + public void updateRoomActivity(String roomId, Long userId) { + String key = ROOM_PRESENCE_KEY_PREFIX + roomId; + double score = Instant.now().toEpochMilli(); + redisTemplate.opsForZSet().add(key, userId.toString(), score); + } + + /** + * 사용자의 마지막 활동 시간 조회 + * + * @param roomId 채팅방 ID + * @param userId 사용자 ID + * @return 마지막 활동 시간 (없으면 null) + */ + public LocalDateTime getLastActivity(String roomId, Long userId) { + String key = ROOM_PRESENCE_KEY_PREFIX + roomId; + Double score = redisTemplate.opsForZSet().score(key, userId.toString()); + + if (score == null) { + return null; + } + + return LocalDateTime.ofInstant( + Instant.ofEpochMilli(score.longValue()), + ZoneId.systemDefault() + ); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/service/redis/SessionRedisService.java b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/SessionRedisService.java new file mode 100644 index 0000000..f225c4c --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/SessionRedisService.java @@ -0,0 +1,146 @@ +package app.dearobjet.backend.domain.chat.service.redis; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Redis 기반 WebSocket 세션 관리 서비스 + * 스케일 아웃 환경에서 사용자가 어느 서버에 연결되어 있는지 추적 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SessionRedisService { + + private static final String SESSION_KEY_PREFIX = "session:user:"; + private static final String SERVER_USERS_KEY_PREFIX = "session:server:"; + private static final Duration SESSION_TTL = Duration.ofMinutes(30); + + private final RedisTemplate chatRedisTemplate; + + /** + * 사용자 세션 등록 + * + * @param userId 사용자 ID + * @param serverId 서버 인스턴스 ID + * @param sessionId WebSocket 세션 ID + */ + public void registerSession(Long userId, String serverId, String sessionId) { + String sessionKey = SESSION_KEY_PREFIX + userId; + + Map sessionData = new HashMap<>(); + sessionData.put("serverId", serverId); + sessionData.put("sessionId", sessionId); + sessionData.put("connectedAt", LocalDateTime.now().toString()); + + chatRedisTemplate.opsForHash().putAll(sessionKey, sessionData); + chatRedisTemplate.expire(sessionKey, SESSION_TTL); + + // 서버별 연결 사용자 목록에 추가 + String serverKey = SERVER_USERS_KEY_PREFIX + serverId + ":users"; + chatRedisTemplate.opsForSet().add(serverKey, userId); + + log.info("Session registered - userId: {}, serverId: {}, sessionId: {}", userId, serverId, sessionId); + } + + /** + * 사용자 세션 해제 + * + * @param userId 사용자 ID + * @param serverId 서버 인스턴스 ID + */ + public void removeSession(Long userId, String serverId) { + String sessionKey = SESSION_KEY_PREFIX + userId; + chatRedisTemplate.delete(sessionKey); + + // 서버별 연결 사용자 목록에서 제거 + String serverKey = SERVER_USERS_KEY_PREFIX + serverId + ":users"; + chatRedisTemplate.opsForSet().remove(serverKey, userId); + + log.info("Session removed - userId: {}, serverId: {}", userId, serverId); + } + + /** + * 사용자가 연결된 서버 ID 조회 + * + * @param userId 사용자 ID + * @return 서버 ID (연결되어 있지 않으면 null) + */ + public String getServerIdForUser(Long userId) { + String sessionKey = SESSION_KEY_PREFIX + userId; + Object serverId = chatRedisTemplate.opsForHash().get(sessionKey, "serverId"); + return serverId != null ? serverId.toString() : null; + } + + /** + * 사용자 연결 여부 확인 + * + * @param userId 사용자 ID + * @return true - 연결됨, false - 미연결 + */ + public boolean isUserConnected(Long userId) { + String sessionKey = SESSION_KEY_PREFIX + userId; + return Boolean.TRUE.equals(chatRedisTemplate.hasKey(sessionKey)); + } + + /** + * 세션 TTL 갱신 (heartbeat) + * + * @param userId 사용자 ID + */ + public void refreshSession(Long userId) { + String sessionKey = SESSION_KEY_PREFIX + userId; + chatRedisTemplate.expire(sessionKey, SESSION_TTL); + } + + /** + * 현재 활성 채팅방 설정 + * + * @param userId 사용자 ID + * @param roomId 채팅방 ID + */ + public void setActiveRoom(Long userId, String roomId) { + String sessionKey = SESSION_KEY_PREFIX + userId; + chatRedisTemplate.opsForHash().put(sessionKey, "activeRoomId", roomId); + } + + /** + * 현재 활성 채팅방 조회 + * + * @param userId 사용자 ID + * @return 활성 채팅방 ID (없으면 null) + */ + public String getActiveRoom(Long userId) { + String sessionKey = SESSION_KEY_PREFIX + userId; + Object roomId = chatRedisTemplate.opsForHash().get(sessionKey, "activeRoomId"); + return roomId != null ? roomId.toString() : null; + } + + /** + * 서버 종료 시 해당 서버의 모든 세션 정리 + * + * @param serverId 서버 인스턴스 ID + */ + public void removeAllSessionsForServer(String serverId) { + String serverKey = SERVER_USERS_KEY_PREFIX + serverId + ":users"; + Set userIds = chatRedisTemplate.opsForSet().members(serverKey); + + if (userIds != null) { + for (Object userId : userIds) { + String sessionKey = SESSION_KEY_PREFIX + userId; + chatRedisTemplate.delete(sessionKey); + } + } + + chatRedisTemplate.delete(serverKey); + log.info("All sessions removed for server: {}", serverId); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/service/redis/UnreadCountRedisService.java b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/UnreadCountRedisService.java new file mode 100644 index 0000000..65067d1 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/service/redis/UnreadCountRedisService.java @@ -0,0 +1,174 @@ +package app.dearobjet.backend.domain.chat.service.redis; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Set; + +/** + * Redis 기반 읽지 않은 메시지 수 관리 서비스 + * 원자적 연산(INCR/DECR)으로 동시성 문제 해결 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UnreadCountRedisService { + + private static final String UNREAD_KEY_PREFIX = "unread:"; + private static final String TOTAL_UNREAD_KEY_PREFIX = "unread:total:"; + private static final String DIRTY_SET_KEY = "unread:dirty"; + private static final Duration UNREAD_TTL = Duration.ofDays(7); + + private final RedisTemplate redisTemplate; + + /** + * 읽지 않은 메시지 수 증가 (원자적 연산) + * + * @param userId 사용자 ID + * @param roomId 채팅방 ID + * @return 증가 후 카운트 + */ + public long incrementUnreadCount(Long userId, String roomId) { + String key = buildUnreadKey(userId, roomId); + Long count = redisTemplate.opsForValue().increment(key); + redisTemplate.expire(key, UNREAD_TTL); + + // 전체 읽지 않은 수도 증가 + incrementTotalUnreadCount(userId); + + // DB 동기화 대상으로 등록 + markDirty(userId, roomId); + + log.debug("Incremented unread count for user {} in room {}: {}", userId, roomId, count); + return count != null ? count : 0; + } + + /** + * 읽지 않은 메시지 수 조회 + * + * @param userId 사용자 ID + * @param roomId 채팅방 ID + * @return 읽지 않은 메시지 수 + */ + public int getUnreadCount(Long userId, String roomId) { + String key = buildUnreadKey(userId, roomId); + try { + String value = redisTemplate.opsForValue().get(key); + return value != null ? Integer.parseInt(value) : 0; + } catch (DataAccessException e) { + log.warn("Failed to get unread count from Redis for user {} in room {}", userId, roomId); + return 0; + } + } + + /** + * 채팅방 읽음 처리 (카운트 초기화) + * + * @param userId 사용자 ID + * @param roomId 채팅방 ID + * @return 초기화 전 카운트 (DB 동기화용) + */ + public int markAsRead(Long userId, String roomId) { + String key = buildUnreadKey(userId, roomId); + String value = redisTemplate.opsForValue().getAndDelete(key); + int previousCount = value != null ? Integer.parseInt(value) : 0; + + // 전체 읽지 않은 수에서 차감 + if (previousCount > 0) { + decrementTotalUnreadCount(userId, previousCount); + } + + // DB 동기화 대상으로 등록 (0으로 초기화된 상태도 동기화 필요) + markDirty(userId, roomId); + + log.debug("Marked as read for user {} in room {}, previous count: {}", userId, roomId, previousCount); + return previousCount; + } + + /** + * 사용자의 전체 읽지 않은 메시지 수 조회 + * + * @param userId 사용자 ID + * @return 전체 읽지 않은 메시지 수 + */ + public int getTotalUnreadCount(Long userId) { + String key = TOTAL_UNREAD_KEY_PREFIX + userId; + try { + String value = redisTemplate.opsForValue().get(key); + return value != null ? Integer.parseInt(value) : 0; + } catch (DataAccessException e) { + log.warn("Failed to get total unread count from Redis for user {}", userId); + return 0; + } + } + + /** + * 캐시 워밍업: DB 값으로 Redis 초기화 + * + * @param userId 사용자 ID + * @param roomId 채팅방 ID + * @param unreadCount DB에서 조회한 읽지 않은 수 + */ + public void warmUp(Long userId, String roomId, int unreadCount) { + if (unreadCount > 0) { + String key = buildUnreadKey(userId, roomId); + redisTemplate.opsForValue().set(key, String.valueOf(unreadCount), UNREAD_TTL); + } + } + + /** + * 특정 채팅방의 모든 참여자 unread 카운트 일괄 증가 + * + * @param roomId 채팅방 ID + * @param participantIds 참여자 ID 목록 + * @param excludeUserId 제외할 사용자 ID (발신자) + */ + public void incrementForParticipants(String roomId, Set participantIds, Long excludeUserId) { + for (Long userId : participantIds) { + if (!userId.equals(excludeUserId)) { + incrementUnreadCount(userId, roomId); + } + } + } + + /** + * 변경된 (userId, roomId) 쌍을 dirty set에 등록 + */ + private void markDirty(Long userId, String roomId) { + // "userId:roomId" 형태로 저장 + redisTemplate.opsForSet().add(DIRTY_SET_KEY, userId + ":" + roomId); + } + + /** + * DB 동기화 대상 dirty entries 조회 후 제거 + * SMEMBERS + DEL로 구현 (원자적이지 않지만, 유실된 entry는 다음 변경 시 재등록됨) + * + * @return dirty entries 목록 ("userId:roomId" 형태) + */ + public Set popDirtyEntries() { + Set entries = redisTemplate.opsForSet().members(DIRTY_SET_KEY); + if (entries != null && !entries.isEmpty()) { + redisTemplate.delete(DIRTY_SET_KEY); + } + return entries != null ? entries : Set.of(); + } + + private String buildUnreadKey(Long userId, String roomId) { + return UNREAD_KEY_PREFIX + userId + ":" + roomId; + } + + private void incrementTotalUnreadCount(Long userId) { + String key = TOTAL_UNREAD_KEY_PREFIX + userId; + redisTemplate.opsForValue().increment(key); + redisTemplate.expire(key, UNREAD_TTL); + } + + private void decrementTotalUnreadCount(Long userId, int amount) { + String key = TOTAL_UNREAD_KEY_PREFIX + userId; + redisTemplate.opsForValue().decrement(key, amount); + } +} From 4efd53b1c12645ba8c9175198638722bba3ed8a8 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 27 Feb 2026 15:03:20 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9/?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=EC=9E=90=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=B0=8F=20REST=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../chat/controller/ChatRoomController.java | 80 +++++++ .../domain/chat/dto/ChatRoomResponse.java | 91 ++++++++ .../chat/dto/CreateChatRoomRequest.java | 32 +++ .../chat/service/ChatParticipantService.java | 169 ++++++++++++++ .../domain/chat/service/ChatRoomService.java | 207 ++++++++++++++++++ .../service/ChatParticipantServiceTest.java | 167 ++++++++++++++ .../chat/service/ChatRoomServiceTest.java | 165 ++++++++++++++ 7 files changed, 911 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/controller/ChatRoomController.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/dto/ChatRoomResponse.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/dto/CreateChatRoomRequest.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/service/ChatParticipantService.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/service/ChatRoomService.java create mode 100644 src/test/java/app/dearobjet/backend/domain/chat/service/ChatParticipantServiceTest.java create mode 100644 src/test/java/app/dearobjet/backend/domain/chat/service/ChatRoomServiceTest.java diff --git a/src/main/java/app/dearobjet/backend/domain/chat/controller/ChatRoomController.java b/src/main/java/app/dearobjet/backend/domain/chat/controller/ChatRoomController.java new file mode 100644 index 0000000..bbdf955 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/controller/ChatRoomController.java @@ -0,0 +1,80 @@ +package app.dearobjet.backend.domain.chat.controller; + +import app.dearobjet.backend.domain.chat.dto.ChatRoomResponse; +import app.dearobjet.backend.domain.chat.dto.CreateChatRoomRequest; +import app.dearobjet.backend.domain.chat.service.ChatRoomService; +import app.dearobjet.backend.global.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 채팅방 REST API 컨트롤러 + */ +@RestController +@RequestMapping("/api/chat/rooms") +@RequiredArgsConstructor +public class ChatRoomController { + + private final ChatRoomService chatRoomService; + + /** + * 채팅방 생성 또는 기존 채팅방 조회 + * + * POST /api/chat/rooms + */ + @PostMapping + public ApiResponse createOrGetChatRoom( + @AuthenticationPrincipal(expression = "userId") Long userId, + @Valid @RequestBody CreateChatRoomRequest request) { + + ChatRoomResponse chatRoom = chatRoomService.createOrGetChatRoom(userId, request); + return ApiResponse.of(chatRoom); + } + + /** + * 내 채팅방 목록 조회 + * + * GET /api/chat/rooms + */ + @GetMapping + public ApiResponse> getMyChatRooms( + @AuthenticationPrincipal(expression = "userId") Long userId) { + + List chatRooms = chatRoomService.getMyChatRooms(userId); + return ApiResponse.of(chatRooms); + } + + /** + * 채팅방 상세 조회 + * + * GET /api/chat/rooms/{roomId} + */ + @GetMapping("/{roomId}") + public ApiResponse getChatRoom( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable String roomId) { + + ChatRoomResponse chatRoom = chatRoomService.getChatRoom(roomId, userId); + return ApiResponse.of(chatRoom); + } + + /** + * 1:1 채팅방 생성 (상대방 ID로 바로 생성) + * + * POST /api/chat/rooms/direct/{partnerId} + */ + @PostMapping("/direct/{partnerId}") + public ApiResponse createDirectChatRoom( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable Long partnerId) { + + CreateChatRoomRequest request = CreateChatRoomRequest.oneToOne(partnerId); + ChatRoomResponse chatRoom = chatRoomService.createOrGetChatRoom(userId, request); + return ApiResponse.of(chatRoom); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/dto/ChatRoomResponse.java b/src/main/java/app/dearobjet/backend/domain/chat/dto/ChatRoomResponse.java new file mode 100644 index 0000000..b452f07 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/dto/ChatRoomResponse.java @@ -0,0 +1,91 @@ +package app.dearobjet.backend.domain.chat.dto; + +import app.dearobjet.backend.domain.chat.entity.ChatParticipant; +import app.dearobjet.backend.domain.chat.entity.ChatRoom; +import app.dearobjet.backend.domain.chat.entity.ChatRoomType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 채팅방 정보 응답 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChatRoomResponse { + + private Long id; + private String roomId; + private ChatRoomType type; + private String lastMessage; + private LocalDateTime lastMessageAt; + private int unreadCount; + private List participants; + + // 1:1 채팅의 경우 상대방 정보 + private String partnerName; + private String partnerProfileImage; + + /** + * 엔티티로부터 DTO 생성 + */ + public static ChatRoomResponse from(ChatRoom chatRoom, ChatParticipant myParticipation, Long currentUserId) { + List participantResponses = chatRoom.getParticipants().stream() + .map(ParticipantResponse::from) + .toList(); + + // 1:1 채팅인 경우 상대방 정보 추출 + String partnerName = null; + String partnerProfileImage = null; + + if (chatRoom.getType() == ChatRoomType.ONE_TO_ONE) { + ChatParticipant partner = chatRoom.getParticipants().stream() + .filter(p -> !p.getUser().getId().equals(currentUserId)) + .findFirst() + .orElse(null); + + if (partner != null) { + partnerName = partner.getUser().getName(); + partnerProfileImage = partner.getUser().getProfileImage(); + } + } + + return ChatRoomResponse.builder() + .id(chatRoom.getId()) + .roomId(chatRoom.getRoomId()) + .type(chatRoom.getType()) + .lastMessage(chatRoom.getLastMessage()) + .lastMessageAt(chatRoom.getUpdatedAt()) + .unreadCount(myParticipation != null ? myParticipation.getUnreadCount() : 0) + .participants(participantResponses) + .partnerName(partnerName) + .partnerProfileImage(partnerProfileImage) + .build(); + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ParticipantResponse { + private Long userId; + private String nickname; + private String profileImageUrl; + private LocalDateTime joinedAt; + + public static ParticipantResponse from(ChatParticipant participant) { + return ParticipantResponse.builder() + .userId(participant.getUser().getId()) + .nickname(participant.getUser().getName()) + .profileImageUrl(participant.getUser().getProfileImage()) + .joinedAt(participant.getJoinedAt()) + .build(); + } + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/dto/CreateChatRoomRequest.java b/src/main/java/app/dearobjet/backend/domain/chat/dto/CreateChatRoomRequest.java new file mode 100644 index 0000000..883e1ba --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/dto/CreateChatRoomRequest.java @@ -0,0 +1,32 @@ +package app.dearobjet.backend.domain.chat.dto; + +import app.dearobjet.backend.domain.chat.entity.ChatRoomType; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 채팅방 생성 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CreateChatRoomRequest { + + @NotNull(message = "채팅방 타입은 필수입니다") + private ChatRoomType type; + + @NotEmpty(message = "참여자 목록은 필수입니다") + private List participantIds; + + /** + * 1:1 채팅방 생성용 편의 생성자 + */ + public static CreateChatRoomRequest oneToOne(Long partnerId) { + return new CreateChatRoomRequest(ChatRoomType.ONE_TO_ONE, List.of(partnerId)); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/service/ChatParticipantService.java b/src/main/java/app/dearobjet/backend/domain/chat/service/ChatParticipantService.java new file mode 100644 index 0000000..f727e56 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/service/ChatParticipantService.java @@ -0,0 +1,169 @@ +package app.dearobjet.backend.domain.chat.service; + +import app.dearobjet.backend.domain.chat.dto.UserPresenceDto; +import app.dearobjet.backend.domain.chat.entity.ChatParticipant; +import app.dearobjet.backend.domain.chat.repository.ChatParticipantRepository; +import app.dearobjet.backend.domain.chat.service.redis.PresenceRedisService; +import app.dearobjet.backend.domain.chat.service.redis.UnreadCountRedisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; + +/** + * 채팅 참여자 관리 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatParticipantService { + + private final ChatParticipantRepository chatParticipantRepository; + private final UnreadCountRedisService unreadCountRedisService; + private final PresenceRedisService presenceRedisService; + + /** + * 참여자 정보 조회 + * + * @param roomId 채팅방 UUID + * @param userId 사용자 ID + * @return 참여자 정보 (없으면 null) + */ + public ChatParticipant getParticipant(String roomId, Long userId) { + return chatParticipantRepository.findByRoomIdAndUserId(roomId, userId).orElse(null); + } + + /** + * 채팅방의 모든 참여자 조회 + * + * @param chatRoomId 채팅방 DB ID + * @return 참여자 목록 + */ + public List getParticipants(Long chatRoomId) { + return chatParticipantRepository.findAllByRoomId(chatRoomId); + } + + /** + * 읽지 않은 메시지 수 조회 (Redis 우선, DB 폴백) + * + * @param userId 사용자 ID + * @param roomId 채팅방 UUID + * @return 읽지 않은 메시지 수 + */ + public int getUnreadCount(Long userId, String roomId) { + // Redis에서 먼저 조회 + int redisCount = unreadCountRedisService.getUnreadCount(userId, roomId); + if (redisCount > 0) { + return redisCount; + } + + // Redis에 없으면 DB에서 조회 + ChatParticipant participant = chatParticipantRepository.findByRoomIdAndUserId(roomId, userId) + .orElse(null); + + if (participant == null) { + return 0; + } + + int dbCount = participant.getUnreadCount(); + + // DB 값이 있으면 Redis에 캐시 + if (dbCount > 0) { + unreadCountRedisService.warmUp(userId, roomId, dbCount); + } + + return dbCount; + } + + /** + * 사용자의 전체 읽지 않은 메시지 수 조회 + * + * @param userId 사용자 ID + * @return 전체 읽지 않은 메시지 수 + */ + public int getTotalUnreadCount(Long userId) { + // Redis에서 조회 + int redisTotal = unreadCountRedisService.getTotalUnreadCount(userId); + if (redisTotal > 0) { + return redisTotal; + } + + // Redis에 없으면 DB에서 계산 + List participations = chatParticipantRepository + .findMyParticipationsWithDetails(userId); + + return participations.stream() + .mapToInt(ChatParticipant::getUnreadCount) + .sum(); + } + + /** + * 채팅방의 온라인 사용자 목록 조회 + * + * @param roomId 채팅방 UUID + * @return 온라인 사용자 ID 목록 + */ + public Set getOnlineUsersInRoom(String roomId) { + return presenceRedisService.getOnlineUsersInRoom(roomId); + } + + /** + * 사용자 온라인 상태 조회 + * + * @param userId 사용자 ID + * @return 온라인 상태 + */ + public UserPresenceDto.Status getUserPresence(Long userId) { + return presenceRedisService.getPresence(userId); + } + + /** + * Redis -> DB unread 카운트 동기화 (배치) + * 주기적으로 Redis의 unread 카운트를 DB에 동기화 + */ + /** + * Redis -> DB unread 카운트 동기화 (배치) + * dirty flag가 있는 항목만 동기화하여 불필요한 DB 쿼리 최소화 + */ + @Scheduled(fixedRate = 300000) // 5분마다 + @Transactional + public void syncUnreadCountsToDb() { + Set dirtyEntries = unreadCountRedisService.popDirtyEntries(); + if (dirtyEntries.isEmpty()) { + return; + } + + log.debug("Syncing {} dirty unread count entries to DB", dirtyEntries.size()); + + int syncedCount = 0; + for (String entry : dirtyEntries) { + try { + // "userId:roomId" 형태 파싱 + String[] parts = entry.split(":", 2); + if (parts.length != 2) { + log.warn("Invalid dirty entry format: {}", entry); + continue; + } + + Long userId = Long.parseLong(parts[0]); + String roomId = parts[1]; + + int redisCount = unreadCountRedisService.getUnreadCount(userId, roomId); + + chatParticipantRepository.findByRoomIdAndUserId(roomId, userId) + .ifPresent(participant -> participant.updateUnreadCount(redisCount)); + + syncedCount++; + } catch (NumberFormatException e) { + log.warn("Invalid userId in dirty entry: {}", entry); + } + } + + log.debug("Unread count sync completed: {}/{} entries", syncedCount, dirtyEntries.size()); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/service/ChatRoomService.java b/src/main/java/app/dearobjet/backend/domain/chat/service/ChatRoomService.java new file mode 100644 index 0000000..994d15e --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/service/ChatRoomService.java @@ -0,0 +1,207 @@ +package app.dearobjet.backend.domain.chat.service; + +import app.dearobjet.backend.domain.chat.dto.ChatRoomResponse; +import app.dearobjet.backend.domain.chat.dto.CreateChatRoomRequest; +import app.dearobjet.backend.domain.chat.entity.ChatParticipant; +import app.dearobjet.backend.domain.chat.entity.ChatRoom; +import app.dearobjet.backend.domain.chat.entity.ChatRoomType; +import app.dearobjet.backend.domain.chat.repository.ChatParticipantRepository; +import app.dearobjet.backend.domain.chat.repository.ChatRoomRepository; +import app.dearobjet.backend.domain.chat.service.redis.UnreadCountRedisService; +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.repository.UserRepository; +import app.dearobjet.backend.global.exception.EntityNotFoundException; +import app.dearobjet.backend.global.exception.ErrorCode; +import app.dearobjet.backend.global.exception.InvalidInputException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.DigestUtils; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 채팅방 비즈니스 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatRoomService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatParticipantRepository chatParticipantRepository; + private final UserRepository userRepository; + private final UnreadCountRedisService unreadCountRedisService; + + /** + * 채팅방 생성 또는 기존 채팅방 반환 + * + * @param currentUserId 현재 사용자 ID + * @param request 채팅방 생성 요청 + * @return 채팅방 정보 + */ + @Transactional + public ChatRoomResponse createOrGetChatRoom(Long currentUserId, CreateChatRoomRequest request) { + // 참여자 목록에 현재 사용자 추가 + List allParticipantIds = new java.util.ArrayList<>(request.getParticipantIds()); + if (!allParticipantIds.contains(currentUserId)) { + allParticipantIds.add(currentUserId); + } + + // 1:1 채팅의 경우 기존 채팅방 확인 + if (request.getType() == ChatRoomType.ONE_TO_ONE && allParticipantIds.size() == 2) { + String participantHash = generateParticipantHash(allParticipantIds); + return chatRoomRepository.findByParticipantHash(participantHash) + .map(room -> { + ChatParticipant myParticipation = chatParticipantRepository + .findByChatRoomIdAndUserId(room.getId(), currentUserId) + .orElse(null); + return ChatRoomResponse.from(room, myParticipation, currentUserId); + }) + .orElseGet(() -> createNewChatRoom(currentUserId, allParticipantIds, request.getType())); + } + + return createNewChatRoom(currentUserId, allParticipantIds, request.getType()); + } + + /** + * 새 채팅방 생성 + */ + @Transactional + protected ChatRoomResponse createNewChatRoom(Long currentUserId, List participantIds, ChatRoomType type) { + List participants = userRepository.findAllById(participantIds); + + if (participants.size() != participantIds.size()) { + throw new InvalidInputException(ErrorCode.INVALID_CHAT_PARTICIPANTS); + } + + ChatRoom chatRoom; + if (type == ChatRoomType.ONE_TO_ONE) { + User currentUser = participants.stream() + .filter(u -> u.getId().equals(currentUserId)) + .findFirst() + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); + + User partner = participants.stream() + .filter(u -> !u.getId().equals(currentUserId)) + .findFirst() + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); + + chatRoom = ChatRoom.createOneToOne(currentUser, partner); + } else { + chatRoom = ChatRoom.createGroup(participants); + } + + chatRoom = chatRoomRepository.save(chatRoom); + + ChatParticipant myParticipation = chatParticipantRepository + .findByChatRoomIdAndUserId(chatRoom.getId(), currentUserId) + .orElse(null); + + log.info("Created new chat room - roomId: {}, type: {}, participants: {}", + chatRoom.getRoomId(), type, participantIds); + + return ChatRoomResponse.from(chatRoom, myParticipation, currentUserId); + } + + /** + * 내 채팅방 목록 조회 + * + * @param userId 사용자 ID + * @return 채팅방 목록 + */ + public List getMyChatRooms(Long userId) { + List participations = chatParticipantRepository + .findMyParticipationsWithDetails(userId); + + return participations.stream() + .map(participation -> { + ChatRoom chatRoom = participation.getChatRoom(); + + // Redis에서 unread 카운트 조회 (캐시 우선) + int unreadCount = unreadCountRedisService.getUnreadCount(userId, chatRoom.getRoomId()); + if (unreadCount == 0) { + // 캐시 미스 시 DB 값 사용 및 캐시 워밍업 + unreadCount = participation.getUnreadCount(); + if (unreadCount > 0) { + unreadCountRedisService.warmUp(userId, chatRoom.getRoomId(), unreadCount); + } + } + + // 임시로 participation의 unreadCount를 Redis 값으로 설정 + ChatRoomResponse dto = ChatRoomResponse.from(chatRoom, participation, userId); + return ChatRoomResponse.builder() + .id(dto.getId()) + .roomId(dto.getRoomId()) + .type(dto.getType()) + .lastMessage(dto.getLastMessage()) + .lastMessageAt(dto.getLastMessageAt()) + .unreadCount(unreadCount) + .participants(dto.getParticipants()) + .partnerName(dto.getPartnerName()) + .partnerProfileImage(dto.getPartnerProfileImage()) + .build(); + }) + .toList(); + } + + /** + * 채팅방 상세 조회 + * + * @param roomId 채팅방 UUID + * @param currentUserId 현재 사용자 ID + * @return 채팅방 정보 + */ + public ChatRoomResponse getChatRoom(String roomId, Long currentUserId) { + ChatRoom chatRoom = chatRoomRepository.findByRoomIdWithParticipants(roomId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.CHAT_ROOM_NOT_FOUND)); + + // 참여자인지 확인 + ChatParticipant myParticipation = chatRoom.getParticipants().stream() + .filter(p -> p.getUser().getId().equals(currentUserId)) + .findFirst() + .orElseThrow(() -> new InvalidInputException(ErrorCode.NOT_CHAT_PARTICIPANT)); + + return ChatRoomResponse.from(chatRoom, myParticipation, currentUserId); + } + + /** + * 채팅방 참여 여부 확인 + * + * @param roomId 채팅방 UUID + * @param userId 사용자 ID + * @return 참여 여부 + */ + public boolean isParticipant(String roomId, Long userId) { + return chatParticipantRepository.findByRoomIdAndUserId(roomId, userId).isPresent(); + } + + /** + * 채팅방 참여자 ID 목록 조회 + * + * @param roomId 채팅방 UUID + * @return 참여자 ID 목록 + */ + public List getParticipantIds(String roomId) { + ChatRoom chatRoom = chatRoomRepository.findByRoomIdWithParticipants(roomId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.CHAT_ROOM_NOT_FOUND)); + + return chatRoom.getParticipants().stream() + .map(p -> p.getUser().getId()) + .toList(); + } + + private String generateParticipantHash(List userIds) { + String sortedIds = userIds.stream() + .sorted() + .map(String::valueOf) + .collect(Collectors.joining(",")); + + return DigestUtils.md5DigestAsHex(sortedIds.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/app/dearobjet/backend/domain/chat/service/ChatParticipantServiceTest.java b/src/test/java/app/dearobjet/backend/domain/chat/service/ChatParticipantServiceTest.java new file mode 100644 index 0000000..be6bd70 --- /dev/null +++ b/src/test/java/app/dearobjet/backend/domain/chat/service/ChatParticipantServiceTest.java @@ -0,0 +1,167 @@ +package app.dearobjet.backend.domain.chat.service; + +import app.dearobjet.backend.domain.chat.entity.ChatParticipant; +import app.dearobjet.backend.domain.chat.entity.ChatRoom; +import app.dearobjet.backend.domain.chat.entity.ChatRoomType; +import app.dearobjet.backend.domain.chat.repository.ChatParticipantRepository; +import app.dearobjet.backend.domain.chat.service.redis.PresenceRedisService; +import app.dearobjet.backend.domain.chat.service.redis.UnreadCountRedisService; +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.enums.Role; +import app.dearobjet.backend.domain.user.enums.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@DisplayName("ChatParticipantService 테스트") +@ExtendWith(MockitoExtension.class) +class ChatParticipantServiceTest { + + @InjectMocks + private ChatParticipantService chatParticipantService; + + @Mock + private ChatParticipantRepository chatParticipantRepository; + @Mock + private UnreadCountRedisService unreadCountRedisService; + @Mock + private PresenceRedisService presenceRedisService; + + private User user; + private ChatRoom chatRoom; + private ChatParticipant participant; + + private static final Long USER_ID = 1L; + private static final String ROOM_ID = "test-room-uuid"; + + @BeforeEach + void setUp() { + user = User.builder() + .id(USER_ID) + .name("Alice") + .email("alice@test.com") + .role(Role.CUSTOMER) + .userStatus(UserStatus.ACTIVE) + .build(); + + chatRoom = ChatRoom.builder() + .id(1L) + .roomId(ROOM_ID) + .type(ChatRoomType.ONE_TO_ONE) + .participantHash("test-hash") + .lastMessage("") + .build(); + + participant = ChatParticipant.builder() + .id(1L) + .user(user) + .chatRoom(chatRoom) + .unreadCount(3) + .build(); + } + + @Nested + @DisplayName("getUnreadCount") + class GetUnreadCountTest { + + @Test + @DisplayName("Redis에 값이 있으면 Redis 값 반환") + void givenRedisHasCount_whenGetUnreadCount_thenReturnRedisValue() { + // given + given(unreadCountRedisService.getUnreadCount(USER_ID, ROOM_ID)) + .willReturn(5); + + // when + int result = chatParticipantService.getUnreadCount(USER_ID, ROOM_ID); + + // then + assertThat(result).isEqualTo(5); + } + + @Test + @DisplayName("Redis에 값이 없으면 DB에서 조회 후 캐시 워밍업") + void givenRedisEmpty_whenGetUnreadCount_thenFallbackToDbAndWarmUp() { + // given + given(unreadCountRedisService.getUnreadCount(USER_ID, ROOM_ID)) + .willReturn(0); + given(chatParticipantRepository.findByRoomIdAndUserId(ROOM_ID, USER_ID)) + .willReturn(Optional.of(participant)); + + // when + int result = chatParticipantService.getUnreadCount(USER_ID, ROOM_ID); + + // then + assertThat(result).isEqualTo(3); + // Redis 캐시 워밍업 확인 + verify(unreadCountRedisService).warmUp(USER_ID, ROOM_ID, 3); + } + + @Test + @DisplayName("Redis와 DB 모두 없으면 0 반환") + void givenNoDataAnywhere_whenGetUnreadCount_thenReturnZero() { + // given + given(unreadCountRedisService.getUnreadCount(USER_ID, ROOM_ID)) + .willReturn(0); + given(chatParticipantRepository.findByRoomIdAndUserId(ROOM_ID, USER_ID)) + .willReturn(Optional.empty()); + + // when + int result = chatParticipantService.getUnreadCount(USER_ID, ROOM_ID); + + // then + assertThat(result).isZero(); + } + } + + @Nested + @DisplayName("syncUnreadCountsToDb") + class SyncUnreadCountsToDbTest { + + @Test + @DisplayName("dirty entries가 있으면 Redis 값으로 DB 업데이트") + void givenDirtyEntries_whenSync_thenUpdateDb() { + // given + Set dirtyEntries = Set.of(USER_ID + ":" + ROOM_ID); + given(unreadCountRedisService.popDirtyEntries()) + .willReturn(dirtyEntries); + given(unreadCountRedisService.getUnreadCount(USER_ID, ROOM_ID)) + .willReturn(7); + given(chatParticipantRepository.findByRoomIdAndUserId(ROOM_ID, USER_ID)) + .willReturn(Optional.of(participant)); + + // when + chatParticipantService.syncUnreadCountsToDb(); + + // then + assertThat(participant.getUnreadCount()).isEqualTo(7); + } + + @Test + @DisplayName("dirty entries가 없으면 아무 작업도 하지 않음") + void givenNoDirtyEntries_whenSync_thenDoNothing() { + // given + given(unreadCountRedisService.popDirtyEntries()) + .willReturn(Set.of()); + + // when + chatParticipantService.syncUnreadCountsToDb(); + + // then + // DB 조회가 발생하지 않아야 함 + verify(chatParticipantRepository, org.mockito.Mockito.never()) + .findByRoomIdAndUserId(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyLong()); + } + } +} diff --git a/src/test/java/app/dearobjet/backend/domain/chat/service/ChatRoomServiceTest.java b/src/test/java/app/dearobjet/backend/domain/chat/service/ChatRoomServiceTest.java new file mode 100644 index 0000000..132fa1b --- /dev/null +++ b/src/test/java/app/dearobjet/backend/domain/chat/service/ChatRoomServiceTest.java @@ -0,0 +1,165 @@ +package app.dearobjet.backend.domain.chat.service; + +import app.dearobjet.backend.domain.chat.dto.ChatRoomResponse; +import app.dearobjet.backend.domain.chat.dto.CreateChatRoomRequest; +import app.dearobjet.backend.domain.chat.entity.ChatParticipant; +import app.dearobjet.backend.domain.chat.entity.ChatRoom; +import app.dearobjet.backend.domain.chat.entity.ChatRoomType; +import app.dearobjet.backend.domain.chat.repository.ChatParticipantRepository; +import app.dearobjet.backend.domain.chat.repository.ChatRoomRepository; +import app.dearobjet.backend.domain.chat.service.redis.UnreadCountRedisService; +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.enums.Role; +import app.dearobjet.backend.domain.user.enums.UserStatus; +import app.dearobjet.backend.domain.user.repository.UserRepository; +import app.dearobjet.backend.global.exception.EntityNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@DisplayName("ChatRoomService 테스트") +@ExtendWith(MockitoExtension.class) +class ChatRoomServiceTest { + + @InjectMocks + private ChatRoomService chatRoomService; + + @Mock + private ChatRoomRepository chatRoomRepository; + @Mock + private ChatParticipantRepository chatParticipantRepository; + @Mock + private UserRepository userRepository; + @Mock + private UnreadCountRedisService unreadCountRedisService; + + private User currentUser; + private User partner; + + private static final Long CURRENT_USER_ID = 1L; + private static final Long PARTNER_ID = 2L; + + @BeforeEach + void setUp() { + currentUser = User.builder() + .id(CURRENT_USER_ID) + .name("Alice") + .email("alice@test.com") + .role(Role.CUSTOMER) + .userStatus(UserStatus.ACTIVE) + .build(); + + partner = User.builder() + .id(PARTNER_ID) + .name("Bob") + .email("bob@test.com") + .role(Role.CUSTOMER) + .userStatus(UserStatus.ACTIVE) + .build(); + } + + @Nested + @DisplayName("createOrGetChatRoom") + class CreateOrGetChatRoomTest { + + @Test + @DisplayName("기존 1:1 채팅방 존재 시 새로 생성하지 않고 기존 채팅방 반환") + void givenExistingOneToOneRoom_whenCreate_thenReturnExisting() { + // given + CreateChatRoomRequest request = CreateChatRoomRequest.oneToOne(PARTNER_ID); + + ChatParticipant myParticipation = ChatParticipant.builder() + .id(1L).user(currentUser).unreadCount(0).build(); + ChatParticipant partnerParticipation = ChatParticipant.builder() + .id(2L).user(partner).unreadCount(0).build(); + + ChatRoom existingRoom = ChatRoom.builder() + .id(1L) + .roomId("existing-room-uuid") + .type(ChatRoomType.ONE_TO_ONE) + .participantHash("test-hash") + .lastMessage("") + .participants(List.of(myParticipation, partnerParticipation)) + .build(); + + given(chatRoomRepository.findByParticipantHash(anyString())) + .willReturn(Optional.of(existingRoom)); + given(chatParticipantRepository.findByChatRoomIdAndUserId(1L, CURRENT_USER_ID)) + .willReturn(Optional.of(myParticipation)); + + // when + ChatRoomResponse result = chatRoomService.createOrGetChatRoom(CURRENT_USER_ID, request); + + // then + assertThat(result.getRoomId()).isEqualTo("existing-room-uuid"); + // 새 채팅방 저장이 호출되지 않음 + verify(chatRoomRepository, never()).save(any()); + } + + @Test + @DisplayName("기존 1:1 채팅방 없으면 새로 생성") + void givenNoExistingRoom_whenCreate_thenCreateNew() { + // given + CreateChatRoomRequest request = CreateChatRoomRequest.oneToOne(PARTNER_ID); + + given(chatRoomRepository.findByParticipantHash(anyString())) + .willReturn(Optional.empty()); + given(userRepository.findAllById(anyList())) + .willReturn(List.of(currentUser, partner)); + given(chatRoomRepository.save(any(ChatRoom.class))) + .willAnswer(invocation -> { + ChatRoom room = invocation.getArgument(0); + // save 후 ID 부여 시뮬레이션 + return ChatRoom.builder() + .id(1L) + .roomId(room.getRoomId()) + .type(room.getType()) + .participantHash(room.getParticipantHash()) + .lastMessage(room.getLastMessage()) + .participants(room.getParticipants()) + .build(); + }); + given(chatParticipantRepository.findByChatRoomIdAndUserId(eq(1L), eq(CURRENT_USER_ID))) + .willReturn(Optional.empty()); + + // when + ChatRoomResponse result = chatRoomService.createOrGetChatRoom(CURRENT_USER_ID, request); + + // then + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(ChatRoomType.ONE_TO_ONE); + verify(chatRoomRepository).save(any(ChatRoom.class)); + } + + @Test + @DisplayName("자기 자신과 채팅방 생성 시 예외") + void givenSameUser_whenCreate_thenThrowException() { + // given + // participantIds=[CURRENT_USER_ID], currentUserId=CURRENT_USER_ID + // → allParticipantIds=[CURRENT_USER_ID] (1명) → 파트너 조회 실패 + CreateChatRoomRequest request = CreateChatRoomRequest.oneToOne(CURRENT_USER_ID); + + given(userRepository.findAllById(anyList())) + .willReturn(List.of(currentUser)); + + // when & then + assertThatThrownBy(() -> chatRoomService.createOrGetChatRoom(CURRENT_USER_ID, request)) + .isInstanceOf(EntityNotFoundException.class); + } + } +} From 18cb119ea2c00e845239428afdb9083a8f7f26b0 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 27 Feb 2026 15:03:29 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20REST=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../controller/ChatMessageController.java | 147 +++++++++ .../chat/dto/ChatMessageCursorResponse.java | 20 ++ .../domain/chat/dto/ChatMessageResponse.java | 63 ++++ .../domain/chat/dto/ReadReceiptDto.java | 30 ++ .../domain/chat/dto/SendMessageRequest.java | 35 ++ .../repository/ChatMessageRepository.java | 19 +- .../chat/service/ChatMessageService.java | 301 ++++++++++++++++++ .../repository/ChatMessageRepositoryTest.java | 65 +++- .../chat/service/ChatMessageServiceTest.java | 274 ++++++++++++++++ 9 files changed, 952 insertions(+), 2 deletions(-) create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/controller/ChatMessageController.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/dto/ChatMessageCursorResponse.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/dto/ChatMessageResponse.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/dto/ReadReceiptDto.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/dto/SendMessageRequest.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/service/ChatMessageService.java create mode 100644 src/test/java/app/dearobjet/backend/domain/chat/service/ChatMessageServiceTest.java diff --git a/src/main/java/app/dearobjet/backend/domain/chat/controller/ChatMessageController.java b/src/main/java/app/dearobjet/backend/domain/chat/controller/ChatMessageController.java new file mode 100644 index 0000000..a3b7732 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/controller/ChatMessageController.java @@ -0,0 +1,147 @@ +package app.dearobjet.backend.domain.chat.controller; + +import app.dearobjet.backend.domain.chat.dto.ChatMessageResponse; +import app.dearobjet.backend.domain.chat.dto.ChatMessageCursorResponse; +import app.dearobjet.backend.domain.chat.dto.SendMessageRequest; +import app.dearobjet.backend.domain.chat.service.ChatMessageService; +import app.dearobjet.backend.domain.chat.service.ChatParticipantService; +import app.dearobjet.backend.global.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 채팅 메시지 REST API 컨트롤러 + */ +@RestController +@RequestMapping("/api/chat") +@RequiredArgsConstructor +public class ChatMessageController { + + private final ChatMessageService chatMessageService; + private final ChatParticipantService chatParticipantService; + + /** + * 메시지 전송 (REST API 방식) + * WebSocket을 사용할 수 없는 환경에서 폴백으로 사용 + * + * POST /api/chat/rooms/{roomId}/messages + */ + @PostMapping("/rooms/{roomId}/messages") + public ApiResponse sendMessage( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable String roomId, + @Valid @RequestBody SendMessageRequest request) { + + ChatMessageResponse message = chatMessageService.sendMessage(userId, roomId, request); + return ApiResponse.of(message); + } + + /** + * 최신 메시지 N개 조회 (커서 초기화) + * + * GET /api/chat/rooms/{roomId}/messages/latest?limit=50 + */ + @GetMapping("/rooms/{roomId}/messages/latest") + public ApiResponse getLatestMessages( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable String roomId, + @RequestParam(defaultValue = "50") int limit) { + + ChatMessageCursorResponse response = chatMessageService.getLatestMessages(roomId, userId, limit); + return ApiResponse.of(response); + } + + /** + * 누락 메시지 동기화 (재접속 복구) + * + * GET /api/chat/rooms/{roomId}/messages/sync?afterMessageId=123&limit=200 + */ + @GetMapping("/rooms/{roomId}/messages/sync") + public ApiResponse syncMessages( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable String roomId, + @RequestParam(defaultValue = "0") long afterMessageId, + @RequestParam(defaultValue = "200") int limit) { + + ChatMessageCursorResponse response = chatMessageService.syncMessages(roomId, userId, afterMessageId, limit); + return ApiResponse.of(response); + } + + /** + * 과거 메시지 더보기 + * + * GET /api/chat/rooms/{roomId}/messages/before?beforeMessageId=123&limit=50 + */ + @GetMapping("/rooms/{roomId}/messages/before") + public ApiResponse getMessagesBefore( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable String roomId, + @RequestParam long beforeMessageId, + @RequestParam(defaultValue = "50") int limit) { + + ChatMessageCursorResponse response = chatMessageService.getMessagesBefore(roomId, userId, beforeMessageId, limit); + return ApiResponse.of(response); + } + + /** + * 채팅방 메시지 히스토리 조회 + * + * GET /api/chat/rooms/{roomId}/messages?page=0&size=20 + */ + @GetMapping("/rooms/{roomId}/messages") + public ApiResponse> getMessages( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable String roomId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + List messages = chatMessageService.getMessages(roomId, userId, page, size); + return ApiResponse.of(messages); + } + + /** + * 읽음 처리 + * + * POST /api/chat/rooms/{roomId}/read + */ + @PostMapping("/rooms/{roomId}/read") + public ApiResponse markAsRead( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable String roomId) { + + chatMessageService.markAsRead(userId, roomId); + return ApiResponse.of(null); + } + + /** + * 사용자의 전체 읽지 않은 메시지 수 조회 + * + * GET /api/chat/unread/total + */ + @GetMapping("/unread/total") + public ApiResponse> getTotalUnreadCount( + @AuthenticationPrincipal(expression = "userId") Long userId) { + + int totalUnread = chatParticipantService.getTotalUnreadCount(userId); + return ApiResponse.of(Map.of("totalUnread", totalUnread)); + } + + /** + * 특정 채팅방의 읽지 않은 메시지 수 조회 + * + * GET /api/chat/rooms/{roomId}/unread + */ + @GetMapping("/rooms/{roomId}/unread") + public ApiResponse> getUnreadCount( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable String roomId) { + + int unreadCount = chatParticipantService.getUnreadCount(userId, roomId); + return ApiResponse.of(Map.of("unreadCount", unreadCount)); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/dto/ChatMessageCursorResponse.java b/src/main/java/app/dearobjet/backend/domain/chat/dto/ChatMessageCursorResponse.java new file mode 100644 index 0000000..7df9939 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/dto/ChatMessageCursorResponse.java @@ -0,0 +1,20 @@ +package app.dearobjet.backend.domain.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +/** + * 커서 기반 메시지 조회 응답 + * - 재접속/누락 복구(sync), 최초 진입(latest), 과거 더보기(before)에서 공통으로 사용 + */ +@Getter +@AllArgsConstructor +public class ChatMessageCursorResponse { + private final String roomId; + private final Long oldestMessageId; + private final Long latestMessageId; + private final List messages; +} + diff --git a/src/main/java/app/dearobjet/backend/domain/chat/dto/ChatMessageResponse.java b/src/main/java/app/dearobjet/backend/domain/chat/dto/ChatMessageResponse.java new file mode 100644 index 0000000..3d2f88d --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/dto/ChatMessageResponse.java @@ -0,0 +1,63 @@ +package app.dearobjet.backend.domain.chat.dto; + +import app.dearobjet.backend.domain.chat.entity.ChatMessage; +import app.dearobjet.backend.domain.chat.entity.MessageType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 채팅 메시지 응답 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChatMessageResponse { + + private Long id; + private String roomId; + private Long senderId; + private String senderName; + private String senderProfileImage; + private String content; + private MessageType messageType; + private LocalDateTime createdAt; + private boolean isMyMessage; + + /** + * 엔티티로부터 DTO 생성 + */ + public static ChatMessageResponse from(ChatMessage message, Long currentUserId) { + return ChatMessageResponse.builder() + .id(message.getId()) + .roomId(message.getRoomId()) + .senderId(message.getSender() != null ? message.getSender().getId() : null) + .senderName(message.getSender() != null ? message.getSender().getName() : "시스템") + .senderProfileImage(message.getSender() != null ? message.getSender().getProfileImage() : null) + .content(message.getContent()) + .messageType(message.getMessageType()) + .createdAt(message.getCreatedAt()) + .isMyMessage(message.getSender() != null && message.getSender().getId().equals(currentUserId)) + .build(); + } + + /** + * Redis 메시지로부터 DTO 생성 + */ + public static ChatMessageResponse from(RedisChatMessageDto redisMessage, Long currentUserId) { + return ChatMessageResponse.builder() + .id(redisMessage.getMessageId()) + .roomId(redisMessage.getRoomId()) + .senderId(redisMessage.getSenderId()) + .senderName(redisMessage.getSenderName()) + .content(redisMessage.getContent()) + .messageType(redisMessage.getMessageType()) + .createdAt(redisMessage.getTimestamp()) + .isMyMessage(redisMessage.getSenderId() != null && redisMessage.getSenderId().equals(currentUserId)) + .build(); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/dto/ReadReceiptDto.java b/src/main/java/app/dearobjet/backend/domain/chat/dto/ReadReceiptDto.java new file mode 100644 index 0000000..6571012 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/dto/ReadReceiptDto.java @@ -0,0 +1,30 @@ +package app.dearobjet.backend.domain.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 읽음 확인 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ReadReceiptDto { + + private String roomId; + private Long userId; + private LocalDateTime readAt; + + public static ReadReceiptDto of(String roomId, Long userId, LocalDateTime readAt) { + return ReadReceiptDto.builder() + .roomId(roomId) + .userId(userId) + .readAt(readAt) + .build(); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/dto/SendMessageRequest.java b/src/main/java/app/dearobjet/backend/domain/chat/dto/SendMessageRequest.java new file mode 100644 index 0000000..bf07995 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/dto/SendMessageRequest.java @@ -0,0 +1,35 @@ +package app.dearobjet.backend.domain.chat.dto; + +import app.dearobjet.backend.domain.chat.entity.MessageType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 메시지 전송 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SendMessageRequest { + + @NotBlank(message = "채팅방 ID는 필수입니다") + private String roomId; + + @NotBlank(message = "메시지 내용은 필수입니다") + @Size(max = 5000, message = "메시지는 5000자를 초과할 수 없습니다") + private String content; + + @NotNull(message = "메시지 타입은 필수입니다") + private MessageType messageType; + + /** + * 텍스트 메시지용 편의 생성자 + */ + public static SendMessageRequest ofText(String roomId, String content) { + return new SendMessageRequest(roomId, content, MessageType.TEXT); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/repository/ChatMessageRepository.java b/src/main/java/app/dearobjet/backend/domain/chat/repository/ChatMessageRepository.java index 1f59959..e4f2979 100644 --- a/src/main/java/app/dearobjet/backend/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/app/dearobjet/backend/domain/chat/repository/ChatMessageRepository.java @@ -24,6 +24,23 @@ public interface ChatMessageRepository extends JpaRepository */ Page findByRoomIdOrderByCreatedAtDesc(String roomId, Pageable pageable); + /** + * 채팅방 메시지 커서 조회용 (ID 최신순) + * + * createdAt 대신 PK(id) 기반으로 커서 페이징을 하기 위해 사용 + */ + Page findByRoomIdOrderByIdDesc(String roomId, Pageable pageable); + + /** + * afterMessageId 이후의 메시지 조회 (누락 복구) + */ + Page findByRoomIdAndIdGreaterThanOrderByIdAsc(String roomId, Long afterMessageId, Pageable pageable); + + /** + * beforeMessageId 이전의 메시지 조회 (과거 더보기) + */ + Page findByRoomIdAndIdLessThanOrderByIdDesc(String roomId, Long beforeMessageId, Pageable pageable); + /** * 특정 시점 이후 메시지 조회 * 읽지 않은 메시지 목록 조회에 사용 @@ -52,4 +69,4 @@ List findByRoomIdAndCreatedAtAfter(@Param("roomId") String roomId, "AND m.createdAt > :createdAt") long countByRoomIdAndCreatedAtAfter(@Param("roomId") String roomId, @Param("createdAt") LocalDateTime createdAt); -} \ No newline at end of file +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/service/ChatMessageService.java b/src/main/java/app/dearobjet/backend/domain/chat/service/ChatMessageService.java new file mode 100644 index 0000000..7587ca9 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/service/ChatMessageService.java @@ -0,0 +1,301 @@ +package app.dearobjet.backend.domain.chat.service; + +import app.dearobjet.backend.domain.chat.dto.ChatMessageResponse; +import app.dearobjet.backend.domain.chat.dto.ChatMessageCursorResponse; +import app.dearobjet.backend.domain.chat.dto.RedisChatMessageDto; +import app.dearobjet.backend.domain.chat.dto.SendMessageRequest; +import app.dearobjet.backend.domain.chat.entity.ChatMessage; +import app.dearobjet.backend.domain.chat.entity.ChatParticipant; +import app.dearobjet.backend.domain.chat.entity.ChatRoom; +import app.dearobjet.backend.domain.chat.repository.ChatMessageRepository; +import app.dearobjet.backend.domain.chat.repository.ChatParticipantRepository; +import app.dearobjet.backend.domain.chat.repository.ChatRoomRepository; +import app.dearobjet.backend.domain.chat.service.redis.ChatMessagePublisher; +import app.dearobjet.backend.domain.chat.service.redis.MessageCacheRedisService; +import app.dearobjet.backend.domain.chat.service.redis.UnreadCountRedisService; +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.repository.UserRepository; +import app.dearobjet.backend.global.exception.EntityNotFoundException; +import app.dearobjet.backend.global.exception.ErrorCode; +import app.dearobjet.backend.global.exception.InvalidInputException; +import app.dearobjet.backend.global.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 채팅 메시지 비즈니스 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatMessageService { + + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomRepository chatRoomRepository; + private final ChatParticipantRepository chatParticipantRepository; + private final UserRepository userRepository; + private final ChatMessagePublisher messagePublisher; + private final UnreadCountRedisService unreadCountRedisService; + private final MessageCacheRedisService messageCacheRedisService; + + /** + * 메시지 전송 + * + * @param senderId 발신자 ID + * @param roomId 채팅방 UUID + * @param request 메시지 전송 요청 + * @return 저장된 메시지 DTO + */ + @Transactional + public ChatMessageResponse sendMessage(Long senderId, String roomId, SendMessageRequest request) { + validateAuthenticated(senderId); + // 채팅방 조회 + ChatRoom chatRoom = chatRoomRepository.findByRoomIdWithParticipants(roomId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.CHAT_ROOM_NOT_FOUND)); + + // 참여자 확인 + boolean isParticipant = chatRoom.getParticipants().stream() + .anyMatch(p -> p.getUser().getId().equals(senderId)); + if (!isParticipant) { + throw new InvalidInputException(ErrorCode.NOT_CHAT_PARTICIPANT); + } + + // 발신자 조회 + User sender = userRepository.findById(senderId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); + + // 메시지 생성 + ChatMessage message = ChatMessage.builder() + .roomId(roomId) + .chatRoom(chatRoom) + .sender(sender) + .content(request.getContent()) + .messageType(request.getMessageType()) + .createdAt(LocalDateTime.now()) + .build(); + + // DB 저장 + message = chatMessageRepository.save(message); + + // 채팅방 마지막 메시지 업데이트 + chatRoom.updateLastMessage(request.getContent()); + + // 발신자 제외한 참여자들의 unread 카운트 증가 (Redis) + Set participantIds = chatRoom.getParticipants().stream() + .map(p -> p.getUser().getId()) + .collect(Collectors.toSet()); + unreadCountRedisService.incrementForParticipants(roomId, participantIds, senderId); + + // DB에도 unread 카운트 증가 (정합성 유지) + chatRoom.incrementUnreadCount(senderId); + + // Redis 캐시에 메시지 추가 + messageCacheRedisService.addRecentMessage(message, senderId); + + // Redis Pub/Sub으로 메시지 브로드캐스트 + RedisChatMessageDto redisMessage = RedisChatMessageDto.ofMessage( + roomId, + message.getId(), + senderId, + sender.getName(), + sender.getProfileImage(), + request.getContent(), + request.getMessageType(), + message.getCreatedAt() + ); + messagePublisher.publishToRoom(redisMessage); + + log.debug("Message sent - roomId: {}, senderId: {}, messageId: {}", + roomId, senderId, message.getId()); + + return ChatMessageResponse.from(message, senderId); + } + + /** + * 채팅방 메시지 히스토리 조회 + * + * @param roomId 채팅방 UUID + * @param currentUserId 현재 사용자 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 메시지 목록 + */ + public List getMessages(String roomId, Long currentUserId, int page, int size) { + validateAuthenticated(currentUserId); + validateParticipant(roomId, currentUserId); + + // 첫 페이지이고 캐시가 있으면 캐시에서 조회 + if (page == 0 && messageCacheRedisService.hasCache(roomId)) { + List cachedMessages = messageCacheRedisService.getRecentMessages(roomId, size); + if (!cachedMessages.isEmpty()) { + log.debug("Messages retrieved from cache - roomId: {}, count: {}", roomId, cachedMessages.size()); + return cachedMessages; + } + } + + // DB에서 조회 + Pageable pageable = PageRequest.of(page, size); + Page messagePage = chatMessageRepository.findByRoomIdOrderByCreatedAtDesc(roomId, pageable); + + List messages = messagePage.getContent().stream() + .map(m -> ChatMessageResponse.from(m, currentUserId)) + .toList(); + + // 첫 페이지면 캐시 워밍업 + if (page == 0 && !messagePage.isEmpty()) { + messageCacheRedisService.warmUp(roomId, messagePage.getContent(), currentUserId); + } + + return messages; + } + + /** + * 읽음 처리 + * + * @param userId 사용자 ID + * @param roomId 채팅방 UUID + */ + @Transactional + public void markAsRead(Long userId, String roomId) { + validateAuthenticated(userId); + // 참여자 조회 + ChatParticipant participant = chatParticipantRepository.findByRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> new InvalidInputException(ErrorCode.NOT_CHAT_PARTICIPANT)); + + // Redis에서 읽음 처리 (카운트 초기화) + unreadCountRedisService.markAsRead(userId, roomId); + + // DB에서도 읽음 처리 + participant.markAsRead(); + + // 읽음 이벤트 브로드캐스트 + RedisChatMessageDto readEvent = RedisChatMessageDto.ofRead(roomId, userId, LocalDateTime.now()); + messagePublisher.publishToRoom(readEvent); + + log.debug("Marked as read - roomId: {}, userId: {}", roomId, userId); + } + + /** + * 특정 시점 이후 읽지 않은 메시지 수 조회 + * + * @param roomId 채팅방 UUID + * @param since 기준 시점 + * @return 읽지 않은 메시지 수 + */ + public long getUnreadMessageCount(String roomId, LocalDateTime since) { + return chatMessageRepository.countByRoomIdAndCreatedAtAfter(roomId, since); + } + + /** + * 방 진입 시 최신 메시지 N개 조회 (커서 초기화) + */ + public ChatMessageCursorResponse getLatestMessages(String roomId, Long currentUserId, int limit) { + validateAuthenticated(currentUserId); + validateParticipant(roomId, currentUserId); + + int size = normalizeLimit(limit, 50, 200); + List desc = chatMessageRepository + .findByRoomIdOrderByIdDesc(roomId, PageRequest.of(0, size)) + .getContent(); + + List asc = new ArrayList<>(desc); + Collections.reverse(asc); + + List messages = asc.stream() + .map(m -> ChatMessageResponse.from(m, currentUserId)) + .toList(); + + return toCursorResponse(roomId, messages); + } + + /** + * 재접속/누락 복구: afterMessageId 이후 메시지 조회 + */ + public ChatMessageCursorResponse syncMessages(String roomId, Long currentUserId, Long afterMessageId, int limit) { + validateAuthenticated(currentUserId); + validateParticipant(roomId, currentUserId); + + long after = afterMessageId != null ? afterMessageId : 0L; + int size = normalizeLimit(limit, 200, 500); + + List messages = chatMessageRepository + .findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, after, PageRequest.of(0, size)) + .getContent() + .stream() + .map(m -> ChatMessageResponse.from(m, currentUserId)) + .toList(); + + return toCursorResponse(roomId, messages); + } + + /** + * 과거 더보기: beforeMessageId 이전 메시지 조회 + */ + public ChatMessageCursorResponse getMessagesBefore( + String roomId, + Long currentUserId, + Long beforeMessageId, + int limit) { + + validateAuthenticated(currentUserId); + validateParticipant(roomId, currentUserId); + + if (beforeMessageId == null) { + throw new InvalidInputException(ErrorCode.INVALID_INPUT, "beforeMessageId는 필수입니다."); + } + + int size = normalizeLimit(limit, 50, 200); + List desc = chatMessageRepository + .findByRoomIdAndIdLessThanOrderByIdDesc(roomId, beforeMessageId, PageRequest.of(0, size)) + .getContent(); + + List asc = new ArrayList<>(desc); + Collections.reverse(asc); + + List messages = asc.stream() + .map(m -> ChatMessageResponse.from(m, currentUserId)) + .toList(); + + return toCursorResponse(roomId, messages); + } + + private void validateAuthenticated(Long userId) { + if (userId == null) { + throw new UnauthorizedException(ErrorCode.UNAUTHORIZED); + } + } + + private void validateParticipant(String roomId, Long userId) { + chatParticipantRepository.findByRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> new InvalidInputException(ErrorCode.NOT_CHAT_PARTICIPANT)); + } + + private int normalizeLimit(int limit, int defaultLimit, int maxLimit) { + if (limit <= 0) { + return defaultLimit; + } + return Math.min(limit, maxLimit); + } + + private ChatMessageCursorResponse toCursorResponse(String roomId, List messages) { + if (messages.isEmpty()) { + return new ChatMessageCursorResponse(roomId, null, null, messages); + } + Long oldest = messages.get(0).getId(); + Long latest = messages.get(messages.size() - 1).getId(); + return new ChatMessageCursorResponse(roomId, oldest, latest, messages); + } +} diff --git a/src/test/java/app/dearobjet/backend/domain/chat/repository/ChatMessageRepositoryTest.java b/src/test/java/app/dearobjet/backend/domain/chat/repository/ChatMessageRepositoryTest.java index 3852ab5..30640b0 100644 --- a/src/test/java/app/dearobjet/backend/domain/chat/repository/ChatMessageRepositoryTest.java +++ b/src/test/java/app/dearobjet/backend/domain/chat/repository/ChatMessageRepositoryTest.java @@ -173,4 +173,67 @@ void countByRoomIdAndCreatedAtAfter_zero() { assertThat(count).isEqualTo(0); } } -} \ No newline at end of file + + @Nested + @DisplayName("커서 기반 조회 (id)") + class CursorBasedQueryTest { + + @Test + @DisplayName("findByRoomIdOrderByIdDesc - 최신 메시지부터 조회") + void findByRoomIdOrderByIdDesc_success() { + // given + ChatRoom room = createChatRoom(user1, user2); + createMessage(room, user1, "m1"); + createMessage(room, user2, "m2"); + createMessage(room, user1, "m3"); + + // when + Page page = chatMessageRepository + .findByRoomIdOrderByIdDesc(room.getRoomId(), PageRequest.of(0, 10)); + + // then + assertThat(page.getContent()).hasSize(3); + assertThat(page.getContent().get(0).getContent()).isEqualTo("m3"); + assertThat(page.getContent().get(1).getContent()).isEqualTo("m2"); + assertThat(page.getContent().get(2).getContent()).isEqualTo("m1"); + } + + @Test + @DisplayName("findByRoomIdAndIdGreaterThanOrderByIdAsc - after id 이후 메시지 조회") + void findByRoomIdAndIdGreaterThanOrderByIdAsc_success() { + // given + ChatRoom room = createChatRoom(user1, user2); + ChatMessage m1 = createMessage(room, user1, "m1"); + ChatMessage m2 = createMessage(room, user2, "m2"); + ChatMessage m3 = createMessage(room, user1, "m3"); + + // when + Page page = chatMessageRepository + .findByRoomIdAndIdGreaterThanOrderByIdAsc(room.getRoomId(), m1.getId(), PageRequest.of(0, 10)); + + // then + assertThat(page.getContent()).hasSize(2); + assertThat(page.getContent().get(0).getId()).isEqualTo(m2.getId()); + assertThat(page.getContent().get(1).getId()).isEqualTo(m3.getId()); + } + + @Test + @DisplayName("findByRoomIdAndIdLessThanOrderByIdDesc - before id 이전 메시지 조회") + void findByRoomIdAndIdLessThanOrderByIdDesc_success() { + // given + ChatRoom room = createChatRoom(user1, user2); + ChatMessage m1 = createMessage(room, user1, "m1"); + ChatMessage m2 = createMessage(room, user2, "m2"); + ChatMessage m3 = createMessage(room, user1, "m3"); + + // when + Page page = chatMessageRepository + .findByRoomIdAndIdLessThanOrderByIdDesc(room.getRoomId(), m3.getId(), PageRequest.of(0, 10)); + + // then + assertThat(page.getContent()).hasSize(2); + assertThat(page.getContent().get(0).getId()).isEqualTo(m2.getId()); + assertThat(page.getContent().get(1).getId()).isEqualTo(m1.getId()); + } + } +} diff --git a/src/test/java/app/dearobjet/backend/domain/chat/service/ChatMessageServiceTest.java b/src/test/java/app/dearobjet/backend/domain/chat/service/ChatMessageServiceTest.java new file mode 100644 index 0000000..b3585a1 --- /dev/null +++ b/src/test/java/app/dearobjet/backend/domain/chat/service/ChatMessageServiceTest.java @@ -0,0 +1,274 @@ +package app.dearobjet.backend.domain.chat.service; + +import app.dearobjet.backend.domain.chat.dto.ChatMessageResponse; +import app.dearobjet.backend.domain.chat.dto.SendMessageRequest; +import app.dearobjet.backend.domain.chat.entity.*; +import app.dearobjet.backend.domain.chat.repository.ChatMessageRepository; +import app.dearobjet.backend.domain.chat.repository.ChatParticipantRepository; +import app.dearobjet.backend.domain.chat.repository.ChatRoomRepository; +import app.dearobjet.backend.domain.chat.service.redis.ChatMessagePublisher; +import app.dearobjet.backend.domain.chat.service.redis.MessageCacheRedisService; +import app.dearobjet.backend.domain.chat.service.redis.UnreadCountRedisService; +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.enums.Role; +import app.dearobjet.backend.domain.user.enums.UserStatus; +import app.dearobjet.backend.domain.user.repository.UserRepository; +import app.dearobjet.backend.global.exception.EntityNotFoundException; +import app.dearobjet.backend.global.exception.InvalidInputException; +import app.dearobjet.backend.global.exception.UnauthorizedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@DisplayName("ChatMessageService 테스트") +@ExtendWith(MockitoExtension.class) +class ChatMessageServiceTest { + + @InjectMocks + private ChatMessageService chatMessageService; + + @Mock + private ChatMessageRepository chatMessageRepository; + @Mock + private ChatRoomRepository chatRoomRepository; + @Mock + private ChatParticipantRepository chatParticipantRepository; + @Mock + private UserRepository userRepository; + @Mock + private ChatMessagePublisher messagePublisher; + @Mock + private UnreadCountRedisService unreadCountRedisService; + @Mock + private MessageCacheRedisService messageCacheRedisService; + + private User sender; + private User receiver; + private ChatRoom chatRoom; + private ChatParticipant senderParticipant; + private ChatParticipant receiverParticipant; + + private static final String ROOM_ID = "test-room-uuid"; + private static final Long SENDER_ID = 1L; + private static final Long RECEIVER_ID = 2L; + + @BeforeEach + void setUp() { + sender = User.builder() + .id(SENDER_ID) + .name("Alice") + .email("alice@test.com") + .role(Role.CUSTOMER) + .userStatus(UserStatus.ACTIVE) + .build(); + + receiver = User.builder() + .id(RECEIVER_ID) + .name("Bob") + .email("bob@test.com") + .role(Role.CUSTOMER) + .userStatus(UserStatus.ACTIVE) + .build(); + + senderParticipant = ChatParticipant.builder() + .id(1L) + .user(sender) + .unreadCount(0) + .build(); + + receiverParticipant = ChatParticipant.builder() + .id(2L) + .user(receiver) + .unreadCount(0) + .build(); + + chatRoom = ChatRoom.builder() + .id(1L) + .roomId(ROOM_ID) + .type(ChatRoomType.ONE_TO_ONE) + .participantHash("test-hash") + .lastMessage("") + .participants(List.of(senderParticipant, receiverParticipant)) + .build(); + } + + @Nested + @DisplayName("sendMessage") + class SendMessageTest { + + @Test + @DisplayName("정상 요청 시 메시지 저장 후 Redis 발행") + void givenValidRequest_whenSendMessage_thenSaveAndPublish() { + // given + SendMessageRequest request = SendMessageRequest.ofText(ROOM_ID, "안녕하세요"); + + given(chatRoomRepository.findByRoomIdWithParticipants(ROOM_ID)) + .willReturn(Optional.of(chatRoom)); + given(userRepository.findById(SENDER_ID)) + .willReturn(Optional.of(sender)); + + ChatMessage savedMessage = ChatMessage.builder() + .id(100L) + .roomId(ROOM_ID) + .chatRoom(chatRoom) + .sender(sender) + .content("안녕하세요") + .messageType(MessageType.TEXT) + .createdAt(LocalDateTime.now()) + .build(); + given(chatMessageRepository.save(any(ChatMessage.class))) + .willReturn(savedMessage); + + // when + ChatMessageResponse result = chatMessageService.sendMessage(SENDER_ID, ROOM_ID, request); + + // then + assertThat(result.getContent()).isEqualTo("안녕하세요"); + assertThat(result.getSenderId()).isEqualTo(SENDER_ID); + verify(messagePublisher).publishToRoom(any()); + verify(unreadCountRedisService).incrementForParticipants(eq(ROOM_ID), anySet(), eq(SENDER_ID)); + verify(messageCacheRedisService).addRecentMessage(any(), eq(SENDER_ID)); + } + + @Test + @DisplayName("존재하지 않는 채팅방이면 EntityNotFoundException") + void givenNonExistentRoom_whenSendMessage_thenThrowEntityNotFound() { + // given + SendMessageRequest request = SendMessageRequest.ofText(ROOM_ID, "test"); + given(chatRoomRepository.findByRoomIdWithParticipants(ROOM_ID)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> chatMessageService.sendMessage(SENDER_ID, ROOM_ID, request)) + .isInstanceOf(EntityNotFoundException.class); + } + + @Test + @DisplayName("비참여자가 메시지 전송 시 InvalidInputException") + void givenNonParticipant_whenSendMessage_thenThrowInvalidInput() { + // given + Long nonParticipantId = 999L; + SendMessageRequest request = SendMessageRequest.ofText(ROOM_ID, "test"); + given(chatRoomRepository.findByRoomIdWithParticipants(ROOM_ID)) + .willReturn(Optional.of(chatRoom)); + + // when & then + assertThatThrownBy(() -> chatMessageService.sendMessage(nonParticipantId, ROOM_ID, request)) + .isInstanceOf(InvalidInputException.class); + } + + @Test + @DisplayName("userId가 null이면 UnauthorizedException") + void givenNullUserId_whenSendMessage_thenThrowUnauthorized() { + // given + SendMessageRequest request = SendMessageRequest.ofText(ROOM_ID, "test"); + + // when & then + assertThatThrownBy(() -> chatMessageService.sendMessage(null, ROOM_ID, request)) + .isInstanceOf(UnauthorizedException.class); + } + } + + @Nested + @DisplayName("markAsRead") + class MarkAsReadTest { + + @Test + @DisplayName("정상 요청 시 unread 카운트 초기화 및 읽음 이벤트 발행") + void givenValidRequest_whenMarkAsRead_thenResetUnreadCount() { + // given + ChatParticipant participant = ChatParticipant.builder() + .id(1L) + .user(sender) + .unreadCount(5) + .build(); + + given(chatParticipantRepository.findByRoomIdAndUserId(ROOM_ID, SENDER_ID)) + .willReturn(Optional.of(participant)); + + // when + chatMessageService.markAsRead(SENDER_ID, ROOM_ID); + + // then + verify(unreadCountRedisService).markAsRead(SENDER_ID, ROOM_ID); + verify(messagePublisher).publishToRoom(any()); + assertThat(participant.getUnreadCount()).isZero(); + } + } + + @Nested + @DisplayName("getMessages") + class GetMessagesTest { + + @Test + @DisplayName("첫 페이지에 캐시가 있으면 캐시에서 조회") + void givenFirstPage_whenGetMessages_thenUseCacheIfAvailable() { + // given + ChatMessageResponse cachedDto = ChatMessageResponse.builder() + .id(1L) + .roomId(ROOM_ID) + .content("cached") + .build(); + + given(chatParticipantRepository.findByRoomIdAndUserId(ROOM_ID, SENDER_ID)) + .willReturn(Optional.of(senderParticipant)); + given(messageCacheRedisService.hasCache(ROOM_ID)).willReturn(true); + given(messageCacheRedisService.getRecentMessages(ROOM_ID, 20)) + .willReturn(List.of(cachedDto)); + + // when + List result = chatMessageService.getMessages(ROOM_ID, SENDER_ID, 0, 20); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getContent()).isEqualTo("cached"); + // DB 조회하지 않음 + verify(chatMessageRepository, never()).findByRoomIdOrderByCreatedAtDesc(anyString(), any()); + } + + @Test + @DisplayName("캐시 미스 시 DB에서 조회") + void givenNoCachedMessages_whenGetMessages_thenQueryDb() { + // given + given(chatParticipantRepository.findByRoomIdAndUserId(ROOM_ID, SENDER_ID)) + .willReturn(Optional.of(senderParticipant)); + given(messageCacheRedisService.hasCache(ROOM_ID)).willReturn(false); + + ChatMessage dbMessage = ChatMessage.builder() + .id(1L) + .roomId(ROOM_ID) + .sender(sender) + .content("db message") + .messageType(MessageType.TEXT) + .createdAt(LocalDateTime.now()) + .build(); + given(chatMessageRepository.findByRoomIdOrderByCreatedAtDesc(eq(ROOM_ID), any())) + .willReturn(new PageImpl<>(List.of(dbMessage))); + + // when + List result = chatMessageService.getMessages(ROOM_ID, SENDER_ID, 0, 20); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getContent()).isEqualTo("db message"); + } + } +} From 105e38fd5df476f9b6d9172b4cdcafb8d27751a0 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 27 Feb 2026 15:03:38 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20WebSocket=20STOMP=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../domain/chat/dto/TypingIndicatorDto.java | 30 ++++ .../websocket/ChatWebSocketController.java | 167 ++++++++++++++++++ .../websocket/WebSocketEventListener.java | 111 ++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/dto/TypingIndicatorDto.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/websocket/ChatWebSocketController.java create mode 100644 src/main/java/app/dearobjet/backend/domain/chat/websocket/WebSocketEventListener.java diff --git a/src/main/java/app/dearobjet/backend/domain/chat/dto/TypingIndicatorDto.java b/src/main/java/app/dearobjet/backend/domain/chat/dto/TypingIndicatorDto.java new file mode 100644 index 0000000..4ce39ec --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/dto/TypingIndicatorDto.java @@ -0,0 +1,30 @@ +package app.dearobjet.backend.domain.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 타이핑 상태 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TypingIndicatorDto { + + private String roomId; + private Long userId; + private String userName; + private boolean isTyping; + + public static TypingIndicatorDto of(String roomId, Long userId, String userName, boolean isTyping) { + return TypingIndicatorDto.builder() + .roomId(roomId) + .userId(userId) + .userName(userName) + .isTyping(isTyping) + .build(); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/websocket/ChatWebSocketController.java b/src/main/java/app/dearobjet/backend/domain/chat/websocket/ChatWebSocketController.java new file mode 100644 index 0000000..9a921c6 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/websocket/ChatWebSocketController.java @@ -0,0 +1,167 @@ +package app.dearobjet.backend.domain.chat.websocket; + +import app.dearobjet.backend.domain.chat.dto.RedisChatMessageDto; +import app.dearobjet.backend.domain.chat.dto.SendMessageRequest; +import app.dearobjet.backend.domain.chat.dto.TypingIndicatorDto; +import app.dearobjet.backend.domain.chat.repository.ChatParticipantRepository; +import app.dearobjet.backend.domain.chat.service.ChatMessageService; +import app.dearobjet.backend.domain.chat.service.redis.ChatMessagePublisher; +import app.dearobjet.backend.domain.chat.service.redis.PresenceRedisService; +import app.dearobjet.backend.global.exception.BusinessException; +import app.dearobjet.backend.global.exception.ErrorCode; +import app.dearobjet.backend.global.exception.InvalidInputException; +import app.dearobjet.backend.global.exception.UnauthorizedException; +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.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.stereotype.Controller; + +import java.security.Principal; +import java.util.Map; + +/** + * WebSocket STOMP 메시지 컨트롤러 + */ +@Slf4j +@Controller +@RequiredArgsConstructor +public class ChatWebSocketController { + + private final ChatMessageService chatMessageService; + private final ChatMessagePublisher messagePublisher; + private final PresenceRedisService presenceRedisService; + private final ChatParticipantRepository chatParticipantRepository; + + /** + * WebSocket 메시지 처리 중 발생한 예외를 클라이언트에 전달 + */ + @MessageExceptionHandler + @SendToUser("/queue/errors") + public Map handleException(Exception e) { + log.warn("WebSocket message error: {}", e.getMessage()); + String code = (e instanceof BusinessException be) ? be.getErrorCode().getCode() : "C004"; + return Map.of( + "code", code, + "message", e.getMessage() + ); + } + + /** + * 메시지 전송 + * 클라이언트 발행: /app/chat/{roomId}/send + * 구독 경로: /topic/chat/{roomId} + */ + @MessageMapping("/chat/{roomId}/send") + public void sendMessage( + @DestinationVariable String roomId, + @Payload SendMessageRequest request, + Principal principal) { + + Long userId = extractUserIdOrThrow(principal); + + log.debug("Message received - roomId: {}, userId: {}, type: {}", + roomId, userId, request.getMessageType()); + + // 메시지 저장 및 브로드캐스트 (참여자 검증은 서비스 레이어에서 수행) + chatMessageService.sendMessage(userId, roomId, request); + } + + /** + * 타이핑 상태 전송 + * 클라이언트 발행: /app/chat/{roomId}/typing + * 구독 경로: /topic/chat/{roomId}/typing + */ + @MessageMapping("/chat/{roomId}/typing") + public void sendTyping( + @DestinationVariable String roomId, + @Payload TypingIndicatorDto typing, + Principal principal) { + + Long userId = extractUserIdOrThrow(principal); + validateParticipant(roomId, userId); + + // 타이핑 이벤트를 Redis Pub/Sub으로 브로드캐스트 + RedisChatMessageDto typingEvent = RedisChatMessageDto.ofTyping(roomId, userId, typing.getUserName()); + messagePublisher.publishToRoom(typingEvent); + + log.debug("Typing event - roomId: {}, userId: {}", roomId, userId); + } + + /** + * 읽음 처리 + * 클라이언트 발행: /app/chat/{roomId}/read + * 구독 경로: /topic/chat/{roomId}/read + */ + @MessageMapping("/chat/{roomId}/read") + public void markAsRead( + @DestinationVariable String roomId, + Principal principal) { + + Long userId = extractUserIdOrThrow(principal); + + // 읽음 처리 (서비스 레이어에서 참여자 검증 + Redis + DB 동기화) + chatMessageService.markAsRead(userId, roomId); + + log.debug("Read event - roomId: {}, userId: {}", roomId, userId); + } + + /** + * 채팅방 입장 + * 클라이언트 발행: /app/chat/{roomId}/join + */ + @MessageMapping("/chat/{roomId}/join") + public void joinRoom( + @DestinationVariable String roomId, + Principal principal, + SimpMessageHeaderAccessor headerAccessor) { + + Long userId = extractUserIdOrThrow(principal); + validateParticipant(roomId, userId); + + presenceRedisService.joinRoom(roomId, userId); + log.debug("User {} joined room {}", userId, roomId); + } + + /** + * 채팅방 퇴장 + * 클라이언트 발행: /app/chat/{roomId}/leave + */ + @MessageMapping("/chat/{roomId}/leave") + public void leaveRoom( + @DestinationVariable String roomId, + Principal principal) { + + Long userId = extractUserIdOrThrow(principal); + validateParticipant(roomId, userId); + + presenceRedisService.leaveRoom(roomId, userId); + log.debug("User {} left room {}", userId, roomId); + } + + /** + * Principal에서 userId 추출, 없으면 UnauthorizedException + */ + private Long extractUserIdOrThrow(Principal principal) { + if (principal instanceof UsernamePasswordAuthenticationToken auth) { + Object principalObj = auth.getPrincipal(); + if (principalObj instanceof Long userId) { + return userId; + } + } + throw new UnauthorizedException(ErrorCode.UNAUTHORIZED); + } + + /** + * 채팅방 참여자 검증 + */ + private void validateParticipant(String roomId, Long userId) { + chatParticipantRepository.findByRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> new InvalidInputException(ErrorCode.NOT_CHAT_PARTICIPANT)); + } +} diff --git a/src/main/java/app/dearobjet/backend/domain/chat/websocket/WebSocketEventListener.java b/src/main/java/app/dearobjet/backend/domain/chat/websocket/WebSocketEventListener.java new file mode 100644 index 0000000..e3b34f2 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/chat/websocket/WebSocketEventListener.java @@ -0,0 +1,111 @@ +package app.dearobjet.backend.domain.chat.websocket; + +import app.dearobjet.backend.domain.chat.dto.UserPresenceDto; +import app.dearobjet.backend.domain.chat.service.redis.PresenceRedisService; +import app.dearobjet.backend.domain.chat.service.redis.SessionRedisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectedEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; + +import java.security.Principal; + +/** + * WebSocket 이벤트 리스너 + * 연결, 구독, 해제 이벤트 처리 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketEventListener { + + private final SessionRedisService sessionRedisService; + private final PresenceRedisService presenceRedisService; + + @Value("${spring.application.name:backend}") + private String serverId; + + /** + * WebSocket 연결 완료 이벤트 + */ + @EventListener + public void handleSessionConnected(SessionConnectedEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String sessionId = accessor.getSessionId(); + Long userId = extractUserId(accessor); + + if (userId != null && sessionId != null) { + // Redis에 세션 등록 + sessionRedisService.registerSession(userId, serverId, sessionId); + // 온라인 상태 설정 + presenceRedisService.setPresence(userId, UserPresenceDto.Status.ONLINE); + + log.info("WebSocket connected - userId: {}, sessionId: {}", userId, sessionId); + } + } + + /** + * WebSocket 연결 해제 이벤트 + */ + @EventListener + public void handleSessionDisconnect(SessionDisconnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String sessionId = accessor.getSessionId(); + Long userId = extractUserId(accessor); + + if (userId != null) { + // Redis에서 세션 제거 + sessionRedisService.removeSession(userId, serverId); + // 오프라인 상태 설정 + presenceRedisService.setOffline(userId); + + log.info("WebSocket disconnected - userId: {}, sessionId: {}", userId, sessionId); + } + } + + /** + * 채팅방 구독 이벤트 + */ + @EventListener + public void handleSessionSubscribe(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String destination = accessor.getDestination(); + Long userId = extractUserId(accessor); + + if (userId != null && destination != null && destination.startsWith("/topic/chat/")) { + // 채팅방 구독 시 해당 방에 입장 처리 + String roomId = extractRoomIdFromDestination(destination); + if (roomId != null) { + presenceRedisService.joinRoom(roomId, userId); + sessionRedisService.setActiveRoom(userId, roomId); + log.debug("User {} subscribed to room {}", userId, roomId); + } + } + } + + private Long extractUserId(StompHeaderAccessor accessor) { + Principal principal = accessor.getUser(); + if (principal instanceof UsernamePasswordAuthenticationToken auth) { + Object principalObj = auth.getPrincipal(); + if (principalObj instanceof Long) { + return (Long) principalObj; + } + } + return null; + } + + private String extractRoomIdFromDestination(String destination) { + // /topic/chat/{roomId} 또는 /topic/chat/{roomId}/typing 등에서 roomId 추출 + String[] parts = destination.split("/"); + if (parts.length >= 4) { + return parts[3]; + } + return null; + } +}