정의
브라우저 확장 프로그램에서 Content Script, Background Service Worker, Side Panel은 서로의 함수를 직접 호출하거나 메모리 객체를 공유할 수 없다. 메시지 기반 RPC(Remote Procedure Call, 다른 실행 공간에 구조화된 요청을 보내 작업 결과를 받는 방식) 는 Chrome의 메시지 API 위에 요청 schema, operation router, handler, 성공·실패 응답 형식을 얹어 함수 호출처럼 사용하는 애플리케이션 계층이다.
Chrome이 완성된 RPC 프레임워크를 제공하는 것은 아니다. 플랫폼은 runtime.sendMessage, runtime.onMessage, tabs.sendMessage 같은 전송 수단을 제공하고, 어떤 메시지를 허용하며 오류를 어떻게 표현할지는 확장 프로그램이 정한다.
왜 필요한가
사용자가 웹페이지에서 선택한 문장을 저장하고 Side Panel에서 목록을 본다고 해 보자. Content Script는 페이지의 DOM Range를 읽을 수 있지만 중앙 저장 책임까지 맡기면 각 탭에 데이터 규칙이 흩어진다. Background Service Worker는 IndexedDB와 권한을 한곳에서 다루기 좋지만 특정 탭의 DOM을 직접 읽을 수 없다. Side Panel도 별도 실행 공간이므로 저장 함수를 import했다고 같은 인스턴스를 호출하는 것이 아니다.
문자열 type과 임의 객체만 주고받으면 잘못된 payload가 handler 깊숙한 곳에서 실패하고, 전송 오류와 비즈니스 오류도 구분하기 어렵다. 요청 경계에서 unknown을 schema로 검증하고, 지원하는 operation만 router가 실행하며, 결과를 일정한 응답 봉투로 돌려주면 실행 공간 사이의 계약을 한곳에서 확인할 수 있다.
동작 원리
일반적인 흐름은 다음과 같다.
UI 또는 Content Script
→ sendRequest({ type, payload, requestId })
→ Chrome message transport
→ Background onMessage listener
→ runtime schema validation
→ operation router
→ handler / transaction
→ { ok: true, data } | { ok: false, error }
수신자는 메시지를 신뢰하지 않고 unknown으로 받는다. schema 검증을 통과한 요청만 type에 등록된 handler로 전달한다. handler 예외는 그대로 직렬화하지 않고 호출자가 처리할 수 있는 오류 코드와 안전한 메시지로 바꾼다. 같은 요청이 중복 도착하면 데이터가 두 번 만들어질 수 있는 작업에는 requestId와 멱등성 기록 또는 저장소의 고유 제약을 둔다.
명령 응답과 다른 화면의 상태 갱신은 별도 문제다. 응답은 요청을 보낸 화면에만 돌아가므로, 다른 화면에는 “데이터가 바뀌었다”는 이벤트를 방송할 수 있다. 수신 화면은 관련 쿼리를 무효화하고 IndexedDB 같은 단일 원본을 다시 읽는다. 이벤트에 전체 데이터를 실어 복제하지 않으면 늦게 열린 화면도 원본에서 최신 값을 얻을 수 있다.
실무 적용
다음처럼 transport와 계약을 얇게 분리할 수 있다.
type Response<T> =
| { ok: true; data: T }
| { ok: false; error: { code: string; message: string } };
browser.runtime.onMessage.addListener(async (raw): Promise<Response<unknown>> => {
const parsed = requestSchema.safeParse(raw);
if (!parsed.success) {
return {
ok: false,
error: { code: "INVALID_REQUEST", message: "Invalid request" },
};
}
return routeRequest(parsed.data);
});
DOM 선택과 하이라이트처럼 탭에 종속된 작업은 tabs.sendMessage(tabId, request)로 Content Script에 보낸다. 저장, 권한 확인, 여러 레코드의 transaction처럼 중앙 조율이 필요한 작업은 Background handler에 둔다. UI는 transport 세부 사항 대신 sendRequest()의 타입 있는 결과만 처리한다.
쓰기 성공 뒤 다른 화면을 갱신할 때는 변경 범위를 포함한 작은 신호만 보낸다. 예를 들어 { type: "items/changed", sourceId }를 받은 화면은 sourceId가 포함된 query key만 무효화한다. 전체 캐시를 지우거나 이벤트 payload를 새 원본처럼 저장하지 않는다.
트레이드오프
- 명시적 계약은 실행 공간의 경계를 분명하게 하고 테스트를 쉽게 하지만, operation마다 schema와 오류 매핑을 유지해야 한다.
- 변경 신호 후 재조회는 단일 원본을 유지하고 늦게 열린 화면에도 안전하지만, 쓰기 직후 추가 조회가 발생한다. 읽기 비용이 매우 크고 즉시성이 중요할 때만 응답 데이터로 제한적인 optimistic update를 함께 고려한다.
- Background 중앙 조율은 데이터 규칙을 한곳에 모으지만 Service Worker가 다시 시작될 수 있으므로 전역 메모리에 상태를 오래 보관할 수 없다.
사용하면 안 되는 경우
- 같은 실행 공간 안의 함수 호출까지 RPC로 감싸지 않는다. 직렬화와 오류 계층만 늘어난다.
- 단순한 일회성 메시지 하나뿐인데 범용 프레임워크나 코드 생성기를 먼저 도입하지 않는다. 작은 schema와 router로 충분한지 확인한다.
- 대용량 바이너리나 고빈도 스트림을 매번 일반 메시지로 복사하지 않는다. 플랫폼 제한과 비용을 측정한 뒤 전용 채널이나 저장소 참조를 사용한다.
흔한 실수
- TypeScript 타입이 있으니 runtime 검증도 된다고 착각한다. 외부 실행 공간에서 도착한 값은 빌드 시 타입의 보호를 받지 않는다.
- 메시지가 전달됐다는 사실을 handler가 성공했다는 뜻으로 취급한다.
- 예외 객체나 직렬화할 수 없는 값을 응답에 그대로 넣는다.
- 명령 응답과 전체 화면 방송을 하나로 취급해 요청하지 않은 화면까지 결과를 직접 덮어쓴다.
- Background Service Worker의 전역 변수를 영구 저장소처럼 사용해 재시작 뒤 상태를 잃는다.
- 모든 변경 이벤트에서 모든 query를 무효화해 불필요한 재조회 폭증을 만든다.
관련 개념
- react-query-invalidate-vs-staletime — 외부 쓰기 알림을 받은 화면이 관련 조회를 즉시 갱신하는 방법