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

과제 기반 — 기차예약

토픽 1 · Phase 1

아키텍처·폴더 구조 + 시작점에서 무엇을 어떻게 분해했는가

질문 5개
  1. Q1-1

    시작점이 `SearchPage.tsx` 185줄에 `CountBottomSheet`/`DateBottomSheet`까지 다 인라인이었는데, 어떤 기준으로 쪼갰나요?

    핵심 포인트

    • 시작점이 그 상태였다는 건 분해 능력 자체를 평가한다는 뜻으로 받아들였음
    • 단일 책임 + 화면 단위 의미가 분해 기준 — "검색 조건의 어떤 한 필드를 책임지는가"로 잘랐음
    • 페이지(SearchPage)는 조립만, 데이터·이벤트는 자식이 자기 atom·query를 직접 읽음
    • 재사용 가능성은 부차적 기준. 6시간 안에 "있을 법한 재사용"을 추상화하지 않음 (PDF: "화려한 방법보다 익숙한 방법")
    • 결과: SearchPage.tsx 185줄 → 43줄 + 5개의 feature 컴포넌트(StationSection, TripTypeTab, DateSection, DateField, CountField + 두 BottomSheet)
    모범 답안먼저 답해보고 펼치기

    시작점을 처음 봤을 때 두 가지 시그널을 읽었습니다. 하나는 SearchPage.tsx 안에 CountBottomSheetDateBottomSheet가 통째로 인라인으로 들어가 있고 모든 onClick이 () => {} 스텁이라는 점, 다른 하나는 package.json에 jotai나 react-query가 없어서 상태 라이브러리를 직접 골라야 한다는 점이었습니다. 토스가 마크업·스타일은 거의 다 깔아놓고 기능만 비워둔 거니까, 결국 평가 포인트는 "이 인라인 덩어리를 어떤 기준으로 쪼갤 줄 아느냐"라고 봤습니다.

    분해 기준은 검색 조건의 어떤 한 필드를 책임지는가로 잡았습니다. 출발/도착역, 편도·왕복, 가는날·오는날, 인원 — 이렇게 네 필드가 있으니 각각을 StationSection, TripTypeTab, DateSection, CountField로 분리하고, 페이지는 그것들을 위에서 아래로 조립만 합니다. 그 결과 SearchPage.tsx는 43줄까지 줄었고, 페이지를 한 번 훑으면 화면 구조가 바로 읽힙니다.

    분해할 때 의식적으로 피한 게 하나 있는데, "미래에 재사용될 수도 있으니까"라는 이유로 추상화하지 않는다는 거였습니다. 6시간짜리 과제고 토스가 "화려한 방법보다 평소에 익숙한 방법"을 권장한 만큼, 지금 화면 한 군데에서만 쓰이는 건 그냥 그 자리에 두자고 결정했습니다. EmptyResult처럼 station/ticket 두 곳에서 같은 빈 상태를 보여줘야 할 때만 components/common/으로 빼냈습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. 그럼 더 잘게 쪼갤 수도 있었을 텐데, 어디서 멈춘 기준은요? → 하나의 컴포넌트가 자기 atom 한두 개와 자기 BottomSheet 하나만 책임지면 거기서 멈췄습니다. CountFieldpassengerCountAtom 하나 + CountBottomSheet 하나를 다루는 정도가 적당했고, 더 잘게 쪼개면 props 드릴링이 늘어나서 오히려 가독성이 떨어진다고 봤습니다.
    • Q-b. 페이지가 너무 얇은 거 아닌가요? 그냥 import만 모아둔 파일 같은데요. → 의도한 부분입니다. 페이지는 라우트 경로와 화면 조립을 책임지고, "이 화면에 뭐가 있느냐"가 한눈에 보이는 게 가치라고 봤습니다. 데이터·상태·이벤트 핸들러는 그걸 실제로 쓰는 자식이 직접 끌어와서 처리하면 props 드릴링이 사라집니다.
    • Q-c. StationSection은 출발역/도착역 두 ListRow를 다 갖고 있는데, 두 개로 또 쪼갤까 고민 안 했나요? → 했습니다. 다만 출발역·도착역 사이에 화살표 아이콘이 끼는 시각적 단위가 있고, 둘이 항상 같이 등장하기 때문에 한 컴포넌트로 두는 게 응집도(cohesion) 측면에서 자연스럽다고 판단했습니다. 만약 둘 사이에 다른 UI가 끼어 들어오는 일이 생기면 그때 분리하는 게 합리적이라고 봤습니다.
    CS · 이론
    • Container vs Presentational: 원조는 Dan Abramov가 2015년에 정의한 패턴 — 데이터를 끌어오는 Container, 받아서 그리기만 하는 Presentational. Hooks 시대 이후 강박은 사라졌지만 "한 컴포넌트가 데이터까지 다 꺼내서 그리느냐, 받아서 그리기만 하느냐"는 여전히 분해 기준의 한 축. 본 과제에서는 StationList(query+atom)·CountField(atom+overlay) 같은 smart 컴포넌트와 DateBottomSheet·StationSearchField 같은 pure 컴포넌트가 공존.
    • Single Responsibility Principle (SRP): 한 모듈은 변경의 이유가 하나여야 한다 (Robert C. Martin). 본 과제에서 분해 기준 — "검색 조건의 어떤 한 필드"가 곧 변경의 이유.
    • Co-location (Kent C. Dodds): 함께 변하는 코드는 함께 둔다. components/{feature}/{ComponentName}/ 구조가 이 원칙. 추후 .module.css, .test.tsx, types.ts 같은 짝 파일이 자연스럽게 같은 폴더로 합류.
    • YAGNI (You Aren't Gonna Need It): "필요할지도 모르니까"의 추상화를 피한다. 6시간 제약 + 토스의 "익숙한 방법" 가이드라인과 정확히 정렬.
  2. Q1-2

    폴더를 `pages` / `components` / `atoms` / `query` / `types` / `utils` / `lib` 으로 나눈 기준은?

    핵심 포인트

    • Layer-based + 일부 Feature-based 혼합 — 최상위는 Layer(역할), components 내부는 Feature(화면)
    • 각 폴더는 한 가지 종류의 모듈만: pages는 라우트 진입점, atoms는 client state, query는 server state hook, types는 도메인 모델, utils는 순수 함수, lib는 third-party 설정
    • atomsquery를 분리한 게 의도적 — client state(검색 조건)와 server state(역·티켓·예약)의 경계 명시
    • 라이브러리 설정(queryClient)을 lib/로 빼서 atoms/query 안에 섞이지 않게
    모범 답안먼저 답해보고 펼치기

    폴더 구조는 두 가지 축을 섞었습니다. 최상위는 역할(layer) 기준 — pages, components, atoms, query, types, utils, lib — 이고, components 안쪽만 기능(feature) 기준으로 한 번 더 나눴습니다. 작은 과제라 React 생태계에서 가장 익숙한 형태로 가는 게 토스 가이드라인에도 맞다고 봤습니다.

    여기서 한 가지 명시적인 의도가 있는데, atomsquery를 따로 둔 부분입니다. atoms는 사용자 입력(출발역, 가는날, 인원 등) 같은 client state를 담고, query는 useStationsQuery, useTicketsQuery, useReservationQuery, useReservationMutation처럼 서버에서 가져오는 server state를 담습니다. 둘이 한 폴더에 섞이면 어디까지가 사용자가 만든 상태고 어디부터가 서버 동기화 상태인지 흐려지는데, 폴더로 분리해두니 새 atom이나 새 쿼리를 추가할 때 어디에 넣어야 할지 고민할 일이 없습니다.

    lib/에는 queryClient 인스턴스 같은 third-party 설정만 두고, atoms·query 같은 우리 코드와 섞이지 않게 했습니다. 만약 axios 인스턴스나 ky 클라이언트 같은 게 추가되면 자연스럽게 lib/에 들어가게 되는 자리입니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. 더 큰 프로젝트라면 이 구조 그대로 갈 수 있을까요? → 페이지가 5개 정도까지는 layer-based가 직관적이지만, 20개를 넘어가면 features/{feature}/{components,atoms,query,types} 같은 feature-based로 평탄화하는 게 응집도 측면에서 낫다고 봅니다. 한 기능에 관련된 코드가 한 디렉토리에 모이니 변경 단위가 명확해집니다.
    • Q-b. types/를 따로 둔 이유는요? 컴포넌트별로 옆에 두는 게 더 응집도 높지 않나요? → 도메인 타입(Station, Ticket, Reservation, TRIP_TYPE)은 여러 페이지·atom·query에서 동시에 import되니 공용 위치에 모았습니다. 반면 컴포넌트 한 군데에서만 쓰는 props 인터페이스는 그 컴포넌트 폴더 안에 같이 두고 있습니다 — confirm/ConfirmTripInfo/TripInfoRow/types.ts 같은 식으로요.
    • Q-c. query/keys.ts는 분리한 이유가 있나요? → query key 문자열을 한 곳에서 관리해서 오타로 인한 캐시 미스를 방지하려는 목적입니다. 사실 6시간 안에서 쓴 양은 STATIONS, TICKETS, RESERVATIONS 세 개라 인라인 문자열로도 충분했지만, 추가 시 자연스럽게 모일 자리를 미리 만들어둔 정도입니다.
    CS · 이론
    • Layer-based vs Feature-based 폴더 구조: layer-based(역할별)은 React 학습 초기에 익숙한 형태. feature-based는 Vue, Nuxt, Angular 진영에서 널리 쓰이고 React에서도 redux-toolkit·Tanstack 진영에서 추천. 큰 프로젝트일수록 feature-based가 응집도·변경 단위 측면에서 유리.
    • Client state vs Server state: TanStack Query 메인테이너 Tanner Linsley가 강조한 구분. 서버에서 가져온 데이터는 남의 데이터의 캐시(stale·refetch·invalidate가 필요), 클라이언트 입력값은 나의 데이터(영속화·검증이 필요). 다루는 도구가 다르니 폴더도 분리해두는 게 자연스럽다.
    • Co-located props vs Shared types: 한 곳에서만 쓰는 타입은 옆에, 여러 곳에서 쓰는 타입은 공용 위치에. Kent C. Dodds의 "Maintainability through colocation" 글이 정설.
  3. Q1-3

    페이지가 유난히 얇은데(`SearchPage` 43줄), 데이터를 페이지에서 가져오지 않고 자식이 직접 atom과 query를 읽도록 한 이유는요?

    핵심 포인트

    • 페이지는 라우트 진입점 + 화면 조립자 역할로 한정
    • 자식이 자기에게 필요한 atom·query를 직접 읽으면 props 드릴링이 사라짐
    • atom 자체가 글로벌이라 페이지가 중간 매개체일 필요가 없음 — useState였다면 페이지가 들고 자식에게 내려줘야 하지만, atom은 어디서든 직접 구독 가능
    • 불필요한 리렌더 최소화 — 페이지가 모든 atom을 구독하면 어떤 필드 하나만 바뀌어도 페이지 통째로 리렌더, 자식이 직접 구독하면 그 자식만 리렌더
    모범 답안먼저 답해보고 펼치기

    페이지를 얇게 유지한 가장 큰 이유는 상태 매개체로 만들지 않는 것입니다. atom이라는 글로벌 client state를 도입한 이상, 페이지가 atom을 구독해서 자식에게 props로 내려줄 필요가 없습니다. 자식이 자기가 필요한 atom을 직접 useAtomValue 하면 됩니다.

    이 결정의 효과 두 가지가 있습니다. 첫 번째는 props 드릴링이 사라지는 것입니다. SearchPage가 출발역·도착역·날짜·인원을 다 들고 있다가 각각 StationSection, DateSection, CountField에 내려주는 구조였으면 페이지 props 시그니처가 길어지고, 자식 컴포넌트도 props로 받느라 결합도가 올라갑니다. 두 번째는 리렌더 범위 최소화입니다. 페이지가 passengerCountAtom, tripTypeAtom, departureStationAtom을 다 구독하면 인원 한 번 바꿔도 페이지 통째로 리렌더되는데, 지금 구조는 인원이 바뀌면 CountField만, 편도/왕복이 바뀌면 TripTypeTab + DateSection만 리렌더됩니다.

    페이지가 유일하게 들고 있는 상태는 useNavigateisSearchValidAtom 정도인데, 둘 다 "기차 보기" 버튼의 활성화/이동을 결정하기 위한 페이지 레벨 책임이라 자연스럽습니다. 만약 페이지에 이런 것까지 자식에 묻어두면 페이지의 역할이 모호해지니까, 라우팅 결정과 검증 결과 소비는 페이지에 두는 식으로 경계를 잡았습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. 그럼 페이지 단위 테스트는 어떻게 하나요? mock이 어렵지 않나요? → atom·query를 자식이 직접 구독하니, 페이지 테스트는 JotaiProvider + QueryClientProvider로 감싸고 atom 초깃값을 주입하면 됩니다. props로 데이터를 주입하는 구조보다 셋업은 한 번 더 거치지만, 페이지 + 자식 통합 테스트 관점에서는 오히려 실제 동작에 가까워집니다. 다만 이번 과제에서는 테스트를 우선순위에서 뺐다고 README에도 적었습니다.
    • Q-b. 페이지에서 atom을 다 들고 있다가 children 패턴으로 내려주면 안 되나요? → 가능하지만 6시간 제약 안에서 한 화면에만 쓰이는 검색 조건을 굳이 그 패턴으로 갈 이유가 약합니다. children 패턴은 같은 자식이 여러 페이지에서 다른 데이터로 재사용될 때 빛나는 패턴이라, 본 과제 규모에서는 직접 atom 구독이 더 단순합니다.
    • Q-c. 그럼 자식 컴포넌트가 너무 똑똑한 거 아닌가요? 재사용이 안 될 텐데요. → 맞는 지적입니다. StationList처럼 atom을 직접 쓰는 컴포넌트는 다른 곳에서 재사용하기 어렵습니다. 다만 본 과제에서 그 컴포넌트들은 각자 한 화면에서만 쓰이고, 재사용 시점이 오면 props로 의존성을 추출하는 리팩터링은 어렵지 않습니다 — 그때 가서 하자는 결정이었습니다.
    CS · 이론
    • Props drilling: 중간 컴포넌트들이 자기 안 쓸 props를 통과시키는 현상. Context/atom/zustand 같은 글로벌 상태 도구가 해결하는 대표적 문제.
    • Atom-based selective subscription: jotai/recoil의 핵심 가치. 컴포넌트가 자기가 구독한 atom이 바뀔 때만 리렌더. Context API는 provider 값이 바뀌면 모든 consumer가 리렌더되는 구조라 이 점이 다르다.
    • Page as composition root: 페이지를 "조립 루트"로만 쓰는 패턴. Angular의 Smart/Dumb 분리, Vue의 Composition API에서 page-level component, Next.js의 page route component 모두 비슷한 철학.
  4. Q1-4

    `components` 안을 `search`/`station`/`ticket`/`confirm`/`complete`/`common` 으로 나눈 기준은요?

    핵심 포인트

    • 화면(페이지) 단위 = 기능 단위로 1:1 매칭. 5개 화면 + 공용
    • 한 컴포넌트가 두 화면에 동시에 쓰이는 경우만 common/으로 — 현재는 EmptyResult 하나
    • components/station/hooks/ 처럼 feature 안에 hooks 폴더를 둬서 그 기능에서만 쓰는 훅도 가까이 둠
    • 이렇게 두면 한 화면을 수정할 때 건드릴 디렉토리가 정확히 하나로 좁혀짐
    모범 답안먼저 답해보고 펼치기

    components 하위를 화면 단위로 나눈 건 **수정의 폭발 반경(blast radius)**을 좁히기 위해서입니다. 검색 화면을 손대야 한다 → components/search/만 보면 됨, 티켓 페이지를 수정해야 한다 → components/ticket/만 보면 됨. 한 폴더 안에 그 화면에 등장하는 컴포넌트가 다 모여 있으니 PR 리뷰할 때도 어디를 봐야 할지 즉각적입니다.

    common/은 의도적으로 매우 보수적으로 운영했습니다. 지금은 EmptyResult 하나만 들어 있는데, station 검색 결과 0개일 때와 ticket 조회 결과 0개일 때 같은 빈 상태 UI를 써야 했기 때문입니다. 두 화면에서 동시에 쓰이지 않으면 common에 두지 않는다는 룰을 지켰습니다 — 그래야 "common 폴더가 어느 시점에 다 흡수해버린 잡동사니 폴더"가 되는 흔한 함정을 피할 수 있습니다.

    components/station/hooks/useStationBack.ts처럼 feature 안에 hooks 폴더를 둔 것도 같은 맥락입니다. 이 훅은 station 페이지에서 직접 URL 진입을 처리하기 위한 전용 훅이라 다른 곳에서는 쓰이지 않습니다. 그래서 글로벌 utils/이나 글로벌 hooks/에 올리지 않고 station feature 안에 격리해뒀습니다. 만약 ticket·confirm에서도 같은 훅이 필요해지면 그때 한 단계 끌어올리면 됩니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. 그럼 같은 컴포넌트가 두 화면에서 거의 비슷하게 쓰일 때 어떻게 판단하시나요? → "거의 비슷"이면 일단 두 곳에 그냥 둡니다. 세 번째 사용처가 생겼을 때 세 곳을 비교해서 진짜 공통 부분만 추출합니다. 두 곳 비교만으로 추상화하면 지나치게 일반화돼서 세 번째가 들어왔을 때 다시 갈라야 하는 경우가 잦습니다. 토스 가이드라인의 "익숙한 방법"이라는 표현도 이런 신중함과 정렬된다고 봤습니다.
    • Q-b. 컴포넌트가 늘어나면 폴더 깊이가 너무 깊어질 텐데요? components/search/CountBottomSheet/CountBottomSheet.tsx 같은 식이라. → 깊이가 늘어나는 건 의도한 비용입니다. 한 컴포넌트당 자기 폴더를 가지면 추후 .module.css, .test.tsx, index.ts 같은 짝 파일이 같이 살게 되고, 컴포넌트 간섭 없이 한 단위로 이동·삭제할 수 있습니다. depth 자체는 IDE 트리에서 시각적 부담일 뿐, 실제 import 경로는 alias(components/search/CountBottomSheet/CountBottomSheet)로 평탄화돼 있습니다.
    CS · 이론
    • Feature-based folder structure: 같은 기능에 속한 코드를 한 폴더에 모으는 패턴. 변경 단위와 폴더 단위가 일치할 때 가장 효율적.
    • Rule of Three (Martin Fowler, Refactoring 2판): 같은 코드가 세 번 등장할 때까지는 추상화하지 않는다. "common"으로 끌어올리는 시점의 휴리스틱.
    • Co-location for hooks: 한 기능에서만 쓰는 훅은 그 feature 안에 둔다 — 글로벌 hooks 디렉토리가 잡동사니가 되는 걸 방지.
  5. Q1-5

    한 컴포넌트가 자기 폴더를 갖는 구조(`StationList/StationList.tsx`)는 굳이 그렇게 한 이유가 있나요?

    핵심 포인트

    • 확장성 — 짝 파일 합류 자리를 미리 만든 것. 추후 StationList.test.tsx, StationList.module.css, types.ts, 서브컴포넌트가 자연스럽게 같은 폴더로 들어옴
    • 실제로 그렇게 진화한 사례가 이미 있음 — confirm/ConfirmTripInfo/ConfirmTripInfo.tsx + 서브컴포넌트 TripInfoRow/ 폴더(거기에 또 types.ts)
    • 단점: 깊이가 깊어진다. 트레이드오프로 받아들임
    모범 답안먼저 답해보고 펼치기

    한 컴포넌트가 자기 폴더를 갖는 구조는, 컴포넌트와 함께 살게 될 짝 파일들의 자리를 미리 마련해두는 것입니다. 처음에는 .tsx 파일 하나만 들어 있어도, 나중에 테스트 파일, CSS 모듈, props 타입 분리 파일, 이 컴포넌트만 쓰는 서브컴포넌트 같은 게 같이 들어올 때 폴더 단위로 묶이니 이동도 삭제도 쉽습니다.

    이게 단순 약속이 아니라 실제로 본 과제에서 자연스럽게 진화한 사례가 있는데, components/confirm/ConfirmTripInfo/ 폴더 안에 ConfirmTripInfo.tsx 본체와, 그 안에서만 쓰는 서브컴포넌트 TripInfoRow/가 다시 자기 폴더로 들어 있고, 거기에 또 TripInfoRow.tsxtypes.ts가 같이 살고 있습니다. props 타입을 별도 파일로 빼고 싶을 때, 컴포넌트 옆에 두는 게 가장 직관적이라 이 구조가 그 결정을 자연스럽게 받쳐줍니다.

    단점은 디렉토리 트리의 깊이가 깊어진다는 점인데, IDE 시각적 부담은 있지만 실제 import는 alias로 평탄화되니 코드 작성·읽기 단계에선 문제없다고 봤습니다. 그리고 협업 관점에서 이 방식이 한 컴포넌트의 ownership을 폴더로 명확히 표현해주는 게 더 큰 가치라고 판단했습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. index.ts로 re-export하는 패턴은 안 쓰셨네요? → 안 썼습니다. index.ts re-export는 import 경로를 짧게 만들지만 "어떤 파일에서 진짜 정의되는지" 추적이 한 단계 더 필요해집니다. 본 과제 규모에서는 StationList/StationList로 명시적으로 import하는 게 IDE Go to Definition이 한 번에 닿는 장점이 더 크다고 봤습니다. 큰 라이브러리 entry라면 index.ts로 public API를 제어하는 게 자연스럽지만 application 코드에서는 의견이 갈리는 부분이라고 알고 있습니다.
    CS · 이론
    • Co-location 원칙: Kent C. Dodds의 글 "Place code as close to where it's relevant as possible". 컴포넌트와 그 컴포넌트만의 짝 파일이 같이 산다.
    • Package-by-feature vs Package-by-layer: 큰 단위에서는 feature, 작은 단위에서는 자기 폴더로 묶는 하이브리드가 본 과제 구조.
    • Barrel files (index.ts re-export) 트레이드오프: 짧은 import vs 추적성. tree-shaking과의 미묘한 상호작용도 있음 (라이브러리 진영에서 자주 쟁점).