본문으로 건너뛰기
DEVKBS
개발 노트
성능읽는 데 약 4분

혼자 쓰던 계산기에 사용자가 생겼을 때

한 파일에 다 밀어 넣은 계산기를 네이버 카페에 공유했더니 사람들이 쓰기 시작했습니다. 그때부터 코드가 문제가 됐습니다.

#React#Zustand#리렌더#개인 프로젝트

공공임대주택에는 보증금을 더 내고 월세를 낮추거나 그 반대로 바꾸는 '상호전환' 제도가 있습니다. 저는 이 계산을 여러 조합으로 해 보고 싶었는데 마땅한 도구가 없어서 직접 만들었습니다. 아이디어가 떠오른 날 바로 개발을 시작해서 며칠 만에 배포했습니다.

코드는 솔직히 엉망이었습니다. 페이지 하나에 입력 필드 여러 개와 계산 로직과 결과 표시가 전부 들어 있었습니다. 컴포넌트를 나누지 않았고, 상태는 맨 위에서 useState로 전부 들고 있었습니다. 혼자 쓸 거라고 생각했으니 문제 될 게 없었습니다.

공유했더니 사람들이 왔다

배포하고 나서 공공임대주택 관련 네이버 카페에 링크를 올렸습니다. 반응이 예상보다 좋았습니다. 댓글이 달리고, 재방문이 생기고, 개선 요청도 들어왔습니다. 개인 프로젝트를 만들고 실제로 누가 쓰는 걸 본 건 처음이었습니다.

그리고 문제 제보가 왔습니다. "숫자 빨리 입력하면 렉이 걸려요."

제 컴퓨터에서는 멀쩡했습니다. 다시 생각해 보니 저는 값을 천천히 하나씩 넣어 보는 사람이었고, 실제 사용자는 계약서를 옆에 두고 숫자를 빠르게 타이핑하는 사람이었습니다. 제가 만든 방식대로 쓰는 사람은 저뿐이었던 겁니다.

원인: 한 글자마다 전체가 다시 그려졌다

구조가 이랬습니다.

tsx처음 구조 — 모든 값이 페이지 컴포넌트에 있다
const Page = () => {
  const [deposit, setDeposit] = useState(0);
  const [rent, setRent] = useState(0);
  const [rate, setRate] = useState(0);
  // ...

  const result = calculate(deposit, rent, rate);

  return (
    <form>
      <DepositInput value={deposit} onChange={setDeposit} />
      <RentInput value={rent} onChange={setRent} />
      <RateInput value={rate} onChange={setRate} />
      <ResultTable result={result} />
      <ComparisonChart result={result} />
      <GuideSection />
    </form>
  );
};

보증금 입력란에 한 글자를 치면 Page가 리렌더되고, 그 아래 전부가 다시 그려집니다. 입력과 아무 상관 없는 안내 섹션까지. 여기에 결과 테이블과 차트처럼 무거운 컴포넌트가 붙어 있으니, 빠르게 타이핑하면 렌더가 입력을 따라오지 못했습니다.

고친 방식: 상태를 밖으로 빼고 selector로 끊는다

값을 Zustand 스토어로 옮기고, 각 컴포넌트가 자기에게 필요한 필드만 구독하게 했습니다. 이제 부모가 리렌더되지 않으므로 값이 바뀐 컴포넌트만 다시 그려집니다.

tsx고친 구조 — 각자 필요한 것만 구독한다
// store
export const useCalcStore = create<CalcState>((set) => ({
  deposit: 0,
  rent: 0,
  rate: 0,
  setDeposit: (deposit) => set({ deposit }),
  setRent: (rent) => set({ rent }),
  setRate: (rate) => set({ rate }),
}));

// 입력 컴포넌트 — 자기 필드만 본다
const DepositInput = () => {
  const deposit = useCalcStore((s) => s.deposit);
  const setDeposit = useCalcStore((s) => s.setDeposit);
  return <input value={deposit} onChange={(e) => setDeposit(+e.target.value)} />;
};

// 페이지는 값을 들고 있지 않으므로 리렌더되지 않는다
const Page = () => (
  <form>
    <DepositInput />
    <RentInput />
    <RateInput />
    <ResultTable />
    <ComparisonChart />
    <GuideSection />  {/* 입력해도 이제 다시 그려지지 않는다 */}
  </form>
);

여기서 한 번 더 틀렸던 것

결과 계산을 이렇게 했다가 아무것도 나아지지 않은 적이 있습니다.

tsx함정 — 매 렌더마다 새 객체를 반환한다
// 나쁨: 참조가 매번 달라져 항상 리렌더된다
const { deposit, rent, rate } = useCalcStore((s) => ({
  deposit: s.deposit,
  rent: s.rent,
  rate: s.rate,
}));

selector가 객체를 새로 만들어 반환하면 Zustand는 매번 '값이 바뀌었다'고 판단합니다. 원시값을 각각 구독하거나, 묶어야 한다면 얕은 비교를 명시해야 합니다.

tsx
import { useShallow } from "zustand/react/shallow";

const { deposit, rent, rate } = useCalcStore(
  useShallow((s) => ({ deposit: s.deposit, rent: s.rent, rate: s.rate })),
);

계산은 화면 밖으로

리팩터링을 하면서 하나 더 정리한 것이 있습니다. 계산 로직이 컴포넌트 안에 흩어져 있었는데, 이걸 순수 함수로 빼냈습니다. 결과 테이블과 차트와 요약 카드가 각자 조금씩 다르게 계산하고 있었던 것을 그제야 발견했습니다. 반올림 처리가 한 곳만 달랐습니다.

ts계산은 화면과 분리한다 — 테스트도 쉬워진다
export const convertDeposit = (input: ConvertInput): ConvertResult => {
  // 반올림 규칙과 최소 단위를 이 안에 가둔다
  // ...
};

타이핑이 밀리는 현상이 사라졌고, 화면마다 다르던 숫자가 하나로 맞춰졌습니다. 함수로 빼내니 계산 규칙에 테스트를 붙일 수 있게 된 것도 컸습니다.

배운 것

이 프로젝트에서 가장 크게 남은 건 성능 기법이 아니었습니다. 사용자가 있는 코드와 혼자 쓰는 코드는 다르다는 점입니다. 같은 코드가 사용자가 생기자 갑자기 문제가 됐습니다. 코드는 그대로였고 바뀐 건 쓰는 사람이었습니다.

그래서 개인 프로젝트는 배포하고 끝내기보다 그 뒤가 더 중요하다고 생각하게 됐습니다. 만들어서 올려 두는 것만으로는 절반이고, 공유하고, 반응을 받고, 그 반응 때문에 고치는 과정까지 가야 나머지 절반이 옵니다.

이 글이 나온 작업