본문 바로가기
websocket & webRTC

4. RTCPeerConnection 간 연결 과정 (offer, answer)

by spare8433 2025. 4. 28.

1. 시그널링 서버를 통한 PeerConnection

📕 기본적인 시그널링 서버 및 클라이언트 연결은 시그널링 서버 연결 및 미디어 장치 연결 포스트 참고

RTCPeerConnection 의 이해가 필요 시 RTCPeerConnection 이해와 관련된 주요 인터페이스 포스트 참고










2. 시그널링 과정

두 사용자 A, B 가 피어 연결을 시도하는 과정 예시

  • offer : offer는 연결을 시작하려는 피어(발신자)가 생성하는 SDP(Session Description Protocol) 정보
  • answer: answeroffer를 받은 피어(수신자)가 생성하는 SDP 정보입니다.



1️⃣ A와 B 방에 입장

  • A: 방에 입장 → 시그널링 서버에 연결
  • B: 뒤이어 입장 → 시그널링 서버에 연결됨

 

2️⃣ 서버가 A에게 B 입장 알림

  • 시그널링 서버가 A에게 "B가 입장했다"는 알림 전송

 

3️⃣ A 측

  • 새로 입장한 B 와 연결을 위한 RTCPeerConnection 생성
  • createOffer()setLocalDescription(offer) 이 시점에 ICE gathering(ICE Candidate 수집) 시작
  • offer를 시그널링 서버 통해 B에게 전송

 

4️⃣ B 측

  • 기존 사용자 A 와 연결을 위한 RTCPeerConnection 생성
  • 시그널링 서버를 통해 offer 전달 받음 ->setRemoteDescription(offer) 이 시점에 ICE gathering(ICE Candidate 수집) 시작
    • offer 수신 시 ICE candidate는 수집 중이거나 이미 수집된 상태일 수 있음 (하위에 서술)
  • createAnswer()setLocalDescription(answer) 이 시점에서도 역시 ICE gathering(ICE Candidate 수집) 시작
  • answer를 시그널링 서버 통해 A에게 전송

 

5️⃣ A 입장

  • 시그널링 서버를 통해 answer 전달 받음 -> setRemoteDescription(answer)






✅ offer 수신 시 ICE candidate는 수집 중이거나 이미 수집된 상태일 수 있다?

 

📢 결론

사용자와의 연결을 위해 시그널링 서버에서 offer 수신 하는 과정 이전에 미리 ICE candidate가 도착할 수 있습니다.




📖 설명

사용자 A는 연결을 위해 offer 를 생성하고 이후 setLocalDescription 를 통해 내용을 저장하고 offer 를 다른 사용자들에게 전달하는 이 과정을 디테일하게 두가지 과정으로 분리한다면

  • setLocalDescription 호출 하여 ICE candidate 수집을 시작하고 RTCPeerConnectiononicecandidate 이벤트를 통해 수집된 후보를 실시간으로 시그널링 서버를 거쳐 다른 사용자들에게 ICE candidate 를 전달(Trickle ICE 방식)합니다.
  • 시그널링 서버에 요청하여 다른 사용자들에게 offer 를 전달합니다.

 

위 두 과정은 수신자 입장에서 모두 비동기적으로 처리 되므로 사용자 B는 offer를 받기 전에 ICE 후보를 먼저 받을 수도 있습니다. 문제는, 아직 setRemoteDescription 를 통해 offer 를 저장하지 않은 상태에서 미리 전달 받은ICE candidateaddIceCandidate 로 등록하면 오류가 발생할 수 있다는 점입니다.



Trickle ICE란?
ICE candidate를 모두 모을 때까지 기다리지 않고, 발견되는 즉시 상대 피어에 전송하여 연결 설정을 빠르게 완료하는 기술입니다. 이를 통해 미디어 통신 시작 시간이 크게 단축됩니다.





📗 해결 방법

이를 방지하기 위해 일반적으로는 offer 수신 전까지 도착한 ICE 후보들을 임시로 큐에 저장해두고, setRemoteDescription이 완료된 뒤에 순차적으로 addIceCandidate를 호출하는 패턴을 주로 사용합니다. 이러한 처리 방식은 연결 설정 시간 단축연결 무결성 보장이라는 두 가지 측면 모두에서 매우 중요합니다.




관련 토의 링크










3. 구현 코드

※ 예시로 작성한 코드이며 일부 내용을 생략한 관계로 참고만 하시길 추천드립니다. ※




✅ 생략한 내용 및 추가할만한 부분

  • 3인 이상의 peer 연결 및 connection 관리에 필요한 로직 (아래 예시는 간단한 1 대 1 상황을 예시로 구현)
    • 클라이언트에서는 peer 의 고유한 id 를 부여, 시그널링 서버에서는 해당 id 를 기반으로 사용자 목록 사용자 및 device 상태 목록 관리
    • 클라이언트에서 다른 사용자들의 Stream 및 PeerConnection 목록 관리 (시그널링 서버에서 사용자 목록도 가져와 활용 가능)
  • 클라이언트에서 상위에 서술한 WebRTC의 ICE Candidate 처리 순서에 따른 에러 가능성에 따른 핸들링



3.1 클라이언트 코드

 

const VideoChat = () => {
  // 현재 내 audio, video track 이 포함된 stream
  const [myStream, setMyStream] = useState<MediaStream>(new MediaStream());
  // 다른 사용자와의 연결을 위한 peerconnection
  const peerConnection = useRef<RTCPeerConnection>(new RTCPeerConnection());

  // 입장 함수
  const enterRoom = async () => videoChatSocket.emit("enter_room");

  useEffect(() => {
    // 다른 사용자 입장시 실행되는 이벤트 핸들러 (offer 생성 및 전달 과정)
    // 시그널링 서버에서 다른 사용자 입장시 그외 사용자들에게 "user_joined" 이벤트를 생성하는 구조
    const onJoinUser = async (roomName: string) => {
      // 새로 입장한 사용자와의 PeerConnection 생성
      const newPC = new RTCPeerConnection();

      // 로컬 미디어 트랙 추가
      myStream.getTracks().forEach((track) => {
        newPC.addTrack(track, myStream);
      });

      const offer = await newPC.createOffer(); // Offer 생성
      await newPC.setLocalDescription(offer); // offer 저장
      peerConnection.current = newPC; // PeerConnection 저장

      // 시그널링 서버에 offer 전달
      videoChatSocket.emit("send_offer", roomName, offer);
    };

    // 서버에서 offer 를 전달 받을 때 실행되는 이벤트 핸들러
    const onReceiveOffer = async (roomName: string, offer: RTCSessionDescriptionInit) => {
      const newPC = new RTCPeerConnection(); // 기존 사용자와의 PeerConnection 생성
      await newPC.setRemoteDescription(offer); // 기존 사용자에게 전달받은 offer 저장

      const answer = await newPC.createAnswer(); // 기존 사용자에게 전달할 answer 생성
      await newPC.setLocalDescription(answer); // answer 저장
      peerConnection.current = newPC; // PeerConnection 저장

      // 시그널링 서버에 answer 전달
      videoChatSocket.emit("send_answer", roomName, answer);
    };

    // 기존 사용자가 새로 입장한 사용자에 answer 전달받는 경우에 이벤트 핸들러
    const onReceiveAnswer = async (answer: RTCSessionDescriptionInit) => {
      await peerConnection.current.setRemoteDescription(answer); // 새로 입장한 사용자에게 전달받은 answer 저장
    };

    // 이벤트 핸들러 등록
    videoChatSocket.on("user_joined", onJoinUser);
    videoChatSocket.on("receive_offer", onReceiveOffer);
    videoChatSocket.on("receive_answer", onReceiveAnswer);

    // 프로세스 종료 후 이벤트 정리
    return () => {
      videoChatSocket.off("user_joined", onJoinUser);
      videoChatSocket.off("receive_offer", onReceiveOffer);
      videoChatSocket.off("receive_answer", onReceiveAnswer);
    };
  }, [myStream]);
  return (
    <div>
      <button onClick={enterRoom}>입장</button>
    </div>
  );
};





3.2 서버 코드

// socket.io 서버 이벤트 리스너
socketServer.on("connection", (socket) => {
  console.log(`🔵Client connected video chat ws server: ${socket.id}`);

  // 입장
  socket.on("enter_room", () => {
    socket.join("videoChatRoom"); // "videoChatRoom" 라는 이름의 room 에 입장

    // 다른 사용자에게 "user_joined" 이벤트 발송
    socket.to("videoChatRoom").emit("user_joined");
  });

  // offer 전달
  socket.on("send_offer", (offer: RTCSessionDescriptionInit) => {
    // 다른 사용자에게 offer 와 함께 "receive_offer" 이벤트 발송
    socket.to("videoChatRoom").emit("receive_offer", offer);
  });

  // answer 전달
  socket.on("send_answer", (answer: RTCSessionDescriptionInit) => {
    // 다른 사용자에게 answer 와 함께 "receive_answer" 이벤트 발송
    socket.to("videoChatRoom").emit("receive_answer", answer);
  });

  // 연결 종료시 room 에서 퇴장
  socket.on("disconnecting", () => {
    socket.leave("videoChatRoom");
  });
});










참고

https://lng1982.tistory.com/491026
https://github.com/w3c/webrtc-pc/issues/2519
https://webrtc.org/getting-started/peer-connections?hl=ko#trickle_ice