리팩토링: 컴포넌트 관심사 분리하기

soleil_lucy·2025년 8월 7일
1

1️⃣ 리팩토링을 하게 된 이유

Receipto는 모임에서 발생한 결제 내역을 정리하고, 1/N 금액을 계산해 쉽게 공유할 수 있는 애플리케이션입니다.

핵심 기능 위주로 MVP를 먼저 완성하고 나니, 이제는 성능을 최적화하거나 UI/UX를 개선하거나, 코드 구조를 정비할 차례가 되었습니다.

그중 가장 시급 하다고 느낀 부분은 App 컴포넌트의 복잡도였습니다.

App 컴포넌트 하나가 너무 많은 책임을 가지고 있었기 때문입니다.

  • 상태 관리: reducer로 receipt 상태를 관리하고, 별도의 useState로 모달 상태까지 다루고 있었습니다.
  • 로직 처리: 텍스트 포맷팅, 1/N 계산, 복사 기능 등 정산 결과 생성 로직도 이 안에 모두 들어 있었습니다.
  • UI 구성: 각종 입력 필드, 리스트, 모달, 버튼 등 UI 구성도 전부 이 컴포넌트에서 처리하고 있었습니다.

덕분에 컴포넌트가 너무 길어졌고, 각 기능이 어디서 처리 되는지 한눈에 파악하기 어려웠습니다.

“이 컴포넌트는 어떤 일을 하고 있는가?”라는 질문에 명확히 답하기 어려웠고, 그만큼 유지보수하기 어려운 코드라 생각했습니다.

📌 리팩토링 전 App 컴포넌트 구조

function App() {
  // 1. 상태 관리
  const [receipt, dispatch] = useReducer(reducer, { ... });
  const [isShareOpen, setIsShareOpen] = useState(false);
  const [isCopied, setIsCopied] = useState(false);

  // 2. 로직 처리
  const getDisplayLength = () => { ... }
  const createReceipt = () => { ... }
  const handleCopyButton = () => { ... }

  // 3. UI 구성
  return (
    <Container>
      <Header />
      <InputReceiptInfo ... />
      <InputPaymentHistory ... />
      <PaymentHistory ... />
      <InputPeopleCount ... />
      <ReceiptResult ... />
      <Dialog>
        <DialogContent>
          ...
        </DialogContent>
      </Dialog>
      <footer>...</footer>
    </Container>
  );
}

2️⃣ 리팩토링의 목표

가장 큰 목표는 가독성이 좋은 코드를 만드는 것입니다.

처음 작성한 App 컴포넌트는 비즈니스 로직, UI 상태 관리, 화면 렌더링이 한 곳에 뒤섞여 있어서, 역할이 명확하지 않고 코드를 읽을수록 점점 지치고 보기 싫어진다는 느낌이 들었습니다.

그래서 다음 두 가지 기준을 세우고 리팩토링을 진행해보려 합니다:

  1. 비즈니스 로직과 UI 로직의 분리

    상태 관리나 정산 계산과 같은 로직은 컴포넌트 내부에서 직접 처리하기보다, React Custom Hook으로 분리해 각 책임을 분명히 나누는 방향으로 개선할 예정입니다.

  2. 가독성 좋은 코드로 수정하기

    컴포넌트를 작게 쪼개고, props 이름도 역할이 잘 드러나도록 변경해 오랜만에 봐도 어떤 역할을 하는 코드인지 쉽게 이해할 수 있도록 바꿔보려고 합니다.

3️⃣ 진행 과정

비즈니스 로직 분리

문제

처음에는 상태 관리 로직정산 내역 공유 메시지를 생성하는 로직이 모두 App 컴포넌트 내부에 함께 존재했습니다. 이로 인해 컴포넌트가 길어지고, 여러 역할이 뒤섞여 있어서 읽기 어려웠습니다.

function App() {
  // 상태 관리
  const [receipt, dispatch] = useReducer(reducer, { ... });
  const [isShareOpen, setIsShareOpen] = useState(false);
  const [isCopied, setIsCopied] = useState(false);

  // 비즈니스 로직
  const handleCopyButton = () => { ... }

  return (...);
}

조치

  • useReceipt 커스텀 훅으로 상태 관리 로직 분리
    useReceipt 커스텀 훅으로 상태 관리 로직 분리한 커밋 보러가기

    • 정산 제목, 날짜, 결제 내역, 인원 수 등의 상태와 업데이트 로직을 커스텀 훅으로 캡슐화

      // App.tsx
      import { useReceipt } from './hooks/useReceipt';
      
      function App() {
      	const {
          receipt,
          changeTitle,
          changeDate,
          addPaymentHistory,
          deletePaymentHistory,
          changePeopleCount,
        } = useReceipt();
        
        // ...
      }
      // hooks/useReceipt.ts
      export function useReceipt() {
        const [receipt, dispatch] = useReducer(reducer, { ... });
      
        const changeTitle = (newTitle: string) => {
          dispatch({ type: 'CHANGED_TITLE', receipt: { title: newTitle } });
        };
      
      	// ...
      	
        return {
          receipt,
          changeTitle,
          changeDate,
          addPaymentHistory,
          deletePaymentHistory,
          changePeopleCount,
        };
      }
  • createReceipt 함수로 공유 메시지 생성 로직 분리

    createReceipt 함수로 공유 메시지 생성 로직 분리한 코드 보러가기

    • App 컴포넌트에 있던 텍스트 포맷 로직을 services/receiptService.ts로 옮겨 관리

    • 내부 구현은 신경쓰지 않고 createReceipt()만 호출하면 되도록 개선

      // App.tsx
      import { createReceipt } from './services/receiptService';
      
      function App() 
      	const handleCopyButton = async () => {
          try {
            const created = createReceipt(receipt);
            // ...
      
          } catch (error) {
            console.error('클립보드 복사 실패.. ', error);
          }
        };
        
       // ...
      }
      // services/receiptService.ts
      export const createReceipt = (receipt: Receipt) => {
        const WIDTH = 25;
        const LINE = '='.repeat(WIDTH);
      
        const title = `[💳 ${receipt.title}]\n`;
        const items = receipt.histories.map((history) => {
      		// 포맷팅 로직 ...
        });
        
        // ...
        
        const totalPriceStr = `총 금액: ...`;
      
        const pricePerPerson = `1/N 금액: ...`;
      
        return [title, ...items, LINE, totalPriceStr, pricePerPerson].join('\n');
      };

결과

  • App 컴포넌트는 UI와 로직을 조합하는 역할만 담당하게 되었습니다.
  • 복잡한 상태 관리나 메시지 생성 구현은 각각의 모듈로 위임되어 가독성과 유지보수성이 향상되었습니다.

UI 컴포넌트 분리

문제

다이얼로그 UI 전체를 App 컴포넌트에서 렌더링하고 있어, UI 구조와 상태 로직이 뒤섞여 있었습니다. 그 결과, 컴포넌트 길이가 길어지고 가독성이 떨어졌습니다.

// App.tsx
function App() {
  const handleCopyButton = async () => {};

  return (
    <Container>
      ...
      <!-- 분리할 필요가 있는 UI 부분 -->
      <Button
        onClick={() => setIsShareOpen(true)}
        className="w-full h-12 text-base font-medium"
      >
        공유하기
      </Button>
      <Dialog open={isShareOpen} onOpenChange={setIsShareOpen}>
        <DialogContent className="sm:max-w-md">
          <DialogHeader>
            <DialogTitle className="text-center">정산 결과 공유</DialogTitle>
          </DialogHeader>
          <article className="bg-gray-50 p-4 rounded-md max-h-[200px] overflow-y-auto whitespace-pre-wrap text-sm">
            {'horay~~~'}
          </article>
          <article className="flex flex-col gap-3">
            <Button
              onClick={handleCopyButton}
              className="w-full flex items-center justify-center gap-2 h-12"
            >
              {isCopied ? '복사 완료!' : '복사하기'}
            </Button>
            <Button
              variant="outline"
              onClick={() => setIsShareOpen(false)}
              className="w-full"
            >
              닫기
            </Button>
          </article>
        </DialogContent>
      </Dialog>
      ...
    </Container>
  );
}

export default App;

조치

  • 다이얼로그 UI 전체를 ShareReceipt라는 독립된 컴포넌트로 분리

    다이얼로그 UI 전체를 ShareReceipt라는 독립된 컴포넌트로 분리한 커밋 보러가기

     // App.tsx
     function App() {
       const {
         receipt,
         // ...
       } = useReceipt();
    
       return (
         <Container>
           ... 
           <ShareReceipt receipt={receipt} />
           ...
         </Container>
       );
     }
    
     export default App;
     // components/ShareReceipt.tsx
    
     // ...
     export function ShareReceipt({
       open,
       onOpenChange,
       isCopied,
       onCopy,
     }: ShareReceiptProps) {
       return (
         <Dialog open={open} onOpenChange={onOpenChange}>
           <DialogContent className="sm:max-w-md">
             <DialogHeader>
               <DialogTitle className="text-center">정산 결과 공유</DialogTitle>
             </DialogHeader>
             <article className="bg-gray-50 p-4 rounded-md max-h-[200px] overflow-y-auto whitespace-pre-wrap text-sm">
               {'horay~~~'}
             </article>
             <article className="flex flex-col gap-3">
               <Button
                 onClick={onCopy}
                 className="w-full flex items-center justify-center gap-2 h-12"
               >
                 {isCopied ? '복사 완료!' : '복사하기'}
               </Button>
               <Button
                 variant="outline"
                 onClick={() => onOpenChange(false)}
                 className="w-full"
               >
                 닫기
               </Button>
             </article>
           </DialogContent>
         </Dialog>
       );
     }

결과

  • App 컴포넌트는 공유 UI의 구체적인 구조를 알 필요 없이, 해당 컴포넌트를 조합만 하면 되므로 역할이 명확해졌습니다.
  • ShareReceipt 컴포넌트는 공유 UI만의 책임을 가지게 되어, 코드 재사용성과 가독성이 높아졌습니다.

4️⃣ 리팩토링 후, 달라진 App 컴포넌트

App 컴포넌트는 이제 상태나 UI 구현의 세부사항에 직접 관여하지 않고, 전체 흐름만 제어하는 역할에 집중합니다. 코드가 훨씬 간결해졌고, 각 기능을 책임지는 컴포넌트와 훅으로 역할이 명확하게 분리되어 가독성과 유지보수성이 향상되었습니다.

리팩토링 후 App Component

// import...

function App() {
  const {
    receipt,
    changeTitle,
    changeDate,
    addPaymentHistory,
    deletePaymentHistory,
    changePeopleCount,
  } = useReceipt();

  return (
    <Container>
      <<Header />
      <InputReceiptInfo
        title={receipt.title}
        date={receipt.date}
        changeTitle={changeTitle}
        changeDate={changeDate}
      />
      <InputPaymentHistory addPaymentHistory={addPaymentHistory} />
      <PaymentHistoryList
        histories={receipt.histories}
        deletePaymentHistory={deletePaymentHistory}
      />
      <InputPeopleCount changePeopleCount={changePeopleCount} />
      <ReceiptResult
        histories={receipt.histories}
        peopleCount={receipt.peopleCount}
      />
      <ShareReceipt receipt={receipt} />
      <footer className="text-center py-4">
        <p className="text-xs text-gray-500">모임 정산이 쉬워졌어요! 🎉</p>
      </footer>
    </Container>
  );
}

export default App;

5️⃣ 배운 점 & 느낀 점

배운 점

이번 리팩토링을 통해 React Custom Hook을 활용해 상태 관리 로직을 분리하는 방법을 배웠습니다. 공통된 로직을 컴포넌트 바깥으로 분리할 수 있어, 코드가 훨씬 간결하고 깔끔해졌습니다. 덕분에 로직의 재사용성과 가독성도 함께 높일 수 있었습니다.

React의 Custom Hook?

컴포넌트 간에 반복되는 로직을 재사용할 수 있도록, 리액트의 기본 훅들을 조합해 만든 사용자가 정의한 함수입니다. 커스텀 훅은 이름을 use로 시작하고, 상태 관리나 효과 같은 로직을 깔끔하게 분리해 여러 컴포넌트에서 활용할 수 있게 합니다.

느낀 점

리팩토링은 여러 기능 개발이 끝난 후 한꺼번에 몰아서 하기보다는, 기능 하나를 작동 가능한 수준으로 구현한 후, 바로 리팩토링하는 방식이 더 효율적이라는 걸 느꼈습니다.

한 번에 하려고 하니 귀찮고 부담돼서 점점 미루게 되었습니다. 앞으로는 작은 단위로 나눠서 개발과 리팩토링을 병행하는 습관을 들여야겠다고 생각했습니다.

참고 자료

profile
여행과 책을 좋아하는 개발자입니다.

0개의 댓글