Search

4장_코드_추상화

4장. 코드 추상화

이제 코드로 들어가요.
1~3장에서 추상(What/How 분리)과 추상화(그걸 만드는 과정)를 다뤘어요. 이번 장에서는 그 원리를 코드에 적용해요. 코드에서 What과 How를 분리하는 구체적인 방법을 배워요.

코드 추상화란

코드 추상화를 “함수 추출”이나 “클래스 분리” 같은 거라고 생각하기 쉬워요. 틀린 건 아니지만, 그건 기법이에요. 본질이 아니에요.
코드 추상화의 본질은 1장에서 배운 거예요. What과 How를 분리하는 것. 코드에서도 똑같아요.
What: 이 코드가 무엇을 하는가
How: 이 코드가 어떻게 하는가
함수를 쪼개도, 파일을 나눠도, 클래스를 만들어도 — What과 How가 섞여 있으면 여전히 복잡해요. 반대로, 함수 하나에 다 들어있어도 What과 How가 분리되어 있으면 읽기 쉬워요.
이번 장에서는 코드에서 What과 How를 분리하는 방법을 다뤄요.
이 장에서 다루는 것: - 코드 추상화의 출발점: 일단 짜고, 복잡도가 신호가 되면 정리 - 코드 추상화의 방향: 안에서 밖으로(Inside-Out), 밖에서 안으로(Wishful Thinking) - 코드 추상화의 도착점: 일반해(Form, List, State, API)와 레이어/협력/축척 구조
그럼 실제 코드에서 어떻게 보이는지 볼게요.

일단 짠다

프론트엔드는 화면에서 시작해요. 왜 그럴까요?
프론트엔드 업무의 본질을 생각해보면 알 수 있어요. 화면을 기준으로 비즈니스 요구사항을 반영하는 일이에요. 기획이 바뀌면 화면이 바뀌고, 화면이 바뀌면 코드가 바뀌어요. 변경의 기준도, 단위도 화면이에요.
그래서 좋은 프론트엔드 코드는 화면의 구조를 그대로 반영해요. 화면을 보면 코드가 예측되고, 코드를 보면 화면이 떠올라요. 기획자가 “이 부분 바꿔주세요”라고 하면, 개발자는 해당 코드를 바로 찾을 수 있어요. 이것이 화면과 코드의 1:1 매칭이에요.
디자인 시안을 보고, 화면을 그리고, 코드를 맞춰가요. 처음부터 완벽한 구조를 설계하고 시작하지 않아요. 일단 짜요.
// ❌ Before: What이 숨어있음 // 문제: "이 컴포넌트가 뭘 하는 건지" 알려면 useState 8개를 다 읽어야 함 function RegistrationForm() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [name, setName] = useState('') const [phone, setPhone] = useState('') const [emailError, setEmailError] = useState('') const [passwordError, setPasswordError] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const handleSubmit = async () => { setIsSubmitting(true) if (!email.includes('@')) { setEmailError('이메일 형식이 아니에요') setIsSubmitting(false) return } if (password !== confirmPassword) { setPasswordError('비밀번호가 일치하지 않아요') setIsSubmitting(false) return } // ... 제출 로직 } return ( <form onSubmit={handleSubmit}> {/* 입력 필드들 */} </form> ) }
TypeScript
복사
useState가 8개예요. 검증 로직이 handleSubmit 안에 섞여 있어요. “폼 상태를 관리한다”는 의도가 코드 어디에도 드러나지 않아요.
복잡해지는 게 신호예요. “이제 정리할 때”라는 신호.

신호를 알아채는 법

복잡해지는 건 신호지만, 어떤 게 복잡한 건지 알아야 해요. 두 가지 능력이 필요해요:
Trigger: 고칠 곳을 알아채는 감각
Ability: 더 나은 형태로 바꾸는 기술
초보자는 Trigger가 발달하지 않아서 코드를 봐도 고칠 곳을 못 찾아요. 혹은 Ability가 부족해서 이상하게 고치기도 해요. 이 장에서는 둘 다 다뤄요.
가장 분명한 신호: “뜬금없이 소리치는 요소”
코드를 위에서 아래로 읽어 내려가다가, 흐름에서 벗어나서 “어? 이게 왜 여기 있지?”라고 느껴지는 요소예요.
// ❌ 뜬금없는 요소들 function PostEditor() { const isLogin = useSearchParams().get('token'); // 에디터인데 로그인 체크? const navigate = useNavigate(); // 에디터인데 네비게이션? // ... } // ✅ 예측 가능한 요소들 function PostEditor({ value, onSave, onCancel }) { const [title, setTitle] = useState(''); const [content, setContent] = useState(''); // 에디터와 관련된 것들만 }
TypeScript
복사
PostEditor라는 이름을 보면 “게시물을 편집하는 것”을 기대해요. 그런데 isLogin이나 navigate가 나오면 “왜?”라는 생각이 들어요. 이게 신호예요.
뜬금없다고 느껴지면: - 함수 이름이 의도를 제대로 표현 못 하는 건 아닌지 - 이 코드가 여기 있어야 하는 건지 - 다른 곳으로 옮겨야 하는 건 아닌지
점검해봐요.
주의: 뜬금없는 요소가 있다고 해서 “다른 파일로 옮기면 된다”가 아니에요. 먼저 이 컴포넌트의 What이 뭔지 명확히 해야 어떻게 분리할지가 보여요. 물리적 분리는 기법일 뿐, 추상화가 아니에요.

Inside-Out: 올라가기

복잡해졌으면 정리해요. 어떻게?
안에서 밖으로 올라가요.

0단계: 모아둘 결심

정리하기 전에 먼저 해야 할 게 있어요. 흩어진 걸 모으는 거예요.
코드가 여러 파일에 흩어져 있으면 시점 이동이 많아져요. 파일 5개를 왔다갔다하면서 “이게 뭐였지?” 싶어져요. 이 상태에서는 패턴이 안 보여요.
일단 인라인해요. 최대한 가까이 둘 수 있을 만큼 가까이. 같은 파일에, 같은 스코프에, 같은 함수 안에.
// ❌ Before: 흩어진 상태 // 문제: 파일 3개를 왔다갔다해야 "이 폼이 뭘 하는지" 파악됨. // 시점 이동 비용이 높아서 패턴이 안 보임. // formUtils.ts에 validateEmail // formHooks.ts에 useFormState // formTypes.ts에 FormValues // ✅ After: 일단 모은 상태 // 핵심: 한 파일에 모으면 "폼 값 + 검증 + 상태"라는 패턴이 눈에 들어옴 function RegistrationForm() { const [email, setEmail] = useState('') const validateEmail = (value: string) => { ... } // 전부 여기에 }
TypeScript
복사
“파일이 길어지는데요?” — 괜찮아요. 물리적으로 긴 파일보다 논리적으로 흩어진 코드가 더 위험해요. 일단 모아야 패턴이 보여요.
abstract as you go(진행하면서 추상화하기) — 패턴이 보이면 그때 함수나 모듈로 추출해요.
미리 “이건 나중에 쓰일 것 같으니까 분리해두자”가 아니에요. 그건 추측이에요. 지금 눈앞에 패턴이 보일 때, 그때 추출하는 거예요.
// ❌ Before: 미리 분리 (추측) // 문제: "나중에 쓰일 것 같다"는 추측으로 분리함. 실제로 쓰이지 않으면 파일만 늘어남. function PostCardHeader({ title }) { return <h2>{title}</h2> } // ✅ After: 인라인 유지 // 핵심: 실제로 재사용되는 순간까지 분리하지 않음. 패턴이 보일 때 추출. function PostCard({ post }) { return ( <Card> <h2>{post.title}</h2> {/* 재사용 전까지는 인라인 */} <p>{post.content}</p> </Card> ) }
TypeScript
복사
추상화는 미리 설계하는 게 아니에요. 패턴을 발견하는 거예요.
분리하려면 명분이 필요해요.
“분리해야겠다”는 생각이 떠오르면 잠깐 멈춰요. 자동으로 분리하지 말고, 스스로 물어보세요:
“이거 분리해서 얻는 게 뭔데?”
“분리하면 뭐가 좋아지는데?”
대답할 수 없으면 분리하지 마세요. 분리에는 비용이 따르기 때문이에요. 파일이 늘어나고, 시점 이동이 생기고, 이해해야 할 조각이 많아져요.
분리의 효익이 비용을 넘어설 때만 분리한다.
이건 일종의 마음챙김이에요. 자동으로 하던 걸 멈추고, “지금 내가 뭘 하려는 거지?”라고 인식하고, 경제적으로 판단하는 거예요. 이 습관이 붙으면 분리와 추상화에 신중해져요. 그리고 정말 필요할 때, 적절한 타이밍에 분리하게 돼요.

1단계: 다 펼쳐놓기

모았으면 이제 봐요. 숨기지 않아요.
// 지금 있는 것들 const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [name, setName] = useState('') const [phone, setPhone] = useState('') const [emailError, setEmailError] = useState('') const [passwordError, setPasswordError] = useState('') const [isSubmitting, setIsSubmitting] = useState(false)
TypeScript
복사

2단계: 리프부터 이름 붙이기

가장 안쪽, 가장 구체적인 부분부터 봐요.
email, password, confirmPassword, name, phone — 이건 뭐지? 폼 값이에요.
emailError, passwordError — 이건 뭐지? 검증 에러예요.
isSubmitting — 이건 뭐지? 제출 상태예요.
이름이 붙었어요: - 폼 값들 - 검증 에러들 - 제출 상태

3단계: 올라가며 경계 발견

이름을 붙이다 보니 경계가 보여요. “여기까지가 하나의 덩어리구나.”
// 폼 값들 — 하나의 덩어리 const formValues = { email: '', password: '', confirmPassword: '', name: '', phone: '' } // 검증 에러들 — 하나의 덩어리 const errors = { email: '', password: '' } // 제출 상태 — 하나의 덩어리 const isSubmitting = false
TypeScript
복사
더 올라가봐요. 이 세 덩어리를 묶으면 뭐지? 폼 상태예요.
// 폼 상태 전체 interface FormState { values: FormValues errors: FormErrors isSubmitting: boolean }
TypeScript
복사
What이 발견됐어요. “이 코드는 폼 상태를 관리해요.”
이게 Inside-Out이에요. 안(세부)에서 시작해서 밖(전체)으로 올라가면서 경계를 발견해요. 아래에서 위로 올라가는 방향이에요.

Wishful Thinking: 내려오기

WT는 호출자 관점에서 시작하는 방법이에요. 구현자 관점(자동으로 떠오름)에서 호출자 관점(의식적 노력 필요)으로 전환하는 건 비용이 들어요. WT는 이 비용을 처음부터 지불해요. 그래서 어렵지만, 그만큼 강력해요.
Inside-Out으로 What을 발견했어요. 이제 반대로 가봐요.
밖에서 안으로 내려가요.
“이런 게 있으면 좋겠다”를 먼저 상상해요. 구현이 어떻게 되든 상관없이.
뭘 기준으로 상상할까요? 화면이에요. 앞에서 말한 1:1 매칭을 떠올려봐요. 화면을 보면 코드가 예측되고, 코드를 보면 화면이 떠오르는 상태. WT의 목표는 그 상태를 만드는 거예요.
// ✅ After: What이 드러남 // 핵심: 줄 수가 줄어서 좋은 게 아님. "폼을 관리한다"는 의도가 useForm, Form, Field라는 이름에 드러나서 좋음 function RegistrationForm() { const form = useForm() return ( <Form {...form}> <Field name="email" /> <Field name="password" /> <Field name="confirmPassword" /> <Field name="name" /> <Field name="phone" /> <SubmitButton /> </Form> ) }
TypeScript
복사
useForm()이 실제로 어떻게 구현되는지는 나중에 생각해요. <Form><Field>가 어떻게 렌더링되는지도 나중에 생각해요. 지금은 What만 있으면 돼요.
What을 먼저 상상하고, How는 나중에 채워요. 위(탑레벨)에서 아래(세부)로 내려가는 방향이에요.
이게 Wishful Thinking이에요. “이미 있다고 가정하고” 생각해요.

예시: 아이폰 홈 버튼

1장에서 아이폰 홈 버튼 예시를 봤어요. What(홈으로 이동)은 그대로인데 How(버튼/제스처)가 바뀌었죠. 코드로 보면 어떨까요?
현재 요구사항에 충실하게 짠 코드:
// ❌ Before: 현재 요구사항에만 맞춤 interface Props { showHomeButton: boolean; } const IPhone = ({ showHomeButton }: Props) => { if (showHomeButton === true) { return <HomeButton onClick={moveHome} />; } else { return <HomeGesture onSwipeUp={moveHome} />; } };
TypeScript
복사
잘 작동해요. 버튼이냐 제스처냐, 둘 중 하나니까요.
그런데 다이얼이 추가되면?
// 요구사항 변경: 다이얼 UI 추가 type HomeUIType = 'dial' | 'gesture' | 'button'; interface Props { homeUIType: HomeUIType; } const IPhone = ({ homeUIType }: Props) => { if (homeUIType === 'button') { return <HomeButton onClick={moveHome} />; } else if (homeUIType === 'gesture') { return <HomeGesture onSwipeUp={moveHome} />; } else if (homeUIType === 'dial') { return <HomeDial onRotate={moveHome} />; } };
TypeScript
복사
props 타입이 바뀌었어요. 조건문이 늘었어요. 새로운 UI가 추가될 때마다 이 컴포넌트를 수정해야 해요.
왜 이렇게 됐을까요?
“홈으로 이동한다”(What)와 “홈 버튼 UI”(How)가 분리 안 됐어요. How가 바뀔 때 What까지 영향을 받는 구조예요.
WT로 다시 생각하면:
“이런 게 있으면 좋겠다”를 먼저 상상해봐요. - 호출자는 “홈으로 이동하는 UI를 넣어줘”라고만 말하고 싶어요 - 어떤 UI인지(버튼, 제스처, 다이얼)는 호출자가 결정하고 싶어요
// ✅ After: What과 How 분리 interface Props { renderHomeUI: (moveHome: () => void) => ReactNode; } const IPhone = ({ renderHomeUI }: Props) => { return renderHomeUI(moveHome); }; // 사용처에서 How를 결정 <IPhone renderHomeUI={(moveHome) => <HomeButton onClick={moveHome} />} /> <IPhone renderHomeUI={(moveHome) => <HomeGesture onSwipeUp={moveHome} />} /> <IPhone renderHomeUI={(moveHome) => <HomeDial onRotate={moveHome} />} />
TypeScript
복사
// ✅ After: What과 How 분리 interface Props { renderHomeUI: (moveHome: () => void) => ReactNode; } const IPhone = ({ renderHomeUI }: Props) => { return renderHomeUI(moveHome); }; // 사용처에서 How를 결정 <IPhone renderHomeUI={(moveHome) => <HomeButton onClick={moveHome} />} /> <IPhone renderHomeUI={(moveHome) => <HomeGesture onSwipeUp={moveHome} />} /> <IPhone renderHomeUI={(moveHome) => <HomeDial onRotate={moveHome} />} />
TypeScript
복사
// ✅ After: What과 How 분리 interface Props { renderHomeUI: (moveHome: () => void) => ReactNode; } const IPhone = ({ renderHomeUI }: Props) => { return renderHomeUI(moveHome); }; // 사용처에서 How를 결정 <IPhone renderHomeUI={(moveHome) => <HomeButton onClick={moveHome} />} /> <IPhone renderHomeUI={(moveHome) => <HomeGesture onSwipeUp={moveHome} />} /> <IPhone renderHomeUI={(moveHome) => <HomeDial onRotate={moveHome} />} />
TypeScript
복사
What: “홈으로 이동하는 UI를 제공한다” — IPhone 컴포넌트의 책임 How: “어떤 UI인지” — 호출자가 결정
새로운 UI 유형이 추가돼도 IPhone 컴포넌트는 수정할 필요 없어요. How의 변화가 What에 영향을 주지 않아요.
이게 WT예요. “이런 게 있으면 좋겠다”를 먼저 상상하고, 그 인터페이스를 만들어요.

왕복

Inside-Out과 Wishful Thinking을 각각 살펴봤어요. 그런데 실제로는 한 방향으로만 가지 않아요. 왔다 갔다 해요.

왜 왕복인가

Inside-Out만 하면: 세부에서 출발해서 올라가는데, 방향을 잃을 수 있어요. 코드는 정리됐는데 “이게 뭘 하는 건지” 모르겠어요.
Wishful Thinking만 하면: 이상적인 인터페이스를 상상했는데, 막상 구현하려니 안 맞아요.
한 방향만 가면 “지금 보이는 것”에 갇혀요. IO로 올라갈 땐 세부에서 출발하니까 세부에 맞는 What을 찾아요. WT로 내려올 땐 이상에서 출발하니까 이상에 맞는 How를 찾아요. 둘 다 “지금 출발점”에 최적화된 결과예요. 왕복해야 전체 최적이 보여요.
그래서 왕복해요.

구체적인 과정

1.
일단 짠다 — 화면 보면서 코드 작성
2.
복잡해진다 — useState 8개, 로직 얽힘
3.
IO로 올라간다 — 펼쳐놓고, 이름 붙이고, 경계 발견
4.
What 발견 — “폼 상태를 관리하는 거구나”
5.
WT로 내려온다 — “이런 인터페이스가 있으면 좋겠다”
6.
구현하다가 — 빠진 게 보임 (유효성 검사 규칙, 비동기 검증…)
7.
다시 IO로 올라간다 — 새로 발견한 것 반영
8.
다시 WT로 내려온다 — 인터페이스 조정
이걸 반복해요.

예시: 회원가입 폼 왕복

1차 시도 (일단 짬)
// ❌ Before: What이 숨어있음 function RegistrationForm() { const [email, setEmail] = useState('') // ... 8개 useState const handleSubmit = () => { if (!email.includes('@')) { /* ... */ } // 검증 로직이 여기저기 } }
TypeScript
복사
복잡해요. “폼 상태를 관리한다”는 의도가 안 보여요.
IO로 올라감
펼쳐놓고 보니까: - 값들이 있고 - 에러들이 있고 - 검증 로직이 있어요
경계가 보여요. “폼”이라는 덩어리.
WT로 내려옴
// ✅ After: What이 드러남 function RegistrationForm() { const form = useForm({ initialValues: { email: '', password: '', ... }, validate: { email: isEmail, password: minLength(8) } }) }
TypeScript
복사
“이런 게 있으면 좋겠다.” useForm이라는 이름이 의도를 말해줘요.
구현하다가 — 문제 발견
이메일 중복 검사는 서버에 물어봐야 해요. 동기 검증만 있으면 안 돼요.
다시 IO로 올라감
새로운 경계 발견: 필드별 검증 규칙
다시 WT로 내려옴
const form = useForm({ validate: { email: [isEmail, checkDuplicate], // 규칙만 선언 password: [minLength(8), hasSpecialChar], }, }) // 동기/비동기는 라이브러리가 알아서 처리
TypeScript
복사
이렇게 왕복하면서 글로벌 최적에 가까워져요. 한 방향만 가면 로컬 최적에 빠져요.

왜 왕복하면 비용이 줄어들까요?

0장의 세 가지 비용 축을 생각해봐요:
처리 비용 감소 (자동화 축): 한 번에 생각할 것을 줄여요. System 2 과부하를 피해요.
관점 비용 감소 (관점 축): 세부(IO)와 전체(WT)를 번갈아 보면서 서로 검증해요.
시간 비용 감소 (시간 축): 작은 단위로 진전을 확인해서 잘못된 방향을 일찍 잡아요.

일반해

왕복을 반복하다 보면 패턴이 보여요.
회원가입 폼, 로그인 폼, 설정 폼, 검색 폼… 다 비슷한 구조예요. 값, 에러, 제출 상태, 검증 로직.
이 패턴에 이름을 붙이면 Form이에요.
React Hook Form, Formik, react-final-form 같은 라이브러리들이 이미 있어요. 이건 누군가가 “폼”이라는 What에 이름을 붙이고, How를 구현해둔 거예요.
이렇게 반복되는 What에 이름을 붙인 것을 일반해라고 불러요.
Form: 입력값 + 검증 + 제출
Dialog: 열기/닫기 상태 + 내용
List: 데이터 배열 + 렌더링 + 페이지네이션
Query: 비동기 데이터 + 로딩/에러/성공 상태
일반해를 알면 강력해요. “이건 Form이네” 하고 알아채는 순간, What이 바로 보여요. 이름을 붙일 필요도 없어요. 이미 붙어 있으니까.
3장에서 배운 “구조 사전”이 바로 이거예요. 자주 보이는 What들의 목록.

일반해 탐색 3단계

일반해를 어떻게 찾을까요? 직접 발명하지 않아요. 이미 있는 것을 발견해요.
“이거 어떻게 해야 하지?”라고 느껴질 때, 아래 3단계를 따라가봐요.
1단계: 언어화 (Verbalize)
지금 겪고 있는 문제를 한 문장으로 적어봐요.
"날짜가 뒤죽박죽인 소비 내역을, 날짜별로 묶어서 보여줘야 한다." "API 호출이 실패하면 자동으로 몇 번 더 시도해야 한다." "사용자 입력이 너무 많고 복잡하다."
Plain Text
복사
2단계: 일반화 (Generalize)
내 문제의 고유한 맥락을 빼고, 보편적인 용어로 재정의해봐요.
"날짜별로 묶어야 한다" → "그룹핑 문제구나" "실패 시 재시도" → "재시도 로직 문제네" "입력이 복잡하다" → "폼 상태 관리 문제야"
Plain Text
복사
3단계: 탐색연결 (Explore & Connect)
일반화된 키워드로 검색해봐요.
"javascript array grouping" → lodash.groupBy, reduce "react query retry" → Tanstack Query의 retry 옵션 "react form state" → React Hook Form, Formik
Plain Text
복사
연결하면 코드가 어떻게 달라지는지 봐요.
// ❌ Before: 직접 구현 // 문제: 날짜별 그룹핑을 매번 직접 구현하면 중복 코드 발생 const grouped = transactions.reduce((acc, tx) => { const date = tx.date.split('T')[0] if (!acc[date]) acc[date] = [] acc[date].push(tx) return acc }, {}) // ✅ After: 일반해 연결 // 핵심: "그룹핑 문제"라고 인식하는 순간 lodash.groupBy가 떠오름 import { groupBy } from 'lodash' const grouped = groupBy(transactions, tx => tx.date.split('T')[0])
TypeScript
복사
핵심: 내 문제는 특별하지 않아요. 전 세계 개발자들이 이미 겪었고, 해결책도 이미 있어요. 그 해결책을 찾아서 연결하면 돼요.

커스텀 훅은 마지막 수단

일반해를 적용할 때 주의할 점이 있어요. React에서 로직을 분리할 때 커스텀 훅을 떠올리기 쉬운데, 이건 마지막 수단이에요.
원칙: 일반 함수 > 커스텀 훅
// ❌ 불필요한 커스텀 훅 function useFormatPrice(price: number) { return `${price.toLocaleString()}` } // ✅ 일반 함수로 충분 function formatPrice(price: number) { return `${price.toLocaleString()}` }
TypeScript
복사
useFormatPrice는 내부에 hook이 없어요. useState도 없고 useEffect도 없어요. 그냥 문자열을 반환하는 순수 함수예요. 그런데 use 접두사를 붙여서 커스텀 훅처럼 만들었어요.
왜 일반 함수가 더 나을까요?
프레임워크 의존성 제거: 일반 함수는 React 없이도 돌아가요. 테스트하기 쉽고, 다른 환경에서도 쓸 수 있어요.
호출 규칙 없음: 커스텀 훅은 컴포넌트나 다른 훅 안에서만 호출할 수 있어요. 일반 함수는 어디서든 호출해요.
useCallback으로 감싼 함수도 밖으로 뺄 수 있어요
// ❌ 컴포넌트 안에서 useCallback function Component() { const handleClick = useCallback(() => { console.log('clicked') }, []) return <Button onClick={handleClick} /> } // ✅ 컴포넌트 밖으로 빼면 useCallback 불필요 function handleClick() { console.log('clicked') } function Component() { return <Button onClick={handleClick} /> }
TypeScript
복사
handleClick이 컴포넌트의 상태나 props를 참조하지 않으면, 밖으로 빼도 돼요. 그러면 useCallback이 필요 없어요.
커스텀 훅이 필요한 경우: 내부에 hook이 있을 때만
// ⚠️ 커스텀 훅이 필요하지만, 데이터 페칭에는 더 나은 방법이 있어요 function useUser(userId: string) { const [user, setUser] = useState(null) useEffect(() => { getUser(userId).then(setUser) }, [userId]) return user }
TypeScript
복사
useStateuseEffect가 있어요. 이 로직을 일반 함수로는 분리할 수 없어요. 이럴 때만 커스텀 훅을 써요.
더 나은 방법: 데이터 페칭에는 선언적 패턴
데이터 페칭이라면 useState + useEffect 대신 TanStack Query(React Query)의 선언적 패턴이 더 나아요.
// ✅ 선언적 데이터 페칭 (권장) // queryOptions로 재사용 가능한 쿼리 정의 const userOptions = (userId: string) => queryOptions({ queryKey: ['user', userId], queryFn: () => getUser(userId), }) // 컴포넌트에서 직접 사용 function UserProfile({ userId }: { userId: string }) { const { data } = useSuspenseQuery(userOptions(userId)) return <div>{data.name}</div> }
TypeScript
복사
왜 더 나을까요? - 캐싱, 재시도, 에러 처리가 자동으로 돼요 - 로딩 상태는 Suspense 경계에서 처리해요 (Parse Don’t Validate) - queryOptions로 정의하면 여러 곳에서 재사용할 수 있어요
React 공식 문서의 권고
React 공식 문서에서도 useEffect로 직접 데이터 페칭하는 것의 단점을 명시해요: - SSR 불가: Effects는 서버에서 실행 안 됨. 초기 HTML에 데이터 없음 - Network Waterfall: 부모 fetch → 자식 렌더 → 자식 fetch. 순차 실행으로 느림 - 캐싱/프리로딩 없음: 언마운트 후 재마운트하면 다시 fetch - Race Condition: ignore 플래그 등 보일러플레이트 필요
권장 대안: TanStack Query, useSWR, React Router 6.4+
정리하면: - 커스텀 훅 = 분리하려는 코드 내부에 hook이 있을 때만 불가피하게 사용 - hook이 없으면 = 일반 함수로 분리 - useCallback 함수 = 상태/props 의존 없으면 밖으로 빼기

코드 구조: 1장 개념의 적용

1장에서 배운 추상의 구조가 코드에서 어떻게 나타나는지 볼게요.

레이어

1장에서 레이어(수직 관계)를 다뤘어요. 코드에서는 어떻게 나타날까요?
레스토랑 예시의 코드 버전:
// 상위 레이어: 손님 입장 (What만 보임) function receiveOrder(request: OrderRequest) { return prepareOrder(request.items) } // 중간 레이어: 주방장 입장 (위에서는 How, 아래에서는 What) function prepareOrder(items: MenuItem[]) { return items.map(item => cook(item)) } // 하위 레이어: 요리사 입장 (구체적인 How) function cook(item: MenuItem) { const recipe = getRecipe(item.name) return executeRecipe(recipe) }
TypeScript
복사
위에서 보면 receiveOrder만 있어요. “주문을 받는다.” What이에요.
한 단계 내려가면 prepareOrder가 보여요. 위에서는 How였지만, 아래에서는 What이에요.
핵심: 상위 레이어의 How가 하위 레이어의 What이에요. 이게 코드에서 레이어가 작동하는 방식이에요.

협력

1장에서 협력(수평 관계)도 다뤘어요. 서로 다른 What들이 각자의 역할을 갖고 메시지로 소통해요.
레스토랑의 수평 협력 코드 버전:
// 각자의 책임(What)을 가진 함수들 function cook(order: Order): Dish[] { return order.items.map(item => prepare(item)) } function serve(tableId: number, dishes: Dish[]): void { deliverTo(tableId, dishes) } function checkout(tableId: number): Receipt { return processPayment(tableId) } // 협력: 함수 호출을 통해 소통 function serveCustomer(tableId: number, order: Order) { const dishes = cook(order) // cook에게 메시지 serve(tableId, dishes) // serve에게 메시지 return checkout(tableId) // checkout에게 메시지 }
TypeScript
복사
각 함수는 자기 역할(What)만 알아요. 다른 함수의 How는 몰라요.
serveCustomer는 조율자예요. “요리해줘”, “서빙해줘”, “계산해줘”라는 메시지만 보내요.

축척

같은 것이 관점에 따라 What도 되고 How도 돼요.
// React 컴포넌트 관점에서 function UserProfile({ userId }: Props) { const { data } = useSuspenseQuery(userOptions(userId)) // What: "유저 정보를 가져온다" return <div>{data.name}</div> } // userOptions 정의 관점에서 const userOptions = (userId: string) => queryOptions({ // 여기서 userOptions는 How가 됨 // 새로운 What: "캐시된 쿼리 설정을 만든다" queryKey: ['user', userId], queryFn: () => getUser(userId), }) // getUser 내부 관점에서 async function getUser(userId: string) { // 또 한 단계 내려감 // 새로운 What: "HTTP 요청을 보낸다" return fetch(`/api/users/${userId}`).then(r => r.json()) }
TypeScript
복사
userOptionsUserProfile 관점에서 What(쿼리 설정)이지만, 내부 관점에서는 How(어떻게 가져올지)예요.
축척의 실용적 의미: 코드 리뷰할 때 “이게 뭘 하는 거야?”라는 질문에 어느 수준에서 대답할지를 정하는 거예요.
높은 축척: “유저 정보를 가져와”
중간 축척: “캐시를 확인하고 없으면 API 호출해”
낮은 축척: “HTTP GET 요청을 /api/users/:id로 보내”

차원 분리

1장의 곰탕 메뉴판 예시를 코드로 표현해볼게요.
Before: 차원이 분리 안 됨
// ❌ Before: What이 섞여있음 // 문제: 메뉴/사이즈/맵기가 하나의 문자열에 뒤섞여서 "어떤 차원이 있는지" 파악 어려움 type MenuItem = | 'KkoriGomtang_S_Mild' | 'KkoriGomtang_S_Medium' | 'KkoriGomtang_S_Spicy' | 'KkoriGomtang_M_Mild' // ... 27개 조합
TypeScript
복사
After: 차원별로 분리
// ✅ After: What이 드러남 // 핵심: 줄 수가 늘었지만, "메뉴/사이즈/맵기"라는 세 가지 독립적 차원이 명확히 보임 type MenuType = 'KkoriGomtang' | 'DoganiTang' | 'ModemSuyuk' type Size = 'S' | 'M' | 'L' type Spiciness = 'Mild' | 'Medium' | 'Spicy' interface Order { menu: MenuType size: Size spiciness: Spiciness }
TypeScript
복사
What(메뉴, 사이즈, 맵기)이 분리되니까: - 새 옵션 추가가 쉬움 - 조합이 자동으로 늘어남 - 타입 시스템이 잘못된 조합을 잡아줌

정리

코드 추상화는 코드에서 What과 How를 분리하는 것이에요.

방법

1.
일단 짠다 — 화면 보면서, 복잡해지는 게 신호
2.
Inside-Out — 흩어진 걸 모으고(모아둘 결심), 안에서 밖으로 올라가며 What 발견
3.
Wishful Thinking — 밖에서 안으로 내려오며 인터페이스 정의
4.
왕복 — IO︎WT 반복하며 글로벌 최적 찾기
5.
일반해 — 반복되는 What에 이름 붙이기

구조

1장에서 배운 것
4장에서 적용
레이어 (수직)
상위 How = 하위 What
협력 (수평)
각자의 What, 메시지로 소통
축척 (관점)
어느 레벨에서 설명할지
차원 분리
독립적인 What들의 조합

핵심

코드 추상화 ≠ 함수 추출
코드 추상화 = What/How 분리를 코드에 적용
IO로 올라가고, WT로 내려오고, 왕복하며 구조를 찾는다

왜 이렇게 하면 비용이 줄어들까요?

방법
낮추는 비용 (0장 축)
이유
일단 짠다
처리 비용
System 1 모드로 빠르게 시작
신호 인식
시간 비용
복잡해지면 일찍 멈추고 정리
IO︎WT 왕복
관점 비용 + 처리 비용
세부︎전체 번갈아 검증
Wishful Thinking으로 “이런 게 있으면 좋겠다”고 상상한 것, 그게 바로 인터페이스예요. 다음 장에서는 What을 어떻게 표현하는지 다뤄요.