Hasura 트랜잭션 처리: Actions와 Functions 활용하기
📌 TL;DR (한 줄 요약)
Hasura v2는 단일 mutation 내부는 자동 트랜잭션이지만,
복잡한 로직은 Actions 또는 PostgreSQL Functions로 처리해야 합니다.
🎯 핵심 정리
- 단일 GraphQL mutation은 자동으로 트랜잭션 처리
- 여러 mutation을 별도 호출하면 트랜잭션 아님
- Hasura Actions로 커스텀 비즈니스 로직 구현 (추천)
- PostgreSQL Functions (RPC)로 DB 레벨 트랜잭션 처리
- Apollo Client의 배치는 서버 트랜잭션이 아님
1. Hasura의 트랜잭션 동작 방식
✅ 자동 트랜잭션 (보장됨)
# 하나의 mutation 요청 = 하나의 트랜잭션
mutation CreateOrderWithItems {
# 1. 주문 생성
insert_orders_one(object: { total: 10000 }) {
id
}
# 2. 주문 항목 생성
insert_order_items(objects: [
{ product_id: "prod-1", quantity: 2 }
]) {
affected_rows
}
}
# ✅ 둘 다 성공 or 둘 다 실패
❌ 트랜잭션 아님 (주의!)
// Apollo Client에서 별도 호출
await client.mutate({ mutation: CREATE_ORDER });
await client.mutate({ mutation: CREATE_ITEMS });
// ❌ 첫 번째 성공, 두 번째 실패 → 롤백 안 됨!
2. 자동 트랜잭션 (단일 Mutation)
패턴 1: 중첩 Insert (1:N 관계)
mutation CreatePostWithComments {
insert_posts_one(
object: {
title: "새 게시글"
content: "내용"
comments: {
data: [
{ text: "댓글1" }
{ text: "댓글2" }
]
}
}
) {
id
title
comments {
id
text
}
}
}
동작:
postsINSERT 성공 +commentsINSERT 성공 → 모두 커밋commentsINSERT 실패 → 모두 롤백
패턴 2: 여러 테이블 동시 업데이트
mutation UpdateUserAndProfile {
# 하나의 mutation에 포함되면 트랜잭션!
update_users_by_pk(
pk_columns: { id: "user-1" }
_set: { name: "새이름" }
) {
id
}
update_profiles_by_pk(
pk_columns: { user_id: "user-1" }
_set: { status: "active" }
) {
user_id
}
}
패턴 3: Upsert (Insert or Update)
mutation UpsertUser {
insert_users_one(
object: { id: "user-1", name: "blahblah" }
on_conflict: {
constraint: users_pkey
update_columns: [name]
}
) {
id
name
}
}
3. 해결책 1: Hasura Actions
Actions란?
Hasura Actions는 커스텀 비즈니스 로직을 REST/GraphQL 엔드포인트로 구현하는 기능.
사용 시기
- 복잡한 비즈니스 로직
- 외부 API 호출 필요
- 트랜잭션 + 복잡한 검증
- Hasura mutation만으로 불가능한 경우
구현 예제: Next.js + Actions
1단계: Next.js API Route 생성
// app/api/hasura/create-order/route.ts
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
export async function POST(request: Request) {
const { input, session_variables } = await request.json();
const { user_id, items } = input;
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. 재고 확인 및 차감
for (const item of items) {
const stock = await client.query(
'SELECT quantity FROM products WHERE id = $1 FOR UPDATE',
[item.product_id]
);
if (stock.rows[0].quantity < item.quantity) {
throw new Error(`재고 부족: ${item.product_id}`);
}
await client.query(
'UPDATE products SET quantity = quantity - $1 WHERE id = $2',
[item.quantity, item.product_id]
);
}
// 2. 주문 생성
const orderResult = await client.query(
'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id',
[user_id, items.reduce((sum, i) => sum + i.price * i.quantity, 0)]
);
const orderId = orderResult.rows[0].id;
// 3. 주문 항목 생성
for (const item of items) {
await client.query(
'INSERT INTO order_items (order_id, product_id, quantity, price) VALUES ($1, $2, $3, $4)',
[orderId, item.product_id, item.quantity, item.price]
);
}
await client.query('COMMIT');
return Response.json({
order_id: orderId,
success: true
});
} catch (error) {
await client.query('ROLLBACK');
return Response.json(
{ message: error.message },
{ status: 400 }
);
} finally {
client.release();
}
}
2단계: Hasura Console에서 Action 정의
# Hasura Console → Actions → Create
type Mutation {
createOrder(
user_id: uuid!
items: [OrderItemInput!]!
): CreateOrderOutput
}
input OrderItemInput {
product_id: uuid!
quantity: Int!
price: Int!
}
type CreateOrderOutput {
order_id: uuid!
success: Boolean!
}
Handler URL: https://your-domain.com/api/hasura/create-order
3단계: Apollo Client에서 사용
// hooks/useCreateOrder.ts
import { gql, useMutation } from '@apollo/client';
const CREATE_ORDER = gql`
mutation CreateOrder($user_id: uuid!, $items: [OrderItemInput!]!) {
createOrder(user_id: $user_id, items: $items) {
order_id
success
}
}
`;
export function useCreateOrder() {
const [createOrder, { loading, error }] = useMutation(CREATE_ORDER);
return {
createOrder,
loading,
error
};
}
// components/CheckoutButton.tsx
'use client';
import { useCreateOrder } from '@/hooks/useCreateOrder';
export function CheckoutButton({ userId, items }) {
const { createOrder, loading } = useCreateOrder();
const handleCheckout = async () => {
try {
const { data } = await createOrder({
variables: {
user_id: userId,
items: items.map(item => ({
product_id: item.id,
quantity: item.quantity,
price: item.price
}))
}
});
if (data.createOrder.success) {
alert('주문이 완료되었습니다!');
}
} catch (error) {
alert('주문 실패: ' + error.message);
}
};
return (
<button onClick={handleCheckout} disabled={loading}>
{loading ? '처리 중...' : '주문하기'}
</button>
);
}
Actions의 장점
- ✅ 복잡한 비즈니스 로직을 TypeScript/JavaScript로 작성
- ✅ 외부 API 호출 가능 (결제, 이메일 등)
- ✅ 트랜잭션 완전 제어
- ✅ 에러 처리 자유롭게 구현
4. 해결책 2: PostgreSQL Functions
Functions (RPC) 사용
Supabase와 동일한 방식!
-- Hasura Console → Data → SQL
CREATE OR REPLACE FUNCTION create_order_with_items(
p_user_id UUID,
p_items JSONB
)
RETURNS JSON
LANGUAGE plpgsql
AS $$
DECLARE
v_order_id UUID;
v_item JSONB;
BEGIN
-- 주문 생성
INSERT INTO orders (user_id, total)
VALUES (
p_user_id,
(SELECT SUM((item->>'quantity')::INT * (item->>'price')::INT)
FROM jsonb_array_elements(p_items) AS item)
)
RETURNING id INTO v_order_id;
-- 주문 항목 생성
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (
v_order_id,
(v_item->>'product_id')::UUID,
(v_item->>'quantity')::INT,
(v_item->>'price')::INT
);
END LOOP;
RETURN json_build_object('order_id', v_order_id, 'success', true);
EXCEPTION
WHEN OTHERS THEN
RETURN json_build_object('success', false, 'error', SQLERRM);
END;
$$;
Hasura에서 Function 추가
- Data → Schema → public → Functions → Track
- Function이 GraphQL에 자동 추가됨
GraphQL로 호출
mutation CreateOrder {
create_order_with_items(
args: {
p_user_id: "user-1"
p_items: [
{ product_id: "prod-1", quantity: 2, price: 10000 }
{ product_id: "prod-2", quantity: 1, price: 5000 }
]
}
) {
order_id
success
}
}
Functions의 장점
- ✅ 데이터베이스 레벨 트랜잭션
- ✅ SQL 최적화 가능
- ✅ 별도 서버 불필요
- ✅ Hasura 권한 시스템 통합
5. Apollo Client와 트랜잭션
❌ 배치는 트랜잭션이 아님
import { ApolloLink } from '@apollo/client';
import { BatchHttpLink } from '@apollo/client/link/batch-http';
// 여러 요청을 묶어서 보내기
const batchLink = new BatchHttpLink({
uri: 'https://your-hasura.hasura.app/v1/graphql',
batchMax: 5,
batchInterval: 20
});
// 하지만 서버에서는 별도 트랜잭션!
await client.mutate({ mutation: MUTATION_1 });
await client.mutate({ mutation: MUTATION_2 });
// ❌ 네트워크는 최적화되지만 트랜잭션 아님
✅ 단일 mutation으로 작성
# 하나의 mutation에 모두 포함
mutation BatchUpdate {
update1: update_users(...) { id }
update2: update_profiles(...) { user_id }
}
Optimistic Updates (낙관적 업데이트)
const [updateUser] = useMutation(UPDATE_USER, {
optimisticResponse: {
update_users_by_pk: {
__typename: 'users',
id: userId,
name: newName
}
},
onError: (error) => {
// 실패 시 UI 자동 롤백
console.error('Update failed:', error);
}
});
6. 패턴 비교 {#6-패턴-비교}
방법별 비교
| 방법 | 트랜잭션 | 복잡도 | 유연성 | 추천 상황 |
|---|---|---|---|---|
| 단일 Mutation | ✅ | 낮음 | 낮음 | 간단한 관계 데이터 |
| Actions (Next.js) | ✅ | 중간 | 높음 | 복잡한 로직, 외부 API |
| PostgreSQL Functions | ✅ | 중간 | 중간 | DB 중심 로직 |
| 별도 Mutation | ❌ | 낮음 | 높음 | 사용 금지 (트랜잭션 X) |
실전 가이드
// ✅ 간단한 INSERT → 단일 mutation
mutation {
insert_posts_one(object: {
title: "제목"
comments: { data: [{ text: "댓글" }] }
}) { id }
}
// ✅ 복잡한 로직 → Actions
- 재고 확인 + 주문 생성 + 결제 + 이메일
- Next.js API Route로 구현
// ✅ DB 중심 로직 → Functions
- 집계, 통계 계산
- 복잡한 데이터 변환
- PostgreSQL Function으로 구현
🎓 결론
핵심 기억사항
- 하나의 mutation = 하나의 트랜잭션: Hasura의 기본 동작
- 복잡한 로직은 Actions: Next.js와 조합이 최고
- DB 중심 로직은 Functions: Supabase와 동일한 패턴
- Apollo Client 배치 ≠ 트랜잭션: 착각하지 말 것
Hasura vs Supabase 트랜잭션 비교
| 측면 | Hasura | Supabase |
|---|---|---|
| 기본 방식 | GraphQL Mutation | RPC Functions |
| 자동 트랜잭션 | ✅ 단일 mutation | ❌ 없음 |
| 커스텀 로직 | Actions | Route Handler |
| DB Functions | ✅ Track | ✅ RPC |
| 학습 곡선 | GraphQL 익숙하면 쉬움 | SQL 익숙하면 쉬움 |
권장 아키텍처
간단한 CRUD
↓
단일 GraphQL Mutation (자동 트랜잭션)
복잡한 비즈니스 로직
↓
Hasura Actions (Next.js API Route)
DB 중심 로직
↓
PostgreSQL Functions (RPC)
📚 참고 자료
'TIL' 카테고리의 다른 글
| [251015 TIL] 문자인증(알리고, with GQL) (0) | 2025.10.15 |
|---|---|
| [251011 TIL] queryFn 에서 메서드 사용시 주의점 (0) | 2025.10.11 |
| [251009 TIL] PostgREST? (supabase vs hasura) (0) | 2025.10.09 |
| [240421 WIL 1주차] 웹디자인, github, env (0) | 2024.04.21 |
| [240419 TIL]첫 팀 프로젝트 회고 (1) | 2024.04.19 |