본문 바로가기
개발 공부/Self Study

[MTFFM] 4일차: 로직 종류에 따라 합쳐진 함수 쪼개기

by jetproc 2026. 5. 23.
728x90
우리가 코딩을 하다 보면 흔히 하는 실수가 있다.
 
바로 usePageState나 usePayment 같은 이름으로 하나의 거대한 커스텀 훅을 만들고, 그 안에 쿼리 파라미터 파싱, 로컬 상태 관리, API 호출, 이벤트 핸들러까지 기술적으로 비슷해 보인다는 이유로 다 몰아넣는 것이다.
 
이렇게 기술적 종류나 페이지 단위로 훅을 크게 뭉쳐두면 어떤 문제가 생길까?
  1. 책임의 비대화 (가독성 저하): 새 기능이 추가될 때마다 "어? 페이지 상태니까 여기 넣으면 되겠네?" 하고 그 거대한 훅에 코드가 계속 붙어서 이름도 역할도 모호한 훅이 탄생한다.
  2. 리액트 렌더링 최적화 붕괴: 그 훅을 바라보는 컴포넌트들은, 훅 내부의 전혀 상관없는 다른 상태 변수가 바뀔 때도 쓸데없이 리렌더링을 겪게 된다. 렌더링 파이프라인에 굳이 안 겪어도 될 부하가 걸리는 것이다.

그래서 Toss Frontend Fundamentals에는 다음과 같이 이야기하고 있다.

기술적 종류가 아니라 실제 변경 단위와 사용 단위(도메인 맥락)에 맞게 훅을 쪼개야 한다.
페이지 전체의 쿼리를 하나로 묶지 말고, '검색어 쿼리 훅', '필터 쿼리 훅'처럼 기능 단위로 전용 훅을 두면 이름도 명확해지고 불필요한 리렌더링 범위도 최소한으로 줄어든다.
 
오늘의 리팩토링 실습은 30분이 채 걸리지 않을 정도로 난이도가 쉬웠지만 중요한 개념이었다.

# Before Code: 기술적 종류로 뭉쳐진 거대한 훅 

오늘 리팩토링의 핵심 주제는 "로직 종류에 따라 합쳐진 함수 쪼개기"였다.
 
제시된 레거시 코드는 주문서 페이지의 배송지, 결제 수단, 약관 동의라는 3개의 전혀 다른 도메인 상태를 `usePaymentPageState`라는 하나의 훅과 객체로 묶어 관리하고 있었다.
//🎯 리팩토링 미션
// 거대한 usePaymentPageState를 기능적 관심사(배송지, 결제수단, 약관동의)에 따라 명확하게 쪼개보세요.
// 하나의 커다란 state 객체로 묶여 있어서 생기는 불필요한 객체 복사 연산과 인지 부하를 줄여보세요.
// 쪼갠 훅들이 각각 어떤 TS 인터페이스나 반환 타입을 가질지 깔끔하게 설계해 보세요.

import { useState, useEffect } from 'react';

interface SharedPaymentState {
  // 1. 유저 주소 관련 상태
  address: string;
  zoneCode: string;
  // 2. 결제 수단 관련 상태
  selectedMethod: 'CARD' | 'TRANSFER' | 'TOSS_PAY';
  isEasyPay: boolean;
  // 3. 약관 동의 관련 상태
  agreedTermsIds: number[];
}

export const usePaymentPageState = () => {
  const [state, setState] = useState<SharedPaymentState>({
    address: '',
    zoneCode: '',
    selectedMethod: 'CARD',
    isEasyPay: false,
    agreedTermsIds: [],
  });

  // 주소 변경 핸들러
  const updateAddress = (address: string, zoneCode: string) => {
    setState((prev) => ({ ...prev, address, zoneCode }));
  };

  // 결제 수단 변경 핸들러
  const changeMethod = (method: 'CARD' | 'TRANSFER' | 'TOSS_PAY') => {
    setState((prev) => ({
      ...prev,
      selectedMethod: method,
      isEasyPay: method === 'TOSS_PAY',
    }));
  };

  // 약관 토글 핸들러
  const toggleTerm = (termId: number) => {
    setState((prev) => {
      const isAgreed = prev.agreedTermsIds.includes(termId);
      const nextTerms = isAgreed
        ? prev.agreedTermsIds.filter((id) => id !== termId)
        : [...prev.agreedTermsIds, termId];
      return { ...prev, agreedTermsIds: nextTerms };
    });
  };

  return {
    state,
    updateAddress,
    changeMethod,
    toggleTerm,
  };
};​

# After Code: 도메인 맥락에 따른 분리와 인터페이스 설계

나는 거대한 상태 객체를 실제 변경 단위와 사용 단위에 맞게 쪼개기 시작했다.

어렵지 않게 분리 후에 피드백을 받았을 때 다음과 같은 말을 들었다.

구조적 분리는 100점입니다! 하지만 이 훅을 실제로 가져다 쓰는 개발자 경험(DX) 관점에서 조금 더 다듬어 볼 부분이 있습니다.
만약 PaymentPage 컴포넌트에서 순재님이 만드신 이 세 개의 훅을 동시에 호출한다면 어떤 일이 벌어질까요?
export function PaymentPage() {
  const { state, updateAddress } = useAddress();
  const { state, changeMethod } = usePaymentMethod();
  const { state, toggleTerm } = useAgreedTerms();
  
  // ...
}

따라서 각 커스텀 훅에서 도메인에 맞는 변수 이름으로 return하는 형태로 변경하여 최종적인 코드는 다음과 같았다.

import { useState } from 'react';

// 1. 명확한 타입 분리
interface UserAddressState {
  address: string;
  zoneCode: string;
}

interface PaymentMethodState {
  selectedMethod: 'CARD' | 'TRANSFER' | 'TOSS_PAY';
  isEasyPay: boolean;
}

interface AgreedTermsState {
  agreedTermsIds: number[];
}

// 2. 도메인 단위로 격리된 커스텀 훅과 DX를 고려한 반환값
export const useAddress = () => {
  const [address, setAddress] = useState<UserAddressState>({
    address: '',
    zoneCode: '',
  });

  const updateAddress = (address: string, zoneCode: string) => {
    setAddress((prev) => ({ ...prev, address, zoneCode }));
  };

  return { address, updateAddress }; // 직관적인 키네이밍
};

export const usePaymentMethod = () => {
  const [paymentMethod, setPaymentMethod] = useState<PaymentMethodState>({
    selectedMethod: 'CARD',
    isEasyPay: false,
  });

  const changeMethod = (method: 'CARD' | 'TRANSFER' | 'TOSS_PAY') => {
    setPaymentMethod((prev) => ({
      ...prev,
      selectedMethod: method,
      isEasyPay: method === 'TOSS_PAY',
    }));
  };

  return { paymentMethod, changeMethod };
};

export const useAgreedTerms = () => {
  const [agreedTerms, setAgreedTerms] = useState<AgreedTermsState>({
    agreedTermsIds: [],
  });

  const toggleTerm = (termId: number) => {
    setAgreedTerms((prev) => {
      const isAgreed = prev.agreedTermsIds.includes(termId);
      const nextTerms = isAgreed ? prev.agreedTermsIds.filter((id) => id !== termId) : [...prev.agreedTermsIds, termId];
      return { ...prev, agreedTermsIds: nextTerms };
    });
  };

  return { agreedTerms, toggleTerm };
};

# 기술적으로 얻은 것 & 배운 점

  1. 기술 중심 묶음의 함정 탈출: 코드를 묶을 때 단순히 '같은 페이지의 상태니까'라는 기술적 이유로 묶으면 안 된다. 이름이 명확해지고 특정 값의 변경으로 인한 파급력을 줄이려면, 실제 함께 변경되고 사용되는 도메인 단위로 쪼개야 한다.
  2. 함수형 접근과 리액트 파이버 렌더링 최적화: 거대한 하나의 객체를 useState로 다루면, '배송지'만 입력해도 '약관 동의' 상태까지 얕은 복사(Shallow Copy)가 일어나 불필요한 렌더링 평가가 일어난다. 도메인별로 훅을 쪼개면 리액트 내부 훅 리스트에서 상태 업데이트가 완벽히 격리되어 렌더링 파이프라인의 낭비를 원천 차단할 수 있다.
  3. DX(Developer Experience)를 고려한 반환 설계: 모든 훅이 관성적으로 { state, setState } 형태를 반환하면 소비하는 측에서 매번 별칭(Alias)을 지어줘야 한다. 반환 객체의 키를 address, paymentMethod처럼 도메인 의미를 담아 설계하면 구조 분해 할당 시 네이밍 충돌을 피하고 읽기 편한 코드가 된다.
  4. 타입스크립트 디테일: interface와 type은 반드시 파스칼 케이스(PascalCase)로 작성하여 변수/인스턴스와 한눈에 구분되도록 약속을 지켜야 한다.
728x90