Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions docs/exec-plans/active/2026-04-17-home-feed-layout-review.md
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 7 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -61,6 +63,8 @@ export default function RootLayout({
}: {
children: ReactNode;
}) {
const posts = getSortedFeedData();

return (
<html lang="ko" suppressHydrationWarning>
<head>
Expand All @@ -81,7 +85,9 @@ export default function RootLayout({
</head>
<body>
<AppProviders>
<div id="app-root">{children}</div>
<div id="app-root">
<AppShell posts={posts}>{children}</AppShell>
</div>
<div id="overlay-root" />
</AppProviders>
</body>
Expand Down
19 changes: 19 additions & 0 deletions src/features/blog/ui/components/PostCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PostCard post={detailedPost} variant="list" />);

expect(screen.getByText('Tech')).toBeInTheDocument();
expect(screen.getByText('Redis 완전정복')).toBeInTheDocument();
expect(screen.getByText('#redis')).toBeInTheDocument();
expect(screen.getByText('#cache')).toBeInTheDocument();
});
});
20 changes: 19 additions & 1 deletion src/features/blog/ui/components/PostCard/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -118,8 +119,13 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-grey-50)] px-2 py-1 text-xs font-medium text-[var(--color-text-secondary)]">
<CategoryIcon category={post.category} />
{post.series ? 'Series' : post.category}
{post.category}
</span>
{post.series ? (
<span className="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-grey-50)] px-2 py-1 text-xs font-medium text-[var(--color-text-secondary)]">
{post.series.title}
</span>
) : null}
<span className="h-1 w-1 rounded-full bg-[var(--color-grey-300)]" />
<time>{formattedDate}</time>
{readingTimeLabel && (
Expand All @@ -135,6 +141,18 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
<p className="mt-1 text-sm text-[var(--color-text-secondary)] line-clamp-2">
{post.description}
</p>
{visibleTags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{visibleTags.map((tag) => (
<span
key={tag}
className="rounded-full bg-[var(--color-grey-50)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-tertiary)]"
>
#{tag}
</span>
))}
</div>
) : null}
</div>
{post.image ? (
<div className="hidden h-20 w-32 shrink-0 overflow-hidden rounded-xl border border-[var(--color-border)] sm:block">
Expand Down
32 changes: 14 additions & 18 deletions src/features/blog/ui/pages/BlogListPage.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -12,23 +12,19 @@ export default function BlogPage() {
const allPosts = getSortedFeedData();

return (
<>
<Header />
<main className="py-10">
<Container size="md">
<header className="mb-12">
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
Blog
</h1>
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
개발과 일상에 대한 이야기를 나눕니다
</p>
</header>

<main className="py-16">
<Container size="md">
<header className="mb-12">
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
Blog
</h1>
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
개발과 일상에 대한 이야기를 나눕니다
</p>
</header>

<BlogListClient posts={allPosts} />
</Container>
</main>
</>
<BlogListClient posts={allPosts} />
</Container>
</main>
);
}
5 changes: 2 additions & 3 deletions src/features/blog/ui/pages/BlogPostPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -92,7 +92,6 @@ export default async function BlogPostPage({
return (
<>
<ReadingProgress />
<Header />
<PostViewTracker
slug={post.slug}
category={post.category}
Expand All @@ -101,7 +100,7 @@ export default async function BlogPostPage({
<DwellTimeTracker slug={post.slug} />
<ScrollDepthTracker slug={post.slug} />

<article className="py-16">
<article className="py-10">
<Container size="md">
{/* Header */}
<header className="mb-10">
Expand Down
36 changes: 16 additions & 20 deletions src/features/blog/ui/pages/EngineeringPage.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -15,25 +15,21 @@ export default function EngineeringPage() {
);

return (
<>
<Header />
<main className="py-10">
<Container size="md">
<header className="mb-10">
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
Engineering
</h1>
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
기술 글과 시리즈를 같은 흐름에서 살펴볼 수 있어요
</p>
</header>

<main className="py-16">
<Container size="md">
<header className="mb-10">
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
Engineering
</h1>
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
기술 글과 시리즈를 같은 흐름에서 살펴볼 수 있어요
</p>
</header>

<Suspense fallback={null}>
<EngineeringPageClient posts={engineeringPosts} />
</Suspense>
</Container>
</main>
</>
<Suspense fallback={null}>
<EngineeringPageClient posts={engineeringPosts} />
</Suspense>
</Container>
</main>
);
}
Loading
Loading