실버케어 플랫폼 프로젝트

[Vue] WebRTC를 통한 1:1 화상 통화 구현 -2(프론트)

삼록이 2025. 4. 17. 15:23

우선 이 글을 읽기 전에 반드시 이전에 썼던 WebRTC 개념에 관해 작성한 1번 글을 읽고 오기를 권한다.

https://gotopm.tistory.com/25

 

[개념]WebRTC 개념 정리

0.WebRTC(Web Real-Time Communication)이란?인터넷 브라우저나 앱에서 플러그인 없이 오디오,비디오와 같은 데이터를 실시간으로 주고받는 통신을 가능하게 해주는 기술로 P2P방식으로 작동하여 서버에

gotopm.tistory.com

 

 

0. 우선 아래와 같은 화면을 구현하였다.

코드가 길다. 따라서 설명을 위해 핵심적인 부분을 나누어서 코드를 올렸다.

전체 코드는 제일 마지막 하단부에 첨부했다.

우리 팀의 프로젝트에 있는 코드라 그대로 복붙해서 써가면 내 아이디와 상대방 아이디를 받아오는 부분 때문에 작동이 안될 것이다.

(내 아이디를 받아오는 myId객체와 상대방의 아이디를 받아오는 loginId, parentType부분만 본인 것에 맞게 수정을 하면 작동이 될 것이다)

 

배포 전 테스트는 하나는 크롬탭에서 다른 하나는 크롬 시크릿 탭에서 진행하면 된다.

 


프론트코드(Vue3)

 

먼저 아래코드의 return{} 부분을 보면 필요한 객체들을 정의하였다.

이 코드에서는 백엔드(시그널링 서버에서) 아이디를 기준으로  세션을 구별하도록 구현했기 때문에 내 아이디와 상대방 아이디가 필요하다.

 

이 화면이 mount되자마자 initLocalMedia, connectSignalingServer, startCall이란 함수를 차례대로 수행할 수 있도록 했다.

initLocalMedia()는 브라우저에서 카메라와 마이크 권한을 요청하고, 내 컴퓨터 화면에 내 미디어스트림이 바로 나오도록 하는 함수다.

(미디어 스트림이란, 브라우저가 사용자의 카메라나 마이크 등 미디어 장치로 부터 받아오는 영상/음성 데이터를 다루는 객체)

connectSigalingServer()는 시그널링 서버에 연결하는 함수다. 웹소켓 프로토콜로 시그널링 서버에 연결한다.

그리고 여기서 상대방에게 온 SDP answer메시지와 ICE Candidate를 수신한다.

startCall()은 내 미디어스트림을 상대에게 연결하고, connectSignalingServer로 통해 연결된 시그널링 서버에 SDP offer메세지와 ICE Candidate를 전송한다.

 

따라서 전체적인 흐름은 아래와 같다.

initLocalMedia(먼저, 발신자의 미디어스트림을 가져온다)->connectSignalingServer(시그널링 서버와 연결한다)->startCall(발신자가 ICE Candidate와 SDP Offer메시지를  시그널링 서버를 통해 수신자에게 전달. 그러면 수신자도 ICE Candidate와  Answer메세지로 응답) ->connectSignalingServer(수신자에게 온 SDP,ICE 메세지를 받고)->startCall(연결이 이루어졌으니 내 미디어 스트림을 상대에게 연결함으로써 화상통화 완료)

 

 

 data(){
      return{
        // 아래 보면 localStream은 내 카메라,마이크에서 얻은 MediaStream을 저장하는 변수
          localStream: null,
        // RTCPeerConnection 인스턴스를 저장할 변수
          peerConnection: null,
        // 시그널링 서버를 연결할 객체를 저장할 변수 
          signalingServer: null,
        // 상대방 로그인 아이디
          loginId: this.$route.params.loginId,
          // 내 로그인 아이디
          myId: localStorage.getItem('loginId'),
          // 라우팅시킨 곳(healthData이면 이름 명시하고 아니면 닉네임 명시)
          parentType: this.$route.query.parentType,
          // 상대방 이름과 닉네임 설정 (백엔드에서 데이터 가져오거나 임시 사용)
          userName: "",
          userNickname: "",
          //상대방 기다리는중인지 여부
          isWaiting: true,
          // 상대방이 통화를 종료했는지 여부
          peerClosed: false
        
      }
  },
  mounted: async function() { 
        await this.initLocalMedia();          // localStream 준비 완료 후
        await this.connectSignalingServer();  // signaling 준비 완료 후
        await this.startCall();               // 이제 safe하게 시작 가능
        // 이 코드는 비동기 작업을 수행하고, 모든 작업이 완료된 후에 실행됨. 즉 initLocalMedia(), connectSignalingServer(), startCall() 순으로 차례로 실행된다는 것
        await this.getNameInfo();
      },

 

1.initLocalMedia 함수

가장먼저 실행되는 함수다.

아래 보면, getUserMedia를 통해 브라우저가 사용자에게 카메라/마이크에 대한 접근 권한을 요청하는데

사용자가 이에 대해 권한을 허용하면 해당 장치에서 나오는 미디어 데이터(음성 및 화상 데이터)가 담긴 MediaStream 객체가 반환된다. 그리고 그 미디어 스트림 객체를  내 화면에 연결하는 것이다. 그러면 내 화면에 내 카메라 영상이 실시간으로 보여지게 되는 것!

 async initLocalMedia(){
          try{
              // navigator.mediaDevices.getUserMedia()는 브라우저에서 카메라와 마이크 권한을 요청하고, MediaStream객체를 호출하는 함수
              this.localStream = await navigator.mediaDevices.getUserMedia({video: true, audio:true})
              // localVideo라는 비디오 태그에 내 미디어 스트림을 연결하는 부분으로 내 카메라 영상을 실시간으로 바로 보여줌
              this.$refs.localVideo.srcObject = this.localStream
          }catch(error){
              console.log('카메라.마이크 접근 실패',error)
          }
      },

 

2.connectSignalingServer 함수

먼저 시그널링 서버와 웹소켓 연결을 진행한다.

(이 프로젝트는 MSA  아키텍처로 진행되어 chat-service라는 경로가 추가되었다.

또한 백엔드에서 웹소켓 연결을 신청하는 아이디를 기준으로 세션을 관리 했기 때문에 쿼리파라미터 형식까지 추가되었다)

 

아래 코드의 //3번 주석부분을 보면 signalingServer가 open되면 발신자가 보내온 메시지를 처리한다.

그런데 발신자가 보내온 메시지를 처리하기 앞서 수신자가 먼저 SDP메시지와 ICE Candidate에 대한 정보를 담은 메시지를 보내야한다.

(this.signalingServer.onmessage는 어차피 상대방으로 부터 메시지가 수신되면 자동으로 호출되는 함수다)

 

따라서, 흐름상 시그널링 서버와 웹소켓 연결이 되면 바로 3번 startCall부분으로 이동하겠다.

 async connectSignalingServer(){
        // 1.시그널링 서버 연결(백엔드에서 맞춰놓은 쿼리파라미터 형식으로)
        //new WebSocket(url)은 웹소켓 객체를 리턴하고 이 객체는 onopen(연결이 열리면 실행할 콜백),onmessage(서버로부터 메시지를 받으면 실행할 콜백),onclose(연결이 종료되었을때 실행할 콜백)
        //send(data)(서버로 메시지를 전송하는 함수),readyState(현재 연결 상태를 나타내는 함수)등 메서드를 내장함
        this.signalingServer = new WebSocket(`ws://localhost:8080/chat-service/signal?userId=${this.myId}`) //WebSocketConfig에 설정한 url과 경로 맞추어야함

        //2. 연결이 열리면 실행
        this.signalingServer.onopen = () =>{
          console.log('시그널링 서버 연결 성공')
        }

        //3. 상대방이 보내온 메시지를 처리하는 부분
        this.signalingServer.onmessage = async(message) => {
          const data = JSON.parse(message.data)

          if(data.type === 'answer'){
            this.handleAnswer(data)
          } else if(data.type === 'candidate') {
            // 상대방이 알려준 후보군을 내 peerConnection에 등록
            const candidate = new RTCIceCandidate(data.candidate)
            await this.peerConnection.addIceCandidate(candidate)
          } else if(data.type === 'offer'){
           try{
            //   // this.startCall()은 내가 offer를 생성해서 보내는 거고 여기서는 상대방의 offer를 받아서 answer를 보내는 로직이 들어가야하는 것
            //  1.내 RTCpeerConnection객체 생성
              const configuration ={
                iceServers:[{urls : 'stun:stun.1.google.com.19302'}]
              };
              this.peerConnection = new RTCPeerConnection(configuration);
            // 2. 내 localStream을 트랙에 추가
              this.localStream.getTracks().forEach(track => {
                this.peerConnection.addTrack(track,this.localStream);
              });
              // 3.상대방의 트랙 수신 처리
              this.peerConnection.ontrack = (event) => {
                console.log('상대방 스트림 수신!');
                this.$refs.remoteVideo.srcObject = event.streams[0];
                this.isWaiting = false; // 상대방 스트림 수신 시 대기 상태 해제
              };

              // 연결 상태 변경 감지
              this.peerConnection.oniceconnectionstatechange = () => {
                console.log("ICE 연결 상태 변경:", this.peerConnection.iceConnectionState);
                if (this.peerConnection.iceConnectionState === 'disconnected' || 
                    this.peerConnection.iceConnectionState === 'closed' || 
                    this.peerConnection.iceConnectionState === 'failed') {
                  this.peerClosed = true;
                  console.log("상대방이 연결을 종료했습니다");
                }
              };
              
              // 4. ICE 후보 발견시 전송
              this.peerConnection.onicecandidate = (event) => {
                if(event.candidate){
                  this.signalingServer.send(JSON.stringify({
                    type: 'candidate',
                    candidate: event.candidate,
                    to: this.loginId,
                    from: this.myId
                  }));
                }
              };
              // 5.상대 offer 등록
              await this.peerConnection.setRemoteDescription(new RTCSessionDescription(data.offer));

              // 6.answer생성 및 전송
              const answer = await this.peerConnection.createAnswer();
              await this.peerConnection.setLocalDescription(answer);
              this. signalingServer.send(JSON.stringify({
                type: 'answer',
                answer: answer,
                // from: this.myId,
                to: this.loginId
              }));

              console.log('answer전송완료');
            } catch(error){
              console.log('offer처리 중 오류 발생', error)
            }
            } else if(data.type === 'leave') {
              // 상대방이 통화를 종료했음을 알리는 메시지
              this.peerClosed = true;
              console.log("상대방이 통화를 종료했습니다");
            }
        }
        //4. 연결 종료 또는 오류 핸들링도 추가 가능
        this.signalingServer.onerror = (error) => {
          console.error('시그널링 서버 오류:', error)
        }
      },

 

3.startCall 함수

 

WebRTC 연결에서 가장 핵심적인 객체가 있다. 바로 RTCPeerConnction객체다. 이 객체는 브라우저와 브라우저 간(P2P) 직접 연결을 맺고 오디오,비디오와 같은 미디어 스트림을 양방향으로 전달까지 하는 객체다.  따라서 우리는 이 객체를 통하여 연결을 맺고 나와 상대방의 미디어스트림을 주고 받아야한다.

 

우리는 개념파트에서 P2P연결을 위해서는 STUN서버가 필요하다고 배웠다.

한 클라이언트가 상대 클라이언트와 연결하기 위해서는 자신의 공인IP와 포트 정보를 전달해줘야하는데 문제는 본인도 본인의 공인 IP와 포트 정보를 모른다. 이 때 사용하는 것이 STUN서버라고 했다.

(STUN서버에 클라이언트가 먼저 요청을 보내면 STUN서버는 그 클라이언트의 공인 IP주소와 포트번호를 알려준다. 이 STUN서버는 구글이 운영하고 있는 서버를 활용한다.)

 

클라이언트는 이 공인 IP주소와 포트번호를 시그널링서버를 통해 상대에게 전달하는 것이다.

그리고 RTCPeerConnection 객체는 이 공인 IP주소와 포트번호를 기반으로  여러 ICE Candidate후보군들도 만들어낸다\

따라서 아래코드를 보면 RTCPeerConnection객체를 만들때 이 STUN서버를 필요로 하는 것이다.

 

그리고 객체에 내 미디어 스트림을 추가하고, RTCPeerConnection객체가 만들어낸 ICE Candidate와 SDP Offer 메시지도 시그널링 서버로 보낸다. 그러면 시그널링 서버는 이 ICE Candidate와 SDP Offer메시지를 상대방에게 전달하는 것이다. 

(그러면 상대방은 Offer메세지를 받아 이에 응답하는 Answer메시지를 보낼 것이다)

 

//  -->startCall()함수는 RTCPeerConnection을 생성하고, 내 스트림을 상대에게 연결하고, SDP Offer와 ICE Candidate를 signaling 서버로 보내서 연결을 성립하는 함수
      async startCall(){
        try{
           // 0. RTCPeerConnection객체의 생성자에 들어갈 옵션객체 정의로, key가 iceServers고 value가 배열인 형태임
        // iceServers는 NAT뒤에 있는 클라이언트끼리 연결 할 수 있도록 STUN/TURN서버 정보를 넣어줌. 구글의 STUN서버 사용
        const configuration ={
          iceServers:[
            {urls: 'stun:stun.1.google.com.19302'}
          ]
        }

        // 1.RTCPeerConnection객체 생성
        this.peerConnection = new RTCPeerConnection(configuration)

        // 2. 내 MediaStream(내가 getUserMedia()로 가져온 객체로 비디오트랙과 오디오트랙이 담겨있음)을 피어커넥션 객체에 추가
        this.localStream.getTracks().forEach(
          track =>{
            this.peerConnection.addTrack(track,this.localStream)
          }
        )

        // 3.상대방으로부터 미디어 트랙을 받으면 remoteVideo에 연결(peerConnection.ontrack은 상대방이 addTrack()으로 트랙을 전송했을때 브라우저가 자동으로 발생시키는 이벤트)
      //  event.streams는 MediaStream객체들의 배열
        this.peerConnection.ontrack = (event) =>{
          console.log('상대방 스트림 수신')
          this.$refs.remoteVideo.srcObject = event.streams[0]
          this.isWaiting = false;//상대방 스트림 수신시 상대방이 채팅에 들어왔다는 거니까 기다리는 상태를 false로 변경
          console.log("상대방 스트림 수신 후 기다리는 상태",this.isWaiting)
        }

        // 연결 상태 변경 감지 - 상대방이 연결을 끊었을 때 감지(상대방이 연결을 끊으면 peerClosed변수를 true로 바꾸어 내 화면에 상대방이 연락을 끊었다고 메시지를 띄우기 위한용도다) 
        this.peerConnection.oniceconnectionstatechange = () => {
          console.log("ICE 연결 상태 변경:", this.peerConnection.iceConnectionState);
          if (this.peerConnection.iceConnectionState === 'disconnected' || 
              this.peerConnection.iceConnectionState === 'closed' || 
              this.peerConnection.iceConnectionState === 'failed') {
            this.peerClosed = true;
            console.log("상대방이 연결을 종료했습니다");
          }
        };

        // 4.ICE Candidate가 생성될 때마다 시그널링 서버로 보낸다(ice후보는 한번에 생성되는게 아니라 그때그때마다 찾아짐 찾아질때마다 상대방(여기선 this.loginId)에게 보내줘야함)
        // this.peerConnection.onicecandidate는 ICE후보가 생성될때마다 호출되는 이벤트 핸들러
        this.peerConnection.onicecandidate = (event) => {
            if(event.candidate && this.signalingServer.readyState === WebSocket.OPEN){

            this.signalingServer.send(JSON.stringify({
              type:'candidate',
              candidate: event.candidate,
              to:this.loginId,
              from:this.myId
            }))
            
          } else{
            console.log("시그널링 서버 연결 대기중")
          }

        }
        // 5.SDP Offer생성후 전송(상대방은 이걸 받고 Answer을 만들어야 연결이 성립된다)
        const offer = await this.peerConnection.createOffer()
        await this.peerConnection.setLocalDescription(offer)

        this.signalingServer.send(JSON.stringify(
          {
            type: 'offer',
            offer: offer,
            from: this.myId,
            to: this.loginId
         //여기서 to~는 반드시 있어야함 백엔드에서 이 메시지의 'to'키값을 찾아서 상대방 로그인아이디를 찾도록 로직처리했으므로
          }))

        }catch(error){
          console.error('startCall 오류:', error)
        }
      },

 

4.다시 connectSignalingServer 함수

  아까 2번 설명항목에서 아래코드의 2번까지(시그널링 서버와의 웹소켓 연결 성공까지) 설명했었다. 

 위에서 수신자에게 SDP Offer 메시지와 ICE Candidate를 시그널링 서버를 통해 보내면 수신자는 이에 응답하는  SDP Answer메세지를 발신자에게 보낼 것이라고 했다. 그래서 발신자로부터 Answer메시지(즉 메시지타입이 answer라면)를 받으면 handleAnswer메서드를 통해 상대방의 SDP Answer메시지를 내 RTCPeerConnction객체에 상대방의 연결정보로 등록한다.

 

그리고 발신자는 먼저 Answer메시지를 보내기전에 수신자가 ICE Candidate를 전송했듯 발신자도 ICE Candidate를 먼저 전송한다. 수신자는 역시 받아서(즉 메시지의 타입이 candidate라면) ICE Candidate후보군들을 내 RTCPeerConnection객체에 등록한다. 그러면 서로간의 ICE Candidate중 가장 연결 품질이 좋은 경로를 WebRTC 엔진이 자동으로(내부적으로) 선택하게 되는 것이다.

 

그리고 이 한 화면에서 상대방과 내가 공통된 화상통화가 이루어지므로 script로직에는 내(발신자)가 상대방(수신자)에게 SDP Offer와 ICE Candidate를 보내고 또 상대방의 SDP Anser와 ICE Candidate를 받아 P2P연결이 완성되고 내 미디어스트림을 전송하고 전송받는 로직이 있지만, 수신자가 SDP Offer 메시지와 ICE Candidate를 받아 SDP Answer메세지와 ICE Candidate를 보내는 로직도 동시에 있어야한다. 그 로직이 바로 data.type이 offer인 if절에 들어가있는 내용이다. 그래서 위의 StartCall 메서드에 있었던 동일한 코드가 중복으로 들어가지만 SDP Offer가 아닌 Answer타입으로 보내는 것에서 차이가 있다.

 

 async connectSignalingServer(){
        // 1.시그널링 서버 연결(백엔드에서 맞춰놓은 쿼리파라미터 형식으로)
        this.signalingServer = new WebSocket(`ws://localhost:8080/chat-service/signal?userId=${this.myId}`) //WebSocketConfig에 설정한 url과 경로 맞추어야함

        //2. 연결이 열리면 실행
        this.signalingServer.onopen = () =>{
          console.log('시그널링 서버 연결 성공')
        }

        //3. 상대방이 보내온 메시지를 처리하는 부분
        this.signalingServer.onmessage = async(message) => {
          const data = JSON.parse(message.data)

          if(data.type === 'answer'){
            this.handleAnswer(data)
          } else if(data.type === 'candidate') {
            // 상대방이 알려준 후보군을 내 peerConnection에 등록
            const candidate = new RTCIceCandidate(data.candidate)
            await this.peerConnection.addIceCandidate(candidate)
          } else if(data.type === 'offer'){
           try{
            //   // this.startCall()은 내가 offer를 생성해서 보내는 거고 여기서는 상대방의 offer를 받아서 answer를 보내는 로직이 들어가야하는 것
            //  1.내 RTCpeerConnection객체 생성
              const configuration ={
                iceServers:[{urls : 'stun:stun.1.google.com.19302'}]
              };
              this.peerConnection = new RTCPeerConnection(configuration);
            // 2. 내 localStream을 트랙에 추가
              this.localStream.getTracks().forEach(track => {
                this.peerConnection.addTrack(track,this.localStream);
              });
              // 3.상대방의 트랙 수신 처리
              this.peerConnection.ontrack = (event) => {
                console.log('상대방 스트림 수신!');
                this.$refs.remoteVideo.srcObject = event.streams[0];
                this.isWaiting = false; // 상대방 스트림 수신 시 대기 상태 해제
              };

              // 연결 상태 변경 감지
              this.peerConnection.oniceconnectionstatechange = () => {
                console.log("ICE 연결 상태 변경:", this.peerConnection.iceConnectionState);
                if (this.peerConnection.iceConnectionState === 'disconnected' || 
                    this.peerConnection.iceConnectionState === 'closed' || 
                    this.peerConnection.iceConnectionState === 'failed') {
                  this.peerClosed = true;
                  console.log("상대방이 연결을 종료했습니다");
                }
              };
              
              // 4. ICE 후보 발견시 전송
              this.peerConnection.onicecandidate = (event) => {
                if(event.candidate){
                  this.signalingServer.send(JSON.stringify({
                    type: 'candidate',
                    candidate: event.candidate,
                    to: this.loginId,
                    from: this.myId
                  }));
                }
              };
              // 5.상대 offer 등록
              await this.peerConnection.setRemoteDescription(new RTCSessionDescription(data.offer));

              // 6.answer생성 및 전송
              const answer = await this.peerConnection.createAnswer();
              await this.peerConnection.setLocalDescription(answer);
              this. signalingServer.send(JSON.stringify({
                type: 'answer',
                answer: answer,
                to: this.loginId,
                from: this.myId
              }));

              console.log('answer전송완료');
            } catch(error){
              console.log('offer처리 중 오류 발생', error)
            }
            } else if(data.type === 'leave') {
              // 상대방이 통화를 종료했음을 알리는 메시지
              this.peerClosed = true;
              console.log("상대방이 통화를 종료했습니다");
            }
        }
        //4. 연결 종료 또는 오류 핸들링도 추가 가능
        this.signalingServer.onerror = (error) => {
          console.error('시그널링 서버 오류:', error)
        }
      },
   async handleAnswer(data){
        try{
          console.log('받은 answer :',data.answer)
        // 상대방이 보낸 answer데이터를 RTCSessionDescription 객체로 변환
        //이 작업은 WebRTC가 이해할 수 있는 객체로 만들어주는 작업임.
          const remoteDesc = new RTCSessionDescription(data.answer)
        // 이 answer를 peerConnection(내 RTCPeerConnection객체)의 상대방의 연결 정보로를 등록
        //이걸 해야 비로소 브라우저 간 연결이 완료됨(즉 P2P연결이 완료)
          await this.peerConnection.setRemoteDescription(remoteDesc)
          console.log('상대방의 answer를 성공적으로 등록')
        }catch(error){
          console.error('answer등록 실패')
        }
      },

 

한 화면에서 수신자와 발신자의 로직이 동시에 이루어지다보니 코드가 중구난방 복잡해졌다...

전체 코드는 아래와 같다. 천천히 하나하나씩 뜯어보며 부디 이해가 되길 바란다...

<template>
  <div class="chat-container">
  
    <div class="video-chat-minimal">
      <div class="videos-container">
        <div class="remote-video-container">
          <video ref="remoteVideo" autoplay playsinline></video>
          <div class="floating-name">
            <span v-if="parentType === 'healthData'">{{ userName }}</span>
            <span v-else>{{ userNickname }}</span>
          </div>
          <div v-if="isWaiting" class="waiting-message">상대방의 화상통화 수신을 기다리고 있습니다.</div>
          <div v-if="peerClosed" class="call-ended-message">
            <div class="message-content">
              <i class="fas fa-phone-slash message-icon"></i>
              <p>상대방이 통화를 종료했습니다</p>
              <button @click="closeAndReturn" class="close-btn">화상통화 나가기</button>
            </div>
          </div>
        </div>
        <div class="local-video-container">
          <video ref="localVideo" autoplay playsinline muted></video>
          <div class="floating-name local">나</div>
        </div>
      </div>
      <div class="minimal-controls">
        <button class="icon-btn" @click="toggleMicrophone"><i class="fas fa-microphone"></i></button>
        <button class="icon-btn" @click="toggleCamera"><i class="fas fa-video"></i></button>
        <button class="icon-btn end" @click="endCall"><i class="fas fa-phone-slash"></i></button>
      </div>
    </div>

  
  </div>
</template>

<script>
import axios from 'axios';
export default {
  name: 'VisualChat',

  data(){
      return{
        // 아래 보면 localStream은 내 카메라,마이크에서 얻은 MediaStream을 저장하는 변수
          localStream: null,
        // RTCPeerConnection 인스턴스를 저장할 변수
          peerConnection: null,
        // 시그널링 서버를 연결할 객체를 저장할 변수 
          signalingServer: null,
        // 상대방 로그인 아이디
          loginId: this.$route.params.loginId,
          // 내 로그인 아이디
          myId: localStorage.getItem('loginId'),
          // 라우팅시킨 곳(healthData이면 이름 명시하고 아니면 닉네임 명시)
          parentType: this.$route.query.parentType,
          // 상대방 이름과 닉네임 설정 (백엔드에서 데이터 가져오거나 임시 사용)
          userName: "",
          userNickname: "",
          //상대방 기다리는중인지 여부
          isWaiting: true,
          // 상대방이 통화를 종료했는지 여부
          peerClosed: false
        
      }
  },
  mounted: async function() { 
        await this.initLocalMedia();          // localStream 준비 완료 후
        await this.connectSignalingServer();  // signaling 준비 완료 후
        await this.startCall();               // 이제 safe하게 시작 가능
        // 이 코드는 비동기 작업을 수행하고, 모든 작업이 완료된 후에 실행됨. 즉 initLocalMedia(), connectSignalingServer(), startCall() 순으로 차례로 실행된다는 것
        await this.getNameInfo();
      },

  methods: {
      async initLocalMedia(){
          try{
              // navigator.mediaDevices.getUserMedia()는 브라우저에서 카메라와 마이크 권한을 요청하고, MediaStream객체를 호출하는 함수
              this.localStream = await navigator.mediaDevices.getUserMedia({video: true, audio:true})
              // localVideo에 내 스트림을 연결하는 부분으로 내 화면을 내 비디오에 바로 띄우는 작업
              this.$refs.localVideo.srcObject = this.localStream
          }catch(error){
              console.log('카메라.마이크 접근 실패',error)
          }
      },
    //  -->startCall()함수는 RTCPeerConnection을 생성하고, 내 스트림을 상대에게 연결하고, SDP Offer와 ICE Candidate를 signaling 서버로 보내서 연결을 성립하는 함수
      async startCall(){
        try{
           // 0. RTCPeerConnection객체의 생성자에 들어갈 옵션객체 정의로, key가 iceServers고 value가 배열인 형태임
        // iceServers는 NAT뒤에 있는 클라이언트끼리 연결 할 수 있도록 STUN/TURN서버 정보를 넣어줌. 구글의 STUN서버 사용
        const configuration ={
          iceServers:[
            {urls: 'stun:stun.1.google.com.19302'}
          ]
        }

        // 1.RTCPeerConnection객체 생성
        this.peerConnection = new RTCPeerConnection(configuration)
        console.log("1피어커넥션객체생성")

        // 2. 내 MediaStream(내가 getUserMedia()로 가져온 객체로 비디오트랙과 오디오트랙이 담겨있음)을 피어커넥션 객체에 추가
        this.localStream.getTracks().forEach(
          track =>{
            this.peerConnection.addTrack(track,this.localStream)
          }
        )
        console.log("2내미디어스트림추가")

        // 3.상대방으로부터 미디어 트랙을 받으면 remoteVideo에 연결(peerConnection.ontrack은 상대방이 addTrack()으로 트랙을 전송했을때 브라우저가 자동으로 발생시키는 이벤트)
      //  event.streams는 MediaStream객체들의 배열
        this.peerConnection.ontrack = (event) =>{
          console.log('상대방 스트림 수신')
          this.$refs.remoteVideo.srcObject = event.streams[0]
          this.isWaiting = false;//상대방 스트림 수신시 상대방이 채팅에 들어왔다는 거니까 기다리는 상태를 false로 변경
          console.log("상대방 스트림 수신 후 기다리는 상태",this.isWaiting)
        }

        // 연결 상태 변경 감지 - 상대방이 연결을 끊었을 때 감지
        this.peerConnection.oniceconnectionstatechange = () => {
          console.log("ICE 연결 상태 변경:", this.peerConnection.iceConnectionState);
          if (this.peerConnection.iceConnectionState === 'disconnected' || 
              this.peerConnection.iceConnectionState === 'closed' || 
              this.peerConnection.iceConnectionState === 'failed') {
            this.peerClosed = true;
            console.log("상대방이 연결을 종료했습니다");
          }
        };

        // 4.ICE Candidate가 생성될 때마다 시그널링 서버로 보낸다(ice후보는 한번에 생성되는게 아니라 그때그때마다 찾아짐 찾아질때마다 상대방(여기선 this.loginId)에게 보내줘야함)
        // this.peerConnection.onicecandidate는 ICE후보가 생성될때마다 호출되는 이벤트 핸들러
        this.peerConnection.onicecandidate = (event) => {
       
            if(event.candidate && this.signalingServer.readyState === WebSocket.OPEN){

            this.signalingServer.send(JSON.stringify({
              type:'candidate',
              candidate: event.candidate,
              to:this.loginId,
              from:this.myId
            }))
            
          } else{
            console.log("시그널링 서버 연결 대기중")
          }

          
        }
        // 5.SDP Offer생성후 전송(상대방은 이걸 받고 Answer을 만들어야 연결이 성립된다)
        const offer = await this.peerConnection.createOffer()
        await this.peerConnection.setLocalDescription(offer)

        this.signalingServer.send(JSON.stringify(
          {
            type: 'offer',
            offer: offer,
            from: this.myId,
            to: this.loginId
         //여기서 to~는 반드시 있어야함 백엔드에서 이 메시지의 'to'키값을 찾아서 상대방 로그인아이디를 찾도록 로직처리했으므로
          }))

        }catch(error){
          console.error('startCall 오류:', error)
        }
      },
       //////// startCall()끝
      async connectSignalingServer(){
        // 1.시그널링 서버 연결(백엔드에서 맞춰놓은 쿼리파라미터 형식으로)
        this.signalingServer = new WebSocket(`ws://localhost:8080/chat-service/signal?userId=${this.myId}`) //WebSocketConfig에 설정한 url과 경로 맞추어야함

        //2. 연결이 열리면 실행
        this.signalingServer.onopen = () =>{
          console.log('시그널링 서버 연결 성공')
        }

        //3. 상대방이 보내온 메시지를 처리하는 부분
        this.signalingServer.onmessage = async(message) => {
          const data = JSON.parse(message.data)

          if(data.type === 'answer'){
            this.handleAnswer(data)
          } else if(data.type === 'candidate') {
            // 상대방이 알려준 후보군을 내 peerConnection에 등록
            const candidate = new RTCIceCandidate(data.candidate)
            await this.peerConnection.addIceCandidate(candidate)
          } else if(data.type === 'offer'){
           try{
            //   // this.startCall()은 내가 offer를 생성해서 보내는 거고 여기서는 상대방의 offer를 받아서 answer를 보내는 로직이 들어가야하는 것
            //  1.내 RTCpeerConnection객체 생성
              const configuration ={
                iceServers:[{urls : 'stun:stun.1.google.com.19302'}]
              };
              this.peerConnection = new RTCPeerConnection(configuration);
            // 2. 내 localStream을 트랙에 추가
              this.localStream.getTracks().forEach(track => {
                this.peerConnection.addTrack(track,this.localStream);
              });
              // 3.상대방의 트랙 수신 처리
              this.peerConnection.ontrack = (event) => {
                console.log('상대방 스트림 수신!');
                this.$refs.remoteVideo.srcObject = event.streams[0];
                this.isWaiting = false; // 상대방 스트림 수신 시 대기 상태 해제
              };

              // 연결 상태 변경 감지
              this.peerConnection.oniceconnectionstatechange = () => {
                console.log("ICE 연결 상태 변경:", this.peerConnection.iceConnectionState);
                if (this.peerConnection.iceConnectionState === 'disconnected' || 
                    this.peerConnection.iceConnectionState === 'closed' || 
                    this.peerConnection.iceConnectionState === 'failed') {
                  this.peerClosed = true;
                  console.log("상대방이 연결을 종료했습니다");
                }
              };
              
              // 4. ICE 후보 발견시 전송
              this.peerConnection.onicecandidate = (event) => {
                if(event.candidate){
                  this.signalingServer.send(JSON.stringify({
                    type: 'candidate',
                    candidate: event.candidate,
                    to: this.loginId,
                    from: this.myId
                  }));
                }
              };
              // 5.상대 offer 등록
              await this.peerConnection.setRemoteDescription(new RTCSessionDescription(data.offer));

              // 6.answer생성 및 전송
              const answer = await this.peerConnection.createAnswer();
              await this.peerConnection.setLocalDescription(answer);
              this. signalingServer.send(JSON.stringify({
                type: 'answer',
                answer: answer,
                to: this.loginId,
                from: this.myId
              }));

              console.log('answer전송완료');
            } catch(error){
              console.log('offer처리 중 오류 발생', error)
            }
            } else if(data.type === 'leave') {
              // 상대방이 통화를 종료했음을 알리는 메시지
              this.peerClosed = true;
              console.log("상대방이 통화를 종료했습니다");
            }
        }
        //4. 연결 종료 또는 오류 핸들링도 추가 가능
        this.signalingServer.onerror = (error) => {
          console.error('시그널링 서버 오류:', error)
        }
      },
          //////
        async handleAnswer(data){
        try{
          console.log('받은 answer :',data.answer)
        // 상대방이 보낸 answer데이터를 RTCSessionDescription 객체로 변환
          const remoteDesc = new RTCSessionDescription(data.answer)
        // 이 answer를 peerConnection의 원격 설명으로 설정해서 연결 정보를 등록
          await this.peerConnection.setRemoteDescription(remoteDesc)
          console.log('상대방의 answer를 성공적으로 등록')
        }catch(error){
          console.error('answer등록 실패')
        }
      },
      //상대방 로그인 아이디 주고 상대방 이름과 닉네임 받아오는 함수
      async getNameInfo(){
        const OpponentId = {opponentId: this.loginId}
        try{
          const response = await axios.post(`${process.env.VUE_APP_API_BASE_URL}/user-service/silverpotion/user/whatisyourname`, OpponentId);
          console.log("이름정보", response)
          this.userName = response.data.result[0]
          this.userNickname = response.data.result[1]
        }catch(error){
          console.error('이름 정보 조회 실패', error)
        }
      },
      //마이크 켜고 끄는 함수
      toggleMicrophone(){
        this.localStream.getAudioTracks().forEach(track => track.enabled = !track.enabled)
      },
      //카메라 켜고 끄는 함수
      toggleCamera(){ 
        this.localStream.getVideoTracks().forEach(track => track.enabled = !track.enabled)
      },
      //통화 종료 함수
      endCall(){
        if (confirm('통화를 종료하시겠습니까?')) {
          // 상대방에게 통화 종료 메시지 전송
          if (this.signalingServer && this.signalingServer.readyState === WebSocket.OPEN) {
            this.signalingServer.send(JSON.stringify({
              type: 'leave',
              from: this.myId,
              to: this.loginId
            }));
          }
          
          this.peerConnection?.close();
          this.signalingServer?.close();
          //바로 전화면으로 돌아가는 것
          this.$router.go(-1);
        }
      },
      // 상대방이 통화를 종료했을 때 나가기 버튼
      closeAndReturn() {
        this.peerConnection?.close();
        this.signalingServer?.close();
        this.$router.go(-1);
      }
  
  }
}
</script>

<style scoped>
/* 공통 스타일 */
.chat-container {
  width: 100%;
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: #f5f5f5;
  overflow: hidden;
  font-family: 'Noto Sans KR', sans-serif;
}

video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 8px;
}

/* 버전 3: 미니멀리스트 디자인 */
.video-chat-minimal {
  display: flex;
  flex-direction: column;
  height: 100%;
  background: #fff;
}

.videos-container {
  position: relative;
  flex: 1;
}

.remote-video-container {
  width: 100%;
  height: 100%;
}

.local-video-container {
  position: absolute;
  width: 180px;
  height: 135px;
  bottom: 20px;
  right: 20px;
  border-radius: 6px;
  overflow: hidden;
  box-shadow: 0 2px 10px rgba(0,0,0,0.2);
}

.floating-name {
  position: absolute;
  bottom: 15px;
  left: 15px;
  background: white;
  color: #333;
  padding: 5px 15px;
  border-radius: 30px;
  font-size: 15px;
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}

.floating-name.local {
  bottom: 5px;
  left: 5px;
  font-size: 12px;
  padding: 2px 8px;
}

.minimal-controls {
  display: flex;
  justify-content: center;
  padding: 15px 0;
  gap: 30px;
  background: white;
  border-top: 1px solid #eee;
}

.icon-btn {
  background: none;
  border: none;
  font-size: 20px;
  color: #555;
  cursor: pointer;
  padding: 10px;
  border-radius: 50%;
  transition: all 0.2s;
}

.icon-btn:hover {
  background: #f5f5f5;
}

.icon-btn.end {
  color: #e74c3c;
}

.icon-btn.end:hover {
  background: #fee;
}


.waiting-message{
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(255, 255, 255, 0.95);
  color: #333;
  padding: 15px 30px;
  border-radius: 20px;
  font-size: 16px;
  font-weight: 500;
  box-shadow: 0 4px 15px rgba(0,0,0,0.15);
  z-index: 2;
  backdrop-filter: blur(5px);
  border: 1px solid rgba(0,0,0,0.05);
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0% {
    box-shadow: 0 4px 15px rgba(0,0,0,0.15);
  }
  50% {
    box-shadow: 0 4px 20px rgba(0,0,0,0.25);
  }
  100% {
    box-shadow: 0 4px 15px rgba(0,0,0,0.15);
  }
}

.call-ended-message {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.7);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 10;
  backdrop-filter: blur(5px);
}

.message-content {
  background: white;
  padding: 30px;
  border-radius: 15px;
  text-align: center;
  box-shadow: 0 5px 30px rgba(0,0,0,0.2);
  max-width: 400px;
  width: 80%;
}

.message-icon {
  font-size: 36px;
  color: #e74c3c;
  margin-bottom: 15px;
}

.message-content p {
  margin: 15px 0;
  font-size: 18px;
  color: #333;
}

.close-btn {
  background: #3498db;
  color: white;
  border: none;
  padding: 10px 20px;
  font-size: 16px;
  border-radius: 30px;
  cursor: pointer;
  margin-top: 10px;
  transition: all 0.3s;
}

.close-btn:hover {
  background: #2980b9;
  transform: translateY(-2px);
  box-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
</style>
728x90