한 문장
인터페이스 설계란, 요구사항에 대한 이해를 명명과 의도적인 제약을 통해 구조에 담아내는 일이에요. 이를 통해 모듈과 모듈이 서로 목적에 맞는 관절을 선택하고, 맞물리고 연결되어 협력할 수 있게 돼요.
왜 필요한가
경계로 나눈 것들은 서로 협력해야 해요. 그런데 아무렇게나 연결하면 안 돼요.
// ❌ 아무렇게나 연결
<결제창
신한카드={{ 카드번호, 유효기간, 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
복사
바뀐 것:
•
selectedMonth → value (보편적 언어)
•
selectMonth → onChange (보편적 언어)
•
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)일 뿐, 인터페이스를 나눌 이유가 없어요.
