Skip to main content

Checkout Extensions

Shopify의 결제는 인터넷에서 가장 높은 전환율을 자랑하며, 완전히 확장 가능합니다. Checkout 확장을 사용하면 Shopify의 핵심 결제 코드를 건드리지 않고 커스텀 UI를 추가하고, 결제 및 배송 옵션을 수정하고, 장바구니 내용을 검증하고, 구매 후 경험을 만들 수 있습니다. 이러한 확장은 결제 성능과 보안이 절대 저하되지 않도록 보장하는 안전한 샌드박스 환경에서 실행됩니다.

결제 확장성

Shopify는 checkout.liquid 커스터마이제이션에서 컴포넌트 기반 확장 모델로 전환했습니다. 모든 새로운 결제 커스터마이제이션은 checkout 확장을 사용해야 합니다. checkout.liquid 방식은 새 스토어에서는 더 이상 사용되지 않으며, 기존 Shopify Plus 스토어에서도 결국 제거될 예정입니다.

Checkout UI Extensions

Checkout UI 확장은 결제 흐름의 특정 지점에서 커스텀 컴포넌트를 렌더링합니다. 가장 일반적인 유형의 결제 확장입니다.

확장 대상

Checkout UI 확장은 다음 위치에서 렌더링할 수 있습니다:

대상위치일반적인 사용 사례
purchase.checkout.block.renderMain checkout bodyCustom fields, upsells, loyalty info
purchase.checkout.header.render-afterBelow checkout headerBanners, trust badges
purchase.checkout.footer.render-beforeAbove checkout footerLegal notices, guarantees
purchase.checkout.delivery-address.render-beforeBefore shipping addressGift messaging, delivery instructions
purchase.checkout.shipping-option-list.render-afterAfter shipping optionsShipping protection, delivery notes
purchase.checkout.payment-method-list.render-beforeBefore payment methodsLoyalty points redemption
purchase.thank-you.block.renderThank-you pagePost-purchase survey, referral
purchase.order-status.block.renderOrder status pageTracking, support, reviews

Checkout UI Extension 구축

# Generate a checkout UI extension
shopify app generate extension --template checkout_ui --name loyalty-points-checkout
// extensions/loyalty-points-checkout/src/Checkout.tsx
import {
reactExtension,
Banner,
BlockStack,
Button,
Checkbox,
Divider,
Heading,
InlineLayout,
Text,
useApi,
useApplyDiscountCodeChange,
useCartLines,
useTotalAmount,
} from '@shopify/ui-extensions-react/checkout';
import { useEffect, useState } from 'react';

const checkoutBlock = reactExtension(
'purchase.checkout.block.render',
() => <LoyaltyPointsRedemption />
);
export default checkoutBlock;

function LoyaltyPointsRedemption() {
const { buyerIdentity } = useApi();
const cartLines = useCartLines();
const totalAmount = useTotalAmount();
const applyDiscountCode = useApplyDiscountCodeChange();

const [loyaltyData, setLoyaltyData] = useState<{
pointsBalance: number;
maxRedeemable: number;
dollarValue: number;
} | null>(null);
const [redeemPoints, setRedeemPoints] = useState(false);
const [applied, setApplied] = useState(false);

const customerEmail = buyerIdentity?.email?.current;

useEffect(() => {
async function fetchLoyalty() {
if (!customerEmail) return;

try {
const response = await fetch(
`/api/loyalty/checkout-balance?email=${encodeURIComponent(customerEmail)}&total=${totalAmount.amount}`
);
const data = await response.json();
setLoyaltyData(data);
} catch (err) {
console.error('Failed to fetch loyalty data');
}
}

fetchLoyalty();
}, [customerEmail, totalAmount.amount]);

async function handleRedeem() {
if (!loyaltyData) return;

try {
// Apply a discount code that corresponds to the points redemption
const result = await applyDiscountCode({
type: 'addDiscountCode',
code: `LOYALTY-${customerEmail}-${loyaltyData.maxRedeemable}`,
});

if (result.type === 'success') {
setApplied(true);
}
} catch (err) {
console.error('Failed to apply loyalty discount');
}
}

if (!loyaltyData || loyaltyData.pointsBalance === 0) {
return null; // Don't render anything if no points available
}

return (
<BlockStack spacing="base">
<Heading level={2}>Loyalty Points</Heading>
<Divider />

<InlineLayout columns={['fill', 'auto']}>
<Text>Available Points</Text>
<Text emphasis="bold">
{loyaltyData.pointsBalance.toLocaleString()}
</Text>
</InlineLayout>

<InlineLayout columns={['fill', 'auto']}>
<Text>Redeemable Value</Text>
<Text emphasis="bold">
${loyaltyData.dollarValue.toFixed(2)}
</Text>
</InlineLayout>

{!applied ? (
<BlockStack spacing="tight">
<Checkbox
checked={redeemPoints}
onChange={setRedeemPoints}
>
Apply {loyaltyData.maxRedeemable.toLocaleString()} points
(-${loyaltyData.dollarValue.toFixed(2)})
</Checkbox>

{redeemPoints && (
<Button kind="secondary" onPress={handleRedeem}>
Redeem Points
</Button>
)}
</BlockStack>
) : (
<Banner status="success">
{loyaltyData.maxRedeemable.toLocaleString()} points applied!
You saved ${loyaltyData.dollarValue.toFixed(2)}.
</Banner>
)}
</BlockStack>
);
}
결제 UI 성능

결제 확장은 500ms 이내에 로드되어야 합니다. Keep your bundle small, minimize API calls during render, and use Shopify's built-in hooks (useCartLines, useTotalAmount, useBuyerIdentity) instead of making separate API requests for data Shopify already provides.

구매 후 확장

구매 후 확장은 고객이 결제를 완료한 직후, 감사 페이지가 표시되기 전에 렌더링됩니다. 이 시점은 업셀과 크로스셀에 높은 전환율을 보이는 순간입니다.

// extensions/post-purchase-upsell/src/PostPurchase.tsx
import {
reactExtension,
useApi,
BlockStack,
Button,
Heading,
Image,
InlineLayout,
Text,
TextBlock,
} from '@shopify/ui-extensions-react/post-purchase';
import { useState } from 'react';

const postPurchase = reactExtension(
'purchase.post.render',
() => <PostPurchaseUpsell />
);
export default postPurchase;

function PostPurchaseUpsell() {
const { inputData, calculateChangeset, applyChangeset, done } = useApi();
const [loading, setLoading] = useState(false);
const [accepted, setAccepted] = useState(false);

// The offer is determined server-side and passed through inputData
const offer = inputData.initialPurchase
? getRecommendedOffer(inputData.initialPurchase)
: null;

if (!offer) {
done(); // No offer to show, proceed to thank-you
return null;
}

async function handleAccept() {
setLoading(true);

// Calculate the new order total with the upsell item
const changeset = await calculateChangeset({
changes: [
{
type: 'add_variant',
variantId: offer.variantId,
quantity: 1,
discount: {
value: offer.discountPercentage,
valueType: 'percentage',
title: 'Post-purchase exclusive',
},
},
],
});

// Apply the changeset -- charges the customer immediately
await applyChangeset(changeset.token);
setAccepted(true);
setLoading(false);
}

function handleDecline() {
done(); // Proceed to thank-you page
}

if (accepted) {
return (
<BlockStack spacing="loose">
<Heading>Added to your order!</Heading>
<Text>
{offer.title} has been added. Your card has been charged an
additional ${offer.discountedPrice.toFixed(2)}.
</Text>
<Button onPress={() => done()}>Continue</Button>
</BlockStack>
);
}

return (
<BlockStack spacing="loose">
<Heading>Wait -- exclusive offer just for you!</Heading>

<InlineLayout columns={['auto', 'fill']} spacing="base">
<Image source={offer.imageUrl} alt={offer.title} />
<BlockStack spacing="tight">
<Text emphasis="bold" size="large">{offer.title}</Text>
<TextBlock>{offer.description}</TextBlock>
<InlineLayout columns={['auto', 'auto']} spacing="tight">
<Text appearance="subdued" accessibilityRole="deletion">
${offer.originalPrice.toFixed(2)}
</Text>
<Text emphasis="bold">
${offer.discountedPrice.toFixed(2)}
</Text>
</InlineLayout>
<Text size="small" appearance="subdued">
{offer.discountPercentage}% off -- this offer expires when
you leave this page
</Text>
</BlockStack>
</InlineLayout>

<InlineLayout columns={['fill', 'fill']} spacing="base">
<Button kind="primary" onPress={handleAccept} loading={loading}>
Add to Order
</Button>
<Button kind="plain" onPress={handleDecline}>
No thanks
</Button>
</InlineLayout>
</BlockStack>
);
}

function getRecommendedOffer(initialPurchase: any) {
// In production, this calls your recommendation engine
// For this example, return a static offer
return {
variantId: 'gid://shopify/ProductVariant/99999',
title: 'Premium Shoe Care Kit',
description: 'Keep your new shoes looking fresh with our complete care kit.',
imageUrl: 'https://example.com/shoe-care.jpg',
originalPrice: 29.99,
discountedPrice: 19.99,
discountPercentage: 33,
};
}
구매 후 결제

구매 후 업셀은 고객이 카드 정보를 다시 입력할 필요 없이 고객의 결제 수단에 즉시 청구합니다. This is powerful but must be used responsibly. Always clearly communicate the additional charge and make the decline option prominent. Abusing post-purchase extensions will result in app removal from the Shopify App Store.

결제 커스터마이제이션

결제 커스터마이제이션 확장을 사용하면 어떤 결제 수단이 사용 가능하고 어떤 순서로 표시되는지를 수정할 수 있습니다, based on cart contents, customer identity, or other conditions.

// extensions/payment-customization/src/run.ts
import type { RunInput, FunctionRunResult } from '../generated/api';

export function run(input: RunInput): FunctionRunResult {
const cart = input.cart;
const buyerCountry = input.buyerIdentity?.countryCode;

const operations: FunctionRunResult['operations'] = [];

// Hide Cash on Delivery for international orders
if (buyerCountry && buyerCountry !== 'US') {
const codMethod = input.paymentMethods.find(
(method) => method.name.includes('Cash on Delivery')
);
if (codMethod) {
operations.push({
hide: {
paymentMethodId: codMethod.id,
},
});
}
}

// Move PayPal to the top for carts over $200
const cartTotal = parseFloat(cart.cost.totalAmount.amount);
if (cartTotal > 200) {
const paypal = input.paymentMethods.find(
(method) => method.name.includes('PayPal')
);
if (paypal) {
operations.push({
move: {
paymentMethodId: paypal.id,
index: 0,
},
});
}
}

return { operations };
}

배송 커스터마이제이션

배송 커스터마이제이션 확장은 사용 가능한 배송비를 수정합니다 -- 조건에 따라 배송비의 이름을 변경하거나, 순서를 바꾸거나, 숨깁니다.

// extensions/shipping-customization/src/run.ts
import type { RunInput, FunctionRunResult } from '../generated/api';

export function run(input: RunInput): FunctionRunResult {
const cart = input.cart;
const operations: FunctionRunResult['operations'] = [];

const cartTotal = parseFloat(cart.cost.totalAmount.amount);
const hasFragileItem = cart.lines.some((line) =>
line.merchandise.__typename === 'ProductVariant' &&
line.merchandise.product.hasAnyTag &&
line.merchandise.product.tags?.includes('fragile')
);

// Rename standard shipping to show estimated date
const standardRate = input.shippingRates.find(
(rate) => rate.title === 'Standard Shipping'
);
if (standardRate) {
operations.push({
rename: {
shippingRateId: standardRate.id,
title: 'Standard Shipping (5-7 business days)',
},
});
}

// Hide economy shipping for fragile items
if (hasFragileItem) {
const economyRate = input.shippingRates.find(
(rate) => rate.title.includes('Economy')
);
if (economyRate) {
operations.push({
hide: {
shippingRateId: economyRate.id,
},
});
}
}

// Free shipping for orders over $100
if (cartTotal >= 100) {
const standardShipping = input.shippingRates.find(
(rate) => rate.title.includes('Standard')
);
if (standardShipping) {
operations.push({
rename: {
shippingRateId: standardShipping.id,
title: 'Free Standard Shipping (5-7 business days)',
},
});
}
}

return { operations };
}

장바구니 및 결제 유효성 검사

유효성 검사 확장은 결제 시 비즈니스 규칙을 적용하여 유효하지 않은 주문이 생성되는 것을 방지합니다.

// extensions/cart-validation/src/run.ts
import type { RunInput, FunctionRunResult } from '../generated/api';

export function run(input: RunInput): FunctionRunResult {
const errors: FunctionRunResult['errors'] = [];
const cart = input.cart;

// Rule 1: Maximum 5 units of any single product
for (const line of cart.lines) {
if (line.quantity > 5) {
const title = line.merchandise.__typename === 'ProductVariant'
? line.merchandise.product.title
: 'this item';
errors.push({
localizedMessage: `Maximum 5 units of ${title} per order. Please reduce the quantity.`,
target: 'cart',
});
}
}

// Rule 2: Certain products cannot be shipped to PO Boxes
const shippingAddress = input.buyerIdentity?.shippingAddress;
const hasRestrictedItem = cart.lines.some(
(line) =>
line.merchandise.__typename === 'ProductVariant' &&
line.merchandise.product.tags?.includes('no-po-box')
);

if (
hasRestrictedItem &&
shippingAddress?.address1 &&
/p\.?o\.?\s*box/i.test(shippingAddress.address1)
) {
errors.push({
localizedMessage:
'One or more items in your cart cannot be shipped to a PO Box. Please use a street address.',
target: 'cart',
});
}

// Rule 3: Minimum order value for wholesale tagged customers
const isWholesale = input.buyerIdentity?.customer?.hasAnyTag &&
input.buyerIdentity.customer.tags?.includes('wholesale');
const cartTotal = parseFloat(cart.cost.totalAmount.amount);

if (isWholesale && cartTotal < 250) {
errors.push({
localizedMessage: `Wholesale accounts have a minimum order of $250.00. Your current total is $${cartTotal.toFixed(2)}.`,
target: 'cart',
});
}

return { errors };
}
유효성 검사 Function 제한

Cart validation functions execute on every checkout update -- when items are added, addresses change, or discount codes are applied. They must complete within 5 seconds and cannot make external network requests. All data must come from the function input. If you need external data, use metafields to store it ahead of time.

배포 및 테스트

# Test checkout extensions locally
shopify app dev

# The CLI provides a preview URL for the checkout:
# https://your-dev-store.myshopify.com/?preview_checkout_extensions=true

# Deploy to production
shopify app deploy

테스트 체크리스트

  • Test with various cart compositions (single item, multiple items, high value, low value)
  • Test with different customer states (logged in, guest, returning customer)
  • Test on mobile devices (checkout is heavily used on mobile)
  • Test error states (network failures, invalid data)
  • Test with discount codes and gift cards applied
  • Verify that the checkout still completes if your extension fails to load
다음 단계

Continue to Theme App Extensions to learn how to add your app's functionality to merchant storefronts using Online Store 2.0 app blocks.