웹소켓, AOP, 스케줄러

강성관·2025년 3월 24일

Framework

목록 보기
11/11

채팅방 필터링

  1. detail.html에서 채팅방 정보 보내기
let socket 
= new WebSocket("ws://localhost:8080/ws/chat?userNo="+senderNo+"&roomNo="+roomNo);
  1. ChatWebSocketHandler에 채팅방 정보 담기
package com.gn.mvc.websocket;

import java.util.HashMap;
import java.util.Map;

import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.gn.mvc.dto.ChatMsgDto;
import com.gn.mvc.entity.ChatMsg;
import com.gn.mvc.entity.ChatRoom;
import com.gn.mvc.entity.Member;
import com.gn.mvc.repository.ChatMsgRepository;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class ChatWebSocketHandler extends TextWebSocketHandler {
	
	// new 연산자 있기 때문에 Bean이 아니어서 requiredConstructor 쓸 필요 없다. 그냥 전역 변수일 뿐이다.
	private static final Map<Long, WebSocketSession> userSessions = new HashMap<Long, WebSocketSession>();
	// roomNo을 쌓는 애를 만들자
	private static final Map<Long, Long> userRooms = new HashMap<Long, Long>();
	// DB에 저장하기 위해 ChatMsgRepository + Lombok의 도움을 받자
	private final ChatMsgRepository chatMsgRepository;
	
	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		// 쿼리 스트링 떼와서 쓰는 법!
		// 1. ws://localhost:8080/ws/chat?senderNo=3
		// 2. senderNo=3
		// 3. 0번 인덱스 -> senderNo, 1번 인덱스 -> 3
		//String senderNo = session.getUri().getQuery().split("=")[1];
		
		// 이제는 두가지 정보 필요!(userNo, roomNo)
		String userNo = getQueryParam(session, "senderNo");
		String roomNo = getQueryParam(session, "roomNo");
		userSessions.put(Long.parseLong(userNo), session);
		// 위 아래의 차이를 잘 알아보자
		// 연결된 사람이 누구고 몇번 채팅방을 사용하느냐!
		userRooms.put(Long.parseLong(userNo), Long.parseLong(roomNo));
		
		
	}

	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
		String payload = message.getPayload();
		// 현제 message 안에 JSON 형태로 되어있다. 파싱 해보자!
		ObjectMapper objectMapper = new ObjectMapper();
		// message.getPayload() 제이슨 형태로 받아오는?? 그래서 아래처럼 파싱한다.
		ChatMsgDto dto = objectMapper.readValue(message.getPayload(), ChatMsgDto.class);
		
		
		// 이 사람이 연결되어 있는 상황이라면~ 뜻!
		if(userSessions.containsKey(dto.getSender_no())) {
			// 화면에 응답해주기 전에 DB에 채팅 메시지 등록
			// 1. dto -> entity
			Member member = Member.builder()
					.memberNo(dto.getSender_no())
					.build();
			ChatRoom chatRoom = ChatRoom.builder()
					.roomNo(dto.getRoom_no())
					.build();
			ChatMsg entity = ChatMsg.builder()
					.sendMember(member)
					.chatRoom(chatRoom)
					.msgContent(dto.getMsg_content())
					.build();
			// 2. entity save
			chatMsgRepository.save(entity);
		}
		
		
		
		
		// 내가 썼지만 상대방도, 나도 기록이 남아야하니까~
		WebSocketSession receiverSession = userSessions.get(dto.getReceiver_no());
		
		//  && receiverRoom == dto.getRoom_no() 받는 사람이 채팅방 연결이 어디에 되어있는가? 확인하는 코드 필요. 방의 정보도 받아와야한다.
		// messagePayload와 맞는지 판단한다. 
		// dto.getReceiver_no() 받는 사람의 정보(PK)
		Long receiverRoom = userRooms.get(dto.getReceiver_no());
		
		if(receiverSession != null && receiverSession.isOpen() && receiverRoom == dto.getRoom_no()) {
			// receiverSession.sendMessage(new TextMessage(dto.getMsg_content()));
			// 메시지 JSON 데이터 전달
			receiverSession.sendMessage(new TextMessage(message.getPayload()));
		}
		WebSocketSession senderSession = userSessions.get(dto.getSender_no());
		Long senderRoom = userRooms.get(dto.getSender_no());
		// 보낸 사람이 똑같은 채팅방 위치에 있는가?
		if(senderSession != null && senderSession.isOpen() && senderRoom == dto.getRoom_no()) {
			senderSession.sendMessage(new TextMessage(message.getPayload()));
		}
		
	}

	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
		// String userNo = session.getUri().getQuery().split("=")[1];
		String userNo = getQueryParam(session, "senderNo");
		userSessions.remove(Long.parseLong(userNo));
		userRooms.remove(Long.parseLong(userNo));
	}
	
	// 기능 : WebSocketSession의 URL 파싱
	// 파라미터 : url, key값
	// 반환값 : value값
	/*
	 * author : 김가남
	 * history : 2025-03-24
	 * param : url, key data
	 * return : value data
	 * role : WebsocketSession url parsing
	 * 이런식으로 쓰면 친절하기는 한데 너무 오래 걸릴 것 같다.
	 */
	// 메소드 호출, 사용방법!
	// getQueryParam(session, "senderNo"); -> 3
	// getQueryParam(session, "roomNo"); -> 1
	private String getQueryParam(WebSocketSession session, String key) {
		// 쿼리스트링 가져온 것
		// senderNo=3&roomNo=1
		// 만약 더 있다면? 숫자만 조금 바꿔서 쓰기 좋음
		String query = session.getUri().getQuery();
		if(query != null) {
			String[] arr = query.split("&");
			// 0번 인덱스 : senderNo=3
			// 1번 인덱스 : roomNo=1
			for(String target : arr) {
				String[] keyArr = target.split("=");
				if(keyArr.length == 2 && keyArr[0].equals(key)) {
					return keyArr[1];
				}
			}
		}
		return null;
	}
	
}

채팅 오른쪽, 왼쪽 정렬

내가 보낸 채팅은 오른쪽 정렬
상대방이 보낸 채팅은 왼쪽 정렬

  1. ChatWebSocketHandler 수정
  • 단순 문자를 반환 받는다면 구분 불가능!
receiverSession.sendMessage(new TextMessage(dto.getMsg_content()));
  • 여러 정보가 담긴 JSON형태로 받아야 구분 가능!
String payload = message.getPayload();
receiverSession.sendMessage(new TextMessage(message.getPayload()));
  1. 메세지가 보여지는 코드 조정
	<script>
    	let senderNo = document.getElementById("senderNo").value;
    	// roomNo를 전역변수로 뺄 수 있는 이유는~ 이 페이지에 진입하는 순간 고정되는 변수이기 때문이다.
    	// 채팅 메시지 같은 변화하는 + 클릭 이벤트에 바뀌는 값들은 전역변수로 사용하면 안된다.
   		let roomNo = document.getElementById('roomNo').value;
    	// 채팅방 정보X -> 필터링 문제 발생
    	// let socket = new WebSocket("ws://localhost:8080/ws/chat?senderNo="+senderNo);
    	// 연결된 사람 누구인지~ + 채팅방이 몇번 채팅방입니까~ 해야한다.
    	// 연결 시점에 채팅방 정보 전달ㄷ
    	let socket = new WebSocket("ws://localhost:8080/ws/chat?senderNo="+senderNo+"&roomNo="+roomNo);
    	
    	const sendMsg = function(){
    		// 채팅방, 받는 사람, 보내는 사람, 메시지
    		let receiverNo = document.getElementById('receiverNo').value;
    		let msgContent = document.getElementById('msgContent').value;
    		
    		if(msgContent != ''){
	    		socket.send(JSON.stringify({
	    			sender_no : senderNo,
	    			room_no : roomNo,
	    			receiver_no : receiverNo,
	    			msg_content : msgContent
	    		}));
    			
    		}
    		
    		
    		document.getElementById('msgContent').value = '';
    	}
    	
    	socket.onmessage = function(event){
    		// 이거는 그냥 문자로 받아버린 것.
    		// document.getElementById('chatBox').innerHTML += "<p>" + event.data + "</p>";
    		
    		// 이제는 JSON 형태로 받아서. 원하는 형태로 자료형을 바꿔보자.
    		let msgData = JSON.parse(event.data);
    		
    		// 1. 채팅 출력 div에 접근
    		let chatBox = document.getElementById("chatBox");
    		// 2. 채팅 하나하나 만들어주기
    		let msgDiv = document.createElement("div");
    		msgDiv.classList.add("msg-box");
    		// 메세지를 보낸 사람이랑, 현재 사람이랑 똑같다면~ = 내가 보낸 거라면!
    		if(msgData.sender_no == senderNo){
    			msgDiv.classList.add("msg-right");
    		} else{
    			msgDiv.classList.add("msg-left");
    		}
    		// 3. 채팅 출력 div에 채팅 하나씩 넣기
    		msgDiv.innerHTML = `<div>${msgData.msg_content}</div>`;
    		chatBox.appendChild(msgDiv);
    		
    		// 4. 스크롤 밑으로 내리기
    		// $('#chatBody').scrollTop($('#chatBody')[0].scrollHeight);
    		chatBox.scrollTop = chatBox.scrollHeight;
    	}
    </script>

DB사용

  1. ChatWebSocketHandler에 채팅 저장 로직 추가
	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
		String payload = message.getPayload();
		// 현제 message 안에 JSON 형태로 되어있다. 파싱 해보자!
		ObjectMapper objectMapper = new ObjectMapper();
		// message.getPayload() 제이슨 형태로 받아오는?? 그래서 아래처럼 파싱한다.
		ChatMsgDto dto = objectMapper.readValue(message.getPayload(), ChatMsgDto.class);
		
		
		// 이 사람이 연결되어 있는 상황이라면~ 뜻!
		if(userSessions.containsKey(dto.getSender_no())) {
			// 화면에 응답해주기 전에 DB에 채팅 메시지 등록
			// 1. dto -> entity
			Member member = Member.builder()
					.memberNo(dto.getSender_no())
					.build();
			ChatRoom chatRoom = ChatRoom.builder()
					.roomNo(dto.getRoom_no())
					.build();
			ChatMsg entity = ChatMsg.builder()
					.sendMember(member)
					.chatRoom(chatRoom)
					.msgContent(dto.getMsg_content())
					.build();
			// 2. entity save
			chatMsgRepository.save(entity);
		}
        
		
		
		
		// 내가 썼지만 상대방도, 나도 기록이 남아야하니까~
		WebSocketSession receiverSession = userSessions.get(dto.getReceiver_no());
		
		//  && receiverRoom == dto.getRoom_no() 받는 사람이 채팅방 연결이 어디에 되어있는가? 확인하는 코드 필요. 방의 정보도 받아와야한다.
		// messagePayload와 맞는지 판단한다. 
		// dto.getReceiver_no() 받는 사람의 정보(PK)
		Long receiverRoom = userRooms.get(dto.getReceiver_no());
		
		if(receiverSession != null && receiverSession.isOpen() && receiverRoom == dto.getRoom_no()) {
			// receiverSession.sendMessage(new TextMessage(dto.getMsg_content()));
			// 메시지 JSON 데이터 전달
			receiverSession.sendMessage(new TextMessage(message.getPayload()));
		}
		WebSocketSession senderSession = userSessions.get(dto.getSender_no());
		Long senderRoom = userRooms.get(dto.getSender_no());
		// 보낸 사람이 똑같은 채팅방 위치에 있는가?
		if(senderSession != null && senderSession.isOpen() && senderRoom == dto.getRoom_no()) {
			senderSession.sendMessage(new TextMessage(message.getPayload()));
		}
		
	}
  1. 채팅이 추가될때마다 채팅방 데이터베이스에 last_msg와 last_date가 변경되도록 설정

SET @OLDTMP_SQL_MODE=@@SQL_MODE, SQL_MODE='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION';
DELIMITER //
CREATE TRIGGER `chat_room_trigger` AFTER INSERT ON `chat_message` FOR EACH ROW BEGIN
 UPDATE `chat_room`
 SET last_msg = NEW.message_content,
 		last_date = NEW.send_date
	WHERE room_no = NEW.room_no;
END//
DELIMITER ;
SET SQL_MODE=@OLDTMP_SQL_MODE;
  1. /chat/detail.html 조회된 채팅 메시지 출력하는 코드 추가
  • classappend 와 삼항연산자를 사용하여 class를 append한다.
		<div id="chatBox">
				
			<div th:if="${!#lists.isEmpty(msgList)}"
			class="msg-box" th:each="chat : ${msgList}"
			th:classappend="${chat.sendMember.memberNo == #authentication.principal.member.memberNo} ? 'msg-right' : 'msg-left'"
			th:text="${chat.msgContent}">
			</div>
			
		</div>

AOP 기본

1) AOP(Aspect Orientied Programming)

  • 관점 지향 프로그래밍
  • 클래스들의 역할에 집중하고 반복되는 기능을 따로 관리

2) Aspect

  • 횡단 관심사(공통으로 실행해야 하는 메소드)를 구현해 놓은 클래스
  • pointcut
    - 실행 대상이 되는 기능을 패턴으로 지정
    - 타겟 지정
  • advisor
    - 언제 실행할지 설정
    - 전,후, 메소드 호출 전,후,예외발생 등 모든 시점

3) execution

  • AOP 적용 대상(pointcut)을 패턴으로 설정
execution([접근제한자] 리턴타입 [클래스명].메소드명(매개변수))
  • *은 모든 값을 뜻하고, ..은 0개 이상을 뜻함.

ex) private한 애들한테만 적용할게요~ 가능!
ex) 반환형이 String인 애들한테만 적용할게요~ 가능!
ex) 메소드명이 list로 끝나는 애들한테만 적용할게요~ 가능!

execution(public String com.gn.mvc..*.*List(*))
  • com.gn.mvc 패키지 및 하위 패키지에 속해 있음
    리턴타입이 String임
    메소드명이 List로 끝남
    파라미터가 1개인 메소드
package com.gn.mvc;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Component
@Aspect
public class LoggerAspect {

	private static final Logger LOGGER = LoggerFactory.getLogger(LoggerAspect.class);
	
	@Before("execution(* com.gn.mvc..*(..))")
	public void methodBefore(JoinPoint jp) {
		String className = jp.getTarget().getClass().getName();
		String methodName = jp.getSignature().getName(); // 시그니처라 aspect를 뜻하는 거다?
		LOGGER.warn(className + "의 " + methodName + "실행 직전");
	}
	
}
  • warn으로 설정한 것은 현재 LOGGER의 설정이 warn이기 때문에 확인하기 쉽게 하려고 설정.
  • 해당 로거 설정으로 메소드 실행을 확인할 수 있다.

Scheduler 기초

  • 일정한 시간 간격으로 반복 작업을 수행하는 기능
    ex) 매일 자정에 데이터베이스 백업, 정기적으로 이메일 발송
  • SpringBoot에서는 별도의 의존성 추가 없이 Schedular 사용 가능

사용방법

1) 스케줄링 관련 클래스를 만들고 @EnableScheduling 어노테이션 추가 + @Configuration 어노테이션 추가
2) 그 안에 메소드 추가하면서 메소드에는 @Scheduled(표현식) 어노테이션 추가
3) 다양한 표현식 사용 가능.
4) Spring이 호출해서 쓰는 것이지 우리가 직접 메소드 호출해서 쓰는 것이 아니다.

package com.gn.mvc;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;

@EnableScheduling
@Configuration
public class SchedulerConfig {

	// 1. fixedRate(이전 작업 시작 시점)
	@Scheduled(fixedRate=5000)
	public void runTask1() {
		System.out.println("5초마다 실행~!!");
		System.out.println(System.currentTimeMillis());
	}
	
	// 2. fixedDelay(이전 작업 종료 시점)
	@Scheduled(fixedDelay = 3000)
	public void runTask2() {
		System.out.println(System.currentTimeMillis());
	}
	
	// 3. initialDelay
	
	@Value("${schedular.enable}")
	private boolean isSchedulerEnabled;
	
	// 4. cron식
	@Scheduled(cron="0 50 15 * * *")
	public void runTask4() {
		if(!isSchedulerEnabled) {
			return;
		}
		System.out.println("매일 3시 50분에 실행");
	}
	
}

표현식

fixedRate
이전 작업의 시작 시점으로부터 고정된 간격으로 실행
실행 간격은 밀리초 단위

fixedDelay
이전 작업의 종료 시점부터 고정된 간격으로 실행
실행 간격은 밀리초 단위

initialDelay
애플리케이션이 시작된 후 일정 시간 후에 실행 시작
fixedRate 또는 fixedDelay와 함께 사용 가능
ex) @Scheduled(initialDelay = 3000, fixedRate = 5000)
애플리케이션 시작 -> 3초 후 initialDelay 동작 -> fixedRate initialDelay 끝나고 5초뒤 동작?

cron 표현식 사용
정교한 스케줄링이 필요한 경우 사용
총 6자리의 숫자가 띄어쓰기를 기준으로 나눠져있음
0 * * * * 매분 0초에 실행
0 0 12
* * 매일 정오 12시에 실행
0 0 0 25 12 ? 매년 크리스마스 자정에 실행

물음표는 필드 중 하나를 설정하지 않을 때 사용


스케줄러 비활성화

로그 또는 출력문이 너무 많이 나오는 경우
스케줄러를 일시적으로 중지하고 싶은 경우

작업

  1. application.properties에 스케줄러 활성화 여부 추가
scheduler.enable=false
  1. 설정값 읽어들여서 실행 여부 결정
	// 4. cron식
	@Scheduled(cron="0 50 15 * * *")
	public void runTask4() {
		if(!isSchedulerEnabled) {
			return;
		}
		System.out.println("매일 3시 50분에 실행");
	}

다시 활성화 하는 경우에는 application.properties에서 스케줄러 활성화 true로 변경

profile
함께 공부해요!

0개의 댓글