공공임대주택에는 보증금을 더 내고 월세를 낮추거나 그 반대로 바꾸는 '상호전환' 제도가 있습니다. 저는 이 계산을 여러 조합으로 해 보고 싶었는데 마땅한 도구가 없어서 직접 만들었습니다. 아이디어가 떠오른 날 바로 개발을 시작해서 며칠 만에 배포했습니다.
코드는 솔직히 엉망이었습니다. 페이지 하나에 입력 필드 여러 개와 계산 로직과 결과 표시가 전부 들어 있었습니다. 컴포넌트를 나누지 않았고, 상태는 맨 위에서 useState로 전부 들고 있었습니다. 혼자 쓸 거라고 생각했으니 문제 될 게 없었습니다.
공유했더니 사람들이 왔다
배포하고 나서 공공임대주택 관련 네이버 카페에 링크를 올렸습니다. 반응이 예상보다 좋았습니다. 댓글이 달리고, 재방문이 생기고, 개선 요청도 들어왔습니다. 개인 프로젝트를 만들고 실제로 누가 쓰는 걸 본 건 처음이었습니다.
그리고 문제 제보가 왔습니다. "숫자 빨리 입력하면 렉이 걸려요."
제 컴퓨터에서는 멀쩡했습니다. 다시 생각해 보니 저는 값을 천천히 하나씩 넣어 보는 사람이었고, 실제 사용자는 계약서를 옆에 두고 숫자를 빠르게 타이핑하는 사람이었습니다. 제가 만든 방식대로 쓰는 사람은 저뿐이었던 겁니다.
원인: 한 글자마다 전체가 다시 그려졌다
구조가 이랬습니다.
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 스토어로 옮기고, 각 컴포넌트가 자기에게 필요한 필드만 구독하게 했습니다. 이제 부모가 리렌더되지 않으므로 값이 바뀐 컴포넌트만 다시 그려집니다.
// 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>
);여기서 한 번 더 틀렸던 것
결과 계산을 이렇게 했다가 아무것도 나아지지 않은 적이 있습니다.
// 나쁨: 참조가 매번 달라져 항상 리렌더된다
const { deposit, rent, rate } = useCalcStore((s) => ({
deposit: s.deposit,
rent: s.rent,
rate: s.rate,
}));selector가 객체를 새로 만들어 반환하면 Zustand는 매번 '값이 바뀌었다'고 판단합니다. 원시값을 각각 구독하거나, 묶어야 한다면 얕은 비교를 명시해야 합니다.
import { useShallow } from "zustand/react/shallow";
const { deposit, rent, rate } = useCalcStore(
useShallow((s) => ({ deposit: s.deposit, rent: s.rent, rate: s.rate })),
);계산은 화면 밖으로
리팩터링을 하면서 하나 더 정리한 것이 있습니다. 계산 로직이 컴포넌트 안에 흩어져 있었는데, 이걸 순수 함수로 빼냈습니다. 결과 테이블과 차트와 요약 카드가 각자 조금씩 다르게 계산하고 있었던 것을 그제야 발견했습니다. 반올림 처리가 한 곳만 달랐습니다.
export const convertDeposit = (input: ConvertInput): ConvertResult => {
// 반올림 규칙과 최소 단위를 이 안에 가둔다
// ...
};타이핑이 밀리는 현상이 사라졌고, 화면마다 다르던 숫자가 하나로 맞춰졌습니다. 함수로 빼내니 계산 규칙에 테스트를 붙일 수 있게 된 것도 컸습니다.
배운 것
이 프로젝트에서 가장 크게 남은 건 성능 기법이 아니었습니다. 사용자가 있는 코드와 혼자 쓰는 코드는 다르다는 점입니다. 같은 코드가 사용자가 생기자 갑자기 문제가 됐습니다. 코드는 그대로였고 바뀐 건 쓰는 사람이었습니다.
그래서 개인 프로젝트는 배포하고 끝내기보다 그 뒤가 더 중요하다고 생각하게 됐습니다. 만들어서 올려 두는 것만으로는 절반이고, 공유하고, 반응을 받고, 그 반응 때문에 고치는 과정까지 가야 나머지 절반이 옵니다.