본문 바로가기
websocket & webRTC

5. RTCPeerConnection 간 ICE candidate 교환 과정

by spare8433 2025. 4. 30.

1. ICE candidate 교환 과정

offeranswer 를 교환하는 과정에서 ICE candidate 생성되며 이후 시그널링 서버를 통해 교환 되어 실제 peer 간 연결이 완료됩니다.

 

이전 포스트와 이어집니다. RTCPeerConnection 간 연결 과정 (offer, answer)




참고할만한 내용

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

RTCPeerConnection 및 ICE candidate 관련 포스트: RTCPeerConnection 이해와 관련된 주요 인터페이스










2. ICE candidate 교환 과정

두 사용자 A, B 가 offer/answer 주고 받는 과정에서 ICE candidate 교환 시점과 흐름 예시



1️⃣ A 측

  • 새로 입장한 B 와 연결을 위한 RTCPeerConnection 생성
  • createOffer()setLocalDescription(offer)
    • 이 시점에 ICE gathering(ICE Candidate 수집) 시작
    • ICE Candidate 생성 되는 시점에 RTCPeerConnection.onicecandidate 이벤트가 비동기적으로 호출되며 이 과정에서 시그널링 서버를 통해 ICE Candidate 를 B 에게 전송
  • offer를 시그널링 서버 통해 B에게 전송



2️⃣ B 측

  • 기존 사용자 A 와 연결을 위한 RTCPeerConnection 생성
  • 시그널링 서버를 통해 offer 전달 받음 -> setRemoteDescription(offer) 이 시점에 ICE gathering(ICE Candidate 수집) 시작
    • offer 수신 시 ICE candidate는 수집 중이거나 이미 수집된 상태일 수 있음 (이전 포스트 참고)
    • 동시에 시그널링 서버를 통해 A의 ICE candidate 들도 비동기적으로 수신 후 addIceCandidate(candidate)를 통해 자신의 PeerConnection에 즉시 추가
  • createAnswer()setLocalDescription(answer)
    • 이 시점에 ICE gathering(ICE Candidate 수집) 시작
    • A 측과 마찬가지로 RTCPeerConnection.onicecandidate 이벤트가 비동기적으로 호출되며 이 과정에서 시그널링 서버를 통해 ICE Candidate 를 A 에게 전송
  • answer를 시그널링 서버 통해 A에게 전송



3️⃣ A 측

  • 시그널링 서버를 통해 answer 전달 받음 -> setRemoteDescription(answer)
    • B 측과 마찬가지로 동시에 시그널링 서버를 통해 B의 ICE candidate 들도 비동기적으로 수신 후 addIceCandidate(candidate)를 통해 자신의 PeerConnection에 즉시 추가










3. 구현 코드

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




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

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

 

클라이언트 코드

const VideoChat = () => {
  const [myStream, setMyStream] = useState<MediaStream>(new MediaStream());
  const [someoneStream, setSomeoneStream] = useState<MediaStream>(new MediaStream());

  const peerConnection = useRef<RTCPeerConnection>(new RTCPeerConnection());

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

  // 새로운 PeerConnection 생성 함수
  const createPeerConnection = useCallback(() => {
    const pc = new RTCPeerConnection(); // 새로운 RTCPeerConnection 객체 생성

    // 내 로컬 스트림의 모든 트랙(audio, video 등)을 PeerConnection에 추가
    myStream.getTracks().forEach((track) => {
      pc.addTrack(track, myStream);
    });

    // ICE 후보가 생성될 때마다 signaling 서버로 전송
    pc.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
      videoChatSocket.emit("send_ice", event.candidate);
    };

    // 상대방으로부터 media 트랙(audio, video)이 들어올 때 실행
    pc.ontrack = (event: RTCTrackEvent) => {
      // media stream 에 track 등록
      setSomeoneStream((prev) => {
        prev.addTrack(event.track);
        return prev;
      });
    };

    // 연결 상태가 변경될 때 실행되는 핸들러
    pc.onconnectionstatechange = () => {
      const state = pc.connectionState;

      // failed 된 경우 close 하여 메모리 내부 자원 정리, 메모리 누수 방지
      if (state === "failed") {
        peerConnection.current.close();
      }

      if (state === "closed") {
        setSomeoneStream(new MediaStream()); // 연결이 끊긴 peer 의 stream 정리
      }
    };

    return pc;
  }, [myStream]);

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

      // 로컬 미디어 트랙 추가
      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", offer);
    };

    // 서버에서 offer 를 전달 받을 때 실행되는 이벤트 핸들러
    const onReceiveOffer = async (offer: RTCSessionDescriptionInit) => {
      const newPC = createPeerConnection(); // 기존 사용자와의 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", answer);
    };

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

    // 기존 사용자가 새로 입장한 사용자에 ICE 후보를 전달받는 경우 처리하는 이벤트 핸들러
    const onReceiveICE = async (ice: RTCIceCandidate) => {
      await peerConnection.current.addIceCandidate(ice); // PeerConnection 에 ICE candidate 저장
    };

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

    // 프로세스 종료 후 이벤트 정리
    return () => {
      videoChatSocket.off("user_joined", onJoinUser);
      videoChatSocket.off("receive_offer", onReceiveOffer);
      videoChatSocket.off("receive_answer", onReceiveAnswer);
      videoChatSocket.off("receive_ice", onReceiveICE);
    };
  }, [createPeerConnection, myStream]);

  return (
    <div>
      <button onClick={enterRoom}>입장</button>
    </div>
  );
};





서버 코드

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);
  });

  // ice 전달
  socket.on("send_ice", (ice: RTCIceCandidate) => {
    console.log("send_ice");
    socket.to("roomName").emit("receive_ice", ice);
  });

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