코드 냄새는 잘 나오는데, 어디부터 고치죠

한가득 나온 코드 냄새를 심각도로 줄 세워 무엇부터 고칠지 정한 기록

ReactRefactoringFrontend

루프팩 2주차 과제는 체크아웃 페이지 리팩토링이었다. CheckoutPage 하나가 11개 상태를 직접 들고, 배송지·쿠폰·적립금·약관까지 8개 섹션을 JSX로 인라인으로 쏟아내는 ~300줄짜리 컴포넌트. 강의에서 받은 11개 전략 렌즈로 훑으니 고칠 거리가 한가득 나왔다.

처음 세운 수정 계획만 해도 길었다. props 정리와 Context, 데이터 계층 강결합 끊기, 주문폼 분리, 컴포넌트 선언 순서, 변수 네이밍, 디렉토리, 상태 재설계, 컴포넌트 재설계… 표가 금방 찼다.

그런데 막상 끝내고 보니 실제로 고친 건 그 목록의 절반도 안 됐다. 이건 게으름이 아니라 이번 과제의 절반이 원래 고치지 않겠다는 결정이었기 때문이다. 그래서 이 글은 두 갈래다 — 무엇을 먼저 고칠지 줄 세운 이야기와, 무엇을 안 고칠지 정한 이야기.

냄새 목록을 받았을 때 진짜 어려운 건 우선순위다

전략 렌즈로 훑으면 냄새는 잘 나온다. God Component, 성급한 추상화, boolean 폭발, props 과다… 표가 금방 채워진다. 그런데 표를 채우고 나면 항목들이 전부 같은 무게로 나란히 누워 있다. "컴포넌트 순서가 추상화 높은 순이 아님"과 "결제 금액이 틀리게 계산됨"이 같은 줄에 있는 것이다.

여기서 그냥 위에서부터 고치기 시작하면, 취향에 가까운 스타일 항목에 시간을 쓰다가 정작 돈이 틀리는 버그를 늦게 만지게 된다. 그래서 고치기 전에 심각도 기준으로 한 번 줄을 세웠다.

티어기준예시
Tier 0정확성 버그 — 틀린 금액/데이터 손실finalPrice가 옛값으로 결제됨
Tier 1상태 설계 결함 — 소유 위치가 버그를 유발필터 켜면 선택 주소가 화면과 어긋남
Tier 2입력 경계 검증 / 구조 분리음수 적립금, God Component 분리
Tier 3스타일 / 관례 — 행위 변화 없음컴포넌트 선언 순서, 의미 없는 별칭

정렬하면서 규칙을 두 개 세웠다.

  • 같은 뿌리는 병합한다. 따로 적힌 두 항목이 한 번의 수정으로 같이 풀리면 하나로 본다.
  • 실제 버그를 위로 올린다. "냄새"라는 단어가 가린, 진짜로 동작이 틀리는 항목을 최상단에.

이 두 규칙을 적용하자 줄 세우기의 결과가 통념과 달라졌다. 가장 위로 올라온 건 흔히 말하는 "코드 냄새"가 아니라, 냄새 뒤에 숨어 있던 결제 금액 버그였다.

Tier 0 — useState에 파생값을 담으면 돈이 틀린다

최상단에 올라온 코드는 이거였다.

// CheckoutPage.tsx
const [finalPrice] = useState(itemTotal + shippingFee - couponDiscount - pointDiscount)

처음엔 그냥 "최종 금액을 상태로 들고 있네" 정도로 읽힌다. 그런데 useState의 초깃값은 첫 마운트 때 딱 한 번 평가된다. 즉 finalPrice는 페이지가 처음 뜨는 순간의 쿠폰·적립금·배송지로 계산된 값에서 영영 멈춘다.

화면에서 벌어지는 일은 이렇다. 사용자가 쿠폰을 적용하면 할인 라인은 갱신된다(그건 매 렌더 다시 그리니까). 그런데 "최종 결제 금액" 줄과 결제 버튼에 박힌 금액만 옛날 값 그대로다. 할인이 화면엔 보이는데 실제로는 할인 전 금액으로 결제되는 상황이다.

회귀 테스트 시나리오를 손으로 밟다가 이게 한 버그가 아니라는 걸 알았다. 쿠폰을 적용해도, 적립금을 입력해도, 배송지를 도서산간으로 바꿔 배송비가 붙어도, 심지어 주문 완료 화면의 안내 금액까지 — 흩어져 보이던 증상이 전부 이 한 줄에서 나왔다.

고치는 방법은 길게 고민할 게 없었다. 렌더 중에 그냥 계산하면 된다.

// 파생값은 state가 아니라 렌더 중 계산
const finalPrice = itemTotal + shippingFee - couponDiscount - pointDiscount

useState를 떼고 const 파생 변수로 바꾸는 순간, 쿠폰·적립금·배송지가 바뀔 때마다 다시 계산된다. 그리고 덤으로, 입력 폼과 완료 화면이 같은 식을 공유하게 된다. 두 곳에 흩어진 금액을 "한 곳으로 끌어올려 동기화하자" 같은 작업이 아예 필요 없어진 것이다. 한 줄을 지웠더니 동기화 문제가 같이 사라졌다.

참고로 이 계산식은 이후 별도 단계에서 pricing.ts의 calcFinalPrice로 분리했다(비즈니스 로직 분리, Tier 2). Tier 0에서 한 일은 "파생값을 state에서 떼어 낸 것"까지다.

사실 이건 프로젝트 CLAUDE.md에 이미 박혀 있는 규칙 위반이기도 했다.

파생 가능한 값의 useState 사용 금지 state: 렌더에 쓰이는 값은 state. 파생값은 렌더 중 직접 계산 — effect로 state에 복사 금지

규칙으로만 읽을 땐 "그래, 파생값은 계산해야지" 하고 넘겼던 문장이다. 그게 실제로 깨졌을 때 어떤 모습인지를 — 결제 금액이 틀리는 형태로 — 이번에 처음 봤다. 안티패턴은 이름을 외우는 것보다 한 번 데어 보는 게 오래 남는다.

상태를 Context로 올리지 않고, 컴포넌트로 내렸다

수정 계획에 "상태 재설계"가 있었다. 그리고 "상태를 정리한다"고 하면 보통 떠오르는 게 Context API나 전역 상태로 끌어올리는 것이다. 실제로 11개 상태가 섹션마다 props로 내려가니, 얼핏 prop drilling처럼 보이기도 했다. "이걸 Context로 묶으면 깔끔하겠는데?" 하는 유혹이 들었다.

그런데 강의가 던진 순서가 있었다. Context 전에 composition. 그래서 질문을 바꿔 봤다. "이 상태들을 어디로 올릴까?"가 아니라 "이 상태들이 정말 CheckoutPage에 있어야 하나?"

답은 아니오였다. 입력 상태(주소·쿠폰·적립금·약관)는 전부 주문서 화면에서만 쓴다. 주문 완료 화면은 결제 시점에 확정된 금액 하나만 있으면 된다. 게다가 두 화면은 동시에 뜨지 않는다. 그렇다면 상태를 위(Context)로 올릴 게 아니라, 그 상태를 쓰는 화면 컴포넌트 안으로 내리면 된다.

리팩토링 전 CheckoutPage는 입력·계산·두 화면을 한 함수에 다 안고 있었다(~300줄). 상태와 결제 버튼만 남기고 줄이면 이런 모양이다.

// CheckoutPage.tsx — 리팩토링 전 (축약)
export function CheckoutPage() {
  // 입력 상태와 주문 처리 상태가 한 컴포넌트에 11개
  const [selectedAddressId, setSelectedAddressId] = useState(ADDRESSES[0].id);
  const [couponCode, setCouponCode] = useState('');
  const [appliedCoupon, setAppliedCoupon] = useState<Coupon | null>(null);
  const [usePoint, setUsePoint] = useState(false);
  const [pointInput, setPointInput] = useState(0);
  const [payment, setPayment] = useState<PaymentMethod>('card');
  const [isTermsOpen, setIsTermsOpen] = useState(false);
  const [agreed, setAgreed] = useState(false);
  const [placed, setPlaced] = useState(false);
  // ... 배송비·쿠폰·적립금 계산, finalPrice ...

  // 주문 완료 화면도 같은 컴포넌트 안에 인라인
  if (placed) {
    return (
      <div className="checkout">
        <h1>주문 완료</h1>
        {/* ... 완료 안내 + '주문서로 돌아가기' 버튼 ... */}
      </div>
    );
  }

  // 주문서 화면 — 8개 섹션을 전부 인라인으로
  return (
    <div className="checkout">
      <h1>주문/결제</h1>
      <DeliverySection /* ... */ />
      <div className="section"><h2>배송 요청사항</h2>{/* ... */}</div>
      <div className="section"><h2>주문 상품</h2>{/* ... */}</div>
      {/* 쿠폰 · 적립금 · 결제수단 · 결제 금액 · 약관 모달 · 최근 주문 … 섹션이 계속 이어짐 */}
      <button className="pay" disabled={!agreed} onClick={() => setPlaced(true)}>
        {finalPrice.toLocaleString()}원 결제하기
      </button>
    </div>
  );
}

그래서 두 화면으로 쪼개고 입력 상태를 아래로 이전했다.

  • OrderSheet — 주문서. 입력 상태 5개 + finalPrice 계산 + 8개 섹션 + 결제 버튼. 외부 인터페이스는 onPlace: (finalPrice: number) => void 단 하나.
  • OrderResultModal — 주문 완료 화면. { finalPrice, onClose }. (로딩·에러는 향후 확장 자리만 비워 둠.)

남은 CheckoutPage는 두 화면의 전환만 맡는 19줄짜리가 됐다.

export function CheckoutPage() {
  const [placed, setPlaced] = useState(false);
  const [finalPrice, setFinalPrice] = useState(0); // 결제 시점에 확정된 금액(결과 모달 표시용)

  const handlePlace = (placedFinalPrice: number) => {
    setFinalPrice(placedFinalPrice);
    setPlaced(true);
  };

  if (placed) {
    return <OrderResultModal finalPrice={finalPrice} onClose={() => setPlaced(false)} />;
  }
  return <OrderSheet onPlace={handlePlace} />;
}

이 분리로 얻은 것:

  • 폼 편집이 OrderSheet만 리렌더한다. 결과 모달과 CheckoutPage는 입력 변화와 무관해진다.
  • prop drilling이라 느꼈던 게 사실은 "상태가 잘못된 집에 살고 있던" 문제였다. 집을 옮기니 drilling 경로 자체가 OrderSheet → 섹션 한 단계로 짧아졌다. Context도, 전역 상태도 만들지 않았다 — composition만으로 충분했으니까.

같은 useState인데, 여기선 얼려 두는 게 맞다

흥미로운 건 CheckoutPage가 finalPrice를 useState로 들고 있다는 점이다. Tier 0에서 "파생값을 state에 담지 마라"고 해 놓고 여기선 state로 둔다. 모순처럼 보이지만 정반대다.

Tier 0의 finalPrice는 입력이 바뀌면 따라 변해야 하는 파생값인데 마운트 값에 얼어붙어 버그였다. 반면 여기 finalPrice는 결제 버튼을 누른 그 순간의 금액을 일부러 박제한 값이다. OrderSheet가 언마운트된 뒤에도 결과 모달이 "결제 당시 금액"을 보여줘야 하니까. 같은 useState라도 — 입력에 반응해야 하면 파생 변수, 한 시점을 고정해야 하면 state — 맥락이 판정을 뒤집는다.

이 분리 과정에서 네이밍도 같이 손봤다. 배송지 주소 선택만 하는데 이름이 DeliverySection이라 배송 전반을 다루는 것처럼 읽히던 컴포넌트는 DeliveryAddressSection으로 역할을 드러냈다.

네이밍도 리팩토링이다 — 타입이 불가능한 상태를 막게

상태와 컴포넌트를 정리하고 나니, 남은 것 중 상당수가 "동작은 맞는데 이름·타입이 의도를 못 드러내는" 항목이었다. 취향 문제로 보이기 쉽지만, 잘 고르면 타입이 버그를 미리 막는다. 세 개를 옮겨 본다.

boolean 5개 → union 1개

주문 상태 뱃지 컴포넌트의 원래 인터페이스는 이랬다.

type Props = {
  isPaid?: boolean
  isPreparing?: boolean
  isShipped?: boolean
  isDelivered?: boolean
  isCancelled?: boolean
}

결제완료·준비중·배송중·배송완료·취소는 동시에 둘일 수 없는 상호 배타적 상태다. 그런데 boolean 5개로 쪼개 두면 isPaid와 isCancelled가 같이 true인 조합이 타입상 멀쩡히 통과한다. 실제 구현도 if를 5번 나열하며 마지막에 켜진 게 이긴다 — 우연히 동작하는 코드다.

상호 배타라는 사실을 타입으로 못 박으면 불가능한 상태가 컴파일 단계에서 막힌다.

type OrderStatus = 'paid' | 'preparing' | 'shipped' | 'delivered' | 'cancelled'

const ORDER_STATUS_DISPLAY: Record<OrderStatus, { label: string; color: string }> = {
  paid: { label: '결제완료', color: '#3b82f6' },
  preparing: { label: '상품 준비중', color: '#f59e0b' },
  shipped: { label: '배송중', color: '#8b5cf6' },
  delivered: { label: '배송완료', color: '#22c55e' },
  cancelled: { label: '주문취소', color: '#ef4444' },
}

export function OrderStatusTag({ status }: { status: OrderStatus }) {
  const { label, color } = ORDER_STATUS_DISPLAY[status]
  return <span className="tag" style={{ color, border: `1px solid ${color}` }}>{label}</span>
}

if 다섯 줄이 Record 조회 한 줄로 줄어든 건 결과일 뿐이고, 진짜 이득은 status에 두 상태를 동시에 넘길 방법이 사라진 것이다.

OrderLineRow 하나 → CartLine + PriceLine 둘

원래 OrderLineRow 하나가 장바구니 상품 행과 가격 명세 행을 type 판별자로 분기 처리하고 있었다. 한 컴포넌트가 두 가지로 변하니 어딜 고치면 어디가 영향받는지 예측이 어려웠다. 두 컴포넌트로 쪼개 파일 단위로 책임을 격리했다.

컴포넌트props역할
CartLinelabel, amount, thumbnail?, option?, quantity?장바구니 상품 한 줄
PriceLinelabel, amount, subLabel?, isDiscount?가격 명세 한 줄

쪼개면서 couponCode라는 필드명을 subLabel로 일반화했다. 쿠폰 코드에만 한정된 이름이라 다른 부가 텍스트를 못 받았는데, 역할만 남기니 어떤 보조 라벨이든 수용한다.

여기서 Item/Row가 아니라 **Line**을 고른 게 의도적이다. 커머스에서 둘은 이렇게 갈린다.

용어의미예시
Line비즈니스 명세의 논리적 항목order line, discount line
RowUI 테이블의 물리적 행CSS class, <tr>

PriceLine(가격 명세 라인)과 결을 맞추려고 CartItem 대신 CartLine을 택했다. Shopify Storefront API도 CartLine·cartLinesAdd를 공식 용어로 쓴다. UI의 행이 아니라 주문 명세의 한 항목이라는 의미를 이름에 담은 것이다.

Price — 구현 디테일이 아니라 도메인 의미로 variant를 묶었다

금액 표시 컴포넌트 Price는 원래 <strong>{amount}원</strong> 한 형태뿐이었고, 할인 색상이나 차감 부호 같은 변형은 호출부마다 인라인 style로 따로 처리하고 있었다. 표시 변형이 늘어날수록(정가 취소선, 할인가 강조, 최종 금액 강조…) 스타일이 호출부에 흩어진다.

여기서 갈림길이 있었다. 변형을 color·size·strikethrough 같은 개별 prop으로 쪼갤까, 아니면 의미 단위 variant 하나로 묶을까.

개별 prop으로 가면 호출부가 <Price strikethrough color="gray" />처럼 **"어떻게 그릴지"**를 매번 지정해야 한다. 디자인이 바뀌어 "정가를 회색 취소선 → 연한 회색"으로 옮기면 모든 호출부를 따라 고쳐야 한다. 그래서 도메인 의미로 묶는 쪽을 택했다.

리팩토링 전에는 할인 색·차감 부호 같은 금액 변형이 이렇게 호출부(OrderLineRow)의 인라인 style에 박혀 있었다.

// 리팩토링 전 — 금액 변형이 호출부에 흩어져 있다
<strong style={{ color: isDiscount ? '#ef4444' : 'var(--text-h)' }}>
  {isDiscount ? '- ' : ''}
  {amount.toLocaleString()}원
</strong>

이걸 도메인 의미 단위 variant 하나로 모았다.

type PriceVariant = 'default' | 'discount' | 'total' // original(정가)·sale(할인가) 등 확장 가능

export function Price({ amount, variant = 'default' }: { amount: number; variant?: PriceVariant }) {
  return (
    <strong className={`price price--${variant}`}>
      {variant === 'discount' ? '- ' : ''}
      {amount.toLocaleString()}원
    </strong>
  )
}

호출부는 <Price variant="discount" />(차감액)·<Price variant="total" />(최종 금액)처럼 역할만 말한다. 취소선이냐 컬러냐는 .price--{variant} CSS로 내려가서, 디자인이 바뀌어도 호출부는 그대로다. 그리고 PriceLine은 인라인 style을 버리고 금액 렌더링을 통째로 Price에 위임한다 — PriceLine은 "라벨 왼쪽·금액 오른쪽" 줄 배치만, 금액을 어떻게 그리느냐는 Price가 단일 출처로 갖는다.

// PriceLine 내부 — 인라인 style 제거, Price에 위임
<Price amount={amount} variant={isDiscount ? 'discount' : 'default'} />

variant 기준을 표현이 아니라 의미로 잡은 덕에, 나중에 sale(내가 내는 값)과 discount(명세에서 빠지는 값)처럼 색이 겹쳐도 의미가 다른 것들을 합치지 않고 따로 둘 자리가 생긴다.

고치지 않기로 한 절반

과제 발제에 이런 문장이 있었다. "과잉 리팩토링도 냄새다. 이번 과제의 절반은 고치지 않겠다는 결정이다." 처음엔 그냥 격언처럼 읽었는데, 막상 계획표를 줄이다 보니 안 고치는 데에도 근거가 든다는 걸 알았다. 미룬 게 아니라 판단한 항목들이다.

  • 전역 상태 / Context — 위 "상태를 내렸다" 절의 결론이 곧 이 결정이다. prop drilling처럼 보였지만 상태의 집을 옮기니 사라졌다. 도입 근거가 없어 안 썼다.
  • React.memo 등 리렌더 최적화 — 측정한 병목이 없는데 메모이제이션부터 까는 건 "부지런한 실수"다. 느려지는 증거가 나오면 그때 본다.
  • 타입에 T/I 접두사 통일 — 처음 계획엔 있었으나 철회했다. TypeScript 핸드북과 typescript-eslint가 I/T 접두사(헝가리안 표기)를 명시적으로 비권장한다. 접두사 없는 현 상태가 오히려 관례에 맞아서, "일관성"을 명분으로 관례를 역행할 뻔한 항목이었다.
  • 소수점 적립금 — 음수 입력은 min={0}으로 경계에서 막았지만(Tier 2), 소수점은 원래 명세가 없다. 고치면 "버그 복구"인지 "동작 변경"인지 모호해서, 현재 동작만 기록하고 리팩토링 범위 밖에 뒀다. 동작 보존이 원칙인 과제에서 임의 변경은 그 자체가 위반이다.
  • Price variant별 컴포넌트 분리(DiscountPrice 등)·as 다형성 — 변형 3~4개 규모엔 과설계라 미뤘다. 변형이 10개를 넘거나 레이아웃 자체가 달라지면 그때 다시 본다.

"안 고침"을 적어 두는 게 중요했던 이유: 다음에 이 코드를 보는 사람(혹은 미래의 나)이 "왜 이건 안 고쳤지?"를 다시 묻지 않게 하려고. 근거 없는 리팩토링을 커밋하지 않는 것처럼, 근거 있는 보류도 기록으로 남긴다.

아쉬운 점 — 안 쓰기로 한 Context, 요구사항을 보니 필요했다

Context를 안 쓴 건 처음엔 분명히 의도적인 선택이었다. 지금 화면 범위에선 입력 상태가 주문서 안에서만 쓰이니 composition으로 충분했고, 근거 없이 전역으로 끌어올리지 않는 게 맞았다. 그런데 과제를 끝내고 기능 요구사항을 끝까지 따라가 보니, 그 결정이 "이번 화면에서만" 옳았다는 게 보였다.

커머스의 주문서 → 결제 흐름은 결제 버튼에서 끝나지 않는다. 주문서에 작성한 모든 내용이, 결제 완료 후 넘어가는 주문 등록(접수) 단계에서 다시 접근 가능해야 한다. 배송지·메모·장바구니·적립금·결제수단 — 지금은 각 section이 서로 다른 관심사로 흩어져 있지만, 주문 데이터를 생성하는 시점엔 전부 한자리에 모여야 한다. 지금 구조에선 이 정보들이 OrderSheet의 지역 상태로 흩어져 있어서, 결제 단계로 통째로 넘기려면 다시 끌어모아야 한다.

그래서 반드시 공유해야 하는 정보만 담는 주문서 상태를 따로 두는 게 맞지 않았을까 싶다. UI 토글(약관 펼침, 배송지 접기, '도서산간 제외' 필터 같은) 지역 상태는 그대로 각 section에 두고, 주문 등록까지 살아남아야 하는 정보만 이런 형태로 묶는 그림이다.

// 주문서 → 결제 등록까지 공유돼야 하는 정보만 묶은 가설
type OrderDraft = {
  delivery: { address: Address; memo: string };
  cart: { productId: string; option: string; quantity: number }[];
  payment: { usedPoint: number; method: PaymentMethod };
};

이쯤 되면 섹션마다 prop으로 내리기보다 Context(혹은 상태 관리 라이브러리)가 더 자연스러운 경계일 수 있다. 다만 이건 아직 가설이다. 이 구조가 커머스 주문서→결제 기능에 정말 적합한지는 결제 등록 단계를 실제로 구현해 봐야 안다. "Context 전에 composition"이 이번 화면에선 맞았지만, 기능이 결제 등록까지 확장되는 순간 다시 따져야 할 결정이라는 것 — 그게 이번에 가장 크게 남은 물음표다.

결국 배운 것 — 정렬과 절제, 둘 다 절반이었다

리팩토링이라고 하면 코드를 고치는 일이라고만 생각했는데, 이번엔 고치기 전에 줄을 세운 것과 안 고칠 것을 정한 것이 각각 절반이었다. 같은 목록을 심각도로 정렬하지 않았다면 컴포넌트 선언 순서 같은 취향 항목과 결제 버그를 같은 무게로 만졌을 거고, 절제하지 않았다면 Context와 메모이제이션을 근거 없이 들이부었을 거다.

실제로 고친 결정들을 한 표로 다시 모으면 이렇다.

한 일진짜 이유효과
finalPrice를 useState → 파생 변수파생값을 state에 담으면 입력 변화가 반영 안 됨흩어진 금액 버그 5개가 한 줄로 해결
입력 상태를 OrderSheet로 내림두 화면이 동시에 안 뜸 → Context 불필요, composition으로 충분폼 편집이 결과 모달을 리렌더 안 함
boolean 5개 → OrderStatus union상호 배타 상태를 타입으로 강제불가능한 상태가 컴파일에서 차단
OrderLineRow → CartLine + PriceLine한 컴포넌트가 두 가지로 변하던 걸 격리영향 범위가 파일 단위로 좁아짐
Price variant를 도메인 의미로표현이 아니라 역할로 부르면 디자인 변경에 안 흔들림인라인 스타일의 단일 출처화

그리고 가장 크게 남은 두 줄.

하나. finalPrice 버그의 정체는 결국 "파생값을 state에 담은 것"이었다. 코드 냄새 목록의 1등이 사실은 안티패턴 하나였고, 그건 내가 규칙으로는 알았지만 데어 본 적은 없던 것이었다. 규칙을 외우는 것과, 그게 결제 금액을 틀리게 만드는 모습을 직접 보는 건 확실히 다른 무게로 남는다.

둘. 가장 고민한 "상태 재설계"의 답이 전역 상태 도입이 아니라 상태를 제자리로 내리기였다는 것. 정리 = 끌어올리기라는 반사신경을 한 번 의심한 게 이번 화면에선 맞았다. 다만 그 절제는 지금 화면 범위에 한정된 정답이었고, 결제 등록까지 기능이 확장되면 결국 그 Context가 필요해질 수 있다 — 위 "아쉬운 점"에 적어 둔, 다음으로 넘긴 숙제다.