Jotai를 사용하다 보면 "atom에 값을 저장하고, 컴포넌트에서 그 값을 읽는다"는 단순한 흐름에서 시작해요.
그러다 보니 서로 다른 atom의 값을 조합해서 새로운 값을 만들어야 할 때, useEffect로 계산 후 또 다른 atom에 저장하는 안티패턴을 택하게 되는 경우가 있습니다.
저도 그랬던 경험이 있어 파생 atom으로 코드를 개선한 경험을 공유합니다.
상품 예약 화면에서 최종 할인가를 계산하는 ProductDiscount 컴포넌트가 있었어요.
calcDiscountPrice() 라는 계산 함수가 두 개의 useEffect에서 각각 호출되고 있었습니다.
// ❌ 문제 코드
// 계산 로직
const calcDiscountPrice = () => {
... 계산값
setFinalPriceData({ percent: ..., amount: finalPrice });
};
// 1번 실행: 데이터 로딩 완료 시
useEffect(() => {
if (discountIsLoading === false) {
calcDiscountPrice(); // 🔴 1회
setSelectedCouponId(firstValidCoupon); // → selectedCouponId 변경!
}
}, [discountIsLoading, period]);
// 2번 실행: 선택한 쿠폰이 바뀌면 또 실행
useEffect(() => {
calcDiscountPrice(); // 🔴 2회 (중복 계산)
}, [selectedCouponId]);
discountIsLoading === false 시점에 첫 번째 effect가 setSelectedCouponId를 호출하고, 이 상태 변경이 두 번째 effect를 연쇄적으로 트리거하기 때문입니다.
사이드 이펙트가 사이드 이펙트를 낳는 구조였죠.
해결 방향은 단순합니다.
"계산을 effect에서 하지 말고, atom 정의 시점에 명세하자"
Jotai는 Read Only atom 을 통해 다른 atom의 값을 구독하고, 자동으로 재계산되는 파생 atom을 만들 수 있어요.
먼저 계산의 입력이 되는 atom을 정의합니다.
// 입력 atom
export const discountCalculationData = atom(null);
export const selectedCouponId = atom(null);
이제 finalPriceData는 입력 atom들을 구독하는 파생 atom이 됩니다.
// ✅ 파생 atom: discountCalculationData나 selectedCouponId가 바뀌면 자동 재계산
export const finalPriceData = atom((get) => {
const calcData = get(discountCalculationData);
if (calcData === null) return { percent: 0, amount: 0 };
const { priceData, ... } = calcData;
// 분리된 계산 로직
return calcFinalPriceResult(priceData);
});
컴포넌트의 두 useEffect는 하나로 합쳐집니다.
// ✅ 개선 코드: useEffect 1개, calcDiscountPrice 함수 없음
useEffect(() => {
if (discountIsLoading === false) {
setDiscountCalculationData({
// data fetching으로 얻은 계산 값들
priceData,
...
});
setSelectedCouponId(firstValidCouponId); // 이후 파생 atom이 자동 재계산
}
}, [discountIsLoading, period]);
기존에는 데이터 로딩 완료 시 계산 로직인 calcDiscountPrice() 가 무조건 두 번 실행 됐습니다.
파생 atom은 구독하는 atom이 변경될 때만 재계산 됩니다.
// 기존: 상태 → effect → 계산 → 상태 → effect → 계산 (순환)
discountIsLoading 변경
→ calcDiscountPrice() + setSelectedCouponId()
→ [selectedCouponId 변경]
→ calcDiscountPrice() (중복)
// 개선: 입력 atom 변경 → 파생 atom 자동 계산 (단방향)
discountCalculationData 변경
selectedCouponId 변경
→ finalPriceData 자동 재계산 (1회)
side effect 남용으로 순환하던 데이터 흐름이 단방향으로 정리됐습니다.
계산 로직이 calcFinalPriceResult() 함수로 분리되어,
atom 의존성 없이 단독으로 테스트할 수 있습니다.
상태 관리 라이브러리를 쓸 때,
"어떻게 저장할까"보다 "어떻게 계산을 정의할까"를 먼저 생각하고
파생 상태가 그 답이 되는 경우가 될 수 있음을 유념 해야 겠습니다.