grep

Engineering

항공 프론트엔드 구축기 (7/10): 창구를 하나만 두었습니다

에릭Eric_jeong(정용욱)여기어때

2026년 8월 10일

원문에서 보기 ↗

글. 정용욱(Eric) / 서비스웹개발팀

항공 프론트엔드 구축기: 창구를 하나만 두었습니다

이번 편은 앱 브릿지입니다. 웹과 앱이 대화하는 통로인데, 문제는 이 통로가 앱에만 있다는 것입니다. 환경을 구분하지 않고 같은 함수를 호출할 수 있게 만든 과정을 적었습니다.

사진 한 장을 첨부하는 버튼

예약 취소 화면에 영수증 사진을 첨부하는 버튼이 있다고 해보겠습니다.

웹에서 누르면 파일 선택창이 열립니다. 앱 웹뷰에서 누르면 네이티브 사진첩이 올라옵니다. 사용자가 보는 결과는 같고, 하는 일은 전혀 다릅니다.

이걸 화면에서 처리하면 이렇게 됩니다.

// 화면마다 반복되는 모양
const handleAttach = async () => {
  if (isApp) {
    // 앱: 브릿지로 사진첩을 열고, 앱이 결과를 줄 때까지 기다린다
  } else {
    // 웹: input을 만들어 클릭하고, 파일을 읽는다
  }
};

문제는 이게 사진에서 끝나지 않는다는 것입니다. 새 창을 여는 것도, 로그인이 필요한지 확인하는 것도 같은 모양이 됩니다. 화면마다 if (isApp)이 붙습니다.

그리고 더 곤란한 게 하나 있습니다. 브릿지는 앱에만 있습니다. 웹에서는 부를 대상 자체가 없습니다.

브릿지가 무엇인지부터

웹뷰 안의 웹은 앱 안에 떠 있지만, 앱과 완전히 같은 세계에 있지는 않습니다. 그래서 웹이 앱의 함수를 직접 부를 수 없고, 앱 역시 웹의 함수를 직접 부를 수 없습니다.

이 둘 사이의 유일한 통로는 서로가 명시적으로 열어둔 길뿐입니다. 앱이 웹 쪽에 객체를 하나 심어 두면 웹이 그 객체의 함수를 부르고, 반대로 앱이 웹에 무언가를 전달할 때는 웹이 전역(Global)에 만들어 둔 함수를 앱이 부르는 식입니다.

앱이 어떻게 웹의 함수를 부르는지 웹뷰를 다뤄보지 않았다면 낯설 수 있습니다. 앱은 웹뷰 안에서 자바스크립트를 강제로 실행시킬 수 있습니다. iOS는 evaluateJavaScript, 안드로이드는 evaluateJavascript를 사용하는데, 앱이 "window.어떤함수()"라는 문자열을 넘기면 그 코드가 웹에서 실행되는 원리입니다.

그래서 앱의 호출을 받을 함수는 전역에 둘 수밖에 없습니다. 앱은 우리 웹의 내부 모듈 구조를 모르고, 이름 하나로 닿을 수 있는 유일한 공간이 window뿐이니까요. 전역에 변수나 함수를 얹는 건 보통 피하는 안티 패턴이지만, 여기서는 앱과 맞춘 규약이기에 예외로 둡니다.

그런데 여기서 큰 제약이 하나 발생합니다. 바로 호출한 함수에서 결괏값을 곧바로 돌려받을 수 없다는 점입니다. 나가는 길과 들어오는 길이 아예 다르기 때문이죠. 이 비동기적인 구조가 뒤에서 계속 우리의 발목을 잡습니다.

우리가 만든 것은 이 불편한 브릿지 위에 얹은 ‘얇은 추상화 층’입니다. 파일 하나에 브릿지 통신 함수를 모아 두고, 화면(컴포넌트)에서는 그 함수만 부르도록 했습니다. 현재 환경이 iOS인지, 안드로이드인지, 혹은 아예 앱이 아닌 웹 브라우저인지는 모두 그 층 안에서 처리됩니다.

없는 것을 어떻게 부르나

이게 이번 편의 문제입니다. 웹에서는 브릿지 객체가 존재하지 않으니, 부르는 쪽은 항상 확인해야 합니다.

if (appBridge) appBridge.requestPhoto();

방법이 몇 가지 있습니다.

하나. 호출부에서 매번 분기한다.

지금 보고 있는 그 모양입니다. 확실하고 명시적이지만, 사진을 첨부하는 화면마다 같은 분기가 복사됩니다. 그리고 그 분기 안에는 “앱은 base64로 준다” 같은 규약 지식이 같이 들어갑니다. 화면 하나가 앱 규약을 알게 되는 셈입니다.

둘. 웹용과 앱용 함수를 각각 만든다.

pickImageFileForWeb과 pickImageFileForApp처럼요. 그러면 분기는 사라지지만 고르는 일이 남습니다. 쓰는 사람은 여전히 지금이 어디인지 알아야 하고, 둘 중 하나를 잘못 고르면 조용히 안 됩니다.

셋. 웹에서 브릿지를 흉내 낸다.

웹에 가짜 네이티브 객체를 만들어 두고, 그 안에서 웹 방식으로 처리하는 겁니다. 호출부는 깨끗해집니다. 다만 흉내 내려면 앱 규약을 웹 쪽에서도 똑같이 구현해야 합니다. iOS가 인자를 어떻게 받는지, 응답이 어떤 모양인지까지요. 감추려던 규약이 한 벌 더 생깁니다.

셋 다 같은 자리에서 막힙니다. 어딘가는 “지금이 앱인가”를 알아야 합니다. 그러면 그 지점을 최대한 아래로 내리는 수밖에 없습니다.

그래서 반대로 갔습니다. 웹에서도 브릿지 객체를 만듭니다. 그저 안이 비어 있을 뿐입니다.

// 환경을 묻지 않고 항상 등록한다
appBridge = {
  requestPhoto,
  requestToken,
  enableWebBackHandling,
  /* ... */
};

이러면 appBridge는 어디서나 존재합니다. 웹에서 부르면 아무 일도 일어나지 않고, 그게 정상 동작입니다.

등록은 딱 한 번만 일어납니다.

// 모듈 스코프. React 렌더 사이클과 무관하다
let isInitialized = false;

if (typeof window !== 'undefined' && !isInitialized) {
  /* 브릿지 등록 */
  isInitialized = true;
}

컴포넌트 안에 두면 리렌더링될 때마다 다시 등록될 수 있어서, 파일 바깥에 플래그를 뒀습니다. 5편의 히스토리 매니저와 같은 방식입니다. 한 번만 있어야 하는 것은 React 밖에 둡니다.

앱에만 있는 것과 양쪽에 있는 것

브릿지를 만들면서 먼저 정해야 했던 게 있습니다. 모든 걸 한 뭉치로 둘 것인가.

나눴습니다. 기준은 “웹에도 대응하는 게 있는가”입니다.

앱에만 있는 것   ->  사진첩 열기, 인증 토큰 요청, 강제 로그아웃, 앱 화면 닫기
양쪽에 다 있는 것 ->  새 창 열기, 사진 고르기, 로그인 확인

앱에만 있는 것은 웹에서 부를 일이 없거나, 불러도 의미가 없는 것들입니다. 이쪽은 웹에서 조용히 지나가면 됩니다.

양쪽에 다 있는 것은 다릅니다. 웹에서도 실제로 동작해야 하고, 결과도 같은 모양이어야 합니다. 여기가 진짜 일이 있는 층입니다.

이 구분을 안 했다면 브릿지 함수 하나하나가 “나는 웹에서 어떻게 동작해야 하지”를 각자 고민했을 겁니다. 나눠 두니 답이 층별로 정해집니다.

층이 하나 더 있습니다. 앱의 다른 화면으로 보내는 것들입니다. 홈 탭으로, 로그인 화면으로, 마이페이지로.

이건 함수 호출이 아니라 정해진 주소로 이동하는 방식입니다. 문제는 그 주소가 사람이 외울 만한 모양이 아니라는 것입니다. 어느 탭인지, 기존 화면을 지울지, 어느 방향으로 열지가 전부 주소 문자열 안에 들어갑니다.

그래서 자주 쓰는 것들을 이름으로 묶어 뒀습니다.

goHome();
goLogin();
openWebView({ title, url });

화면 코드에서 주소 문자열을 직접 쓸 일은 없습니다. 틀리게 써도 오류가 나지 않고 그냥 아무 일도 일어나지 않기 때문에, 손으로 적게 두면 안 됐습니다.

세 환경이 함수 하나에 모여 있습니다

브릿지 호출이 실제로 어디로 가는지 보겠습니다. 모든 호출이 이 함수 하나를 지납니다.

const postMessage = (handlerName: BridgeHandlerName, args?: any) => {
  if (isIos) {
    // iOS는 인자가 없으면 null을 명시적으로 보내야 함
    const message = args === undefined ? null : args;
    window.webkit?.messageHandlers?.[handlerName]?.postMessage(message);
  } else if (isAos) {
    // 안드로이드는 앱이 주입한 객체의 함수를 직접 부른다
    const native = /* 앱이 주입한 객체 */;
    if (native?.[handlerName]) {
      args ? native[handlerName](args) : native[handlerName]();
    }
  } else {
    console.warn(`[AppBridge] Not Supported OS: ${handlerName}`);
  }
};

여기에 세 가지가 다 있습니다.

첫째, iOS와 안드로이드는 부르는 방법이 애초에 다릅니다. iOS는 메시지 핸들러에 메시지를 보내는 방식이고, 안드로이드는 앱이 심어 둔 객체의 함수를 그냥 부르는 방식입니다. 같은 “앱”이라고 묶었지만 규약이 다릅니다.

둘째, iOS는 인자가 없을 때 null을 명시해야 합니다. undefined를 보내면 안 됩니다. 이런 건 문서를 봐서 아는 게 아니라 부딪혀야 압니다.

같은 종류가 하나 더 있습니다. 어떤 함수는 true/false를 받는데, 시뮬레이터에서는 그 boolean이 오류가 납니다. 문자열로 보내야 통과하고요. 그래서 그 함수만 두 번 시도합니다.

try {
  postMessage('setFlag', value);
} catch {
  // 시뮬레이터에서는 문자열로 보내야 오류가 안 난다
  postMessage('setFlag', `${value}`);
}

try와 catch로 같은 걸 두 번 부르는 코드는 보기 좋지 않습니다. 다만 이런 게 브릿지 작업의 실제 모양이기도 합니다. 양쪽 규약이 완전히 맞물리지 않는 지점이 남고, 그걸 어딘가는 떠안아야 합니다. 그 자리를 화면이 아니라 이 파일로 정한 것입니다.

셋째, 웹에서는 경고만 찍고 지나갑니다. 에러가 아닙니다. 이 한 줄이 “웹에서도 그냥 불러도 된다”를 실제로 성립시킵니다.

핸들러 이름도 타입으로 묶어 뒀습니다. 앱이 지원하는 목록에 없는 이름을 쓰면 컴파일이 되지 않습니다. 문자열로 주고받는 통신이라 오타가 나면 조용히 아무 일도 일어나지 않는데, 그 조용함을 컴파일 시점으로 끌어올린 셈입니다.

덕분에 초기화 코드에도 분기가 없습니다.

appBridge.enableWebBackHandling();   // 웹에서도 그냥 부른다

지난 편들에서 나온 그 한 줄인데, 웹에서 부르면 조용히 무시됩니다. 그래서 앱용 초기화와 웹용 초기화를 따로 만들 필요가 없었습니다.

대신 대가가 있습니다. 웹에서 앱 전용 기능을 불러도 아무 일이 일어나지 않고, 타입도 통과합니다. 왜 안 되는지 알려면 콘솔을 봐야 합니다. 타입을 환경별로 나눠서 막을 수도 있었지만, 그러면 호출부가 다시 환경을 알아야 해서 애초에 없애려던 게 돌아옵니다.

답을 받아야 할 때

여기까지는 던지기만 하는 호출입니다. 그런데 사진이나 토큰처럼 값을 받아 와야 하는 것들이 있습니다.

브릿지에는 반환값이 없습니다. 앱이 답을 주는 방법은 하나뿐입니다. 전역 함수를 부르는 것.

그래서 나가는 길과 들어오는 길이 다릅니다. 그 둘을 Promise가 잇습니다.

웹에서 앱으로는 메시지, 앱에서 웹으로는 전역 함수 호출로 오가는 구조

요청은 오른쪽으로, 응답은 왼쪽으로 간다. 부르는 쪽에는 이 왕복이 한 줄로 보인다.

const requestToken = () =>
  new Promise<string>((resolve, reject) => {
    // 3초 동안 응답이 없으면 실패로 본다
    timeoutId = setTimeout(() => reject(new Error('Response Timeout')), 3000);
    
    // 앱이 부를 함수를 미리 심어 둔다
    window.receiveToken = (token) => {
      clearTimeout(timeoutId);
      resolve(token);
    };
    
    // 모든 세팅이 끝난 후, 네이티브 함수 호출
    postMessage('requestToken');
  });

순서가 계약입니다. 받을 함수를 먼저 심고, 그다음에 네이티브를 부릅니다. 반대로 하면 답이 먼저 도착해서 아무도 못 받는 경우가 생깁니다. 코드에도 그 순서를 지키라고 주석이 붙어 있습니다.

부르는 쪽에서는 이게 그냥 await입니다.

const token = await appBridge.requestToken();

같은 함수, 다른 세계

이제 웹과 앱 양쪽에서 다 되어야 하는 것들입니다. 실제로 여기 들어 있는 건 셋뿐입니다. 새 창 열기, 사진 고르기, 로그인 확인.

적다는 게 오히려 중요합니다. 나머지 환경 차이는 이미 다른 곳에서 흡수했고, 여기 남은 건 웹에도 앱에도 있지만 방식이 완전히 다른 것들뿐입니다.

새 창 열기

가장 단순한 대비입니다.

if (isApp) {
  appTarget === 'webview' ? 새_웹뷰로_열기(params) : 외부_브라우저로_열기(params);
} else {
  window.open(params.url, '_blank', 'noopener,noreferrer');
}

앱에서는 웹뷰를 하나 더 띄우거나 기기 브라우저로 나가고, 웹에서는 새 탭이 뜹니다. 부르는 쪽은 windowOpen({ url }) 한 줄입니다.

사진 고르기

이쪽은 양쪽을 같은 형태로 수렴시킨 게 핵심입니다.

앱은 사진을 base64 문자열로 돌려줍니다. 그래서 웹도 base64로 맞췄습니다.

window.receivePhoto = (base64) => resolve(base64를_파일로(base64));

if (isApp) appBridge.requestPhoto();          // 네이티브 사진첩
else openFileWebFn(window.receivePhoto);      // <input type="file">

웹 쪽은 input 엘리먼트를 코드로 만들어 클릭하고, 선택된 파일을 읽어서 base64로 바꿔 같은 콜백에 넣습니다. 지난 편의 뒤로가기와 같은 모양입니다. 원리는 다르고 만나는 지점만 같습니다.

여기에도 걸린 게 하나 있습니다. 파일 선택창을 열었다가 그냥 닫으면 아무 이벤트도 오지 않습니다. 취소를 알 방법이 없어서, 다음 요청이 들어올 때 이전 것을 정리합니다.

로그인 확인

셋 중 가장 깊습니다. 그리고 웹과 앱의 차이가 가장 이상하게 벌어지는 곳입니다.

화면 쪽 코드는 이렇습니다.

const canProceed = await checkRequiredLogin({ isLoggedIn, confirmLoginIntent });
if (!canProceed) return;

웹에서는 이 함수가 끝나지 않습니다.

location.href = `${플랫폼_로그인}/login?redirectUri=...`;

// 페이지가 이동하기 전까지 빈 Promise를 반환해 로직을 일시정지시킨다
return new Promise(() => {});

로그인 페이지로 떠날 것이므로 resolve할 이유가 없습니다. 오히려 resolve하면 리다이렉트 직전 찰나에 다음 줄이 실행돼 버립니다. 그래서 일부러 영원히 대기하는 Promise를 돌려줍니다.

앱에서는 반대로 한참을 기다립니다. 순서가 이렇습니다.

네이티브 로그인 창을 띄웁니다. 앱이 성공을 알려 오면 세션을 다시 맞춥니다. 그런데 여기서 바로 끝내면 안 됩니다. 세션을 맞추는 건 서버와 한 번 더 통신하는 일이라, 그 결과가 화면에 반영되기까지 시간이 걸립니다. 그래서 로그인 상태를 표시하는 DOM 속성이 실제로 바뀌는 것까지 확인한 뒤에 끝냅니다.

확인할 대상을 못 찾으면 어떻게 할까요. 그냥 통과시키면 로그인이 안 된 채로 다음 단계가 진행됩니다. 그래서 못 찾으면 실패로 처리합니다. 애매할 때 통과시키지 않는 쪽을 골랐습니다.

사용자가 로그인 창을 그냥 닫고 돌아오는 경우도 있습니다. 이때는 앱이 아무것도 알려 주지 않으니, 화면이 다시 보이는 순간을 감지해서 정리합니다.

같은 한 줄인데 한쪽은 영영 끝나지 않고, 다른 쪽은 여러 단계를 지나 끝납니다. 부르는 화면은 둘 다 모릅니다.

결과

화면 코드에 남는 건 이만큼입니다.

const { file } = await pickImageFile();

이 줄은 지금이 웹인지 앱인지 묻지 않습니다. iOS인지 안드로이드인지도 묻지 않습니다. 인자를 null로 보내야 하는지, 답이 전역 함수로 오는지, 3초를 기다려야 하는지도 묻지 않습니다.

1편에서 한 벌로 갈 수 있는지가 기술 판단이라기보다 암묵지의 문제였다고 썼습니다. 이 회사의 웹뷰 규약은 검색해서 배울 수 있는 게 아니라 겪어야 아는 것이었고, 그래서 아는 사람이 병목이 된다고요.

이번 편에서 만든 게 정확히 그 병목을 옮기는 작업입니다. iOS가 null을 요구한다는 것, 응답 함수를 먼저 심어야 한다는 것, 파일창을 닫으면 이벤트가 없다는 것. 전부 겪어야 알던 것들인데 지금은 한 파일 안에 들어 있습니다.

감춘다는 게 남이 모르게 한다는 뜻은 아닙니다. 필요할 때 찾아볼 수 있게 한곳에 모아 두고, 평소에는 안 봐도 되게 하는 것에 가깝습니다.

다만 병목이 사라진 건 아닙니다. 앱 규약이 바뀌면 여전히 이 파일을 고쳐야 하고, 고치려면 규약을 알아야 합니다. 줄어든 건 지식의 양이 아니라 그 지식이 필요한 자리의 수입니다. 예전에는 화면마다 필요했고, 지금은 한 곳에서 필요합니다.

남은 것

3초는 근거가 약합니다. 앱이 응답을 주지 않을 때 그걸 알아챌 방법이 타임아웃밖에 없어서 둔 값이고, 기기가 느리거나 앱이 바쁘면 정상 응답을 실패로 볼 수 있습니다. 5편의 100밀리초와 같은 종류입니다. 기다려 보는 것 말고 방법이 없을 때 나오는 숫자입니다.

다음 편에서는 인증 이야기를 하겠습니다. 웹은 쿠키를 쓰고 앱은 토큰을 주는데, 화면은 로그인했는지만 알면 됩니다.