Toss Frontend Fundamentals 실전 적용 - 반환값 18개짜리 훅 해체하기

2026. 5. 25. 21:20·프론트엔드

실전에 적용해보기

첫 실전 코드로 페이먼츠 미션의 `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
'프론트엔드' 카테고리의 다른 글
  • 내 손으로 만든 Toss Frontend Fundamentals 코치
  • XHR부터 Ky까지 HTTP 클라이언트 라이브러리 파헤치기
  • [Web Storage] localStorage 파헤치기
  • [Web API] Intersection Observer API
yun_cic
yun_cic
  • yun_cic
    체대생의 개발 기록
    yun_cic
  • 전체
    오늘
    어제
    • 분류 전체보기 (43) N
      • 백엔드 (1)
      • 프로젝트 (5)
      • etc (6)
      • 대외활동 (1)
      • 강의자료 (5)
      • 프론트엔드 (13)
        • Language (4)
        • Library (2)
      • 우테코 (12) N
        • 회고 (6) N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • GitHub
    • 포트폴리오 페이지
  • 공지사항

  • 인기 글

  • 태그

    해커톤
    KUCC
    개발자 #코딩 #체대생
    채널톡
    백엔드
    Crawling
    fastapi
    우테코 8기
    크몽
    MySQL
    우아한테크코스 8기
    Selenium
    크롤링
    메모
    todo
    외주
    Python
    fe
    bs4
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
yun_cic
Toss Frontend Fundamentals 실전 적용 - 반환값 18개짜리 훅 해체하기
상단으로

티스토리툴바