Engineering
항공 프론트엔드 구축기 (6/10): 컨테이너를 둘로 쪼개지 않았다
에릭Eric_jeong(정용욱)여기어때
2026년 8월 7일
원문에서 보기 ↗글. 정용욱(Eric) / 서비스웹개발팀

항공 프론트엔드 구축기: 컨테이너를 둘로 쪼개지 않았다
안녕하세요. 서비스웹개발팀의 에릭입니다.
지난 편 끝에 레이아웃 컨테이너를 예고했습니다. 헤더와 본문과 하단 버튼, 거의 모든 화면이 쓰는 뼈대 이야기입니다. 환경마다 따로 만들자고 적어 뒀던 컴포넌트가 왜 하나로 남았는지 적었습니다.
이번 편은 4편 "앱이냐"와 "좁냐"는 다른 질문이다의 연장선입니다. 거기서 세운 기준을 그대로 가져다 쓰는 대목이 여러 번 나오니, 아직 안 보셨다면 4편을 먼저 읽고 오시는 편이 좋습니다.
화면마다 똑같이 반복되는 것
예약 화면을 만든다고 해보겠습니다. 위에 헤더가 있고, 가운데 내용이 스크롤되고, 아래에 결제 버튼이 붙습니다.
그다음 화면도 같은 구조입니다. 검색 결과도, 예약 상세도, 취소 화면도 같습니다. 항공 서비스의 거의 모든 화면이 헤더와 본문과 하단 버튼입니다.
그러니 이걸 컴포넌트 하나로 묶는 건 당연한 선택입니다. 문제는 그 하나가 웹에서도, 모바일웹에서도, 앱 웹뷰에서도 돌아야 한다는 것이었습니다.
설계 문서에 제가 적어 둔 안은 이랬습니다.
// 설계 문서 원안
if (isWebview) return <WebviewContainer {...props} />;
return <WebContainer {...props} />;
환경이 둘이니 컨테이너도 둘. 자연스러워 보였습니다.
쪼개면 그 안으로 무엇이 들어오나
쪼갠다면 이 컴포넌트가 무엇을 알아야 하는지부터 봐야 합니다.
뒤로가기를 누가 처리하는지. 화면 높이를 무엇으로 재는지. 아래쪽에 홈 인디케이터를 피할 여백이 필요한지. 헤더를 고정할지 말지.
전부 환경마다 답이 다릅니다. 쪼갠다는 건 이 답들을 컨테이너 안에 넣는다는 뜻입니다. 그러면 컨테이너가 환경을 아는 컴포넌트가 됩니다.
여기서 두 가지가 걸렸습니다.
하나는 목록이 늘어나기만 한다는 점입니다. 앱에서 새 기능이 생길 때마다, 브라우저에 새 문제가 생길 때마다 이 파일이 갈라집니다. 화면 뼈대를 잡는 컴포넌트가 환경 지식의 저장소가 되어 갑니다.
다른 하나는 둘로 끝나지 않는다는 점입니다. 웹과 웹뷰로 나눴다고 해도 데스크톱과 모바일이 그대로 남습니다. 그럼 넷인가요. 4편에서 이야기한 대로 “앱이냐”와 “좁냐”는 다른 질문이라, 곱해집니다.
그리고 두 컨테이너가 실제로 얼마나 다른가도 걸렸습니다.
헤더가 있고 본문이 스크롤되고 하단 버튼이 붙는 구조는 양쪽이 같습니다. 그림자가 언제 생기는지도 같고, 스크롤이 어디서 일어나는지도 같습니다. 다른 건 앞에 적은 몇 가지뿐이었습니다.
그러면 다음 수순이 뻔합니다. 같은 부분을 다시 뽑아서 공통 컴포넌트로 만들고, 두 컨테이너가 그걸 감싸는 것. 그런데 그렇게 하면 컨테이너가 셋이 됩니다. 공통 하나와 껍데기 둘.
그 껍데기가 하는 일은 결국 환경마다 다른 값 몇 개를 채워 넣는 것입니다. 값 몇 개 때문에 컴포넌트를 셋으로 늘리는 셈이고, 화면을 만드는 사람은 그중 무엇을 써야 하는지 매번 골라야 합니다.
1편에서 “로직만 공유하고 화면은 따로”를 접었던 이유와 같은 모양입니다. 절충안이 오히려 더 많은 것을 만듭니다.
그래서 질문을 바꿨습니다. “이 컨테이너가 환경을 어떻게 분기할까”가 아니라, “환경을 몰라도 되게 하려면 무엇을 밖으로 내보내야 할까”.
남은 분기는 한 줄이었습니다
결과부터 보겠습니다. 지금 이 파일에서 환경을 묻는 곳은 한 군데뿐입니다.
const fullPageViewportHeight = isWeb ? '100dvh' : '100vh';
전체 화면 모드일 때 높이를 무엇으로 잡을지, 그것 하나입니다.
이건 감출 수 없는 차이라서 남았습니다. 웹 브라우저에는 주소창이 있고, 스크롤을 내리면 사라졌다가 올리면 다시 나타납니다. 화면 높이가 실제로 변합니다. 100vh로 잡으면 주소창이 있는 동안 아래가 잘리고, 사라지는 순간 화면이 튑니다. 그래서 웹에서는 지금 보이는 높이를 따라가는 100dvh를 씁니다.
웹뷰에는 주소창이 없습니다. 높이가 변하지 않으니 100vh가 정확하고, dvh를 쓰면 오히려 계산할 이유가 없는 걸 계산합니다.
나머지는 전부 아래로 내려갔습니다. 뒤로가기는 지난 편에 나온 트리거가, 겹치는 화면은 오버레이가, 아래 여백은 전용 훅이 각자 가져갔습니다. 컨테이너는 그것들이 무슨 일을 하는지 모릅니다.
그래서 컨테이너가 하는 일
남은 건 뼈대입니다. 받는 것도 셋뿐입니다.
<LayoutContainer
mode={isDesktop ? 'modal' : 'fullPage'}
header={<LayoutContainerHeader leftButton="arrow_left" onClickLeftButton={goBack}>
예약 확인
</LayoutContainerHeader>}
bottomBar={<BoxButton size="l">결제하기</BoxButton>}
>
{/* 본문 */}
</LayoutContainer>
mode가 무엇을 바꾸는지는 4편에서 이야기했습니다. 전체 화면이면 페이지가 스크롤하고, 카드면 카드 안쪽이 스크롤합니다. 코드로는 한 줄입니다.
const scrollTargetRef = mode === 'fullPage' ? rootRef : bodyRef;

전체 화면 모드와 카드 모드에서 스크롤하는 요소가 달라지는 모습
파란 막대가 스크롤을 맡은 쪽이다. 헤더와 하단 버튼은 어느 쪽에서도 고정이다.
이게 prop 하나로 끝난 건, 바깥에서 판단하고 안에서는 듣기만 하기 때문입니다. 컨테이너는 지금이 넓은 화면인지 스스로 재지 않습니다. 그래서 데스크톱과 모바일 사이를 오갈 때도 이 컴포넌트는 죽지 않고, 스크롤 위치와 열려 있던 것들이 그대로 남습니다. 4편의 드롭다운과 정확히 같은 이유입니다.
이 편에서 하고 싶은 이야기는 그다음입니다. 여기 안 적힌 것들이요.
화면이 묻지 않아도 되는 것들
헤더 그림자
본문을 조금이라도 내리면 헤더 아래에 그림자가 생깁니다. 맨 위로 돌아오면 사라집니다. 스크롤되는 내용이 헤더 밑으로 지나간다는 걸 알려주는 흔한 처리입니다.

헤더는 붙어 있고 본문만 흐른다. 제목이 헤더 밑으로 사라지는 순간 그림자가 켜진다.
이걸 화면마다 만들면 화면마다 스크롤 위치를 들고 있어야 합니다. 그래서 컨테이너가 합니다.
const isScrolled = scrollState.scrollTop > 0;
// 그림자는 "스크롤됨"과 "그림자 종류"가 겹칠 때만 켜진다
compoundVariants: [
{ isScrolled: true, headerShadow: 'header', className: tw`yf-shadow-header` },
{ isScrolled: true, headerShadow: 'raised', className: tw`yf-shadow-raised` },
]
화면 쪽 코드에는 이 이야기가 한 글자도 없습니다. 헤더를 넘겨주면 그림자가 알아서 생깁니다.
아래쪽 여백
아이폰에는 화면 맨 아래에 홈 인디케이터가 있습니다. 하단 버튼을 화면 끝에 붙이면 그 위에 겹칩니다.
브라우저는 이 여백을 CSS로 알려주고, 우리는 그걸 읽어 씁니다. 그런데 값을 그대로 쓸 수 있는 건 아니었습니다.
processedBottom: isIos && !isWebAccess ? 18 : parseValue(bottomStr)
같은 “아래 여백”인데 환경마다 다른 값이 나옵니다. 브라우저에서 열었을 때와 앱 웹뷰로 열었을 때가 다르고, 기기를 가로로 눕히면 또 달라집니다. 그래서 화면이 회전할 때마다 다시 읽습니다.
중요한 건 이 판단이 훅 안에서 끝난다는 점입니다. 컨테이너는 숫자 하나를 받아서 그만큼 빈 자리를 만듭니다. 지금이 iOS인지 안드로이드인지 웹인지 묻지 않습니다.
mode === 'fullPage' && processedBottom
? <div style={{ height: processedBottom }} />
: null
하단 버튼이 자리를 잡기 전
이건 만들면서 부딪힌 쪽입니다.
전체 화면 모드(mode=”fullPage” )에서 본문 높이는 화면 높이에서 헤더와 하단 버튼을 뺀 만큼입니다. 그런데 하단 버튼의 높이는 그려 봐야 압니다. 버튼이 한 줄일 수도 있고, 위에 안내 문구가 붙어 두 줄일 수도 있으니까요.
그래서 처음 그릴 때 순서가 이렇게 됩니다. 하단 버튼 높이를 0으로 두고 본문을 그린다. 본문이 화면보다 길어져서 스크롤이 생긴다. 그다음 순간 하단 버튼 높이가 측정되고, 본문이 그만큼 줄어들면서 스크롤이 사라집니다.
내용이 위아래로 한 번 튑니다. 코드 주석에 그때 상황이 남아 있습니다.
해당 처리를 하지 않으면, 스크롤이 생겼다가 사라져서 레이아웃 쉬프트가 일어남

앞부분은 이 처리를 껐을 때다. 누를 때마다 스크롤이 생겼다가 사라진다. 뒤에서 체크를 켜면 같은 화면이 흔들리지 않는다. 실제로는 순식간에 지나가는 일이라, 과정이 보이도록 측정을 늦춰 두었다.
해결은 단순합니다. 재기 전까지는 하단 버튼을 화면 아래에 고정해 둡니다. 본문 높이 계산에 끼어들지 않게요. 다 재고 나면 원래 자리로 돌아옵니다.
const [isLayoutReady, setIsLayoutReady] = useState(!bottomBar);
const isBottomFixed = mode === 'fullPage' && !isLayoutReady;
지난 편들에서 서버가 모르는 값을 다룬 적이 있는데, 이것도 같은 모양입니다. 저쪽은 “서버가 모른다”였고 이쪽은 “아직 안 쟀다”입니다. 답은 같습니다. 모르는 동안 자리를 흔들지 않습니다.
제목이 길 때 어디서 잘리는지
헤더도 컴포넌트를 따로 뒀습니다. 모바일에서 흔히 보는 그 헤더입니다. 왼쪽에 뒤로가기, 가운데 제목, 오른쪽에 아이콘 하나.
여기서 손이 가는 건 제목입니다. 가운데 정렬인데 길어지면 좌우 아이콘 자리까지 밀고 들어옵니다. 그렇다고 폭을 좁게 고정하면, 아이콘이 하나도 없는 화면에서 제목이 이유 없이 짧게 잘립니다.
그래서 아이콘이 있는지에 따라 최대 폭이 달라집니다.
// 가운데 정렬 + 아이콘 없음
{ contentAlign: 'center', hasLeftButton: false, hasRightButton: false,
className: tw`yf-max-w-[calc(100%-44px)]` },
// 가운데 정렬 + 한쪽에 아이콘
{ contentAlign: 'center', hasLeftButton: true,
className: tw`yf-max-w-[calc(100%-104px)]` },
아이콘이 없으면 양쪽에서 조금씩만 덜어 내고, 있으면 그만큼 더 덜어 냅니다. 제목은 어느 쪽이든 화면 정가운데를 유지한 채 말줄임됩니다.

주황 점선이 제목이 쓸 수 있는 폭의 끝이다. 아이콘이 붙으면 그 선이 안쪽으로 들어와 더 일찍 잘리지만, 파란 세로선을 보면 가운데는 그대로다.
화면 쪽에서 넘기는 건 제목 문자열과 아이콘 종류뿐입니다. 몇 픽셀을 빼야 하는지는 묻지 않습니다.
스크롤이 필요할 때만
가끔은 화면 쪽에서 스크롤을 만져야 합니다. 유효성 검사에 걸린 입력으로 올려 준다거나, 목록 맨 위로 보내는 버튼을 단다거나.
컨테이너가 스크롤 주체를 쥐고 있으니 밖에서는 손댈 수가 없습니다. 그래서 훅으로 열어 뒀습니다. 다만 두 개로 나눴습니다.
useLayoutContainerActions() // scrollTo, scrollToBottom
useLayoutContainerState() // scrollTop, hasScroll, isScrolled
하나로 묶으면 “맨 위로” 버튼이 스크롤할 때마다 다시 그려집니다. 그 버튼에 필요한 건 함수뿐인데, 값이 같이 들어 있으면 값이 바뀔 때마다 딸려 옵니다. 함수만 주는 쪽은 스크롤과 무관하게 가만히 있습니다.
둘 다 필요하면 합쳐진 훅도 있습니다. 대신 주석에 적어 뒀습니다. “주의: 스크롤마다 리렌더링됨.”
컨테이너 밖에서 부르면 에러를 던집니다. 조용히 빈 값을 주면 스크롤이 안 되는 이유를 한참 찾게 되니까요.
이게 없었다면 화면마다 무엇이 있었을까
지금까지 나온 것들을 화면 쪽에서 직접 한다고 생각해 보겠습니다. 예약 화면 하나가 들고 있어야 할 것이 이만큼입니다.
스크롤 위치를 담을 상태와 리스너. 그 값으로 헤더 그림자를 켜고 끄는 분기. 하단 버튼 높이를 재는 ref와, 그 높이를 뺀 본문 높이 계산. 안전 영역(safe area)을 읽는 코드와, 화면이 회전하면 다시 읽는 코드. 지금이 웹인지 웹뷰인지 물어서 높이 단위를 고르는 줄.
그리고 이걸 화면 수만큼 곱합니다.
여기서 진짜 문제는 분량이 아닙니다. 하나라도 빠뜨렸을 때 티가 잘 안 난다는 겁니다. 그 화면만 그림자가 안 생기고, 그 화면만 버튼이 홈 인디케이터에 살짝 겹칩니다. 리뷰에서 잡히지 않고, 눈에 띄었을 때는 이미 화면마다 제각각입니다.
지금은 그 자리에 예약 로직이 들어갑니다. 화면 코드를 열면 요금을 어떻게 계산하고 어떤 순서로 검증하는지가 보입니다. 뼈대는 앞에서 본 세 줄이고, 나머지는 전부 그 화면이 실제로 하는 일입니다.
같은 규칙이 세 번 나옵니다
만들다 보니 되풀이된 게 하나 있습니다. “지금 맨 위에 있는 게 누구인가” 를 계속 묻게 된다는 것입니다.
뒤로가기가 그랬고, 겹쳐 뜬 화면 중 어느 쪽이 스크롤을 가져갈지가 그랬고, 무엇이 무엇 위에 그려질지가 그랬습니다. 답은 셋 다 같습니다. 가장 나중에 열린 것이 이깁니다.
그런데 구현은 셋 다 다릅니다. 뒤로가기는 DOM에 새긴 순번을 읽고, 스크롤 권한은 모듈 안의 카운터를 구독하고, 쌓임 순서는 그냥 전역 숫자를 하나씩 올립니다.
같은 규칙이니 하나로 묶을 수 있지 않았을까 싶지만, 부르는 쪽이 셋 다 다릅니다. 하나는 앱이 부르는 전역 함수고, 하나는 React 컴포넌트고, 하나는 CSS 속성입니다. 묶으려면 셋 다 아는 층을 새로 만들어야 하는데, 그건 이 편에서 안 만들기로 한 바로 그것입니다.
남은 것
mode가 바뀔 때 스크롤 대상도 같이 바뀝니다. 전체 화면일 때는 루트고 카드일 때는 바디니까요.
이건 감춰지지 않았습니다. 스크롤 요소를 한 번 잡아 두고 계속 쓰는 코드는 창 크기를 넘나든 뒤에 엉뚱한 곳을 붙들고 있게 됩니다. 컨테이너 안에서는 처리해 뒀지만, 밖에서 스크롤을 직접 다루는 코드는 이 전환을 전제로 써야 합니다. 4편에서 “마운트는 한 번, 모양은 여러 번”이라고 했던 그 비용이 여기에도 그대로 붙어 있습니다.
훅으로 열어 둔 것도 그래서입니다. 스크롤 요소를 직접 넘기지 않고 함수만 넘기면, 대상이 바뀌어도 부르는 쪽은 그대로면 됩니다.
결과
화면을 만드는 사람이 이 컨테이너에 주는 건 셋입니다. 헤더, 하단 버튼, 그리고 지금이 넓은 화면인지.
그림자를 언제 켤지, 아래를 얼마나 비울지, 무엇이 스크롤할지, 높이를 재는 동안 화면이 튀지 않게 할지는 묻지 않습니다. 웹인지 웹뷰인지도 묻지 않습니다.
돌아보면 이 편의 이야기는 하나입니다. 환경 차이를 한곳에 모으지 않고 흩어 놓은 것.
모으는 쪽이 처음에는 깔끔해 보입니다. 분기가 한 파일에 있으니 찾기도 쉽고요. 그런데 그렇게 하면 그 파일이 모든 환경을 알아야 하고, 환경이 늘 때마다 거기서 갈라집니다. 반대로 각자 흡수하게 하면 아무도 전체를 알 필요가 없어집니다. 컨테이너는 뼈대만 알고, 여백은 여백을 아는 훅이 알고, 뒤로가기는 뒤로가기를 아는 트리거가 압니다.
1편에서 목표를 “감출 수 있는 건 감추고, 감출 수 없는 건 한 줄로 갈리게”라고 적었습니다. 이 파일에서 감출 수 없었던 건 주소창 하나였고, 정말로 한 줄로 갈렸습니다.
다음 편에서는 앱 브릿지 이야기를 하겠습니다. 브릿지는 앱에만 있는데, 화면은 환경을 구분하지 않고 같은 함수를 호출했습니다.