01 · 빠른 시작
제어판에서 posts 컬렉션을 만들고 읽기를 공개로 바꾸세요. 제목이 있는 문서를 하나 저장한 뒤 페이지에 아래 코드를 넣으세요.
{
"title": "첫 번째 글"
}<ul id="posts"></ul>
<script type="module">
import { createNaru } from "https://naru.pub/sdk/1/naru.js";
const naru = createNaru();
const { documents } = await naru.collection("posts").list();
for (const post of documents) {
const item = document.createElement("li");
item.textContent = post.data.title;
document.querySelector("#posts").append(item);
}
</script>방명록에 인사 남기기
제어판에서 guestbook 컬렉션을 만들고 읽기는 ‘누구나’, 쓰기는 ‘누구나 생성만’으로 설정하세요.
<button id="hello">인사 남기기</button>
<p id="result"></p>
<script type="module">
import { createNaru } from "https://naru.pub/sdk/1/naru.js";
const naru = createNaru();
const button = document.querySelector("#hello");
const result = document.querySelector("#result");
button.onclick = async () => {
button.disabled = true;
try {
const saved = await naru.collection("guestbook").add({
name: "방문자", message: "안녕하세요!",
});
result.textContent = saved.data.message + " 저장됨";
} catch (error) {
result.textContent = "결과를 확인하지 못했습니다. 방명록을 확인하세요.";
console.error(error);
}
};
</script>02 · 컬렉션과 공개 범위
컬렉션은 문서를 모으는 곳이고, 문서는 ID 하나와 JSON 데이터 하나입니다. 이름과 문서 ID는 영문·숫자·_·- 1~64자입니다.
| 설정 | 방문자가 할 수 있는 일 | |
|---|---|---|
| 읽기 | 관리자만 | 방문자는 읽을 수 없습니다. |
| 읽기 | 누구나 | get·list·count·pages로 누구나 읽습니다. |
| 쓰기 | 관리자만 | 방문자는 쓸 수 없습니다. |
| 쓰기 | 누구나 생성만 | add로 새 문서만 만듭니다. 방명록에 알맞습니다. |
| 쓰기 | 누구나 생성·덮어쓰기·삭제 | 누구나 모든 문서를 바꾸고 지울 수 있습니다. 대부분은 필요 없습니다. |
관리자는 로그인하면 등록한 컬렉션을 공개 범위와 상관없이 읽고 쓸 수 있습니다. 공개 읽기 컬렉션의 문서는 누구나 읽을 수 있으니, 비공개로 둘 데이터는 ‘관리자만’ 읽는 컬렉션에 따로 저장하세요.
03 · 관리자 로그인
나루 홈페이지의 소유자가 로그인하면 홈페이지에 로그인할 수 있습니다.
- 제어판의 ‘웹사이트 관리자 로그인’에 관리자 페이지 주소(예:
https://example.naru.pub/)와 쓸 컬렉션을 등록합니다. naru.auth.signIn()으로 로그인하고naru.auth.session()으로 관리자 클라이언트를 받습니다.
<button id="login">관리자 로그인</button>
<p id="auth-status"></p>
<script type="module">
import { createNaru } from "https://naru.pub/sdk/1/naru.js";
const naru = createNaru();
const admin = await naru.auth.session();
const login = document.querySelector("#login");
login.hidden = !!admin;
document.querySelector("#auth-status").textContent =
admin ? "로그인되었습니다." : "글을 고치려면 로그인하세요.";
login.onclick = () => naru.auth.signIn({ collections: ["posts", "drafts"] });
// 다음 예제의 owner는 이 스크립트 안에서 사용하세요.
</script>04 · 글 수정과 충돌 처리
| 호출 | 동작 |
|---|---|
add(data) | 새 ID로 문서를 만듭니다. |
set(id, data, { condition }) | 그 ID의 문서를 만들거나 통째로 바꿉니다. 필드를 합치지 않습니다. |
delete(id, { condition }) | 문서를 지웁니다. 없는 문서를 지워도 성공합니다. |
condition으로 덮어쓰기를 막을 수 있습니다. { revision }은 읽은 뒤 바뀌지 않았을 때만, { absent: true }는 아직 없는 문서일 때만 저장하고, 아니면 CONFLICT로 실패합니다.
const post = await admin.collection("posts").get("hello");
try {
await admin.collection("posts").set(
"hello",
{ ...post.data, title: "새 제목" },
{ condition: { revision: post.revision } },
);
} catch (error) {
if (error.code !== "CONFLICT") throw error;
alert("다른 곳에서 먼저 저장했습니다. 새로고침 후 다시 시도하세요.");
}데이터에는 JSON 값만 넣으세요.
여러 변경을 함께 저장하기
admin.batch()은 여러 컬렉션의 set·delete를 최대 100개까지 묶어, 원자적으로 반영합니다. 각 변경의 condition도 함께 검사합니다. 미리 읽은 값이 저절로 보호되는 것은 아니므로 고치는 문서의 revision을 넘기세요. 결과는 쓴 순서대로, set은 { id, revision, createdAt, updatedAt }, delete는 null입니다.
const [saved] = await admin.batch([
{ collection: "posts", set: { id, data: post, condition: { absent: true } } },
{ collection: "drafts", delete: { id, condition: { revision: draft.revision } } },
]);
// saved.revision → 다음 저장의 condition05 · 목록과 페이지 나누기
const posts = naru.collection("posts");
const post = await posts.get("hello");
// → { id, data, revision, createdAt, updatedAt }
const query = {
filter: { category: "일상", date: { gte: "2026-09-01" } },
sort: [["date", "desc"]],
};
const page = await posts.list({ ...query, size: 20 });
// → { documents, nextCursor }
if (page.nextCursor) {
const next = await posts.list({ ...query, size: 20, after: page.nextCursor });
console.log(next.documents);
}
// 조건에 맞는 문서 수
const total = await posts.count({ filter: query.filter });
// 끝까지 한 쪽씩 (nextCursor를 대신 따라갑니다)
for await (const { documents } of posts.pages({ ...query, size: 100 })) {
console.log(documents);
}- filter: 최상위 필드를 값으로 비교하거나
{ gt, gte, lt, lte }로 범위를 찾습니다. 조건은 모두 만족해야 하며(AND) 최대 5개입니다. 날짜는"2026-09-01"처럼 자리를 채운 문자열로 저장하면 범위로 찾을 수 있습니다. - sort:
[필드, 방향]을 한두 개 넘깁니다. 생성·수정 시각은{ metadata: "createdAt" }처럼 씁니다. 기본은 ID 오름차순입니다. 같은 날짜에서 최신 글을 먼저 보려면[["date", "desc"], [{ metadata: "createdAt" }, "desc"]]처럼 두 키를 씁니다. 문자열은 언어별 정렬 없이 유니코드 순서로, 숫자는 크기로 비교합니다. 오름차순에서 없는 값·null·배열·객체, 문자열, 숫자, 불리언 순입니다. - size: 한 번에 기본 50개, 최대 100개입니다. 다음 페이지는 같은 filter·sort에
nextCursor를after로 넘기고,nextCursor가null이면 끝입니다.includeTotal: true면 전체 개수도 받습니다. 개수만 필요하면count()를, 모든 쪽이 필요하면pages()를 쓰세요. 누구나 쓸 수 있는 컬렉션은 끝까지 읽지 말고 필요한 만큼만 받으세요.
06 · 사용 예
블로그 글 공개
posts · 읽기 누구나 / 쓰기 관리자만
naru
.collection("posts")
.list({
sort: [["date", "desc"]],
});방명록
guestbook · 읽기 누구나 / 쓰기 누구나 생성만
naru.collection("guestbook")
.add({ name, message });비공개 문의 받기
inquiries · 읽기 관리자만 / 쓰기 누구나 생성만
naru.collection("inquiries")
.add({ email, message });초안 저장 후 공개
drafts · 읽기 관리자만 / 쓰기 관리자만
admin.batch([
{ collection: "posts",
set: { id, data, condition: { absent: true } } },
{ collection: "drafts",
delete: { id, condition: { revision: draft.revision } } },
]);글에 이미지 붙이기
미디어 라이브러리 · 관리자 업로드
const image =
await admin.media.upload(file);
const posts = admin.collection("posts");
const post = await posts.get(id);
await posts.set(id, { ...post.data, cover: image.url },
{ condition: { revision: post.revision } });특정 글의 댓글
comments · 읽기 누구나 / 쓰기 누구나 생성만
naru
.collection("comments")
.list({
filter: { postId: "hello" },
});예제 블로그 ‘작은 기록’
글 목록·상세·방명록·관리자 편집·비공개 초안을 갖춘 정적 사이트입니다. ZIP 내려받기 · 실제 사이트 보기 ↗
- 제어판에서
posts,guestbook,drafts컬렉션을 위 설정대로 만듭니다. - ‘웹사이트 관리자 로그인’에 올릴 위치의
admin/주소와posts·drafts를 등록합니다. - 연결한 도메인에 올린다면
config.js의site에 로그인 이름을 적습니다. - 파일을 새 폴더에 올리고
index.html을 열어 글을 써 봅니다.
07 · 한도와 오류
- 사이트당 컬렉션 100개, 문서 10,000개, 데이터 10 MiB. 요청 하나는 64 KiB까지입니다.
- 방문자의
add는 분당 횟수가 제한됩니다. - 관리자 로그인 페이지는 사이트당 20개까지 등록합니다.
실패하면 NaruError를 던집니다. code로 구분하세요.
| code | 뜻 |
|---|---|
NOT_FOUND | 문서, 컬렉션 또는 사이트가 없습니다. |
ACCESS_DENIED | 공개 범위나 등록된 컬렉션이 허용하지 않습니다. |
AUTH_REQUIRED | 관리자 권한이 만료되었습니다. 다시 로그인하세요. |
CONFLICT | condition이 맞지 않습니다. |
QUOTA_EXCEEDED | 데이터나 미디어 저장 공간이 가득 찼습니다. |
RATE_LIMITED | 요청이 너무 많습니다. 잠시 후 다시 시도하세요. |
INVALID_REQUEST | 이름, 필터, 크기 등 요청 형식이 잘못되었습니다. |
UNAVAILABLE | 일시적인 오류입니다. |
try {
await naru.collection("guestbook").add({ message });
} catch (error) {
status.textContent =
error.code === "RATE_LIMITED" ? "잠시 후 다시 시도하세요." : error.message;
}