@villion-inc/blog-sdk
v2.1.0
Published
Villion Blog public read API SDK — fetch articles, sites, and feeds from your Next.js / Node / browser app.
Readme
@villion-inc/blog-sdk
Villion 블로그 글을 내 사이트에서 불러오는 공식 TypeScript SDK입니다. 글 작성과 발행은 Villion 대시보드에서 하고, 화면은 내 사이트가 그립니다. SDK는 읽기만 하므로 비밀 키를 담지 않습니다.
npm install @villion-inc/blog-sdk화면을 직접 만들지 않고 reader.villion.io 와 같은 디자인을 쓰려면 @villion-inc/blog-ui 를 씁니다. Next.js App Router 사이트에서
npx @villion-inc/blog-ui init한 줄로/blog를 설치합니다. Villion 고객사 전용입니다.
v1(0.1.x)을 쓰고 있다면 v1에서 옮기기를 먼저 읽어 주세요. 글 목록의 기본 언어와
getArticle인자 순서가 바뀌었습니다.
v2 핵심
| 항목 | v2 동작 |
|---|---|
| 글 목록 | locale을 비우면 사이트 기본 언어 글만 옵니다. 모든 언어는 locale: "all"입니다. |
| 글 하나 | getArticle(slug, { locale }, init)로 언어를 고릅니다. |
| 글 주소 | siteUrl과 주소 규칙으로 hreflang, 사이트맵 주소를 만들어 줍니다. |
| 캐시 | requestInit에 한 번 넣으면 모든 요청에 붙습니다. |
| 2.1 추가 | 카테고리, 관련 글, 댓글 목록, CMS 폼 조회. 방문자 브라우저용 createPublicClient(댓글 작성, 구독, 폼 제출) |
준비
대시보드의 블로그 설치 화면에서 개발자 연동을 고릅니다. Villion 호스팅과 개발자 연동은 함께 쓸 수 없습니다. 이 SDK는 개발자 연동용입니다.
| 값 | 찾는 곳 | 형태 |
|---|---|---|
| 사이트 ID | 블로그 설치 화면 주소 /publish/{siteId}/integrations의 {siteId} | UUID |
| API 키 | 블로그 설치 > 개발자 연동 > API 키 탭 | vk_pub_... |
| Webhook 시크릿 | 블로그 설치 > 개발자 연동 > Webhook 탭 | whsec_... |
| 사이트 주소 | 블로그를 설치할 내 사이트 | https://www.example.com |
사이트 ID 자리에 사이트 slug를 넣으면 안 됩니다. slug는 사이트끼리 겹칠 수 있어 식별자로 쓰지 않습니다.
# .env.local
SITE_URL=https://www.example.com
VILLION_SITE_ID=00000000-0000-0000-0000-000000000000
VILLION_API_KEY=vk_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VILLION_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx # webhook 을 받을 때만빠른 시작
import { createClient } from "@villion-inc/blog-sdk";
const villion = createClient({
siteId: process.env.VILLION_SITE_ID!,
apiKey: process.env.VILLION_API_KEY!,
siteUrl: process.env.SITE_URL, // hreflang, 사이트맵 주소를 만들 때 씁니다
});
const { articles, total } = await villion.listArticles({ limit: 20 }); // 기본 언어 글
const { articles: en } = await villion.listArticles({ locale: "en" }); // 영어 글
const post = await villion.getArticle("hello-world"); // 기본 언어 글 하나
const enPost = await villion.getArticle("hello-world", { locale: "en" }); // 영어 글 하나언어 처리
글마다 언어(locale)가 있습니다. 언어가 비어 있는 글은 사이트 기본 언어 글로 봅니다. 대시보드에서 번역한 글은 같은 번역 묶음으로 이어지고, 글 상세의 translations에 실립니다.
| 호출 | 받는 글 |
|---|---|
| listArticles() | 사이트 기본 언어 글 |
| listArticles({ locale: "en" }) | 영어 글 |
| listArticles({ locale: "all" }) | 모든 언어 글 |
| getArticle(slug) | 기본 언어 글. 그 slug의 기본 언어 글이 없으면 404 |
| getArticle(slug, { locale: "en" }) | 영어 글 |
사이트 기본 언어는 대시보드 설정값을 한 번 조회해 씁니다. defaultLocale 옵션을 주면 조회하지 않고 그 값을 씁니다.
글 주소 규칙
기본 규칙은 아래와 같습니다. getArticleUrl, getAlternates, sitemapEntries가 이 규칙으로 주소를 만듭니다.
| 글 | 주소 |
|---|---|
| 기본 언어 글 | {siteUrl}/blog/{slug} |
| 다른 언어 글 | {siteUrl}/{locale}/blog/{slug} |
사이트 라우트가 다르면 articlePath로 바꿉니다.
// 모든 언어에 접두사를 붙이는 사이트: /ko/blog/a, /en/blog/a
createClient({
siteId, apiKey, siteUrl,
articlePath: ({ slug, locale }) => `/${locale}/blog/${encodeURIComponent(slug)}`,
});
// 블로그 경로가 /news 인 사이트: /news/a, /en/news/a
createClient({
siteId, apiKey, siteUrl,
articlePath: ({ slug, locale, isDefaultLocale }) =>
isDefaultLocale ? `/news/${slug}` : `/${locale}/news/${slug}`,
});siteUrl을 비우면 /blog/a 같은 경로만 돌려줍니다. 사이트맵은 전체 주소가 필요하니 siteUrl을 꼭 넣어 주세요.
Next.js App Router
기본 언어는 /blog, 영어는 /en/blog에 두는 예시입니다.
| 파일 | 역할 |
|---|---|
| lib/villion.ts | 클라이언트와 글 조회 함수 |
| app/blog/page.tsx | 기본 언어 글 목록 |
| app/blog/[slug]/page.tsx | 기본 언어 글 화면 |
| app/en/blog/page.tsx | 영어 글 목록 |
| app/en/blog/[slug]/page.tsx | 영어 글 화면 |
| app/sitemap.ts | 사이트맵 |
| app/api/villion-webhook/route.ts | 발행 알림을 받아 화면 새로 만들기 |
클라이언트
// lib/villion.ts
import { cache } from "react";
import { createClient } from "@villion-inc/blog-sdk";
export const villion = createClient({
siteId: process.env.VILLION_SITE_ID!,
apiKey: process.env.VILLION_API_KEY!,
siteUrl: process.env.SITE_URL,
// 모든 요청을 5분 동안 캐시합니다(ISR). 발행 알림이 오면 바로 새로 만듭니다.
requestInit: { next: { revalidate: 300 } },
});
// generateMetadata 와 화면이 같은 글을 두 번 부르지 않게 한 요청 안에서 묶습니다.
export const getPost = cache((slug: string, locale?: string) =>
villion.getArticleOrNull(slug, { locale }),
);글 목록
// app/blog/page.tsx
import Link from "next/link";
import { villion } from "@/lib/villion";
export default async function BlogPage() {
const { articles } = await villion.listArticles({ limit: 20 });
return (
<ul>
{articles.map((a) => (
<li key={a.id}>
<Link href={`/blog/${a.slug}`}>{a.title}</Link>
</li>
))}
</ul>
);
}영어 목록(app/en/blog/page.tsx)은 listArticles({ limit: 20, locale: "en" })로 부르고, 링크를 /en/blog/${a.slug}로 바꿉니다.
글 화면과 hreflang
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { villion, getPost } from "@/lib/villion";
type Props = { params: Promise<{ slug: string }> };
export async function generateStaticParams() {
const articles = await villion.listAllArticles();
return articles.filter((a) => a.slug).map((a) => ({ slug: a.slug! }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return {};
const alternates = await villion.getAlternates(post);
return {
title: post.seo_title || post.title,
description: post.meta_description ?? undefined,
alternates: {
canonical: post.canonical_url || (await villion.getArticleUrl(post)),
languages: Object.fromEntries(alternates.map((a) => [a.hreflang, a.url])),
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.body_html }} />
</article>
);
}getAlternates는 번역본마다 한 줄씩 주고, 마지막에 x-default를 붙입니다. x-default는 기본 언어 번역본을 가리킵니다. 기본 언어 번역본이 없으면 지금 글을 가리킵니다.
다른 언어 글 화면
app/en/blog/[slug]/page.tsx는 위 파일과 같습니다. 두 곳만 영어로 바꿉니다.
// app/en/blog/[slug]/page.tsx
const LOCALE = "en";
export async function generateStaticParams() {
const articles = await villion.listAllArticles({ locale: LOCALE });
return articles.filter((a) => a.slug).map((a) => ({ slug: a.slug! }));
}
// generateMetadata 와 PostPage 안에서
const post = await getPost(slug, LOCALE);언어가 여럿이면 app/[lang]/blog/[slug]/page.tsx 하나로 묶을 수 있습니다. 이때는 export const dynamicParams = false를 두고, generateStaticParams에서 쓰는 언어만 돌려줍니다. 그래야 다른 주소가 이 라우트에 걸리지 않습니다.
사이트맵
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { villion } from "@/lib/villion";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await villion.sitemapEntries(); // 모든 언어의 글 주소
return [
// 내 사이트의 다른 주소는 여기에 그대로 둡니다
...posts.map((p) => ({ url: p.url, lastModified: p.lastModified ?? undefined })),
];
}getSitemap()이 주는 XML은 Villion 호스팅 블로그 주소 기준입니다. 설치형 사이트에서는 sitemapEntries()를 씁니다.
캐시와 즉시 갱신
| 방법 | 쓰는 곳 | 설명 |
|---|---|---|
| 공통 캐시 | createClient({ requestInit }) | 모든 요청에 붙습니다. |
| 호출별 캐시 | 각 메서드의 마지막 인자 init | 공통 값보다 우선합니다. |
| 즉시 갱신 | Webhook | 글을 발행하면 알림을 받아 해당 화면을 새로 만듭니다. |
// 목록만 10분 캐시하고 태그를 붙이는 예
const { articles } = await villion.listArticles(
{ limit: 20 },
{ next: { revalidate: 600, tags: ["blog-index"] } },
);Webhook
Webhook 탭에 https://www.example.com/api/villion-webhook을 등록합니다. Villion은 요청 본문에 HMAC-SHA256 서명을 붙여 X-Villion-Signature 헤더로 보냅니다.
// app/api/villion-webhook/route.ts
import { revalidatePath } from "next/cache";
import { verifyWebhookSignature, type WebhookArticlePayload } from "@villion-inc/blog-sdk";
// 사이트가 쓰는 언어 접두사. 기본 언어는 접두사가 없습니다.
const PREFIXES = ["", "/en"];
export async function POST(req: Request) {
const body = await req.text();
const ok = await verifyWebhookSignature(
process.env.VILLION_WEBHOOK_SECRET!,
body,
req.headers.get("x-villion-signature") || "",
);
if (!ok) return new Response("invalid signature", { status: 401 });
const { data } = JSON.parse(body) as WebhookArticlePayload;
const slug = data.article?.slug;
for (const p of PREFIXES) {
revalidatePath(`${p}/blog`);
if (slug) revalidatePath(`${p}/blog/${slug}`);
}
revalidatePath("/sitemap.xml");
return new Response("ok");
}알림에는 아직 글 언어가 없습니다. 그래서 위 예시처럼 모든 언어 경로를 새로 만듭니다.
| 이벤트 | 보내는 때 |
|---|---|
| article.published | 발행, 변경사항 발행, 예약 발행, 검토 승인 |
| article.unpublished | 발행 취소 |
| article.deleted | 삭제 |
article.updated와 site.updated는 등록할 수 있지만 지금은 보내지 않습니다.
에러 처리
요청이 실패하면 VillionApiError를 던집니다.
import { VillionApiError } from "@villion-inc/blog-sdk";
try {
const post = await villion.getArticle(slug);
} catch (e) {
if (e instanceof VillionApiError) {
if (e.isNotFound) { /* 없는 글 */ }
if (e.isAuth) { /* 401, 403 */ }
if (e.isRateLimit) { /* 재시도를 다 쓴 레이트리밋 */ }
}
throw e;
}| 상황 | SDK 동작 |
|---|---|
| 429, 500, 502, 503, 504, 네트워크 오류 | maxRetries(기본 2)만큼 다시 시도합니다. 429는 Retry-After를 따릅니다. |
| 그 밖의 4xx | 다시 시도하지 않고 바로 던집니다. |
| 응답이 늦음 | timeoutMs(기본 10초)가 지나면 끊고 다시 시도합니다. |
| 호출한 쪽에서 취소 | 다시 시도하지 않습니다. |
없는 글을 빈 화면으로 다룰 때는 getArticleOrNull이 편합니다. 404면 null을 돌려주고, 다른 오류는 그대로 던집니다.
작성자 표시
resolveAuthor는 글 작성자가 없으면 사이트 기본 작성자를 씁니다. 둘 다 없거나 작성자 표시를 끈 사이트면 show가 false입니다.
import { resolveAuthor } from "@villion-inc/blog-sdk";
const author = resolveAuthor(post, await villion.getSiteMeta());
if (author.show) {
// author.name, author.avatarUrl, author.bio 로 표시
}Next.js Pages Router
// pages/blog/index.tsx
import { villion } from "@/lib/villion";
export async function getStaticProps() {
const { articles } = await villion.listArticles({ limit: 20 });
return { props: { articles }, revalidate: 300 };
}Pages Router에서는 lib/villion.ts의 requestInit과 cache를 빼고 씁니다.
이미지
hero_image_url과 og_image_url은 외부 도메인을 가리킬 수 있습니다. next/image로 그리려면 그 호스트를 remotePatterns에 넣어야 합니다. 넣지 않으면 실행 중에 오류가 납니다. 일반 <img>를 쓰면 설정이 필요 없습니다.
// next.config.js
module.exports = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "**.villion.io" },
// 이미지가 다른 CDN 도메인에 있으면 그 호스트도 넣습니다
],
},
};API 참조
createClient(options)
| 옵션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| siteId | string | 필수 | 사이트 UUID |
| apiKey | string | 필수 | vk_pub_... 읽기 전용 키 |
| siteUrl | string | 없음 | 내 사이트 주소. https://로 시작해야 합니다. |
| defaultLocale | string | 사이트 설정값 | 언어를 비웠을 때 쓸 언어 |
| articlePath | 함수 | defaultArticlePath | 글 주소 규칙 |
| requestInit | RequestInit | 없음 | 모든 요청에 붙일 fetch 옵션 |
| maxRetries | number | 2 | 일시 오류 재시도 횟수. 0이면 끕니다. |
| timeoutMs | number | 10000 | 요청 한 번의 제한 시간(ms) |
| baseUrl | string | https://api.villion.io | API 주소. 바꿀 일이 거의 없습니다. |
| fetch | 함수 | globalThis.fetch | 테스트나 특수 런타임용 fetch |
| userAgent | string | villion-blog-sdk/<버전> | 요청 식별용 |
메서드
init은 선택 인자이고, fetch 옵션(RequestInit)을 받습니다. Next.js의 { next: { revalidate, tags } }, cache도 여기에 넣습니다.
| 메서드 | 돌려주는 값 | 설명 |
|---|---|---|
| listArticles(params?, init?) | { articles, total } | 글 목록 한 페이지. total은 조건에 맞는 전체 글 수입니다. |
| listAllArticles(params?, opts?, init?) | BlogArticleSummary[] | 모든 페이지를 이어 받습니다. opts.pageSize 기본 100, 최대 200. |
| getArticle(slug, params?, init?) | BlogArticleDetail | 글 하나. 없으면 404 오류를 던집니다. |
| getArticleOrNull(slug, params?, init?) | BlogArticleDetail \| null | 글 하나. 없으면 null입니다. |
| getArticleUrl(article) | string | 글 주소 |
| getAlternates(article) | ArticleAlternate[] | hreflang 목록 (hreflang, url) |
| sitemapEntries(init?) | SitemapEntry[] | 모든 언어 글의 주소와 수정일 |
| getSiteMeta() | BlogSiteMeta | 사이트 정보. 한 번 받은 뒤 다시 씁니다. |
| getSitemap() | string | Villion 호스팅 기준 sitemap XML |
| getFeed() | string | Villion 호스팅 기준 RSS XML |
| listCategories(init?) | BlogCategory[] | 카테고리 목록 (2.1) |
| listCategoryArticles(slug, params?, init?) | { articles, total } | 카테고리 하나의 글. locale 규칙은 listArticles와 같습니다. 없는 카테고리는 404 (2.1) |
| getRelatedArticles(slug, params?, init?) | BlogArticleSummary[] | 관련 글. 태그가 겹치는 글 먼저, 모자라면 최신 글. 같은 언어 글만 옵니다 (2.1) |
| listComments(slug, params?, init?) | BlogComment[] | 발행된 댓글 (2.1) |
| getForm(formId, init?) | BlogForm | CMS 폼 정의. 활성 폼만 옵니다 (2.1) |
listArticles의 params는 아래 값을 받습니다.
| 값 | 설명 |
|---|---|
| limit | 한 페이지 글 수. 1~200, 기본 50 |
| offset | 건너뛸 글 수 |
| locale | 글 언어. 비우면 기본 언어, "all"이면 모든 언어 |
| tag | 이 태그가 붙은 글만. 태그 이름과 정확히 같아야 합니다. |
| sort | published_at(기본), updated_at, title |
| order | desc(기본), asc |
createPublicClient(options) (2.1)
방문자 브라우저에서 쓰는 클라이언트입니다. API 키를 쓰지 않습니다. 댓글 작성, 구독, 폼 제출은 이 클라이언트로 브라우저에서 보냅니다. Villion은 요청한 사람의 IP로 호출 한도와 스팸을 판단하는데, 사이트 서버를 거치면 모든 방문자가 서버 IP 하나로 보이기 때문입니다.
"use client";
import { createPublicClient } from "@villion-inc/blog-sdk";
const pub = createPublicClient({ siteId: process.env.NEXT_PUBLIC_VILLION_SITE_ID! });
await pub.subscribe({ email, source: "blog_banner" });사이트 ID는 공개돼도 되는 값이라 NEXT_PUBLIC_으로 브라우저에 넘겨도 됩니다. API 키는 넘기지 않습니다.
| 메서드 | 설명 |
|---|---|
| listComments(slug, params?) | 발행된 댓글 |
| createComment(slug, input, params?) | 댓글 작성. input.website는 봇 걸러내기용 숨긴 칸 값입니다 |
| subscribe(input) | 구독. 이미 구독 중이면 조용히 성공합니다 |
| getForm(formId) | CMS 폼 정의 |
| submitForm(formId, data, { articleId? }) | CMS 폼 제출 |
쓰기 요청은 중복 저장을 막으려고 재시도하지 않습니다. 댓글과 구독 폼은 사이트 정보의 comments_enabled, subscribe_enabled가 켜진 사이트에서만 그립니다(기본 꺼짐).
함수와 상수
| 이름 | 설명 |
|---|---|
| verifyWebhookSignature(secret, body, signature) | Webhook 서명 검증. Web Crypto와 Node 모두 지원 |
| resolveAuthor(article, site) | 표시할 작성자 정하기 |
| defaultArticlePath(input) | 기본 글 주소 규칙 |
| ALL_LOCALES | "all" |
| FORM_TOKEN_RE | 글 본문의 CMS 폼 자리 표시 [[form:ID]]를 찾는 정규식 (2.1) |
| SDK_VERSION | SDK 버전 문자열 |
타입은 모두 export합니다.
import type {
BlogArticleSummary, BlogArticleDetail, BlogArticleTranslation, BlogSiteMeta,
ArticleAlternate, SitemapEntry, ListArticlesParams, GetArticleParams,
ListArticlesResponse, VillionClient, VillionClientOptions, WebhookArticlePayload,
BlogCategory, BlogComment, BlogForm, BlogFormField, CreateCommentInput, SubscribeInput,
VillionPublicClient, VillionPublicClientOptions,
} from "@villion-inc/blog-sdk";v1에서 옮기기
v1 코드는 v2에서 그대로 돌지 않을 수 있습니다. 아래 표대로 고칩니다.
| v1 | v2 | 할 일 |
|---|---|---|
| listArticles()가 모든 언어 글을 줌 | 기본 언어 글만 줌 | 모든 언어가 필요하면 { locale: "all" } |
| listAllArticles()가 모든 언어 글을 줌 | 기본 언어 글만 줌 | 같음 |
| getArticle(slug, init, locale) | getArticle(slug, { locale }, init) | 인자 순서를 바꿉니다. |
| getArticleOrNull(slug, init, locale) | getArticleOrNull(slug, { locale }, init) | 같음 |
| 기본 API 주소 api-dev.villion.io | api.villion.io | 할 일 없음. 같은 서버입니다. |
| 호출마다 캐시 옵션 | requestInit 한 번 | 선택 |
| hreflang, 사이트맵을 직접 만듦 | getAlternates, sitemapEntries | 선택 |
// v1
await client.getArticle(slug, { next: { revalidate: 300 } }, "en");
// v2
await client.getArticle(slug, { locale: "en" }, { next: { revalidate: 300 } });TypeScript는 v1 순서로 부르면 빌드에서 오류를 냅니다. JavaScript는 오류 없이 언어와 캐시 옵션을 놓치니 직접 찾아 고쳐야 합니다.
자주 겪는 문제
| 증상 | 원인 | 해결 |
|---|---|---|
| 영어 글이 /en/blog에 안 보임 | 영어 목록과 글 화면이 없거나, 언어 없이 조회함 | /en/blog 라우트를 만들고 locale: "en"으로 조회합니다. |
| 영어 글 주소가 404 | getArticle(slug)는 기본 언어 글을 찾음 | getArticle(slug, { locale: "en" }) |
| 사이트맵, hreflang 주소가 /blog/...로만 나옴 | siteUrl이 없음 | createClient에 siteUrl을 넣습니다. |
| 발행했는데 화면이 그대로 | 캐시가 남아 있음 | Webhook을 등록하거나 캐시 시간을 줄입니다. |
| 429 오류가 자주 남 | 키가 없거나 틀려서 익명 한도(분당 30회)가 적용됨 | 응답 헤더 x-ratelimit-scope가 anon이면 API 키 탭에서 키를 확인합니다. |
| 몇 초 걸린 뒤 500 오류 | siteId에 UUID가 아닌 값(slug 등)을 넣음 | 블로그 설치 화면 주소의 UUID를 넣습니다. |
| next/image 오류 | 이미지 호스트가 remotePatterns에 없음 | 이미지를 봅니다. |
런타임
| 환경 | 지원 | |---|---| | Next.js App Router, Pages Router, SSG, ISR | 지원 | | Node 18 이상 | 지원 | | Edge 런타임 | 지원 | | 브라우저 | 지원. 아래 보안 안내를 봅니다. |
표준 Web API(fetch, AbortController, URLSearchParams, Headers)만 씁니다. 다른 fetch가 필요하면 createClient({ fetch })로 넣습니다.
보안
API 키(vk_pub_...)는 읽기 전용입니다. 글을 쓰거나 지울 수 없습니다. 대시보드에서 언제든 폐기할 수 있습니다.
브라우저에서 직접 부르면 키가 번들에 드러납니다. 콘텐츠가 새지는 않지만, 다른 사람이 키 사용량을 쓸 수 있습니다. 서버 컴포넌트나 라우트 핸들러에서 부르기를 권합니다.
읽기 API는 키 없이도 답합니다. 키가 틀렸거나 폐기된 경우도 오류 없이 익명 요청으로 받습니다. 대신 요청 한도가 다릅니다.
| 요청 | 분당 | 시간당 | 한도 기준 | |---|---|---|---| | 맞는 키 | 120회 | 3,000회 | 키 | | 키 없음, 틀린 키 | 30회 | 600회 | IP |
한도를 넘으면 429가 납니다. SDK는 Retry-After만큼 기다렸다 다시 시도합니다. 글이 많은 사이트를 한 번에 빌드할 때 이 한도에 걸릴 수 있으니, 운영 환경에서는 항상 apiKey를 넣어 주세요.
Webhook 시크릿(whsec_...)으로 서명을 꼭 검증하세요. 검증하지 않으면 누구나 갱신 요청을 보낼 수 있습니다.
라이선스
MIT © Villion Inc.
