2026.09.14 · frontend · 17 min

WebRTC 카메라 월 안정화기: 첫 프레임, 재연결, 좀비 세션 그리고 0fps

요즘 로봇 관제 시스템을 만들고 있다.

로봇마다 카메라가 여러 대 달려 있고, 새로 만든 관제 화면에서는 이 영상들을 한 화면에 그리드 형태로 띄워 놓고 본다. 이 화면을 카메라 월(Camera Wall)이라고 부른다.

처음엔 별로 어려울 게 없어 보였다. 카메라 영상을 받아서 타일마다 붙여주면 되는 거 아닌가 단순하게 생각했고, 실제로 두세 대 정도 띄울 때까지는 딱히 문제가 없었다. 그런데 카메라 수가 늘어나기 시작하니까 이상한 것들이 하나씩 보이기 시작했다.

로봇이 이미 꺼졌는데 타일은 계속 연결 중이라고 떠 있고, 영상을 숨겼다 띄웠다를 반복하면 브라우저가 점점 버벅였다. 가장 문제였던 건 영상이 완전히 멈춰 있는데도 타일에는 버젓이 LIVE가 떠 있는 경우였다. 관제 화면이다 보니 마지막 문제가 제일 신경 쓰였다. 차라리 화면이 안 나오는 게 낫지, 멈춰 있는 화면을 정상 영상처럼 보여 주는 건 이야기가 다르다.

그래서 하나씩 뜯어보기 시작했다.

이 글은 WebRTC 사용법을 설명하는 글이라기보다는, 실제 관제 화면에서 WebRTC를 여러 개 돌리면서 겪었던 문제와 그걸 어떻게 좁혀 갔는지를 정리한 기록에 가깝다.

WebRTC는 전화에 가깝다

브라우저에서 영상을 보는 방법은 여러 가지가 있지만, 뭉뚱그려서 두 가지로 생각할 수 있다. 유튜브처럼 영상을 어느 정도 받아 놓고 재생하는 방식이 있고, 전화처럼 상대와 연결해서 들어오는 데이터를 최대한 실시간에 가깝게 받는 방식이 있다. WebRTC는 후자에 가깝다.

일반적인 동영상 서비스는 어느 정도의 버퍼를 확보할 수 있다. 영상을 조금 미리 받아 놓으면 네트워크가 잠깐 흔들려도 사용자 입장에서는 영상이 계속 자연스럽게 재생될 수 있다. 대신 실시간으로 발생한 영상과 실제 화면에 보이는 영상 사이에는 어느 정도 지연이 생긴다. 영상의 재생 안정성과 지연 시간 사이에서 트레이드오프가 있는 셈이다.

로봇 관제에서는 이 지연이 꽤 중요하다. 특히 사람이 영상을 보면서 로봇을 조작하는 상황이라면, 로봇이 지금 보고 있는 화면이 몇 초 뒤에 도착해서는 곤란하다. 그래서 우리는 영상 전송에 WebRTC를 사용한다. 낮은 지연으로 실시간에 가까운 영상을 보내는 데 적합하기 때문이다.

전체 구조

구조는 아래처럼 되어 있다.

그림 1. Robot → Media Server → Browser
그림 1. Robot → Media Server → Browser

로봇의 카메라가 영상을 미디어 서버로 보내고, 브라우저는 다시 미디어 서버에서 영상을 받아 간다. 중요한 건 브라우저가 로봇 카메라에 직접 붙는 구조가 아니라는 것이다. 가운데 미디어 서버가 있고, 이게 뒤에서 세션 정리 문제를 볼 때 조금 중요해진다.

WebRTC 연결을 시작하려면 먼저 서로 어떤 방식으로 통신할지를 맞춰야 한다. 전화로 비유하면 통화하기 전에,

나는 이런 코덱을 쓸 수 있고, 이런 방식으로 데이터를 받을 수 있어.

같은 정보를 먼저 주고받는 과정이다. WebRTC에서는 이 정보가 SDP에 들어간다. 먼저 보내는 쪽의 SDP가 offer, 상대가 돌려주는 게 answer다. 그런데 WebRTC는 이 offer와 answer를 어떻게 상대에게 전달할지까지 정해 주지는 않는다. 이 부분은 별도의 시그널링 방식이 필요하다.

우리는 여기에 WHEP을 사용하고 있다. 브라우저에서 SDP offer를 HTTP로 보내고, 미디어 서버가 SDP answer를 HTTP 응답으로 돌려준다. 대략 이런 흐름이다.

그림 2. WHEP 세션 생성과 종료
그림 2. WHEP 세션 생성과 종료

브라우저에서는 RTCPeerConnection을 만들고 offer를 만든다. 그 offer를 POST /whep으로 보내면 서버가 answer를 내려 주고, 브라우저는 그 answer를 setRemoteDescription()에 넣는다. 여기까지는 비교적 익숙한 WebRTC 연결 과정이다.

그런데 WHEP에서는 하나 더 챙겨야 할 게 있었다. 서버가 응답할 때 Location 헤더로 현재 만들어진 세션의 주소를 내려 준다. 예를 들면 이런 식이다.

Location: /session/abc123

이 주소는 나중에 연결을 끝낼 때 다시 필요하다.

DELETE /session/abc123

이 요청을 보내 서버 쪽 세션도 함께 정리한다.

처음 구현할 때는 이걸 빼먹었다. 그때는 별문제가 없어 보였는데, 나중에 카메라를 붙였다 뗐다 반복하면서 이게 꽤 골치 아픈 문제가 됐다.

처음에는 별문제가 없어 보였다

처음 구조는 정말 단순했다.

타일 하나에 WebRTC 연결 하나.

카메라가 추가되면 연결하고, 타일이 없어지면 연결을 끊는다. 카메라가 두세 개일 때는 문제를 거의 느끼지 못했다. 그런데 개수가 늘면서 세 가지 문제가 같이 보이기 시작했다.

첫 번째는 브라우저가 점점 무거워지는 문제였다. 두 번째는 로봇이 이미 오프라인인데도 해당 타일이 계속 연결 중이라고 표시되는 문제였다. 세 번째는 영상이 멈췄는데도 LIVE라고 표시되는 문제였다.

특히 세 번째가 제일 찜찜했다. 관제 화면에서 멈춰 있는 화면이 살아 있는 것처럼 보이는 건 아예 화면이 안 나오는 것보다 오히려 더 위험할 수 있다. 그래서 LIVE 상태부터 다시 보기 시작했다.

검은 화면인데 LIVE

당시 LIVE를 판단하는 조건은 단순했다. mediaStream이 있으면 LIVE였다. 지금 보면 좀 이상한 기준인데, 처음 구현했을 때는 크게 의심하지 않았다.

문제는 MediaStream 객체가 존재한다는 것과 실제로 <video>에 영상이 나오고 있다는 게 전혀 같은 이야기가 아니라는 점이었다.

그림 3. Connection ≠ Stream ≠ Frame ≠ LIVE
그림 3. Connection ≠ Stream ≠ Frame ≠ LIVE

WebRTC 연결이 만들어질 수 있다. 그 안에 MediaStream도 있을 수 있고, Video Track도 존재할 수 있다. 그렇다고 해서 지금 사용자가 보는 <video>에 새 프레임이 계속 그려지고 있다는 뜻은 아니다.

이 차이가 실제로 문제를 만들었다. 로봇 쪽에서 영상 송출을 늦게 시작하면 스트림 객체는 먼저 생겼는데 화면은 계속 검은 상태일 수 있었다. 그런데 기존 조건으로는 이미 LIVE였다. 반대로 연결이 끊긴 뒤에도 객체가 남아 있으면 마지막 프레임이 화면에 얼어붙은 채로 LIVE가 유지되는 경우도 있었다.

결국 우리가 알고 싶은 건 스트림 객체가 있느냐가 아니라, 지금 실제 프레임이 화면에 그려지고 있느냐였다.

브라우저에는 이걸 확인할 수 있는 requestVideoFrameCallback()이 있다. <video>가 새 프레임을 처리할 때 콜백을 받을 수 있어서 첫 프레임을 확인하는 기준으로 사용했고, 지원하지 않는 환경을 위한 fallback으로 loadeddata도 함께 사용했다. 그래서 WebRTC 연결 상태와는 따로, 첫 프레임을 기다리는 함수를 하나 만들었다.

export const watchFirstFrame = (video, onReady) => {
  let done = false

  function cleanup() {
    done = true
    video.removeEventListener('loadeddata', mark)
    if (handle != null) {
      video.cancelVideoFrameCallback?.(handle)
    }
  }

  function mark() {
    if (done) return
    cleanup()
    onReady()
  }

  const handle = video.requestVideoFrameCallback?.(mark)
  video.addEventListener('loadeddata', mark)

  return cleanup
}

스트림을 <video>에 붙였다고 바로 LIVE로 바꾸지 않는다. 첫 프레임이 확인되고 나서야 LIVE로 바꾼다. 새로운 스트림으로 교체되면 다시 첫 프레임부터 기다리고, 트랙에서 ended 이벤트가 오면 기존 미디어 상태도 비워 준다.

이렇게 하고 나니까 적어도

연결은 이미 끊겼는데 마지막 화면만 얼어붙은 상태로 LIVE가 남아 있다.

같은 상황은 없어졌다.

여기서 작은 문제가 하나 더 있었다. 처음에는 스트림이 없어지면 전부 재연결 중이라고 표시했다. 그런데 생각해 보면 처음부터 영상이 한 번도 나온 적이 없는 타일과, 잘 나오다가 끊긴 타일은 의미가 다르다. 한 번도 프레임이 나온 적이 없다면 그냥 연결 중이고, 실제 영상을 보여 주다가 끊겼다면 그때는 재연결 중이 맞다.

그래서 타일마다 이전에 실제 프레임을 보여 준 적이 있는지도 따로 기억하도록 했다. 별거 아닌 차이처럼 보이는데 UI에서는 꽤 크게 느껴졌다.

꺼져 있는 로봇한테 계속 전화하고 있었다

다음 문제는 오프라인 상태였다. 로봇 전원을 껐는데 카메라 타일에서는 계속 스피너가 돌면서 연결 중이라고 나왔다. 이건 두 가지 문제가 섞여 있었다.

하나는 단순히 표시 문제였다. 사실 프론트는 로봇이 오프라인이라는 걸 이미 알고 있었다. 로봇에서 1초마다 MQTT로 하트비트를 보내고 있었고, 일정 시간 하트비트가 들어오지 않으면 해당 로봇을 오프라인으로 판단하고 있었다. 그런데 카메라 타일에서는 그 정보를 전혀 사용하지 않았다. 카메라 입장에서는 그냥,

스트림이 없네? 그럼 아직 연결 중인가 보다.

하고 있었던 셈이다.

다른 하나는 재시도였다. 당시에는 영상 연결이 실패하면 2초 뒤에 다시 연결했다.

또 실패하면 다시 2초.

계속 반복했다.

로봇이 꺼져 있든, 영상 송출이 죽어 있든, 똑같이 2초마다 재시도했다. 로봇 열 대가 동시에 꺼졌다고 생각하면 브라우저가 계속 의미 없는 연결 요청을 만들고 있는 셈이다.

처음에는 자연스럽게 지수 백오프를 생각했다. 2초 → 4초 → 8초 → 16초 → 30초. 그런데 이것만 적용하니 또 애매했다. 백오프가 이미 30초까지 늘어난 상태에서 로봇이 다시 켜지면, 운이 나쁘면 영상을 다시 보기까지 30초 가까이 기다려야 한다.

그러다 생각해 보니 좀 이상했다. 로봇이 다시 켜졌는지를 영상 연결로 추측할 필요가 없었다. 이미 하트비트가 있었기 때문이다. 그래서 재연결 전략을 나눴다.

그림 4. Robot Online + Backoff 재연결 흐름
그림 4. Robot Online + Backoff 재연결 흐름

로봇이 오프라인이면 영상 재연결 자체를 중단한다. 없는 상대한테 계속 전화하지 않는 셈이다. 그러다가 MQTT 하트비트가 다시 들어오면서 로봇이 온라인으로 바뀌면, 그때는 현재 백오프가 몇 초까지 늘어났는지와 상관없이 바로 연결한다.

반대로 로봇 자체는 온라인인데 영상만 연결되지 않는 경우에는 백오프를 적용한다. 2초부터 시작해서 두 배씩 늘리고 최대 30초까지 기다린다. 여러 카메라가 동시에 재접속하지 않도록 각 시도에는 약 ±20% 정도의 jitter도 섞었고, 연결이 성공하면 백오프 값은 다시 초기화한다.

이렇게 바꾸고 나니 로봇을 껐다가 다시 켰을 때 영상이 돌아오는 시간이 백오프 단계에 끌려다니지 않게 됐다. 거의 하트비트를 다시 감지하는 시간만 기다리면 된다. 그리고 자동 재연결만 믿도록 만들기도 싫어서, 재연결 중인 타일에는 지금 다시 연결 버튼도 하나 뒀다.

브라우저에서는 끊었는데 서버에는 세션이 남아 있었다

앞에서 Location 주소를 기억해야 한다고 했던 이유가 여기서 나온다.

타일이 사라지면 브라우저에서는 pc.close()를 호출했다. 당연히 처음에는 이걸로 연결이 끝난 줄 알았다. 그런데 우리가 사용하던 미디어 서버 쪽에서는 브라우저가 pc.close()했다고 해서 서버 세션이 바로 없어지는 건 아니었다. 세션이 타임아웃될 때까지 남아 있는 경우가 있었고, 카메라를 붙였다 지웠다 반복하다 보니 사용하지 않는 세션들이 쌓이기 시작했다.

그래서 WHEP 연결을 만들 때 받은 Location을 저장해 두고, 연결을 정리할 때 해당 주소로 DELETE를 보내도록 바꿨다.

DELETE /session/abc123

이 DELETE는 best-effort로 처리했다. 이미 서버에서 세션이 없어졌거나 요청이 실패한다고 해서 프론트 정리가 실패하면 안 되기 때문이다.

그런데 이것만 넣고 끝난 것도 아니었다. 재연결 과정에도 구멍이 있었다. start()를 다시 호출하면서 새 WHEP 세션을 열기 전에 이전 세션을 제대로 정리하지 않는 경로가 있었다. 또 요청을 보낸 뒤 응답을 기다리는 사이에 타일이 언마운트되는 경우도 있었다. 이 경우 응답은 뒤늦게 돌아오는데, 정작 그 세션을 사용할 타일은 이미 없다. 그러면 새로 만들어진 세션이 그대로 서버에 남을 수 있었다.

결국 세션을 버리는 로직을 한 군데로 모았다. 정상 종료든, 재연결을 위한 재진입이든, 응답이 늦게 도착했는데 이미 필요 없어졌든 결국 같은 정리 경로를 타도록 했다.

브라우저 쪽에도 비슷한 문제가 하나 있었다. 언마운트할 때 reader만 정리하고 미디어 트랙의 track.stop()을 호출하지 않는 경로가 있었다. 다른 종료 경로에서는 전부 하고 있었는데 언마운트에서만 빠져 있었다. 카메라를 추가했다 지웠다 반복하면 점점 버벅인다는 현상의 직접적인 원인 중 하나였다.

이런 건 코드를 보고 있으면 사소해 보이는데, 실제로는 종료 경로가 하나만 빠져도 시간이 지나면서 티가 난다.

상태 판단은 한 군데에서만 하기로 했다

이쯤 되니 타일 하나가 가질 수 있는 상태도 꽤 많아졌다.

  • LIVE
  • 처음 연결 중
  • 재연결 중
  • 연결이 오래 지연되는 상태
  • 로봇 신호가 불안정한 상태
  • 오프라인

문제는 카메라 월 화면이 하나가 아니었다. 전체 관제 화면에도 카메라 타일이 있고, 로봇 한 대만 보는 단일 관제 화면에도 비슷한 타일이 있었다. 처음에는 각 화면에서 if문으로 상태를 판단했다. 그랬더니 당연하게도 조금씩 달라지기 시작했다. 한 화면에서는 신호 불안정이 먼저 나오고, 다른 화면에서는 재연결 중이 먼저 나왔다. 실제로 신호 불안정 상태가 재연결 중을 가리면서 사용자가 눌러야 할 재연결 버튼이 사라지는 경우도 있었다.

그래서 상태 판정 자체를 순수 함수 하나로 모았다. 대략 이런 형태다.

export const resolveTileStatus = ({
  connection,
  hasStream,
  everStreamed,
  waitingMs = 0,
}) => {
  if (connection === 'offline') return 'offline'
  if (hasStream) return 'live'
  if (waitingMs >= CONNECT_DELAYED_MS) return 'delayed'
  if (everStreamed) return 'reconnecting'
  if (connection === 'unstable') return 'unstable'
  return 'connecting'
}

실제 코드에서 hasStream은 단순히 MediaStream 객체가 존재한다는 뜻으로 사용하면 안 된다. 앞에서 이야기한 것처럼 실제 첫 프레임까지 확인된 상태와 연결되어 있어야 한다.

이 함수에서 중요한 건 조건 자체보다는 순서가 곧 우선순위라는 점이었다. 로봇이 오프라인이라면 나머지는 볼 필요가 없다. 실제 영상이 정상적으로 나오고 있다면 LIVE다. 그다음으로 연결 지연이나 재연결처럼 사용자가 알아야 하거나 직접 대응할 수 있는 상태를 보여 주고, 신호 불안정처럼 참고용 정보는 그보다 뒤에 둔다.

그리고 전체 관제와 단일 관제 모두 이 함수를 사용하게 했다. 적어도 이제 두 화면에서 같은 카메라를 보고 있는데 서로 다른 상태를 보여 줄 일은 없어졌다. 상태 우선순위도 테스트로 고정했다.

UI 쪽 규칙도 같이 단순하게 잡았다. 상태가 변한다고 타일 레이아웃 자체를 흔들지는 않는다. 기본 골격은 그대로 두고 배지와 안내 문구만 바꾼다. 색도 정상 상태에서 이것저것 쓰지 않고, 사용자가 봐야 할 이상 상태에만 사용했다. 관제 화면에서는 모든 게 강조되어 있으면 결국 아무것도 강조되지 않기 때문이다.

0fps 미스터리

여기까지 하고 이제 어느 정도 정리됐다고 생각했다. 그런데 또 하나가 나왔다.

연결 상태도 정상이고 로봇도 온라인인데 영상만 갑자기 멈췄다. 몇 초 동안 그대로 멈춰 있다가 화면이 한번 깜빡이고 다시 살아났다.

깜빡이는 이유 자체는 알고 있었다. 앞에서 만든 감시 로직 때문에 4초 동안 새 프레임이 그려지지 않으면 현재 WebRTC 연결을 끊고 다시 연결하도록 해 뒀기 때문이다. 그러니까 복구는 되고 있었다. 문제는 왜 멈추는지를 몰랐다.

처음에는 당연히 네트워크를 의심했다. 무선 구간도 있고 로봇도 움직이니까 패킷이 빠지는 것 아닐까 싶었다. 그런데 추측만 해서는 계속 네트워크 탓만 하게 될 것 같아서 브라우저의 WebRTC 통계를 보기 시작했다.

RTCPeerConnection.getStats()를 이용하면 수신 스트림에 대한 여러 통계를 볼 수 있다. 여기서 특히 세 값을 봤다.

  • packetsReceived
  • framesReceived
  • framesDecoded

택배로 생각하면 대충 이런 느낌이다. 패킷이 들어왔는지 보고, 그 패킷들로 영상 프레임이 만들어졌는지 보고, 만들어진 프레임을 실제로 디코더가 풀었는지를 보는 것이다.

그래서 영상이 멈춰서 재연결하기 직전에 이 값들을 로그로 남기기 시작했다. 대략 이렇게 범위를 나눠 볼 수 있었다.

관측 우선 의심할 범위
패킷부터 증가하지 않음 송출, 세션, 네트워크
패킷은 오는데 프레임이 증가하지 않음 패킷 손실, 프레임 조립 과정
프레임은 증가하는데 디코드가 멈춤 스트림 상태, 코덱, 디코더

로그를 찍어 보니 예상과 조금 달랐다. 영상이 멈춘 동안에도 packetsReceived는 계속 증가하고 있었다. framesReceived도 계속 증가했다. 그런데 framesDecoded만 더 이상 증가하지 않았다.

그림 5. Packet → Frame → Decode → Render
그림 5. Packet → Frame → Decode → Render

즉 패킷 자체는 브라우저까지 들어오고 있었고, 프레임 조립도 되고 있었다. 그런데 그다음 단계인 디코드가 진행되지 않았다. 그래서 최종적으로 <video>에서는 새 프레임이 그려지지 않았고 0fps 상태가 됐다.

이 시점에서 적어도 한 가지는 말할 수 있었다. 무조건 네트워크 문제라고 볼 수는 없었다. 브라우저까지 데이터가 들어오고 있었기 때문이다.

그리고 여기까지가 프론트에서 확인할 수 있는 사실이었다.

패킷은 온다. 프레임도 만들어진다. 디코드만 멈춘다. 그리고 WebRTC 연결을 새로 만들면 바로 다시 살아난다.

여기서부터는 가설이다. 가장 의심했던 건 로봇 쪽 카메라 송출 프로세스가 중간에 재시작되는 경우였다. 송출 프로세스가 다시 뜨면서 스트림을 구성하는 정보가 달라지고, 기존 연결의 디코더가 그 변화를 제대로 따라가지 못한 게 아닐까 생각했다. 새로 WebRTC 연결을 만들면 다시 정상적으로 보인다는 것도 이 설명과는 잘 맞았다.

다만 이건 브라우저 로그만 보고 증명할 수 있는 내용은 아니다. 카메라 송출 프로세스가 실제로 재시작됐는지까지 프론트에서는 알 수 없다. 그래서 스톨이 발생했을 때 SSRC, 코덱, 해상도 같은 정보도 같이 로그로 남기도록 했다. 재연결 전후에 값이 달라지는지 확인하고, 나중에는 같은 시간대의 서버나 송출 쪽 로그와 맞춰 볼 수 있도록 하기 위해서다.

이 문제는 아직 완전히 결론이 나지는 않았다. 그래도 예전과는 차이가 있다. 예전 같았으면 그냥

네트워크가 좀 불안정한 것 같은데요.

정도로 끝났을 문제를 이제는

패킷과 프레임은 브라우저까지 정상적으로 들어오는데 디코드가 멈췄고, 재연결하면 복구된다.

정도까지는 확실하게 전달할 수 있다. 프론트에서 원인을 전부 해결하지 못하더라도, 어디까지 확인했고 다음 사람이 무엇을 확인해야 하는지를 남기는 것은 중요하다고 생각한다.

남겨둔 숙제

브라우저의 영상 디코딩 자원은 무한하지 않다. 해상도나 FPS, 코덱, 하드웨어 가속 여부에 따라 차이는 있지만 동시에 디코딩하는 영상 수가 늘어나면 CPU/GPU와 메모리 부담도 같이 커진다. 이건 프론트 코드 몇 줄 최적화한다고 전부 해결되는 문제는 아니다.

카메라가 훨씬 많아진다면 화면 밖에 있는 타일은 연결을 끊거나, 동시에 활성화할 수 있는 스트림 수에 상한을 두는 것도 필요하다. 전체 관제에서는 저화질 서브스트림이나 스냅샷을 보여 주고, 사용자가 특정 카메라를 선택했을 때만 고화질 스트림으로 바꾸는 식의 구조도 생각할 수 있다.

지금은 실제 운용하는 로봇 수가 아직 많지 않아서 여기까지는 하지 않았다. 필요해지는 시점에 서버 쪽과 함께 고도화할 생각이다.

정리

처음 만들었을 때와 지금을 비교하면 대략 이렇게 바뀌었다.

LIVE 판정 스트림 객체가 있으면 첫 프레임을 실제로 확인한 뒤
재연결 2초마다 계속 재시도 로봇 상태를 먼저 보고, 영상만 실패할 때 백오프
오프라인 계속 연결 시도 하트비트가 돌아올 때까지 중단
세션 종료 pc.close() WHEP DELETE까지 함께 처리
브라우저 자원 정리 종료 경로마다 조금씩 다름 세션·트랙 정리 경로 통합
상태 판정 화면마다 별도 if문 하나의 함수에서 우선순위 결정
장애 분석 네트워크 문제 추정 getStats()로 패킷·프레임·디코드 단계 확인

이번에 제일 크게 느낀 건 관제 화면에서 보여 주는 상태를 추측으로 만들면 안 된다는 것이었다. LIVE라고 보여 주려면 실제 프레임이 그려졌다는 근거가 있어야 했고, 연결 중이라고 보여 주려면 적어도 로봇이 살아 있다는 근거가 있어야 했다. 재연결 중이라고 하려면 그전에 실제 영상이 나온 적이 있어야 했다.

결국 화면에 어떤 상태를 보여 줄지 정하는 일도 로그를 보는 일과 크게 다르지 않았다. 확인한 것만 보여 주는 게 제일 안전했다.

그리고 WHEP를 쓰면서 하나 더 배웠다. 브라우저에서 close()했다고 해서 서버까지 알아서 깔끔하게 끝난 건 아니었다.

전화를 걸었으면 끊을 때까지 챙겨야 했다.