Shopify 앱 인증
Shopify는 앱 유형과 사용 사례에 따라 여러 인증 흐름을 제공합니다. 최신 표준은 토큰 교환(2024+) (서버 렌더링 앱), Managed Installation(헤드리스 앱), OAuth 2.0 인가 코드 승인(레거시/커스텀 구현)입니다. 모든 흐름은 Shopify GraphQL Admin API에 대한 액세스 토큰을 생성합니다.
인증 흐름 개요
1. 토큰 교환 (2024+ 권장)
사용 사례: 서버 렌더링 앱(Remix, SSR이 있는 Next.js), Shopify CLI 앱 흐름: 판매자가 앱 설치 → Shopify가 임시 교환 토큰 생성 → 앱이 액세스 토큰으로 교환 보안: 클라이언트 시크릿 노출 없음; 요청마다 PKCE 방식 로테이션 사용 토큰 수명: 액세스 토큰은 수명이 짧음; 리프레시 토큰은 자동으로 로테이션
토큰 교환 다이어그램:
1. 판매자가 Shopify Admin에서 "설치" 클릭
2. Shopify 리디렉션: https://your-app.com/auth/callback?code=EXCHANGE_TOKEN
3. 앱이 HMAC 검증, 코드를 액세스 토큰으로 교환 (비공개, 서버 측만)
4. Shopify Admin API가 범위 부여; 토큰을 세션/데이터베이스에 저장
5. 만료 시 다음 요청에서 토큰이 자동으로 갱신됨
Remix 구현 (토큰 교환):
// shopify.app.ts (앱 구성)
import { shopifyApp } from '@shopify/shopify-app-remix/server';
import { restResources } from '@shopify/shopify-api/rest/admin/2026-07';
import { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';
import { prisma } from '~/db.server';
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY || '',
apiSecret: process.env.SHOPIFY_API_SECRET || '',
scopes: process.env.SCOPES?.split(',') || [
'write_products',
'read_products',
'write_orders',
'read_orders',
'write_customers',
'read_customers',
'write_discounts',
'read_discounts',
'write_fulfillments',
'read_fulfillments',
'write_inventory',
'read_inventory',
],
appUrl: process.env.SHOPIFY_APP_URL || 'http://localhost:3000',
auth: {
path: '/auth',
callbackPath: '/auth/callback',
},
webhooks: {
path: '/webhooks',
},
isEmbeddedApp: true, // Polaris 관리자 대시보드
sessionStorage: new PrismaSessionStorage(prisma),
restResources, // REST API 헬퍼 포함
});
export default shopify;
환경 변수 (.env):
SHOPIFY_API_KEY=partner-dashboard에서 받은 공개 API 키
SHOPIFY_API_SECRET=비공개 API 시크릿
SHOPIFY_APP_URL=https://your-domain.ngrok.io # 또는 프로덕션 URL
SCOPES=write_products,read_products,write_orders,read_orders
인증 라우트 (routes/auth.$.tsx - 모든 경로 처리):
import { redirect } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { authenticate } = await import('~/shopify.server');
return authenticate.admin(request); // 여기서 토큰 교환 발생
};
콜백 처리 (routes/auth.callback.tsx):
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
// 세션에는 다음이 포함:
// - session.accessToken (API 호출에 유효)
// - session.shop (판매자의 상점 도메인)
// - session.scope (부여된 범위)
// - session.state (선택적 커스텀 데이터)
return redirect('/app'); // 인증 후 대시보드로 리디렉션
};
API 호출에서 토큰 사용 (routes/app.products.tsx):
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
import { GraphQLClient } from 'graphql-request';
export const loader = async ({ request }) => {
const { session, admin } = await authenticate.admin(request);
// 방법 1: Shopify의 admin 헬퍼 사용 (권장)
const response = await admin.graphql(`
query GetProducts {
products(first: 10) {
edges {
node {
id
title
handle
}
}
}
}
`);
// 방법 2: 수동 GraphQL 호출
const client = new GraphQLClient(
`https://${session.shop}/admin/api/2026-07/graphql.json`,
{
headers: {
'X-Shopify-Access-Token': session.accessToken,
'Content-Type': 'application/json',
},
}
);
const data = await client.request(/* ... */);
return json({ products: response.data?.products?.edges || [] });
};
토큰 갱신 (자동): 토큰 교환 토큰은 Shopify의 세션 미들웨어를 통해 자동 갱신됩니다. 수동 갱신 불필요:
// 각 요청에서 토큰이 투명하게 갱신됨
const response = await admin.graphql(query); // 내부적으로 갱신 처리
2. Managed Installation (헤드리스/커스텀 앱)
사용 사례: 헤드리스 스토어프론트, 모바일 앱, 타사 통합 흐름: 판매자가 앱 승인 → Shopify가 영구 액세스 토큰 생성 (시크릿 로테이션 없음) 토큰 수명: 수명이 긺; 갱신 불필요 보안: 토큰은 영구적임; 환경 변수에 안전하게 저장
Managed Installation 설정:
Shopify Partner 대시보드에서:
- 앱 설정 > API 자격 증명
- Admin API 액세스 범위에서 "Managed installation" 선택
- 판매자가 한 번 권한 부여
- 액세스 토큰을 환경에 복사
# .env
SHOPIFY_ADMIN_ACCESS_TOKEN=<SHOPIFY_ADMIN_ACCESS_TOKEN>
SHOPIFY_SHOP_URL=example-shop.myshopify.com
Managed Installation 토큰 사용:
import { GraphQLClient } from 'graphql-request';
const client = new GraphQLClient(
`https://${process.env.SHOPIFY_SHOP_URL}/admin/api/2026-07/graphql.json`,
{
headers: {
'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || '',
},
}
);
// 토큰은 만료되지 않음; 언제든지 API 호출 가능
const query = `query { products(first: 10) { edges { node { id title } } } }`;
const products = await client.request(query);
3. OAuth 2.0 인가 코드 승인 (레거시, 여전히 지원)
사용 사례: 전통적인 OAuth 흐름을 사용하는 공개 앱 흐름: 판매자가 "설치" 클릭 → 앱이 Shopify OAuth로 리디렉션 → 판매자 승인 → 앱이 코드 수신 → 앱이 코드를 토큰으로 교환 토큰 수명: 수명이 긴 액세스 토큰 (취소되지 않는 한 만료 없음) 보안: 클라이언트 시크릿 필요; PKCE 선택 사항
OAuth 흐름 다이어그램:
1. 판매자 방문: https://your-app.com/auth
2. 앱 리디렉션: https://your-shop.myshopify.com/admin/oauth/authorize?client_id=KEY&scope=write_products&redirect_uri=https://your-app.com/auth/callback&state=RANDOM
3. 판매자가 Shopify Admin에서 앱 승인
4. Shopify 리디렉션: https://your-app.com/auth/callback?code=AUTHORIZATION_CODE&hmac=SIGNATURE&state=RANDOM&shop=your-shop.myshopify.com
5. 앱이 HMAC 및 state 검증
6. 앱이 코드를 토큰으로 교환 (서버 측, 클라이언트 시크릿 사용)
7. 앱이 토큰을 데이터베이스에 저장
Express.js OAuth 예제:
import express from 'express';
import axios from 'axios';
import crypto from 'crypto';
const app = express();
const API_KEY = process.env.SHOPIFY_API_KEY || '';
const API_SECRET = process.env.SHOPIFY_API_SECRET || '';
const REDIRECT_URI = process.env.REDIRECT_URI || 'https://your-app.com/auth/callback';
const SCOPES = 'write_products,read_products';
// 1단계: 판매자를 Shopify OAuth로 리디렉션
app.get('/auth', (req, res) => {
const shop = req.query.shop as string;
if (!shop || !shop.includes('.myshopify.com')) {
return res.status(400).send('상점 매개변수가 없거나 유효하지 않습니다');
}
const state = crypto.randomBytes(16).toString('hex');
const nonce = crypto.randomBytes(16).toString('hex');
// 검증을 위해 state를 세션에 저장 (또는 데이터베이스)
req.session.state = state;
req.session.nonce = nonce;
const authUrl = new URL(
`/admin/oauth/authorize`,
`https://${shop}`
);
authUrl.searchParams.append('client_id', API_KEY);
authUrl.searchParams.append('scope', SCOPES);
authUrl.searchParams.append('redirect_uri', REDIRECT_URI);
authUrl.searchParams.append('state', state);
res.redirect(authUrl.toString());
});
// 2단계: OAuth 콜백 처리
app.get('/auth/callback', async (req, res) => {
const { code, hmac, shop, state } = req.query;
// HMAC 검증
const message = Object.entries(req.query)
.filter(([key]) => key !== 'hmac')
.map(([key, value]) => `${key}=${value}`)
.sort()
.join('&');
const hash = crypto
.createHmac('sha256', API_SECRET)
.update(message, 'utf8')
.digest('base64');
if (hash !== hmac) {
return res.status(401).send('승인되지 않은 요청이 감지되었습니다');
}
// state 검증
if (state !== req.session.state) {
return res.status(401).send('State 불일치');
}
try {
// 코드를 액세스 토큰으로 교환
const response = await axios.post(
`https://${shop}/admin/oauth/access_token`,
{
client_id: API_KEY,
client_secret: API_SECRET,
code,
}
);
const { access_token, scope } = response.data;
// 액세스 토큰 저장 (지속성을 위해 세션이 아닌 데이터베이스에)
await storeAccessToken(shop as string, access_token, scope);
// 앱 대시보드로 리디렉션
res.redirect(`/app?shop=${shop}`);
} catch (error) {
console.error('토큰 교환 오류:', error);
res.status(500).send('인증 실패');
}
});
// 토큰 저장 헬퍼 함수
async function storeAccessToken(shop: string, token: string, scope: string) {
// 데이터베이스에 저장 (예시: mock 저장소 사용)
const db = {
shops: {} as Record<string, { token: string; scope: string }>,
};
db.shops[shop] = { token, scope };
}
app.listen(3000);
세션 스토리지 어댑터
액세스 토큰은 영구적으로 저장되어야 합니다. Shopify는 일반적인 스토리지 백엔드용 어댑터를 제공합니다:
Prisma (Remix에 권장)
스키마 (prisma/schema.prisma):
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
accessToken String
refreshToken String?
scope String
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([shop, state])
@@index([shop])
}
설정 (shopify.app.ts):
import { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';
import { prisma } from '~/db.server';
const shopify = shopifyApp({
// ...
sessionStorage: new PrismaSessionStorage(prisma),
});
Redis
import { RedisSessionStorage } from '@shopify/shopify-app-session-storage-redis';
import redis from 'redis';
const redisClient = redis.createClient({
host: 'localhost',
port: 6379,
});
const sessionStorage = new RedisSessionStorage({
client: redisClient,
prefix: 'shopify_session:',
});
const shopify = shopifyApp({
// ...
sessionStorage,
});
인메모리 (개발 전용)
import { MemorySessionStorage } from '@shopify/shopify-app-session-storage';
const sessionStorage = new MemorySessionStorage();
const shopify = shopifyApp({
// ...
sessionStorage, // 주의: 앱 재시작 시 세션 손실; 개발 전용
});
DynamoDB
import { DynamoDBSessionStorage } from '@shopify/shopify-app-session-storage-dynamodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
const dynamoDBClient = new DynamoDBClient({ region: 'us-east-1' });
const sessionStorage = new DynamoDBSessionStorage({
client: dynamoDBClient,
tableName: 'shopify-sessions',
});
const shopify = shopifyApp({
// ...
sessionStorage,
});
온라인 토큰 vs 오프라인 토큰
온라인 토큰:
- 범위: 현재 사용자의 권한 (일반적으로 관리자 사용자)
- 만료: 24시간
- 사용 사례: 브라우저 기반 작업 (Polaris 관리자 대시보드)
- 제한 사항: 백그라운드 작업 실행 불가; 사용자 로그아웃 시 제한됨
오프라인 토큰:
- 범위: 앱에 부여된 범위
- 만료: 없음; 취소될 때까지 영구적
- 사용 사례: 백그라운드 작업, 웹훅, 예약된 작업
- 제한 사항: 없음; 앱 작업에 기본적으로 사용
토큰 교환에서 오프라인 토큰 요청:
// shopify.app.ts
const shopify = shopifyApp({
// ...
auth: {
path: '/auth',
callbackPath: '/auth/callback',
},
});
// Remix는 기본적으로 오프라인 토큰을 자동으로 요청
// 별도 조치 불필요; API 호출에 session.accessToken 사용
웹훅에서 오프라인 토큰 사용:
// webhooks/products-update.ts
export const webhooks = {
APP_UNINSTALLED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/app-uninstalled',
},
PRODUCTS_UPDATE: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/products-update',
},
};
export default defineWebhooksConfig(
async (request, { admin, session }) => {
const { body, query } = await graphql.query(request, {
query: GET_PRODUCT,
variables: { id: 'gid://shopify/Product/123' },
});
// session.accessToken은 오프라인 토큰; 여기서 유효함
console.log(`상점 ${session.shop}의 토큰으로 웹훅 처리됨`);
},
webhooksConfig
);
앱 프록시 인증
사용 사례: 스토어프론트(공개)에서 앱 백엔드로의 요청 인증: HMAC 서명 검증 (웹훅과 유사) 흐름: 스토어프론트 → Liquid 프록시 요청 → 앱 백엔드(HMAC 검증) → 응답
앱 프록시 설정 (shopify.app.toml):
[[extensions]]
type = "app_proxy"
name = "Storefront API"
url = "/api/proxy"
subpath = "loyalty" # /apps/loyalty/* 요청이 여기로 라우팅됨
앱 프록시 핸들러 (Remix routes/api/proxy.ts):
import { json } from '@shopify/remix-oxygen';
import crypto from 'crypto';
export const loader = async ({ request }) => {
const url = new URL(request.url);
const hmac = url.searchParams.get('hmac') || '';
const timestamp = url.searchParams.get('_t') || '';
const shop = url.searchParams.get('shop') || '';
// HMAC 검증
const sortedParams = new URLSearchParams(url.searchParams);
sortedParams.delete('hmac');
sortedParams.sort();
const message = sortedParams.toString();
const hash = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET || '')
.update(message, 'utf8')
.digest('hex');
if (hash !== hmac) {
return json({ error: 'Invalid HMAC' }, { status: 401 });
}
// Storefront GraphQL API 호출 예시
const response = await fetch(
`https://${shop}/api/2026-07/graphql`,
{
method: 'POST',
headers: {
'X-Shopify-Storefront-Access-Token': process.env.STOREFRONT_API_TOKEN || '',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: `{ products(first: 5) { edges { node { title } } } }` }),
}
);
const data = await response.json();
return json(data);
};
환경 변수 요약
| 변수 | 설명 | 필수 |
|---|---|---|
SHOPIFY_API_KEY | Shopify Partner 대시보드의 공개 API 키 | 예 |
SHOPIFY_API_SECRET | Shopify Partner 대시보드의 비공개 API 시크릿 | 예 |
SHOPIFY_APP_URL | 앱의 공개 URL (개발 시 ngrok URL) | 예 |
SCOPES | 쉼표로 구분된 권한 범위 목록 | 예 |
SHOPIFY_ADMIN_ACCESS_TOKEN | Managed Installation용 영구 액세스 토큰 | 조건부 |
STOREFRONT_API_TOKEN | Storefront API용 공개 토큰 (앱 프록시용) | 조건부 |
인증 방식 선택 가이드
| 앱 유형 | 권장 인증 방식 |
|---|---|
| Remix/Next.js SSR 앱 | 토큰 교환 (기본값) |
| 단일 테넌트 헤드리스 앱 | Managed Installation |
| 다중 테넌트 공개 앱 | OAuth 2.0 (레거시) 또는 토큰 교환 (2024+) |
| CLI/봇/통합 | Managed Installation |
| 모바일 앱 | Managed Installation (토큰은 앱 내에 저장) |
문제 해결
일반적인 문제
-
HMAC 검증 실패
- 모든 쿼리 매개변수가 올바르게 정렬되었는지 확인
- Shopify API 시크릿이 올바른지 확인
- 타임스탬프가 만료되지 않았는지 확인 (일반적으로 5분)
-
토큰 교환 실패
SHOPIFY_API_KEY및SHOPIFY_API_SECRET이 올바르게 설정되었는지 확인- 리디렉션 URI가 Partner 대시보드의 허용 목록과 일치하는지 확인
- 세션 스토리지가 올바르게 구성되었는지 확인
-
Managed Installation 토큰이 작동하지 않음
- 토큰이 올바른 상점에 대한 것인지 확인
- 상점 URL이 유효한 myshopify.com 도메인인지 확인
- Admin API 범위가 올바르게 설정되었는지 확인
-
API 속도 제한
- Shopify Admin API는 앱당 초당 40개 요청으로 제한
- Webhook과 백그라운드 작업은 더 높은 한도가 있음
- 토큰 교환은 내장된 속도 제한 처리가 있음