Engineering
항공 프론트엔드 구축기 (4/10): “앱이냐”와 “좁냐”는 다른 질문이다
에릭Eric_jeong(정용욱)여기어때
2026년 8월 6일
원문에서 보기 ↗글. 정용욱(Eric) / 서비스웹개발팀

항공 프론트엔드 구축기: “앱이냐”와 “좁냐”는 다른 질문이다
안녕하세요. 서비스웹개발팀의 에릭입니다.
앞 편까지는 옮기고 세우는 이야기였습니다. 이번 편부터는 세 환경을 실제로 한 벌로 굴리는 이야기입니다. 처음에는 환경 판단이 하나면 될 줄 알았는데, 만들어 보니 서로 다른 질문 셋이었습니다.
창을 좁혔더니 열려 있던 게 그대로 있었습니다
문의 화면에는 이용 상태를 고르는 드롭다운이 있습니다.
데스크톱에서 이걸 열어 둔 채로 브라우저 창을 좁혀 봅니다.
드롭다운이 사라지고 바텀시트가 올라옵니다. 그런데 열린 상태 그대로 올라옵니다. 다시 눌러서 열 필요가 없습니다.

드롭다운을 열어 둔 채 창을 좁히면 바텀시트로, 팝업은 전체 화면으로 바뀌는 모습
드롭다운이 시트로 바뀌는 동안 팝업도 가운데 카드에서 전체 화면으로 바뀐다. 둘 다 열린 상태 그대로고, 다시 넓혀도 마찬가지다.
디자인 스펙에 있던 동작은 아닙니다. 다만 반응형이라면 이래야 한다고 봤습니다.
사용자는 창 너비를 바꿨을 뿐이지, 하던 일을 취소한 게 아니니까요. 너비가 상태를 건드리기 시작하면 어디까지 살아남고 어디부터 날아가는지를 화면마다 따로 외워야 합니다.
별것 아닌 것 같지만, 웹용 화면과 웹뷰용 화면을 따로 만들었다면 이렇게 되지 않습니다.
그리고 여기서 이 편의 질문이 시작됩니다. 방금 바뀐 건 화면 너비 뿐이고, 앱인지 웹인지는 그대로였습니다. 앱인데 태블릿이라 넓을 수도 있고, 웹인데 휴대폰이라 좁을 수도 있습니다. 둘은 같이 움직이지 않습니다.
하나인 줄 알았던 질문이 셋이었습니다
처음 검토안에는 환경 판단이 하나였습니다. isWebview 하나로 분기하면 될 거라고 봤습니다.
만들어 보니 서로 다른 질문 셋이었습니다.
- 첫째, 앱 안에서 뜬 화면인가. 뒤로가기를 앱에게 넘겨받아야 하는지, 브릿지를 쓸 수 있는지가 여기 달렸습니다. 이 값은 서버에서 정해져 내려오고, 세션 내내 바뀌지 않습니다.
- 둘째, 지금 화면이 좁은가. 데스크톱 레이아웃을 쓸지 모바일 레이아웃을 쓸지의 문제입니다. 브라우저 창을 줄이면 바뀝니다.
- 셋째, 마우스가 있는가. hover 스타일을 줘도 되는지의 문제입니다.
셋은 서로 독립입니다. 앞의 둘이 그랬고, 마우스가 있느냐도 나머지 둘과 따로 놉니다. 하나로 묶었다면 “앱이면 무조건 모바일 레이아웃” 같은 분기가 굳어졌을 겁니다.

앱이냐와 좁냐를 두 축으로 놓았을 때의 네 가지 조합
플래그 하나로는 대각선 두 칸을 표현할 수 없다. 이 둘이 실재하니 축도 둘이어야 한다.
그래서 훅을 나눴습니다.
const { isApp } = usePlatform(); // 앱 안에서 뜬 화면인가 (서버 주입, 불변)
const { isMobile } = useScreen(); // 지금 화면이 좁은가 (리사이즈마다 갱신)
첫 번째는 값이 어디서 오는지가 좀 특이합니다. 서버가 정해서 내려보내는데, 판단 조건이 셋입니다.
const computedIsWebAccess = isWeb || isWebAccess || forceWebAccess;
// 1. 웹으로 들어왔으면 웹
// 2. 루트 레이아웃에서 웹이라고 넘겨주면 웹
// 3. 개발자가 강제로 웹이라고 켜 두면 웹
세 번째가 실무용입니다. 배포된 앱 빌드를 웹브라우저로 열어서 확인할 때 씁니다. 앱 안에서만 되는 화면을 디버깅하려고 매번 기기에 붙일 수는 없으니까요. 설계 단계에서는 없던 조건인데, 만들면서 필요해졌습니다.
hover는 훅이 아니라 CSS 쪽으로 갔습니다.
터치 디바이스에서 hover:를 그냥 쓰면 이상하게 동작합니다. 손가락에는 "올려놓기"가 없어서, 브라우저가 탭을 hover로 한 번 처리하고 넘어갑니다. 그래서 버튼을 누르고 떼면 눌린 색이 그대로 남습니다. 다른 곳을 눌러야 풀립니다.
목록에서 특히 티가 납니다. 항목을 눌러 들어갔다가 뒤로 나오면, 아까 누른 항목만 회색으로 떠 있습니다.
그래서 변형자를 하나 만들었습니다.
addVariant('hoverable', '@media (hover: hover) and (pointer: fine)');
이제 hoverable:hover:yf-bg-... 로 쓰면 마우스가 있는 환경에서만 적용됩니다. 두 줄짜리 플러그인이지만, 이걸 안 만들었으면 컴포넌트마다 같은 고민을 했을 겁니다.
감출 차이와 드러낼 차이
앞 편들에서 “환경 차이를 감춘다”는 이야기를 했습니다. 그런데 이 셋은 성격이 다릅니다.
첫 번째는 감춥니다. 뒤로가기 규약이나 앱 브릿지는 개발자가 몰라도 되도록 라이브러리가 흡수했습니다. 이건 다음 편 주제입니다.
두 번째는 감추지 않습니다. 데스크톱과 모바일의 디자인이 실제로 다르기 때문입니다. 억지로 감추면 어느 한쪽이 이상해집니다.
숫자로 보면 분명합니다. useScreen을 쓰는 파일이 앱과 라이브러리를 합쳐 백 개가 넘습니다. 아무도 화면 크기를 모르고 개발하지 않습니다.
한 벌로 간다는 게 분기가 없다는 뜻이 아니라는 걸, 이 숫자가 보여줍니다. 분기는 합니다. 다만 싸게 합니다.
싸다는 건 실행이 빠르다는 뜻이 아닙니다. 고치는 값이 싸다는 뜻입니다. 한 화면을 데스크톱과 모바일로 갈라 놓는 데 파일을 새로 만들 필요가 없고, 나중에 디자인이 바뀌어도 고칠 자리가 한 군데면 됩니다. 뒤에서 그게 실제로 몇 줄인지 보겠습니다.
그래서 경계를 어디에 둘지도 정해야 했습니다. 데스크톱은 912px부터, 모바일은 그 미만입니다. 다만 CSS 쪽 값이 조금 이상하게 생겼습니다.
mobile: { max: '911.98px' },
desktop: { min: '912px' },
911.98은 오타가 아닙니다. CSS 미디어쿼리에는 "미만"이 없고 "이하"만 있습니다. max-width: 912px로 쓰면 912가 양쪽에 다 걸려서, 창을 딱 912로 맞췄을 때 두 규칙이 겹칩니다. 그래서 소수점을 조금 빼 둡니다. Bootstrap도 같은 방식을 씁니다.
이 사정은 설정 파일에 주석으로 남겨 뒀습니다.
// media query level 4의 "<" "<=" 구문을 쓰기 전까지는 완벽한 해결이 불가능
JS 쪽은 이런 게 필요 없습니다. width < 912 라고 그냥 쓰면 되니까요. 같은 경계를 두 언어로 두 번 표현하는 셈이고, 값이 어긋나지 않게 맞춰 둬야 합니다.
서버는 화면 크기를 모릅니다
useScreen을 만들면서 먼저 부딪힌 건 서버 렌더링이었습니다. 서버에는 window가 없으니 화면 너비를 알 수 없습니다.
const width = useSyncExternalStore(
subscribe, // resize 이벤트 구독
() => window.innerWidth, // 브라우저: 실제 너비
() => 0 // 서버: 알 수 없음
);
useState에 resize 리스너를 붙이는 방식도 있습니다. 그런데 useSyncExternalStore를 쓴 이유가 둘 있습니다.
하나는 서버용 값을 따로 줄 수 있다 는 점입니다. 세 번째 인자가 그겁니다. useState로 하면 초기값을 하나만 정할 수 있어서, 서버에서 쓸 값과 브라우저에서 쓸 값을 나눌 자리가 없습니다.
다른 하나는 이게 브라우저 바깥에 있는 값이기 때문입니다. 창 크기는 React가 관리하는 상태가 아니라 브라우저가 들고 있는 값입니다. React 18부터는 이런 외부 값을 읽을 때 렌더 도중에 값이 바뀌면 화면 일부가 옛날 값으로 남을 수 있는데, 이 훅이 그걸 막아 줍니다. 창 크기처럼 자주 바뀌는 값에는 안전한 선택입니다.
그래서 첫 렌더는 항상 “모르는 상태”로 나갑니다. 브라우저에서 하이드레이션되는 순간 실제 너비가 들어오고, 그때 레이아웃이 한 번 튑니다(CLS).
검토한 대안이 있었습니다. 서버에서 User-Agent를 보고 추정하는 방법입니다. 모바일 기기면 375px, 아니면 1440px로 가정해서 내려보내는 거죠.
안 썼습니다. User-Agent는 기기 종류를 알려줄 뿐 브라우저 너비를 알려주지 않기 때문입니다. iPad Pro는 태블릿이지만 1024px가 넘고, 데스크톱에서 창을 좁게 쓰는 사람도 있습니다. 정확하지도 않은 값을 얻자고 미들웨어와 전역 Provider를 추가하는 건 비용이 더 컸습니다.
대신 튀는 것 자체를 막기로 했습니다. 방법이 둘인데, 하나를 골라 통일하지 않고 컴포넌트마다 갈라 씁니다. 왜 그런지는 둘을 보고 나면 분명해집니다.
방법 1. 둘 다 그려 놓고 CSS로 가린다
마이페이지 상단 탭이 이렇습니다.
<LineTab className="mobile:yf-hidden" type="scrollable" size="l-24" {...props} />
<LineTab className="desktop:yf-hidden" type="fixed" size="s" {...props} />
JS는 아무 판단도 하지 않습니다. 미디어쿼리는 브라우저가 처음부터 알고 있으니, 서버 HTML과 화면이 어긋날 일이 없습니다. 첫 화면부터 맞는 게 보입니다.
대신 DOM이 두 벌입니다. 탭은 안에서 너비를 재고 크기 변화를 관찰하는데, 안 보이는 쪽에서도 그게 돌아갑니다.
방법 2. 자리를 잡아 두고 나중에 그린다
다른 방법은 이렇습니다.
{!isMounted && <div className="yf-h-40 yf-w-full" />} {/* 자리만 잡아 둔다 */}
{isMounted && (
<div className="yf-animate-fade-in">
<RoundTab slidesOffset={isDesktop ? 0 : 20} {...props} />
</div>
)}
서버에서는 같은 높이의 빈 상자만 그립니다. 브라우저에 도착하면 그 자리에 진짜 탭이 들어오는데, 높이가 이미 맞으니 아래 내용이 밀리지 않습니다. 0.3초 페이드인을 걸어서 갑자기 나타나는 느낌도 없앴습니다.
isMounted도 같은 훅으로 만들었습니다.
useSyncExternalStore(
emptySubscribe,
() => true, // 브라우저
() => false // 서버
);
만드는 건 하나뿐입니다. 대신 잠깐 비어 있고, 높이를 미리 알고 있어야 합니다. yf-h-40 같은 숫자를 손으로 적어 두는 셈이라, 디자인이 바뀌면 이것도 같이 바꿔야 합니다.
둘은 트레이드오프입니다
한쪽이 정답은 아닙니다.
- 1번 : 첫 화면부터 제대로 보인다. 대신 DOM과 측정이 두 벌
- 2번 : 하나만 만든다. 대신 잠깐 비고, 높이를 알아야 한다
가벼운 컴포넌트고 첫 화면이 중요하면 1번이 편하고, 무겁거나 여러 개가 붙으면 2번이 낫습니다. 높이를 아예 예측할 수 없을 때는 보이지 않는 복제본으로 자리를 잡는 컴포넌트도 따로 두었습니다.
셋 다 같은 제약에서 나왔습니다. 서버 렌더링을 쓰는 이상 첫 화면은 너비를 모르는 채로 나가고, 그러면 남는 선택지가 “미리 둘 다 그려 두거나, 자리만 잡아 두거나”뿐입니다.
그래서 공통점도 하나입니다. 값을 추측하지 않는다는 것. 서버가 모르는 건 모르는 대로 두고, 대신 자리가 흔들리지 않게 합니다.
이 걱정이 필요 없는 곳도 있습니다
다만 모든 화면이 이런 건 아닙니다.
팝업이나 바텀시트는 처음부터 화면에 없습니다. 사용자가 무언가를 눌러야 나타나고, 그 시점에는 이미 브라우저에서 돌고 있습니다. 서버 HTML에 들어갈 이유도 없고 (검색 엔진이 볼 필요도 없으니) 들어가서도 안 됩니다.
그러니 이쪽에서는 useScreen 값이 처음부터 정확합니다. 하이드레이션 이후에만 존재하는 것들이니까요. 자리를 미리 잡아 둘 필요도, 값이 튀는 걸 걱정할 필요도 없습니다.
정리하면 이렇습니다.
- 첫 화면에 그려지는 것: 서버가 모르는 값이라 조심해야 한다
- 눌러야 나타나는 것: 이미 클라이언트다. 그냥 분기하면 된다
이 구분을 해 두면 어디에 신경 쓸지가 명확해집니다. 그리고 다음 이야기가 두 번째 경우입니다.
컴포넌트는 자기가 좁은지 모릅니다
이제 처음 이야기로 돌아갑니다. 창을 좁혔는데 드롭다운이 열린 채로 남아 있던 이유요.
드롭다운은 화면 크기에 따라 완전히 다른 것을 그립니다. 데스크톱에서는 트리거 아래 붙는 레이어고, 모바일에서는 화면 아래에서 올라오는 시트입니다.
“다르다”가 모양만은 아닙니다. 데스크톱 레이어는 열릴 때 뷰포트를 재서, 아래 공간이 모자라면 위로 뒤집습니다.
const spaceBelow = window.innerHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
// 선호 위치가 안 들어가고 반대쪽이 들어가면 뒤집는다
바텀시트에는 이런 계산이 없습니다. 항상 화면 아래에서 올라오니까요. 포커스 이동도 각각 붙어 있고, 바깥을 눌러 닫는 처리도 다릅니다. 바텀시트는 포털로 그려지니 “바깥 클릭”의 기준이 달라지거든요.
그런데 이 컴포넌트는 useScreen을 쓰지 않습니다.
interface DropDownProps {
/** 모바일 환경 여부. OptionLayer 대신 BottomSheet로 노출된다 */
isMobile?: boolean;
}
prop으로 받습니다. 판단은 쓰는 쪽이 하고, 컴포넌트는 듣기만 합니다.
const { isMobile } = useScreen();
<SimpleDropDown isMobile={isMobile} options={options} onChange={onChange} />
이 차이가 결과를 가릅니다. isMobile이 바뀌어도 컴포넌트 자신은 언마운트되지 않습니다. prop 하나가 바뀌고 다시 그려질 뿐입니다. 그래서 안에 있던 상태가 살아남습니다.
const [isOpen, setIsOpen] = useState(false);
열림 상태는 이 하나뿐입니다. 데스크톱에서 true였다면 모바일로 바뀐 뒤에도 true고, 이번에는 그 값이 바텀시트를 엽니다.
만약 이렇게 만들었다면 어땠을까요.
{isMobile ? <MobileDropDown /> : <DesktopDropDown />}
브레이크포인트를 넘는 순간 다른 컴포넌트가 새로 마운트됩니다. 컴포넌트 안에 있던 isOpen은 초기화되고, 사용자가 열어 둔 목록은 닫힙니다. 같은 props를 그대로 넘겨도 마찬가지입니다. props는 상태를 옮겨 주지 않으니까요.
막으려면 열림 상태를 부모로 올려야 합니다. 그러면 선택 중이던 인덱스도, 포커스 위치도, 스크롤 위치도 차례로 따라 올라옵니다. 1편에서 “화면을 나누면 상태가 갈리고 결국 로직도 갈린다”고 했던 게, 드롭다운 하나에서 이렇게 시작됩니다.
파일도 두 벌이 됩니다. 그런데 겹치는 쪽이 더 큽니다. 나란히 놓으면 이렇습니다.
// DesktopDropDown
<DropDown isOpen={isOpen} aria-expanded={isOpen} onClick={() => setIsOpen(!isOpen)}>
{displayValue}
</DropDown>
{isOpen && <OptionLayer>{options}</OptionLayer>}
// MobileDropDown
<DropDown isOpen={isOpen} aria-expanded={isOpen} onClick={() => setIsOpen(!isOpen)}>
{displayValue}
</DropDown>
<BottomSheet isShow={isOpen}>{options}</BottomSheet>
트리거는 글자 하나 다르지 않습니다. 표시할 라벨을 고르는 규칙도, 열려 있는지를 알리는 접근성 속성도, 선택값을 올려 보내는 계약도 같습니다. 다른 건 마지막 한 줄입니다.
그 한 줄 때문에 나머지를 복사하면, 고칠 일이 생길 때마다 두 번 고쳐야 합니다. 한쪽을 빠뜨리면 두 화면이 조금씩 달라집니다.
그리고 이건 상태가 하나뿐인 드롭다운이라 이 정도입니다. 같은 자리에 오는 게 레이아웃 컨테이너처럼 큰 것이면 다시 마운트될 때 도는 일이 훨씬 많습니다. 읽고 있던 위치가 맨 위로 돌아가고, 하단 버튼 높이를 처음부터 다시 잽니다. 마운트할 때 한 번만 하면 되는 일들을 다시 하는 셈입니다. 창을 조금 좁혔을 뿐인데 화면이 새로 열린 것처럼 됩니다.
거꾸로 말하면 이런 뜻이기도 합니다. 한 벌로 가면 컴포넌트는 마운트를 한 번만 하고, 모양은 여러 번 바꿉니다. 편한 대신 조심할 게 하나 생깁니다. 컴포넌트가 죽지 않으니, 처음에 한 번 찾아 둔 것을 계속 쓰는 코드는 모양이 바뀐 뒤에도 옛날 것을 들고 있습니다.
스크롤을 맡은 요소가 그렇습니다. 넓은 화면과 좁은 화면에서 스크롤하는 쪽이 서로 다른데, 처음에 찾아 둔 것을 계속 쓰면 전환한 뒤에는 엉뚱한 것을 붙들고 있게 됩니다. 레이아웃 컨테이너 안에 그 처리가 들어 있고, 자세한 이야기는 6편에서 하겠습니다.
자주 밟는 함정은 아닙니다. 애초에 사용자가 브라우저 창을 좁혔다 늘렸다 하는 일 자체가 드무니까요. 다만 훅을 쓸 때 이 전제가 바뀐다는 건 알고 있어야 합니다.
화면 전체도 같은 방식입니다
드롭다운은 작은 사례입니다. 같은 일이 화면 단위로도 일어나고, 앞의 화면에서 이미 같이 일어나고 있었습니다.
드롭다운을 담고 있던 그 문의하기 팝업이 그렇습니다. 데스크톱에서는 가운데 뜨는 카드고, 모바일에서는 전체 화면입니다. 등장 방향도 다릅니다. 가운데서 커지는 것과 아래에서 올라오는 것.
앞에서 말한 대로 이쪽은 서버 렌더링과 무관합니다. 그래서 분기를 그냥 씁니다. 네 줄입니다.
const { isDesktop } = useScreen();
<ModalOverlay
enterFrom={isDesktop ? 'center' : 'bottom'}
exitTo={isDesktop ? 'center' : 'bottom'}
>
<LayoutContainer
mode={isDesktop ? 'modal' : 'fullPage'}
isFixedCenter={isDesktop}
>
mode 하나에 꽤 많은 게 걸려 있습니다. fullPage는 화면 높이를 다 쓰고 루트가 스크롤합니다. modal은 최대 높이가 제한되고 바디가 스크롤하며, 배경색과 모서리가 붙습니다.
스크롤 주체가 바뀐다는 게 특히 중요합니다. 전체 화면일 때는 페이지 자체를 내리는 게 자연스럽고, 카드일 때는 카드 안쪽만 내려가야 합니다. 헤더와 하단 버튼은 고정된 채로요. 이걸 매번 손으로 맞췄다면 팝업마다 스크롤이 조금씩 다르게 동작했을 겁니다.
이 패턴을 쓰는 팝업이 앱 곳곳에 있습니다. 항공권 규정, 보험 약관, 영수증, 문의하기, 예약 취소, e티켓 상세. 전부 같은 네 줄입니다.
정리
솔직히 말하면, 창을 좁혔다 늘렸다 하는 사용자는 많지 않습니다. 이번 편을 연 그 장면도 실제로 자주 일어나는 일은 아닙니다.
그런데도 이걸 먼저 보여드린 이유가 있습니다. 되는지 안 되는지를 보면 구조를 알 수 있기 때문입니다.