@ph-cms/client-sdk
v0.1.46
Published
Unified PH-CMS Client SDK (React + Core)
Downloads
1,214
Readme
@ph-cms/client-sdk 0.1.45
PH-CMS 클라이언트 SDK — 브라우저 및 React 애플리케이션을 위한 통합 클라이언트.
주요 기능:
- 타입이 지정된 API 클라이언트
- Auth Provider 인터페이스 및 구현체 (
LocalAuthProvider,FirebaseAuthProvider,StaticAuthProvider) - React Context 및 Hooks
- React Query 통합
- 장소 검색 모듈 및 React hook
- 자동 토큰 갱신 (Proactive + Reactive)
- SSR (Server-Side Rendering) 지원 및 Cookie 연동 가능
- Firebase ↔ PH-CMS 인증 동기화
- 세분화된 인증 상태 관리 (
authStatus)
Installation
npm install @ph-cms/client-sdk
@tanstack/react-query는 직접 의존성으로 포함되어 있어 별도 설치가 필요 없습니다. Firebase를 사용하는 경우firebase를 별도로 설치하세요 (선택적 peer dependency).
Type Imports
SDK에서 사용하는 모든 타입은 @ph-cms/client-sdk에서 직접 import할 수 있습니다.
@ph-cms/api-contract를 직접 참조할 필요 없이 SDK 하나로 모든 타입을 가져올 수 있습니다.
// SDK 내부 타입
import type {
AuthProvider,
AuthStatus,
JwtPayload,
PHCMSClientConfig,
PHCMSContextType,
PHCMSProviderProps,
UseFirebaseAuthSyncOptions,
UseFirebaseAuthSyncReturn,
FirebaseAuthSyncProps,
} from '@ph-cms/client-sdk';
// API 도메인 타입 (api-contract에서 re-export)
import type {
// Auth
LoginRequest,
RegisterRequest,
RegisterInput, // register() 훅에 전달하는 유니온 타입 (method: 'email' | 'firebase')
FirebaseRegisterRequest, // method: 'firebase' 방식의 페이로드 타입
RefreshTokenRequest,
FirebaseExchangeRequest,
AuthResponse,
// User
UserDto,
BlockUserRequest,
BlockUserResponse,
BlockedUserDto,
FollowStatusResponse,
FollowUserResponse,
PagedBlockedUserListResponse,
SuspendUserRequest,
SuspendUserResponse,
// Channel
ChannelDto,
CheckHierarchyQuery,
// Content
ContentDto,
ContentMediaDto,
CreateContentRequest,
UpdateContentRequest,
CreateStampTourRequest,
UpdateStampTourRequest,
ListContentQuery,
ListLikersQuery,
ListLikedContentQuery,
PagedContentListResponse,
ReportContentRequest,
ReportContentResponse,
// Hierarchy / Policy
HierarchySetDto,
PermissionPolicySetDto,
// Terms
TermDto,
// Geo
GeoJSON,
// Media
MediaUploadTicketRequest,
MediaUploadTicketResponse,
MediaUploadTicketBatchRequest,
MediaUploadTicketBatchResponse,
// Common
PagedResponse,
UserProfileDto,
IntegratedPlace,
PlaceSearchQuery,
PlaceSearchResponse,
} from '@ph-cms/client-sdk';타입들은 src/types.ts에 도메인별로 정리되어 있으므로, IDE의 자동완성에서 바로 확인할 수 있습니다.
Place Search
SDK에서 통합 장소 검색 API를 바로 호출할 수 있습니다.
Core Client
const result = await client.place.search({
keyword: '강남역 맛집',
});React Hook
import { usePlaceSearch } from '@ph-cms/client-sdk';
const { data, isLoading } = usePlaceSearch(
{ keyword: '강남역 맛집' },
true,
);응답의 각 장소 항목은 항상 동일한 키를 가집니다.
phoneNumber:string | nulllink:string | nullphotos:[]포함 항상 존재reviews:[]포함 항상 존재
Authentication Architecture
Overview
SDK의 인증 시스템은 세 개의 레이어로 구성됩니다:
| Layer | Component | Role |
|---|---|---|
| Provider | LocalAuthProvider / FirebaseAuthProvider | 토큰 저장·조회·갱신·삭제를 담당하는 저수준 어댑터 |
| Module | AuthModule (client.auth) | 서버 API 호출 (login, loginWithFirebase, me, refresh, logout) |
| Hook / Context | PHCMSProvider, useAuth | React 상태 관리 — authStatus, 로그인·로그아웃 액션 |
PHCMSProvider — useQuery(['auth','me'])를 authProvider.hasToken()이 true일 때만 실행하여 불필요한 401을 방지하고, context로 { user, authStatus, hasToken, ... }을 제공합니다.
useAuth() — 각 액션은 서버 호출 → provider.setTokens() → refreshUser()(= me() 재조회) 순서로 동작합니다:
| 액션 | 서버 호출 | 후속 동작 |
|---|---|---|
| login() | POST /api/auth/login | setTokens() → refreshUser() |
| loginWithFirebase() | POST /api/auth/firebase/exchange | setTokens() → refreshUser() |
| register() (method: 'email') | POST /api/auth/register | setTokens() → refreshUser() |
| register() (method: 'firebase') | POST /api/auth/register (provider: firebase:password) | setTokens() → refreshUser() |
| logout() | POST /api/auth/logout | provider.logout() → refreshUser() (상태 초기화) |
Authentication Lifecycle
1. 초기 마운트 (비인증 상태)
App Mount
→ PHCMSProvider mount
→ authProvider.hasToken() ← false (localStorage에 토큰 없음)
→ useQuery enabled: false ← me() 호출하지 않음
→ authStatus: 'unauthenticated'서버에 불필요한 401 요청을 보내지 않습니다.
2. 초기 마운트 (기존 세션 복원)
App Mount
→ PHCMSProvider mount
→ authProvider.hasToken() ← true (localStorage에 토큰 존재)
→ useQuery enabled: true
→ authStatus: 'loading' ← 이 시점에 스플래시 화면 표시 가능
→ GET /api/auth/me
→ authStatus: 'authenticated', user: { ... }3. 로그인 (이메일/비밀번호)
user calls login({ email, password })
→ POST /api/auth/login
→ provider.setTokens(accessToken, refreshToken)
→ refreshUser()
→ GET /api/auth/me
→ authStatus: 'authenticated', user: { ... }4. 로그아웃
user calls logout()
→ provider.logout() ← localStorage 토큰 삭제
→ POST /api/auth/logout ← 서버 세션 무효화 (실패해도 무시)
→ refreshUser()
→ hasToken: false → queryData 초기화
→ authStatus: 'unauthenticated', user: nullToken Refresh (자동 토큰 갱신)
SDK는 두 가지 방식으로 토큰을 자동 갱신합니다:
Proactive Refresh (사전 갱신)
Provider의 getToken() 호출 시 JWT의 exp 클레임을 검사합니다.
만료 임박 시 (expiryBufferMs 이내, 기본 60초) 서버에 갱신 요청을 보냅니다.
Request Interceptor
→ provider.getToken()
→ JWT exp 검사: 만료 임박?
→ Yes: tryRefresh() → POST /api/auth/refresh
→ 새 토큰 저장 → 새 accessToken 반환
→ No: 기존 accessToken 반환
→ Authorization: Bearer {token}Reactive Refresh (401 대응 갱신)
서버가 401을 반환하면 interceptor가 자동으로 토큰을 갱신하고 원본 요청을 재시도합니다.
API Request → 401 Unauthorized
→ coordinatedRefresh(refreshToken)
→ POST /api/auth/refresh (with _skipAuth flag)
→ 성공: provider.setTokens() → 원본 요청 재시도
→ 실패: ApiError throw동시 요청 처리: 여러 요청이 동시에 401을 받으면 하나의 refresh 요청만 실행되고, 나머지 요청은 큐에서 대기합니다 (de-duplication).
순환 방지: refresh 요청 자체는 _skipAuth 플래그가 붙어 request interceptor에서
토큰 첨부를 건너뛰므로, getToken() → tryRefresh() 재귀 호출이 발생하지 않습니다.
Server-Side Rendering (SSR)
SDK는 SSR 환경(Next.js, Remix 등)에서 인증된 상태로 API를 호출할 수 있는 기능을 제공합니다. 브라우저의 쿠키를 서버가 읽어 SDK에 직접 주입하는 방식으로 작동합니다.
1. StaticAuthProvider
SSR 환경에서는 localStorage가 없으므로, 메모리에만 토큰을 들고 있는 StaticAuthProvider를 사용하거나 PHCMSClient 설정에 직접 토큰을 주입합니다.
import { PHCMSClient } from '@ph-cms/client-sdk/core';
// Next.js Server Component 또는 API Route 예시
export async function getServerSideProps({ req }) {
// .env 등에 설정된 프리픽스를 가져옵니다 (기본값 phcms_)
const prefix = process.env.NEXT_PUBLIC_PH_CMS_AUTH_STORAGE_PREFIX || "phcms_";
const accessToken = req.cookies[`${prefix}access_token`];
const refreshToken = req.cookies[`${prefix}refresh_token`];
const client = new PHCMSClient({
baseURL: process.env.API_URL,
accessToken, // 쿠키에서 뽑은 토큰 주입
refreshToken,
});
// 이제 이 호출은 인증된 상태(Bearer 헤더 포함)로 서버에서 실행됩니다.
const content = await client.content.get(uid);
return { props: { content } };
}2. Next.js Middleware 연동 (추천)
httpOnly 쿠키를 사용할 경우, 브라우저의 SDK가 리프레시 토큰에 접근할 수 없습니다. 이때 Next.js 미들웨어를 통해 토큰 갱신을 처리하면 매끄러운 UX를 제공할 수 있습니다.
// middleware.ts (Next.js)
import { NextResponse, NextRequest } from 'next/server';
export async function middleware(req: NextRequest) {
const accessToken = req.cookies.get('access_token')?.value;
const refreshToken = req.cookies.get('refresh_token')?.value;
// 액세스 토큰은 만료되었으나 리프레시 토큰이 있는 경우
if (!accessToken && refreshToken) {
const res = await fetch(`${process.env.API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (res.ok) {
const tokens = await res.json();
const response = NextResponse.next();
response.cookies.set('access_token', tokens.accessToken, { httpOnly: true, path: '/' });
response.cookies.set('refresh_token', tokens.refreshToken, { httpOnly: true, path: '/' });
return response;
}
}
return NextResponse.next();
}Auth Providers
모든 Provider는 BaseAuthProvider 추상 클래스를 상속합니다.
공통 로직(토큰 저장, 갱신, de-duplication, localStorage 관리)은 BaseAuthProvider에 구현되어 있고,
각 Provider는 getToken()과 logout() 등 고유 로직만 오버라이드합니다.
LocalAuthProvider
이메일/비밀번호 기반 인증에 사용합니다. 토큰을 localStorage에 저장합니다.
import { LocalAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';
const authProvider = new LocalAuthProvider('my_app_', {
expiryBufferMs: 60_000, // 선택 — 만료 60초 전부터 갱신 시도 (기본값)
});
const client = new PHCMSClient({
baseURL: 'https://api.example.com',
auth: authProvider,
});| 생성자 인자 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| storageKeyPrefix | string | 'ph_cms_' | localStorage 키 접두사 ({prefix}access_token, {prefix}refresh_token) |
| options.expiryBufferMs | number | 60_000 | 만료 몇 ms 전부터 토큰을 갱신할지 |
FirebaseAuthProvider
Firebase Authentication과 연동합니다. PH-CMS 토큰이 없으면 Firebase ID 토큰으로 fallback합니다.
import { FirebaseAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
const firebaseApp = initializeApp({ /* Firebase config */ });
const firebaseAuth = getAuth(firebaseApp);
const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_', {
expiryBufferMs: 60_000,
});
const client = new PHCMSClient({
baseURL: 'https://api.example.com',
auth: authProvider,
});| 생성자 인자 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| auth | firebase/auth.Auth | (필수) | Firebase Auth 인스턴스 |
| storageKeyPrefix | string | 'ph_cms_fb_' | localStorage 키 접두사 |
| options.expiryBufferMs | number | 60_000 | 만료 몇 ms 전부터 토큰을 갱신할지 |
getToken() 동작:
- PH-CMS access token이 유효하면 → 그대로 반환
- 만료 임박이면 →
tryRefresh()시도 → 성공하면 새 토큰 반환 - PH-CMS 토큰이 없거나 갱신 실패 → Firebase ID 토큰으로 fallback
AuthProvider Interface
커스텀 인증 Provider를 구현하려면 아래 인터페이스를 따릅니다.
BaseAuthProvider를 상속하면 대부분의 메서드가 이미 구현되어 있으므로, type과 getToken()만 구현하면 됩니다.
interface AuthProvider {
type: 'FIREBASE' | 'LOCAL';
getToken(): Promise<string | null>;
hasToken(): boolean;
getRefreshToken(): string | null;
setRefreshFn(fn: (refreshToken: string) => Promise<{ accessToken: string; refreshToken: string }>): void;
setTokens(accessToken: string, refreshToken: string): void;
onTokenExpired(callback: () => Promise<void>): void;
logout(): Promise<void>;
}| 메서드 | 설명 |
|---|---|
| hasToken() | 토큰 존재 여부 (동기). React의 useQuery enabled 조건에 사용되므로 반드시 동기여야 합니다 |
| getToken() | 유효한 access token 반환. 만료 시 자동 갱신 시도 후 반환 |
| getRefreshToken() | 현재 refresh token 반환 |
| setRefreshFn(fn) | PHCMSClient가 생성 시 호출. Provider가 자체적으로 토큰을 갱신할 수 있게 함 |
| setTokens(access, refresh) | 새 토큰 쌍 저장 (login/refresh 성공 시 자동 호출) |
| onTokenExpired(callback) | 토큰 만료 + 갱신 실패 시 호출할 콜백 등록 |
| logout() | 세션 정리 (토큰 삭제) |
React Usage
Basic Setup
import { PHCMSClient, LocalAuthProvider, PHCMSProvider } from '@ph-cms/client-sdk';
const authProvider = new LocalAuthProvider('my_app_');
const client = new PHCMSClient({
baseURL: 'https://api.example.com',
auth: authProvider,
});
export function App() {
return (
<PHCMSProvider client={client}>
<YourComponents />
</PHCMSProvider>
);
}PHCMSProvider는 내부적으로 QueryClientProvider를 포함합니다.
외부에서 직접 관리하는 QueryClient를 사용하려면 queryClient prop으로 전달합니다:
import { QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient();
<PHCMSProvider client={client} queryClient={queryClient}>
...
</PHCMSProvider>Authentication Status (authStatus)
Context는 세분화된 인증 상태를 authStatus로 제공합니다:
| authStatus | hasToken | user | 의미 |
|---|---|---|---|
| 'unauthenticated' | false | null | 미로그인 (토큰 없음) → 로그인 화면 표시 |
| 'loading' | true | null | 세션 복원 중 (me() 호출 중) → 스플래시 화면 표시 |
| 'authenticated' | true | UserDto | 인증 완료 → 메인 화면 표시 |
| 'unauthenticated' | true | null | 토큰 있으나 me() 실패 → 재로그인 유도 |
import { usePHCMSContext } from '@ph-cms/client-sdk';
function AppRouter() {
const { authStatus, hasToken } = usePHCMSContext();
switch (authStatus) {
case 'loading':
return <SplashScreen />;
case 'authenticated':
return <MainApp />;
case 'unauthenticated':
// hasToken이 true이면 토큰은 있었으나 만료/검증 실패
if (hasToken) return <SessionExpiredScreen />;
return <LoginScreen />;
}
}
isAuthenticated와isLoading은 하위 호환을 위해 유지되지만, 내부적으로authStatus에서 파생됩니다 (isAuthenticated === authStatus === 'authenticated').
Authentication with useAuth
useAuth는 인증 상태와 액션을 통합 제공하는 메인 훅입니다.
import { useAuth } from '@ph-cms/client-sdk';
function AuthComponent() {
const {
// 상태
user, // UserDto | null
isAuthenticated, // boolean
isLoading, // boolean
// 액션 (모두 Promise 반환)
login, // (data: LoginRequest) => Promise<AuthResponse>
loginWithFirebase, // (data: FirebaseExchangeRequest) => Promise<AuthResponse>
register, // (data: RegisterInput) => Promise<AuthResponse> ← method: 'email' | 'firebase'
loginAnonymous, // (data?: AnonymousLoginRequest) => Promise<AuthResponse>
upgradeAnonymous, // (data: { email, password, display_name?, username?, phone_number? }) => Promise<UserDto>
logout, // () => Promise<void>
// 뮤테이션 상태 (isPending, error 등)
loginStatus,
loginWithFirebaseStatus,
registerStatus,
loginAnonymousStatus,
upgradeAnonymousStatus,
logoutStatus,
} = useAuth();
}이메일/비밀번호 로그인
function LoginForm() {
const { login, loginStatus, isAuthenticated, user } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
if (isAuthenticated) {
return <p>Welcome, {user?.display_name}</p>;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login({ email, password });
// 성공 → 자동으로 me() 호출 → user, isAuthenticated 갱신
} catch (error) {
console.error('Login failed:', error);
}
};
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button type="submit" disabled={loginStatus.isPending}>
{loginStatus.isPending ? '로그인 중...' : '로그인'}
</button>
{loginStatus.error && <p>{(loginStatus.error as Error).message}</p>}
</form>
);
}Firebase 로그인
Firebase 로그인은 두 단계로 이루어집니다:
- Firebase SDK로 인증하여 ID 토큰 획득
loginWithFirebase()로 PH-CMS 서버에 토큰 교환
import { getAuth, signInWithPopup, GoogleAuthProvider } from 'firebase/auth';
function FirebaseLoginButton() {
const { loginWithFirebase, loginWithFirebaseStatus } = useAuth();
const handleFirebaseLogin = async () => {
const auth = getAuth();
const result = await signInWithPopup(auth, new GoogleAuthProvider());
const idToken = await result.user.getIdToken();
await loginWithFirebase({ idToken });
// 성공 → 자동으로 me() 호출 → 인증 상태 갱신
};
return (
<button onClick={handleFirebaseLogin} disabled={loginWithFirebaseStatus.isPending}>
{loginWithFirebaseStatus.isPending ? 'Firebase 로그인 중...' : 'Google 로그인'}
</button>
);
}익명 회원가입
이메일·비밀번호 없이 임시 계정을 생성합니다. 생성된 계정은 나중에 이메일 계정으로 전환할 수 있으며, 그 동안 쌓인 활동 히스토리(좋아요, 스탬프 등)는 그대로 유지됩니다.
두 가지 방식이 있으며 결과는 동일합니다.
방식 1 — 순수 익명 (Firebase 없이)
서버가 임시 계정(provider: anonymous)을 직접 생성합니다.
function GuestButton() {
const { loginAnonymous, loginAnonymousStatus } = useAuth();
return (
<button
onClick={() => loginAnonymous()}
disabled={loginAnonymousStatus.isPending}
>
{loginAnonymousStatus.isPending ? '입장 중...' : '게스트로 시작하기'}
</button>
);
}channelUid는 PHCMSProvider에 설정된 값이 자동으로 사용됩니다. 직접 지정하려면 인자로 전달합니다:
await loginAnonymous({ channelUid: 'channel-uid', displayName: '손님1' });방식 2 — Firebase 익명 (signInAnonymously 연동)
Firebase의 signInAnonymously()로 익명 인증을 먼저 수행한 뒤, 발급된 ID 토큰을 loginAnonymous()에 전달합니다. 서버는 이를 검증하여 provider: firebase:anonymous 계정을 생성합니다.
이 방식은 Firebase 익명 계정을 나중에 linkWithCredential() 등으로 실계정에 연결하는 Firebase 네이티브 플로우와 함께 사용할 때 적합합니다.
import { getAuth, signInAnonymously } from 'firebase/auth';
function FirebaseGuestButton() {
const { loginAnonymous, loginAnonymousStatus } = useAuth();
const handleAnonymous = async () => {
const firebaseAuth = getAuth();
const { user } = await signInAnonymously(firebaseAuth);
const firebaseIdToken = await user.getIdToken();
await loginAnonymous({ firebaseIdToken });
// 성공 → 자동으로 me() 호출 → 인증 상태 갱신
};
return (
<button onClick={handleAnonymous} disabled={loginAnonymousStatus.isPending}>
{loginAnonymousStatus.isPending ? '입장 중...' : '게스트로 시작하기'}
</button>
);
}주의: Firebase 익명 계정은
POST /auth/firebase/exchange(토큰 교환)로는 등록되지 않습니다. 신규 익명 유저는 반드시loginAnonymous({ firebaseIdToken })을 통해 등록해야 합니다.FirebaseAuthSync를 사용하는 경우 이 분기가 자동으로 처리됩니다 (Firebase Auth Sync 참고).
익명 계정 → 이메일 계정 전환
upgradeAnonymous()를 호출하면 user ID를 유지한 채로 정식 이메일 계정으로 전환됩니다.
function UpgradeForm() {
const { upgradeAnonymous, upgradeAnonymousStatus, user } = useAuth();
const handleUpgrade = async (email: string, password: string, displayName: string, phoneNumber?: string) => {
await upgradeAnonymous({ email, password, display_name: displayName, phone_number: phoneNumber });
// 성공 → role이 ['user']로 전환됨 → 기존 히스토리 유지
};
if (!user?.role.includes('anonymous')) return null;
return (
<form onSubmit={e => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
handleUpgrade(
fd.get('email') as string,
fd.get('password') as string,
fd.get('display_name') as string,
fd.get('phone_number') as string || undefined,
);
}}>
<input name="email" type="email" placeholder="이메일" />
<input name="password" type="password" placeholder="비밀번호" />
<input name="display_name" placeholder="이름" />
<input name="phone_number" placeholder="전화번호 (선택)" />
<button type="submit" disabled={upgradeAnonymousStatus.isPending}>
{upgradeAnonymousStatus.isPending ? '전환 중...' : '계정 만들기'}
</button>
</form>
);
}회원가입
register()는 method 필드로 이메일 방식과 Firebase 방식을 구분합니다.
channelUid 또는 channelSlug 중 하나는 필수입니다 (PHCMSProvider에 설정된 값이 자동으로 사용됩니다).
import type { RegisterInput } from '@ph-cms/client-sdk';방식 1 — 이메일/비밀번호 (method: 'email' 또는 생략)
function RegisterForm() {
const { register, registerStatus } = useAuth();
const handleRegister = async () => {
await register({
// method: 'email', // 생략 가능 (기본값)
email: '[email protected]',
password: 'password123',
display_name: '홍길동',
phone_number: '010-1234-5678', // 선택 사항
termCodes: ['SERVICE_TERM', 'PRIVACY_TERM'], // 동의할 약관 코드 목록
// channelUid는 PHCMSProvider에 설정된 값이 자동 사용됨
// 명시적으로 지정하려면: channelUid: 'my-channel'
});
// 성공 → 자동으로 me() 호출 → 즉시 인증 상태로 전환
};
return (
<button onClick={handleRegister} disabled={registerStatus.isPending}>
{registerStatus.isPending ? '가입 중...' : '회원가입'}
</button>
);
}방식 2 — Firebase 회원가입 (method: 'firebase')
서버가 Firebase 계정 생성과 PH-CMS 계정 생성을 하나의 트랜잭션으로 처리합니다. DB 트랜잭션 실패 시 서버가 Firebase 계정을 즉시 롤백하므로 고아 계정이 발생하지 않습니다.
클라이언트는 Firebase ID 토큰 없이 이메일/비밀번호만 전송합니다.
function FirebaseRegisterForm() {
const { register, registerStatus } = useAuth();
const handleRegister = async () => {
await register({
method: 'firebase',
email: '[email protected]',
password: 'password123',
display_name: '홍길동',
phone_number: '010-1234-5678', // 선택 사항
termCodes: ['SERVICE_TERM', 'PRIVACY_TERM'],
// channelUid는 PHCMSProvider에 설정된 값이 자동 사용됨
});
// 성공 → Firebase 계정 + PH-CMS 계정 동시 생성
// → 자동으로 me() 호출 → 즉시 인증 상태로 전환
};
return (
<button onClick={handleRegister} disabled={registerStatus.isPending}>
{registerStatus.isPending ? '가입 중...' : 'Firebase로 회원가입'}
</button>
);
}Firebase 방식과
loginWithFirebase()의 차이| |
register({ method: 'firebase' })|loginWithFirebase({ idToken })| |---|---|---| | 목적 | 신규 가입 | 기존 Firebase 계정으로 로그인 / 신규 가입 | | 클라이언트 역할 | 이메일·비밀번호만 전송 | Firebase SDK로 직접 인증 후 ID 토큰 전달 | | Firebase 계정 생성 | 서버가 처리 (롤백 보장) | 클라이언트가 처리 | | 용도 | 서버 사이드 Firebase 가입 플로우 | 소셜 로그인(Google 등) 또는 자체 Firebase 로그인 |
로그아웃
function LogoutButton() {
const { logout } = useAuth();
return <button onClick={() => logout()}>로그아웃</button>;
}Context API (usePHCMSContext)
useAuth 대신 context에 직접 접근할 수도 있습니다.
import { usePHCMSContext } from '@ph-cms/client-sdk';
function MyComponent() {
const {
client, // PHCMSClient 인스턴스
user, // UserDto | null
isAuthenticated, // boolean
isLoading, // boolean
authStatus, // 'loading' | 'authenticated' | 'unauthenticated'
hasToken, // boolean — Provider에 토큰이 존재하는지
refreshUser, // () => Promise<void> — 수동으로 프로필 다시 조회
} = usePHCMSContext();
const handleProfileUpdate = async () => {
// 프로필 수정 API 호출 후
await refreshUser(); // context 갱신
};
}Liked Contents (useLikedContents)
특정 사용자가 좋아요를 누른 콘텐츠 목록을 조회합니다.
import { useLikedContents } from '@ph-cms/client-sdk';
function UserLikedContents({ userUid }) {
const { data, isLoading } = useLikedContents(userUid, { type: 'post', page: 1, limit: 10 });
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{data?.content.map(item => (
<li key={item.uid}>{item.title}</li>
))}
</ul>
);
}Public Profile (useUserProfile)
다른 사용자의 공개 프로필(이름, 아바타, 자기소개, 팔로워 수 등)을 조회할 때 사용하는 훅입니다.
import { useUserProfile } from '@ph-cms/client-sdk';
function UserProfileCard({ userId }) {
const { data: profile, isLoading, error } = useUserProfile(userId);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>User not found</div>;
return (
<div>
<img src={profile.avatar_url} alt={profile.display_name} />
<h3>{profile.display_name} (@{profile.username})</h3>
<p>followers: {profile.follower_count}</p>
<p>{profile.profile_data?.bio}</p>
</div>
);
}Follow Status / Follow / Unfollow
특정 사용자에 대한 팔로우 상태를 조회하고, 팔로우/언팔로우할 수 있습니다.
import {
useFollowStatus,
useFollowUser,
useUnfollowUser,
} from '@ph-cms/client-sdk';
function FollowButton({ userUid }: { userUid: string }) {
const { data: status, isLoading } = useFollowStatus(userUid);
const { mutate: followUser, isPending: isFollowingPending } = useFollowUser();
const { mutate: unfollowUser, isPending: isUnfollowingPending } = useUnfollowUser();
if (isLoading) return <button disabled>Loading...</button>;
const isFollowing = status?.is_following ?? false;
const isPending = isFollowingPending || isUnfollowingPending;
return (
<button
disabled={isPending}
onClick={() => {
if (isFollowing) unfollowUser(userUid);
else followUser(userUid);
}}
>
{isFollowing ? '언팔로우' : '팔로우'}
</button>
);
}Followers / Followings List
특정 사용자의 팔로워 목록/팔로잉 목록을 페이지네이션으로 조회할 수 있습니다.
import { useFollowers, useFollowings } from '@ph-cms/client-sdk';
function FollowLists({ userUid }: { userUid: string }) {
const { data: followers, isLoading: isFollowersLoading } = useFollowers(userUid, { page: 1, limit: 20 });
const { data: followings, isLoading: isFollowingsLoading } = useFollowings(userUid, { page: 1, limit: 20 });
if (isFollowersLoading || isFollowingsLoading) return <div>Loading...</div>;
return (
<div>
<p>followers: {followers?.total ?? 0}</p>
<p>followings: {followings?.total ?? 0}</p>
</div>
);
}useLikedStats (Liked Statistics)
특정 사용자가 좋아요를 누른 콘텐츠의 타입별 통계를 조회합니다.
import { useLikedStats } from '@ph-cms/client-sdk';
function UserLikedStats({ userUid }) {
const { data: stats, isLoading } = useLikedStats(userUid);
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{stats?.map(item => (
<li key={item.type}>{item.type}: {item.count}</li>
))}
</ul>
);
}
### Profile Update (`useUpdateProfile`)
사용자가 자신의 프로필을 수정할 수 있는 훅입니다. 업데이트가 성공하면 내부적으로 `refreshUser()`가 호출되어 컨텍스트와 UI가 즉각적으로 갱신됩니다.
```tsx
import { useUpdateProfile } from '@ph-cms/client-sdk';
function ProfileEditor({ userUid }) {
const { mutateAsync: updateProfile, isPending } = useUpdateProfile();
const handleSave = async () => {
await updateProfile({
uid: userUid,
data: {
display_name: 'New Name',
avatar_url: 'https://example.com/new-avatar.png',
profile_data: { bio: 'Hello World' },
}
});
alert('프로필이 업데이트되었습니다.');
};
return <button onClick={handleSave} disabled={isPending}>저장</button>;
}Using API Directly (Without Hooks)
React Hook을 사용하지 않는 환경(Vanilla JS, Node.js, 또는 React 컴포넌트 외부)에서도 PHCMSClient를 직접 사용하여 모든 기능을 이용할 수 있습니다.
1. Client 초기화
import { PHCMSClient, LocalAuthProvider } from '@ph-cms/client-sdk';
const authProvider = new LocalAuthProvider('my_app_');
const client = new PHCMSClient({
baseURL: 'https://api.example.com',
auth: authProvider,
});2. 인증 및 사용자 정보
// 로그인 (토큰은 provider에 자동 저장됨)
const authResponse = await client.auth.login({
email: '[email protected]',
password: 'password',
});
// 현재 사용자 프로필 조회
// channelUid를 넘기면 해당 채널에 대한 약관 준수 상태 등이 포함됩니다.
const me = await client.auth.me({ channelUid: 'my-channel-uid' });
console.log(`Hello, ${me.display_name}`);
// 로그아웃
await client.auth.logout();3. 콘텐츠 및 채널 관리
// 콘텐츠 목록 조회
const posts = await client.content.list({
channelUid: 'my-channel-uid',
type: 'post',
limit: 10
});
// 콘텐츠 상세 조회
const detail = await client.content.get('content-uid');
// 채널 정보 조회
const channel = await client.channel.getBySlug('my-channel-slug');4. 약관 동의
// 채널별 약관 목록 조회
const terms = await client.terms.listByChannel('my-channel-uid');
// 약관 동의 제출
await client.terms.agree({
channelUid: 'my-channel-uid',
termCodes: ['service_terms_v1', 'privacy_policy_v1']
});5. 토큰 수동 관리 (선택 사항)
일반적으로 SDK가 가로채기(Interceptor)를 통해 토큰을 자동으로 관리하지만, 직접 제어해야 할 경우 다음과 같이 사용합니다.
// 현재 저장된 리프레시 토큰 가져오기
const refreshToken = authProvider.getRefreshToken();
if (refreshToken) {
// 토큰 갱신 요청
const newTokens = await client.auth.refresh(refreshToken);
// 새 토큰 저장
authProvider.setTokens(newTokens.accessToken, newTokens.refreshToken);
}Legacy Hooks
하위 호환성을 위해 개별 훅도 제공됩니다. 새 코드에서는 useAuth를 권장합니다.
| Hook | 설명 |
|---|---|
| useUser() | { data: UserDto \| null, isLoading, isAuthenticated } |
| useLogin() | { mutateAsync: login } |
| useLogout() | { mutateAsync: logout } |
Firebase Auth Sync
Firebase 인증 상태가 변경될 때 PH-CMS 백엔드와 자동으로 동기화할 수 있습니다.
| 방식 | 용도 |
|---|---|
| useFirebaseAuthSync 훅 | 기존 컴포넌트에 동기화 로직을 삽입할 때 |
| <FirebaseAuthSync> 컴포넌트 | 컴포넌트 트리를 감싸서 선언적으로 사용할 때 |
동기화 동작
Firebase onAuthStateChanged
│
├─ fbUser 존재 + PH-CMS 비인증 상태
│ → fbUser.getIdToken()
│ │
│ ├─ fbUser.isAnonymous === true
│ │ → client.auth.loginAnonymous({ channelUid, firebaseIdToken })
│ │ ※ POST /auth/anonymous (등록 또는 기존 계정 토큰 재발급)
│ │
│ └─ fbUser.isAnonymous === false (Google, 이메일 등)
│ → client.auth.loginWithFirebase({ idToken })
│ ※ POST /auth/firebase/exchange (기존 유저 로그인 전용)
│
│ → provider.setTokens(...)
│ → refreshUser() ← me() 호출하여 프로필 로드
│
└─ fbUser null (로그아웃) + PH-CMS 인증 상태
→ client.auth.logout()
→ refreshUser() ← 상태 초기화
POST /auth/firebase/exchange는 기존 유저 로그인 전용입니다. 신규 익명 유저를 이 엔드포인트로 생성하려 하면 서버가 에러를 반환합니다.FirebaseAuthSync를 사용하면 익명/비익명 분기가 자동으로 처리됩니다.
<FirebaseAuthSync> 컴포넌트
<PHCMSProvider> 안에서 사용합니다:
import { PHCMSProvider, FirebaseAuthSync } from '@ph-cms/client-sdk';
import { getAuth } from 'firebase/auth';
const firebaseAuth = getAuth(firebaseApp);
function App() {
return (
<PHCMSProvider client={client}>
<FirebaseAuthSync firebaseAuth={firebaseAuth}>
<MainContent />
</FirebaseAuthSync>
</PHCMSProvider>
);
}| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| firebaseAuth | firebase/auth.Auth | (필수) | Firebase Auth 인스턴스 |
| logoutOnFirebaseSignOut | boolean | true | Firebase 로그아웃 시 PH-CMS도 자동 로그아웃할지 |
| onSyncSuccess | () => void | — | 토큰 교환 성공 시 콜백 |
| onSyncError | (error: unknown) => void | — | 토큰 교환 실패 시 콜백 |
useFirebaseAuthSync 훅
import { useFirebaseAuthSync } from '@ph-cms/client-sdk';
import { getAuth } from 'firebase/auth';
const firebaseAuth = getAuth(firebaseApp);
function AppContent() {
const { isSyncing } = useFirebaseAuthSync({
firebaseAuth,
onSyncSuccess: () => console.log('Firebase↔PH-CMS 동기화 완료'),
onSyncError: (err) => console.error('동기화 실패:', err),
});
if (isSyncing) return <div>인증 동기화 중...</div>;
return <MainContent />;
}전체 구성 예시 (Firebase)
import {
PHCMSClient,
FirebaseAuthProvider,
PHCMSProvider,
FirebaseAuthSync,
} from '@ph-cms/client-sdk';
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
const firebaseApp = initializeApp({ /* config */ });
const firebaseAuth = getAuth(firebaseApp);
const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_');
const client = new PHCMSClient({
baseURL: 'https://api.example.com',
auth: authProvider,
});
function App() {
return (
<PHCMSProvider client={client}>
<FirebaseAuthSync
firebaseAuth={firebaseAuth}
onSyncError={(err) => alert('인증 동기화에 실패했습니다.')}
>
<Router />
</FirebaseAuthSync>
</PHCMSProvider>
);
}이 구성에서는:
- Firebase 로그인 시
FirebaseAuthSync가 자동으로 PH-CMS 토큰 교환 + 프로필 조회를 수행 - Firebase 로그아웃 시 PH-CMS 세션도 자동 정리
- 앱 재방문 시 localStorage 토큰으로 세션 자동 복원
- 토큰 만료 시 자동 갱신 (proactive + reactive)
Using Data Hooks
Content
useContentList, useCreateContent 등 콘텐츠 관련 훅은 PHCMSProvider에 설정된 channelUid를 자동으로 사용합니다.
import { useContentList } from '@ph-cms/client-sdk';
function ContentList() {
// channelUid를 직접 넘기지 않아도 컨텍스트의 값을 사용합니다.
const { data, isLoading } = useContentList({ limit: 10 });
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{data?.content.map(item => (
<li key={item.uid}>{item.title}</li>
))}
</ul>
);
}Like (좋아요)
콘텐츠에 좋아요를 남기고, 좋아요 상태를 확인할 수 있습니다.
좋아요 토글 (인증 필수)
// 모듈 직접 사용
const result = await client.content.toggleLike('content-uid');
// => { liked: true, likeCount: 42 }
// React Hook 사용 (Optimistic update 포함)
const { mutate: toggleLike } = useToggleLike();
toggleLike('content-uid');좋아요 상태 확인 (인증 선택)
// 모듈 직접 사용
const status = await client.content.getLikeStatus('content-uid');
// => { liked: true }
// React Hook 사용
const { data: status } = useLikeStatus('content-uid');
console.log(status?.liked); // true or false좋아요 관련 목록 조회 (Public)
특정 콘텐츠를 좋아하는 사용자 목록이나, 특정 사용자가 좋아하는 콘텐츠 목록을 조회할 수 있습니다.
// 1. 특정 콘텐츠에 좋아요를 누른 사용자 목록
const likers = await client.content.getLikers('content-uid', { page: 1, limit: 10 });
// => PagedResponse<UserProfileDto>
// 2. 특정 사용자가 좋아요를 누른 콘텐츠 목록 (타입 필터링 지원)
const likedContents = await client.user.getLikedContents('user-uid', { type: 'post', page: 1 });
// => PagedContentListResponse
// 3. 특정 사용자가 좋아요를 누른 콘텐츠 타입별 통계
const likedStats = await client.user.getLikedStats('user-uid');
// => [{ type: 'post', count: 5 }, { type: 'place', count: 2 }]Content Report (콘텐츠 신고)
부적절한 콘텐츠를 신고할 수 있습니다. (인증 필수)
// 모듈 직접 사용
const result = await client.content.report('content-uid', {
reason: 'SPAM',
description: '이 콘텐츠는 스팸입니다.'
});
// => { message: 'Report submitted successfully' }
// React Hook 사용
const { mutate: reportContent } = useReportContent();
reportContent({
uid: 'content-uid',
data: {
reason: 'INAPPROPRIATE',
description: '부적절한 내용이 포함되어 있습니다.'
}
});User Block (사용자 차단)
특정 사용자를 차단하거나 차단 해제할 수 있습니다. (인증 필수)
// 1. 사용자 차단
const blockResult = await client.user.block('target-user-uid', {
reason: '부적절한 언행'
});
// => { success: true, message: 'User blocked' }
// React Hook 사용
const { mutate: blockUser } = useBlockUser();
blockUser({ uid: 'target-user-uid', data: { reason: '사유' } });
// 2. 사용자 차단 해제
const unblockResult = await client.user.unblock('target-user-uid');
// => { success: true, message: 'User unblocked' }
// React Hook 사용
const { mutate: unblockUser } = useUnblockUser();
unblockUser('target-user-uid');
// 3. 차단한 사용자 목록 조회
const blockedUsers = await client.user.getBlockedUsers('my-user-uid', { page: 1, limit: 10 });
// => PagedBlockedUserListResponse
// React Hook 사용
const { data: blockedList } = useBlockedUsers('my-user-uid', { page: 1 });User Follow (사용자 팔로우)
특정 사용자를 팔로우하거나 언팔로우할 수 있습니다. (인증 필수)
// 1. 팔로우 상태 조회
const followStatus = await client.user.getFollowStatus('target-user-uid');
// => { is_following: false }
// React Hook 사용
const { data: status } = useFollowStatus('target-user-uid');
// 2. 사용자 팔로우
const followResult = await client.user.follow('target-user-uid');
// => { success: true, message: 'User followed', is_following: true }
// React Hook 사용
const { mutate: followUser } = useFollowUser();
followUser('target-user-uid');
// 3. 사용자 언팔로우
const unfollowResult = await client.user.unfollow('target-user-uid');
// => { success: true, message: 'User unfollowed', is_following: false }
// React Hook 사용
const { mutate: unfollowUser } = useUnfollowUser();
unfollowUser('target-user-uid');
// 4. 사용자 팔로워 목록 조회 (Public)
const followers = await client.user.getFollowers('target-user-uid', { page: 1, limit: 20 });
// => PagedUserProfileListResponse
// React Hook 사용
const { data: followerList } = useFollowers('target-user-uid', { page: 1, limit: 20 });
// 5. 사용자 팔로잉 목록 조회 (Public)
const followings = await client.user.getFollowings('target-user-uid', { page: 1, limit: 20 });
// => PagedUserProfileListResponse
// React Hook 사용
const { data: followingList } = useFollowings('target-user-uid', { page: 1, limit: 20 });Global Ban (글로벌 밴 - 관리자 전용)
특정 사용자의 활동을 전역적으로 정지시키거나 해제할 수 있습니다. (관리자 권한 필수)
// 1. 사용자 활동 정지 (Suspend)
const suspendResult = await client.user.suspend('target-user-uid', {
reason: '운영 정책 위반'
});
// => { success: true, message: 'User suspended' }
// React Hook 사용
const { mutate: suspendUser } = useSuspendUser();
suspendUser({ uid: 'target-user-uid', data: { reason: '사유' } });
// 2. 사용자 활동 정지 해제 (Unsuspend)
const unsuspendResult = await client.user.unsuspend('target-user-uid');
// => { success: true, message: 'User unsuspended' }
// React Hook 사용
const { mutate: unsuspendUser } = useUnsuspendUser();
unsuspendUser('target-user-uid');Stamp Tour
스탬프 투어 기능을 사용하여 특정 지점(Marker) 방문을 인증하고 진행 현황을 조회하거나, 새로운 투어를 생성할 수 있습니다.
스탬프 투어 생성 (인증 필수)
여러 개의 마커(Marker)를 묶어 새로운 스탬프 투어를 생성합니다.
import { useCreateStampTour } from '@ph-cms/client-sdk';
function CreateTourForm() {
const { mutateAsync: createTour, isPending } = useCreateStampTour();
const handleCreate = async () => {
await createTour({
channelSlug: 'my-channel',
parentUid: 'folder-uid',
title: '서울 명소 투어',
summary: '서울의 주요 명소를 방문하세요.',
markerUids: ['marker-1-uid', 'marker-2-uid'],
isActive: true,
tags: ['서울', '여행'],
startsAt: '2026-04-01T00:00:00Z',
endsAt: '2026-06-30T23:59:59Z'
});
alert('투어가 생성되었습니다.');
};
return <button onClick={handleCreate} disabled={isPending}>생성</button>;
}User Terms (사용자 약관)
PH-CMS는 글로벌 약관과 채널별 약관을 지원하며, 사용자의 동의 여부와 최신 버전 준수 여부를 트래킹합니다.
Channel Context Setup
특정 채널의 서비스라면 PHCMSProvider에 channelUid를 전달하여, 해당 채널의 약관 준수 상태를 자동으로 가져올 수 있습니다.
<PHCMSProvider client={client} channelUid="my-channel-slug">
<App />
</PHCMSProvider>이렇게 설정하면 useAuth()나 usePHCMSContext()를 통해 가져오는 user 객체의 channel_agreements 필드에 해당 채널의 약관 요약 정보가 포함됩니다.
Checking Compliance
사용자가 필수 약관에 모두 동의했는지, 혹은 약관이 개정되어 재동의가 필요한지 확인합니다.
function TermsGuard({ children }) {
const { user } = useAuth();
// 현재 채널에 대한 동의 요약 찾기
const compliance = user?.channel_agreements?.[0];
if (compliance && !compliance.is_fully_compliant) {
return <TermsAgreementModal summary={compliance} />;
}
return children;
}Hooks for Terms
1. useChannelTerms(channelUid)
특정 채널에 할당된 약관 목록을 조회합니다.
const { data: terms } = useChannelTerms('my-channel-slug');2. useAgreeTerms()
약관 동의를 제출합니다.
const { mutateAsync: agree } = useAgreeTerms();
const handleAgree = async (termCodes: string[]) => {
await agree({
termCodes,
channelUid: 'my-channel-slug' // 선택 사항 (Provider에 설정되어 있어도 명시 가능)
});
};스탬프 투어 수정 (인증 필수)
기존 스탬프 투어의 정보나 마커 목록을 수정합니다. (참여자가 있는 경우 마커 목록 수정은 제한될 수 있습니다.)
import { useUpdateStampTour } from '@ph-cms/client-sdk';
function UpdateTourForm({ tourUid }) {
const { mutateAsync: updateTour, isPending } = useUpdateStampTour();
const handleUpdate = async () => {
await updateTour({
uid: tourUid,
data: {
title: '수정된 투어 제목',
isActive: false,
markerUids: ['marker-1-uid', 'marker-3-uid']
}
});
alert('투어가 수정되었습니다.');
};
return <button onClick={handleUpdate} disabled={isPending}>투어 수정</button>;
}스탬프 획득 (인증)
사용자의 현재 GPS 좌표를 전송하여 특정 투어 내의 마커 방문을 인증합니다.
import { useStamp } from '@ph-cms/client-sdk';
function StampButton({ tourUid, markerUid }) {
const { mutateAsync: collectStamp, isPending } = useStamp();
const handleStamp = async () => {
try {
const result = await collectStamp({
tourUid,
markerUid,
data: { lat: 37.5511, lng: 126.9882 }
});
console.log(`거리: ${result.distance}m`);
if (result.isCompletion) {
alert('축하합니다! 투어를 완주하셨습니다.');
}
} catch (error) {
alert('스탬프를 획득할 수 없습니다. (너무 멀거나 이미 획득함)');
}
};
return <button onClick={handleStamp} disabled={isPending}>스탬프 찍기</button>;
}클라이언트 위치 검증 (Geo)
스탬프를 획득하기 전 클라이언트 창에서 사용자의 현재 GPS 좌표와 마커 위치를 비교하여, 반경 내에 들어왔는지 선제적으로 검증할 수 있습니다. checkStampAvailability 함수와 지리 정보 훅 useGeolocation을 조합하여 다양한 검증 상태(available, distance_far, not_stamp_location, checking_geo 등)를 처리합니다.
import { useGeolocation, checkStampAvailability } from '@ph-cms/client-sdk';
function StampLocationGuard({ marker, stampStatus, isAuthenticated }) {
// 실시간 GPS 좌표 및 권한 상태 감지
const { latitude, longitude, geoError } = useGeolocation();
// 클라이언트 단에서 마커 접근 가능성 확인
const availability = checkStampAvailability({
markerUid: marker.uid,
markerLocation: marker.location, // { latitude, longitude }
stampStatus, // 내가 획득한 스탬프 정보 (StampStatusDto)
isLoggedIn: isAuthenticated, // 권한 상태 확인
latitude,
longitude,
geoError,
distanceThreshold: 40, // 스탬프 획득 가능 반경 (단위: 미터)
});
return (
<div>
<p>상태: {availability.stateMessage}</p>
<p>힌트: {availability.hintMessage}</p>
<p>거리: {availability.distance ? `${availability.distance.toFixed(1)}m` : '알 수 없음'}</p>
{availability.state === 'available' && (
<button onClick={handleCollect}>스탬프 획득하기</button>
)}
</div>
);
}투어 진행 현황 조회
특정 투어에 대해 내가 획득한 스탬프 목록과 완주 여부를 확인합니다.
import { useStampStatus } from '@ph-cms/client-sdk';
function TourProgress({ tourUid }) {
const { data: status, isLoading } = useStampStatus(tourUid);
if (isLoading) return <div>Loading...</div>;
return (
<div>
<p>진행도: {status?.collected_markers} / {status?.total_markers}</p>
{status?.is_completed && <p>🏆 완주 완료!</p>}
</div>
);
}투어 전체 통계 (관리자용)
투어의 총 참여자 수, 완주율 등 관리용 데이터를 조회합니다. (적절한 권한 필요)
import { useTourStats } from '@ph-cms/client-sdk';
function AdminStats({ tourUid }) {
const { data: stats, isLoading } = useTourStats(tourUid);
// ...
}Media & File Upload
미디어 업로드는 3단계로 진행됩니다: Ticket 발급 → S3 업로드 → Content 생성/수정.
import { useMediaUploadTickets, useUploadToS3, useCreateContent } from '@ph-cms/client-sdk';
function MediaUploader() {
const { mutateAsync: getTickets } = useMediaUploadTickets();
const { mutateAsync: uploadToS3 } = useUploadToS3();
const { mutateAsync: createContent } = useCreateContent();
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Step 1: Upload Ticket 발급 (Presigned URL)
const tickets = await getTickets([{
filename: file.name,
contentType: file.type,
fileSize: file.size,
}]);
const { mediaUid, uploadUrl } = tickets[0];
// Step 2: S3에 파일 직접 업로드
await uploadToS3({ url: uploadUrl, file });
// Step 3: mediaUid로 Content 생성
await createContent({
type: 'post',
title: 'Post with image',
mediaAttachments: [mediaUid],
});
};
return <input type="file" onChange={handleFileChange} />;
}Error Handling
SDK는 세 종류의 에러를 throw합니다:
| Error Class | 상황 | 주요 필드 |
|---|---|---|
| ValidationError | Zod 스키마 검증 실패 (클라이언트 측) | errors: ZodIssue[] |
| ApiError | 서버가 2xx 이외의 응답을 반환 | statusCode: number, originalError: any |
| PHCMSError | 네트워크 오류 등 기타 에러 | message: string |
import { ApiError } from '@ph-cms/client-sdk';
try {
await login({ email, password });
} catch (error) {
if (error instanceof ApiError) {
if (error.statusCode === 401) {
console.error('잘못된 인증 정보입니다.');
}
}
}PHCMSClient
const client = new PHCMSClient({
baseURL: string; // 서버 URL (필수)
apiPrefix?: string; // API 경로 접두사 (기본값: '/api')
auth?: AuthProvider; // 인증 프로바이더
timeout?: number; // 요청 타임아웃 ms (기본값: 10000)
axiosInstance?: AxiosInstance; // 직접 커스터마이징한 Axios 인스턴스 (선택 사항)
});
client.authProvider // AuthProvider | undefined
client.axiosInstance // AxiosInstance — 내부 axios 인스턴스 직접 접근
client.auth // AuthModule
client.user // UserModule
client.content // ContentModule
client.channel // ChannelModule
client.terms // TermsModule
client.media // MediaModule
await client.getToken() // 현재 유효한 PH-CMS access token 반환 (만료 시 자동 갱신, 없으면 null)getToken()은 PH-CMS SDK가 아닌 외부 서비스(예: 알림 서비스)를 클라이언트에서 직접 호출할 때 Authorization 헤더에 넣을 토큰을 가져오는 데 사용합니다.
const token = await client.getToken();
const res = await fetch(`${NOTIFICATION_SERVICE_URL}/notifications`, {
headers: { Authorization: `Bearer ${token}` },
});apiPrefix는 생성 시 각 모듈에 직접 주입됩니다. 예를 들어 apiPrefix: '/v2/api'로 설정하면 모든 모듈의 요청 URL이 /v2/api/contents/..., /v2/api/auth/... 형태로 전송됩니다.
// 기본값: /api
const client = new PHCMSClient({ baseURL: 'https://api.example.com' });
// 커스텀 prefix
const client = new PHCMSClient({
baseURL: 'https://api.example.com',
apiPrefix: '/v2/api',
});커스텀 Axios 인스턴스 사용 (서버 to 서버 등)
인증서(SSL)가 필요하거나 특수한 Agent 설정이 필요한 경우, 직접 생성한 Axios 인스턴스를 주입할 수 있습니다.
import axios from 'axios';
import https from 'https';
import { PHCMSClient } from '@ph-cms/client-sdk';
const customInstance = axios.create({
httpsAgent: new https.Agent({
rejectUnauthorized: false, // 혹은 인증서 설정
}),
});
const client = new PHCMSClient({
baseURL: 'https://internal-api.ph-cms.com',
axiosInstance: customInstance,
});React 없는 환경에서 사용 (Node.js, Next.js 서버 컴포넌트 등)
메인 엔트리포인트(@ph-cms/client-sdk)는 React Context(PHCMSProvider 등)를 포함하고 있어, React가 없는 환경에서 임포트하면 TypeError: createContext is not a function 오류가 발생합니다.
이 경우 React 의존성이 제거된 /core 서브패스를 사용하세요.
PHCMSClient를 통해 사용 (권장)
import { PHCMSClient } from '@ph-cms/client-sdk/core';
const client = new PHCMSClient({
baseURL: 'https://api.ph-cms.com',
apiPrefix: '/api', // 생략 시 기본값 '/api'
});
// apiPrefix가 각 모듈에 직접 주입되므로 URL이 올바르게 구성됩니다
const result = await client.content.list({ channelUid: 'my-channel' });모듈 클래스를 직접 인스턴스화하는 경우
모듈 생성자의 두 번째 인자로 prefix를 전달합니다.
import axios from 'axios';
import { ContentModule } from '@ph-cms/client-sdk/core';
const axiosInstance = axios.create({ baseURL: 'https://api.ph-cms.com' });
// 두 번째 인자로 prefix 전달 (기본값: '/api')
const content = new ContentModule(axiosInstance, '/v2/api');
const result = await content.list({ channelUid: 'my-channel' });
// → GET https://api.ph-cms.com/v2/api/contents/core에서 export되는 항목은 다음과 같습니다 (React 관련 항목 제외):
| 카테고리 | export 항목 |
|---|---|
| 클라이언트 | PHCMSClient |
| 모듈 | ContentModule, AuthModule, UserModule, ChannelModule, MediaModule, TermsModule |
| Auth 프로바이더 | BaseAuthProvider, LocalAuthProvider, JwtUtils |
| 타입/에러 | PHCMSClientError, 각종 DTO/Request 타입 |
React 환경에서는 기존대로
@ph-cms/client-sdk에서 임포트하면PHCMSProvider, hooks 등을 모두 사용할 수 있습니다.
UserModule (client.user)
| 메서드 | 설명 |
|---|---|
| getProfile(uid: string) | 유저의 공개 프로필 정보 조회 → UserProfileDto |
| getFollowStatus(uid: string) | 현재 로그인 사용자의 대상 유저 팔로우 상태 조회 → FollowStatusResponse |
| follow(uid: string) | 특정 사용자 팔로우 → FollowUserResponse |
| unfollow(uid: string) | 특정 사용자 언팔로우 → FollowUserResponse |
| getFollowers(uid: string, params?: { page?: number; limit?: number }) | 특정 사용자의 팔로워 목록 조회 → PagedUserProfileListResponse |
| getFollowings(uid: string, params?: { page?: number; limit?: number }) | 특정 사용자의 팔로잉 목록 조회 → PagedUserProfileListResponse |
| updateProfile(uid: string, data: UpdateUserProfileRequest) | 유저의 프로필 정보 업데이트 → UserDto |
| getLikedContents(uid: string, params?: Partial<ListLikedContentQuery>) | 사용자가 좋아요 누른 콘텐츠 목록 조회 → PagedContentListResponse |
| getLikedStats(uid: string) | 사용자가 좋아요 누른 콘텐츠 타입별 통계 조회 → LikedStatsByTypeResponse |
| block(uid: string, data?: BlockUserRequest) | 특정 사용자 차단 → BlockUserResponse |
| unblock(uid: string) | 특정 사용자 차단 해제 → BlockUserResponse |
| getBlockedUsers(uid: string, params?: { page?: number; limit?: number }) | 차단한 사용자 목록 조회 → PagedBlockedUserListResponse |
| suspend(uid: string, data?: SuspendUserRequest) | 유저 활동 정지 (글로벌 밴) → SuspendUserResponse |
| unsuspend(uid: string) | 유저 활동 정지 해제 → SuspendUserResponse |
AuthModule (client.auth)
| 메서드 | 설명 |
|---|---|
| login(data: LoginRequest) | 이메일/비밀번호 로그인 → AuthResponse |
| loginWithFirebase(data: FirebaseExchangeRequest) | Firebase ID 토큰 교환 → AuthResponse |
| register(data: RegisterRequest) | 이메일 회원가입 → AuthResponse (channelUid | channelSlug 필수, phone_number 지원) |
| registerWithFirebase(data: FirebaseRegisterRequest) | 서버 사이드 Firebase 회원가입 → AuthResponse (channelUid | channelSlug 필수, phone_number 지원) |
| loginAnonymous(data?: AnonymousLoginRequest) | 익명 계정 생성/로그인 → AuthResponse |
| upgradeAnonymous(data: { email, password, display_name?, username?, phone_number? }) | 익명 → 정식 이메일 계정 전환 → UserDto (role: ['user']) |
| me(params?: { channelUid?: string }) | 현재 사용자 프로필 조회 → UserDto |
| refresh(refreshToken: string) | 토큰 갱신 → { accessToken, refreshToken } |
| logout() | 로그아웃 (프로바이더 토큰 삭제 + 서버 세션 무효화) |
ContentModule (client.content)
| 메서드 | 설명 |
|---|---|
| list(query: ListContentQuery) | 콘텐츠 목록 조회 → PagedContentListResponse |
| get(uid: string, params?: { withParent?: boolean; withDetail?: boolean }) | 단일 콘텐츠 상세 조회 → ContentDto |
| create(data: CreateContentRequest) | 일반 콘텐츠 생성 → ContentDto |
| update(uid: string, data: UpdateContentRequest) | 콘텐츠 수정 → ContentDto |
| delete(uid: string) | 콘텐츠 삭제 |
| createStampTour(data: CreateStampTourRequest) | 스탬프 투어 생성 → ContentDto |
| updateStampTour(uid: string, data: UpdateStampTourRequest) | 스탬프 투어 수정 → ContentDto |
| stamp(tourUid: string, markerUid: string, data: CollectStampRequest) | 스탬프 획득 인증 → { isCompletion, distance } |
| getStampStatus(tourUid: string) | 내 스탬프 획득 현황 조회 → StampStatusDto |
| getTourStats(tourUid: string) | 투어 전체 통계 조회 → TourStatsDto |
| toggleLike(uid: string) | 좋아요 토글 → ToggleLikeResponse |
| getLikeStatus(uid: string) | 내 좋아요 여부 확인 → LikeStatusResponse |
| getLikers(uid: string, params?: Partial<ListLikersQuery>) | 콘텐츠에 좋아요 누른 사용자 목록 조회 → PagedResponse<UserProfileDto> |
| report(uid: string, data: ReportContentRequest) | 콘텐츠 신고 → ReportContentResponse |
예시:
await client.content.list({ withParent: true, withDetail: true, page: 1, limit: 20 });
await client.content.get('cnt_123', { withParent: true, withDetail: true });ChannelModule (client.channel)
| 메서드 | 설명 |
|---|---|
| getByUid(uid: string) | UID로 채널 상세 조회 |
| getBySlug(slug: string) | 슬러그로 채널 상세 조회 |
| update(uid: string, data: any) | 채널 정보 수정 |
| checkHierarchy(params: CheckHierarchyQuery) | 하이어라키 규칙 체크 |
TermsModule (client.terms)
| 메서드 | 설명 |
|---|---|
| get(id: number) | 약관 상세 조회 |
| agree(data: { termCodes: string[]; channelUid?: string }) | 약관 동의 제출 |
| listByChannel(channelUid: string) | 채널별 약관 목록 조회 |
JWT Utilities
클라이언트에서 토큰 상태를 확인할 수 있는 유틸리티입니다 (서명 검증은 하지 않음).
import {
decodeJwtPayload,
getTokenExpirationMs,
isTokenExpired,
getTokenTTL,
} from '@ph-cms/client-sdk';
const payload = decodeJwtPayload(token); // JwtPayload | null
const expiresAt = getTokenExpirationMs(token); // number (ms) | null
const expired = isTokenExpired(token, 60_000); // boolean | null
const ttl = getTokenTTL(token); // number (ms) | nullLicense
MIT
