Admin UI Extensions
Admin UI extensions를 사용하면 Shopify Admin에 커스텀 인터페이스를 직접 삽입할 수 있습니다. 판매자가 앱의 전체 페이지로 이동하도록 강제하는 대신, 판매자가 필요한 곳 -- 상품 페이지, 주문 상세 보기, 고객 프로필 등 -- 에 기능을 정확히 표시할 수 있습니다. 이러한 확장은 Shopify의 UI 컴포넌트에 접근할 수 있는 샌드박스 환경을 사용하여 Shopify Admin 내에서 렌더링됩니다.
확장 유형 개요
Shopify는 여러 카테고리의 Admin UI 확장을 제공합니다:
| 확장 유형 | 표시 위치 | 사용 사례 |
|---|---|---|
| Admin Action | 리소스 페이지의 액션 메뉴 | 컨텍스트 메뉴에서 워크플로우 트리거 |
| Admin Block | 리소스 상세 페이지에 임베디드 | Shopify 데이터와 함께 앱 데이터 표시 |
| Product Configuration | 상품 생성/편집 양식 | 상품 설정에 커스텀 필드 추가 |
| Navigation | Admin 사이드바 | 앱 섹션으로 딥 링크 |
| Bulk Action | 리소스 목록 페이지 | 여러 항목에 대해 한 번에 작업 |
Admin Action Extensions
Admin actions는 리소스 페이지의 "추가 작업" 드롭다운에 나타납니다. 확장이 렌더링되는 모달 오버레이가 열립니다.
Admin Action 생성
# admin action 확장 생성
shopify app generate extension --template admin_action --name ai-description-generator
확장 스캐폴드가 생성됩니다:
extensions/ai-description-generator/
├── src/
│ └── ActionExtension.tsx
├── locales/
│ └── en.default.json
├── shopify.extension.toml
└── package.json
확장 설정
# extensions/ai-description-generator/shopify.extension.toml
api_version = "2025-01"
[[extensions]]
type = "admin_action"
name = "Generate AI Description"
handle = "ai-description-generator"
[[extensions.targeting]]
module = "./src/ActionExtension.tsx"
target = "admin.product-details.action.render"
target 필드는 확장이 나타나는 위치를 결정합니다. 일반적인 대상은 다음과 같습니다:
admin.product-details.action.render-- 상품 상세 페이지 액션admin.order-details.action.render-- 주문 상세 페이지 액션admin.customer-details.action.render-- 고객 상세 페이지 액션admin.product-index.action.render-- 상품 목록 페이지 액션
액션 UI 구축
Admin 확장은 Shopify의 원격 UI 컴포넌트를 사용합니다 -- 샌드박스 확장 환경을 위해 설계된 Polaris 컴포넌트의 하위 집합입니다:
// extensions/ai-description-generator/src/ActionExtension.tsx
import { useEffect, useState } from 'react';
import {
reactExtension,
useApi,
AdminAction,
BlockStack,
Button,
InlineStack,
ProgressIndicator,
Select,
Text,
TextField,
} from '@shopify/ui-extensions-react/admin';
const TARGET = 'admin.product-details.action.render';
export default reactExtension(TARGET, () => <GenerateDescriptionAction />);
function GenerateDescriptionAction() {
const { data, close, intents } = useApi(TARGET);
const [tone, setTone] = useState('professional');
const [generatedDescription, setGeneratedDescription] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const productId = data.selected?.[0]?.id;
async function handleGenerate() {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/generate-description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId,
tone,
}),
});
if (!response.ok) {
throw new Error('Failed to generate description');
}
const result = await response.json();
setGeneratedDescription(result.description);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}
async function handleApply() {
try {
// 확장에서 직접 Admin API 사용
const response = await fetch('shopify:admin/api/graphql.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
mutation updateProduct($input: ProductInput!) {
productUpdate(input: $input) {
product {
id
descriptionHtml
}
userErrors {
field
message
}
}
}
`,
variables: {
input: {
id: productId,
descriptionHtml: generatedDescription,
},
},
}),
});
close();
} catch (err) {
setError('Failed to update product');
}
}
return (
<AdminAction
title="Generate AI Description"
primaryAction={
<Button
onPress={handleApply}
disabled={!generatedDescription}
>
Apply Description
</Button>
}
secondaryAction={
<Button onPress={() => close()}>Cancel</Button>
}
>
<BlockStack gap="large">
<Select
label="Tone"
value={tone}
onChange={setTone}
options={[
{ label: 'Professional', value: 'professional' },
{ label: 'Casual & Friendly', value: 'casual' },
{ label: 'Luxurious', value: 'luxurious' },
{ label: 'Technical', value: 'technical' },
{ label: 'Playful', value: 'playful' },
]}
/>
<Button onPress={handleGenerate} disabled={loading}>
{loading ? 'Generating...' : 'Generate Description'}
</Button>
{loading && <ProgressIndicator size="small" />}
{error && (
<Text tone="critical">{error}</Text>
)}
{generatedDescription && (
<BlockStack gap="small">
<Text variant="headingSm">Generated Description</Text>
<TextField
label="Description"
value={generatedDescription}
onChange={setGeneratedDescription}
multiline={6}
autoComplete="off"
/>
</BlockStack>
)}
</BlockStack>
</AdminAction>
);
}
Admin 확장은 샌드박스 환경에서 실행됩니다. window, document에 접근하거나 임의의 npm 패키지를 사용할 수 없습니다. Shopify가 제공하는 UI 컴포넌트와 API만 사용할 수 있습니다. 백엔드를 호출해야 하는 경우 상대 URL로 fetch()를 사용하십시오 -- Shopify가 요청을 앱 서버로 프록시합니다.
Admin Block Extensions
Admin 블록은 리소스 상세 페이지에 나타나는 영구적인 UI 패널입니다. 액션(모달로 열리는)과 달리 블록은 항상 보이며 실시간으로 업데이트됩니다.
// extensions/loyalty-block/src/BlockExtension.tsx
import { useEffect, useState } from 'react';
import {
reactExtension,
useApi,
AdminBlock,
BlockStack,
InlineStack,
Text,
Badge,
Divider,
ProgressIndicator,
Heading,
} from '@shopify/ui-extensions-react/admin';
const TARGET = 'admin.customer-details.block.render';
export default reactExtension(TARGET, () => <LoyaltyBlock />);
function LoyaltyBlock() {
const { data } = useApi(TARGET);
const [loyaltyData, setLoyaltyData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const customerId = data.selected?.[0]?.id;
useEffect(() => {
async function fetchLoyaltyData() {
try {
const response = await fetch(
`/api/loyalty/customer?id=${encodeURIComponent(customerId)}`
);
const result = await response.json();
setLoyaltyData(result);
} catch (err) {
console.error('Failed to fetch loyalty data:', err);
} finally {
setLoading(false);
}
}
if (customerId) {
fetchLoyaltyData();
}
}, [customerId]);
if (loading) {
return (
<AdminBlock title="Loyalty Program">
<ProgressIndicator size="small" />
</AdminBlock>
);
}
if (!loyaltyData) {
return (
<AdminBlock title="Loyalty Program">
<Text>Customer is not enrolled in the loyalty program.</Text>
</AdminBlock>
);
}
return (
<AdminBlock title="Loyalty Program">
<BlockStack gap="base">
<InlineStack gap="base" blockAlignment="center">
<Text variant="headingSm">Tier:</Text>
<Badge tone={loyaltyData.tier === 'Gold' ? 'warning' : 'info'}>
{loyaltyData.tier}
</Badge>
</InlineStack>
<Divider />
<InlineStack gap="large">
<BlockStack gap="tight">
<Text variant="bodySm" tone="subdued">포인트 잔액</Text>
<Heading>{loyaltyData.pointsBalance.toLocaleString()}</Heading>
</BlockStack>
<BlockStack gap="tight">
<Text variant="bodySm" tone="subdued">누적 포인트</Text>
<Heading>{loyaltyData.lifetimePoints.toLocaleString()}</Heading>
</BlockStack>
<BlockStack gap="tight">
<Text variant="bodySm" tone="subdued">사용 횟수</Text>
<Heading>{loyaltyData.totalRedemptions}</Heading>
</BlockStack>
</InlineStack>
<Divider />
<Text variant="bodySm" tone="subdued">
가입일: {new Date(loyaltyData.enrolledAt).toLocaleDateString()}
</Text>
</BlockStack>
</AdminBlock>
);
}
Product Configuration Extension
상품 구성 확장은 상품 생성 및 편집 경험에 커스텀 필드를 추가합니다. 추가 상품 메타데이터가 필요한 앱에 적합합니다.
// extensions/product-ai-config/src/ProductConfig.tsx
import { useState } from 'react';
import {
reactExtension,
useApi,
AdminBlock,
BlockStack,
Checkbox,
Select,
Text,
TextField,
} from '@shopify/ui-extensions-react/admin';
const TARGET = 'admin.product-details.configuration.render';
export default reactExtension(TARGET, () => <ProductAIConfig />);
function ProductAIConfig() {
const { data, applyMetafieldsChange } = useApi(TARGET);
const [autoDescription, setAutoDescription] = useState(false);
const [targetAudience, setTargetAudience] = useState('general');
const [keywords, setKeywords] = useState('');
async function handleAutoDescriptionToggle(checked: boolean) {
setAutoDescription(checked);
await applyMetafieldsChange({
type: 'updateMetafield',
namespace: 'ai_config',
key: 'auto_description',
value: String(checked),
valueType: 'boolean',
});
}
async function handleAudienceChange(value: string) {
setTargetAudience(value);
await applyMetafieldsChange({
type: 'updateMetafield',
namespace: 'ai_config',
key: 'target_audience',
value,
valueType: 'single_line_text_field',
});
}
async function handleKeywordsChange(value: string) {
setKeywords(value);
await applyMetafieldsChange({
type: 'updateMetafield',
namespace: 'ai_config',
key: 'seo_keywords',
value,
valueType: 'single_line_text_field',
});
}
return (
<AdminBlock title="AI Configuration">
<BlockStack gap="base">
<Text variant="bodySm" tone="subdued">
AI 도구가 이 상품과 상호작용하는 방식을 구성합니다.
</Text>
<Checkbox
label="저장 시 설명 자동 생성"
checked={autoDescription}
onChange={handleAutoDescriptionToggle}
/>
<Select
label="대상 고객"
value={targetAudience}
onChange={handleAudienceChange}
options={[
{ label: '일반', value: 'general' },
{ label: '젊은 성인 (18-25)', value: 'young_adults' },
{ label: '전문가', value: 'professionals' },
{ label: '부모', value: 'parents' },
{ label: '매니아', value: 'enthusiasts' },
]}
/>
<TextField
label="SEO 키워드"
value={keywords}
onChange={handleKeywordsChange}
helpText="AI 생성 콘텐츠에서 강조할 쉼표로 구분된 키워드"
autoComplete="off"
/>
</BlockStack>
</AdminBlock>
);
}
확장 배포
확장은 Shopify CLI를 사용하여 앱과 함께 배포됩니다:
# 개발 모드 -- 실시간 리로드
shopify app dev
# 프로덕션에 배포
shopify app deploy
# 확장만 배포 (앱 코드 건너뛰기)
shopify app deploy --reset
확장 버전 관리
각 배포는 확장의 새 버전을 생성합니다. Shopify가 배포를 관리합니다:
# 확장 상태 확인
shopify app versions list
# 특정 버전 릴리스
shopify app release --version 1.2.0
개발 중에 shopify app dev는 터널을 시작하고 확장에 대한 핫 리로딩을 활성화합니다. 확장 코드 변경 사항은 재배포 없이 Shopify Admin에 즉시 반영됩니다. 테스트에는 개발 스토어(라이브 스토어가 아닌)를 사용하십시오.
배포 전 체크리스트
Admin UI 확장을 프로덕션에 배포하기 전에:
- 다양한 화면 크기에서 테스트 -- Admin은 데스크톱, 태블릿, 모바일 기기에서 사용됩니다
- 로딩 상태 처리 -- 데이터를 가져오는 동안 항상 스피너나 스켈레톤을 표시합니다
- 오류 상태 처리 -- 네트워크 장애가 발생합니다. 유용한 메시지와 재시도 옵션을 표시합니다
- 레이트 리밋 준수 -- Admin API에는 레이트 리밋이 있습니다. 가능한 경우 응답을 캐시합니다
- 문자열 현지화 -- 모든 사용자 대면 텍스트에
locales/디렉토리를 사용합니다 - 접근성 확인 -- 확장이 키보드로 탐색 가능하고 스크린 리더에 친화적인지 확인합니다
Admin UI 확장의 최대 번들 크기는 512 KB(압축)입니다. 종속성을 최소화하십시오. 큰 라이브러리를 번들에 포함하지 마십시오 -- 자체 컴포넌트 라이브러리를 가져오는 대신 Shopify가 제공하는 UI 컴포넌트를 사용하십시오.
Checkout Extensions로 이동하여 커스텀 UI, 결제 로직, 배송 규칙으로 Shopify 결제 경험을 커스터마이즈하는 방법을 알아보십시오.