5장. 사례와 함정
4장에서 코드 추상화의 원리를 다뤘어요. 이번 장에서는 실무에서 자주 만나는 사례와 피해야 할 함정을 볼게요.
실무에서 자주 만나는 사례
프론트엔드 개발에서 추상화가 가장 필요한 곳들이에요.
사례 1: Form
폼은 추상화가 가장 필요한 곳 중 하나예요.
Before: What/How가 뒤섞임
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만 드러남
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
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
const { data: users, isLoading, error } = useQuery(
["users", page],
() => fetchUsers(page)
);
if (isLoading) return <Spinner />;
if (error) return <Error />;
return users.map((user) => <UserCard user={user} />);
TypeScript
복사
What: “비동기 데이터를 불러와서 보여준다”
How: useQuery 안에 숨어있어요.
사례 3: State
상태 관리에서 What/How가 섞이면 복잡해져요.
Before
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
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
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
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를 호출한다”
피해야 할 함정
함정 1: 표면대로 옮기면 망한다
Before:
> “필드가 개인정보 텍스트면 마스킹하세요.”
> “필드가 개인정보 첨부파일이면 마스킹하세요.”
// 원본: 마스킹 로직이 필요한 상황
function renderField(field: Field) {
// 개인정보면 마스킹해야 함
return <FieldView field={field} />;
}
TypeScript
복사
Bad: 잘못된 추상화 시도
// 요구사항을 표면 그대로 옮김
if (hasAttachment && isPrivacyText) { mask(); }
if (hasAttachment && isPrivacyAttachment) { mask(); }
if (!hasAttachment && isPrivacyText) { mask(); }
if (!hasAttachment && isPrivacyAttachment) { mask(); }
// 조건문 4개. 변경할 때 모든 조합을 다시 찾아야 함
TypeScript
복사
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: 잘못된 추상화 시도
// 얽힘을 그대로 둔 채 훅으로만 빼냄
function useUserProfile(id) {
const [user, setUser] = useState(null);
const [isEditing, setIsEditing] = useState(false);
// 여전히 모든 관심사가 하나의 훅에 섞여있음
// 데이터 + UI상태 + 검증이 분리되지 않음
return { user, isEditing, handleSave };
}
TypeScript
복사
Good: 잘된 추상화
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)
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: 잘못된 추상화 시도
// 토큰 키를 상수로만 뺌 - 여전히 흩어져 있음
const TOKEN_KEY = "token";
// Header.tsx - 여전히 직접 접근
if (localStorage.getItem(TOKEN_KEY)) { ... }
// api.ts - 여전히 직접 접근
headers: { Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}` }
TypeScript
복사
Good: 잘된 추상화
// 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: 잘못된 추상화 시도
// 첫 번째 폼인데 바로 범용 추상화
const UniversalForm = ({ config }) => {
// 100줄의 복잡한 설정 기반 렌더링
// 아직 패턴이 안 보이는데 미리 추상화
};
<UniversalForm config={signupFormConfig} />
TypeScript
복사
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: 잘못된 추상화 시도
// 모든 경우를 다 커버하려는 과도한 추상화
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: 잘된 추상화
const { data } = useQuery(["user"], fetchUser);
TypeScript
복사
본질: “데이터를 가져온다”가 What이에요. 추상화는 복잡함을 줄일 때만 해요. 더 복잡해지면 안 해요.
함정 6: 잘못된 경계
Before:
> “회원가입하면 저장하고 환영 이메일 보내주세요.”
// 원본: 회원가입 후 처리 로직
async function handleSignup(user: User) {
validateUser(user);
await saveToDatabase(user);
await sendEmail(user.email, "환영합니다!");
}
TypeScript
복사
Bad: 잘못된 추상화 시도
// 관련 없는 것(저장, 이메일)을 하나로 묶음
function processUserAndSendEmail(user: User) {
validateUser(user);
saveUser(user);
sendWelcomeEmail(user);
}
// 이메일 로직만 바꾸고 싶은데 전체를 건드려야 함
TypeScript
복사
Good: 잘된 추상화
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개+ | 단순하게 |
잘못된 경계 | 너무 좁거나/넓거나/엉뚱함 | 책임 기준으로 분리 |
다음 장에서는 심화 사례를 다뤄요. 언어를 초월하는 추상화, 플랫폼이 복잡도를 흡수하는 패턴을 볼게요.
