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

과제 기반 — 기차예약

토픽 7 · Phase 2

타입스크립트 도메인 모델링

질문 5개
  1. Q7-1

    `types/` 폴더에 station, ticket, reservation, trip, passenger 5개 파일을 두셨는데, 분리 기준은 뭐였나요?

    핵심 포인트

    • 하나의 도메인 entity = 하나의 파일 — 데이터 모양과 그에 결합된 enum/유틸이 같은 개념을 표현하면 한 파일로 묶음.
    • 5개 파일이 각각 표현하는 entity: 역(station), 기차표(ticket), 예약(reservation), 여행 방식(trip), 인원수(passenger).
    • reservationPick<Ticket>, Station, PassengerCount, TRIP_TYPE을 합성 — 도메인 의존성을 import로 표현.
    • 화면이 아닌 도메인 entity 단위로 나눔 — 화면이 늘어나도 type 파일은 그대로.
    모범 답안먼저 답해보고 펼치기

    타입을 어디에 두느냐는 코드의 첫인상을 결정한다고 봅니다. 그래서 types/ 분리 기준을 의식적으로 정했어요.

    기준은 하나의 도메인 entity를 한 파일에 모은다입니다. 예를 들어 station.ts에는 Station 인터페이스와 STATION_TYPE enum이 같이 들어가요. 둘 다 "역"이라는 entity의 일부니까요. ticket.ts에는 Ticket 인터페이스와 TICKET_STATUS const + TicketStatus 타입을 묶습니다. passenger.ts에는 PASSENGER_TYPE enum, PassengerCount 인터페이스, INITIAL_PASSENGER_COUNT 상수, MAX_PASSENGER_TOTAL 상수, totalPassengers/formatPassengerCount 함수까지 함께 담겨요. 인원이라는 도메인을 다루는 데 필요한 모든 단위가 한 파일에 있습니다.

    이렇게 도메인 단위로 나누면 import 그래프가 의미 있게 그려져요. reservation.ts를 보면 import { PassengerCount } from './passenger', import { Station } from './station', import { Ticket } from './ticket', import { TRIP_TYPE } from './trip' — 예약이라는 entity가 어떤 entity들의 합성으로 만들어지는지가 import만 봐도 보입니다. 이게 도메인 모델 다이어그램 역할을 하는 셈이에요.

    화면이 아니라 도메인 단위로 나눈 게 중요한 결정이었습니다. 만약 화면 단위로 — 예를 들어 searchTypes.ts, confirmTypes.ts — 나눴다면 같은 Station을 SearchPage용, ConfirmPage용으로 두 번 정의하거나 cross-import가 어지러워졌을 거예요. 도메인 단위는 화면이 추가/제거돼도 변하지 않는 안정적 축이라 나누기에 딱 맞았습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q7-1-a. utils와 types를 한 파일에 둔 게 좀 어색한데요? passenger.tstotalPassengers 함수가 들어가 있어요. → 의식적으로 그 결정을 했습니다. totalPassengersformatPassengerCount는 PassengerCount 데이터 위에서만 의미 있는 함수예요. 도메인 entity와 함께 두면 entity가 자신을 다루는 함수까지 캡슐화하는 모양이 되어, 사용처에서 import { PassengerCount, totalPassengers, formatPassengerCount } from 'types/passenger' 한 줄로 다 가져올 수 있습니다. utils로 빼면 같은 도메인이 두 폴더로 흩어지는 비용이 더 큽니다. 다만 date.ts처럼 특정 도메인에 묶이지 않는 일반 유틸은 utils에 두는 식으로 구분했어요.

    • Q7-1-b. Station은 그냥 { id, name } 두 필드뿐인데 굳이 따로 인터페이스로 둘 필요가 있나요? → 두 필드뿐이라도 따로 두는 게 맞다고 봤습니다. 첫째, 도메인 entity의 이름이 코드 곳곳에 등장하면 의도가 분명해져요 — Station[] vs Array<{ id: number; name: string }>. 둘째, 미래에 Station이 더 많은 필드를 가지게 됐을 때 — 영문명, 코드, 좌표 등 — 한 군데만 고치면 모든 사용처가 자동 반영됩니다. Naming things가 cache invalidation만큼 어렵다는 격언이 있을 정도로 이름의 가치가 큰데, 도메인 entity는 그 이름의 가장 단단한 닻이에요.

    • Q7-1-c. enum을 쓴 곳(PASSENGER_TYPE, TRIP_TYPE, STATION_TYPE)과 as const를 쓴 곳(TICKET_STATUS)이 섞여 있는데 이유가 있나요? → 처음 작성 시 일관성 측면에서는 한쪽으로 통일하는 게 더 좋았을 거 같아요. as const + (typeof X)[keyof typeof X] 패턴은 tree shaking과 컴파일 결과(런타임 객체가 따로 안 만들어짐) 측면에서 enum보다 가볍습니다. enum은 isolated modules·tree shaking에 약점이 있어서 최근 TypeScript 컨벤션은 as const 쪽을 권장하는 흐름이에요. 이번 과제에서는 enum과 as const가 혼재한 건 사실인데, 둘 다 만들 때의 결정이 일관되지 못했던 부분이라 인터뷰에서 솔직하게 인정하고 "통일한다면 as const 쪽으로" 답변할 생각입니다.

    CS · 이론
    • Domain-Driven Design의 entity 개념: 도메인 모델링에서 entity는 "ID로 식별 가능한 비즈니스 개념" — 역, 기차표, 예약 모두 ID를 갖는 entity입니다. 이걸 한 파일에 모으는 게 도메인 단위 모듈화의 자연스러운 출발점.
    • TypeScript enum vs as const: enum은 런타임 객체를 만들어 tree shaking에 약점이 있고, isolated modules에 호환 이슈. as const는 zero-runtime cost — 최근 권장 컨벤션입니다.
    • Module의 cohesion: 한 파일에 들어간 코드가 같은 변경 이유를 공유해야 함. PassengerCount 모양이 바뀌면 INITIAL_PASSENGER_COUNT, totalPassengers, formatPassengerCount가 같이 바뀌니까 한 파일이 정답.
  2. Q7-2

    API 호출마다 `GetXxxApiParams`, `GetXxxApiResponse` 같은 컨벤션을 쓰셨는데, 이 네이밍 패턴을 고른 이유는요?

    핵심 포인트

    • 한 API 호출의 입출력 타입을 같은 파일에 같은 prefix로 묶음 — 호출 시그니처를 빠르게 추적 가능.
    • 형식: Get|Post + EntityName + Api + Params|Response|Body.
    • 도메인 entity(Station, Ticket, Reservation)와 API DTO를 의도적으로 분리 — DTO가 변해도 entity는 안 흔들림.
    • mutation에는 Body, query에는 Params로 HTTP semantic을 네이밍에 반영.
    모범 답안먼저 답해보고 펼치기

    API 통합 코드는 query/ 폴더에 모았는데, 각 query 파일 안에서 입출력 타입을 컨벤션화했어요.

    규칙은 단순합니다 — [HTTP method] + [Entity name] + Api + [Role]. useStationsQuery.ts에는 GetStationApiParams, GetStationApiResponse. useTicketsQuery.ts에는 GetTicketApiParams, GetTicketApiResponse. useReservationMutation.ts에는 PostReservationApiBody, PostReservationApiResponse. mutation은 Body, query는 Params로 HTTP semantic을 prefix에 반영했어요.

    이 네이밍의 이점은 한 API의 모든 부품이 한 파일·같은 prefix에 모인다는 거예요. 인터뷰어가 "stations 검색 API의 입출력은?" 하고 물으면 useStationsQuery.ts 한 파일만 열면 됩니다. GetStationApi로 자동완성을 시작하면 IDE가 Params·Response·Body를 한 묶음으로 보여주니까 추적도 빠릅니다.

    또 한 가지 의식한 건 — 도메인 entity와 API DTO를 의도적으로 분리한다는 점이에요. Stationtypes/station.ts의 도메인 모델이고, GetStationApiResponse = Station[]은 그 entity의 list 형태로 표현된 API DTO입니다. 지금은 우연히 두 개가 같은 모양이지만, API가 wrapper 객체({ data: Station[], meta: {...} })로 바뀐다고 해도 도메인 entity는 안 흔들려요. DTO 변경이 entity까지 전염되지 않게 차단하는 layer를 의식적으로 만든 셈입니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q7-2-a. Get/Post 두 종류뿐인데, Put/Delete/Patch는 어떻게 처리하실 건가요? → 같은 컨벤션을 그대로 따라가면 됩니다. PutXxxApiBody, DeleteXxxApiParams 같은 식으로요. PATCH는 부분 업데이트라 PatchXxxApiBody도 자연스럽고요. 컨벤션이 method 이름을 prefix로 하니까 새로운 method가 추가돼도 일관성이 유지됩니다.

    • Q7-2-b. GetStationApiResponse = Station[]처럼 단순 alias라면 굳이 새 타입을 만들 필요 있나요? → 약간의 over-engineering 같지만 두 가지 이유로 정당화한다고 생각해요. 첫째, 미래에 응답이 wrapper 객체로 바뀔 때 alias만 수정하면 모든 사용처가 자동 반영. 둘째, query 파일 안에서 GetStationApiResponse라는 이름이 보이는 게 "이 query의 응답"이라는 의도를 명시합니다. 그냥 Station[]만 보면 "이게 검색 응답인지, 다른 데서 가져온 list인지"가 한 단계 추론이 필요해요.

    • Q7-2-c. 지금 컨벤션의 한계나 실수할 수 있는 부분은요? → entity name이 단수/복수가 섞일 수 있는 게 한 가지 위험입니다. GetStationApiResponse = Station[](단수 + array)인데 GetTicketsApiResponse = Ticket[]처럼 entity를 복수로 갖다 쓰는 사람이 있을 수 있어요. 컨벤션을 "단수 entity + 응답이 array면 array로"로 정해서 일관성을 유지했는데, lint rule이나 codegen으로 자동화하면 더 안전합니다. 또 query factory와 결합하면 zod schema와 묶어서 런타임 validation까지 가는 길이 있어요.

    CS · 이론
    • DTO vs Domain Model: API 응답(DTO)과 비즈니스 entity는 분리해야 합니다. 둘이 같다고 가정하면 API 변경이 도메인 코드 전체로 전염됩니다. 이게 anti-corruption layer 패턴의 본질.
    • Naming Convention의 가치: 컨벤션은 "선택의 인지 부담을 줄이는 도구"입니다. 새 API를 추가할 때마다 "이름을 어떻게 짓지?"를 고민하지 않아도 되도록 룰을 미리 정해두면 코드베이스가 일관되어집니다.
    • HTTP method 의미를 타입 시스템에 반영: GET = Params(query string), POST = Body(JSON). 네이밍에 method를 박으면 코드 읽는 순간 HTTP semantic까지 같이 들어옵니다.
  3. Q7-3

    `Reservation`을 `ReservationOneWay | ReservationRound` discriminated union으로 모델링하셨는데, 이게 왜 단순 객체보다 나은가요?

    핵심 포인트

    • type: TRIP_TYPE.ONE_WAY vs type: TRIP_TYPE.ROUND을 discriminator로 가진 union — TypeScript가 자동 narrowing.
    • 편도는 ticket 단일 필드, 왕복은 outboundTicket + inboundTicket 두 필드 — 존재하지 않는 필드는 타입 시스템이 차단.
    • 단일 객체로 { ticket?: ..., outboundTicket?: ..., inboundTicket?: ... }로 모델링하면 "편도인데 outbound 접근" 같은 불가능한 케이스가 컴파일에 통과해버림.
    • 합성 가능: ReservationTicket = Pick<Ticket, ...>로 entity의 부분 view를 명시 — 응답에 필요한 필드만 골라 표현.
    모범 답안먼저 답해보고 펼치기

    Reservation 모델링은 이 과제에서 타입 시스템을 가장 적극적으로 활용한 부분이에요.

    편도 예약과 왕복 예약은 본질적으로 다른 데이터 모양이에요. 편도는 ticket 한 장, 왕복은 outbound/inbound ticket 두 장. 이걸 단일 객체로 { type, ticket?, outboundTicket?, inboundTicket?, ... }처럼 모델링하면, type === ONE_WAY일 때 outboundTicket이 undefined일 수 있다는 걸 컴파일러가 모릅니다. UI에서 "왕복 예약 보여주기"를 짤 때 outboundTicket에 옵셔널 체이닝을 잔뜩 박아야 하고, 빠뜨리면 런타임 오류로 터지죠.

    discriminated union은 이걸 타입 시스템 레벨에서 풀어요. ReservationOneWaytype: TRIP_TYPE.ONE_WAY + ticket: ReservationTicket, ReservationRoundtype: TRIP_TYPE.ROUND + outboundTicket: ReservationTicket + inboundTicket: ReservationTicket. Reservation = ReservationOneWay | ReservationRound. UI에서 if (reservation.type === TRIP_TYPE.ONE_WAY) 분기에 들어가면 TypeScript가 자동으로 narrowing해서 reservation.ticket만 접근 가능하고 reservation.outboundTicket은 컴파일 에러가 납니다. 불가능한 케이스를 코드에 표현조차 못 하게 막는 게 핵심이에요.

    ReservationTicket = Pick<Ticket, 'id' | 'departureTime' | 'arrivalTime' | 'trainNumber'>로 entity의 부분 view를 명시했어요. 예약 응답에는 ticket의 모든 필드(예: status, departureStationId)가 다 필요하지 않거든요. 응답에 실제로 들어가는 필드만 골라 표현하니까, 백엔드 응답 스펙이 명시적으로 코드에 드러납니다. Pick으로 entity를 재사용하면서 응답 모양을 따로 명시하는 게 — 도메인 entity를 단일 source로 두는 원칙과 응답 DTO 표현을 동시에 챙기는 길이에요.

    이 답변, 어땠나요?

    꼬리 질문
    • Q7-3-a. PostReservationApiBody도 같은 패턴인데, 응답(Reservation)과 요청(PostReservationApiBody)에서 ticket 필드 모양이 다르네요. 왜 다른가요? → 요청은 ticket의 ID만 보내면 충분해요(서버가 ID로 ticket 정보를 채움). 그래서 PostReservationApiBodyOneWayticketId: number, Round는 outboundTicketId: number, inboundTicketId: number. 응답은 사용자에게 보여줄 ticket 정보가 필요하니까 ReservationTicket = Pick<Ticket, ...>로 부분 객체. 같은 도메인 흐름의 두 끝(요청/응답)이 다른 데이터 모양을 갖는 게 자연스럽고, discriminated union은 두 끝 모두에서 동일한 패턴으로 적용 가능합니다.

    • Q7-3-b. exhaustive check는 어떻게 하시나요? switch에서 새 type이 추가됐을 때 컴파일 에러가 나도록.switch (reservation.type) 패턴에서 default: const _exhaustive: never = reservation; throw new Error(...) 방식으로 했어요. 새 type(예를 들어 MULTI_LEG)이 추가되면 _exhaustive: never 할당이 컴파일 에러를 내서 switch가 모든 케이스를 다루는지를 강제합니다. 이번 과제에서는 분기가 두 곳뿐이라 명시적으로 안 박았는데, 더 큰 코드베이스라면 utility로 assertNever(value: never): never 함수를 두는 게 표준이에요.

    • Q7-3-c. discriminated union 대신 클래스 + 다형성으로 풀 수도 있을 텐데요? → 가능합니다. class OneWayReservation, class RoundReservation이 같은 인터페이스의 getTotalPrice() 같은 메서드를 구현하는 식. 다만 React에서는 데이터를 plain object로 다루는 게 일반적이라 — JSON 응답이 그대로 들어오고, jotai atom이나 react-query 캐시가 plain object를 가정 — 클래스를 쓰면 직렬화/역직렬화 layer가 추가로 필요해집니다. 함수형 React 환경에서는 discriminated union이 더 자연스럽다고 봤어요.

    CS · 이론
    • Discriminated Union (Tagged Union): 함수형 언어에서는 sum type으로 부르는 패턴. 한 필드(discriminator)의 값으로 union의 어느 변종인지를 컴파일 타임에 좁힐 수 있게 만든 구조.
    • Make Illegal States Unrepresentable (Yaron Minsky의 격언): "잘못된 상태가 코드에 표현될 수 없도록 타입을 설계하라". 단일 객체에 옵셔널 필드를 잔뜩 두는 것보다 union으로 분기하는 게 이 격언에 부합.
    • Utility Types(Pick, Omit, Partial): entity의 부분 view를 명시할 때 가장 가벼운 도구. 엔티티 정의 한 곳을 단일 source로 두면서, 사용처마다 필요한 모양을 골라 쓸 수 있습니다.
  4. Q7-4

    `passengerCountAtom`은 atom 안에 객체로 들고 있는데, 그 객체 모양(`PassengerCount`)을 type으로 인터페이스로 두신 이유는요? key를 enum으로 또 둔 게 redundant 하지 않나요?

    핵심 포인트

    • PASSENGER_TYPE enum + PassengerCount interface 조합 — enum이 interface의 key 타입을 보증.
    • [PASSENGER_TYPE.ADULTS]: number; 같은 computed property로 interface 정의 — enum 값이 변경되면 interface가 자동으로 따라옴.
    • enum은 데이터 외부에서 string literal을 식별자로 쓸 때 가치 — handleChange(PASSENGER_TYPE.ADULTS) 같은 호출.
    • 단순 string union으로도 같은 일을 할 수 있지만, enum은 namespace 역할 + 자동완성 측면에서 명확성이 더 좋다고 판단.
    모범 답안먼저 답해보고 펼치기

    PASSENGER_TYPE enum과 PassengerCount interface를 함께 둔 게 처음 보면 redundant해 보일 수 있어요. enum의 세 값(ADULTS, CHILDREN, NEWBORN)이 interface key로도 그대로 등장하니까요.

    다만 redundant라기보다는 단일 source of truth를 만드는 패턴이에요. enum을 정의한 뒤 interface는 [PASSENGER_TYPE.ADULTS]: number; [PASSENGER_TYPE.CHILDREN]: number; [PASSENGER_TYPE.NEWBORN]: number; 형태의 computed property로 정의했습니다. 만약 enum의 한 값이 'kids'로 바뀌면 interface key도 자동으로 'kids'로 바뀌어요. enum이 진짜 source고 interface는 그걸 key로 쓰는 derived 정의라는 관계가 코드에 표현됩니다.

    enum이 더 가치를 발휘하는 자리는 데이터 외부에서 식별자가 필요할 때예요. CountBottomSheet의 handleChange(PASSENGER_TYPE.ADULTS) 호출이나, NumericSpinner에 prop으로 넘길 때 string literal 'adults'를 직접 쓰지 않고 enum value로 전달하면 — (1) IDE 자동완성, (2) refactor 시 enum 한 곳만 고치면 모든 사용처 자동 반영, (3) 오타 차단. 이 세 가지 이점이 의미 있다고 봤습니다.

    string union(type PassengerType = 'adults' | 'children' | 'newborn')으로도 같은 일을 할 수 있어요. 사실 최근 TypeScript 컨벤션은 enum보다 string union이나 as const object를 권장하는 흐름인데, 이번 과제에서 enum을 쓴 건 — 데이터 외부에서 식별자로 namespace처럼 쓰일 일이 많아서 PASSENGER_TYPE.X 형태가 가독성에 더 좋다고 판단한 거예요. 다만 일관성 측면에서 TICKET_STATUSas const 패턴을 썼는데 이건 통일하지 못한 부분이라 아쉬워요.

    이 답변, 어땠나요?

    꼬리 질문
    • Q7-4-a. INITIAL_PASSENGER_COUNTPassengerCount 타입이 아니라 const라 type assertion 같은 게 보이는데요? → 코드 보면 export const INITIAL_PASSENGER_COUNT: PassengerCount = { [PASSENGER_TYPE.ADULTS]: 1, [PASSENGER_TYPE.CHILDREN]: 0, [PASSENGER_TYPE.NEWBORN]: 0 }; 형태로 type annotation을 명시했어요. as const로 narrow하게 가는 것보다 PassengerCount 그대로 두는 게 atom의 default value로 쓰기 좋습니다(atom 타입이 PassengerCount라 자동 호환). 어른 default를 1로 둔 건 가장 자주 쓰이는 케이스(어른 1명)를 미리 채워둬서 한 번의 클릭을 줄이는 UX 결정이에요.

    • Q7-4-b. formatPassengerCount에서 count.adults처럼 직접 접근하던데, enum을 통해 접근하지 않은 이유가 있나요? → 의식적으로 그렇게 했어요. count[PASSENGER_TYPE.ADULTS]도 가능하지만, 이미 PassengerCount 타입 안에서는 key가 좁혀진 상태라 직접 접근이 더 가독성이 좋다고 봤습니다. enum의 가치는 "외부에서 식별자로 전달할 때"에 있고, 데이터 내부 접근에서는 count.adults가 자연스러워요. 다만 일관성 측면에서는 둘 중 하나로 통일하는 게 좋긴 합니다.

    • Q7-4-c. MAX_PASSENGER_TOTAL = 9 같은 상수도 type 파일에 있던데, 비즈니스 룰을 type 파일에 두는 게 맞나요? → 도메인 단위 모듈화 원칙을 따른 건데, 인터뷰어가 짚을 만한 부분이에요. PassengerCount 도메인의 invariant("합계 ≤ 9")가 같은 파일에 있으면 도메인 자체를 한 군데서 다 보는 이점이 있습니다. 다만 비즈니스 룰이 늘어나면 passengerRules.ts로 분리하는 게 맞을 거예요. Rule of Three — 한두 개는 entity 파일에, 셋 이상이면 분리. 지금은 한 개라 같이 뒀습니다.

    CS · 이론
    • Computed Property Names + Enum: [ENUM.KEY]: T 패턴은 enum과 type을 자동 동기화하는 표준 트릭. enum 변경 시 type도 같이 따라옴.
    • Enum vs as const object vs string union: 세 가지 표현이 가능한 동일 의미. enum은 namespace 효과, as const는 zero-runtime, string union은 가장 가벼운 표현. 프로젝트 컨벤션에 따라 선택.
    • Co-locating data and rules: 도메인 entity와 그 invariant·기본값을 한 파일에 두면 이해 비용은 줄지만, 룰이 많아지면 분리해야 함 — Rule of Three이 가이드.
  5. Q7-5

    API 응답 필드명을 그대로 두셨는데(`departureStationId`, `inboundTicketId`), 클라이언트 컨벤션(camelCase 그대로 OK이지만 더 짧게 줄이는 등)으로 변환하는 layer를 안 둔 이유는요?

    핵심 포인트

    • 백엔드 응답 필드명이 이미 camelCase로 와서 굳이 변환할 이유가 없음.
    • "변환 layer를 두면 도메인 진리가 두 군데로 분리"되어 디버깅·이해 비용 증가.
    • 응답에 등장하는 필드명을 그대로 보존하니 네트워크 탭과 코드가 1:1 대응 — 디버깅 친화.
    • 변환이 필요해지는 시점은 (1) snake_case → camelCase, (2) DTO → Domain 의미 전환이 필요할 때 — 이번 과제는 둘 다 해당 안 됨.
    모범 답안먼저 답해보고 펼치기

    응답 필드명을 코드에서도 그대로 쓰는 게 의식적인 결정이었어요.

    Reservation 응답을 보면 outboundTicket, inboundTicket, departureStation, arrivalStation 같은 필드가 그대로 코드에 등장합니다. 이걸 클라이언트 측에서 outbound, inbound로 짧게 줄이는 변환 layer를 두는 길도 있는데, 이번 과제에서는 일부러 안 뒀어요.

    이유는 — 변환 layer가 들어가면 도메인 진리가 두 군데로 갈라진다는 점입니다. 네트워크 탭에서 응답을 본 사람이 코드에서 같은 필드를 검색했을 때 못 찾으면 디버깅 비용이 커져요. "이 데이터는 응답의 outboundTicket인데, 코드에서는 outbound로 변환돼 있다" 같은 정보를 항상 머리에 갖고 있어야 합니다. 작은 프로젝트일수록 이 비용이 비싸요.

    변환 layer가 정당화되는 시점은 두 가지라고 봐요. 첫째, 백엔드가 snake_case를 보낼 때 — JavaScript 컨벤션과 맞지 않으니 변환이 자연스럽습니다. 둘째, DTO와 Domain의 의미가 정말 다를 때 — 예를 들어 백엔드의 user_id 응답을 클라이언트에서는 currentUserId처럼 컨텍스트가 더 명확한 이름으로 바꾸고 싶을 때. 이번 과제는 (1) 응답이 이미 camelCase, (2) 의미가 명확해 변환할 동기가 없어서 그대로 갔습니다.

    토스 가이드의 "화려한 방법보다 익숙한 방법" 원칙에도 이 결정이 부합해요. anti-corruption layer 같은 패턴은 정말 필요할 때 쓰는 도구지, 미리 깔아두면 코드 베이스가 무거워집니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q7-5-a. 만약 백엔드가 응답 필드명을 바꾸면 클라이언트 코드 전체를 수정해야 할 텐데요? → 그 위험은 있지만, 변환 layer를 둔다고 위험이 사라지지 않아요. 변환 layer 안에서 같은 변경을 처리해야 하는 건 똑같습니다. 차이는 변경의 노출 범위 — layer가 있으면 변경이 layer 안에 격리되고, 없으면 사용처 모두에 퍼집니다. 다만 격리의 비용이 평소의 추적 비용보다 클 때만 layer가 정당화돼요. 4개 API + 작은 프로젝트에서는 평소 비용이 더 큽니다.

    • Q7-5-b. zod 같은 런타임 검증을 도입했다면 자연스럽게 변환 layer도 같이 들어왔을 텐데요? → 맞아요. zod의 .transform()을 쓰면 검증과 변환을 한 번에 할 수 있고, 응답이 예상과 다를 때 런타임 보호도 됩니다. 6시간 안에서 zod 도입이 ROI가 어느 정도일지 고민했는데 — schema 정의 + transform 작성 시간이 만만치 않고, 4개 API라 정의해야 할 schema가 적지 않아요. 그래서 README to-do에 "API 응답 zod 검증 도입" 후보로 남길 수는 있는데, 이번 과제에서는 우선순위에서 뺐습니다.

    • Q7-5-c. 응답 필드명을 그대로 가져가다 보니 outboundTicket/inboundTicket 같은 긴 이름이 코드에 자주 등장하는데 가독성이 어땠나요? → 길긴 한데, 의미가 분명해서 짧게 줄이는 것보다 가독성이 좋다고 봤어요. out/in으로 줄이면 의미가 모호해지고, outbound/inbound는 이미 traffic·로직의 표준 용어라 줄이면 오히려 컨벤션에서 벗어납니다. 길이는 IDE 자동완성으로 보완되니 큰 비용이 아니에요.

    CS · 이론
    • Anti-Corruption Layer (DDD): 외부 시스템(API)의 모델이 도메인 모델을 오염시키지 않도록 사이에 두는 변환 layer. 단순한 1:1 매핑이라면 over-engineering이 되기 쉬움 — 정말 의미 차이가 있을 때만 정당화.
    • Domain Model 일관성과 디버깅 비용의 trade-off: 변환 layer는 일관성을 추가하지만 디버깅을 어렵게 합니다. 트레이드오프를 의식해서 결정해야 함.
    • Runtime validation (zod, io-ts): TypeScript 타입은 컴파일 타임에만 보장되므로, API 응답이 진짜 그 모양인지는 런타임 검증이 필요합니다. 큰 시스템에서는 거의 필수, 작은 프로젝트에서는 trade-off.