항공 프론트엔드 구축기 (9/10): 쓰자고 해 놓고 안 썼습니다
2026년 9월 10일
원문에서 보기 ↗글. 정용욱(Eric) / 서비스웹개발팀
항공 프론트엔드 구축기: 쓰자고 해 놓고 안 썼습니다
안녕하세요. 서비스웹개발팀의 에릭입니다.
이번 편은 상태관리입니다. 프로젝트를 시작하며 전역 저장소를 넣자고 해 놓고, 결국 안 넣었습니다. 그 사이에 판단이 어떻게 바뀌었는지를 적었습니다.
처음에는 필요하다고 봤습니다
프로젝트를 시작하면서 상태관리 라이브러리를 하나 넣자고 이야기했습니다. Zustand나 Recoil 같은 것들이요.
이유는 단순했습니다. 만들 화면이 많았거든요. 검색, 검색 결과, 상품 상세, 예약, 결제, 결제 결과, 예약 목록, 예약 상세. 여기에 국내선과 해외선이 각각 붙습니다. 거기에 웹과 웹뷰를 한 벌로 가기로 했으니 컴포넌트도 깊어질 참이었고요.
이 정도 규모면 상태가 여기저기 흩어지고, prop을 몇 단계씩 내려보내게 되고(props drilling), 그러다 결국 전역 저장소를 찾게 됩니다. 그렇게 될 거라고 봤습니다.
그런데 안 썼습니다.
지금 항공 서비스의 화면 코드에는 전역 저장소가 없습니다. 예약도, 결제도, 마이페이지도요.
상태를 다루는 라이브러리를 아예 안 쓴다는 말은 아닙니다. 서버에서 온 데이터는 React Query가, 주소창에 들어가는 값은 nuqs가 맡습니다. 이것들도 넓게 보면 상태관리 도구고요. 안 쓴 건 종류를 가리지 않고 다 받아 주는 저장소 쪽입니다. 어떤 값이든 거기 두면 어디서든 꺼내 쓸 수 있는 그 하나요.
무엇을 고를지부터 봤습니다
검토는 라이브러리 비교부터 시작했습니다.
Zustand는 가볍고 쓸 준비가 거의 필요 없습니다. Provider로 감싸지 않아도 훅으로 바로 꺼내 쓸 수 있고, 필요한 값만 골라 구독해서 화면이 덜 다시 그려집니다.
Recoil은 상태를 잘게 쪼개서 각각 구독하는 모델입니다. 어떤 값에서 파생되는 값을 선언적으로 만들 수 있다는 게 매력적이었습니다.
둘 다 그럴듯해 보였는데, 정하지 못한 채로 시간이 지났습니다. 지금 보면 이유가 분명합니다. 어떤 상태를 거기 넣을지가 안 정해져 있었기 때문입니다.
라이브러리를 고르는 일과 상태를 정리하는 일 중에서는 뒤가 먼저인데, 순서를 반대로 잡고 있었습니다.
무엇을 잘못 봤나
돌아보면 제가 본 건 규모였습니다. 화면이 몇 개고 컴포넌트가 몇 단계인지요.
그게 틀린 기준이었습니다. 실제로 문제가 되는 건 상태의 개수가 아니라 그 상태가 언제까지 살아야 하고 누가 주인인지였습니다.
만들면서 상태를 하나씩 놓고 그 두 가지를 물어봤더니, 대부분 갈 자리가 이미 정해져 있었습니다.
사실 겪어 본 적이 있었습니다
더 민망한 건, 이걸 처음 겪은 게 아니라는 점입니다.
기존 Vue2 웹뷰에도 해외선 예약 상세 화면이 있었습니다. 항공편, 탑승객, 결제, 보험, 환불 규정이 한 화면에 다 들어가는 그 화면이요. 거기서도 같은 문제를 풀어야 했고, 답은 Vue의 provide/inject였습니다.
IntlMypageDetail.vue @ProvideReactive 위에서 값을 내려둔다
└ +IntlMypageDetail/… @InjectReactive 아래 어디서든 꺼내 쓴다
상위에서 값을 놓아두면 하위 어디서든 꺼내 쓰는 기능입니다. React의 Context와 발상이 같습니다. 그때도 prop을 몇 단계씩 넘기지 않았고, 전역 저장소도 없었습니다.
그러니까 "이 정도 화면은 저장소 없이 된다"는 걸 이미 해 본 셈입니다. 그런데도 이번엔 라이브러리부터 골랐습니다. 규모가 커졌으니 이번엔 다를 거라고 생각했거든요.
실제로 달랐던 건 규모가 아니었습니다. 서버 데이터를 어떻게 다루느냐가 달랐고, 그건 React Query가 가져갔습니다. 그러고 나니 남은 문제는 예전과 같은 크기였습니다.
상태를 넷으로 나눴습니다
설계 문서에는 셋으로 적었습니다. 서버 데이터, UI 상태, 환경. 만들다 보니 하나가 더 나왔습니다.
서버에서 온 데이터. 항공편 목록, 예약 내역, 쿠폰. 이건 우리가 만든 값이 아니라 서버에 있는 값의 사본입니다. 그러니 주인이 서버고, 우리는 언제 다시 물어볼지만 정하면 됩니다. React Query가 하는 일이 정확히 그겁니다.
주소창에 있어야 하는 것. 검색 조건, 필터, 정렬. 주인이 URL이고, nuqs가 그 자리를 맡습니다. 이게 설계 때 빠져 있던 축입니다.
화면 하나가 들고 있는 것. 예약 화면의 입력값들처럼 여러 컴포넌트가 같이 봐야 하는 값. Context로 충분합니다.
한 컴포넌트만 아는 것. 열림 여부, 토글, 입력 중인 값. useState입니다.
넷으로 나누고 나니 남는 게 없었습니다. 전역 저장소가 필요한 자리가 안 나왔습니다.
설계 문서의 세 분류가 실제로는 넷이 된 대조표
설계 때 있던 "환경"은 상태가 아니라 판단이었고, 그 자리에 없던 축이 하나 들어왔다.
사실은 내려보내기가 싫었던 겁니다
저장소를 원하게 되는 이유는 대개 하나입니다. 값을 몇 단계씩 아래로 내려보내는 게 싫어서입니다. 설계 문서에도 그걸 없애는 걸 목표로 적어 두었습니다.
그런데 내려보내기 싫은 값이 뭔지 하나씩 보면 대개 둘 중 하나였습니다.
서버에서 온 데이터라면 내려보낼 필요가 없습니다. 필요한 컴포넌트가 각자 부르면 됩니다. 같은 키로 부르면 요청은 한 번만 나가고, 두 번째부터는 이미 받아 둔 값을 씁니다. 위에서 받아 아래로 넘기는 과정 자체가 사라집니다.
화면 전체가 같이 보는 값이라면 Context입니다. 이건 원래 그러라고 있는 도구고요.
그러고 남는 건 진짜로 위아래로만 흐르는 값입니다. 부모가 알고 자식이 쓰는 값. 그건 그냥 내려보내는 게 맞습니다. 그걸 저장소에 넣으면 흐름이 안 보이게 되고, 어디서 바뀌었는지 찾기만 어려워집니다.
저장소가 필요하다는 느낌의 상당 부분은 사실 이 정리를 안 해서 생긴 것이었습니다.
화면 하나를 열어 보면
말로만 하면 잘 안 와닿으니 제일 복잡한 화면을 열어 보겠습니다. 해외선 예약 상세입니다.
여기에는 항공편 정보, 탑승객 목록, 결제 내역, 보험, 환불 규정, 취소 절차가 다 들어갑니다. 예약 상태에 따라 보여줄 것도 달라지고요. 전역 저장소를 원하게 된다면 이런 화면에서일 겁니다.
폴더는 이렇게 생겼습니다.
[reservationSeq]/
├── _hooks/ useBookingDetail · useRefundInfo · usePaymentInfos · useFinalTotalFares · …
├── _ui/ 이 화면에서만 쓰는 표시용 컴포넌트
├── _widget/ 화면을 이루는 큰 덩어리들
│ ├── +StepView/
│ └── +OnlyDetailView/
└── _types/
이름 앞의 기호에 규칙이 있습니다. 전체가 쓰는 건 프로젝트 루트의 shared에 두고, 그 아래에서는 _로 시작하면 그 계층 안에서 여럿이 나눠 쓰는 것 , +로 시작하면 기호를 뗀 이름의 파일 전용입니다. +StepView에 든 건 StepView.tsx 말고는 아무도 안 씁니다. (이 규칙은 다음 편에서 따로 다루겠습니다. 여기서는 코드를 읽는 데 필요한 만큼만 적었습니다.)
여기서 볼 것은 _hooks 쪽입니다. 화면이 필요로 하는 값이 훅으로 쪼개져 있습니다. 환불 정보, 결제 정보, 최종 금액, 탑승객 묶음. 각각이 자기가 무엇을 어떻게 가져올지 알고 있습니다.
그리고 데이터는 이렇게 꺼냅니다.
const { data } = useSuspenseBookingDetail();
이 훅을 부르는 파일이 이 화면 안에서만 여든 곳이 넘습니다. 그런데 서버 요청은 한 번만 나갑니다. 같은 키니까요.
내려보내는 방식이었다면 어땠을지 그려 보면 차이가 분명합니다. 예약 정보를 화면 맨 위에서 받아서 섹션으로, 섹션에서 카드로, 카드에서 행으로 넘깁니다. 중간 컴포넌트들은 자기가 안 쓰는 값도 받아서 전달만 합니다. 그러다 아래쪽에서 새 필드가 필요해지면 그 경로에 있는 파일을 전부 고쳐야 합니다.
훅으로 부르면 그 경로 자체가 없습니다. 필요한 곳이 직접 묻고, 안 쓰는 컴포넌트는 그런 값이 있는지도 모릅니다.
예약 정보를 위에서 아래로 넘기는 방식과 각자 훅으로 부르는 방식의 비교
위쪽 가운데 두 칸은 자기가 쓰지도 않는 값을 받아서 넘기기만 한다. 아래쪽에는 그 칸이 없다.
다만 아무 데서나 되는 건 아닙니다
이게 성립하는 데는 조건이 둘 있습니다. 둘 다 없으면 오히려 나빠집니다.
하나, 이 컴포넌트들이 이 화면 전용입니다.
앞에서 본 _와 + 규칙이 여기서 값을 합니다. _widget 안에 있는 것들은 이 화면 밖에서 쓰이지 않습니다. 그러니 "예약 상세 데이터는 당연히 있다"를 전제로 깔 수 있습니다.
공용 컴포넌트였다면 못 합니다. 어디서 쓰일지 모르니 데이터가 있을 수도 없을 수도 있고, 그러면 결국 prop으로 받는 게 맞습니다. 재사용할 컴포넌트와 이 화면만의 컴포넌트를 폴더로 갈라 둔 게 여기서 효과를 냅니다.
둘, 로딩과 에러를 루트에서 한 번만 처리합니다.
{isLoading ? (
<LoadingView />
) : (
<>
{isSuccess && <SuccessBody />}
{isError && <ErrorBody />}
</>
)}
성공했을 때만 아래를 그립니다. 그래서 SuccessBody 밑에 있는 컴포넌트들은 데이터가 없을 수 있다는 걸 신경 쓰지 않습니다. 로딩 표시도, 빈 값 검사도 없습니다. 훅에서 받은 값을 그냥 씁니다.
이 둘이 없으면 훅으로 부르는 방식이 오히려 손해입니다. 여든 곳에서 각자 "아직 안 왔으면 어떡하지"를 처리하게 되니까요. 내려보내기를 없앤 대신 같은 조건 검사를 여든 번 하게 되면 남는 게 없습니다.
정리하면 이렇습니다. 훅으로 데이터를 꺼내 쓰는 방식은 전용 컴포넌트 + 위에서 한 번 거른 상태라는 두 조건 위에서 성립합니다. 조건이 안 맞으면 내려보내는 게 낫습니다.
Context는 그럼 어디 쓰냐면, 서버에서 오지 않는 값에 씁니다. 지금 몇 번째 단계인지, 어떤 항목이 펼쳐져 있는지 같은 것들이요. 서버 데이터는 훅으로, 화면 상태는 Context로 나뉘어 있고 둘이 섞이지 않습니다.
필터를 어디에 둘 것인가
넷 중에서 두 번째가 이 프로젝트의 답을 갈랐습니다.
항공권 검색 결과 화면을 생각해 보겠습니다. 출발 시간대를 좁히고, 직항만 보고, 가격순으로 정렬합니다. 이 셋은 분명 "화면의 상태"처럼 보입니다.
그래서 처음에는 Context에 두는 게 자연스러워 보입니다. 그런데 그렇게 두면 이런 일이 생깁니다.
새로고침하면 필터가 풀립니다. 링크를 복사해서 보내면 상대는 필터가 없는 화면을 봅니다. 뒤로 갔다 오면 처음 상태로 돌아옵니다.
항공 검색에서는 이 손실이 꽤 큽니다. 조건을 맞춰 놓고 링크를 보내는 일이 흔하니까요.
그래서 주소창에 뒀습니다.
domestic/srp/_hooks/ usePlpSearchParams · usePlpFilterParams · usePlpSortTypeParams
international/srp/_hooks/ usePlpSearchParams · usePlpFilterParams · usePlpSortTypeParams
국내선과 해외선이 대칭입니다. 검색 조건, 필터, 정렬을 각각 URL에서 읽고 씁니다. nuqs가 useState처럼 생긴 훅을 주는데, 값이 컴포넌트가 아니라 쿼리 스트링에 들어갑니다. 상태를 우리가 들고 있지 않고 주소창이 들고 있습니다.
새로고침해도 살아 있고, 링크를 보내면 상대도 같은 화면을 봅니다. 뒤로가기는 브라우저가 알아서 해 줍니다. 5편에서 뒤로가기를 그렇게 고생해서 만들었는데, 이쪽은 아무것도 안 해도 됩니다.
한 벌이라서 생긴 성질
여기에 이 프로젝트만의 이야기가 하나 붙습니다.
앱 웹뷰에서 검색 결과를 보다가 그 링크를 브라우저에서 열면, 같은 화면이 나옵니다. 필터도 정렬도 그대로요.
당연해 보이지만 당연하지 않습니다. 웹용 화면과 웹뷰용 화면을 따로 만들었다면 주소 체계가 갈렸을 테고, 갈리면 이게 안 됩니다. 상태를 각자의 저장소에 넣어 뒀다면 더더욱 안 되고요.
URL은 환경을 건너뛰는 유일한 통로입니다. 브릿지도 쿠키도 못 하는 걸 합니다. 그리고 한 벌로 갔기 때문에 그 통로가 양쪽에서 같은 의미를 가집니다.
지난 편들에서 환경 차이를 감추는 이야기를 했는데, 이건 반대쪽입니다. 감출 필요조차 없는 자리가 있고, 상태를 거기 두면 그렇게 됩니다.
서버 데이터를 상태로 다루지 않기
첫 번째 갈래도 짚고 갈 만합니다.
전역 저장소를 쓰게 되는 흔한 경로가 있습니다. API로 받아온 목록을 저장소에 넣고, 여러 화면에서 꺼내 쓰고, 갱신이 필요하면 다시 불러서 덮어씁니다.
이렇게 하면 그 데이터가 언제 낡는지를 우리가 관리해야 합니다. 다른 화면에서 예약을 취소했으면 목록도 갱신해야 하고, 그 연결을 손으로 이어야 합니다.
React Query는 그걸 대신합니다. 우리는 "이 키의 데이터가 무효해졌다"고만 알려 주면 됩니다.
그래서 키를 잘 정하는 게 중요해졌고, 쿼리마다 키를 따로 적다 보면 어긋납니다. 그래서 도메인별로 키와 쿼리를 한 덩어리로 묶어 뒀습니다.
export const airlineAllianceQueries = {
keys: {
all: () => ['airline-alliances'] as const,
list: () => [...airlineAllianceQueries.keys.all(), 'list'] as const,
},
list: (request, options?) => queryOptions({ /* ... */ }),
};
무효화할 때 키를 다시 적을 일이 없습니다. keys.all()을 부르면 그 도메인 전체가 갱신됩니다. 이런 파일이 스물다섯 개쯤 있고 전부 같은 모양입니다.
서버 데이터를 상태로 안 다루니 전역 저장소가 할 일이 크게 줄었습니다. 저장소가 필요해 보였던 값의 대부분이 사실은 서버 데이터였습니다.
남은 것은 Context 몇 개
그러고 나면 진짜 "화면 상태"만 남습니다. 실제로 손에 꼽습니다. 해외선 예약 화면 하나, 팝업 두 개 정도요.
이것도 규칙을 하나 두었습니다. Provider를 화면의 레이아웃 파일에 둡니다.
// layout.tsx
<BookingProvider>{children}</BookingProvider>
그러면 그 화면을 벗어나는 순간 Provider가 사라지고, 안에 있던 상태도 같이 사라집니다. 초기화 코드를 따로 쓸 일이 없습니다.
전역 저장소를 쓰면 이게 반대가 됩니다. 앱이 살아 있는 동안 계속 남아 있어서, 언제 비울지를 우리가 정해야 합니다. 화면을 나갈 때 지우는 걸 깜빡하면 다음에 들어왔을 때 지난 값이 보입니다.
상태의 수명을 코드로 관리하느냐, 배치로 해결하느냐의 차이입니다. 후자가 잊어버릴 일이 없습니다.
다만 Context가 만능은 아닙니다
여기까지 읽으면 "그럼 Context를 쓰면 되겠네"로 들릴 수 있는데, 그렇지 않습니다.
Context에 값을 넣으면 그 값이 바뀔 때마다 그 아래가 전부 다시 그려집니다. 그래서 무엇을 넣느냐가 중요합니다.
들어가도 되는 건 자주 안 바뀌는 값입니다. 예약 화면의 입력값처럼 사용자가 손댈 때만 바뀌는 것들이요. 사용자는 초당 몇 번씩 입력하지 않습니다.
들어가면 안 되는 건 반대입니다. 스크롤 위치처럼 계속 바뀌는 값을 Context에 넣으면 그 화면 전체가 계속 다시 그려집니다. 6편에서 스크롤을 다룰 때 컨텍스트를 함수용과 값용으로 나눈 이야기를 했는데, 그게 이 문제를 피하는 방법이었습니다.
"Context로 충분하다"는 조건부입니다. 무엇을 넣느냐에 달렸고, 그 판단은 결국 앞에서 한 것과 같습니다. 이 값의 주인이 누구이고 얼마나 자주 바뀌는가.
그래서 틀렸나
처음 판단이 틀렸다고 생각합니다. 다만 어떻게 틀렸는지는 조금 다릅니다.
"저장소가 필요 없다"가 답이 아닙니다. 저는 규모를 보고 필요 여부를 판단했는데, 그 기준이 틀렸습니다. 화면이 많으면 상태도 많고, 상태가 많으면 저장소가 필요하다고 생각한 거죠.
실제로 필요한지를 가르는 건 주인이 없는 상태가 있느냐였습니다. 서버 것도 아니고, URL에 둘 것도 아니고, 어느 화면 하나에 속하지도 않는데, 여러 곳에서 오래 살아야 하는 값. 그런 게 있으면 저장소가 필요합니다.
이번 서비스에는 그런 값이 거의 없었습니다. 예약은 화면을 벗어나면 끝나고, 검색 조건은 주소창에 있고, 회원 정보는 서버에 있습니다.
규모가 아니라 소유권을 봤어야 했습니다.
돌아보면 판단에 필요한 건 질문 두 개였습니다. 상태 하나를 놓고 이렇게 물어보면 됩니다.
이 값의 주인은 누구인가. 서버인지, 주소창인지, 이 화면인지, 이 컴포넌트 하나인지.
이 값은 언제까지 살아야 하는가. 새로고침을 넘어야 하는지, 화면을 벗어나면 사라져도 되는지.
두 질문에 답이 나오면 갈 자리가 정해집니다. 답이 안 나오는 값, 그러니까 주인이 애매한데 오래 살아야 하는 값만 저장소로 갑니다. 이 질문을 먼저 했다면 라이브러리를 비교하는 데 쓴 시간은 안 써도 됐을 겁니다.
다만 순서 하나는 결과적으로 맞았습니다. 안 넣고 시작한 것이요. 넣고 시작했으면 이미 있으니 썼을 테고, 쓰고 나면 그게 필요했는지 확인할 방법이 없어집니다. 없이 만들어 봤기 때문에 없어도 된다는 걸 알게 됐습니다.
다음 편에서는 방금 지나간 _와 + 이야기를 하겠습니다. 폴더 구조를 제대로 잡아 보려다 기호 두 개만 남긴 과정입니다.
항공 프론트엔드 구축기 (9/10): 쓰자고 해 놓고 안 썼습니다 was originally published in 여기어때 기술블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.