라떼군 이야기


JavaScript에서 함수 스코프를 벗어난 WebSocket 객체가 가비지 컬렉션(GC)되지 않는 이유

Problem

자바스크립트 개발 중 특정 함수 내에서 지역 변수로 WebSocket 객체를 생성하고 이벤트 리스너를 등록하는 경우가 있습니다. 일반적인 자바스크립트의 스코프 규칙에 따르면, 함수 실행이 종료된 후 내부의 지역 변수는 참조를 잃고 가비지 컬렉터(Garbage Collector, GC)에 의해 메모리에서 해제되어야 합니다.

하지만 WebSocket의 경우 함수가 종료되어 변수가 스코프를 벗어났음에도 불구하고, 서버에서 메시지가 오면 여전히 이벤트 리스너가 동작하는 것을 볼 수 있습니다. 이로 인해 개발자는 ‘왜 객체가 파괴되지 않는지’, ‘메모리 누수가 발생하는 것은 아닌지’ 혼란을 겪게 됩니다.

Background

이 문제를 이해하려면 자바스크립트의 메모리 관리 방식과 브라우저의 비동기 API 동작 원리를 알아야 합니다. 자바스크립트는 ‘도달 가능성(Reachability)‘을 기준으로 가비지 컬렉션을 수행합니다. 루트(Root)에서부터 참조를 따라갈 수 없는 객체는 메모리에서 삭제됩니다.

하지만 브라우저 환경에서 제공하는 Web API(WebSocket, setTimeout, XMLHttpRequest 등)는 자바스크립트 엔진의 동기적인 실행 흐름과는 독립적으로 동작합니다. 브라우저 런타임은 이벤트가 발생했을 때 콜백을 실행하기 위해 이러한 비동기 객체들을 ‘활성(Active)’ 상태로 유지하며, 이들은 가비지 컬렉션의 새로운 루트(Root) 역할을 하게 됩니다.

Solution

이 현상은 버그가 아니라 WebSocket 명세(Specification)와 브라우저 엔진의 의도된 동작입니다. 객체가 스코프를 벗어났다고 해서 연결이 끊어지면 비동기 통신 자체가 불가능해지기 때문입니다.

1. WebSocket 명세에 따른 가비지 컬렉션 방지 규칙

공식 명세에 따르면, WebSocket 객체는 특정 상태와 리스너 조건에 부합할 경우 절대 가비지 컬렉션 되어서는 안 됩니다.

  • CONNECTING (0): open, message, error, close 이벤트 리스너가 하나라도 등록되어 있다면 GC 대상에서 제외됩니다.
  • OPEN (1): message, error, close 이벤트 리스너가 등록되어 있다면 GC 대상에서 제외됩니다.
  • CLOSING (2): error, close 이벤트 리스너가 등록되어 있다면 GC 대상에서 제외됩니다.
  • 전송 대기 중인 데이터: 네트워크로 전송하기 위해 대기 중인 데이터가 큐에 남아있다면 GC 대상에서 제외됩니다.

2. 브라우저 런타임의 참조 유지

소켓 연결이 열려있는 동안 브라우저 엔진은 이벤트 발생 시 리스너를 호출해야 하므로, 이벤트 리스너(또는 이벤트의 .target이 되는 WebSocket 인스턴스 전체)에 대한 참조를 내부적으로 쥐고 있습니다. 이 숨겨진 참조가 GC를 막는 방패 역할을 합니다.

3. 올바른 메모리 해제 방법 (코드 예제)

메모리 누수를 방지하려면 더 이상 소켓이 필요 없을 때 명시적으로 연결을 종료해야 합니다. 연결이 닫히고 남은 이벤트가 없어야 브라우저가 참조를 놓고 GC가 객체를 수거할 수 있습니다.

function connectAndManage() {
    // 지역 변수로 WebSocket 생성
    var ws = new WebSocket('wss://example.com/socket');

    // 메시지 수신 리스너 등록 (이로 인해 함수 종료 후에도 GC되지 않음)
    ws.onmessage = function(e) {
        console.log('메시지 수신:', e.data);
    };

    // 5초 후에 명시적으로 연결을 종료하는 로직 추가
    setTimeout(() => {
        console.log('WebSocket 연결을 명시적으로 종료합니다.');
        // 1. 연결 닫기
        ws.close(); 
        
        // 2. (선택사항) 리스너 참조 해제 (GC를 돕기 위한 좋은 습관)
        ws.onmessage = null; 
    }, 5000);
}

connectAndManage();
// 함수가 즉시 종료되지만, ws 객체는 5초 동안 살아있다가 close() 호출 후 GC 대상이 됩니다.

Deep Dive

이러한 가비지 컬렉션 특성은 WebSocket뿐만 아니라 setTimeout, setInterval, fetch, RTCPeerConnection 등 대부분의 비동기 Web API에 동일하게 적용됩니다.

특히 React나 Vue 같은 Single Page Application(SPA) 환경에서는 컴포넌트가 언마운트(Unmount)될 때 주의해야 합니다. 컴포넌트 내부에서 생성한 WebSocket이나 타이머를 useEffect의 클린업(cleanup) 함수 등에서 명시적으로 종료(ws.close(), clearInterval())하지 않으면, 눈에 보이지 않는 백그라운드에서 객체가 계속 살아남아 심각한 메모리 누수(Memory Leak)와 예기치 않은 동작을 유발할 수 있습니다.

Conclusion

JavaScript에서 WebSocket 객체는 함수 스코프를 벗어나더라도, 연결이 유지되어 있고 이벤트 리스너가 존재하는 한 가비지 컬렉션되지 않습니다. 이는 비동기 이벤트를 정상적으로 처리하기 위한 브라우저의 필수적인 동작입니다. 따라서 개발자는 소켓 사용이 끝나는 시점에 반드시 .close() 메서드를 호출하여 시스템 자원과 메모리를 명시적으로 반환하는 습관을 가져야 합니다.

References

협업 및 후원 연락하기 →