Skip to content

Commit dc92342

Browse files
Merge pull request #183 from prgrms-web-devcourse-final-project/feat/#141
[Chat] 실시간 채팅 구현
2 parents 33962be + 3d27086 commit dc92342

8 files changed

Lines changed: 332 additions & 3 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.back.web7_9_codecrete_be.domain.chats.controller;
2+
3+
import java.security.Principal;
4+
5+
import org.springframework.messaging.handler.annotation.MessageMapping;
6+
import org.springframework.stereotype.Controller;
7+
8+
import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest;
9+
import com.back.web7_9_codecrete_be.domain.chats.service.ChatMessageService;
10+
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
14+
@Slf4j
15+
@Controller
16+
@RequiredArgsConstructor
17+
public class ChatMessageController {
18+
19+
private final ChatMessageService chatMessageService;
20+
21+
@MessageMapping("/chat/send")
22+
public void send(ChatMessageRequest message, Principal principal) {
23+
24+
if (principal == null) {
25+
throw new IllegalStateException("Unauthenticated WebSocket access");
26+
}
27+
28+
chatMessageService.sendMessage(message, principal);
29+
}
30+
}
31+
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.back.web7_9_codecrete_be.domain.chats.controller;
2+
3+
import org.springframework.web.bind.annotation.GetMapping;
4+
import org.springframework.web.bind.annotation.RequestMapping;
5+
import org.springframework.web.bind.annotation.RestController;
6+
7+
import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest;
8+
import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse;
9+
10+
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.media.Content;
12+
import io.swagger.v3.oas.annotations.media.Schema;
13+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
14+
import io.swagger.v3.oas.annotations.tags.Tag;
15+
16+
@RestController
17+
@RequestMapping("/docs/chat")
18+
@Tag(name = "Chat STOMP", description = "WebSocket / STOMP 채팅 프로토콜 문서. 문서용 API. 사용X")
19+
public class ChatStompDocsController {
20+
21+
@Operation(
22+
summary = "채팅 메시지 전송 (STOMP)",
23+
description = """
24+
### 📡 WebSocket STOMP 채팅 메시지 전송
25+
26+
#### 1️⃣ WebSocket Endpoint
27+
```
28+
ws://localhost:8080/ws-chat
29+
or
30+
wss://www.naeconcertbutakhae.shop/ws-chat
31+
```
32+
33+
#### 2️⃣ SEND Destination
34+
```
35+
/app/chat/send
36+
```
37+
38+
#### 3️⃣ SUBSCRIBE Destination
39+
```
40+
/topic/chat/{concertId}
41+
```
42+
43+
#### 4️⃣ SEND Payload
44+
```json
45+
{
46+
"concertId": 1,
47+
"content": "안녕하세요!"
48+
}
49+
```
50+
51+
#### 5️⃣ SUBSCRIBE Response
52+
```json
53+
{
54+
"concertId": 1,
55+
"senderId": 10,
56+
"senderName": "테스트 유저",
57+
"content": "안녕하세요!",
58+
"sentAt": "2025-12-23T15:30:00"
59+
}
60+
```
61+
"""
62+
)
63+
@GetMapping("/stomp")
64+
public void stompChatGuide() {}
65+
66+
@Operation(
67+
summary = "STOMP 채팅 메시지 전송 규격",
68+
description = """
69+
WebSocket + STOMP 기반 채팅 메시지 전송 규격입니다.
70+
71+
- 실제 사용되는 HTTP API 아닙니다.
72+
- Swagger 문서용
73+
""",
74+
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
75+
description = "STOMP 메세지 SEND하면 전달되는 요청 데이터",
76+
required = true,
77+
content = @Content(
78+
schema = @Schema(implementation = ChatMessageRequest.class)
79+
)
80+
),
81+
responses = {
82+
@ApiResponse(
83+
responseCode = "200",
84+
description = "STOMP SUBSCRIBE로 수신되는 메시지",
85+
content = @Content(
86+
schema = @Schema(implementation = ChatMessageResponse.class)
87+
)
88+
)
89+
}
90+
)
91+
@GetMapping("/message-schema")
92+
public ChatMessageResponse messageSchema() {
93+
return null; // 실제 반환 목적 X
94+
}
95+
}
96+
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.back.web7_9_codecrete_be.domain.chats.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
@Schema(description = "채팅 메시지 전송 요청")
12+
public class ChatMessageRequest {
13+
14+
@Schema(description = "공연 ID", example = "1")
15+
private Long concertId;
16+
@Schema(description = "메시지 내용", example = "안녕하세요")
17+
private String content;
18+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.back.web7_9_codecrete_be.domain.chats.dto;
2+
3+
import java.time.LocalDateTime;
4+
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
@Getter
11+
@NoArgsConstructor
12+
@AllArgsConstructor
13+
@Schema(description = "채팅 메시지 응답")
14+
public class ChatMessageResponse {
15+
16+
@Schema(description = "공연 ID", example = "1")
17+
private Long concertId;
18+
@Schema(description = "발신자 ID", example = "2")
19+
private Long senderId;
20+
@Schema(description = "발신자 닉네임", example = "테스트 유저")
21+
private String senderName;
22+
@Schema(description = "메시지 내용", example = "안녕하세요")
23+
private String content;
24+
@Schema(description = "전송 시각", example = "2025-12-23T16:28:07.8806432")
25+
private LocalDateTime sentDate;
26+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.back.web7_9_codecrete_be.domain.chats.service;
2+
3+
import java.security.Principal;
4+
import java.time.LocalDateTime;
5+
6+
import org.springframework.messaging.simp.SimpMessagingTemplate;
7+
import org.springframework.stereotype.Service;
8+
9+
import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest;
10+
import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse;
11+
import com.back.web7_9_codecrete_be.domain.users.entity.User;
12+
import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository;
13+
import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode;
14+
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
15+
16+
import lombok.RequiredArgsConstructor;
17+
import lombok.extern.slf4j.Slf4j;
18+
19+
@Slf4j
20+
@Service
21+
@RequiredArgsConstructor
22+
public class ChatMessageService {
23+
24+
private final UserRepository userRepository;
25+
private final SimpMessagingTemplate messagingTemplate;
26+
27+
public void sendMessage(ChatMessageRequest message, Principal principal) {
28+
29+
String email = principal.getName();
30+
31+
// TODO: 캐싱처리
32+
User user = userRepository.findByEmail(email)
33+
.orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND));
34+
35+
Long senderId = user.getId();
36+
String senderName = user.getNickname();
37+
38+
ChatMessageResponse response = new ChatMessageResponse(
39+
message.getConcertId(),
40+
senderId,
41+
senderName,
42+
message.getContent(),
43+
LocalDateTime.now()
44+
);
45+
46+
log.info("[SEND MESSAGE] From User ID: {}, Content: {}", senderId, message.getContent());
47+
48+
messagingTemplate.convertAndSend(
49+
"/topic/chat/" + message.getConcertId(),
50+
response
51+
);
52+
}
53+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.back.web7_9_codecrete_be.global.config;
2+
3+
import org.springframework.context.annotation.Configuration;
4+
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
5+
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
6+
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
7+
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
8+
9+
import com.back.web7_9_codecrete_be.global.websocket.JwtHandshakeHandler;
10+
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
14+
@Slf4j
15+
@Configuration
16+
@EnableWebSocketMessageBroker
17+
@RequiredArgsConstructor
18+
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
19+
20+
private final JwtHandshakeHandler jwtHandshakeHandler;
21+
22+
@Override
23+
public void configureMessageBroker(MessageBrokerRegistry config) {
24+
25+
config.enableSimpleBroker("/topic");
26+
config.setApplicationDestinationPrefixes("/app");
27+
28+
}
29+
30+
@Override
31+
public void registerStompEndpoints(StompEndpointRegistry registry) {
32+
33+
log.info("WebSocket 엔드포인트 등록: /ws-chat");
34+
35+
registry.addEndpoint("/ws-chat")
36+
.setHandshakeHandler(jwtHandshakeHandler)
37+
.setAllowedOriginPatterns("http://localhost:3000",
38+
"https://www.naeconcertbutakhae.shop");
39+
40+
}
41+
42+
}

src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package com.back.web7_9_codecrete_be.global.security;
22

3-
import com.back.web7_9_codecrete_be.domain.auth.service.TokenService;
4-
import lombok.RequiredArgsConstructor;
3+
import java.util.List;
4+
55
import org.springframework.context.annotation.Bean;
66
import org.springframework.context.annotation.Configuration;
77
import org.springframework.security.authentication.AuthenticationManager;
@@ -14,7 +14,9 @@
1414
import org.springframework.web.cors.CorsConfiguration;
1515
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
1616

17-
import java.util.List;
17+
import com.back.web7_9_codecrete_be.domain.auth.service.TokenService;
18+
19+
import lombok.RequiredArgsConstructor;
1820

1921
@Configuration
2022
@RequiredArgsConstructor
@@ -48,6 +50,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
4850
// Authorization 설정
4951
.authorizeHttpRequests(auth -> auth
5052
.requestMatchers(
53+
"/ws-chat/**",
5154
"/actuator/**",
5255
"/api/v1/auth/**", // 로그인/회원가입은 허용
5356
"/v3/api-docs/**", // Swagger
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.back.web7_9_codecrete_be.global.websocket;
2+
3+
import java.security.Principal;
4+
import java.util.Map;
5+
6+
import org.springframework.http.server.ServerHttpRequest;
7+
import org.springframework.http.server.ServletServerHttpRequest;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.web.socket.WebSocketHandler;
10+
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
11+
12+
import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode;
13+
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
14+
import com.back.web7_9_codecrete_be.global.security.JwtTokenProvider;
15+
16+
import jakarta.servlet.http.Cookie;
17+
import jakarta.servlet.http.HttpServletRequest;
18+
import lombok.RequiredArgsConstructor;
19+
20+
/**
21+
* WebSocket Handshake 단계에서 쿠키에 포함된 JWT를 추출하여
22+
* 사용자 Authentication(Principal)을 설정하는 HandshakeHandler.
23+
*
24+
* HTTP 필터 체인이 적용되지 않는 WebSocket 연결 단계에서
25+
* 별도의 인증 처리를 담당
26+
*/
27+
@Component
28+
@RequiredArgsConstructor
29+
public class JwtHandshakeHandler extends DefaultHandshakeHandler {
30+
31+
private final JwtTokenProvider jwtTokenProvider;
32+
33+
@Override
34+
protected Principal determineUser(
35+
ServerHttpRequest request,
36+
WebSocketHandler wsHandler,
37+
Map<String, Object> attributes) {
38+
39+
if (!(request instanceof ServletServerHttpRequest servletRequest)) {
40+
throw new BusinessException(AuthErrorCode.UNAUTHORIZED_USER);
41+
}
42+
43+
HttpServletRequest httpRequest = servletRequest.getServletRequest();
44+
45+
if (httpRequest.getCookies() == null) {
46+
throw new BusinessException(AuthErrorCode.UNAUTHORIZED_USER);
47+
}
48+
49+
for (Cookie cookie : httpRequest.getCookies()) {
50+
if ("ACCESS_TOKEN".equals(cookie.getName())) {
51+
String token = cookie.getValue();
52+
53+
return jwtTokenProvider.getAuthentication(token);
54+
}
55+
}
56+
57+
throw new BusinessException(AuthErrorCode.UNAUTHORIZED_USER);
58+
}
59+
}
60+

0 commit comments

Comments
 (0)