데이터베이스 연결하기

앱 전용 Postgres DB를 켜고, 코드에서 읽고 쓰고, 콘솔에서 표로 확인하는 방법이에요. 템플릿 앱은 이미 켜져 있어요.

주문 목록이나 회원 정보처럼 앱이 다루는 데이터는 앱 전용 데이터베이스에 저장해요.
템플릿으로 만든 앱은 이미 켜져 있어서, 바로 표를 만들고 읽고 쓰면 돼요.

만들어지는 건 표준 Postgres예요. 그래서 postgres · pg · Prisma · Drizzle 등 평소 쓰던 도구를 그대로 써요.

템플릿으로 만든 앱이라면

Next.js · Astro 템플릿은 아래가 이미 되어 있어요.

준비된 것내용
axhub.yaml 선언database: engine: postgres가 들어 있어요
접속 코드lib/db.ts의 db()가 접속을 맡아요
표 만들기같은 파일의 ensureSchema()에 CREATE TABLE을 적어요
읽고 쓰기
import { db, ensureSchema } from '@/lib/db';

await ensureSchema();
const rows = await db()`SELECT * FROM todos WHERE user_key = ${userKey}`;

값을 ${...}로 넘기면 알아서 안전하게 처리돼요 — 문자열을 직접 이어 붙이지 마세요.

표를 새로 만들거나 컬럼을 늘리려면 ensureSchema() 안의 CREATE TABLE IF NOT EXISTS 블록을 늘리면 돼요. 평범한 SQL이에요.

내 컴퓨터에서 개발할 때

템플릿에 Postgres를 띄우는 명령이 들어 있어요.

npm run db:up      # 로컬 Postgres 시작
npm run db:reset   # 데이터까지 지우고 새로 시작

.env.local의 DATABASE_URL이 이 로컬 DB를 가리켜요. 배포하면 AxHub가 넣어 주는 값으로 바뀌고, 코드는 그대로예요.

직접 켜는 경우

템플릿을 안 썼다면 axhub.yaml에 두 줄을 넣고 배포해요.

axhub.yaml
database:
  engine: postgres

engine에는 현재 postgres만 넣을 수 있어요. 앱 전용 DB를 켜는 방법은 이 선언 하나예요.

다음 배포에서 앱 전용 DB가 만들어지고, 접속 정보가 환경변수로 들어와요.

환경변수용도
DATABASE_URL평소 데이터를 읽고 쓸 때 쓰는 주소예요. 접속을 모아서 관리해 주는 중계기(커넥션 풀러)를 트랜잭션 단위로 거쳐요
DIRECT_DATABASE_URL표 구조를 바꾸는 작업(마이그레이션)처럼 세션 잠금이 필요한 일에 써요. 중계기를 세션 단위로 거쳐요

두 주소 모두 sslmode=disable 이 붙어 있어요.

pg 드라이버
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const { rows } = await pool.query('SELECT * FROM orders WHERE done = $1', [false]);
Prisma (schema.prisma)
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")        // 쿼리
  directUrl = env("DIRECT_DATABASE_URL") // 마이그레이션
}

prepare: false가 필요할 수 있어요. DATABASE_URL이 거치는 중계기는 prepared statement를 지원하지 않아서, postgres 같은 드라이버는 이 옵션을 꺼야 동작해요. 로컬에서는 잘 되다가 배포 후에만 실패한다면 이걸 확인하세요.

postgres(process.env.DATABASE_URL, { prepare: false })

production(실제 서비스)과 staging(시험용)은 서로 다른 DB를 받아요. 각 환경에 배포하면 그 환경의 DB가 만들어지고, 서로의 데이터가 섞이지 않아요. 스테이징을 쓰는 앱이라면 스테이징에 배포할 때 스테이징 DB가, 운영에 반영(promote)할 때 운영 DB가 만들어져요.

확장이 필요할 때

벡터 검색(pgvector)이나 비슷한 글자 찾기(pg_trgm)처럼 Postgres 확장이 필요하면 axhub.yaml에 한 줄 더 적어요. 앱은 확장을 직접 설치할 권한이 없어서, AxHub가 배포할 때 대신 설치해요.

axhub.yaml
database:
  engine: postgres
  extensions: [pgvector, pg_trgm]

다음 배포부터 앱이 새 버전으로 뜨기 전에 확장이 준비돼요. 운영과 스테이징 DB 각각에 들어가고, 이미 쓰던 DB에 나중에 추가해도 데이터는 그대로예요.

적을 수 있는 이름은 이 13개예요 — pgvector(vector로 적어도 같아요) · pg_trgm · citext · unaccent · fuzzystrmatch · uuid-ossp · pgcrypto · hstore · ltree · intarray · tablefunc · btree_gin · btree_gist. 목록 밖 이름을 적으면 배포가 시작 단계에서 실패하고, 실패 사유에 가능한 값이 같이 보여요.

마이그레이션 코드는 안 고쳐도 돼요. CREATE EXTENSION IF NOT EXISTS vector 같은 구문이 이미 있다면, 확장이 먼저 설치돼 있어서 그대로 통과해요.

DB 계정을 나눠 쓸 때

앱 DB 계정은 기본으로 하나예요. 이 계정이 표를 만든 주인이라 무엇이든 읽고 쓸 수 있어요. 대부분의 앱은 이걸로 충분하지만, 이런 앱은 계정을 나눠야 해요.

  • 로그인 정보를 따로 지키고 싶을 때 — 비밀번호 해시나 세션 표를 로그인 코드만 읽게 하기
  • 한 앱이 바깥 고객 여럿을 받을 때 — Postgres RLS(행 단위 보안)로 고객사마다 자기 행만 보이게 하기. 표 주인 계정은 RLS를 받지 않아서, 계정이 하나면 격리가 성립하지 않아요
AI 에이전트용 요약
  • 쓰는 때: 앱이 바깥 고객(회사·조직) 여럿의 데이터를 한 DB에 담는다, 또는 앱이 자체 회원·비밀번호 표를 가진다. 둘 다 아니면 쓰지 않는다(axhub 로그인만 쓰는 앱은 필요 없다).
  • 선언: database.roles에 용도별 이름(예: runtime, auth)을 적는다. FORCE RLS를 걸 거면 owner_bypass_rls: true도 적는다.
  • 연결: 마이그레이션은 DIRECT_DATABASE_URL(기존 계정), 일반 요청은 DATABASE_URL_RUNTIME, 로그인은 DATABASE_URL_AUTH.
  • 권한: 마이그레이션에서 current_database() || '_<이름>' 계정에 GRANT 하고 RLS 정책을 만든다. 플랫폼은 권한을 주지 않는다.
  • 고객사 지정: 요청마다 트랜잭션 안에서 SET LOCAL 또는 set_config(…, true). 그냥 SET 금지.
  • 확인: 배포 뒤 runtime 계정으로 다른 고객사 행·인증 표를 읽어 보면 막혀야 한다.

axhub.yaml에 계정 이름을 적으면 AxHub가 계정을 만들어 주고 접속 주소를 넣어 줘요.

axhub.yaml
database:
  engine: postgres
  roles: [runtime, auth]      # 추가 계정 (최대 3개)
  owner_bypass_rls: true      # 선택 — FORCE RLS를 쓸 때

배포하면 기존 주소에 더해 계정마다 두 주소가 들어와요.

환경변수계정쓰는 곳
DATABASE_URL · DIRECT_DATABASE_URL기존 계정 (표 주인)마이그레이션 · 관리 작업 — 지금과 같아요
DATABASE_URL_RUNTIME · DIRECT_DATABASE_URL_RUNTIMEruntime일반 API 요청
DATABASE_URL_AUTH · DIRECT_DATABASE_URL_AUTHauth로그인 · 세션 처리

DATABASE_URL_<이름>은 중계기를 트랜잭션 단위로, DIRECT_DATABASE_URL_<이름>은 세션 단위로 거쳐요 — 기존 두 주소와 같은 규칙이에요.

권한은 마이그레이션에서 줘요

새 계정은 처음엔 접속만 되고 표는 하나도 못 읽어요. 어느 표를 누가 볼지는 앱이 정해요. 기존 계정(표 주인)으로 도는 마이그레이션에 이렇게 적어요.

계정 이름은 <DB 이름>_<적은 이름>이에요. 운영과 스테이징의 DB 이름이 달라서, 이름을 직접 적지 말고 current_database()로 만들어요.

migration.sql
DO $$
DECLARE
  runtime_role text := current_database() || '_runtime';
  auth_role    text := current_database() || '_auth';
BEGIN
  EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON deals TO %I', runtime_role);
  EXECUTE format('GRANT SELECT, INSERT, UPDATE ON users, sessions TO %I', auth_role);
  -- 앞으로 만들 표에도 같은 권한을 주려면
  EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO %I', runtime_role);
END $$;

-- 고객사마다 자기 행만 보이게
ALTER TABLE deals ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_only ON deals
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

앱은 일반 요청을 DATABASE_URL_RUNTIME으로, 로그인을 DATABASE_URL_AUTH로 보내요. 그러면 runtime 계정은 그 고객사 행만 보고 users 표는 읽지 못해요.

고객사는 트랜잭션 안에서 SET LOCAL로 알려 주세요. DATABASE_URL_<이름>은 중계기를 트랜잭션 단위로 거쳐서, 그냥 SET으로 정한 값은 다음 쿼리에서 다른 연결로 넘어가 사라지거나 다른 요청에 남을 수 있어요.

BEGIN;
SET LOCAL app.tenant_id = '…';   -- 또는 SELECT set_config('app.tenant_id', '…', true);
SELECT * FROM deals;              -- 그 고객사 행만 나와요
COMMIT;

Prisma로 쓸 때

마이그레이션은 지금처럼 DIRECT_DATABASE_URL(기존 계정)로 돌고, 앱 코드는 용도별로 클라이언트를 따로 만들어요.

lib/db.ts
import { PrismaClient } from '@prisma/client';

export const db = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL_RUNTIME });
export const authDb = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL_AUTH });

// 고객사 범위 쿼리 — set_config 의 세 번째 인자 true 가 SET LOCAL 과 같아요
export function forTenant<T>(tenantId: string, run: (tx: typeof db) => Promise<T>) {
  return db.$transaction(async (tx) => {
    await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
    return run(tx as typeof db);
  });
}

권한·RLS SQL은 npx prisma migrate dev --create-only로 빈 마이그레이션을 만든 뒤 migration.sql에 위 예시를 넣어요.

FORCE RLS를 쓸 때 — owner_bypass_rls

표 주인(기존 계정)은 원래 RLS를 받지 않아요. 그래서 실수로 일반 요청을 DATABASE_URL로 보내면 격리가 통째로 무시돼요. 이 실수까지 막으려면 표에 FORCE ROW LEVEL SECURITY를 걸어 표 주인도 RLS를 받게 해요.

그런데 그러면 마이그레이션, 표 주인 권한으로 도는 SECURITY DEFINER 함수, AxHub의 DB 백업까지 RLS에 걸려요. owner_bypass_rls: true를 적으면 기존 계정이 RLS를 건너뛰는 권한(BYPASSRLS)을 받아 이 셋이 그대로 돌아요. 추가 계정에는 이 권한이 절대 붙지 않아요.

FORCE RLS를 걸었다면 owner_bypass_rls: true를 꼭 같이 적으세요. 없으면 DB 백업이 실패해요 — 실패 사유에 이 설정을 넣으라는 안내가 보여요.

알아둘 규칙

  • 이름은 영어 소문자로 시작하는 소문자 · 숫자 16자 이내예요. 밑줄은 쓸 수 없고, ro · staging은 이미 쓰는 이름이라 안 돼요.
  • 계정마다 동시에 5개까지 접속할 수 있어요. 기존 계정의 한도는 그대로예요.
  • 운영과 스테이징에 각각 따로 만들어져요. 운영에 반영(promote)하면 심사한 스테이징 버전의 선언대로 운영 DB에 맞춰요.
  • 선언에서 빼면 그 계정은 로그인만 막혀요. 받았던 권한은 남아 있어서, 다시 적으면 새 비밀번호로 같은 권한이 돌아와요. owner_bypass_rls를 빼면 다음 배포에서 바로 회수돼요.
  • 백업을 복원해도 추가 계정에 준 권한이 그대로 돌아와요.
  • 앱 DB를 끄면 추가 계정도 같이 접속이 끊기고, 다시 켜면 함께 돌아와요.

콘솔에서 표로 보기

데이터가 실제로 어떻게 쌓였는지는 앱 콘솔 → 리소스 탭 → 테이블에서 그대로 조회할 수 있어요.

터미널에서는 이렇게 봐요.

axhub tables db-list --app demo                 # 테이블 목록
axhub tables db-rows orders --app demo          # 행 조회
axhub tables db-rows orders --app demo --environment staging

앱 코드 안에서는 SDK로 sdk.apps.rawDb.tables(appId) · sdk.apps.rawDb.tableRows(appId, 'orders')를 부르면 같은 걸 읽어요 — SDK 설정은 SDK 설치하기에서 해요.

세 곳 모두 읽기 전용이라 여기서 데이터를 고칠 수는 없어요. 그리고 앱 소유자가 열람 정책으로 잠가 둔 앱이라면, 관리자라도 콘솔 · 터미널에서 행 내용 열람이 제한될 수 있어요.

알아두면 좋은 것

  • 화면만 있는 앱에는 못 붙여요 — 정적 호스팅 앱은 계속 돌고 있는 서버가 없어서 접속 정보를 넣을 곳이 없어요. 선언한 채 배포하면 배포가 명확히 실패해요.
  • 선언을 지워도 DB는 사라지지 않아요 — 데이터와 주입은 유지돼요. 정말 끄려면 axhub apps raw-db disable --app <앱> --execute를 쓰세요. 이때도 표와 데이터는 그대로 남고 접속만 끊겨요 — 선언을 둔 채 다시 배포하면 새 비밀번호로 접속 정보가 다시 들어와요.
  • 확장도 선언에서 지운다고 회수되지 않아요 — 그 확장을 쓰는 표와 데이터가 그대로 남아요.
  • DATABASE_URL은 직접 덮어쓸 수 없어요 — DB를 켠 앱에서는 AxHub가 넣어 주는 값이 항상 이겨요(추가 계정의 DATABASE_URL_<이름>도 같아요). 같은 이름으로 저장한 환경변수는 무시돼요. (DB를 켜지 않은 앱은 외부 DB 주소를 직접 등록해 쓸 수 있어요 — 5432·3306 같은 DB 포트는 공인 주소로만 열려 있고, 회사 내부망 DB 에는 연결되지 않아요. 밖으로는 널리 쓰는 포트만 나간다를 보세요.)
  • 이미 떠 있는 앱에는 소급 주입되지 않아요 — 선언한 뒤 첫 배포부터 환경변수가 들어가요.
  • SDK에는 행 쓰기 API가 없어요 — 쓰기는 앱 런타임의 DATABASE_URL로 해요.

이렇게 보이면 성공

배포가 끝나고, 앱 콘솔 → 리소스 탭 → 테이블에서 내 앱의 테이블과 행이 표로 보이면 성공이에요. 앱 코드가 DATABASE_URL로 읽고 쓴 내용이 그대로 나타나요.

다음은 스토리지 연결하기에서 이미지 · 첨부파일 둘 곳을 만들어요.