Skip to content

Commit 6acdcac

Browse files
authored
feat(home): Linear형 앱 셸 레이아웃 전환 (#91)
1 parent 21a4a6a commit 6acdcac

20 files changed

Lines changed: 1380 additions & 335 deletions
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# 홈 메인 피드형 레이아웃 전환 검토
2+
3+
## Goal
4+
5+
메인 페이지를 큰 히어로 중심 소개형 랜딩에서, 콘텐츠 접근성이 높은
6+
피드형 홈으로 전환하기 위한 방향과 판단 근거를 정리한다.
7+
8+
이번 문서는 실제 코드 구현보다 앞선 실행 메모 성격의 문서다. 홈의 역할을
9+
`자기소개 우선`에서 `콘텐츠 발견 우선`으로 재정의하고, 어떤 구조와 원칙으로
10+
전환할지 결정 완료 상태로 남긴다.
11+
12+
## Scope
13+
14+
- 현재 홈 구조의 한계와 전환 배경 정리
15+
- TO-BE 레이아웃과 정보 구조 정리
16+
- 프로필 이미지 없이도 성립하는 브랜딩 방식 정리
17+
- 외부 참고 서비스 관점의 평가 정리
18+
- 구현 시 바로 참고할 수 있는 메모 정리
19+
20+
이번 범위에는 실제 화면 구현, 컴포넌트 수정, 라우팅 변경은 포함하지 않는다.
21+
22+
## Constraints
23+
24+
- 기존 코드 구조는 유지한다.
25+
- `src/features/home`
26+
- `src/features/blog`
27+
- `src/shared`
28+
- 메인 홈은 피드 중심으로 재구성하되, 블로그 전체를 평범한 게시판처럼
29+
보이게 만들면 안 된다.
30+
- 우측 브랜딩 영역은 프로필 이미지 의존성을 두지 않는다.
31+
- 첫 버전에서 보조 위젯은 최소화한다.
32+
- 상단 탐색 컨트롤은 `검색 + 정렬 + 카테고리`까지 포함한다.
33+
- 모바일에서는 1컬럼 중심으로 자연스럽게 접혀야 한다.
34+
35+
## Milestones
36+
37+
### 1. 배경
38+
39+
현재 홈은 큰 히어로를 중심으로 구성되어 있고, 사용자가 먼저 보게 되는 것은
40+
소개와 브랜딩이다. 이 구조는 처음 방문한 사람에게 정체성을 전달하는 데는
41+
유리하지만, 글을 바로 읽고 싶은 사용자 입장에서는 첫 클릭까지 거리가 있다.
42+
43+
블로그 글이 충분히 쌓인 시점에는 "내가 누구인가"보다 "무엇을 꾸준히 쓰는가"를
44+
빠르게 보여주는 편이 더 설득력 있다. 따라서 홈의 중심축을 소개에서 피드로
45+
옮기는 것이 적절하다고 판단했다.
46+
47+
### 2. AS-IS 진단
48+
49+
- 브랜딩 전달은 강하지만 콘텐츠 탐색이 느리다.
50+
- 홈의 중심이 소개형 섹션이라 최신 글 소비 흐름이 약하다.
51+
- 글이 늘어날수록 홈의 정보 구조가 확장성보다는 랜딩 성격에 머무를 수 있다.
52+
- 대표 글, 시리즈, 이력 요약이 분리 섹션으로 존재해 한 화면에서의 집중도가
53+
분산된다.
54+
55+
### 3. TO-BE 방향
56+
57+
메인 홈은 3컬럼 구조로 재구성한다.
58+
59+
- 좌측: 탐색
60+
- 카테고리 또는 핵심 진입점
61+
- 데스크톱에서는 고정 또는 스티키
62+
- 모바일에서는 상단으로 접거나 가로 스크롤 형태로 축약
63+
- 중앙: 피드 메인
64+
- 작은 인트로 스트립
65+
- 검색 입력
66+
- 정렬 컨트롤
67+
- 카테고리 필터
68+
- 썸네일, 제목, 날짜, 요약, 태그를 포함한 포스트 피드
69+
- 우측: 브랜딩
70+
- 이미지 없는 텍스트형 프로필 카드
71+
- 이름, 한 줄 소개, 역할/관심사
72+
- 이력서, GitHub, 이메일 등 핵심 링크
73+
74+
핵심 원칙은 `중앙 피드가 주인공`이어야 한다는 점이다. 좌우 영역은 메인을
75+
보조해야 하며, 메인보다 더 튀거나 더 많은 판단을 요구하면 안 된다.
76+
77+
### 4. 텍스트 기반 브랜딩 카드 원칙
78+
79+
우측 카드는 프로필 이미지를 넣지 않아도 매력적으로 보여야 한다.
80+
81+
- 얼굴 이미지 대신 문장과 타이포로 인상을 만든다.
82+
- 이름보다 "무엇을 쓰는 사람인가"가 더 빨리 읽혀야 한다.
83+
- 링크는 많기보다 선명해야 한다.
84+
- 카드 자체가 화려하기보다 중앙 피드의 신뢰도를 높이는 역할을 해야 한다.
85+
86+
즉, 브랜딩을 제거하는 것이 아니라 압축하는 방향이다.
87+
88+
### 5. 외부 관점 평가
89+
90+
아래 평가는 공개된 제품 패턴과 문서를 바탕으로 정리한 추론이다.
91+
92+
#### 토스 관점
93+
94+
토스 계열의 UX 감각에서는 탐색 진입점의 명확성과 사용자의 이동 비용 감소가
95+
긍정적으로 평가될 가능성이 높다. 큰 소개보다 바로 읽을 수 있는 글 피드를
96+
앞세우는 방향은 서비스형 탐색 경험과 잘 맞는다.
97+
98+
다만 주변 위젯이 많아지거나 좌우 정보가 과도하면, 메인 액션이 흐려진다는
99+
지적을 받을 수 있다.
100+
101+
#### Medium / Substack 관점
102+
103+
콘텐츠 플랫폼 관점에서는 메인 화면의 주인공이 콘텐츠여야 한다는 점에서
104+
방향성이 맞다. 특히 최신 글, 발견, 추천의 흐름이 홈에 직접 드러나는 구조는
105+
자연스럽다.
106+
107+
반대로 모든 카드가 같은 무게로만 쌓이면 에디토리얼 위계가 약해 보일 수 있다.
108+
첫 번째 대표 글 또는 고정 글에 대한 강조가 추후 필요할 수 있다.
109+
110+
#### LinkedIn / SNS 크리에이터 관점
111+
112+
작성자 정체성은 남겨야 하지만, 압축된 형태여야 한다는 평가가 예상된다.
113+
프로필 이미지를 빼는 선택은 가능하지만, 대신 한 줄 소개와 역할 정의가 더
114+
정확해야 한다.
115+
116+
잘 되면 성숙한 텍스트 기반 브랜딩으로 보이지만, 약하면 익명성 높은 목록형
117+
페이지처럼 보일 위험이 있다.
118+
119+
### 6. 최종 의사결정
120+
121+
- 큰 히어로는 줄인다.
122+
- 홈의 중심은 중앙 피드로 바꾼다.
123+
- 우측은 이미지 없는 텍스트형 프로필 카드로 간다.
124+
- 첫 버전은 보조 위젯을 넣지 않는다.
125+
- 상단 컨트롤은 `검색 + 정렬 + 카테고리`를 포함한다.
126+
- 홈은 소개형 랜딩보다 콘텐츠 접근성이 우선인 구조로 전환한다.
127+
128+
### 7. 구현 메모
129+
130+
- `HomePage`는 기존의 큰 `HeroSection` 의존을 줄이고, 홈 전용 레이아웃을
131+
중심으로 재구성한다.
132+
- 글 데이터는 기존 블로그 도메인과 리스트/카드 컴포넌트를 최대한 재사용한다.
133+
- 리스트 UI는 현재 `PostList``PostCard``list` 변형을 기반으로 조정한다.
134+
- 모바일에서는 좌우 컬럼을 접고 중앙 피드 우선 구조를 유지한다.
135+
- 첫 구현에서는 `Friends`, `Old Blog` 같은 부가 위젯은 제외한다.
136+
137+
## Verification
138+
139+
- 홈의 목적이 `소개 우선`에서 `콘텐츠 발견 우선`으로 명확히 전환되었는지
140+
문서 수준에서 설명 가능해야 한다.
141+
- 좌측 탐색, 중앙 피드, 우측 브랜딩의 역할이 중복 없이 분리되어 있어야 한다.
142+
- 프로필 이미지 없이도 브랜딩 카드가 성립하는 이유가 문서에 드러나야 한다.
143+
- 외부 참고 서비스 관점에서의 긍정 포인트와 리스크가 함께 정리되어 있어야
144+
한다.
145+
- 구현자가 이 문서만 읽고 홈 구조 개편의 의도와 우선순위를 이해할 수 있어야
146+
한다.
147+
148+
## Rollback
149+
150+
실제 구현 단계에서 아래 신호가 보이면 피드형 전환 강도를 낮춘다.
151+
152+
- 좌우 영역 때문에 메인 피드 집중도가 떨어지는 경우
153+
- 검색/정렬/카테고리가 한 화면에서 과밀하게 느껴지는 경우
154+
- 텍스트형 브랜딩 카드가 충분한 존재감을 만들지 못하는 경우
155+
- 전체 홈이 generic한 블로그 목록처럼 보이는 경우
156+
157+
이 경우 롤백 방향은 `완전한 피드형 폐기`가 아니라, 상단 compact intro를 조금
158+
키우거나 대표 글 1개를 더 강하게 강조하는 방식으로 위계를 보정하는 것이다.
159+
160+
## References
161+
162+
- [Toss Navigation Score 사례](https://toss.tech/article/Toss_Navigation_Score)
163+
- [Toss Engineering 카테고리](https://toss.tech/category/engineering)
164+
- [Medium homepage help](https://help.medium.com/hc/en-us/articles/115012586467-Your-homepage)
165+
- [Substack app home guide](https://support.substack.com/hc/en-us/articles/19291693034004-Getting-started-on-the-Substack-app)
166+
- [Substack homepage layouts](https://support.substack.com/hc/en-us/articles/360039015892-How-do-I-switch-my-publication-s-homepage-to-a-different-layout)
167+
- [LinkedIn articles/newsletters guide](https://members.linkedin.com/content/dam/me/members/en-us/pdf/articlesnewsletters.pdf)

src/app/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import '@/styles/globals.css';
44
import '@/styles/tossface.css';
55

66
import AppProviders from '@/core/providers/AppProviders';
7+
import { getSortedFeedData } from '@/features/blog/services/post-repository';
8+
import { AppShell } from '@/shared/layout';
79
import {
810
SITE_AUTHOR,
911
SITE_DESCRIPTION,
@@ -61,6 +63,8 @@ export default function RootLayout({
6163
}: {
6264
children: ReactNode;
6365
}) {
66+
const posts = getSortedFeedData();
67+
6468
return (
6569
<html lang="ko" suppressHydrationWarning>
6670
<head>
@@ -81,7 +85,9 @@ export default function RootLayout({
8185
</head>
8286
<body>
8387
<AppProviders>
84-
<div id="app-root">{children}</div>
88+
<div id="app-root">
89+
<AppShell posts={posts}>{children}</AppShell>
90+
</div>
8591
<div id="overlay-root" />
8692
</AppProviders>
8793
</body>

src/features/blog/ui/components/PostCard.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,23 @@ describe('PostCard', () => {
8888
'/images/test.png'
8989
);
9090
});
91+
92+
it('shows category, series title, and tags in list variant', () => {
93+
const detailedPost: FeedData = {
94+
...basePost,
95+
tags: ['redis', 'cache'],
96+
series: {
97+
id: 'redis',
98+
title: 'Redis 완전정복',
99+
order: 1,
100+
},
101+
};
102+
103+
render(<PostCard post={detailedPost} variant="list" />);
104+
105+
expect(screen.getByText('Tech')).toBeInTheDocument();
106+
expect(screen.getByText('Redis 완전정복')).toBeInTheDocument();
107+
expect(screen.getByText('#redis')).toBeInTheDocument();
108+
expect(screen.getByText('#cache')).toBeInTheDocument();
109+
});
91110
});

src/features/blog/ui/components/PostCard/PostCard.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
7373
day: 'numeric',
7474
});
7575
const readingTimeLabel = post.readingTime ? `약 ${post.readingTime}분` : null;
76+
const visibleTags = post.tags?.slice(0, 3) ?? [];
7677

7778
if (variant === 'featured') {
7879
return (
@@ -118,8 +119,13 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
118119
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
119120
<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)]">
120121
<CategoryIcon category={post.category} />
121-
{post.series ? 'Series' : post.category}
122+
{post.category}
122123
</span>
124+
{post.series ? (
125+
<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)]">
126+
{post.series.title}
127+
</span>
128+
) : null}
123129
<span className="h-1 w-1 rounded-full bg-[var(--color-grey-300)]" />
124130
<time>{formattedDate}</time>
125131
{readingTimeLabel && (
@@ -135,6 +141,18 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
135141
<p className="mt-1 text-sm text-[var(--color-text-secondary)] line-clamp-2">
136142
{post.description}
137143
</p>
144+
{visibleTags.length > 0 ? (
145+
<div className="mt-3 flex flex-wrap gap-2">
146+
{visibleTags.map((tag) => (
147+
<span
148+
key={tag}
149+
className="rounded-full bg-[var(--color-grey-50)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-tertiary)]"
150+
>
151+
#{tag}
152+
</span>
153+
))}
154+
</div>
155+
) : null}
138156
</div>
139157
{post.image ? (
140158
<div className="hidden h-20 w-32 shrink-0 overflow-hidden rounded-xl border border-[var(--color-border)] sm:block">
Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Metadata } from 'next';
22
import { getSortedFeedData } from '@/features/blog/services/post-repository';
3-
import { Header, Container } from '@/shared/layout';
3+
import { Container } from '@/shared/layout';
44
import BlogListClient from './BlogListClient';
55

66
export const metadata: Metadata = {
@@ -12,23 +12,19 @@ export default function BlogPage() {
1212
const allPosts = getSortedFeedData();
1313

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

18-
<main className="py-16">
19-
<Container size="md">
20-
<header className="mb-12">
21-
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
22-
Blog
23-
</h1>
24-
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
25-
개발과 일상에 대한 이야기를 나눕니다
26-
</p>
27-
</header>
28-
29-
<BlogListClient posts={allPosts} />
30-
</Container>
31-
</main>
32-
</>
26+
<BlogListClient posts={allPosts} />
27+
</Container>
28+
</main>
3329
);
3430
}

src/features/blog/ui/pages/BlogPostPage.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
getMdxSource,
1010
parseHeadingsFromMdx,
1111
} from '@/features/blog/services/markdown-parser';
12-
import { Header, Container } from '@/shared/layout';
12+
import { Container } from '@/shared/layout';
1313
import {
1414
ReadingProgress,
1515
TableOfContents,
@@ -92,7 +92,6 @@ export default async function BlogPostPage({
9292
return (
9393
<>
9494
<ReadingProgress />
95-
<Header />
9695
<PostViewTracker
9796
slug={post.slug}
9897
category={post.category}
@@ -101,7 +100,7 @@ export default async function BlogPostPage({
101100
<DwellTimeTracker slug={post.slug} />
102101
<ScrollDepthTracker slug={post.slug} />
103102

104-
<article className="py-16">
103+
<article className="py-10">
105104
<Container size="md">
106105
{/* Header */}
107106
<header className="mb-10">
Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Metadata } from 'next';
22
import { Suspense } from 'react';
33
import { getSortedFeedData } from '@/features/blog/services/post-repository';
4-
import { Header, Container } from '@/shared/layout';
4+
import { Container } from '@/shared/layout';
55
import EngineeringPageClient from './EngineeringPageClient';
66

77
export const metadata: Metadata = {
@@ -15,25 +15,21 @@ export default function EngineeringPage() {
1515
);
1616

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

21-
<main className="py-16">
22-
<Container size="md">
23-
<header className="mb-10">
24-
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
25-
Engineering
26-
</h1>
27-
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
28-
기술 글과 시리즈를 같은 흐름에서 살펴볼 수 있어요
29-
</p>
30-
</header>
31-
32-
<Suspense fallback={null}>
33-
<EngineeringPageClient posts={engineeringPosts} />
34-
</Suspense>
35-
</Container>
36-
</main>
37-
</>
29+
<Suspense fallback={null}>
30+
<EngineeringPageClient posts={engineeringPosts} />
31+
</Suspense>
32+
</Container>
33+
</main>
3834
);
3935
}

0 commit comments

Comments
 (0)