Search

인터페이스 설계 멘탈 모델 v2

한 문장

인터페이스 설계란, 분리된 것들을 어떤 형태로 드러내고, 어떻게 연결할지 결정하는 일이에요.

전제

인터페이스 설계는 추상화 이후에 오는 작업이에요.
시작 조건:
What과 How가 분리되어 있음
각 모듈이 "뭘 하는지"는 알고 있음
"어떤 형태로 드러내고, 어떻게 연결할지"를 결정해야 하는 상태
아직 What/How가 안 나뉘어 있다면: → 추상화 교과서 먼저

전문가의 인지적 절차

인터페이스를 설계할 때 전문가가 거치는 인지적 과정이에요.
1. 본질 파악 "이게 뭐랑 닮았지?" → 일반해 탐색 (Form, List, Input, Dialog...) 2. 관용어 동원 "뻔한 이름이 뭐지?" → 예측 가능한 어휘 선택 (value, onChange, onSubmit...) 3. 관절 결정 "어떤 자유도로 열지?" → 콜백, 슬롯, 설정, 고정 중 선택 4. 이름으로 What 드러내기 "함수명이 의도를 말하나?" → 인라인 람다 대신 이름 붙인 함수 5. 호출자 관점 점검 "처음 보는 사람이 예측할 수 있나?" → 구현 모드에서 점검 모드로 전환
Plain Text
복사
핵심: 구현(How)부터 시작하지 않아요. "뭐랑 닮았지?"부터 시작해요.

초보자 vs 전문가 사고흐름

위 절차가 실제로 어떻게 적용되는지 예시로 보여드릴게요.
시나리오: "검색/필터/정렬되는 상품 목록 만들기"

초보자

"상품 목록인데 검색이랑 필터랑 정렬이 필요하네..." "일단 ProductList 컴포넌트 만들고, 필요한 거 다 props로 받아야지" "검색어가 필요하니까 searchQuery 받고..." "최소가격 필터도 있으니까 minPrice도 받고..." "정렬 기준도 받아야 하니까 sortBy도..." "아 정렬 방향도 필요하네, sortOrder도..."
Plain Text
복사
<ProductList items={products} searchQuery={searchQuery} minPrice={minPrice} sortBy="name" sortOrder="asc" onSearch={setSearchQuery} onFilterPrice={setMinPrice} onSort={setSortBy} />
TypeScript
복사
"음 props 좀 많은데... 나중에 필터 추가되면 또 props 늘어나겠네" "일단 동작하니까 됐지"
Plain Text
복사

전문가

"상품 목록 + 검색/필터/정렬... 본질이 뭐지?" "목록을 보여주는데, 어떤 조건으로 걸러내고, 어떤 순서로 보여준다" "걸러내기 = filter, 순서 = sort. 배열 메서드랑 같네" ← 관용어 "그럼 filterBy, sortBy로 받으면 뻔하겠다" ← 예측 가능한 어휘 "근데 조건을 어떻게 받지? searchQuery, minPrice 이렇게?" "아니, 그러면 필터 종류가 늘어날 때마다 props가 늘어나잖아" ← 망치손 문제 "조건 자체를 함수로 받으면? filter 함수 배열로" "그럼 사용처에서 원하는 조건을 자유롭게 조합할 수 있겠다" ← 관절 자유도 "근데 함수로 넘기면 안에서 뭘 하는지 안 보이잖아?" "이름을 지어서 넘기면 되겠다. bySearchQuery, byMinPrice..." ← What이 드러나게
Plain Text
복사
const bySearchQuery = (item) => item.name.includes(searchQuery) const byMinPrice = (item) => item.price > minPrice const byNameAsc = (a, b) => a.name.localeCompare(b.name) <ProductList filterBy={[bySearchQuery, byMinPrice]} sortBy={[byNameAsc]} />
TypeScript
복사
"읽으면 바로 보이네: 검색어로 필터, 최소가격으로 필터, 이름순 정렬" "필터 추가해도 props 안 늘어나고, 함수 하나 더 만들면 끝"
Plain Text
복사

차이

초보자
전문가
props 설계
필요한 값 하나씩 추가
함수 배열로 열어둠
확장성
필터 추가 = props 추가
필터 추가 = 함수 추가
가독성
props 보면 값만 보임
함수명 보면 의도가 보임
관점
"뭘 넘겨야 하지?"
"어떤 관절로 열지?"
아래에서 전문가가 동원한 개념들을 하나씩 설명할게요.

구조란

앎이 새겨진 것. 코드의 형태에 엔지니어의 이해가 새겨져, 그 의도가 읽히고 실용적 효과를 내는 것.
앎이 이름으로 새겨지는 과정:
// 탐색 전: 구멍 투성이 for (let i = 0; i < cart.items.length; i++) { if (cart.items[i].category === 'electronics') { total += cart.items[i].price * 0.9 } } // 탐색하며 앎을 획득한다: // "category === 'electronics'가 뭐야?" → "전자제품인지 확인하는 것" const isElectronics = (item) => item.category === 'electronics' // "price * 0.9가 뭐야?" → "10% 할인을 적용하는 것" const applyDiscount = (item, { rate }) => item.price * (1 - rate) // ↑ 이름 ↑ 순서 ↑ 객체 ↑ prop명 // // 앎이 구조로 새겨지는 곳들: // 1) 이름: applyDiscount (할인을 적용한다) // 2) 파라미터 순서: item이 먼저, options가 나중 // 3) 옵션을 객체로: 나중에 확장 가능 // 4) prop 이름: rate (비율) // 5) 값의 형태: number, 소수점 (0.1 = 10%) // 탐색 후: 앎이 이름으로 새겨짐 cart.items .filter(isElectronics) .reduce((sum, item) => sum + applyDiscount(item, { rate: 0.1 }), 0)
TypeScript
복사
좋은 구조는 의도의 선명함이 느껴져요. 작성자가 문제를 깊이 이해한 다음, 그 이해를 코드에 표현해낸 것. 읽는 사람이 "아, 이 사람은 이 문제를 이렇게 보고 있구나"라는 게 코드만으로 전달돼요. 물이 위에서 아래로 흐르듯 인지가 자연스럽게 흘러요:
이름만 보고 뭔지 안다
경계만 보고 어디까지인지 안다
연결만 보고 어떻게 협력하는지 안다
나쁜 구조는 읽기의 흐름이 막히고 고여서 냄새가 나요. 나중에 코드가 추가되거나 수정될 때 본질에 대한 이해를 어렵게 만들기 때문에, 말 그대로 코드가 상하고 썩게 되는 주요 원인이 될 수 있어요:
이름을 봐도 모르겠다
하나를 이해하려면 전부 봐야 한다
연결이 왜 이렇게 되어 있는지 모르겠다

연결이란

나눈 단위들이 인터페이스를 통해 협력하는 방식.
경계로 나눈 것들은 서로 협력해야 해요. 연결의 형태에 설계자의 의도가 새겨져요. 어떤 협력을 허용하고, 어떤 협력을 제한할지가 인터페이스 구조로 표현돼요.
관절 심상이 도움이 돼요:
관절
움직임
인터페이스
문짝 관절
한 방향만
콜백 (onClose)
어깨 관절
자유롭게
슬롯 (children)
무릎 관절
단계별로
설정 (variant)
두개골 관절
안 움직임
고정 (내부 상수)
연결의 층위:
onClose: withFoo(domainFn) // ↑ ↑ ↑ // 구조 층 실제 로직 // 1. onClose — 함수가 들어오도록 허용하는 관절 // 2. withFoo — 층 (관심사를 끼워넣음) // 3. domainFn — 실제 로직 (도메인의 일) // 각 층위가 자기 책임만 지고, 관절을 통해 협력한다
TypeScript
복사

앎의 원천

인터페이스를 설계할 때 동원하는 기존 앎은 어디서 오나요? 네 가지 원천이 있어요.

도메인 언어

기획서와 디자인이 문제 영역을 부르는 말. 경계의 위치를 알려줘요.
"기획서가 '할인'이라 부르면, 코드에서도 '할인'이 경계가 된다."
자의적 언어 대신 도메인 언어를 따르면:
변경이 올 때 어디를 고쳐야 하는지 예측 가능
기획자와 개발자가 같은 말로 대화 가능
요구사항 문서와 코드가 1:1로 대응

일반해

검증된 문제 해결 패턴. 문제 유형에 대한 축적된 앎. 구조의 뼈대를 제공해요.
예시: Form, List, Dialog, Query, Mutation, Input, Modal...
일반해가 제공하는 것:
뼈대 — "Form이면 input, 모으기, submit이 있다"
연결의 형태 — "value/onChange가 표준이다"
기대되는 협력 — "onSubmit은 제출 시점에 호출된다"
일반해는 발견의 도구지 적용의 법칙이 아니에요. "이거 Form 같은데?" → 확인 → 아니면 버림.
유명한 라이브러리를 공부하세요.
바닥에서부터 설계하지 말고, 이미 검증된 인터페이스를 참고하세요.
// react-hook-form의 useForm const { register, handleSubmit, watch, formState } = useForm() // tanstack-query의 useQuery const { data, isLoading, error, refetch } = useQuery({ queryKey, queryFn }) // zustand의 create const useStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), }))
TypeScript
복사
이 라이브러리들이 고민한 것들:
무엇을 노출하고 무엇을 숨길지
호출자 입장에서 인지 비용을 어떻게 줄일지
확장 가능하면서도 뻔하게 만드는 방법
리액트도 새로 발명하지 않았어요. 스케줄러 + 링크드 리스트라는 기존 개념을 조합해서 Fiber 기반 렌더링을 만들었어요. 좋은 설계는 기존의 앎을 잘 조합하는 것이에요.

관용어

개발 씬에서 통용되는 뻔한 표현들. 예측 가능한 어휘를 제공해요.
층위
예시
OS/시스템
file, stream, pipe, process, handle
디자인 패턴
Factory, Observer, Strategy, Adapter
패턴 용어
create, handler, listener, config, options
웹 표준
value/onChange, isOpen/onClose, onSubmit
프레임워크
useXxx, withXxx, renderXxx
보편적 언어 (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
뻔한 인터페이스 예시:
// 이름만 보고 뭔지 안다 const finalPrice = getFinalPrice() const view = useView() const { isOpen, open, close } = useModal() const { data, isLoading } = useQuery(...) // 익숙한 인터페이스를 차용 → 학습 비용 제로 const [state, setState] = useState() const [state, setState] = useAtom() // jotai: useState 인터페이스 그대로
TypeScript
복사
굳이 startProductLoading, handleMonthSelection 같은 걸 만들 필요 없어요. 그냥 loading, onSelect면 충분해요.
창의적인 것보다 뻔한 게 낫다.
관용어를 따르면:
이름만 봐도 역할이 예측된다
예측이 맞으면 코드를 안 봐도 된다
예측이 틀리면 놀람 → 버그 가능성
학습 전이가 일어나요:
// ❌ 도메인마다 다른 이름 handleProductSelection(productId) handleUserChoice(userId) handleOrderPick(orderId) // ✓ 본질만 남김 onSelect(id)
TypeScript
복사
onSelect를 한 번 배운 개발자는 다른 컴포넌트에서도 onSelect를 찾아요. 같은 패턴이 반복되니까 학습이 전이돼요.

협력의 형태

모듈들이 협력하게 만드는 설계 기법. 연결의 방식을 제공해요.
기법
하는 일
예시
위임 (IoC)
결정권을 밖으로 민다
onSubmit={...}
슬롯 (Slot)
내용물을 밖에서 채운다
children
설정 (Config)
선택지를 열어둔다
variant="primary"
합성 (Composition)
작은 것들을 조립한다
<Form><Input/></Form>
어댑터 (Adapter)
시그니처를 맞춘다
형태 변환

결정할 것 두 가지

1. 형태: 어떤 형태로 드러낼지

각 What을 어떤 형태로 표현할지 결정해요.
일반해가 있으면 표준 인터페이스를 차용해요.
일반해
표준 인터페이스
Input
value, onChange, disabled
Toggle
checked, onChange
Selector
value, onChange, options
List
items, renderItem
Dialog
isOpen, onClose
Form
onSubmit, children
"월을 선택한다" ↓ "범위 내에서 값을 선택" → input[type='date']와 닮음 ↓ value, onChange, min, max
Plain Text
복사
일반해가 없으면 최대한 관용적인 형태를 찾아요.
"세상의 누군가는 이 문제 풀었다"는 마인드
도메인 특수한 것도 고객 언어로 표현
콜센터 테스트: 고객에게 전화로 설명한다고 상상했을 때, 자연스럽게 말이 되면 고객 언어
// ❌ 구현 언어 products.sort((a, b) => b.annualRate - a.annualRate) // ✓ 고객 언어 const 이자율높은순 = (a, b) => b.annualRate - a.annualRate products.sort(이자율높은순)
TypeScript
복사

2. 연결: 어떻게 연결할지

What들 사이를 어떤 관절로 연결할지 결정해요.
관절 유형:
유형
자유도
"밖에서 뭘 결정하나"
예시
콜백
한 방향
뭘 할지
onSubmit, onChange
슬롯
자유
뭘 보여줄지
children
설정
이산적
어떤 변형인지
variant, size
고정
없음
(안 열음)
내부 상수
결정 기준:
"이 관절이 없어도 이 컴포넌트인가?" → Yes: 열지 않음 (고정) → No: 열어야 함 → 어떤 유형으로? "밖에서 뭘 결정하게 할 건가?" → 뭘 할지 → 콜백 → 뭘 보여줄지 → 슬롯 → 어떤 변형인지 → 설정
Plain Text
복사
관절 갯수 원칙:
필요한 것만 열어요. 열지 않는 것도 설계예요.
기본값만 노출하고, 파생값은 사용처에서 조합하게 해요.

점검

"호출자 관점에서 괜찮은가?"

왜 호출자 관점이 어려운가

인터페이스 설계가 어려운 이유는 관점 전환 비용 때문이에요.
구현자 관점 (내가 어떻게 짤까): 공짜로 떠올라요
호출자 관점 (다른 사람이 어떻게 쓸까): 의식적 노력이 필요해요
구현하면서 동시에 호출자 관점을 유지하려면 두 관점을 동시에 들고 있어야 해요. 인지 자원이 이미 "구현"에 쓰이고 있는데, "호출자 시뮬레이션"까지 하려니 작업기억이 터져요.

실천: 구현 모드 → 점검 모드 분리

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

점검 질문

질문
확인하는 것
"인터페이스만 보고 안 까봐도 돼?"
놀람 최소화
"props가 통로로 전락하지 않았나?"
본질이 드러나는가
"이 관절이 왜 필요해?"
불필요한 열림
"함수가 받은 것만으로 일할 수 있어?"
자기완결성

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

인터페이스는 "누가 쓰는가"에 따라 설계가 달라져요.
사용자
인터페이스 수준
같은 팀
좀 거칠어도 됨, 변경 시 같이 고치면 됨
다른 팀
내부를 몰라야 함, 변경 시 깨지면 안 됨
외부 (라이브러리)
버전 관리, 하위 호환성 필수
사용자가 멀수록 인터페이스는 더 안정적이고, 더 명확하고, 더 작아야 해요.

예시: MonthSelector

형태 결정:
"월을 선택한다" → "범위 내에서 값 선택" → input[type='date']와 닮음 → value, onChange, min, max
Plain Text
복사
연결 결정:
- value: 콜백 (필수) - onChange: 콜백 (필수) - min, max: 설정 (선택) - children: 안 열음 (내부에서 처리)
Plain Text
복사
결과:
<MonthSelector value={selectedMonth} onChange={setSelectedMonth} min={1} max={currentMonth} />
TypeScript
복사
점검:
✓ input처럼 익숙함
✓ prev/next 버튼은 내부에서 처리
✓ 사용처는 "값과 변경"만 알면 됨

예시: ProductList

형태 결정:
"제품 목록을 필터/정렬해서 보여준다" → List + filter/sort 조합 → items 대신 filterBy, sortBy를 순수함수 배열로 받음
Plain Text
복사
연결 결정:
- filterBy: 콜백 배열 (순수함수들) - sortBy: 콜백 배열 (순수함수들)
Plain Text
복사
결과:
// 순수함수에 이름을 지어서 what이 드러나게 const bySearchQuery = (item) => item.name.includes(searchQuery) const byMinPrice = (item) => item.price > minPrice const byNameAsc = (a, b) => a.name.localeCompare(b.name) <ProductList filterBy={[bySearchQuery, byMinPrice]} sortBy={[byNameAsc]} />
TypeScript
복사
ProductList 내부:
function ProductList({ filterBy = [], sortBy = [] }) { const { data } = useQuery({ queryKey: ['products'], queryFn: fetchProducts, select: (data) => { let result = data for (const filter of filterBy) { result = result.filter(filter) } for (const sort of sortBy) { result = result.sort(sort) } return result } }) return <List items={data} /> }
TypeScript
복사

오작동 방지

getFoo, calculateBar 함수 경계하기

짬통 함수가 되기 쉬워요.
// ❌ 짬통 함수: 인자가 이상하게 생김 function getOrderData( order, includeShipping, applyDiscount, formatCurrency, userLocale, showTax ) { ... } // ✓ 책임별로 분리 function getOrderSummary(order) { ... } function calculateShipping(order) { ... } function formatPrice(amount, { locale = 'ko' } = {}) { ... }
TypeScript
복사
징후: 인자 3개 초과, boolean 플래그 여러 개

props가 통로로 전락할 때

// ❌ 내부에서 쓰는 걸 그대로 뚫음 <MonthSelector selectedMonth={selectedMonth} currentMonth={currentMonth} selectMonth={selectMonth} /> // ✓ 본질만 드러냄 <MonthSelector value={selectedMonth} onChange={setSelectedMonth} min={1} max={currentMonth} />
TypeScript
복사

파생값을 인터페이스에 붙일 때

망치를 쥐어줄지, 몸통에 망치손을 새로 달지의 문제예요.
// ❌ 망치손을 붙임: 파생값을 전부 인터페이스에 useOrderStore({ order, getTotalPrice, getDiscountedPrice, getFormattedPrice, }) // 새 파생값이 필요할 때마다 인터페이스가 커짐 // ✓ 망치를 쥐어줌: 기본값만 노출, 사용처에서 조합 useOrderStore({ order }) const total = calculateTotal(order) // 순수함수로 파생 // 새 파생값이 필요하면 순수함수 하나 만들면 됨
TypeScript
복사

추상화 레벨이 안 맞을 때

같은 인터페이스 내 메서드들은 같은 추상화 레벨이어야 해요.
// ❌ 하나만 레벨이 다름 interface DataManager { fetch() update() delete() saveUserProfile() // 🤔 너무 구체적 } interface Modal { open() close() minimize() openWithProductData() // 🤔 도메인 특화 } interface Form { validate() submit() reset() validateEmailFormat() // 🤔 특정 필드만 }
TypeScript
복사
나머지는 일반적인 동작인데, 하나만 특정 도메인이나 구체적인 동작을 나타내고 있어요. 이러면 인터페이스가 오염돼요.