공개 검색 인덱스 추가

This commit is contained in:
2026-07-03 15:43:27 +09:00
parent 33c92a6f72
commit 58fbae5a03
10 changed files with 148 additions and 42 deletions

View File

@@ -0,0 +1,81 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS search_text TEXT NOT NULL DEFAULT '';
ALTER TABLE pages
ADD COLUMN IF NOT EXISTS search_text TEXT NOT NULL DEFAULT '';
CREATE OR REPLACE FUNCTION normalize_public_search_text(value TEXT)
RETURNS TEXT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT lower(trim(regexp_replace(
regexp_replace(
regexp_replace(coalesce(value, ''), E'!\\[[^]]*\\]\\([^)]*\\)', ' ', 'g'),
E'/uploads/[^[:space:])"]+',
' ',
'g'
),
E'[[:space:]]+',
' ',
'g'
)))
$$;
CREATE OR REPLACE FUNCTION set_post_search_text()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.search_text := normalize_public_search_text(concat_ws(E'\n', NEW.title, NEW.excerpt, NEW.content));
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION set_page_search_text()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.search_text := normalize_public_search_text(concat_ws(E'\n', NEW.title, NEW.listing_section, NEW.listing_title, NEW.listing_description, NEW.content));
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS posts_search_text_before_write ON posts;
CREATE TRIGGER posts_search_text_before_write
BEFORE INSERT OR UPDATE OF title, excerpt, content
ON posts
FOR EACH ROW
EXECUTE FUNCTION set_post_search_text();
DROP TRIGGER IF EXISTS pages_search_text_before_write ON pages;
CREATE TRIGGER pages_search_text_before_write
BEFORE INSERT OR UPDATE OF title, listing_section, listing_title, listing_description, content
ON pages
FOR EACH ROW
EXECUTE FUNCTION set_page_search_text();
UPDATE posts
SET search_text = normalize_public_search_text(concat_ws(E'\n', title, excerpt, content));
UPDATE pages
SET search_text = normalize_public_search_text(concat_ws(E'\n', title, listing_section, listing_title, listing_description, content));
CREATE INDEX IF NOT EXISTS posts_public_search_text_trgm_idx
ON posts USING gin (search_text gin_trgm_ops)
WHERE status = 'published';
CREATE INDEX IF NOT EXISTS pages_public_search_text_trgm_idx
ON pages USING gin (search_text gin_trgm_ops)
WHERE status = 'published' AND is_listed = true;
CREATE INDEX IF NOT EXISTS tags_name_trgm_idx
ON tags USING gin ((lower(name)) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS tags_slug_trgm_idx
ON tags USING gin ((lower(slug)) gin_trgm_ops);

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.10.27에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.10.28에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -68,7 +68,7 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
```
> 지난 버전 마이그레이션을 아직 적용하지 않았다면 `062_site_settings_display_counts.sql`과 `063_site_settings_upload_limits.sql`이 순차 적용되어야 한다. 일반 배포에서는 `npm run db:migrate:prod` 또는 NAS 컨테이너 내부 마이그레이션 명령이 이 순서를 자동 처리한다.
> 지난 버전 마이그레이션을 아직 적용하지 않았다면 `062_site_settings_display_counts.sql`부터 `065_public_search_index.sql`까지 순차 적용되어야 한다. 일반 배포에서는 `npm run db:migrate:prod` 또는 NAS 컨테이너 내부 마이그레이션 명령이 이 순서를 자동 처리한다.
### 확인 주소

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-07-03 v1.10.28 — 공개 검색은 정규화 컬럼과 trigram 인덱스를 쓴다
게시물이 수천 개로 늘면 통합 검색 입력마다 `lower(content)`를 직접 계산하는 방식은 본문 길이에 비례해 비용이 커진다. 공개 검색은 부분 문자열 검색과 한글 검색이 중요하므로 PostgreSQL full-text search보다 `pg_trgm` 인덱스를 먼저 사용한다. 게시물과 페이지에는 업로드 경로·마크다운 이미지 토큰을 제거한 `search_text` 컬럼을 두고, DB 트리거가 저장·수정 시 자동 갱신한다.
애플리케이션 쿼리는 `search_text LIKE '%검색어%'`만 확인해 인덱스가 탈 수 있게 하고, 제목·요약 앞부분 일치 결과를 먼저 보여 준다. 이렇게 하면 공개 검색 결과의 기존 부분 일치 감각은 유지하면서 검색 모달 입력마다 본문 전체를 반복 가공하는 일을 줄일 수 있다.
## 2026-07-03 v1.10.27 — 미디어 사용처 계산은 목록 요약과 분리한다
관리자 글 목록은 성능을 위해 본문을 제외한 요약 응답을 사용한다. 하지만 미디어 사용처 계산은 게시물 본문과 페이지 본문 안의 이미지 URL을 정확히 확인해야 하므로 같은 목록 함수를 재사용하면 본문 이미지가 모두 미사용으로 분류될 수 있다. 따라서 미디어 관리는 목록 UI용 함수가 아니라 미디어 참조 필드만 따로 조회하는 전용 함수를 사용한다.

View File

@@ -84,6 +84,7 @@
| db/migrations/062_site_settings_display_counts.sql | 사이트 설정 공개 목록 표시개수 컬럼 추가 |
| db/migrations/063_site_settings_upload_limits.sql | 사이트 설정 관리자 업로드 제한 컬럼 추가 |
| db/migrations/064_posts_admin_pinned.sql | 관리자 글 목록 전용 상단 고정 컬럼·정렬 인덱스 추가 |
| db/migrations/065_public_search_index.sql | 공개 통합 검색용 `search_text` 컬럼·자동 갱신 트리거·`pg_trgm` GIN 인덱스 추가 |
## 서버 미들웨어
@@ -236,7 +237,7 @@
| server/api/pages/[slug].get.js | 공개 고정 페이지 상세 API(`published`만) |
| server/api/tags.get.js | 공개 태그 목록 API, 기본 메인 태그 반환·`tagType=all` 전체 태그 메타 반환 |
| server/routes/admin/api/tags/cover.post.js | 관리자 태그 헤더 이미지 업로드 API(`/uploads/system/tag-cover-*.webp`) |
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
| server/api/search.get.js | 통합 검색 API(`q` 쿼리, 게시물·페이지 `search_text` 인덱스 기반) |
| server/api/site-settings.get.js | 공개 사이트 설정 API |
| server/api/homepage-widget.get.js | gethomepage customapi용 당일·7일·30일·180일·365일·누적·콘텐츠 통계 위젯 API |
| server/repositories/blog-statistics-repository.js | 공개 게시글·댓글·페이지·태그·회원·누적 방문 통계 집계 |

View File

@@ -338,6 +338,7 @@ components/content/
| canonical_url | String | canonical URL |
| noindex | Boolean | 검색엔진 노출 제외 여부 |
| og_image | String nullable | OG 이미지 |
| search_text | Text | 공개 통합 검색용 정규화 텍스트. DB 트리거가 제목·요약·본문에서 자동 생성 |
| status | Enum | `published` / `draft` / `members` / `private`(예약은 `published` + 미래 `published_at`) |
| published_at | DateTime | 발행일 |
| created_at | DateTime | 생성일 |
@@ -432,6 +433,7 @@ components/content/
| listing_title | String | 홈 Pages 진열장·검색 결과용 제목(비어 있으면 페이지 제목 사용) |
| listing_description | String | 검색 결과·관리용 목록 설명 |
| listing_sort_order | Integer | 섹션 내 정렬 순서 |
| search_text | Text | 공개 통합 검색용 정규화 텍스트. DB 트리거가 제목·섹션·목록 설명·본문에서 자동 생성 |
| status | Enum | `published` / `draft` / `private` |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
@@ -587,7 +589,7 @@ components/content/
- `GET /api/pages` - 공개 고정 페이지 목록. `?listed=1`이면 홈 Pages 진열장에 노출할 공개 페이지만 반환하며, 홈 목록 성능을 위해 `content`는 빈 문자열로 내려준다. `listed=1&limit=N`이면 섹션별 최대 N개만 반환한다.
- `GET /api/pages/:slug` - 고정 페이지 상세
- `GET /api/tags` - 태그 목록. 기본값은 메인 태그(`managed`)만 반환하며, `?tagType=all`은 일반 태그를 포함한 전체 태그 메타를 반환한다. `postCount`는 공개 발행 게시물 기준이다.
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content`, 홈 표시 페이지의 `listingSection`·`listingTitle`·`listingDescription`·`content` 부분 일치)
- `GET /api/search?q=` - 통합 검색. 태그는 `name`·`slug`, 게시물은 DB 트리거가 갱신하는 `posts.search_text`, 홈 표시 페이지는 `pages.search_text` 기준으로 부분 일치한다. `search_text`는 제목·요약·본문·페이지 섹션/목록 설명에서 업로드 경로와 이미지 마크다운 토큰을 제거한 정규화 문자열이며 `pg_trgm` GIN 인덱스를 사용한다.
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
- `GET /api/homepage-widget` - gethomepage customapi용 공개 블로그 통계. 기본 필드 `title`, `updatedAt`, `todayVisitors`, `totalVisitors`, `todayPageViews`, `totalPageViews`, `visitorsLast7Days`, `visitorsLast30Days`, `visitorsLast180Days`, `visitorsLast365Days`, `pageViewsLast30Days`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `totalPosts`, `totalComments`, `totalPages`, `totalTags`, `totalMembers`, `items[]`를 반환한다. 기간별 방문자는 최근 7·30·180·365일의 일별 순방문자 합계다. `totalVisitors`는 날짜별 순방문자 수의 누적 합계라 동일 방문자가 다른 날짜에 다시 포함될 수 있다. 게시글·댓글·페이지 수는 현재 공개 가능한 항목만 집계한다.
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다.
@@ -690,7 +692,7 @@ components/content/
- 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
- 관리자 글 목록의 `published` 상태 표시와 상태 필터 문구는 페이지 목록과 같은 `공개`로 표시한다. 제목 링크는 관리자 수정 화면으로 이동하고, 공개 가능한 글은 제목 아래 주소 오른쪽에 hover/focus 시 공개 보기 아이콘을 표시해 `/post/:slug`를 새 탭으로 연다.
- 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
- 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 검색, 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 둔다. 검색은 제목·슬러그·요약·본문·태그 기준 부분 일치로 적용한다.
- 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 검색, 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 둔다. 목록 검색은 빠른 조회를 위해 제목·슬러그·요약·태그 기준 부분 일치로 적용한다.
- 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. 추천 열과 제목 열 사이에는 대표 이미지 썸네일을 작은 크기로 표시하며, 이미지가 없으면 회색 placeholder만 유지한다.
- 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
- 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다.

View File

@@ -10,9 +10,6 @@
- 업로드 총량 제한 및 메모리 사용량 개선.
- 관리자 업로드는 사이트 설정에서 1회 최대 파일 수·전체 용량을 조절하고 클라이언트·서버 양쪽에서 검사함.
- 서버는 아직 multipart 전체를 메모리에 받은 뒤 검사하므로, 고용량 다중 업로드의 메모리 피크를 더 줄이려면 스트리밍 업로드 파서 도입 검토.
- 공개 통합 검색 인덱스 개선.
- 현재 검색은 게시물·페이지 `content`에 `lower(content)` 부분 일치를 수행함.
- 게시물이 수백~수천 개로 늘면 검색 모달 입력마다 DB 스캔 비용이 커질 수 있으므로 PostgreSQL full-text search, `pg_trgm`, 검색용 정규화 컬럼 중 하나로 전환 검토.
## 콘텐츠 스타일 구현

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.10.28
- 공개 통합 검색용 `posts.search_text`, `pages.search_text` 컬럼과 `pg_trgm` GIN 인덱스 추가.
- 게시물·페이지 검색 텍스트를 저장/수정 시 DB 트리거로 자동 갱신하도록 추가.
- `/api/search`가 본문 전체 `lower(content)` 스캔 대신 정규화된 검색 컬럼을 기준으로 조회하도록 수정.
- 공개 통합 검색 인덱스 개선 과제를 완료 처리.
## v1.10.27
- 관리자 미디어 사용처 계산이 본문 없는 글 목록 요약 대신 미디어 참조 전용 게시물·페이지 조회를 사용하도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.10.27",
"version": "1.10.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.10.27",
"version": "1.10.28",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.10.27",
"version": "1.10.28",
"private": true,
"type": "module",
"imports": {

View File

@@ -1721,7 +1721,6 @@ export const listAdminTags = async (options = {}) => listTags(options)
const SEARCH_TAG_LIMIT = 12
const SEARCH_POST_LIMIT = 12
const SEARCH_PAGE_LIMIT = 8
const SEARCH_POST_CANDIDATE_LIMIT = 48
/**
* 공개 검색어를 비교 가능한 문자열로 정리한다.
@@ -1756,11 +1755,15 @@ export const searchPublicContent = async (rawQuery) => {
if (!q) {
return { tags: [], posts: [], pages: [] }
}
const searchTextQuery = normalizeSearchContent(q).toLowerCase()
if (!searchTextQuery) {
return { tags: [], posts: [], pages: [] }
}
const sql = getPostgresClient()
if (!sql) {
const needle = q.toLowerCase()
const needle = searchTextQuery
const posts = getSamplePosts()
.filter((post) => {
const hay = normalizeSearchContent(`${post.title}\n${post.excerpt || ''}\n${post.content || ''}`).toLowerCase()
@@ -1796,58 +1799,67 @@ export const searchPublicContent = async (rawQuery) => {
return { tags, posts, pages }
}
const searchPattern = `%${searchTextQuery}%`
const searchPrefixPattern = `${searchTextQuery}%`
const tagRows = await sql`
SELECT name, slug
FROM tags
WHERE
position(lower(${q}) in lower(name)) > 0
OR position(lower(${q}) in lower(slug)) > 0
ORDER BY sort_order ASC, name ASC
lower(name) LIKE ${searchPattern}
OR lower(slug) LIKE ${searchPattern}
ORDER BY
CASE
WHEN lower(name) = ${searchTextQuery} OR lower(slug) = ${searchTextQuery} THEN 0
WHEN lower(name) LIKE ${searchPrefixPattern} OR lower(slug) LIKE ${searchPrefixPattern} THEN 1
ELSE 2
END,
sort_order ASC,
name ASC
LIMIT ${SEARCH_TAG_LIMIT}
`
const postRows = await sql`
SELECT posts.slug, posts.title, posts.excerpt, posts.content
SELECT posts.slug, posts.title, posts.excerpt
FROM posts
WHERE posts.status = 'published'
AND (posts.published_at IS NULL OR posts.published_at <= now())
AND (
position(lower(${q}) in lower(posts.title)) > 0
OR position(lower(${q}) in lower(coalesce(posts.excerpt, ''))) > 0
OR position(lower(${q}) in lower(posts.content)) > 0
)
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
LIMIT ${SEARCH_POST_CANDIDATE_LIMIT}
AND posts.search_text LIKE ${searchPattern}
ORDER BY
CASE
WHEN lower(posts.title) = ${searchTextQuery} THEN 0
WHEN lower(posts.title) LIKE ${searchPrefixPattern} THEN 1
WHEN lower(coalesce(posts.excerpt, '')) LIKE ${searchPrefixPattern} THEN 2
ELSE 3
END,
posts.published_at DESC NULLS LAST,
posts.created_at DESC
LIMIT ${SEARCH_POST_LIMIT}
`
const pageRows = await sql`
SELECT slug, title, content, listing_section, listing_title, listing_description
SELECT slug, title, listing_section, listing_title, listing_description
FROM pages
WHERE status = 'published'
AND is_listed = true
AND (
position(lower(${q}) in lower(title)) > 0
OR position(lower(${q}) in lower(coalesce(listing_section, ''))) > 0
OR position(lower(${q}) in lower(coalesce(listing_title, ''))) > 0
OR position(lower(${q}) in lower(coalesce(listing_description, ''))) > 0
OR position(lower(${q}) in lower(content)) > 0
)
ORDER BY is_featured DESC, listing_section ASC, listing_sort_order ASC, updated_at DESC
AND search_text LIKE ${searchPattern}
ORDER BY
CASE
WHEN lower(coalesce(listing_title, title)) = ${searchTextQuery} THEN 0
WHEN lower(coalesce(listing_title, title)) LIKE ${searchPrefixPattern} THEN 1
WHEN lower(coalesce(listing_description, '')) LIKE ${searchPrefixPattern} THEN 2
ELSE 3
END,
is_featured DESC,
listing_section ASC,
listing_sort_order ASC,
updated_at DESC
LIMIT ${SEARCH_PAGE_LIMIT}
`
const needle = q.toLowerCase()
const posts = postRows
.filter((row) => {
const hay = normalizeSearchContent(`${row.title}\n${row.excerpt || ''}\n${row.content || ''}`).toLowerCase()
return hay.includes(needle)
})
.slice(0, SEARCH_POST_LIMIT)
return {
tags: tagRows.map((row) => ({ name: row.name, slug: row.slug })),
posts: posts.map((row) => ({
posts: postRows.map((row) => ({
slug: row.slug,
title: row.title,
excerpt: row.excerpt || ''