Skip to main content

Miscellaneous

IndexedDB 아키텍처: 다중 프로세스 접근 원리​

웹 애플리케이션에서 IndexedDB는 Service Worker Process와 Renderer Process(메인 스레드가 실행되는 탭) 양쪽에서 모두 접근할 수 있는 유일한 영구 저장소입니다. 이러한 공유 접근이 가능한 이유는 localStorage와 근본적으로 다른 아키텍처 설계에 있습니다.


1. localStorage의 한계​

localStorage는 서비스 워커 컨텍스트에서 사용할 수 없으며, 그 이유는 다음과 같습니다.

  • window 객체 종속성: localStorage는 window.localStorage API를 통해 노출됩니다. window 객체는 Renderer Process(탭)에만 존재하는 전역 객체이며, Service Worker Process에는 window 객체가 존재하지 않아 접근이 원천적으로 불가능합니다.
  • 동기(Synchronous) API: localStorage는 동기식 API입니다. 만약 서비스 워커에서 이 API를 사용할 수 있게 허용한다면, 모든 백그라운드 작업을 처리하는 서비스 워커 스레드가 차단(blocking)되어 브라우저 전체의 성능에 심각한 영향을 미칠 수 있습니다.

2. IndexedDB의 아키텍처: IPC (프로세스 간 통신)​

IndexedDB는 특정 프로세스의 메모리(Heap)에 저장되는 데이터가 아니며, 다음과 같은 브라우저의 핵심 아키텍처를 통해 작동합니다.

1. 관리 주체: 브라우저 프로세스 (Browser Process)​

IndexedDB의 실제 데이터는 사용자의 디스크(Disk)에 파일 형태(LevelDB)로 저장됩니다. 이 디스크 파일에 직접 접근하여 데이터를 읽고 쓰는 유일한 주체는 브라우저의 핵심인 **브라우저 프로세스(Browser Process)**입니다.

모든 탭(Renderer Process)과 서비스 워커(Service Worker Process)는 이 데이터를 직접 관리하지 않습니다.

2. 접근 방식: 프로세스 간 통신 (IPC)​

Renderer Process나 Service Worker Process에서 indexedDB.open() 같은 API를 호출할 때, 실제로는 해당 프로세스의 메모리에서 작업을 수행하는 것이 아닙니다.

  1. 비동기 요청: IndexedDB API 호출은 브라우저의 저수준(low-level) IPC(Inter-Process Communication) 메커니즘을 통해 "브라우저 프로세스"에게 **비동기 메시지(Request)**를 전송합니다.
  2. 중앙 관리: 브라우저 프로세스는 모든 프로세스(여러 탭, 서비스 워커)로부터 받은 요청을 **중앙 큐(Queue)**에서 순서대로 관리합니다. 트랜잭션과 파일 잠금(Lock)을 보장하며 안전하게 디스크의 데이터를 처리합니다.
  3. 비동기 응답: 작업이 완료되면, 브라우저 프로세스는 다시 IPC를 통해 **결과 메시지(Response)**를 원래 요청했던 프로세스에게 돌려보냅니다.

3. 이벤트 루프와 태스크 큐 (Event Loop & Task Queue)​

브라우저 프로세스로부터 반환된 IPC 결과 메시지는 해당 프로세스(Renderer 또는 SW)의 **태스크 큐(Task Queue, Macrotask Queue)**에 등록됩니다.

그러면 해당 프로세스의 **이벤트 루프(Event Loop)**가 이 태스크를 발견하고, onsuccess 콜백 함수를 실행하거나 관련 Promise를 resolve시킵니다.

결론적으로, IndexedDB는 브라우저의 핵심인 브라우저 프로세스가 중앙에서 관리하는 디스크 기반 저장소입니다. 모든 컨텍스트(탭, 서비스 워커)는 오직 비동기 IPC라는 표준화된 통신 채널을 통해서만 접근을 "요청"하고 "응답"받기 때문에, 여러 프로세스 간에 데이터를 안전하게 공유할 수 있습니다.


Service Worker의 수명 주기와 Web API (setInterval)​

1. 문제의 배경: 역설(Paradox)​

setInterval과 같은 Web API는 JS 메인 스레드의 콜 스택에서 직접 시간을 세지 않고, 브라우저 내부의 별도 메커니즘(타이머 관리 시스템)에게 "위임" 됩니다. 이 타이머 관리 시스템은 Renderer Process 내의 별도 스레드이거나 Browser Process의 일부일 수 있으며, 정확한 구현은 브라우저마다 다릅니다.

여기서 다음과 같은 논리적인 질문이 생깁니다.

"Service Worker(SW)의 실행 컨텍스트가 종료되더라도, 타이머를 위임받은 브라우저 내부 시스템은 살아있습니다. 그렇다면 왜 setInterval이 멈추는 것입니까? 타이머는 계속 돌고, 나중에 SW가 다시 살아났을 때 밀린 콜백을 처리해야 하는 것 아닙니까?"

이 질문의 답은 Task Queue(작업 큐) 가 작동하는 방식에 있습니다.


2. 핵심 원리: Task Queue는 실행 컨텍스트에 종속적입니다.​

Task Queue(이벤트 큐)는 브라우저 전체에 하나만 존재하는 "공용 큐"가 아닙니다.

  • 탭 1의 Renderer Process 는 "탭 1 전용 Task Queue" 를 가집니다.
  • 탭 2의 Renderer Process 는 "탭 2 전용 Task Queue" 를 가집니다.
  • Service Worker의 실행 컨텍스트 는 "SW 전용 Task Queue" 를 가집니다.

참고: Service Worker는 어디에서 실행되는가?

Chromium 기준으로 Service Worker는 반드시 독립적인 프로세스에서 실행되는 것이 아닙니다.

  • 독립 프로세스: Site Isolation 정책 등에 의해 별도의 Renderer Process를 할당받아 실행될 수 있습니다.
  • 기존 Renderer Process 내 별도 스레드 : 같은 origin의 Renderer Process 안에서 Worker 스레드로 실행될 수도 있습니다.

어느 경우든, Service Worker는 자신만의 실행 컨텍스트(Call Stack, Heap, Task Queue) 를 가지며, 이 컨텍스트가 종료되면 모든 것이 함께 사라집니다. 따라서 이 문서에서는 프로세스/스레드 구분 대신 "SW 실행 컨텍스트" 라는 표현을 사용합니다.

Web API에게 작업을 위임한다는 것은 "그냥 실행해"가 아니라, "작업이 끝나면 나의 전용 Task Queue에 콜백을 넣어줘" 라는 의미입니다.


3. setInterval이 멈추는 정확한 순서​

  1. [SW 실행 컨텍스트] setInterval(myCallback, 1000)을 호출합니다.

    • 이것은 브라우저의 타이머 관리 시스템에게 보내는 요청입니다.
    • 요청 내용: "1초마다 myCallback을 SW 전용 Task Queue에 배달해주세요."
  2. [브라우저 타이머 시스템] "알겠습니다." (타이머 시작)

  3. [SW 실행 컨텍스트] 현재 처리 중이던 이벤트(예: install 또는 activate)를 완료하고 "유휴(Idle)" 상태가 됩니다.

  4. [Browser Process] Service Worker는 리소스 절약을 위해 "이벤트 기반"으로 작동하도록 설계되었습니다. Browser Process는 SW가 유휴 상태인 것을 확인하고, SW의 실행 컨텍스트를 강제로 종료(Terminate) 시킵니다.

  5. [SW 실행 컨텍스트] 실행 컨텍스트가 종료되면서, 이 컨텍스트에 속해있던 Call Stack, Heap, 그리고 "SW 전용 Task Queue"가 모두 메모리에서 사라집니다.

  6. [브라우저 타이머 시스템] (1초 뒤) "약속대로 myCallback을 SW 전용 Task Queue에 배달해야지."

  7. [브라우저 타이머 시스템] "...어? 배달할 주소(SW 전용 Task Queue)가 존재하지 않습니다."

  8. [브라우저 타이머 시스템] 배달 대상을 잃은 타이머는 작업을 중단하거나 취소됩니다.


🍕 피자 배달 비유​

  • setInterval은 10분마다 피자(myCallback)를 "A사무실" (SW 실행 컨텍스트)로 배달해달라고 주문하는 것과 같습니다.
  • "A사무실"이 갑자기 폐업해서 사라졌습니다.
  • 피자 배달부(브라우저의 타이머 시스템)는 10분 뒤 "A사무실"에 도착했지만, 사무실 자체가 사라진 것을 확인합니다. 배달부는 피자를 버리고(작업 취소), 더 이상 "A사무실"로 배달을 오지 않습니다.

결론: setInterval은 Service Worker의 수명을 연장해주지 못합니다. 브라우저의 타이머 시스템이 멈추는 것이 아니라, 콜백을 전달받을 대상(Task Queue) 이 실행 컨텍스트 종료와 함께 사라지기 때문에 멈추는 것입니다.


4. 핵심 출처 (Sources)​

  1. Service Worker의 수명 주기 (MDN):

    • https://www.w3.org/TR/service-workers/#service-worker-lifetime
    • 내용: 이 문서, 특히 '수명 주기(Lifecycle)' 섹션에서는 서비스 워커가 이벤트를 처리하지 않을 때 "유휴(idle)" 상태가 되고 결국 "종료(terminated)" 된다고 명시하고 있습니다. setInterval은 이 수명을 연장시키는 "이벤트"로 간주되지 않습니다.
  2. 이벤트 루프와 태스크 큐 (MDN):

    • https://developer.mozilla.org/docs/Web/JavaScript/EventLoop
    • 내용: 이 문서는 Web API(예: setInterval)의 콜백이 어떻게 "태스크 큐(Task Queue)" 를 통해 처리되는지 설명합니다. 우리가 논의한 "배달 주소"가 바로 이 큐이며, 이 큐는 실행 컨텍스트와 생명 주기를 함께합니다.

WebRTC 로컬 연결 장애 트러블슈팅: Host Candidate와 프라이버시 정책​

AI를 활용해 브라우저 내부 로그만으로 원인을 특정하고 해결한 사례입니다. Firefox의 소스 레벨 정책 때문에 발생한 문제라 검색만으로는 원인을 찾기 어려웠고, 로그의 단편적인 단서들을 AI와 함께 하나의 가설로 좁혀나가는 과정이 핵심이었습니다.

문제 상황​

로컬 네트워크(LAN) 환경에서 mediasoup 기반 화상 통화를 테스트하던 중, Firefox에서만 재현되는 이상한 비대칭 현상이 발생했습니다.

  • 송신자(Producer): 카메라를 켜고 방에 입장하면 연결이 정상적으로 수립됩니다.
  • 수신자(Consumer): 화면만 보려고 입장하면 연결이 멈춘 채(Stuck) 진행되지 않습니다.

코드상의 명백한 에러는 없었기 때문에, Firefox의 내부 네트워크 로그(about:webrtc)를 직접 확인해야 했습니다. 거기서 다음과 같은 에러를 발견했습니다.

Ignoring addr from interface other than lo
failed to create passive TCP host candidate
failed to find default addresses

수신자 측 transport에서는 유효한 IP 후보(Host Candidate)가 아예 생성되지 않고 있었습니다.

원인 분석​

이 로그를 AI에게 그대로 제시하고, "왜 송신자는 되는데 수신자는 안 되는가"라는 비대칭성을 함께 짚어가며 원인을 좁혀나갔습니다. 결론은 네트워크 환경 문제가 아니라 Firefox의 의도적인 정책이었습니다.

  1. Ignoring addr from interface other than lo가 의미하는 것: 브라우저가 랜카드의 실제 IP를 찾지 못한 게 아니라, 의도적으로 무시하고 루프백(loopback)만 바라보고 있다는 뜻이었습니다. (문제 해결 문서에서 정리한 ICE Candidate 3종(host/srflx/relay) 중 host candidate만 선택적으로 차단된 상태였습니다.)
  2. 왜 로컬 IP를 숨기는가 (Fingerprinting 방지): 로컬 LAN IP(예: 192.168.0.5)는 공인 IP와 조합되면 쿠키 없이도 사용자를 식별할 수 있는 고유 식별자가 됩니다. 일반적인 HTTP 통신에서는 NAT가 로컬 IP를 자연스럽게 가려주지만, WebRTC는 P2P 연결 최적화를 위해 브라우저가 OS 네트워크 인터페이스에 직접 로컬 IP를 물어보고 이를 상대방에게 그대로 전달합니다. 즉 NAT가 막아주던 경로를 WebRTC API가 우회해버리는 셈이라, 브라우저가 별도의 프라이버시 정책으로 이를 다시 막아둔 것입니다.
  3. 송신자는 왜 예외였는가 (getUserMedia 권한): 브라우저는 카메라/마이크 권한(getUserMedia)을 획득한 사이트를 "신뢰할 수 있는 사이트"로 간주해 이 정책의 예외로 두고 host candidate 노출을 허용합니다. 송신자는 화면 공유를 위해 이미 권한을 받았지만, 화면만 보는 수신자는 getUserMedia를 호출한 적이 없어 정책 적용 대상에서 예외를 받지 못했습니다.

해결 방식​

원인이 "권한 여부"로 좁혀지자 해결책은 명확했습니다. 수신자여도 연결을 맺는 시점에 더미(dummy) 미디어 권한을 강제로 요청하는 것입니다.

더미 미디어 권한 요청 (개념 코드)
useEffect(() => {
let dummyStream: MediaStream | null = null;

// 실제로 화면을 보내지 않더라도, host candidate 노출을 위해
// 신뢰 가능한 사이트로 인식되도록 권한만 미리 확보해둔다.
navigator.mediaDevices
.getUserMedia({ audio: true, video: true })
.then((stream) => {
// 트랙을 즉시 stop하면 Firefox가 다시 프라이버시 모드로 돌아가
// host candidate를 숨기므로, 연결이 끝날 때까지 트랙을 유지해야 한다.
dummyStream = stream;
})
.catch((e) => {
console.warn('ICE permission probe failed:', e);
});

return () => {
dummyStream?.getTracks().forEach((track) => track.stop());
};
}, []);

이때 주의할 점은, 권한만 받고 트랙을 바로 정지하면 안 된다는 것입니다. 트랙을 즉시 stop하면 Firefox는 다시 프라이버시 모드로 돌아가 host candidate를 숨기므로, 연결이 유지되는 동안에는 더미 스트림을 살려둬야 합니다. 이 코드를 적용한 뒤 수신자 쪽에서도 정상적으로 IP 후보가 생성되어 문제가 해결되었습니다.

다른 대안(Firefox about:config에서 media.peerconnection.ice.obfuscate_host_addresses를 끄거나, TURN 서버를 통해 relay candidate로 우회하는 방법)도 있지만, 전자는 개발자 로컬 환경에만 적용되고 후자는 로컬 개발 환경에서 당장 쓸 수 없어 채택하지 않았습니다.

이 과정에서 AI를 활용한 방식​

about:webrtc가 출력하는 로그는 mediasoup나 WebRTC 튜토리얼 어디에도 정리돼 있지 않은, Firefox 내부 구현 세부사항에 가까운 문구였습니다. 이 로그를 AI에게 그대로 제시하고 "송신자는 되는데 수신자는 안 된다"는 비대칭적 증상을 함께 제공한 것이 결정적이었습니다 — 로그 문구 자체보다, 두 경로의 유일한 차이(getUserMedia 호출 여부)를 대조시켜 가설을 검증하는 방식으로 원인을 좁혀나갔습니다. 이후 이 가설(fingerprinting 방지 정책)이 실제 Firefox의 문서화된 동작과 일치하는지 교차 확인해 결론을 확정했습니다. AI가 만들어준 코드를 그대로 받아쓴 것이 아니라, 낯선 브라우저 내부 로그를 해석하고 원인 가설을 좁혀나가는 데 AI를 페어 프로그래밍 파트너로 활용한 사례입니다.