WebSocket으로 인터랙티브 웹 어플리케이션 만들기
ai agent 통화 서비스의 백엔드 로직을 고민하던 중에 websocket에 대해 이해해보고 싶어서 스프링 공식 가이드를 공부했다.
깃허브 코드 : https://github.com/kchabin/spring-gs
WebSocket?
When the connection is established and alive the communication takes place using the same connection channel until it is terminated.
HTTP와 같은 프로토콜, 하나의 TCP 연결에서 양방향 통신을 제공
- HTTP는 단방향, stateless → 매번 새 연결 설정, 응답을 받은 후 종료
- WebSocket은 양방향, stateful
- 클라이언트나 서버에 의해 종료되기 전까지는 연결이 유지됨.
- HTTP와 스킴이 다르다 . http:// vs ws://
- TCP Handshake를 통해 HTTP Upgrade Header를 사용해 WebSocket 프로토콜로 변경된다.
- 101 정보성 상태코드가 프로토콜 스위칭을 나타낸다.
- 예시 : 비트코인 트레이딩 시스템. 실시간으로 가격 변동 데이터를 지속적으로 백엔드에서 클라이언트로 웹 소켓 채널을 통해 푸시한다.
- 채팅 앱 : 구독자(subscribers)간에 메시지가 교환, 게시, 브로드캐스팅 되도록 오직 한번만 커넥션을 설정한다. 메시지를 보내고 받는 것, 일대일 메시지 전송을 위해 동일한 웹소켓 연결을 재사용한다.
Resource Repersentation Class 생성
프로젝트와 빌드 시스템 셋업이 끝나면, 이제 STOMP 메시지 서비스를 만들 수 있다.
- 본문에 JSON 객체를 포함하는 STOMP 메시지를 수락하는 서비스.
{
"name": "Fred"
}
메시지가 name을 나를 수 있도록 name 속성, getName() 메서드와 함께 pojo를 생성한다.
package gs.websocket.kch;
public class HelloMessage {
private String name;
public HelloMessage(){}
public HelloMessage(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
수신한 메시지에서 name을 추출해서 greeting을 생성한다.
생성된 인사말 메시지는 별도의 큐에 게시되고, 클라이언트는 이 큐를 구독하고 있어 전달된 인사말 메시지를 받을 수 있다.
{
"content": "Hello, Fred!"
}
greeting도 JSON 객체.
사용 가능한 시나리오
- 챗봇 서비스 : 사용자가 메시지를 보낼 때 챗봇이 해당 메시지를 처리하고 응답을 큐로 전달한다.
- 이벤트 처리 시스템
Message-handling Controller
hello 메시지를 받으면 greeting 메시지를 전송하는 컨트롤러를 만든다.
@Controller
public class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
Thread.sleep(1000);
return new Greeting("Hello " + HtmlUtils.htmlEscape(message.getName()) + "!");
}
}
- @MessageMapping : @RequestMapping 처럼 “/hello” 경로로 메시지가 보내지면 greeting() 메서드가 호출되도록 경로와 메서드를 매핑한다.
- 메시지 페이로드는 HelloMessage 객체에 바인딩되고, greeting() 메서드에 파라미터로 주어진다.
- 비동기 처리 → Thread.sleep(1000)으로 1초 만큼 sleep해서 메시지를 서버가 비동기적으로 처리하도록 함.
- 1초 딜레이 후 → greeting() 메소드는 Greeting 객체를 만들어 반환한다.
- @SentTo로 지정된 topic /topic/greetings 를 구독하고 있는 클라이언트에게 리턴값을 브로드캐스팅한다.
- 서버는 메시지를 하나의 클라이언트가 아닌, 여러 클라이언트에게 전송할 수 있다.
- HtmlUtils.htmlEscape() 를 사용하면 HTML 특수문자를 변환한다. → Sanitization
- 예: <script> → <script>
- 데이터가 브라우저 DOM에서 렌더링될 경우, 정제가 필수임.
STOMP messaging 설정
스프링에서 WebSocket과 STOMP 메시징이 가능하도록 설정하는 WebSocketConfig.java 클래스를 만든다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config){
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry){
registry.addEndpoint("/gs-guide-websocket"); //websocket 연결을 시작하는 경로
}
}
- WebSocket 클래스는 WebSocketMessageBrokerConfigurer 인터페이스를 구현한다.
configureMessageBroker
- enableSimpleBroker(”/topic”)
- destinationPrefixes 지정.
- “topic”으로 시작하는 클라이언트에 greeting message 전달
- setApplicationDestinationPrefixes(”/app”)
- 클라이언트가 /app으로 시작하는 경로로 메시지를 보내면 @MessageMapping이 바인딩된 메서드로 매핑
registerStompEndpoints
- StompEndpointRegistry : STOMP 프로토콜 기반 메시징을 사용하기 위해 WebSocket 엔드포인트를 등록하고 설정하는데 사용하는 클래스.
- 클라이언트가 서버와 WebSocket 연결을 초기화할 때 사용할 엔드포인트를 정의한다.
@Override
public void registerStompEndpoints(StompEndpointRegistry registry){
registry.addEndpoint("/gs-guide-websocket"); //websocket 연결을 시작하는 경로
}
StompJS
index.html에서 StompJS 라는 자바스크립트 라이브러리를 임포트한다. 웹소켓을 통한 STOMP로 서버와 통신하는데 사용된다.
- app.js 에 클라이언트 로직을 작성한다.
stompClient.onConnect
클라이언트는 서버가 greeting messages를 게시할 /topic/greetings 를 subscribe 한다.
해당 경로에 greeting이 도착하면 DOM에 paragraph elements를 추가해서 greeting message를 보여준다.
stompClient.onConnect = (frame) => {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/greetings', (greeting) => {
showGreeting(JSON.parse(greeting.body).content);
});
};
sendName()
user에 의해 보내진 name을 검색하고, STOMP 클라이언트를 사용해서 /app/hello 로 전송한다.
빌드하기
- ./gradlew build
- ls build/libs에서 jar를 찾는다
- java -jar build/libs/kch-0.0.1-SNAPSHOT.jar
plain jar는 종속 라이브러리를 포함하지 않은 기본 JAR 파일이다.
Connect를 누르고 이름을 send하면 greetings를 받을 수 있다.
Dockerfile 생성
도커 이미지 만들어서 실행하는 실습도 해보고 싶어서..
FROM openjdk:17-jdk-slim
LABEL authors="kchabin"
ADD build/libs/kch-0.0.1-SNAPSHOT.jar kch.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "kch.jar"]
기본 이미지 openjdk:17-jdk-slim은 Java 실행 환경을 포함한다.
ENTRYPOINT는 컨테이너가 시작될 때 실행할 기본 명령을 설정한다.
- java -jar kch.jar 명령을 실행해서 애플리케이션이 작동하도록 한다.
- docker build : docker 이미지 빌드
- --no-cache : 캐시 없이 빌드/
- -t springwebsocket/test:1.0 : 이미지 이름 태그, 버전 지정
docker hub에 푸시한다. 만들어둔 리포지토리와 태그 이름이 다르면 denied: requested access to the resource is denied 에러가 뜬다.
minikube service
로컬에서 minkube 클러스터에 배포된 서비스에 접근할 수 있도록 하는 명령어. 실행하면 서비스의 url을 자동으로 열어주고, 로컬에서 해당 서비스에 접근할 수 있다.
근데 저렇게 하고 페이지는 떴는데 커넥션이 안 만들어졌다..
지피티는 cors 오류일수도있다고 해서 관련 코드를 WebSocketConfig 클래스에 추가하고 디플로이먼트, 서비스 yaml을 수정했다.
이때 yaml 수정하고 어떻게 새로 반영해서 배포해야 할지 몰라서 열심히 공부했다.
kubectl rollout restart 명령어는 디플로이먼트와 같은 리소스에 대해서만 사용할 수 있고, service에는 적용되지 않는다고 한다. 단지 트래픽을 특정 pod로 라우팅하는 일종의 로드밸런서 비스무리한거라 '재시작'할 필요가 없다고...
근데 디플로이먼트도 kubectl describe deployment 했을 때 container 이름, 이미지 버전 전부 변경안된 상태였다. 그냥 delete하고 새로 apply하는 게 속 시원하다.. 왜 스프링 웹소켓 공부하다 말고 쿠버네티스 공부가 된 것 같지..?
일단 웹소켓 배포는 다시 해봐야할 것 같다.. docker image도 잘 만들어지고 리소스도 잘 띄워지는데 왜 커넥션이 안되는걸까...
일단 이걸 응용하면 채팅앱도 만들 수 있을 것이다.
- HelloMessage를 그냥 일반적인 메시지로 하고
- GreetingController를 잘 변형해서 1:1 채팅에 사용되도록 하면 되지 않을까?