Search

인터페이스 설계 멘탈 모델

한 문장

인터페이스 설계란, 요구사항에 대한 이해를 명명과 의도적인 제약을 통해 구조에 담아내는 일이에요. 이를 통해 모듈과 모듈이 서로 목적에 맞는 관절을 선택하고, 맞물리고 연결되어 협력할 수 있게 돼요.

왜 필요한가

경계로 나눈 것들은 서로 협력해야 해요. 그런데 아무렇게나 연결하면 안 돼요.
// ❌ 아무렇게나 연결 <결제창 신한카드={{ 카드번호, 유효기간, CVC }} 토스페이={{ 토큰, 사용자키 }} 국민계좌={{ 계좌번호, 예금주 }} /> // → 결제창이 모든 결제수단의 내부 구조를 알아야 함 // → 네이버페이 추가되면? 또 구조 파악해서 추가해야 함 // ✓ 의도적으로 설계된 연결 <결제창 결제수단={토스페이} /> // → "결제수단"이라는 본질이 발견됨 // → 결제창은 결제수단의 내부를 몰라도 됨 // → 새 결제수단 추가해도 결제창은 변경 없음
TypeScript
복사
인터페이스는 단순한 통로가 아니에요. 어떤 협력을 허용하고, 어떤 협력을 제한할지를 결정하는 거예요. 잘 설계된 인터페이스는 사용하는 쪽의 자유를 적절히 제한해서, 오용을 막고 의도대로 쓰이게 해요.

협력이란

협력은 서로 다른 변경 이유를 가진 것들이, 각자의 책임을 지면서, 인터페이스를 통해 함께 일하는 거예요.
왜 "서로 다른 변경 이유"가 중요한가:
// ❌ 변경 이유가 다른 것들이 섞여 있음 function CheckoutPage() { // 결제 로직 (결제팀이 바꿈) const processPayment = () => { ... } // 분석 로직 (마케팅팀이 바꿈) const trackPurchase = () => { ... } // UI 로직 (디자인팀이 바꿈) const renderButton = () => { ... } // 세 팀이 같은 파일을 건드림 → 충돌 }
TypeScript
복사
서로 다른 이유로 바뀌는 것들이 한 곳에 있으면, 한쪽을 고칠 때 다른 쪽이 깨져요. 이걸 분리하는 게 경계고, 분리된 것들이 함께 일하게 하는 게 협력이에요.
좋은 협력의 조건:
조건
의미
위반 시
서로 모른다
한쪽이 바뀌어도 다른 쪽은 영향 없음
한 줄 고쳤는데 여기저기 터짐
자기 수준만 해결한다
각자 자기 계층의 문제만 다룸
고수준에서 저수준 디테일을 알아야 함
연결 지점이 작다
인터페이스가 최소한으로 유지됨
뭘 하려면 알아야 할 게 너무 많음
협력의 예:
// 결제창과 결제수단의 협력 <결제창 결제수단={토스페이} /> // 협력의 계약: // - 결제창: "나는 결제수단을 받아서 보여주고, 결제를 요청할 거야" // - 결제수단: "나는 pay()를 호출하면 결제를 처리할 거야" // - 서로의 내부는 모름. 계약만 지키면 됨.
TypeScript
복사
협력이 잘 설계되면:
결제수단 팀이 토스페이 내부를 바꿔도 결제창은 모름
결제창 팀이 UI를 바꿔도 결제수단은 모름
새 결제수단(네이버페이)을 추가해도 결제창 코드는 안 고침
왜 호출자 관점이 어려운가:
인터페이스 설계가 어려운 이유는 관점 전환 비용 때문이에요.
구현자 관점 (내가 어떻게 짤까): 공짜로 떠올라요
호출자 관점 (다른 사람이 어떻게 쓸까): 의식적 노력이 필요해요
구현하면서 동시에 호출자 관점을 유지하려면 두 관점을 동시에 들고 있어야 해요. 인지 자원이 이미 "구현"에 쓰이고 있는데, "호출자 시뮬레이션"까지 하려니 작업기억이 터져요.
그래서 구현 끝나고 "호출자 관점 점검 시간"을 따로 두는 게 현실적이에요.

관절이라는 심상

연결의 형태를 떠올릴 때 인체의 관절을 심상으로 써요.
관절은 뼈와 뼈를 연결하면서, 동시에 움직임의 자유도를 결정해요. 문짝 관절은 한 방향으로만 움직이고, 어깨 관절은 자유롭게 움직이고, 무릎 관절은 단계별로 걸리고, 두개골은 아예 움직이지 않아요.
인터페이스도 마찬가지예요. 어떤 관절을 선택하느냐가 설계자의 의도를 드러내요.

관절 유형

관절 유형
움직임
인터페이스 대응
언제 쓰나
문짝 관절
한 방향
정해진 방향으로만 확장
콜백, 이벤트 핸들러
어깨 관절
자유
열린 확장
children, render props
무릎 관절
단계별 걸림
이산적 선택지
variant, size, type
두개골
고정
확정, 열림 없음
직접 구현, 확장 불가

문짝 관절: 한 방향으로만 열린다

onClose: () => void onSubmit: (data: FormData) => void onChange: (value: string) => void
TypeScript
복사
"닫힐 때", "제출할 때", "바뀔 때" — 특정 시점에 뭔가를 할 수 있게 열어둬요. 하지만 언제 그 시점인지는 컴포넌트가 결정해요. 사용하는 쪽은 무엇을 할지만 정해요.

어깨 관절: 자유롭게 움직인다

children: ReactNode renderItem: (item: T) => ReactNode
TypeScript
복사
안에 뭘 넣을지 완전히 열려 있어요. 최대한의 자유를 주지만, 그만큼 사용하는 쪽의 책임도 커요. "아무거나 넣어도 돼"라는 건 "뭘 넣을지 니가 알아서 해"라는 뜻이기도 해요.

무릎 관절: 단계별로 걸린다

variant: 'primary' | 'secondary' | 'danger' size: 'sm' | 'md' | 'lg'
TypeScript
복사
정해진 선택지 중에서 고르게 해요. 자유도를 의도적으로 제한해서 일관성을 확보해요. "이 중에서 골라"라는 건 "이 외에는 안 돼"라는 뜻이기도 해요.

두개골: 움직이지 않는다

// 내부에서 직접 구현, 외부에서 바꿀 수 없음 const DISCOUNT_RATE = 0.1
TypeScript
복사
확정된 것. 열어두지 않기로 결정한 것. 모든 걸 열어두면 안 돼요. 열지 않는 것도 설계예요.

층 쌓기

관절 하나에 여러 관심사를 층으로 쌓을 수 있어요.
onClose: withFoo(domainFn) // ↑ ↑ ↑ // 구조 층 실제 로직 // 1. onClose — 함수가 들어오도록 허용하는 관절 // 2. withFoo — 층 (로깅, 분석, 에러 처리 등 관심사를 끼워넣음) // 3. domainFn — 실제 로직 (도메인의 일)
TypeScript
복사
각 층이 자기 책임만 지고, 관절을 통해 협력해요. 층을 쌓으면:
관심사가 분리돼요 (로깅은 로깅, 도메인은 도메인)
층을 끼우거나 빼기 쉬워요
테스트할 때 층별로 따로 테스트할 수 있어요

추상화 벽

층 사이에는 보이지 않는 벽이 있어요. 이 벽을 추상화 벽이라고 해요.
┌─────────────────────────────────┐ │ 상위 계층 (비즈니스 로직) │ │ - calc_total, gets_free_shipping │ ├─────────────────────────────────┤ ← 추상화 벽 │ 하위 계층 (데이터 구조) │ │ - add_item, remove_item │ └─────────────────────────────────┘
Plain Text
복사
추상화 벽의 효과:
벽 위에서는 벽 아래의 구현을 몰라도 돼요
벽 아래가 배열에서 객체로 바뀌어도 벽 위는 안 고쳐요
서로 다른 팀이 벽을 기준으로 독립적으로 일할 수 있어요
언제 벽을 세우나:
구현에 대한 확신이 없을 때 (나중에 바꿀 수 있게)
팀 간에 책임을 나눠야 할 때
"이 아래는 몰라도 돼"라고 말하고 싶을 때
주의할 점:
벽에 함수가 많으면 벽이 두꺼워져요. 두꺼운 벽은 고칠 게 많아요.
새 기능은 가능하면 벽 위에서 기존 인터페이스를 조합해서 만들어요.
벽 아래에 기능을 추가하는 건 "계약을 하나 더 맺는 것"이에요.

협력 설계 기법

관절을 만드는 구체적인 방법들이에요. 각 기법은 **"무엇을 밖으로 밀 것인가"**라는 질문에 대한 서로 다른 답이에요.

위임 (IoC, Inversion of Control)

핵심 의도: "뭘 할지"의 결정권을 밖으로 밀어요.
언제 쓰는가:
같은 구조인데 "뭘 할지"만 달라질 때
컴포넌트가 특정 동작에 종속되면 안 될 때
테스트할 때 동작을 갈아끼우고 싶을 때
// ❌ 안에서 결정: PaymentForm이 saveOrder에 종속됨 function PaymentForm() { const handleSubmit = () => { saveOrder() // 뭘 할지 안에서 정해버림 } return <form onSubmit={handleSubmit}>...</form> } // ✓ 밖에서 결정하도록 위임 function PaymentForm({ onSubmit }) { return <form onSubmit={onSubmit}>...</form> } // 사용하는 쪽에서 결정 <PaymentForm onSubmit={saveOrder} /> <PaymentForm onSubmit={withAnalytics(saveOrder)} /> <PaymentForm onSubmit={mockSubmit} /> // 테스트용
TypeScript
복사
효과:
PaymentForm은 "제출하면 뭔가 한다"만 알고, "뭘 하는지"는 몰라요
같은 폼을 여러 맥락에서 다르게 쓸 수 있어요
테스트할 때 실제 API 호출 없이 mock을 넣을 수 있어요
주의할 점:
모든 걸 위임하면 사용하는 쪽이 너무 많은 결정을 해야 해요
"이건 항상 이렇게 동작해야 해"라면 위임하지 말고 안에서 확정해요

슬롯 (Slot)

핵심 의도: "뭘 보여줄지"의 결정권을 밖으로 밀어요.
언제 쓰는가:
껍데기(레이아웃, 스타일)는 같은데 내용물만 달라질 때
어떤 컴포넌트가 들어올지 미리 알 수 없을 때
컴포넌트를 조립해서 쓰고 싶을 때
// ❌ 안에서 내용물 결정: Card가 특정 내용에 종속됨 function Card() { return ( <div className="card"> <h2>고정된 제목</h2> <p>고정된 내용</p> </div> ) } // ✓ 밖에서 내용물 채움 function Card({ children }) { return <div className="card">{children}</div> } // 사용하는 쪽에서 결정 <Card> <UserProfile user={user} /> </Card> <Card> <PaymentSummary order={order} /> </Card>
TypeScript
복사
여러 슬롯이 필요할 때:
function Dialog({ header, children, footer }) { return ( <div className="dialog"> <div className="dialog-header">{header}</div> <div className="dialog-body">{children}</div> <div className="dialog-footer">{footer}</div> </div> ) } <Dialog header={<h2>결제 확인</h2>} footer={<Button onClick={confirm}>확인</Button>} > 정말 결제하시겠습니까? </Dialog>
TypeScript
복사
효과:
Card는 "카드 모양의 껍데기"라는 책임만 져요
안에 뭐가 들어가든 Card는 변경할 필요 없어요
주의할 점:
슬롯이 너무 많으면 조립이 복잡해져요
특정 형태만 들어와야 한다면 슬롯보다 명시적 props가 나을 수 있어요

설정 (Config)

핵심 의도: "어떤 변형인지"를 선택지로 열어둬요.
언제 쓰는가:
같은 컴포넌트의 변형이 몇 가지로 정해져 있을 때
디자인 시스템에서 일관된 선택지를 강제하고 싶을 때
"아무 값이나"가 아니라 "이 중에서 골라"일 때
// ❌ 하드코딩: 변형마다 컴포넌트를 만들어야 함 function PrimaryButton() { return <button className="btn-primary">...</button> } function DangerButton() { return <button className="btn-danger">...</button> } // ✓ 설정으로 선택지 제공 function Button({ variant = 'primary', size = 'md' }) { return <button className={`btn-${variant} btn-${size}`}>...</button> } // 타입으로 선택지를 명시 type ButtonProps = { variant: 'primary' | 'secondary' | 'danger' size: 'sm' | 'md' | 'lg' }
TypeScript
복사
효과:
허용된 변형만 쓸 수 있어서 일관성이 유지돼요
"이 외에는 안 돼"라는 제약이 명확해요
IDE 자동완성으로 선택지를 바로 볼 수 있어요
주의할 점:
선택지가 계속 늘어나면 설계를 다시 생각해봐요
variant="custom-red-bold" 같은 요청이 오면 슬롯이 맞을 수도 있어요

합성 (Composition)

핵심 의도: 작은 것들을 조립해서 큰 것을 만들어요.
언제 쓰는가:
하나의 거대한 컴포넌트가 너무 많은 일을 할 때
조합 방식이 여러 가지일 때
부분만 교체하고 싶을 때
// ❌ 모든 걸 하나에: props가 폭발함 <MegaForm hasHeader={true} headerTitle="결제" hasFooter={true} footerButtons={['cancel', 'submit']} hasValidation={true} validationRules={rules} hasProgress={true} currentStep={2} /> // ✓ 조립: 필요한 것만 조합 <Form> <Form.Header> <h2>결제</h2> <ProgressBar current={2} total={3} /> </Form.Header> <Form.Body> <PaymentFields /> </Form.Body> <Form.Footer> <Button variant="secondary" onClick={cancel}>취소</Button> <Button variant="primary" type="submit">결제</Button> </Form.Footer> </Form>
TypeScript
복사
Compound Component 패턴:
// Form이 하위 컴포넌트들을 네임스페이스로 제공 const Form = ({ children }) => <form>{children}</form> Form.Header = ({ children }) => <div className="form-header">{children}</div> Form.Body = ({ children }) => <div className="form-body">{children}</div> Form.Footer = ({ children }) => <div className="form-footer">{children}</div>
TypeScript
복사
효과:
각 부분이 독립적이라 부분만 테스트/교체할 수 있어요
조합의 자유도가 높아요
props 폭발을 막을 수 있어요
주의할 점:
조립 방식이 한 가지로 정해져 있다면 굳이 쪼갤 필요 없어요
너무 잘게 쪼개면 조립하는 쪽이 피곤해요

어댑터 (Adapter)

핵심 의도: 형태를 맞춰서 연결해요.
언제 쓰는가:
데이터 형태와 컴포넌트가 기대하는 형태가 다를 때
외부 API 응답을 내부 모델로 바꿀 때
서로 다른 인터페이스를 가진 것들을 연결할 때
// API 응답 형태 (백엔드가 정한 것) type ApiUser = { user_name: string user_age: number created_at: string } // 컴포넌트가 기대하는 형태 (프론트엔드가 정한 것) type User = { name: string age: number joinedAt: Date } // ❌ 컴포넌트 안에서 변환: 컴포넌트가 API 형태를 알아야 함 function UserCard({ apiUser }: { apiUser: ApiUser }) { const name = apiUser.user_name // API 형태에 종속 const age = apiUser.user_age // ... } // ✓ 어댑터로 경계에서 변환 const toUser = (apiUser: ApiUser): User => ({ name: apiUser.user_name, age: apiUser.user_age, joinedAt: new Date(apiUser.created_at) }) function UserCard({ user }: { user: User }) { // API 형태 모름, User 형태만 앎 } // 사용 <UserCard user={toUser(apiResponse)} />
TypeScript
복사
효과:
컴포넌트가 외부 형태에 종속되지 않아요
API가 바뀌어도 어댑터만 수정하면 돼요
내부 모델을 우리가 원하는 대로 정할 수 있어요
주의할 점:
어댑터가 너무 많아지면 관리가 어려워요
단순 필드명 변경 정도면 어댑터 없이 직접 써도 돼요

일반해로부터 출발

인터페이스를 새로 발명하지 마세요. 이미 세상에 검증된 인터페이스가 있어요.

절차

1. 본질 파악: "이게 뭐랑 닮았지?"
요구사항을 보고 구현해야 하는 대상의 본질을 파악해요.
"디자인 보니까 가운데는 월을 표시하고, 좌우 화살표로 월을 선택할 수 있어야 하는구나." → 본질: "범위 내에서 값을 선택하는 것"
Plain Text
복사
2. 일반해 매칭: 내가 아는 것 중 뭐랑 닮았나?
Input? Modal? Slider? Calendar? Form? Selector? → "월을 선택하는 거니까 input[type='date']랑 비슷하네"
Plain Text
복사
3. 표준 인터페이스 차용
매칭된 일반해가 가진 인터페이스를 그대로 가져와요. 웹 표준이나 주요 라이브러리의 관례를 따르면 예측 가능성이 높아져요.
// input[type='date']의 인터페이스 <input type="date" value={value} onChange={onChange} min={min} max={max} /> // 그대로 차용 <MonthSelector value={currentMonth} onChange={setCurrentMonth} min={MIN_MONTH} max={MAX_MONTH} />
TypeScript
복사
4. 인터페이스에 맞춰 구현
구현에 인터페이스를 맞추지 않고, 인터페이스에 맞춰 구현해요.
// ❌ 구현이 인터페이스를 결정함: props가 데이터의 통로로 전락 interface Props { selectedMonth: number; currentMonth: number; selectMonth: (month: number) => void; // 내부에서 쓰는 걸 그대로 뚫음. 본질이 안 보임. } // ✓ 인터페이스가 구현을 결정함: props가 본질을 드러냄 interface Props { value: number; onChange: (value: number) => void; min?: number; max?: number; // "범위 내에서 값을 선택"이라는 본질이 보임. }
TypeScript
복사

예시: Before → After

// ❌ Before: props가 데이터의 통로로 전락함 <NavigationSection selectedMonth={selectedMonth} currentMonth={currentMonth} selectMonth={selectMonth} /> // → 세 개가 따로 논다. 관계가 인터페이스에 안 드러남. // → 구현이 인터페이스를 결정함. 내부에서 쓰는 걸 그대로 뚫음. // ✓ After: 본질이 인터페이스에 드러남 <MonthSelector value={selectedMonth} onChange={setSelectedMonth} min={1} max={currentMonth} /> // → "범위 내에서 값을 선택"이라는 본질이 보임. // → input[type='date']처럼 익숙함.
TypeScript
복사
바뀐 것:
selectedMonthvalue (보편적 언어)
selectMonthonChange (보편적 언어)
currentMonth를 직접 받던 것 → max로 제약 (본질)
prev/next disabled 로직 → 컴포넌트 내부로 (구현 은닉)

보편적 언어 (Universal Language)

개발 세계에는 이미 합의된 언어가 있어요. 이걸 쓰면 설명이 필요 없어요.
Actions: open/close, show/hide, enable/disable, select/deselect States: loading/success/error, active/inactive, valid/invalid Events: onChange, onSubmit, onCancel, onComplete, onSelect Values: value, defaultValue, checked, selected, disabled
Plain Text
복사
굳이 startProductLoading, handleMonthSelection 같은 걸 만들 필요 없어요. 그냥 loading, onSelect면 충분해요.

언제 적용하나

적극적으로 적용:
디자인 시스템 컴포넌트 (Button, Modal, Input)
여러 곳에서 쓰이는 공유 모듈
2~3회 이상 반복되는 패턴
관대하게 적용:
일회성 컴포넌트 (이벤트 배너)
재사용 가능성이 없는 특수 도메인
빠르게 만들고 버릴 것이 명확한 코드
모든 것을 일반화하려는 시도는 오버 엔지니어링으로 이어질 수 있어요.

점검 렌즈

이 관절이 왜 필요한가?

모든 props, 모든 콜백에 대해 물어봐요. "이게 없어도 여전히 이 컴포넌트인가?" 필요 없는 관절은 닫아요.
// ❌ 불필요한 관절들 <MonthSelector currentMonth={currentMonth} setCurrentMonth={setCurrentMonth} goPrevMonth={goPrevMonth} // 내부에서 처리 가능 goNextMonth={goNextMonth} // 내부에서 처리 가능 isError={isError} // 여기서 안 씀 data={data} // 여기서 안 씀 /> // ✓ 필요한 관절만 (input[type=date] 차용) <MonthSelector value={currentMonth} onChange={setCurrentMonth} min={MIN_MONTH} max={MAX_MONTH} />
TypeScript
복사

자유도가 적절한가?

너무 열려 있으면 오용되고, 너무 닫혀 있으면 못 써요.
어깨 관절(children)을 썼는데 실제로는 특정 컴포넌트만 들어와야 한다면 → 무릎 관절(variant)이 맞을 수도
무릎 관절(variant)을 썼는데 선택지가 계속 늘어난다면 → 어깨 관절(children)이 맞을 수도

놀람 최소화 원칙

이름과 파라미터가 의도를 드러내는가? 인터페이스를 보고 기대한 것과 실제 동작이 일치해야 해요.
"인터페이스만 보고 안 까봐도 될 거 같아?"
// ❌ 약속보다 적게 함: 이름이 과장 const getOrderSummary = (order) => order.items.length // "summary"라면서 개수만 반환함 // ❌ 약속보다 많이 함: 이름에 없는 일을 함 const formatPrice = (price) => { trackPriceView(price) // 이름에 없는 분석 호출 return `${price.toLocaleString()}` } // ✓ 약속대로 함: 놀람 없음 const formatPrice = (price) => `${price.toLocaleString()}`
TypeScript
복사

함수 입장에서 이치에 맞는가?

외부에 대한 정보가 없다고 생각하고, 함수 입장이 되어봐요.
"이 함수는 자기가 받은 것만으로 자기 일을 할 수 있어?"
함수가 자기 일을 하기 위해 "밖을 기웃거려야" 한다면, 인터페이스가 불완전한 거예요.

분리해서 얻는 게 뭔데?

"뭐가 경제적일까? 얻는 게 뭔데?"
분리에는 비용이 있어요. 파일이 늘어나고, 시점 이동이 생기고, 연결을 관리해야 해요. 그 비용을 상쇄할 이득이 있어야 해요.
분리하면 좋은 경우:
따로 테스트하고 싶을 때
따로 변경될 때 (변경 이유가 다를 때)
따로 재사용할 때
분리 안 해도 되는 경우:
항상 같이 쓰일 때
항상 같이 바뀔 때
한쪽만 따로 테스트할 필요 없을 때
"분리하면 좋겠다"가 아니라 "분리해서 뭘 얻는지"를 먼저 말할 수 있어야 해요.

망치를 쥐어줄지, 망치손을 붙일지?

인터페이스에 파생값을 덕지덕지 붙일지, 기본값만 내보내고 사용처에서 조합하게 할지 결정해야 해요.
// 🦾 망치손을 붙임: 파생값을 전부 인터페이스에 박음 const useOrderStore = create((set, get) => ({ order: null, // 파생값들이 인터페이스에 덕지덕지 getTotalPrice: () => get().order?.items.reduce(...), getDiscountedPrice: () => ..., getItemCount: () => ..., getFormattedPrice: () => ..., getShippingFee: () => ..., // 새 요구사항 올 때마다 여기에 추가됨 })) // 🔨 망치를 쥐어줌: 기본값만 내보내고, 파생은 사용처에서 const useOrderStore = create(() => ({ order: null, })) // 사용처에서 순수함수로 파생 const order = useOrderStore(state => state.order) const totalPrice = calculateTotal(order) const discountedPrice = applyDiscount(totalPrice, rate)
TypeScript
복사
망치를 쥐어줄 때 (기본값만 노출):
인터페이스가 작게 유지됨
파생 로직이 순수함수라 테스트하기 쉬움
새 파생값이 필요해도 인터페이스 안 고침
망치손을 붙일 때 (파생값을 인터페이스에):
사용처마다 같은 계산을 반복하고 싶지 않을 때
파생 로직이 복잡해서 한 곳에서 관리하고 싶을 때
대부분의 경우 기본값만 노출하고 사용처에서 조합하는 게 나아요. 인터페이스에 파생값을 박으면 인터페이스가 비대해지고, 변경할 때마다 인터페이스를 고쳐야 해요.

누가 이 인터페이스를 쓰는가?

인터페이스는 "누가 쓰는가"에 따라 설계가 달라져요. 사용하는 쪽(액터)이 다르면 변경 이유도 달라요.
// 이 컴포넌트를 누가 쓰나? // 1. 같은 팀 개발자가 쓴다면 // → 내부 사정을 알아도 됨, 변경 시 같이 고치면 됨 // → 인터페이스가 좀 거칠어도 괜찮음 // 2. 다른 팀이 쓴다면 // → 내부를 몰라야 함, 변경 시 깨지면 안 됨 // → 추상화 벽이 필요함 // 3. 외부 사용자(라이브러리)가 쓴다면 // → 버전 관리, 하위 호환성 필수 // → 인터페이스 변경 = breaking change
TypeScript
복사
질문해볼 것:
이 인터페이스를 바꾸면 누가 영향을 받나?
그 사람들과 나는 얼마나 자주 소통할 수 있나?
그들이 이 내부 구현을 알아야 하나, 몰라야 하나?
사용자가 멀수록(다른 팀, 외부) 인터페이스는 더 안정적이고, 더 명확하고, 더 작아야 해요.

실천: 호출자 관점 점검 시간

구현하면서 동시에 호출자 관점을 유지하기는 어려워요. 인지 자원이 이미 "구현"에 쓰이고 있거든요.
구현 끝나고 따로 시간을 내서 점검하세요:
1.
내가 만든 인터페이스를 처음 보는 사람이라고 상상해요
2.
이름만 보고 뭘 하는지 알 수 있나?
3.
파라미터만 보고 뭘 넣어야 하는지 알 수 있나?
4.
내부 구현을 몰라도 쓸 수 있나?
동시에 하려면 작업기억이 터져요. 구현 모드 → 점검 모드를 분리하세요.

오작동을 방지하려면

인터페이스 설계가 아닌 것

모든 걸 열어두기: "나중에 필요할 수도 있으니까" 다 열어두면 안 돼요. 열지 않는 것도 설계예요.
Props 전달만 하기: 구체를 그대로 흘려보내는 건 인터페이스가 아니라 통로예요.
이름 없이 연결하기: data, info, handler 같은 이름은 의도를 담지 않아요.

흔한 실수

"확장성을 위해 다 열어뒀어요"
확장성은 "다 열어두기"가 아니에요. 확장될 방향을 예측해서 그 방향만 열어두는 거예요. 예측이 안 되면 닫아두고, 필요해질 때 열어도 돼요.
"재사용하려고 추상화했어요"
재사용은 결과지 목적이 아니에요. 본질을 잘 드러낸 인터페이스는 자연스럽게 재사용돼요. 재사용을 목적으로 억지로 추상화하면 오히려 이상해져요.
"유연하게 만들었어요"
유연함에는 비용이 있어요. 사용하는 쪽이 더 많은 결정을 해야 해요. 때로는 딱딱한 게 나아요. "이렇게 써"라고 정해주는 게 사용자에게 편할 때가 있어요.
"구현 방식으로 인터페이스를 나눴어요"
구현 세부사항(How)을 인터페이스로 끌어올리면 안 돼요.
// ❌ 구현 방식(동기/비동기)으로 인터페이스를 나눔 const form = useForm({ validate: { email: isEmail, // 동기 검증 }, validateOnServer: { email: checkDuplicate, // 비동기 검증 }, }) // 호출자가 "이건 동기, 이건 비동기"를 알아야 함 // ✓ What만 표현: 검증 규칙만 선언 const form = useForm({ validate: { email: [isEmail, checkDuplicate], // 뭘로 검증할지만 }, }) // 동기/비동기는 내부에서 알아서 처리
TypeScript
복사
"이 값이 유효한가?"는 하나의 책임(What)이에요. 동기든 비동기든 구현 방식(How)일 뿐, 인터페이스를 나눌 이유가 없어요.