프론트엔드의 통신 설계를 떠올리면, 보통은 이미 널리 알려진 도구부터 생각하게 된다. CustomEvent를 쓸지, 이벤트 버스를 둘지, postMessage를 붙일지, 아니면 그냥 전역 상태로 밀어붙일지 같은 것들이다.
그런데 실제로 통신 설계에서 먼저 봐야 하는 것은 도구가 아니다. 지금 어떤 경계를 넘고 있는지부터 구분해야 한다.
통신 설계에서 생기는 많은 실수는 여기서 시작된다. 지금 내가 누구와 대화하고 있는지, 그리고 그 사이에 어떤 벽이 있는지 정확히 나누지 않은 채 구현부터 시작하는 것이다.
이 문제는 MFE 환경에서 특히 더 자주 드러난다. 겉으로 보면 모두 단순히 “프론트엔드끼리 통신하는 문제”처럼 보이지만, 실제로는 서로 전혀 다른 종류의 문제를 풀고 있는 경우가 많다.
같은 런타임 안에서 가볍게 신호를 주고받는 상황도 있고, iframe처럼 실행 컨텍스트가 분리된 경우도 있다. WebView 브릿지처럼 플랫폼 자체가 다른 경우도 있고, 서버 소켓처럼 아예 네트워크를 타는 경우도 있다.
이런 경계를 구분하지 않은 채 통신을 설계하면 금방 어긋난다. 가까운 곳에 지나치게 무거운 프로토콜을 올리기도 하고, 반대로 멀리 있는 대상을 너무 안일하게 다루기도 한다.
이 글에서는 프론트엔드 통신을 단순히 데이터를 전달하는 기술이 아니라, 경계를 넘는 설계라는 관점에서 다시 정리해보려 한다.
기술보다 먼저 봐야 하는 것
통신 방식을 고르기 전에 먼저 확인해야 할 질문은 하나다.
지금 누구와, 어떤 벽을 사이에 두고 대화하고 있는가.
프론트엔드에서 자주 마주치는 경계는 대체로 네 가지 정도로 나눌 수 있다.
같은 런타임
같은 메모리 공간을 공유하는 경우다. 같은 앱 안의 모듈, 같은 브라우저 컨텍스트 안의 코드가 여기에 가깝다.
예를 들면 이런 경우다.
- 하나의 SPA 안에서 헤더 위젯이 알림 카운트를 갱신하는 경우
- 같은 페이지 안의 두 MFE가 같은 셸 런타임을 공유하는 경우
- 동일한 JS 번들 안에서 모듈 간 이벤트를 흘리는 경우
이 경우에는 함수 호출, 가벼운 pub/sub, 상태 저장소 공유 정도로 충분한 때가 많다.
크로스 컨텍스트
같은 브라우저 안에 있더라도 실행 컨텍스트가 분리된 경우다.
대표적으로는 아래가 여기에 들어간다.
iframe과 부모 창Worker,SharedWorker
이 시점부터는 더 이상 같은 메모리를 바로 공유한다고 보기 어렵다. 메시지를 직렬화해야 하고, 생명주기와 타이밍 문제도 함께 고려해야 한다.
플랫폼 경계
웹과 네이티브 앱, WebView와 호스트 앱처럼 실행 환경 자체가 다른 경우다.
예를 들면 이런 상황이다.
- React 웹뷰가 네이티브 앱에 카메라 권한을 요청하는 경우
- 결제 완료 이벤트를 웹에서 앱으로 올려 보내는 경우
- 앱 버전에 따라 브릿지 메서드가 있기도 하고 없기도 한 경우
이 구간은 단순한 브라우저 이벤트 감각으로 접근하면 거의 항상 나중에 문제가 생긴다. 버전 차이, 비동기 응답, 권한, 플랫폼별 구현 차이가 한꺼번에 얽히기 때문이다.
네트워크 경계
서버나 외부 시스템과 통신하는 경우다. HTTP, WebSocket, SSE가 여기에 들어간다.
이때부터는 실패를 예외가 아니라 기본값으로 두는 편이 맞다. 지연, 순서 뒤바뀜, 중복 전달, 끊김, 재연결을 당연한 조건으로 봐야 한다.
경계가 달라지면 실패 방식도 달라진다
이 구분이 중요한 이유는 단순하다. 경계가 달라지면 실패하는 방식도 달라지고, 보장할 수 있는 것도 달라진다.
같은 런타임이라면 대부분 즉시 호출과 동기적인 제어 흐름이 가능하다. 이 경우 문제는 오히려 결합도가 너무 높아지는 쪽에서 생긴다.
반면 컨텍스트가 분리되면 메시지를 직렬화해야 하고, 상대가 아직 살아 있는지, 지금 받을 준비가 되어 있는지까지 봐야 한다.
플랫폼 경계에서는 “웹에서 보낸 요청을 네이티브가 언제 처리할지” 자체가 불확실해진다.
네트워크 경계에서는 더 분명하다. 응답이 늦을 수 있고, 아예 오지 않을 수도 있고, 재시도 과정에서 같은 요청이 두 번 전송될 수도 있다.
그래서 통신 기술은 정답이라기보다 결과물에 가깝다. 앞단에서 boundary를 잘못 보면, 뒤에서 어떤 도구를 붙여도 계속 어긋난다.
이벤트 버스, CustomEvent, postMessage는 같은 게 아니다
실무에서 가장 자주 섞여 쓰이는 것이 이 셋이다. 이름만 보면 비슷해 보이지만, 실제로는 서로 다른 층의 문제를 다룬다.
이벤트 버스
이벤트 버스는 특정 브라우저 API라기보다 패턴에 가깝다. 누군가 이벤트를 발행하고, 필요한 쪽이 구독해서 처리하는 구조다.
같은 런타임 안에서 여러 모듈을 느슨하게 연결할 때 유용하다. 특히 React나 Vue 기반 MFE에서는 DOM 이벤트보다 순수 JavaScript 버스를 두는 편이 다루기 쉬운 경우가 많다.
예를 들어 셸 앱이 로그인 상태를 바꾸면, 헤더 MFE와 프로필 MFE가 각각 그 이벤트를 받아 자기 화면만 갱신하는 식이다.
장점은 분명하다.
- 특정 프레임워크에 덜 묶인다
- 타입을 붙이기 좋다
- 테스트가 비교적 단순하다
반대로 주의할 점도 있다. 이벤트 이름이 늘어나기 시작하면 문서화가 금방 중요해지고, 누가 발행하고 누가 소비하는지 추적하기 어려워질 수 있다.
같은 런타임 안이라고 해서 무제한으로 이벤트를 늘려도 된다는 뜻은 아니다.
CustomEvent
CustomEvent는 브라우저의 DOM 이벤트 시스템을 활용하는 방식이다. 같은 window, 같은 DOM 트리 안에서 신호를 흘릴 때 꽤 편하다.
예를 들어 레거시 페이지 안에 새 위젯 하나를 붙였는데, 그 위젯이 DOM 레벨에서 호스트 페이지와 느슨하게만 연결돼 있다면 CustomEvent가 잘 맞을 수 있다.
이런 식이다.
- 호스트 페이지가
user:changed이벤트를 디스패치한다 - 임베드된 위젯이 그 이벤트를 듣고 자기 UI를 갱신한다
문제는 여기서 자주 착각이 생긴다는 점이다. 서로 다른 MFE가 CustomEvent로 잘 통신된다고 해서, 그 구조가 어디서나 통한다고 생각하면 곤란하다.
대부분은 우연히 같은 전역 객체와 같은 DOM 컨텍스트를 공유하고 있어서 되는 것뿐이다.
즉 CustomEvent는 “브라우저 안에서 다 되는 범용 IPC”가 아니다. 적용 범위가 꽤 명확한 도구다.
postMessage
postMessage는 실제로 분리된 컨텍스트 사이를 연결할 때 쓰는 도구다.
iframe과 부모 창- WebView 브릿지와 유사한 인터페이스
이 시점부터는 메시지 포맷, origin 검증, 응답 방식 같은 계약이 중요해진다.
실무에서는 보통 아래와 같은 envelope을 먼저 두는 편이 좋다.
typerequestIdsourceversionpayload
이 정도만 있어도 나중에 디버깅과 버전 관리가 훨씬 쉬워진다.
특히 결제나 인증처럼 iframe 바깥과 안쪽이 오래 살아남아야 하는 시나리오에서는 postMessage를 그냥 값 하나 던지는 API처럼 쓰면 안 된다.
정리하면 이렇다.
- 같은 런타임 내부의 느슨한 연결: 이벤트 버스
- 같은 DOM 컨텍스트 안의 브라우저 이벤트 활용:
CustomEvent - 분리된 실행 컨텍스트 사이 통신:
postMessage
이 셋을 같은 층위에서 비교하면 선택이 계속 흔들린다. 지금 넘는 경계가 무엇인지 먼저 보는 편이 훨씬 정확하다.
Module Federation으로 보면
MFE 이야기를 할 때 Webpack의 Module Federation 관점을 함께 보면 이 boundary 구분이 더 또렷해진다.
여기서 중요한 점은 Module Federation이 통신 수단이라기보다 런타임 조립 방식에 가깝다는 것이다.
호스트가 remote를 불러와 같은 페이지 안에서 조합하는 순간, 우리는 흔히 “MFE끼리 통신한다”고 말하게 된다. 그런데 조금만 나눠서 보면 사실 층이 두 개다.
첫 번째는 로딩 시점의 경계다.
remoteEntry.js를 어디서 가져오는가- 그 파일을 지금 받을 수 있는가
- 공유 라이브러리 버전이 맞는가
- remote가 초기화에 성공하는가
이 구간은 사실상 네트워크와 배포 경계에 가깝다. remote를 아직 못 불러온 상태라면, 그건 통신 이전에 조립 자체가 안 된 상태다.
두 번째는 마운트 이후의 경계다.
remote가 정상적으로 로드되어 같은 페이지, 같은 JS 런타임 안에서 돌기 시작하면 그 뒤 상호작용은 대개 같은 런타임 경계로 내려온다.
예를 들면 이런 상황이다.
- 셸이 사용자 세션을 들고 있다
- 장바구니 remote가 같은 세션을 읽는다
- 프로모션 remote가 옵션 변경 이벤트를 구독한다
이 단계에서는 postMessage보다 이벤트 버스나 공유 인터페이스가 더 자연스러운 경우가 많다.
즉 Module Federation을 쓴다고 해서 모든 통신이 자동으로 “분리된 시스템 간 통신”이 되는 것은 아니다. 불러오는 순간은 네트워크/배포 경계일 수 있고, 불러온 뒤의 상호작용은 같은 런타임 경계일 수 있다.
이 차이를 구분하지 않으면 설계가 쉽게 과해진다. 같은 런타임에서 풀어도 되는 문제를 너무 무거운 브릿지처럼 다루게 된다.
반대로 Module Federation에서 실제로 자주 터지는 문제는 통신보다 조립 쪽인 경우가 많다.
- 특정 remote만 배포가 덜 된 상태
remoteEntry캐시가 꼬여서 구버전을 보는 상태shared라이브러리 버전이 맞지 않아 런타임 에러가 나는 상태- 필수 remote가 늦게 떠서 셸 전체가 같이 늦어지는 상태
그래서 Module Federation 환경에서는 통신 설계만큼 아래 항목도 같이 챙겨야 한다.
- 어떤 remote가 필수인지, 어떤 remote는 없어도 되는지
- 로드 실패 시 셸이 어떻게 degrade될지
- share scope 충돌을 어떻게 제한할지
- remote 계약을 타입과 버전으로 어떻게 관리할지
실무에서는 이 판단이 중요하다. 예를 들어 결제, 주문, 로그인처럼 서비스의 중심 흐름을 담당하는 영역이라면 remote 로딩 실패가 곧 전체 장애로 번지지 않게 더 보수적으로 가져가는 편이 낫다.
반대로 추천 영역, 배너, 실험용 위젯처럼 없어도 핵심 흐름이 유지되는 기능이라면 Module Federation으로 느슨하게 분리하고, 실패 시 셸이 조용히 fallback UI를 보여주는 구성이 잘 맞는다.
통신의 모양도 같이 봐야 한다
실무에서 자주 놓치는 지점이 하나 더 있다. 경계만 다른 것이 아니라, 통신의 모양 자체도 서로 다르다는 점이다.
프론트엔드 통신은 보통 아래 세 가지로 나눠서 보면 정리가 잘 된다.
단방향 이벤트
어떤 일이 일어났다는 사실만 알리면 되는 경우다.
예를 들면 이런 것들이다.
- 로그인 완료
- 장바구니 수량 변경
- 테마 변경
이런 경우에는 누가 이벤트를 발행하고 누가 듣는지만 명확하면 비교적 단순하다.
같은 런타임 안이라면 이벤트 버스나 CustomEvent로 충분한 경우가 많다.
요청-응답
상대에게 어떤 동작을 요청하고, 결과를 다시 받아야 하는 경우다.
예를 들면 이런 상황이다.
- 결제창 열기 요청 후 결과 받기
- 네이티브 카메라 호출 후 촬영 결과 받기
- iframe 안의 인증 완료 여부 확인하기
이 경우에는 단순 이벤트보다 계약이 훨씬 중요해진다.
요청 타입, 응답 타입, 성공/실패 표현, requestId 매칭, timeout 기준이 같이 있어야 한다.
그래서 이 형태는 이벤트보다 RPC에 더 가깝다.
상태 동기화
이게 가장 까다롭다. 한 번 이벤트를 보내는 것이 아니라, 여러 주체가 같은 상태를 계속 비슷하게 바라봐야 하는 경우다.
예를 들면 이런 경우다.
- 셸 앱과 여러 MFE가 같은 사용자 세션을 바라보는 경우
- 웹뷰와 네이티브가 로그인 상태를 맞춰야 하는 경우
- 실시간 주문 상태를 여러 위젯이 동시에 보여주는 경우
이 문제를 단순 이벤트 발행만으로 풀려고 하면 금방 drift가 생긴다. 누군가는 최신 값을 알고 있고, 누군가는 예전 이벤트만 들고 있는 상태가 되기 쉽다.
그래서 상태 동기화 문제는 가볍게 이벤트만 뿌리는 것으로 끝내기보다, 누가 source of truth를 가지는지, 초기 snapshot을 어떻게 받고 이후 변경을 어떻게 반영할지까지 함께 설계하는 편이 좋다.
즉 “통신을 한다”는 말 안에는 이벤트 전달, 요청-응답, 상태 동기화가 뒤섞여 있다. 이 셋을 나누지 않으면 버스로 풀어야 할 문제를 RPC처럼 만들기도 하고, 반대로 상태 동기화를 이벤트 몇 개로 버티려다가 나중에 정합성 비용을 크게 치르게 된다.
계약이 먼저다
경계를 넘는 순간부터는 “메시지를 보낸다”보다 어떤 계약으로 대화하느냐가 더 중요해진다.
프론트엔드 통신 코드가 오래 버티지 못하는 경우를 보면 대개 포맷이 너무 암묵적이다.
처음엔 { type, payload } 정도로 시작한다. 그러다가 응답 매칭이 필요해져서 requestId가 붙고, 디버깅이 필요해져서 source와 timestamp가 붙고, 브릿지 버전이 갈리면서 version이 붙는다.
이걸 뒤늦게 붙이기 시작하면 보통 이미 호출부가 여러 군데 퍼져 있다.
그래서 초반부터 최소한 아래 정도는 고정하는 편이 낫다.
- 메시지 타입 이름 규칙
- 필수 필드와 선택 필드
- 요청-응답 매칭 방식
- 버전 호환 정책
- 에러 표현 방식
예를 들어 WebView 브릿지라면 PAYMENT_REQUEST, PAYMENT_RESULT 같은 식으로 메시지 이름을 명확히 두고, 결제 상태나 실패 사유도 구조화해서 내려주는 편이 좋다.
그래야 브릿지를 호출하는 쪽에서 문자열 비교와 분기문이 과하게 불어나지 않는다.
경계를 넘는 순간, 실패를 기본값으로 둬야 한다
같은 런타임 안에서는 대부분 호출이 즉시 끝난다. 하지만 플랫폼 경계나 네트워크 경계를 넘는 순간부터는 이야기가 완전히 달라진다.
그때는 “보내면 오겠지”가 아니라, 안 올 수도 있다를 기준으로 설계해야 한다.
실제로 필요한 것은 보통 아래 여섯 가지다.
1. Ack
메시지를 보냈으면 받았다는 응답이 있어야 한다.
이걸 빼면 상대가 못 받은 건지, 받았는데 처리 중인 건지, 응답만 유실된 건지 구분이 안 된다.
보통은 requestId 같은 값을 붙여서 요청과 응답을 매칭한다.
2. Timeout
응답을 무한정 기다리면 안 된다. 일정 시간이 지나면 실패로 판단하고, 상위 레이어가 다음 행동을 결정할 수 있어야 한다.
예를 들어 WebView 결제 요청을 보냈는데 30초가 지나도 결과가 오지 않는다면, 사용자에게 재시도나 취소 UI를 보여줄 수 있어야 한다.
3. Retry
일시적인 실패라면 재시도가 도움이 된다. 다만 재시도는 만능이 아니다.
같은 요청을 다시 보내도 안전한지, 중복 처리를 어떻게 막을지까지 같이 봐야 한다.
알림 읽음 처리 같은 idempotent한 요청은 재시도를 붙이기 쉽지만, 결제 승인처럼 중복이 위험한 요청은 훨씬 더 신중해야 한다.
4. Backoff
실패했다고 바로 연속 재시도를 때리면 오히려 상황을 더 악화시킨다.
간격을 늘려가며 다시 시도하는 전략이 필요하다. 그래야 상대 시스템이 버틸 시간을 벌 수 있다.
실시간 시세 연결이 끊겼을 때 즉시 무한 재연결을 시도하는 코드는 클라이언트와 서버 모두를 쉽게 힘들게 만든다.
5. Idempotency
멀리 있는 대상을 상대할수록 “같은 요청이 두 번 가도 안전한가”를 봐야 한다.
재시도를 붙이는 순간 중복 가능성은 늘 따라온다.
주문 제출, 포인트 적립, 쿠폰 사용 같은 행위는 프론트엔드에서 재시도를 붙이기 전에 서버가 중복 요청을 어떻게 처리하는지까지 함께 확인하는 편이 안전하다.
6. Observability
실패를 다룰 생각이라면 최소한 무엇이 오갔는지 볼 수 있어야 한다.
요청 타입, requestId, 소요 시간, 성공/실패 여부 정도는 로그나 디버그 툴에서 확인 가능해야 한다.
그렇지 않으면 통신 문제를 재현도 못 하고 설명도 못 하게 된다.
이 여섯 가지는 거창한 고급 기능이 아니다. 멀리 있는 대상과 대화할 때 필요한 최소한의 장치에 가깝다.
통신 코드는 계층을 나눠야 오래 버틴다
프론트엔드 통신 코드를 보면 전송 방식, 메시지 포맷, 재시도, 비즈니스 로직이 한 파일에 뒤섞여 있는 경우가 많다.
처음에는 빨라 보이지만, 요구사항이 조금만 바뀌어도 수정 범위가 급격히 커진다.
그래서 보통은 최소한 아래 정도로 나누는 편이 좋다.
Transport Layer
postMessage, WebSocket, HTTP 같은 실제 전달 수단이다.
여기서는 “어떻게 보낼까”에 집중한다. 재연결, origin 검증, 브라우저 API 래핑 같은 책임이 여기에 들어간다.
Message Layer
메시지 타입, payload 구조, requestId, 버전 같은 프로토콜 규약을 정의한다.
여기서 계약이 선명해야 호출부가 단순해진다.
Reliability Layer
Ack, timeout, retry, backoff, dedupe 같은 신뢰성 문제를 다룬다.
이 계층을 분리해두면 비즈니스 코드가 재시도 정책을 직접 품지 않아도 된다.
Application Layer
실제 비즈니스 동작을 연결한다.
예를 들면 이런 것들이다.
- 결제 시작
- 로그인 완료 후 사용자 정보 갱신
- 장바구니 수량 동기화
이렇게 나누면 장점이 분명하다.
- 전송 수단이 바뀌어도 상위 로직을 덜 건드린다
- 테스트를 계층별로 나눠서 할 수 있다
- 통신 문제와 비즈니스 문제를 분리해서 추적할 수 있다
예를 들어 처음에는 postMessage로 붙였다가 나중에 WebView bridge로 바꾸는 상황이 생겨도, 메시지 계약과 비즈니스 흐름이 잘 분리돼 있으면 교체 비용이 생각보다 커지지 않는다.
실무에서는 이런 식으로 판단하게 된다
아주 복잡하게 볼 필요는 없다. 대부분은 아래 질문 순서로 정리하면 방향이 꽤 빨리 잡힌다.
- 지금 통신 상대는 같은 런타임 안에 있는가?
- 실행 컨텍스트가 분리돼 있는가?
- 메시지 손실이나 지연을 고려해야 하는가?
- 요청-응답 계약이 필요한가, 아니면 단방향 이벤트면 충분한가?
- 전송 수단이 바뀌어도 유지돼야 하는 인터페이스가 있는가?
이 질문에 답하고 나면 도구 선택은 생각보다 자연스럽게 따라온다.
실무 예시
여기서는 자주 나오는 예시를 몇 가지로 나눠보자.
같은 런타임 안의 MFE
상품 상세 페이지 안에 가격 위젯, 프로모션 위젯, 구매 버튼 위젯이 각각 독립 배포된다고 해보자.
셋이 같은 셸 런타임을 공유한다면 무거운 브릿지를 둘 이유가 없다.
이럴 때는 보통 타입이 붙은 이벤트 버스나 공유 스토어 인터페이스 정도면 충분하다.
예를 들어 옵션 선택 변경 이벤트를 가격 위젯과 구매 버튼 위젯이 동시에 듣는 식이다.
이때 핵심은 이벤트 이름과 payload 구조를 누가 소유하는가를 초기에 정해두는 것이다.
iframe 기반 결제 모듈
결제창이 iframe 안에서 뜨고, 부모 페이지가 결제 완료 여부를 받아야 하는 상황은 postMessage가 잘 맞는다.
대신 여기서는 반드시 아래를 같이 봐야 한다.
origin검증- 메시지 타입 계약
requestId매칭- timeout 처리
결제 완료 이벤트 하나만 받으면 된다고 생각해도, 실제로는 로딩 실패, 사용자 취소, 중복 수신, 예상치 못한 창 종료 같은 경우가 늘 따라온다.
WebView 브릿지
앱 안의 웹뷰가 네이티브 기능을 호출하는 구조라면 보통 bridge.call("openCamera") 같은 형태로 감싸게 된다.
이 경우 프론트엔드 입장에서는 로컬 함수처럼 보이지만, 실제로는 플랫폼 경계를 넘는 비동기 호출이다.
그래서 아래가 특히 중요해진다.
- 브릿지 메서드 존재 여부 확인
- 앱 버전별 capability 분기
- 응답 지연 시 fallback
- 실패 시 사용자 메시지
브릿지 호출을 단순히 전역 객체 한 번 찍어보는 방식으로 여기저기 퍼뜨리면, 앱 버전이 늘어날수록 관리가 빠르게 어려워진다.
서버와의 실시간 통신
주문 상태, 주가, 실시간 채팅처럼 연결이 오래 살아 있어야 하는 경우는 WebSocket이나 SSE가 후보가 된다.
이때는 통신 API 선택보다 연결이 끊겼을 때 어떻게 복구할지가 더 중요하다.
예를 들면 이런 것들이다.
- 재연결 간격은 어떻게 둘지
- 재연결 중 UI는 어떻게 보일지
- 마지막 수신 시점은 어떻게 노출할지
- 중복 이벤트는 어떻게 걸러낼지
이런 항목이 같이 설계돼야 실제 서비스에서 버틴다.
키오스크와 오프라인 앱에서는 더 보수적으로 봐야 한다
키오스크 같은 환경에서는 이런 문제들이 더 날것으로 드러난다.
개발 환경에서는 네트워크도 안정적이고, 배포도 금방 반영되고, 운영자가 브라우저를 새로고침해줄 수 있다고 가정하기 쉽다. 하지만 현장 키오스크는 전혀 다르다.
- 네트워크가 끊기거나 불안정할 수 있다
- 장시간 재시작 없이 살아 있을 수 있다
- 운영자가 장애 원인을 바로 파악하기 어렵다
- 프린터, 스캐너, 카드리더기 같은 주변 장치가 함께 얽힌다
그래서 이런 환경에서는 “통신이 되면 좋다”가 아니라, 어느 부분이 실패해도 핵심 흐름을 어떻게 유지할지를 먼저 봐야 한다.
1. remote 로딩 실패
Module Federation을 쓰는 키오스크라면 가장 먼저 볼 것은 remote 로딩 실패다.
현장 네트워크가 잠깐만 흔들려도 remoteEntry.js 하나를 못 받아서 앱 전체가 뜨지 않는 구조가 쉽게 나온다.
이 경우에는 보통 아래 전략이 필요하다.
- 셸은 로컬 패키지로 항상 뜨게 한다
- 필수 기능은 remote 의존도를 낮추거나 로컬 fallback을 둔다
- 선택 기능만 federation remote로 분리한다
- remote 로드 timeout을 두고 실패 시 바로 대체 UI를 보여준다
즉 키오스크의 핵심 구매 흐름까지 “네트워크에서 remote를 받아와야만 시작되는 구조”로 두는 것은 꽤 위험하다.
2. 주변 장치 브릿지 실패
키오스크에서는 프린터, 바코드 스캐너, 카메라, 카드 단말기 같은 장치와의 브릿지가 자주 등장한다.
이건 전형적인 플랫폼 경계다. 그래서 아래를 함께 봐야 한다.
- 장치가 지금 연결돼 있는지
- 브릿지 메서드가 살아 있는지
- 호출은 성공했지만 물리 장치가 실패한 것은 아닌지
- 실패 시 운영자에게 어떤 안내를 줄지
예를 들어 영수증 출력은 print() 호출 성공만으로 끝내면 안 된다. 실제로 종이가 없거나 프린터가 오프라인이면 사용자 입장에서는 출력 실패다.
그래서 키오스크에서는 호출 Ack와 장치 결과 Ack를 분리해서 보는 편이 낫다.
- 브릿지가 요청을 받았는가
- 장치가 실제 작업을 끝냈는가
이 둘이 분리되지 않으면 “출력 버튼은 눌렀는데 왜 영수증이 안 나왔는지”를 현장에서 설명하기 어려워진다.
3. 네트워크 일시 장애
주문 제출, 재고 확인, 결제 승인처럼 서버가 필요한 구간은 네트워크 장애의 영향을 직접 받는다.
이때 중요한 것은 실패를 숨기는 것이 아니라 시스템 상태를 솔직하게 드러내는 것이다.
예를 들면 아래와 같은 상태다.
- 지금은 주문 접수가 지연 중인지
- 로컬에 임시 저장됐는지
- 재전송 대기 중인지
- 사용자 행동이 더 필요한지
이런 상태를 명확히 보여줘야 한다.
특히 오프라인 환경에서는 retry와 backoff만으로 충분하지 않은 경우가 많다. 로컬 저장과 재동기화 전략이 함께 필요해진다.
예를 들어 주문 생성 요청이라면 이런 흐름이다.
- 우선 로컬 큐에 기록한다
- 서버 전송을 시도한다
- 실패하면
pending sync상태로 남긴다 - 네트워크 회복 시 재전송한다
- 서버는 idempotency key로 중복을 막는다
이런 흐름이 있어야 현장에서 네트워크가 잠깐 끊겨도 주문 자체가 통째로 사라지지 않는다.
4. 장시간 실행으로 인한 상태 표류
키오스크는 장시간 살아 있는 경우가 많다. 이럴수록 상태 동기화 문제가 더 자주 드러난다.
예를 들면 이런 상황이다.
- 셸은 로그인 세션이 만료됐는데 하위 위젯은 예전 상태를 들고 있는 경우
- 가격 정책이 바뀌었는데 일부 위젯만 구버전 데이터를 바라보는 경우
- WebSocket이 끊겼는데 화면은 여전히 실시간처럼 보이는 경우
이런 문제는 단순 이벤트 몇 개로는 잘 안 잡힌다.
그래서 키오스크나 오프라인 앱에서는 초기 snapshot과 이후 delta를 분리해서 생각하는 편이 좋다.
- 앱 시작 시점에 기준 상태를 다시 받는다
- 변경은 이벤트나 스트림으로 반영한다
- 일정 주기마다 재검증한다
- 끊겼을 때는 stale 상태를 명시한다
즉 “최신 상태를 계속 흘려보낸다”와 “현재 화면이 최신이라고 믿어도 되는가”는 다른 문제다.
5. 운영 가능한 장애 처리
현장 앱에서는 기술적으로 복구 가능한 것만큼, 운영자가 이해 가능한 메시지가 중요하다.
예를 들면 이런 식이다.
- “네트워크 연결이 불안정합니다. 주문은 임시 저장되었고 연결 복구 후 자동 전송됩니다.”
- “프린터 상태를 확인해주세요. 영수증 출력은 완료되지 않았습니다.”
- “서버와 연결이 끊겨 실시간 정보가 갱신되지 않고 있습니다.”
이런 안내가 없으면 사용자는 앱이 멈췄다고 느끼고, 운영자는 새로고침이나 재부팅 외에는 할 수 있는 것이 없어진다.
키오스크에서 graceful handling은 단순히 그럴듯한 fallback UI를 보여주는 것으로 끝나지 않는다. 복구 가능성, 현재 상태, 다음 행동을 운영자와 사용자 모두가 이해할 수 있게 드러내는 것까지 포함한다.
이런 환경에서는 판단 기준도 조금 달라진다
일반 웹 서비스에서의 “느슨한 분리”가 키오스크에서는 오히려 위험할 수 있다.
그래서 현장형 앱에서는 아래처럼 더 보수적으로 판단하게 된다.
- 핵심 흐름은 로컬에서 최대한 닫히게 한다
- 선택 기능만 원격 분리한다
- 통신 실패 시 즉시 fallback 상태를 노출한다
- 재시도보다 로컬 기록과 재동기화를 더 중요하게 본다
- 운영자가 이해할 수 있는 장애 메시지를 함께 설계한다
즉 이 환경에서 통신 설계는 개발 편의성보다 운영 복원력에 더 가깝다.
자주 놓치는 부분
프론트엔드 통신 설계에서 자주 빠지는 부분도 몇 가지 있다.
Ownership
이 이벤트 이름과 메시지 스키마를 누가 소유하는지 불분명하면 나중에 충돌이 많이 난다.
셸이 소유하는지, 특정 도메인 팀이 소유하는지, 문서 위치가 어디인지부터 정하는 편이 낫다.
Versioning
브릿지나 postMessage 계약은 시간이 지나면 거의 반드시 바뀐다.
그래서 버전 필드 없이 오래 가는 경우는 드물다. 적어도 어디까지를 호환 범위로 볼지는 정해두는 편이 좋다.
Observability
통신이 실패했는데 브라우저 콘솔에 아무것도 남지 않으면 현장에서 문제를 좁히기 어렵다.
디버그 로그든, 개발용 패널이든, 최소한 흐름을 따라갈 수 있는 관찰 지점은 있어야 한다.
마무리
프론트엔드 통신 설계는 “어떤 API를 쓸까”를 고민하는 일이라기보다, 지금 어떤 boundary를 넘고 있는지를 먼저 구분하는 일에 가깝다.
이걸 먼저 나눠두면 설계가 훨씬 단순해진다. 같은 런타임 안에서는 가볍게 풀 수 있고, 경계가 멀어질수록 계약과 신뢰성을 더 신경 쓰면 된다.
MFE든 WebView 브릿지든, 겉으로는 다른 문제처럼 보이지만 실제로 부딪히는 지점은 꽤 비슷하다. 통신 자체보다 통신이 실패했을 때 시스템이 어떻게 동작해야 하는지까지 함께 설계해야 한다는 점이다.
그래서 통신 구조를 볼 때도 보통 이런 순서로 생각하게 된다.
지금 같은 런타임 안의 문제인가
실행 컨텍스트가 분리돼 있는가
플랫폼이나 네트워크 경계를 넘고 있는가
이 질문이 먼저 정리되면 이벤트 버스를 쓸지, CustomEvent를 쓸지, postMessage를 쓸지 같은 선택은 생각보다 자연스럽게 따라온다.
결국 중요한 건 어떤 데이터를 보내느냐보다, 지금 어떤 경계를 넘는 코드인가를 먼저 이해하는 것에 가깝다.