MJLog
읽는 데 약 13분

공유 상태의 문제는 상태가 아니었다

상태를 소유하는 경계를 따라가며

tech react state-management

목차
  1. 검색어는 그대로였고, 소유자만 바뀌었다
  2. 소유자가 보이지 않으면 수명도 설명하기 어려워진다
  3. 파생 상태도 어떤 경계에 속한다
  4. 컴포넌트가 나뉘었다고 기능까지 나뉘는 것은 아니다
  5. 같은 상품과 같은 구매 흐름은 다르다
  6. 네 문제 뒤에는 하나의 경계가 있었다
  7. 좋은 추상화는 무엇을 감춰야 할까
  8. 참고한 글과 문서

상태를 소유하는 경계를 따라가며

최근 「프론트엔드 상태 관리의 네 가지 난제」라는 글을 읽었다.

글은 프론트엔드에서 반복해서 만나는 네 가지 문제를 다룬다.

  1. 상태의 공유 범위가 바뀌면 표현 방법까지 바뀐다.
  2. 공유 상태의 수명을 자연스럽게 표현하기 어렵다.
  3. 같은 파생 상태를 여러 컴포넌트가 효율적으로 공유하기 어렵다.
  4. 여러 상태와 동작을 기능 단위로 응집시키기 어렵다.

useState로 시작한 값이 props를 거쳐 Context가 되고, 전역 Store에 올린 상태는 페이지를 떠나도 남는다. 같은 파생 Hook을 여러 컴포넌트가 호출하면 계산도 여러 번 생긴다. 하나의 기능을 커스텀 Hook으로 묶어도 여러 컴포넌트가 같은 기능을 사용하려면 다시 Provider가 필요해진다.

원문이 포착한 증상은 모두 실제였다.

그런데 첫 번째 사례를 따라가다가 한 가지가 마음에 걸렸다.

같은 keyword 상태인데
useState → props → Context로
표현 방법이 바뀐다.

코드는 실제로 그렇게 바뀐다.

하지만 검색어가 useState였다가 Context가 된 것일까.

검색어가 표현하는 의미는 처음부터 끝까지 같았다.

현재 입력된 검색어를 저장한다.
검색어를 변경한다.

달라진 것은 검색어 자체가 아니었다.

그 검색 상태를 누가 소유하고, 누구와 함께 사용하느냐가 달라졌다.

이 구분으로 나머지 사례를 다시 읽어 보았다.

그러자 서로 다른 문제처럼 보였던 네 사례에서 비슷한 질문이 반복되고 있었다.

이 상태는 누구의 것인가?


검색어는 그대로였고, 소유자만 바뀌었다

처음에는 검색어가 SearchBox 안에서만 필요하다.

function SearchBox() {
  const [keyword, setKeyword] =
    useState("");

  return (
    <input
      value={keyword}
      onChange={(event) => {
        setKeyword(event.target.value);
      }}
    />
  );
}

나중에는 SearchResult도 같은 검색어를 사용해야 한다.

function SearchPage() {
  const [keyword, setKeyword] =
    useState("");

  return (
    <>
      <SearchBox
        keyword={keyword}
        onKeywordChange={setKeyword}
      />
      <SearchResult keyword={keyword} />
    </>
  );
}

검색어를 읽는 곳이 하나 늘었을 뿐인데 상태 선언 위치와 props, 상태를 얻는 방법이 함께 바뀐다.

여기까지는 원문의 지적이 정확하다.

다만 두 코드에서 실제로 달라진 관계를 보면 조금 다르게 읽힌다.

처음에는 SearchBox가 검색 상태를 소유했다.

SearchBox
└── keyword

두 번째 코드에서는 SearchPage가 검색 상태를 소유한다.

SearchPage
├── keyword
├── SearchBox
└── SearchResult

검색어의 의미는 그대로다.

상태를 소유하는 경계가 SearchBox에서 SearchPage로 이동했다.

하나의 검색어 상태를 검색창에서 상위 검색 페이지로 끌어올려 검색창과 결과가 함께 사용하게 되는 장면

이 차이는 상태를 객체로 옮겨 보면 조금 더 분명해진다.

class SearchState {
  keyword = "";

  setKeyword(keyword: string) {
    this.keyword = keyword;
  }
}

한 검색창만 사용한다면 검색창이 인스턴스를 소유한다.

function SearchBox() {
  const [search] = useState(
    () => new SearchState(),
  );

  return <SearchInput search={search} />;
}

검색 결과도 같은 흐름에 참여한다면 상위 기능이 소유한다.

function SearchPage() {
  const [search] = useState(
    () => new SearchState(),
  );

  return (
    <>
      <SearchInput search={search} />
      <SearchResult search={search} />
    </>
  );
}

트리가 깊어진다면 같은 객체를 Context로 전달할 수 있다.

<SearchContext.Provider value={search}>
  <SearchPageContent />
</SearchContext.Provider>

이 과정에서 검색 상태가 표현하는 것은 바뀌지 않았다.

search.keyword;
search.setKeyword(keyword);

달라진 것은 어느 경계가 하나의 검색 흐름을 소유하는지다.

SearchBox가 소유
→ SearchPage가 소유
→ 더 상위 기능이 소유

Context도 검색 상태의 새로운 형태는 아니다.

더 넓은 경계가 소유한 상태 인스턴스를 하위 컴포넌트에 전달하는 방법이다.

물론 처음 지역 상태였던 값을 여러 컴포넌트가 참여하는 기능 상태로 바꾸는 순간에는 코드가 달라질 수밖에 없다.

프로그램의 의미가 실제로 바뀌었기 때문이다.

SearchBox만의 검색 흐름

SearchBox와 SearchResult가 공유하는 검색 흐름

좋은 추상화가 이 변화를 감춰야 하는 것은 아니다.

다만 소유 경계가 이동했다는 이유로 검색 상태의 의미와 동작까지 다시 정의할 필요는 없다.

공유 범위가 바뀐다는 것은 상태가 다른 종류로 변한다는 뜻이 아니라, 그 상태를 소유하는 경계가 이동한다는 뜻에 더 가까웠다.


소유자가 보이지 않으면 수명도 설명하기 어려워진다

상품 필터를 전역 Store로 만든다고 해보자.

class ProductFilter {
  category: string | null = null;

  selectCategory(
    category: string | null,
  ) {
    this.category = category;
  }

  reset() {
    this.category = null;
  }
}

모듈에 인스턴스를 하나 만든다.

export const productFilter =
  new ProductFilter();

이제 여러 컴포넌트가 필터를 쉽게 사용할 수 있다.

하지만 사용자가 카테고리를 선택하고 페이지를 떠나도 인스턴스는 남아 있다.

페이지가 사라질 때 직접 초기화할 수 있다.

useEffect(() => {
  return () => {
    productFilter.reset();
  };
}, []);

코드는 동작한다.

다만 필터를 만든 곳과 필터의 끝을 알고 있는 곳이 서로 다르다.

모듈
→ 필터 인스턴스를 생성함

ProductPage
→ 이 필터를 언제 초기화해야 하는지 알고 있음

ProductPage가 이 규칙을 기억하지 못하면 필터는 계속 남는다.

같은 필터를 페이지가 소유하게 만들면 관계가 달라진다.

function ProductPage() {
  const [filter] = useState(
    () => new ProductFilter(),
  );

  return (
    <ProductFilterContext.Provider
      value={filter}
    >
      <ProductPageContent />
    </ProductFilterContext.Provider>
  );
}

필터가 표현하는 값과 동작은 그대로다.

다만 이번에는 인스턴스가 페이지의 실행 경계 안에 있다.

ProductPage
└── ProductFilter

Layout이 소유하면 여러 페이지를 오가는 동안 유지할 수 있고, 애플리케이션이 소유하면 전역 상태가 된다.

페이지가 소유
→ 페이지와 함께 끝남

Layout이 소유
→ 하위 페이지를 이동해도 유지됨

애플리케이션이 소유
→ 애플리케이션이 실행되는 동안 유지됨

전역 상태에 수명이 없는 것은 아니었다.

전역에 놓는 순간 상태의 소유 경계와 수명도 애플리케이션까지 넓어진 것이다.

문제는 그 수명이 필터가 의미적으로 유효한 기간과 일치하지 않았다는 데 있다.

외부 리소스가 붙으면 수명의 기준은 더 다양해진다.

WebSocket으로 실시간 시세를 가져오는 위젯을 생각해 보자.

위젯이 화면에 나타날 때 연결하고, 시세를 보여주는 위젯이 모두 사라졌을 때 연결을 끊는 것은 자연스럽다.

실시간 시세 위젯의 화면과 연결이 같은 시점에 시작하고 끝나는 수명 관계

이 경우에는 화면의 수명과 연결의 수명이 함께 끝난다.

하지만 모든 상태가 화면과 함께 끝나는 것은 아니다.

checkout을 진행하는 도중 구매 요약 화면에서 결제 화면으로 이동했다고 해보자.

구매 상태를 읽던 컴포넌트는 사라지지만, checkout이 진행 중이라면 선택한 옵션과 수량, 주문 정보까지 버리면 안 된다.

구매 요약 화면이 먼저 끝나도 Checkout 상태는 완료 시점까지 이어지는 수명 관계

화면은 사라졌지만 checkout 상태는 남아야 한다.

반대 상황도 있다.

사용자가 프로필 화면에서 로그아웃 버튼을 누른 직후에는 이전 화면이 잠시 남아 있을 수 있다.

화면은 아직 사용자 정보를 읽고 있더라도, logout이 발생한 순간 이전 인증 상태는 더 이상 유효하지 않다.

프로필 화면이 남아 있어도 Logout 시점에 인증 상태가 먼저 끝나는 수명 관계

화면은 남아 있지만 인증 상태는 끝나야 한다.

세 사례를 함께 놓으면 상태를 지금 누가 읽고 있는지와 그 상태가 아직 유효한지는 같은 질문이 아니라는 점이 보인다.

Observation ≠ Ownership

관찰 여부는 지금 이 상태를 읽는 컴포넌트가 있는지를 말한다.

소유 경계는 이 상태가 어떤 기능에 속해 있고, 그 기능이 아직 진행 중인지를 말한다.

어떤 상태는 화면이 사라질 때 끝난다.

어떤 상태는 마지막 관찰자가 사라질 때 끝난다.

checkout 완료나 logout 같은 도메인 사건으로 끝나는 상태도 있고, 더 이상 사용되지 않더라도 일정 시간 보존해야 하는 상태도 있다.

그래서 공유 상태에 자연스러운 수명이 없다는 표현은 절반만 맞는 것처럼 보였다.

모든 공유 상태에 적용할 수 있는 하나의 자연스러운 수명이 없는 것은 아닐까.

상태 관리 도구는 여러 수명 신호를 표현할 수 있다.

하지만 어떤 신호가 이 상태의 끝인지는 제품이 결정해야 한다.

수명은 상태 값에서 자동으로 나오지 않는다.

그 상태를 소유하는 실행 경계가 무엇이며, 그 경계가 언제 끝나는지를 알아야 비로소 정할 수 있다.


파생 상태도 어떤 경계에 속한다

상품 목록을 현재 카테고리로 필터링한다고 해보자.

function useVisibleProducts() {
  const {
    data: products = [],
  } = useQuery({
    queryKey: ["products"],
    queryFn: fetchProducts,
  });

  const { category } =
    useProductFilter();

  return products.filter(
    (product) =>
      category === null ||
      product.category === category,
  );
}

상품 목록과 상품 개수가 이 Hook을 각각 호출한다.

function ProductList() {
  const products =
    useVisibleProducts();

  return (
    <ProductItems products={products} />
  );
}

function ProductCount() {
  const products =
    useVisibleProducts();

  return (
    <span>{products.length}개</span>
  );
}

두 컴포넌트가 원하는 것은 같은 visibleProducts다.

하지만 계산은 각 Hook 호출에 속해 있기 때문에 두 개가 생긴다.

ProductList
└── visibleProducts A

ProductCount
└── visibleProducts B

이 현상을 중복 계산의 문제로만 보면 계산을 어떻게 캐시할지부터 고민하게 된다.

하지만 앞의 사례와 같은 질문을 던질 수도 있다.

visibleProducts는 누구의 것인가?

상품 목록과 현재 필터, 그로부터 계산된 목록이 하나의 상품 목록 기능에 속한다면 같은 경계 안에 둘 수 있다.

class ProductListState {
  products: Product[] = [];
  category: string | null = null;

  get visibleProducts() {
    return this.products.filter(
      (product) =>
        this.category === null ||
        product.category ===
          this.category,
    );
  }
}

ProductListProductCount가 같은 상품 목록에 참여한다면 같은 인스턴스를 사용한다.

ProductListState
├── products
├── category
└── visibleProducts
    ├── ProductList
    └── ProductCount

반대로 추천 목록과 검색 결과는 같은 계산식을 사용하더라도 서로 다른 파생 상태다.

const recommendation =
  new ProductListState();

const searchResult =
  new ProductListState();
recommendation.visibleProducts

searchResult.visibleProducts

둘 다 상품을 카테고리로 필터링한다.

하지만 서로 다른 상품 목록 기능에 속한다.

계산식이 같다는 이유만으로 같은 상태가 되는 것은 아니다.

파생 상태의 동일성에는 계산식뿐 아니라 어느 상태 인스턴스에서 나온 값인지도 포함된다.

원문의 사례에서 파생 계산이 여러 개 생긴 이유도 파생 상태를 표현할 수 없었기 때문만은 아니었다.

파생 상태를 소비자인 컴포넌트 Hook에 두면서 각 소비자가 자신의 파생 상태를 하나씩 소유하게 된 것이다.

파생 상태도 계산식 위에 떠 있는 값이 아니라, 어떤 실행 경계에 속한 값이었다.

여기까지는 순수한 파생 값에 관한 이야기다.

파생 결과에 따라 요청을 만들거나 구독을 연결한다면 요청을 언제 취소하고 얼마나 보존할지, 구독을 언제 끊을지까지 함께 정해야 한다.

그 순간에는 파생 관계뿐 아니라 외부 리소스의 수명도 문제에 들어온다.

원문이 지적한 불필요한 요청과 구독 재연결 문제는 이 경계에서 여전히 남는다.

다만 순수한 파생 값과 수명을 가진 외부 리소스를 하나의 문제로 묶으면, 이미 표현할 수 있는 부분과 여전히 어려운 부분이 함께 흐려지는 것은 아닐까.


컴포넌트가 나뉘었다고 기능까지 나뉘는 것은 아니다

상품 구매 기능에는 여러 값과 동작이 함께 움직인다.

선택한 옵션
수량
재고
가격
구매 요청

이를 하나의 Hook으로 묶는 것은 자연스럽다.

function usePurchase(
  productId: string,
) {
  const [optionId, setOptionId] =
    useState<string | null>(null);

  const [quantity, setQuantity] =
    useState(1);

  const stock = useQuery({
    queryKey: [
      "stock",
      productId,
      optionId,
    ],
    queryFn: () =>
      fetchStock(productId, optionId!),
    enabled: optionId !== null,
  });

  const price = calculatePrice(
    productId,
    optionId,
    quantity,
  );

  const purchase = useMutation({
    mutationFn: () =>
      purchaseProduct({
        productId,
        optionId,
        quantity,
      }),
  });

  return {
    optionId,
    setOptionId,
    quantity,
    setQuantity,
    stock,
    price,
    purchase,
  };
}

이 Hook 안에서는 재고와 가격, 구매 요청이 같은 옵션과 수량을 사용한다.

기능의 제약도 한곳에 있다.

문제는 옵션 선택기와 가격, 구매 버튼이 이 Hook을 각각 호출할 때 생긴다.

function OptionSelector({ productId }) {
  const purchase =
    usePurchase(productId);
}

function PurchasePrice({ productId }) {
  const purchase =
    usePurchase(productId);
}

function BuyButton({ productId }) {
  const purchase =
    usePurchase(productId);
}

같은 productId를 전달했지만 세 컴포넌트는 같은 구매 흐름을 사용하지 않는다.

각 Hook 호출이 자신의 옵션과 수량, Query와 Mutation을 만든다.

OptionSelector
└── Purchase A

PurchasePrice
└── Purchase B

BuyButton
└── Purchase C

하나의 기능을 컴포넌트 셋으로 잘못 나눈 것이 아니다.

서로 다른 세 기능 인스턴스를 만든 것이다.

구매 상태를 하나의 세션으로 보면 구분이 더 분명해진다.

class PurchaseSession {
  optionId: string | null = null;
  quantity = 1;

  constructor(
    readonly productId: string,
  ) {}

  selectOption(optionId: string) {
    this.optionId = optionId;
  }

  setQuantity(quantity: number) {
    this.quantity = quantity;
  }

  get price() {
    return calculatePrice(
      this.productId,
      this.optionId,
      this.quantity,
    );
  }

  get purchaseInput() {
    return {
      productId: this.productId,
      optionId: this.optionId,
      quantity: this.quantity,
    };
  }
}

이 객체가 Query와 Mutation을 대체하는 것은 아니다.

옵션과 수량, 가격과 구매 입력이 하나의 구매 흐름에 속한다는 경계를 드러낼 뿐이다.

재고 Query와 구매 Mutation도 이 세션이 가진 상태를 기준으로 동작한다.

하나의 구매 화면에서는 하나의 세션을 만든다.

function PurchasePanel({
  productId,
}: {
  productId: string;
}) {
  const purchase =
    usePurchase(productId);

  return (
    <>
      <OptionSelector
        purchase={purchase}
      />
      <PurchasePrice
        purchase={purchase}
      />
      <BuyButton
        purchase={purchase}
      />
    </>
  );
}

컴포넌트는 세 개지만 구매 흐름은 하나다.

PurchaseSession
├── optionId
├── quantity
├── stock
├── price
├── purchase

├── OptionSelector
├── PurchasePrice
└── BuyButton

여기서 컴포넌트 경계와 기능의 경계가 갈라진다.

컴포넌트 경계

기능의 소유 경계

기능을 응집한다는 것은 모든 코드를 한 Hook이나 class 안에 밀어 넣는 일이 아닐 수 있다.

서로 다른 컴포넌트에 나뉘어 있더라도 어떤 상태와 계산과 동작이 같은 실행 인스턴스에 속하는지 유지하는 일이 더 중요할 수 있다.

같은 상품과 같은 구매 흐름은 다르다

본문 구매 폼과 빠른 구매 모달이 같은 상품을 보여준다고 해보자.

같은 상품을 보여주지만 본문 구매 폼과 빠른 구매 모달이 서로 다른 옵션과 수량을 유지하는 장면

const mainPurchase =
  new PurchaseSession(productId);

const quickPurchase =
  new PurchaseSession(productId);

두 세션은 같은 상품을 다룬다.

하지만 옵션과 수량은 독립적이다.

같은 productId

같은 PurchaseSession

productId는 무엇을 구매하는지를 식별한다.

PurchaseSession은 어느 구매 흐름에서 옵션과 수량을 선택하고 있는지를 식별한다.

같은 상품을 다룬다는 이유만으로 전역 Store 하나를 찾아오면 두 흐름이 의도치 않게 합쳐질 수 있다.

getPurchaseStore(productId);

본문 구매 폼에서 수량을 변경했는데 빠른 구매 모달의 수량까지 바뀌는 식이다.

반대로 옵션 선택기와 가격, 구매 버튼이 같은 구매 흐름에 참여한다면 동일한 세션을 사용해야 한다.

라이브러리는 같은 productId를 받은 두 UI가 같은 세션이어야 하는지 알 수 없다.

그것은 제품이 정해야 하는 Identity다.

컴포넌트가 깊이 떨어져 있다면 같은 PurchaseSession을 Context로 전달할 수 있다.

하지만 Provider가 기능의 경계를 만드는 것은 아니다.

이미 결정된 기능 인스턴스를 컴포넌트 트리 안에서 전달할 뿐이다.

Provider가 필요하다는 비용과 기능이 응집되어 있는지는 서로 다른 문제일 수 있다.


네 문제 뒤에는 하나의 경계가 있었다

원문의 네 사례는 서로 다른 문제처럼 보였다.

하지만 상태의 의미를 고정한 채 다시 따라가 보니, 모두 하나의 경계를 다른 방향에서 묻고 있었다.

공유 범위
= 소유 경계를 어디까지 넓힐 것인가

수명
= 그 경계는 언제 끝나는가

파생 상태
= 어느 경계가 이 계산을 소유하는가

기능 응집
= 어떤 값과 동작이 같은 경계에 속하는가

Identity, Ownership, Scope, Lifetime은 이 경계를 서로 다른 방향에서 설명하는 말에 가까웠다.

검색어의 의미를 바꾸지 않고도 소유자를 SearchBox에서 SearchPage로 옮길 수 있다.

상품 필터의 의미를 바꾸지 않고도 애플리케이션이 소유하던 인스턴스를 페이지가 소유하게 할 수 있다.

같은 필터식을 사용하면서도 추천 목록과 검색 결과가 서로 다른 파생 상태를 가질 수 있다.

같은 상품을 구매하면서도 본문 구매 폼과 빠른 구매 모달이 서로 다른 구매 세션을 가질 수 있다.

원문이 포착한 증상은 모두 실제였다.

다만 그 증상들을 하나의 상태 관리 한계로 묶으면, 하나의 새로운 추상화가 네 문제를 동시에 해결해야 하는 것처럼 보인다.

실제로는 각 사례가 묻는 질문이 달랐다.

Context는 이미 정해진 공유 범위를 코드에 표현한다.

Store는 하나의 상태 인스턴스 안에 값과 동작, 파생 관계를 묶는다.

Query와 Mutation은 서버 상태와 비동기 작업의 생명주기를 다룬다.

그러나 어떤 상태가 하나의 인스턴스인지, 누가 그 인스턴스를 소유하며 언제 그 경계가 끝나는지는 제품의 의미에서 나온다.

도구는 결정된 경계를 표현할 수는 있어도, 그 경계를 대신 정해 주지는 않는다.


좋은 추상화는 무엇을 감춰야 할까

나는 한동안 좋은 상태 관리 추상화라면 공유 범위가 달라져도 코드가 변하지 않아야 한다고 생각했다.

Provider를 추가하거나 Store의 생성 위치를 옮겨야 한다면 기존 추상화가 충분하지 않은 것처럼 보였다.

하지만 두 컴포넌트가 각자의 상태를 사용하다가 하나의 상태를 공유하게 되었다면 프로그램의 의미가 실제로 달라진 것이다.

어느 경계가 그 상태를 소유하는지 나타내는 코드는 필요하다.

페이지를 떠날 때 필터를 버릴지, checkout이 끝날 때까지 구매 상태를 유지할지도 누군가는 결정해야 한다.

같은 상품을 보여주는 두 구매 화면이 옵션과 수량까지 공유해야 하는지도 제품이 정해야 한다.

이 결정들은 없앨 수 없다.

전역 Store나 자동 registry 뒤에 숨길 수 있을 뿐이다.

좋은 추상화는 이런 결정을 대신 내려주는 것이 아니라, 결정이 필요한 위치를 한곳으로 모으고 관계없는 코드까지 번지지 않게 하는 것에 가까울 것이다.

공유 범위가 바뀌면 소유 경계는 이동할 수 있다.

하지만 검색 상태의 의미와 동작까지 다시 작성할 필요는 없다.

수명 정책이 바뀌면 owner와 종료 신호는 달라질 수 있다.

하지만 모든 소비자가 cleanup을 기억할 필요는 없다.

파생 상태가 추가되면 기능의 계산 관계는 달라질 수 있다.

하지만 각 컴포넌트가 같은 계산을 따로 소유할 필요는 없다.

독립된 구매 흐름이 하나 더 생기면 인스턴스도 하나 더 필요하다.

하지만 기능 내부의 제약을 다시 여러 Hook의 인자로 풀어놓을 필요는 없다.

원문에서 발견한 네 증상은 모두 실제였다.

다만 그것들이 하나의 새로운 상태 관리 도구가 필요한 네 가지 난제라기보다, 상태의 의미와 그 상태 인스턴스의 소유 경계를 구분하지 않았을 때 서로 다른 위치에서 나타나는 증상은 아니었을까.

공유 상태를 어렵게 만든 것은 값을 공유하는 방법이 아니라,

그 상태가 누구의 것이며 어디까지가 하나의 실행 경계인지 정하지 않은 채 공유하기 시작한 것이 아니었을까?


참고한 글과 문서

이 글에서 더 궁금한 점이 있나요?

민제님이 공개한 글과 프로젝트, 프로필을 보고 답해요. 질문과 AI 답변, 익명 방문자 ID는 개선을 위해 90일간 저장됩니다.