본문으로 건너뛰기

상태의 경계와 서버 상태 분리 - 디자인 패턴, 시스템 수준 편 ep.07

같은 값이 여섯 곳에 있는데, 그중 어느 것이 진짜인지 아무도 모른다.


이 경계는 무엇을 청구하는가

이제부터는 값이 움직입니다. 앞의 세 편이 코드가 움직이는 이야기였다면, 이번에는 데이터가 어디에 머무는지를 다룹니다.

상품 목록 화면에 필터가 있습니다. 사용자가 카테고리, 가격 범위, 정렬 순서 필터를 바꿉니다. 이 값은 어디에 저장될까요.

실제 코드베이스를 열어 보면 여섯 군데에 흩어져 있는 경우가 흔합니다. 컴포넌트의 useState에 하나, 전역 스토어에 하나, URL 쿼리스트링에 하나, React Query 캐시 키에 하나, sessionStorage에 하나, 서버의 사용자 설정에 하나씩 흩어져 있습니다.

각각을 추가한 데에는 이유가 있었습니다. 새로고침해도 유지되게, 링크를 공유할 수 있게, 다른 화면에서도 보이게, 다음 방문에도 남게 하기 위해서였습니다. 하나씩 보면 모두 타당합니다.

문제는 그 여섯이 어긋났을 때 어느 것이 진짜인지 정하는 규칙이 없다는 점입니다.

이 경계가 청구하는 것은 동기화 비용입니다. 상태를 한 곳에만 두면 다른 자리에서 못 씁니다. 그런데 여러 곳에 두면 맞춰야 합니다.

상태의 경계가 답하는 질문은 하나입니다. 이 값의 소유자는 누구인가.


이 경계에 붙어 있는 이름들

여섯 개의 자리

상태를 둘 수 있는 곳은 대략 여섯 곳입니다. 각각 수명과 범위가 다릅니다.

위치수명공유 범위새로고침링크 공유
컴포넌트 로컬언마운트까지그 컴포넌트사라짐안 됨
전역 스토어탭이 닫힐 때까지앱 전체사라짐안 됨
URL페이지 이동까지앱 전체유지됨
sessionStorage탭이 닫힐 때까지같은 탭유지안 됨
localStorage지울 때까지같은 브라우저유지안 됨
서버영구모든 기기유지계정 기준

이 표를 거꾸로 읽으면 선택 기준이 나옵니다. “이 값을 링크로 공유해야 하는가”에 예라면 URL밖에 답이 없습니다. 나머지 자리도 같은 방식으로 걸러집니다. 위에서부터 내려가며 첫 번째로 맞는 곳에 둡니다.

  1. 링크로 공유되어야 한다면 URL입니다.
  2. 다른 기기에서도 보여야 한다면 서버입니다.
  3. 새로고침 후에도 남아야 한다면 localStorage나 sessionStorage입니다.
  4. 여러 화면이 함께 봐야 한다면 전역 스토어입니다.
  5. 그 외에는 컴포넌트 로컬입니다.

대부분의 상태 관리 부채는 “일단 전역에 두고 나중에 정리하자”에서 시작합니다.

상태 위치 결정 트리 다이어그램. 최상단에 '이 값은 어디에 두어야 하는가'라는 질문이 있고, 다섯 단계의 분기가 세로로 이어집니다. 첫 번째 분기는 '링크로 공유되어야 하는가'로, 예이면 URL로 이어집니다. 두 번째는 '다른 기기에서도 보여야 하는가'로, 예이면 서버로 이어집니다. 세 번째는 '새로고침 후에도 남아야 하는가'로, 예이면 localStorage 또는 sessionStorage로 이어집니다. 네 번째는 '여러 화면이 함께 봐야 하는가'로, 예이면 전역 스토어로 이어집니다. 모두 아니오이면 마지막으로 컴포넌트 로컬에 도달합니다. 각 도착 지점에는 그 위치의 수명과 공유 범위가 함께 표시되어 있습니다. 다이어그램 하단에는 위에서 아래로 내려오며 첫 번째로 맞는 곳에 두라는 원칙과, 아래에서 위로 올라가지 말라는 경고가 배치되어 있습니다.

서버 상태와 클라이언트 상태

여섯 자리를 두 종류로 다시 묶으면 더 단순해집니다.

서버 상태(server state) 는 서버가 소유하는 값입니다. 예를 들어 상품 목록, 주문 내역, 사용자 프로필과 같은 값입니다. 특징이 있습니다.

  • 브라우저가 만든 값이 아니라 빌려온 값입니다.
  • 브라우저가 모르는 사이 서버에서 바뀝니다.
  • 따라서 브라우저 손에 있는 것은 사본이고, 언제든 낡을 수 있습니다.

클라이언트 상태(client state) 는 브라우저가 소유하는 값입니다. 모달이 열려 있는지, 어느 탭이 선택되었는지, 폼에 무엇을 입력 중인지와 같은 값입니다. 서버는 이 값을 모르고, 알 필요도 없습니다.

두 가지를 같은 도구로 다루면 문제가 생깁니다. Redux에 상품 목록을 넣으면, 그 목록이 낡았는지 판단하는 로직을 직접 만들어야 합니다. 재검증, 중복 요청 제거, 캐시 무효화, 백그라운드 갱신을 전부 만들어야 합니다.

// 서버 상태를 클라이언트 상태처럼 다룰 때
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)

useEffect(() => {
  setLoading(true)
  fetchProducts()
    .then(setProducts)
    .catch(setError)
    .finally(() => setLoading(false))
}, [])
// 재검증은? 중복 요청은? 다른 화면에서 이미 받았다면?

서버 상태를 “상태”가 아니라 “캐시”로 다루면 해결됩니다.

// 서버 상태 전용 도구를 쓸 때
const { data: products, isPending, error } = useQuery({
  queryKey: ['products', filters],
  queryFn: () => fetchProducts(filters),
  staleTime: 60_000,
})
// 재검증·중복 제거·캐시 공유가 기본 동작입니다

TanStack Query, SWR, RTK Query를 사용할 수 있습니다. 이 관점은 ep.08 캐시 경계에서 본격적으로 다룹니다.

이 구분을 하고 나면 전역 스토어가 갑자기 작아집니다. 대부분의 전역 상태는 사실 서버 상태여야 했기 때문입니다.

같은 필터 조건이 여섯 곳에 저장된 상황을 보여주는 다이어그램. 중앙에 필터 조건 값이 있고, 그 주위로 여섯 개의 저장 위치가 방사형으로 배치되어 있습니다. 컴포넌트 로컬 useState, 전역 스토어, URL 쿼리스트링, 쿼리 캐시 키, sessionStorage, 서버 사용자 설정입니다. 각 위치에는 그것이 추가된 이유가 짧게 적혀 있습니다. 새로고침 유지, 링크 공유, 다른 화면 접근, 다음 방문 유지 등입니다. 여섯 위치 사이에는 동기화가 필요한 연결선이 그물처럼 그어져 있고, 그 위에 '열다섯 개의 동기화 경로'라는 라벨이 붙어 있습니다. 하단에는 이 여섯이 어긋났을 때 어느 것이 진짜인지 정하는 규칙이 없다는 점이 문제로 지적되어 있습니다.

CQRS

같은 발상이 서버 쪽에도 있습니다. 앞 절이 값의 소유자로 나눴다면, 이번에는 나누는 축이 쓰기와 읽기입니다. CQRS(Command Query Responsibility Segregation) 는 쓰기와 읽기를 분리하는 패턴입니다.

상태를 바꾸는 요청을 커맨드(command), 상태를 바꾸지 않고 값만 돌려주는 요청을 쿼리(query) 라고 합니다. 주문을 넣는 것이 커맨드, 주문 목록을 보는 것이 쿼리입니다. 이름 그대로 쓰기와 읽기이고, 둘은 요구사항이 다릅니다.

  • 쓰기는 정확해야 합니다. 불변식을 지키고, 트랜잭션이 필요하고, 정규화된 구조가 유리합니다.
  • 읽기는 빨라야 합니다. 조인이 적어야 하고, 화면에 맞게 미리 조립되어 있으면 좋습니다.

하나의 모델로 둘 다 하려면 타협해야 합니다. 정규화를 지키면 읽을 때 조인이 여섯 개가 되고, 읽기를 위해 비정규화하면 쓸 때 정합성을 지키기 어려워집니다.

// Command 쪽 — 정확성이 우선입니다
type PlaceOrderCommand = {
  userId: string
  items: { productId: string; quantity: number }[]
}

async function placeOrder(cmd: PlaceOrderCommand): Promise<OrderId> {
  return db.$transaction(async (tx) => {
    // 불변식 검사, 재고 확인, 정규화된 테이블에 저장
  })
}
// Query 쪽 — 화면이 필요한 모양 그대로
type OrderListView = {
  orderId: string
  productNames: string[]     // 조인 결과를 미리 담아 둡니다
  thumbnailUrl: string
  statusLabel: string        // 코드가 아니라 라벨로
  totalPrice: number
}

async function getOrderList(userId: string): Promise<OrderListView[]> {
  // 읽기 전용 뷰 또는 별도 읽기 모델에서 가져옵니다
}

여기서 오해가 생길 수 있는데, CQRS는 무조건 데이터베이스를 두 개 쓰는 것이 아닙니다. 같은 데이터베이스에서 쓰기 모델과 읽기 모델을 나누는 것만으로도 CQRS입니다. 읽기 전용 복제본이나 별도 저장소는 규모가 커졌을 때의 선택지이지 전제가 아닙니다.

그리고 두 모델을 분리하는 순간 시차가 생깁니다. 주문을 넣자마자 목록을 조회했는데 아직 안 보일 수 있습니다. 이 문제는 ep.09 일관성 경계로 이어집니다.

Repository

ep.02 의존 방향에서 본 포트가 데이터 접근에 적용된 형태가 Repository입니다. 도메인이 “저장소”라는 개념만 알고, 그것이 PostgreSQL인지 메모리인지 모르게 합니다.

CQRS와 함께 쓰면 자연스럽게 두 개로 갈립니다.

// 쓰기용 — 도메인 객체 단위
interface OrderRepository {
  save(order: Order): Promise<void>
  findById(id: OrderId): Promise<Order | null>
}

// 읽기용 — 화면 단위
interface OrderQueryService {
  listForUser(userId: string, page: number): Promise<OrderListView[]>
  getDetailView(id: string): Promise<OrderDetailView | null>
}

도메인 모델은 상태를 바꿀 때 규칙이 깨지지 않게 막는 장치입니다. 취소된 주문은 배송할 수 없다, 총액은 항목 합과 같아야 한다 같은 규칙이 객체 안에 있고, 쓰기는 반드시 이 관문을 지납니다.

읽기는 상태를 바꾸지 않으니 깨질 규칙이 없습니다. 그런데도 경유하면 비용만 남습니다. 목록 화면에 필요한 것은 다섯 개 필드인데, Order 객체를 만들려면 생성자가 요구하는 연관을 전부 조인하고, 스무 건이면 객체 스무 개를 조립한 뒤 다섯 개만 꺼내 버려야 합니다. 그래서 SQL을 직접 써서 화면에 필요한 모양으로 바로 뽑는 것이 오히려 낫습니다. 읽기 경로에서 도메인 모델을 억지로 경유하면 조인만 늘어납니다.

CQRS의 쓰기 경로와 읽기 경로 분리 다이어그램. 좌측 상단에서 Command가 들어와 도메인 모델을 거칩니다. 그 결과가 정규화된 쓰기 테이블에 저장되는 경로가 그려집니다. 이 경로에는 트랜잭션, 불변식 검사, 정규화라는 특성이 표시됩니다. 우측에서는 Query가 들어와 읽기 모델을 거쳐 화면에 필요한 형태로 바로 반환되는 경로가 그려집니다. 이 경로에는 조인 최소화, 미리 조립된 뷰, 라벨 포함이라는 특성이 표시됩니다. 두 경로 사이에는 동기화 화살표가 있고 그 위에 시차가 발생하는 지점이라는 경고가 붙어 있습니다. 하단에는 CQRS가 데이터베이스를 두 개 쓰는 것이 아니라 같은 데이터베이스에서 모델을 나누는 것만으로도 성립한다는 설명이 배치되어 있습니다.

안티패턴

모든 것을 전역으로: 모달 열림 여부까지 전역 스토어에 넣으면, 스토어가 애플리케이션의 모든 세부사항을 알게 됩니다. 그 시점에 전역 스토어는 경계가 아니라 잡동사니 서랍입니다.

서버 상태를 복제해 두고 직접 관리: useQuery로 받은 데이터를 useState에 다시 복사해 넣는 코드가 있습니다. 그 순간 캐시와 복사본이 어긋나기 시작합니다. 파생 값은 계산만 하고, 저장하지 않아야 합니다.

URL 무시하기: 필터·검색어·페이지 번호·정렬은 URL에 있어야 합니다. 없으면 사용자가 링크를 공유할 수도, 뒤로 가기를 쓸 수도, 새 탭에서 열 수도 없습니다.

localStorage에 스키마 없이 저장: 6개월 전 버전이 저장한 데이터가 현재 버전의 코드에 들어옵니다. ep.03 계약 경계에서 본 파싱이 필요한 자리입니다.


이 경계가 화면에 드러나는 자리

상태의 위치는 사용자 경험을 직접 결정합니다. 특히 URL이 그렇습니다.

URL이 상태를 가질 때 얻는 것

필터 조건을 URL에 두면 네 가지가 따라옵니다.

  • 공유: 링크를 보내면 상대가 같은 화면을 봅니다.
  • 뒤로 가기: 브라우저의 뒤로 가기가 필터 되돌리기가 됩니다.
  • 새로고침 안정성: 새로고침해도 같은 화면입니다.
  • 새 탭: 링크를 새 탭에서 열어 비교할 수 있습니다.

이 네 가지는 개발자가 만들어 주는 기능이 아니라 브라우저가 원래 하는 일입니다. URL을 쓰지 않으면 그 기능을 포기하고, 대신 직접 만들어야 합니다. 필터 저장 기능, 히스토리 스택, “이 검색 결과 공유하기” 버튼 같은 것들을 직접 만들어야 합니다.

// URL을 상태의 원본으로
function useFilters() {
  const [searchParams, setSearchParams] = useSearchParams()

  const filters = {
    category: searchParams.get('category') ?? 'all',
    sort: searchParams.get('sort') ?? 'recent',
    page: Number(searchParams.get('page') ?? 1),
  }

  function setFilter(key: string, value: string) {
    const next = new URLSearchParams(searchParams)
    next.set(key, value)
    if (key !== 'page') next.set('page', '1')   // 필터 바뀌면 1페이지로
    setSearchParams(next)
  }

  return { filters, setFilter }
}

여기서 실무적인 판단이 하나 필요합니다. 모든 상태를 URL에 넣으면 URL이 지저분해집니다. 기준을 세울 수 있습니다.

URL에 넣는다URL에 넣지 않는다
검색어 · 필터 · 정렬모달 열림 여부
페이지 번호 · 탭드롭다운 펼침 상태
상세 항목 ID폼 입력 중인 값
지도의 좌표·줌스크롤 위치

기준은 “이 상태를 남에게 보여주고 싶은가” 입니다. 검색 결과는 남에게 보여줄 만하지만, 드롭다운이 열려 있다는 사실은 굳이 보여줄만한 건 아닙니다.

되돌리기가 되는 화면과 안 되는 화면

상태의 위치는 되돌리기 가능성도 결정합니다.

  • 컴포넌트의 로컬 상태만 쓰면, 필터 세 개를 조정한 뒤 뒤로 가기를 누르면 화면 자체를 이탈해 이전 페이지로 나가버립니다.
  • URL에 두면 뒤로 가기가 필터 하나씩을 되돌립니다.

후자가 언제나 옳은 것은 아닙니다. 필터를 조정할 때마다 히스토리가 쌓이면, 목록으로 돌아가려고 뒤로 가기를 열 번씩 눌러야 할 수도 있습니다. 이때는 replace를 씁니다.

// push — 히스토리에 쌓입니다. 되돌릴 수 있습니다.
setSearchParams(next)

// replace — 현재 항목을 덮어씁니다. 되돌리기 대상이 아닙니다.
setSearchParams(next, { replace: true })

의도적으로 되돌릴 수 있어야 하는 조작만 히스토리에 쌓습니다. 슬라이더를 드래그하는 동안의 중간값은 replace, 손을 뗐을 때의 최종값은 push가 자연스럽습니다.

낙관적 값과 확정 값

ep.05 네트워크 경계에서 본 낙관적 업데이트는 상태 경계의 문제이기도 합니다. 화면에 보이는 값이 아직 서버가 모르는 값일 수 있기 때문입니다.

사용자에게 이 값의 차이를 보여줄지는 디자인 결정입니다.

  • 좋아요 → 보여주지 않습니다. 서버에 반영이 되었는지 확인하지 않고 즉시 반영된 것처럼 보여줍니다.
  • 문서 편집 → 보여줍니다. “저장 중” · “저장됨” 표시를 합니다.
  • 결제 → 보여주다 못해, 확정될 때까지 다른 진행을 막아야 합니다.

서버가 아직 모른다는 사실이 사용자에게 중요한 순간에만 표시합니다. 모든 곳에 저장 상태를 표시하면 화면이 시끄러워지고, 아무 데도 표시하지 않으면 사용자가 저장 여부를 확신하지 못합니다.


참고 자료


다음 편 예고

ep.08 - 캐시 경계와 무효화 전략

값의 소유자를 정하고 나면 다음 문제가 옵니다. 그 값의 사본이 몇 개인가. 캐시는 성능 최적화가 아니라 사본을 만드는 결정입니다.