From 5ee6fcd54b7cfeed50fa906628918005a6eb0475 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 29 Apr 2026 15:22:54 +0900 Subject: [PATCH] =?UTF-8?q?PostgreSQL=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +- db/migrations/001_initial_schema.sql | 47 ++++++ db/migrations/002_seed_development.sql | 65 +++++++++ docker-compose.yml | 21 +++ docs/changelog.md | 7 + docs/deploy.md | 8 +- docs/history.md | 10 ++ docs/map.md | 9 ++ docs/spec.md | 21 ++- docs/todo.md | 12 +- docs/update.md | 9 ++ package-lock.json | 21 ++- package.json | 6 +- server/api/pages.get.js | 4 +- server/api/pages/[slug].get.js | 6 +- server/api/posts.get.js | 4 +- server/api/posts/[slug].get.js | 6 +- server/api/tags.get.js | 4 +- server/repositories/content-repository.js | 170 ++++++++++++++++++++++ server/repositories/postgres-client.js | 25 ++++ 20 files changed, 429 insertions(+), 34 deletions(-) create mode 100644 db/migrations/001_initial_schema.sql create mode 100644 db/migrations/002_seed_development.sql create mode 100644 server/repositories/content-repository.js create mode 100644 server/repositories/postgres-client.js diff --git a/.env.example b/.env.example index edc3ac8..ca4503e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Database -DATABASE_URL= -DATABASE_NAME= +DATABASE_URL=postgres://sori_studio:change-this-password@sori-studio-db:5432/sori_studio +DATABASE_NAME=sori_studio +POSTGRES_DB=sori_studio +POSTGRES_USER=sori_studio +POSTGRES_PASSWORD=change-this-password +DB_PORT=43119 # Auth ADMIN_EMAIL= diff --git a/db/migrations/001_initial_schema.sql b/db/migrations/001_initial_schema.sql new file mode 100644 index 0000000..10b5d4f --- /dev/null +++ b/db/migrations/001_initial_schema.sql @@ -0,0 +1,47 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS posts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + excerpt TEXT NOT NULL DEFAULT '', + featured_image TEXT, + status TEXT NOT NULL DEFAULT 'draft', + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft', 'private')) +); + +CREATE INDEX IF NOT EXISTS posts_status_published_at_idx + ON posts (status, published_at DESC); + +CREATE TABLE IF NOT EXISTS pages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + featured_image TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS post_tags ( + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (post_id, tag_id) +); + +CREATE INDEX IF NOT EXISTS post_tags_tag_id_idx + ON post_tags (tag_id); diff --git a/db/migrations/002_seed_development.sql b/db/migrations/002_seed_development.sql new file mode 100644 index 0000000..0c04393 --- /dev/null +++ b/db/migrations/002_seed_development.sql @@ -0,0 +1,65 @@ +INSERT INTO tags (id, name, slug, description) +VALUES + ('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.'), + ('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO posts ( + id, + title, + slug, + content, + excerpt, + status, + published_at, + created_at, + updated_at +) +VALUES + ( + '11111111-1111-4111-8111-111111111111', + 'sori.studio를 직접 만들기 시작하며', + 'hello-sori-studio', + '개인 블로그와 포털 역할을 한 공간에 담기 위한 첫 글입니다.', + '블로그와 포털의 경계에 있는 개인 공간을 직접 구축하기 위한 첫 기록입니다.', + 'published', + '2026-04-29T00:00:00.000Z', + '2026-04-29T00:00:00.000Z', + '2026-04-29T00:00:00.000Z' + ), + ( + '22222222-2222-4222-8222-222222222222', + '글쓰기 도구는 왜 직접 만들게 되는가', + 'custom-writing-tool', + '기존 도구를 거치며 남은 취향의 빈칸을 직접 채우는 과정입니다.', + '네이버 블로그, 티스토리, 워드프레스, Ghost를 거쳐 남은 취향의 빈칸을 정리합니다.', + 'published', + '2026-04-29T00:00:00.000Z', + '2026-04-29T00:00:00.000Z', + '2026-04-29T00:00:00.000Z' + ) +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO post_tags (post_id, tag_id) +VALUES + ('11111111-1111-4111-8111-111111111111', '44444444-4444-4444-8444-444444444444'), + ('22222222-2222-4222-8222-222222222222', '55555555-5555-4555-8555-555555555555') +ON CONFLICT (post_id, tag_id) DO NOTHING; + +INSERT INTO pages ( + id, + title, + slug, + content, + created_at, + updated_at +) +VALUES ( + '33333333-3333-4333-8333-333333333333', + 'About', + 'about', + 'sori.studio 소개 페이지입니다.', + '2026-04-29T00:00:00.000Z', + '2026-04-29T00:00:00.000Z' +) +ON CONFLICT (slug) DO NOTHING; diff --git a/docker-compose.yml b/docker-compose.yml index d70f1e1..a0f91da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,4 +10,25 @@ services: - "${APP_PORT:-43118}:3000" volumes: - ./public/uploads:/app/public/uploads + depends_on: + - sori-studio-db restart: unless-stopped + + sori-studio-db: + image: postgres:16-alpine + container_name: sori-studio-db + env_file: + - .env.production + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "${DB_PORT:-43119}:5432" + volumes: + - sori-studio-postgres:/var/lib/postgresql/data + - ./db/migrations:/docker-entrypoint-initdb.d:ro + restart: unless-stopped + +volumes: + sori-studio-postgres: diff --git a/docs/changelog.md b/docs/changelog.md index 1067806..1f846da 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,12 @@ # 업데이트 요약 +## v0.0.5 + +- PostgreSQL 초기 스키마와 개발용 시드 데이터를 추가. +- Nuxt 서버 API에 DB 저장소 계층을 추가. +- DB 연결이 없을 때는 샘플 데이터로 동작하도록 fallback 구조를 추가. +- Docker Compose에 PostgreSQL 서비스를 추가. + ## v0.0.4 - 헤더 좌측 아이콘을 사이드바 메뉴 토글 버튼으로 수정. diff --git a/docs/deploy.md b/docs/deploy.md index cb20f5c..5695eb4 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -34,6 +34,7 @@ npm install # 개발 환경 변수 설정 cp .env.example .env.development # .env.development 파일에 개발 DB 연결 정보 입력 +# 로컬 DB 컨테이너를 호스트에서 접근할 때는 127.0.0.1:43119 사용 # 개발 서버 실행 (127.0.0.1:43117) npm run dev @@ -71,9 +72,10 @@ cd sori.studio # 운영 환경 변수 설정 cp .env.example .env.production # .env.production 파일에 운영 DB 연결 정보와 APP_PORT=43118 입력 +# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용 # Docker 빌드 및 실행 -docker-compose up -d +docker compose --env-file .env.production up -d ``` ### 프로덕션 빌드 (NAS에서) @@ -92,6 +94,7 @@ docker run -d -p 3000:3000 sori.studio:latest - 로컬 개발: 43117 - NAS Docker 외부: 43118 - 컨테이너 내부: 3000 +- PostgreSQL 외부: 43119 - HTTPS: 3001 (SSL 설정 시) --- @@ -100,9 +103,12 @@ docker run -d -p 3000:3000 sori.studio:latest - 로컬 개발: `.env.development`의 `DATABASE_URL` - NAS 운영: `.env.production`의 `DATABASE_URL` +- 로컬 개발 예시: `postgres://sori_studio:비밀번호@127.0.0.1:43119/sori_studio` +- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio` - 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리 - 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음 - 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계 +- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성 ## 사용자 액션 필요 항목 diff --git a/docs/history.md b/docs/history.md index 26463c2..0a4d48b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,15 @@ # 의사결정 이력 +## 2026-04-29 v0.0.5 + +### PostgreSQL 기반 데이터 계층 결정 + +DB 관리 도구로 CloudBeaver를 고려하고 NAS Docker 배포를 전제로 하기 때문에 초기 데이터베이스는 PostgreSQL로 잡는다. SQLite보다 운영/개발 분리, 외부 관리 도구 연결, 향후 확장에 유리하다. + +Nuxt 서버 API는 바로 DB에 강결합하지 않고 `server/repositories`를 통해 콘텐츠를 조회한다. `DATABASE_URL`이 설정된 환경에서는 PostgreSQL을 사용하고, 설정되지 않은 환경에서는 샘플 데이터를 사용해 화면과 API 개발을 계속할 수 있게 했다. + +Docker Compose에는 앱과 PostgreSQL 서비스를 함께 두되, 실제 운영 비밀번호와 연결 문자열은 `.env.production`에서 관리한다. DB 외부 포트는 기존 사용 포트와 겹치지 않도록 `43119`를 사용한다. + ## 2026-04-29 v0.0.4 ### 메뉴 토글 구현 방식 결정 diff --git a/docs/map.md b/docs/map.md index ee3eefe..01b4bef 100644 --- a/docs/map.md +++ b/docs/map.md @@ -73,6 +73,15 @@ | server/api/tags.get.js | 태그 목록 샘플 API | | server/utils/content-schema.js | Zod 콘텐츠 스키마 | | server/utils/sample-content.js | 샘플 콘텐츠 저장소 | +| server/repositories/postgres-client.js | PostgreSQL 클라이언트 | +| server/repositories/content-repository.js | 콘텐츠 조회 저장소 | + +## 데이터베이스 + +| 파일 | 기능 | +|------|------| +| db/migrations/001_initial_schema.sql | PostgreSQL 초기 테이블 스키마 | +| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 | ## 설정/배포 diff --git a/docs/spec.md b/docs/spec.md index d3bc0ac..0608d67 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -99,6 +99,7 @@ components/content/ ### 환경 분리 원칙 +- 데이터베이스는 PostgreSQL을 기준으로 한다. - 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용 - 로컬 개발 서버는 개발 DB만 연결 - NAS 배포 환경은 운영 DB만 연결 @@ -114,7 +115,7 @@ components/content/ | slug | String | URL 슬러그 | | content | Text | 마크다운 콘텐츠 | | excerpt | String | 요약 | -| featured_image | String | 대표 이미지 | +| featured_image | String nullable | 대표 이미지 | | status | Enum | published/draft/private | | published_at | DateTime | 발행일 | | created_at | DateTime | 생성일 | @@ -128,7 +129,7 @@ components/content/ | title | String | 제목 | | slug | String | URL 슬러그 | | content | Text | 마크다운 콘텐츠 | -| featured_image | String | 대표 이미지 | +| featured_image | String nullable | 대표 이미지 | | created_at | DateTime | 생성일 | | updated_at | DateTime | 수정일 | @@ -140,6 +141,8 @@ components/content/ | name | String | 태그명 | | slug | String | URL 슬러그 | | description | String | 설명 | +| created_at | DateTime | 생성일 | +| updated_at | DateTime | 수정일 | ### PostTags (다대다) @@ -147,6 +150,7 @@ components/content/ |------|------|------| | post_id | UUID | FK → Posts | | tag_id | UUID | FK → Tags | +| created_at | DateTime | 생성일 | --- @@ -159,6 +163,8 @@ components/content/ - 별도 `backend/` 앱을 두지 않고 Nuxt/Nitro 서버 기능을 사용 - 공개 API는 `server/api`에 작성 - 서버 공통 스키마와 샘플 데이터는 `server/utils`에 작성 +- PostgreSQL 연결과 조회 로직은 `server/repositories`에 작성 +- `DATABASE_URL`이 없으면 샘플 데이터 저장소를 사용 - 초기 단계에서는 같은 앱 배포로 관리 비용을 낮춤 - 독립 API 서버가 필요해지는 시점에만 백엔드 분리를 재검토 @@ -203,8 +209,12 @@ components/content/ ```env # Database -DATABASE_URL= -DATABASE_NAME= +DATABASE_URL=postgres://sori_studio:change-this-password@sori-studio-db:5432/sori_studio +DATABASE_NAME=sori_studio +POSTGRES_DB=sori_studio +POSTGRES_USER=sori_studio +POSTGRES_PASSWORD=change-this-password +DB_PORT=43119 # Auth ADMIN_EMAIL= @@ -237,11 +247,12 @@ APP_PORT=43118 | 로컬 개발 서버 | 43117 | | NAS Docker 외부 포트 | 43118 | | 컨테이너 내부 포트 | 3000 | +| PostgreSQL 외부 포트 | 43119 | --- ## 버전 관리 -- 현재 버전: v0.0.4 +- 현재 버전: v0.0.5 - 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가 - 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정 diff --git a/docs/todo.md b/docs/todo.md index 067b980..1859b2a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -51,17 +51,15 @@ ## 데이터베이스 -- [ ] Posts 테이블 설계 -- [ ] Pages 테이블 설계 -- [ ] Tags 테이블 설계 -- [ ] PostTags 테이블 설계 -- [ ] 로컬 개발 DB 연결 설정 작성 -- [ ] NAS 운영 DB 연결 설정 작성 +- [ ] PostgreSQL 마이그레이션 실행 스크립트 작성 +- [ ] 로컬 개발 DB 컨테이너 실행 가이드 작성 +- [ ] NAS 운영 DB 연결 설정 실제 값 작성 - [ ] 개발 DB와 운영 DB 분리 검증 절차 작성 -- [ ] CloudBeaver 등 DB 관리 도구 연결 방식 결정 +- [ ] CloudBeaver PostgreSQL 연결 방식 확정 ## 배포 - [ ] UGREEN NAS Docker 배포 가이드 작성 - [ ] Docker 빌드 검증 +- [ ] `.env.production` 작성 후 `docker compose --env-file .env.production config` 검증 - [ ] NAS 운영 환경 변수 작성 diff --git a/docs/update.md b/docs/update.md index df5d6cd..f6d9604 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v0.0.5 + +- PostgreSQL 초기 스키마 마이그레이션 추가. +- 개발용 시드 데이터 SQL 추가. +- Nuxt 서버 API 저장소 계층 추가. +- `DATABASE_URL`이 있으면 PostgreSQL을 사용하고, 없으면 샘플 데이터를 사용하도록 수정. +- Docker Compose에 PostgreSQL 서비스와 전용 DB 포트 43119 추가. +- 공개 API가 저장소 계층을 통해 게시물, 페이지, 태그를 조회하도록 수정. + ## v0.0.4 - 헤더 좌측 아이콘을 브랜드 마크에서 메뉴 토글 버튼으로 수정. diff --git a/package-lock.json b/package-lock.json index b671158..e2d4ff7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,21 @@ { "name": "sori.studio", - "version": "0.0.4", + "version": "0.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "0.0.4", + "version": "0.0.5", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", "nuxt": "^3.16.2", + "postgres": "^3.4.9", "vue": "^3.5.13", "vue-router": "^4.5.0", "zod": "^3.24.2" - }, - "devDependencies": {} + } }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -8844,6 +8844,19 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", diff --git a/package.json b/package.json index 5109cbc..bb64eea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.4", + "version": "0.0.5", "private": true, "type": "module", "scripts": { @@ -12,9 +12,9 @@ "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", "nuxt": "^3.16.2", + "postgres": "^3.4.9", "vue": "^3.5.13", "vue-router": "^4.5.0", "zod": "^3.24.2" - }, - "devDependencies": {} + } } diff --git a/server/api/pages.get.js b/server/api/pages.get.js index 47d963b..9931c13 100644 --- a/server/api/pages.get.js +++ b/server/api/pages.get.js @@ -1,7 +1,7 @@ -import { getSamplePages } from '../utils/sample-content' +import { listPages } from '../repositories/content-repository' /** * 공개 고정 페이지 목록 API * @returns {Array} 고정 페이지 목록 */ -export default defineEventHandler(() => getSamplePages()) +export default defineEventHandler(() => listPages()) diff --git a/server/api/pages/[slug].get.js b/server/api/pages/[slug].get.js index b640025..15e0734 100644 --- a/server/api/pages/[slug].get.js +++ b/server/api/pages/[slug].get.js @@ -1,13 +1,13 @@ -import { getSamplePageBySlug } from '../../utils/sample-content' +import { getPageBySlug } from '../../repositories/content-repository' /** * 공개 고정 페이지 상세 API * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Object} 고정 페이지 상세 */ -export default defineEventHandler((event) => { +export default defineEventHandler(async (event) => { const slug = getRouterParam(event, 'slug') - const page = getSamplePageBySlug(slug) + const page = await getPageBySlug(slug) if (!page) { throw createError({ diff --git a/server/api/posts.get.js b/server/api/posts.get.js index b950d0c..c9cfd30 100644 --- a/server/api/posts.get.js +++ b/server/api/posts.get.js @@ -1,7 +1,7 @@ -import { getSamplePosts } from '../utils/sample-content' +import { listPosts } from '../repositories/content-repository' /** * 공개 게시물 목록 API * @returns {Array} 게시물 목록 */ -export default defineEventHandler(() => getSamplePosts()) +export default defineEventHandler(() => listPosts()) diff --git a/server/api/posts/[slug].get.js b/server/api/posts/[slug].get.js index 2a3a439..9e2a57c 100644 --- a/server/api/posts/[slug].get.js +++ b/server/api/posts/[slug].get.js @@ -1,13 +1,13 @@ -import { getSamplePostBySlug } from '../../utils/sample-content' +import { getPostBySlug } from '../../repositories/content-repository' /** * 공개 게시물 상세 API * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Object} 게시물 상세 */ -export default defineEventHandler((event) => { +export default defineEventHandler(async (event) => { const slug = getRouterParam(event, 'slug') - const post = getSamplePostBySlug(slug) + const post = await getPostBySlug(slug) if (!post) { throw createError({ diff --git a/server/api/tags.get.js b/server/api/tags.get.js index ddfb3e1..bf0d1bd 100644 --- a/server/api/tags.get.js +++ b/server/api/tags.get.js @@ -1,7 +1,7 @@ -import { getSampleTags } from '../utils/sample-content' +import { listTags } from '../repositories/content-repository' /** * 공개 태그 목록 API * @returns {Array} 태그 목록 */ -export default defineEventHandler(() => getSampleTags()) +export default defineEventHandler(() => listTags()) diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js new file mode 100644 index 0000000..2bbd864 --- /dev/null +++ b/server/repositories/content-repository.js @@ -0,0 +1,170 @@ +import { + getSamplePageBySlug, + getSamplePages, + getSamplePostBySlug, + getSamplePosts, + getSampleTags +} from '../utils/sample-content' +import { getPostgresClient } from './postgres-client' + +/** + * 게시물 행을 API 응답 구조로 변환 + * @param {Object} row - 게시물 행 + * @returns {Object} 게시물 응답 + */ +const mapPostRow = (row) => ({ + id: row.id, + title: row.title, + slug: row.slug, + content: row.content, + excerpt: row.excerpt, + featuredImage: row.featured_image, + status: row.status, + publishedAt: row.published_at ? row.published_at.toISOString() : null, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + tags: row.tags || [] +}) + +/** + * 고정 페이지 행을 API 응답 구조로 변환 + * @param {Object} row - 고정 페이지 행 + * @returns {Object} 고정 페이지 응답 + */ +const mapPageRow = (row) => ({ + id: row.id, + title: row.title, + slug: row.slug, + content: row.content, + featuredImage: row.featured_image, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString() +}) + +/** + * 태그 행을 API 응답 구조로 변환 + * @param {Object} row - 태그 행 + * @returns {Object} 태그 응답 + */ +const mapTagRow = (row) => ({ + id: row.id, + name: row.name, + slug: row.slug, + description: row.description +}) + +/** + * 공개 게시물 목록 조회 + * @returns {Promise} 게시물 목록 + */ +export const listPosts = async () => { + const sql = getPostgresClient() + + if (!sql) { + return getSamplePosts() + } + + const rows = await sql` + SELECT + posts.*, + COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags + FROM posts + LEFT JOIN post_tags ON post_tags.post_id = posts.id + LEFT JOIN tags ON tags.id = post_tags.tag_id + WHERE posts.status = 'published' + GROUP BY posts.id + ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC + ` + + return rows.map(mapPostRow) +} + +/** + * 공개 게시물 상세 조회 + * @param {string} slug - 게시물 슬러그 + * @returns {Promise} 게시물 상세 + */ +export const getPostBySlug = async (slug) => { + const sql = getPostgresClient() + + if (!sql) { + return getSamplePostBySlug(slug) + } + + const rows = await sql` + SELECT + posts.*, + COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags + FROM posts + LEFT JOIN post_tags ON post_tags.post_id = posts.id + LEFT JOIN tags ON tags.id = post_tags.tag_id + WHERE posts.slug = ${slug} + AND posts.status = 'published' + GROUP BY posts.id + LIMIT 1 + ` + + return rows[0] ? mapPostRow(rows[0]) : null +} + +/** + * 공개 고정 페이지 목록 조회 + * @returns {Promise} 고정 페이지 목록 + */ +export const listPages = async () => { + const sql = getPostgresClient() + + if (!sql) { + return getSamplePages() + } + + const rows = await sql` + SELECT * + FROM pages + ORDER BY created_at DESC + ` + + return rows.map(mapPageRow) +} + +/** + * 공개 고정 페이지 상세 조회 + * @param {string} slug - 페이지 슬러그 + * @returns {Promise} 고정 페이지 상세 + */ +export const getPageBySlug = async (slug) => { + const sql = getPostgresClient() + + if (!sql) { + return getSamplePageBySlug(slug) + } + + const rows = await sql` + SELECT * + FROM pages + WHERE slug = ${slug} + LIMIT 1 + ` + + return rows[0] ? mapPageRow(rows[0]) : null +} + +/** + * 공개 태그 목록 조회 + * @returns {Promise} 태그 목록 + */ +export const listTags = async () => { + const sql = getPostgresClient() + + if (!sql) { + return getSampleTags() + } + + const rows = await sql` + SELECT * + FROM tags + ORDER BY name ASC + ` + + return rows.map(mapTagRow) +} diff --git a/server/repositories/postgres-client.js b/server/repositories/postgres-client.js new file mode 100644 index 0000000..121d3e1 --- /dev/null +++ b/server/repositories/postgres-client.js @@ -0,0 +1,25 @@ +import postgres from 'postgres' + +let client = null + +/** + * PostgreSQL 클라이언트 조회 + * @returns {ReturnType | null} PostgreSQL 클라이언트 + */ +export const getPostgresClient = () => { + const config = useRuntimeConfig() + + if (!config.databaseUrl) { + return null + } + + if (!client) { + client = postgres(config.databaseUrl, { + max: 5, + idle_timeout: 20, + connect_timeout: 10 + }) + } + + return client +}