WebSocket - 채팅 기능

박영준·2023년 8월 21일
0

Spring

목록 보기
52/58

1. WebSocket 정의

  • Transport protocol의 일종

  • 서버와 클라이언트 간에 Socket Connection 을 유지해서, 언제든 양방향 통신 또는 데이터 전송이 가능하도록 하는 기술

  • Real-time web application구현을 위해 널리 사용되고 있다.

    • 예시 : SNS어플리케이션, LoL같은 멀티플레이어 게임, 구글 Doc, 증권거래, 화상채팅 등

2. 작동 원리

  • HTTP프로토콜을 통해 WebSocket 접속이 이루어지고, 이후 통신은 서버와 클라이언트 간의 WebSocket 으로 진행된다.
    • 연결이 정상적으로 이루어 진다면, 서버와 클라이언트 간에 WebSocket연결이 이루어진다.
    • 일정 시간이 지나면, HTTP연결은 자동으로 끊어진다.

3. 특징

1) 실시간 네트워킹(Real Time-Networking) & 양방향 통신(Full-Duplex)

  • HTTP 통신은 클라이언트 쪽에서 Request를 할 때만, 서버가 Response를 하는 방식으로 통신이 진행되는 한방향 통신이다.
    이 경우, 서버 쪽 데이터가 업데이트 되더라도 클라이언트 쪽 화면에서 Refresh하지 않는 한 변경된 데이터가 업데이트 되지 않는 문제가 발생한다.

  • Web Socket 은 Stateful protocol(상태유지)이기 때문에, 클라이언트와 한 번 연결이 되면 계속 같은 라인을 사용해서 통신한다.
    따라서, HTTP 사용에서 발생하는 HTTP와 TCP연결 트래픽을 피할 수 있다.

2) 방화벽 재설정 X

  • Web Socket은 HTTP와 같은 포트(80)을 사용하기 때문에, 기업용 어플리케이션에 적용할 때 방화벽을 재설정 하지 않아도 된다.
    • websocket 연결은 HTTP 연결로부터 시작된다.
      따라서, 클라이언트와 서버 양측에 새로운 포트를 열거나 방화벽을 새로 뚫지 않아도 단순 websocket 요청으로도 접근 가능

4. 단점

1. 프로그램 구현에 보다 많은 복잡성을 초래

  • (HTTP와 달리) websocket 은 Stateful protocol이기 때문에, 서버와 클라이언트 간의 연결을 항상 유지해야 하며 만약 비정상적으로 연결이 끊어졌을때 적절하게 대응해야 한다.
    • 이는 기존의 HTTP 사용시와 비교했을 때, 코딩의 복잡성을 가중시키는 요인이 될 수 있습니다.

2. 서버와 클라이언트 간의 Socket 연결을 유지하는 것 자체가 비용이 든다.

  • 특히 트래픽 양이 많은 서버같은 경우, CPU에 큰 부담이 될 수 있습니다.

3. 오래된 버전의 웹 브라우저에서는 지원 X

5. 구현

1) 의존성 추가

dependencies {
	// WebSocket
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
}

2) WebSocketConfig

@Configuration
@EnableWebSocket        // WebSocket 활성화
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {		// WebSocketConfigurer 인터페이스를 구현

    private final WebSocketHandler webSocketHandler;

    // 1. WebSocket 연결
    @Override
    public void registerWebSocketHandlers (WebSocketHandlerRegistry registry) {		
        registry.addHandler(webSocketHandler, "ws/chat")
                .setAllowedOriginPatterns("*"); 		// 2.
    }
}

1.

  • 사용할 Handler 주소(webSocketHandler)와 WebSocket 주소("ws/chat") 를 WebSocketHandlerRegistry 에 추가하면, 해당 주소로 접근하여 websocket 연결이 가능해진다.

2.

  • WebSocket 에서 CORS 처리를 위한 허용 패턴
    • .setAllowedOrigins("*") 메서드는 일정 버전부터 사용 불가능
    • 그 대신 .setAllowedOriginPatterns("*") 를 사용할 수 있다.

3) WebSocketHandler

메서드는 크게 3가지로 나뉜다.

  • handleTextMessage : 메시지 전송 메서드
  • afterConnectionEstablished : websocket 연결 후
  • afterConnectionClosed : websocket 연결 종료 후
@Component
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {		// 1.

    private final ObjectMapper objectMapper;
    private final ChatService chatService;
   
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    	// Json 형식으로 메세지를 웹소켓을 통해 서버로 보낸다
        String payload = textMessage.getPayload();
        
        // Handler 는 이를 받아, ObjectMapper 를 통해서 해당 Json 데이터를 MessageDto.class 에 맞게 파싱하여 MessageDto 객체로 변환
        MessageDto messageDto = objectMapper.readValue(payload, MessageDto.class);
        
        // 이 Json 데이터에 들어있는 roomId 를 이용해서, 해당 채팅방을 찾아 handlerAction() 이라는 메서드를 실행
        messageService.findRoomById(messageDto.getRoomId());

        // 2. 해당 참여자가 채팅방에 접속 상태인지, 이미 참여해 있는지에 따라 메시지 전송 방식 차별
        handlerActions(session, messageDto, messageService);
    }
    
    public void handlerActions(WebSocketSession session, MessageDto messageDto, MessageService messageService) {
        sessions.add(session);
        sendMessage(messageDto, messageService);
    }

    // 메세지 전송
    private <T> void sendMessage(T message, MessageService messageService) {
        sessions.parallelStream()
                .forEach(session -> messageService.sendMessage(session, message));
    }

    // 새로운 WebSocket 세션 연결 시, 세션을 sessions 집합에 추가
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.add(session);
    }

    // session 삭제
    @Override
    public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) {
        deleteSession(webSocketSession);
    }

    public void deleteSession(WebSocketSession webSocketSession) {
        if (webSocketSession.isOpen()) {
            try {
                webSocketSession.close();
            } catch (IOException e) {
                log.error("WebSocket 세션을 닫는 중 오류가 발생했습니다: {}", e.getMessage());
            }
        }

        sessions.remove(webSocketSession);
    }
}

1.

  • 채팅 기능 구현을 위해 TextWebSocketHandler 를 상속받는다

2.

  • 채팅방에 처음 참여한다면, session을 연결해줌과 동시에 메시지를 보낸다.
  • 처음 참여하는 것이 아니라면, 해당 채팅방으로 메시지를 보낸다.

4) DTO

(1) MessageDto

채팅에 관한 정보

@Getter
@Setter
public class MessageDto {
    public enum MessageType {		// 1.
        ENTER, TALK
    }

    private MessageType type;
    private String roomId;
    private String sender;
    private String message;
}

1.

  • ENTER : 사용자가 처음 채팅방에 들어오는 상태일 경우
  • TALK : 이미 session 에 연결되어 채팅 중인 경우

(2) MessageRoomDto

채팅방에 관한 정보

@Getter
public class MessageRoomDto {
    private String roomId;

    @Builder
    public MessageRoomDto(String roomId) {
        this.roomId = roomId;
    }
}

5) MessageRoomController

@RestController
@RequestMapping("/messageRoom")
@RequiredArgsConstructor
public class MessageRoomController {
    private final MessageRoomService messageRoomService;
    private final MessageService messageService;

    // 1. 채팅방 생성
    @PostMapping
    public MessageRoomDto createRoom(@AuthenticationPrincipal UserDetailsImpl userDetails) {
        return messageService.createRoom(userDetails.getUser());
    }

    // 채팅방 전체 조회
    @GetMapping
    public List<MessageRoomDto> findAllRoom() {
        return messageService.findAllRoom();
    }

    // 채팅방 삭제
    @DeleteMapping("/{id}")
    public MsgResponseDto deleteRoom(@PathVariable Long id, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return messageRoomService.deleteRoom(id, userDetails.getUser());
    }
}

1.

  • 레퍼런스한 블로그들에는 보통 채팅방 생성 시, 매개변수에 name 을 넣어서 채팅방 이름을 설정해주기도 했음

6) Service

(1) MessageService

@RequiredArgsConstructor
@Service
public class MessageService {

    private final ObjectMapper objectMapper;
    private final MessageRoomRepository messageRoomRepository;
    private Map<String, MessageRoomDto> messageRoomDtoList;		// key 는 roomId, value 는 채팅방 정보

    @PostConstruct
    private void init() {
         messageRoomDtoList = new LinkedHashMap<>();
    }
    
	// 채팅방 전체 조회
    public List<MessageRoomDto> findAllRoom() {
        return new ArrayList<>(messageRoomDtoList.values());
    }
    
    // 채팅방 생성
    public MessageRoomDto createRoom(User user) {
    	// roomId 는 UUID 를 통해 랜덤 생성
        String randomId = UUID.randomUUID().toString();
        MessageRoomDto messageRoomDto = MessageRoomDto.builder()
                .roomId(randomId)
                .name(user.getNickname())
                .build();

		// Map 에 채팅방 및 채팅 내용을 저장
        messageRoomDtoList.put(randomId, messageRoomDto);

		// DB 에 저장
        messageRoomRepository.save(new MessageRoom(messageRoomDto.getName(), messageRoomDto.getRoomId(), user));

        return messageRoomDto;
    }

    // 1. 채팅방의 webSocket session 에 메시지 전송
    public <T> void sendMessage(WebSocketSession session, T message) {
        try{
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
    }
}

1.

  • TALK 상태일 경우 실행되는 메서드
  • 메시지를 해당 채팅방의 webSocket 세션에 보내는 메서드

(2) MessageRoomService

@Service
@RequiredArgsConstructor
public class MessageRoomService {
    private final MessageRoomRepository messageRoomRepository;

	// 채팅방 삭제
    public MsgResponseDto deleteRoom(Long id, User user) {
        MessageRoom messageRoom = messageRoomRepository.findByIdAndUser(id, user);
        messageRoomRepository.delete(messageRoom);

        return new MsgResponseDto("채팅방을 삭제했습니다.", HttpStatus.OK.value());
    }
}

7) Entity

DB 에 message 와 messageRoom 을 저장하기 위해 엔티티를 만들어준다.

(1) Message

@Entity
@Getter
@Table(name = "message")
@NoArgsConstructor
public class Message extends Timestamped {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String sender;

    @Column(name = "message")
    private String message;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "messageRoomId", nullable = false)
    private MessageRoom messageRoom;

    @ManyToOne
    @JoinColumn(name = "userId", nullable = false)
    private User user;
}

(2) MessageRoom

@Entity
@Setter
@Getter
@Table(name = "messageRoom")
@NoArgsConstructor
public class MessageRoom {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @Column(unique = true)
    private String roomId;

    @OneToMany(mappedBy = "messageRoom", cascade = CascadeType.REMOVE)
    private List<Message> messageList = new ArrayList<>();

    @ManyToOne
    @JoinColumn(name = "userId", nullable = false)
    private User user;

    public MessageRoom(String name, String roomId, User user) {
        this.name = name;
        this.roomId = roomId;
        this.user = user;
    }
}

8) Repository

(1) MessageRepository

public interface MessageRepository extends JpaRepository<Message, Long> {
}

(2) MessageRoomRepository

public interface MessageRoomRepository extends JpaRepository<MessageRoom, Long> {
    MessageRoom findByIdAndUser(Long id, User user);
}

6. 테스트

Simple Websocket Client 을 확장 프로그램으로 설치하거나 Postman 을 통해 websocket 채팅 기능을 테스트해 볼 수 있다.

1. 웹 소켓 주소를 입력 후, Open 눌러 연결해준다.(사용자1)

2. 하나의 창에서 Request 부분에 아래 내용을 입력하고 보내준다. (Json 형태)

{
  "type":"ENTER",
  "roomId":"e4f57c65-0b0f-4972-b20c-4a024a0a4f81",
  "sender":"사용자1",
  "message":"asd"
}

3. 채팅의 송수신을 확인하는 것이기 때문에 2개의 서버가 필요하다. 따라서 1번의 창을 하나 더 띄워서 추가로 연결시켜줘야 한다.(사용자2)

2번의 전송 내용도 보낸다.

4. 이제 두 사용자는 서로 연결된 상태가 된다.

5. 한쪽에서 채팅을 전송하면, 양쪽 모두에게 해당 내용이 전송되어 Message Log 에서 확인이 가능하다.

{
  "type":"TALK",
  "roomId":"e4f57c65-0b0f-4972-b20c-4a024a0a4f81",
  "sender":"사용자1",
  "message":"asd"
}


참고: [Spring] webSocket으로 채팅서버 구현하기 (1)
참고: Spring websocket chatting server(1) - basic websocket server
참고: WebSocket 이란?
참고: [웹소켓] WebSocket의 개념 및 사용이유, 작동원리, 문제점
참고: 웹소켓과 일반 소켓의 차이는 뭔가요?

profile
개발자로 거듭나기!

0개의 댓글