이커머스 추적 — Enhanced Ecommerce·변형 상품

2026-05-03확률과 통계 마스터 노트

GA4 + GTM 마스터 노트 시리즈 5편. view_item_list → select_item → view_item → add_to_cart → begin_checkout → purchase 전체 이커머스 플로우, items 배열 표준 구조, REST API 동적 데이터 수집, 변형 상품(색상·사이즈) 추적, Race Condition 방지, 서버사이드 보안과 캐싱 최적화까지.

이 글은 GA4 + GTM 마스터 노트 시리즈의 다섯 번째 편입니다. 4편(전환)에서 비즈니스 핵심 이벤트의 토대를 잡았다면, 이번엔 이커머스 사이트의 전체 쇼핑 여정 추적입니다.

이커머스 추적이 GA4 활용에서 가장 복잡한 영역이에요. 9가지 표준 이벤트, items 배열의 일관된 구조, 변형 상품 처리, REST API 비동기 호출의 Race Condition — 한 번에 다 들어오면 머리가 어지럽지만, 한 번 패턴이 잡히면 같은 구조의 반복일 뿐이에요.

처음 이커머스 추적이 어렵게 느껴지는 이유

이유는 두 가지예요.

첫째, 이벤트 9개가 정확한 순서로 발동되어야 보고서가 의미를 가집니다. view_item_list → select_item → view_item → add_to_cart → begin_checkout → add_payment_info → add_shipping_info → purchase. 한 단계라도 빠지면 퍼널 분석이 깨져요. 그리고 각 이벤트마다 items 배열의 형식이 정확히 같아야 합니다.

둘째, 상품 데이터를 어디서 가져올지가 어렵습니다. 클릭한 상품의 ID·이름·가격·카테고리·변형(색상·사이즈)을 어떻게 다 모을지. 정적으로 HTML data 속성에 박을지, 동적으로 REST API를 호출할지, 서버에서 미리 출력할지 — 각 방식마다 트레이드오프가 다릅니다.

해결법은 두 가지. 첫째, 공통 formatItem 함수를 한 번 만들어 모든 이벤트에 재사용하세요. items 배열 만드는 코드를 매번 새로 쓰면 변형이 미묘하게 어긋나요. 둘째, 상품 데이터는 가능한 한 페이지 로드 시 한 번에 캐싱하고, 이후 이벤트는 캐시에서 가져오세요. API 호출을 매 이벤트마다 하면 성능이 무너집니다.

이커머스 이벤트 플로우 — 9개의 정해진 순서

사용자 쇼핑 여정:
1. view_item_list      — 상품 목록 조회
2. select_item         — 상품 클릭
3. view_item           — 상품 상세 조회
4. add_to_cart         — 장바구니 추가
5. view_cart           — 장바구니 보기
6. begin_checkout      — 체크아웃 시작
7. add_payment_info    — 결제 정보 입력
8. add_shipping_info   — 배송 정보 입력
9. purchase            — 구매 완료

부가:
- remove_from_cart     — 장바구니에서 삭제
- view_promotion       — 프로모션 조회
- select_promotion     — 프로모션 클릭

이 9개를 다 구현하지 않아도 GA4 보고서가 작동은 해요. 다만 Funnel Exploration이나 어트리뷰션 분석은 단계가 빠질수록 의미가 줄어듭니다.

items 배열 — 모든 이벤트의 공통 구조

이커머스 이벤트는 모두 같은 items 배열 형식을 공유해요.

{
  'items': [
    {
      'item_id': 'SKU-001',           // 필수
      'item_name': 'Blue T-Shirt',    // 필수
      'item_brand': 'MyBrand',        // 권장
      'item_category': 'Clothing',    // 권장
      'item_category2': 'T-Shirts',   // 권장
      'item_variant': 'Blue / M',     // 권장 (변형 정보)
      'price': 29.99,                 // 권장
      'quantity': 2,                  // 권장
      'discount': 5.00,               // 선택
      'coupon': 'SAVE10',             // 선택
      'affiliation': 'Online Store',  // 선택
      'index': 1,                     // 권장 (목록 내 위치)
      'item_list_id': 'related_products',
      'item_list_name': '관련 상품'
    }
  ]
}

여기서 시험 함정이 하나 있어요. item_iditem_name만 필수입니다. 나머지는 권장이지만, 빠질수록 보고서 분석 깊이가 떨어져요. 특히 item_variant(색상·사이즈)와 index(목록 내 위치)는 어떤 변형이·어떤 위치에서 잘 팔리는지 알려주는 핵심 차원입니다.

공통 포맷 함수 — 모든 이벤트의 토대

이커머스 코드의 가장 중요한 패턴 — formatItem 함수를 한 번 만들고 모든 이벤트에서 재사용.

// 기본 포맷
function formatProductItem(product, index, quantity) {
  return {
    'item_id': capitalizeId(product.id),
    'item_name': product.name,
    'item_brand': product.brand || '',
    'item_category': product.categories && product.categories[0] 
                     ? product.categories[0].name : '',
    'price': parseFloat(product.price),
    'quantity': quantity || 1,
    'index': index || 1
  };
}

// 변형 상품 포함 포맷
function formatProductItemWithVariant(product, variant, index, quantity) {
  var item = formatProductItem(product, index, quantity);
  if (variant) {
    item.item_variant = variant.attributes
      .map(attr => attr.option)
      .join(' / ');
    item.price = parseFloat(variant.price || product.price);
  }
  return item;
}

// 상품 ID 표준화
function capitalizeId(productId) {
  return 'WC-' + String(productId).toUpperCase();
}

이제 모든 이벤트는 이 함수를 호출하기만 하면 됩니다. items 형식이 흔들릴 일이 없어요.

view_item_list — 상품 목록 조회

쇼핑 페이지·카테고리 페이지가 로드될 때 발동.

window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'ecommerce': null }); // 이전 데이터 초기화 — 매우 중요!

window.dataLayer.push({
  'event': 'view_item_list',
  'ecommerce': {
    'item_list_id': 'shop_page',
    'item_list_name': '쇼핑 목록',
    'items': [
      {
        'item_id': 'SKU-001',
        'item_name': 'Blue T-Shirt',
        'item_category': 'Clothing',
        'price': 29.99,
        'index': 1
      },
      {
        'item_id': 'SKU-002',
        'item_name': 'Red Pants',
        'item_category': 'Clothing',
        'price': 49.99,
        'index': 2
      }
    ]
  }
});

여기서 정말 중요한 시험 함정 — window.dataLayer.push({ 'ecommerce': null }) 한 줄을 매 이커머스 이벤트 직전에 박아야 합니다. 안 그러면 이전 이벤트의 items가 새 이벤트에 섞여요. 7편(베스트 프랙티스)에서 자세히 다룹니다.

REST API로 상품 데이터 동적 수집

WordPress/WooCommerce의 REST API를 활용한 패턴.

// REST API 인증 (OAuth 1.0a)
function generateOAuthSignature(method, url, params, consumerSecret) {
  var baseString = method + '&' + 
                   encodeURIComponent(url) + '&' + 
                   encodeURIComponent(Object.keys(params)
                     .sort()
                     .map(k => k + '=' + params[k])
                     .join('&'));
  
  return CryptoJS.HmacSHA1(baseString, consumerSecret + '&').toString(CryptoJS.enc.Base64);
}

// API 호출
function fetchProductData(productId, callback) {
  var url = window.location.origin + '/wp-json/wc/v3/products/' + productId;
  
  var xhr = new XMLHttpRequest();
  xhr.open('GET', url + '?consumer_key=' + consumerKey + '&consumer_secret=' + consumerSecret, true);
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4 && xhr.status === 200) {
      var product = JSON.parse(xhr.responseText);
      callback(product);
    }
  };
  xhr.send();
}

여기서 시험 함정이 하나 있어요. Consumer Secret을 클라이언트 코드에 박으면 보안 사고예요. 누구나 브라우저 개발자 도구로 확인 가능합니다. 7편(베스트 프랙티스)에서 다루는 서버사이드 프록시 패턴이 표준입니다.

select_item — 상품 클릭

document.addEventListener('click', function(e) {
  var productCard = e.target.closest('.product-card, .woocommerce-loop-product');
  if (!productCard) return;
  
  var productId = getProductId(productCard);
  
  fetchProductData(productId, function(product) {
    window.dataLayer.push({ 'ecommerce': null });
    window.dataLayer.push({
      'event': 'select_item',
      'ecommerce': {
        'item_list_id': 'product_list',
        'items': [formatProductItem(product, 1)]
      }
    });
  });
});

상품 ID 추출 함수:

function getProductId(element) {
  if (!element) return null;
  
  // 방법 1: CSS 클래스 (예: post-24, product-24)
  var productClass = Array.from(element.classList)
    .find(cls => cls.startsWith('post-') || cls.match(/product-\d+/));
  
  if (productClass) {
    var parts = productClass.split('-');
    return parseInt(parts[parts.length - 1]);
  }
  
  // 방법 2: data 속성 (권장)
  return element.getAttribute('data-product-id');
}

view_item — 상품 상세 조회

상세 페이지 로드 시 1회 발동.

window.addEventListener('load', function() {
  var productElement = document.querySelector('.type-product');
  if (!productElement) return;
  
  var rawId = productElement.id.split('-')[1]; // post-24 → 24
  
  fetchProductData(rawId, function(product) {
    var item;
    
    if (product.type === 'variable') {
      // 변형 상품 — 현재 선택된 변형 찾기
      var selectedVariant = getCurrentVariant(product);
      item = formatProductItemWithVariant(product, selectedVariant, 1);
    } else {
      item = formatProductItem(product, 1);
    }
    
    window.dataLayer.push({ 'ecommerce': null });
    window.dataLayer.push({
      'event': 'view_item',
      'ecommerce': {
        'currency': 'USD',
        'value': item.price,
        'items': [item]
      }
    });
  });
});

add_to_cart — 장바구니 추가

document.querySelectorAll('.add_to_cart_button, .single_add_to_cart_button')
  .forEach(function(button) {
    button.addEventListener('click', function() {
      var productId = this.getAttribute('data-product_id') 
                      || getProductId(this.closest('.product-card'));
      var quantity = parseInt(document.querySelector('.quantity input')?.value || 1);
      
      fetchProductData(productId, function(product) {
        window.dataLayer.push({ 'ecommerce': null });
        window.dataLayer.push({
          'event': 'add_to_cart',
          'ecommerce': {
            'currency': 'USD',
            'value': parseFloat(product.price) * quantity,
            'items': [formatProductItem(product, 1, quantity)]
          }
        });
      });
    });
  });

Race Condition 해결 — 빠른 연속 클릭

비동기 API 호출의 골치 아픈 문제. 빠른 연속 클릭 시 응답 순서가 보장되지 않아요.

// 문제: 응답 순서가 클릭 순서와 다를 수 있음
// 해결: 요청 ID로 최신 응답만 처리

var pendingRequests = {};
var requestCounter = 0;

function fetchProductDataSafe(productId, callback) {
  var requestId = ++requestCounter;
  pendingRequests[productId] = requestId;
  
  fetchProductData(productId, function(product) {
    // 가장 최신 요청만 처리
    if (pendingRequests[productId] === requestId) {
      delete pendingRequests[productId];
      callback(product);
    }
  });
}

여기서 시험 함정이 하나 있어요. 빠른 연속 클릭 → 응답 도착 순서 → GA4에 잘못된 데이터 패턴은 디버깅이 매우 어려워요. 평소엔 문제 없다가 사용자 트래픽 폭증할 때 갑자기 데이터가 어긋나기 시작합니다. 위 패턴을 처음부터 박아두는 게 안전.

remove_from_cart

document.querySelectorAll('.remove').forEach(function(removeBtn) {
  removeBtn.addEventListener('click', function() {
    var cartItem = this.closest('.cart_item');
    var productId = cartItem.getAttribute('data-product_id');
    var quantity = parseInt(cartItem.querySelector('.qty').value || 1);
    var price = parseFloat(cartItem.querySelector('.amount').innerText.replace('$', ''));
    
    window.dataLayer.push({ 'ecommerce': null });
    window.dataLayer.push({
      'event': 'remove_from_cart',
      'ecommerce': {
        'currency': 'USD',
        'value': price * quantity,
        'items': [{
          'item_id': productId,
          'item_name': cartItem.querySelector('.product-name').innerText,
          'price': price,
          'quantity': quantity
        }]
      }
    });
  });
});

begin_checkout — 체크아웃 시작

function implementBeginCheckout(cartItems) {
  window.dataLayer.push({ 'ecommerce': null });
  window.dataLayer.push({
    'event': 'begin_checkout',
    'ecommerce': {
      'currency': 'USD',
      'value': calculateCartTotal(cartItems),
      'coupon': getAppliedCoupon(),
      'items': cartItems.map((item, index) => ({
        'item_id': item.id,
        'item_name': item.name,
        'price': item.price,
        'quantity': item.quantity,
        'index': index + 1
      }))
    }
  });
}

purchase — 구매 완료 (가장 중요한 한 이벤트)

이커머스 추적의 핵심. 잘못되면 매출 데이터 전체가 어긋나요.

서버에서 주문 데이터 출력

<?php
echo "<script>
  var orderData = " . json_encode([
    'id' => $order->get_id(),
    'total' => $order->get_total(),
    'currency' => $order->get_currency(),
    'tax' => $order->get_total_tax(),
    'shipping' => $order->get_shipping_total(),
    'coupon' => $order->get_coupon_codes(),
    'items' => array_map(function($item) {
      $product = $item->get_product();
      return [
        'id' => $product->get_id(),
        'name' => $product->get_name(),
        'price' => $product->get_price(),
        'quantity' => $item->get_quantity()
      ];
    }, $order->get_items())
  ]) . ";
</script>";
?>

JavaScript에서 이벤트 발동

if (typeof orderData !== 'undefined') {
  window.dataLayer.push({ 'ecommerce': null });
  window.dataLayer.push({
    'event': 'purchase',
    'ecommerce': {
      'transaction_id': String(orderData.id),
      'value': parseFloat(orderData.total),
      'currency': orderData.currency,
      'tax': parseFloat(orderData.tax || 0),
      'shipping': parseFloat(orderData.shipping || 0),
      'coupon': orderData.coupon || '',
      'items': orderData.items.map((item, index) => ({
        'item_id': capitalizeId(item.id),
        'item_name': item.name,
        'price': parseFloat(item.price),
        'quantity': item.quantity,
        'index': index + 1
      }))
    }
  });
}

여기서 정말 중요한 시험 함정 — transaction_id 가 빠지면 GA4가 중복 제거를 못 합니다. 사용자가 결제 완료 페이지를 새로고침하면 같은 구매가 두 번 카운트돼요. 반드시 String 변환 + 고유 ID 박기.

변형 상품 (Product Variations) 추적

T-Shirt 상품
- 변형 1: 색상=Blue, 사이즈=S (가격 29.99)
- 변형 2: 색상=Blue, 사이즈=M (가격 29.99)
- 변형 3: 색상=Red,  사이즈=S (가격 31.99)

각 변형은 별도 가격·재고·ID를 가질 수 있어요.

현재 선택된 변형 감지

function getCurrentVariant(product, selectedAttributes) {
  if (!product.variations || product.variations.length === 0) return null;
  
  return product.variations.find(function(variation) {
    return variation.attributes.every(function(attr) {
      return selectedAttributes[attr.name] === attr.option;
    });
  });
}

// 변형 선택 이벤트 감지
document.querySelectorAll('.variations select').forEach(function(select) {
  select.addEventListener('change', function() {
    updateViewItemWithVariation();
  });
});

function updateViewItemWithVariation() {
  var selectedAttributes = {};
  document.querySelectorAll('.variations select').forEach(function(sel) {
    selectedAttributes[sel.getAttribute('name')] = sel.value;
  });
  
  var currentVariant = getCurrentVariant(currentProduct, selectedAttributes);
  if (currentVariant) {
    window.dataLayer.push({ 'ecommerce': null });
    window.dataLayer.push({
      'event': 'view_item',
      'ecommerce': {
        'items': [formatProductItemWithVariant(currentProduct, currentVariant, 1)]
      }
    });
  }
}

item_variant: 'Blue / M' 같은 형식으로 GA4에 들어가서, 어떤 색상·사이즈가 잘 팔리는지 보고서에서 확인 가능해요.

서버사이드 최적화 — 보안과 성능

Consumer Secret 보호

클라이언트에 절대 노출 X. 서버사이드 프록시.

// PHP — 서버에서 인증 처리
add_action('wp_ajax_get_product_data', 'get_product_data_ajax');
add_action('wp_ajax_nopriv_get_product_data', 'get_product_data_ajax');

function get_product_data_ajax() {
    $product_id = intval($_POST['product_id']);
    $product = wc_get_product($product_id);
    
    if ($product) {
        wp_send_json_success([
            'id' => $product->get_id(),
            'name' => $product->get_name(),
            'price' => $product->get_price(),
            'categories' => wp_get_post_terms($product_id, 'product_cat')
        ]);
    }
}
// JavaScript — 내부 엔드포인트만 호출
function fetchProductDataServer(productId, callback) {
    fetch('/wp-admin/admin-ajax.php', {
        method: 'POST',
        body: new URLSearchParams({
            action: 'get_product_data',
            product_id: productId,
            nonce: wpData.nonce  // CSRF 방지
        })
    })
    .then(r => r.json())
    .then(data => callback(data.data));
}

캐싱 — 동일 상품 반복 호출 방지

var productCache = {};

function fetchProductDataCached(productId, callback) {
  if (productCache[productId]) {
    callback(productCache[productId]);
    return;
  }
  
  fetchProductData(productId, function(product) {
    productCache[productId] = product;
    callback(product);
  });
}

페이지 안에서 같은 상품 정보를 반복 호출하는 자리(예: select_item → add_to_cart)에서 큰 차이가 나요.

프로모션 추적

view_promotion — 배너 노출 시

window.dataLayer.push({
  'event': 'view_promotion',
  'ecommerce': {
    'items': [{
      'item_id': 'PROMO-001',
      'item_name': '여름 세일',
      'promotion_id': 'SUMMER_SALE',
      'promotion_name': '여름 대세일 50% 할인',
      'creative_name': 'summer_banner_hero',
      'creative_slot': 'homepage_hero'
    }]
  }
});

select_promotion — 배너 클릭 시

document.querySelectorAll('.promo-banner').forEach(function(banner) {
  banner.addEventListener('click', function() {
    window.dataLayer.push({
      'event': 'select_promotion',
      'ecommerce': {
        'items': [{
          'promotion_id': this.getAttribute('data-promo-id'),
          'promotion_name': this.getAttribute('data-promo-name'),
          'creative_slot': this.getAttribute('data-slot')
        }]
      }
    });
  });
});

맞춤 측정기준 등록

이커머스 매개변수를 GA4 보고서에서 차원으로 활용하려면 등록 필요.

GA4 Admin → Custom Definitions → Custom Dimensions → Create

표준 등록 항목:
- Item ID         | item_id
- Item Category   | item_category
- Item Variant    | item_variant
- Transaction ID  | transaction_id
- Item List Name  | item_list_name

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 5편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • 이커머스 9개 이벤트 — view_item_listselect_itemview_itemadd_to_cartview_cartbegin_checkoutadd_payment_infoadd_shipping_infopurchase
  • 부가 — remove_from_cart, view/select_promotion
  • items 배열은 모든 이벤트 공통 구조
  • 필수 — item_id, item_name
  • 권장 — item_brand, item_category, item_variant, price, quantity, index
  • 공통 formatItem 함수 = 일관성의 핵심
  • window.dataLayer.push({ ecommerce: null }) 매 이커머스 이벤트 직전 필수
  • 안 박으면 이전 items가 새 이벤트에 섞임
  • REST API 활용 — WooCommerce 동적 데이터 수집
  • Consumer Secret 클라이언트 노출 금지 — 서버사이드 프록시
  • Race Condition — 빠른 연속 클릭 시 응답 순서 깨짐
  • 해결 — 요청 ID(counter) 로 최신만 처리
  • transaction_id 필수 — 새로고침 시 중복 제거
  • 변형 상품 — item_variant: 'Blue / M' 형식
  • 변형마다 별도 가격·재고·ID 가능
  • 변형 선택 이벤트 — select.addEventListener('change', ...)
  • 캐싱으로 동일 상품 반복 호출 최소화
  • 프로모션 — view_promotion (노출), select_promotion (클릭)
  • creative_name·creative_slot으로 배너 위치 추적
  • 이커머스 매개변수도 맞춤 측정기준 등록 필수
  • Item Variant·Transaction ID 등 등록 안 하면 보고서 분석 X
  • value 매개변수 = 가격 × 수량 (장바구니/구매 합계)
  • currency 필수 (USD, KRW 등)

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 톤으로 묶어 정리되어 있어요. 5편 이커머스가 잡히면 6편 보고서에서 이 데이터를 실제로 어떻게 활용하는지가 보입니다.

공식 문서: GA4 Ecommerce Implementation Guide에서 모든 이벤트의 표준 매개변수를 확인할 수 있어요.

다음 글(6편)에서는 표준 보고서·탐색 보고서(Funnel·Path·Cohort)·세그먼트·Looker Studio·BigQuery 연동까지 — 모은 데이터를 실제 인사이트로 바꾸는 도구들을 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!