Jotai 파생 atom으로 useEffect 안티패턴 제거하기

sy.kim·2026년 2월 20일
post-thumbnail

서론

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를 연쇄적으로 트리거하기 때문입니다.

사이드 이펙트가 사이드 이펙트를 낳는 구조였죠.

파생 상태(Derived Atom) 추가

해결 방향은 단순합니다.
"계산을 effect에서 하지 말고, atom 정의 시점에 명세하자"

Jotai는 Read Only atom 을 통해 다른 atom의 값을 구독하고, 자동으로 재계산되는 파생 atom을 만들 수 있어요.

https://tutorial.jotai.org/quick-start/readonly-atoms

먼저 계산의 입력이 되는 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]);

효율성 측면에서 얻은 점

1. 계산 횟수 감소

기존에는 데이터 로딩 완료 시 계산 로직인 calcDiscountPrice() 가 무조건 두 번 실행 됐습니다.
파생 atom은 구독하는 atom이 변경될 때만 재계산 됩니다.

2. 데이터 흐름이 단방향으로 정리

// 기존: 상태 → effect → 계산 → 상태 → effect → 계산 (순환)
discountIsLoading 변경
  → calcDiscountPrice() + setSelectedCouponId()
    → [selectedCouponId 변경]
      → calcDiscountPrice() (중복)

// 개선: 입력 atom 변경 → 파생 atom 자동 계산 (단방향)
discountCalculationData 변경
selectedCouponId 변경
  → finalPriceData 자동 재계산 (1회)

side effect 남용으로 순환하던 데이터 흐름이 단방향으로 정리됐습니다.

3. 계산 로직의 테스트 가능성

계산 로직이 calcFinalPriceResult() 함수로 분리되어,
atom 의존성 없이 단독으로 테스트할 수 있습니다.


상태 관리 라이브러리를 쓸 때,
"어떻게 저장할까"보다 "어떻게 계산을 정의할까"를 먼저 생각하고
파생 상태가 그 답이 되는 경우가 될 수 있음을 유념 해야 겠습니다.

profile
프론트엔드 개발자

0개의 댓글