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)
하나를 고치려고 봤더니 세 개를 알아야 해요. 세 개 중 하나를 알려고 봤더니 또 다른 게 필요해요. 이게 계속되면 “간단한 수정”이 사라져요.
얽힘이 있으면:
•
하나를 바꾸려면 다른 것도 알아야 해요
•
알아야 하는 것의 개수가 늘어나요
•
변경의 파급효과가 커져요
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: 과도한 추상화
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 피로 (복잡함이 역효과) |
잘못된 경계 | 너무 좁거나/넓거나/엉뚱함 | 책임 기준으로 분리 | 지식의 저주 (경계 못 봄) |
다음 장에서는 심화 사례를 다뤄요. 언어를 초월하는 추상화, 플랫폼이 복잡도를 흡수하는 패턴을 볼게요.
