Skip to content

Commit f123729

Browse files
authored
Merge pull request #313 from KW-ClassLog/Feat/#304/chatting
✨ Feat/#304 실시간 채팅 기능 구현
2 parents 3092f4b + 8fcc9ed commit f123729

File tree

13 files changed

+336
-10
lines changed

13 files changed

+336
-10
lines changed

backend/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dependencies {
4646
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.600'
4747
implementation 'com.amazonaws:aws-java-sdk-core:1.12.681'
4848
implementation 'org.apache.commons:commons-lang3:3.12.0'
49+
implementation 'org.springframework.boot:spring-boot-starter-websocket'
4950
}
5051

5152
tasks.named('test') {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.example.backend.domain.question.controller;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.example.backend.domain.question.dto.request.MessageRequestDTO;
6+
import org.example.backend.domain.question.service.ChatService;
7+
import org.example.backend.global.websocket.StompPrincipal;
8+
import org.springframework.messaging.handler.annotation.DestinationVariable;
9+
import org.springframework.messaging.handler.annotation.MessageMapping;
10+
import org.springframework.messaging.handler.annotation.Payload;
11+
import org.springframework.stereotype.Controller;
12+
13+
import java.security.Principal;
14+
import java.util.UUID;
15+
16+
/**
17+
* STOMP 메시지 수신 & Redis Pub/Sub으로 전달
18+
*/
19+
@Slf4j
20+
@Controller
21+
@RequiredArgsConstructor
22+
public class ChatController {
23+
24+
private final ChatService chatService;
25+
26+
/**
27+
* 클라이언트기 "/pub/lecture/{lectureId}"로 메시지를 전송하면 해당 메시지를 redis pub/sub 채널로 publish
28+
* @param lectureId
29+
* @param messageDTO
30+
*/
31+
@MessageMapping("/lecture/{lectureId}")
32+
public void sendMessage(@DestinationVariable UUID lectureId,
33+
@Payload MessageRequestDTO.MessageDTO messageDTO,
34+
Principal principal) {
35+
log.info("메시지 수신: {}", messageDTO);
36+
log.info("principal = {}", principal);
37+
38+
StompPrincipal stompPrincipal = (StompPrincipal) principal;
39+
UUID userId = stompPrincipal.userId();
40+
String role = stompPrincipal.role();
41+
42+
chatService.sendMessage(lectureId,messageDTO,userId,role);
43+
}
44+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package org.example.backend.domain.question.dto.request;
2+
3+
import lombok.*;
4+
import org.example.backend.domain.user.entity.Role;
5+
6+
import java.time.LocalDateTime;
7+
import java.util.UUID;
8+
9+
public class MessageRequestDTO {
10+
@Getter
11+
@Builder
12+
public static class MessageDTO {
13+
private UUID senderId;
14+
private String senderName;
15+
private String content;
16+
private Role role;
17+
private LocalDateTime timestamp;
18+
}
19+
}
20+

backend/src/main/java/org/example/backend/domain/question/exception/QuestionErrorCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
@Getter
1010
@AllArgsConstructor
1111
public enum QuestionErrorCode implements BaseErrorCode {
12-
_FORBIDDEN_LECTURE_ACCESS(HttpStatus.FORBIDDEN,"QUESTION403_1","해당 강의를 수강중인 학생만 조회가능합니다.");
12+
_FORBIDDEN_LECTURE_ACCESS(HttpStatus.FORBIDDEN,"QUESTION403_1","해당 강의를 수강중인 학생만 조회가능합니다."),
13+
_CHAT_MESSAGE_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "QUESTION500_1", "채팅 메시지 전송에 실패했습니다.");
1314

1415
private final HttpStatus httpStatus;
1516
private final String code;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package org.example.backend.domain.question.service;
2+
3+
import org.example.backend.domain.question.dto.request.MessageRequestDTO;
4+
5+
import java.util.UUID;
6+
7+
public interface ChatService {
8+
void sendMessage(UUID lectureId, MessageRequestDTO.MessageDTO messageDTO, UUID userId, String role);
9+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package org.example.backend.domain.question.service;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.example.backend.domain.question.dto.request.MessageRequestDTO;
7+
import org.example.backend.domain.question.exception.QuestionErrorCode;
8+
import org.example.backend.domain.question.exception.QuestionException;
9+
import org.example.backend.domain.user.entity.User;
10+
import org.example.backend.domain.user.exception.UserErrorCode;
11+
import org.example.backend.domain.user.exception.UserException;
12+
import org.example.backend.domain.user.repository.UserRepository;
13+
import org.springframework.data.redis.core.RedisTemplate;
14+
import org.springframework.stereotype.Service;
15+
16+
import java.time.LocalDateTime;
17+
import java.util.UUID;
18+
19+
@Slf4j
20+
@Service
21+
@RequiredArgsConstructor
22+
public class ChatServiceImpl implements ChatService {
23+
24+
private final RedisTemplate<String, String> redisTemplate;
25+
private final ObjectMapper objectMapper;
26+
private final UserRepository userRepository;
27+
28+
@Override
29+
public void sendMessage(UUID lectureId, MessageRequestDTO.MessageDTO messageDTO, UUID userId, String role) {
30+
/**
31+
* STOMP로부터 수신한 메시지를 Redis Pub/Sub으로 전파하고,
32+
* Redis List에 저장하는 메서드
33+
*/
34+
35+
try{
36+
// 사용자 정보 조회
37+
User sender = userRepository.findById(userId)
38+
.orElseThrow(() -> new UserException(UserErrorCode._USER_NOT_FOUND));
39+
40+
String senderName = sender.getName();
41+
42+
// 1. 메시지 저장
43+
// 저장용 DTO 구성
44+
MessageRequestDTO.MessageDTO originalMessage = MessageRequestDTO.MessageDTO.builder()
45+
.senderId(userId)
46+
.senderName(senderName)
47+
.content(messageDTO.getContent())
48+
.role(sender.getRole())
49+
.timestamp(LocalDateTime.now())
50+
.build();
51+
52+
String originalMessageJson = objectMapper.writeValueAsString(originalMessage); // DTO -> JSON 직렬화
53+
54+
// Redis List 키 이름 지정(채팅 내용 저장용): chat:lecture:{lectureId}
55+
String redisListKey = "chat:lecture:"+ lectureId;
56+
57+
// Redis list - 메시지 저장
58+
redisTemplate.opsForList().rightPush(redisListKey, originalMessageJson);
59+
60+
61+
// 2. 메시지 전파
62+
// 메시지 구성
63+
MessageRequestDTO.MessageDTO maskMessage = MessageRequestDTO.MessageDTO.builder()
64+
.senderId(null)
65+
.senderName(null)
66+
.content(messageDTO.getContent())
67+
.role(sender.getRole())
68+
.timestamp(LocalDateTime.now())
69+
.build();
70+
71+
String maskMessageJson = objectMapper.writeValueAsString(maskMessage); // DTO -> JSON 직렬화
72+
73+
// Redis 채널 이름 지정: lecture:{lectureId}
74+
String studentChannel = "lecture:" + lectureId;
75+
// Redis pub/sub - 메시지 전파
76+
redisTemplate.convertAndSend(studentChannel, maskMessageJson);
77+
78+
79+
} catch (Exception e){
80+
log.error("채팅 메시지 전송 실패",e);
81+
throw new QuestionException(QuestionErrorCode._CHAT_MESSAGE_SEND_FAIL);
82+
}
83+
}
84+
}

backend/src/main/java/org/example/backend/global/redis/RedisConfig.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
77
import org.springframework.data.redis.core.RedisTemplate;
88
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.data.redis.listener.PatternTopic;
10+
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
911
import org.springframework.data.redis.serializer.StringRedisSerializer;
1012

1113
@Configuration
@@ -30,4 +32,17 @@ public RedisTemplate<String, String> redisTemplate() {
3032
redisTemplate.setValueSerializer(new StringRedisSerializer()); // value를 문자열로 직렬화
3133
return redisTemplate;
3234
}
35+
36+
@Bean
37+
public RedisMessageListenerContainer redisMessageListenerContainer(
38+
RedisConnectionFactory redisConnectionFactory,
39+
RedisMessageSubscriber redisMessageSubscriber
40+
) {
41+
// redis pub/sub 메시지 처리 listener
42+
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
43+
container.setConnectionFactory(redisConnectionFactory());
44+
45+
container.addMessageListener(redisMessageSubscriber, new PatternTopic("lecture:*"));
46+
return container;
47+
}
3348
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package org.example.backend.global.redis;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.example.backend.domain.question.dto.request.MessageRequestDTO;
7+
import org.springframework.data.redis.connection.Message;
8+
import org.springframework.data.redis.connection.MessageListener;
9+
import org.springframework.messaging.simp.SimpMessageSendingOperations;
10+
import org.springframework.stereotype.Service;
11+
12+
/**
13+
* redis subscriber 역할을 수행하는 클래스
14+
* redis pub/sub 채널로 메시지를 수신하면, 해당 메시지를 STOMP를 통해 web socket 구독자에게 브로드캐스트
15+
*/
16+
@Service
17+
@RequiredArgsConstructor
18+
@Slf4j
19+
public class RedisMessageSubscriber implements MessageListener {
20+
21+
private final SimpMessageSendingOperations simpMessageSendingOperations;
22+
private final ObjectMapper objectMapper;
23+
24+
/**
25+
* redis로부터 메시지를 수신했을 때 호출되는 메소드
26+
*
27+
* @param message redis에서 전달된 메시지(json 형태)
28+
* @param pattern 구독중인 채널 패턴
29+
*/
30+
@Override
31+
public void onMessage(Message message, byte[] pattern) {
32+
try{
33+
String channel = new String(message.getChannel());
34+
35+
System.out.println(channel);
36+
String[] parts = channel.split(":");
37+
String lectureId = parts[1];
38+
39+
String body = new String(message.getBody());
40+
41+
// JSON -> DTO 역직렬화
42+
MessageRequestDTO.MessageDTO chatMessage = objectMapper.readValue(body, MessageRequestDTO.MessageDTO.class);
43+
44+
// subscriber에게 STOMP 메시지 전송
45+
simpMessageSendingOperations.convertAndSend("/sub/lecture/"+ lectureId, chatMessage);
46+
} catch (Exception e) {
47+
log.error("Redis 메시지 수신 실패", e);
48+
}
49+
}
50+
}

backend/src/main/java/org/example/backend/global/security/config/SecurityConfig.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
1515
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1616
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
17+
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
1718
import org.springframework.security.config.http.SessionCreationPolicy;
1819
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
1920
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -74,6 +75,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
7475
// .anyRequest().permitAll()
7576
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
7677
.requestMatchers("/actuator/health").permitAll()
78+
.requestMatchers("/ws-connect/**").permitAll()
7779
.requestMatchers("/api/users","/api/users/verify-email","/api/users/login","/api/users/password/temp").permitAll()
7880
.anyRequest().authenticated())
7981
.addFilterBefore(new FilterExceptionHandler(), LogoutFilter.class) // 예외처리 필터
@@ -98,4 +100,11 @@ public CorsConfigurationSource corsConfigurationSource() {
98100

99101
return source;
100102
}
103+
104+
// web socket 허용
105+
@Bean
106+
public WebSecurityCustomizer webSecurityCustomizer() {
107+
return (web) -> web.ignoring()
108+
.requestMatchers("/ws-connect/**");
109+
}
101110
}

backend/src/main/java/org/example/backend/global/security/filter/JWTFilter.java

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
4141
uri.equals("/api/users")||
4242
uri.equals("/api/users/password/temp")||
4343
uri.equals("/api/users/verify-email")||
44-
uri.equals("/api/users/refresh")) {
44+
uri.equals("/api/users/refresh")||
45+
uri.startsWith("/ws-connect")) {
4546
filterChain.doFilter(request, response);
4647
return;
4748
}
@@ -55,14 +56,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
5556
// request에서 Authorization 헤더를 찾음
5657
String authorization = request.getHeader("Authorization");
5758

58-
// // Authorization 헤더 검증
59-
// if(authorization == null || !authorization.startsWith("Bearer ")){
60-
// System.out.println("token null or invalid");
61-
// setErrorResponse(response, UserErrorCode._TOKEN_MISSING);
62-
//
63-
// // 조건이 해당되면 메소드 종료
64-
// return;
65-
// }
59+
// Authorization 헤더 검증
60+
if(authorization == null || !authorization.startsWith("Bearer ")){
61+
System.out.println("token null or invalid");
62+
setErrorResponse(response, UserErrorCode._TOKEN_MISSING);
63+
64+
// 조건이 해당되면 메소드 종료
65+
return;
66+
}
6667

6768
// Bearer 제외하고 토큰만 획득
6869
String token = authorization.substring(7);

0 commit comments

Comments
 (0)