이번 포스팅에서는 저번에 백엔드에서 구현해놓았던 STOMP 프로토콜을 Vue.js에서 사용하는 방법을 작성해보려고 합니다. 특히 저는 여러 페이지에 걸쳐 웹소켓 연결을 유지해놓고, 데이터를 자주 사용해야 했기 때문에 이것을 해결했던 방법을 간략하게 포스팅해보려고 합니다.
이번에 제가 채팅으로 사용했던 것은 아니지만, 채팅을 구현하시는 분들에게도 공통적으로 사용할 수 있는 내용입니다. 제가 프론트를 전문적으로 공부하지 않다보니 부정확한 내용이 있을 수 있습니다. 가볍게 참고만 해주시면 감사하겠습니다.
- 백엔드 구현 포스팅
https://100cblog.tistory.com/63?category=1073821
SocketStore
설명하기에 앞서, STOMP 프로토콜에 대해 궁금하신 분은 위 포스팅을 먼저 보고오시는 것을 추천드립니다.
저는 여러 페이지에서 연결 상태를 유지해야 했기 때문에, Vue의 Pinia 라이브러리를 사용해 상태관리를 해주었습니다. 그래서 SockerStore 파일에 연결, 입장, 연결 해제 등의 동작을 수행하도록 하였고, 특정 컴포넌트에서 watch() 메서드를 통해 동작 감지를 할 수 있도록 하였습니다.
연결
connect() {
if (this.stompClient) return; // 이미 연결되어 있으면 반환
this.stompClient = Stomp.client('wss://together-pay.store/ws');
const orderStore = useOrderStore(); // orderStore 인스턴스 가져오기
const memberStore = useMemberStore();
const headers = {
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
'MemberId': memberStore.memberId,
'accept-version': `1.3,1.2,1.1,1.0`,
'heart-beat':'10000,10000',
}
this.stompClient.connect(headers, () => {
console.log('소켓 연결 성공. member Id:', memberStore.memberId);
const subscriptionHeaders = {
'id': `sub-1`,
'MemberId': memberStore.memberId,
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
};
// orderIdx를 사용하여 구독 경로 설정
this.stompClient.subscribe(`/sub/order/room/${orderStore.orderIdx}`, (message) => {
console.log('받은 메시지:', message.body);
this.addMessage(message.body); // this.addMessage는 이제 정상 작동
}, subscriptionHeaders);
this.enter();
}, (error) => {
console.error('소켓 연결 실패:', error);
});
},
먼저, stompjs 라이브러리를 활용하여 서버에 연결을 진행하였습니다. 저는 https 연결을 적용하였기 때문에 웹소켓을 연결했을때 주소를 wss://도메인/ws로 설정하여 연결해주었습니다. http 연결은 앞에 ws를, https 연결은 앞에 wss를 연결해주는게 핵심입니다.
그다음 연결을 하기 위해 서버에서 요구하는 정보를 헤더를 실어서 보내야 합니다. 저는 백엔드에서 유저 식별과 세션관리를 위해 memberId, 보안 연결을 위해 JWT 토큰을 함께 보내도록 구현하였기 때문에, 프론트에서도 이와 같은 정보를 실어주었습니다.
const headers = {
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
'MemberId': memberStore.memberId,
'accept-version': `1.3,1.2,1.1,1.0`,
'heart-beat':'10000,10000',
}
그 외 기본적으로 들어가야 하는 accept-version, heart-beat등의 헤더도 같이 첨부하였습니다.
이렇게 메시지를 구성하여 헤더와 함께 연결 요청을 전송하였습니다.
this.stompClient.connect(headers, () => {
console.log('소켓 연결 성공. member Id:', memberStore.memberId);
const subscriptionHeaders = {
'id': `sub-1`,
'MemberId': memberStore.memberId,
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
};
연결이 성공했으면, 콘솔에 연결 성공을 출력하도록 하고, 구독 요청을 보낼 헤더를 만들어 구독 요청을 보냈습니다.
// orderIdx를 사용하여 구독 경로 설정
this.stompClient.subscribe(`/sub/order/room/${orderStore.orderIdx}`, (message) => {
console.log('받은 메시지:', message.body);
this.addMessage(message.body); // this.addMessage는 이제 정상 작동
}, subscriptionHeaders);
this.enter();
}, (error) => {
console.error('소켓 연결 실패:', error);
});
마찬가지로 구독 헤더에도 서버에서 요구하는 정보를 넣어 특정 채널의 구독을 요청합니다.
이제 연결, 채널 구독까지 완료되었습니다. stompClient에 connect, subscribe 함수를 이용하였습니다.
이제 메뉴 선택, 결제 관리등을 수행하기 위한 특정 방 입장을 위해 일반적으로 STOMP프로토콜에 따라 원하는 메시지를 보낼 수 있는 enter() 함수를 살펴보겠습니다. 채팅 구현을 하시는 분들에게는 채팅방에 입장 요청을 하는 로직이라고 생각하면 됩니다.
// stompClient가 존재하고 연결되어 있는지 확인
if (this.stompClient && this.stompClient.connected) {
try {
this.stompClient.send(
`/pub/order/room/enter`,
headers, // 헤더를 추가하여 요청에 포함
JSON.stringify({
"orderIdx": orderIdx, // 동적으로 orderIdx 설정
"memberId": memberStore.memberId,
})
);
console.log(`방 ${orderIdx}에 입장 요청을 보냈습니다. memberId: ` + memberStore.memberId);
} catch (error) {
console.error('메시지 전송 실패:', error);
alert('입장 요청을 보내는 중 오류가 발생했습니다.'); // 사용자에게 오류 알림
}
} else {
console.error('소켓이 연결되어 있지 않거나 stompClient가 존재하지 않습니다.');
alert('소켓이 연결되어 있지 않습니다.'); // 사용자에게 소켓 연결 상태 알림
}
},
addMessage(message) {
// 메시지를 배열에 추가하는 메서드
this.messages.push(message);
},
saveMenuInfo(menuInfo) {
// MENU_INFO 데이터를 저장하는 메서드
this.menuInfo = menuInfo;
}
여기서 봐야할 것은, 원하는 작업을 수행하기 위해 STOMP의 send()메서드를 활용하는 부분입니다.
if (this.stompClient && this.stompClient.connected) {
try {
this.stompClient.send(
`/pub/order/room/enter`,
headers, // 헤더를 추가하여 요청에 포함
JSON.stringify({
"orderIdx": orderIdx, // 동적으로 orderIdx 설정
"memberId": memberStore.memberId,
})
);
console.log(`방 ${orderIdx}에 입장 요청을 보냈습니다. memberId: ` + memberStore.memberId);
} catch (error) {
console.error('메시지 전송 실패:', error);
alert('입장 요청을 보내는 중 오류가 발생했습니다.'); // 사용자에게 오류 알림
}
} else {
console.error('소켓이 연결되어 있지 않거나 stompClient가 존재하지 않습니다.');
alert('소켓이 연결되어 있지 않습니다.'); // 사용자에게 소켓 연결 상태 알림
}
},
먼저 소켓 연결 상태를 확인하고, 연결되어 있다면 stompClient의 send() 메서드를 사용해 데이터를 전송합니다. stomp 프로토콜에서 send는 body에 데이터를 실을 수 있어 restApi와 같이 json 형식으로 통신할 수 있습니다.
header에 필요한 정보를 설정하고, body에도 json으로 변환된 데이터를 설정하여 통신할 수 있습니다.
이제 구독하고 연결하고, 보내는것 까지 완성하였습니다. 받는것은 어떻게 처리할 수 있을지 알아보겠습니다.
수신한 메시지 처리
저는 SocketStore에 메시지가 들어오면 저장해둘 메시지 배열을 선언하였습니다. 또한 subscribe 메서드를 자세히 보면, 그 구독 채널로 들어오는 메시지는 메시지 배열에 저장하도록 로직을 구성하였습니다.
// SocketStore에 선언한 내용
state: () => ({
stompClient: null,
messages: [],
menuInfo: null,
}),
this.stompClient.subscribe(`/sub/order/room/${orderStore.orderIdx}`, (message) => {
console.log('받은 메시지:', message.body);
this.addMessage(message.body); // 메시지 추가
}, subscriptionHeaders);
addMessage(message) {
// 메시지를 배열에 추가하는 메서드
this.messages.push(message);
},
saveMenuInfo(menuInfo) {
// MENU_INFO 데이터를 저장하는 메서드
this.menuInfo = menuInfo;
}
이렇게 메시지를 스토어에 있는 배열에 추가하게 되면, 이 변경사항에 대한 작업은 각 컴포넌트가 watch()를 통해 상태 감지를 하고 있다가 변경시에 작업을 수행하면 됩니다.
예를 들어, 아래는 실시간으로 메뉴를 선택하는 페이지에서 사용한 예시입니다.
// 소켓 메시지에 따라 선택 인원 수를 업데이트하는 함수
watch(
() => socketStore.messages,
(newMessages) => {
const lastMessage = newMessages[newMessages.length - 1];
if (!lastMessage) return; // 메시지가 없으면 처리하지 않음
try {
const parsedMessage = JSON.parse(lastMessage);
// 메뉴 선택 처리
if (parsedMessage.type === 'MENU_SELECT' || parsedMessage.type === 'MENU_CANCEL') {
const menu = orderInfoStore.orderMenuList.find(item => item.menuIdx === parsedMessage.menuIdx);
let totalPriceTmp = 0;
if (menu) {
const selectedMenus = parsedMessage.selectedMenuList || [];
const nowMenu = selectedMenus.find(item => item.menuIdx === menu.menuIdx);
// 선택 인원 수 업데이트
menu.selectedCount = (nowMenu.currentAmount || 0);
selectedMenus.forEach(item => {
const menu = orderInfoStore.orderMenuList.find(menu => menu.menuIdx === item.menuIdx);
if (menu) {
if (item.memberIdxList.includes(memberStore.idx)) {
menu.selectedByUser = true;
totalPriceTmp += menu.price; // 선택된 메뉴의 금액을 더함
}
}
});
selectedPaymentAmount.value = totalPriceTmp;
}
}
// ERROR 메시지 처리: 선택 상태 롤백
if (parsedMessage.type === 'ERROR') {
console.log("ERROR:", parsedMessage);
// ERROR가 발생했으므로, 롤백 처리 (선택 상태 복원)
orderInfoStore.orderMenuList.forEach(item => {
if (parsedMessage.memberIdx === memberStore.idx) {
item.selectedByUser = false;
}
});
const selectedMenus = parsedMessage.selectedMenuList || [];
selectedMenus.forEach(item => {
const menu = orderInfoStore.orderMenuList.find(menu => menu.menuIdx === item.menuIdx);
if (menu) {
if (item.memberIdxList.includes(memberStore.idx)) {
menu.selectedByUser = true;
}
}
});
if (parsedMessage.code === 'ORDER4014' && parsedMessage.memberIdx === memberStore.idx) {
setErrorMessage('더 이상 선택할 수 없는 메뉴입니다');
}
if (parsedMessage.code === 'ORDER4017' && parsedMessage.memberIdx === memberStore.idx) {
setErrorMessage('금액이 일치하지 않습니다.');
if (isReadySent.value) {
isReadySent.value = false;
readyMent.value = '선택완료';
}
}
}
// START_PAY 메시지가 도착하면 소켓 연결 해제 및 페이지 이동
if (parsedMessage.type === 'START_PAY') {
console.log('START_PAY 메시지 도착:', parsedMessage);
socketStore.disconnect(); // 소켓 연결 해제
router.push('/solopay'); // 결제 페이지로 이동
}
} catch (error) {
console.error('메시지 파싱 실패:', error);
}
},
{ deep: true }
);
새로 받은 메시지가 있다면, 그것을 JSON 형태로 변환하여 type을 보고, 작업을 수행하도록 처리하였습니다. (서버에서 json형식으로 데이터를 보냈기 때문에)
또한, 이 페이지에서 서버에 전송해야할 내용은 아래와 같이 처리하였습니다.
const splitByAmount = () => {
const destination = isReadySent.value
? '/pub/order/room/ready/cancel'
: '/pub/order/room/ready';
const message = {
memberId: memberStore.memberId,
orderIdx: orderInfoStore.orderIdx,
};
try {
socketStore.stompClient.send(
destination,
{
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
'MemberId': memberStore.memberId,
'content-type': 'application/json',
},
JSON.stringify(message)
);
isReadySent.value = !isReadySent.value; // 상태 토글
readyMent.value = isReadySent.value ? '선택취소' : '선택완료';
console.log(`isReadySent 상태:`, isReadySent.value); // 상태 확인
console.log(`선택 완료 ${isReadySent.value ? '요청' : '취소'} 전송`);
} catch (error) {
console.error('READY 메시지 전송 실패:', error);
setErrorMessage('READY 요청을 전송하는 중 오류가 발생했습니다.');
}
};
아까 소켓스토어에서 본 send()와 같이 메시지를 전송하였습니다.
앞으로 필요한 기능은 이것과 같이 메시지 형식만 잘 갖추어서 사용하면 됩니다.
앞서 말씀드린 것 처럼, 이 글은 팀원들과 어떻게 기술 구현했는지 공유하고 배운것을 간략하게 정리하려는 의도로 작성해서 내용이 많이 없습니다. 전체 코드는 아래 깃허브 링크를 참고해주시면 감사하겠습니다.
깃허브 주소
https://github.com/dh1010a/team2Rum_front