나는 useQuery/Promise.all 스파게티에 지쳐서 이것을 만들었다 🫖🦡
Source: Dev.to
I use React. I don’t like React. I’m stuck with it.
My backend is a hot bunch of micro‑services. Lots of them. Referential data everywhere — tickets, users, teams, roles, watchers, leads, permissions. As the product scales, the frontend turns into a request relay:
- Fetch a ticket.
- Fetch the assignee.
- Fetch the assignee’s team.
- Fetch the team lead.
- Fetch the watchers.
- Fetch the roles.
Each field is an ID, each ID is another round‑trip. Sometimes the same user ID shows up three times in the tree, resulting in three fetches. Thank God for TanStack Query, or I would have lost my mind years ago.
In a saner world we’d have Redis, GraphQL, or a single coherent API. Instead we have goblin micro‑services with no common ground. There’s Elasticsearch, but it fumes when you look at it wrong. So here we are.
I got tired, so I built a thing.
The Chore
당신의 REST API는 ID만 반환합니다. UI는 객체가 필요하죠. 그래서 해상도 코드를 작성합니다.
처음엔 간단합니다:
const ticket = await fetchTicket('t-1');
const assignee = await fetchUser(ticket.assigneeId);
하지만 점점 복잡해집니다. 티켓에 이제 워처가 생기고, 담당자에게는 팀이 있으며, 팀에는 리드가 있습니다. 결국 Promise.all, useQueries, null 체크, 개별 fetch 로 가득 찬 블롭이 됩니다. 배치가 전혀 없고, 중복 제거도 되지 않으며, 타입도 엉망입니다. 새로운 필드가 추가될 때마다 보일러플레이트와 추가 렌더링이 발생합니다.
const assignee = ticket.assigneeId ? await fetchUser(ticket.assigneeId) : null;
const watchers = await Promise.all(
(ticket.watcherIds ?? []).map(id => (id ? fetchUser(id) : null))
);
const team = assignee?.teamId ? await fetchTeam(assignee.teamId) : null;
const lead = team?.leadUserId ? await fetchUser(team.leadUserId) : null;
// … you get the idea
페이지마다 수십 개의 참조 필드가 생기고, 코드는 점점 지치게 됩니다.
내가 만든 것
@nimir/references – 타입‑안전한 중첩 레퍼런스 해결기. sources(배치를 가져오는 방법)를 한 번 정의하면, 어떤 필드가 ID인지 선언만 하면 됩니다. 라이브러리는 배치 처리, 중복 제거, 캐싱, 그리고 중첩 탐색(최대 10단계)을 자동으로 수행합니다. 널‑안전하고 완전하게 타입이 지정됩니다.
소스 정의
import { defineReferences } from '@nimir/references';
const refs = defineReferences(c => ({
User: c.source({ batch: ids => fetchUsers(ids) }),
Team: c.source({ batch: ids => fetchTeams(ids) }),
Role: c.source({ batch: ids => fetchRoles(ids) }),
}));
레퍼런스 맵 선언
const result = await refs.inline(ticket, {
fields: {
assigneeId: {
source: 'User',
fields: {
teamId: {
source: 'Team',
fields: { leadUserId: 'User' },
},
roleIds: 'Role',
},
},
watcherIds: 'User',
},
});
모든 User 조회(담당자, 감시자, 팀 리드)는 하나의 호출로 배치됩니다. 중복된 ID는 한 번만 가져옵니다. 해결된 값은 T 접미사(assigneeIdT, watcherIdTs, …)와 함께 붙으며, TypeScript가 자동으로 타입을 추론합니다.
React + TanStack Query
React에 얽매여 있다면, React 진입점이 있습니다.
import { defineReferences } from '@nimir/references/react';
const useTicket = refs.hook(useGetTicket, {
fields: { assigneeId: 'User', watcherIds: 'User' },
});
function TicketCard({ id }: { id: string }) {
const { result, status, error, invalidate } = useTicket(id);
// result.assigneeIdT → User | null
}
또는 인라인 데이터를 반응형으로 해결합니다:
const resolved = refs.use(data, { fields: { assigneeId: 'User' } });
TanStack Query, SWR, 혹은 데이터를 반환하는 어떤 훅과도 함께 사용할 수 있습니다.
캐싱
Sources support pluggable caches: in‑memory, IndexedDB (via idb-keyval), or Redis. You can configure TTL, negative caching, and cache warming. If you have Redis on the backend, plug it in; otherwise, in‑memory or IndexedDB still cuts down repeat fetches.
Caveats
- Depth limit of 10 levels (prevents infinite loops on circular configs).
- Unknown source names are silently skipped – a typo in
fieldsyields no data. TypeScript helps, but runtime won’t warn.
아마도 나만의 단일 사용‑케이스
나는 이것을 내 혼란을 위해 만들었다: REST 마이크로‑서비스, 곳곳에 있는 ID들, GraphQL 없음, 통합 백엔드 없음. 레거시 API, 서드‑파티 서비스, 혹은 혼합 데이터 소스를 다루고 있다면 이것이 도움이 될 수 있다. API를 직접 제어하고 GraphQL을 사용할 수 있다면 대신 그것을 사용하라.
다른 이야기
만드는 것이 정말 즐거웠어요. 직장에서 시작했는데, 결국 이 오픈소스 패키지로 변형됐어요. 이해는 못하지만 도덕적으로 응원해준 여자친구에게 감사드립니다. 건배!