<template>
<v-container>
<v-row>
<v-col>
<v-card>
<v-card-title class="text-center text h-5">
채팅방 목록
<div class="d-flex justify-end">
<v-btn color="secondary" @click="showCreateRoomModal = true ">
채팅방 생성
</v-btn>
</div>
</v-card-title>
<v-card-text>
<v-table>
<thead>
<tr>
<th>방번호</th>
<th>방제목</th>
<th>채팅</th>
</tr>
</thead>
<tbody>
<tr v-for="chat in chatRoomList" :key="chat.roomId">
<td>{{ chat.roomId }}</td>
<td>{{ chat.roomName }}</td>
<td>
<v-btn color="primary" @click="joinChatRoom(chat.roomId)"
>참여하기</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</v-col>
<v-dialog v-model="showCreateRoomModal" max-width="500px">
<v-card>
<v-card-title class="text-h6">
채팅방 생성
</v-card-title>
<v-card-text>
<v-text-field label="방제목" v-model="newRoomTitle"/>
</v-card-text>
<v-card-actions>
<v-btn color="grey" @click="showCreateRoomModal=false">
취소
</v-btn>
<v-btn color="primary" @click="createChatRoom">
생성
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
</v-container>
</template>
<script>
import axios from 'axios';
export default{
data(){
return {
chatRoomList :[],
showCreateRoomModal:false,
newRoomTitle:"",
}
},
async created(){
// 들어오자마자 이 함수를 호출한다.
this.loadChatRoom();
},
methods:{
async joinChatRoom(roomId){
await axios.post(`${process.env.VUE_APP_API_BASE_URL}/chat/room/group/${roomId}/join`);
// 화면 전환을 해주는 용도
this.$router.push(`/chatpage/${roomId}`);
},
async createChatRoom(){
await axios.post(`${process.env.VUE_APP_API_BASE_URL}/chat/room/group/create?roomName=${this.newRoomTitle}`,null);
this.showCreateRoomModal=false;
this.loadChatRoom();
},
async loadChatRoom(){
const response=await axios.get(`${process.env.VUE_APP_API_BASE_URL}/chat/room/group/list`);
this.chatRoomList=response.data;
}
}
}
</script>
router로 화면전화을 따로 해준다.
API 로 기능을 다시 회고하자면
그룹 채팅방 개설 및 참여 이전 포스트 글에서 다루었다.
이전메시지 내역을 조회하자면
public List<ChatMessageDto> getChatHistory(Long roomId){
// 특정룸에
// 내가 해당 채팅방의 참여자가 아닐경우 에러
ChatRoom chatRoom = chatRoomRepository.findById(roomId).orElseThrow(()-> new EntityNotFoundException("room cannot be found"));
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName()).orElseThrow(()->new EntityNotFoundException("member cannot be found"));
// 해당 방의 참여자를 탐색한다.
List<ChatParticipant> chatParticipants = chatParticipantRepository.findByChatRoom(chatRoom);
boolean check = false;
for(ChatParticipant c : chatParticipants){
if(c.getMember().equals(member)){
check = true;
}
}
if(!check)throw new IllegalArgumentException("본인이 속하지 않은 채팅방입니다.");
// 특정 room에 대한 message조회
List<ChatMessage> chatMessages = chatMessageRepository.findByChatRoomOrderByCreatedTimeAsc(chatRoom);
List<ChatMessageDto> chatMessageDtos = new ArrayList<>();
for(ChatMessage c : chatMessages){
ChatMessageDto chatMessageDto = ChatMessageDto.builder()
.message(c.getContent())
.senderEmail(c.getMember().getEmail())
.build();
chatMessageDtos.add(chatMessageDto);
}
return chatMessageDtos;
}
참여자들로부터 채팅방을 찾고 참여자들이 같으면 이전 메시지를 조회할 수 있게 한다.
보낸 사람에 대한 이메일까지 쓸 수 있다.
@Component
public class StompHandler implements ChannelInterceptor {
@Value("${jwt.secretKey}")
private String secretKey;
private final ChatService chatService;
public StompHandler(ChatService chatService) {
this.chatService = chatService;
}
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if(StompCommand.CONNECT == accessor.getCommand()){
System.out.println("connect요청시 토큰 유효성 검증");
String bearerToken = accessor.getFirstNativeHeader("Authorization");
String token = bearerToken.substring(7);
// 토큰 검증
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
System.out.println("토큰 검증 완료");
}
if(StompCommand.SUBSCRIBE == accessor.getCommand()){
System.out.println("subscribe 검증");
String bearerToken = accessor.getFirstNativeHeader("Authorization");
String token = bearerToken.substring(7);
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
String email = claims.getSubject();
// 요청 URL 을 꺼낼수 있다. 배열로 리턴되었을 떄 / 2번째를 알 수 있다.
String roomId = accessor.getDestination().split("/")[2];
// 참여자이면 true이고 아니면 false겠지?
if(!chatService.isRoomPaticipant(email, Long.parseLong(roomId))){
throw new AuthenticationServiceException("해당 room에 권한이 없습니다.");
}
}
return message;
}
}
STOMP 핸들러에 방에 접근 권한 여부를 설정 해주자. SUBSCRIBE이 되어 있다면 메시지를 볼 수 있게 설정을 해준다.
SUBSCRIBE 을 할때에 검증을 할 수 있게 해 주자.
여기 이것말고도 많다고 하셨다.
웹소켓 URL만 알고 구독하는 것을 방지할 수 있습니다.
채팅 메시지 읽음에 관한 처리
크게 두가지가 있다.
첫번째는 세션을 관리 ( 로직이 복잡하다.) 방에 일일히 누가 구독을 하였는지 조사 해 보는 것이다.
두번째는 우리가 화면을 끄거나 라우터로 다른 곳으로 이동을 하였을 경우 그 방에 있는 메시지를 전부 읽음 처리로 바꾸어 주는 것이다.
우리는 여기서 disconnect(화면 이동)으로 처리를 해 보겠다.
// 채팅메시지 읽음처리
// A라는 사람이 채팅방에 채팅을 다 읽었다.
@PostMapping("/room/{roomId}/read")
public ResponseEntity<?> messageRead(@PathVariable Long roomId){
chatService.messageRead(roomId);
return ResponseEntity.ok().build();
}
채팅방 떠나기 기능
public void leaveGroupChatRoom(Long roomId){
ChatRoom chatRoom = chatRoomRepository.findById(roomId).orElseThrow(()-> new EntityNotFoundException("room cannot be found"));
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName()).orElseThrow(()->new EntityNotFoundException("member cannot be found"));
if(chatRoom.getIsGroupChat().equals("N")){
throw new IllegalArgumentException("단체 채팅방이 아닙니다.");
}
ChatParticipant c = chatParticipantRepository.findByChatRoomAndMember(chatRoom, member).orElseThrow(()->new EntityNotFoundException("참여자를 찾을 수 없습니다."));
chatParticipantRepository.delete(c);
List<ChatParticipant> chatParticipants = chatParticipantRepository.findByChatRoom(chatRoom);
if(chatParticipants.isEmpty()){
chatRoomRepository.delete(chatRoom);
}
}
채팅방을 떠날때 여기서는 참여자에서 도 제외를 시켜주고 방에 참가자가 아예 없다면 그 방도 삭제를 해 준다.
이거를 하기 위해서는 엔티티의 관계가 중요하다.
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class ChatRoom extends BaseTimeEntity
{ @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<ChatMessage> chatMessages = new ArrayList<>();
}
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class ChatMessage extends BaseTimeEntity {
@OneToMany(mappedBy = "chatMessage", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<ReadStatus> readStatuses = new ArrayList<>();
}
이렇게 ChatRoom -> ChatMessaage -> ReadStatus 부모 객체가 삭제가 되면 자식 객체도 같이 삭제가 되게 처리를 해 주어야 한다 .
그 밖에도 서비스를 구상 할때 A가 채팅을 칠 때 B가 안 읽고 있으면 읽음 처리가 되는지
채팅방 목록을 나갔을 때 읽음 처리 여부 등등 이런 것을 화면에서 테스트 해 보아야 한다.
<template>
<v-container>
<v-row justify="center">
<v-col cols="12" md="8">
<v-card>
<v-card-title class="text-center text-h5">
채팅
</v-card-title>
<v-card-text>
<div class="chat-box">
<div
v-for="(msg, index) in messages"
:key="index"
:class="['chat-message', msg.senderEmail === senderEmail ? 'sent' : 'received']"
>
<strong>{{ msg.senderEmail }}:</strong> {{ msg.message }}
</div>
</div>
<v-text-field
v-model="newMessage"
label="메시지 입력"
@keyup.enter="sendMessage"
/>
<v-btn color="primary" block @click="sendMessage">전송</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
import SockJS from 'sockjs-client';
import Stomp from 'webstomp-client';
import axios from "axios";
export default {
data() {
return {
messages: [],
newMessage: '',
stompClient: null,
senderEmail: null,
roomId: null ,
};
},
async created() {
this.senderEmail = localStorage.getItem("email");
this.roomId=this.$route.params.roomId;
const response=await axios.get(`${process.env.VUE_APP_API_BASE_URL}/chat/history/${this.roomId}`);
this.messages=response.data;
this.connectWebsocket();
},
//사용자가 현재 라우트에서 다른 곳으로 이동할때 호출되는함수
beforeRouteLeave(to, from, next) {
this.disconnectWebSocket();
next(); // ✅ 필수
},
// 화면을 완전히 꺼버렸을 때
beforeUnmount() {
this.disconnectWebSocket();
},
methods: {
connectWebsocket() {
// 이미 연결되어 있으면 제외
if (this.stompClient && this.stompClient.connected) return;
const socketJs = new SockJS(`${process.env.VUE_APP_API_BASE_URL}/connect`);
this.stompClient = Stomp.over(socketJs);
this.token=localStorage.getItem("token");
this.stompClient.connect( {
Authorization: `Bearer ${this.token}`
},
() => {
console.log('STOMP 연결 성공');
// 방번호에 맞게 보냄
this.stompClient.subscribe(`/topic/${this.roomId}`, (message) => {
const parsed = JSON.parse(message.body);
this.messages.push(parsed);
this.scrollToBottom();
// 서버에서 subscribe로 보내겠다.
},{Authorization:`Bearer ${this.token}` });
},
(error) => {
console.error('STOMP 연결 실패:', error);
}
);
},
sendMessage() {
if (this.newMessage.trim() ==="") return;
// 메시지를 json으로 바꾸어줌
const message = {
senderEmail: this.senderEmail,
message: this.newMessage
};
// roomId로 정보를 보냄 자바스크립트객체에서 json으로 바꾸어줌 이걸 백에서 dto로 받음
this.stompClient.send(`/publish/${this.roomId}`, JSON.stringify(message));
this.newMessage = ""
},
scrollToBottom() {
this.$nextTick(() => {
const chatBox = this.$el.querySelector('.chat-box');
chatBox.scrollTop = chatBox.scrollHeight;
});
},
async disconnectWebSocket() {
// 지금까지 메시지는 모두 읽었다.
await axios.post(`${process.env.VUE_APP_API_BASE_URL}/chat/room/${this.roomId}/read`);
if (this.stompClient && this.stompClient.connected) {
this.stompClient.unsubscribe(`/topic/${this.roomId}`);
this.stompClient.disconnect();
console.log('STOMP 연결 해제');
}
}
}
};
</script>
<style>
.chat-box {
height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
margin-bottom: 10px;
}
.chat-message {
margin-bottom: 10px;
}
.sent {
text-align: right;
}
.received {
text-align: left;
}
</style>
StompChatPage에서 지금까지 메시지는 모두 읽었다 . 처리를 위해 post를 해준다.
실시간 숫자 변화는 (여기서는 한 번 화면을 렌더링을 해 주어야 하지만 ) SSE를 해 주어야 한다.
1대 1 채팅
<template>
<v-container>
<v-row>
<v-col>
<v-card>
<v-card-title class="text-center text-h5">
회원목록
</v-card-title>
<v-card-text>
<v-table>
<thead>
<tr>
<th>ID</th>
<th>이름</th>
<th>email</th>
<th>채팅</th>
</tr>
</thead>
<tbody>
<tr v-for="member in memberList" :key="member.id">
<td>{{member.id}}</td>
<td>{{member.name}}</td>
<td>{{member.email}}</td>
<td>
<v-btn color="primary" @click="startChat(member.id)">채팅하기</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
import axios from 'axios';
export default{
data(){
return {
memberList: []
}
},
async created(){
const response = await axios.get(`${process.env.VUE_APP_API_BASE_URL}/member/list`);
this.memberList = response.data;
},
methods:{
async startChat(otherMemberId){
// 기존의 채팅방이 있으면 return받고, 없으면 새롭게 생성된 roomId return.
const response = await axios.post(`${process.env.VUE_APP_API_BASE_URL}/chat/room/private/create?otherMemberId=${otherMemberId}`);
const roomId = response.data;
this.$router.push(`/chatpage/${roomId}`);
}
}
}
</script>
startChat(ID) : 여기에서 ID는 상대방 ID를 이용을 해서 만약 기존의 방이 있으면 return 받고 없으면 새롭게 생성된 roomId 를 반환 받는다.
public Long getOrCreatePrivateRoom(Long otherMemberId){
// 나의 채팅방을 찾고
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName()).orElseThrow(()->new EntityNotFoundException("member cannot be found"));
Member otherMember = memberRepository.findById(otherMemberId).orElseThrow(()->new EntityNotFoundException("member cannot be found"));
// 나와 상대방이 1:1채팅에 이미 참석하고 있다면 해당 roomId return
Optional<ChatRoom> chatRoom = chatParticipantRepository.findExistingPrivateRoom(member.getId(), otherMember.getId());
if(chatRoom.isPresent()){
return chatRoom.get().getId();
}
// 만약에 1:1채팅방이 없을경우 기존 채팅방 개설
ChatRoom newRoom = ChatRoom.builder()
.isGroupChat("N")
// 만든 사람의 ID 랑 상대방입장 ID를 넣어서 방이름을 만들겠다.
.name(member.getName() + "-" + otherMember.getName())
.build();
chatRoomRepository.save(newRoom);
// 두사람 모두 참여자로 새롭게 추가
addParticipantToRoom(newRoom, member);
addParticipantToRoom(newRoom, otherMember);
return newRoom.getId();
}
나와 상대방의 ID를 가지고 방을 찾는다. 여기에 중요한 로직
상대와 내가 방에 같이 있는 것을 어떻게 알 수 있을까?
이거는 일단 내가 들어간 방을 찾고 --1
상대방이 방에 있는 지 탐색을 하고 --2
그리고 그게 단체 채팅방이 아닌지 찾으면 된다. --3
@Repository
public interface ChatParticipantRepository extends JpaRepository<ChatParticipant, Long> {
List<ChatParticipant> findByChatRoom(ChatRoom chatRoom);
Optional<ChatParticipant> findByChatRoomAndMember(ChatRoom chatRoom, Member member);
List<ChatParticipant> findAllByMember(Member member);
// 단체 채팅방이 아닌경우도 추가를 한다.
@Query("SELECT cp1.chatRoom FROM ChatParticipant cp1 JOIN ChatParticipant cp2 ON cp1.chatRoom.id = cp2.chatRoom.id WHERE cp1.member.id = :myId AND cp2.member.id = :otherMemberId AND cp1.chatRoom.isGroupChat = 'N'")
Optional<ChatRoom> findExistingPrivateRoom(@Param("myId") Long myId, @Param("otherMemberId") Long otherMemberId);
}
그리고 방이 있다면 들어가고 없다면 이름까지 만들어서 방을 개설하면 된다.
Redis +Pub/Sub 기능 구현하기
redis 라이브러리 import
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
// 연결기본객체
@Bean
@Qualifier("chatPubSub")
public RedisConnectionFactory chatPubSubFactory(){
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
configuration.setHostName(host);
configuration.setPort(port);
// redis pub/sub에서는 특정 데이터베이스에 의존적이지 않음.
// configuration.setDatabase(0);
return new LettuceConnectionFactory(configuration);
}
// publish객체
@Bean
@Qualifier("chatPubSub")
// publish 객체가 여러개 있을 수 있음
// 일반적으로는 RedisTemplate<key데이터타입, value데이터타입>을 사용 위에 있는 것을 사용을 하겠다.
public StringRedisTemplate stringRedisTemplate(@Qualifier("chatPubSub") RedisConnectionFactory redisConnectionFactory){
return new StringRedisTemplate(redisConnectionFactory);
}
// subscribe객체
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
@Qualifier("chatPubSub") RedisConnectionFactory redisConnectionFactory,
MessageListenerAdapter messageListenerAdapter
){
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
// 리슨(subscribe)하고 나서 수신된 메시지를 처리해주는 매서드를 넣는다. redisPubSub 도 특정 주제에 관해 발행 subscribe 한다.
container.addMessageListener(messageListenerAdapter, new PatternTopic("chat"));
return container;
}
// redis에서 수신된 메시지를 처리하는 객체 생성
@Bean
public MessageListenerAdapter messageListenerAdapter(RedisPubSubService redisPubSubService){
// RedisPubSubService의 특정 메서드가 수신된 메시지를 처리할수 있도록 지정 onMessage 매서드가 나옴
return new MessageListenerAdapter(redisPubSubService, "onMessage");
}
application.yml
data:
redis:
host: localhost
port: 6379
이렇게 Qualifier 로 주입 될 Bean 을 지정해준다.
Redis 에 연결 객체를 만들어 줄때
host와 port를 만들고 DB까지 셋팅을 해 주지 않는다.
publish 객체를 만들때도 이렇게 연결객체 (chatPubSub) 를 주입시켜준다.
subscribe 객체가 메시지를 수신 받으면 밑에 메시지 리스너가 이거를 처리를 해준다.
/chat 이라는 채널을 통해서
메시지 리스너는 Pub/Sub에 메시즈를 위임을 한다.
여기서 onMessage 가 처리를 해준다.
@Service
public class RedisPubSubService implements MessageListener {
private final StringRedisTemplate stringRedisTemplate;
private final SimpMessageSendingOperations messageTemplate;
public RedisPubSubService(@Qualifier("chatPubSub") StringRedisTemplate stringRedisTemplate, SimpMessageSendingOperations messageTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
this.messageTemplate = messageTemplate;
}
// 메시지 발행 객체가 필요하다.
public void publish(String channel, String message){
stringRedisTemplate.convertAndSend(channel, message);
}
@Override
// Message 에 실질적인 메시지가 담겨있다.
// pattern에는 topic의 이름의 패턴이 담겨있고, 이 패턴을 기반으로 다이나믹한 코딩
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody());
ObjectMapper objectMapper = new ObjectMapper();
try {
// dto 로 바꾸어주는 형변환이 필요하다.
ChatMessageDto chatMessageDto = objectMapper.readValue(payload, ChatMessageDto.class);
//room 에 정보를 보내주는 역할이다.
messageTemplate.convertAndSend("/topic/"+chatMessageDto.getRoomId(), chatMessageDto);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
patter에는 topic 패턴을 더 설정을 해줄 수 도 있고 Redis에서 받은 메시지를 서버에게 뿌린다.
@Controller
public class StompController {
private final SimpMessageSendingOperations messageTemplate;
private final ChatService chatService;
private final RedisPubSubService pubSubService;
public StompController(SimpMessageSendingOperations messageTemplate, ChatService chatService, RedisPubSubService pubSubService) {
this.messageTemplate = messageTemplate;
this.chatService = chatService;
this.pubSubService = pubSubService;
}
//// 방법1.MessageMapping(수신)과 SenTo(topic에 메시지전달)한꺼번에 처리
// @MessageMapping("/{roomId}") //클라이언트에서 특정 publish/roomId형태로 메시지를 발행시 MessageMapping 수신
// @SendTo("/topic/{roomId}") //해당 roomId에 메시지를 발행하여 구독중인 클라이언트에게 메시지 전송
//// DestinationVariable : @MessageMapping 어노테이션으로 정의된 Websocket Controller 내에서만 사용
// public String sendMessage(@DestinationVariable Long roomId, String message){
// System.out.println(message);
// return message;
// }
// 방법2.MessageMapping어노테이션만 활용.
@MessageMapping("/{roomId}")
public void sendMessage(@DestinationVariable Long roomId, ChatMessageDto chatMessageReqDto) throws JsonProcessingException {
System.out.println(chatMessageReqDto.getMessage());
// 콘솔 출력 DB에 데이터가 저장 특정 룸에 채팅방에 채팅내용(DTO)를 저장
chatService.saveMessage(roomId, chatMessageReqDto);
chatMessageReqDto.setRoomId(roomId);
// messageTemplate.convertAndSend("/topic/"+roomId, chatMessageReqDto);
ObjectMapper objectMapper = new ObjectMapper();
String message = objectMapper.writeValueAsString(chatMessageReqDto);
// String 형태로 바꾸어준다.
pubSubService.publish("chat", message);
}
}
chat이라는 채널에 message를 주겠다.
publish 한 메시지를 onMessage 가 받는다.
@Override
// Message 에 실질적인 메시지가 담겨있다.
// pattern에는 topic의 이름의 패턴이 담겨있고, 이 패턴을 기반으로 다이나믹한 코딩
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody());
ObjectMapper objectMapper = new ObjectMapper();
try {
// dto 로 바꾸어주는 형변환이 필요하다.
ChatMessageDto chatMessageDto = objectMapper.readValue(payload, ChatMessageDto.class);
//room 에 정보를 보내주는 역할이다.
messageTemplate.convertAndSend("/topic/"+chatMessageDto.getRoomId(), chatMessageDto);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
그리고 각각의 서버룸에 보내준다. 밑에 있는 것을 이용해서
SimpMessageSendingOperations는 STOMP 메시지를 클라이언트로 전송하기 위한 인터페이스입니다. 일반적으로 내부 구현체는 SimpMessagingTemplate입니다.
Spring Boot WebSocket에서는 이 객체를 통해 다음과 같이 서버가 **특정 대상(구독자, 특정 유저 등)**에게 STOMP 메시지를 능동적으로(push) 보낼 수 있습니다.
local 에서는 front 는 300
'채팅' 카테고리의 다른 글
채팅방 ERD 설계 + 구조 (0) | 2025.06.17 |
---|---|
STOMP (0) | 2025.06.16 |
웹소켓 통신 (0) | 2025.06.16 |
WebSocket 통신 (1) (0) | 2025.06.16 |
AWS 기본 정리(1) (0) | 2025.03.21 |