From 8a774a187428863b61ba1822c2da12370941dc64 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 3 Jul 2026 18:54:08 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EB=AC=BC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=9D=B4=EB=8F=99=20=EC=A7=80=EC=97=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=A0=84=EC=B2=B4=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=A0=9C=EA=B1=B0=20(v1.10.37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 본문만 블로킹 조회, 태그·광고는 lazy로 전환 - 이전·다음 글을 상세 API에서 window 함수로 계산해 응답에 포함 - 전체 게시물(본문 포함) 조회 제거, 광고 site-settings 캐시 재사용 Co-authored-by: Cursor --- docs/spec.md | 2 +- docs/update.md | 7 +++ package.json | 2 +- pages/post/[slug].vue | 24 +++----- server/api/posts/[slug].get.js | 17 ++++-- server/repositories/content-repository.js | 67 +++++++++++++++++++++++ 6 files changed, 95 insertions(+), 24 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 07ceb3a..5c9161f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -582,7 +582,7 @@ components/content/ ### 공개 API (`/api/`) - `GET /api/posts` - 게시물 목록. `limit`, `featured=1`, `tag=:slug` 쿼리로 서버 목록 범위를 제한할 수 있다. `paginated=1&page=N&limit=N`이면 `{ items, pagination }` 형태로 응답하고, 없으면 기존처럼 배열을 반환한다. -- `GET /api/posts/:slug` - 게시물 상세 +- `GET /api/posts/:slug` - 게시물 상세. 응답에 목록과 같은 발행일 정렬 기준의 인접 글을 `previous`(더 최신)·`next`(더 예전) 객체(`{ title, slug }` 또는 `null`)로 포함해, 게시물 상세 하단 이전·다음 글 이동에 전체 목록 조회 없이 바로 사용한다. - `GET /api/posts/:slug/comments` - 게시물 댓글 목록 - `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단) - `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요) diff --git a/docs/update.md b/docs/update.md index 8e65eb5..e0463b0 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.10.37 + +- 게시물 상세 이동 지연 개선: 본문만 블로킹 조회, 태그·광고는 lazy로 전환해 이동 후 채움. +- 이전·다음 글 계산을 위해 받던 전체 게시물(본문 포함) 조회 제거. +- 상세 API가 window 함수(LAG/LEAD)로 인접 글 `previous`·`next`를 계산해 응답에 포함(`getAdjacentPosts` 추가). +- 광고용 `site-settings`를 `app.vue`와 같은 키로 재사용해 중복 요청 제거. + ## v1.10.36 - 게시물 카드 보조 피드백 추가: 제목 강조와 함께 썸네일 은은한 확대(데스크톱 hover 기기·모바일 `:active` 눌림). diff --git a/package.json b/package.json index fcf04e2..22a3514 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.36", + "version": "1.10.37", "private": true, "type": "module", "imports": { diff --git a/pages/post/[slug].vue b/pages/post/[slug].vue index 7ecc5e9..88b61b5 100644 --- a/pages/post/[slug].vue +++ b/pages/post/[slug].vue @@ -8,14 +8,15 @@ definePageMeta({ const route = useRoute() const slug = computed(() => String(route.params.slug || '')) +// 본문은 화면 구성의 필수 데이터이므로 블로킹으로 받아 이동 시점에 완성된 글을 보여준다. +// 이전·다음 글은 상세 응답에 포함되어 별도 목록 요청 없이 즉시 표시된다. const { data: post } = await useFetch(() => `/api/posts/${slug.value}`) -const { data: tags } = await useFetch('/api/tags?tagType=all', { +// 태그 표시명/색, 광고는 이동을 막지 않도록 lazy로 받아 이동 후 채운다. +const { data: tags } = useLazyFetch('/api/tags?tagType=all', { default: () => [] }) -const { data: posts } = await useFetch('/api/posts', { - default: () => [] -}) -const { data: siteSettings } = await useFetch('/api/site-settings', { +const { data: siteSettings } = useLazyFetch('/api/site-settings', { + key: 'site-settings-public', default: () => ({}) }) const postToc = useState('post-detail-toc', () => []) @@ -59,21 +60,12 @@ const canEditPost = computed(() => Boolean( )) const postEditPath = computed(() => `/admin/posts/${post.value.id}`) -const currentIndex = computed(() => posts.value.findIndex((item) => item.slug === post.value.slug)) const previousPost = computed(() => { - if (currentIndex.value <= 0) { - return null - } - - const candidate = posts.value[currentIndex.value - 1] + const candidate = post.value?.previous return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null }) const nextPost = computed(() => { - if (currentIndex.value === -1 || currentIndex.value >= posts.value.length - 1) { - return null - } - - const candidate = posts.value[currentIndex.value + 1] + const candidate = post.value?.next return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null }) diff --git a/server/api/posts/[slug].get.js b/server/api/posts/[slug].get.js index 09bcf0b..334c74a 100644 --- a/server/api/posts/[slug].get.js +++ b/server/api/posts/[slug].get.js @@ -1,19 +1,20 @@ -import { getPostBySlug } from '../../repositories/content-repository' +import { getAdjacentPosts, getPostBySlug } from '../../repositories/content-repository' import { canAccessMembershipContent } from '../../repositories/member-repository' import { getMemberSession } from '../../utils/member-auth' /** * 공개 게시물 상세 API * @param {import('h3').H3Event} event - 요청 이벤트 - * @returns {Object} 게시물 상세 + * @returns {Object} 게시물 상세(이전·다음 글 포함) */ export default defineEventHandler(async (event) => { const slug = getRouterParam(event, 'slug') const session = getMemberSession(event) const includeMembership = await canAccessMembershipContent(session?.userId) - const post = await getPostBySlug(slug, { - includeMembership - }) + const [post, adjacent] = await Promise.all([ + getPostBySlug(slug, { includeMembership }), + getAdjacentPosts(slug, { includeMembership }) + ]) if (!post) { throw createError({ @@ -22,5 +23,9 @@ export default defineEventHandler(async (event) => { }) } - return post + return { + ...post, + previous: adjacent.previous, + next: adjacent.next + } }) diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js index 13610b2..1ea8262 100644 --- a/server/repositories/content-repository.js +++ b/server/repositories/content-repository.js @@ -1085,6 +1085,73 @@ export const getPostBySlug = async (slug, { includeMembership = false } = {}) => return rows[0] ? mapPostRow(rows[0]) : null } +/** + * 공개 게시물 상세의 이전(더 최신)·다음(더 예전) 글을 조회한다. + * 목록과 같은 정렬(발행일 DESC) 기준으로 인접 글의 제목·슬러그만 반환한다. + * @param {string} slug - 기준 게시물 슬러그 + * @param {{ includeMembership?: boolean }} [options] - 조회 옵션 + * @returns {Promise<{ previous: { title: string, slug: string } | null, next: { title: string, slug: string } | null }>} 인접 글 + */ +export const getAdjacentPosts = async (slug, { includeMembership = false } = {}) => { + const sql = getPostgresClient() + + if (!sql) { + const samplePosts = getSamplePosts() + .filter((post) => post.status === 'published' && (!post.publishedAt || new Date(post.publishedAt) <= new Date())) + .sort((left, right) => { + const leftTime = new Date(left.publishedAt || left.createdAt || 0).getTime() + const rightTime = new Date(right.publishedAt || right.createdAt || 0).getTime() + return rightTime - leftTime + }) + const index = samplePosts.findIndex((post) => post.slug === slug) + if (index === -1) { + return { previous: null, next: null } + } + const toNeighbor = (post) => (post ? { title: post.title, slug: post.slug } : null) + return { + previous: index > 0 ? toNeighbor(samplePosts[index - 1]) : null, + next: index < samplePosts.length - 1 ? toNeighbor(samplePosts[index + 1]) : null + } + } + + const rows = await sql` + WITH ordered AS ( + SELECT + posts.slug, + posts.title, + LAG(posts.slug) OVER w AS previous_slug, + LAG(posts.title) OVER w AS previous_title, + LEAD(posts.slug) OVER w AS next_slug, + LEAD(posts.title) OVER w AS next_title + FROM posts + WHERE ( + posts.status = 'published' + OR (${includeMembership} = true AND posts.status = 'members') + ) + AND ( + posts.status = 'members' + OR posts.published_at IS NULL + OR posts.published_at <= now() + ) + WINDOW w AS (ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC) + ) + SELECT previous_slug, previous_title, next_slug, next_title + FROM ordered + WHERE slug = ${slug} + LIMIT 1 + ` + + const row = rows[0] + if (!row) { + return { previous: null, next: null } + } + + return { + previous: row.previous_slug ? { title: row.previous_title, slug: row.previous_slug } : null, + next: row.next_slug ? { title: row.next_title, slug: row.next_slug } : null + } +} + /** * 공개 고정 페이지 목록 조회 * @param {{ listedOnly?: boolean, limitPerSection?: number|null }} [options] - 조회 옵션