<!-- mobian-agent-page publisher="dailydev" canonical="https://daily.dev/posts/7-10--w7ejjjv60" -->

---
title: 항공 프론트엔드 구축기 (7/10): 창구를 하나만 두었습니다 | daily.dev
description: Discussion about "항공 프론트엔드 구축기 (7/10): 창구를 하나만 두었습니다" on daily.dev - join the developer community
canonical: https://daily.dev/posts/7-10--w7ejjjv60
twitter:card: summary_large_image
twitter:site: @dailydotdev
og:type: website
og:site_name: daily.dev
og:title: 항공 프론트엔드 구축기 (7/10): 창구를 하나만 두었습니다 | daily.dev
og:description: Discussion about "항공 프론트엔드 구축기 (7/10): 창구를 하나만 두었습니다" on daily.dev - join the developer community
og:url: https://daily.dev/posts/7-10--w7ejjjv60
og:image: https://api.daily.dev/og/posts/w7EJJJv60.png
og:image:alt: 항공 프론트엔드 구축기 (7/10): 창구를 하나만 두었습니다
og:image:width: 1200
og:image:height: 630
og:locale: en
---

> ## Documentation Index
> Fetch the complete documentation index at: https://daily.dev/llms.txt
> Use this file to discover all available pages before exploring further.

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

**[Korean Devs](https://daily.dev/sources/koreandevs)** · [@seyoungcho](https://daily.dev/seyoungcho) · 0 upvotes · 0 comments

## Content

**_출처: 여기어때 기술 블로그_**

[_글 원문 보기_](https://techblog.gccompany.co.kr/%ED%95%AD%EA%B3%B5-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B5%AC%EC%B6%95%EA%B8%B0-5-10-%EB%92%A4%EB%A1%9C%EA%B0%80%EA%B8%B0%EA%B0%80-%EA%B0%80%EC%9E%A5-%EC%96%B4%EB%A0%A4%EC%9B%A0%EB%8B%A4-a8d8a5d75715)

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

## **사진 한 장을 첨부하는 버튼**

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

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

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

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

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

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

## **브릿지가 무엇인지부터**

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

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

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

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

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

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

## **없는 것을 어떻게 부르나**

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

```ts
if (appBridge) appBridge.requestPhoto();
```

방법이 몇 가지 있습니다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```ts
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이 오류가 납니다.** 문자열로 보내야 통과하고요. 그래서 그 함수만 두 번 시도합니다.

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

```

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

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

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

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

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

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

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

## **답을 받아야 할 때**

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

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

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

![22\.webp](https://media.daily.dev/image/upload/s---Hojp-pi--/f_auto/v1787468077/ugc/content_56bf9135-8f9e-4456-8f81-b3964dd489eb?_a=BAMAMicg0)

```ts
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`입니다.

```ts
const token = await appBridge.requestToken();
```

## **같은 함수, 다른 세계**

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

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

## **새 창 열기**

가장 단순한 대비입니다.

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

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

## **사진 고르기**

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

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

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

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

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

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

## **로그인 확인**

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

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

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

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

```ts
location.href = `${플랫폼_로그인}/login?redirectUri=...`;
// 페이지가 이동하기 전까지 빈 Promise를 반환해 로직을 일시정지시킨다
return new Promise(() => {});
```

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

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

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

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

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

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

## **결과**

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

```ts
const { file } = await pickImageFile();
```

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

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

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

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

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

## **남은 것**

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

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

---

[View this post on daily.dev](https://daily.dev/posts/7-10--w7ejjjv60)

```json
{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://daily.dev/#organization","name":"daily.dev","url":"https://daily.dev","logo":{"@type":"ImageObject","url":"https://daily.dev/apple-touch-icon.png","width":180,"height":180},"sameAs":["https://twitter.com/dailydotdev","https://github.com/dailydotdev","https://www.linkedin.com/company/daily-dev-ltd"]},{"@type":"WebSite","@id":"https://daily.dev/#website","url":"https://daily.dev","name":"daily.dev","publisher":{"@id":"https://daily.dev/#organization"},"potentialAction":{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https://daily.dev/search?q={search_term_string}"},"query-input":"required name=search_term_string"}}]}
{"@context":"https://schema.org","@type":"DiscussionForumPosting","mainEntityOfPage":"https://daily.dev/posts/7-10--w7ejjjv60","headline":"항공 프론트엔드 구축기 (7/10): 창구를 하나만 두었습니다","text":"Discussion about \"항공 프론트엔드 구축기 (7/10): 창구를 하나만 두었습니다\" on daily.dev - join the developer community","url":"https://daily.dev/posts/7-10--w7ejjjv60","datePublished":"2026-08-23T06:58:34.223Z","dateModified":"2026-08-23T06:58:34.223Z","author":{"@type":"Person","name":"SeyoungCho","url":"https://daily.dev/seyoungcho","image":"https://media.daily.dev/image/upload/s---QniJFEK--/f_auto/v1724901542/avatars/avatar_O3ucZSadxc0iLADSOH3rX","interactionStatistic":{"@type":"InteractionCounter","interactionType":{"@type":"EndorseAction"},"userInteractionCount":9190}},"image":"https://media.daily.dev/image/upload/s--lXWdLI0X--/f_auto/v1787468314/posts/w7EJJJv60?_a=BAMAMicg0","interactionStatistic":[{"@type":"InteractionCounter","interactionType":{"@type":"LikeAction"},"userInteractionCount":0},{"@type":"InteractionCounter","interactionType":{"@type":"CommentAction"},"userInteractionCount":0}],"isPartOf":{"@type":"WebPage","url":"https://daily.dev/squads/koreandevs","name":"Korean Devs"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://daily.dev"},{"@type":"ListItem","position":2,"name":"Korean Devs","item":"https://daily.dev/squads/koreandevs"},{"@type":"ListItem","position":3,"name":"항공 프론트엔드 구축기 (7/10): 창구를 하나만 두었습니다"}]}
```

