JAVA 웹개발 과정 국비 86일차 정리

86일차

※ 배우는 과정이므로 정확하지 않을 수 있습니다.
잘못된 점은 알려주시면 수정하도록 하겠습니다!


1. Web Socket

실시간 채팅 기능을 구현하기 위해 Web Socket을 배웠다.

1) Web Socket의 필요성

Web Application은 기본적으로 HTTP 프로토콜을 따른다. HTTP 프로토콜은 Request가 들어와야 Response가 나가는 것이 기본적인 흐름이다.
Request는 클라이언트에서 서버 방향으로만 흐르고, 반대는 동작할 수 없다.
즉, 서버는 클라이언트에게 먼저 말을 걸 수 없다.
따라서 HTTP 프로토콜의 특성의 제약 때문에 채팅 기능을 구현하기가 어려워진다. 서버가 먼저 클라이언트에게 말을 걸 수 없기 때문에 실시간 채팅을 하려면 클라이언트 쪽에서 주기적으로 서버에게 request를 보내야하는 것이다.
AJAX를 사용해 비동기 통신으로 채팅을 구현할 수는 있지만, 서버에 부하가 많이 걸려서 안정적이라고 보기는 어렵다.

WebSocket은 HTTP 프로토콜 베이스가 아니다.
엄밀히 따지면 소켓 베이스라고 볼 수 있다.
WebSocket을 사용하면 양방향 통신이 가능하기 때문에 위와 같은 단점이 없다.

2) Maven에 라이브러리 추가

Maven Repository에 가서 websocket을 검색하여 WebSocket Server API를 가져온다.

3) Web Socket의 통신 방법

웹소켓도 HTTP처럼 request를 보낼 수 있다.
Dispatcher가 HTTP 프로토콜을 받으면 기존에 알고 있는 순서로 동작할 것이고, 웹소켓 통신으로 request를 받으면 웹소켓을 처리해주는 완전 다른 곳(WebSocket Endpoint)으로 보낸다. 스프링 MVC 패턴과는 전혀 관련이 없다.
그래서 웹소켓을 사용할 때 스프링의 기능들을 사용할 수 없다. 예를 들면 Autowired도 사용할 수 없다. DL(Dependency Lookup)을 사용해야한다.

4) 클라이언트 코드

① 웹소켓 객체 생성

ws://localhost라고 써도 실습은 가능하다.
그러나 다른 클라이언트가 접속할 때, localhost는 자신의 컴퓨터로 가는 것이지 서버로 가는 것이 아니기 때문에 자신의 아이피 주소(IPv4)를 적어주면 다른 사람도 들어오는 실제 서비스가 가능하다.
뒤쪽에는 클래스의 별명(Endpoint 이름)을 적어준다. 아무렇게나 적어주자.
이렇게 적어주면 크롬 브라우저가 웹소켓 인스턴스를 생성하며 서버에서 통신을 걸게 된다.
서버에서 받아줄 준비가 되어있다면, 스트림(인풋, 아웃풋 모두)이 개방된다.

let ws = new WebSocket("ws://110.8.171.159/chat");

② 전송 버튼을 눌렀을 때

보낼 텍스트의 내용을 가져오고, 텍스트 박스의 내용은 지워준다(초기화)
ws.send로 텍스트를 전송하면 끝이다. 간단하다.

$("#send").on("click", function() {
		let text = $("#message").val();
		$("#message").val("");

		ws.send(text);
	})

③ 채팅 보여주기

웹소켓을 이용해서 서버에서 클라이언트로 온 메세지를 받았을 때 이벤트가 동작하는 콜백함수를 만들어준다.
JSON 형식으로 메세지를 받았으니 parse해주는 것을 잊지 말자.

ws.onmessage = function(e) {
	let line = $("<div>");
	
	let msgObj = JSON.parse(e.data);
	line.append(msgObj.id + " : " + msgObj.message);
	$(".chat-contents").append(line);
}

5) 서버 코드

① Endpoint 클래스 생성

Endpoint 클래스를 생성해준다. 어노테이션은 @ServerEndpoint 를 적어주고, url을 적어주는데 클라이언트 코드의 Endpoint 이름과 동일하게 적어준다.

@ServerEndpoint("/chat")
public class ChatEndpoint {

}

② 클라이언트의 통신 요청 받기

클라이언트가 url을 타고 서버에게 통신을 거는 것을 우선적으로 받아줘야 한다.
@OnOpen 어노테이션은 클라이언트 웹소켓 통신이 들어왔을 때 제일 먼저 onConnect 메소드를 실행시키라고 알려주는 역할이다.

메세지를 받는 것 까지는 했는데, 이제 A가 보낸 메세지를 B한테도 보내고 C한테도 보내고.. 이런 과정이 필요하다.
그렇다면 B, C 는 어떤 식으로 구분해야 할까?
바로 session을 사용한다. HttpSession이 아닌 WebSocket Session이다.
OnConnect 메소드가 실행 될 때, 즉 클라이언트가 서버에 통신 요청을 처음 보낼 때 인자값으로 session을 받으면 된다.
session은 클라이언트와 서버 사이의 접속 정보(연결 정보)를 가진 객체이다.
static List를 생성해서 클라이언트가 접속할 때마다 session을 저장한다.

@OnOpen
public void onConnect(Session session, EndpointConfig config) {
	clients.add(session);
	this.session = (HttpSession)config.getUserProperties().get("hSession");
}

③ 클라이언트의 메세지 받기

@OnMessage 어노테이션은 클라이언트가 메세지를 보냈을 때 onMessage 메소드를 실행시키라고 알려주는 역할을 한다.
메세지를 같은 웹소켓 앤드포인트에 접속한 클라이언트들에게 배포해야하므로 for문을 돌려서 각각 배포해준다.

synchronized는 동기화블럭으로 Thread 파트와 연관이 있다. 어려운 부분이므로 깊게 이해가 안된다면 나중에 다시 공부해보자.
사용하는 이유는 for문이 도는 중간에 특정 클라이언트가 접속을 해제하면 for문이 도는 clients 리스트에서 요소가 하나 빠지면서 동시성오류(ConcurrencyException)가 생긴다.
try-catch문은 for문을 돌 때 발생하는 예외를 캐치하는 것이므로 이 예외는 잡지 못한다. 따라서 for문이 돌고 난 후 다음 for문이 시작될 때 그 작은 틈에 클라이언트를 삭제하게끔 만들어주는 역할이다.
즉, 하나 이상의 쓰레드가 동시에 for문을 건드리지 못하게 막는 것이다.
물론 for문에다가 블럭을 감싸준다고 해결 되는 것은 아니고, list 자체도 동기화 된 리스트를 만들어 줘야 한다.
이 작업을 안해주면 특정 사용자는 메세지를 못받는 에러가 생길 가능성이 있다.
try-catch문을 한번 더 감싸서 ConcurrencyException을 잡아준다고 해도 for문이 동작하는데 문제가 생기기 때문에 좋은 해결방법이라고 할 수 없다.

private static List<Session> clients = Collections.synchronizedList(new ArrayList<>());

@OnMessage
public void onMessage(String message) {
	
	String userID = (String)this.session.getAttribute("loginID");
	JsonObject obj = new JsonObject();
	obj.addProperty("id", userID);
	obj.addProperty("message", message);
	
	synchronized(clients){
		for(Session client : clients) {
			try {
				client.getBasicRemote().sendText(obj.toString());
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
}

④ 연결이 끊어졌을 때

클라이언트가 접속을 끊었을 때 어떻게 할지를 구현한다.

@OnClose
public void onClose(Session session) {
	clients.remove(session);
	System.out.println("클라이언트의 접속이 끊어졌습니다.");
}

⑤ session 값 가져오기

닉네임이나 아이디를 표기하는 채팅을 만들려면 session에서 값을 꺼내와야 하는데, 위에 언급했듯이 웹소켓에서는 autowired를 사용할 수 없다.
웹소켓이라는 프로토콜이 서버와 클라이언트가 연결될 때 제일 먼저 하는 작업이 있다.
나는 이러이러한 정보를 가지고 있으니까 알아줘 라고 하면 상대가 그걸 받아서 어 이런이런 정보를 가지고 있구나 그럼 너도 이정보를 가지고 있어 우리 나중에 통신하자 데이터 잘 왔다갔다 하는지 테스트해볼까? 이렇게 스스로 웹소켓을 연결해주는 과정을 수행하는데 이 과정을 네트워크에서는 HandShake라고 표현한다.
간단히 정리하자면 클라이언트의 통신이 웹소켓 서버와 처음 만났을 때 앞으로 우리 통신합시다 하며 스트림을 개방하는 이 과정을 HandShake라고 부른다.

클라이언트와 앤트포인트를 핸드쉐이킹하는 작업이 일어나는데 우리가 이 과정에 개입해야 한다. 뭘해야하냐면 스프링 관련된 정보를 꺼내서 핸드쉐이킹 과정에 끼워 넣어야하는 것이다.

새로운 클래스를 생성하여 Configurator를 extends 하고, 기본 핸드쉐이크 과정을 오버라이드 하고 핸드쉐이크 과정에 추가 작업(세션 집어넣기)을 넣어야한다.
ServerEndpointConfig 객체에 넣고 싶은 정보를 담아주면 된다. 이 객체는 앤드포인트에게 전달되는 정보 중 하나이기 때문이다.

package kh.spring.configurator;

import javax.servlet.http.HttpSession;
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
import javax.websocket.server.ServerEndpointConfig.Configurator;

public class WSConfig extends Configurator {
	@Override
	public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
		
		HttpSession session = (HttpSession)request.getHttpSession();
		sec.getUserProperties().put("hSession", session);
		
		super.modifyHandshake(sec, request, response);
	}
}

이렇게 만든 config를 Endpoint에 넣어주면 된다.
그리고 클라이언트가 접속할 때 세션을 이용하여 정보를 받을 수 있게 onConnect 메소드에 인자값으로 config를 추가해준다.

@ServerEndpoint(value="/chat", configurator = WSConfig.class)

@OnOpen
public void onConnect(Session session, EndpointConfig config) {
	clients.add(session);
	this.session = (HttpSession)config.getUserProperties().get("hSession");
}

6) 예제 소스 코드

예제 코드

댓글남기기