Shopify에서 Meta 마케팅 API로 실시간 데이터 파이프라인 구축

발행: (2025년 12월 19일 오전 09:41 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

지난 몇 달 동안 Audience+ 를 구축했습니다 — Shopify 고객 데이터를 실시간으로 Meta의 광고 플랫폼과 동기화하는 도구입니다.

아래는 작동 방식, 우리가 해결한 과제, 그리고 비슷한 것을 구축하려는 분들에게 도움이 될 구체적인 코드 패턴을 명확하고 이해하기 쉽게 정리한 내용입니다.

우리가 해결하고자 하는 문제

Meta의 브라우저 기반 추적은 근본적으로 깨졌습니다.

  • iOS 14.5 앱 추적 투명성(ATT)은 약 75 %의 아이폰 사용자를 숨깁니다.
  • Meta 픽셀은 데이터 보관 기간이 180 일에 불과합니다.
  • Meta는 대부분의 스토어에서 **실제 전환 데이터의 30–40 %**만을 최적화합니다.

해결책: 서버‑사이드 API를 사용해 Shopify에서 Meta로 1인당 고객 및 구매 데이터를 직접 전송합니다.

아키텍처 개요

┌───────────────┐   ┌───────────────┐   ┌───────────────┐
│ Shopify Store │──▶│  Audience+ API│──▶│   Meta API    │
│  (Webhooks)   │   │ (Processing)  │   │               │
└───────────────┘   └───────────────┘   └───────────────┘


                  ┌───────────────┐
                  │  PostgreSQL   │
                  │ (Customer DB)│
                  └───────────────┘

기술 스택

구성 요소선택
FrameworkNext.js 15 (App Router)
LanguageTypeScript
API LayertRPC
DatabasePostgreSQL (Neon serverless)
ORMPrisma
AuthBetter‑Auth + Shopify OAuth
HostingVercel

Shopify 웹훅 통합

Shopify는 고객 및 주문 라이프사이클 이벤트에 대한 웹훅을 전송합니다. 우리는 처리하기 전에 HMAC 서명을 사용하여 각 요청의 진위 여부를 확인합니다.

// app/api/webhooks/shopify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(req: NextRequest) {
  const body = await req.text();
  const hmac = req.headers.get('x-shopify-hmac-sha256');

  if (!verifyShopifyWebhook(body, hmac)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const topic = req.headers.get('x-shopify-topic');
  const payload = JSON.parse(body);

  switch (topic) {
    case 'orders/create':
      await handleNewOrder(payload);
      break;
    case 'customers/create':
      await handleNewCustomer(payload);
      break;
    case 'customers/update':
      await handleCustomerUpdate(payload);
      break;
  }

  return NextResponse.json({ received: true });
}

function verifyShopifyWebhook(body: string, hmac: string | null): boolean {
  if (!hmac) return false;

  const hash = crypto
    .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET!)
    .update(body, 'utf8')
    .digest('base64');

  return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(hmac));
}

고객 데이터 정규화

고객 데이터는 Meta의 요구 사항(소문자, 공백 제거, SHA‑256)을 충족하도록 정규화 및 해시 처리됩니다.

// lib/customer-processor.ts
import crypto from 'crypto';

interface ShopifyCustomer {
  id: number;
  email: string;
  phone?: string;
  first_name?: string;
  last_name?: string;
  orders_count: number;
  total_spent: string;
  created_at: string;
}

interface MetaUserData {
  em?: string;
  ph?: string;
  fn?: string;
  ln?: string;
  external_id?: string;
}

function processCustomerForMeta(customer: ShopifyCustomer): MetaUserData {
  const userData: MetaUserData = {};

  if (customer.email) {
    userData.em = hashForMeta(customer.email.toLowerCase().trim());
  }

  if (customer.phone) {
    userData.ph = hashForMeta(normalizePhone(customer.phone));
  }

  if (customer.first_name) {
    userData.fn = hashForMeta(customer.first_name.toLowerCase().trim());
  }

  if (customer.last_name) {
    userData.ln = hashForMeta(customer.last_name.toLowerCase().trim());
  }

  userData.external_id = hashForMeta(customer.id.toString());

  return userData;
}

function hashForMeta(value: string): string {
  return crypto.createHash('sha256').update(value).digest('hex');
}

/* Helper to normalize phone numbers – implementation omitted for brevity */
function normalizePhone(phone: string): string {
  // e.g., strip non‑numeric characters, ensure E.164 format, etc.
  return phone.replace(/\D+/g, '');
}

Source:

Meta 마케팅 API 통합

우리는 두 개의 Meta API와 통합합니다:

  1. Custom Audiences API – 고객 목록을 동기화합니다.
  2. Conversions API – 실시간 서버‑사이드 이벤트를 전송합니다.

맞춤 잠재고객 동기화

// lib/meta-audience-sync.ts
import { chunk, sleep } from './utils';

const META_API_VERSION = 'v18.0';

interface AudienceUser {
  email?: string;
  phone?: string;
  firstName?: string;
  lastName?: string;
}

async function addUsersToAudience(
  audienceId: string,
  users: AudienceUser[],
  accessToken: string
): Promise {
  const BATCH_SIZE = 10_000;
  const batches = chunk(users, BATCH_SIZE);

  for (const batch of batches) {
    const payload = {
      schema: ['EMAIL', 'PHONE', 'FN', 'LN'],
      data: batch.map(user => [
        user.email ? hashForMeta(user.email.toLowerCase()) : '',
        user.phone ? hashForMeta(normalizePhone(user.phone)) : '',
        user.firstName ? hashForMeta(user.firstName.toLowerCase()) : '',
        user.lastName ? hashForMeta(user.lastName.toLowerCase()) : '',
      ]),
    };

    const response = await fetch(
      `https://graph.facebook.com/${META_API_VERSION}/${audienceId}/users`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ payload, access_token: accessToken }),
      }
    );

    if (!response.ok) {
      const err = await response.text();
      throw new Error(`Meta API error: ${err}`);
    }

    // Respect rate limits
    await sleep(1000);
  }
}

구매 이벤트 전송 (Conversions API)

// lib/meta-conversions.ts
import crypto from 'crypto';

interface ProcessedCustomer {
  emailHash?: string;
  phoneHash?: string;
  // …other hashed fields
}

interface ShopifyOrder {
  id: number;
  created_at: string;
  total_price: string;
  currency: string;
  // …other order fields
}

async function sendPurchaseEvent(
  pixelId: string,
  customer: ProcessedCustomer,
  order: ShopifyOrder,
  accessToken: string
) {
  const event = {
    event_name: 'Purchase',
    event_time: Math.floor(new Date(order.created_at).getTime() / 1000),
    event_id: `order_${order.id}`,
    action_source: 'website',
    user_data: {
      em: customer.emailHash,
      ph: customer.phoneHash,
      // add other hashed identifiers as needed
    },
    custom_data: {
      currency: order.currency,
      value: parseFloat(order.total_price),
      // optional: contents, order_id, etc.
    },
  };

  const response = await fetch(
    `https://graph.facebook.com/${META_API_VERSION}/${pixelId}/events`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        data: [event],
        access_token: accessToken,
      }),
    }
  );

  if (!response.ok) {
    const err = await response.text();
    throw new Error(`Conversions API error: ${err}`);
  }
}

요약

  • First‑party data는 브라우저 기반 추적의 제한을 우회합니다.
  • Server‑side hashing은 매치율을 유지하면서 Meta의 개인정보 보호 요구사항을 충족합니다.
  • Batching & rate‑limit handling은 대규모 오디언스 목록을 처리할 때 필수적입니다.

유사한 파이프라인을 구축하고 있다면, 위의 패턴들이 탄탄한 출발점을 제공할 것입니다. 즐거운 해킹 되세요!

// Example: Sending a Conversions API event to Meta
const event = {
  event_name: 'Purchase',
  event_time: Math.floor(Date.now() / 1000),
  user_data: {
    em: customer.hashedEmail,
    ph: customer.hashedPhone,
    client_ip_address: order.client_details?.browser_ip,
    client_user_agent: order.client_details?.user_agent,
  },
  custom_data: {
    value: parseFloat(order.total_price),
    currency: order.currency,
    content_ids: order.line_items.map(i => i.product_id.toString()),
    content_type: 'product',
    num_items: order.line_items.reduce((s, i) => s + i.quantity, 0),
  },
};

await fetch(
  `https://graph.facebook.com/${META_API_VERSION}/${pixelId}/events`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ data: [event], access_token: accessToken }),
  }
);

잠재고객 세분화

고객은 Meta와 동기화된 세그먼트로 자동 분류됩니다.

enum CustomerSegment {
  NEW = 'new',
  ENGAGED = 'engaged',
  EXISTING = 'existing',
}

function classifyCustomer(customer: CustomerWithOrders): CustomerSegment {
  if (customer.orders_count === 0) return CustomerSegment.ENGAGED;
  if (customer.orders_count >= 1) return CustomerSegment.EXISTING;
  return CustomerSegment.NEW;
}

주요 과제 및 해결책

속도 제한

Meta는 엄격한 제한을 적용합니다. 우리는 지수 백오프 재시도를 사용합니다.

async function withRetry(
  fn: () => Promise,
  maxRetries = 3
): Promise {
  let lastError: Error;

  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err as Error;
      await sleep(2 ** i * 1000); // exponential back‑off
    }
  }
  throw lastError!;
}

멱등 웹훅 처리

async function processWebhookOnce(
  webhookId: string,
  handler: () => Promise
): Promise {
  const existing = await prisma.processedWebhook.findUnique({
    where: { id: webhookId },
  });

  if (existing) return; // already processed

  await handler();

  await prisma.processedWebhook.create({
    data: { id: webhookId, processedAt: new Date() },
  });
}

결과

**Audience+**를 사용하는 스토어는 일반적으로 다음을 확인합니다:

  • 50–100 % 더 많은 전환 (Meta에 표시)
  • 10–20 % ROAS 향상
  • 최초로 정확한 제외 및 리타게팅

내가 다르게 할 일

  • 먼저 Conversions API부터 시작한다.
  • 모니터링을 더 일찍 추가한다.
  • 동기식 처리 대신 첫날부터 큐를 사용한다.

직접 사용해 보기

직접 빌드하지 않고 사용하고 싶다면, 다음을 확인하세요:

👉 https://www.audience-plus.com

10분 설정. 완전 자동화.

구현에 대해 질문이 있나요? 댓글에 남겨 주세요.

Back to Blog

관련 글

더 보기 »