웹소켓이 HTTP 기반이 아닌 TCP 기반이라고 하는 이유는 웹소켓이 HTTP와는 다른 프로토콜을 기반으로 동작하기 때문
HTTP
는 클라이언트가 서버에 요청을 보내고, 서버가 그에 대한 응답을 보내는 단방향 통신 프로토콜
이다. 클라이언트가 서버에게 요청을 보내면 서버는 그에 대한 응답을 보내고, 이후에 클라이언트가 다시 요청을 보낼 때까지 서버는 대기 상태
에 있는다. 클라이언트와 서버 간의 통신이 비동기적이지 않고, 실시간으로 데이터를 주고받기 어렵게 만든다
반면 웹소켓
은 양방향 통신
을 지원하는 프로토콜로, 클라이언트와 서버 간에 실시간으로 데이터를 주고받을 수
있다 웹소켓이 TCP 기반으로 동작하기 때문
TCP는 신뢰성 있는 양방향 통신을 제공하는 프로토콜로, 데이터를 주고받는 과정에서 데이터의 손실이나 손상을 최소화한다
반대로 HTTP는 단순한 요청과 응답을 주고받는 프로토콜로, 실시간 통신에 적합하지 않다.
웹소켓과 함께 사용되는 메시지 전송 프로토콜
간단한 텍스트 기반 프로토콜로 클라이언트와 서버간에 메세지를 주고받을 수 있다.
연결 설정: 클라이언트는 웹소켓을 통해 서버에 연결하고, STOMP 프로토콜을 사용하여 통신할 준비를 한다. 이때 웹소켓 연결 URL을 사용하여 서버에 연결하고, STOMP 프로토콜을 사용할 것을 url로 알린다.
세션 시작: 클라이언트는 서버에게 STOMP 세션을 시작하도록 요청
구독:
- 클라이언트가 특정 topic 구독합니다.
클라이언트가 topic 구독하면, 해당 topic 전송되는 모든 메시지를 수신할 수 있게된다.
ex) 클라이언트가 "/topic/messages"라는 주제를 구독하면, 해당 주제로 전송되는 모든 메시지를 수신할 수 있다
메시지 전송: 클라이언트는 서버에게 메시지를 전송할 수 있다.
이때 특정 topic으로 메시지를 보내거나, 특정 사용자에게 메시지를 보낼 수 있다.
메시지 수신: 클라이언트가 구독한 주제로부터 메시지를 수신
세션 종료:
처음에 웹소켓 기능을 구현할 때 위에 개념들이 정리가 되지 않아서 고민이 많았었다. 처음엔 이정도 개념을 알고 기능을 구현하면서 어떻게 통신이 되는지 이해할 수 있으니 처음에 개념적으로 이해가 가지 않는다고 머리가 아프다면, 일단 기능구현하면서 공부해보는 것도 방법일 것 같다
처음에 아무것도 모르고 기능 구현할때와 기능 구현을 끝내고 다시 개념정리를 하는건 확실히 다른 것 같다!
그럼 어떻게 프로젝트에 실시간 채팅을 구현했는지 기록해야겠다
프로젝트에 필요한 채팅은 1:1 채팅이였다
유저와 유저와의 1:1 채팅이 아닌, 관리자와 유저(사장님 (owner))와의 1:1 채팅이였다
관리자와 사장님의 1:1 채팅이므로 채팅룸엔 1:N 비식별 매핑이 필요하다 판단했고, 채팅룸과 채팅메세지는 1:N 식별관계라고 판단해서 위에 erd처럼 기획을 하고 채팅구현을 시작했다
@Getter
@NoArgsConstructor
@Entity
@Table(name = "chatting_room")
public class ChattingRoom extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long roomId;
// owner shopId에서 shopName 가져오기
private String roomName;
@OneToMany(mappedBy = "chattingRoom", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
private List<Message> messageList;
@ManyToOne
@JoinColumn(name = "admin_id")
private Admin admin;
@ManyToOne
@JoinColumn(name = "owner_id")
private Owner owner;
@Builder
public ChattingRoom(String roomName, Admin admin, Owner owner) {
this.roomName = roomName;
this.admin = admin;
this.owner = owner;
}
}
@Getter
@NoArgsConstructor
@Entity
@Table(name = "chat_message")
public class Message extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "message_id")
private Long messageId;
private String contents;
private String writer; // owner nickname, admin nickname (admin)
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "room_id")
private ChattingRoom chattingRoom;
@Builder
public Message(String contents,String writer,ChattingRoom chattingRoom) {
this.contents = contents;
this.writer = writer;
this.chattingRoom = chattingRoom;
}
}
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker // WebSocket 메시지 브로커를 활성화하는 어노테이션
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/stomp/chat")
/*.setAllowedOrigins("http://localhost:8080")*/ // 배포시 도메인 적용
.setAllowedOriginPatterns("*")
.withSockJS();
}
/*어플리케이션 내부에서 사용할 path를 지정할 수 있음*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Client 에서 SEND 요청을 처리
//Spring docs 에서는 /topic, /queue로 나오나 편의상 /pub, /sub로 변경
registry.setApplicationDestinationPrefixes("/pub");
//해당 경로로 SimpleBroker를 등록.
// SimpleBroker는 해당하는 경로를 SUBSCRIBE하는 Client에게 메세지를 전달하는 간단한 작업을 수행
registry.enableSimpleBroker("/sub");
//enableStompBrokerRelay
//SimpleBroker의 기능과 외부 Message Broker( RabbitMQ, ActiveMQ 등 )에 메세지를 전달하는 기능을 가짐
}
지금은 개발단계이기 때문에 .setAllowedOriginPatterns("*") 모드 허용하고 있지만, 이 부분엔 로컬환경, 도메인 환경으로 수정해야한다
@Component
@Slf4j
public class StompEventListener {
@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = headerAccessor.getSessionId();
log.info("[Connected] websocket session id : {}", sessionId);
}
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = headerAccessor.getSessionId();
log.info("[Disconnected] websocket session id : {}", sessionId);
}
}
프로젝트에서 프론트없이 백만으로 프로젝트를 구성하다보니, 타임리프를 사용 중이다 그래서 mvc 패턴으로 모델(model)을 사용하여 객체데이터를 넘겨주고, 컨트롤러에서 생성한 데이터를 뷰(View)에 전달하고 있다.
// 사장님 페이지
// 채팅방 개설
@PreAuthorize("hasRole('ROLE_OWNER')")
@GetMapping("/owner/roomForm")
public String roomForm(Authentication authentication,@AuthenticationPrincipal MemberDetails memberDetails,Model model) {
SessionDto sessionDto = memberService.getSessionDto(authentication, memberDetails);
model.addAttribute("sessionDto", sessionDto);
try {
chattingRoomService.checkingChatRoom(sessionDto.getId());
return "owner/owner_chat_room_form";
} catch (BadRequestException b) {
Member member = memberDetails.getMember();
ChattingRoomDetailDto chattingRoom = chattingRoomService.getMyChatRoom(member);
return "redirect:/chatroom/" + chattingRoom.getRoomId();
}
}
@PreAuthorize("hasRole('ROLE_OWNER')")
@PostMapping("/chatroom")
public String createChatRoom(RedirectAttributes redirectAttributes, @AuthenticationPrincipal MemberDetails memberDetails,Authentication authentication) {
SessionDto sessionDto = memberService.getSessionDto(authentication, memberDetails);
ChattingRoom chattingRoom = chattingRoomService.createChatRoom(sessionDto.getId());
redirectAttributes.addFlashAttribute("roomId", chattingRoom.getRoomId());
return "redirect:/chatroom/" + chattingRoom.getRoomId(); // 채팅방 입장 페이지로 리다이렉트
}
public void checkingChatRoom(Long memberId) {
Member member = memberRepository.findByMemberIdAndIsDeleted(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
Owner owner = ownerRepository.findByOwnerId(member.getOwner()
.getOwnerId())
.orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
ChattingRoom chattingRoom = chattingRoomRepository.findByOwnerId(owner.getOwnerId());
if(chattingRoom != null ){
throw new BadRequestException(ErrorCode.CHAT_ROOM_ALREADY_EXIST);
}
}
public ChattingRoom createChatRoom(Long memberId) {
Member member = memberRepository.findByMemberIdAndIsDeleted(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
Shop shop = shopRepository.findAllShopListByOwnerId(member.getOwner().getOwnerId());
String shopName = shop.getName();
//owner 정보 가져오기
Owner owner = ownerRepository.findByOwnerId(member.getOwner().getOwnerId())
.orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
// Admin 정보 가져오기
Long adminId = 1L;
Admin admin = adminRepository.findById(adminId)
.orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
ChattingRoom chattingRoom = ChattingRoom.builder()
.roomName(shopName)
.admin(admin)
.owner(owner)
.build();
return chattingRoomRepository.save(chattingRoom);
}
-> 사장님 페이지에서 채팅방을 열어주었다
사장님 권한으로 변경 (일반유저 -> 사장님 권한 변경 설정된다 ) 할때마다 채팅방을 열어주게 되면 채팅이 필요없는 상태에서 채팅방이 생성되면 이건 불필요한 데이터를 생성하는 것 같아 사장님이 채팅이 필요할때 채팅방을 생성하게 계획했다
Admin을 지금은 이렇게 주입을 시키고 있는데, 그 이유는 채팅 관리하는 관리자는 1명으로 지정을 했기때문이다
서비스가 커지면 이 부분은 수정이 당연히 필요할것이다
지금은 프로젝트 상이라 주입 방식을 사용했다는 점, 이것보다 더 좋은 방법이 있으면 바뀔 예정!
@PreAuthorize("hasRole('ROLE_OWNER')")
@GetMapping("/chatroom/{roomId}")
public String joinRoom(@PathVariable("roomId") Long roomId,
Model model,
@AuthenticationPrincipal MemberDetails memberDetails,
Authentication authentication) throws JsonProcessingException {
try {
SessionDto sessionDto = memberService.getSessionDto(authentication,memberDetails);
model.addAttribute("sessionDto", sessionDto);
Owner owner = ownerService.getOwnerInfo(sessionDto.getId());
// 채팅룸 정보
ChattingRoomDetailDto chattingRoomDetailDto = chattingRoomService.findChatRoomId(roomId);
// 매장 정보
Shop shop = shopDetailService.findMyShopId(owner.getOwnerId());
if (shop.getIsDeleted()) {
model.addAttribute("shopErrorCode", ErrorCode.DELETED_SHOP);
}
CommonResponseDto<Object> shopDetail = shopDetailService.getMyShopDetail(sessionDto.getId());
ResultDto<MyShopDetailListResponseDto> resultDto = ResultDto.in(shopDetail.getStatus(), shopDetail.getMessage());
resultDto.setData((MyShopDetailListResponseDto) shopDetail.getData());
// 채팅 메세지
List<Map<String, Object>> messageListDto = messageService.getMessageList(roomId);
String messageResponseDtoListJson = objectMapper.writeValueAsString(messageListDto);
// 알람
Member receiver = alarmService.getAdminInfo(roomId);
model.addAttribute("shopDto", resultDto);
model.addAttribute("chatRoomDto", chattingRoomDetailDto);
model.addAttribute("messageResponseDtoListJson", messageResponseDtoListJson);
model.addAttribute("receiver",receiver);
return "owner/owner_chat_room_detail";
} catch (NotFoundException e) {
model.addAttribute("errorCode", ErrorCode.CHAT_ROOM_NOT_FOUND);
return "owner/owner_chat_room_form";
}
}
-> 생성된 채팅룸에 join하기
public ChattingRoomDetailDto findChatRoomId(Long roomId) {
ChattingRoom chattingRoom = chattingRoomRepository.findById(roomId)
.orElseThrow(() -> new NotFoundException(ErrorCode.CHAT_ROOM_NOT_FOUND));
return ChattingRoomDetailDto.builder()
.roomId(chattingRoom.getRoomId())
.roomName(chattingRoom.getRoomName())
.adminId(chattingRoom.getAdmin()
.getAdminId())
.ownerId(chattingRoom.getOwner()
.getOwnerId())
.build();
}
// 기존 채팅 메세지 조회
// 채팅룸에 해당되는 메세지 리스트 조회
public List<Map<String, Object>> getMessageList(Long roomId) {
List<Message> messageList = messageRepository.findAllByRoomId(roomId);
List<Map<String, Object>> resultList = new ArrayList<>();
for (Message message : messageList) {
Map<String, Object> messageMap = new HashMap<>();
messageMap.put("messageId", message.getMessageId());
messageMap.put("writer", message.getWriter());
messageMap.put("contents", message.getContents());
messageMap.put("messageDate",message.getCreatedAt().format(DateTimeFormatter.ISO_DATE_TIME));
resultList.add(messageMap);
}
return resultList;
}
@Controller
@RequiredArgsConstructor
public class MessageController {
// 메세지 전송 기능 구현
private final SimpMessagingTemplate messagingTemplate;
private final MessageService messageService;
//pub/chatroom/{roomId}
@MessageMapping("/chatroom/{roomId}") // 클라이언트에서 /send/chatroom/{roomId}로 메시지를 보낼 때 해당 메소드가 호출
public void sendMessage(@DestinationVariable Long roomId, MessageRequestDto messageRequestDto
) {
messageService.saveMessage(messageRequestDto);
// 메시지를 해당 채팅방 ID를 구독하고 있는 클라이언트들에게 전달
messagingTemplate.convertAndSend("/sub/chatroom/" + roomId, messageRequestDto);
}
}
// 채팅 메세지 저장
public void saveMessage(MessageRequestDto messageRequestDto) {
ChattingRoom chattingRoom = chattingRoomRepository.findById(messageRequestDto.getRoomId())
.orElseThrow(() -> new NotFoundException(ErrorCode.CHAT_ROOM_NOT_FOUND));
Message message = Message.builder()
.contents(messageRequestDto.getContents())
.writer(messageRequestDto.getWriter())
.chattingRoom(chattingRoom)
.build();
messageRepository.save(message);
}
view와 js 파일도 첨부하고 싶지만,
js 파일은 바닐라 js로 정말..맨땅에 헤딩식으로 코드를 구현( chat gpt)도움도 많이 받았기 때문에..
이 부분은 크게 도움이 되지 않을 것 같아 공유하는게 어렵다 판단했다... 더 나은 코드를 짤 수 있게 되면 그때 추가하도록 해보야겠다
프로젝트를 하면서 항상 websocket으로 채팅 구현을 해보고 싶었다
구글링을 하면서 여러 코드들을 정말 닥치는대로 읽었다고 해도 과언이 아닐 정도로
계속 읽었다
그리고 나서 지금 팀프로젝트에 적용을 해보면서 더 이해를 할 수 있었다
사실 코드를 구현하고 프로젝트를 하고 나면, 아쉬움이 깊게 남기도 한다
더 코드를 잘 짜고 싶고 더 깔끔한 코드를 구현하고 싶은데.. 기간내에 프로젝트를 하다보면
확실히 기능구현!에만 초점을 맞추다보니 이런부분들을 놓치게 되는 것 같다
언젠가, 깔끔한 코드를 구현하게 된다면 그 방법도 공유해보고 싶다!
틀린 부분도 있고, 어설픈 부분도 있을 수 있습니다!
코드는 참고용으로만 봐주세요~~