본문으로 건너뛰기
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 => {
  // 반올림 규칙과 최소 단위를 이 안에 가둔다
  // ...
};

배운 것

이 프로젝트에서 얻은 가장 큰 교훈은 성능 기법이 아니었습니다. 사용자가 있는 코드와 나만 쓰는 코드는 다른 물건이라는 것입니다. 같은 코드가 사용자가 생기는 순간 갑자기 문제가 됐습니다. 코드는 그대로였고 바뀐 것은 쓰는 사람이었습니다.

그래서 개인 프로젝트는 배포가 끝이 아니라 시작이라고 생각하게 됐습니다. 만들어서 올려두는 것만으로는 얻을 수 있는 게 절반입니다. 공유하고, 반응을 받고, 그 반응 때문에 고치는 과정까지 가야 나머지 절반이 옵니다.

내가 만든 방식대로 쓰는 사람은 나뿐입니다.

이 글이 나온 작업