본문으로 건너뛰기
면접 학습
복습세션 →

과제 기반 — 기차예약

토픽 4 · Phase 1

"다시 선택" 영속화 흐름 — PDF 명시 요구사항

질문 5개
  1. Q4-1

    토스 가이드에 "기차표를 제외하고 출발역/도착역/여행 방식/인원수는 유지되어야 해요"라는 요구가 있었는데요, 어떻게 풀었나요?

    핵심 포인트

    • PDF에 명시된 요구사항 — 즉, 평가 대상이라는 시그널.
    • 검색 조건과 선택한 티켓의 수명을 다르게 가져가야 풀리는 문제.
    • 그래서 atom 파일을 두 개로 분리했습니다 — searchConditionAtom(역/날짜/여행방식/인원수)과 ticketConditionAtom(가는편/오는편 티켓).
    • "다시 선택하기" 버튼은 티켓 atom만 초기화하고 검색 조건 atom은 손대지 않습니다.
    • atom이 모듈 스코프에 살아 있으므로, navigate로 SearchPage로 돌아가도 검색 조건은 그대로 남아 있습니다.
    모범 답안먼저 답해보고 펼치기

    이 부분이 토스 가이드에 명시적으로 적힌 요구사항이라 가장 신경 쓴 지점이었습니다.

    해결의 핵심은 수명이 다른 두 종류의 상태를 분리하는 것이었어요. 검색 조건 — 역, 날짜, 여행 방식, 인원수 — 은 사용자가 다시 선택하기를 눌러도 그대로 남아야 하고, 선택한 티켓 — 가는편/오는편 — 은 새로 골라야 하니 초기화돼야 합니다. 그래서 atom 파일을 의도적으로 두 개로 나눴습니다. atoms/searchConditionAtom.ts에는 6개 검색 조건 primitive atom을 두고, atoms/ticketConditionAtom.ts에는 departureTicketAtom/arrivalTicketAtom + resetSelectedTicketsAtom만 따로 분리했습니다.

    이렇게 분리하니까 ConfirmPage의 "다시 선택하기" CTA는 한 줄로 끝나요. ConfirmBottomCTA.tsxhandleReselect를 보면 resetSelectedTickets()를 호출한 뒤 navigate(SEARCH, { replace: true })로 끝납니다. 검색 조건 atom은 손도 대지 않아요. atom이 모듈 스코프에 그대로 살아 있기 때문에 SearchPage로 돌아가면 자연스럽게 직전에 입력한 역·날짜·인원수가 화면에 다시 그려집니다. 별도의 sessionStorage나 라우터 state 전달 같은 장치가 필요 없었던 게 atom 분리 덕이었습니다.

    또 하나 중요한 건 — 이 분리가 단순히 "다시 선택" 시나리오만을 위한 게 아니라, 도메인적으로도 의미가 맞다는 점이에요. 검색 조건은 사용자가 직접 채우는 입력값이고, 티켓은 그 입력값으로 조회한 결과 중 사용자가 선택한 값입니다. 책임이 다른 상태들이라 분리하는 게 자연스러웠고, 결과적으로 다시 선택 시나리오가 자동으로 풀린 거죠.

    이 답변, 어땠나요?

    꼬리 질문
    • Q4-1-a. 만약 atom 한 파일에 다 몰아넣고 resetAllAtom만 만들었다면 어떻게 됐을까요? → "다시 선택하기"가 검색 조건까지 다 날려버려서 PDF 요구사항을 위반합니다. 결국 partial reset 함수를 또 만들어야 하는데, 그러면 reset 책임이 한 atom에 모이고 변경할 때마다 빠뜨릴 위험이 생깁니다. 파일을 나누면 reset 단위가 자연스럽게 정해지죠.

    • Q4-1-b. 그럼 둘을 한 파일에 두되 reset 함수만 두 개 두는 건 어땠을까요? → 가능하지만 도메인 경계가 흐려집니다. 검색 조건은 SearchPage가 주로 쓰고, 티켓은 TicketsPage 이후가 씁니다. 라이프사이클과 사용처가 다른 상태를 한 파일에 두면 import 그래프와 의존성도 섞여서, 나중에 누가 봐도 어디서 reset돼야 하는지 헷갈릴 거 같았습니다.

    • Q4-1-c. SearchPage로 navigate할 때 replace: true를 주신 이유는요? → 히스토리 스택을 깔끔하게 유지하기 위해서입니다. ConfirmPage에서 뒤로가기가 다시 ConfirmPage로 돌아가지 않도록 하려는 의도예요. 그렇게 하지 않으면 사용자 입장에서 뒤로가기 버튼의 행동이 헷갈립니다.

    CS · 이론
    • 상태의 수명(lifetime) 관점에서의 모듈 분리: 한 객체에 들어가야 할지 분리해야 할지를 결정할 때 "같이 만들어지고 같이 죽는가"를 묻는 게 좋은 휴리스틱입니다. 검색 조건과 선택 티켓은 라이프사이클이 다르므로 분리가 정답이었습니다.
    • Single Source of Truth & 부분 초기화(Partial Reset): SoT를 지키되, reset 단위를 도메인 경계와 일치시키면 reset 함수가 단순해지고 누락 위험이 줄어듭니다.
    • Module-scope state: jotai의 atom은 import 시점에 모듈 스코프에 생성되어 페이지 이동에도 살아남습니다. 단점은 새로고침 시 날아간다는 것 — 이건 Q4-4에서 다룹니다.
  2. Q4-2

    만약 atom이 아니라 `useState`로 풀었다면 어떤 문제가 있었을까요?

    핵심 포인트

    • useState컴포넌트 스코프 — 라우트가 바뀌면 언마운트되며 상태가 사라짐.
    • 매 페이지마다 props로 내려야 하거나, 가장 위 라우터까지 끌어올려야 함(prop drilling).
    • "다시 선택" 시 SearchPage로 navigate하면 SearchPage 컴포넌트가 새로 마운트되어 초기값으로 리셋됨 — 요구사항 위반.
    • 우회하려면 라우터 location.state로 들고 다니거나 sessionStorage로 빼야 하는데, 둘 다 boilerplate가 늘어남.
    모범 답안먼저 답해보고 펼치기

    useState로는 풀기 까다로운 시나리오였습니다. SearchPage → StationPage → TicketsPage → ConfirmPage로 라우트가 4단계로 이어지는데, useState는 컴포넌트가 언마운트되면 초기화돼요. SearchPage에서 입력한 검색 조건을 ConfirmPage까지 끌고 가려면 어딘가 위쪽 — 가장 합리적인 건 라우터 바로 바깥 — 에 상태를 두고 props나 Context로 내려야 합니다.

    특히 "다시 선택"의 경우, ConfirmPage에서 SearchPage로 navigate('/search', { replace: true }) 하면 react-router는 SearchPage 컴포넌트를 새로 마운트합니다. 컴포넌트 내부 useState였다면 초기값으로 리셋돼서 사용자가 직전에 입력한 역·날짜가 다 사라져요. 이게 PDF 요구사항을 정면으로 위반하는 지점입니다.

    우회는 두 가지 정도 떠올랐습니다. 첫째, navigate('/search', { state: prevSearchCondition })로 라우터 location state에 실어 넘기는 방법. 둘째, sessionStorage에 직렬화해서 SearchPage 마운트 시 hydrate하는 방법. 둘 다 동작은 하지만 boilerplate가 페이지마다 반복되고, 직렬화 키 관리가 새로 생깁니다. atom으로 풀면 상태 자체가 라우트와 분리된 모듈 스코프에 있기 때문에 이런 문제가 애초에 없었어요.

    이 답변, 어땠나요?

    꼬리 질문
    • Q4-2-a. Context API를 라우터 바깥에 두는 건 어땠을까요? → 동작은 합니다. 다만 Context는 값이 바뀌면 consumer 전체가 리렌더되는 구조라, 검색 조건이 6개 primitive로 자주 바뀔 때 비용이 큽니다. 또 atom이 자연스럽게 풀어주는 atomFamily/derived atom 같은 패턴을 Context로 흉내내려면 직접 selector를 만들거나 useContextSelector 같은 외부 라이브러리가 필요합니다. jotai를 쓴 이유 중 하나가 "Context의 한계를 가볍게 푸는 atom 모델"이에요.

    • Q4-2-b. 그럼 라우터 location.state에 들고 다니는 방식은 왜 선택지에 안 둔 건가요? → location.state는 직렬화 가능한 값만 들어가야 하고, 페이지마다 명시적으로 forwarding 해야 합니다. SearchPage에서 StationPage로 갈 때 검색 조건을 state로 넘기고, StationPage에서 다시 SearchPage로 돌아올 때도 state로 돌려보내야 하죠. 4단계 페이지를 다 지나는 흐름이라 중간 어딘가에서 빠뜨리면 데이터가 사라집니다. 디버그 비용이 큽니다.

    CS · 이론
    • 상태 끌어올리기(Lifting State Up) vs 외부 스토어: React 공식 가이드도 "여러 컴포넌트가 공유하는 상태는 가장 가까운 공통 부모로"라고 하지만, 그 공통 부모가 라우터 바깥이라면 사실상 전역 스토어와 같은 역할입니다. atom은 "라우터 바깥의 상태"를 가장 가볍게 표현하는 도구입니다.
    • Routing & Component Lifecycle: react-router는 path가 바뀌면 라우트 컴포넌트를 언마운트합니다. URL을 단일 source로 쓰는 SSR 친화적 아키텍처에서는 자연스럽지만, 입력값을 가진 SPA 흐름에서는 외부 저장소가 필요해집니다.
    • Prop drilling의 비용: 단순히 코드 길이의 문제가 아니라, prop 시그니처가 페이지마다 변하면 타입과 시그니처 변경 비용이 커지고, 중간 페이지가 자기 일과 무관한 데이터를 알아야 합니다.
  3. Q4-3

    `handleReselect`가 `resetSelectedTickets()` + `navigate(SEARCH)` 단 두 줄로 끝나도록 의도하신 이유가 있나요?

    핵심 포인트

    • "다시 선택" = 티켓 atom만 비우고 검색 조건은 그대로 — 한 줄로 표현돼야 의도가 또렷.
    • atom 분리가 잘 됐다는 검증 지표 — call site가 단순해졌으면 모델링이 맞은 것.
    • replace: true로 history 스택 단정하게 유지.
    • "예약 완료" 후의 reset과 "다시 선택" 시 reset은 다른 행동이라는 점이 코드에 그대로 드러남.
    모범 답안먼저 답해보고 펼치기

    이건 atom 파일을 분리한 결과의 검증 같은 부분이에요.

    handleReselect의 본문은 두 줄입니다 — resetSelectedTickets() 호출하고 navigate(SEARCH, { replace: true }). 이게 이렇게 짧아졌다는 게 atom 모델링이 의도와 맞아떨어졌다는 신호라고 봤습니다. 만약 handleReselect 안에서 검색 조건을 보존하기 위해 sessionStorage에 직렬화한다거나, location state로 넘긴다거나 하는 코드가 들어갔다면, atom 분리가 잘못된 거예요. call site가 단순한지가 모델링의 좋은 검증 지표라고 평소에 생각합니다.

    replace: true를 준 이유는 히스토리 스택 정리입니다. ConfirmPage에서 다시 선택을 누른 뒤 사용자가 브라우저 뒤로가기를 누르면, 의도는 "이전 단계로" 가는 거지 "방금 떠난 ConfirmPage로 다시 가는" 게 아니에요. push로 두면 ConfirmPage가 스택에 남아서 뒤로가기가 어색하게 동작합니다.

    그리고 비슷한 형태로 CompletePage에는 resetAllAtom을 호출하는 분기가 따로 있어요. 거기서는 검색 조건도 다 비우고 처음부터 새 흐름을 시작하는 게 맞으니까요. "다시 선택"과 "예약 완료 후 새 검색"은 사용자에게 다른 의미고, 이게 reset 함수의 분리로 코드에 그대로 드러납니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q4-3-a. resetSelectedTicketsAtom을 atom으로 둔 이유 — 그냥 setDepartureTicket(null), setArrivalTicket(null)을 두 번 호출해도 되지 않나요? → 호출자 입장에서 의도를 표현하기 위해서입니다. "두 atom을 null로 만든다"가 아니라 "선택한 티켓을 초기화한다"는 도메인 행위가 한 번에 표현됩니다. 또 만약 나중에 티켓 관련 atom이 하나 추가되면, reset 행위는 호출자가 모르는 사이에 자동으로 그 atom까지 챙기게 되죠. write-only atom의 가치는 여기에 있다고 봅니다.

    • Q4-3-b. setDepartureDateAtom에서 날짜가 바뀌면 자동으로 resetSelectedTicketsAtom을 호출하던데, 이런 cross-atom 부작용은 위험하지 않나요? → 도메인적으로 의미가 분명한 경우에만 씁니다. 가는날이 바뀌면 직전에 고른 티켓은 더 이상 그 날짜의 티켓이 아니니까 무효 — 이건 비즈니스 룰이라 atom 레벨에서 강제하는 게 맞다고 봤어요. 만약 이걸 컴포넌트 레벨에서 챙기면 SearchPage·DateBottomSheet·StationPage 어디서 날짜를 바꾸든 똑같은 코드를 반복해야 합니다. 다만 cross-atom 부작용은 투명해야 해서 atom 이름을 setDepartureDateAtom으로 명확히 하고 한 파일 안에 모았습니다.

    CS · 이론
    • 호출 사이트(Call Site)의 단순성을 모델링 검증의 지표로 삼기: 좋은 모듈 경계는 사용처가 짧고 의도가 명확하게 표현됩니다. 호출하는 쪽이 매번 같은 boilerplate를 반복한다면 추상화가 잘못된 위치에 있습니다.
    • Write-only Atom (Action Atom): jotai에서 atom(null, (get, set) => ...) 형태의 write-only atom은 도메인 액션을 객체-지향 메서드처럼 표현합니다. 컴포넌트는 "어떻게"가 아니라 "무엇을"만 호출합니다.
    • Navigate replace vs push: 라우팅도 UX 일부 — 사용자의 mental model("뒤로가기는 직전 단계")과 history 스택을 일치시키는 게 핵심입니다.
  4. Q4-4

    atom이 모듈 스코프에 살아 있다고 하셨는데, 새로고침하면 어떻게 되나요? 그건 어떻게 다루셨나요?

    핵심 포인트

    • atom = 모듈 스코프 변수 → 새로고침 시 자바스크립트 모듈이 새로 평가되며 모든 atom이 초기값으로 리셋.
    • TicketsPage·ConfirmPage·CompletePage는 검색 조건·선택 티켓 없이는 의미 있는 화면을 그릴 수 없음.
    • 그래서 README to-do에 "각 페이지에서 사용하는 필수 데이터가 없을 경우 search page로 redirect 처리. (URL로 바로 접근 방지)"를 명시했습니다.
    • 6시간 제약 안에서 의식적으로 우선순위를 미룬 항목 — 회피가 아니라 의도적인 선택임을 분명히 함.
    모범 답안먼저 답해보고 펼치기

    좋은 지적이세요. atom은 결국 모듈 스코프에 사는 자바스크립트 변수이기 때문에, 사용자가 새로고침하면 자바스크립트가 새로 평가되면서 모든 atom이 초기값으로 돌아갑니다. 그러니까 ConfirmPage에서 새로고침을 누르면 선택한 티켓도, 검색 조건도 다 사라져요. 화면에는 빈 상태로 뭔가 깨진 ConfirmPage가 그려지게 됩니다.

    이걸 어떻게 다뤘냐면 — 두 가지 선택지가 있었어요. 첫째, 필수 데이터가 없으면 SearchPage로 강제 redirect하는 가드를 넣는 것. 둘째, 검색 조건/티켓을 sessionStorage 같은 곳에 persist하는 것. 6시간 제약을 고려해서 둘 다 미루고, README to-do에 "각 페이지에서 사용하는 필수 데이터가 없을 경우 search page로 redirect 처리. (URL로 바로 접근 방지)"라고 명시했습니다. 토스 가이드의 "사용자 경험 개선 지점은 README 등에 문서화" 원칙과 직접 연결되는 부분이에요.

    만약 시간을 더 받았다면, 첫 번째 선택지부터 갈 거 같아요. CompletePage에서 이미 비슷한 패턴을 쓰고 있어서 — useEffect에서 reservation == null이면 /search로 navigate replace — 이 패턴을 라우트 가드 컴포넌트로 일반화할 수 있습니다. persistence까지 가는 건 sessionStorage 키 관리, 직렬화 가능한 형태로 atom 정리, hydration 타이밍 같은 추가 의사결정이 필요해서 6시간 안에서는 ROI가 낮다고 봤습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q4-4-a. atom Storage 어댑터 — atomWithStorage를 쓰면 한 줄로 풀리지 않나요? → jotai/utils의 atomWithStorage를 쓰면 사실상 한 줄로 sessionStorage·localStorage에 persist됩니다. 다만 검색 조건의 Date 타입은 JSON 직렬화 시 문자열로 바뀌어서 hydrate 시점에 다시 new Date() 변환이 필요하고, Station·PassengerCount 같은 객체도 schema validation을 어디까지 할지 생각해야 합니다. 시간을 더 받으면 atomWithStorage + custom serializer로 가는 게 가장 깔끔한 길이라고 봅니다.

    • Q4-4-b. 가드 컴포넌트는 어떻게 짜실 생각이세요? → 라우트 단위로 <RequireSearchCondition>, <RequireSelectedTicket> 같은 wrapper를 만들고, atom value를 검사해서 null이면 <Navigate to={SEARCH} replace />를 반환하는 식이 가장 단순합니다. CompletePage에서 이미 손으로 같은 패턴을 쓰고 있어서, 그걸 그대로 추출하면 됩니다. 토스 가이드의 "익숙한 방법"에도 부합합니다.

    • Q4-4-c. README의 to-do에 적은 게 회피처럼 보이지는 않을지? → 그래서 의식적으로 "왜 안 했는가"를 인터뷰에서 분명히 말씀드릴 준비를 했어요. 6시간 제약 안에서 골격(편도/왕복, 다시 선택, 인원·날짜 검증)을 흔들림 없이 만드는 데 우선 시간을 썼고, 새로고침 가드는 골격이 안정된 뒤에 붙이는 게 맞다고 판단한 결과입니다. 토스 가이드의 "사용자 경험 개선 지점은 README 등에 문서화" 가이드라인을 그대로 따른 것이기도 합니다.

    CS · 이론
    • In-memory state vs Persisted state: 같은 "글로벌 상태"라도 새로고침을 견디는지가 갈라집니다. atom은 기본적으로 in-memory — atomWithStorage로 명시적으로 옮길 때만 persist됩니다.
    • Route guards / 권한·전제조건 가드: SPA에서 "이 페이지에 들어올 자격이 있는가"를 검사하는 패턴. 보통 HOC, wrapper 컴포넌트, 또는 라우터 loader로 구현됩니다.
    • 6시간 제약과 우선순위 결정: 토스 PDF의 "스스로 합리적인 가설을 세우고 계속 진행" 원칙 — 모든 걸 다 만들 시간이 없을 때, 무엇을 빼고 무엇을 README에 미루는지의 판단이 평가 대상입니다.
  5. Q4-5

    ConfirmPage에서 "다시 선택"과 CompletePage에서 "처음으로" 같은 흐름이 다르게 동작한다고 하셨는데, 어떻게 다른가요?

    핵심 포인트

    • ConfirmPage "다시 선택" → resetSelectedTicketsAtom만 호출 → 검색 조건 유지, 티켓만 초기화.
    • CompletePage "처음으로" → resetAllAtom 호출 → 모든 atom 초기화.
    • 사용자 입장의 mental model이 다름: "이번 검색 안에서 티켓만 다시 고르기" vs "예약 완료, 새 여정 시작".
    • atom 파일 분리 + reset atom 두 가지 분리가 이 두 행동을 자연스럽게 구분.
    모범 답안먼저 답해보고 펼치기

    같은 "초기화" 같지만 사용자 의도가 다른 두 행동이에요.

    ConfirmPage의 "다시 선택하기"는 — 사용자가 "이번에 검색한 조건은 맞는데 티켓 선택만 다시 하고 싶다"는 의도예요. 그래서 resetSelectedTicketsAtom만 호출해서 가는편/오는편 티켓만 비웁니다. 검색 조건 atom은 손도 대지 않으니까 SearchPage로 돌아가면 직전에 입력한 역·날짜·인원수가 그대로 보입니다. 토스 가이드 명시 요구사항 그 자체죠.

    반면 CompletePage의 "처음으로" 같은 흐름은 — "예약이 끝났고 새로운 여정을 시작한다"는 의미예요. 그래서 resetAllAtom을 호출합니다. 이 atom은 검색 조건 6개 + 선택한 티켓 2개를 모두 초기값으로 되돌립니다. 사용자가 SearchPage에 도착했을 때 빈 상태로 시작하는 게 맞아요.

    이렇게 두 reset 함수를 따로 둔 게 atom 파일 분리의 또 다른 효과예요. 검색 조건 atom과 티켓 atom을 한 파일에 몰아넣고 resetAtom 하나만 만들었다면, 호출자가 매번 "어디까지 reset할 건지"를 직접 챙겨야 했을 거예요. atom 측에서 도메인 의도(부분 reset vs 전체 reset)를 분리해서 노출하니까, 컴포넌트 측 코드는 자기 행동에 맞는 atom을 골라 호출하기만 하면 됩니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q4-5-a. resetAllAtomresetSelectedTicketsAtom을 내부에서 호출하던데, 이런 atom 간 호출 패턴이 권장되나요? → jotai write-only atom에서 다른 write-only atom을 set(otherAtom) 형태로 호출하는 건 정상 패턴이고 jotai 공식 문서에서도 예시로 보여줍니다. 도메인 행동을 합성하는 데 자연스럽고, 결정적으로 "전체 reset에 부분 reset이 포함된다"는 관계가 코드에 그대로 드러나서 좋습니다.

    • Q4-5-b. 만약 resetAllAtom에서 resetSelectedTicketsAtom을 호출하지 않고 직접 두 ticket atom을 set null로 했다면 어떤 차이가 있나요? → 동작은 같지만 추후 ticket 관련 atom이 늘어났을 때 누락 위험이 생깁니다. resetSelectedTicketsAtom을 single source로 두면 ticket 도메인의 reset 정의가 한 곳에 있고, resetAllAtom은 "ticket reset + 검색 조건 reset"이라는 의도만 표현하면 됩니다. 변경 비용 측면에서 합성이 정답이라고 봤어요.

    • Q4-5-c. 만약 사용자가 "예약 완료 후에도 검색 조건은 유지하고 싶다"고 한다면 어떻게 바꾸시겠어요? → CompletePage의 reset 호출을 resetAllAtom에서 resetSelectedTicketsAtom으로 바꾸면 끝납니다. atom 분리 덕에 변경 지점이 한 줄이에요. 이게 atom 파일 분리가 가져다주는 진짜 이점이라고 봅니다 — 정책 변경의 비용이 지역화됩니다.

    CS · 이론
    • 사용자 의도(User Intent)에 따른 행동 분리: UI에서 같은 시각적 결과처럼 보이는 행동도 사용자 mental model이 다르면 코드 레벨에서 분리해서 표현하는 게 좋습니다. 정책이 바뀔 때 변경 지점이 한 곳에 모입니다.
    • 합성 가능한 액션(Composable Actions): write-only atom 간 호출은 도메인 액션을 작은 단위로 정의하고 합성하는 패턴 — Redux의 thunk가 다른 thunk를 dispatch하는 것과 같은 발상입니다.
    • Locality of Change: 좋은 모듈화의 척도는 "정책이 바뀔 때 몇 군데를 고쳐야 하는가"입니다. atom 분리 + 합성으로 변경 지점이 1~2곳에 머무는 게 이상적입니다.