상태 관리는 상태의 원본과 변경 주체, 필요 범위, 만료 시점, 복구 방식을 기준으로 상태의 위치와 책임을 프론트엔드 시스템에서 설계하는 일이다.
*아래 정리한 내용엔 Query Key나 Mutation처럼 다 익숙한 것들이 있다. 그런데 몇몇 표현들 때문에 이해가 안돼서 멈춰가면서 좀 뜻을 찾아보면서 들었다. 그런 부분들은 StatePlacement(S) = f(Origin, Writers, Scope, ...) 같은 표기가 나오는 부분인데 이건 정착된 표기법이라기보단 강사님이 설명용으로 만든 의사수식인 것 같다. 그러니 수식 자체를 외우기보다 그 안에 어떤 판단 기준이 들어있는지를 보자. 일단 아래 정리에서는 강사님이 쓴 표기를 그대로 옮겨두었다.
1. 상태를 함수처럼 분석하기
어떤 상태 S가 있다고 하자. 이 상태를 다음 속성들의 집합으로 본다.
State S = {
Origin,
Writers,
Scope,
Lifetime,
Expiration,
Recovery,
Derivability,
Consistency Risk
}
상태가 생기면 바로 "React Query에 넣자", "Redux에 넣자"처럼 결정하는게 아니라 그전에 상태를 함수처럼 분석해야 한다. 상태의 위치는 취향으로 정하는 값이 아니라, 상태의 성질을 입력값으로 넣었을 때 나오는 결과다.
StatePlacement(S) = f(
Origin,
Writers,
Scope,
Lifetime,
Expiration,
Recovery,
Derivability,
Consistency Risk
)
StatePlacement(S)가 상태의 위치값을 의미한다.
2. Source of Truth
먼저 알아야 할 것은 이 상태의 진짜 원본이 어디 있는가, 데이터는 어디로부터 오는가다.
State <= source of truth
SourceOfTruth(S) = Owner that can produce the canonical value of S
어떤 상태 S의 진짜값, 최종적으로 믿어야 하는 값을 만들 수 있는 주체가 누구인지를 알기 위한 것이 SourceOfTruth다.
예를 들어 상품 가격을 보자.
S = product.price
SourceOfTruth(S) = Pricing Service
여기서 Pricing Service는 특정 제품 이름이 아니라 가격을 결정하는 서버 모듈을 가리키는 말이(라고 예상한)다. 아마 판매가, 할인과 쿠폰, 회원 등급별 가격, 지역별 통화 같은 계산을 담당할 것이다.
가격은 이 Pricing Service로부터 오는 값이다. 프론트엔드는 가격을 보여주지만 가격을 결정하지는 않는다. 따라서 프론트엔드의 가격값은 원본이 아니라 복사본이다.
FrontendPrice = copy(ServerPrice)
복사본은 시간이 지나면 달라질 수 있다. 서버에서 가격이 바뀔 수 있기 때문이다. 그래서 상품 가격은 Local State나 Global Client State가 아니라 Server State로 관리해야 한다.
상품 가격을 React State에 저장할 수는 있다.
const [price, setPrice] = useState(129);
이 코드가 있다고 해서 프론트엔드가 가격의 주인이 되는 것은 아니다. 가격의 진짜 소스는 서버이고, 프론트엔드는 잠깐 복사해서 들고 있을 뿐이다. 서버에서 오는 가격이 99달러인데 클라이언트 가격이 129달러라면, 서버 가격을 믿고 프론트엔드에서 보여줘야 한다. 이것이 Source of Truth의 중요성이다.
3. Derived State
어떤 값이 서버에서 온 값처럼 보여도, 사실 다른 상태에서 계산이 가능하면 별도로 저장하면 안 된다.
cartItems = [
{ id: 1, quantity: 2 },
{ id: 2, quantity: 3 }
]
여기서 cartCount는 따로 저장할 필요가 없다.
cartCount = Σ item.quantity = 2 + 3 = 5
cartCount = derive(cartItems)
SourceOfTruth(cartCount) => cartItems
cartCount 자체는 원본이 아니다. Derived State는 f(baseState)로 생각하면 된다.
DerivedState = f(baseState)
cartCount = f(cartItems)
cartCount = Σ item.quantity
subtotal = Σ item.price * item.quantity
filteredProducts = products.filter(product satisfies filters)
sortedProducts = sort(products, sortOption)
즉 Derived State는 원본 데이터에서 계산이 가능한 값이다.
Derived State를 별도 State로 저장하면 위험한 이유
const [cartItems, setCartItems] = useState([]);
const [cartCount, setCartCount] = useState(0);
처음에는 괜찮아 보인다. 하지만 상태가 두 개가 되었다.
source A = cartItems
source B = cartCount
문제는 두 값이 항상 같은 의미를 유지해야 한다는 점이다. 수학적으로 말하면 항상 다음 불변식이 성립해야 한다.
cartCount = Σ cartItems[i].quantity
그런데 아이템을 삭제하면서 cartCount 업데이트를 깜빡했다고 하자.
cartItems = [ { quantity: 2 } ]
cartCount = 5
이제 이 식이 깨졌다. 이 상태를 state inconsistency, 상태 불일치라고 한다.
그래서 원본은 저장하고 계산 가능한 값은 계산해야 한다.
const cartCount = cartItems.reduce(
(sum, item) => sum + item.quantity, 0
);
이렇게 하면 cartItems만 원본이다. Source of Truth가 원본이고, cartCount는 매번 원본에서 계산되어야 한다. 그렇게 하지 않으면 상태 불일치가 일어난다.
상태가 늘어날수록 관계는 더 빠르게 늘어난다
대규모 상태 관리의 문제 중 하나는 동기화해야 할 값이 많아질수록 오류 확률이 커진다는 것이다.
어떤 화면에서 관리해야 하는 상태가 두 개라고 하자.
A = cartItems
B = cartCount
B = f(A)
이 관계를 항상 맞춰야 한다. 상태가 세 개가 되면 이렇게 된다.
A = cartItems
B = cartCount
C = subtotal
B = f(A)
C = g(A)
상태가 5개, 10개가 되면 동기화해야 하는 관계는 더 많아진다. 상태 간의 관계 수는 아래와 같이 증가한다.
possible relationship = n(n - 1) / 2
(10 * 9) / 2 = 45
상태가 3개면 3, 10개면 45다. 상태 수가 많아질수록 관리해야 하는 관계는 선형이 아니라 더 빠르게 증가한다.
그래서 대규모 상태 관리는 상태를 많이 저장하는 일이 아니라, 저장해야 하는 상태를 줄이고 계산 가능한 값은 계산하는 일이다.
4. Local State 판단하기
Local State는 다음 조건들로 판단한다.
Origin(S) = Current User Interaction
Scope(S) = Component or Small Subtree
Persistency(S) = Not Required After Unmount → 시간축. 컴포넌트가 죽은 뒤에도 살아야 하나
ServerSync(S) = Not Required → 저장축. 서버가 이 값을 알아야 하나
Shareability(S)= Not Required → 공간축. 다른 컴포넌트가 읽어야 하나
상품 이미지 갤러리에 selectedImageIndex라는 상태가 있다고 하자. 선택된 이미지의 인덱스 값이 상태로 들어가 있는 것이다.
Origin(selectedImageIndex) = 사용자의 이미지 클릭
Writer(selectedImageIndex) = 현재 사용자
Scope(selectedImageIndex) = 이미지 갤러리 컴포넌트
Lifetime(selectedImageIndex) = 프로덕트 페이지가 살아있는 동안
Recovery(selectedImageIndex) = 페이지 나가면 초기화
ServerSync(selectedImageIndex) = 필요 없음
StatePlacement(selectedImageIndex) = Local State
왜 Global State로 관리하지 않는가
전역 스토어에 넣으면 상태의 영향 범위가 커진다. Local State의 영향 범위는 해당 컴포넌트와 서브 트리다. 여기서 서브 트리는 해당 컴포넌트 안에 존재하는 컴포넌트들을 말한다. 반면 Global State의 영향 범위는 앱 전체 또는 많은 컴포넌트다.
영향 범위가 커지면 비용이 이렇게 증가한다.
Cost = DebugCost + RenderCost + ResetCost + CouplingCost
전역 상태에 가까워지고 그 수가 많아질수록 디버그 비용, 렌더 비용, 리셋 비용, 커플링 비용이 증가하고 결과적으로 유지 보수 비용이 증가한다.
selectedImageIndex를 전역 스토어에 넣어서 관리하면 이런 문제가 생긴다.
- 페이지를 나갔다 돌아와도 이전 인덱스가 남아있다.
- 다른 Product 페이지와 충돌할 수 있다.
- 테스트할 때 스토어 초기화가 필요하다.
- 상태 변경 영향 범위가 커진다.
- 컴포넌트 재사용성이 낮아진다.
이 다섯 가지 때문에 selectedImageIndex는 전역 스토어로 관리하지 않는다. 상태는 가능한 한 가까운 곳에 두고, 필요할 때만 올리는 것이 좋다. 상태를 사용하는 컴포넌트들이 접근할 수 있는 최소 범위를 주는 것이다.
5. Global Client State
Global Client State의 조건은 다음과 같다.
Origin(S) = Client → 서버가 내려준 값이 아니라 클라이언트에서 만들어진 값
Writer(S) = Current User / Client App → 다른 사용자나 서버가 끼어들어서 바꾸는 일 X
Scope(S) = Multiple Unrelated Components → 부모자식이 아닌, 트리에서 멀리 떨어진 컴포넌트들이 읽음
ServerAuthority(S) = None → 서버에 권한이 없으니 stale이라는 개념 자체가 없음
Persistence(S) = Optional → 새로고침 후에 남길지는 선택. 분류를 바꾸지는 않음
보통 Global State로 관리되는 값들은 Theme, 디자인, 지역성(locale), 그리고 전역으로 관리해야 하는 모델 같은 것들이다.
"여러 곳에서 필요하다"만으로는 충분하지 않다. 이 상태의 원본이 클라이언트인지도 확인해야 한다.
Product List를 보자. Product List는 Product Grid, Filter Summary, Recommendation, Analytics 등 여러 컴포넌트에서 필요할 수 있다. 하지만 Product List의 원본은 서버다. 따라서 이것은 Global Client State가 아니라 Server State로 관리해야 한다.
판단 순서는 이렇다.
- 여러 컴포넌트에서 필요한가
- 원본이 어디인가
if Scope(S) = Global and Origin(S) = Client => Global Client State
if Scope(S) = Global and Origin(S) = Server => Server State
6. Server State와 staleness
Server State는 원본이 서버에 있는 상태다.
Origin(S) = Server
ClientValue(S) = Cached
클라이언트에 있는 Server State는 원본이 아니라 캐시다.
시간이 지나면 서버값이 바뀔 수 있고, 이 차이를 stale이라고 한다.
staleness는 클라이언트가 들고 있는 값이 서버 최신값과 어긋날 가능성이다. 이를 fresh와 stale로 구분한다.
- fresh: 지금 보여줘도 최신이라고 믿을 수 있는 상태
- stale: 보여줄 수는 있지만 최신이라고 확신할 수 없는 상태
stale이라고 해서 화면에서 지워야 한다는 뜻은 아니다. 오히려 대규모 앱에서는 stale 데이터를 먼저 보여주고 백그라운드에서 새로운 데이터를 가져오는 것이 좋은 UX다. 이 전략은 사용자에게 빈 화면을 덜 보여줄 수 있다.
staleTime은 어떻게 정하는가
데이터의 변경 가능성과 사용자의 위험도에 따라 달라진다.
StaleRisk = ChangeFrequency * UserImpact
변경이 자주 일어나고 사용자 영향(틀렸을 때 위험한 데이터)이 크면 staleTime을 짧게 잡아야 한다.
| 데이터 | Change Frequency | User Impact | staleTime |
| 상품 가격 | Medium ~ High | High | Short |
| 인벤토리 재고 | High | High | Very Short |
| 카테고리 리스트 | Low | Low | Long |
7. Query Key
Query Key는 서버 데이터가 어떤 조건으로 만들어진 결과인지를 표현하는 캐시 주소다.
프론트엔드에서 서버 데이터를 가져올 때 매번 API를 다시 호출하면 느리다. 그래서 React Query 같은 도구는 한번 가져온 데이터를 메모리에 저장한다.
상품 상세 페이지에서 이 API를 호출했다고 하자.
GET /api/products/p123
서버 응답은 이렇다.
{ "id": "p123", "name": "Nike Shoes", "price": 120 }
React Query는 이 데이터를 아무데나 저장하지 않고 키, 즉 주소를 붙여서 저장한다.
queryKey: ['product', 'p123']
이 말은 product 종류의 데이터 중에서 p123 상품 데이터라는 뜻이다. product/p123 데이터를 찾기 위한 캐시 주소다.
Query Key는 API 주소와 비슷하지만 같지는 않다. 비슷한 정보를 담고 있지만 역할이 다르다.
- API URL: 서버에게 어떤 데이터를 요청할지 알려주는 주소
- Query Key: 클라이언트 캐시에서 어떤 데이터를 저장하고 조회할지 알려주는 주소
QueryKey = Address(ServerState)
조건이 결과를 바꾸면 Query Key에 들어가야 한다
검색 결과는 조건에 따라 달라진다.
SearchResult = f(q, filters, sort, page)
queryKey = ['search', q, filters, sort, page]
결과를 바꾸는 입력이 캐시 주소에 포함되어 있지 않으면, 서로 다른 결과가 같은 주소를 공유하게 된다.
나쁜 예를 보자.
useQuery({
queryKey: ['search'],
queryFn: () => searchProducts(q, sort, filters)
});
이 경우 q 결과, filters 결과, sort: price ASC 결과가 모두 같은 캐시 주소에 저장된다. 조건에 따라 검색 결과가 달라지는데 캐시 주소는 항상 똑같다. 함수 입력을 무시하는 것과 같다.
검색창에서 이런 요청을 한다고 하자.
searchProducts({ q: 'nike', sort: 'price_low', filters: { color: 'black' } })
이 결과는 검은색 나이키 상품을 가격 낮은 순으로 정렬한 결과다. 그런데 사용자가 조건을 바꿔서 아디다스를 검색하고, rating으로 정렬하고, 화이트 색상을 원한다고 하자. 결과는 완전히 달라진다.
그런데 Query Key를 ['search']로 잡으면 React Query 입장에서는 둘 다 같은 데이터다. 캐시 주소가 같기 때문이다. 첫 번째 결과 주소도 search, 두 번째 결과 주소도 search가 된다. 서로 다른 검색 결과를 같은 캐시에 넣는 것이다.
그러면 React Query가 나중에 search를 볼 때 이게 나이키 결과인지 아디다스 결과인지 구분할 수 없다. 그래서 이런 문제가 생긴다.
- 이전 결과가 잠깐 보인다.
- 잘못된 캐시가 재사용된다.
- 조건이 바뀌었는데도 같은 데이터로 취급된다.
Query Key는 이렇게 되어야 한다.
queryKey: ['search', q, sort, filters]
이러면 캐시 주소가 조건마다 달라진다.
['search', 'nike', 'price_low', { color: 'black' }]
React Query는 이제 이 둘이 서로 다른 검색 결과라는 것을 알게 된다.
정리하면, x가 바뀌었을 때 API 결과가 바뀐다면 x는 Query Key에 들어가야 한다. 검색 결과를 바꾸는 값은 q, sort, filters 같은 것들이다. 검색 결과를 바꾸지 않는 값은 드롭다운 오픈 여부, hover 여부, 필터 패널 오픈 여부 같은 상태값들이다.
8. URL State
URL State에 있는 상태값들은 화면 결과를 결정하는 값들이다.
/search?q=shoes&brand=nike&sort=price_low&page=2
이 URL은 화면 결과를 결정한다. q, brand, sort, page 파라미터가 있기 때문이다. 수학적으로는 이렇게 표현된다.
SearchPageView = f(URLSearchParams)
q, brand, sort, page 파라미터로 화면 결과가 바뀐다. 따라서 이 값들은 URL에 있어야 한다.
useState로 관리하면 안 되는 이유
const [query, setQuery] = useState('');
const [sort, setSort] = useState('price_low');
이 방식은 화면 안에서 동작한다. 하지만 URL과 상태가 분리된다.
URL = /search
React State = { q: 'shoes', brand: 'nike' }
이렇게 되면 다음 문제가 생긴다.
- 새로고침하면 React State가 사라진다.
- 링크 공유가 안 된다.
- 뒤로 가기가 화면 상태와 연결되지 않는다.
- Analytics에서 어떤 검색 조건인지 추적하기 어렵다.
- Query와 URL이 달라질 수 있다.
좋은 구조는 이렇다.
URL Search Params(Source of Truth) -> Query Key -> Server State -> UI
9. Mutation
Mutation은 서버 상태를 바꾸는 함수다.
Mutation: ServerState(t) -> ServerState(t + 1)
Query와 Mutation의 차이를 보면, Query는 서버 상태를 읽고 Mutation은 서버 상태를 바꾼다.
// Query: 서버 데이터를 읽기만 한다
getProducts()
getProductDetail(productId)
// Mutation: 서버 상태를 바꾼다
addToCart(productId)
현재 서버의 장바구니가 이렇게 생겼다고 하자.
serverCartBefore = [ { productId: 'p1', quantity: 1 } ]
사용자가 상품 P2를 장바구니에 추가한다.
addToCart('p2', 1)
그러면 서버의 장바구니 상태가 바뀐다.
serverCartAfter = [
{ productId: 'p1', quantity: 1 },
{ productId: 'p2', quantity: 1 }
]
addToCart가 serverCartBefore를 serverCartAfter로 바뀌게 한 것이다. Mutation 전 서버 상태와 Mutation 후 서버 상태를 시간 개념으로 표현한 식이 위의 수식이다.
Client Cache가 문제가 되는 지점
React Query는 서버 데이터를 클라이언트에 캐시해둔다. 클라이언트 캐시는 서버에서 가져온 복사본이다.
clientCacheCart = [ { productId: 'p1', quantity: 1 } ]
이제 사용자가 장바구니에 P2를 추가한다. 서버는 성공적으로 바뀌었는데 클라이언트 캐시는 아직 예전 값이다. 사용자 입장에서는 장바구니에 상품을 추가했는데 화면에서는 안 보일 수 있다.
ClientCache(cart) must converge to ServerState(cart)
Mutation 이후에 Client Cache도 결국 Server State와 같아져야 한다. 매 순간 같을 필요는 없지만 최종적으로는 같아져야 한다. 이것을 Eventual Consistency, 최종 일관성이라고 한다. 클라이언트 화면이 서버의 실제 상태와 맞아야 한다는 의미다.
10. Mutation 이후 전략
Mutation 이후에 캐시를 맞추는 전략은 세 가지다.
Invalidate
내 캐시는 이제 오래됐다고 표시하고 다시 가져오는 방식이다.
invalidate(queryKey) -> refetch
정확하고 구현도 단순하다. 다시 fetch를 해야 하기 때문에 느릴 수 있다.
정확성이 중요할 때, 데이터 구조가 복잡할 때, Mutation 결과로 여러 값이 바뀔 때 적합하다.
Direct Cache Update
Mutation 결과로 최신 데이터를 주면 그걸 캐시에 넣는 전략이다.
clientCache(S) = MutationResponse
빠르고 추가 fetch가 필요 없다. 여러 캐시 엔트리를 직접 맞춰야 하면 복잡해진다는 단점이 있다.
Optimistic Update
서버 응답 전에 성공했다고 가정하고 UI를 먼저 바꾸는 방식이다.
적합한 경우는 성공 확률이 높고, 실패했을 때 되돌리기 쉽고, 즉각적인 UI 피드백의 가치가 높을 때다. 좋아요, 북마크, 읽음 처리가 좋은 예다.
조심해야 하는 것은 결제, 주문, 송금, 권한 변경, 재고 같은 사항들이다. 이것들은 실패했을 때 롤백 비용과 신뢰 손상이 크다.
RollbackCost = High
TrustDamage = High
이런 경우에는 Optimistic Update를 함부로 적용하면 안 된다.
11. 강의 外 더 알아본 것들
여기부터는 강의 내용이 아니라, 정리하면서 따로 찾아보거나 걸렸던 지점들이다.
1) 계산 가능한 값은 항상 매번 계산해야 하나
강의는 파생값을 별도 state로 저장하지 말라고 한다.
이때 구분할 것이 하나 있다. useState로 따로 들고 불변식을 수동으로 맞추는 것과, useMemo나 selector로 결과를 캐싱하는 것은 다르다. 후자는 원본이 바뀌면 자동으로 무효화되니까 불변식이 코드로 보장된다. 강의에서 위험하다고 한 것은 전자!
캐싱까지 갈 만한 경우는 두 가지다. 1) 계산 비용이 실제로 큰 경우, 그리고 2) 참조 동일성이 필요한 경우다.
파생된 객체나 배열을 useEffect deps나 memo된 자식에 내려주면 매번 새 참조가 생겨서 불필요한 렌더나 무한 루프가 생긴다.
이건 성능이 아니라 정확성 문제가 되어버린다. 반대로 reduce 한 번 도는 O(n) 연산에 측정 없이 useMemo부터 붙이는 것은 코드만 늘린다.
2) 파생처럼 보이지만 파생이 아닌 값
강의는 subtotal = Σ price * quantity를 파생값 예시로 든다. 실무에서는 이게 틀릴 때가 많다. 세금, 배송비, 묶음 할인, 프로모션이 붙으면 서버가 계산한 금액과 클라이언트 계산값이 달라진다. 이때 subtotal의 원본은 서버이고, 그러면 파생 상태가 아니라 Server State 라SourceOfTruth가 된다
시점이 고정되는 값도 파생이 아니다. 주문 내역의 결제 금액은 주문한 그 순간의 값으로 확정된다. 상품 가격이 나중에 인하돼도 이미 결제한 금액은 그대로여야 하니까, 현재 상품 가격에서 계산하면 안 된다.
cartCount = f(현재 cartItems) // 원본이 바뀌면 같이 바뀌어야 함 (파생)
order.price = 주문 시점에 확정된 값 // 원본이 바뀌어도 그대로여야 함 (서버)
배송지 주소도 마찬가지다. 프로필 주소를 바꿔도 이미 발송된 주문의 배송지는 그대로 남아야 한다. 이런 값들은 대부분 서버가 확정해서 저장하고 클라이언트는 받아와서 보여주기만 한다. 같은 가격인데 상품 가격은 staleTime을 짧게, 확정된 주문 금액은 길게 잡아도 되는 이유가 여기 있다.
3) 사용자가 편집 중인 값
폼에서 서버 값을 불러와 수정하는 중이라면, 그 입력값은 원본의 파생이 아니라 아직 서버에 반영되지 않은 독립된 상태다.
이때는 원본, 편집 중인 값, 서버 반영 후를 각각 구분해서 다뤄야 한다.
4) 추가 레퍼런스
상태 구조와 위치
- Choosing the State Structure (React 공식 문서) 파생값을 state로 두지 말라는 원칙이 공식 문서에 정리되어 있다. 이 강의의 cartCount 예시와 거의 같은 이야기를 fullName 예시로 설명한다.
- Application State Management with React (Kent C. Dodds) 모든 상태를 Server Cache와 UI State 두 갈래로 보는 관점. 강의의 3분류와 비교해서 읽으면 좋다.
- State Colocation will make your React app faster (Kent C. Dodds) 상태를 쓰는 곳 가까이 두라는 원칙이 유지보수뿐 아니라 렌더 성능에도 영향을 준다는 내용. 강의의 Cost 식에서 RenderCost 부분을 실제 프로파일링으로 보여준다.
- Normalizing State Shape (Redux 공식 문서) 같은 데이터가 여러 곳에 중복 저장될 때 생기는 문제를 데이터베이스 정규화 관점으로 다룬다. 강의의 상태 불일치 논의를 한 단계 더 밀고 간 버전이다.
서버 상태와 React Query
- Practical React Query (TkDodo) React Query 메인테이너가 쓴 시리즈의 1편. staleTime과 gcTime의 차이, 서버 상태와 클라이언트 상태를 분리해야 하는 이유를 다룬다.
- Effective React Query Keys (TkDodo) 강의의 Query Key 내용을 앱이 커진 뒤 어떻게 구조화할지까지 확장한 글.
- Mastering Mutations in React Query (TkDodo) invalidate와 direct update를 언제 고를지, mutate와 mutateAsync의 차이를 다룬다.
- Thinking in React Query (TkDodo) 라이브러리 사용법보다 사고방식 쪽에 무게를 둔 글.
useEffect로 파생값을 동기화하는 패턴
- You Might Not Need an Effect (React 공식 문서) useEffect로 파생값을 동기화하는 패턴이 왜 문제인지 다룬다. (강의에서 말한 상태 불일치가 실제 코드에서 가장 자주 나타나는 형태)
'Frontend' 카테고리의 다른 글
| 웹 접근성 레퍼런스 모음 (3) | 2026.08.20 |
|---|---|
| 프론트엔드 시스템 디자인 - 3. Agentic UI와 AI-native 프론트엔드 설계 (0) | 2026.08.19 |
| Tailwind-a11y 개발 기록 4편 (출시 2주 뒤 ! 2300+ download 라니) (1) | 2026.08.10 |
| 프론트엔드 시스템 디자인 - 2. 자동완성 검색창 (0) | 2026.07.31 |
| 프론트엔드 시스템 디자인 - 1. RADIO 접근법 (1) | 2026.07.15 |
댓글