- TIL -
구현 목표)
WebSocket 과 STOMP 를 활용하여 랜덤한 유저끼리 1:1 채팅을 맺어주는 서비스를 개발할 것이다.
이때 A, B, C, D 순으로 웹소켓에 접근하게되면 접근 순서대로 A, B 를 매칭시키고 C, D 를 매칭시키는 형식으로 개발을 진행할 것이며
프론트에서 먼저 채팅 시작 버튼을 누르게되면 Spring 서버로 API 요청을 보내게 되며 서버는 이 요청을 통해 DB 에 매칭된 방에 대한 데이터를
저장한 뒤 랜덤하게 생성된 방번호의 ID 를 반환하게 되며 프론트에서는 이 ID 를 받아 해당 ID 를 붙인 STOMP 서버를 구독하게 만들 것이다.
- Spring 서버 코드 -
의존성 추가)
// Websocket 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// STOMP 가 포함된 Spring-Boot-Starter-Web 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-web'
spring-boot-starter-web 의존성은 기본적으로 스프링 서버를 구현할때 추가하는 의존성이므로 이미 존재한다면 추가하지 않아도 된다.
WebSocketConfig.class 작성)
package com.randomchat.main.config.chat;
import org.springframework.context.annotation.Configuration;
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;
@Configuration
@EnableWebSocketMessageBroker
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// WebSocket 의 엔드포인트 설정
registry.addEndpoint("/chat") // 클라이언트의 연결 엔드포인트
.setAllowedOrigins("*"); // 모든 도메인에서의 연결을 허용
}
}
클라이언트의 웹소켓 접근 엔드포인트를 설정
ChatController.class 작성)
package com.randomchat.main.controller.chat;
import com.randomchat.main.service.chat.MatchingService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatController {
private final MatchingService matchingService;
@PostMapping("/enterChatRoom")
public ResponseEntity<Map<String, Long>> enterChatRoom(@RequestBody String nickname) {
Long chatUUID = matchingService.enterChatRoom(nickname);
Map<String, Long> response = new HashMap<>();
response.put("roomId", chatUUID);
return ResponseEntity.ok(response); // OK 상태코드와 함께 생성된 방의 UUID 를 반환한다.
}
}
클라이언트는 최초 채팅방 접속 시도 시 서버에 API 요청을 통해 자신의 닉네임 정보를 전송하고 서버는 이를 받아 DB 에 새로운 채팅방 정보를
저장한 뒤 생성된 방번호의 ID 정보를 클라이언트에게 리턴한다.
( 이후 클라이언트는 이 방번호로 채팅 채널을 구독한다. )
MatchingService.class 작성)
package com.randomchat.main.service.chat;
import com.randomchat.main.domain.chat.ChatRoom;
import com.randomchat.main.repository.chat.ChatRoomRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class MatchingService {
private final ChatRoomRepository chatRoomRepository;
private final Map<String, Long> chatRoomList = new ConcurrentHashMap<>();
public Long enterChatRoom(String nickname) {
Optional<ChatRoom> findRoom = chatRoomRepository.findFirstBySecondUserIsNullAndOpenTrue();
if(findRoom.isPresent()) {
ChatRoom chatRoom = findRoom.get();
ChatRoom changeChatRoom = chatRoom.addUser(nickname);
chatRoomRepository.save(changeChatRoom);
chatRoomList.put(nickname, chatRoom.getId()); // 채팅방 관리 Map 에 닉네임과 채팅방 ID 등록
return chatRoom.getId();
}else {
// 매칭 가능한 방이 없는 경우 새 방 생성
ChatRoom chatRoom = new ChatRoom(nickname);
chatRoomRepository.save(chatRoom);
chatRoomList.put(nickname, chatRoom.getId()); // 채팅방 관리 Map 에 닉네임과 채팅방 ID 등록
return chatRoom.getId();
}
}
public void closeChatRoom(String channel) {
String[] parts = channel.split("/");
Long roomId = Long.parseLong(parts[parts.length - 1]);
ChatRoom room = chatRoomRepository.findById(roomId).get();
ChatRoom ClosedChatRoom = room.closeRoom();
chatRoomRepository.save(ClosedChatRoom);
}
}
사용자가 접속 시 채팅방을 생성 및 접속 해제 시 채팅방을 닫는 메소드
( chatRoomList 는 추후에 토큰 값으로 유저를 관리하려고 생성해둔 부분 )
ChatRoomRepository.class 작성)
package com.randomchat.main.repository.chat;
import com.randomchat.main.domain.chat.ChatRoom;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
Optional<ChatRoom> findFirstBySecondUserIsNullAndOpenTrue();
}
Spring Data JPA 구현체를 활용하여 채팅방을 탐색하여 두 번째 유저가 null 이며 채팅방 상태가 활성화(Open == true)인 데이터를 가져오는
메소드 생성
ChatRoom.class 작성)
package com.randomchat.main.domain.chat;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor
public class ChatRoom {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(nullable = false)
private String firstUser;
@Column
private String secondUser;
@Column(nullable = false)
private boolean open;
public ChatRoom(String nickname) {
this.firstUser = nickname;
this.open = true;
}
public ChatRoom addUser(String nickname) {
this.secondUser = nickname;
return this;
}
public ChatRoom closeRoom() {
this.open = false;
return this;
}
}
채팅방 유저 둘과 채팅방 아이디, 채팅방 활성화 상태를 관리하는 ChatRoom 도메인
ChannelEventListener.class 작성)
package com.randomchat.main.config.chat;
import com.randomchat.main.dto.chat.SendMessageDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Component
@RequiredArgsConstructor
public class ChannelEventListener {
// 매칭 이벤트를 담당하는 클래스
// 2명이 같은 채널에 입장 시 그 채널로 메세지 발송
private final ConcurrentHashMap<String, AtomicInteger> channelSubscribers = new ConcurrentHashMap<>(); // 채널별 구독자 수를 관리하기 위한 맵
private final SimpMessagingTemplate simpMessagingTemplate;
public final Map<String, String> sessionRoomNumberList = new ConcurrentHashMap<>(); // 세션과 채널을 매칭하는 맵
// 구독 이벤트 처리
@EventListener
public void handleSubscribeEvent(SessionSubscribeEvent event) {
SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.wrap(event.getMessage());
String destination = headers.getDestination(); // 구독 경로
String sessionId = headers.getSessionId(); // 세션 아이디
if (destination != null) {
// 채널의 구독자 수 증가
int subscriberCount = channelSubscribers
.computeIfAbsent(destination, key -> new AtomicInteger(0))
.incrementAndGet();
sessionRoomNumberList.put(sessionId, destination);
if (subscriberCount == 2) {
// 구독자가 2명이 되면 매칭 안내
sendMatchingMessage(destination);
}else {
// 구독자가 1명이면 기다리는 중 안내
sendWaitingMessage(destination);
}
}
}
public void sendWaitingMessage(String destination) {
// 구독 시간 대기 0.3초
try {
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
SendMessageDTO message = new SendMessageDTO("waiting", "", "상대방을 기다리는 중입니다.");
simpMessagingTemplate.convertAndSend(destination, message);
}
public void sendMatchingMessage(String destination) {
// 구독 시간 대기 0.3초
try {
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
SendMessageDTO message = new SendMessageDTO("matched", "", "매칭되었습니다.");
simpMessagingTemplate.convertAndSend(destination, message);
}
}
채널을 구독하게 되면 이벤트 리스너를 통해 감지하게 되며 구독 경로와 경로의 접속인원 수를 ConcurrentHashMap 객체를 사용해서 체크한 뒤
1명만 접속 중이라면 "상대방을 기다리는 중입니다." 메세지를 전송, 2명이 접속되었다면 "매칭되었습니다." 메세지를 전송
또한, 접속한 Socket 세션 아이디와 방번호를 ConcurrentHashMap 객체를 사용해서 저장한 뒤 소켓 연결이 끊어지면 해당 방 번호에 상대방이
떠났음을 안내하는 용도로 사용
package com.randomchat.main.dto.chat;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class SendMessageDTO {
private String type;
private String sender;
private String message;
}
SendMessageDTO.class 의 구조
ChatMessageController.class 작성)
package com.randomchat.main.controller.chat;
import com.randomchat.main.dto.chat.ReceiveMessageDTO;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
@Controller
public class ChatMessageController {
@MessageMapping("/send/{roomId}")
@SendTo("/room/{roomId}")
public ReceiveMessageDTO sendMessage(ReceiveMessageDTO receiveMessageDTO) {
return receiveMessageDTO;
}
}
클라이언트가 메세지 전송 채널로 메세지를 전달하면 해당 내용을 서버에서 받아 다시 구독 채널로 뿌려준다.
package com.randomchat.main.dto.chat;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class ReceiveMessageDTO {
private String sender;
private String message;
}
RecieveMessageDTO.class 의 구조
( 클라이언트는 메세지를 전송할 때 자신의 닉네임을 sender 로, 채팅 내용을 message 로 JSON 형태로 매핑하여 전송한다. )
- React 코드 -
randomchat_info.js 작성)
import './../css/randomchat_info.css';
import { useContext, useEffect, useState } from 'react';
import { Client } from '@stomp/stompjs'; // STOMP 클라이언트
import { MyContext } from '../../App';
import { IoMdPerson } from "react-icons/io";
import { FiSearch } from "react-icons/fi";
import { GiSouthAfrica } from 'react-icons/gi';
const Randomchat_info = () => {
const { api } = useContext(MyContext);
const [stompClient, setStompClient] = useState(null); // STOMP 클라이언트 상태
const [input, setInput] = useState(''); // 메시지 입력 상태
const [messages, setMessages] = useState([]); // 메시지 목록
const [nickname, setNickname] = useState(''); // 닉네임 상태
const [roomId, setRoomId] = useState(null); // 구독할 채널 번호 상태
const [isComposing, setIsComposing] = useState(false); // IME 조합 상태 관리
// STOMP 클라이언트 초기화
useEffect(() => {
const client = new Client({
brokerURL: '{서버 주소}', // STOMP 서버 WebSocket URL
reconnectDelay: 5000, // 재연결 간격
debug: (str) => console.log(str), // 디버그 로그
});
client.onConnect = () => console.log("STOMP connected");
client.onStompError = (error) => console.error("STOMP error:", error);
client.activate(); // 연결 시작
setStompClient(client);
return () => client.deactivate(); // 컴포넌트 언마운트 시 연결 종료
}, []);
// 닉네임 설정 후 채팅방 연결
const handleConnect = () => {
if (nickname.trim()) {
fetch('{서버주소}/chat/enterChatRoom', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nickname }), // 닉네임 전송
})
.then((response) => response.json())
.then((data) => {
setRoomId(data.roomId); // 응답값에서 채널 번호 추출
subscribeToRoom(data.roomId); // 채널 구독
})
.catch((error) => console.error('Error:', error));
}
};
// 채널 구독
const subscribeToRoom = (roomId) => {
if (stompClient) {
stompClient.subscribe(`/room/${roomId}`, (message) => {
const msg = JSON.parse(message.body); // 서버로부터 메시지 수신
console.log("msg 내용입니다 >>>>> ", msg)
setMessages((prevMessages) => [
...prevMessages,
{ type: msg.type, sender: msg.sender, message: msg.message }, // 메시지 목록 업데이트
]);
});
console.log(`Subscribed to room ${roomId}`);
}
};
// 메시지 전송
const sendMessage = () => {
if (input.trim() && stompClient && roomId) {
const message = { message: input };
stompClient.publish({
destination: `/send/${roomId}`, // 메시지 전송 경로
body: JSON.stringify({"sender" : nickname, "message" : message.message}),
});
console.log("메세지 전송 완료!!! >>> " , message.message);
setInput('');
}
};
// 디버그용 메시지 로그
useEffect(() => {
console.log("Messages:", messages);
}, [messages]);
// 메세지별 css 클래스네임 정의
const getMessageClassName = (msg) => {
console.log("메세지 내용 >>>>> " , msg)
if(msg.type !== undefined) return 'randomchat_info_text_alert';
else if(msg.sender === nickname) return 'randomchat_info_text_me';
else return 'randomchat_info_text_other';
}
return (
<div className='randomchat_info_container'>
{!roomId ? (
<div className='randomchat_waiting_screen'>
<div className='randomchat_nickname_div'>
<input
className='randomchat_nickname_input_box'
type="text"
placeholder="닉네임을 입력하세요"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/>
<button onClick={handleConnect}>연결</button>
</div>
</div>
) : (
<>
<div className='randomchat_info_title_content'>
<div className='randomchat_info_title_icon_content'>
<IoMdPerson className='randomchat_info_title_icon'></IoMdPerson>
</div>
<div className='randomchat_info_title_text'>채팅방</div>
<div className='randomchat_info_title_search'>
<FiSearch />
</div>
</div>
<div className='randomchat_info_content'>
{messages.length === 0 ? (
<div className='randomchat_info_none_content'>대화 내용이 없습니다.</div>
) : (
<div className='randomchat_info_text_container'>
{messages.map((msg, index) => (
<div
key={index}
className={getMessageClassName(msg)}
>
{msg.message}
</div>
))}
</div>
)}
</div>
<div className='randomchat_info_input_content'>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isComposing) {
// IME 조합 중이 아닐 때만 실행
e.preventDefault();
if (input.trim()) {
sendMessage();
}
}
}}
onCompositionStart={() => setIsComposing(true)} // IME 입력 시작
onCompositionEnd={() => setIsComposing(false)} // IME 입력 완료
className='randomchat_info_input'
/>
<div onClick={sendMessage} className='randomchat_info_input_btn'>
Send
</div>
</div>
</>
)}
</div>
);
};
export default Randomchat_info;
STOMP 라이브러리를 설치 한 이후 진행
useEffect 를 사용하여 메세지가 수, 발신 될때마다 메세지를 리렌더링
메세지 내용을 useState 를 사용하여 관리
.randomchat_info_container{
width: 1280px;
margin: auto;
}
/* 제목 */
.randomchat_info_title_content{
height: 120px;
display: flex;
align-items: center;
border-bottom: 1px solid rgba(0,0,0,0.5);
}
.randomchat_info_title_icon_content{
width: 90px;
height: 90px;
background-color: rgba(128,128,128,0.2);
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
}
.randomchat_info_title_icon{
width: 80px;
height: 80px;
color: rgba(128,128,128,0.7);
border-radius: 50%;
}
.randomchat_info_title_text{
font-size: 20px;
font-weight: bold;
margin-left: 10px;
}
.randomchat_info_title_search{
margin-left: 1060px;
font-size: 50px;
color: #3578FF;
display: flex;
align-items: center;
}
/* 채팅 정보 */
.randomchat_info_content{
height: 600px;
margin-top: 10px;
overflow-y: auto;
}
/* 스크롤바를 완전히 숨기기 */
.randomchat_info_content::-webkit-scrollbar {
display: none; /* 스크롤바를 숨깁니다 */
}
/* 없음 */
.randomchat_info_none_content{
height: 600px;
display: flex;
justify-content: center;
align-items: center;
font-size: 25px;
color: rgba(0,0,0,0.5);
}
.randomchat_info_text_container{
/* display: flex; */
}
/* 아이콘 */
.randomchat_info_other_icon_container{
display: flex;
flex-direction: column;
}
.randomchat_info_other_icon_content{
width: 40px;
height: 40px;
background-color: rgba(128,128,128,0.2);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
margin-top: auto;
}
.randomchat_info_other_icon{
width: 30px;
height: 30px;
color: rgba(128,128,128,0.7);
border-radius: 50%;
}
/* 텍스트 */
.randomchat_info_text_content{
width: 1280px;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.randomchat_info_text_me,
.randomchat_info_text_other,
.randomchat_info_text_alert {
max-width: 600px;
width: auto;
min-height: 40px;
color: white;
font-size: 20px;
background-color: #3578FF;
display: flex;
align-items: center;
justify-content: center; /* 텍스트를 중앙 정렬 */
border-radius: 8px;
margin-top: 5px;
padding-left: 10px;
padding-right: 10px;
word-wrap: break-word;
white-space: normal;
width: 100%; /* 한 줄을 차지하도록 설정 */
box-sizing: border-box; /* padding 포함한 전체 크기 계산 */
}
.randomchat_info_text_other {
background-color: rgba(128,128,128,0.5);
margin-left: 0; /* 왼쪽으로 정렬 */
margin-right: auto; /* 오른쪽 여유 공간 */
text-align: left; /* 텍스트 왼쪽 정렬 */
justify-content: left;
}
.randomchat_info_text_me {
background-color: #3578FF;
margin-left: auto; /* 왼쪽 여유 공간 */
margin-right: 0; /* 오른쪽으로 정렬 */
text-align: right; /* 텍스트 오른쪽 정렬 */
justify-content: right;
}
.randomchat_info_text_alert {
background-color: #fc0000;
margin-left: auto; /* 왼쪽 여유 공간 */
margin-right: auto; /* 오른쪽 여유 공간 */
text-align: center; /* 텍스트 중앙 정렬 */
}
.randomchat_nickname_div {
height: 600px;
overflow-y: auto;
display: flex; /* 플렉스 박스 활성화 */
justify-content: center; /* 가로 정렬 */
align-items: center; /* 세로 정렬 */
}
.randomchat_nickname_input_box {
/* 닉네임 인풋박스 css*/
border: 1px solid rgb(159, 153, 153);
border-radius: 8px;
text-align: center;
height: 30px;
width: 210px;
}
.randomchat_nickname_div > button {
/* 연결 버튼 css */
margin-left: 10px;
background-color: #3578FF; /* 메인 색 배경 */
color: white; /* 글자색 */
font-size: 15px; /* 글자 크기 */
height: 20px;
width: 40px;
border: none; /* 테두리 제거 */
border-radius: 5px; /* 모서리 둥글게 */
cursor: pointer; /* 마우스 포인터 변경 */
box-shadow: 0 4px #184192; /* 그림자 */
transition: background-color 0.3s ease; /* 배경색 변화 효과 */
}
.randomchat_nickname_div > button:hover {
background-color: #143371;
}
.randomchat_nickname_div > button:active {
box-shadow: 0 2px #184192; /* 그림자 줄이기 */
transform: translateY(2px); /* 버튼 눌리는 효과 */
}
/* 나 */
.randomchat_info_me_content{
}
.randomchat_info_me_text_content{
background-color: #3578FF;
color: white;
}
/* 입력 */
.randomchat_info_input_content{
margin-top: 10px;
margin-bottom: 30px;
display: flex;
}
.randomchat_info_input{
width: 1190px;
height: 50px;
padding: 0px 0px 0px 10px;
outline: none;
font-size: 22px;
}
.randomchat_info_input_btn{
width: 80px;
height: 53px;
background-color: #3578FF;
color: white;
font-size: 20px;
font-weight: 500;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
randomchat_info.css 작성
Nginx 추가 설정)
worker_processes 1;
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name www.random-chat.site;
location / {
proxy_pass http://randomchat-react:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
server {
listen 80;
server_name api.random-chat.site;
location / {
proxy_pass http://randomchat-spring:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_cache_bypass $http_upgrade;
}
}
}
현재 Nginx 의 리버스 프록시 기능을 사용하는데 웹 소켓 통신의 경우 헤더에 소켓 정보를 담아 보내기 때문에
필요한 헤더 정보를 추가하는 3줄의 코드를 추가해줬다.
proxy_set_header Uprade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_cache_bypass $http_upgrade;
랜덤 채팅 작동 영상
▼ 완성된 랜덤채팅 사이트 방문하기 ▼
React App
www.random-chat.site