Day 88. Web Socket 2 : 채팅 내역 유지하기

ho_c·2022년 6월 26일
1

국비교육

목록 보기
69/71
post-thumbnail
post-custom-banner

오늘은 지난 시간에 만든 채팅 프로그램을 좀 더 발전시켜, 대화 목록을 DB에 저장하거나 채팅화면이 아닐지라도 채팅 목록을 수신받을 수 있도록 만들 예정이다.

그 전에! 지난 시간의 수업 내용을 전반적으로 살펴보자.

지난 시간 정리

HTTP, HTTPS

웹 애플리케이션을 개발하면 어쩔 수없이 ‘HTTP, HTTPS’라는 프로토콜, 곧 통신 규칙을 사용한다. 근데 이 규칙에는 크게 두가지 특성이 있다.

1) Stateless

일단 상태 정보를 유지하지 않는다. 그래서 로그인 정보를 기억하지 않기 때문에 Session, cookie를 이용하여, 상태정보를 저장한다.

2) Request, Response

통신 구조가 “요청이 있어야만 응답이 있다”라는 규칙을 따른다. 그래서 요청 없으면 서버는 능동적으로 응답으로 데이터를 전송할 수 없다.

이로 인해 채팅 애플리케이션은 Ajax를 통해서 구현하면 주기적으로 요청해 응답을 받지 않으면 구현이 어렵다. 하지만 안되는 걸 하는 만큼 서버 부하가 심하다. 이를 극복하는 방법이 Web Socket이다.

WS / WSS

우리의 서버인 Tomcat은 HTTP, WS 모두 지원한다. 그래서 프로토콜에 따라 HTTP는 DS로 WS-Endpoint로 연결이 된다,


웹 소켓 프로세스

1) HandShaking - WS과 톰캣 사이에서 일어나는 과정

let ws = new WebSocket(URL)

우리가 프론트에서 해당 코드를 통해 웹 소켓 객체를 만들어 연결을 시도하면 1차적으로 핸드쉐이킹 과정이 발생한다. 즉, TOMCAT과 WS 사이에 통신 규정을 정하는데 이를 ‘HandShake’(OSI 7 Layer)라고 한다. (*규정을 정함)

따라서 통신-연결이 아니라, 통신이 연결되면서 연결 신호, 데이터는 어떤 형태 등등 뭘 보낼지, 어떻게 보내고, 어떻게 응답할지 이런 과정을 내부적으로 신호를 주고받는다.

2) Modify HandShake

기본적으로 세팅된 HandShake(규칙)을 사용하면, HTTP로 오는 데이터 정보를 WS의 EndPoint로 끌어올 수 없다. 그래서 규칙을 바꿔서 HTTP Session을 넣어서 EndPoint로 보내줘야 한다.

EndPoint로 데이터를 보낼 땐, 톰캣에서 ServerEndpointConfig 객체에 넣어서 보낸다. 여기에는 설정정보 뿐만 아니라, 유저 속성을 넣을 수 있는 저장소가 존재한다. 그리고 저장소 안의 .getUserProperties(); 칸에 HTTP Session을 넣어서 보낸다.

3) EndPoint(@ServerEndPoint)

핸드쉐이킹 과정에서 EndPoint 어노테이션을 따라, 요청된 엔드포인트를 찾고 인스턴스를 생성한다. 해당 인스턴스 안에서 각 연결에 따른 작업이 실행되고, 개별 세션을 갖고 있다.

4) @OnOpen

핸드쉐이킹이 끝나고 WS 세션이 연결되었을 때, 실행되는 메서드다. 주로 채팅에서는 각 인스턴스의 session 정보를 Set에 저장한다.

clients.add(session0); // HashSet에 동일 인스턴스들의 세션 정보를 넣어둔다.

5) WS Session

private static Set<Session> clients = Collections.synchronizedSet(new HashSet<>());

모든 WS 연결자들의 세션을 공통으로 관리할 수 있도록, 쉽게 말하면 각 인스턴스가 메시지를 연결자 모두에게 보낼 수 있도록 ‘정적 변수’를 만들어 준다.

제대로 들어가면 Set 보다는 Map을 사용해 더 다양한 정보를 담아둔다.

6) @OnMessage

클라이언트에서 WS 객체를 통해 서버로 메시지를 보내면, @OnMessage 어노테이션으로 연결된 메서드에서 매개변수로 데이터를 받는다. 이렇게 받은 데이터를 연결된 모든 사람에게 보낸다.

주로 이 상황에서도 멀티쓰레드에 의한 동시성 문제로 인해 세션을 저장한 Set의 길이가 줄어들면서 예외가 발생한다. 그래서 동기화 블록으로 작업을 고정처리 해준다.

synchronized(clients) { // 동기화 블록으로 고정처리 해준다.
	for(Session client : clients) {
		try{
			clients.getBasicRemote().sendText(message);
		} catch(Exception e) {}
	}
}

7) @OnClose, @OnError

해당 어노테이션으로 세션이 닫히고, 에러가 생겼을 때 세션 저장소에서 각 세션을 지운다.

8) 연결이 끊김

결과적으로 우리가 방을 나가고, 다시 들어가면 기존 웹 소켓이 사라지고, 새로운 웹 소켓이 생성된다. 즉, 페이지 전환마다 웹 소켓은 새로 만들어진다. 그래서 처음 대화 내용은 다 사라진다.

물론 그 원인은 DB의 영역이 아닌 HTML로만 남겨놔서 그런 부분도 있다. 이제부터는 이전 대화 내용, 연결이 끊겨도 채팅을 유지하는 방법을 찾아보자.


채팅 내역 유지 구현

먼저 대화 내용은 그 어디에도 저장되지 않고 HTML 안에 흔적으로 남을 뿐이다. 이에 대한 정책적인 부분은 개발자를 따른다. 하지만 우리는 유지하고 싶으니까, 그 방법을 알아보자.

일단 크게 2가지 방향성이 있다.

  • 로그아웃 전까지만 유지
  • 반영구적으로 유지

1. 로그아웃 전까지만 유지

로그아웃 전까지만 유지한다는 것은 말 그대로 로그인 된 상태에선 페이지 전환이 일어나도, 채팅은 지속으로 진행된다는 것이다. 예를 들어 우리가 채팅 페이지에서 내 정보 확인 페이지를 다녀와도, 그 사이에 진행된 채팅 내역을 볼 수 있다는 것이다.

이 방법이 가장 간단한 방법인데, 주로 FIFO(First In First Out) 구조인 큐(Queue)를 사용해서 각 세션에서 보내는 메시지들을 저장하는 것이다. 그럼 FIFO(First In First Out)이 뭘까?

데이터를 다루는 자료구조 중 하나인데, 리스트의 형태를 갖고 있다. 다만 데이터가 들어오는 입구와 나가는 곳이 있는데, 일정 길이 안에서 데이터가 차면 먼저 들어온 데이터부터 나가는 형식이다.

이를 구현하기 위해서 구글에서 만든 EvictingQueue를 사용할 것이다. 그럼 차근 차근 사용법을 알아보자.


Guava EvictingQueue

대화 내역을 EvictingQueue을 저장한 다음, 이를 세션에 저장하여 RAM에다가 넣어두면, 큐가 꽉 찼다는 가정하에 오래 순서대로 자동 삭제가 된다.

1) Guava 라이브러리 저장

먼저 Maven에서 Guava 라이브러리를 끌어와 pom.xml에 넣어준다.

<dependency>
	<groupId>com.google.guava</groupId>
	<artifactId>guava</artifactId>
	<version>31.1-jre</version>
</dependency>

EvictingQueue는 Guava 라이브러리 내부에 있기에, 톰캣이 실행되면서 메모리 상에 인터페이스가 존재하니, 이를 가져다 쓰면 된다.


2) 인스턴스 생성

생성 방법은 간단하다. EvictingQueue의 클래스 메서드를 통해서 인자로 길이를 저장해주면 된다.

EvictingQueue<String> queue = EvctiongQueue.create(2); // 최대 2개 저장
queue.add(1);
queue.add(2);
queue.add(3);

queue를 출력하면 {2, 3}이 출력된다.


3) 클래스 변수로 만들기

이렇게 생성된 queue는 클래스 변수가 되어야 한다. 이건 앞서 WS 세션을 저장한 Set과 마찬가지인데, 나중에 저장된 대화 내역을 클라이언트로 뿌려줄 것이기 때문에 모두가 하나의 객체를 공유해야 한다.

private static EvictingQueue<ChatDTO> queue = EvictingQueue.create(20);

참고로 제너릭은 ChatDTO으로 정하여, 이 DTO 안에 각 세션에서 보낸 대화와 정보를 저장해둘 것이다.

[ ChatDTO ]

public class ChatDTO {
	private String sender;
	private String msg;
	private Timestamp msg_Date;
	
	public ChatDTO() {}

	public ChatDTO(String sender, String msg, Timestamp msg_Date) {
		super();
		this.sender = sender;
		this.msg = msg;
		this.msg_Date = msg_Date;
	}

	public String getSender() {
		return sender;
	}

	public void setSender(String sender) {
		this.sender = sender;
	}

	public String getMsg() {
		return msg;
	}

	public void setMsg(String msg) {
		this.msg = msg;
	}

	public Timestamp getMsg_Date() {
		return msg_Date;
	}

	public void setMsg_Date(Timestamp msg_Date) {
		this.msg_Date = msg_Date;
	}
}

4) @onMessage

이제 메시지를 보낼 때마다 큐 안에 데이터를 저장해둘 것이다.

@OnMessage
public void onMessage(String message) {
	synchronized (clients) {
			
		String longinID = (String)httpSession.getAttribute("loginID");
		queue.add(new ChatDTO(longinID, message, new Timestamp(System.currentTimeMillis()))); // 메시지를 보내면 그 정보를 DTO에 담는다.
	}	
}

5) @onOpen

@onMessage에서 저장한 대화 내역을 이제, WS로 접속하는 시점에 클라이언트로 보내서 이를 HTML로 출력하게 할 것이다. 단, 형식은 사용하기 쉽도록 JSON으로 보낸다.

@OnOpen
public void onOpen(Session session, EndpointConfig config) {
	this.httpSession = (HttpSession)config.getUserProperties().get("hSession");
	clients.add(session);
		
	try {
		session.getBasicRemote().sendText(gson.toJson(queue)); // 접속하면 queue에 들은 내용을 프론트로 보낸다.
	} catch (IOException e) {
		e.printStackTrace();
	}	
}

6) 클라이언트에서 출력하기

돌아온 JSON을 반복문을 돌려서 출력해준다.

ws.onmessage  = function(e){
			
	let data = JSON.parse(e.data)
			
	for (let i = 0; i<data.length; i++){
				
		if('${loginID}'== data[i].sender){
				
			let box = $("<div style='text-align:right;'>")
				
			let id = $("<div class='id'>");
			let msg = $("<span class='msg' style='background-color:yellow;'>");
			let time = $("<div class='time'>")
			let line = $("<br>");
				
			id.append(data[i].sender);
			msg.append(data[i].msg);
			time.append(data[i].time)
				
			box.append(id);
			box.append(msg);
			box.append(time);
				
			$("#msg_box").append(box);
			$("#msg_box").append(line);
				
		} else{
				
			let box = $("<div>")
				
			let id = $("<div class='id'>");
			let msg = $("<span class='msg'>");
			let time = $("<div class='time'>")
			let line = $("<br>");
				
			id.append(data[i].sender);
			msg.append(data[i].msg);
			time.append(data[i].time)
				
			box.append(id);
			box.append(msg);
			box.append(time);
				
			$("#msg_box").append(box);
			$("#msg_box").append(line);
				
		}				
	}			
}

2. DB에 저장하기

1) 개요

이 방식은 지우지 않는 한 대화 내용이 사라지지 않는다. 이를 메신저형라고 하는데, 이를 위해선 DB에다가 채팅 내용을 전부 기록한다.

DB 저장 방식은 다음 2가지로 간단히 볼 수 있다.

  • 입력이 올 때마다 DB에 넣는다.
  • 쌓아놓고 DB에 넣는다.

데이터를 넣는 과정는 @OnMessage 안에서 데이터를 사용자에게 뿌리기 전에 진행되며, 이 때문에 DB와 연결 과정을 Service 페이지로 사용하는게 좋다.

그래서 흔히들 @Autowired하거나 @Component로 Service 계층을 만들어서 해서 사용하려 한다. 물론 만들 수는 있고, 적용도 된다.

문제는 사용자는 매번 소켓을 통해 EndPoint를 새로 만들어서 쓰기 때문에 전혀 상관이 없다. (이게 기본 매커니즘이다)

더욱이 스프링 어노테이션으로 설정 인스턴스는 그 당시, 스프링 풀이 실행되면서 딱 한 번만 가능하다. 그래서 DI로 미리 꽂아놓는 것도 의미가 없다.

그래서 DI가 아닌 DL로 내가 원할 때 메모리에 생성된 인스턴스를 찾아서 꺼내와서 사용한다. 즉, 스프링 요소가 아닌 클래스가 스프링 풀 안의 것을 가져다 쓰고 싶으면 DL을 하면 된다는 것이다.

전체적으로 다시 보면 Spring Element로 웹 소켓 EndPoint 인스턴스를 만들고, 쓸 수도 있고, 근데 아무리 DI해봤자, 사용자가 웹 소켓을 사용할 때 기본 매커니즘은 스프링 풀 밖에다가 매번 새로 만들어, 아예 생성 시점도 맞지도 않다. 따라서 무용지물이 된다.

결과적으로 DI로 서비스 레이어를 DI 해봤자 의미가 없게 된다. 따라서 스프링 풀 밖에서 스프링 풀 안에 있는 요소를 DL로 꺼내 쓰면 된다.


2) 문제

일단, Spring Container라고 불리는 것은 코드 상에선 ApplicationContext 클래스를 의미한다. 그래서 스프링에서 DL을 할 때는, 해당 참조변수에 xml를 분석해서 컨테이너를 올려 놓고 getBean(“클래스명”) 으로 가져다 쓴다.

근데 DL을 할 건데, 중요한 건 EndPoint의 우리는 톰캣이 만들어 준 스프링 컨테이너의 주소를 모른다는 것이다.

3) implement ApplicationContextAware

앞서 문제는 getBean();이 들어있는 인스턴스의 주소를 모른다는 것이다. 그래서 해결 방법은 직접 ApplicationContextProvider 클래스를 만들어 사용한다.

먼저 ApplicationContextProvider라는 이름의 클래스를 하나 만들어 준 뒤, 인터페이스로 ApplicationContextAware를 상속해준다.

@Component
public class CTXProvider implements ApplicationContextAware{	
}

4) 오버라이딩

인터페이스를 상속받은 클래스는 그 내용을 오버라이딩해줘야 한다. setApplicationContext() 메서드를 오버라이딩 할 것이다.

@Component
public class CTXProvider implements ApplicationContextAware(자각하다){

	public static ApplicationContext ctx;

	@Override
	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {	
		this.ctx = applicationContext;
	}	
}

보다시피 setApplicationContext는 매개변수로 ApplicationContext, 곧 스프링 컨테이너의 주소를 받는다. 그래서 우리는 이를 사용할 수 있는데, 인스턴스 해당 인스턴스 자체는 서버 구동 시, 만들어지니 이를 클래스 변수로 연결해서 사용하면 된다. (어차피 한 개만 만들어지고...)

이 과정을 쉽게 보면, 웹 애플리케이션 같은 스프링 컨테이너의 주소를 알 수 없는 상황에선 DL을 할 수 없으니까, 대안으로 아무 클래스를 만들고 ApplicationContextAware를 상속 받으라는 것이다.

그렇게 ApplicationContextAware를 구현하는 클래스가 Bean으로 생성되었다면, 그 클래스는 setApplicationContext() 추상메서드를 채웠을테니, 스프링이 스프링 컨테이너가 가동되는 시점에, 이 주소를 메서드의 매개변수로 넣어준다는 것이다.

5) Service 레이어 생성

실험용으로 Service 레이어를 만들어준다. 이때 어노테이션 처리를 해줬기 때문에 해당 인스턴스도 메모리 상에 존재하게 된다.

@Service
public class ChatService {
	
	public void test() {
		System.out.println("서비스 동작 확인");
	}
}

6) .getBean(자료형.class)

이제 엔드포인트에서 .getBean()을 통해서 가져다 사용하면 되는데, 글이 길어지니 DAO 로직을 개인 재량에 맡기도록 하겠다.

[ EndPoint ]

@ServerEndpoint(value = "/chat", configurator = ChatConfigurator.class)
public class ChatEndpoint {

	private ChatService cServ = CTXProvider.ctx.getBean(ChatService.class)
	
	@OnMessage
	public void onMessage(String message) {
		cServ.insert(message);
	}
}
profile
기록을 쌓아갑니다.
post-custom-banner

0개의 댓글