websocket stomp 테스터 만들기 with sock.js
내 환경에 맞으면서 프로젝트 인증방식에 맞는 websocket 테스터가 없었다. 그래서 만들었다.
1. 웹소켓 테스트를 어떻게 할까?
일반적으로 웹소켓 Stomp 를 활용해 코드를 작성 후, 아래 세군데 정도에서 테스트 할 수 있습니다.
하지만 apic 의 경우 아래의 단점이 있었습니다.
- 크롬이 막혀있다.
- mac 에서는 실행이 안된다.
그래서 이전에는 WebSocket Debug Tool 을 사용했습니다. 헤더에 토큰을 넣을 수도 있고, 여러모로 사용이 편했지만 이번에 버전 3 로 넘어가면서 조금 더 나은 테스트 툴이 없을까 하는 생각을 하게 되었습니다. 🤔
1.1. 인증 방식의 변화
Stomp 를 사용하여 실시간 서비스 개발하기 를 보면 알다시피 여타 블로그에 있는 방식과 약간 다릅니다. 대부분 연결할 때, 그리고 발행할 때 전부 헤더에 토큰을 넣고 인증하는 방식을 사용했습니다. 처음에 구현할 때에는 이를 차용하여 같은 로직으로 구현했지만, 토큰의 유출 빈도가 높은 것 같아 웹소켓 연결 처음에만 인증 절차를 거치는 것으로 변경했습니다. 지속적으로 토큰 인증을 하고 싶은 경우, 웹소켓용 토큰을 만들어야 하지 않나 하는 생각이 들긴 합니다. 🤔
어 쨌 든 ! 바뀐 인증 방식과 원하는 기능이 있는 웹소켓 테스터를 찾았으나 찾지 못해서 결국 직접 만들었습니다.
2. WebSocket Tester GitHub
만든 코드를 올린 repository 입니다. 급하게 만드느라 app.vue 에 다 박아 둔게 맘에 걸립니다만 나중에 시간나면 리팩토링을 하는걸로..
https://github.com/sieunnnn/websocketTester
👉🏻 자유롭게 변형 / 공유해도 괜찮습니다. 각자의 프로젝트에 맞게 사용해주세요. 🤗
위의 README.md 에서 아래와 같은 내용을 확인할 수 있습니다.
- 사용 방법을 담은 영상
- 관련된 글의 링크 리스트
위의 테스터를 사용하면 아래와 같은 장점이 있습니다.
- 하나의 페이지에서 구독 / 발행 전부 가능하다❗
- 따라서 창을 여러 개 띄울 필요가 없습니다.
- 발행 주소를 마구 바꿀 수 있다❗
- 구독 주소로 오는 메세지가 예쁘게 출력 된다❗
2. 어떻게 구현했을까? 🤔
Vue3, TypeScript 와 Sock.js 를 사용하여 구현하였습니다.
2.1. Sock.js 연결하기
2.1.1. package.json
아래와 같이 stomp 와 sock.js 연결을 위해 필요한 dependencies 를 넣어주세요.

1
2
3
4
5
"dependencies": {
"@stomp/stompjs": "^7.0.0",
"sockjs-client": "^1.6.1",
...
}
2.1.2. vite.config.ts
그냥 실행 시, global 오류가 발생하므로, 아래와 같이 넣어주세요.

1
2
3
4
5
6
7
8
9
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
define: {
global: 'globalThis',
},
});
2.2. Client 연결
2.2.1. App.vue
이곳에서는 입력받은 연결주소를 사용하여 웹소켓 연결을 수행합니다.

1
2
3
4
5
6
7
8
9
10
11
12
<div class="size">
<n-h3 prefix="bar" style="margin: 40px 0 5px 0">
<n-text type="primary">연결 하기</n-text>
</n-h3>
<div class="connect-container">
<n-input-group-label style="margin-right: 5px">🔑 연결 주소</n-input-group-label>
<n-input v-model:value="connectionUrl" size="small" type="text" placeholder="웹소켓 연결을 위한 엔드포인트를 넣어주세요." :disabled="connected"/>
<n-button :disabled="connected" n-button type="primary" @click="connect" style="margin-left: 10px">
연결 하기
</n-button>
</div>
</div>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import { defineComponent, ref, computed } from 'vue';
import SockJS from 'sockjs-client';
import { Client, Message } from '@stomp/stompjs';
import VueJsonPretty from 'vue-json-pretty';
import 'vue-json-pretty/lib/styles.css';
export default defineComponent({
components: {
VueJsonPretty,
},
name: 'App',
setup() {
const connected = ref(false);
const client = ref<Client | null>(null);
const subscriptionUrl = ref('');
const publishDestination = ref('');
const messages = ref<string[]>([]);
const messageContent = ref('');
const connectionUrl = ref('');
const selectedFiles = ref<File[]>([]);
const connect = async () => {
if (connected.value) {
alert('이미 연결된 상태입니다.');
return;
}
if (!connectionUrl.value) {
alert('웹소켓 연결을 위한 엔드포인트가 필요해요.');
return;
}
const socketUrl = `${connectionUrl.value}`;
try {
const response = await fetch(socketUrl);
if (!response.ok) {
const error = await response.json();
console.log(error.errorCode);
return;
}
} catch (error) {
console.error('HTTP 요청에 실패했어요.:', error);
return;
}
client.value = new Client({
webSocketFactory: () => {
const sock = new SockJS(socketUrl);
sock.onclose = (event) => {
console.error('웹소켓 연결이 끊어졌어요.:', event);
alert('웹소켓 연결이 끊어졌어요: ' + event.reason);
};
return sock;
},
debug: (str) => {
console.log(str);
},
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
client.value.onConnect = () => {
console.log('웹소켓 연결 완료!');
connected.value = true;
};
client.value.onDisconnect = () => {
connected.value = false;
console.log('웹소켓 연결 해제');
};
client.value.onStompError = (frame) => {
console.error('Broker 가 에러를 반환 했어요.: ' + frame.headers['message']);
console.error('상세 에러문구: ' + frame.body);
};
client.value.activate();
};
...
}
2.3. Subscript 하기
이곳에서는 입력받은 구독주소를 사용하여 웹소켓 구독을 수행합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<div class="size">
<n-h3 prefix="bar" style="margin: 20px 0 5px 0">
<n-text type="primary">구독 하기</n-text>
</n-h3>
<div>구독 주소를 입력해 주세요.</div>
<div class="connect-container">
<n-input-group-label style="margin-right: 5px">🔑 구독 주소</n-input-group-label>
<n-input v-model:value="subscriptionUrl" size="small" type="text" placeholder="구독 주소를 넣어주세요." />
<n-button type="primary" @click="subscribe" style="margin-left: 10px">
구독 하기
</n-button>
</div>
...

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const subscribe = () => {
if (!client.value) {
alert('먼저 연결을 해주세요.');
return;
}
if (!subscriptionUrl.value) {
alert('구독 주소를 입력해주세요.');
return;
}
client.value.subscribe(subscriptionUrl.value, (message: Message) => {
messages.value.push(message.body);
});
};
2.4. Publish 하기
2.4.1. 이미지 제외 발행
- 먼저 발생주소를 받습니다.
- 그리고 알맞은 DTO를 JSON 형태로 받습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div>
<n-h3 prefix="bar" style="margin: 20px 0 5px 0">
<n-text type="primary">발행 하기</n-text>
</n-h3>
<div class="connect-container">
<n-input-group-label style="margin-right: 5px">📨 발행 주소</n-input-group-label>
<n-input v-model:value="publishDestination" size="small" type="text" placeholder="발행 주소를 넣어주세요."/>
</div>
...
<div class="connect-container" style="margin-top: 10px">
<n-input-group-label style="margin-right: 5px">📃 DTO(JSON)</n-input-group-label>
<n-input v-model:value="messageContent" size="large" type="textarea" placeholder="JSON 형태의 DTO 를 넣어 주세요." style="height: 250px"/>
</div>
<div class="size" style="display: flex; flex-direction: row; justify-content: flex-end; width: 100%">
<n-button type="primary" @click="publish" style="width: 150px; margin-top: 10px">
발행 하기
</n-button>
</div>
</div>
...

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const publish = async () => {
if (!client.value || !publishDestination.value || !messageContent.value) {
alert('발행 주소와 메시지 내용을 입력해주세요.');
return;
}
...
const messageObject = JSON.parse(messageContent.value);
messageObject.images = encodedFiles.length > 0 ? encodedFiles : null;
client.value.publish({
destination: publishDestination.value,
body: JSON.stringify(messageObject),
headers: { 'Content-Type': 'application/json' },
});
};
2.4.2. 이미지를 전송하고 싶다면? 🌄

1
2
3
4
<div class="connect-container" style="margin-top: 10px">
<n-input-group-label style="margin-right: 5px">📷 이미지 업로드</n-input-group-label>
<input type="file" @change="handleFileChange" multiple />
</div>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 파일의 변화를 감지하는 메서드 추가
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input && input.files) {
selectedFiles.value = Array.from(input.files);
console.log('선택된 파일:', selectedFiles.value);
} else {
console.error('파일을 찾을 수 없습ㄴ디ㅏ.');
}
};
// 위에 있는 구독 메서드
const publish = async () => {
if (!client.value || !publishDestination.value || !messageContent.value) {
alert('발행 주소와 메시지 내용을 입력해주세요.');
return;
}
// 파일 관련 내용을 추가해줍니다.
let encodedFiles: { name: string, content: string }[] = [];
if (selectedFiles.value.length > 0) {
encodedFiles = await Promise.all(
selectedFiles.value.map((file) => {
return new Promise<{ name: string; content: string }>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve({ name: file.name, content: reader.result as string });
};
reader.onerror = (error) => {
console.error('FileReader 오류:', error);
reject(error);
};
reader.readAsDataURL(file);
});
})
);
}
2.4.3. 서버에 발행 메세지 보내기
서버에 값을 보낼때는 JSON.stringify() 를 사용하여 JavaScript 객체를 JSON 형식의 문자열로 변환해야 합니다.

1
2
3
4
5
6
client.value.publish({
destination: publishDestination.value,
body: JSON.stringify(messageObject),
headers: { 'Content-Type': 'application/json' },
});
};
2.5. Print 하기
이곳에서는 요청값을 토대로 서버에서 받은 값을 출력합니다.

1
2
3
4
5
6
7
8
<div>
<div style="font-size: 18px; font-weight: bold; margin: 30px 0 10px 0">📩 구독 주소로 아래와 같이 메세지가 도착 해요.</div>
<div class="json-container">
<div v-for="(message, index) in formattedMessages" :key="index" style="margin: 30px">
<vue-json-pretty theme="light" :data="message" />
</div>
</div>
</div>
서버에서 받은 메세지를 JavaScript 에서 사용하려면 JSON.parse() 를 사용하여 JSON 형식의 문자열을 parsing 하여 JavaScript 객체로 변환해야 합니다.

1
2
3
4
5
6
7
8
9
const formattedMessages = computed(() => {
return messages.value.map((message) => {
try {
return JSON.parse(message);
} catch (e) {
return { error: 'JSON 형태가 아니에요. 다시 한번 확인 해주세요.' };
}
});
});
⚠️ JSON.stringfy() 와 JSON.parse() 에 대해 더 알고 싶다면 이곳 을 참고해주세요!
2.5.1. 만약 Print 를 이쁘게 하고 싶다면? 🤔
veu json pretty 를 사용해보세요!
1. package.json

1
2
3
4
"dependencies": {
...
"vue-json-pretty": "^2.4.0"
},
2. App.vue

1
2
3
4
5
6
7
8
<div>
<div style="font-size: 18px; font-weight: bold; margin: 30px 0 10px 0">📩 구독 주소로 아래와 같이 메세지가 도착 해요.</div>
<div class="json-container">
<div v-for="(message, index) in formattedMessages" :key="index" style="margin: 30px">
<vue-json-pretty theme="light" :data="message" />
</div>
</div>
</div>

1
2
3
import VueJsonPretty from 'vue-json-pretty';
import 'vue-json-pretty/lib/styles.css';
...
3. 사용법
사용법을 안내하는 영상입니다. 나중에 영상을 다시 업데이트 할 예정입니다!