소켓 통신이란? https://helloworld-88.tistory.com/215
🏷️ HTTP 통신과 소켓 통신의 차이점
- HTTP 통신은 소켓 기반으로 작동하지만, 일반 소켓 통신과는 구별되는 특징을 가집니다.
- 데이터를 빈번하게 주고받아야 하는 상황에서는 일반적으로 소켓 통신을 선호하며, 그렇지 않은 경우 HTTP 통신이 더 적합합니다.
- HTTP는 사용자가 서버에 요청을 보내고 그에 대한 응답을 받는 단방향 통신 방식인 반면, 소켓 통신은 서버와 클라이언트 간 양방향 통신을 지원합니다.
- 연결을 지속적으로 유지하는 소켓 통신은 HTTP 통신보다 더 많은 자원을 사용합니다.
웹소켓이 존재하기 전에는 Polling이나 Long Polling,Streaming 등의 방식으로 해결했었다.
위와 같은 방식은 오른쪽 링크 참조하길 바란다. 참조 링크
이처럼 HTTP 통신의 기본 작동 방식인 연결을 맺고 바로 해제하는 절차는 데이터 전송 효율을 저하시키며, 이로 인해 실시간 통신이 필요한 웹 애플리케이션에서는 대체적으로 외부 플러그인에 의존하게 되었습니다.
이러한 문제점을 해결하고자, 2014년에 HTML5 표준에 웹소켓 기술이 통합되었습니다. 웹소켓 기술은 한 번의 접속 요청과 서버의 응답 이후에도 연결을 지속 유지하며, 서버가 클라이언트로부터 별도의 요청을 받지 않아도 데이터를 송신할 수 있게 하는 프로토콜입니다.
이 프로토콜은 ws://를 통한 요청으로 시작하며, 보안을 강화한 wss:// 형태도 지원합니다. 웹소켓은 HTTP 환경 내에서 양방향, 즉 전이중 통신을 가능하게 하는 프로토콜로, RFC 6455 문서에 그 세부 사항이 명시되어 있습니다.
초기 연결 설정은 HTTP 프로토콜을 통해 수행되나, HandShaking 절차가 완료된 이후에는 HTTP와는 다른, 웹소켓 자체의 통신 방식을 사용합니다.
웹소켓의 핵심 장점은 처음 연결을 시작할 때 보통의 웹 요청(HTTP)으로 통신을 시작한다는 것입니다. 이 방법은 우리가 이미 익숙한 웹 환경에 자연스럽게 녹아들며, 일반적인 웹 포트인 80과 443을 사용함으로써 별도의 방화벽 설정 없이도 양방향 통신을 가능하게 해줍니다. 더불어, 웹의 보안 규칙(CORS)과 인증 절차를 기존 방식대로 유지할 수 있어 보안성도 높습니다.
웹소켓은 웹에 동적인 기능을 추가하기에 탁월하지만, 모든 상황에 완벽한 해법은 아닙니다. 변경이 드물고 양이 적은 데이터의 경우, Ajax나 스트리밍, 롱 폴링 같은 기술이 더 적합할 수 있습니다. 그러나 실시간으로 데이터를 주고받아야 하고, 데이터의 변경이 잦으며, 빠른 반응속도가 필요한 경우 웹소켓이 더 나은 해결책입니다.
예를 들어, 뉴스나 이메일, 소셜 미디어 업데이트 같은 것은 몇 분마다 한 번씩 정보를 새로고침하는 것이 적당합니다. 하지만, 협업 툴, 온라인 게임, 금융 앱 등 실시간 작업이 중요한 서비스는 웹소켓의 실시간 통신 능력이 큰 장점이 됩니다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
소켓 통신을 통해 서버는 여러 클라이언트와 동시에 연결을 유지할 수 있는 1:N 관계를 형성합니다. 이는 하나의 서버가 여러 클라이언트의 접속을 받아들일 수 있음을 의미합니다. 이러한 구조에서 서버는 다양한 클라이언트로부터 전송된 메시지를 적절히 처리하기 위해 특별한 핸들러가 필요합니다.
텍스트 기반의 채팅 애플리케이션을 만들기 위해서는 TextWebSocketHandler 클래스를 활용할 것입니다. 이 클래스를 상속받아 구현된 핸들러는 클라이언트로부터 메시지를 받았을 때 이를 로그로 기록하고, 클라이언트에게 환영 메시지를 보내는 기능을 수행합니다.
아래 코드는 직접 구현해본 Handler이다.
@Slf4j
@RequiredArgsConstructor
@Component
public class WebsocketHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper;
private final ChattRoomService chattRoomService;
private final ChattService chattService;
private Map<String, HashSet<WebSocketSession>> roomSessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("웹소켓 연결이 설정되었습니다.");
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
ChattDTO chatt = objectMapper.readValue(payload, ChattDTO.class);
String roomNumStr = String.valueOf(chatt.getRoomNum());
switch (chatt.getType()) {
case ENTER:
enterRoom(session, chatt, roomNumStr);
break;
case TALK:
saveAndBroadcastMessage(session, chatt, roomNumStr);
break;
case QUIT:
quitRoom(session, chatt, roomNumStr);
break;
}
}
private void enterRoom(WebSocketSession session, ChattDTO chatt, String roomNumStr) throws IOException {
HashSet<WebSocketSession> sessions = roomSessions.computeIfAbsent(roomNumStr, k -> new HashSet<>());
sessions.add(session);
// 현재 채팅방에 입장한 사용자 수
int numberOfUsers = sessions.size();
// 사용자 입장 메시지 방송
chatt.setMsg(chatt.getSender() + "님이 입장했습니다.");
broadcast(chatt, roomNumStr);
}
private void saveAndBroadcastMessage(WebSocketSession session, ChattDTO chattDTO, String roomNumStr)
throws IOException {
chattDTO.setRoomNum(Integer.parseInt(roomNumStr));
chattDTO.setInTime(LocalDateTime.now());
chattDTO.setReadStatus(1);
String receiver = determineReceiver(chattDTO.getRoomNum(), chattDTO.getSender());
chattDTO.setRecipient(receiver);
Chatt savedChatt = chattService.save(new Chatt(chattDTO)); // DTO를 엔티티로 변환 후 저장
broadcast(chattDTO, roomNumStr);
}
private String determineReceiver(Integer roomNum, String sender) {
String receiver = chattRoomService.findOtherParticipant(roomNum, sender);
return receiver;
}
private void quitRoom(WebSocketSession session, ChattDTO chatt, String roomNumStr) throws IOException {
log.info("Handling quit for user: {} in room: {}", chatt.getSender(), roomNumStr);
HashSet<WebSocketSession> sessions = roomSessions.get(roomNumStr);
if (sessions != null) {
sessions.remove(session);
log.info("Updated presence status to off for user: {} in room: {}", chatt.getSender(), roomNumStr);
chatt.setMsg(chatt.getSender() + "님이 퇴장했습니다.");
broadcast(chatt, roomNumStr);
}
}
private void broadcast(ChattDTO chatt, String roomNumStr) throws IOException {
HashSet<WebSocketSession> sessions = roomSessions.get(roomNumStr);
if (sessions != null) {
TextMessage textMessage = new TextMessage(objectMapper.writeValueAsString(chatt));
for (WebSocketSession session : sessions) {
session.sendMessage(textMessage);
}
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("웹소켓 연결이 종료되었습니다: " + session.getId());
// 모든 채팅방에서 해당 세션 제거
roomSessions.forEach((roomNum, sessions) -> sessions.remove(session));
}
// 모든 연결된 세션에 Heartbeat 메시지를 보냄
@Scheduled(fixedRate = 10000) // 10초마다 실행
public void sendHeartbeat() {
roomSessions.values().forEach(sessions -> {
sessions.forEach(session -> {
if (session.isOpen()) {
try {
session.sendMessage(new TextMessage("{\"type\":\"HEARTBEAT\",\"message\":\"ping\"}"));
log.info("Heartbeat 메시지 'ping'을 전송했습니다.");
} catch (IOException e) {
log.error("Heartbeat 메시지 전송 중 오류 발생", e);
}
}
});
});
}
}
- 웹소켓 연결 설정 : afterConnectionEstablished 메서드
- 클라이언트와의 웹소켓 연결이 성공적으로 이루어지면 로그 출력
- 텍스트 메시지 처리 : handleTextMessage 메서드
- 클라이언트로부터 받은 텍스트 메시지를 처리합니다. 메시지는 JSON 형태로 예상되며, ChattDTO 객체로 변환됩니다.
- 메시지 타입에 따라 다른 동작을 수행합니다. (ENTER: 방 입장, TALK: 메시지 송수신, QUIT: 방 퇴장)
- 방 입장 시 enterRoom, 메시지 저장 및 방송 saveAndBroadcastMessage, 방 퇴장 처리 quitRoom, 메시지 방송 broadcast
- 연결 종료 처리 : afterConnectionClosed 메서드
- 웹소켓 연결이 종료되면, 해당 세션을 모든 채팅방에서 제거
- 하트비트 메시지 전송 sendHearbeat 메서드
- 정기적으로(위 코드에선 10초마다) 모든 연결된 세션에 하트비트 메시지를 보내 연결 상태를 확인
WebSocket 통신 기능을 활성화하기 위해 @EnableWebSocket 어노테이션을 사용합니다. 이는 클래스가 WebSocket 서버로 동작하도록 설정하는 데 필요합니다.
WebSocket에 접속할 수 있는 엔드포인트를 /ws/chat으로 설정합니다. 또한, 다른 도메인에서의 접속을 허용하기 위해 setAllowedOrigins("*")를 추가하여 CORS 정책을 설정합니다. 이로써 클라이언트는 ws://localhost:8080/ws/chat을 통해 서버와의 연결을 시도하고 메시지 통신을 시작할 수 있습니다.
@RequiredArgsConstructor
@Configuration
@EnableWebSocket //이게 websocket 서버로서 동작하겠다는 어노테이션
public class WebsocketConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
// handler 등록, js에서 new Websocket할 때 경로 지정
//다른 url에서도 접속할 수있게(CORS방지)
}
}
@Controller
@RequiredArgsConstructor
@RequestMapping("/ozMarket")
public class ChattController {
private final ChattRoomService chattRoomService;
private final ChattService chattService;
private final MarketProService marketProService;
private static final String PATH = "C:\\ozMarket\\";
// 채팅 리스트
@GetMapping("/chatts")
public String chatList(Model model, @AuthenticationPrincipal MemberSecurityDTO member, HttpServletRequest req) {
if (member == null) {
req.setAttribute("msg", "로그인 후 이용가능합니다.");
req.setAttribute("url", "/main");
return "message";
}
List<ChattRoom> roomList = chattRoomService.findBymyId(member.getMemberNickname());
String nickname = member.getMemberNickname();
roomList.forEach(room -> {
Optional<Chatt> lastMessage = Optional.ofNullable(chattService.findLastMessageByRoomNum(room.getRoomNum()));
if (lastMessage.isPresent()) {
room.setLastMessage(lastMessage.get().getMsg());
} else {
room.setLastMessage("주고 받은 메시지가 없습니다.");
}
});
for (ChattRoom r : roomList) {
if (r.getMyId().equals(nickname)) {
r.setPartner(r.getOtherId());
} else {
r.setPartner(r.getMyId());
}
}
model.addAttribute("roomList", roomList);
model.addAttribute("nickname", nickname);
return "client/ozMarket/chatRoom";
}
// 채팅방 생성
@PostMapping("/chatt")
public String createChatRoom(@AuthenticationPrincipal MemberSecurityDTO member,
@RequestParam("proNum") Integer proNum, @RequestParam("sellerNickname") String sellerNickname, HttpServletRequest req, Model model) {
if (member == null) {
req.setAttribute("msg", "로그인 후 이용가능합니다.");
req.setAttribute("url", "/main");
return "message";
}
ChattRoom room = chattRoomService.findOrCreateRoom(member.getMemberNickname(), sellerNickname, proNum);
model.addAttribute("room", room);
return "redirect:/ozMarket/chattRoom/" + room.getRoomNum();
}
// 채팅방 입장
@GetMapping("/chattRoom/{roomNum}")
public String chatRoom(HttpServletRequest req, @AuthenticationPrincipal MemberSecurityDTO member, Model model,
@PathVariable("roomNum") String roomNum) throws IOException {
ChattRoom room = chattRoomService.findRoomByNum(Integer.parseInt(roomNum));
List<ChattRoom> roomList = chattRoomService.findBymyId(member.getMemberNickname());
String nickname = member.getMemberNickname();
for (ChattRoom r : roomList) {
if (r.getMyId().equals(nickname)) {
r.setPartner(r.getOtherId());
} else {
r.setPartner(r.getMyId());
}
}
model.addAttribute("roomList", roomList);
model.addAttribute("memberNickname", member.getMemberNickname());
model.addAttribute("roomNum", roomNum);
model.addAttribute("room", room);
model.addAttribute("nickname", nickname);
Integer proNum = room.getProNum();
String root = PATH + "\\" + "img";
Optional<OzMarketProDTO> optionalDto = Optional.of(marketProService.getProduct(proNum));
if (optionalDto.isPresent()) {
OzMarketProDTO dto = optionalDto.get();
req.setAttribute("getProduct", dto);
List<String> encodedImagesPro = new ArrayList<>();
String[] imageProFiles = dto.getProImgPro().split(",");
for (String imageFileName : imageProFiles) {
File imageProFile = new File(root, imageFileName);
}
}
String partnerNickname;
if (room.getMyId().equals(nickname)) {
partnerNickname = room.getOtherId();
} else {
partnerNickname = room.getMyId();
}
roomList.forEach(room1 -> {
Optional<Chatt> lastMessage = Optional.ofNullable(chattService.findLastMessageByRoomNum(room1.getRoomNum()));
if (lastMessage.isPresent()) {
room1.setLastMessage(lastMessage.get().getMsg());
} else {
room1.setLastMessage("주고 받은 메시지가 없습니다.");
}
});
model.addAttribute("partnerNickname", partnerNickname);
model.addAttribute("roomList", roomList);
model.addAttribute("nickname", nickname);
return "client/ozMarket/chatt";
}
// 채팅방 메시지 로드 엔드포인트
@CrossOrigin
@GetMapping("/chattRoom/messages/{roomNum}")
public ResponseEntity<List<Chatt>> getMessagesByRoomNum(@PathVariable("roomNum") Integer roomNum) {
List<Chatt> messages = chattService.findMessagesByRoomNum(roomNum);
return ResponseEntity.ok(messages);
}
@PostMapping("/reserveProduct/{proNum}")
public String reserveProduct(HttpServletRequest req, @PathVariable("proNum") Integer proNum,
@AuthenticationPrincipal MemberSecurityDTO member) {
boolean res = marketProService.reserveProduct(proNum, member.getMemberNickname());
if (res) {
req.setAttribute("msg", "성공했습니다.");
} else {
req.setAttribute("msg", "실패했습니다. 다시 시도하세요.");
}
req.setAttribute("url", "/ozMarket/myInfo");
return "message";
}
@PostMapping("/confirmPurchase/{proNum}")
public String confirmPurchase(HttpServletRequest req, @PathVariable("proNum") Integer proNum,
@AuthenticationPrincipal MemberSecurityDTO member) {
boolean res = marketProService.confirmPurchase(proNum, member.getMemberNickname());
if (res) {
req.setAttribute("msg", "성공했습니다.");
} else {
req.setAttribute("msg", "실패했습니다. 다시 시도하세요.");
}
req.setAttribute("url", "/ozMarket/myInfo");
return "message";
}
@PostMapping("/cancelReservation/{proNum}")
public String cancelReservation(HttpServletRequest req, @PathVariable("proNum") Integer proNum) {
boolean res = marketProService.cancelReservation(proNum);
if (res) {
req.setAttribute("msg", "성공했습니다.");
} else {
req.setAttribute("msg", "실패했습니다. 다시 시도하세요.");
}
req.setAttribute("url", "/ozMarket/myInfo");
return "message";
}
@PostMapping("/deleteChatRoom/{roomNum}")
public String deleteChatRoom(@PathVariable("roomNum") int roomNum, Model model) {
chattService.deleteMessagesByRoomNum(roomNum);
// 채팅방 삭제 기능을 호출하고 결과를 받아옵니다.
chattRoomService.deleteChatRoom(roomNum);
// 채팅방 목록 페이지로 리다이렉트합니다.
return "redirect:/ozMarket/chatts";
}
}
위 코드에서 채팅방 목록 보기, 채팅방 생성, 입장 그리고 이전 채팅 메시지 보기, 상품 예약, 구매 확정, 예약 취소, 채팅방 삭제기능을 구현했습니다.
제일 중요한 실시간 채팅을 위해 아래 jsp 코드를 보자.
<script>
var wsUrl = "ws://" + location.host + "/ws/chat"; // 웹소켓 서버 URL
var socket; // 웹소켓 객체를 전역 변수로 선언
var sender = '<c:out value="${memberNickname}" />'; // 현재 사용자의 닉네임
var roomNum = '<c:out value="${room.roomNum}" />'; // 현재 방 번호
var nickname = "<c:out value='${nickname}'/>"; // JSP에서 JavaScript 변수로 값을 전달
function initWebSocket() {
socket = new WebSocket(wsUrl); // 방 번호를 URL에 포함하여 웹소켓 연결
socket.onopen = function(event) {
console.log("Connected to WebSocket server.");
sendEventMessage("ENTER");
loadChatHistory(roomNum);
};
socket.onmessage = function(event) {
var msgData = JSON.parse(event.data); // 서버에서 받은 메시지 데이터
console.log("Received message:", msgData);
if (msgData.type === "TALK") {
displayMessage(msgData); // 메시지 표시 함수 호출
}
};
socket.onclose = function(event) {
console.log("Disconnected from WebSocket server.");
};
socket.onerror = function(event) {
console.error("WebSocket error: ", event);
};
}
// 메시지를 화면에 표시하는 함수
function displayMessage(messageData) {
var msgArea = document.querySelector('.msgArea');
var msgDiv = document.createElement('div'); // Create a new div for the message
var msgBubble = document.createElement('div');
var msgContent = document.createElement('div');
var msgTime = document.createElement('div');
msgContent.textContent = messageData.msg;
msgContent.className = 'message-content';
msgTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
msgTime.className = 'message-time';
msgBubble.appendChild(msgContent);
msgBubble.appendChild(msgTime);
if (messageData.sender === sender) {
msgDiv.className = 'chat-message sent';
msgBubble.className = 'message-bubble sent';
} else {
msgDiv.className = 'chat-message received';
msgBubble.className = 'message-bubble received';
}
msgDiv.appendChild(msgBubble);
msgArea.appendChild(msgDiv);
// Scroll to the new message
msgArea.scrollTop = msgArea.scrollHeight;
}
// 시간 데이터를 '오전/오후 시:분' 형식으로 변환하는 함수
function formatTime(timestamp) {
var date = new Date(timestamp); // Timestamp를 Date 객체로 변환
return date.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
}
function sendMsg() {
var messageInput = document.getElementById('messageInput');
var message = messageInput.value;
if (message === "") {
alert("메시지를 입력하세요.");
return;
}
var msgData = {
type : "TALK",
sender : sender,
msg : message,
roomNum : roomNum
};
socket.send(JSON.stringify(msgData));
messageInput.value = ''; // 입력 필드 초기화
}
function sendEventMessage(eventType) {
var msgData = {
type : eventType,
sender : sender,
roomNum : roomNum
};
socket.send(JSON.stringify(msgData));
}
window.onload = function() {
initWebSocket();
document.getElementById('messageInput').addEventListener(
'keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); // 기본 이벤트 방지
sendMsg(); // 메시지 전송 함수 호출
}
});
};
var httpUrl = "http://" + location.host + "/ozMarket/chattRoom/messages/"+ roomNum;
// 채팅 기록을 불러오는 함수
function loadChatHistory(roomNumber) {
// 서버에 AJAX 요청을 보내 이전 메시지를 불러옵니다.
var xhr = new XMLHttpRequest();
xhr.open('GET', httpUrl, true);
xhr.onload = function() {
if (this.status == 200) {
var messages = JSON.parse(this.responseText);
insertDateDivider(messages);
// 메시지를 화면에 표시
messages.forEach(function(message) {
displayMessage(message);
});
// 스크롤을 맨 아래로 설정
var msgArea = document.querySelector('.msgArea');
msgArea.scrollTop = msgArea.scrollHeight;
} else {
console.error('메시지를 불러오는데 실패했습니다.');
}
};
xhr.send();
}
function insertDateDivider(messages) {
let lastDate = null;
const msgArea = document.querySelector('.msgArea'); // msgArea를 여기서 정의
messages.forEach((message) => {
const messageDate = new Date(message.inTime).toLocaleDateString('ko-KR');
if (messageDate !== lastDate) {
lastDate = messageDate;
const dateDivider = document.createElement('div');
dateDivider.className = 'date-divider';
dateDivider.textContent = messageDate; // 현지화된 날짜 표시
msgArea.appendChild(dateDivider);
}
displayMessage(message);
});
}
function quit() {
sendEventMessage("QUIT");
socket.close();
window.location.href = '/ozMarket/chatts'; // 채팅 목록 페이지로 리다이렉션
}
</script>