최근 튜플이라는 개념을 알게 되면서, 이 개념이 권한 설계(ReBAC)와 React의 useState에서 어떻게 쓰이는지 정리해보기로 했다.
튜플은 순서가 정해져 있고 칸 수가 고정된 값의 묶음이다. 배열이 '같은 종류 여러 개'라면, 튜플은 '역할이 다른 값을 한 세트로' 묶은 것이다. 예를 들어 (아람, member, 프론트팀)은 첫 칸이 누가, 둘째 칸이 관계, 셋째 칸이 대상이라는 뜻을 가진다.
이 튜플이 실제로 쓰이는 곳 중 하나가 권한 시스템이다. 먼저 익숙한 RBAC부터 비교해보자.
RBAC는 역할 기반이며 "너는 무슨 역할이야?"라고 물어보게 된다. 프론트는 사용자가 어떤 역할인지 알아내서 이 메뉴에 접근이 가능한지, 버튼을 클릭할 수 있는지에 대해 판단한다.그런데 '우리 팀 문서만 수정 가능'처럼 특정 대상에 따라 권한이 달라져야 하면, 역할만으로는 표현하기 어렵다. 역할은 어떤 문서인지를 모르기 때문이다.
ReBAC(Relationship-Based Access Control)는 관계 기반이며 "너는 이것과 어떤 사이야?"라고 물어보게 된다. 프론트는 사용자가 지금 보고 있는 대상과 어떤 관계인지 확인해서 그 대상에 대한 버튼과 메뉴를 결정한다. 예를 들어 같은 전사 문서 목록 화면이라도 내 팀 소유의 문서에는 수정 버튼이 활성화되고, 다른 팀의 문서에는 비활성화된다. 단, 다른 팀 문서라도 나에게 편집 권한으로 공유된 문서라면 수정 버튼이 활성화된다. 이처럼 ReBAC에서는 소유, 공유 등 여러 관계 중 하나라도 연결되어 있으면 권한이 생긴다.
1. ReBAC를 이루는 3가지
1) 관계 튜플(사실)
'누가 - 어떤 관계로 - 무엇에' 연결되어 있는지 기록한 데이터이다.
(user:아람, member, team:프론트팀)
(team:프론트팀, owner, document:A)
(user:아람, editor, document:B)
2) 모델(규칙)
관계가 어떻게 이어지면 어떤 권한이 생기는지 정의한다.
문서를 소유한 팀의 member는 → 그 문서를 수정할 수 있다
문서의 editor로 지정된 사람은 → 그 문서를 수정할 수 있다
3) 체크 (질문)
아래처럼 물으면 엔진이 튜플을 규칙대로 따라간다.
"아람이 문서 A를 수정할 수 있어?"
아람 → 프론트팀 member → 프론트팀이 문서 A의 owner ✅
ReBAC는 "누가 무엇과 어떻게 연결되어 있는가"를 데이터(튜플)로 쌓아두고, 그 연결을 따라가서 권한을 판단하는 방식이다.
2. 백엔드와 프론트는 어떻게 나눠 맡을까

[서비스 이벤트] → 백엔드가 튜플 기록 → 권한 엔진(튜플 저장소)
↑
[프론트 화면] → 백엔드 API 호출 → 백엔드가 권한 엔진에 질문 → 결과를 응답에 담아 전달
핵심은 프론트는 튜플을 직접 만지지 않는다는 점이다. 튜플을 쌓고 판단하는 건 백엔드, 프론트는 판단 결과만 받아 화면에 반영한다.
1) 백엔드
1단계. 규칙(모델)을 먼저 정의한다.
앞의 문서 예시를 OpenFGA 문법으로 쓰면 아래와 같다.
model
schema 1.1
type user
type team
relations
define member: [user]
type document
relations
define owner: [team]
define editor: [user] or member from owner
마지막 줄은 문서의 editor는 직접 editor로 지정된 사용자 또는 문서를 소유한 팀의 member라는 뜻이다.
2단계. 튜플은 테이블의 행으로 저장된다.
권한 엔진 내부에서는 결국 DB 테이블에 쌓인다.
| user | relation | object |
| user:아람 | member | team:프론트팀 |
| team:프론트팀 | owner | document:A |
| team:백엔드팀 | owner | document:B |
| user:아람 | editor | document:B |
3단계. 이벤트가 생기면 튜플을 쓴다.
문서가 공유되는 순간, 백엔드는 서비스 DB 저장과 함께 권한 엔진에 튜플을 기록한다.
POST /stores/{store_id}/write
{
"writes": {
"tuple_keys": [
{ "user": "user:아람", "relation": "editor", "object": "document:B" }
]
}
}
4단계. 권한이 필요하면 질문한다.
POST /stores/{store_id}/check
{
"tuple_key": { "user": "user:아람", "relation": "editor", "object": "document:A" }
}
→ { "allowed": true }
엔진이 아람 -> 프론트 팀 -> document:A로 이어지는 경로를 찾아서 true를 돌려준다.(문서를 소유한 팀의 멤버는 editor다)

2) 프론트
방법 1. 항목마다 권한 API 호출(비추천)
const { data } = useQuery({
queryKey: ['can', docId, 'edit'],
queryFn: () => api.get(`/documents/${docId}/can-edit`),
});
목록에 문서가 50개면 요청도 50번이 나간다. 단건 상세 화면 정도에서 쓸 수 있다.
방법 2. 목록 응답에 권한 함께 담기(추천)
백엔드가 목록을 내려줄 때 엔진에 한꺼번에 물어보고, 결과를 항목마다 붙여준다.
[
{ "id": "A", "title": "API 명세", "permissions": { "canEdit": true, "canDelete": true } },
{ "id": "B", "title": "배포 가이드", "permissions": { "canEdit": true, "canDelete": false } },
{ "id": "C", "title": "인프라 문서", "permissions": { "canEdit": false, "canDelete": false } }
]
프론트는 받은 값을 그대로 쓰면 된다.
function DocumentRow({ doc }: { doc: Document }) {
return (
<tr>
<td>{doc.title}</td>
<td>
<button disabled={!doc.permissions.canEdit}>수정</button>
{doc.permissions.canDelete && <button>삭제</button>}
</td>
</tr>
);
}
요청은 한 번이고, 프론트에는 권한 계산 로직이 전혀 없다.
방법 3. 볼 수 있는 것만 목록으로 받기
백엔드가 ListObjects로 '아람이 editor인 문서 목록'을 먼저 뽑고, 그 문서들만 내려준다. 목록 자체를 권한으로 걸러야 할 때 쓴다.
프론트 버튼 비활성화는 표시용일 뿐이고, 실제 보안은 백엔드가 요청이 올 때마다 다시 check해야 한다.
튜플은 권한 시스템에만 있는게 아니다. 프론트 개발자가 매일 쓰는 useState도 튜플을 돌려준다.
3. useState와 튜플
1. useState는 무엇을 돌려주나?
const [count, setCount] = useState(0);
useState(0)이 실제로 돌려주는 값은 길이 2짜리 배열이다.
useState(0) // → [0, ƒ setCount]
// 값 변경 함수
0번 칸 : 현재 상태 값, 1번 칸 : 상태를 바꾸는 함수
칸 수가 항상 2개로 고정이고, 칸마다 역할이 다르다. 앞에서 정리한 튜플의 조건(순서에 의미, 칸 수 고정) 그대로이다.
2. js 배열인데 왜 튜플이라고 하지?
- 런타임(javascript) : 그냥 평범한 배열. JS에는 튜플이라는 자료형이 따로 없다.
- 타입(Typescript) : 튜플 타입으로 선언되어 있다.
function useState<S>(
initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>];
React 타입 정의를 보면 반환 타입 [S, Dispatch<...>]가 바로 튜플 타입이다. 덕분에 Typescript가 칸별로 타입을 정확히 알고 있다.
const [count, setCount] = useState(0);
// count → number
// setCount → (value: number | ((prev: number) => number)) => void
만약 튜플이 아니라 그냥 배열 (S | Dispatch)[]였다면, count가 숫자인지 함수인지 Typescript가 구분하지 못해서 매번 타입 체크를 해야 했을 것이다.
3. 왜 객체가 아니라 튜플로 만들었을까?
만약 React가 객체로 돌려줬다면 이렇게 써야 한다.
// 가상의 객체 버전
const { value: count, setValue: setCount } = useState(0);
const { value: name, setValue: setName } = useState('');
컴포넌트 안에서 useState를 여러 번 쓰니까, 객체라면 이름이 겹쳐서 매번 value: count처럼 이름을 바꿔줘야 한다. 튜플은 꺼낼 때 이름을 마음대로 붙일 수 있다.
const [count, setCount] = useState(0);
const [name, setName] = useState('');
자리(순서)로 꺼내기 때문에 이름 충돌이 없다. 이게 React가 튜플을 고른 가장 큰 이유이다.
4. 직접 커스텀 훅을 만들 때 주의할 점
같은 패턴으로 훅을 만들면 Typescript가 튜플이 아닌 배열로 추론해 버리는 함정이 있다.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn(v => !v);
return [on, toggle];
}
const [isOpen, toggleOpen] = useToggle();
// isOpen의 타입: boolean | (() => void) ← 원하는 게 아님
Typescript는 배열 리터럴을 기본적으로 '길이가 자유로운 배열'로 본다. 해결방법은 두 가지이다.
// 방법 1: as const
return [on, toggle] as const;
// 방법 2: 반환 타입 명시
function useToggle(initial = false): [boolean, () => void] { ... }
이제 isOpen은 boolean, toggleOpen은 () => void로 정확하게 잡힌다.
5. 튜플 vs 객체, 언제 뭘 쓸까?
| 관점 | 튜플이 유리 | 객체가 유리 |
| 반환 값 개수 | 2개, 많아야 3개 | 4개 이상 |
| 사용 빈도 | 한 컴포넌트에서 여러 번 호출 | 보통 한 번 호출 |
| 필요한 값 | 매번 전부 씀 | 일부만 골라 씀 |
- useState, useToggle처럼 값과 변경 함수 한 쌍 -> 튜플
- Tanstack Query의 useQuery처럼 data, isLoading, error 등 여럿 중 골라 씀 -> 객체
useQuery가 const { data, isLoading } = useQuery(...) 형태인 이유가 바로 이거다. 반환 값이 많고, 필요한 것만 꺼내 쓰기 때문이다.
6. React에 있는 다른 튜플들
const [state, dispatch] = useReducer(reducer, init);
const [isPending, startTransition] = useTransition();
const [optimistic, addOptimistic] = useOptimistic(state, updateFn);
const [state, formAction, isPending] = useActionState(action, init); // 3칸
모두 '값 + 그 값을 다루는 함수' 조합이라 튜플로 되어 있다.
정리하면 ReBAC는 (누가, 관계, 대상) 튜플로 권한을 데이터로 쌓고, useState [값, 변경 함수] 튜플로 상태를 다룬다. 쓰이는 곳은 달라도, 칸의 순서가 정해져 있고, 칸마다 역할이 다르다는 점은 같았다. 튜플이라는 개념 하나를 알고 나니 권한 설계와 React 훅이 같은 아이디어 위에 있다는게 보였다.
참고 자료:
- React 공식 문서: useState
- TypeScript 핸드북: Tuple Types
- TypeScript: const assertions
- DefinitelyTyped: React 타입 정의
'tech epilogue' 카테고리의 다른 글
| SaaS vs 온프레미스 : 프론트엔드에서 달라지는 것 (0) | 2026.10.16 |
|---|---|
| Claude Agent SDK로 플래너 + 실행자 멀티 에이전트 만들어보기 (0) | 2026.09.17 |
| 인앱웹뷰에서 화면마다 히스토리를 관리했더니 벌어진 일 (0) | 2026.09.09 |
| 브라우저 렌더링 (0) | 2026.09.03 |
| B2B 어드민 서비스에 웹 접근성 적용하기 (0) | 2026.07.27 |