Search

6장_사례와_함정

6장. 사례와 함정

이 장은 실무 적용편이에요. 1-5장에서 배운 원리가 실제 코드에서 어떻게 작동하는지, 그리고 어디서 실패하는지 볼게요.
이 장에서 다루는 것:
실무 사례 4가지: Form, List, State, API — What/How 분리의 Before/After
함정 6가지: 추상화할 때 흔히 빠지는 실수들 (왜 빠지는지도 함께)

실무에서 자주 만나는 사례

프론트엔드 개발에서 추상화가 가장 필요한 곳들이에요.

사례 1: Form

폼은 추상화가 가장 필요한 곳 중 하나예요.
Before: What/How가 뒤섞임
// ❌ Before: What/How가 뒤섞임 // 문제: "폼 상태 관리"라는 의도가 useState 3개, validate 함수, handleSubmit 사이에 흩어져 있음 function SignupForm() { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [errors, setErrors] = useState({}); const validate = () => { const newErrors = {}; if (!name) newErrors.name = "이름을 입력하세요"; if (!email) newErrors.email = "이메일을 입력하세요"; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = () => { if (validate()) { /* 제출 로직 */ } }; }
TypeScript
복사
모든 폼마다 비슷한 코드가 반복돼요.
After: What만 드러남
// ✅ After: What만 드러남 // 핵심: 줄 수가 아니라 "폼 관리"라는 의도가 useForm 한 곳에 모여서 좋음 const { values, errors, handleChange, handleSubmit } = useForm({ initialValues: { name: "", email: "" }, validate: (values) => ({ name: values.name ? undefined : "이름을 입력하세요", email: values.email ? undefined : "이메일을 입력하세요", }), onSubmit: (values) => { /* 제출 로직 */ }, });
TypeScript
복사
What: “폼 상태를 관리하고 검증한다” How: useForm 안에 숨어있어요.

사례 2: List

목록 렌더링도 반복되는 패턴이에요.
Before
// ❌ Before: What이 숨어있음 // 문제: "비동기 데이터 로딩"이라는 의도가 4개 useState와 useEffect 안에 분산됨 function UserList() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [page, setPage] = useState(1); useEffect(() => { setLoading(true); fetchUsers(page) .then(setUsers) .catch(setError) .finally(() => setLoading(false)); }, [page]); if (loading) return <Spinner />; if (error) return <Error />; return users.map((user) => <UserCard user={user} />); }
TypeScript
복사
After
// ✅ After: What이 드러남 // 핵심: useSuspenseQuery가 "비동기 데이터를 불러온다"는 의도를 명확히 표현. // 로딩은 Suspense 경계에서, 에러는 ErrorBoundary에서 처리 (Parse Don't Validate) const userListOptions = (page: number) => queryOptions({ queryKey: ["users", page], queryFn: () => fetchUsers(page), }); function UserList({ page }: { page: number }) { const { data: users } = useSuspenseQuery(userListOptions(page)); return users.map((user) => <UserCard key={user.id} user={user} />); } // 사용처 <ErrorBoundary fallback={<Error />}> <Suspense fallback={<Spinner />}> <UserList page={1} /> </Suspense> </ErrorBoundary>;
TypeScript
복사
What: “비동기 데이터를 불러와서 보여준다” How: useSuspenseQuery 안에 숨어있고, 로딩/에러는 경계에서 선언적으로 처리해요.

사례 3: State

상태 관리에서 What/How가 섞이면 복잡해져요.
Before
// ❌ Before: What이 숨어있음 // 문제: "장바구니 관리"라는 의도가 addItem, removeItem, total 계산 로직 사이에 흩어져 있음 function Cart() { const [items, setItems] = useState([]); const addItem = (item) => { const existing = items.find((i) => i.id === item.id); if (existing) { setItems( items.map((i) => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i ) ); } else { setItems([...items, { ...item, quantity: 1 }]); } }; const removeItem = (id) => { setItems(items.filter((i) => i.id !== id)); }; const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0); }
TypeScript
복사
After
// ✅ After: What이 드러남 // 핵심: useCart라는 이름이 "장바구니 관리"라는 의도를 바로 말해줌. 내부 복잡성은 숨음 const { items, addItem, removeItem, total } = useCart(); // How는 커스텀 훅 안에 function useCart() { const [items, setItems] = useState([]); const addItem = (item) => { /* 복잡한 로직 */ }; const removeItem = (id) => { /* 로직 */ }; const total = useMemo(() => /* 계산 */, [items]); return { items, addItem, removeItem, total }; }
TypeScript
복사
What: “장바구니를 관리한다”

사례 4: API 호출

API 호출 패턴도 추상화 대상이에요.
Before
// ❌ Before: What이 숨어있음 // 문제: "API 호출"이라는 의도가 method, headers, body, 에러 처리 등 세부사항에 묻혀 있음 const response = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!response.ok) throw new Error("Failed"); return response.json();
TypeScript
복사
After
// ✅ After: What이 드러남 // 핵심: api.post가 "POST 요청을 보낸다"는 의도를 바로 말해줌. HTTP 세부사항은 내부에 숨음 const user = await api.post("/users", data); // How는 api 클라이언트 안에 const api = { async post(url, data) { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!response.ok) throw new Error("Failed"); return response.json(); }, };
TypeScript
복사
What: “API를 호출한다”

피해야 할 함정

총 6가지 함정을 다뤄요. 각 함정이 왜 발생하는지(비용 프레임)도 함께 설명해요.

함정 1: 표면대로 옮기면 망한다

왜 표면만 옮기게 될까요? 시간 할인 때문이에요. 요구사항 문장을 그대로 코드로 옮기면 빨리 끝나요. 본질을 찾으려면 한 발짝 물러서서 “이 조건들의 공통점이 뭐지?”를 생각해야 하는데, 그건 지금 당장의 비용이에요. “나중에 정리하지 뭐”라고 생각하지만, 조건이 늘어날수록 정리 비용은 기하급수적으로 커져요.
Before:
“필드가 개인정보 텍스트면 마스킹하세요.” “필드가 개인정보 첨부파일이면 마스킹하세요.”
// 원본: 마스킹 로직이 필요한 상황 function renderField(field: Field) { // 개인정보면 마스킹해야 함 return <FieldView field={field} />; }
TypeScript
복사
Bad: 잘못된 추상화 시도
// ❌ Bad: 표면만 옮김 // 문제: 요구사항 문장을 그대로 코드로 번역. 조건의 본질(개인정보 여부)을 놓침 if (hasAttachment && isPrivacyText) { mask(); } if (hasAttachment && isPrivacyAttachment) { mask(); } if (!hasAttachment && isPrivacyText) { mask(); } if (!hasAttachment && isPrivacyAttachment) { mask(); } // 조건문 4개. 변경할 때 모든 조합을 다시 찾아야 함
TypeScript
복사
Good: 잘된 추상화
// ✅ Good: 본질을 옮김 // 핵심: "개인정보면 마스킹"이라는 본질이 함수명에 드러남. 조건이 바뀌어도 한 곳만 수정 if (isMaskingRequired(field)) { mask(); } function isMaskingRequired(field: Field): boolean { return field.isPrivacy; }
TypeScript
복사
본질: 마스킹 조건의 핵심은 “개인정보 여부” 하나예요. 요구사항이 바뀌면 한 곳만 고치면 돼요.

함정 2: 얽힘 (Entanglement)

왜 얽힘이 생길까요? 분리하는 것 자체가 비용이기 때문이에요. “일단 돌아가게” 짜면 관심사가 자연스럽게 섞여요. 나중에 분리하려면 “이건 데이터, 이건 UI, 이건 검증…”을 의식적으로 구분해야 해요. 바쁘거나 피곤하면 뇌가 그 비용을 안 써요.
하나를 고치려고 봤더니 세 개를 알아야 해요. 세 개 중 하나를 알려고 봤더니 또 다른 게 필요해요. 이게 계속되면 “간단한 수정”이 사라져요.
얽힘이 있으면:
하나를 바꾸려면 다른 것도 알아야 해요
알아야 하는 것의 개수가 늘어나요
변경의 파급효과가 커져요
Before:
“유저 프로필 페이지를 만들어주세요. 수정 기능도 있어야 해요.”
// 원본 코드: 데이터 fetch, UI 상태, 검증이 한 곳에 섞여있음 function UserProfile() { const [user, setUser] = useState(null); const [isEditing, setIsEditing] = useState(false); useEffect(() => { fetch(`/api/users/${id}`) .then((r) => r.json()) .then(setUser); }, [id]); const handleSave = async () => { const isValid = user.name.length > 0 && user.email.includes("@"); if (!isValid) return alert("입력을 확인하세요"); await fetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(user), }); setIsEditing(false); }; }
TypeScript
복사
Bad: 잘못된 추상화 시도
// ❌ Bad: 얽힘을 그대로 둔 채 훅으로만 빼냄 // 문제: 데이터/UI상태/검증이 여전히 하나에 섞임. 훅으로 뺐지만 얽힘은 그대로 function useUserProfile(id) { const [user, setUser] = useState(null); const [isEditing, setIsEditing] = useState(false); // 여전히 모든 관심사가 하나의 훅에 섞여있음 // 데이터 + UI상태 + 검증이 분리되지 않음 return { user, isEditing, handleSave }; }
TypeScript
복사
Good: 잘된 추상화
// ✅ Good: 관심사별로 분리 // 핵심: 데이터/UI상태/검증이 각각 독립적인 훅. 하나만 바꿔도 나머지에 영향 없음 function UserProfile() { const { user, updateUser } = useUser(id); // 데이터 레이어 const { isEditing, startEdit, endEdit } = useEditMode(); // UI 상태 const { validate } = useUserValidation(); // 검증 로직 const handleSave = async () => { if (!validate(user)) return; await updateUser(user); endEdit(); }; }
TypeScript
복사
본질: 관심사(데이터, UI상태, 검증)는 각각 독립적인 What이에요. 하나로 묶으면 변경 시 전체가 흔들려요. 얽힘을 풀면 → 한 번에 알아야 하는 코드의 양이 줄어들어요.

함정 3: 흩어짐 (Scattering)

왜 흩어지게 될까요? 관점 전환 비용 때문이에요. 코드를 작성할 때는 “지금 이 파일”에 집중하고 있어요. “이 기능이 다른 데서도 쓰이나?”를 확인하려면 전체를 봐야 해요. 그 비용을 안 쓰면 같은 로직이 여기저기 흩어져요.
“토큰 관련 코드 어디 있지?” 파일 4개를 왔다갔다하면서 찾아본 적 있나요? 그 왔다갔다가 비용이에요.
aabb = 높은 응집 (관련된 것이 함께) abab = 낮은 응집 (관련된 것이 흩어짐)
Plain Text
복사
함께 바뀌는 것은 함께 두세요. (Colocation)
Before:
“로그인 기능을 추가해주세요. 토큰 기반 인증이에요.”
// 원본: 토큰 관련 코드가 여러 파일에 흩어져 있음 // Header.tsx if (localStorage.getItem("token")) { showLogout(); } // api.ts headers: { Authorization: `Bearer ${localStorage.getItem("token")}`; } // LoginPage.tsx localStorage.setItem("token", response.token); // ProfilePage.tsx if (!localStorage.getItem("token")) redirect("/login");
TypeScript
복사
Bad: 잘못된 추상화 시도
// ❌ Bad: 상수만 뺌 // 문제: 토큰 키만 공유하고 로직은 여전히 흩어짐. 토큰 저장 방식 바꾸면 4군데 수정 필요 const TOKEN_KEY = "token"; // Header.tsx - 여전히 직접 접근 if (localStorage.getItem(TOKEN_KEY)) { ... } // api.ts - 여전히 직접 접근 headers: { Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}` }
TypeScript
복사
Good: 잘된 추상화
// ✅ Good: 한 곳에 모음 // 핵심: "인증"이라는 하나의 What이 auth 객체 한 곳에. 저장 방식 바꿔도 여기만 수정 // auth.ts - 한 곳에 모음 const auth = { getToken: () => localStorage.getItem("token"), setToken: (token) => localStorage.setItem("token", token), isLoggedIn: () => !!auth.getToken(), logout: () => localStorage.removeItem("token"), }; // 쓰는 곳에서는 auth만 참조 if (auth.isLoggedIn()) { ... }
TypeScript
복사
본질: “토큰 기반 인증”은 하나의 What이에요. 하나의 What은 한 곳에서 관리되어야 해요. 한 곳에 모으면 → 파일 간 시점 이동이 줄어들어요.

함정 4: 이른 추상화

왜 이르게 추상화하게 될까요? 시간 할인의 또 다른 형태예요. “나중에 또 비슷한 게 나올 거야”라고 예상하고 미리 범용화해요. 하지만 미래의 패턴은 불확실해요. 뇌는 불확실한 미래 이익보다 지금 당장의 “범용적으로 만들었다”는 성취감을 더 크게 느껴요.
Before:
“회원가입 폼을 만들어주세요.”
// 원본: 첫 번째 폼을 만드는 상황 function SignupForm() { const [name, setName] = useState(""); const [email, setEmail] = useState(""); // ... 폼 로직 }
TypeScript
복사
Bad: 잘못된 추상화 시도
// ❌ Bad: 이른 추상화 // 문제: 패턴이 안 보이는데 범용 추상화. 실제 필요 없는 복잡성만 추가됨 const UniversalForm = ({ config }) => { // 100줄의 복잡한 설정 기반 렌더링 // 아직 패턴이 안 보이는데 미리 추상화 }; <UniversalForm config={signupFormConfig} />;
TypeScript
복사
Good: 잘된 추상화
// ✅ Good: 세 번째에 추상화 // 핵심: 패턴이 세 번 보인 후에야 추상화. 실제로 반복되는 것만 추출해서 단순함 // 1차: SignupForm - 그냥 작성 // 2차: LoginForm - 비슷하네, 메모 // 3차: ProfileForm - 패턴 보인다! 이제 추상화 const useForm = (config) => { // 세 번 반복된 패턴만 추출 };
TypeScript
복사
본질: 추상화할 패턴이 아직 없어요. 패턴은 반복에서 드러나요. “세 번째 보이면” 추상화해요.

함정 5: 과도한 추상화

왜 과도하게 추상화하게 될까요? 추상화는 원래 복잡함을 줄여서 인지 비용을 낮추는 거예요. 그런데 타입 파라미터 4개, 설정 5개를 쓰는 추상화는 오히려 비용을 높여요. 추상화의 목적을 잊고 “더 일반적으로”만 생각하면 이렇게 돼요.
Before:
“유저 데이터를 가져오는 훅을 만들어주세요.”
// 원본: 단순한 데이터 fetch 필요 function useUser(id) { const [user, setUser] = useState(null); useEffect(() => { fetch(`/api/users/${id}`) .then((r) => r.json()) .then(setUser); }, [id]); return user; }
TypeScript
복사
Bad: 잘못된 추상화 시도
// ❌ Bad: 과도한 추상화 // 문제: 단순한 fetch에 타입 4개, 설정 5개. 쓰는 사람이 오히려 더 힘들어짐 interface DataLayerConfig<T, P, R, E> { fetcher: Fetcher<T, P>; transformer: Transformer<T, R>; errorHandler: ErrorHandler<E>; cacheStrategy: CacheStrategy; retryPolicy: RetryPolicy; } const useData = <T, P, R, E>(config: DataLayerConfig<T, P, R, E>) => { ... }; // 타입 파라미터 4개, 설정 5개. 쓰는 사람이 더 힘들어요.
TypeScript
복사
Good: 잘된 추상화
// ✅ Good: 단순함 유지 // 핵심: "데이터를 가져온다"는 의도가 한 줄에 명확히 드러남. 필요한 만큼만 추상화 const userOptions = (id: string) => queryOptions({ queryKey: ["user", id], queryFn: () => fetchUser(id), }); const { data } = useSuspenseQuery(userOptions(userId));
TypeScript
복사
본질: “데이터를 가져온다”가 What이에요. 추상화는 복잡함을 줄일 때만 해요. 더 복잡해지면 안 해요.

함정 6: 잘못된 경계

왜 경계를 잘못 잡게 될까요? 지식의 저주 때문이에요. 코드를 작성하는 나에게는 “저장하고 이메일 보내기”가 하나의 흐름으로 보여요. 하지만 나중에 이 코드를 쓰는 사람에게는? “저장”과 “이메일”이 다른 시점에 필요할 수 있어요. 내 관점에서는 하나인 것이 다른 관점에서는 둘이에요.
Before:
“회원가입하면 저장하고 환영 이메일 보내주세요.”
// 원본: 회원가입 후 처리 로직 async function handleSignup(user: User) { validateUser(user); await saveToDatabase(user); await sendEmail(user.email, "환영합니다!"); }
TypeScript
복사
Bad: 잘못된 추상화 시도
// ❌ Bad: 잘못된 경계 // 문제: "저장"과 "이메일"은 다른 What인데 하나로 묶음. 이메일만 바꾸려면 전체 수정 필요 function processUserAndSendEmail(user: User) { validateUser(user); saveUser(user); sendWelcomeEmail(user); } // 이메일 로직만 바꾸고 싶은데 전체를 건드려야 함
TypeScript
복사
Good: 잘된 추상화
// ✅ Good: What별로 분리 // 핵심: "저장"과 "이메일"이 독립적인 함수. 이메일만 바꾸거나, 순서 바꾸거나, 빼거나 자유로움 function saveUser(user: User) { ... } function sendWelcomeEmail(user: User) { ... } // 호출하는 쪽에서 조합 await saveUser(user); await sendWelcomeEmail(user);
TypeScript
복사
본질: “저장”과 “이메일 발송”은 다른 What이에요. 다른 What은 다른 함수로 분리해야 조합이 자유로워요.
다른 변형들:
너무 좁음: addOne, addTwo 대신 add(a, b)
너무 넓음: processData(action) 대신 filterData, sortData 분리

자기 점검 체크리스트

코드를 작성하거나 리뷰할 때 체크해보세요.

What/How 분리

이 함수/컴포넌트의 What을 한 문장으로 말할 수 있는가?
이름이 What을 잘 드러내는가?
How가 적절히 숨겨져 있는가?

함정 회피

표면이 아닌 본질을 옮겼는가?
얽힘: 관련 없는 것들이 섞여있지 않은가?
흩어짐: 하나의 기능이 여러 곳에 흩어져있지 않은가?
이른 추상화: 패턴이 세 번 보이기 전에 추상화하지 않았나?
과도한 추상화: 추상화가 복잡함을 더하진 않는가?

정리

사례
What
Form
폼 상태를 관리하고 검증한다
List
비동기 데이터를 불러와서 보여준다
State
상태를 관리한다 (장바구니, 인증 등)
API
API를 호출한다
함정
증상
해결
왜 빠지나 (비용)
표면대로 옮김
조건 폭발
본질(What) 찾기
시간 할인 (빨리 끝내려고)
얽힘
관련 없는 것이 섞임
What별로 분리
분리 비용 회피
흩어짐
한 기능이 여러 곳에
한 곳으로 모음
관점 전환 비용 (영향 범위 못 봄)
이른 추상화
첫 번째에서 범용화
세 번째 보이면
시간 할인 (빨리 범용화)
과도한 추상화
타입 파라미터 3개+
단순하게
System 2 피로 (복잡함이 역효과)
잘못된 경계
너무 좁거나/넓거나/엉뚱함
책임 기준으로 분리
지식의 저주 (경계 못 봄)
다음 장에서는 심화 사례를 다뤄요. 언어를 초월하는 추상화, 플랫폼이 복잡도를 흡수하는 패턴을 볼게요.