Engineering
항공 프론트엔드 구축기 (1/10): 웹, 모바일웹, 웹뷰를 코드 한 벌로
에릭Eric_jeong(정용욱)여기어때
2026년 8월 5일
원문에서 보기 ↗글. 정용욱(Eric) / 서비스웹개발팀

항공 프론트엔드 구축기: 웹, 모바일웹, 웹뷰를 코드 한 벌로
안녕하세요. 서비스웹개발팀의 에릭입니다.
항공 서비스를 새로 만들면서 웹과 모바일웹, 앱 웹뷰를 코드 한 벌로 가기로 했습니다. 그 뒤로 정한 것들을 열 편으로 나눠 적어 보려고 합니다.
첫 편은 그 결정 자체에 대한 이야기입니다. 무엇을 만들었는지보다, 왜 그렇게 가기로 했고 그게 성립하려면 무엇이 필요했는지를 적었습니다.
얼마나 공유할 수 있는가
2025년 10월부터 서비스웹개발팀과 웹뷰개발팀이 함께 일하기 시작했고, 이듬해 초 공식적으로 하나가 됐습니다. 항공 서비스 리뉴얼은 합친 뒤 처음 맡은 신규 서비스였습니다.
당시 항공 서비스의 프론트엔드는 Vue2 기반 앱 전용 웹뷰 하나가 전부였습니다. 이번 리뉴얼은 기존 웹뷰를 새로 만드는 일인 동시에, 아예 없던 웹을 0부터 만드는 일이기도 했습니다.
처음에는 사내에서 하던 방식 그대로 가기로 했습니다. 반응형 웹은 웹대로, 웹뷰는 웹뷰대로 만드는 구성입니다. 그런데 Vue2를 계속 안고 갈 것인가에 대해 의견이 갈렸습니다. 오래된 스택을 새 서비스에 다시 얹는 게 맞느냐는 문제 제기였고, 타당한 지적이었습니다.
그래서 질문이 하나 남았습니다.
웹과 웹뷰는 코드를 얼마나 공유할 수 있는가.
이 글은 그 질문에 답을 정하는 과정입니다. 일정은 넉넉하지 않았고, 결정을 미룰수록 개발에 쓸 시간이 줄어드는 상황이었습니다. 그래서 제가 검토안을 문서로 정리했습니다. 여러 그림을 말로 주고받는 것보다, 하나를 놓고 반박하는 편이 빠르다고 봤습니다.
선택지는 셋이었습니다
1안. 하던 대로 간다. 반응형 웹과 웹뷰를 각각 만듭니다. 사내에서 익숙한 구성이라 새로 정할 게 없고, 가장 빠르게 출발할 수 있습니다.
이걸 먼저 후보에 둔 건 실제로 합리적이기 때문입니다. 새 구조를 만들다 실패하면 일정이 통째로 날아가는데, 이 안은 그 위험이 없습니다. 모르는 게 없으니까요.
대신 같은 화면을 두 번 만들게 되고, 고칠 때마다 두 곳을 고쳐야 합니다. 그리고 이번에는 만들 화면이 적지 않았습니다. 검색, 검색 결과, 상품 상세, 예약, 결제, 결제 결과, 예약 목록, 예약 상세. 여기에 국내선과 해외선이 각각 붙습니다.
2안. 로직만 공유하고 화면은 따로 만든다. 웹과 웹뷰는 화면 요구가 다르니, 공통되는 부분만 뽑아 쓰자는 겁니다. 검색 로직, 요금 계산, API 호출. 확실히 공유할 수 있어 보입니다.
3안. 코드 한 벌로 간다. 웹, 모바일웹, 웹뷰를 하나의 코드로 처리하고 환경 차이는 감춥니다.
2안은 절충안이고, 절충안은 대개 안전합니다. 그래서 이 안을 먼저 따져 봐야 했습니다.
2안이 부딪히는 지점
이 방식은 “로직”과 “화면”이 나뉜다는 전제 위에 서 있습니다. 그 전제가 어디서 깨지는지가 문제였습니다.
드롭다운이 열려 있는 상태를 생각해 보겠습니다. 이건 로직인가요, 화면인가요.
무엇이 선택됐는지는 로직입니다. 그런데 지금 열려 있는지, 몇 번째 항목에 포커스가 있는지, 목록이 어디까지 스크롤됐는지는 어떤가요. 화면처럼 보이지만, 화면을 따로 만드는 순간 이것들도 각각 갖게 됩니다.
더 큰 것도 있습니다. 뒤로가기를 눌렀을 때 무엇이 닫혀야 하는가. 이건 라우팅이자 히스토리이면서, 동시에 “지금 무엇이 떠 있는가”라는 화면의 문제입니다. 웹과 웹뷰의 화면 구조가 갈리면 이 판단도 각각 만들어야 합니다.
그러면 순서가 이렇게 됩니다. 처음엔 로직만 공유합니다. 그러다 상태가 갈립니다. 상태를 맞추려다 라우팅이 갈립니다. 결국 화면과 로직이 함께 두 벌이 됩니다.

로직만 공유하는 구조가 상태와 라우팅이 갈리면서 결국 두 벌이 되는 과정
화면 쪽이 두꺼워지는 만큼 공유층은 얇아진다. 다만 사라지지는 않는다.
문제는 그게 처음부터 두 벌인 것보다 나쁘다는 점입니다. 공유하는 층이 남아 있으니 양쪽 다 그 층의 눈치를 봐야 하거든요. 고칠 때마다 “이건 공통인가 아닌가”를 먼저 판단해야 합니다.
당시에는 이게 예측이었습니다. 지금은 코드에서 확인할 수 있는 대목들이 생겼고, 그 이야기를 이 시리즈에서 하려고 합니다.
무엇을 아는가의 문제이기도 했습니다
한 벌로 간다는 건 웹과 웹뷰의 차이를 감춘다는 뜻입니다. 그러려면 앱이 던질 수 있는 경우를 전부 알아야 합니다.
그런데 이 회사의 웹뷰 브릿지는 업계 표준이 아니라 고유 규약입니다. 앱과 웹이 주고받는 방식, 네이티브가 웹뷰를 띄우고 닫는 방식, 스키마 연동 규칙. 검색해서 배울 수 있는 게 아니라 겪어야 알 수 있는 것들입니다.
모르는 표면 위에는 추상화를 세울 수 없습니다. 그래서 이 결정은 “설계를 잘할 수 있는가”보다 “그 규약이 어디까지 파악되어 있는가”에 더 많이 달려 있었습니다.
그리고 이건 그 자체로 위험 신호이기도 합니다. 소수만 아는 규약 위에 서비스를 올리면, 그 규약을 아는 사람이 병목이 됩니다. 한 벌로 가든 안 가든 언젠가는 풀어야 할 문제였습니다.
한 벌이 성립하려면
검토안에는 세 가지 조건을 적었습니다.
첫째, 소수 인원이 프로젝트와 라이브러리를 빠르게 구성해야 한다. 기간이 짧기 때문입니다. 함께 만들면 좋지만 시작부터 사공이 많으면 출발 자체가 늦어집니다. 구성한 뒤에 피드백을 받는 편이 낫다고 봤습니다.
둘째, 웹뷰 라이브러리의 공통 컴포넌트를 그대로 포팅할 수 있어야 한다. 컴포넌트를 새로 만들 시간은 없었습니다. 다행히 기존 Vue2 라이브러리는 Vue에 종속된 UI 라이브러리를 쓰지 않았고, 디자인 토큰이 Tailwind 설정에 정리되어 있었습니다. 옮길 수 있어 보였습니다.
셋째, 개발자 경험(Developer Experience, DX)이 가장 중요하다. 검토안에는 이렇게 적혀 있습니다. “프론트 프로젝트 세팅이 오래 걸리는 건 구성이 잘못된 것이다.” 개발자가 싫어하는 프로젝트는 설정이 어렵고 흐름이 추적되지 않는 프로젝트입니다. 웹과 앱의 차이는 라이브러리가 전부 감싸고, 그 위에서 개발하는 사람은 로직에만 신경 쓸 수 있어야 한다고 봤습니다.
편하자고만 둔 조건은 아닙니다. 환경을 신경 쓰느라 나가는 시간이 줄면, 그만큼이 로직과 예외 처리를 들여다보는 시간이 됩니다. 일정이 넉넉하지 않을수록 그 시간이 곧 품질이라고 봤습니다.
둘째와 셋째는 지켜졌습니다. 첫째는 조금 다르게 갔습니다.
“2~3명”이라고 썼지만, 실제로는 제가 한 달간 혼자 선행하는 것으로 협의했습니다. 조건에 적어 둔 이유(시작부터 사공이 많으면 출발 자체가 늦어진다)를 끝까지 밀면 그렇게 되는 면도 있었습니다. 이후 프로젝트를 진행하면서 팀에서 함께 보완했습니다.
정해서 간 일이니 누가 잘못한 건 없습니다. 다만 그렇다고 비용이 사라지는 건 아닙니다. 한 사람이 기반을 잡으면 그 판단의 빈틈이 그대로 남습니다.
그리고 앞에서 말한 병목이 그대로 생겼습니다. 제가 알고 있다는 건 팀에서 저만 안다는 뜻이니까요.
그래서 혼자 준비하는 동안 목표를 하나 더 두었습니다. 아는 것을 제 바깥에 옮겨 둘 것. 앱과 맞춘 규약은 브릿지 코드로, 그렇게 정한 이유는 가이드 문서로, 컴포넌트의 동작은 스토리북으로 옮겼습니다.
기반이 서면 그 위의 로직 개발은 동료들이 맡을 것이었고, 그때 저를 찾아와 물어야만 아는 것이 많을수록 앞의 병목이 굳어집니다. 병목을 없애지는 못해도, 저를 거치지 않고 찾아볼 수 있게는 만들 수 있었습니다.
그래서 무엇을 만들어야 하는가
조건이 갖춰졌는지와, 그래서 뭘 만들어야 하는지는 다른 문제입니다. 목록을 뽑아 보니 이랬습니다.
- 공용 컴포넌트: 버튼부터 바텀시트까지. 기존 Vue2 라이브러리에서 옮겨온다
- 환경 추상화: 앱 브릿지를 감싸서, 웹이든 웹뷰든 같은 함수를 부르게
- 뒤로가기 제어: 브라우저 히스토리와 안드로이드 백키를 하나의 규약으로
- 레이아웃: 헤더·본문·하단 버튼 구조를 한 컴포넌트로
- 인증: 웹은 쿠키, 앱은 브릿지 토큰. 둘을 같은 인터페이스로
앞의 하나는 옮기는 일이고, 나머지 넷은 한 벌로 가기로 했기 때문에 새로 필요해진 것들입니다. 웹뷰만 있던 시절에는 브라우저 히스토리를 신경 쓸 일도, 쿠키 인증을 붙일 일도 없었으니까요.
이 목록이 그대로 이 시리즈의 목차가 됐습니다.
목표는 “차이를 없애는 것”이 아니었습니다
그래서 목표를 정했습니다. 다만 “환경 차이를 없앤다”는 아니었습니다.
없앨 수 있는 차이가 아니기 때문입니다. 뒤로가기 규약이나 앱 브릿지처럼 감춰야 하는 것 도 있지만, 데스크톱과 모바일의 디자인처럼 실제로 달라야 하는 것도 있습니다. 후자를 억지로 감추면 오히려 이상한 화면이 나옵니다.
그래서 이렇게 잡았습니다. 감출 수 있는 건 감추고, 감출 수 없는 건 한 줄로 갈리게 하자.
한 벌로 간다는 건 분기가 없다는 뜻이 아닙니다. 분기가 싸다는 뜻입니다. 데스크톱에서 가운데 뜨는 팝업을 모바일에서 전체 화면으로 바꾸는 데 네 줄이면 되고, 뒤로가기는 아예 신경 쓰지 않아도 되는 상태. 세 번째 조건과 같은 이야기이고, 이 시리즈에서 소개할 것들은 대부분 그 값을 낮추는 작업이었습니다.
걱정했던 다섯 가지
검토안 마지막에는 걱정되는 점을 적어 두었습니다. 그대로 옮기면 이렇습니다.
- 개발 기간이 너무 짧다. 한 벌로 갈 준비가 되어 있어도 부족한 일정이었습니다.
- 앱과 연관된 기능. 네이티브에서의 웹뷰 검색·캘린더 호출, 스키마 연동 같은 것들입니다. 놓치면 이미 운영 중인 이벤트에 영향이 갑니다.
- 환경별 코드 차이를 없애는 준비의 어려움. 분량이 꽤 될 것으로 봤습니다.
- App Router 도입에 따른 러닝커브. 기존에는 양쪽 다 Page Router 기반이었습니다.
- 국내선 웹뷰는 당분간 기존 Vue2를 그대로 써야 한다. 결국 Vue2 프로젝트 관리가 남습니다.
앞의 넷은 실제로 마주쳤습니다. 어떤 건 예상대로였고, 어떤 건 예상과 달랐습니다. 그 이야기는 시리즈에서 하나씩 하려고 합니다.
다섯 번째는 다르게 풀렸습니다. 국내선 웹뷰도 이번에 같이 새로 만들기로 했거든요. 그래서 Vue2를 계속 안고 갈 일은 없어졌고, 대신 할 일이 늘었습니다.
걱정을 적어 두긴 했지만, 그렇다고 시작을 미룰 수 있는 상황은 아니었습니다.
가장 먼저 정한 건 저장소 구조였습니다
공용 컴포넌트를 라이브러리로 만들기로 했으니, 그걸 어디에 둘지 정해야 했습니다.
처음에는 별도 저장소에 두고 패키지로 배포하는 방식을 생각했습니다. 그런데 구성 초기에는 라이브러리가 하루에도 몇 번씩 바뀝니다. 버튼 하나 고칠 때마다 커밋하고, 버전을 올리고, 배포하고, 서비스 쪽에서 다시 설치해야 합니다. 한 번 확인하는 데 몇 분이 걸립니다. 이걸 반복하면서 라이브러리를 만들 수는 없었습니다.
그래서 모노레포로 묶었습니다.
monorepo/
├── apps/
│ └── flight/ 서비스. package.json에서 "workspace:*" 로 아래를 참조
├── packages/
│ ├── core/ 공용 컴포넌트 · 훅 · 브릿지
│ └── web-ui/ 도메인 컴포넌트
└── pnpm-workspace.yaml
workspace:*는 "배포된 버전"이 아니라 "옆 폴더"를 가리킵니다. 라이브러리 파일을 저장하는 순간 서비스 화면에 반영됩니다.
특별한 선택은 아닙니다. 모노레포를 쓰는 가장 흔한 이유가 이겁니다. 배포와 설치를 거치지 않고 여러 패키지를 함께 고치는 것. 표준적인 용례를 그대로 따랐습니다.
다만 배포가 없어진 건 아닙니다. 모노레포 밖에 있는 기존 프로젝트도 이 라이브러리를 써야 해서, 사내 레지스트리로 배포하는 경로는 따로 두었습니다. 모노레포가 배포를 없앤 게 아니라, 개발하는 동안의 루프에서 배포를 걷어낸 겁니다.
결과
국내선과 해외선을 함께 열었습니다. 사용자에게는 탭 하나 차이로 보이지만, 두 서비스는 예약 모델부터 다릅니다. 해외선에는 여권, 마일리지, 항공사 연합, e티켓처럼 국내선에 없는 개념이 붙습니다.
얼마나 다른지는 API 디렉토리를 보면 분명합니다.
src/
├── domestic/api/
│ ├── booking/ 예약
│ ├── cancel/ 취소
│ ├── coupon/ 쿠폰
│ ├── schedule/ 운항 스케줄
│ └── ...
│
├── international/api/
│ ├── booking/ ← 이름은 같고, 구현은 다르다
│ ├── cancel/
│ ├── coupon/
│ ├── schedule/
│ ├── passport/ 여권
│ ├── mileage/ 마일리지
│ ├── airline-alliance/ 항공사 연합
│ ├── eticket/ e티켓
│ └── ...
│
└── shared/api/
├── auth/ ← 양쪽이 함께 쓰는 것
├── server-time/
├── calendar/
├── exhibition/ 전시 배너
└── ...
예약, 취소, 쿠폰, 운항 스케줄. 이름이 같은 것들도 각자 갖고 있습니다. 함께 쓰는 쪽에 남은 건 인증, 서버 시간, 달력, 전시 배너처럼 예약 바깥의 것들입니다. 탑승객 정보조차 해외선은 여권 항목이 붙어서 따로 갖고 있습니다.
사실상 두 개의 예약 서비스를 동시에 연 셈입니다.
그 위에 환경은 셋입니다. 웹, 모바일웹, 앱 웹뷰.
한 벌이 실제로 됐는지는 이렇게 확인할 수 있습니다. 라우트 트리는 웹용과 웹뷰용 두 벌입니다.
app/
├── (web)/ 웹 / 모바일웹
│ ├── layout.tsx
│ ├── domestic/{srp, booking, payment}
│ ├── international/{srp, booking}
│ └── mypage/bookings/{domestic, international}
│
└── (native)/ 앱 웹뷰
├── layout.tsx
└── (같은 트리)
그런데 화면 구현은 한 벌입니다. 양쪽 라우트 파일이 모두 같은 구현을 가리킵니다.
$ diff "app/(web)/domestic/srp/page.tsx" "app/(native)/domestic/srp/page.tsx"
23c23
< export default async function WebDomesticSrp(pageProps: DomesticSrpPageProps) {
---
> export default async function NativeDomesticSrp(pageProps: DomesticSrpPageProps) {
양쪽에 같은 이름으로 있는 라우트 대부분이 이렇습니다. 함수 이름만 다르거나 아예 같습니다.
나머지에서도 화면은 같습니다. 다른 건 서버에서 하는 일이었습니다. 웹에서만 로그인 가드를 걸거나, 목록을 미리 받아 두거나, 점검 안내 문구가 다르거나. 화면 컴포넌트를 부르는 줄은 양쪽이 똑같습니다.
실질적인 차이는 레이아웃 파일에 모였습니다. 사용자 정보를 서버에서 주입할지, 플랫폼 값을 무엇으로 내려보낼지. 검토안에 “차이는 레이아웃 캡슐화 정도”라고 적어 두었는데, 그 예측은 크게 어긋나지 않았습니다.
그 아래에 라이브러리가 있습니다. 컴포넌트와 훅, 초기화 모듈 수십 개. 앞에서 뽑았던 목록이 이만큼이 됐습니다.
다음 편부터 하나씩 들어가겠습니다. Vue2 디자인 시스템을 어떻게 옮겼는지, 클래스 이름 앞에 세 글자를 붙였다가 무슨 일이 있었는지, “앱이냐”와 “좁냐”를 왜 다른 질문으로 나눴는지, 그리고 뒤로가기 하나가 왜 이 구성에서 가장 어려운 문제였는지.