2025 . 오은

repo

Hasura에서 트랜잭션 처리

2025.10.31

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
    }
  }
}

동작:

  • posts INSERT 성공 + comments INSERT 성공 → 모두 커밋
  • comments INSERT 실패 → 모두 롤백

패턴 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: "오은" }
    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 추가

  1. Data → Schema → public → Functions → Track
  2. 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 }
}

6. 패턴 비교

방법별 비교

방법트랜잭션복잡도유연성추천 상황
단일 Mutation✅낮음낮음간단한 관계 데이터
Actions (Next.js)✅중간높음복잡한 로직, 외부 API
PostgreSQL Functions✅중간중간DB 중심 로직
별도 Mutation❌낮음높음사용 금지 (트랜잭션 X)

결론

  • 하나의 mutation = 하나의 트랜잭션: Hasura의 기본 동작
  • 복잡한 로직은 Actions: Next.js와 조합이 최고
  • DB 중심 로직은 Functions: Supabase와 동일한 패턴
  • Apollo Client 배치 ≠ 트랜잭션: 착각하지 말 것

Hasura vs Supabase 트랜잭션 비교

측면HasuraSupabase
기본 방식GraphQL MutationRPC Functions
자동 트랜잭션✅ 단일 mutation❌ 없음
커스텀 로직ActionsRoute Handler
DB Functions✅ Track✅ RPC
학습 곡선GraphQL 익숙하면 쉬움SQL 익숙하면 쉬움

권장 아키텍처

간단한 CRUD
↓
단일 GraphQL Mutation (자동 트랜잭션)

복잡한 비즈니스 로직
↓
Hasura Actions (Next.js API Route)

DB 중심 로직
↓
PostgreSQL Functions (RPC)

참고 자료

  • Hasura Actions 공식 문서
  • PostgreSQL Functions in Hasura
  • Apollo Client Transactions
  • Hasura v3 (DDN) 변경사항