PocketBase로 백엔드 빠르게 만들기: 단일 실행 파일, SQLite, 인증, 실시간 구독, 파일 업로드

이 글의 핵심

작은 서비스나 사이드 프로젝트에 Express와 데이터베이스, 관리자 화면을 따로 붙이는 일은 생각보다 손이 많이 갑니다. SQLite를 내장한 PocketBase가 이 조합을 파일 하나로 대신하는 방식과, 그만큼 수평 확장에 제약이 따르는 점을 짚어 어떤 규모의 프로젝트에 맞는지 판단하게 합니다.

이 글의 핵심

PocketBase로 백엔드를 빠르게 세우는 과정을 정리한 글입니다. 단일 파일 실행, SQLite DB, 실시간 구독, 인증, 파일 스토리지를 예제로 다룹니다.

실무에서 마주치는 문제들

서버 설정이 어려워요

작은 서비스라도 Express로 API를 만들면 DB 연결, ORM, 마이그레이션, 비밀번호 해싱, 세션·JWT 발급, 파일 업로드 처리, 이메일 인증까지 각각 라이브러리를 골라 붙여야 합니다. PocketBase는 이 기능을 Go로 작성된 실행 파일 하나에 묶어 두었고, 데이터는 같은 폴더의 SQLite 파일에 저장합니다.

Admin 패널이 필요해요

운영하다 보면 데이터를 직접 고치거나 사용자를 확인할 화면이 필요합니다. PocketBase는 컬렉션(테이블) 설계, 레코드 편집, 접근 규칙 설정, 로그 확인을 모두 웹 관리 화면에서 할 수 있습니다.

배포가 어려워요

DB 서버, 애플리케이션 서버, 파일 스토리지를 따로 띄우는 대신 PocketBase는 VPS 한 대에 실행 파일 하나를 올리고 데이터 폴더만 보존하면 됩니다.

이 단순함에는 분명한 한계가 따릅니다. SQLite는 한 파일을 한 프로세스가 다루는 구조라 PocketBase 인스턴스를 여러 대 띄워 부하를 나눌 수 없습니다. 서버 한 대의 성능이 곧 서비스의 상한이고, 그 서버가 멈추면 서비스도 멈춥니다. 또 PocketBase는 아직 1.0 이전 버전이라 마이너 버전 업데이트에서도 호환성이 깨지는 변경이 나오므로, 업그레이드 전에 변경 로그를 꼭 확인해야 합니다.


PocketBase란?

핵심 특징

PocketBase는 오픈소스 백엔드입니다. 주요 장점:

  • 단일 파일: 실행 파일 하나
  • SQLite: 내장 데이터베이스
  • Admin UI: 내장 관리 패널
  • 실시간: 실시간 구독
  • 인증: 이메일, OAuth
  • 파일 스토리지: 내장

설치 및 실행

다운로드

# macOS/Linux
wget https://github.com/pocketbase/pocketbase/releases/download/v0.20.0/pocketbase_0.20.0_linux_amd64.zip
unzip pocketbase_0.20.0_linux_amd64.zip
# 실행
./pocketbase serve

예제의 v0.20.0은 작성 당시 버전일 뿐이므로 GitHub 릴리스 페이지에서 최신 버전과 운영체제·CPU에 맞는 파일을 받습니다. Apple Silicon Mac이라면 darwin_arm64, 일반 Linux 서버라면 linux_amd64입니다. serve를 실행하면 같은 디렉토리에 pb_data 폴더가 생기고, 여기에 SQLite 데이터 파일과 업로드된 파일이 모두 저장됩니다. 즉 이 폴더가 곧 서비스의 전체 데이터이므로 백업과 배포 설계는 이 폴더를 중심으로 생각하면 됩니다.

Admin UI

http://localhost:8090/_/

처음 접속하면 관리자 계정을 만들라는 화면이 나옵니다. v0.23부터는 관리자(admin)가 _superusers라는 시스템 컬렉션으로 바뀌었고, 서버 로그에 출력되는 설치 링크나 ./pocketbase superuser create 명령으로 첫 계정을 만듭니다. 관리 화면은 인터넷에 그대로 노출되므로, 운영 서버에서는 강한 비밀번호를 쓰고 가능하면 리버스 프록시에서 /_/ 경로 접근을 IP로 제한하는 편이 안전합니다.

컬렉션을 만들 때 가장 중요한 설정은 API Rules입니다. 목록 조회, 단건 조회, 생성, 수정, 삭제 각각에 규칙을 적는데, 기본값은 “관리자만 허용”(잠김)입니다. 규칙을 빈 문자열로 두면 누구나 접근할 수 있게 되고, @request.auth.id != ""는 로그인한 사용자만, author = @request.auth.id는 작성자 본인만 허용합니다. 처음 쓸 때 SDK 호출이 403 Only superusers can perform this action으로 실패한다면 대부분 이 규칙이 잠김 상태로 남아 있기 때문입니다.


JavaScript SDK

설치

npm install pocketbase

초기화

// lib/pocketbase.ts
import PocketBase from 'pocketbase';
export const pb = new PocketBase('http://localhost:8090');

SDK 인스턴스는 로그인 토큰을 authStore에 보관하고, 브라우저에서는 기본적으로 localStorage에 저장합니다. 그래서 브라우저에서는 이렇게 모듈 하나에서 만든 인스턴스를 공유하는 것이 맞습니다. 반면 Next.js 서버 컴포넌트나 API Route처럼 서버에서 쓸 때 모듈 전역 인스턴스를 공유하면, 한 사용자의 로그인 상태가 다른 사용자의 요청에 섞이는 심각한 문제가 생깁니다. 서버에서는 요청마다 새 인스턴스를 만들고 쿠키에서 토큰을 읽어 넣어야 합니다.


CRUD

Create

// 변수 선언 및 초기화
const record = await pb.collection('posts').create({
  title: 'My First Post',
  content: 'Hello PocketBase!',
  author: userId,
});

Read

// 전체 조회
const records = await pb.collection('posts').getFullList();
// 단일 조회
const record = await pb.collection('posts').getOne(recordId);
// 필터링
const records = await pb.collection('posts').getList(1, 20, {
  filter: 'published = true',
  sort: '-created',
});

getFullList()는 내부적으로 페이지를 여러 번 요청해 모든 레코드를 가져오므로, 레코드가 많은 컬렉션에서는 응답이 느리고 메모리도 많이 씁니다. 화면에 목록을 보여줄 때는 getList(page, perPage)로 페이지 단위로 가져오는 것이 기본입니다. 필터 문법은 SQL과 비슷하지만 PocketBase 전용이며, 사용자 입력을 문자열로 이어 붙이면 필터 인젝션이 가능하므로 pb.filter('title ~ {:q}', { q: keyword })처럼 파라미터 바인딩을 쓰는 것이 안전합니다. 관계 필드를 함께 가져오려면 expand: 'author' 옵션을 넘기면 결과의 expand.author에 연결된 레코드가 담깁니다.

sort: '-created'처럼 자동 생성 필드를 쓸 때는 버전을 확인해야 합니다. v0.23부터는 새로 만든 컬렉션에 created/updated 필드가 자동으로 붙지 않고 autodate 필드로 직접 추가해야 해서, 필드가 없는 컬렉션에서 이 정렬을 쓰면 에러가 납니다.

Update

const record = await pb.collection('posts').update(recordId, {
  title: 'Updated Title',
});

Delete

await pb.collection('posts').delete(recordId);

update는 넘긴 필드만 바꾸는 부분 수정입니다. 수정과 삭제가 허용되는지는 앞에서 말한 API Rules가 서버에서 판단하므로, 클라이언트 코드에서 “내 글일 때만 버튼을 보여준다” 같은 처리는 편의 기능일 뿐 보안 장치가 아닙니다. 다른 레코드가 관계 필드로 참조하는 레코드를 지우면, 관계 필드의 “cascade delete” 설정에 따라 함께 삭제되거나 삭제가 거부됩니다.


인증

회원가입

// 변수 선언 및 초기화
const record = await pb.collection('users').create({
  email: '[email protected]',
  password: 'password123',
  passwordConfirm: 'password123',
  name: 'John',
});

로그인

// 변수 선언 및 초기화
const authData = await pb.collection('users').authWithPassword(
  '[email protected]',
  'password123'
);
console.log(pb.authStore.token);
console.log(pb.authStore.model);

users는 PocketBase가 기본으로 만들어 두는 auth 컬렉션입니다. auth 컬렉션은 이메일·비밀번호 필드와 인증 API를 자동으로 갖고 있어 일반 컬렉션처럼 create로 가입시키고 authWithPassword로 로그인시킵니다. 로그인에 성공하면 토큰과 사용자 레코드가 authStore에 저장되고, 이후 모든 SDK 요청에 Authorization 헤더로 자동으로 붙습니다. 최신 SDK에서는 authStore.model 대신 authStore.record를 쓰도록 이름이 바뀌었습니다.

가입 직후 로그인이 되지 않는다면 컬렉션 설정에서 “이메일 인증 필요” 옵션이 켜져 있는지 확인합니다. 인증 메일을 보내려면 관리 화면의 Settings에서 SMTP 서버를 설정해야 하며, 설정하지 않으면 메일 발송이 조용히 실패합니다.

OAuth

OAuth를 쓰려면 먼저 관리 화면에서 users 컬렉션의 OAuth2 공급자(Google, GitHub 등)를 켜고 클라이언트 ID와 시크릿을 넣습니다. Google 콘솔에 등록할 리디렉션 URL은 https://<내 도메인>/api/oauth2-redirect입니다.

const authData = await pb.collection('users').authWithOAuth2({
  provider: 'google',
});

authWithOAuth2는 팝업 창을 열어 로그인을 진행하고, 실시간 연결로 결과를 받아 창을 닫습니다. 팝업 방식이라 브라우저가 팝업을 막으면 아무 일도 일어나지 않는데, 특히 Safari는 사용자 클릭 이벤트와 팝업 열기 사이에 await가 끼어 있으면 팝업을 차단합니다. 버튼 클릭 핸들러에서 다른 비동기 작업 없이 바로 이 함수를 호출하는 것이 좋습니다.

세션 관리

// 현재 사용자
const user = pb.authStore.model;
// 로그아웃
pb.authStore.clear();
// 세션 변경 감지
pb.authStore.onChange((token, model) => {
  console.log('Auth changed:', token, model);
});

authStore.clear()는 클라이언트에 저장된 토큰을 지울 뿐이고 서버 쪽 토큰을 무효화하지는 않습니다. PocketBase 토큰은 상태 없는 JWT라 만료 시간(컬렉션 설정에서 지정)까지는 유효하므로, 토큰이 유출되었다면 해당 사용자의 비밀번호를 바꾸거나 컬렉션의 토큰 키를 재발급해야 무효화됩니다. 로그인 상태를 오래 유지하려면 만료 전에 pb.collection('users').authRefresh()를 호출해 토큰을 갱신합니다. pb.authStore.isValid는 토큰의 만료 시간만 확인하므로, 삭제된 사용자인지까지 알고 싶다면 authRefresh로 서버에 확인해야 합니다.


실시간 구독

// 구독
pb.collection('posts').subscribe('*', (e) => {
  console.log('Event:', e.action, e.record);
});
// 특정 레코드 구독
pb.collection('posts').subscribe(recordId, (e) => {
  console.log('Record updated:', e.record);
});
// 구독 해제
pb.collection('posts').unsubscribe();

PocketBase 실시간 기능은 WebSocket이 아니라 Server-Sent Events(SSE)로 동작합니다. 서버에서 클라이언트 방향으로만 이벤트가 흐르고, 구독 등록은 일반 HTTP 요청으로 처리됩니다. e.action은 create, update, delete 중 하나이고 e.record는 변경된 레코드입니다. 구독에도 API Rules가 적용되어, 목록·조회 규칙상 볼 수 없는 레코드의 이벤트는 받지 못합니다. “구독은 되는데 이벤트가 안 온다”면 규칙부터 확인합니다.

인자 없는 unsubscribe()는 해당 컬렉션의 모든 구독을 한꺼번에 해제합니다. 여러 컴포넌트가 같은 컬렉션을 구독하는 경우 한 컴포넌트가 정리하면서 다른 컴포넌트의 구독까지 끊어 버리므로, subscribe()가 반환하는 해제 함수를 받아 두었다가 그 구독만 해제하는 방식이 안전합니다. 리버스 프록시 뒤에서 SSE가 끊기거나 이벤트가 몰려서 늦게 도착한다면, Nginx의 proxy_buffering off;와 충분히 긴 proxy_read_timeout 설정이 빠진 경우가 많습니다.

React 예제

'use client';
import { useEffect, useState } from 'react';
import { pb } from '@/lib/pocketbase';
export default function Posts() {
  const [posts, setPosts] = useState([]);
  useEffect(() => {
    fetchPosts();
    pb.collection('posts').subscribe('*', () => {
      fetchPosts();
    });
    return () => {
      pb.collection('posts').unsubscribe();
    };
  }, []);
  async function fetchPosts() {
    const records = await pb.collection('posts').getFullList();
    setPosts(records);
  }
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

이 예제는 이벤트가 올 때마다 목록 전체를 다시 불러옵니다. 구현이 단순하고 정렬·필터 결과가 항상 서버와 같다는 장점이 있지만, 레코드가 많거나 변경이 잦으면 요청이 크게 늘어납니다. 규모가 커지면 e.action과 e.record로 상태 배열을 직접 갱신하는 방식으로 바꾸는 편이 좋습니다.

React에서 PocketBase를 쓸 때 처음 흔히 겪는 문제는 ClientResponseError 0: The request was autocancelled 에러입니다. JS SDK는 같은 컬렉션에 대한 같은 종류의 요청이 진행 중일 때 새 요청이 오면 이전 요청을 자동으로 취소하는데, 개발 모드의 StrictMode가 effect를 두 번 실행하면서 첫 번째 getFullList가 취소되는 것입니다. 요청 옵션에 { requestKey: null }을 넘기거나 pb.autoCancellation(false)로 이 기능을 끄면 해결됩니다. 같은 이유로 StrictMode에서는 구독도 두 번 등록되므로, 정리 함수에서 구독을 해제하는 코드가 꼭 필요합니다.


파일 업로드

파일 필드가 있는 레코드는 JSON이 아니라 FormData로 보내야 합니다. 파일 필드의 최대 크기와 허용 MIME 타입은 컬렉션의 필드 설정에서 지정하며, 제한을 넘으면 400 응답과 함께 필드별 에러가 돌아옵니다.

const formData = new FormData();
formData.append('title', 'Post with Image');
formData.append('image', file);
const record = await pb.collection('posts').create(formData);
// 파일 URL
const url = pb.files.getUrl(record, record.image);

레코드에 저장되는 값은 파일 자체가 아니라 PocketBase가 붙인 고유 파일명(원래 이름 뒤에 임의 문자열이 붙은 형태)이고, 실제 URL은 getUrl(최신 SDK에서는 getURL)로 만듭니다. { thumb: '100x100' } 옵션을 넘기면 필드 설정에 정의해 둔 크기의 썸네일을 받을 수 있습니다. 파일 필드를 “Protected”로 설정하면 URL만으로는 접근할 수 없고 pb.files.getToken()으로 받은 짧은 수명의 토큰을 붙여야 하므로, 개인 문서처럼 민감한 파일은 이 설정을 켜야 합니다. 파일은 기본적으로 pb_data/storage에 저장되며, 관리 화면에서 S3 호환 스토리지로 바꿀 수 있습니다.


배포

Docker

FROM alpine:latest
RUN apk add --no-cache ca-certificates
COPY pocketbase /usr/local/bin/pocketbase
EXPOSE 8090
CMD ["/usr/local/bin/pocketbase", "serve", "--http=0.0.0.0:8090"]

이 Dockerfile에서 가장 중요한 것은 적혀 있지 않은 부분입니다. pb_data를 볼륨으로 마운트하지 않으면 데이터가 컨테이너 내부에 저장되어, 컨테이너를 다시 만드는 순간 모든 데이터가 사라집니다. 이미지 업데이트를 위해 docker compose up -d --build를 한 번 실행했을 뿐인데 사용자와 게시글이 전부 없어졌다는 사고가 PocketBase를 Docker로 처음 배포할 때 가장 흔합니다. 실행할 때 --dir=/pb_data 옵션으로 데이터 경로를 지정하고 -v /srv/pb_data:/pb_data처럼 호스트 경로를 마운트해야 합니다.

--http=0.0.0.0:8090은 컨테이너 밖에서 접속할 수 있도록 모든 인터페이스에 바인딩하는 설정입니다. 기본값인 127.0.0.1로 두면 컨테이너 내부에서만 접속되어 포트를 열어도 연결이 안 됩니다. 운영 환경에서는 앞에 Nginx나 Caddy를 두어 HTTPS를 처리하거나, serve yourdomain.com처럼 도메인을 넘겨 PocketBase가 Let’s Encrypt 인증서를 직접 발급받게 할 수도 있습니다.

백업은 pb_data 폴더를 복사하면 되지만, 쓰기가 일어나는 도중에 SQLite 파일을 그냥 복사하면 WAL 파일과 내용이 어긋난 손상된 백업이 될 수 있습니다. 관리 화면의 Backups 기능이나 sqlite3 data.db ".backup backup.db"처럼 일관성을 보장하는 방법을 쓰는 것이 좋습니다.


정리 및 체크리스트

핵심 요약

  • PocketBase: 오픈소스 백엔드
  • 단일 파일: 실행 파일 하나
  • SQLite: 내장 데이터베이스
  • Admin UI: 내장 관리 패널
  • 실시간: 실시간 구독
  • 인증: 이메일, OAuth

구현 체크리스트

  • PocketBase 다운로드
  • 서버 실행
  • Collection 생성
  • CRUD 구현
  • 인증 구현
  • 실시간 구독 구현
  • 배포

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Supabase와 비교하면 어떤가요?

A. PocketBase는 실행 파일 하나라 셀프 호스팅이 훨씬 간단합니다. Supabase는 PostgreSQL, 인증 서버, 스토리지, 실시간 서버 등 여러 서비스로 구성되어 직접 호스팅하기는 무겁지만, SQL과 PostgreSQL 확장, 읽기 복제본 같은 확장 수단을 그대로 쓸 수 있습니다.

Q. PocketBase를 운영 환경에 쓸 때 주의할 점은?

A. 단일 서버로 감당할 수 있는 규모라면 쓸 수 있습니다. 다만 1.0 이전이라 업그레이드 시 호환성 변경이 있을 수 있으므로 버전을 고정하고, 업그레이드 전 백업과 변경 로그 확인을 습관화해야 합니다.

Q. 무료인가요?

A. MIT 라이선스 오픈소스라 무료입니다. 서버 비용은 직접 부담합니다.

Q. 확장성은 어떤가요?

A. SQLite의 WAL 모드 덕분에 읽기 동시성은 좋은 편이지만, 쓰기는 한 번에 하나씩 처리되고 인스턴스를 여러 대로 늘릴 수 없습니다. 쓰기가 많거나 고가용성이 필요한 서비스라면 PostgreSQL 기반 백엔드를 검토하는 것이 맞습니다. 백엔드 로직이 필요하면 PocketBase를 Go 라이브러리로 가져와 훅을 작성하거나, pb_hooks 폴더에 JavaScript 훅을 둘 수 있습니다.