항해플러스 프론트엔드 5기 후기(4주차) - 클린코드와 리팩토링

유한별·2025년 4월 19일
4
post-thumbnail

이번 과제는 단순히 기능을 리팩토링하거나 React로 마이그레이션하는 작업이 아니었다.
기존 구조의 문제를 “어디까지 분리할 수 있을까”, 그리고 “어디서부터가 타협해야 하는 지점일까”를 끝없이 고민하게 만든 과제였다.
Vanilla JS 기반의 기존 코드는 의도적으로 더티하게 구성된 코드였고, 그 안에서 역할을 분리하고 흐름을 재구성하는 것 자체가 하나의 설계 경험이었다.
코드를 어떻게 정리할지가 아니라, 어떤 방향으로 구조를 재설계할 것인가가 핵심이었다.

기존 코드 예시
수 많은 빨간줄(lint error)이 보인다.

🛠️ Vanilla JS 구조화

전역 상태를 Store로 추상화

과제를 처음 시작했을 때 가장 먼저 마주한 건 전역에 흩어져 있던 var 변수들과 무분별한 DOM 접근이었다. 상태는 var 변수로 호이스팅 되어있었고, DOM 요소들도 전역에서 선언되어 여기저기서 직접 수정되고 있었다. 코드를 따라가 보면 값이 어디서 바뀌는지 파악하기 힘들고, DOM과 로직이 뒤엉켜 있어 기능 하나만 변경하려해도 전체 흐름을 수정해야 했다.

(대충 이런 느낌)

이를 해결하기 위해 상태는 역할 단위로 분리하여 CartStore, SelectedProductStore 등의 모듈로 구성했고, get / set 방식의 단방향 데이터 흐름을 만들었다. 값을 직접 수정하지 않고, 명시적인 흐름을 통해 상태 변경이 이뤄지도록 하면서 추적성과 유지보수성을 확보했다. 리액트의 불변성 개념을 염두에 두며, set 이후엔 항상 명시적으로 render 함수가 호출되도록 강제했다.

// cartStore.js
export const createStore = (initialState) => {
  const state = { ...initialState };

  const get = (key) => (key ? state[key] : { ...state });

  const set = (key, value) => {
    if (state[key] === value) return;
    state[key] = value;
  };

  return { get, set };
};

export const CartStore = createStore({
  itemCount: 0,
  subTotal: 0,
  totalAmount: 0,
});

기능 단위의 DOM 모듈 분리

DOM 관리 역시 같은 문제를 안고 있었다. 초기 코드는 모든 DOM 요소를 전역에서 선언하고 직접 조작하는 구조였다.

이를 개선하기 위해 CartItemsContainerDOM, ProductSelectDOM, LayoutDOM 등 기능 단위로 DOM 모듈을 나누고, 각 모듈에서는 init()으로 DOM 요소를 생성해 get()으로 DOM 인스턴스를 반환하도록 구성했다.

// CartItemsContainerDOM.js

import { handleCartItemsContainerClick } from '../handlers';
import { DOM_IDS } from '../consts';

export const CartItemsContainerDOM = {
  _element: null,

  init() {
    this._element = document.createElement('div');
    this._element.id = DOM_IDS.CART.CONTAINER;

    this._element.addEventListener('click', (event) =>
      handleCartItemsContainerClick(event),
    );
  },

  get() {
    return this._element;
  },
};

이 구조를 적용하니 핸들러나 로직에서 필요한 DOM 요소를 명확하게 가져다 쓸 수 있었고, 각 UI 요소의 역할도 분리돼 유지보수가 쉬워졌다. DOM을 별도 모듈로 분리하면서 흐름을 추적하기도 한결 수월해졌다.

역할에 따른 함수 분리

기존 코드에서는 계산, 렌더링, 상태 변경이 하나의 함수에 뒤섞여 있었다. 이를 개선하기 위해 각 함수의 책임을 분명히 나눴다.

calculateCartAmounts, getDiscountRate는 계산 로직으로, renderBonusPoints, updateStockStatus는 DOM 조작으로, handleAddButtonClick, handleCartItemsContainerClick은 이벤트 핸들러로 각각 분리했다.

이렇게 역할에 따라 함수를 나누다 보니 자연스럽게 “상태나 DOM을 props로 모두 넘겨야 할까?”, “아니면 외부 모듈에서 직접 가져올까?”에 대한 고민이 따라붙었다.
props를 통해 모든 의존성을 전달하면 함수는 순수해지고 테스트도 쉬워지지만, 그만큼 함수 호출부는 점점 복잡해지고 가독성도 떨어지기 시작했다.
반면 store나 DOM 모듈을 직접 참조하면 사용은 편리하지만, 결국 함수가 외부에 의존하는 구조가 되고 데이터 흐름도 예측하기 어려워진다.

결국 나는 전역 store에서 상태를 가져오고 DOM 역시 외부 모듈에서 가져오는 방식을 택했지만, 이 방식이 궁극적으로 순수하지 않은 함수를 만들 수밖에 없다는 점은 여전히 마음에 걸렸다.
지금도 “이 정도 외부 의존성은 허용 가능한가?”, “순수함수로 만들기 위해 감수해야 할 복잡성은 어느 정도인가?” 같은 질문에 대해 확실한 답을 내리진 못했다.
순수성과 현실적인 유지보수 사이의 균형은 지금도 여전히 고민 중이다.

네이밍 컨벤션 정립

초기 코드에서는 prodList, sel, cartDisp, sum 등 직관적이지 않은 축약어 기반의 변수들이 사용되고 있었고, main, calcCart, updateSelOpts처럼 혼용된 스타일의 함수들도 많았다.
각 이름이 어떤 역할을 수행하는지 파악하기 어려웠고, DOM 요소와 데이터 상태, 계산 함수 등이 뒤섞인 채 동작하는 구조였다.

이런 혼란을 줄이기 위해 네이밍 컨벤션을 새롭게 정립했다. 기능을 기준으로 명확하게 역할을 드러낼 수 있도록 네 가지 키워드를 중심으로 나눴다.

  • create: 새로운 DOM 요소를 생성할 때 사용
  • render: 상태를 기반으로 UI를 그릴 때 사용
  • update: 기존 데이터를 수정하거나 화면 일부를 갱신할 때 사용
  • init: 초기 세팅이나 등록 작업을 수행할 때 사용

예를 들어 상품 셀렉트 박스를 구성하는 함수는 updateProductSelectOptions로, 포인트를 보여주는 함수는 renderBonusPoints로 명명했다.

이렇게 이름만 봐도 해당 함수의 역할이 드러나도록 정리하면서 전체 구조를 파악하기 쉬워졌고, 이후 React 마이그레이션 시에도 자연스럽게 컴포넌트 단위로 대응할 수 있었다.

⚛️ React + TypeScript 마이그레이션

기존 순수함수 재활용: TypeScript 래핑

가장 먼저 시도한 건 계산이나 포맷팅처럼 DOM에 의존하지 않는 순수 함수들을 그대로 가져와 TypeScript에서 래핑해 사용하는 방식이었다.

예를 들어 calculateTotalPrice, calculateBonusPoints 같은 함수는 기존 JS 코드에서 그대로 가져와 export const calculateTotalPrice = () => {} 같은 식으로 래핑한 뒤, 타입만 덧붙여 사용하는 형태로 점진적인 전환을 시도했다.

import {
  calculateTotalPrice as basicCalculateTotalPrice,
} from "@/basic/utils/calculate";

export const calculateTotalPrice = (price: number, quantity: number): number => {
  return basicCalculateTotalPrice(price, quantity);
};

이 방식은 생각보다 유용했다. 기존 코드의 흐름을 유지하면서도 타입 안정성과 코드 예측 가능성을 확보할 수 있었고, “어떤 로직은 굳이 React 방식으로 고쳐 쓸 필요가 없다”는 걸 실감하게 해줬다. 동시에 “이 함수는 순수하니까 그대로 쓰고, 이건 DOM을 만지니까 새로 만들어야겠다”는 판단 기준도 더 명확해졌다.

컴포넌트 중심의 UI 구성

UI는 기능 단위로 분리해 React 컴포넌트로 구성했다. 기존의 핸들러나 비즈니스 로직 내부에서는 직접 DOM을 조작하거나 innerHTML을 수정하는 식의 구현이 많았지만, React에서는 그런 방식은 지양해야 했다.
그래서 우선 UI 조작 로직을 걷어내고, 각 컴포넌트는 자신이 받아야 할 상태와 액션만 전달받아 렌더링은 오직 JSX를 통해서만 처리하도록 구조를 바꿨다.

예를 들어, 장바구니 아이템 하나는 <CartItem />, 전체 리스트는 <CartItemListContainer />로 나누고, <CartItem />은 오직 props로 전달받은 item 값만으로 렌더링하도록 구성했다. 삭제 버튼이나 수량 변경 버튼 역시 외부에서 주어진 이벤트 핸들러만 호출하도록 연결했고, 내부에서 DOM을 직접 변경하는 일은 전혀 없도록 했다.

// CartItem.tsx
interface CartItemProps {
  item: Product;
}

export const CartItem: React.FC<CartItemProps> = ({ item }) => {
  const { updateProductQuantity, removeProductFromCart } = useShoppingContext();
  
  return (
    <div id={item.id} className={STYLES.LAYOUT.FLEX}>
      <span data-value={item.value} data-quantity={item.quantity}>
        {`${item.name} - ${formatPrice(item.value)} x ${item.quantity}`}
      </span>
      <div>
        <button
          type="button"
          className={`${STYLES.BUTTON.PRIMARY} ${STYLES.BUTTON.SMALL} ${DOM_CLASSES.BUTTON.QUANTITY_CHANGE}`}
          data-product-id={item.id}
          onClick={() => updateProductQuantity(item.id, -1)}
        >
          -
        </button>
		...
      </div>
    </div>
  );
};
// CartItemListContainer.tsx
export const CartItemListContainer: React.FC = () => {
  const { cartItems } = useShoppingContext();

  return (
    <div id={DOM_IDS.CART.CONTAINER}>
      {cartItems.map((item) => (
        <CartItem key={item.id} item={item} />
      ))}
    </div>
  );
};

이렇게 하면서 UI 구성의 흐름이 명확해졌고, 컴포넌트는 순수하게 UI만 담당하는 레이어가 될 수 있었다. 이전에는 로직과 UI가 뒤섞여 있었지만, 이제는 상태가 바뀌면 그에 따라 UI가 자연스럽게 반응하는 React의 방식에 훨씬 가까워졌다.

상태 분리 및 커스텀 훅 구조화

처음 상태 구조를 설계할 때는 기존 JavaScript 코드에서 사용하던 store 개념을 Reactcontext로 그대로 옮겨왔다. products, cartItems, selectedProduct 등 모든 상태를 context에 몰아넣고 전역처럼 관리하는 방식은 쉽고 빠르게 전체 흐름을 구성할 수 있었다.

하지만 시간이 지날수록 이 구조가 리액트스럽지 않다는 생각이 들기 시작했다. 상태별로 관심사를 나누지 않았고, 컴포넌트 단위에서 필요한 데이터만 가져오는 것도 어려웠다. 그래서 상태를 각각의 기능에 맞춰 커스텀 훅으로 나누기 시작했다. useProductState, useCartActions, useSelectedProduct, useCartCalculations 등을 따로 만들고, 이들을 조합해 하나의 useShopping 훅으로 묶었다.

그런데 여기서 예기치 못한 문제가 생겼다. useShopping은 커스텀 훅이기 때문에 호출될 때마다 새로운 인스턴스를 반환했고, 이로 인해 내부 상태들이 지속되지 않거나 값이 꼬이는 현상이 발생했다.
예를 들어 버튼 클릭 시마다 useShopping을 다시 호출하면서 전혀 새로운 상태를 사용하는 문제가 생긴 것이다.

이 문제를 해결하기 위해 결국 useShopping 훅 자체를 context로 감싸는 구조로 변경했다. 이렇게 하니 훅을 통해 상태와 액션을 기능별로 나누면서도, 최상위에서 한번만 초기화한 값을 하위 컴포넌트에 안정적으로 전달할 수 있었다.

🧹 Biome 도입과 포매터 병행 적용

이번 과제에서는 포매터를 두 개 사용하는 구조도 실험적으로 적용해보았다. 기존 basic 코드는 그대로 Prettier + ESLint 조합을 유지하고, advanced는 팀 컨벤션에 따라 새롭게 Biome 기반의 포매팅을 적용하는 방식이었다.

Biome 도입 이유

BiomePrettier, ESLint, TypeScript 도구군을 하나로 통합한 올인원 포매터다. 실행 속도가 빠르고, 설정이 간편하며 타입 정보 없이도 기본적인 분석이 가능하다는 점이 장점이다.
특히 러스트 기반으로 작성되어 퍼포먼스가 뛰어나고 추후 확장성 측면에서도 기대할 수 있다는 점에서 선택하게 되었다.

https://biomejs.dev/

포매터 영역 분리

루트에 .biomeignore, .prettierignore, .eslintignore를 명확히 설정해서 각 포매터가 자신의 영역만 포맷하도록 구성했다. src/advanced/ 디렉토리만 Biome이 포맷하고, 나머지 디렉토리는 기존 포매터가 담당하는 구조였다. 실제로 pnpm format을 할 때도 충돌 없이 각 영역에 맞는 도구가 동작하도록 구성했다.

이전까지는 포매터를 하나만 사용하는 것이 일반적이었기 때문에 단일 프로젝트 내에서 두 가지 포매터를 병행 적용하는 것이 처음에는 꽤 낯설었다. 하지만 설정만 잘 해두면 큰 문제 없이 병행 운용이 가능하다는 걸 확인할 수 있었다. 특히 마이그레이션 중인 프로젝트에서 점진적으로 도입할 수 있다는 점에서 의미 있는 시도였다.

✍️ 회고

🧠 느낀점

이번 과제는 단순한 기능 리팩토링이나 React 마이그레이션 이상의 의미가 있었다. 기존 코드의 구조를 어떻게 “더 나은 흐름”으로 바꿀 수 있을지를 고민하는 과정이었고, 이를 코드 단위로 하나하나 풀어내며 설계와 구현이 서로 영향을 주고받는 과정을 체감할 수 있었다.

Vanilla JS 구조에서 가장 힘들었던 건 ‘순수함수처럼 만들고 싶다’는 욕심과 ‘DOM을 직접 조작해야 하는 현실’ 사이의 타협이었다. 데이터는 store로, UI는 render 함수로 나누더라도 결국 이벤트 핸들러에서는 외부 DOM과 상태를 직접 다뤄야 했다.
“정말 이렇게까지 분리하는 게 의미가 있을까?”에 대한 의문을 반복하면서, 기능을 추가하거나 수정할 때 구조적 이점을 느끼게 되면서 점차 확신으로 바뀌었다.

React로 넘어온 후에는 상태를 어떻게 관리할지에 대한 고민이 이어졌다. 초반엔 context 하나로 상태를 몰아넣었고, 이후에는 custom hook으로 쪼갰지만 결국 useShopping이라는 통합 훅을 만들게 됐다. 이 훅은 매 호출마다 새 인스턴스를 생성했기 때문에 상태를 유지할 수 없었고, 결국 context로 감싸서 안정적으로 흐름을 유지하는 구조로 마무리하게 되었다.
이 과정은 React의 상태 관리 철학, 그리고 커스텀 훅과 context의 역할을 다시 정리할 수 있는 좋은 계기가 됐다.

또한 기존 로직을 얼마나 순수 함수로 분리해두느냐에 따라 마이그레이션의 난이도가 크게 달라진다는 걸 뼈저리게 느꼈다. DOM에 직접 접근하거나 상태를 전역에서 수정하는 구조는 그대로 가져올 수 없었고, 결국 대부분 새로 짜야 했다. 반면 계산 로직이나 텍스트 포맷팅 등은 그대로 TypeScript로 감싸 재사용할 수 있어 구조 분리의 중요성을 다시 한번 체감했다.

🤔 향후 개선 방향

함수형 설계를 지향하면서도 DOM 의존을 완전히 제거하지 못한 점은 아쉬움으로 남는다. 데이터의 흐름과 UI 업데이트를 완전히 분리할 수 있는 구조가 무엇일지, 그리고 어떤 방식이 유지보수성과 가독성을 모두 만족시킬 수 있을지에 대한 고민은 여전히 이어지고 있다. 단순히 render 함수를 따로 두는 것만으로는 충분하지 않았고, 함수가 외부 DOM에 직접 접근하는 상황을 어떻게 처리해야 할지는 앞으로도 더 다양한 방식을 실험해봐야 할 것 같다.

이번 과제를 진행하며, 지금의 구조 말고도 다른 방식으로도 구현할 수 있었겠다는 생각이 자꾸 들었다. 예를 들어 reducer 기반의 상태 관리를 도입했다면 상태 흐름을 더 선언적으로 표현할 수 있었을지도 모른다. 상태를 context에 담기보다는 컴포넌트 간 props 전달이나 render props 패턴처럼 명시적인 흐름으로 풀어가는 방식도 있었겠지만, 각 방식마다 명확한 트레이드오프가 존재한다. 결국 이번에는 비교적 익숙한 context + custom hook 구조를 선택했지만, 기능이 더 복잡해질수록 다른 구조도 충분히 고려해볼 수 있을 것 같다.

또한 이번에는 기능 구현에 집중하느라 테스트 가능성이나 확장성에 대한 고민이 상대적으로 부족했던 것 같다. 상태와 로직을 나누면서 테스트 가능한 구조를 어느 정도는 만들어냈지만, 실제로 테스트를 작성하거나 테스트 중심으로 구조를 설계하지는 못했다. 다음 과제에서는 기능을 구현하는 동시에, 어떻게 하면 더 유연하고 검증 가능한 구조로 만들 수 있을지도 함께 고민해보고 싶다.

과제 결과 및 코드

  • 스스로 아쉬웠던만큼, 통과에 의의를 두었습니다...!
profile
세상에 못할 일은 없어!

1개의 댓글

comment-user-thumbnail
2025년 4월 25일

와 좋은 회고 잘봤습니다.
biome 저도 써봐야 겠어요!!

답글 달기