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

과제 기반 — 기차예약

토픽 5 · Phase 1

폼·검증 정책 (인원·날짜)

질문 5개
  1. Q5-1

    인원 선택 BottomSheet에서 9명 초과·유아 단독을 막는 검증을 어디에 두셨나요? 왜 거기인가요?

    핵심 포인트

    • tosslibNumericSpinnerminNumber로 0 미만은 자체 차단하지만, 상한은 합계가 9명이라 spinner 단독으로 표현 불가.
    • CountBottomSheet.tsxhandleChange에서 증가 동작만 가로채서 검증 → 통과 시에만 setCount.
    • 두 가지 룰: (1) 합계 ≥ 9면 "최대 9명까지" 토스트, (2) key === 'newborn' && adults === 0이면 "유아는 보호자와 함께" 토스트.
    • 감소는 검증 없이 통과 — UX상 줄이는 행위는 항상 안전.
    • 실패 케이스를 토스트로 알려주고 setCount는 호출하지 않음 — 사용자에게 "왜 안 됐는지" 피드백.
    모범 답안먼저 답해보고 펼치기

    검증을 어디에 둘지가 인원 BottomSheet에서 가장 고민한 부분이에요.

    tosslibNumericSpinnerminNumber 같은 단순 상·하한을 prop으로 표현할 수 있지만, "어른+어린이+유아 합계가 9명"은 다른 spinner 상태에 의존하는 룰이라 spinner 단독으로 표현이 안 돼요. 그래서 검증을 한 단계 위 — CountBottomSheethandleChange — 에서 가로챘습니다.

    handleChange에서 가장 먼저 한 일은 "이 동작이 증가인가 감소인가"를 판별하는 거예요. next > count[key]이면 증가. 증가일 때만 두 가지 룰을 검사합니다 — 합계가 이미 9명 이상이면 토스트 띄우고 setCount 호출하지 않고 early return, 또 key === 'newborn'이고 count.adults === 0이면 "유아는 보호자와 함께" 토스트. 룰을 통과해야만 setCount({...count, [key]: next})가 호출됩니다.

    감소는 검증 없이 통과시켰어요. UX 관점에서 "줄이는 동작이 막히는 경우"는 거의 없고, 막더라도 사용자가 헷갈리기만 합니다. 일단 줄이게 하고, 다음 증가 시점에 다시 검증하는 게 마음이 편합니다.

    토스트로 피드백을 주는 건 토스 가이드라인의 "사용자 경험 개선 지점" 원칙과 맞닿아 있어요. 그냥 막아만 두면 사용자는 "왜 안 되지?"가 궁금해집니다. 토스트로 짧게 이유를 알려주면 그 자리에서 결정 — "어른을 한 명 더 추가해야겠다" — 이 가능합니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q5-1-a. 감소도 검증해야 하는 경우가 있나요? 예를 들면 어른이 0명이 되는데 유아가 남아있는 경우. → 그런 케이스도 룰 자체로는 위반인데, 의도적으로 감소 시점에는 막지 않았어요. 사용자가 어른 수를 임시로 0으로 줄였다가 다른 인원을 조정한 뒤 다시 어른을 늘리는 흐름이 자연스러울 수 있거든요. 대신 "확인" 버튼을 눌러 commit하는 시점에 다시 검증하는 게 답이라고 봤는데, 6시간 안에서는 증가 시점 검증만으로도 큰 문제가 생기지 않아 우선 미뤘습니다. README to-do 후보 중 하나입니다.

    • Q5-1-b. 검증을 atom 레벨로 끌어올릴 수도 있을 텐데요? setPassengerCountAtom을 write-only atom으로 만들고 거기서 검증하는 식으로. → 가능합니다. 다만 검증 실패 시 "토스트를 띄운다"는 UI side effect가 atom으로 가면 atom이 React/overlay-kit에 의존하게 돼서 테스트·재사용이 까다로워져요. 그래서 "도메인 룰은 atom, UI 피드백은 컴포넌트"로 책임을 나눴습니다. 사실 passengerCountAtom은 BottomSheet 안에서 useState 임시 카운트로 받았다가 "확인" 시 한 번 commit하는 구조라, atom 레벨 검증보다 컴포넌트 레벨 검증이 자연스러웠어요.

    • Q5-1-c. 합계 검증은 totalPassengers(count) >= MAX_PASSENGER_TOTAL인데, > 9가 아니라 >= 9로 한 이유가 뭔가요? → 증가 직전 시점에 검사하는 거라 그렇습니다. 현재 합계가 이미 9면 한 번 더 증가시키는 순간 10명이 되니까, 증가 자체를 막아야 해요. > MAX_PASSENGER_TOTAL로 두면 9명 → 10명으로 잠깐 통과한 뒤에 막혀서 한 박자 늦은 피드백이 됩니다.

    CS · 이론
    • Boundary validation: 도메인 룰은 가능한 한 데이터가 변경되는 boundary에서 검증해야 합니다. spinner 컴포넌트 내부가 아니라, "spinner가 주는 next 값을 setCount로 commit하기 직전"이 그 boundary였습니다.
    • Pessimistic vs Optimistic feedback: 증가를 막고 토스트로 알리는 건 pessimistic 접근 — 잘못된 상태가 일순간이라도 화면에 보이지 않습니다. 금융 도메인에서는 pessimistic이 기본값으로 안전합니다.
    • 부정 피드백(Negative feedback)의 비용: 막혔는데 이유가 없으면 사용자가 답답해합니다. 토스 디자인의 결을 따라 짧고 명확한 토스트로 이유를 함께 전달했습니다.
  2. Q5-2

    가는날을 바꾸면 오는날이 자동으로 보정되는 동작은 어떻게 구현하셨고, 왜 그 위치에 두셨나요?

    핵심 포인트

    • setDepartureDateAtom write-only atom 안에서, 가는날이 바뀌면 오는날을 검사해서 가는날보다 이전이면 undefined로 비움.
    • 위치 선정 이유: 비즈니스 룰 "가는날 ≤ 오는날"은 도메인 invariant — 가는날을 set하는 모든 호출 지점에 흩어두면 위반 위험.
    • 추가로 가는날이 변경되면 resetSelectedTicketsAtom도 자동 호출 — 직전 티켓 선택은 더 이상 그 날짜의 티켓이 아니라 무효.
    • DateBottomSheet의 disableDate는 calendar 단의 시각적 차단이고, atom의 보정은 데이터 단의 진짜 invariant 강제 — 두 layer가 서로 보완.
    모범 답안먼저 답해보고 펼치기

    이건 "비즈니스 룰을 어디에 둘 것인가"를 가장 또렷하게 보여주는 부분이에요.

    룰은 단순해요 — 가는날은 오는날보다 같거나 이전이어야 한다. 사용자가 처음에는 5월 1일을 가는날로, 5월 3일을 오는날로 잡았다고 칠게요. 그런데 가는날을 5월 5일로 바꿉니다. 이러면 오는날(5월 3일)이 가는날(5월 5일)보다 빨라져서 invariant가 깨집니다.

    이걸 푸는 위치가 두 군데 있을 수 있어요. 첫째, SearchPage의 가는날 BottomSheet onConfirm 핸들러에서. 둘째, atom 레벨에서. 저는 atom 레벨을 골랐습니다 — setDepartureDateAtom write-only atom을 두고, 거기서 (1) 가는날을 set하고, (2) 오는날이 가는날보다 이전이면 undefined로 비웁니다. 추가로 (3) 가는날이 실제로 바뀌었다면 resetSelectedTicketsAtom도 호출해서 직전에 고른 티켓도 비웁니다.

    atom 레벨로 둔 이유는 — 가는날을 set하는 주체가 미래에 늘어날 수 있기 때문이에요. 지금은 SearchPage의 DateBottomSheet에서만 set하지만, 나중에 "최근 검색 기록 다시 적용" 같은 기능이 생기면 거기서도 set할 거고, deep link로 검색 조건을 받는 시나리오도 가능합니다. 그때마다 보정 로직을 호출자가 챙기게 두면 빠뜨릴 위험이 큽니다. invariant는 데이터 변경 boundary에 두는 게 맞다고 봤어요.

    DateBottomSheet의 disableDate도 같이 보면 재밌는 부분이에요. 오는날 BottomSheet에는 가는날 이후만 선택 가능하도록 disableDate={d => startOfDay(d) < min}을 줍니다. 이건 calendar의 시각적 차단 — 사용자가 잘못된 날짜를 클릭조차 못 하게 하는 layer입니다. atom의 보정은 그게 뚫렸을 때(예: 가는날을 나중에 바꿔서 기존 오는날이 무효가 됐을 때)의 진짜 invariant 강제. 두 layer가 서로 다른 역할로 보완합니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q5-2-a. 오는날을 단순히 비우는 게 아니라 "가는날과 같은 날짜로 자동 조정"하는 건 어떨까요? → 사용자 의도를 추측하는 건 위험하다고 봤어요. 사용자가 가는날을 바꾼 의도가 "기간을 더 길게 잡고 싶다"였을 수도 있고, "여행 자체를 미루고 싶다"였을 수도 있어서, 오는날을 추측해서 자동 채우면 사용자 의도와 어긋날 가능성이 큽니다. undefined로 비워두면 사용자에게 "다시 선택하라"는 시그널이 명확하고, isSearchValidAtom이 false가 되어 검색 버튼이 disabled 됩니다.

    • Q5-2-b. hasDepartureDateChanged로 같은 날짜 set은 reset에서 제외하셨던데, 이 디테일이 왜 중요한가요? → 사용자가 같은 가는날을 다시 누르는 케이스 — 예를 들어 BottomSheet 재오픈 시 똑같은 날짜를 confirm하는 경우 — 까지 티켓을 reset하면 UX가 어색합니다. "내가 안 바꿨는데 왜 티켓이 사라지지?"가 되니까요. 그래서 startOfDay(...).getTime() 비교로 진짜 변경된 경우에만 티켓을 reset합니다. 시간 부분을 자르고 비교하는 게 핵심 — 같은 날짜라도 Date 객체는 시·분이 다를 수 있어요.

    • Q5-2-c. isSameDay 대신 startOfDay(...).getTime() 비교를 쓰신 이유는요? → 의도는 같지만 isSameDay는 boolean을 반환하는데, 여기서는 "변경됐는가?"라는 negation이 필요해서 !== 비교가 의도가 더 직접적이에요. isSameDay(a, b) ? ... : ... 보다 startOfDay(a).getTime() !== startOfDay(b).getTime()이 같은 일을 하는데 의미가 분명합니다. 사실 둘 다 가능한데 일관성 측면에서 한쪽으로 정리할 여지는 있어요.

    CS · 이론
    • Domain Invariant: 도메인 룰 중에서도 "절대 깨지면 안 되는 관계"를 invariant라고 합니다. invariant는 변경 boundary에서 강제하는 게 정석 — 컨테이너 안의 모든 변경 경로에 흩어두면 빠뜨림 위험이 큽니다.
    • 다층 검증(Defense in Depth): UI 단의 차단(disableDate)과 데이터 단의 보정(atom)을 둘 다 두는 패턴. 한 쪽이 뚫려도 다른 쪽이 막아주는 안전망.
    • Cross-state side effect의 명시화: "X를 바꾸면 Y가 영향받는다"는 관계를 컴포넌트에서 챙기지 말고 atom 안에서 명시화 — 호출자는 단순히 "X를 바꿔달라"만 부탁하면 됩니다.
  3. Q5-3

    `formatPassengerCount`에서 0인 항목을 표시 제외하는 처리가 토스 가이드 미세 룰 중 하나였는데요, 이걸 어디서 어떻게 처리하셨나요?

    핵심 포인트

    • 토스 PDF에 명시된 미세 룰: "어른 1명, 어린이 0명, 유아 0명" 같은 0인 항목은 표시 제외.
    • utils/passenger.tsformatPassengerCount에서 처리 — count.adults > 0일 때만 parts.push.
    • 단순한 if push 패턴 — 화려한 추상화 없이 명시적 — 토스 가이드 "익숙한 방법" 원칙.
    • 출력은 parts.join(', ') — 결과: "어른 1명" (0인 항목 자동 제외, 구분자도 자동).
    모범 답안먼저 답해보고 펼치기

    이건 사소해 보이지만 토스 PDF에 명시된 미세 룰이라 지나치지 않게 신경 썼어요.

    utils/passenger.tsformatPassengerCount 함수에서 처리합니다. 함수는 parts: string[]를 만들어두고, 어른·어린이·유아를 순서대로 검사해서 count가 0보다 클 때만 parts.push(...)를 호출합니다. 마지막에 parts.join(', ')로 결과를 합칩니다.

    이렇게 하면 "어른 1명, 어린이 0명, 유아 0명" 입력은 parts = ['어른 1명']이 되고 join 결과는 "어른 1명"이 됩니다. 0인 항목이 자동 제외될 뿐 아니라 구분자(", ")도 자연히 따라옵니다. 화려한 처리 없이 단순한 if-push 패턴인데, 토스 가이드의 "화려한 방법보다 평소에 하던 가장 익숙한 방법"이 정확히 이런 자리에 어울린다고 생각해요.

    위치를 utils에 둔 이유는 — 인원 표시는 화면 여러 곳에서 쓰일 수 있어서요. SearchPage의 인원 입력 칩, ConfirmPage의 예약 정보 요약, CompletePage의 완료 화면 — 같은 표현이 반복되니 한 곳에 모아둔 게 자연스러웠습니다. atom이나 컴포넌트에 두면 재사용이 어렵고, format 책임은 데이터 변환이라 atom·컴포넌트의 책임 영역이 아닙니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q5-3-a. parts.join(', ') || '0분' 같은 fallback도 보이던데, formatPassengerCount에는 그런 fallback이 없는 이유가 있나요?formatDuration에는 fallback이 있는데 formatPassengerCount에는 빈 문자열이 나올 수 있어요. 다만 isSearchValidAtomtotalPassengers === 0이면 검색을 막기 때문에 실제 화면에는 빈 문자열이 표시되는 경로가 없습니다. 그래서 fallback 없이 둔 건 의도적이긴 한데, 만약 테스트 환경이나 deep link로 비정상 상태가 들어올 수 있다면 명시적으로 "성인 1명" 같은 기본값을 두는 게 안전하긴 해요. 6시간 안에서는 isSearchValid 검증을 신뢰하는 쪽으로 간 결정입니다.

    • Q5-3-b. 0 항목 제외 로직을 Object.entries(count).filter(...).map(...) 같은 reduce-style로 짜는 건 어땠을까요? → 그렇게 짜면 코드 줄 수는 줄지만 두 가지 단점이 생깁니다. 첫째, label("어른"/"어린이"/"유아") 매핑을 별도 객체로 빼야 해서 유닛이 분리됩니다. 둘째, key 순서가 enum 정의에 의존하게 돼서 표시 순서가 우연한 일이 됩니다. 명시적 if-push로 두면 "어른 → 어린이 → 유아" 표시 순서가 코드에 그대로 드러나서 가독성이 더 좋다고 봤어요. 이게 "익숙한 방법" 원칙에 맞습니다.

    • Q5-3-c. 만약 인원 항목이 5~6개로 늘어나면 그때는 reduce로 가는 게 맞을까요? → 네, 그때는 매핑 객체를 분리하는 게 정당화됩니다. 룰 오브 쓰리(Rule of Three) 관점에서 3개까지는 명시적으로 두고, 그 이상은 추상화 — 지금은 3개라 명시적으로 두는 게 정답이라고 봤습니다.

    CS · 이론
    • Format 함수의 단일 책임: 데이터 → 화면 표시 문자열로의 변환은 atom·컴포넌트 어디에도 속하지 않는 순수 함수 영역. utils로 분리하면 단위 테스트도 쉽고, 화면 여러 곳에서 재사용됩니다.
    • 표현 순서와 정렬 안정성: 자바스크립트 객체 key 순회 순서는 spec상 보장은 되지만, 명시적 push 순서로 코드가 표현 순서를 직접 드러내는 게 코드 읽기에 친화적입니다.
    • Defensive default vs Trusted invariant: fallback("0명")을 넣을지, 아니면 상위 invariant(isSearchValidAtom)를 신뢰할지의 trade-off. 후자가 더 단순하지만 invariant가 깨졌을 때 화면에 이상한 게 보일 수 있습니다.
  4. Q5-4

    CountBottomSheet에서 `useState`로 임시 count를 받았다가 "확인" 시점에 atom으로 commit하는 패턴을 쓰셨는데요, 왜 이렇게 했나요?

    핵심 포인트

    • BottomSheet 안에서는 사용자가 spinner를 여러 번 누르면서 임시값을 만지는 단계.
    • 이 임시값을 atom에 직접 흘리면 "취소" 시 원래 값으로 돌리기가 어려워짐.
    • 그래서 initialCount로 atom 값을 받아 useState로 복사해두고, "확인" 시 onConfirm(count) callback으로 부모에 넘김 — 부모가 atom에 commit.
    • 사용자가 "X" 누르면 onClose만 호출되고 atom은 손도 안 댐 — Cancel/Confirm 패턴의 정석.
    모범 답안먼저 답해보고 펼치기

    BottomSheet의 commit-on-confirm 패턴이에요. 토스 디자인 시스템뿐 아니라 일반적인 폼 다이얼로그의 표준이라고 봅니다.

    상황을 보면 — 사용자가 인원 BottomSheet를 열고 spinner를 여러 번 누릅니다. 어른 1 → 2 → 3, 어린이 0 → 1, 어른 다시 3 → 2 — 이 모든 중간 상태가 atom에 그대로 흘러 들어가면, 사용자가 BottomSheet를 "X"로 닫았을 때 마지막 상태가 그대로 남아 있게 됩니다. "취소했는데 왜 변경됐지?"가 되는 거예요.

    그래서 CountBottomSheet의 첫 줄에서 const [count, setCount] = useState<PassengerCount>(initialCount)로 atom 값을 복사한 임시 상태를 만듭니다. spinner 변화는 모두 이 임시 count만 바꿉니다. 사용자가 "확인"을 누르면 onConfirm(count)로 부모 컴포넌트에 commit, "X"로 닫으면 onClose()만 호출되고 atom은 손도 닿지 않아요.

    부모 컴포넌트(SearchPage)에서는 onConfirm 콜백 안에서 setPassengerCount(count)로 atom에 한 번 반영하면 됩니다. atom의 commit point가 명확해서 디버깅·로깅도 쉬워요.

    이 패턴의 또 다른 이점은 atom의 변경 횟수가 줄어든다는 점이에요. spinner 한 번 누를 때마다 atom을 update하면 atom에 의존하는 모든 컴포넌트가 매번 리렌더되는데, 임시 useState로 받으면 BottomSheet 내부만 리렌더되고 atom은 confirm 시 한 번만 update됩니다. jotai의 입자도(granularity) 모델에서도 의미가 있습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q5-4-a. atom을 직접 BottomSheet 안에서 useAtom으로 읽고 쓰면 안 됐을까요? → 동작은 합니다. 다만 위에 말한 cancel 시나리오가 깨져요. atom에 직접 쓰면 "변경 → 닫기"가 곧 "commit"이 됩니다. 이걸 우회하려면 BottomSheet 열 때 atom 값을 백업해두고 cancel 시 복구하는 코드를 BottomSheet에 넣어야 하는데, 결국 useState로 임시 복사하는 것과 같은 일을 더 어렵게 하는 셈입니다.

    • Q5-4-b. initialCount prop이 변하면 useState는 어떻게 되나요? "stale state" 문제 없나요? → useState의 initial value는 첫 마운트 시 한 번만 사용되고, 이후 prop이 바뀌어도 자동으로 동기화되지 않습니다. 다만 BottomSheet는 open={true}로 열릴 때 새로 마운트되는 구조라 — overlay-kit이 보통 이렇게 동작 — 매번 열릴 때 최신 atom 값이 initialCount로 들어옵니다. open/close가 mount/unmount와 일치하면 stale 문제가 없어요. 만약 open={false}일 때 컴포넌트가 살아 있는 구현이라면 useEffect([initialCount])로 sync해야 합니다.

    • Q5-4-c. 이 패턴을 추상화해서 useTempState(atom) 같은 hook으로 빼는 건 어떨까요? → 가능하지만 지금은 인원 BottomSheet 한 곳뿐이라 추상화의 비용이 이득보다 큽니다. Rule of Three — 3번 반복되면 그때 추상화. 6시간 안에서는 명시적 useState로 두는 게 토스 가이드의 "익숙한 방법"에도 맞고요. 만약 날짜 BottomSheet도 같은 패턴을 쓰는데 — 실제로 DateBottomSheetuseState<Date | undefined>(initialDate)로 같은 패턴 — 그럼 두 번이라 아직 추상화 시점은 아닙니다. 세 번째 케이스가 생기면 그때 hook으로.

    CS · 이론
    • Controlled Cancel/Confirm 패턴: 사용자의 임시 입력을 commit point에서만 전역 상태에 반영하는 패턴. 다이얼로그·모달·BottomSheet의 표준 UX 모델.
    • Local state as buffer: useState를 atom의 staging buffer로 쓰는 발상. atom = 영속적 sourceof-truth, useState = 임시 작업장.
    • Render granularity: atom 변경 횟수를 줄이면 의존 컴포넌트의 리렌더 횟수도 줄어듭니다. jotai는 atom 단위로 구독하기 때문에 리렌더 비용이 atom write 횟수에 비례합니다.
  5. Q5-5

    `isSearchValidAtom` derived atom으로 검색 버튼 disabled를 묶었는데, 이 atom 안에 7개 분기가 있어요. 왜 한 atom에 다 모았나요?

    핵심 포인트

    • 검색 버튼 활성/비활성은 6개 검색 조건이 모두 만족해야 결정되는 single answer.
    • 7가지 분기: 출발역/도착역 존재, 출발=도착 차단, 가는날 존재, 왕복일 때 오는날 존재, 인원 합계 ≥ 1, 유아 동반 어른 ≥ 1, 모두 통과 시 true.
    • derived atom으로 한 곳에 모아 두면 SearchPage의 CTA 버튼은 useAtomValue(isSearchValidAtom) 한 줄로 끝.
    • console.warn을 분기마다 둔 건 개발 중 디버깅용 — 실제 운영이라면 분석 이벤트나 disabled 이유 toast로 발전 여지.
    모범 답안먼저 답해보고 펼치기

    검색 버튼이 disabled인지 enabled인지는 single boolean answer에요. 그런데 그 답을 만드는 입력은 6개 atom 전부 — 출발역, 도착역, 여행 방식, 가는날, 오는날, 인원수.

    이 계산을 SearchPage 컴포넌트 안에서 직접 하면 — "출발역 있고, 도착역 있고, 같지 않고, 가는날 있고, 왕복이면 오는날 있고, 인원 ≥ 1, 유아 있으면 어른 ≥ 1" — 7개 분기가 컴포넌트 안에 풀어지게 됩니다. SearchPage의 CTA 버튼 props에 7개 boolean이 흘러 들어가면 가독성도 떨어지고, 비슷한 로직이 다른 화면(예: ConfirmPage의 "확인" 버튼이 검색 조건을 다시 검증해야 하는 경우)에서 재사용도 안 돼요.

    그래서 isSearchValidAtom을 derived atom으로 만들었습니다. 7개 분기를 atom 안에 모으고, SearchPage는 useAtomValue(isSearchValidAtom) 한 줄로 boolean을 받아 disabled={!isSearchValid}에 꽂습니다. 검색 조건이 어디서 어떻게 변하든 derived atom이 자동으로 재계산되니까, 컴포넌트는 신경 쓸 게 없어요.

    각 분기에 console.warn을 둔 건 개발 중 디버깅 목적이에요. "검색 버튼이 왜 disabled지?"가 헷갈릴 때 콘솔에 이유가 떠서 빠르게 진단할 수 있습니다. 운영 환경이라면 이 자리를 분석 이벤트나 사용자에게 보여줄 toast 메시지로 발전시킬 수 있어요. "오는날을 선택해주세요" 같은 친절한 안내가 가능해집니다 — 6시간 안에서는 console.warn까지가 최선이었습니다.

    derived atom을 한 곳에 두는 또 다른 이점은 검증 룰의 변경 비용이 한 곳에 모인다는 거예요. 만약 "성인이 어린이 수보다 적으면 안 된다" 같은 룰이 추가되면, isSearchValidAtom 안에 분기 한 줄을 더하면 끝입니다. 모든 사용처가 자동으로 새 룰을 적용받아요.

    이 답변, 어땠나요?

    꼬리 질문
    • Q5-5-a. 분기마다 메시지를 다르게 두면, 사용자에게 disabled 이유를 보여주는 toast로 발전 가능할 거 같은데요? → 네, 그 방향이 자연스러운 발전입니다. 지금은 boolean 한 개를 반환하지만, 같은 derived atom을 { valid: boolean, reason: string | null } 같은 객체로 바꾸면 사용처가 reason을 받아 toast로 보여줄 수 있어요. 다만 toast 결정은 도메인 룰을 넘어 UX 정책이라 — "disabled인 채로 두기" vs "비활성 시점에 안내 toast" — 디자이너와 합의가 필요한 영역입니다. 그래서 자료에서는 derived atom의 boolean까지로 두고, README에 발전 후보로 적는 게 맞다고 봤어요.

    • Q5-5-b. derived atom의 7개 분기 순서가 검증 우선순위를 의미하나요? 순서가 바뀌면 다르나요? → 코드 읽기 측면에서는 의미가 있지만 결과적으로는 모든 분기를 통과해야 true가 나오기 때문에 함수의 결과는 순서 독립입니다. 다만 console.warn으로 첫 번째 실패 이유를 출력할 때는 순서가 우선순위로 작용해요. 지금은 "기본 입력 → 동일 역 차단 → 날짜 → 인원" 순으로 둬서 사용자가 보통 채우는 순서를 따라갑니다 — UX 친화적인 우선순위입니다.

    • Q5-5-c. useMemo로 같은 일을 할 수도 있을 텐데, derived atom을 고른 이유는 뭐예요? → useMemo는 컴포넌트 스코프라 isSearchValid를 쓰는 컴포넌트마다 동일한 의존 배열을 적어야 하고, 한 군데에서 의존을 빠뜨리면 stale value가 됩니다. derived atom은 atom 의존성을 자동으로 추적해서 — get(departureStationAtom)을 호출하면 dependency로 자동 등록 — stale 위험이 없습니다. 또 atom으로 두면 react-router 바깥, 예를 들어 router loader나 react 컴포넌트 외부에서도 접근할 수 있어요. jotai의 가장 큰 장점이 derived atom의 자동 의존 추적이라고 봅니다.

    CS · 이론
    • Single Responsibility & Cohesion: 검증 룰이 한 atom에 모여 있으면 "검색 가능 여부"라는 단일 답에 대한 단일 책임 — cohesion이 높습니다. 분산되면 변경 비용이 커집니다.
    • Derived state의 자동 의존 추적: jotai/recoil 같은 atom-based 라이브러리의 가장 큰 강점. computed/Vue computed, MobX의 derived, RxJS의 combineLatest와 같은 발상입니다.
    • Disabled 상태와 사용자 안내(Affordance)의 관계: disabled로 두는 건 "왜 안 되는가"를 사용자가 추측해야 한다는 비용을 의미합니다. 금융 도메인에서는 단순 disabled보다 "왜 막혔는지" 안내가 신뢰감을 만듭니다.