상태 관리에 대해 얘기하던중 공원이 SSOT라는 키워드를 나에게 던져주었다. 상태를 관리할때 이 개념도 중요하게 고려해야 한다 하여, 프론트엔드에서 SSOT가 무엇인지 궁금증이 생겼다.
정보 시스템 설계 및 이론에서, 단일 진실 공급원(영어: single source of truth, SSOT)은 정보 모형과 관련된 데이터 스키마를 모든 데이터 요소를 한 곳에서만 제어 또는 편집하도록 조직하는 관례를 이른다.
즉, 자원의 출저를 하나만 두고 한곳에서만 제어하는 것을 말한다.
버튼을 통해 숫자를 더하고 뺄 수 있는 간단한 코드가 있다.
숫자는 count라는 수정가능한 데이터이고 여러 컴포넌트에서 사용되는 데이터다.
import { useState } from 'react';
function IncrementButton() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>+</button>
);
}
function DecrementButton() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count - 1)}>–</button>
);
}
function CountText() {
const [count] = useState(0);
return <div>현재 Count: {count}</div>;
}
export default function App() {
return (
<div>
<IncrementButton />
<DecrementButton />
<CountText />
</div>
);
}
위와 같이 SSOT를 지키지 않고 작성하면 count의 출처가 한곳이 아니여서 변경한 내역이 다른 곳에 적용되지 않는다. 하위에서 count를 +1,-1하더라도 CountText에 반영되지 않는 문제가 발생한다.
위 코드는 아래와 같이 변경해야 정상 작동 한다.
import { useState } from 'react';
function App() {
const [count, setCount] = useState(0);
return (
<div>
<IncrementButton setCount={setCount} />
<DecrementButton setCount={setCount} />
<CountText count={count} />
</div>
);
}
function IncrementButton({ setCount }) {
return <button onClick={() => setCount((c) => c + 1)}>+</button>;
}
function DecrementButton({ setCount }) {
return <button onClick={() => setCount((c) => c - 1)}>–</button>;
}
function CountText({ count }) {
return <div>현재 Count: {count}</div>;
}
export default App;
로컬 상태와 비슷하다.
data가 변경되면 그 변경을 감지해서 새롭게 불러낼 수 있지 않다. 그렇기 때문에 api 응답값을 state로 저장하고, 수정시 수정 api를 보내 반환값으로 해당 state를 갱신해주는 식이다.
이때 이 api 응답값을 담는 state가 단일 원천이 아니라면, 수정 api 요청을 보내 실제 서버에서 데이터 수정이 완료됐어도 화면에서는 수정되지 않는 것을 계속 띄우게 되는 문제가 발생할 수 있다.
function ProductListPage() {
return (
<>
<CartItemCount />
<ProductList />
</>
);
}
function CartItemCount() {
const [cartItems, setCartItems] = useState<CartItemType[]>([]);
useEffect(() => {
getCartItems().then(setCartItems);
}, []);
return <div>{cartItems.length} items</div>;
}
function ProductList() {
const [products, setProducts] = useState<ProductType[]>(mockProducts);
const [cartItems, setCartItems] = useState<CartItemType[]>([]);
useEffect(() => {
getCartItems().then((data) => setCartItems(data));
}, []);
const handleToggleCartItem = async (productId: number) => {
const existingCartItem = cartItems.find(
(item) => item.product.id === productId
);
if (existingCartItem) {
await deleteCartItem(existingCartItem.id);
} else {
await addCartItem(productId);
}
const updatedCartItems = await getCartItems();
setCartItems(updatedCartItems);
};
return (
<>
{products.map((product) => (
<ProductItem
key={product.id}
product={product}
isInCart={cartItems.some((item) => item.product.id === product.id)}
onToggleCart={() => handleToggleCartItem(product.id)}
/>
))}
</>
);
서버에서 가져온 데이터를 애플리케이션 전체의 단일 원천으로 삼는 것이 중요합니다. 로컬 state처럼 컴포넌트별로 useState에 데이터를 저장하면, 수정 API 호출 후에도 일부 컴포넌트에만 반영되고 나머지는 갱신되지 않는 불일치가 발생할 수 있습니다.
export interface CartItemType {
id: number;
product: { id: number; name: string; /* ... */ };
}
interface CartContextType {
cartItems: CartItemType[];
toggleCartItem: (productId: number) => Promise<void>;
refreshCart: () => Promise<void>;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
export function CartProvider({ children }: { children: ReactNode }) {
const [cartItems, setCartItems] = useState<CartItemType[]>([]);
// 초기 및 수동 갱신용 fetch 함수
const refreshCart = async () => {
const data = await getCartItems();
setCartItems(data);
};
// 마운트 시 한 번만 불러오기
useEffect(() => {
refreshCart();
}, []);
// 장바구니 담기/삭제 로직
const toggleCartItem = async (productId: number) => {
const existing = cartItems.find((item) => item.product.id === productId);
if (existing) {
await deleteCartItem(existing.id);
} else {
await addCartItem(productId);
}
await refreshCart();
};
return (
<CartContext.Provider value={{ cartItems, toggleCartItem, refreshCart }}>
{children}
</CartContext.Provider>
);
}
// Context 사용을 위한 커스텀 훅
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within CartProvider');
return ctx;
}
// CartItemCount.tsx
import React from 'react';
import { useCart } from './CartContext';
export function CartItemCount() {
const { cartItems } = useCart();
return <div>{cartItems.length} items</div>;
}
// ProductList.tsx
import React from 'react';
import { useCart } from './CartContext';
import { mockProducts, ProductType } from './mockData'; // 가정된 데이터
import { ProductItem } from './ProductItem';
export function ProductList() {
const { cartItems, toggleCartItem } = useCart();
return (
<>
{mockProducts.map((product: ProductType) => (
<ProductItem
key={product.id}
product={product}
isInCart={cartItems.some((item) => item.product.id === product.id)}
onToggleCart={() => toggleCartItem(product.id)}
/>
))}
</>
);
}
로직에 대해서도 SSOT를 지켜야 한다. 이말을 듣고 로직에 대해서도 지켜야 할까? 하는 의문이 들었다.
만약 할인 로직이 결제 페이지와 관리자 페이지 모두 사용된다고 해보자
이중에서 결제페이지에서만 로직을 변경시켰다. 그렇게 되면 관리자 페이지에서의 할인 코드는 수정되지 않아 똑같은 기능을 해야 하는 코드가 같은 반환값을 내지 않게 된다.
// 관리자 페이지
function calculateDiscount(price: number) {
return price * 0.9;
}
// 결제 페이지
function calculateDiscount(price: number) {
return price * 0.95;
}
그렇기 때문에 로직도 데이터처럼 ‘단일 원천’을 가져야 한다. 위의 예시처럼 어느 한쪽만 수정했을 때 서로 다른 결과를 내는 불일치가 발생하기 쉽기 때문이다.
// 유틸함수
export function calculateDiscount(price: number) {
return price * 0.9;
}
// 관리자 페이지
import { calculateDiscount } from './discount';
// 결제 페이지
import { calculateDiscount } from './discount';
유틸함수뿐 아니라 커스텀훅으로 로직을 분리하는 것도 이에 해당한다.
그렇다면 SSOT를 지키지 않아도 되는 경우는 없을까??
대부분 데이터와 로직의 일관성을 위해 지키는 것이 권장되지만 모든 상황에서 지켜야 하는 것은 아니다.
API 호출에 비용이 많이 들때 매번 최신 데이터를 호출하면 오히려 UX가 나빠지고 비용이 많이 드나. 그래서 캐시된 값을 보여주고 후에 업데이트 하는 방법이다.
장바구니에 아이템을 담고 빼는 것과 같이 토글 기반 작업은 사용자가 빠르게 반응을 기대하므로, 서버 요청전 로컬 상태를 즉시 업데이트 하고 이후 실패 시 롤백하는 방식을 사용한다. 서버와는 잠시 데이터가 일치하지 않지만 곧 싱크를 맞춘다.
오프라인 상태일때 로컬에 임시 기록을 한 후 온라인 상태가 되면 서버로 동기화 한다. 이때 임시 원천이 로컬이 되므로 SSOT를 잠시 어기지만 온라인 상태로 됐을때 동기화를 하면서 일관성을 맞춘다.
공유되거나 동기화가 필요한 상태가 아니라면 SSOT를 지키지 않아도 된다.
[참고]
좋은 코드, 나쁜 코드
https://ko.wikipedia.org/wiki/%EB%8B%A8%EC%9D%BC_%EC%A7%84%EC%8B%A4_%EA%B3%B5%EA%B8%89%EC%9B%90
로직까지 SSOT 좋은데요????
참고 자료도 좋은데요??????? 👍