실전에 적용해보기
첫 실전 코드로 페이먼츠 미션의 `useCardRegisterForm`을 들고 갔다.
import { useState } from "react";
import { getCardBrand } from "../utils/getCardBrand";
import { getCardNumberErrorMessage } from
"../utils/getCardNumberErrorMessage";
import { getExpNumberErrorMessage } from
"../utils/getExpNumberErrorMessage";
import { getCvcNumberErrorMessage } from
"../utils/getCvcNumberErrorMessage";
import { getPasswordErrorMessage } from
"../utils/getPasswordErrorMessage";
import type { CardNumbers } from
"../components/InputField/CardNumberField";
import type { ExpNumber } from
"../components/InputField/ExpNumberField";
export default function useCardRegisterForm() {
const [cardNumbers, setCardNumbers] = useState({
first: "",
second: "",
third: "",
fourth: "",
});
const [expNumbers, setExpNumbers] = useState({ mm: "", yy: ""
});
const [cvcNumbers, setCvcNumbers] = useState("");
const [cardFirm, setCardFirm] = useState({ value: "", label: ""
});
const [passwordNumbers, setPasswordNumbers] = useState("");
const cardBrand = getCardBrand(cardNumbers);
const [isCardNumberCompleted, setIsCardNumberCompleted] =
useState(false);
const [isExpNumberCompleted, setIsExpNumberCompleted] =
useState(false);
const [isCvcNumberCompleted, setIsCvcNumberCompleted] =
useState(false);
const isAllValid =
cardFirm.value !== "" &&
getCardNumberErrorMessage(cardNumbers, cardBrand) === null &&
getExpNumberErrorMessage(expNumbers) === null &&
getCvcNumberErrorMessage(cvcNumbers) === null &&
getPasswordErrorMessage(passwordNumbers) === null;
const onCardNumberChange = (value: CardNumbers) =>
setCardNumbers(value);
const onCardFirmChange = (value: string, label: string) =>
setCardFirm({ value, label });
const onExpNumberChange = (value: ExpNumber) =>
setExpNumbers(value);
const onCvcNumberChange = (value: string) =>
setCvcNumbers(value);
const onPasswordNumberChange = (value: string) =>
setPasswordNumbers(value);
const onCardNumberComplete = (isCompleted: boolean) => {
if (isCompleted) setIsCardNumberCompleted(true);
};
const onExpNumberComplete = (isCompleted: boolean) => {
if (isCompleted) setIsExpNumberCompleted(true);
};
const onCvcNumberComplete = (isCompleted: boolean) => {
if (isCompleted) setIsCvcNumberCompleted(true);
};
return {
cardNumbers,
expNumbers,
cvcNumbers,
cardFirm,
passwordNumbers,
cardBrand,
isCardNumberCompleted,
isExpNumberCompleted,
isCvcNumberCompleted,
isAllValid,
onCardNumberChange,
onExpNumberChange,
onCvcNumberChange,
onCardFirmChange,
onPasswordNumberChange,
onCardNumberComplete,
onExpNumberComplete,
onCvcNumberComplete,
};
}
반환값이 18개였다... ㅋㅋ
카드번호, 유효기간, CVC, 카드사, 비밀번호 — 다섯 필드의 상태, 핸들러, 완료 여부를 전부 한 훅이 들고 있었다.
처음엔 "좀 많긴 하네" 정도였다.
이 훅은 2단계 페이먼츠 미션에서 만들었다. 폼 전체를 한 훅이 관리하는 구조였고, 당시엔 그게 편하다고 생각했다.
3단계 PR에서 리뷰어가 이미 이 구조를 짚었다.
"추출이지 추상화가 아니다. 폼 관리와 API 호출이 한 곳에 뭉쳐 있다." 그 말이 정확히 어디서 틀렸는지는 몰랐다.
그냥 받아들이고 분리했다.
FF 코치와의 리펙토링은 그 이유를 파고드는 자리였다.
FF코치와의 세션
코치가 물었다. "이 코드 어때 보여?"
한참 생각했다. 결합도 문제인 것 같다고 했다. 수정하면 영향 범위 예측이 안 된다고.
근데 코치가 다시 물었다. "처음 보는 사람이 이 훅 보면 뭘 파악해야 해?"
그때 바뀌었다.
결합도 이전에 가독성이 먼저였다. 18개 반환값을 한눈에 파악하는 사람은 없다. 영향 범위 예측보다 "처음 보는 사람이 이해 못한다"는 게 더 직접적인 문제였다.
그리고 이전에 받았던 피드백 하나가 연결됐다.
전에 필드별로 훅을 분리했다가 "쓸데없는 비즈니스 로직 분리"라는 피드백을 받은 적이 있었다.
왜 그랬을까 생각해보니, 그때는 로직 종류 기준으로 묶었던 거였다. useRef랑 이벤트 핸들러를 같이 묶는 식으로.
기능 단위로 묶는 것과 다르다. 카드번호 필드 하나의 상태 흐름 전체를 묶는 게 맞는 분리다.
1일차 설계 결론은 이렇게 나왔다.
useCardNumberField → { cardNumbers, onCardNumberChange, isCardNumberCompleted,
onCardNumberComplete, cardBrand, isCardNumberFieldValid }
useExpNumberField → { expNumbers, onExpNumberChange, isExpNumberCompleted,
onExpNumberComplete, isExpFieldValid }
useCvcNumberField → { cvcNumbers, onCvcNumberChange, isCvcNumberCompleted, onCvcNumberComplete, isCvcFieldValid }
useCardFirmField → { cardFirm, onCardFirmChange }
usePasswordNumberField → { passwordNumbers, onPasswordNumberChange, isPasswordFieldValid }
`useCardRegisterForm`을 없애고, 페이지에서 각 훅을 직접 호출한다.
`isAllValid`는 페이지에서 각 boolean을 AND연산자로 합친다.
각 훅이 isValid를 자기 validator로 직접 계산해서 반환하면, 복잡한 조건에 이름 붙이기가 자연스럽게 적용된다는 것도 그때 짚었다.
설계를 실제로 구현하면서 더 나왔다.
`cardBrand`가 `useCardRegisterForm`에 있었는데, 이건 `cardNumbers`에서 파생되는 값이다.
카드번호가 바뀌면 브랜드도 같이 바뀐다. 수정할 때 반드시 같이 수정해야 하는 관계인데 다른 곳에 있었다. 응집도 문제다. `useCardNumberField` 안으로 옮겼다.
각 필드의 유효성 검사 로직도 `useCardRegisterForm`에 몰려 있었다.
카드번호 유효성 검사가 카드번호 훅 밖에 있다는 게 말이 안 됐다. 각 필드 훅으로 분산시켰다.
18개 반환값을 만든 근본 원인이 여기 있었다. 관련 없는 로직이 한 곳에 다 모여 있었던 거다.
구현이 끝난 뒤 세션을 한 번 더 가졌다.
`onCardNumberComplete(isCompleted: boolean)`라는 콜백이 있었는데, 실제로 `isCompleted=false`로 호출되는 경우가 없었다. 완료됐을 때만 호출하면 파라미터 자체가 필요 없는 거였다. 시그니처에 숨은 조건이 있었다. 예측 가능성 문제다.
`isCardNumberCompleted state`도 봤다. 이게 결국 `getCardNumberErrorMessage() === null`과 동일한 값이었다. `isCardNumberValid`랑 같다. 중복 상태였다.
`getCardNumberErrorMessage`가 훅과 필드 컴포넌트 두 곳에서 호출되는 것도 짚었다.
step2에서 이렇게 했던 이유는 UX적으로 이전 입력값을 유저가 수정할 시에 이미 등장했던 컴포넌트가 사라지는 문제가 발생한다.
취향 차이이지만 나는 이 UX가 어색하다고 판단했고, 한 번 등장한 컴포넌트는 그대로 둬야한다고 판단했기에 그걸 따로 관리하는 상태를 만들었던 것이었다. 하지만 페이먼츠 미션이 끝난 시점이었고, 코드적으로 더 깔끔하게 리펙토링해보고 싶어서 해당 세션을 진행했다.
FF에서 이 기준이 결합도에 있다. 우연한 중복은 허용, 반드시 같이 바뀌어야 하는 건 공통화. 그 판단을 코드 보면서 직접 했다.
`cardFirm.value`로 조건 분기하던 것도 `isCardFirmValid`로 통일했다.
페이지 전체에서 isXxxValid 패턴이 일관되게 맞춰지니까 읽을 때 맥락이 줄었다.
매번 .value가 뭔지, 비어있으면 유효하지 않은 건지 따라가지 않아도 됐다.
분리한 필드 흐름별 훅 예시
나누었던 훅중에 `useCardNumberField.ts` 만 보여주자면 이렇게 생겼다.
import { useState } from "react";
import type { CardNumbers } from "../components/InputField/CardNumberField";
import { getCardBrand } from "../utils/getCardBrand";
import { getCardNumberErrorMessage } from "../utils/getCardNumberErrorMessage";
export default function useCardNumberField() {
const [cardNumbers, setCardNumbers] = useState({
first: "",
second: "",
third: "",
fourth: "",
});
const cardBrand = getCardBrand(cardNumbers);
const onCardNumberChange = (value: CardNumbers) => setCardNumbers(value);
const isCardNumberValid =
getCardNumberErrorMessage(cardNumbers, cardBrand) === null;
return {
cardNumbers,
cardBrand,
onCardNumberChange,
isCardNumberValid,
};
}
마무리
훅 하나를 분해하면서 FF 네 가지 기준이 한 코드 안에 다 엮여 있다는 걸 봤다.
가독성, 응집도, 결합도가 따로 노는 개념이 아니었다. 하나 고치면 다른 게 보이고, 그걸 고치면 또 다른 게 보였다.
책이나 강의로 학습했으면 그냥 개념으로 남았을 것들이다.
세션이 끝나고 3단계 PR 리뷰어가 남긴 말이 다시 읽혔다.
"추출이지 추상화가 아니다."
그때는 그냥 넘겼는데, 이제는 왜 그 말을 했는지 알 것 같았다. 코드를 옮기는 건 누구나 할 수 있다.
왜 분리해야 하는지 이유를 알고 나서야 추상화가 된다.
계속 연습하고 고민하면서 추상화에 대한 나만의 기준을 대략적이게나마 잡는 것이 이번 우테코 레벨 2의 목표이다.
'프론트엔드' 카테고리의 다른 글
| 내 손으로 만든 Toss Frontend Fundamentals 코치 (2) | 2026.05.25 |
|---|---|
| XHR부터 Ky까지 HTTP 클라이언트 라이브러리 파헤치기 (2) | 2026.04.24 |
| [Web Storage] localStorage 파헤치기 (1) | 2026.04.14 |
| [Web API] Intersection Observer API (0) | 2026.04.11 |
| Network 탭으로 읽는 HTTP (0) | 2026.03.30 |