토이 프로젝트/Spring&Java 갖고놀기

Spring Boot Web Chatting : 스프링 부트로 실시간 채팅 만들기 (6) WebRTC 를 이용한 실시간 화상 채팅 구현하기(feat. https 인증서 적용)

TerianP 2022. 10. 29.
728x90

1. 실시간 화상 채팅 구현하기!

사실 실시간 화상 채팅이고 뭐고 순서상 JPA 를 사용한 회원가입을 구현하는게 먼저였지만...갑자기 화상 채팅에 꽂혀버려서 구현해봤습니다ㅋㅋㅋㅋ

 

솔직히 이번에는 다른 것보다 어렵겠구나 라는 생각을 했었습니다. 왜냐하면 아주 단순하게 생각해도 화상 채팅을 구현하기 위해서는 클라이언트끼리의 화면 연결, 음성 연결 등등등 정말 생각하고 고려할게 많았기 때문입니다.  그래도 만들려보고 키보드를 두들겼던 가장 큰 이유는 이전 채팅처럼 쉬운 예제가 많이 있다고 생각했었기 때문입니다. 그러나 의외로 참고할 자료가 많이 없었고, 이 부분이 굉장히 큰 에러였던 것 같습니다ㅠㅠ

 

처음에 예제를 찾아봤을 때 대부분이 스프링 대신 node.js 로 구현한 경우가 많았습니다. 솔직히 중간부터는 나도 node js 로 구현할까라고 생각할 정도였습니다. 그러다가 다행히도 git 이라는 보물창고에서 정말정말 좋은 예제를 찾았고, 이를 맘껏 활용해서 구현을 성공적을 마쳤습니다! 아 물론, 자바 예제 코드가 결코 쉽지 않았다는 점ㅠ.ㅠ

 

이번에도 관련 코드는 git 에 올려두었으며, 참고한 git은 아래 Reference 에 링크 달아두었습니다. 혹시나 제 코드를 베이스로 webRTC 를 구현하고자 하는 경우 제가 참고했던 git 를 먼저 확인하시면 많은 도움이 되리라 생각합니다.

 

 

1) WebRTC 를 이용한 실시간 화상 채팅의 특징과 아쉬운 점

- 음성, 영상 모두 가능하나, Only 1:1 만 지원한다. 이는 Mesh 방식의 1:1 P2P 방식의 코드로 구현했기 때문이다. 목표는 1:N 이었는데 이렇게되면 단순한 Mesh 방식 대신 미디어 서버를 두는 방법을 사용해야했다. 결국 이 미디어 서버의 구현에서 막혀버렸다.
  --> 미디어 서버의 경우 Kurento 와 openvidu 라는 오픈 소스를 찾았고 이를 이용하면 원하는 1:N 혹은 N:M 방식으로 훨씬 이쁘게 구현 가능할 것으로 생각된다.
  --> 다만 미디어 서버까지 두고 만드는 것은 생각보다 커질 듯 하고, 프론트에 대한 기술들 - React, nodeJS 등 - 이 더 들어가서 프론트와 함께 만져야할 것이라고 생각된다. 따라서 이왕 만드는거 가능하다면 추후 플젝 팀을 구해서 만들어보면 어떨까...하는 생각도 든다  
- 현재로서는 음성+화상 동시에 사용 불가능하다. 추후 합쳐서 만드는 것도 고려하는 중
- 모바일의 경우 삼성 인터넷이 아닌 크롬과 파이어폭스 앱을 이용하면 된다! 아마 삼성 인터넷은 권한 문제 때문에 안되는듯 하다ㅋㅋ
- 자신의 화면 공유 기능은 없다. 이 부분은 다른 것들에 비해서 그나마 쉬울 듯해서 추후 구현 예정!!
- 역시나 view 페이지가 굉장히 미흡히다. 사실 만진다고 만졌다가 점점 찌그러지는 화면을 보고 일단 기본 view 그대로 놔두었다. 추후 수정 예정. 누군가 뿅하고 나타나서 만들어줬으면 좋겠다ㅠㅠㅠㅠ
- 이번 코드는 기존 코드에는 별로 없었던 람다식이 정말 많이 사용되었다. 이는 예제 코드에 람다식이 특히 많았기 때문으로 코드 읽어보고 다 바꿀까 하다가...그래도 람다를 공부해야하는 입장에서 그냥 두고 제 나름대로 공부하고 고쳐봤다. 따라서 왜 이렇게...? 라는 부분이 있을 수 있으니 이 부분은 양해 부탁드립니다.

 

- master-Webrtc-jpa 브렌치를 확인해주세요!

https://github.com/SeJonJ/Spring-WebSocket-WebRTC-Chatting

 

GitHub - SeJonJ/Spring-WebSocket-WebRTC-Chatting: SpringBoot WebSocket Chatting

SpringBoot WebSocket Chatting. Contribute to SeJonJ/Spring-WebSocket-WebRTC-Chatting development by creating an account on GitHub.

github.com

 

 

2. 개념잡기 - WebRTC 란 무엇인가

1) WebRTC 란?

WebRTC (Web Real-Time Communication)는 웹 브라우저 간에 플러그인의 도움 없이 서로 통신할 수 있도록 설계된 API이다. W3C 에서 제시된 초안이며, 음성 통화, 영상 통화, P2P 파일 공유 등으로 활용될 수 있다.

 

2) WebRTC 의 종류 : Mash, MCU, SFU

Mesh
- 미디어 정보를 직접 peer(클라이언트) 끼리 connection 을 맺어 주고 받는다
- 서버는 peer 끼리  connection 을 맺기 위한 시그널 정보를 주고 받을 수 있게 돕는 역할을 한다.
- peer 에서 직접 미디어 정보를 주고 받기 때문에 peer 에게 부담이 될 수 있다.
- 1:1 에서 가장 많이 사용되는 방식, 1:N, N:M 도 가능하나 당연히 클라이언트에게 더욱 더 부담이 된다.
- 내 코드에서 사용된 방식으로 나는 1:1 만 구현하였다.

SFU
- peer 의 부하는 mesh 에 비해서 줄어들고, 그만큼 서버가 대신하여 부담을 갖는다.
- 1개의 upstream 과 N 개의 downstream 을 갖는 구조이다.
- 미디어 서버가 필요하다!!!

MCU
- peer 의 부하가 가장 적다 -> 당연히 서버의 부하는 그만큼 커진다
- 1 개의 upstream 과 1 개의 downstream 을 갖는 구조
- 서버에서 peer 의 스트림을 모아 인코딩&&디코딩 모두를 하기 때문에 서버가 좋아야한다.
- 역시 미디어 서버 필수!!

 

3) 어떻게 만들어?

WebRTC 서버를 제대로 구축하기 위해서는 기본적으로 4가지의 서버가 필요하다. 시그널링 서버, stun 서버, turn 서버, 미디어 서버이다.

그래서 이 4가지를 모두 구현해야하는가? 라고한다면 당연히 NO! 다 구현해야했다면 지금 이 글은 없었을 것이다.

뒤에서 다시 설명하겠지만 mesh 방식으로 구현한다면 오직 시그널링 서버만 필요하다!

 

4) 시그널링 서버 : 누구와 통신해?

시그널링 서버를 쉽게 이야기하자면 "누구와 통신하는지 파악하는 것을 돕는 서버"라고 할 수 있다.

인터넷 세상에서 아직 서로에 대해 모르는 두 클라이언트 peer 가 만나기 위한 약속을 잡기 위해서 sdp 와 ice 를 사용해서 peer 가 또 다른 peer 를 인식 할 수 있도록 돕고, 두 peer 가 연결될 수 있도록 돕는다. 

 

시그널링 서버의 통신 방법에 대해서는 꼭! 알고 넘어가야한다.

현재 Alice와 Bob 두명의 Peer가 있다. Alice와 Bob은 서로 SDP 기반의 Offer와 Answer 메시지를 주고 받는다.

  • Alice 가 SDP 형태의 Offer 메시지를 생성한다
  • Alice 가 생성된 Offer 메시지를 본인의 LocalDescription 으로 등록한다. ⇒ 여기도 문제
  • Alice 가 Offer 메시지를 시그널링 서버에게 전달한다.
  • 시그널링 서버는 상대방 Bob 를 찾아서 SDP 정보를 전달한다.
  • Bob 전달받은 Offer 메시지를 본인의 RemoteDescription 에 등록한다. ⇒ 여기서 에러
  • Bob 는 Answer 메시지를 생성한다.
  • 생성된 Answer 메시지를 본인의 LocalDescription 으로 등록한다.
  • Bob 은 Answer 메시지를 시그널링 서버에게 전달한다.
  • 시그널링 서버는 상대방 Alice를 찾아서 Answer 메시지를 전달한다.
  • Alice 전달받은 answer 메시지를 본인의 RemoteDescription 에 등록한다.
  • Alice 와 Bob 의 통신 시작!!!

 

5) stun 서버와 turn 서버 : 어디서로 통신해?

시그널링 서버가 "누구" 를 알려주는 역할이었다면 stun 서버와 turn 서버는 "서로가 어디에있으며 어디로 통신해야하는지 알려주는 서버"이다.

생각해보면 인터넷에서 서로가 통신할 때 어디로 통신을 하는가를 묻는다면 IP 를 통해 통신한다라고 대답할 수 있다. 결국 stun(Session Traversal Uilities for NAT) 서버와 turn 서버는 서로의 IP 를 알려주는 서버이다. 

STUN 서버의 경우 클라이언트 peer 의 public IP 를 확인하기 위해 stun 서버에 요청을 보내고 서버로 부터 자신의 public IP 를 받는다. 이때부터 클라이언트는 자신이 받은 public IP 를 이용하여 시그널링 할때 받은 정보를 이용해 시그널링한다.

 

그러나 stun 서버만으로 ip 를 정확하게 알기 힘들다. 두 Client가 같은 네트워크에 존재하고 있을때는 이것으로는 해결이 되지 않는다. 따라서 public 망에 존재하는 turn 서버를 경유하여 통신하게 된다. 정확히는 클라이언트 자신의 privateIP 가 포함된 turn 메시지를 turn 서버로 보낸다. 그러면 그러면 TURN 서버는 메세지에 포함된 Network Layer IP 주소와 Transport Layer의 UDP 포트 넘버와의 차이를 확인하고 클라이언트의 Public IP로 응답하게 된다. 이때 NAT는 NAT 매핑테이블에 기록되어 있는 정보에 따라서 내부 네트워크에 있는 클라이언트의 Private IP 로 메세지를 전송한다.

 

참고로 이 두개의 서버는 만들 필요가 없다. 갓구글님께 stun 서버를 제공해주시기에 우리는 그곳에 요청만 하면 된다.

 

어렵다ㅠㅠㅠ

 

한번 정리하고 넘어가겠다. 시그널링 서버란 peer 가 또 다른 peer 를 인식하고 이와 연결될 수 있도록 돕는 서버이다. turn 서버와 stun 는 IP를 확인해서 알려주기 위한 서버이다. 결국 인터넷에서 "통신" 의 기준이 되는 것은 IP 이기 때문에

 

6) ICE(Interactive Connectivity Establishment)

ICE는 Client가 모든 통신 가능한 주소를 식별하는 것을 의미하는데 클라이언트는 STUN 메세지를 TURN 서버로 요청 및 응답과정에서 다음 3가지의 주소를 확인 하게 된다.

Relayed Address : TURN 서버가 패킷 릴레이를 위해 할당하는 주소
Server Reflexive Address : NAT 가 매핑한 클라이언트의 공인망(Public IP, Port)
Local Address : 클라이언트의 사설주소(Private IP, Port)

여기서 STUN 서버는 Server Reflexive Address 공인망 IP 만을 응답하지만 TURN 서버는 Relayed Address와 Server Reflexive Address 공인망+사설망 IP 를 모두 응답한다.

 

추가로 Candidate라는 개념이 존재하는데 이것은 IP와 포트의 조합으로 표시된 주소이며 이제 이 확보된것을 통해서 연결을 한다.

Direct Connection : Host 같의 직접적인 미디어 송수신
Server Reflexive Connection : Server Reflexive Candidate를 이용한 미디어 송수신
TURN Relay Connection : Relay Candidate를 이용한 미디어 송수신

이렇게 확보된 3개의 주소들의 우선순위를 정하여 SDP내에 포함시켜 전송한다. Connection을 체크한 후 Connection이 완료되면 RTP 및 RTCP 패킷을 전송하여 통화가 가능하게 된다.

 

7) SDP : Session Description Protocol

ICE 를 통해 P2P 통신을 할 수 있는 주소 후보들을 확인했다면 이제 본격적을 정보를 주고 받는다. 이때 사용되는 기술이 SDP 이다

SDP(Session Description Protocol)는 WebRTC에서 스트리밍 미디어의 해상도나 형식, 코덱 등의 멀티미디어 컨텐츠의 초기 인수를 설명하기 위한 프로토콜이다. 비디오의 해상도, 오디오 전송 또는 수신 여부 등을 송수신 할 수 있다.

 

1. 미디어와 관련된 초기 세팅 정보를 주고받는 SDP 는 응답 모델(Offer/Answer) 을 갖고 있다. 즉 어떤 peer 가 특정한 미디어 스트림을 교환 할 것을 제안하면 상대방으로부터 수신 응답을 기다린다.

2. 응답을 받게되면 각 peer 가 수집한 ICE 후보중에서 최적의 경로를 결정하고 협상하는 프로세스가 발생하고, 수집한 ICE 후보들로 패킹을 보내 가장 지연 시간이 적고 안정적인 경로를 찾는다.

3. 최적의 ICE 후보가 선택되면 통신에 필요한 모든 메타 데이터와 IP 주소, 포트, 미디어 정보에 대해 피어간 합의가 완료된다.

4. 1~3 까지의 과정이 끝나면 피어간 P2P 연결이 완전히 설정되고, 활성화된다. 각 피어에 의해 로컬 데이터 스트림의 엔드 포인트가 생성되며 양방향 통신 기술 websockt 등 을 사용하여 양방향으로 전송한다.

 

 

3. Back-End : 시그널 서버 구성

먼저 git 에서 확인했던 예제 코드에서 유저 정보를 담는 Map 은 사진처럼 생겼었다. 이를 내 코드에 적용하기 위해서 그림처럼 고치게 되었다.

중앙선을 기준으로 위쪽이 git 예제 코드, 아래가 내 코드

0) WebRtcConfig

- WebRTC 을 위한 시그널링 서버는 WebSocket 을 사용하기 때문이 이에 대한 설정이 필요하다!

@Configuration
@EnableWebSocket // 웹 소켓에 대해 자동 설정
@RequiredArgsConstructor
public class WebRtcConfig implements WebSocketConfigurer {
    /* TODO WebRTC 관련 */
    private final SignalHandler signalHandler;

    // signal 로 요청이 왔을 때 아래의 WebSockerHandler 가 동작하도록 registry 에 설정
    // 요청은 클라이언트 접속, close, 메시지 발송 등에 대해 특정 메서드를 호출한다
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(signalHandler, "/signal")
                .setAllowedOrigins("*");
    }

    // 웹 소켓에서 rtc 통신을 위한 최대 텍스트 버퍼와 바이너리 버퍼 사이즈를 설정한다?
    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(8192);
        container.setMaxBinaryMessageBufferSize(8192);
        return container;
    }
}

 

1) ChatRoomDto

고민했던 점 1

- 화상 채팅을 만들기 위해서 가장 고민을 많이 했던 부분이었다. 화상 채팅의 경우 Map 에 유저의 UUID 와 WebSocketSession 정보를 저장해야 했는데 기존에는 String 타입만 저장했었기 때문이다. 따라서 ChatRoomDto 를 2개로 나눠서 사용하거나 두 개의 데이터를 모두 담을 수 있도록 코드를 짜야만 했다.

- 만약 채팅방을 나누더라도 문제가 있다. 2개로 나눌 경우 추후 roomlist 를 불러올 때 2개 종류의 채팅방을 모두 불러와서 view 에서 보여줘야 하기 때문이다. 이는 html 코드를 더 많이 손봐야하고, css 도 더많이 손봐야하고...힘들다ㅠㅠ

 

해결책

- 문제에 대한 해결책으로 ChatRoomdto 에서 유저를 저장하기 위해서 사용하던 Map 은 기존의 <String, String> 에서 와일드 카드를 사용한 <String, ?> 로 만들었다.

-  즉 일반채팅에서 사용하는 정보 String, String 과 화상 채팅을 하기 위해서 사용하는 정보 String, WebSocketSession 을 한번에 모두 저장하기 위해서 와일드카드 ? 를 사용하였다.

- 또한 ChatType 를 통해 MSG - 문자 채팅- 과 RTC - 화상 채팅 - 을 구분하였다. 

public class ChatRoomDto {
    @NotNull
    private String roomId; // 채팅방 아이디
    private String roomName; // 채팅방 이름 
    private int userCount; // 채팅방 인원수
    private int maxUserCnt; // 채팅방 최대 인원 제한

    private String roomPwd; // 채팅방 삭제시 필요한 pwd
    private boolean secretChk; // 채팅방 잠금 여부
    public enum ChatType{  // 화상 채팅, 문자 채팅
        MSG, RTC
    }
    private ChatType chatType; //  채팅 타입 여부

    // ChatRoomDto 클래스는 하나로 가되 서비스를 나누었음
    private Map<String, ?> userList;

}

 

2) ChatRoomMap

고민했던 점2 

- 두 종류의 chatRoom 을 합치고도 문제가 발생했다. Dto 는 하나로 가더라도 결국 Service 는 다르게 만들어야하는데 어떻게 채팅방의 정보를 저장하던 ChatRoomMap 을 모든 서비스에서 불러올까? 였다.

- 심지어는 코드를 작성하다보니 2개라고 생각했던 서비스에서 공통된 부분을 추출한다고 했을 때 3가지의 서비스로 나왔기 때문이었다 -> 이유는 추후 Controller 에서 설명

- 처음에는 공통된 부분을 갖는 ChatServiceMain 에 변수로 두고 여기서 불러와도 된다고 생각했지만 이 역시도 불가능했다. 이는 ChatServiceMain 에서 나머지 2개의 서비스를 불러오고, 나머지 2개의 서비스에서도 ChatServiceMain 을 불러오면...당연히 하나의 서비스가 생길 때 계속 서로를 불러오게 되고 루프가 발생해 로직상 문제가 생겼다.

 

해결책

- 복잡하게 생각하지 않고, ChatRoomMap 을 싱글톤으로 만들어 그안에 선언된 ChatRooms 를 불러서 쓰도록 하여 문제를 해결했다.

- 내 머리르로는 최선의 방법이라고 생각했지만, 이 부분은 좀 더 좋은 로직이 있다면 고민해보면 좋을 듯 하다. 

// 싱글톤으로 생성
// 모든 ChatService 에서 ChatRooms가 공통된 필요함으로
@Getter
@Setter
public class ChatRoomMap {
    private static ChatRoomMap chatRoomMap = new ChatRoomMap();
    private Map<String, ChatRoomDto> chatRooms = new LinkedHashMap<>();

//    @PostConstruct
//    private void init() {
//        chatRooms = new LinkedHashMap<>();
//    }

    private ChatRoomMap(){}

    public static ChatRoomMap getInstance(){
        return chatRoomMap;
    }

}

 

3) WebSocketMessage

- WebRTC 연결 시 사용되는 클래스로 WebSocket 연결 정보를 주고 받을 때 사용된다.

// WebRTC 연결 시 사용되는 클래스
@Data
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMessage {
    private String from; // 보내는 유저 UUID
    private String type; // 메시지 타입
    private String data; // roomId
    private Object candidate; // 상태
    private Object sdp; // sdp 정보
}

 

4) ChatServiceMain

- MsgChatService 와 RtcChatService 의 공동된 부분을 모아놓은 서비스!!

- 채팅방 생성, 삭제, 유저 입장 시 userCnt+1, 퇴장시 -1 등등

@Service
@Getter
@Setter
@RequiredArgsConstructor
@Slf4j
public class ChatServiceMain {

    private final MsgChatService msgChatService;
    private final RtcChatService rtcChatService;

    // 채팅방 삭제에 따른 채팅방의 사진 삭제를 위한 fileService 선언
    private final FileService fileService;


    // 전체 채팅방 조회
    public List<ChatRoomDto> findAllRoom(){
        // 채팅방 생성 순서를 최근순으로 반환
        List<ChatRoomDto> chatRooms = new ArrayList<>(ChatRoomMap.getInstance().getChatRooms().values());
        Collections.reverse(chatRooms);

        return chatRooms;
    }

    // roomID 기준으로 채팅방 찾기
    public ChatRoomDto findRoomById(String roomId){
        return ChatRoomMap.getInstance().getChatRooms().get(roomId);
    }

    // roomName 로 채팅방 만들기
    public ChatRoomDto createChatRoom(String roomName, String roomPwd, boolean secretChk, int maxUserCnt, String chatType){

        ChatRoomDto room;

        // 채팅방 타입에 따라서 사용되는 Service 구분
        if(chatType.equals("msgChat")){
            room = msgChatService.createChatRoom(roomName, roomPwd, secretChk, maxUserCnt);
        }else{
            room = rtcChatService.createChatRoom(roomName, roomPwd, secretChk, maxUserCnt);
        }

        return room;
    }

    // 채팅방 비밀번호 조회
    public boolean confirmPwd(String roomId, String roomPwd) {
//        String pwd = chatRoomMap.get(roomId).getRoomPwd();

        return roomPwd.equals(ChatRoomMap.getInstance().getChatRooms().get(roomId).getRoomPwd());

    }

    // 채팅방 인원+1
    public void plusUserCnt(String roomId){
        log.info("cnt {}",ChatRoomMap.getInstance().getChatRooms().get(roomId).getUserCount());
        ChatRoomDto room = ChatRoomMap.getInstance().getChatRooms().get(roomId);
        room.setUserCount(room.getUserCount()+1);
    }

    // 채팅방 인원-1
    public void minusUserCnt(String roomId){
        ChatRoomDto room = ChatRoomMap.getInstance().getChatRooms().get(roomId);
        room.setUserCount(room.getUserCount()-1);
    }

    // maxUserCnt 에 따른 채팅방 입장 여부
    public boolean chkRoomUserCnt(String roomId){
        ChatRoomDto room = ChatRoomMap.getInstance().getChatRooms().get(roomId);


        if (room.getUserCount() + 1 > room.getMaxUserCnt()) {
            return false;
        }

        return true;
    }

    // 채팅방 삭제
    public void delChatRoom(String roomId){

        try {
            // 채팅방 타입에 따라서 단순히 채팅방만 삭제할지 업로드된 파일도 삭제할지 결정
            ChatRoomMap.getInstance().getChatRooms().remove(roomId);

            if (ChatRoomMap.getInstance().getChatRooms().get(roomId).getChatType().equals(ChatRoomDto.ChatType.MSG)) { // MSG 채팅방은 사진도 추가 삭제
                // 채팅방 안에 있는 파일 삭제
                fileService.deleteFileDir(roomId);
            }

            log.info("삭제 완료 roomId : {}", roomId);

        } catch (Exception e) { // 만약에 예외 발생시 확인하기 위해서 try catch
            System.out.println(e.getMessage());
        }
        
    }

}

 

5) SignalHandler : 아주 중요!! 제일 중요!! => 시그널 서버 역할을 담당하는 클래스

- 이번 글에서 그리고 모든 코드를 통틀어서 가장 중요한 클래스, 동시에 가장 어려운 클래스이다.

- 중요한 부분이 많아 부득이하게 2부분으로 나누어 설명하도록 한다.

 

- 변수 선언과 WebSocket 이 연결되었을 때 이벤트 처리와 끊어졌을 때 이벤트처리를 담당하는 메서드가 있다.

- 특히 중요한 부분은 forceDisConn() 와 afterConnectionEstablished 에서 주석 처리된 부분이다.

- 주석 처리된 부분에 대해서 설명을 덧붙이자면 원본 코드에서는 Boolean.toString(!rooms.isEmpty()) 이 부분이 true 인지 false 인지에 따라서 추가적인 클라이언트가 있는지 여부를 판단한다. 즉 isEmpty() 했을 때 이미 방 안에 유저가 존재한다면 true 를 반환하고, 방 안에 있는 유저와 SDP 를 수행한다.

- 그러나 내 코드는 rooms 안에 있는 room 안에 있는 userList 에 유저 정보가 담기기 때문에 이 부분을 고쳐야만 했다. 이 부분은 ajax 와 rtcChatService 에서 해결했다.

@Component
@RequiredArgsConstructor
public class SignalHandler extends TextWebSocketHandler {

    private final RtcChatService rtcChatService;
    private final ChatServiceMain chatServiceMain;

    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    private final ObjectMapper objectMapper = new ObjectMapper();

    // roomID to room Mapping
    private Map<String, ChatRoomDto> rooms = ChatRoomMap.getInstance().getChatRooms();

    // message types, used in signalling:
    // SDP Offer message
    private static final String MSG_TYPE_OFFER = "offer";
    // SDP Answer message
    private static final String MSG_TYPE_ANSWER = "answer";
    // New ICE Candidate message
    private static final String MSG_TYPE_ICE = "ice";
    // join room data message
    private static final String MSG_TYPE_JOIN = "join";
    // leave room data message
    private static final String MSG_TYPE_LEAVE = "leave";

    // 연결 끊어졌을 때 이벤트처리
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        logger.info("[ws] Session has been closed with status [{} {}]", status, session);
    }

    // 소켓 연결되었을 때 이벤트 처리
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        /*
        * 웹 소켓이 연결되었을 때 클라이언트 쪽으로 메시지를 발송한다
        * 이때 원본 코드에서는 rooms.isEmpty() 가 false 를 전달한다. 이 의미는 현재 room 에 아무도 없다는 것을 의미하고 따라서 추가적인 ICE 요청을 하지 않도록 한다.
        *
        * 현재 채팅 코드에서는 chatRoom 안에 userList 안에 user가 저장되기 때문에 rooms 이 아닌 userList 에 몇명이 있는지 확인해야 했다.
        * 따라서 js 쪽에서 ajax 요청을 통해 rooms 가 아닌 userList 에 몇명이 있는지 확인하고
        * 2명 이상인 경우에만 JS에서 이와 관련된 변수를 true 가 되도록 변경하였다.
        *
        * 이렇게 true 상태가 되면 이후에 들어온 유저가 방안에 또 다른 유저가 있음을 확인하고,
        * P2P 연결을 시작한다.
        * */
        sendMessage(session, new WebSocketMessage("Server", MSG_TYPE_JOIN, Boolean.toString(!rooms.isEmpty()), null, null));
    }

 

사실 handleTextMessage 메서드는 굉장히 난해했던 부분이었다. 특히 기존 코드가 람다로 작성된 부분이 많아서 대체 이게 뭔 코드인지 뭔 소리인지 해석하는데 오히려 더 오래 걸렸다.여기를 뜯어고치면서 정말 때려칠까 많이 생각했다. 그래서 그런가 결국 고쳐서 돌아가도록 만드니 오히려 굉장히 뿌듯했던 것 같다.

 - 해당 handleTextMessage 메서드는 Socket JS 에서 전달받은 메시지를 수신하는 메서드이다. 해당 메서드를 기준으로 ICE 와 SDP 통신이 일어난다.
- 메서드가 실행되면서 userUUID 와 roomID 를 저장한다. 이후 전달받은 메시지의 타입에 따라서 시그널 서버의 기능을 시작한다.

- OFFER, ANSWER, ICE 의 경우 roomDTO 에서 roomID 를 기준으로 room 정보를 가져온다. 해당 room 의 userList key 정보들을 가져와서 확인한다. userList key 중 현재 유저의 uuid 와 다른 uuid 가 있다면 다른 유저가 존재하는것으로 판단하고 해당 유저의 session 에(나와 다른 유저의 session 정보) 에 현재 유저의 UUID, message.type, roomID, 상태, sdp 를 담아서 보내게 된다.
- JOIN 은 유저가 방에 참가했을 때 실행되며, roomId 를 기준으로 room 을 가져온 후 현재 유저를 추가한다. 또한 room 의 userCnt 도 +1한다.
- LEAVE 는 유저가 방에서 떠났을 때의 이벤트 처리이다.  떠났을 때의 기준은 웹 브라우저에서 '방을 나간경우' 로 취급한다. 즉 exit 버튼을 눌렀거나 브라우저를 종료한 경우 모두 방을 떠났다고 판단하여 LEAVE 메시지를 보내게 된다. 여기에서는 room 정보를 찾아온 후 userList 에서 key 값만 가져와서 떠난 유저와 동일한 uuid 가 있는지 확인한다. 만약 있다면 해당 유저를 userList 에서 제거한다
=> 이 부분이 굉장한 람다식으로 만들어진 것은 기존의 코드가 그렇게 되어있었고, 나 역시 완전히 바꾸기보다 람다를 조금이라도 공부하기 위해서 나에게 맞게 살짝만 바꾸고 여전히 람다를 사용하였다.
    // 소켓 메시지 처리
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage textMessage) {
        // a message has been received
        try {
            // 웹 소켓으로부터 전달받은 메시지
            // 소켓쪽에서는 socket.send 로 메시지를 발송한다 => 참고로 JSON 형식으로 변환해서 전달해온다
            WebSocketMessage message = objectMapper.readValue(textMessage.getPayload(), WebSocketMessage.class);
            logger.debug("[ws] Message of {} type from {} received", message.getType(), message.getFrom());
            // 유저 uuid 와 roomID 를 저장
            String userUUID = message.getFrom(); // 유저 uuid
            String roomId = message.getData(); // roomId

//            logger.info("Message {}", message.toString());

            ChatRoomDto room;
            // 메시지 타입에 따라서 서버에서 하는 역할이 달라진다
            switch (message.getType()) {

                // 클라이언트에게서 받은 메시지 타입에 따른 signal 프로세스
                case MSG_TYPE_OFFER:
                case MSG_TYPE_ANSWER:
                case MSG_TYPE_ICE:
                    Object candidate = message.getCandidate();
                    Object sdp = message.getSdp();

                    logger.debug("[ws] Signal: {}",
                            candidate != null
                                    ? candidate.toString().substring(0, 64)
                                    : sdp.toString().substring(0, 64));

                    /* 여기도 마찬가지 */
                    ChatRoomDto roomDto = rooms.get(roomId);

                    if (roomDto != null) {
                        Map<String, WebSocketSession> clients = rtcChatService.getClients(roomDto);

                        /*
                         * Map.Entry 는 Map 인터페이스 내부에서 Key, Value 를 쌍으로 다루기 위해 정의된 내부 인터페이스
                         * 보통 key 값들을 가져오는 entrySet() 과 함께 사용한다.
                         * entrySet 을 통해서 key 값들을 불러온 후 Map.Entry 를 사용하면서 Key 에 해당하는 Value 를 쌍으로 가져온다
                         *
                         * 여기를 고치면 1:1 대신 1:N 으로 바꿀 수 있지 않을까..?
                         */
                        for(Map.Entry<String, WebSocketSession> client : clients.entrySet())  {

                            // send messages to all clients except current user
                            if (!client.getKey().equals(userUUID)) {
                                // select the same type to resend signal
                                sendMessage(client.getValue(),
                                        new WebSocketMessage(
                                                userUUID,
                                                message.getType(),
                                                roomId,
                                                candidate,
                                                sdp));
                            }
                        }
                    }
                    break;

                // identify user and their opponent
                case MSG_TYPE_JOIN:
                    // message.data contains connected room id
                    logger.debug("[ws] {} has joined Room: #{}", userUUID, message.getData());

//                    room = rtcChatService.findRoomByRoomId(roomId)
//                            .orElseThrow(() -> new IOException("Invalid room number received!"));
                    room = ChatRoomMap.getInstance().getChatRooms().get(roomId);

                    // room 안에 있는 userList 에 유저 추가
                    rtcChatService.addClient(room, userUUID, session);

                    // 채팅방 입장 후 유저 카운트+1
                    chatServiceMain.plusUserCnt(roomId);

                    /* 이 부분에서 session.getID 대신 roomID 를 사용하면 문제 생김*/
                    rooms.put(roomId, room);
                    break;

                case MSG_TYPE_LEAVE:
                    // message data contains connected room id
                    logger.info("[ws] {} is going to leave Room: #{}", userUUID, message.getData());

                    // roomID 기준 채팅방 찾아오기
                    room = rooms.get(message.getData());

                    // room clients list 에서 해당 유저 삭제
                    // 1. room 에서 client List 를 받아와서 keySet 을 이용해서 key 값만 가져온 후 stream 을 사용해서 반복문 실행
                    Optional<String> client = rtcChatService.getClients(room).keySet().stream()
                            // 2. 이때 filter - 일종의 if문 -을 사용하는데 entry 에서 key 값만 가져와서 userUUID 와 비교한다
                            .filter(clientListKeys -> StringUtils.equals(clientListKeys, userUUID))
                            // 3. 하여튼 동일한 것만 가져온다
                            .findAny();

                    // 만약 client 의 값이 존재한다면 - Optional 임으로 isPersent 사용 , null  아니라면 - removeClientByName 을 실행
                    client.ifPresent(userID -> rtcChatService.removeClientByName(room, userID));

                    // 채팅방에서 떠날 시 유저 카운트 -1
                    chatServiceMain.minusUserCnt(roomId);

                    logger.debug("삭제 완료 [{}] ",client);
                    break;

                // something should be wrong with the received message, since it's type is unrecognizable
                default:
                    logger.debug("[ws] Type of the received message {} is undefined!", message.getType());
                    // handle this if needed
            }

        } catch (IOException e) {
            logger.debug("An error occured: {}", e.getMessage());
        }
    }

    private void sendMessage(WebSocketSession session, WebSocketMessage message) {
        try {
            String json = objectMapper.writeValueAsString(message);
            session.sendMessage(new TextMessage(json));
        } catch (IOException e) {
            logger.debug("An error occured: {}", e.getMessage());
        }
    }

 

6) MsgChatService

- 문자 채팅에 사용되는 서비스를 모아둔 클래스

@Slf4j
@RequiredArgsConstructor
@Service
public class MsgChatService {


    // 채팅방 삭제에 따른 채팅방의 사진 삭제를 위한 fileService 선언
    private final FileService fileService;

    public ChatRoomDto createChatRoom(String roomName, String roomPwd, boolean secretChk, int maxUserCnt) {
        // roomName 와 roomPwd 로 chatRoom 빌드 후 return
        ChatRoomDto room = ChatRoomDto.builder()
                .roomId(UUID.randomUUID().toString())
                .roomName(roomName)
                .roomPwd(roomPwd) // 채팅방 패스워드
                .secretChk(secretChk) // 채팅방 잠금 여부
                .userCount(0) // 채팅방 참여 인원수
                .maxUserCnt(maxUserCnt) // 최대 인원수 제한
                .build();

        room.setUserList(new HashMap<String, String>());

        // msg 타입이면 ChatType.MSG
        room.setChatType(ChatRoomDto.ChatType.MSG);

        // map 에 채팅룸 아이디와 만들어진 채팅룸을 저장
        ChatRoomMap.getInstance().getChatRooms().put(room.getRoomId(), room);

        return room;
    }


    // 채팅방 유저 리스트에 유저 추가
    public String addUser(Map<String, ChatRoomDto> chatRoomMap, String roomId, String userName){
        ChatRoomDto room = chatRoomMap.get(roomId);
        String userUUID = UUID.randomUUID().toString();

        // 아이디 중복 확인 후 userList 에 추가
        //room.getUserList().put(userUUID, userName);

        HashMap<String, String> userList = (HashMap<String, String>)room.getUserList();
        userList.put(userUUID, userName);


        return userUUID;
    }

    // 채팅방 유저 이름 중복 확인
    public String isDuplicateName(Map<String, ChatRoomDto> chatRoomMap, String roomId, String username){
        ChatRoomDto room = chatRoomMap.get(roomId);
        String tmp = username;

        // 만약 userName 이 중복이라면 랜덤한 숫자를 붙임
        // 이때 랜덤한 숫자를 붙였을 때 getUserlist 안에 있는 닉네임이라면 다시 랜덤한 숫자 붙이기!
        while(room.getUserList().containsValue(tmp)){
            int ranNum = (int) (Math.random()*100)+1;

            tmp = username+ranNum;
        }

        return tmp;
    }

    // 채팅방 userName 조회
    public String findUserNameByRoomIdAndUserUUID(Map<String, ChatRoomDto> chatRoomMap, String roomId, String userUUID){
        ChatRoomDto room = chatRoomMap.get(roomId);
        return (String) room.getUserList().get(userUUID);
    }

    // 채팅방 전체 userlist 조회
    public ArrayList<String> getUserList(Map<String, ChatRoomDto> chatRoomMap, String roomId){
        ArrayList<String> list = new ArrayList<>();

        ChatRoomDto room = chatRoomMap.get(roomId);

        // hashmap 을 for 문을 돌린 후
        // value 값만 뽑아내서 list 에 저장 후 reutrn
        room.getUserList().forEach((key, value) -> list.add((String) value));
        return list;
    }

    // 채팅방 특정 유저 삭제
    public void delUser(Map<String, ChatRoomDto> chatRoomMap, String roomId, String userUUID){
        ChatRoomDto room = chatRoomMap.get(roomId);
        room.getUserList().remove(userUUID);
    }
}

 

7) RtcChatService : 아주중요2!! WebRTC 를 위한 서비스

- WebRTC 에 맞게 새로 추가된 기능들을 정의하는 서비스 클래스이다.

- getClient 와 addClient 는 사실 코드도 간단하고 이름 그대로 userList에서 유저를 가져오거나 추가하는 기능을 한다. removeClientByName 는 유저 uuid 를 기준으로 유저를 삭제한다.

- 가장 중요한 부분은 findUserCount 이다. 이 메서드는 앞서 SignalHandler 에서 설명했던 내가 참여한 room 에 또 다른 유저가 있는지 판별해준다. 즉 userlist 의 size 가 '나' 를 포함해 2명 이상이라면 현재 방에 있는 또 다른 유저와 통신이 필요하다는 것을 확인하여 이에 맞는 boolean 값을 return 한다.

@Slf4j
@RequiredArgsConstructor
@Service
public class RtcChatService {

    // repository substitution since this is a very simple realization

    public ChatRoomDto createChatRoom(String roomName, String roomPwd, boolean secretChk, int maxUserCnt) {
        // roomName 와 roomPwd 로 chatRoom 빌드 후 return
        ChatRoomDto room = ChatRoomDto.builder()
                .roomId(UUID.randomUUID().toString())
                .roomName(roomName)
                .roomPwd(roomPwd) // 채팅방 패스워드
                .secretChk(secretChk) // 채팅방 잠금 여부
                .userCount(0) // 채팅방 참여 인원수
                .maxUserCnt(maxUserCnt) // 최대 인원수 제한
                .build();

        room.setUserList(new HashMap<String, WebSocketSession>());

        // msg 타입이면 ChatType.MSG
        room.setChatType(ChatRoomDto.ChatType.RTC);

        // map 에 채팅룸 아이디와 만들어진 채팅룸을 저장
        ChatRoomMap.getInstance().getChatRooms().put(room.getRoomId(), room);

        return room;
    }

    public Map<String, WebSocketSession> getClients(ChatRoomDto room) {
            // 공부하기 좋은 기존 코드
        // unmodifiableMap : read-only 객체를 만들고 싶을 때 사용
        // Collections emptyMap() : 결과를 반환할 시 반환할 데이터가 없거나 내부조직에 의해 빈 데이터가 반환되어야 하는 경우
        // NullPointException 을 방지하기 위하여 반환 형태에 따라 List 나 Map 의 인스턴스를 생성하여 반환하여 처리해야하는 경우
        // size 메서드 등을 체크하고 추가적인 값을 변경하지 않는 경우 Collections.emptyMap() 를 사용하면 매번 동일한 정적 인스턴스가
        // 변환되므라 각 호출에 대한 불필요한 인스턴스 생성하지 않게 되어 메모리 사용량을 줄일 수 있다

        Optional<ChatRoomDto> roomDto = Optional.ofNullable(room);

//        return (Map<String, WebSocketSession>) Optional.ofNullable(room)
//                .map(r -> Collections.unmodifiableMap(r.getUserList()))
//                .orElse(Collections.emptyMap());

        return (Map<String, WebSocketSession>) roomDto.get().getUserList();
    }

    public Map<String, WebSocketSession> addClient(ChatRoomDto room, String name, WebSocketSession session) {
        Map<String, WebSocketSession> userList = (Map<String, WebSocketSession>) room.getUserList();
        userList.put(name, session);
        return userList;
    }

    // userList 에서 클라이언트 삭제
    public void removeClientByName(ChatRoomDto room, String userUUID) {
        room.getUserList().remove(userUUID);
    }

    // 유저 카운터 return
    public boolean findUserCount(WebSocketMessage webSocketMessage){
        ChatRoomDto room = ChatRoomMap.getInstance().getChatRooms().get(webSocketMessage.getData());
//        log.info("ROOM COUNT : [{} ::: {}]",room.toString(),room.getUserList().size());
        return room.getUserList().size() > 1;
    }


}

 

8) ChatRoomController

- 서비스가 총 3개가 된 가장 큰 이유ㅠㅠ

- 서비스를 2개로만 하면 채팅방의 비밀번호 확인, 삭제, 유저 카운트 등등 모두 따로 해야하기 때문에 공통된 부분을 모은 ChatServiceMain 이 탄생하였다.

@Controller
@RequiredArgsConstructor
@Slf4j
public class ChatRoomController {

    // ChatService Bean 가져오기
    private final ChatServiceMain chatServiceMain;

    // 채팅방 생성
    // 채팅방 생성 후 다시 / 로 return
    @PostMapping("/chat/createroom")
    public String createRoom(@RequestParam("roomName") String name,
                             @RequestParam("roomPwd") String roomPwd,
                             @RequestParam("secretChk") String secretChk,
                             @RequestParam(value = "maxUserCnt", defaultValue = "2") String maxUserCnt,
                             @RequestParam("chatType") String chatType,
                             RedirectAttributes rttr) {

        // log.info("chk {}", secretChk);

        // 매개변수 : 방 이름, 패스워드, 방 잠금 여부, 방 인원수
        ChatRoomDto room;

        room = chatServiceMain.createChatRoom(name, roomPwd, Boolean.parseBoolean(secretChk), Integer.parseInt(maxUserCnt), chatType);


        log.info("CREATE Chat Room [{}]", room);

        rttr.addFlashAttribute("roomName", room);
        return "redirect:/";
    }

    // 채팅방 입장 화면
    // 파라미터로 넘어오는 roomId 를 확인후 해당 roomId 를 기준으로
    // 채팅방을 찾아서 클라이언트를 chatroom 으로 보낸다.
    @GetMapping("/chat/room")
    public String roomDetail(Model model, String roomId, @AuthenticationPrincipal PrincipalDetails principalDetails){

        log.info("roomId {}", roomId);

        // principalDetails 가 null 이 아니라면 로그인 된 상태!!
        if (principalDetails != null) {
            // 세션에서 로그인 유저 정보를 가져옴
            model.addAttribute("user", principalDetails.getUser());
        }

        ChatRoomDto room = ChatRoomMap.getInstance().getChatRooms().get(roomId);

        model.addAttribute("room", room);


        if (ChatRoomDto.ChatType.MSG.equals(room.getChatType())) {
            return "chatroom";
        }else{
            model.addAttribute("uuid", UUID.randomUUID().toString());

            return "rtcroom";
        }
    }

    // 채팅방 비밀번호 확인
    @PostMapping("/chat/confirmPwd/{roomId}")
    @ResponseBody
    public boolean confirmPwd(@PathVariable String roomId, @RequestParam String roomPwd){

        // 넘어온 roomId 와 roomPwd 를 이용해서 비밀번호 찾기
        // 찾아서 입력받은 roomPwd 와 room pwd 와 비교해서 맞으면 true, 아니면  false
        return chatServiceMain.confirmPwd(roomId, roomPwd);
    }

    // 채팅방 삭제
    @GetMapping("/chat/delRoom/{roomId}")
    public String delChatRoom(@PathVariable String roomId){

        // roomId 기준으로 chatRoomMap 에서 삭제, 해당 채팅룸 안에 있는 사진 삭제
        chatServiceMain.delChatRoom(roomId);

        return "redirect:/";
    }

    // 유저 카운트
    @GetMapping("/chat/chkUserCnt/{roomId}")
    @ResponseBody
    public boolean chUserCnt(@PathVariable String roomId){

        return chatServiceMain.chkRoomUserCnt(roomId);
    }
}

 

9) RtcChatController

- 유저 수 확인을 위한 ajax 처리를 위해 사용하는 컨트롤러. 그래도 다른 것들과 합칠까 하다가 그래도 나중을 위해서 나누어두었다.

@RestController
@RequiredArgsConstructor
@Slf4j
public class RtcController {

    private final RtcChatService rtcChatService;

    @PostMapping("/webrtc/usercount")
    public String webRTC(@ModelAttribute WebSocketMessage webSocketMessage) {
        log.info("MESSAGE : {}", webSocketMessage.toString());
        return Boolean.toString(rtcChatService.findUserCount(webSocketMessage));
    }


}

 

4. Front-End : Webrtc.js 와 html

1) Webrtc_client.js : 이번 글에서 2번째로 중요한 친구

- 이 JS 에 대한 코드 설명은 생략하겠다. 절대 귀찮거나 그래서가 아니라 단순히 너무 길다. 정말정말 길다. 내가 다 이해하지 못한 것도 그렇고 코드만 그대로 복붙한다고 의미는 없으니...

- 다만 중요한 내용은 정말 열심히 주석을 달아두었으니 이것을 확인하자.

- 특히 중요한 메인 부분을 따로 첨부한다.

function start() {
    // 페이지 시작시 실행되는 메서드 -> socket 을 통해 server 와 통신한다
    socket.onmessage = function(msg) {
        let message = JSON.parse(msg.data);
        switch (message.type) {

            case "offer":
                log('Signal OFFER received');
                handleOfferMessage(message);
                break;

            case "answer":
                log('Signal ANSWER received');
                handleAnswerMessage(message);
                break;

            case "ice":
                log('Signal ICE Candidate received');
                handleNewICECandidateMessage(message);
                break;

            case "join":
                // ajax 요청을 보내서 userList 를 다시 확인함
                message.data = chatListCount();

                log('Client is starting to ' + (message.data === "true)" ? 'negotiate' : 'wait for a peer'));
                log("messageDATA : "+message.data)
                handlePeerConnection(message);
                break;

            case "leave":
                stop();
                break;

            default:
                handleErrorMessage('Wrong type message received from server');
        }
    };


    // ICE 를 위한 chatList 인원 확인
    function chatListCount(){

        let data;

        $.ajax({
            url : "/webrtc/usercount",
            type : "POST",
            async : false,
            data : {
                "from" : localUserName,
                "type" : "findCount",
                "data" : localRoom,
                "candidate" : null,
                "sdp" : null
            },
            success(result){
                data = result;
            },
            error(result){
                console.log("error : "+result);
            }
        });

        return data;
    }

 

2) html

- rtcroom.html 은 사실 예제 코드를 거의 그대로 가져다 사용했기 때문에 생략한다ㅋㅋㅋ

- 다만 roomlist 에서 많은 변동이 있었다. 일반 채팅- 화상채팅 구분과 이에 해당하는 로직 추가 등등 따라서 roomlist 의 js 코드와 바뀐 부분을 첨부해둔다.

    // 화상 채팅 시 1:1 임으로 2명 고정
    $rtcType.change(function() {
        if($rtcType.is(':checked')){
            let number = 2;

            $("#maxUserCnt").val(parseInt(2));
            // $("#maxUserCnt").attr('value', 2)
            $("#maxUserCnt").attr('disabled', true);
        }
    })

    // 문자 채팅 누를 시 disabled 풀림
    $msgType.change(function(){
        if($msgType.is(':checked')){
            $maxUserCnt.attr('disabled', false);
        }
    })
})
       // 채팅방 생성
        function createRoom() {

            let name = $("#roomName").val();
            let pwd = $("#roomPwd").val();
            let secret = $("#secret").is(':checked');
            let secretChk = $("#secretChk");

            // console.log("name : " + name);
            // console.log("pwd : " + pwd);

            if (name === "") {
                alert("방 이름은 필수입니다")
                return false;
            }
            if ($("#" + name).length > 0) {
                alert("이미 존재하는 방입니다")
                return false;
            }
            if (pwd === "") {
                alert("비밀번호는 필수입니다")
                return false;
            }

            // 최소 방 인원 수는 2, 최대 100명
            if($("#maxUserCnt").val() <= 1){
                alert("채팅은 최소 2명 이상!!");
                return false;
            }else if ($("#maxUserCnt").val() > 100) {
                alert("100명 이상은 서버가 못 버텨요ㅠ.ㅠ");
                return false;
            }

            // 채팅 타입 필수
            if ($('input[name=chatType]:checked').val() == null) {
                alert("채팅 타입은 필수입니다")
                return false;
            }

            if (secret) {
                secretChk.attr('value', true);
            } else {
                secretChk.attr('value', false);
            }

            if(!numberChk()){
                return false;
            }

            return true;
        }
        
        
        -------------------------------------------------------------------------
                   <form method="post" action="/chat/createroom" onsubmit="return createRoom()">
                <div class="modal-body">
                    <div class="mb-3">
                        <label for="roomName" class="col-form-label">방 이름</label>
                        <input type="text" class="form-control" id="roomName" name="roomName">
                    </div>
                    <div class="mb-3">
                        <label for="roomPwd" class="col-form-label">방 설정 번호(방 삭제시 필요합니다)</label>
                        <div class="input-group">
                            <input type="password" name="roomPwd" id="roomPwd" class="form-control" data-toggle="password">
                            <div class="input-group-append">
                                <span class="input-group-text"><i class="fa fa-eye"></i></span>
                            </div>
                        </div>
                    </div>
                    <div class="mb-3">
                        <div class="form-check">
                            <input class="form-check-input" type="radio" name="chatType" id="msgType" value="msgChat">
                            <label class="form-check-label" for="msgType">
                                일반 채팅(최대 100명)
                            </label>
                        </div>
                        <div class="form-check">
                            <input class="form-check-input" type="radio" name="chatType" id="rtcType" value="rtcChat">
                            <label class="form-check-label" for="rtcType">
                                화상 채팅(1:1 Only)
                            </label>
                        </div>
                    </div>
                    <div class="mb-3">
                        <label for="maxUserCnt" class="col-form-label">채팅방 인원 설정
                            <!--<input class="form-check-input" type="checkbox" id="maxChk">--></label>
                        <input type="text" class="form-control" id="maxUserCnt" name="maxUserCnt">
                    </div>
                    <div class="form-check">
                        <input class="form-check-input" type="checkbox" id="secret">
                        <input type="hidden" name="secretChk" id="secretChk" value="">
                        <label class="form-check-label" for="secret">
                            채팅방 잠금
                        </label>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
                    <input type="submit" class="btn btn-primary" value="방 생성하기">
                </div>
            </form>

 

 

 

5. tomcat https 설정 : 이거 없으면 실행 불가!!! 꼭!!

- 화상 채팅을 위해서는 https 설정이 필수!! 이다. 이것 땜시 몇시간을 날렸는지 모르겠다ㅠㅠㅠㅠ

- https 설정을 위한 인증서를 만드는 방법은 아래와 같다. 관리자 권한으로 cmd 창을 열어서 아래 순서대로 진행하자

 

1) cmd 창에서 https 인증서를 생성할 프로젝트의 /src/main/java/resources 로 이동한다.

2) 아래 명령어 실행

keytool -genkey -alias [인증서이름] -keyalg RSA -keysize 2048 -validity 700 -keypass [인증서패스워드] -storepass [저장소패스워드] -keystore [인증서파일명].jks
<sample>
keytool -genkey -alias selfsigned_localhost_sslserver -keyalg RSA -keysize 2048 -validity 700 -keypass test1234 -storepass test1234 -keystore ssl-server.jks

명령어 실행 결과

3) application.properties 설정

## SSL config
server.ssl.enabled=true
server.ssl.key-store=classpath:[인증서 파일명].jks
server.ssl.key-store-password=[인증서 패스워드]
server.ssl.key-store-type=JKS
server.ssl.keyAlias=[인증서이름]

// 포트가 2개인 이유는 8443 은 https 를 위한 포트이고, 8080 은 http 를 위한 포트이다.
server.port=8443
server.port.http=8080

 

4) SslConfig

- SSL 설정을 추가한다.

- 주요 설정은 http 8080 으로 요청이 들어왔을 때 자동으로 https 8443 으로 리다이렉트 해주는 부분이다.

@Configuration
public class SslConfig {

    @Bean
    public ServletWebServerFactory servletContainer() {
        // Enable SSL Trafic
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
            @Override
            protected void postProcessContext(Context context) {
                SecurityConstraint securityConstraint = new SecurityConstraint();

                securityConstraint.setUserConstraint("CONFIDENTIAL");
                SecurityCollection collection = new SecurityCollection();
                collection.addPattern("/*");
                securityConstraint.addCollection(collection);
                context.addConstraint(securityConstraint);
            }
        };

        // Add HTTP to HTTPS redirect : http 로 요청이 들어오면 https 로 리다이렉트
        tomcat.addAdditionalTomcatConnectors(httpToHttpsRedirectConnector());

        return tomcat;
    }

    /*
        http 를 https 로 리다이렉트한다.
        즉 http://8080 으로 요청이 들어온 경우 리다이렉트를 통해서 https://8443 으로 변경해준다
     */
    private Connector httpToHttpsRedirectConnector() {
        Connector connector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL);
        connector.setScheme("http");
        connector.setPort(8080);
        connector.setSecure(false);
        connector.setRedirectPort(8443);
        return connector;
    }

}

tomcat 실행 시 https 가 보이면 성공!


6. 실행 확인!

- 구현 완료한 사진과 동영상.

- 사실 조금 아쉬웠던 부분은 1:N 과 화면 공유까지 하는걸 목표로 했는데 이 부분들이 들어가지 못해서 아쉬웠다. 하지만...곧 기능을 덕지덕지 붙여서 돌아올 것이다!!ㅋㅋㅋ

- 그래도 나름 화상채팅이기에 사진 대신 동영상 촬영했는데...카페여서 그런가 주위 소리가 많이 시끄럽다ㅠㅠ

심지어 내 목소리가 저렇게 이상했나 싶다ㅠ.ㅠ 

 

 

 

 

 


Reference

참고 GIT

https://github.com/Benkoff/WebRTC-SS

 

GitHub - Benkoff/WebRTC-SS: WebRTC Signaling Server

WebRTC Signaling Server. Contribute to Benkoff/WebRTC-SS development by creating an account on GitHub.

github.com

 

WebRTC DOC

WebSocket - Web API | MDN

 

WebSocket - Web API | MDN

WebSocket 객체는 WebSocket 서버 연결의 생성과 관리 및 연결을 통한 데이터 송수신 API를 제공합니다.

developer.mozilla.org

 

Signaling and video calling - Web API | MDN

 

Signaling and video calling - Web API | MDN

WebRTC 는 리얼 타임 음성, 영상, 데이터 교환을 할 수 있는 완전한 p2p 기술이다. 다른 곳에서 논의한 것 처럼 (en-US) 서로 다른 네트워크에 있는 2개의 디바이스들을 서로 위치시키기 위해서는, 각

developer.mozilla.org

 

WebRTC 개념 잡기

https://andonekwon.tistory.com/59

 

WebRTC란? (STUN과 TURN 서버의 이해) (2)

이전 글 복습 중간에 방화벽이 존재하거나 NAT 환경에 놓여 있는 경우에는 각 Peer에 대한 직접적인 시그널링이 불가능하다고 이야기하였다. 그렇기 떄문에 시그널링을 하고 연결을 하기 위해서

andonekwon.tistory.com

https://withseungryu.tistory.com/130

 

[WebRTC] Signaling Server ( 시그널링 서버 )

WebRTC에 대해서 이야기를 해봤는데 WebRTC를 유기적으로 잘 사용하기 위해서는 아래와 같은 서버가 필요하다. Signaling - Always needed NAT Traversal - need for production Media - depends on the app 이번..

withseungryu.tistory.com

https://kid-dev.tistory.com/4

 

[언택트 기술 시리즈]webrtc 서버 구축 1편 -기초

아자르, 스무디, 행아웃 언택트 시대하면 위 서비스들을 한번쯤은 들어보거나 사용해봤을 것이다. 맞다. 위 서비스들은 webrtc로 만들어진 서비스 들이다. 'webrtc = 영상통화,화상회의'라고 말할수

kid-dev.tistory.com

https://velog.io/@gojaegaebal/210307-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%8090%EC%9D%BC%EC%B0%A8-%EC%A0%95%EA%B8%80-%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-WebRTC%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%802-ICE-SDP-Signalling

 

210307 개발일지(90일차) - 정글 나만의 무기 프로젝트 - WebRTC란 무엇인가?(2) : ICE, SDP, Signalling

위의 STUN, TURN 등으로 찾아낸 연결 가능한 네트워크 주소들을 Candidate(후보)라고 할 수 있다.ICE라는 프레임워크에서 Finding Candidate(후보 찾기)를 한다. 보통 3종류의 후보들을 갖게되는데, 그 종류

velog.io

1. 화상 채팅 예제로 익히는 WebRTC - 기본 예제

 

1. 화상 채팅 예제로 익히는 WebRTC - 기본 예제

WebRTC는 구글에서 공유한 웹 기반 커뮤니케이션 라이브러리로, 별도의 설치 없이 웹 브라우저에서 화상채팅을 할 수 있는 기능을 제공한다. WebRTC는 화상채팅, 음성채팅 등을 쉽게 제작할 수 있도

forest71.tistory.com

초보개발자 WebRTC 시그널링서버 만들기

 

초보개발자 WebRTC 시그널링서버 만들기

WebRTC는 실시간 음성, 영상, 데이터를 교환할 수 있는 P2P 기술이다. 서로 다른 네트워크에 있는 2개의 클라이언트 간 미디어 포맷등을 상호 연동하기 위한 협의과정(Negotiation)이 필요하다.

medium.com

 

https 설정을 위한 인증서 생성

https://ayoteralab.tistory.com/entry/Spring-Boot-24-https-TLS-SSL-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0

 

[Spring Boot] 24. https TLS SSL 적용하기

지금까지 만들어온 Spring Boot 프로젝트는 browser에서 http://localhost:port를 입력해서 접속을 했습니다. 이는 향후 정상적인 DNS를 적용해도 동일합니다. 오늘은 SSL을 적용한 https로 접근하도록 Spring Boo

ayoteralab.tistory.com

https://stackoverflow.com/questions/32858217/spring-boot-executable-war-keystore-not-found

 

spring-boot executable war keystore not found

I build spring-boot executable war with ssl support. My application.properties file is: server.port = 8443 server.ssl.key-store = classpath:keystore.jks server.ssl.key-store-password = secret serv...

stackoverflow.com

 

댓글