diff --git a/docs/exec-plans/active/2026-04-17-home-feed-layout-review.md b/docs/exec-plans/active/2026-04-17-home-feed-layout-review.md
new file mode 100644
index 00000000..5ed21eb8
--- /dev/null
+++ b/docs/exec-plans/active/2026-04-17-home-feed-layout-review.md
@@ -0,0 +1,167 @@
+# 홈 메인 피드형 레이아웃 전환 검토
+
+## Goal
+
+메인 페이지를 큰 히어로 중심 소개형 랜딩에서, 콘텐츠 접근성이 높은
+피드형 홈으로 전환하기 위한 방향과 판단 근거를 정리한다.
+
+이번 문서는 실제 코드 구현보다 앞선 실행 메모 성격의 문서다. 홈의 역할을
+`자기소개 우선`에서 `콘텐츠 발견 우선`으로 재정의하고, 어떤 구조와 원칙으로
+전환할지 결정 완료 상태로 남긴다.
+
+## Scope
+
+- 현재 홈 구조의 한계와 전환 배경 정리
+- TO-BE 레이아웃과 정보 구조 정리
+- 프로필 이미지 없이도 성립하는 브랜딩 방식 정리
+- 외부 참고 서비스 관점의 평가 정리
+- 구현 시 바로 참고할 수 있는 메모 정리
+
+이번 범위에는 실제 화면 구현, 컴포넌트 수정, 라우팅 변경은 포함하지 않는다.
+
+## Constraints
+
+- 기존 코드 구조는 유지한다.
+ - `src/features/home`
+ - `src/features/blog`
+ - `src/shared`
+- 메인 홈은 피드 중심으로 재구성하되, 블로그 전체를 평범한 게시판처럼
+ 보이게 만들면 안 된다.
+- 우측 브랜딩 영역은 프로필 이미지 의존성을 두지 않는다.
+- 첫 버전에서 보조 위젯은 최소화한다.
+- 상단 탐색 컨트롤은 `검색 + 정렬 + 카테고리`까지 포함한다.
+- 모바일에서는 1컬럼 중심으로 자연스럽게 접혀야 한다.
+
+## Milestones
+
+### 1. 배경
+
+현재 홈은 큰 히어로를 중심으로 구성되어 있고, 사용자가 먼저 보게 되는 것은
+소개와 브랜딩이다. 이 구조는 처음 방문한 사람에게 정체성을 전달하는 데는
+유리하지만, 글을 바로 읽고 싶은 사용자 입장에서는 첫 클릭까지 거리가 있다.
+
+블로그 글이 충분히 쌓인 시점에는 "내가 누구인가"보다 "무엇을 꾸준히 쓰는가"를
+빠르게 보여주는 편이 더 설득력 있다. 따라서 홈의 중심축을 소개에서 피드로
+옮기는 것이 적절하다고 판단했다.
+
+### 2. AS-IS 진단
+
+- 브랜딩 전달은 강하지만 콘텐츠 탐색이 느리다.
+- 홈의 중심이 소개형 섹션이라 최신 글 소비 흐름이 약하다.
+- 글이 늘어날수록 홈의 정보 구조가 확장성보다는 랜딩 성격에 머무를 수 있다.
+- 대표 글, 시리즈, 이력 요약이 분리 섹션으로 존재해 한 화면에서의 집중도가
+ 분산된다.
+
+### 3. TO-BE 방향
+
+메인 홈은 3컬럼 구조로 재구성한다.
+
+- 좌측: 탐색
+ - 카테고리 또는 핵심 진입점
+ - 데스크톱에서는 고정 또는 스티키
+ - 모바일에서는 상단으로 접거나 가로 스크롤 형태로 축약
+- 중앙: 피드 메인
+ - 작은 인트로 스트립
+ - 검색 입력
+ - 정렬 컨트롤
+ - 카테고리 필터
+ - 썸네일, 제목, 날짜, 요약, 태그를 포함한 포스트 피드
+- 우측: 브랜딩
+ - 이미지 없는 텍스트형 프로필 카드
+ - 이름, 한 줄 소개, 역할/관심사
+ - 이력서, GitHub, 이메일 등 핵심 링크
+
+핵심 원칙은 `중앙 피드가 주인공`이어야 한다는 점이다. 좌우 영역은 메인을
+보조해야 하며, 메인보다 더 튀거나 더 많은 판단을 요구하면 안 된다.
+
+### 4. 텍스트 기반 브랜딩 카드 원칙
+
+우측 카드는 프로필 이미지를 넣지 않아도 매력적으로 보여야 한다.
+
+- 얼굴 이미지 대신 문장과 타이포로 인상을 만든다.
+- 이름보다 "무엇을 쓰는 사람인가"가 더 빨리 읽혀야 한다.
+- 링크는 많기보다 선명해야 한다.
+- 카드 자체가 화려하기보다 중앙 피드의 신뢰도를 높이는 역할을 해야 한다.
+
+즉, 브랜딩을 제거하는 것이 아니라 압축하는 방향이다.
+
+### 5. 외부 관점 평가
+
+아래 평가는 공개된 제품 패턴과 문서를 바탕으로 정리한 추론이다.
+
+#### 토스 관점
+
+토스 계열의 UX 감각에서는 탐색 진입점의 명확성과 사용자의 이동 비용 감소가
+긍정적으로 평가될 가능성이 높다. 큰 소개보다 바로 읽을 수 있는 글 피드를
+앞세우는 방향은 서비스형 탐색 경험과 잘 맞는다.
+
+다만 주변 위젯이 많아지거나 좌우 정보가 과도하면, 메인 액션이 흐려진다는
+지적을 받을 수 있다.
+
+#### Medium / Substack 관점
+
+콘텐츠 플랫폼 관점에서는 메인 화면의 주인공이 콘텐츠여야 한다는 점에서
+방향성이 맞다. 특히 최신 글, 발견, 추천의 흐름이 홈에 직접 드러나는 구조는
+자연스럽다.
+
+반대로 모든 카드가 같은 무게로만 쌓이면 에디토리얼 위계가 약해 보일 수 있다.
+첫 번째 대표 글 또는 고정 글에 대한 강조가 추후 필요할 수 있다.
+
+#### LinkedIn / SNS 크리에이터 관점
+
+작성자 정체성은 남겨야 하지만, 압축된 형태여야 한다는 평가가 예상된다.
+프로필 이미지를 빼는 선택은 가능하지만, 대신 한 줄 소개와 역할 정의가 더
+정확해야 한다.
+
+잘 되면 성숙한 텍스트 기반 브랜딩으로 보이지만, 약하면 익명성 높은 목록형
+페이지처럼 보일 위험이 있다.
+
+### 6. 최종 의사결정
+
+- 큰 히어로는 줄인다.
+- 홈의 중심은 중앙 피드로 바꾼다.
+- 우측은 이미지 없는 텍스트형 프로필 카드로 간다.
+- 첫 버전은 보조 위젯을 넣지 않는다.
+- 상단 컨트롤은 `검색 + 정렬 + 카테고리`를 포함한다.
+- 홈은 소개형 랜딩보다 콘텐츠 접근성이 우선인 구조로 전환한다.
+
+### 7. 구현 메모
+
+- `HomePage`는 기존의 큰 `HeroSection` 의존을 줄이고, 홈 전용 레이아웃을
+ 중심으로 재구성한다.
+- 글 데이터는 기존 블로그 도메인과 리스트/카드 컴포넌트를 최대한 재사용한다.
+- 리스트 UI는 현재 `PostList`와 `PostCard`의 `list` 변형을 기반으로 조정한다.
+- 모바일에서는 좌우 컬럼을 접고 중앙 피드 우선 구조를 유지한다.
+- 첫 구현에서는 `Friends`, `Old Blog` 같은 부가 위젯은 제외한다.
+
+## Verification
+
+- 홈의 목적이 `소개 우선`에서 `콘텐츠 발견 우선`으로 명확히 전환되었는지
+ 문서 수준에서 설명 가능해야 한다.
+- 좌측 탐색, 중앙 피드, 우측 브랜딩의 역할이 중복 없이 분리되어 있어야 한다.
+- 프로필 이미지 없이도 브랜딩 카드가 성립하는 이유가 문서에 드러나야 한다.
+- 외부 참고 서비스 관점에서의 긍정 포인트와 리스크가 함께 정리되어 있어야
+ 한다.
+- 구현자가 이 문서만 읽고 홈 구조 개편의 의도와 우선순위를 이해할 수 있어야
+ 한다.
+
+## Rollback
+
+실제 구현 단계에서 아래 신호가 보이면 피드형 전환 강도를 낮춘다.
+
+- 좌우 영역 때문에 메인 피드 집중도가 떨어지는 경우
+- 검색/정렬/카테고리가 한 화면에서 과밀하게 느껴지는 경우
+- 텍스트형 브랜딩 카드가 충분한 존재감을 만들지 못하는 경우
+- 전체 홈이 generic한 블로그 목록처럼 보이는 경우
+
+이 경우 롤백 방향은 `완전한 피드형 폐기`가 아니라, 상단 compact intro를 조금
+키우거나 대표 글 1개를 더 강하게 강조하는 방식으로 위계를 보정하는 것이다.
+
+## References
+
+- [Toss Navigation Score 사례](https://toss.tech/article/Toss_Navigation_Score)
+- [Toss Engineering 카테고리](https://toss.tech/category/engineering)
+- [Medium homepage help](https://help.medium.com/hc/en-us/articles/115012586467-Your-homepage)
+- [Substack app home guide](https://support.substack.com/hc/en-us/articles/19291693034004-Getting-started-on-the-Substack-app)
+- [Substack homepage layouts](https://support.substack.com/hc/en-us/articles/360039015892-How-do-I-switch-my-publication-s-homepage-to-a-different-layout)
+- [LinkedIn articles/newsletters guide](https://members.linkedin.com/content/dam/me/members/en-us/pdf/articlesnewsletters.pdf)
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f1dbf6ec..6f837312 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -4,6 +4,8 @@ import '@/styles/globals.css';
import '@/styles/tossface.css';
import AppProviders from '@/core/providers/AppProviders';
+import { getSortedFeedData } from '@/features/blog/services/post-repository';
+import { AppShell } from '@/shared/layout';
import {
SITE_AUTHOR,
SITE_DESCRIPTION,
@@ -61,6 +63,8 @@ export default function RootLayout({
}: {
children: ReactNode;
}) {
+ const posts = getSortedFeedData();
+
return (
@@ -81,7 +85,9 @@ export default function RootLayout({
- {children}
+
diff --git a/src/features/blog/ui/components/PostCard.test.tsx b/src/features/blog/ui/components/PostCard.test.tsx
index da4ec606..bcd287da 100644
--- a/src/features/blog/ui/components/PostCard.test.tsx
+++ b/src/features/blog/ui/components/PostCard.test.tsx
@@ -88,4 +88,23 @@ describe('PostCard', () => {
'/images/test.png'
);
});
+
+ it('shows category, series title, and tags in list variant', () => {
+ const detailedPost: FeedData = {
+ ...basePost,
+ tags: ['redis', 'cache'],
+ series: {
+ id: 'redis',
+ title: 'Redis 완전정복',
+ order: 1,
+ },
+ };
+
+ render( );
+
+ expect(screen.getByText('Tech')).toBeInTheDocument();
+ expect(screen.getByText('Redis 완전정복')).toBeInTheDocument();
+ expect(screen.getByText('#redis')).toBeInTheDocument();
+ expect(screen.getByText('#cache')).toBeInTheDocument();
+ });
});
diff --git a/src/features/blog/ui/components/PostCard/PostCard.tsx b/src/features/blog/ui/components/PostCard/PostCard.tsx
index 6767449d..cefd1ea7 100644
--- a/src/features/blog/ui/components/PostCard/PostCard.tsx
+++ b/src/features/blog/ui/components/PostCard/PostCard.tsx
@@ -73,6 +73,7 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
day: 'numeric',
});
const readingTimeLabel = post.readingTime ? `약 ${post.readingTime}분` : null;
+ const visibleTags = post.tags?.slice(0, 3) ?? [];
if (variant === 'featured') {
return (
@@ -118,8 +119,13 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
- {post.series ? 'Series' : post.category}
+ {post.category}
+ {post.series ? (
+
+ {post.series.title}
+
+ ) : null}
{formattedDate}
{readingTimeLabel && (
@@ -135,6 +141,18 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
{post.description}
+ {visibleTags.length > 0 ? (
+
+ {visibleTags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ ) : null}
{post.image ? (
diff --git a/src/features/blog/ui/pages/BlogListPage.tsx b/src/features/blog/ui/pages/BlogListPage.tsx
index e90911c0..69d159af 100644
--- a/src/features/blog/ui/pages/BlogListPage.tsx
+++ b/src/features/blog/ui/pages/BlogListPage.tsx
@@ -1,6 +1,6 @@
import { Metadata } from 'next';
import { getSortedFeedData } from '@/features/blog/services/post-repository';
-import { Header, Container } from '@/shared/layout';
+import { Container } from '@/shared/layout';
import BlogListClient from './BlogListClient';
export const metadata: Metadata = {
@@ -12,23 +12,19 @@ export default function BlogPage() {
const allPosts = getSortedFeedData();
return (
- <>
-
+
+
+
+
+ Blog
+
+
+ 개발과 일상에 대한 이야기를 나눕니다
+
+
-
-
-
-
- Blog
-
-
- 개발과 일상에 대한 이야기를 나눕니다
-
-
-
-
-
-
- >
+
+
+
);
}
diff --git a/src/features/blog/ui/pages/BlogPostPage.tsx b/src/features/blog/ui/pages/BlogPostPage.tsx
index 55a4f5c6..16c7cb0a 100644
--- a/src/features/blog/ui/pages/BlogPostPage.tsx
+++ b/src/features/blog/ui/pages/BlogPostPage.tsx
@@ -9,7 +9,7 @@ import {
getMdxSource,
parseHeadingsFromMdx,
} from '@/features/blog/services/markdown-parser';
-import { Header, Container } from '@/shared/layout';
+import { Container } from '@/shared/layout';
import {
ReadingProgress,
TableOfContents,
@@ -92,7 +92,6 @@ export default async function BlogPostPage({
return (
<>
-
-
+
{/* Header */}
diff --git a/src/features/blog/ui/pages/EngineeringPage.tsx b/src/features/blog/ui/pages/EngineeringPage.tsx
index d9045b5a..e470b1a0 100644
--- a/src/features/blog/ui/pages/EngineeringPage.tsx
+++ b/src/features/blog/ui/pages/EngineeringPage.tsx
@@ -1,7 +1,7 @@
import { Metadata } from 'next';
import { Suspense } from 'react';
import { getSortedFeedData } from '@/features/blog/services/post-repository';
-import { Header, Container } from '@/shared/layout';
+import { Container } from '@/shared/layout';
import EngineeringPageClient from './EngineeringPageClient';
export const metadata: Metadata = {
@@ -15,25 +15,21 @@ export default function EngineeringPage() {
);
return (
- <>
-
+
+
+
-
-
-
-
-
-
-
-
-
- >
+
+
+
+
+
);
}
diff --git a/src/features/blog/ui/pages/EngineeringSeriesPage.tsx b/src/features/blog/ui/pages/EngineeringSeriesPage.tsx
index 5f42e550..a0b32813 100644
--- a/src/features/blog/ui/pages/EngineeringSeriesPage.tsx
+++ b/src/features/blog/ui/pages/EngineeringSeriesPage.tsx
@@ -2,7 +2,7 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getSeriesSummaries } from '@/features/blog/model/series-group';
import { getSortedFeedData } from '@/features/blog/services/post-repository';
-import { Header, Container } from '@/shared/layout';
+import { Container } from '@/shared/layout';
interface EngineeringSeriesPageProps {
seriesId: string;
@@ -31,63 +31,59 @@ export default function EngineeringSeriesPage({
}
return (
- <>
-
+
+
+
+ ←
+ Engineering으로 돌아가기
+
-
-
-
- ←
- Engineering으로 돌아가기
-
+
-
+
+ {seriesSummary.posts.map((post) => {
+ const order = post.series?.order ?? 0;
-
- {seriesSummary.posts.map((post) => {
- const order = post.series?.order ?? 0;
-
- return (
-
-
-
-
- {order}편 · {formatPostDate(post.date)}
-
-
- {post.title}
-
-
- {post.description}
-
-
-
- 글 보기
-
-
-
- );
- })}
-
-
-
- >
+ return (
+
+
+
+
+ {order}편 · {formatPostDate(post.date)}
+
+
+ {post.title}
+
+
+ {post.description}
+
+
+
+ 글 보기
+
+
+
+ );
+ })}
+
+
+
);
}
diff --git a/src/features/blog/ui/pages/LifePage.tsx b/src/features/blog/ui/pages/LifePage.tsx
index e19c1c4c..ca8b1788 100644
--- a/src/features/blog/ui/pages/LifePage.tsx
+++ b/src/features/blog/ui/pages/LifePage.tsx
@@ -1,6 +1,6 @@
import { Metadata } from 'next';
import { getSortedFeedData } from '@/features/blog/services/post-repository';
-import { Header, Container } from '@/shared/layout';
+import { Container } from '@/shared/layout';
import { PostList } from '@/features/blog/ui/components';
export const metadata: Metadata = {
@@ -12,23 +12,19 @@ export default function LifePage() {
const lifePosts = getSortedFeedData().filter((post) => post.category === 'Life');
return (
- <>
-
+
+
+
-
-
-
-
-
-
-
- >
+
+
+
);
}
diff --git a/src/features/blog/ui/pages/SeriesPage.tsx b/src/features/blog/ui/pages/SeriesPage.tsx
index 96a7dd1b..74e2b638 100644
--- a/src/features/blog/ui/pages/SeriesPage.tsx
+++ b/src/features/blog/ui/pages/SeriesPage.tsx
@@ -1,6 +1,6 @@
import { Metadata } from 'next';
import { getSortedFeedData } from '@/features/blog/services/post-repository';
-import { Header, Container } from '@/shared/layout';
+import { Container } from '@/shared/layout';
import { EmptyState } from '@/shared/ui';
import { getSeriesSummaries } from '@/features/blog/model/series-group';
import { SeriesHubList } from '@/features/blog/ui/components';
@@ -19,40 +19,36 @@ export default function SeriesPage() {
);
return (
- <>
-
+
+
+
-
-
-
-
- {seriesSummaries.length === 0 ? (
- 📚}
- title="아직 등록된 시리즈가 없어요"
- description="새로운 시리즈를 준비 중입니다. 먼저 블로그의 다른 글을 둘러보세요."
- size="sm"
- action={{
- label: 'Engineering 둘러보기',
- href: '/engineering',
- variant: 'secondary',
- }}
- />
- ) : (
-
- )}
-
-
- >
+ {seriesSummaries.length === 0 ? (
+ 📚}
+ title="아직 등록된 시리즈가 없어요"
+ description="새로운 시리즈를 준비 중입니다. 먼저 블로그의 다른 글을 둘러보세요."
+ size="sm"
+ action={{
+ label: 'Engineering 둘러보기',
+ href: '/engineering',
+ variant: 'secondary',
+ }}
+ />
+ ) : (
+
+ )}
+
+
);
}
diff --git a/src/features/home/model/home-feed.test.ts b/src/features/home/model/home-feed.test.ts
new file mode 100644
index 00000000..6fef3e06
--- /dev/null
+++ b/src/features/home/model/home-feed.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest';
+import type { FeedData } from '@/domains/post/model/types';
+import {
+ buildHomeCategoryCounts,
+ filterHomePosts,
+ type HomePopularView,
+} from './home-feed';
+
+function createPost(
+ slug: string,
+ category: FeedData['category'],
+ date: string,
+ overrides: Partial = {}
+): FeedData {
+ return {
+ slug,
+ title: slug,
+ description: `${slug} description`,
+ date,
+ category,
+ ...overrides,
+ };
+}
+
+describe('home-feed model', () => {
+ it('builds category counts for all home filters', () => {
+ const posts = [
+ createPost('one', 'Tech', '2026-04-01'),
+ createPost('two', 'Life', '2026-04-02'),
+ createPost('three', 'Tech', '2026-04-03'),
+ ];
+
+ expect(buildHomeCategoryCounts(posts)).toEqual({
+ All: 3,
+ Tech: 2,
+ Life: 1,
+ });
+ });
+
+ it('filters posts by query across title, description, tags, and series', () => {
+ const posts = [
+ createPost('redis', 'Tech', '2026-04-03', {
+ title: 'Redis 운영 회고',
+ tags: ['Cache'],
+ }),
+ createPost('travel', 'Life', '2026-04-02', {
+ title: '베트남 여행 후기',
+ }),
+ createPost('flink-series', 'Tech', '2026-04-01', {
+ title: '체크포인트 정리',
+ series: {
+ id: 'flink',
+ title: 'Flink 완전정복',
+ order: 1,
+ },
+ }),
+ ];
+
+ expect(
+ filterHomePosts(posts, [], {
+ query: 'cache',
+ category: 'All',
+ sortOrder: 'latest',
+ }).map((post) => post.slug)
+ ).toEqual(['redis']);
+
+ expect(
+ filterHomePosts(posts, [], {
+ query: 'flink',
+ category: 'All',
+ sortOrder: 'latest',
+ }).map((post) => post.slug)
+ ).toEqual(['flink-series']);
+ });
+
+ it('sorts by popularity first and falls back to latest order', () => {
+ const posts = [
+ createPost('older-popular', 'Tech', '2026-04-01'),
+ createPost('latest-normal', 'Tech', '2026-04-03'),
+ createPost('mid-popular', 'Life', '2026-04-02'),
+ ];
+ const popularViews: HomePopularView[] = [
+ { slug: 'mid-popular', count: 10 },
+ { slug: 'older-popular', count: 20 },
+ ];
+
+ expect(
+ filterHomePosts(posts, popularViews, {
+ query: '',
+ category: 'All',
+ sortOrder: 'popular',
+ }).map((post) => post.slug)
+ ).toEqual(['older-popular', 'mid-popular', 'latest-normal']);
+ });
+});
diff --git a/src/features/home/model/home-feed.ts b/src/features/home/model/home-feed.ts
new file mode 100644
index 00000000..3876b3fa
--- /dev/null
+++ b/src/features/home/model/home-feed.ts
@@ -0,0 +1,83 @@
+import type { FeedData } from '@/domains/post/model/types';
+import type { Category } from '@/features/blog/ui/components/CategoryFilter';
+
+export interface HomePopularView {
+ slug: string;
+ count: number;
+}
+
+export type HomeSortOrder = 'latest' | 'popular';
+
+interface HomeFeedOptions {
+ query: string;
+ category: Category;
+ sortOrder: HomeSortOrder;
+}
+
+export function buildHomeCategoryCounts(
+ posts: FeedData[]
+): Record {
+ return {
+ All: posts.length,
+ Tech: posts.filter((post) => post.category === 'Tech').length,
+ Life: posts.filter((post) => post.category === 'Life').length,
+ };
+}
+
+function buildSearchText(post: FeedData): string {
+ return [
+ post.title,
+ post.description,
+ post.category,
+ post.series?.title,
+ ...(post.tags ?? []),
+ ]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase();
+}
+
+function compareByDateDesc(a: FeedData, b: FeedData): number {
+ if (a.date === b.date) {
+ return a.slug.localeCompare(b.slug);
+ }
+
+ return a.date < b.date ? 1 : -1;
+}
+
+export function filterHomePosts(
+ posts: FeedData[],
+ popularViews: HomePopularView[],
+ { query, category, sortOrder }: HomeFeedOptions
+): FeedData[] {
+ const normalizedQuery = query.trim().toLowerCase();
+ const popularCountBySlug = new Map(
+ popularViews.map((entry) => [entry.slug, entry.count])
+ );
+
+ return [...posts]
+ .filter((post) => {
+ if (category !== 'All' && post.category !== category) {
+ return false;
+ }
+
+ if (!normalizedQuery) {
+ return true;
+ }
+
+ return buildSearchText(post).includes(normalizedQuery);
+ })
+ .sort((left, right) => {
+ if (sortOrder === 'popular') {
+ const countGap =
+ (popularCountBySlug.get(right.slug) ?? 0) -
+ (popularCountBySlug.get(left.slug) ?? 0);
+
+ if (countGap !== 0) {
+ return countGap;
+ }
+ }
+
+ return compareByDateDesc(left, right);
+ });
+}
diff --git a/src/features/home/ui/pages/HomePage.test.tsx b/src/features/home/ui/pages/HomePage.test.tsx
index 73cf0437..12db3797 100644
--- a/src/features/home/ui/pages/HomePage.test.tsx
+++ b/src/features/home/ui/pages/HomePage.test.tsx
@@ -4,30 +4,21 @@ import type { FeedData } from '@/domains/post/model/types';
import HomePage from './HomePage';
const mockGetSortedFeedData = vi.fn<() => FeedData[]>(() => []);
-const mockGetSeriesSummaries = vi.fn(() => []);
const mockGetPopularViewsInRecentDays = vi.fn(async () => []);
vi.mock('@/features/blog/services/post-repository', () => ({
getSortedFeedData: () => mockGetSortedFeedData(),
}));
-vi.mock('@/features/blog/model/series-group', () => ({
- getSeriesSummaries: (...args: unknown[]) => mockGetSeriesSummaries(...args),
-}));
-
vi.mock('@/app/actions/view', () => ({
getPopularViewsInRecentDays: (...args: unknown[]) =>
mockGetPopularViewsInRecentDays(...args),
}));
-vi.mock('@/shared/layout', () => ({
- Header: () =>
,
-}));
-
-const mockHeroSection = vi.fn(() =>
);
+const mockHomePageClient = vi.fn(() =>
);
-vi.mock('@/features/home/ui/sections/HeroSection', () => ({
- default: (props: unknown) => mockHeroSection(props),
+vi.mock('./HomePageClient', () => ({
+ default: (props: unknown) => mockHomePageClient(props),
}));
function createPost(index: number): FeedData {
@@ -41,7 +32,7 @@ function createPost(index: number): FeedData {
}
describe('HomePage', () => {
- it('fills popular list with latest non-series posts when view data is empty', async () => {
+ it('passes posts and normalized popular views to the home client', async () => {
mockGetSortedFeedData.mockReturnValue([
createPost(1),
createPost(2),
@@ -50,23 +41,28 @@ describe('HomePage', () => {
createPost(5),
createPost(6),
]);
- mockGetSeriesSummaries.mockReturnValue([]);
- mockGetPopularViewsInRecentDays.mockResolvedValue([]);
+ mockGetPopularViewsInRecentDays.mockResolvedValue([
+ {
+ slug: 'post-2',
+ count: 17,
+ updated_at: '2026-03-20T00:00:00.000Z',
+ },
+ ]);
const element = await HomePage();
render(element);
- expect(screen.getByTestId('mock-header')).toBeInTheDocument();
- expect(screen.getByTestId('hero')).toBeInTheDocument();
+ expect(screen.getByTestId('home-page-client')).toBeInTheDocument();
+ expect(mockGetPopularViewsInRecentDays).toHaveBeenCalledWith(30, 6);
- const firstCallArgs = mockHeroSection.mock.calls[0]?.[0] as {
- popularPosts: Array<{ post: FeedData; viewCount: number | null }>;
+ const firstCallArgs = mockHomePageClient.mock.calls[0]?.[0] as {
+ posts: FeedData[];
+ popularViews: Array<{ slug: string; count: number }>;
};
- expect(firstCallArgs.popularPosts).toHaveLength(5);
- expect(
- firstCallArgs.popularPosts.every((item) => item.viewCount === null)
- ).toBe(true);
- expect(firstCallArgs.popularPosts[0].post.slug).toBe('post-1');
+ expect(firstCallArgs.posts).toHaveLength(6);
+ expect(firstCallArgs.popularViews).toEqual([
+ { slug: 'post-2', count: 17 },
+ ]);
});
});
diff --git a/src/features/home/ui/pages/HomePage.tsx b/src/features/home/ui/pages/HomePage.tsx
index 1c6b463d..dac4ab1c 100644
--- a/src/features/home/ui/pages/HomePage.tsx
+++ b/src/features/home/ui/pages/HomePage.tsx
@@ -1,106 +1,25 @@
-import { Header } from '@/shared/layout';
-import type { FeedData } from '@/domains/post/model/types';
import { getPopularViewsInRecentDays } from '@/app/actions/view';
import { getSortedFeedData } from '@/features/blog/services/post-repository';
-import { getSeriesSummaries } from '@/features/blog/model/series-group';
-import HeroSection from '@/features/home/ui/sections/HeroSection';
+import HomePageClient from './HomePageClient';
-const POPULAR_POST_LIMIT = 5;
const POPULAR_VIEW_DAYS = 30;
-export interface HomePopularPost {
- post: FeedData;
- viewCount: number | null;
-}
-
-function buildPopularPosts(
- allPosts: FeedData[],
- popularViews: Array<{ slug: string; count: number }>
-): HomePopularPost[] {
- const nonSeriesPosts = allPosts.filter((post) => !post.series);
- const postMap = new Map(nonSeriesPosts.map((post) => [post.slug, post]));
- const pickedSlugs = new Set();
-
- const rankedMatches = popularViews
- .map((entry) => {
- const post = postMap.get(entry.slug);
-
- if (!post) {
- return null;
- }
-
- return {
- post,
- viewCount: entry.count,
- };
- })
- .filter((entry): entry is HomePopularPost => entry !== null)
- .sort((a, b) => {
- const countGap = (b.viewCount ?? 0) - (a.viewCount ?? 0);
- if (countGap !== 0) {
- return countGap;
- }
-
- return a.post.date < b.post.date ? 1 : -1;
- });
-
- const result: HomePopularPost[] = [];
-
- for (const entry of rankedMatches) {
- if (result.length >= POPULAR_POST_LIMIT) {
- break;
- }
-
- if (pickedSlugs.has(entry.post.slug)) {
- continue;
- }
-
- pickedSlugs.add(entry.post.slug);
- result.push(entry);
- }
-
- if (result.length >= POPULAR_POST_LIMIT) {
- return result;
- }
-
- for (const post of nonSeriesPosts) {
- if (result.length >= POPULAR_POST_LIMIT) {
- break;
- }
-
- if (pickedSlugs.has(post.slug)) {
- continue;
- }
-
- pickedSlugs.add(post.slug);
- result.push({
- post,
- viewCount: null,
- });
- }
-
- return result;
-}
-
export default async function HomePage() {
const sortedPosts = getSortedFeedData();
- const seriesSummaries = getSeriesSummaries(sortedPosts);
const popularViews = await getPopularViewsInRecentDays(
POPULAR_VIEW_DAYS,
- POPULAR_POST_LIMIT
+ Math.max(sortedPosts.length, 1)
);
- const popularPosts = buildPopularPosts(sortedPosts, popularViews);
return (
- <>
-
-
-
-
- >
+
+ ({
+ slug,
+ count,
+ }))}
+ />
+
);
}
diff --git a/src/features/home/ui/pages/HomePageClient.tsx b/src/features/home/ui/pages/HomePageClient.tsx
new file mode 100644
index 00000000..9251d17c
--- /dev/null
+++ b/src/features/home/ui/pages/HomePageClient.tsx
@@ -0,0 +1,145 @@
+'use client';
+
+import { useDeferredValue, useMemo, useState } from 'react';
+import { clsx } from 'clsx';
+import type { FeedData } from '@/domains/post/model/types';
+import {
+ CategoryFilter,
+ PostList,
+ type Category,
+} from '@/features/blog/ui/components';
+import {
+ buildHomeCategoryCounts,
+ filterHomePosts,
+ type HomePopularView,
+ type HomeSortOrder,
+} from '@/features/home/model/home-feed';
+import { Container } from '@/shared/layout';
+
+interface HomePageClientProps {
+ posts: FeedData[];
+ popularViews: HomePopularView[];
+}
+
+const FEED_CATEGORIES: Category[] = ['All', 'Tech', 'Life'];
+const SORT_OPTIONS: Array<{ value: HomeSortOrder; label: string }> = [
+ { value: 'latest', label: '최신순' },
+ { value: 'popular', label: '인기순' },
+];
+
+export default function HomePageClient({
+ posts,
+ popularViews,
+}: HomePageClientProps) {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [activeCategory, setActiveCategory] = useState('All');
+ const [sortOrder, setSortOrder] = useState('latest');
+ const deferredSearchQuery = useDeferredValue(searchQuery);
+
+ const categoryCounts = useMemo(() => buildHomeCategoryCounts(posts), [posts]);
+ const filteredPosts = useMemo(
+ () =>
+ filterHomePosts(posts, popularViews, {
+ query: deferredSearchQuery,
+ category: activeCategory,
+ sortOrder,
+ }),
+ [activeCategory, deferredSearchQuery, popularViews, posts, sortOrder]
+ );
+
+ const activeSortLabel =
+ SORT_OPTIONS.find((option) => option.value === sortOrder)?.label ??
+ '최신순';
+
+ return (
+
+
+
+
+
+
+ Archive
+
+
+ {filteredPosts.length}개의 글
+ •
+ {activeSortLabel}
+
+
+
+ {deferredSearchQuery.trim() ? (
+
+ "{deferredSearchQuery.trim()}" 검색 결과
+
+ ) : null}
+
+
+
+ 홈 피드 검색
+
+
+
+
+
+
+ setSearchQuery(event.target.value)}
+ placeholder="글 제목, 설명, 태그로 검색해보세요"
+ className="w-full bg-transparent text-sm text-[var(--color-grey-900)] outline-none placeholder:text-[var(--color-grey-400)]"
+ />
+
+
+
+
+
+
+ {SORT_OPTIONS.map((option) => (
+ setSortOrder(option.value)}
+ className={clsx(
+ 'rounded-full px-4 py-2 text-sm font-medium transition-colors',
+ sortOrder === option.value
+ ? 'bg-[var(--color-bg-primary)] text-[var(--color-grey-900)] shadow-sm'
+ : 'text-[var(--color-grey-500)] hover:text-[var(--color-grey-700)]'
+ )}
+ aria-pressed={sortOrder === option.value}
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+export type { HomePageClientProps };
diff --git a/src/features/resume/ui/pages/ResumePage.tsx b/src/features/resume/ui/pages/ResumePage.tsx
index 32e872a5..67811d50 100644
--- a/src/features/resume/ui/pages/ResumePage.tsx
+++ b/src/features/resume/ui/pages/ResumePage.tsx
@@ -1,6 +1,6 @@
import { Metadata } from 'next';
import type { ReactNode } from 'react';
-import { Header, Container } from '@/shared/layout';
+import { Container } from '@/shared/layout';
import {
experiences,
personalInfo,
@@ -104,11 +104,8 @@ export default function ResumePage() {
};
return (
- <>
-
-
-
-
+
+
{/* Profile Header */}
@@ -171,7 +168,7 @@ export default function ResumePage() {
{/* Skills Section */}
-
+
🛠️ Skills
@@ -188,7 +185,7 @@ export default function ResumePage() {
{/* Experience Section */}
-
+
💼
Experience
@@ -281,7 +278,7 @@ export default function ResumePage() {
{/* Personal Projects Section */}
-
+
🚀 Personal Projects
@@ -368,7 +365,7 @@ export default function ResumePage() {
{/* Education Section */}
-
+
🎓 Education
@@ -463,7 +460,6 @@ export default function ResumePage() {
-
- >
+
);
}
diff --git a/src/shared/layout/AppShell/AppShell.tsx b/src/shared/layout/AppShell/AppShell.tsx
new file mode 100644
index 00000000..4006e51c
--- /dev/null
+++ b/src/shared/layout/AppShell/AppShell.tsx
@@ -0,0 +1,616 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import type { ReactNode } from 'react';
+import { useEffect, useMemo, useState } from 'react';
+import { AnimatePresence, motion } from 'framer-motion';
+import { useKBar } from 'kbar';
+import { clsx } from 'clsx';
+import type { FeedData } from '@/domains/post/model/types';
+import { getSeriesSummaries } from '@/features/blog/model/series-group';
+import { personalInfo } from '@/features/resume/model/resume-data';
+import ThemeToggle from '@/shared/ui/ThemeToggle';
+
+type AppSection = 'home' | 'engineering' | 'life' | 'resume';
+
+interface AppShellProps {
+ children: ReactNode;
+ posts: FeedData[];
+}
+
+interface RailNavItem {
+ id: AppSection;
+ href: string;
+ label: string;
+ description: string;
+ icon: ReactNode;
+}
+
+interface ExternalLinkItem {
+ href: string;
+ label: string;
+ icon: ReactNode;
+}
+
+const DESKTOP_PANEL_WIDTH = 'clamp(220px, 18vw, 280px)';
+
+const RAIL_NAV_ITEMS: RailNavItem[] = [
+ {
+ id: 'home',
+ href: '/',
+ label: '홈',
+ description: '전체 피드와 최근 글',
+ icon: (
+
+
+
+
+
+ ),
+ },
+ {
+ id: 'engineering',
+ href: '/engineering',
+ label: 'Engineering',
+ description: '기술 글과 시리즈',
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ id: 'life',
+ href: '/life',
+ label: 'Life',
+ description: '에세이와 회고',
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ id: 'resume',
+ href: '/resume',
+ label: 'Resume',
+ description: '경력과 프로젝트',
+ icon: (
+
+
+
+
+
+
+ ),
+ },
+];
+
+const EXTERNAL_LINKS: ExternalLinkItem[] = [
+ {
+ href: personalInfo.github,
+ label: 'GitHub',
+ icon: (
+
+
+
+ ),
+ },
+ {
+ href: `mailto:${personalInfo.email}`,
+ label: 'Email',
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ href: '/feed.xml',
+ label: 'RSS',
+ icon: (
+
+
+
+
+
+ ),
+ },
+];
+
+function formatDate(value: string): string {
+ return new Date(value).toLocaleDateString('ko-KR', {
+ month: 'long',
+ day: 'numeric',
+ });
+}
+
+function resolveSection(pathname: string, posts: FeedData[]): AppSection {
+ if (pathname.startsWith('/resume')) {
+ return 'resume';
+ }
+
+ if (pathname.startsWith('/life')) {
+ return 'life';
+ }
+
+ if (pathname.startsWith('/engineering') || pathname.startsWith('/series')) {
+ return 'engineering';
+ }
+
+ if (pathname.startsWith('/blog/')) {
+ const slug = pathname.slice('/blog/'.length);
+ const matchedPost = posts.find((post) => post.slug === slug);
+
+ if (matchedPost?.category === 'Life') {
+ return 'life';
+ }
+
+ if (matchedPost?.category === 'Tech') {
+ return 'engineering';
+ }
+ }
+
+ return 'home';
+}
+
+function PanelSection({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function SidebarRow({
+ href,
+ title,
+ detail,
+ active = false,
+}: {
+ href: string;
+ title: string;
+ detail?: string;
+ active?: boolean;
+}) {
+ return (
+
+
+ {title}
+
+ {detail ? (
+ {detail}
+ ) : null}
+
+ );
+}
+
+function SearchButton() {
+ const { query } = useKBar();
+
+ return (
+ query.toggle()}
+ className="inline-flex h-11 w-full items-center gap-3 rounded-full border border-[var(--color-grey-200)] bg-[var(--color-grey-50)] px-4 text-sm text-[var(--color-grey-500)] transition-colors hover:border-[var(--color-grey-300)] hover:text-[var(--color-grey-900)]"
+ aria-label="검색 열기"
+ >
+
+
+
+
+ 검색어, 태그, 글 제목 검색
+
+ );
+}
+
+export default function AppShell({ children, posts }: AppShellProps) {
+ const pathname = usePathname();
+ const [desktopPanelOpen, setDesktopPanelOpen] = useState(true);
+ const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
+
+ useEffect(() => {
+ setMobilePanelOpen(false);
+ }, [pathname]);
+
+ const activeSection = useMemo(
+ () => resolveSection(pathname, posts),
+ [pathname, posts]
+ );
+
+ const techPosts = useMemo(
+ () => posts.filter((post) => post.category === 'Tech'),
+ [posts]
+ );
+ const lifePosts = useMemo(
+ () => posts.filter((post) => post.category === 'Life'),
+ [posts]
+ );
+ const seriesSummaries = useMemo(() => getSeriesSummaries(techPosts), [techPosts]);
+
+ const sidebarContent = useMemo(() => {
+ switch (activeSection) {
+ case 'engineering':
+ return (
+ <>
+
+
+
+
+
+
+ {techPosts.slice(0, 6).map((post) => (
+
+ ))}
+
+ >
+ );
+ case 'life':
+ return (
+ <>
+
+
+
+
+
+ {lifePosts.slice(0, 6).map((post) => (
+
+ ))}
+
+ >
+ );
+ case 'resume':
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ case 'home':
+ default:
+ return (
+ <>
+
+
+
+
+
+
+
+ {posts.slice(0, 7).map((post) => (
+
+ ))}
+
+ >
+ );
+ }
+ }, [activeSection, lifePosts, pathname, posts, seriesSummaries, techPosts]);
+
+ const desktopPanelWidth = desktopPanelOpen ? DESKTOP_PANEL_WIDTH : '0px';
+
+ return (
+
+
+
+ {RAIL_NAV_ITEMS.map((item) => {
+ const isActive = item.id === activeSection;
+
+ return (
+
+ {item.icon}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
{
+ if (window.innerWidth >= 768) {
+ setDesktopPanelOpen((current) => !current);
+ return;
+ }
+
+ setMobilePanelOpen((current) => !current);
+ }}
+ className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--color-grey-200)] bg-[var(--color-bg-primary)] text-[var(--color-grey-600)] transition-colors hover:bg-[var(--color-grey-50)] hover:text-[var(--color-grey-900)]"
+ aria-label="사이드바 토글"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{children}
+
+
+
+ {mobilePanelOpen ? (
+ <>
+ setMobilePanelOpen(false)}
+ />
+
+
+
+ {RAIL_NAV_ITEMS.map((item) => {
+ const isActive = item.id === activeSection;
+
+ return (
+
+ {item.icon}
+
+ );
+ })}
+
+
+
+
+
+
+ {sidebarContent}
+
+
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/src/shared/layout/AppShell/index.ts b/src/shared/layout/AppShell/index.ts
new file mode 100644
index 00000000..5541a059
--- /dev/null
+++ b/src/shared/layout/AppShell/index.ts
@@ -0,0 +1 @@
+export { default as AppShell } from './AppShell';
diff --git a/src/shared/layout/index.ts b/src/shared/layout/index.ts
index ca37cdca..3b41d3fa 100644
--- a/src/shared/layout/index.ts
+++ b/src/shared/layout/index.ts
@@ -2,4 +2,5 @@
export { Header } from './Header';
export { Footer } from './Footer';
export { Container } from './Container';
+export { AppShell } from './AppShell';
export type { ContainerProps } from './Container';
diff --git a/src/shared/ui/ThemeToggle.tsx b/src/shared/ui/ThemeToggle.tsx
index d17148f6..8eea8a0b 100644
--- a/src/shared/ui/ThemeToggle.tsx
+++ b/src/shared/ui/ThemeToggle.tsx
@@ -2,7 +2,7 @@
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
-import { motion, AnimatePresence } from 'framer-motion';
+import { motion } from 'framer-motion';
import { AnalyticsEvents, trackEvent } from '@/shared/analytics/lib/analytics';
export default function ThemeToggle() {
@@ -15,8 +15,8 @@ export default function ThemeToggle() {
if (!mounted) {
return (
-
-
+
);
}
@@ -25,7 +25,8 @@ export default function ThemeToggle() {
const nextTheme = isDark ? 'light' : 'dark';
return (
-
{
setTheme(nextTheme);
trackEvent(AnalyticsEvents.theme, {
@@ -33,60 +34,63 @@ export default function ThemeToggle() {
to_theme: nextTheme,
});
}}
- className="relative p-2 rounded-lg hover:bg-[var(--color-grey-100)] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)]"
+ className="relative inline-flex h-11 w-[88px] items-center rounded-full border border-[var(--color-grey-200)] bg-[var(--color-grey-50)] px-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)]"
aria-label={`${isDark ? '라이트' : '다크'} 모드로 전환`}
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- {isDark ? (
- // Dark Mode -> Show Sun (Switch to Light)
-
-
-
-
-
-
-
-
-
-
-
- ) : (
- // Light Mode -> Show Moon (Switch to Dark)
-
-
-
- )}
-
-
-
+
+
+
+
+
+
);
}