Skip to content

Commit be42fce

Browse files
committed
refactor(home): 최신순 단일 피드로 단순화
홈의 정렬 선택 UI와 런타임 인기 조회 경로를 제거했습니다. 카테고리 필터는 저장소의 최신순을 보존하고 홈을 정적 생성으로 되돌렸습니다. 글 상세의 누적 조회수와 중복 방지 계약은 유지했습니다.
1 parent 28dc694 commit be42fce

22 files changed

Lines changed: 112 additions & 605 deletions

app/page.test.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

app/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import type { Metadata } from 'next';
22
import { SITE_DESCRIPTION, SITE_NAME, createSiteUrl } from '@/site/config/site';
33

4-
export const dynamic = 'force-dynamic';
5-
64
const homeUrl = createSiteUrl();
75

86
export const metadata: Metadata = {

blog/api/view.test.ts

Lines changed: 2 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import { cookies, headers } from 'next/headers';
33
import { getSupabaseServerClient } from '@/infra/integrations/supabase';
4-
import {
5-
getPopularViewsInRecentDays,
6-
getViewCount,
7-
incrementView,
8-
trackView,
9-
} from './view';
4+
import { getViewCount, incrementView, trackView } from './view';
105

116
vi.mock('@/infra/integrations/supabase', () => ({
127
getSupabaseServerClient: vi.fn(),
@@ -28,9 +23,6 @@ type SupabaseLike = {
2823
__queryMock: {
2924
select: ReturnType<typeof vi.fn>;
3025
eq: ReturnType<typeof vi.fn>;
31-
gte: ReturnType<typeof vi.fn>;
32-
order: ReturnType<typeof vi.fn>;
33-
limit: ReturnType<typeof vi.fn>;
3426
maybeSingle: ReturnType<typeof vi.fn>;
3527
};
3628
};
@@ -51,9 +43,6 @@ function createQueryMock(payload: {
5143
return {
5244
select: vi.fn().mockReturnThis(),
5345
eq: vi.fn().mockReturnThis(),
54-
gte: vi.fn().mockReturnThis(),
55-
order: vi.fn().mockReturnThis(),
56-
limit: vi.fn().mockResolvedValue(payload),
5746
maybeSingle: vi.fn().mockResolvedValue(payload),
5847
};
5948
}
@@ -78,23 +67,6 @@ function createSupabaseMock(options: {
7867
const mockedGetSupabase = vi.mocked(getSupabaseServerClient);
7968
const mockedCookies = vi.mocked(cookies);
8069
const mockedHeaders = vi.mocked(headers);
81-
const originalNextPhase = process.env.NEXT_PHASE;
82-
const originalNpmLifecycleEvent = process.env.npm_lifecycle_event;
83-
84-
function restoreBuildPhaseEnv() {
85-
if (originalNextPhase === undefined) {
86-
delete process.env.NEXT_PHASE;
87-
} else {
88-
process.env.NEXT_PHASE = originalNextPhase;
89-
}
90-
91-
if (originalNpmLifecycleEvent === undefined) {
92-
delete process.env.npm_lifecycle_event;
93-
} else {
94-
process.env.npm_lifecycle_event = originalNpmLifecycleEvent;
95-
}
96-
}
97-
9870
function createCookieStoreMock(
9971
visitorId: string | null = null
10072
): CookieStoreLike {
@@ -118,7 +90,6 @@ function createHeaderStoreMock(
11890

11991
describe('view actions', () => {
12092
beforeEach(() => {
121-
restoreBuildPhaseEnv();
12293
vi.clearAllMocks();
12394
mockedCookies.mockResolvedValue(createCookieStoreMock());
12495
mockedHeaders.mockResolvedValue(
@@ -130,10 +101,6 @@ describe('view actions', () => {
130101
);
131102
});
132103

133-
afterEach(() => {
134-
restoreBuildPhaseEnv();
135-
});
136-
137104
it('increments view when slug is valid and client exists', async () => {
138105
const client = createSupabaseMock({
139106
queryPayload: { data: { count: 10 }, error: null },
@@ -358,93 +325,4 @@ describe('view actions', () => {
358325
})
359326
);
360327
});
361-
362-
it('fetches recent daily view totals through the popular views rpc', async () => {
363-
const client = createSupabaseMock({
364-
queryPayload: { data: null, error: null },
365-
rpcPayload: {
366-
data: [
367-
{
368-
slug: 'a',
369-
count: 10,
370-
updated_at: '2026-03-05T00:00:00.000Z',
371-
},
372-
{
373-
slug: 'b',
374-
count: 10,
375-
updated_at: '2026-03-04T00:00:00.000Z',
376-
},
377-
],
378-
error: null,
379-
},
380-
});
381-
mockedGetSupabase.mockReturnValue(client);
382-
383-
const result = await getPopularViewsInRecentDays(30, 5);
384-
385-
expect(client.rpc).toHaveBeenCalledWith('get_popular_views', {
386-
days_input: 30,
387-
limit_input: 5,
388-
});
389-
expect(result).toEqual([
390-
{ slug: 'a', count: 10, updated_at: '2026-03-05T00:00:00.000Z' },
391-
{ slug: 'b', count: 10, updated_at: '2026-03-04T00:00:00.000Z' },
392-
]);
393-
});
394-
395-
it('returns empty list when supabase is unavailable for popular query', async () => {
396-
mockedGetSupabase.mockReturnValue(null);
397-
398-
const result = await getPopularViewsInRecentDays(30, 5);
399-
400-
expect(result).toEqual([]);
401-
});
402-
403-
it('returns empty list when popular query fails', async () => {
404-
const client = createSupabaseMock({
405-
queryPayload: { data: null, error: null },
406-
rpcPayload: {
407-
data: null,
408-
error: { message: 'failed' },
409-
},
410-
});
411-
mockedGetSupabase.mockReturnValue(client);
412-
413-
const result = await getPopularViewsInRecentDays(30, 5);
414-
415-
expect(result).toEqual([]);
416-
});
417-
418-
it('skips popular query during production build phase', async () => {
419-
process.env.NEXT_PHASE = 'phase-production-build';
420-
mockedGetSupabase.mockReturnValue(
421-
createSupabaseMock({
422-
queryPayload: {
423-
data: [{ slug: 'a', count: 10, updated_at: '2026-03-05' }],
424-
error: null,
425-
},
426-
rpcPayload: { data: 0, error: null },
427-
})
428-
);
429-
430-
const result = await getPopularViewsInRecentDays(30, 5);
431-
432-
expect(result).toEqual([]);
433-
expect(mockedGetSupabase).not.toHaveBeenCalled();
434-
});
435-
436-
it('normalizes invalid day/limit inputs for popular query', async () => {
437-
const client = createSupabaseMock({
438-
queryPayload: { data: null, error: null },
439-
rpcPayload: { data: [], error: null },
440-
});
441-
mockedGetSupabase.mockReturnValue(client);
442-
443-
await getPopularViewsInRecentDays(0, 0);
444-
445-
expect(client.rpc).toHaveBeenCalledWith('get_popular_views', {
446-
days_input: 1,
447-
limit_input: 1,
448-
});
449-
});
450328
});

blog/api/view.ts

Lines changed: 0 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const VIEW_FINGERPRINT_SALT =
1111
`${SITE_BRAND.technicalName}-view-fingerprint-v1`;
1212
const VIEW_FALLBACK_VISITOR_COOKIE = 'view_visitor_id';
1313
const VIEW_FALLBACK_VISITOR_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
14-
const NEXT_PHASE_PRODUCTION_BUILD = 'phase-production-build';
1514
const shouldLogViewIssues = process.env.NODE_ENV !== 'test';
1615

1716
function readErrorField(error: unknown, field: 'code' | 'message'): string {
@@ -55,13 +54,6 @@ function logViewError(message: string, error: unknown): void {
5554
);
5655
}
5756

58-
function isProductionBuildPhase(): boolean {
59-
return (
60-
process.env.NEXT_PHASE === NEXT_PHASE_PRODUCTION_BUILD ||
61-
process.env.npm_lifecycle_event === 'build'
62-
);
63-
}
64-
6557
function normalizeSlug(slug: string): string | null {
6658
const value = slug.trim();
6759
return value.length > 0 ? value : null;
@@ -80,14 +72,6 @@ function toCount(value: unknown): number | null {
8072
return null;
8173
}
8274

83-
function normalizePositiveInt(value: number, fallback: number): number {
84-
if (!Number.isFinite(value)) {
85-
return fallback;
86-
}
87-
88-
return Math.max(1, Math.floor(value));
89-
}
90-
9175
function readHeaderValue(
9276
headerStore: Awaited<ReturnType<typeof headers>>,
9377
names: string[]
@@ -186,12 +170,6 @@ function isLegacyIncrementViewSignatureError(error: {
186170
);
187171
}
188172

189-
export interface PopularViewEntry {
190-
slug: string;
191-
count: number;
192-
updated_at: string;
193-
}
194-
195173
async function readViewCount(slug: string): Promise<number | null> {
196174
const supabase = getSupabaseServerClient();
197175
if (!supabase) {
@@ -305,62 +283,3 @@ export async function trackView(slug: string): Promise<number | null> {
305283

306284
return readViewCount(normalizedSlug);
307285
}
308-
309-
export async function getPopularViewsInRecentDays(
310-
days: number,
311-
limit: number
312-
): Promise<PopularViewEntry[]> {
313-
if (isProductionBuildPhase()) {
314-
return [];
315-
}
316-
317-
const supabase = getSupabaseServerClient();
318-
if (!supabase) {
319-
logViewWarning(
320-
'Supabase env is missing. Returning empty popular view list.'
321-
);
322-
return [];
323-
}
324-
325-
const normalizedDays = normalizePositiveInt(days, 30);
326-
const normalizedLimit = normalizePositiveInt(limit, 5);
327-
const { data, error } = await supabase.rpc('get_popular_views', {
328-
days_input: normalizedDays,
329-
limit_input: normalizedLimit,
330-
});
331-
332-
if (error) {
333-
logViewError('Failed to fetch popular view entries.', error);
334-
return [];
335-
}
336-
337-
if (!Array.isArray(data)) {
338-
return [];
339-
}
340-
341-
return data
342-
.map((entry) => {
343-
const count = toCount(entry.count);
344-
const slug = typeof entry.slug === 'string' ? entry.slug.trim() : '';
345-
const updatedAt =
346-
typeof entry.updated_at === 'string' ? entry.updated_at : '';
347-
348-
if (!slug || count === null || !updatedAt) {
349-
return null;
350-
}
351-
352-
return {
353-
slug,
354-
count,
355-
updated_at: updatedAt,
356-
} satisfies PopularViewEntry;
357-
})
358-
.filter((entry): entry is PopularViewEntry => entry !== null)
359-
.sort((a, b) => {
360-
if (a.count === b.count) {
361-
return a.updated_at < b.updated_at ? 1 : -1;
362-
}
363-
364-
return b.count - a.count;
365-
});
366-
}

blog/index.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,7 @@ export {
1515
type SeriesGroup,
1616
type SeriesSummary,
1717
} from './model/series-group';
18-
export {
19-
getPopularViewsInRecentDays,
20-
getViewCount,
21-
incrementView,
22-
trackView,
23-
type PopularViewEntry,
24-
} from './api/view';
18+
export { getViewCount, incrementView, trackView } from './api/view';
2519
export type {
2620
Feed,
2721
FeedData,

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Last updated: 2026-07-13
2222
- `docs/adr/0021-load-heavy-mdx-visualizations-on-demand.md`
2323
- `docs/adr/0022-default-new-posts-to-private.md`
2424
- `docs/adr/0023-run-the-release-quality-gate-in-ci.md`
25+
- `docs/adr/0024-use-latest-only-home-feed.md`
2526
- `docs/blog-quality-guide.md`
2627
- `docs/database/db-schema.md`
2728
- `docs/database/supabase-view-count.sql`

docs/adr/0018-use-semantic-surface-tokens-for-content-discovery.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# 0018. 콘텐츠 탐색 표면에 의미 기반 반경과 무그림자 상태를 사용한다
22

33
Date: 2026-07-13
4-
Status: Accepted
4+
Status: Accepted; sort-control clause amended by
5+
[0024](0024-use-latest-only-home-feed.md)
56

67
## 배경
78

docs/adr/0020-use-runtime-daily-views-for-popular-feed.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# 0020. 인기 피드를 런타임 일별 조회수로 계산한다
22

33
Date: 2026-07-13
4-
Status: Accepted
4+
Status: Superseded by [0024](0024-use-latest-only-home-feed.md)
55
Supersedes: 0015
66

77
## 배경
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# 0024. 홈 피드를 최신순 단일 경로로 유지한다
2+
3+
Date: 2026-07-13
4+
Status: Accepted
5+
Supersedes: 0020
6+
Amends: 0018의 정렬 컨트롤과 회귀 테스트 결정
7+
8+
## 배경
9+
10+
홈의 인기순은 최근 30일 조회수 집계를 위해 동적 route, Supabase RPC, 일별
11+
조회수 테이블과 클라이언트 정렬 상태를 요구한다. 그러나 운영 환경에는 해당
12+
RPC가 적용되지 않아 인기순을 선택해도 최신순으로 fallback되고 있었다.
13+
14+
개인 블로그의 기본 탐색에서는 최신 기록을 시간순으로 보는 경로가 더 예측
15+
가능하다. 낮은 사용 가치에 비해 런타임 외부 의존, 배포 순서와 실패 fallback을
16+
유지하는 비용이 크다.
17+
18+
## 결정
19+
20+
- 홈 글 목록은 발행일 내림차순만 사용한다.
21+
- 최신순과 인기순 선택 UI 및 클라이언트 정렬 상태를 제거한다.
22+
- 카테고리 필터는 유지하며 필터 결과도 저장소가 제공한 최신순을 보존한다.
23+
- 홈에서 Supabase 인기 조회수를 읽지 않고 정적 생성 가능 상태로 되돌린다.
24+
- 글 상세의 누적 조회수와 중복 방지, Umami 분석은 ADR 0007에 따라 유지한다.
25+
- ADR 0020 전용 일별 인기 집계 API와 저장소 계약은 제거한다.
26+
- ADR 0018의 의미 기반 반경과 무그림자 표면 결정은 유지하되, 정렬 컨트롤과
27+
해당 회귀 테스트 결정만 이 ADR로 대체한다.
28+
29+
## 결과
30+
31+
- 홈의 순서와 버튼 상태가 어긋나는 fallback 문제가 사라진다.
32+
- 홈 요청마다 Supabase RPC를 실행하지 않는다.
33+
- 인기 집계를 위한 DB 배포 선행 조건이 사라진다.
34+
- 오래된 글의 재발견은 카테고리, 검색과 시리즈 경로에 의존한다.
35+
- 인기순을 다시 도입하려면 실제 제품 필요와 데이터 계약을 새 ADR에서 다시
36+
결정해야 한다.
37+
38+
## 검토한 대안
39+
40+
- UI만 숨기고 인기 집계 인프라 유지: 사용처 없는 코드와 DB 계약이 남는다.
41+
- 운영 RPC를 적용해 인기순 복구: 정확성은 해결하지만 현재 필요한 탐색
42+
경로보다 운영 비용이 크다.
43+
- 최신순을 기본값으로 두고 인기순 유지: 단일 탐색 경로로 단순화하려는 목적과
44+
맞지 않는다.
45+
- Umami 인기 페이지를 홈에 사용: 분석 도구와 사용자 UI를 결합하고 별도 실패
46+
계약이 필요하다.
47+
48+
## Related History
49+
50+
- `9379593`: 최근 30일 인기 조회 집계 도입

0 commit comments

Comments
 (0)