diff --git a/AGENTS.md b/AGENTS.md index 76e7395e..f181baf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,6 @@ ADR 작성 기준: - `blog/`: 글 도메인. post schema, repository, publication policy, series, blog UI, RSS feed serialization, view-count use case - `resume/`: 이력서 도메인. resume data, ordering, resume UI -- `search/`: 검색 도메인. command palette, search action, recommendation - `site/`: 도메인 조합 layer. home, AppShell, navigation, provider, site config - `infra/`: 외부/런타임 인프라. Supabase, Umami analytics, SEO helper, integration adapter diff --git a/README.md b/README.md index 7bb15b85..a0919f40 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,6 @@
TypeScript -Three.js -
Three.js - - CSS
Tailwind @@ -49,7 +45,6 @@ **애니메이션:** -- **3D:** Three.js + @react-three/fiber + @react-three/drei - **모션:** Framer Motion **콘텐츠 처리:** @@ -80,7 +75,6 @@ ark/ ├── 📁 app/ # Next.js route adapter ├── 📁 blog/ # 글 도메인 ├── 📁 resume/ # 이력서 도메인 -├── 📁 search/ # 검색 도메인 ├── 📁 site/ # 홈, AppShell, provider, site config ├── 📁 infra/ # Supabase, Umami, SEO integration ├── 📁 ui/ # 도메인 지식 없는 UI/레이아웃/모션 diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index 9ebe2d17..95efc5c2 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -40,7 +40,7 @@ export async function GET(req: NextRequest) { flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', - backgroundImage: 'linear-gradient(to bottom right, #E0F2FF, #fff)', + backgroundImage: 'linear-gradient(to bottom right, #EAEBEA, #E0E1E3)', padding: '80px', }} > @@ -56,7 +56,7 @@ export async function GET(req: NextRequest) {
{date} @@ -66,7 +66,7 @@ export async function GET(req: NextRequest) { style={{ fontSize: '60px', fontWeight: 700, - color: '#191f28', + color: '#252525', lineHeight: 1.2, wordBreak: 'keep-all', }} @@ -86,8 +86,8 @@ export async function GET(req: NextRequest) { key={tag} style={{ display: 'flex', - backgroundColor: 'rgba(49, 130, 246, 0.1)', - color: '#3182f6', + backgroundColor: '#E0E1E3', + color: '#3F3F46', padding: '8px 24px', borderRadius: '50px', fontSize: '24px', @@ -114,7 +114,7 @@ export async function GET(req: NextRequest) { style={{ fontSize: '32px', fontWeight: 700, - color: '#3182f6', + color: '#3F3F46', fontFamily: 'Pretendard', }} > diff --git a/app/layout.test.ts b/app/layout.test.ts index 90031b05..3437a823 100644 --- a/app/layout.test.ts +++ b/app/layout.test.ts @@ -13,9 +13,11 @@ describe('root layout font loading', () => { expect(layoutContent).not.toContain('href="/fonts/TossFaceFontWeb.otf"'); }); - it('passes a public client DTO instead of full editorial metadata', () => { - expect(layoutContent).toContain('selectClientPosts(getSortedFeedData())'); - expect(layoutContent).toContain(''); + it('passes only navigation metadata to the app shell', () => { + expect(layoutContent).toContain('getSortedFeedData().map'); + expect(layoutContent).toContain(''); + expect(layoutContent).toContain(''); + expect(layoutContent).not.toContain('selectClientPosts'); }); it('keeps Tossface demand-driven for matching emoji glyphs', () => { diff --git a/app/layout.tsx b/app/layout.tsx index b6263975..00859f06 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,7 +5,6 @@ import '@/styles/tossface.css'; import AppProviders from '@/site/providers/AppProviders'; import { getSortedFeedData } from '@/blog/services/post-repository'; -import { selectClientPosts } from '@/site/providers/client-posts'; import { AppShell } from '@/site/shell/AppShell'; import { SITE_AUTHOR, @@ -55,16 +54,19 @@ export const metadata: Metadata = { }; export const viewport: Viewport = { - themeColor: '#ffffff', + themeColor: '#EAEBEA', width: 'device-width', initialScale: 1, }; export default function RootLayout({ children }: { children: ReactNode }) { - const posts = selectClientPosts(getSortedFeedData()); + const posts = getSortedFeedData().map(({ slug, category }) => ({ + slug, + category, + })); return ( - + - +
{children}
-
diff --git a/app/not-found.tsx b/app/not-found.tsx index e77f9c85..0378aad2 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -17,7 +17,7 @@ export default function NotFound() {

홈으로 돌아가기 diff --git a/blog/services/markdown-parser.test.ts b/blog/services/markdown-parser.test.ts index ad6bb347..07055263 100644 --- a/blog/services/markdown-parser.test.ts +++ b/blog/services/markdown-parser.test.ts @@ -68,7 +68,7 @@ describe('parseHeadingsFromMdx', () => { describe('getMdxSource', () => { it('returns markdown source for an existing slug', () => { - const source = getMdxSource('redis-deep-dive-02-core-data-types'); + const source = getMdxSource('llm-wiki-build-retrospective'); expect(typeof source).toBe('string'); expect(source).toContain('#'); diff --git a/blog/services/post-repository.test.ts b/blog/services/post-repository.test.ts index 45079424..8a1f04c8 100644 --- a/blog/services/post-repository.test.ts +++ b/blog/services/post-repository.test.ts @@ -60,14 +60,13 @@ ${'x'.repeat(5000)} expect(hasExtractedImage).toBe(true); }); - it('returns series posts sorted by order', () => { - const redisPosts = getSeriesPosts('redis-deep-dive', { - includePrivate: true, - }); - - expect(redisPosts.length).toBeGreaterThan(0); - const orders = redisPosts.map((post) => post.series?.order ?? 0); - expect(orders).toEqual([...orders].sort((a, b) => a - b)); + it('does not retain learning series migrated to llm-wiki', () => { + expect(getSeriesPosts('redis-deep-dive', { includePrivate: true })).toEqual( + [] + ); + expect(getSeriesPosts('flink-mastery', { includePrivate: true })).toEqual( + [] + ); }); it('returns empty array for unknown series id', () => { @@ -112,13 +111,6 @@ ${'x'.repeat(5000)} expect(await getFeedData(privateSlug)).toBeNull(); }); - it('filters private series posts from helper lookups', () => { - expect(getSeriesPosts('redis-deep-dive')).toEqual([]); - expect( - getSeriesPosts('redis-deep-dive', { includePrivate: true }).length - ).toBeGreaterThan(0); - }); - it('returns null for non-existent posts folder slug', () => { expect(getFolderSlug('missing-folder-slug')).toBeNull(); }); diff --git a/blog/ui/components/CategoryFilter.test.tsx b/blog/ui/components/CategoryFilter.test.tsx index 6222c200..9391b021 100644 --- a/blog/ui/components/CategoryFilter.test.tsx +++ b/blog/ui/components/CategoryFilter.test.tsx @@ -79,18 +79,19 @@ describe('CategoryFilter', () => { expect(onCategoryChange).toHaveBeenCalledWith('Life'); }); - it('renders active style classes', () => { + it('renders the active category as an underlined text control', () => { render( ); const lifeButton = screen .getAllByRole('button') - .find((button) => button.textContent === 'Life2'); + .find((button) => button.textContent === 'Life(2)'); expect(lifeButton).toBeDefined(); @@ -98,9 +99,10 @@ describe('CategoryFilter', () => { throw new Error('Life category button not found'); } - expect(lifeButton).toHaveClass('bg-[var(--color-toss-blue)]'); - expect(lifeButton).toHaveClass('rounded-[var(--radius-selection)]'); - expect(lifeButton).not.toHaveClass('shadow-sm'); - expect(lifeButton).not.toHaveClass('active:translate-y-px'); + expect(lifeButton).toHaveClass('border-b-2'); + expect(lifeButton).toHaveClass('border-[var(--color-toss-blue)]'); + expect(lifeButton).toHaveClass('text-[var(--color-toss-blue)]'); + expect(lifeButton).not.toHaveClass('rounded-[var(--radius-selection)]'); + expect(lifeButton).not.toHaveClass('bg-[var(--color-toss-blue)]'); }); }); diff --git a/blog/ui/components/CategoryFilter/CategoryFilter.tsx b/blog/ui/components/CategoryFilter/CategoryFilter.tsx index 87e13eb0..1852b9bd 100644 --- a/blog/ui/components/CategoryFilter/CategoryFilter.tsx +++ b/blog/ui/components/CategoryFilter/CategoryFilter.tsx @@ -3,6 +3,7 @@ import { clsx } from 'clsx'; type Category = 'All' | 'Tech' | 'Life'; +type CategoryFilterVariant = 'links' | 'pills'; const CATEGORY_LABELS: Record = { All: 'All', @@ -15,6 +16,7 @@ interface CategoryFilterProps { activeCategory: Category; onCategoryChange: (category: Category) => void; categoryCounts: Record; + variant?: CategoryFilterVariant; } export default function CategoryFilter({ @@ -22,37 +24,62 @@ export default function CategoryFilter({ activeCategory, onCategoryChange, categoryCounts, + variant = 'pills', }: CategoryFilterProps) { + const isLinkVariant = variant === 'links'; + return ( -
- {categories.map((category) => ( - - ))} -
+ + {CATEGORY_LABELS[category]} + + {isLinkVariant ? ( + + ({categoryCounts[category]}) + + ) : ( + + {categoryCounts[category]} + + )} + + ))} +
+ ); } -export type { CategoryFilterProps, Category }; +export type { CategoryFilterProps, Category, CategoryFilterVariant }; diff --git a/blog/ui/components/GiscusComments.test.tsx b/blog/ui/components/GiscusComments.test.tsx index 791e0248..d654d942 100644 --- a/blog/ui/components/GiscusComments.test.tsx +++ b/blog/ui/components/GiscusComments.test.tsx @@ -1,16 +1,11 @@ -import { act, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useTheme } from 'next-themes'; import { GiscusComments } from './GiscusComments'; const mockGiscus = vi.fn((_props: Record) => (
)); -vi.mock('next-themes', () => ({ - useTheme: vi.fn(), -})); - vi.mock('@giscus/react', () => ({ default: (props: unknown) => mockGiscus(props), })); @@ -20,30 +15,9 @@ describe('GiscusComments', () => { mockGiscus.mockClear(); }); - it('renders section and applies dark theme to iframe when present', async () => { - const postMessage = vi.fn(); - - const iframe = document.createElement('iframe'); - iframe.className = 'giscus-frame'; - Object.defineProperty(iframe, 'contentWindow', { - value: { - postMessage, - }, - configurable: true, - }); - document.body.appendChild(iframe); - - vi.mocked(useTheme).mockReturnValue({ - theme: 'light', - resolvedTheme: 'dark', - } as ReturnType); - + it('renders the comment embed with the fixed paper theme', () => { render(); - await act(async () => { - await Promise.resolve(); - }); - expect( screen.getByRole('heading', { level: 2, name: '댓글' }) ).toBeInTheDocument(); @@ -52,38 +26,8 @@ describe('GiscusComments', () => { expect.objectContaining({ mapping: 'specific', term: 'redis', - theme: 'dark', + theme: 'light', }) ); - expect(postMessage).toHaveBeenCalledWith( - { - giscus: { - setConfig: { - theme: 'dark', - }, - }, - }, - 'https://giscus.app' - ); - - document.body.removeChild(iframe); - }); - - it('keeps the comment embed mounted when the theme changes', () => { - vi.mocked(useTheme).mockReturnValue({ - theme: 'light', - resolvedTheme: 'light', - } as ReturnType); - - const { rerender } = render(); - const initialEmbed = screen.getByTestId('giscus-embed'); - - vi.mocked(useTheme).mockReturnValue({ - theme: 'dark', - resolvedTheme: 'dark', - } as ReturnType); - rerender(); - - expect(screen.getByTestId('giscus-embed')).toBe(initialEmbed); }); }); diff --git a/blog/ui/components/GiscusComments.tsx b/blog/ui/components/GiscusComments.tsx index cbd1092f..2309c990 100644 --- a/blog/ui/components/GiscusComments.tsx +++ b/blog/ui/components/GiscusComments.tsx @@ -1,8 +1,6 @@ 'use client'; import Giscus from '@giscus/react'; -import { useEffect } from 'react'; -import { useTheme } from 'next-themes'; interface GiscusCommentsProps { slug: string; @@ -16,27 +14,6 @@ interface GiscusCommentsProps { * https://giscus.app 에서 설정 후 값을 확인하세요. */ export function GiscusComments({ slug }: GiscusCommentsProps) { - const { resolvedTheme } = useTheme(); - const giscusTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; - - useEffect(() => { - const iframe = document.querySelector( - 'iframe.giscus-frame' - ); - if (!iframe) return; - - iframe.contentWindow?.postMessage( - { - giscus: { - setConfig: { - theme: giscusTheme, - }, - }, - }, - 'https://giscus.app' - ); - }, [giscusTheme]); - return (

@@ -56,7 +33,7 @@ export function GiscusComments({ slug }: GiscusCommentsProps) { reactionsEnabled="1" emitMetadata="0" inputPosition="top" - theme={giscusTheme} + theme="light" lang="ko" loading="lazy" /> diff --git a/blog/ui/components/MermaidDiagram.test.tsx b/blog/ui/components/MermaidDiagram.test.tsx index e7435c4d..44cc09eb 100644 --- a/blog/ui/components/MermaidDiagram.test.tsx +++ b/blog/ui/components/MermaidDiagram.test.tsx @@ -8,12 +8,6 @@ import { import { describe, expect, it, vi } from 'vitest'; import { MermaidDiagram } from './MermaidDiagram'; -vi.mock('next-themes', () => ({ - useTheme: () => ({ - resolvedTheme: 'light', - }), -})); - const mockMermaidRender = vi.fn(async () => ({ svg: 'diagram', })); @@ -37,6 +31,7 @@ describe('MermaidDiagram', () => { expect(mockMermaidInitialize).toHaveBeenCalledWith( expect.objectContaining({ securityLevel: 'strict', + theme: 'neutral', }) ); diff --git a/blog/ui/components/MermaidDiagram.tsx b/blog/ui/components/MermaidDiagram.tsx index 7820c2d2..0f856a84 100644 --- a/blog/ui/components/MermaidDiagram.tsx +++ b/blog/ui/components/MermaidDiagram.tsx @@ -1,7 +1,6 @@ 'use client'; import { useId, useEffect, useRef, useState, type MouseEvent } from 'react'; -import { useTheme } from 'next-themes'; interface MermaidDiagramProps { chart: string; @@ -13,7 +12,6 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) { const [inlineScale, setInlineScale] = useState(1); const [fullscreenScale, setFullscreenScale] = useState(1); const [isFullscreen, setIsFullscreen] = useState(false); - const { resolvedTheme } = useTheme(); const uniqueId = useId().replace(/:/g, ''); const inlineHostRef = useRef(null); const fullscreenHostRef = useRef(null); @@ -100,7 +98,7 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) { mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', - theme: resolvedTheme === 'dark' ? 'dark' : 'default', + theme: 'neutral', }); const { svg: renderedSvg } = await mermaid.render( @@ -123,7 +121,7 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) { return () => { isActive = false; }; - }, [chart, resolvedTheme, uniqueId]); + }, [chart, uniqueId]); useEffect(() => { applyScale(inlineHostRef.current, inlineScale); diff --git a/blog/ui/components/PostCard.test.tsx b/blog/ui/components/PostCard.test.tsx index 31c6309d..83590980 100644 --- a/blog/ui/components/PostCard.test.tsx +++ b/blog/ui/components/PostCard.test.tsx @@ -65,6 +65,47 @@ describe('PostCard', () => { expect(screen.queryByRole('presentation')).not.toBeInTheDocument(); }); + it('renders date and title only in archive variant', () => { + const detailedPost: FeedData = { + ...basePost, + readingTime: 12, + series: { + id: 'redis', + title: 'Redis 완전정복', + order: 1, + }, + tags: ['redis', 'cache'], + }; + + render(); + + const link = screen.getByRole('link', { + name: /2026\.02\.10 테스트 글/, + }); + + expect(link).toHaveAttribute('href', '/blog/test-post'); + expect(link).toHaveClass( + 'min-h-11', + 'flex-col', + 'sm:flex-row', + 'items-start', + 'sm:items-baseline' + ); + expect(link).not.toHaveClass('border'); + expect(screen.getByText('2026.02.10')).toHaveAttribute( + 'dateTime', + '2026-02-10T00:00:00.000Z' + ); + expect(screen.queryByText('Tech')).not.toBeInTheDocument(); + expect(screen.queryByText('테스트 설명')).not.toBeInTheDocument(); + expect(screen.queryByText('#redis')).not.toBeInTheDocument(); + expect(screen.queryByText('약 12분')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '테스트 글' })).toHaveClass( + 'text-lg', + 'sm:text-base' + ); + }); + it('uses a static content surface for the list variant', () => { render(); diff --git a/blog/ui/components/PostCard/PostCard.tsx b/blog/ui/components/PostCard/PostCard.tsx index 2518df62..e3c843dc 100644 --- a/blog/ui/components/PostCard/PostCard.tsx +++ b/blog/ui/components/PostCard/PostCard.tsx @@ -5,7 +5,17 @@ import { CategoryIcon } from '@/ui/icons/AppSectionIcon'; interface PostCardProps { post: FeedData; - variant?: 'default' | 'list'; + variant?: 'archive' | 'default' | 'list'; +} + +function formatArchiveDate(date: string): string { + const matchedDate = /^(\d{4})-(\d{2})-(\d{2})/.exec(date); + + if (!matchedDate) { + return date; + } + + return `${matchedDate[1]}.${matchedDate[2]}.${matchedDate[3]}`; } export default function PostCard({ post, variant = 'default' }: PostCardProps) { @@ -17,6 +27,29 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) { const readingTimeLabel = post.readingTime ? `약 ${post.readingTime}분` : null; const visibleTags = post.tags?.slice(0, 3) ?? []; + if (variant === 'archive') { + return ( + + +

+ {post.title} +

+ + ); + } + if (variant === 'list') { return ( { it('shows empty state when no posts exist', () => { render(); - expect(screen.getByRole('status')).toHaveTextContent('아직 작성된 글이 없어요'); + expect(screen.getByRole('status')).toHaveTextContent( + '아직 작성된 글이 없어요' + ); }); it('renders a list of posts', () => { @@ -59,4 +61,15 @@ describe('PostList', () => { expect(links[0]).toHaveAttribute('href', '/blog/one'); expect(screen.getByText('첫 번째 글')).toBeInTheDocument(); }); + + it('renders archive layout when requested', () => { + render(); + + expect(screen.getByRole('list')).toBeInTheDocument(); + expect(screen.getByRole('list')).toHaveClass('space-y-2', 'sm:space-y-1'); + expect(screen.getAllByRole('listitem')).toHaveLength(2); + expect( + screen.getByRole('link', { name: /2026\.02\.01 첫 번째 글/ }) + ).toHaveAttribute('href', '/blog/one'); + }); }); diff --git a/blog/ui/components/PostList/PostList.tsx b/blog/ui/components/PostList/PostList.tsx index fe04dc97..89460be6 100644 --- a/blog/ui/components/PostList/PostList.tsx +++ b/blog/ui/components/PostList/PostList.tsx @@ -12,7 +12,7 @@ import { interface PostListProps { posts: FeedData[]; - layout?: 'grid' | 'list'; + layout?: 'archive' | 'grid' | 'list'; } function getContainerVariants( @@ -49,6 +49,7 @@ function getItemVariants(effectiveMotionMode: EffectiveMotionMode): Variants { } const layoutClassNames = { + archive: 'space-y-2 sm:space-y-1', grid: 'grid gap-6 md:grid-cols-2', list: 'space-y-4', } satisfies Record, string>; @@ -68,12 +69,25 @@ export default function PostList({ posts, layout = 'grid' }: PostListProps) { if (effectiveMotionMode === 'off') { return ( -
+
{posts.map((post) => ( -
+
))} @@ -84,17 +98,25 @@ export default function PostList({ posts, layout = 'grid' }: PostListProps) { const containerVariants = getContainerVariants(effectiveMotionMode); const itemVariants = getItemVariants(effectiveMotionMode); - if (layout === 'list') { + if (layout === 'list' || layout === 'archive') { return ( {posts.map((post) => ( - - + + ))} diff --git a/blog/ui/components/ScrollWorkflow.test.tsx b/blog/ui/components/ScrollWorkflow.test.tsx deleted file mode 100644 index e4d94438..00000000 --- a/blog/ui/components/ScrollWorkflow.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { resetDomState, setWindowScrollY } from '@/tests/support/dom-mocks'; -import { ScrollWorkflow } from './ScrollWorkflow'; - -function createSection(id: string, top: number) { - const section = document.createElement('section'); - section.id = id; - section.getBoundingClientRect = () => ({ - top: top - window.scrollY, - } as DOMRect); - document.body.appendChild(section); - return section; -} - -describe('ScrollWorkflow', () => { - it('renders first step and updates to final step while scrolling down', () => { - resetDomState(); - Object.defineProperty(window, 'innerHeight', { - configurable: true, - writable: true, - value: 800, - }); - - document.body.innerHTML = ''; - createSection('5-knowledge-stack을-분리한-이유', 1000); - createSection('6-현재-운영하는-workflow', 1600); - createSection('7-내-환경에서-실제로-쓰는-homebrew-도구', 2200); - - setWindowScrollY(0); - render(); - - expect(screen.getByText('Step 1/8')).toBeInTheDocument(); - expect(screen.getAllByText('Atlas Research')).toHaveLength(2); - - act(() => { - setWindowScrollY(1700); - window.dispatchEvent(new Event('scroll')); - }); - - expect(screen.getByText('Step 8/8')).toBeInTheDocument(); - expect(screen.getAllByText('Warp CLI 운영·배포 작업')).toHaveLength(2); - }); - - it('falls back safely when workflow sections are missing', () => { - resetDomState(); - document.body.innerHTML = ''; - createSection('6-현재-운영하는-workflow', 1200); - createSection('7-내-환경에서-실제로-쓰는-homebrew-도구', 1700); - - setWindowScrollY(0); - render(); - - expect(screen.getByText('Step 1/8')).toBeInTheDocument(); - - act(() => { - setWindowScrollY(300); - window.dispatchEvent(new Event('scroll')); - }); - - expect(screen.getByText('Step 1/8')).toBeInTheDocument(); - }); -}); diff --git a/blog/ui/components/ScrollWorkflow.tsx b/blog/ui/components/ScrollWorkflow.tsx deleted file mode 100644 index a0e7c794..00000000 --- a/blog/ui/components/ScrollWorkflow.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; - -interface WorkflowStep { - id: string; - title: string; - detail: string; -} - -const WORKFLOW_STEPS: WorkflowStep[] = [ - { id: 'A', title: 'Atlas Research', detail: '탐색 범위를 정의하고 레퍼런스를 빠르게 모읍니다.' }, - { id: 'B', title: 'Knowledge 정리', detail: 'Notion/Obsidian에 재사용 가능한 컨텍스트를 축적합니다.' }, - { id: 'C', title: 'ChatGPT 설계 검토', detail: '구조/트레이드오프를 점검하고 실패 비용을 줄입니다.' }, - { id: 'D', title: 'Codex Prototype 생성', detail: '반복 구현을 빠르게 실험 가능한 형태로 만듭니다.' }, - { id: 'E', title: 'IntelliJ 구현', detail: '도메인 로직과 품질 기준을 반영해 본 구현으로 고도화합니다.' }, - { id: 'F', title: 'DataGrip DB 검증', detail: '스키마/쿼리/인덱스 관점에서 데이터 품질을 검증합니다.' }, - { id: 'G', title: 'Postman API 테스트', detail: '계약 검증과 에러 케이스를 확인해 릴리스 리스크를 낮춥니다.' }, - { id: 'H', title: 'Warp CLI 운영·배포 작업', detail: '실행/운영 루틴을 정리해 반복 가능한 배포 흐름을 만듭니다.' }, -]; - -export function ScrollWorkflow() { - const containerRef = useRef(null); - const [progress, setProgress] = useState(0); - const [activeIndex, setActiveIndex] = useState(0); - - useEffect(() => { - function updateProgress() { - const viewport = window.innerHeight; - const section5 = document.getElementById('5-knowledge-stack을-분리한-이유'); - const section6 = document.getElementById('6-현재-운영하는-workflow'); - const section7 = document.getElementById('7-내-환경에서-실제로-쓰는-homebrew-도구'); - - if (!section5 || !section6) return; - - const startY = - window.scrollY + section5.getBoundingClientRect().top - viewport * 0.5; - const section6Top = - window.scrollY + section6.getBoundingClientRect().top - viewport * 0.25; - const section6VisibleY = - window.scrollY + section6.getBoundingClientRect().top - viewport * 0.35; - const section7Top = section7 - ? window.scrollY + section7.getBoundingClientRect().top - viewport * 0.85 - : section6Top + viewport * 0.7; - const endY = Math.max(section6Top + 120, section7Top); - const effectiveStartY = Math.max(startY, section6VisibleY); - - const totalScrollable = Math.max(1, endY - effectiveStartY); - const passed = window.scrollY - effectiveStartY; - const nextProgress = Math.min(1, Math.max(0, passed / totalScrollable)); - const holdFirstStepUntil = 0.14; - const normalizedProgress = - nextProgress <= holdFirstStepUntil - ? 0 - : (nextProgress - holdFirstStepUntil) / (1 - holdFirstStepUntil); - const nextIndex = Math.min( - WORKFLOW_STEPS.length - 1, - Math.floor(normalizedProgress * WORKFLOW_STEPS.length) - ); - - setProgress(nextProgress); - setActiveIndex(nextIndex); - } - - updateProgress(); - window.addEventListener('scroll', updateProgress, { passive: true }); - window.addEventListener('resize', updateProgress); - - return () => { - window.removeEventListener('scroll', updateProgress); - window.removeEventListener('resize', updateProgress); - }; - }, []); - - const activeStep = WORKFLOW_STEPS[activeIndex]; - - return ( -
-
-
- - Step {activeIndex + 1}/{WORKFLOW_STEPS.length} - - {Math.round(progress * 100)}% -
- -
-
-
- -
- {WORKFLOW_STEPS.map((step, index) => { - const isActive = index <= activeIndex; - return ( -
- {step.id} - {step.title} -
- ); - })} -
- -
-

{activeStep.title}

-

{activeStep.detail}

-
-
-
- ); -} diff --git a/blog/ui/components/SeriesNavigation/SeriesNavigation.module.css b/blog/ui/components/SeriesNavigation/SeriesNavigation.module.css index bce272d6..08afe9de 100644 --- a/blog/ui/components/SeriesNavigation/SeriesNavigation.module.css +++ b/blog/ui/components/SeriesNavigation/SeriesNavigation.module.css @@ -1,190 +1,195 @@ .container { - margin: 24px 0 32px; - border: 1px solid var(--color-grey-100); - border-radius: 16px; - background: var(--color-grey-50); - overflow: hidden; + margin: 24px 0 32px; + border: 1px solid var(--color-border); + border-radius: 16px; + background: var(--color-bg-primary); + overflow: hidden; } .header { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 16px 20px; - background: transparent; - border: none; - cursor: pointer; - transition: background 200ms ease; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 16px 20px; + background: transparent; + border: none; + cursor: pointer; + transition: background 200ms ease; } .header:hover { - background: rgba(0, 0, 0, 0.02); + background: var(--color-bg-secondary); } .headerContent { - display: flex; - align-items: center; - gap: 12px; + display: flex; + align-items: center; + gap: 12px; } .seriesLabel { - padding: 4px 8px; - font-size: 12px; - font-weight: 500; - color: white; - background: var(--color-toss-blue); - border-radius: 6px; + padding: 4px 8px; + font-size: 12px; + font-weight: 500; + color: var(--color-accent-foreground); + background: var(--color-toss-blue); + border-radius: 6px; } .seriesTitle { - font-size: 15px; - font-weight: 600; - color: var(--color-grey-900); + font-size: 15px; + font-weight: 600; + color: var(--color-grey-900); } .count { - font-size: 13px; - color: var(--color-grey-500); + font-size: 13px; + color: var(--color-grey-500); } .chevron { - color: var(--color-grey-500); - transition: transform 200ms ease; + color: var(--color-grey-500); + transition: transform 200ms ease; } .chevronOpen { - transform: rotate(180deg); + transform: rotate(180deg); } .content { - padding: 0 20px 20px; + padding: 0 20px 20px; } .list { - display: flex; - flex-direction: column; - gap: 4px; - list-style: none; - padding: 0; - margin: 0 0 16px; + display: flex; + flex-direction: column; + gap: 4px; + list-style: none; + padding: 0; + margin: 0 0 16px; } .item { - font-size: 14px; - line-height: 1.6; + font-size: 14px; + line-height: 1.6; } .order { - display: inline-block; - width: 24px; - color: var(--color-grey-500); + display: inline-block; + width: 24px; + color: var(--color-grey-500); } .link { - display: block; - padding: 8px 12px; - color: var(--color-grey-600); - text-decoration: none; - border-radius: 8px; - transition: background 150ms ease, color 150ms ease; + display: block; + padding: 8px 12px; + color: var(--color-grey-600); + text-decoration: none; + border-radius: 8px; + transition: + background 150ms ease, + color 150ms ease; } .link:hover { - background: white; - color: var(--color-grey-900); + background: var(--color-bg-secondary); + color: var(--color-grey-900); } .current { - display: block; - padding: 8px 12px; - color: var(--color-toss-blue); - font-weight: 500; - background: white; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: block; + padding: 8px 12px; + color: var(--color-toss-blue); + font-weight: 500; + background: var(--color-bg-secondary); + border-radius: 8px; + box-shadow: var(--shadow-sm); } .itemActive .order { - color: var(--color-toss-blue); + color: var(--color-toss-blue); } .navigation { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - padding-top: 16px; - border-top: 1px solid var(--color-grey-100); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding-top: 16px; + border-top: 1px solid var(--color-grey-100); } .navLink { - display: flex; - align-items: center; - gap: 8px; - padding: 12px; - background: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: 12px; - text-decoration: none; - color: var(--color-grey-700); - transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease; + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: 12px; + text-decoration: none; + color: var(--color-grey-700); + transition: + transform 150ms ease, + box-shadow 150ms ease, + border-color 150ms ease; } .navLink:hover { - transform: translateY(-2px); - border-color: var(--color-border-hover); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); + border-color: var(--color-border-hover); + box-shadow: var(--shadow-md); } .navLink:active { - transform: scale(0.98); + transform: scale(0.98); } .navLinkNext { - justify-content: flex-end; - text-align: right; + justify-content: flex-end; + text-align: right; } .navText { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; } .navLabel { - font-size: 12px; - color: var(--color-grey-500); + font-size: 12px; + color: var(--color-grey-500); } .navTitle { - font-size: 13px; - font-weight: 500; - color: var(--color-grey-900); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + font-size: 13px; + font-weight: 500; + color: var(--color-grey-900); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } @media (max-width: 640px) { - .headerContent { - flex-wrap: wrap; - gap: 8px; - } - - .seriesTitle { - flex-basis: 100%; - order: 3; - font-size: 14px; - } - - .navigation { - grid-template-columns: 1fr; - } - - .navLinkNext { - justify-content: flex-start; - text-align: left; - flex-direction: row-reverse; - } + .headerContent { + flex-wrap: wrap; + gap: 8px; + } + + .seriesTitle { + flex-basis: 100%; + order: 3; + font-size: 14px; + } + + .navigation { + grid-template-columns: 1fr; + } + + .navLinkNext { + justify-content: flex-start; + text-align: left; + flex-direction: row-reverse; + } } diff --git a/blog/ui/components/index.ts b/blog/ui/components/index.ts index 21289c5b..90736e6f 100644 --- a/blog/ui/components/index.ts +++ b/blog/ui/components/index.ts @@ -15,7 +15,6 @@ export { ReadingProgress } from './ReadingProgress'; export { ImageGrid } from './ImageGrid'; export { GiscusComments } from './GiscusComments'; export { MermaidDiagram } from './MermaidDiagram'; -export { ScrollWorkflow } from './ScrollWorkflow'; export { SeriesNavigation } from './SeriesNavigation'; export { default as SeriesHubCard } from './SeriesHubCard'; diff --git a/blog/ui/mdx/components.test.tsx b/blog/ui/mdx/components.test.tsx index 33c3ef59..cb297fe1 100644 --- a/blog/ui/mdx/components.test.tsx +++ b/blog/ui/mdx/components.test.tsx @@ -47,15 +47,4 @@ describe('getMDXComponents', () => { expect(screen.getByText('본문 문단')).not.toHaveClass('text-base'); expect(screen.getByText('본문 문단')).not.toHaveClass('leading-relaxed'); }); - - it('does not register Three.js visualizations for every MDX post', () => { - const components = getMDXComponents({}); - - expect(components).not.toHaveProperty('BinarySearchVisualization'); - expect(components).not.toHaveProperty('DPVisualization'); - expect(components).not.toHaveProperty('GraphTraversalVisualization'); - expect(components).not.toHaveProperty('SlidingWindowVisualization'); - expect(components).not.toHaveProperty('SortingVisualization'); - expect(components).not.toHaveProperty('TwoPointerVisualization'); - }); }); diff --git a/blog/ui/mdx/components.tsx b/blog/ui/mdx/components.tsx index fae02c8a..16013f14 100644 --- a/blog/ui/mdx/components.tsx +++ b/blog/ui/mdx/components.tsx @@ -10,7 +10,6 @@ import Image from 'next/image'; import { ImageGrid, MermaidDiagram, - ScrollWorkflow, } from '@/blog/ui/components'; function extractText(node: ReactNode): string { @@ -108,7 +107,6 @@ export function getMDXComponents(components: MDXComponents): MDXComponents { ); }, ImageGrid, - ScrollWorkflow, pre: (props) => { const preProps = props as ComponentPropsWithoutRef<'pre'> & { 'data-language'?: string; diff --git a/blog/ui/mdx/visualization-components.test.ts b/blog/ui/mdx/visualization-components.test.ts deleted file mode 100644 index fe85f9b8..00000000 --- a/blog/ui/mdx/visualization-components.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const visualizationNames = [ - 'BinarySearchVisualization', - 'DPVisualization', - 'GraphTraversalVisualization', - 'SlidingWindowVisualization', - 'SortingVisualization', - 'TwoPointerVisualization', -] as const; - -const loaderPath = path.resolve( - process.cwd(), - 'blog/ui/mdx/visualization-components.tsx' -); -const loaderContent = fs.readFileSync(loaderPath, 'utf8'); -const postPath = path.resolve(process.cwd(), 'posts/알고리즘-시각화/index.mdx'); -const postContent = fs.readFileSync(postPath, 'utf8'); - -describe('MDX visualization loading boundary', () => { - it('loads each heavy visualization through its own dynamic import', () => { - expect(loaderContent).toContain("'use client';"); - expect(loaderContent).toContain('lazy(loader)'); - expect(loaderContent).not.toContain("from 'next/dynamic'"); - expect(loaderContent).not.toContain("import('@/blog/ui/visualization')"); - - for (const name of visualizationNames) { - expect(loaderContent).toContain( - `import('@/blog/ui/visualization/${name}')` - ); - } - }); - - it('opts the visualization post into the heavy component bundle', () => { - expect(postContent).toContain( - "from '@/blog/ui/mdx/visualization-components';" - ); - - for (const name of visualizationNames) { - expect(postContent).toContain(`<${name} />`); - } - }); -}); diff --git a/blog/ui/mdx/visualization-components.tsx b/blog/ui/mdx/visualization-components.tsx deleted file mode 100644 index 6046dae4..00000000 --- a/blog/ui/mdx/visualization-components.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; - -import { - lazy, - Suspense, - type ComponentType, - type FunctionComponent, -} from 'react'; - -type VisualizationLoader = () => Promise<{ - default: ComponentType; -}>; - -function createLazyVisualization( - displayName: string, - loader: VisualizationLoader -): FunctionComponent { - const LazyVisualization = lazy(loader); - - function Visualization() { - return ( - - - - ); - } - - Visualization.displayName = displayName; - return Visualization; -} - -export const BinarySearchVisualization = createLazyVisualization( - 'BinarySearchVisualization', - () => import('@/blog/ui/visualization/BinarySearchVisualization') -); -export const DPVisualization = createLazyVisualization( - 'DPVisualization', - () => import('@/blog/ui/visualization/DPVisualization') -); -export const GraphTraversalVisualization = createLazyVisualization( - 'GraphTraversalVisualization', - () => import('@/blog/ui/visualization/GraphTraversalVisualization') -); -export const SlidingWindowVisualization = createLazyVisualization( - 'SlidingWindowVisualization', - () => import('@/blog/ui/visualization/SlidingWindowVisualization') -); -export const SortingVisualization = createLazyVisualization( - 'SortingVisualization', - () => import('@/blog/ui/visualization/SortingVisualization') -); -export const TwoPointerVisualization = createLazyVisualization( - 'TwoPointerVisualization', - () => import('@/blog/ui/visualization/TwoPointerVisualization') -); diff --git a/blog/ui/pages/EngineeringPageClient.tsx b/blog/ui/pages/EngineeringPageClient.tsx index 59f2d2db..decd18e1 100644 --- a/blog/ui/pages/EngineeringPageClient.tsx +++ b/blog/ui/pages/EngineeringPageClient.tsx @@ -117,7 +117,7 @@ export default function EngineeringPageClient({ className={clsx( 'rounded border px-3 py-1.5 transition-colors', page === currentPage - ? 'border-[var(--color-toss-blue)] bg-[var(--color-toss-blue)] text-white' + ? 'border-[var(--color-toss-blue)] bg-[var(--color-toss-blue)] text-[var(--color-accent-foreground)]' : 'border-[var(--color-grey-200)] bg-[var(--color-bg-primary)] text-[var(--color-grey-700)] hover:bg-[var(--color-grey-50)]' )} > diff --git a/blog/ui/visualization/BinarySearchVisualization.tsx b/blog/ui/visualization/BinarySearchVisualization.tsx deleted file mode 100644 index 281697c3..00000000 --- a/blog/ui/visualization/BinarySearchVisualization.tsx +++ /dev/null @@ -1,337 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import { Canvas, useFrame } from '@react-three/fiber'; -import { OrbitControls, Text } from '@react-three/drei'; -import * as THREE from 'three'; - -interface Element { - value: number; - status: 'default' | 'left' | 'right' | 'mid' | 'found' | 'excluded'; -} - -// 3D Box component -function Box3D({ - position, - value, - status, -}: { - position: [number, number, number]; - value: number; - status: string; -}) { - const meshRef = useRef(null); - - useFrame(() => { - if (meshRef.current && status === 'mid') { - const scale = 1 + Math.sin(Date.now() * 0.005) * 0.15; - meshRef.current.scale.setScalar(scale); - } else if (meshRef.current) { - meshRef.current.scale.setScalar(1); - } - }); - - const color = - status === 'found' - ? '#6bcf7f' - : status === 'mid' - ? '#ff6b6b' - : status === 'left' - ? '#ffd93d' - : status === 'right' - ? '#ffd93d' - : status === 'excluded' - ? '#2c3e50' - : '#4a90e2'; - - const opacity = status === 'excluded' ? 0.3 : 1; - - return ( - - - - - - - {value} - - {status === 'left' && ( - - L - - )} - {status === 'right' && ( - - R - - )} - {status === 'mid' && ( - - MID - - )} - - ); -} - -// Scene component -function BinarySearchScene({ elements }: { elements: Element[] }) { - const spacing = 1.2; - const startX = (-(elements.length - 1) * spacing) / 2; - - return ( - <> - - - - - {elements.map((element, index) => ( - - ))} - - - - ); -} - -export default function BinarySearchVisualization() { - const [elements, setElements] = useState([]); - const [target, setTarget] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [message, setMessage] = useState(''); - const [speed, setSpeed] = useState(800); - - // Initialize sorted array - const initializeArray = () => { - const newElements: Element[] = []; - for (let i = 0; i < 12; i++) { - newElements.push({ - value: (i + 1) * 5, - status: 'default', - }); - } - setElements(newElements); - setTarget( - newElements[Math.floor(Math.random() * newElements.length)].value - ); - setMessage(''); - setIsPlaying(false); - }; - - useEffect(() => { - initializeArray(); - }, []); - - const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const binarySearch = async () => { - setIsPlaying(true); - const arr = [...elements]; - let left = 0; - let right = arr.length - 1; - let steps = 0; - - // Reset all to default - arr.forEach((el) => (el.status = 'default')); - setElements([...arr]); - await sleep(speed); - - while (left <= right) { - steps++; - - // Mark excluded regions - for (let i = 0; i < left; i++) { - arr[i].status = 'excluded'; - } - for (let i = right + 1; i < arr.length; i++) { - arr[i].status = 'excluded'; - } - - // Mark left and right pointers - arr[left].status = 'left'; - arr[right].status = 'right'; - setElements([...arr]); - setMessage(`Step ${steps}: 탐색 범위 [${left}, ${right}]`); - await sleep(speed); - - const mid = Math.floor((left + right) / 2); - - // Mark mid - arr[mid].status = 'mid'; - setElements([...arr]); - setMessage( - `Step ${steps}: mid = ${mid}, arr[${mid}] = ${arr[mid].value}, target = ${target}` - ); - await sleep(speed * 1.5); - - if (arr[mid].value === target) { - arr[mid].status = 'found'; - setElements([...arr]); - setMessage(`✅ 찾았습니다! ${steps}번 만에 발견 (인덱스: ${mid})`); - setIsPlaying(false); - return; - } - - if (arr[mid].value < target) { - setMessage(`${arr[mid].value} < ${target} → 오른쪽 탐색`); - left = mid + 1; - } else { - setMessage(`${arr[mid].value} > ${target} → 왼쪽 탐색`); - right = mid - 1; - } - - await sleep(speed); - } - - setMessage(`❌ 찾지 못했습니다 (${steps}번 탐색)`); - setIsPlaying(false); - }; - - return ( -
- {/* Controls */} -
-
-
-
-
- - setTarget(Number(e.target.value))} - disabled={isPlaying} - className="px-3 py-2 bg-slate-700 text-white rounded-lg w-20 text-center font-bold disabled:opacity-50" - min="5" - max="60" - step="5" - /> -
-
- - setSpeed(Number(e.target.value))} - className="w-32" - disabled={isPlaying} - /> -
-
- -
- - -
-
- - {/* Message */} - {message && ( -
-

{message}

-
- )} - - {/* Legend */} -
-
-
- 탐색 가능 -
-
-
- Left / Right -
-
-
- Mid (비교 중) -
-
-
- 제외됨 -
-
-
- 찾음! -
-
-
-
- - {/* Canvas */} -
- - - - -
-
- ); -} diff --git a/blog/ui/visualization/DPVisualization.tsx b/blog/ui/visualization/DPVisualization.tsx deleted file mode 100644 index 83d281f2..00000000 --- a/blog/ui/visualization/DPVisualization.tsx +++ /dev/null @@ -1,390 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import { Canvas, useFrame } from '@react-three/fiber'; -import { OrbitControls, Text } from '@react-three/drei'; -import * as THREE from 'three'; - -interface DPCell { - index: number; - value: number | null; - status: 'empty' | 'calculating' | 'computed' | 'using'; -} - -function createInitialCells(limit: number): DPCell[] { - const newCells: DPCell[] = []; - for (let i = 0; i <= limit; i++) { - newCells.push({ - index: i, - value: null, - status: 'empty', - }); - } - - return newCells; -} - -// 3D Cell component -function Cell3D({ - position, - index, - value, - status, -}: { - position: [number, number, number]; - index: number; - value: number | null; - status: string; -}) { - const meshRef = useRef(null); - - useFrame(() => { - if (meshRef.current && status === 'calculating') { - const scale = 1 + Math.sin(Date.now() * 0.01) * 0.15; - meshRef.current.scale.setScalar(scale); - } else if (meshRef.current) { - meshRef.current.scale.setScalar(1); - } - }); - - const color = - status === 'computed' - ? '#6bcf7f' - : status === 'calculating' - ? '#ff6b6b' - : status === 'using' - ? '#ffd93d' - : '#4a90e2'; - - return ( - - - - - - {/* Index label */} - - F({index}) - - {/* Value */} - - {value !== null ? value : '?'} - - - ); -} - -// Arrow component -function Arrow({ - from, - to, -}: { - from: [number, number, number]; - to: [number, number, number]; -}) { - const direction = new THREE.Vector3( - to[0] - from[0], - to[1] - from[1], - to[2] - from[2] - ); - const length = direction.length(); - direction.normalize(); - - const arrowHelper = new THREE.ArrowHelper( - direction, - new THREE.Vector3(...from), - length, - 0xffd93d, - 0.3, - 0.2 - ); - - return ; -} - -// Scene component -function DPScene({ - cells, - showArrows, - arrowFrom, - arrowTo, -}: { - cells: DPCell[]; - showArrows: boolean; - arrowFrom: number[]; - arrowTo: number; -}) { - const spacing = 1.3; - const startX = (-(cells.length - 1) * spacing) / 2; - - return ( - <> - - - - - {/* Cells */} - {cells.map((cell, index) => ( - - ))} - - {/* Arrows showing dependencies */} - {showArrows && - arrowFrom.map((fromIdx, i) => { - if ( - fromIdx >= 0 && - fromIdx < cells.length && - arrowTo >= 0 && - arrowTo < cells.length - ) { - return ( - - ); - } - return null; - })} - - - - ); -} - -export default function DPVisualization() { - const [n, setN] = useState(8); - const [cells, setCells] = useState([]); - const [isPlaying, setIsPlaying] = useState(false); - const [message, setMessage] = useState(''); - const [speed, setSpeed] = useState(800); - const [showArrows, setShowArrows] = useState(false); - const [arrowFrom, setArrowFrom] = useState([]); - const [arrowTo, setArrowTo] = useState(-1); - - // Initialize DP table - const initializeTable = () => { - setCells(createInitialCells(n)); - setMessage(''); - setIsPlaying(false); - setShowArrows(false); - setArrowFrom([]); - setArrowTo(-1); - }; - - useEffect(() => { - setCells(createInitialCells(n)); - setMessage(''); - setIsPlaying(false); - setShowArrows(false); - setArrowFrom([]); - setArrowTo(-1); - }, [n]); - - const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const fibonacci = async () => { - setIsPlaying(true); - const dp = [...cells]; - - // Base cases - dp[0].value = 0; - dp[0].status = 'calculating'; - setCells([...dp]); - setMessage('F(0) = 0 (기저 사례)'); - await sleep(speed); - dp[0].status = 'computed'; - setCells([...dp]); - await sleep(speed * 0.5); - - dp[1].value = 1; - dp[1].status = 'calculating'; - setCells([...dp]); - setMessage('F(1) = 1 (기저 사례)'); - await sleep(speed); - dp[1].status = 'computed'; - setCells([...dp]); - await sleep(speed * 0.5); - - // Fill table - for (let i = 2; i <= n; i++) { - dp[i].status = 'calculating'; - setCells([...dp]); - setMessage(`F(${i}) 계산 중...`); - await sleep(speed); - - // Show dependencies - dp[i - 1].status = 'using'; - dp[i - 2].status = 'using'; - setShowArrows(true); - setArrowFrom([i - 2, i - 1]); - setArrowTo(i); - setCells([...dp]); - setMessage( - `F(${i}) = F(${i - 1}) + F(${i - 2}) = ${dp[i - 1].value} + ${dp[i - 2].value}` - ); - await sleep(speed * 1.5); - - // Calculate - dp[i].value = dp[i - 1].value! + dp[i - 2].value!; - dp[i].status = 'computed'; - dp[i - 1].status = 'computed'; - dp[i - 2].status = 'computed'; - setShowArrows(false); - setCells([...dp]); - setMessage(`F(${i}) = ${dp[i].value} ✓`); - await sleep(speed); - } - - setMessage(`✅ 완료! F(${n}) = ${dp[n].value}`); - setIsPlaying(false); - }; - - return ( -
- {/* Controls */} -
-
-
-
-
- - - setN(Math.max(2, Math.min(12, Number(e.target.value)))) - } - disabled={isPlaying} - className="px-3 py-2 bg-slate-700 text-white rounded-lg w-16 text-center font-bold disabled:opacity-50" - min="2" - max="12" - /> -
-
- - setSpeed(Number(e.target.value))} - className="w-32" - disabled={isPlaying} - /> -
-
- -
- - -
-
- - {/* Message */} - {message && ( -
-

{message}

-
- )} - - {/* Info */} -
-

- 💡 - 동적 프로그래밍 (DP): 작은 부분 문제의 결과를 - 테이블에 저장하고 재사용하여, 중복 계산을 제거합니다. 피보나치는 - O(2ⁿ) → O(n)으로 최적화됩니다. -

-
- - {/* Legend */} -
-
-
- 미계산 -
-
-
- 계산 중 -
-
-
- 참조 중 -
-
-
- 계산 완료 -
-
-
-
- - {/* Canvas */} -
- - - - -
-
- ); -} diff --git a/blog/ui/visualization/GraphTraversalVisualization.tsx b/blog/ui/visualization/GraphTraversalVisualization.tsx deleted file mode 100644 index 45c1a050..00000000 --- a/blog/ui/visualization/GraphTraversalVisualization.tsx +++ /dev/null @@ -1,437 +0,0 @@ -'use client'; - -import React, { useRef, useState, useEffect, useMemo } from 'react'; -import { Canvas, useFrame } from '@react-three/fiber'; -import { OrbitControls, Text, Line } from '@react-three/drei'; -import * as THREE from 'three'; - -// Graph node structure -interface GraphNode { - id: number; - position: [number, number, number]; - connections: number[]; -} - -// Node component -function Node({ - position, - label, - isVisited, - isActive, - isQueued, -}: { - position: [number, number, number]; - label: string; - isVisited: boolean; - isActive: boolean; - isQueued: boolean; -}) { - const meshRef = useRef(null); - - useFrame(() => { - if (meshRef.current) { - // Pulse animation for active node - if (isActive) { - const scale = 1 + Math.sin(Date.now() * 0.005) * 0.2; - meshRef.current.scale.setScalar(scale); - } else { - meshRef.current.scale.setScalar(1); - } - } - }); - - const color = isActive - ? '#ff6b6b' - : isQueued - ? '#ffd93d' - : isVisited - ? '#6bcf7f' - : '#4a90e2'; - - return ( - - - - - - - {label} - - - ); -} - -// Edge component -function Edge({ - start, - end, - isTraversed, -}: { - start: [number, number, number]; - end: [number, number, number]; - isTraversed: boolean; -}) { - const points = useMemo( - () => [new THREE.Vector3(...start), new THREE.Vector3(...end)], - [start, end] - ); - - return ( - - ); -} - -// Graph scene component -function GraphScene({ - nodes, - visitedNodes, - activeNode, - queuedNodes, - traversedEdges, -}: { - nodes: GraphNode[]; - visitedNodes: Set; - activeNode: number | null; - queuedNodes: Set; - traversedEdges: Set; -}) { - return ( - <> - - - - - {/* Render edges */} - {nodes.map((node) => - node.connections.map((targetId) => { - const target = nodes.find((n) => n.id === targetId); - if (!target) return null; - - const edgeKey = `${node.id}-${targetId}`; - const reverseEdgeKey = `${targetId}-${node.id}`; - const isTraversed = - traversedEdges.has(edgeKey) || traversedEdges.has(reverseEdgeKey); - - return ( - - ); - }) - )} - - {/* Render nodes */} - {nodes.map((node) => ( - - ))} - - - - ); -} - -// Main visualization component -export default function GraphTraversalVisualization() { - const [algorithm, setAlgorithm] = useState<'DFS' | 'BFS'>('DFS'); - const [isPlaying, setIsPlaying] = useState(false); - const [visitedNodes, setVisitedNodes] = useState>(new Set()); - const [activeNode, setActiveNode] = useState(null); - const [queuedNodes, setQueuedNodes] = useState>(new Set()); - const [traversedEdges, setTraversedEdges] = useState>(new Set()); - const [currentStep, setCurrentStep] = useState(0); - const [traversalPath, setTraversalPath] = useState([]); - - // Define graph structure (tree-like graph) - const nodes: GraphNode[] = useMemo( - () => [ - { id: 1, position: [0, 3, 0], connections: [2, 3, 4] }, - { id: 2, position: [-3, 1, 0], connections: [1, 5, 6] }, - { id: 3, position: [0, 1, 0], connections: [1, 7] }, - { id: 4, position: [3, 1, 0], connections: [1, 8, 9] }, - { id: 5, position: [-4, -1, 0], connections: [2] }, - { id: 6, position: [-2, -1, 0], connections: [2] }, - { id: 7, position: [0, -1, 0], connections: [3] }, - { id: 8, position: [2, -1, 0], connections: [4] }, - { id: 9, position: [4, -1, 0], connections: [4] }, - ], - [] - ); - - // DFS algorithm - const runDFS = (startNode: number): number[] => { - const visited = new Set(); - const path: number[] = []; - - const dfs = (nodeId: number) => { - visited.add(nodeId); - path.push(nodeId); - - const node = nodes.find((n) => n.id === nodeId); - if (node) { - for (const neighbor of node.connections) { - if (!visited.has(neighbor)) { - dfs(neighbor); - } - } - } - }; - - dfs(startNode); - return path; - }; - - // BFS algorithm - const runBFS = (startNode: number): number[] => { - const visited = new Set(); - const queue: number[] = [startNode]; - const path: number[] = []; - - visited.add(startNode); - - while (queue.length > 0) { - const nodeId = queue.shift()!; - path.push(nodeId); - - const node = nodes.find((n) => n.id === nodeId); - if (node) { - for (const neighbor of node.connections) { - if (!visited.has(neighbor)) { - visited.add(neighbor); - queue.push(neighbor); - } - } - } - } - - return path; - }; - - // Toggle play/pause - const togglePlayPause = () => { - if (traversalPath.length === 0 || currentStep >= traversalPath.length) { - // Start new traversal - setVisitedNodes(new Set()); - setActiveNode(null); - setQueuedNodes(new Set()); - setTraversedEdges(new Set()); - setCurrentStep(0); - const path = algorithm === 'DFS' ? runDFS(1) : runBFS(1); - setTraversalPath(path); - setIsPlaying(true); - } else { - // Toggle pause/resume - setIsPlaying(!isPlaying); - } - }; - - // Handle algorithm change - const handleAlgorithmChange = (newAlgorithm: 'DFS' | 'BFS') => { - setAlgorithm(newAlgorithm); - // Reset visualization when algorithm changes - setIsPlaying(false); - setVisitedNodes(new Set()); - setActiveNode(null); - setQueuedNodes(new Set()); - setTraversedEdges(new Set()); - setCurrentStep(0); - setTraversalPath([]); - }; - - // Animation effect - useEffect(() => { - if (!isPlaying || currentStep >= traversalPath.length) { - if (currentStep >= traversalPath.length && traversalPath.length > 0) { - setIsPlaying(false); - setActiveNode(null); - } - return; - } - - const timer = setTimeout(() => { - const currentNodeId = traversalPath[currentStep]; - const prevNodeId = - currentStep > 0 ? traversalPath[currentStep - 1] : null; - - setActiveNode(currentNodeId); - setVisitedNodes((prev) => new Set([...prev, currentNodeId])); - - // Add traversed edge - if (prevNodeId !== null) { - setTraversedEdges( - (prev) => new Set([...prev, `${prevNodeId}-${currentNodeId}`]) - ); - } - - // Update queued nodes for BFS - if (algorithm === 'BFS') { - const remainingPath = traversalPath.slice(currentStep + 1); - setQueuedNodes(new Set(remainingPath.slice(0, 5))); // Show next 5 in queue - } - - setCurrentStep((prev) => prev + 1); - }, 800); - - return () => clearTimeout(timer); - }, [isPlaying, currentStep, traversalPath, algorithm]); - - return ( -
- {/* Controls & Legend */} -
-
-
-
- - -
- -
- -
-
- - {/* Traversal Path */} -
-

- 탐색 순서 -

-
- {traversalPath.length > 0 ? ( -
- {traversalPath.map((nodeId, index) => ( - - {nodeId} - - ))} -
- ) : ( - - 알고리즘을 선택하고 재생 버튼을 눌러보세요 - - )} -
-
- - {/* Legend */} -
-
-
- 미방문 -
-
-
- - 대기중 (Queue/Stack) - -
-
-
- 현재 방문 -
-
-
- 방문 완료 -
-
-
-
- - {/* Canvas */} -
- - - - -
-
- ); -} diff --git a/blog/ui/visualization/SlidingWindowVisualization.tsx b/blog/ui/visualization/SlidingWindowVisualization.tsx deleted file mode 100644 index 6460088c..00000000 --- a/blog/ui/visualization/SlidingWindowVisualization.tsx +++ /dev/null @@ -1,420 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import { Canvas, useFrame } from '@react-three/fiber'; -import { OrbitControls, Text } from '@react-three/drei'; -import * as THREE from 'three'; - -interface Element { - value: number; - status: 'default' | 'in-window' | 'max-window' | 'passed'; -} - -// 3D Box component -function Box3DWindow({ - position, - value, - status, -}: { - position: [number, number, number]; - value: number; - status: string; -}) { - const meshRef = useRef(null); - - useFrame(() => { - if (meshRef.current && status === 'max-window') { - const scale = 1 + Math.sin(Date.now() * 0.008) * 0.12; - meshRef.current.scale.setScalar(scale); - } else if (meshRef.current) { - meshRef.current.scale.setScalar(1); - } - }); - - const color = - status === 'max-window' - ? '#6bcf7f' - : status === 'in-window' - ? '#3498db' - : status === 'passed' - ? '#95a5a6' - : '#4a90e2'; - - const opacity = status === 'passed' ? 0.4 : 1; - - return ( - - - - - - - {value} - - - ); -} - -// Window frame component -function WindowFrame({ - position, - width, -}: { - position: [number, number, number]; - width: number; -}) { - return ( - - {/* Top bar */} - - - - - {/* Bottom bar */} - - - - - {/* Left bar */} - - - - - {/* Right bar */} - - - - - - ); -} - -// Scene component -function SlidingWindowScene({ - elements, - windowStart, - windowSize, -}: { - elements: Element[]; - windowStart: number; - windowSize: number; -}) { - const spacing = 1.2; - const startX = (-(elements.length - 1) * spacing) / 2; - const windowCenterX = startX + (windowStart + windowSize / 2 - 0.5) * spacing; - - return ( - <> - - - - - {/* Window frame */} - {windowStart >= 0 && ( - - )} - - {/* Elements */} - {elements.map((element, index) => ( - - ))} - - - - ); -} - -export default function SlidingWindowVisualization() { - const [elements, setElements] = useState([]); - const [windowSize, setWindowSize] = useState(3); - const [windowStart, setWindowStart] = useState(-1); - const [isPlaying, setIsPlaying] = useState(false); - const [message, setMessage] = useState(''); - const [maxSum, setMaxSum] = useState(0); - const [maxWindowStart, setMaxWindowStart] = useState(-1); - const [speed, setSpeed] = useState(800); - - // Initialize array - const initializeArray = () => { - const newElements: Element[] = []; - for (let i = 0; i < 10; i++) { - newElements.push({ - value: Math.floor(Math.random() * 9) + 1, - status: 'default', - }); - } - setElements(newElements); - setWindowStart(-1); - setMaxSum(0); - setMaxWindowStart(-1); - setMessage(''); - setIsPlaying(false); - }; - - useEffect(() => { - initializeArray(); - }, []); - - const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const slidingWindow = async () => { - setIsPlaying(true); - const arr = [...elements]; - let currentMax = 0; - let currentMaxStart = 0; - - // Reset all to default - arr.forEach((el) => (el.status = 'default')); - setElements([...arr]); - await sleep(speed); - - // Calculate first window - let windowSum = 0; - for (let i = 0; i < windowSize; i++) { - windowSum += arr[i].value; - arr[i].status = 'in-window'; - } - - setWindowStart(0); - setElements([...arr]); - currentMax = windowSum; - currentMaxStart = 0; - setMaxSum(currentMax); - setMaxWindowStart(currentMaxStart); - setMessage(`초기 윈도우 [0-${windowSize - 1}]: 합 = ${windowSum}`); - await sleep(speed * 1.5); - - // Slide window - for (let i = windowSize; i < arr.length; i++) { - const step = i - windowSize + 1; - - // Remove leftmost element from window - arr[i - windowSize].status = 'passed'; - - // Add rightmost element to window - arr[i].status = 'in-window'; - - // Update sum - windowSum = windowSum - arr[i - windowSize].value + arr[i].value; - - setWindowStart(step); - setElements([...arr]); - setMessage( - `윈도우 [${step}-${i}]: 합 = ${windowSum} (제거: ${arr[i - windowSize].value}, 추가: ${arr[i].value})` - ); - await sleep(speed); - - // Update max - if (windowSum > currentMax) { - // Reset previous max window - for (let j = currentMaxStart; j < currentMaxStart + windowSize; j++) { - if (arr[j].status === 'max-window') { - arr[j].status = j < step ? 'passed' : 'in-window'; - } - } - - currentMax = windowSum; - currentMaxStart = step; - setMaxSum(currentMax); - setMaxWindowStart(currentMaxStart); - - // Mark new max window - for (let j = step; j <= i; j++) { - arr[j].status = 'max-window'; - } - setElements([...arr]); - setMessage( - `✨ 새로운 최대값 발견! 윈도우 [${step}-${i}]: 합 = ${windowSum}` - ); - await sleep(speed * 1.5); - } - } - - setMessage( - `✅ 완료! 최대 합: ${currentMax} (윈도우 [${currentMaxStart}-${currentMaxStart + windowSize - 1}])` - ); - setIsPlaying(false); - }; - - return ( -
- {/* Controls */} -
-
-
-
-
- - - setWindowSize( - Math.max(2, Math.min(5, Number(e.target.value))) - ) - } - disabled={isPlaying} - className="px-3 py-2 bg-slate-700 text-white rounded-lg w-16 text-center font-bold disabled:opacity-50" - min="2" - max="5" - /> -
-
- - setSpeed(Number(e.target.value))} - className="w-32" - disabled={isPlaying} - /> -
-
- -
- - -
-
- - {/* Stats */} - {maxSum > 0 && ( -
-
-

- 현재 윈도우:{' '} - {windowStart >= 0 - ? `[${windowStart}-${windowStart + windowSize - 1}]` - : '-'} -

-
-
-

- 최대 합: {maxSum} (윈도우 [{maxWindowStart}- - {maxWindowStart + windowSize - 1}]) -

-
-
- )} - - {/* Message */} - {message && ( -
-

{message}

-
- )} - - {/* Info */} -
-

- 💡 - 슬라이딩 윈도우: 고정 크기의 윈도우를 한 칸씩 - 이동하며, 이전 계산 결과를 재사용하여 O(n) 시간에 최대/최소값을 - 찾습니다. -

-
- - {/* Legend */} -
-
-
- 미확인 -
-
-
- 현재 윈도우 -
-
-
- 최대 합 윈도우 -
-
-
- 지나감 -
-
-
-
- - {/* Canvas */} -
- - - - -
-
- ); -} diff --git a/blog/ui/visualization/SortingVisualization.tsx b/blog/ui/visualization/SortingVisualization.tsx deleted file mode 100644 index 73fb2c99..00000000 --- a/blog/ui/visualization/SortingVisualization.tsx +++ /dev/null @@ -1,445 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import { Canvas, useFrame } from '@react-three/fiber'; -import { OrbitControls, Text } from '@react-three/drei'; -import * as THREE from 'three'; - -interface Bar { - value: number; - status: 'default' | 'comparing' | 'swapping' | 'sorted' | 'pivot'; -} - -// 3D Bar component -function Bar3D({ - position, - height, - status, -}: { - position: [number, number, number]; - height: number; - status: string; -}) { - const meshRef = useRef(null); - - useFrame(() => { - if (meshRef.current) { - if (status === 'swapping') { - const scale = 1 + Math.sin(Date.now() * 0.01) * 0.1; - meshRef.current.scale.set(1, scale, 1); - } else { - meshRef.current.scale.set(1, 1, 1); - } - } - }); - - const color = - status === 'sorted' - ? '#6bcf7f' - : status === 'comparing' - ? '#ffd93d' - : status === 'swapping' - ? '#ff6b6b' - : status === 'pivot' - ? '#9b59b6' - : '#4a90e2'; - - return ( - - - - - - - {Math.round(height * 10)} - - - ); -} - -// Scene component -function SortingScene({ bars }: { bars: Bar[] }) { - const spacing = 1.2; - const startX = (-(bars.length - 1) * spacing) / 2; - - return ( - <> - - - - - {bars.map((bar, index) => ( - - ))} - - - - ); -} - -export default function SortingVisualization() { - const [algorithm, setAlgorithm] = useState<'quick' | 'merge' | 'heap'>( - 'quick' - ); - const [bars, setBars] = useState([]); - const [isPlaying, setIsPlaying] = useState(false); - const [speed, setSpeed] = useState(500); - - // Initialize random array - const initializeArray = () => { - const newBars: Bar[] = []; - for (let i = 0; i < 10; i++) { - newBars.push({ - value: Math.random() * 5 + 1, - status: 'default', - }); - } - setBars(newBars); - setIsPlaying(false); - }; - - useEffect(() => { - initializeArray(); - }, []); - - // Quick Sort - const quickSort = async () => { - const arr = [...bars]; - - async function partition(low: number, high: number): Promise { - const pivot = arr[high].value; - arr[high].status = 'pivot'; - setBars([...arr]); - await sleep(speed); - - let i = low - 1; - - for (let j = low; j < high; j++) { - arr[j].status = 'comparing'; - setBars([...arr]); - await sleep(speed); - - if (arr[j].value < pivot) { - i++; - [arr[i], arr[j]] = [arr[j], arr[i]]; - arr[i].status = 'swapping'; - arr[j].status = 'swapping'; - setBars([...arr]); - await sleep(speed); - } - - arr[j].status = 'default'; - } - - [arr[i + 1], arr[high]] = [arr[high], arr[i + 1]]; - arr[i + 1].status = 'sorted'; - arr[high].status = 'default'; - setBars([...arr]); - await sleep(speed); - - return i + 1; - } - - async function quickSortHelper(low: number, high: number) { - if (low < high) { - const pi = await partition(low, high); - await quickSortHelper(low, pi - 1); - await quickSortHelper(pi + 1, high); - } else if (low === high) { - arr[low].status = 'sorted'; - setBars([...arr]); - } - } - - await quickSortHelper(0, arr.length - 1); - - // Mark all as sorted - arr.forEach((bar) => (bar.status = 'sorted')); - setBars([...arr]); - setIsPlaying(false); - }; - - // Merge Sort - const mergeSort = async () => { - const arr = [...bars]; - - async function merge(left: number, mid: number, right: number) { - const leftArr = arr.slice(left, mid + 1); - const rightArr = arr.slice(mid + 1, right + 1); - - let i = 0, - j = 0, - k = left; - - while (i < leftArr.length && j < rightArr.length) { - arr[k].status = 'comparing'; - setBars([...arr]); - await sleep(speed); - - if (leftArr[i].value <= rightArr[j].value) { - arr[k] = { ...leftArr[i], status: 'swapping' }; - i++; - } else { - arr[k] = { ...rightArr[j], status: 'swapping' }; - j++; - } - setBars([...arr]); - await sleep(speed); - arr[k].status = 'default'; - k++; - } - - while (i < leftArr.length) { - arr[k] = { ...leftArr[i], status: 'swapping' }; - setBars([...arr]); - await sleep(speed); - arr[k].status = 'default'; - i++; - k++; - } - - while (j < rightArr.length) { - arr[k] = { ...rightArr[j], status: 'swapping' }; - setBars([...arr]); - await sleep(speed); - arr[k].status = 'default'; - j++; - k++; - } - } - - async function mergeSortHelper(left: number, right: number) { - if (left < right) { - const mid = Math.floor((left + right) / 2); - await mergeSortHelper(left, mid); - await mergeSortHelper(mid + 1, right); - await merge(left, mid, right); - } - } - - await mergeSortHelper(0, arr.length - 1); - - arr.forEach((bar) => (bar.status = 'sorted')); - setBars([...arr]); - setIsPlaying(false); - }; - - // Heap Sort - const heapSort = async () => { - const arr = [...bars]; - - async function heapify(n: number, i: number) { - let largest = i; - const left = 2 * i + 1; - const right = 2 * i + 2; - - if (left < n) { - arr[left].status = 'comparing'; - setBars([...arr]); - await sleep(speed); - - if (arr[left].value > arr[largest].value) { - largest = left; - } - arr[left].status = 'default'; - } - - if (right < n) { - arr[right].status = 'comparing'; - setBars([...arr]); - await sleep(speed); - - if (arr[right].value > arr[largest].value) { - largest = right; - } - arr[right].status = 'default'; - } - - if (largest !== i) { - [arr[i], arr[largest]] = [arr[largest], arr[i]]; - arr[i].status = 'swapping'; - arr[largest].status = 'swapping'; - setBars([...arr]); - await sleep(speed); - arr[i].status = 'default'; - arr[largest].status = 'default'; - - await heapify(n, largest); - } - } - - // Build heap - for (let i = Math.floor(arr.length / 2) - 1; i >= 0; i--) { - await heapify(arr.length, i); - } - - // Extract elements from heap - for (let i = arr.length - 1; i > 0; i--) { - [arr[0], arr[i]] = [arr[i], arr[0]]; - arr[0].status = 'swapping'; - arr[i].status = 'sorted'; - setBars([...arr]); - await sleep(speed); - arr[0].status = 'default'; - - await heapify(i, 0); - } - - arr[0].status = 'sorted'; - setBars([...arr]); - setIsPlaying(false); - }; - - const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const handleStart = async () => { - setIsPlaying(true); - - if (algorithm === 'quick') { - await quickSort(); - } else if (algorithm === 'merge') { - await mergeSort(); - } else if (algorithm === 'heap') { - await heapSort(); - } - }; - - const handleAlgorithmChange = (newAlgorithm: 'quick' | 'merge' | 'heap') => { - setAlgorithm(newAlgorithm); - initializeArray(); - }; - - return ( -
- {/* Controls */} -
-
-
- - - -
- -
- - setSpeed(Number(e.target.value))} - className="w-32" - disabled={isPlaying} - /> - - -
-
- - {/* Legend */} -
-
-
- 기본 -
-
-
- 비교 중 -
-
-
- 교환 중 -
- {algorithm === 'quick' && ( -
-
- 피벗 -
- )} -
-
- 정렬 완료 -
-
-
- - {/* Canvas */} -
- - - - -
-
- ); -} diff --git a/blog/ui/visualization/TwoPointerVisualization.tsx b/blog/ui/visualization/TwoPointerVisualization.tsx deleted file mode 100644 index ac2b1b4a..00000000 --- a/blog/ui/visualization/TwoPointerVisualization.tsx +++ /dev/null @@ -1,359 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import { Canvas, useFrame } from '@react-three/fiber'; -import { OrbitControls, Text } from '@react-three/drei'; -import * as THREE from 'three'; - -interface Element { - value: number; - status: - | 'default' - | 'left-pointer' - | 'right-pointer' - | 'both-pointers' - | 'found' - | 'checked'; -} - -// 3D Box with pointer -function Box3DWithPointer({ - position, - value, - status, -}: { - position: [number, number, number]; - value: number; - status: string; -}) { - const meshRef = useRef(null); - - useFrame(() => { - if ( - meshRef.current && - (status === 'left-pointer' || - status === 'right-pointer' || - status === 'both-pointers') - ) { - const scale = 1 + Math.sin(Date.now() * 0.008) * 0.1; - meshRef.current.scale.setScalar(scale); - } else if (meshRef.current) { - meshRef.current.scale.setScalar(1); - } - }); - - const color = - status === 'found' - ? '#6bcf7f' - : status === 'both-pointers' - ? '#e74c3c' - : status === 'left-pointer' - ? '#3498db' - : status === 'right-pointer' - ? '#f39c12' - : status === 'checked' - ? '#95a5a6' - : '#4a90e2'; - - return ( - - - - - - - {value} - - {(status === 'left-pointer' || status === 'both-pointers') && ( - - - - - - - L - - - )} - {(status === 'right-pointer' || status === 'both-pointers') && ( - - - - - - - R - - - )} - - ); -} - -// Scene component -function TwoPointerScene({ elements }: { elements: Element[] }) { - const spacing = 1.2; - const startX = (-(elements.length - 1) * spacing) / 2; - - return ( - <> - - - - - {elements.map((element, index) => ( - - ))} - - - - ); -} - -export default function TwoPointerVisualization() { - const [elements, setElements] = useState([]); - const [target, setTarget] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [message, setMessage] = useState(''); - const [speed, setSpeed] = useState(800); - - // Initialize sorted array - const initializeArray = () => { - const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - const newElements: Element[] = arr.map((val) => ({ - value: val, - status: 'default', - })); - setElements(newElements); - setTarget(15); // Default target sum - setMessage(''); - setIsPlaying(false); - }; - - useEffect(() => { - initializeArray(); - }, []); - - const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const twoPointerSearch = async () => { - setIsPlaying(true); - const arr = [...elements]; - let left = 0; - let right = arr.length - 1; - let steps = 0; - - // Reset all to default - arr.forEach((el) => (el.status = 'default')); - setElements([...arr]); - await sleep(speed); - - while (left < right) { - steps++; - - // Mark checked elements - for (let i = 0; i < left; i++) { - if (arr[i].status !== 'found') arr[i].status = 'checked'; - } - for (let i = right + 1; i < arr.length; i++) { - if (arr[i].status !== 'found') arr[i].status = 'checked'; - } - - // Mark pointers - if (left === right) { - arr[left].status = 'both-pointers'; - } else { - arr[left].status = 'left-pointer'; - arr[right].status = 'right-pointer'; - } - - setElements([...arr]); - const sum = arr[left].value + arr[right].value; - setMessage( - `Step ${steps}: arr[${left}] + arr[${right}] = ${arr[left].value} + ${arr[right].value} = ${sum}` - ); - await sleep(speed); - - if (sum === target) { - arr[left].status = 'found'; - arr[right].status = 'found'; - setElements([...arr]); - setMessage( - `✅ 찾았습니다! [${arr[left].value}, ${arr[right].value}] = ${target} (${steps}번 만에 발견)` - ); - setIsPlaying(false); - return; - } - - if (sum < target) { - setMessage(`${sum} < ${target} → Left 포인터 이동 (오른쪽으로)`); - await sleep(speed * 0.7); - left++; - } else { - setMessage(`${sum} > ${target} → Right 포인터 이동 (왼쪽으로)`); - await sleep(speed * 0.7); - right--; - } - } - - setMessage(`❌ 찾지 못했습니다 (${steps}번 탐색)`); - setIsPlaying(false); - }; - - return ( -
- {/* Controls */} -
-
-
-
-
- - setTarget(Number(e.target.value))} - disabled={isPlaying} - className="px-3 py-2 bg-slate-700 text-white rounded-lg w-20 text-center font-bold disabled:opacity-50" - min="3" - max="19" - /> -
-
- - setSpeed(Number(e.target.value))} - className="w-32" - disabled={isPlaying} - /> -
-
- -
- - -
-
- - {/* Message */} - {message && ( -
-

{message}

-
- )} - - {/* Info */} -
-

- 💡 - 투 포인터 알고리즘: 정렬된 배열에서 두 - 포인터를 양 끝에서 시작하여, 합이 목표값보다 작으면 왼쪽 포인터를 - 오른쪽으로, 크면 오른쪽 포인터를 왼쪽으로 이동합니다. -

-
- - {/* Legend */} -
-
-
- 미확인 -
-
-
- Left 포인터 -
-
-
- Right 포인터 -
-
-
- 확인 완료 -
-
-
- 정답! -
-
-
-
- - {/* Canvas */} -
- - - - -
-
- ); -} diff --git a/blog/ui/visualization/index.ts b/blog/ui/visualization/index.ts deleted file mode 100644 index fb672f70..00000000 --- a/blog/ui/visualization/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { default as BinarySearchVisualization } from './BinarySearchVisualization'; -export { default as DPVisualization } from './DPVisualization'; -export { default as GraphTraversalVisualization } from './GraphTraversalVisualization'; -export { default as SlidingWindowVisualization } from './SlidingWindowVisualization'; -export { default as SortingVisualization } from './SortingVisualization'; -export { default as TwoPointerVisualization } from './TwoPointerVisualization'; diff --git a/docs/README.md b/docs/README.md index b11609fb..da113ae3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # 문서 인덱스 -Last updated: 2026-07-18 +Last updated: 2026-07-20 이 인덱스는 현재 코드베이스와 함께 유지해야 하는 문서만 추적한다. 계속 업데이트할 문서가 아니라면 삭제하거나, 오래 남겨야 하는 결정만 ADR로 옮긴다. @@ -26,7 +26,16 @@ Last updated: 2026-07-18 - `docs/adr/0025-use-node-runtime-for-og-image-route.md` - `docs/adr/0026-use-repo-local-title-review-skill.md` - `docs/adr/0027-use-resume-specific-editorial-grid.md` +- `docs/adr/0028-retire-private-webgl-visualizations-and-adopt-selective-interactions.md` +- `docs/adr/0029-use-link-like-controls-for-home-category-filtering.md` +- `docs/adr/0030-use-date-title-rows-for-home-archive.md` +- `docs/adr/0031-adopt-a-single-paper-theme-and-retire-command-palette.md` +- `docs/adr/0032-reduce-desktop-rail-to-home-wordmark-and-utilities.md` +- `docs/adr/0033-use-a-white-canvas-without-content-cards.md` +- `docs/adr/0034-adopt-the-graphite-ink-palette.md` +- `docs/adr/0035-prioritize-mobile-archive-reading-order.md` - `docs/blog-quality-guide.md` +- `docs/content-publication-candidates.md` - `docs/database/db-schema.md` - `docs/database/supabase-view-count.sql` - `docs/guides/testing-guide.md` diff --git a/docs/adr/0028-retire-private-webgl-visualizations-and-adopt-selective-interactions.md b/docs/adr/0028-retire-private-webgl-visualizations-and-adopt-selective-interactions.md new file mode 100644 index 00000000..5e984397 --- /dev/null +++ b/docs/adr/0028-retire-private-webgl-visualizations-and-adopt-selective-interactions.md @@ -0,0 +1,53 @@ +# 0028. 비공개 WebGL 시각화를 제거하고 선택적 상호작용만 도입한다 + +Date: 2026-07-18 +Status: Accepted + +## 배경 + +ADR 0021은 알고리즘 시각화가 필요한 글만 Three.js 관련 모듈을 지연 +로딩하도록 했다. 하지만 여섯 개의 WebGL 시각화는 비공개 글 한 편에서만 +사용되고, 공개할 계획도 없다. 공개 사이트의 읽기 경험과 무관한 직접 +의존성, 클라이언트 모듈, 테스트 경계를 계속 유지할 이유가 없다. + +앞으로는 테마 전환, drawer, modal처럼 공개 경로에 실제로 필요한 +상호작용을 우선한다. 이때 많은 컴포넌트를 한꺼번에 추가하면 사이트의 +의존성 예산과 모션 언어가 다시 흐려질 수 있다. + +## 결정 + +- `three`, `@react-three/fiber`, `@react-three/drei`, `@types/three`와 + 여섯 개의 알고리즘 시각화 구현을 제거한다. +- 비공개 알고리즘 글은 설명 콘텐츠로 남기되, 삭제한 시각화를 참조하지 + 않는다. +- 테마 selector처럼 현재 의존성으로 충분한 상호작용은 `framer-motion`과 + 기존 motion mode 정책으로 구현한다. +- `overlay-kit`은 실제 drawer 또는 modal을 이관하는 변경에서만 추가한다. + 단순한 시각 효과와 theme wash에는 사용하지 않는다. +- React Bits는 패키지 전체를 도입하지 않고, 필요한 컴포넌트를 코드 단위로 + 선택해 가져온다. 새 런타임 의존성은 해당 컴포넌트에 필수일 때만 추가한다. + +## 결과 + +- 공개 기능과 무관한 WebGL 의존성 및 코드 경계가 사라진다. +- 비공개 콘텐츠의 텍스트는 유지하면서 빌드와 유지보수 대상은 줄어든다. +- 상호작용은 이미 설치된 Motion을 우선 재사용하므로, 테마 전환 같은 + 작은 실험에 라이브러리 비용을 추가하지 않는다. +- overlay를 사용하는 첫 리팩터링에서는 공통 focus, dismiss, layer 정책을 + 검토할 명확한 시점이 생긴다. + +## 검토한 대안 + +- ADR 0021의 지연 로딩 경계를 유지한다: 일반 글의 초기 비용은 낮지만, + 사용하지 않는 WebGL 구현과 의존성을 계속 관리해야 한다. +- 모든 React Bits 컴포넌트와 의존성을 즉시 도입한다: 실험 속도는 빠르지만, + 현재 필요한 범위를 넘는 코드와 런타임 비용을 추가한다. +- overlay-kit을 즉시 추가한다: 미래 사용 가능성은 생기지만, 현재 테마 + selector에는 overlay가 필요 없어 미사용 의존성이 된다. + +## Related History + +- [ADR 0021](0021-load-heavy-mdx-visualizations-on-demand.md): 이 결정으로 + 대체되는 글 단위 WebGL 지연 로딩 전략 +- [ADR 0006](0006-isolate-visualization-heavy-components.md): 시각화 경계의 + 기존 원칙 diff --git a/docs/adr/0029-use-link-like-controls-for-home-category-filtering.md b/docs/adr/0029-use-link-like-controls-for-home-category-filtering.md new file mode 100644 index 00000000..de2f1734 --- /dev/null +++ b/docs/adr/0029-use-link-like-controls-for-home-category-filtering.md @@ -0,0 +1,47 @@ +# 0029. 홈 카테고리 필터를 링크형 상태 컨트롤로 표현한다 + +Date: 2026-07-19 +Status: Accepted + +## 배경 + +홈은 최신 글을 훑는 아카이브다. 기존 카테고리 필터는 테두리 컨테이너 안에 +채워진 pill 버튼과 카운트 배지로 배치되어, 글 탐색보다 독립된 설정 화면처럼 +보였다. 홈 피드는 선택해야 할 복잡한 옵션이 아니라 글의 범위를 빠르게 +좁히는 짧은 상태 전환만 제공한다. + +필터 상태는 현재 클라이언트에서 즉시 전환되며 URL이나 route를 변경하지 +않는다. 시각적으로 링크처럼 보여도 실제 anchor를 사용하면 브라우저 탐색 +의미와 동작이 맞지 않는다. + +## 결정 + +- 홈 카테고리 필터는 별도 테두리 컨테이너 없이 제목 아래에 직접 배치한다. +- 각 항목은 `button`의 semantic을 유지하되, 텍스트 링크처럼 보이게 한다. +- 활성 항목은 Toss blue와 하단선으로만 구분하고, 비활성 항목은 중립 텍스트와 + hover 하단선으로 처리한다. +- 카운트는 배지 대신 항목 이름 뒤 괄호 표기로 표시한다. +- `aria-pressed`와 keyboard focus ring을 유지해 현재 선택 상태와 조작 가능성을 + 명시한다. + +## 결과 + +- 아카이브의 시각적 계층이 제목과 글 목록에 집중된다. +- 필터는 상태 전환이라는 실제 동작에 맞는 semantic을 유지한다. +- 이후 URL 기반 카테고리 archive를 도입할 때에는 이 컨트롤을 anchor와 route + 상태로 별도 전환해야 한다. + +## 검토한 대안 + +- 채워진 pill 버튼과 독립 컨테이너를 유지한다: 선택 상태는 강하지만, 홈 + 아카이브에는 과한 표면과 시각적 무게를 만든다. +- 실제 anchor 링크로 바꾼다: 링크 모양과 의미는 일치하지만 현재의 in-place + filtering 및 URL 유지 동작과는 맞지 않는다. +- 카운트 배지를 유지한다: 수치 구분은 쉽지만, 작은 인터페이스 표면을 더해 + 링크형 톤을 흐린다. + +## Related History + +- [ADR 0018](0018-use-semantic-surface-tokens-for-content-discovery.md): 콘텐츠 + 탐색 화면의 surface 선택 원칙 +- [ADR 0024](0024-use-latest-only-home-feed.md): 홈의 최신순 단일 피드 결정 diff --git a/docs/adr/0030-use-date-title-rows-for-home-archive.md b/docs/adr/0030-use-date-title-rows-for-home-archive.md new file mode 100644 index 00000000..4479e5ca --- /dev/null +++ b/docs/adr/0030-use-date-title-rows-for-home-archive.md @@ -0,0 +1,42 @@ +# 0030. 홈 아카이브를 날짜와 제목만의 행 목록으로 표현한다 + +Date: 2026-07-19 +Status: Accepted + +## 배경 + +홈은 최신 글을 시간순으로 훑어보는 아카이브다. 기존 글 목록은 카테고리, +읽기 시간, 설명, 태그를 포함한 큰 카드로 구성되어 있어 한 화면에서 비교할 수 +있는 글 수가 적고, 카테고리 필터와 같은 계층의 시각적 표면이 반복됐다. + +글을 선택하기 전 필요한 최소 정보는 발행일과 제목이다. 설명, 태그, 카테고리와 +읽기 시간은 글 상세와 카테고리별 목록에서 여전히 확인할 수 있다. + +## 결정 + +- 홈의 `PostList`에는 `archive` layout을 제공한다. +- archive 행은 `YYYY.MM.DD` 발행일과 제목만 표시한다. +- archive 행은 card 배경, 테두리, 요약, 태그, 카테고리와 읽기 시간을 표시하지 + 않는다. +- hover와 keyboard focus에서는 제목 색과 focus ring으로 글 링크임을 알린다. +- Engineering, Life, `/blog`의 기존 `list`와 `grid` layout은 유지한다. + +## 결과 + +- 홈에서 더 많은 최신 글을 빠르게 비교할 수 있다. +- 필터와 글 목록이 같은 평면의 아카이브 언어로 정리된다. +- 각 글의 맥락 정보는 상세 및 목적별 목록에 남아, 홈의 정보 밀도를 낮춘다. + +## 검토한 대안 + +- 카드의 여백만 줄인다: 기존 card 계층과 반복되는 메타 정보는 남는다. +- 설명을 한 줄만 남긴다: 제목 스캔 속도는 개선되지만, 홈이 여전히 요약 카드 + 목록처럼 보인다. +- 모든 목록에 날짜와 제목 행을 적용한다: 목적별 페이지에서 필요한 카테고리와 + 맥락 정보까지 함께 잃는다. + +## Related History + +- [ADR 0024](0024-use-latest-only-home-feed.md): 홈 최신순 단일 피드 결정 +- [ADR 0029](0029-use-link-like-controls-for-home-category-filtering.md): 홈 필터의 + 링크형 상태 컨트롤 결정 diff --git a/docs/adr/0031-adopt-a-single-paper-theme-and-retire-command-palette.md b/docs/adr/0031-adopt-a-single-paper-theme-and-retire-command-palette.md new file mode 100644 index 00000000..c1c7fb15 --- /dev/null +++ b/docs/adr/0031-adopt-a-single-paper-theme-and-retire-command-palette.md @@ -0,0 +1,51 @@ +# 0031. 단일 Paper 테마를 채택하고 명령 팔레트를 제거한다 + +Date: 2026-07-19 +Status: Accepted + +## 배경 + +Ark의 공개 글 수와 탐색 깊이는 별도 검색 기능이 필요한 규모가 아니다. 고정 +검색 입력창과 명령 팔레트는 콘텐츠보다 앱 셸을 먼저 인식하게 만들었고, +라이트·다크 전환은 같은 읽기 경험에 두 개의 색 체계와 클라이언트 상태를 +추가했다. + +홈 아카이브를 날짜와 제목의 행 목록으로 단순화한 뒤에도, 검색과 테마 제어가 +남아 있으면 화면의 밀도와 제품적인 인상이 다시 커진다. 코드 예시는 긴 글에서 +구분되어야 하므로, 본문 표면과 코드 표면을 같은 밝기로 만들 필요는 없다. + +## 결정 + +- UI는 Paper 팔레트 하나만 사용한다. 기본 표면은 `#F4F3EF`, 본문 잉크는 + `#24241F`, 보조 텍스트는 `#62625A`, 구분선은 `#DAD9D2`, 강조색은 + `#2F6B5F`로 둔다. +- 라이트·다크 선택, 시스템 테마 동기화, 전환 애니메이션을 제거한다. +- 전역 검색 입력창, `/` 단축키, KBar 명령 팔레트와 `search/` 도메인을 + 제거한다. +- Giscus는 고정된 밝은 임베드 테마를 사용하고 Mermaid는 중립 렌더링 테마를 + 사용한다. +- 코드 블록은 어두운 잉크 표면을 유지한다. 이는 사이트 테마가 아니라 읽기 중 + 코드와 본문을 구분하는 콘텐츠 토큰이다. + +## 결과 + +- 탐색은 섹션 내비게이션과 홈 카테고리 링크로 집중된다. +- `kbar`, `next-themes` 및 해당 클라이언트 provider가 사라져 앱 셸의 런타임 + 상태와 의존성이 줄어든다. +- 테마 선택을 제공하지 않으므로, 향후 어두운 테마를 다시 도입하려면 색상 토큰, + 임베드, 접근성 대비, 브라우저 회귀 검증을 함께 설계하는 새 ADR이 필요하다. + +## 검토한 대안 + +- 시스템 설정에 따라 자동 전환한다: 사용자 선택 UI는 줄지만 두 색 체계와 + 검증 범위는 그대로 남는다. +- 테마 전환만 제거하고 다크 모드는 유지한다: 화면의 제품적 복잡도와 + 유지보수 범위가 충분히 줄지 않는다. +- 검색만 숨기고 KBar를 보존한다: 공개 탐색 경로로 사용하지 않는 기능과 + 의존성이 계속 남는다. + +## Related History + +- [ADR 0005](0005-use-token-first-tailwind-styling.md): token-first 스타일링 +- [ADR 0009](0009-use-app-shell-for-primary-navigation.md): AppShell 내비게이션 +- [ADR 0030](0030-use-date-title-rows-for-home-archive.md): 홈 아카이브 행 목록 diff --git a/docs/adr/0032-reduce-desktop-rail-to-home-wordmark-and-utilities.md b/docs/adr/0032-reduce-desktop-rail-to-home-wordmark-and-utilities.md new file mode 100644 index 00000000..fcd93764 --- /dev/null +++ b/docs/adr/0032-reduce-desktop-rail-to-home-wordmark-and-utilities.md @@ -0,0 +1,46 @@ +# 0032. 데스크톱 레일을 홈 워드마크와 보조 링크로 축소한다 + +Date: 2026-07-19 +Status: Accepted + +## 배경 + +홈은 전체 아카이브를 보여 주고 Tech와 Life 필터를 제공한다. 데스크톱 레일의 +Home, Tech, Life 항목은 이 탐색 경로를 반복해 콘텐츠보다 앱 내비게이션의 +비중을 키웠다. + +이력서와 외부 연락 채널은 글 분류와 다른 성격의 목적지다. 이들은 아카이브의 +주요 경로와 경쟁하지 않도록 레일 하단에 모으는 편이 자연스럽다. + +## 결정 + +- 데스크톱 레일 상단에는 홈으로 연결되는 `ark` 워드마크 링크 하나만 둔다. +- Resume, GitHub, Email, RSS는 레일 하단의 보조 링크로 둔다. +- Tech와 Life route 및 홈의 카테고리 필터는 유지하되, 데스크톱 레일에서는 + 직접 노출하지 않는다. +- 모바일 드로어는 좁은 화면에서의 명시적 탐색 수단으로 유지한다. + +## 결과 + +- 홈이 아카이브의 단일 진입점이라는 정보 구조가 선명해진다. +- 레일은 서비스 메뉴가 아니라 사이트 정체성과 보조 목적지를 담는 여백으로 + 동작한다. +- 직접 URL, 기존 Tech·Life route, 모바일 탐색은 보존되어 링크와 검색 엔진 + 경로에 영향을 주지 않는다. + +## 검토한 대안 + +- Home, Tech, Life를 모두 유지한다: 카테고리 필터와 같은 목적의 컨트롤이 + 반복된다. +- 모든 화면에서 Tech와 Life 진입점을 제거한다: 모바일과 목적별 목록에서 + 빠른 이동 경로까지 잃는다. +- Resume를 별도 상단 메뉴로 둔다: 아카이브 탐색과 개인 정보 목적지가 같은 + 위계를 갖게 된다. + +## Related History + +- [ADR 0009](0009-use-app-shell-for-primary-navigation.md): AppShell 기반 탐색 +- [ADR 0029](0029-use-link-like-controls-for-home-category-filtering.md): 홈 + 카테고리 필터 +- [ADR 0031](0031-adopt-a-single-paper-theme-and-retire-command-palette.md): + Paper 테마와 명령 팔레트 제거 diff --git a/docs/adr/0033-use-a-white-canvas-without-content-cards.md b/docs/adr/0033-use-a-white-canvas-without-content-cards.md new file mode 100644 index 00000000..c62d5f98 --- /dev/null +++ b/docs/adr/0033-use-a-white-canvas-without-content-cards.md @@ -0,0 +1,40 @@ +# 0033. 콘텐츠 카드 없이 흰 캔버스를 사용한다 + +Date: 2026-07-19 +Status: Accepted + +## 배경 + +Ark는 홈 아카이브와 글 읽기 화면을 카드 모음보다 하나의 연속된 편집 지면으로 +옮긴다. 카드의 흰 표면을 따로 유지하면 페이지 배경과 콘텐츠 표면이 같은 역할을 +나누어 가지며, 카드가 사라지는 레이아웃에서는 기존의 Paper 배경이 불필요한 층으로 +남는다. + +## 결정 + +- 기본 페이지 배경과 의미 기반 surface를 모두 `#FFFFFF`로 둔다. +- 카드 제거와 별개로, `#EAE8E1` 및 더 진한 중립색은 hover, 구분, 보조 영역처럼 + 의도적으로 경계가 필요한 경우에만 사용한다. +- 모바일 탐색 표면도 기본 배경 토큰을 참조해 페이지와 같은 캔버스를 유지한다. +- 남아 있는 개별 카드 UI의 구조 변경은 이 색상 토큰 결정에 포함하지 않는다. + +## 결과 + +- 페이지와 콘텐츠 영역이 하나의 흰 지면으로 이어져 읽기 밀도가 낮아진다. +- 카드가 없는 레이아웃에서도 의미 없는 표면 대비가 생기지 않는다. +- 강조색과 구분선은 콘텐츠 위계를 보여 주는 데만 사용하므로, 향후 카드 UI를 + 다시 도입할 때는 별도의 surface 역할을 다시 정의해야 한다. + +## 검토한 대안 + +- 기존 Paper 배경을 유지한다: 따뜻한 인상은 유지하지만 카드가 없는 콘텐츠와 + 페이지 바탕의 역할이 겹친다. +- 모든 보조 중립색을 제거한다: 지면은 더 평평해지지만 hover와 구분 상태의 + 정보 밀도도 함께 낮아진다. + +## Related History + +- [ADR 0018](0018-use-semantic-surface-tokens-for-content-discovery.md): + 콘텐츠 탐색 surface 토큰 +- [ADR 0031](0031-adopt-a-single-paper-theme-and-retire-command-palette.md): + 단일 Paper 테마 diff --git a/docs/adr/0034-adopt-the-graphite-ink-palette.md b/docs/adr/0034-adopt-the-graphite-ink-palette.md new file mode 100644 index 00000000..ed505d47 --- /dev/null +++ b/docs/adr/0034-adopt-the-graphite-ink-palette.md @@ -0,0 +1,43 @@ +# 0034. Graphite Ink 팔레트를 채택한다 + +Date: 2026-07-19 +Status: Accepted + +## 배경 + +카드가 없는 Ark의 읽기 화면에는 페이지 전체를 받쳐 주는 중립 바탕과, 글을 +방해하지 않으면서 링크·선택·행동을 표시하는 포인트가 필요하다. 기존 Paper +팔레트의 그린과 브라운 계열은 카테고리마다 색의 존재감이 커져, 하나의 편집 +지면이라는 방향과 맞지 않았다. + +## 결정 + +- 기본 배경과 surface는 `#EAEBEA`, 본문 잉크는 `#252525`, 포인트는 + `#3F3F46`로 둔다. +- 보조 텍스트, hover, 구분선, 카테고리 배지는 이 세 색에서 파생한 무채색만 + 사용한다. +- 포인트 배경 위 텍스트는 `#EAEBEA`를 사용해 같은 팔레트 안에서 대비를 + 유지한다. +- 피드백 상태도 별도 색상 대신 무채색 농도로 표현한다. + +## 결과 + +- 페이지와 콘텐츠가 하나의 옅은 회색 지면으로 연결되고, 본문이 가장 진하게 + 읽힌다. +- 카테고리, 링크, CTA가 색상 경쟁 대신 농도와 위치로 위계를 만든다. +- 경고·오류를 색만으로 빠르게 구분하는 UI가 필요해지면, 접근성 검증을 거친 + 별도 semantic feedback 팔레트를 새 ADR로 정의해야 한다. + +## 검토한 대안 + +- Clean Cobalt: 기술적 선명도는 높지만 블루가 링크와 행동을 강하게 끌어당긴다. +- Deep Teal: 개인적인 온도감은 남지만 무채색 편집 지면의 절제된 인상은 약해진다. +- Brick Red: 개성은 가장 뚜렷하지만 긴 읽기 화면에서 포인트의 시각적 무게가 + 커진다. + +## Related History + +- [ADR 0031](0031-adopt-a-single-paper-theme-and-retire-command-palette.md): + 단일 테마 +- [ADR 0033](0033-use-a-white-canvas-without-content-cards.md): + 카드 없는 지면의 배경 결정 diff --git a/docs/adr/0035-prioritize-mobile-archive-reading-order.md b/docs/adr/0035-prioritize-mobile-archive-reading-order.md new file mode 100644 index 00000000..cea9abbc --- /dev/null +++ b/docs/adr/0035-prioritize-mobile-archive-reading-order.md @@ -0,0 +1,61 @@ +# 0035. 모바일 아카이브의 읽기 순서를 우선한다 + +Date: 2026-07-20 +Status: Accepted + +## 배경 + +데스크톱과 태블릿에서는 얇은 레일, 링크형 필터, 날짜와 제목만 있는 아카이브 +행이 하나의 편집 지면으로 읽힌다. 반면 모바일 상단에는 메뉴 버튼만 보이고, +드로어는 아이콘, 설명, 카드형 링크를 앞세워 사이트의 읽기 의도보다 탐색 UI를 +먼저 인식하게 했다. + +모바일 아카이브 행은 날짜와 제목을 같은 줄에 배치했다. 좁은 화면에서 긴 제목은 +날짜와 가로 폭을 경쟁하므로, 제목을 읽는 흐름도 데스크톱보다 약해졌다. + +## 결정 + +- `md` 미만의 상단 헤더에는 홈으로 연결되는 `ark` 워드마크와 명시적인 메뉴 + 조작을 함께 둔다. +- 모바일 메뉴는 전체 화면의 텍스트 중심 탐색으로 표현한다. 기존 Home, Tech, + Life, Resume route와 외부 링크는 유지하고, 아이콘, 설명, 카드형 표면은 + 제거한다. +- 홈 아카이브 행은 `sm` 미만에서 발행일을 제목 위에 둔다. `sm` 이상에서는 + 기존처럼 날짜와 제목을 한 행에 배치한다. +- 홈 아카이브에는 계속 발행일과 제목만 표시한다. 카테고리, 요약, 태그, 읽기 + 시간, 카드 표면은 추가하지 않는다. +- 메뉴의 Escape 닫기, 배경 클릭 닫기, scroll lock, `inert`, analytics event는 + 유지한다. + +## 결과 + +- 모바일 첫 화면에서도 `ark`와 글 아카이브가 먼저 보이며, 메뉴를 열어야만 + 탐색 선택지가 전면에 나타난다. +- 긴 제목은 날짜와 가로 폭을 나누지 않고 한 줄 또는 여러 줄로 자연스럽게 + 읽힌다. +- 데스크톱과 태블릿의 레일, 링크형 필터, 수평 아카이브 행은 그대로 유지된다. + +## 검토한 대안 + +- 하단 탭 바를 둔다: 탐색은 빠르지만 모든 읽기 화면에 앱 형태의 고정 chrome을 + 추가한다. +- 기존 측면 드로어의 여백만 줄인다: 아이콘과 설명, 카드형 위계가 남아 + 콘텐츠보다 메뉴가 먼저 보이는 문제를 해결하지 못한다. +- 모바일에서도 날짜와 제목을 한 행에 유지한다: 행 수는 줄지만 긴 제목의 + 가독성과 제목 우선 위계를 잃는다. + +## 검증 + +- `PostCard`와 `PostList` 컴포넌트 테스트로 모바일 수직 행과 `sm` 이상 수평 + 행의 class 경계를 검증한다. +- 모바일 Playwright smoke test로 워드마크, 메뉴 열기와 닫기, route 이동을 + 검증한다. + +## Related History + +- [ADR 0029](0029-use-link-like-controls-for-home-category-filtering.md): 홈 + 필터의 링크형 상태 컨트롤 +- [ADR 0030](0030-use-date-title-rows-for-home-archive.md): 홈 아카이브의 최소 + 정보 구조 +- [ADR 0032](0032-reduce-desktop-rail-to-home-wordmark-and-utilities.md): + 데스크톱 레일과 모바일 드로어의 역할 구분 diff --git a/docs/adr/README.md b/docs/adr/README.md index e0e6d83b..5e4f25c8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -1,6 +1,6 @@ # Architecture Decision Records -Last updated: 2026-07-18 +Last updated: 2026-07-20 이 디렉터리는 커밋 히스토리에서 복원한 아키텍처 의사결정을 기록한다. ADR은 AI 협업 가이드와 별개의 문서다. 사람이 결정했든 AI가 초안을 @@ -16,35 +16,43 @@ ADR은 AI 협업 가이드와 별개의 문서다. 사람이 결정했든 AI가 ## 기록 -| ID | 상태 | 결정 | -| ----------------------------------------------------------------- | ---------- | ------------------------------------------------------------ | -| [0001](0001-use-nextjs-app-router.md) | Accepted | 블로그 런타임으로 Next.js App Router를 사용한다 | -| [0002](0002-keep-custom-mdx-webpack-pipeline.md) | Accepted | 커스텀 MDX webpack 파이프라인을 유지한다 | -| [0003](0003-store-posts-as-folder-mdx-and-meta.md) | Accepted | 글을 중첩 가능한 `index.mdx`와 `meta.json` 폴더로 저장한다 | -| [0004](0004-preserve-feature-first-source-structure.md) | Superseded | feature-first 소스 구조를 유지한다 | -| [0005](0005-use-token-first-tailwind-styling.md) | Accepted | token-first Tailwind 스타일링을 사용한다 | -| [0006](0006-isolate-visualization-heavy-components.md) | Accepted | 시각화 중심 컴포넌트를 격리한다 | -| [0007](0007-use-umami-and-supabase-view-counts.md) | Accepted | Umami 분석과 Supabase 조회수를 함께 사용한다 | -| [0008](0008-enforce-publication-policy-in-content-ingestion.md) | Accepted | 콘텐츠 수집 경로에서 공개 정책을 강제한다 | -| [0009](0009-use-app-shell-for-primary-navigation.md) | Accepted | 주요 탐색과 레이아웃에 AppShell을 사용한다 | -| [0010](0010-use-targeted-documentation-harness.md) | Accepted | 핵심 문서 검증에 targeted documentation harness를 사용한다 | -| [0011](0011-adopt-frontend-modular-monolith.md) | Accepted | Domain-first Modular Monolith를 사용한다 | -| [0012](0012-use-root-app-route-adapter.md) | Accepted | Next.js route adapter를 루트 `app/`에 둔다 | -| [0013](0013-clarify-support-boundaries-and-docs.md) | Accepted | 지원 모듈, 유지 문서, 공개 route 경계를 명확히 한다 | -| [0014](0014-use-ark-as-public-site-identity.md) | Accepted | 공개 사이트 정체성으로 아크를 사용한다 | -| [0015](0015-skip-view-count-query-during-build.md) | Superseded | 빌드 중 조회수 집계 쿼리를 건너뛴다 | -| [0016](0016-use-content-type-and-growth-review-scores.md) | Accepted | 글 형식과 성장 리뷰 점수를 분리한다 | -| [0017](0017-allow-private-post-preview-in-development.md) | Accepted | 개발 환경에서 private 글 상세 미리보기를 허용한다 | -| [0018](0018-use-semantic-surface-tokens-for-content-discovery.md) | Accepted | 콘텐츠 탐색 표면에 의미 기반 반경과 무그림자 상태를 사용한다 | -| [0019](0019-use-content-first-typography-scale.md) | Accepted | 콘텐츠 읽기 흐름에 16px 기준과 17px 본문을 사용한다 | -| [0020](0020-use-runtime-daily-views-for-popular-feed.md) | Superseded | 인기 피드를 런타임 일별 조회수로 계산한다 | -| [0021](0021-load-heavy-mdx-visualizations-on-demand.md) | Accepted | 무거운 MDX 시각화를 글 단위로 로드한다 | -| [0022](0022-default-new-posts-to-private.md) | Accepted | 새 글을 명시적으로 검토한 뒤 공개한다 | -| [0023](0023-run-the-release-quality-gate-in-ci.md) | Accepted | 배포 품질 gate를 CI에서 실행한다 | -| [0024](0024-use-latest-only-home-feed.md) | Accepted | 홈 피드를 최신순 단일 경로로 유지한다 | -| [0025](0025-use-node-runtime-for-og-image-route.md) | Accepted | OG 이미지 route에 Node.js runtime을 사용한다 | -| [0026](0026-use-repo-local-title-review-skill.md) | Accepted | 제목 선택은 repo-local skill로 보조한다 | -| [0027](0027-use-resume-specific-editorial-grid.md) | Accepted | 이력서에는 콘텐츠 밀도에 맞춘 에디토리얼 그리드를 사용한다 | +| ID | 상태 | 결정 | +| ------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------ | +| [0001](0001-use-nextjs-app-router.md) | Accepted | 블로그 런타임으로 Next.js App Router를 사용한다 | +| [0002](0002-keep-custom-mdx-webpack-pipeline.md) | Accepted | 커스텀 MDX webpack 파이프라인을 유지한다 | +| [0003](0003-store-posts-as-folder-mdx-and-meta.md) | Accepted | 글을 중첩 가능한 `index.mdx`와 `meta.json` 폴더로 저장한다 | +| [0004](0004-preserve-feature-first-source-structure.md) | Superseded | feature-first 소스 구조를 유지한다 | +| [0005](0005-use-token-first-tailwind-styling.md) | Accepted | token-first Tailwind 스타일링을 사용한다 | +| [0006](0006-isolate-visualization-heavy-components.md) | Accepted | 시각화 중심 컴포넌트를 격리한다 | +| [0007](0007-use-umami-and-supabase-view-counts.md) | Accepted | Umami 분석과 Supabase 조회수를 함께 사용한다 | +| [0008](0008-enforce-publication-policy-in-content-ingestion.md) | Accepted | 콘텐츠 수집 경로에서 공개 정책을 강제한다 | +| [0009](0009-use-app-shell-for-primary-navigation.md) | Accepted | 주요 탐색과 레이아웃에 AppShell을 사용한다 | +| [0010](0010-use-targeted-documentation-harness.md) | Accepted | 핵심 문서 검증에 targeted documentation harness를 사용한다 | +| [0011](0011-adopt-frontend-modular-monolith.md) | Accepted | Domain-first Modular Monolith를 사용한다 | +| [0012](0012-use-root-app-route-adapter.md) | Accepted | Next.js route adapter를 루트 `app/`에 둔다 | +| [0013](0013-clarify-support-boundaries-and-docs.md) | Accepted | 지원 모듈, 유지 문서, 공개 route 경계를 명확히 한다 | +| [0014](0014-use-ark-as-public-site-identity.md) | Accepted | 공개 사이트 정체성으로 아크를 사용한다 | +| [0015](0015-skip-view-count-query-during-build.md) | Superseded | 빌드 중 조회수 집계 쿼리를 건너뛴다 | +| [0016](0016-use-content-type-and-growth-review-scores.md) | Accepted | 글 형식과 성장 리뷰 점수를 분리한다 | +| [0017](0017-allow-private-post-preview-in-development.md) | Accepted | 개발 환경에서 private 글 상세 미리보기를 허용한다 | +| [0018](0018-use-semantic-surface-tokens-for-content-discovery.md) | Accepted | 콘텐츠 탐색 표면에 의미 기반 반경과 무그림자 상태를 사용한다 | +| [0019](0019-use-content-first-typography-scale.md) | Accepted | 콘텐츠 읽기 흐름에 16px 기준과 17px 본문을 사용한다 | +| [0020](0020-use-runtime-daily-views-for-popular-feed.md) | Superseded | 인기 피드를 런타임 일별 조회수로 계산한다 | +| [0021](0021-load-heavy-mdx-visualizations-on-demand.md) | Superseded | 무거운 MDX 시각화를 글 단위로 로드한다 | +| [0022](0022-default-new-posts-to-private.md) | Accepted | 새 글을 명시적으로 검토한 뒤 공개한다 | +| [0023](0023-run-the-release-quality-gate-in-ci.md) | Accepted | 배포 품질 gate를 CI에서 실행한다 | +| [0024](0024-use-latest-only-home-feed.md) | Accepted | 홈 피드를 최신순 단일 경로로 유지한다 | +| [0025](0025-use-node-runtime-for-og-image-route.md) | Accepted | OG 이미지 route에 Node.js runtime을 사용한다 | +| [0026](0026-use-repo-local-title-review-skill.md) | Accepted | 제목 선택은 repo-local skill로 보조한다 | +| [0027](0027-use-resume-specific-editorial-grid.md) | Accepted | 이력서에는 콘텐츠 밀도에 맞춘 에디토리얼 그리드를 사용한다 | +| [0028](0028-retire-private-webgl-visualizations-and-adopt-selective-interactions.md) | Accepted | 비공개 WebGL 시각화를 제거하고 선택적 상호작용만 도입한다 | +| [0029](0029-use-link-like-controls-for-home-category-filtering.md) | Accepted | 홈 카테고리 필터를 링크형 상태 컨트롤로 표현한다 | +| [0030](0030-use-date-title-rows-for-home-archive.md) | Accepted | 홈 아카이브를 날짜와 제목만의 행 목록으로 표현한다 | +| [0031](0031-adopt-a-single-paper-theme-and-retire-command-palette.md) | Accepted | 단일 Paper 테마를 채택하고 명령 팔레트를 제거한다 | +| [0032](0032-reduce-desktop-rail-to-home-wordmark-and-utilities.md) | Accepted | 데스크톱 레일을 홈 워드마크와 보조 링크로 축소한다 | +| [0033](0033-use-a-white-canvas-without-content-cards.md) | Accepted | 콘텐츠 카드 없이 흰 캔버스를 사용한다 | +| [0034](0034-adopt-the-graphite-ink-palette.md) | Accepted | Graphite Ink 팔레트를 채택한다 | +| [0035](0035-prioritize-mobile-archive-reading-order.md) | Accepted | 모바일 아카이브의 읽기 순서를 우선한다 | ## 작성 조건 diff --git a/docs/content-publication-candidates.md b/docs/content-publication-candidates.md new file mode 100644 index 00000000..b8dc64a2 --- /dev/null +++ b/docs/content-publication-candidates.md @@ -0,0 +1,45 @@ +# 공개 후보 큐 + +Last updated: 2026-07-20 + +이 문서는 private 글 가운데 공개 리라이팅 후보의 순서와 선행 조건을 +기록한다. 공개 정책과 점수 기준의 기준 문서는 +`docs/blog-quality-guide.md`다. + +공개 전환 전인 항목은 리라이팅과 품질 리뷰가 끝날 때까지 `private`를 유지한다. +`visibility` 변경은 이 큐의 순서가 아니라 해당 글의 근거, 안전성, 점수 +검토 또는 작성자 승인을 거쳐서만 한다. + +## 리라이팅 완료 · 공개 검토 + +| 우선순위 | 글 | 현재 상태 | 공개 승격 조건 | +| -------: | ------------------------------ | --------------------------- | ---------------------------------------------------------------------- | +| 1 | `posts/블로그-시스템-구축기` | 리라이팅 완료, private 유지 | 독립 content review와 공개용 release quality gate를 통과한다. | +| 2 | `posts/ai-시대-개발-환경-고정` | 리라이팅 완료, private 유지 | 실제 적용 사례와 검증 결과를 보강한 뒤 독립 content review를 통과한다. | + +## 보류 + +| 글 | 현재 상태 | 공개 승격 조건 | +| ----------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `posts/알고리즘-시각화` | private 유지 | 일반 알고리즘 요약을 유지하지 않는다. 실제 문제, 선택 실패, 구현 검증을 중심으로 범위를 좁힌 독립 글로 재구성하고 Tech 핵심 점수 평균을 3.0 초과로 만든다. | + +## 외부 검증 또는 승인 필요 + +| 글 | 현재 상태 | 공개 승격 조건 | +| ------------------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------- | +| `posts/토스증권-직무면접-회고` | private 유지 | NDA와 공개 범위를 작성자가 재확인한다. 회사·조직 맥락과 직접 질문 인용을 제거하거나 공개 가능 범위로 일반화한다. | +| `posts/거제-야호로부터-배운-리센느-역주행의-진짜-본질` | 공개 전환 완료 | 멤버 이력, 발매·역주행 타임라인, 외부 선정 이력의 1차 출처를 확인한다. 가사 인용은 삭제하거나 허용 범위로 줄인다. | +| `posts/함께-자라기-리뷰` | 공개 전환 완료 | 책 인용과 실험·조사 수치에 원문 페이지 또는 연구 출처를 추가한다. 작성자의 실제 적용 사례를 중심으로 다시 편집한다. | + +## llm-wiki 이관 완료 + +| 범위 | 현재 상태 | 공개 승격 조건 | +| ------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `posts/레디스-완전정복/**` 8편 | llm-wiki로 이관 후 Ark에서 제거 | 원문은 `src_ark_redis_learning_series_2026_07_20`으로 보존한다. 공개 글이 필요하면 현재 공식 문서와 실제 운영 evidence를 대조한 비시리즈 글로 새로 작성한다. | +| `posts/플링크-완전정복/**` 8편 | llm-wiki로 이관 후 Ark에서 제거 | 원문은 `src_ark_flink_learning_series_2026_07_20`으로 보존한다. 공개 글이 필요하면 현재 공식 문서와 실제 운영 evidence를 대조한 비시리즈 글로 새로 작성한다. | + +## 다음 작업 순서 + +1. 블로그 시스템 구축기의 공개 전 독립 content review를 진행한다. +2. AI 개발 환경 글에 실제 적용 사례와 검증 결과를 보강한 뒤 독립 content review를 진행한다. +3. 각 글은 별도 리뷰 후에만 `visibility: public` 승격을 검토한다. diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index e83eeff9..5e1a1271 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -24,9 +24,6 @@ - `blog/services/post-repository.test.ts` - 정렬/시리즈 조회/SKU 슬러그 수집 -- `search/model/get-search-actions.test.ts` - - 키워드 생성/nullable tags 처리 - - `blog/services/markdown-parser.test.ts` - TOC ID 생성 및 중복 처리 @@ -40,7 +37,7 @@ ### components - `site/navigation/MobileBottomNav.test.tsx` - - 아이템 수, active 상태, 토큰 기반 class, search action, analytics tracking + - 아이템 수, active 상태, 토큰 기반 class, analytics tracking - `blog/ui/components/*.test.tsx` - PostCard/PostList/CategoryFilter 상태별 렌더/이벤트 - `ui/**/*.test.tsx` diff --git a/infra/analytics/lib/analytics.ts b/infra/analytics/lib/analytics.ts index 83b3e315..7c0ee5de 100644 --- a/infra/analytics/lib/analytics.ts +++ b/infra/analytics/lib/analytics.ts @@ -1,9 +1,7 @@ export const AnalyticsEvents = { view: 'view', click: 'click', - search: 'search', error: 'error', - theme: 'theme', motion: 'motion_mode_changed', visualizationStarted: 'visualization_started', visualizationPaused: 'visualization_paused', diff --git a/next.config.mjs b/next.config.mjs index 598dc299..a4c5e43e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -4,10 +4,7 @@ import rehypeSlug from 'rehype-slug'; /** @type {import('rehype-pretty-code').Options} */ const prettyCodeOptions = { - theme: { - light: 'github-light', - dark: 'houston', - }, + theme: 'github-dark', keepBackground: true, defaultLang: 'plaintext', }; diff --git a/package-lock.json b/package-lock.json index d10d37f8..49e629a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,6 @@ "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@next/bundle-analyzer": "^16.1.4", - "@react-three/drei": "^10.7.7", - "@react-three/fiber": "^9.5.0", "@supabase/supabase-js": "^2.95.3", "@tailwindcss/typography": "^0.5.19", "cheerio": "^1.1.2", @@ -24,10 +22,8 @@ "date-fns": "^4.1.0", "framer-motion": "^11.15.0", "gray-matter": "^4.0.3", - "kbar": "^0.1.0-beta.48", "mermaid": "^11.12.2", "next": "^16.1.4", - "next-themes": "^0.4.6", "react": "^19.2.3", "react-dom": "^19.2.3", "rehype-pretty-code": "^0.14.1", @@ -36,7 +32,6 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^3.21.0", - "three": "^0.182.0", "unist-util-visit": "^5.1.0", "zod": "^4.3.6" }, @@ -48,7 +43,6 @@ "@types/node": "^20.17.10", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", - "@types/three": "^0.170.0", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.18", "autoprefixer": "^10.4.23", @@ -382,6 +376,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -2525,12 +2520,6 @@ "react": ">=16" } }, - "node_modules/@mediapipe/tasks-vision": { - "version": "0.10.17", - "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", - "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", - "license": "Apache-2.0" - }, "node_modules/@mermaid-js/parser": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", @@ -2540,18 +2529,6 @@ "langium": "3.3.1" } }, - "node_modules/@monogrid/gainmap-js": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", - "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", - "license": "MIT", - "dependencies": { - "promise-worker-transferable": "^1.0.4" - }, - "peerDependencies": { - "three": ">= 0.159.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2781,195 +2758,6 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.10.tgz", - "integrity": "sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@reach/observe-rect": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz", - "integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==", - "license": "MIT" - }, - "node_modules/@react-three/drei": { - "version": "10.7.7", - "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", - "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mediapipe/tasks-vision": "0.10.17", - "@monogrid/gainmap-js": "^3.0.6", - "@use-gesture/react": "^10.3.1", - "camera-controls": "^3.1.0", - "cross-env": "^7.0.3", - "detect-gpu": "^5.0.56", - "glsl-noise": "^0.0.0", - "hls.js": "^1.5.17", - "maath": "^0.10.8", - "meshline": "^3.3.1", - "stats-gl": "^2.2.8", - "stats.js": "^0.17.0", - "suspend-react": "^0.1.3", - "three-mesh-bvh": "^0.8.3", - "three-stdlib": "^2.35.6", - "troika-three-text": "^0.52.4", - "tunnel-rat": "^0.1.2", - "use-sync-external-store": "^1.4.0", - "utility-types": "^3.11.0", - "zustand": "^5.0.1" - }, - "peerDependencies": { - "@react-three/fiber": "^9.0.0", - "react": "^19", - "react-dom": "^19", - "three": ">=0.159" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/@react-three/fiber": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", - "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.8", - "@types/webxr": "*", - "base64-js": "^1.5.1", - "buffer": "^6.0.3", - "its-fine": "^2.0.0", - "react-use-measure": "^2.1.7", - "scheduler": "^0.27.0", - "suspend-react": "^0.1.3", - "use-sync-external-store": "^1.4.0", - "zustand": "^5.0.3" - }, - "peerDependencies": { - "expo": ">=43.0", - "expo-asset": ">=8.4", - "expo-file-system": ">=11.0", - "expo-gl": ">=11.0", - "react": ">=19 <19.3", - "react-dom": ">=19 <19.3", - "react-native": ">=0.78", - "three": ">=0.156" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - }, - "expo-asset": { - "optional": true - }, - "expo-file-system": { - "optional": true - }, - "expo-gl": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -3905,11 +3693,6 @@ } } }, - "node_modules/@tweenjs/tween.js": { - "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==" - }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4252,12 +4035,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/draco3d": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", - "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4326,12 +4103,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/offscreencanvas": { - "version": "2019.7.3", - "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", - "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", - "license": "MIT" - }, "node_modules/@types/phoenix": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", @@ -4356,38 +4127,11 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, + "dev": true, "peerDependencies": { "@types/react": "^18.0.0" } }, - "node_modules/@types/react-reconciler": { - "version": "0.28.9", - "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", - "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/stats.js": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", - "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==" - }, - "node_modules/@types/three": { - "version": "0.170.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.170.0.tgz", - "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==", - "dependencies": { - "@tweenjs/tween.js": "~23.1.3", - "@types/stats.js": "*", - "@types/webxr": "*", - "@webgpu/types": "*", - "fflate": "~0.8.2", - "meshoptimizer": "~0.18.1" - } - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4399,11 +4143,6 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" }, - "node_modules/@types/webxr": { - "version": "0.5.24", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", - "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==" - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4923,24 +4662,6 @@ "win32" ] }, - "node_modules/@use-gesture/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", - "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", - "license": "MIT" - }, - "node_modules/@use-gesture/react": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", - "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", - "license": "MIT", - "dependencies": { - "@use-gesture/core": "10.3.1" - }, - "peerDependencies": { - "react": ">= 16.8.0" - } - }, "node_modules/@vitejs/plugin-react": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", @@ -5104,11 +4825,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webgpu/types": { - "version": "0.1.69", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", - "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==" - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5516,26 +5232,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.9.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", @@ -5544,14 +5240,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -5613,30 +5301,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5693,19 +5357,6 @@ "node": ">=6" } }, - "node_modules/camera-controls": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", - "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", - "license": "MIT", - "engines": { - "node": ">=22.0.0", - "npm": ">=10.5.1" - }, - "peerDependencies": { - "three": ">=0.126.1" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001765", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", @@ -6039,7 +5690,8 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/colorette": { "version": "2.0.20", @@ -6117,28 +5769,11 @@ "layout-base": "^1.0.0" } }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -7109,15 +6744,6 @@ "node": ">=6" } }, - "node_modules/detect-gpu": { - "version": "5.0.70", - "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", - "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", - "license": "MIT", - "dependencies": { - "webgl-constants": "^1.1.1" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -7220,12 +6846,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/draco3d": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", - "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", - "license": "Apache-2.0" - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8261,11 +7881,6 @@ } } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -8457,15 +8072,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fuse.js": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", - "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==", - "license": "Apache-2.0", - "engines": { - "node": ">=10" - } - }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -8725,12 +8331,6 @@ "node": ">= 4" } }, - "node_modules/glsl-noise": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", - "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", - "license": "MIT" - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9119,12 +8719,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hls.js": { - "version": "1.6.15", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", - "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", - "license": "Apache-2.0" - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -9244,26 +8838,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9273,12 +8847,6 @@ "node": ">= 4" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9717,12 +9285,6 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "license": "MIT" - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9881,7 +9443,8 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -9939,18 +9502,6 @@ "node": ">= 0.4" } }, - "node_modules/its-fine": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", - "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", - "license": "MIT", - "dependencies": { - "@types/react-reconciler": "^0.28.9" - }, - "peerDependencies": { - "react": "^19.0.0" - } - }, "node_modules/jackspeak": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", @@ -10148,44 +9699,6 @@ "node": ">= 12" } }, - "node_modules/kbar": { - "version": "0.1.0-beta.48", - "resolved": "https://registry.npmjs.org/kbar/-/kbar-0.1.0-beta.48.tgz", - "integrity": "sha512-HD5A1dqfK6XGeoH4fRWTmRt4y76sDbtGxY4Dh2xNa5MYtvtKsqfz+nRZ0tKgcrjjGYN4rf5TLXMJuiE7Pb8rXg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-portal": "^1.0.1", - "fast-equals": "^2.0.3", - "fuse.js": "^6.6.2", - "react-virtual": "^2.8.2", - "tiny-invariant": "^1.2.0" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/kbar/node_modules/fast-equals": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz", - "integrity": "sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==", - "license": "MIT" - }, - "node_modules/kbar/node_modules/react-virtual": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/react-virtual/-/react-virtual-2.10.4.tgz", - "integrity": "sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==", - "funding": [ - "https://github.com/sponsors/tannerlinsley" - ], - "license": "MIT", - "dependencies": { - "@reach/observe-rect": "^1.1.0" - }, - "peerDependencies": { - "react": "^16.6.3 || ^17.0.0" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10277,15 +9790,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -10886,16 +10390,6 @@ "lz-string": "bin/bin.js" } }, - "node_modules/maath": { - "version": "0.10.8", - "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", - "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", - "license": "MIT", - "peerDependencies": { - "@types/three": ">=0.134.0", - "three": ">=0.134.0" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -11456,20 +10950,6 @@ "uuid": "^11.1.0" } }, - "node_modules/meshline": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", - "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/meshoptimizer": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", - "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==" - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -12427,16 +11907,6 @@ } } }, - "node_modules/next-themes": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", - "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -12850,6 +12320,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "engines": { "node": ">=8" } @@ -12972,7 +12443,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -13053,14 +12523,9 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, - "node_modules/potpack": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", - "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", - "license": "ISC" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13124,16 +12589,6 @@ "license": "MIT", "peer": true }, - "node_modules/promise-worker-transferable": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", - "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", - "license": "Apache-2.0", - "dependencies": { - "is-promise": "^2.1.0", - "lie": "^3.0.2" - } - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -13242,21 +12697,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-use-measure": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", - "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.13", - "react-dom": ">=16.13" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, "node_modules/recma-build-jsx": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", @@ -13578,14 +13018,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -13986,6 +13418,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -13997,6 +13430,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } @@ -14245,32 +13679,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stats-gl": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", - "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", - "license": "MIT", - "dependencies": { - "@types/three": "*", - "three": "^0.170.0" - }, - "peerDependencies": { - "@types/three": "*", - "three": "*" - } - }, - "node_modules/stats-gl/node_modules/three": { - "version": "0.170.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", - "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", - "license": "MIT" - }, - "node_modules/stats.js": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", - "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", - "license": "MIT" - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -14624,15 +14032,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/suspend-react": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", - "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", - "license": "MIT", - "peerDependencies": { - "react": ">=17.0" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -14665,50 +14064,6 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/three": { - "version": "0.182.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", - "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT" - }, - "node_modules/three-mesh-bvh": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", - "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", - "license": "MIT", - "peerDependencies": { - "three": ">= 0.159.0" - } - }, - "node_modules/three-stdlib": { - "version": "2.36.1", - "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", - "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", - "license": "MIT", - "dependencies": { - "@types/draco3d": "^1.4.0", - "@types/offscreencanvas": "^2019.6.4", - "@types/webxr": "^0.5.2", - "draco3d": "^1.4.1", - "fflate": "^0.6.9", - "potpack": "^1.0.1" - }, - "peerDependencies": { - "three": ">=0.128.0" - } - }, - "node_modules/three-stdlib/node_modules/fflate": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", - "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", - "license": "MIT" - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -14823,36 +14178,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/troika-three-text": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", - "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", - "license": "MIT", - "dependencies": { - "bidi-js": "^1.0.2", - "troika-three-utils": "^0.52.4", - "troika-worker-utils": "^0.52.0", - "webgl-sdf-generator": "1.1.1" - }, - "peerDependencies": { - "three": ">=0.125.0" - } - }, - "node_modules/troika-three-utils": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", - "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.125.0" - } - }, - "node_modules/troika-worker-utils": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", - "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", - "license": "MIT" - }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -14912,43 +14237,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "node_modules/tunnel-rat": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", - "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", - "license": "MIT", - "dependencies": { - "zustand": "^4.3.2" - } - }, - "node_modules/tunnel-rat/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -15285,30 +14573,12 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utility-types": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", - "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -15593,17 +14863,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/webgl-constants": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", - "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" - }, - "node_modules/webgl-sdf-generator": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", - "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", - "license": "MIT" - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -15684,6 +14943,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -15999,35 +15259,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zustand": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", - "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 429e58be..7953eb7a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "analyze": "ANALYZE=true next build --webpack", "test": "vitest", "test:unit": "vitest run", - "test:components": "vitest run blog resume search ui site infra styles", + "test:components": "vitest run blog resume ui site infra styles", "test:coverage": "vitest run --coverage", "test:ci": "npm run lint && npm run lint:css:syntax && npm run verify:docs && npm run content:audit && npm run test:unit && npm run build && npm run test:e2e", "test:e2e": "npx playwright test", @@ -45,8 +45,6 @@ "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@next/bundle-analyzer": "^16.1.4", - "@react-three/drei": "^10.7.7", - "@react-three/fiber": "^9.5.0", "@supabase/supabase-js": "^2.95.3", "@tailwindcss/typography": "^0.5.19", "cheerio": "^1.1.2", @@ -54,10 +52,8 @@ "date-fns": "^4.1.0", "framer-motion": "^11.15.0", "gray-matter": "^4.0.3", - "kbar": "^0.1.0-beta.48", "mermaid": "^11.12.2", "next": "^16.1.4", - "next-themes": "^0.4.6", "react": "^19.2.3", "react-dom": "^19.2.3", "rehype-pretty-code": "^0.14.1", @@ -66,7 +62,6 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^3.21.0", - "three": "^0.182.0", "unist-util-visit": "^5.1.0", "zod": "^4.3.6" }, @@ -78,7 +73,6 @@ "@types/node": "^20.17.10", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", - "@types/three": "^0.170.0", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.18", "autoprefixer": "^10.4.23", diff --git a/playwright.config.ts b/playwright.config.ts index 26d4a51c..a1bfeee6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,13 +38,5 @@ export default defineConfig({ colorScheme: 'light', }, }, - { - name: 'mobile-chrome-dark', - use: { - browserName: 'chromium', - ...devices['iPhone 14'], - colorScheme: 'dark', - }, - }, ], }); diff --git "a/posts/ai-\354\213\234\353\214\200-\352\260\234\353\260\234-\355\231\230\352\262\275-\352\263\240\354\240\225/index.mdx" "b/posts/ai-\354\213\234\353\214\200-\352\260\234\353\260\234-\355\231\230\352\262\275-\352\263\240\354\240\225/index.mdx" index d877ee74..67a12284 100644 --- "a/posts/ai-\354\213\234\353\214\200-\352\260\234\353\260\234-\355\231\230\352\262\275-\352\263\240\354\240\225/index.mdx" +++ "b/posts/ai-\354\213\234\353\214\200-\352\260\234\353\260\234-\355\231\230\352\262\275-\352\263\240\354\240\225/index.mdx" @@ -1,166 +1,109 @@ -## 들어가기 +## 문제는 도구가 아니라 전환 비용이었다 -최근 몇 년 사이, 개발자의 선택지는 폭발적으로 늘어났어요. +AI 도구를 도입한 뒤부터 개발 환경을 더 자주 바꾸게 됐다. 리서치에 쓰는 +도구, 설계를 검토하는 도구, 코드를 작성하는 도구가 각각 좋아 보였고, 새 기능이 +나올 때마다 기존 흐름보다 나은지 확인하고 싶었다. -이전에는 언어, 프레임워크, 인프라가 주된 고민이었다면, -지금은 어떤 AI 모델과 어떤 도구를 쓸지부터 결정해야 해요. +문제는 도구를 비교하는 시간이 따로 들었다는 데 있지 않다. 작업 중인 문제의 +맥락, 검증 방법, 결정의 근거가 도구를 옮길 때마다 흩어졌다. 다음 작업에서도 +같은 판단을 다시 설명해야 한다면, 좋은 기능 하나를 얻어도 전체 작업은 빨라지지 +않는다. -문제는 여기서 시작됐어요. +이 글은 AI 도구를 많이 쓰는 개발자가 무엇을 고정하고 무엇을 실험해야 하는지 +정리한다. 결론부터 말하면 특정 제품을 고정하는 것이 아니라, **판단의 입력과 +검증 산출물이 남는 흐름**을 고정해야 한다. -- 새로운 도구가 계속 등장해요 -- 매번 비교하고 갈아타요 -- 선택 자체가 또 하나의 업무가 돼요 +## 고정하는 것은 제품명이 아니다 -결국 생산성은 코드가 아니라 **선택 피로**에서 먼저 깎였어요. -그래서 이 글에서는 무엇을 고정했고 무엇은 일부러 열어뒀는지, 제 개발 환경을 유지하는 기준을 함께 정리해요. +환경을 고정한다는 말을 처음에는 익숙한 도구를 바꾸지 않는다는 뜻으로 이해했다. +하지만 제품은 기능, 가격, 조직 정책에 따라 언제든 바뀔 수 있다. 제품명을 기준으로 +환경을 고정하면 더 나은 선택지가 생겨도 기존 습관을 방어하게 된다. ---- +대신 아래 네 가지를 고정했다. -## 2. 문제를 어떻게 정의했는가 +| 고정 영역 | 남겨야 하는 것 | 이유 | +| --------- | ---------------------------- | ------------------------------------------------ | +| 문제 정의 | 범위, 제약, 성공 조건 | 도구가 바뀌어도 같은 문제를 풀고 있는지 확인한다 | +| 결정 | 선택한 대안, 버린 대안, 이유 | 나중에 같은 결정을 반복하지 않는다 | +| 검증 | 테스트, 빌드, 리뷰 기준 | 생성 결과가 아니라 동작을 기준으로 확인한다 | +| 기록 | 변경 이력과 다음에 쓸 기준 | 한 번의 작업을 다음 작업의 입력으로 만든다 | -처음에는 "더 좋은 도구를 찾으면 해결된다"고 여겼어요. -실제로 모델, IDE 플러그인, 코딩 에이전트를 꽤 많이 바꿔봤어요. +예를 들어 이 블로그 저장소에서는 결정은 ADR과 관련 문서에 남기고, 구현은 테스트와 +빌드로 확인한다. 도구가 초안을 만들거나 코드를 제안할 수는 있어도, 이 산출물을 +대체하지는 못한다. 이 경계가 있으면 새로운 도구를 써도 작업 방식이 흔들리지 +않는다. -하지만 반복 실험 끝에 결론은 반대였어요. +## 실험 영역은 작고 되돌릴 수 있게 둔다 -> 생산성은 더 많은 도구에서 나오지 않았어요. -> 사고 흐름이 끊기지 않는 고정된 환경에서 나왔어요. +모든 도구를 고정하면 학습도 멈춘다. 그래서 리서치 방식, 반복 작업 자동화, +코드 초안 생성처럼 결과를 독립적으로 검증할 수 있는 영역은 실험 공간으로 남긴다. -그래서 문제를 이렇게 다시 정의했어요. +다만 실험은 다음 조건을 지킨다. -> "최신 도구를 찾아다니는 문제"가 아니라, -> "지속 가능한 개발 시스템을 설계하는 문제" +- 원본 문서와 운영 명령은 기존 저장소에 남긴다. +- 검증 명령과 코드 리뷰를 건너뛰지 않는다. +- 실패했을 때 기존 흐름으로 바로 돌아갈 수 있어야 한다. +- 한 번의 인상 대신 같은 유형의 실제 작업에서 다시 확인한다. ---- +이 기준은 새 도구를 거절하기 위한 장벽이 아니다. 새 도구가 실제로 전환 비용을 +낮추는지, 아니면 다른 종류의 확인 비용을 추가하는지 구분하기 위한 안전장치다. -## 3. 제가 고정한 Core AI Stack +## 도입과 교체는 이 질문으로 판단한다 -| Tool | Description | Official | -|---|---|---| -| Atlas Browser | AI-integrated research browser | https://atlas.openai.com | -| ChatGPT | Reasoning / research / planning assistant | https://chat.openai.com | -| Codex | AI coding agent | https://openai.com/codex | +새 도구를 도입하거나 기존 도구를 교체할 때는 기능 목록보다 아래 질문을 먼저 +확인한다. -선정 기준은 3가지였어요. +1. 이 도구가 줄이는 반복 작업은 무엇인가? +2. 결과를 기존 테스트, 문서, 리뷰 흐름 안에서 확인할 수 있는가? +3. 다른 사람이 같은 변경을 이어받을 때 필요한 맥락이 남는가? +4. 문제가 생겼을 때 데이터와 작업 흐름을 되돌릴 수 있는가? +5. 도입 뒤에도 기존보다 결정과 검증이 더 단순한가? -1. 장기적으로 유지 가능한 ecosystem인가 -2. 현재 workflow에 자연스럽게 붙는가 -3. 코딩뿐 아니라 설계/리서치까지 확장되는가 +다섯 질문 중 하나라도 답할 수 없으면, 그 도구는 아직 주력 환경이 아니라 +실험 대상으로 둔다. 특히 AI가 만든 결과는 첫 번째 답변보다 마지막 두 질문에서 +차이가 크게 난다. 구현 속도가 빨라져도 근거와 검증이 사라지면 다음 변경의 비용이 +커지기 때문이다. -이 기준으로 역할을 분리해 고정했어요. +## 작업 흐름은 산출물로 연결한다 -- Atlas: 리서치 속도 -- ChatGPT: 설계 검토, 사고 정리 -- Codex: 구현, 반복 작업 가속 +현재의 작업 흐름은 도구 순서가 아니라 산출물 순서로 관리한다. -핵심은 "많이 쓰는 것"이 아니라 "겹치지 않게 쓰는 것"이었어요. +| 단계 | 확인할 질문 | 남기는 산출물 | +| --------- | -------------------------------------- | ---------------------------- | +| 문제 파악 | 무엇을 바꾸고 왜 지금 바꾸는가 | 범위와 제약이 적힌 작업 설명 | +| 선택 | 어떤 대안과 트레이드오프가 있는가 | 설계 메모 또는 ADR | +| 구현 | 결정이 코드와 콘텐츠에 반영됐는가 | 변경 파일과 대상 테스트 | +| 검증 | 의도한 동작과 회귀 방지가 확인됐는가 | lint, 테스트, build 결과 | +| 기록 | 다음 작업에서 재사용할 기준은 무엇인가 | 커밋, PR, 문서의 결정 기록 | ---- +모든 작업이 다섯 단계를 같은 깊이로 거칠 필요는 없다. 그러나 영향 범위가 넓거나 +되돌리기 어려운 변경이라면, 최소한 선택·검증·기록은 생략하지 않는다. 이렇게 하면 +리서치 도구나 코딩 에이전트를 바꿔도, 팀과 미래의 내가 확인해야 할 기준은 같다. -## 4. Development Stack은 왜 최소 변경으로 갔는가 +## 다음 작업에 적용하는 방법 -| Tool | Description | Official | -|---|---|---| -| IntelliJ IDEA | Backend development IDE | https://www.jetbrains.com/idea/ | -| DataGrip | Database IDE | https://www.jetbrains.com/datagrip/ | -| Warp | AI terminal | https://www.warp.dev | -| Postman | API testing | https://www.postman.com | +새 도구가 필요해 보일 때는 설치 목록부터 늘리지 않는다. 먼저 줄이고 싶은 반복 +작업 하나와, 그 결과를 확인할 테스트 또는 리뷰 방법 하나를 적는다. 실제 작업에 +적용한 뒤에는 결정 기록과 검증 결과가 남았는지 확인한다. -개발 속도는 "도구 개수"보다 "전환 비용"에 더 크게 좌우됐어요. +둘 중 하나라도 남지 않았다면 도구의 기능과 별개로 주력 흐름에 넣지 않는다. 반대로 +같은 기준을 지키면서 반복 작업을 줄였다면, 그때 고정 영역에 편입한다. 이 순서를 +지키면 도구 평가는 취향 비교가 아니라 다음 작업을 더 안전하게 만드는 판단이 된다. -코드 작성 → DB 검증 → API 테스트 → 운영 확인 +## 고정에는 한계가 있다 -이 루프를 매일 반복하는 입장에서, -도구를 늘리는 것보다 **마찰을 줄이는 쪽**이 훨씬 효과적이었어요. +이 방식이 생산성을 수치로 증명한다고 말할 수는 없다. 작업의 종류, 팀의 협업 방식, +보안 요구사항에 따라 전환 비용은 달라진다. 개인에게 잘 맞는 흐름이 조직의 표준이 +될 수 있다는 뜻도 아니다. ---- - -## 5. Knowledge Stack을 분리한 이유 - -| Tool | Description | Official | -|---|---|---| -| Notion | Team knowledge base | https://www.notion.so | -| Obsidian | Personal knowledge graph | https://obsidian.md | - -AI 시대에는 기억력보다 검색력이 중요해졌어요. -정확히는 **필요한 컨텍스트를 빠르게 꺼내 LLM에 넣는 능력**이 중요해졌어요. - -그래서 아래 항목을 별도 저장소처럼 관리하고 있어요. - -- 설계 판단 근거 -- 장애 대응 기록 -- 프롬프트 템플릿 -- 운영 명령어 스니펫 - ---- - -## 6. 현재 운영하는 Workflow - - - -이 구조의 목적은 단순해요. - -- 리서치와 구현 사이의 문맥 단절을 줄여요 -- 구현과 검증 사이 왕복 비용을 줄여요 -- 반복 가능한 루프를 만들어요 - ---- - -## 7. 제 환경에서 실제로 쓰는 Homebrew 도구 - -실제로 매일 쓰는 Formula만 추려서 정리했어요. - -| Formula | Description | Install | Version | -|---|---|---|---| -| `mole` | SSH 기반에서 원격 서버의 동일 세션을 안전하게 공유하는 터미널 협업 도구 | [GitHub](https://github.com/tw93/Mole) / [Homebrew](https://formulae.brew.sh/formula/mole) | 1.25.0 | -| `nvm` | 프로젝트별 Node.js 버전을 격리해 런타임 충돌을 줄이는 버전 매니저 | [Homebrew](https://formulae.brew.sh/formula/nvm) | 0.40.3 | -| `k6` | API/시나리오 기반 부하 테스트를 코드로 자동화하는 성능 검증 도구 | [Homebrew](https://formulae.brew.sh/formula/k6) | 1.4.2 | -| `duckdb` | 로컬에서 대용량 데이터를 SQL로 빠르게 탐색/분석하는 OLAP 엔진 | [Homebrew](https://formulae.brew.sh/formula/duckdb) | 1.4.2 | -| `ffmpeg` | 인코딩, 리사이징, 포맷 변환 등 미디어 파이프라인 처리 도구 | [Homebrew](https://formulae.brew.sh/formula/ffmpeg) | 8.0.1 | - -추가로 설치 속도 관점에서는 [ZeroBrew](https://github.com/lucasgelfond/zerobrew)도 꽤 괜찮은 대안이에요. -Homebrew를 대체하기보다는, 초기 셋업이나 반복 설치가 많은 환경에서 병행 검토할 만하다고 봐요. - -정리하면서 다시 느꼈죠. -툴을 많이 아는 것보다, **제 흐름에 맞는 도구를 고정해 오래 쓰는 것**이 더 중요했어요. - ---- - -## 8. 개발 환경을 고정하기로 결정한 이유 - -초기에는 새 도구를 빠르게 실험하는 것도 필요해요. -하지만 어느 시점 이후에는 선택 자체가 생산성을 갉아먹어요. - -그래서 아래 전략으로 바꿨어요. - -- Core AI stack 고정 -- 개발 도구 최소 변경 -- workflow 자산화 -- 자동화 가능한 작업은 시스템화 - -단기 최적화보다 **장기 생산성 곡선**을 선택한 셈이에요. - ---- - -## 9. 앞으로의 방향 - -앞으로 개발자의 경쟁력은 단순 숙련도만으로 설명되지 않는다고 봐요. - -- 문제 해결 속도 -- 학습 속도 -- AI workflow 설계 능력 -- 지식 자산화 능력 - -저는 이 네 가지를 높이는 방향으로, -지금의 고정 스택 위에서 자동화 범위를 계속 확장할 계획이에요. - ---- +그래서 환경을 고정한 뒤에도 실패가 반복되거나 검증 비용이 늘어나면 다시 열어 본다. +고정은 변화하지 않겠다는 선언이 아니라, 변화를 받아들일 때도 같은 기준으로 +판단하겠다는 약속이다. ## 마무리 -도구 선택은 취향의 문제가 아니에요. -**사고 흐름을 얼마나 덜 끊는가**의 문제예요. - -실험 끝에 내린 결론은 명확해요. +AI 시대에 안정적인 개발 환경은 도구 목록이 아니다. 새 제품이 나와도 흔들리지 않는 +문제 정의, 되돌릴 수 있는 실험, 검증 가능한 결과, 그리고 다음에 재사용할 기록의 +조합이다. -> 더 많은 도구보다, 더 안정적인 시스템이 생산성을 만들어요. +그래서 나는 도구보다 판단 흐름을 고정하기로 했다. diff --git "a/posts/ai-\354\213\234\353\214\200-\352\260\234\353\260\234-\355\231\230\352\262\275-\352\263\240\354\240\225/meta.json" "b/posts/ai-\354\213\234\353\214\200-\352\260\234\353\260\234-\355\231\230\352\262\275-\352\263\240\354\240\225/meta.json" index 26382ac9..7ae012d6 100644 --- "a/posts/ai-\354\213\234\353\214\200-\352\260\234\353\260\234-\355\231\230\352\262\275-\352\263\240\354\240\225/meta.json" +++ "b/posts/ai-\354\213\234\353\214\200-\352\260\234\353\260\234-\355\231\230\352\262\275-\352\263\240\354\240\225/meta.json" @@ -1,32 +1,31 @@ { "title": "AI 시대, 나는 개발 환경을 고정하기로 했다", "slug": "fixed-ai-dev-environment", - "description": "도구를 자주 바꾸는 비용을 줄이기 위해 개인 개발 환경을 어떻게 고정했는지 정리해요.", + "description": "AI 도구를 고정하는 대신, 도입·실험·교체를 판단하는 개발 환경의 기준을 정리한다.", "date": "2026-02-12", + "updated": "2026-07-20", "category": "Tech", "contentType": "essay", "tags": [ "AI Workflow", - "Productivity", "Developer Experience", - "Codex", - "ChatGPT", - "Atlas" + "Engineering Practice", + "Decision Making" ], "featured": false, "visibility": "private", "qualityReview": { - "philosophy": 3.5, - "design": 3, - "implementation": 2.5, - "brandFit": 2.5, - "clarity": 3.5, - "structure": 3.5, + "philosophy": 4, + "design": 4, + "implementation": 3.5, + "brandFit": 3.5, + "clarity": 4, + "structure": 4, "evidence": 3, - "usefulness": 3.5, + "usefulness": 4, "originality": 3.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "비공개" + "polish": 4, + "reviewedAt": "2026-07-20", + "notes": "도구 카탈로그를 제거하고 문제 정의, 실험 경계, 교체 조건, 검증 산출물 중심의 에세이로 재작성함" } } diff --git "a/posts/\352\261\260\354\240\234-\354\225\274\355\230\270\353\241\234\353\266\200\355\204\260-\353\260\260\354\232\264-\353\246\254\354\204\274\353\212\220-\354\227\255\354\243\274\355\226\211\354\235\230-\354\247\204\354\247\234-\353\263\270\354\247\210/meta.json" "b/posts/\352\261\260\354\240\234-\354\225\274\355\230\270\353\241\234\353\266\200\355\204\260-\353\260\260\354\232\264-\353\246\254\354\204\274\353\212\220-\354\227\255\354\243\274\355\226\211\354\235\230-\354\247\204\354\247\234-\353\263\270\354\247\210/meta.json" index cb902bf4..2047884d 100644 --- "a/posts/\352\261\260\354\240\234-\354\225\274\355\230\270\353\241\234\353\266\200\355\204\260-\353\260\260\354\232\264-\353\246\254\354\204\274\353\212\220-\354\227\255\354\243\274\355\226\211\354\235\230-\354\247\204\354\247\234-\353\263\270\354\247\210/meta.json" +++ "b/posts/\352\261\260\354\240\234-\354\225\274\355\230\270\353\241\234\353\266\200\355\204\260-\353\260\260\354\232\264-\353\246\254\354\204\274\353\212\220-\354\227\255\354\243\274\355\226\211\354\235\230-\354\247\204\354\247\234-\353\263\270\354\247\210/meta.json" @@ -5,14 +5,10 @@ "date": "2026-06-12", "category": "Life", "contentType": "essay", - "tags": [ - "Career", - "Life", - "Thoughts" - ], + "tags": ["Career", "Life", "Thoughts"], "image": "/images/posts/거제-야호로부터-배운-리센느-역주행의-진짜-본질/yaho.jpg", "featured": false, - "visibility": "private", + "visibility": "public", "qualityReview": { "philosophy": 4.5, "design": 4, @@ -24,7 +20,7 @@ "usefulness": 3, "originality": 4, "polish": 3.5, - "reviewedAt": "2026-07-13", - "notes": "외부 사실 출처와 가사 사용 범위를 보강하기 전까지 비공개" + "reviewedAt": "2026-07-20", + "notes": "작성자 승인으로 공개 전환. 외부 사실 출처와 가사 인용 범위는 후속 점검" } } diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/01-\353\266\204\354\202\260-\355\231\230\352\262\275\354\235\230-\352\263\265\354\234\240-\353\251\224\353\252\250\353\246\254/index.mdx" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/01-\353\266\204\354\202\260-\355\231\230\352\262\275\354\235\230-\352\263\265\354\234\240-\353\251\224\353\252\250\353\246\254/index.mdx" deleted file mode 100644 index 89a6deaf..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/01-\353\266\204\354\202\260-\355\231\230\352\262\275\354\235\230-\352\263\265\354\234\240-\353\251\224\353\252\250\353\246\254/index.mdx" +++ /dev/null @@ -1,76 +0,0 @@ -## 1. 개요 - -캐시는 빠릅니다. 하지만 분산 환경에서 로컬 캐시만으로 확장하면 정합성 비용이 커집니다. -이 글은 로컬 캐시가 만드는 구조적 한계를 정리하고, Redis를 공유 메모리 계층으로 도입해야 하는 기술적 근거를 설명합니다. - ---- - -## 2. 문제 정의: 분산 환경에서 로컬 캐시가 만드는 기술 부채 - -### 2.1 서버별 메모리 분리로 인한 데이터 불일치 - -각 서버는 독립된 힙 메모리를 사용합니다. A 서버에서 갱신한 재고가 B 서버에 반영되지 않으면, 사용자는 결제 시점에 품절 오류를 만납니다. -이 문제는 단순 성능 이슈가 아니라 비즈니스 신뢰도 문제입니다. - -### 2.2 중복 적재에 따른 메모리 비효율 - -전역 공지, 공통 코드, 동일 조회 데이터가 서버 수만큼 중복 적재됩니다. -인스턴스가 늘어날수록 인프라 차원의 메모리 효율은 급격히 나빠집니다. - -### 2.3 Cache Cold Start가 만드는 연쇄 장애 - -배포나 재시작 직후 캐시는 비어 있습니다. 이때 모든 요청이 DB로 몰리면 커넥션 풀이 빠르게 고갈되고, 서비스 전체 지연이 커집니다. - ---- - -## 3. 해결책: Redis를 공유 메모리 계층으로 두는 이유 - -### 3.1 중앙 집중형 접근으로 정합성 확보 - -모든 애플리케이션 인스턴스가 동일한 Redis 엔드포인트를 조회하면, 데이터 기준점을 한 곳으로 모을 수 있습니다. -이 방식은 분산 환경에서 일관된 읽기/쓰기 흐름을 설계하는 데 유리합니다. - -### 3.2 Persistence로 캐시의 휘발성 리스크 완화 - -Redis는 메모리 기반이지만 디스크 영속화 전략을 제공합니다. - -- `RDB`: 시점 스냅샷 저장, 복구 속도에 유리 -- `AOF`: 쓰기 명령 로그 기반, 데이터 손실 최소화에 유리 - -### 3.3 싱글 스레드 이벤트 루프와 원자적 연산 - -Redis 명령은 단일 스레드 이벤트 루프에서 순차 처리됩니다. -동일 키에 대한 경쟁 연산(`INCR`, `DECR`, `SETNX`)에서도 락 복잡도를 낮추면서 일관된 결과를 얻을 수 있습니다. - -```mermaid -graph LR - U["User Request"] --> A["App Server A"] - U --> B["App Server B"] - A --> R["Redis (Shared Memory)"] - B --> R - R --> D["Database"] -``` - ---- - -## 4. 의사결정 가이드 - -| 비교 항목 | Local Cache (In-process) | Remote Cache (Redis) | -| --- | --- | --- | -| I/O 오버헤드 | 매우 낮음 | 네트워크 왕복 비용 존재 | -| 데이터 정합성 | 서버별 편차 발생 가능 | 전역 기준점 유지 가능 | -| 수평 확장성 | 불리함 | 유리함 | -| 운영 복잡도 | 낮음 | Redis 운영 포인트 필요 | -| 적합한 사례 | 변경 적은 참조 데이터 | 세션, 재고, 랭킹, 공유 상태 | - ---- - -## 5. 도입 체크리스트 - -1. 서버가 2대 이상으로 확장되고 있는가? -2. 사용자 관점에서 데이터 불일치가 비즈니스 손실로 이어지는가? -3. 배포/재시작 시 DB 스파이크가 반복되는가? -4. 원자적 카운팅 또는 분산 락이 필요한가? - -위 질문 중 2개 이상이 Yes라면 Redis를 우선 검토할 시점입니다. - diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/01-\353\266\204\354\202\260-\355\231\230\352\262\275\354\235\230-\352\263\265\354\234\240-\353\251\224\353\252\250\353\246\254/meta.json" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/01-\353\266\204\354\202\260-\355\231\230\352\262\275\354\235\230-\352\263\265\354\234\240-\353\251\224\353\252\250\353\246\254/meta.json" deleted file mode 100644 index a56aaeb8..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/01-\353\266\204\354\202\260-\355\231\230\352\262\275\354\235\230-\352\263\265\354\234\240-\353\251\224\353\252\250\353\246\254/meta.json" +++ /dev/null @@ -1,35 +0,0 @@ -{ - "title": "Redis Deep Dive #01 - 분산 환경의 공유 메모리와 도입 근거", - "slug": "redis-deep-dive-01-shared-memory", - "description": "로컬 캐시의 한계를 분석하고 Redis를 분산 환경의 단일 진실 원천으로 도입하는 근거를 정리합니다.", - "date": "2026-02-13", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Redis", - "Caching", - "Distributed Systems", - "Data Consistency" - ], - "featured": false, - "series": { - "id": "redis-deep-dive", - "title": "Redis 완전정복", - "order": 1 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 4, - "structure": 4, - "evidence": 3.5, - "usefulness": 4, - "originality": 3, - "polish": 3.5, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/02-\352\270\260\353\263\270-5\353\214\200-\354\236\220\353\243\214\355\230\225/index.mdx" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/02-\352\270\260\353\263\270-5\353\214\200-\354\236\220\353\243\214\355\230\225/index.mdx" deleted file mode 100644 index b25d3f2e..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/02-\352\270\260\353\263\270-5\353\214\200-\354\236\220\353\243\214\355\230\225/index.mdx" +++ /dev/null @@ -1,79 +0,0 @@ -## 1. 개요 - -Redis를 Key-Value 저장소로만 보면 절반만 사용한 것입니다. -핵심은 데이터 요구사항에 맞는 자료형을 선택해 메모리 사용량과 연산 복잡도를 함께 최적화하는 데 있습니다. - ---- - -## 2. 문제 정의: 모든 데이터를 String으로 저장할 때의 비용 - -### 2.1 필드 하나 수정에도 전체 객체 재기록 - -`JSON.stringify()`로 저장한 객체는 작은 수정에도 전체 Read-Modify-Write가 필요합니다. -결과적으로 네트워크 트래픽과 CPU 파싱 비용이 함께 증가합니다. - -### 2.2 자료구조 최적화 기회 상실 - -데이터 특성에 맞는 타입을 쓰지 않으면 Redis 내부 인코딩 최적화를 활용하기 어렵습니다. -같은 정보라도 메모리 사용량과 처리량 차이가 크게 납니다. - ---- - -## 3. 해결책: 5대 자료형과 실무 적용 포인트 - -### 3.1 String - -단일 값 캐싱, 카운터, 토큰 저장에 적합합니다. -`INCR` 같은 원자적 연산이 필요할 때 가장 단순하고 강력합니다. - -### 3.2 Hash - -객체 필드 단위 수정이 가능한 타입입니다. -프로필/설정처럼 필드가 많은 데이터에서 메모리 효율과 수정 효율이 좋습니다. - -### 3.3 List - -양끝 삽입/삭제가 빠릅니다. -작업 큐, 타임라인, 최근 이벤트 버퍼에 적합합니다. - -### 3.4 Set - -중복 없는 멤버십 관리에 최적입니다. -권한 집합, 방문자 집합, 교집합/차집합 연산 시 유용합니다. - -### 3.5 Sorted Set - -점수(score) 기반 정렬이 필요한 랭킹 시나리오에 필수입니다. -정렬 상태를 유지한 채 범위 조회가 가능합니다. - -```mermaid -flowchart LR - R{"요구사항"} - R -->|"단일 값/카운터"| ST["String"] - R -->|"객체 필드 갱신"| H["Hash"] - R -->|"순서 있는 큐"| L["List"] - R -->|"중복 없는 집합"| S["Set"] - R -->|"점수 기반 랭킹"| Z["Sorted Set"] -``` - ---- - -## 4. 의사결정 표 - -| 자료형 | 대표 사용 사례 | 핵심 명령어 | 평균 복잡도 | 주의사항 | -| --- | --- | --- | --- | --- | -| String | 세션, 토큰, 카운터 | `SET`, `GET`, `INCR` | O(1) | 큰 payload는 네트워크 비용 증가 | -| Hash | 사용자 프로필, 설정 | `HSET`, `HGET`, `HINCRBY` | O(1) | `HGETALL` 남용 시 비용 증가 | -| List | 큐, 타임라인 | `LPUSH`, `RPOP`, `LRANGE` | O(1)~O(N) | 중간 인덱스 접근은 비효율 | -| Set | 멤버십, 중복 제거 | `SADD`, `SISMEMBER`, `SINTER` | O(1)~O(N) | 대형 집합 연산은 부하 점검 필요 | -| Sorted Set | 랭킹, 우선순위 | `ZADD`, `ZRANGE`, `ZREVRANGE` | O(log N) | 대량 삭제/갱신 시 배치 전략 필요 | - ---- - -## 5. 실무 선택 규칙 - -1. 부분 업데이트가 필요하면 String보다 Hash를 우선 고려합니다. -2. 랭킹 데이터는 애플리케이션 정렬 대신 Sorted Set으로 위임합니다. -3. 중복 여부가 핵심이면 Set을 사용해 애플리케이션 로직을 단순화합니다. -4. 자료형 선택 전 예상 연산(`쓰기/조회/정렬/집합`)의 복잡도를 먼저 계산합니다. - diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/02-\352\270\260\353\263\270-5\353\214\200-\354\236\220\353\243\214\355\230\225/meta.json" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/02-\352\270\260\353\263\270-5\353\214\200-\354\236\220\353\243\214\355\230\225/meta.json" deleted file mode 100644 index 9f40b845..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/02-\352\270\260\353\263\270-5\353\214\200-\354\236\220\353\243\214\355\230\225/meta.json" +++ /dev/null @@ -1,35 +0,0 @@ -{ - "title": "Redis Deep Dive #02 - 기본 5대 자료형과 선택 전략", - "slug": "redis-deep-dive-02-core-data-types", - "description": "Redis 5대 자료형의 내부 특성과 시간 복잡도를 기준으로 실무 선택 전략을 정리합니다.", - "date": "2026-02-13", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Redis", - "Data Structures", - "Performance", - "Data Engineering" - ], - "featured": false, - "series": { - "id": "redis-deep-dive", - "title": "Redis 완전정복", - "order": 2 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3.5, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/03-\355\212\271\355\231\224-\354\236\220\353\243\214\355\230\225-\353\251\224\353\252\250\353\246\254-\354\265\234\354\240\201\355\231\224/index.mdx" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/03-\355\212\271\355\231\224-\354\236\220\353\243\214\355\230\225-\353\251\224\353\252\250\353\246\254-\354\265\234\354\240\201\355\231\224/index.mdx" deleted file mode 100644 index f469abdb..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/03-\355\212\271\355\231\224-\354\236\220\353\243\214\355\230\225-\353\251\224\353\252\250\353\246\254-\354\265\234\354\240\201\355\231\224/index.mdx" +++ /dev/null @@ -1,75 +0,0 @@ -## 1. 개요 - -대규모 시스템에서 가장 비싼 자원은 보통 메모리입니다. -Redis의 특화 자료형은 정확도, 메모리, 처리량 사이의 트레이드오프를 명확히 선택하게 도와줍니다. - ---- - -## 2. 문제 정의 - -### 2.1 1억 유저 상태를 저장할 때의 비용 - -유저별 접속 여부를 일반 구조로 저장하면 메타데이터 오버헤드까지 포함해 메모리가 빠르게 커집니다. - -### 2.2 고유 방문자(UV) 집계의 메모리 폭증 - -정확한 UV를 Set으로 계산하면 대상이 커질수록 메모리 사용량도 선형으로 증가합니다. - -### 2.3 메시징 인프라 복잡도 증가 - -단순 이벤트 전달만 필요한데도 무거운 브로커를 먼저 도입하면 운영 복잡도가 급격히 올라갑니다. - ---- - -## 3. 해결책: Redis 특화 자료형 - -### 3.1 Bitmaps - -비트를 상태값으로 다루는 방식입니다. -유저 ID를 offset으로 사용하면 `SETBIT`, `GETBIT`, `BITCOUNT`로 대규모 상태 집계를 할 수 있습니다. - -- 장점: 메모리 효율이 매우 높고 연산이 빠름 -- 한계: ID 매핑 전략이 필요하고, sparse 데이터에는 비효율 구간이 생길 수 있음 - -### 3.2 HyperLogLog - -고유 개수(Cardinality) 추정 자료구조입니다. -정확한 목록이 아니라 "대략적인 유니크 수"가 목적일 때 매우 유리합니다. - -- 장점: 고정 메모리(약 12KB) -- 한계: 오차(약 0.81%)가 존재하고 개별 ID 조회는 불가 - -### 3.3 Streams - -Redis 기반 이벤트 로그/큐 자료형입니다. -`Consumer Group`을 사용하면 여러 소비자가 병렬로 처리할 수 있습니다. - -- 장점: Redis 기반으로 빠른 도입, ACK 기반 처리 가능 -- 한계: 초대형 장기 보관/재처리 시나리오는 전용 브로커 대비 한계가 있음 - -```mermaid -flowchart LR - R{"요구사항"} - R -->|"유저 상태 Y/N"| B["Bitmap"] - R -->|"고유 방문자 수"| H["HyperLogLog"] - R -->|"이벤트 스트림"| S["Redis Streams"] -``` - ---- - -## 4. 의사결정 표 - -| 기술 | 목적 | 메모리 특성 | 정확도 | 대표 명령 | -| --- | --- | --- | --- | --- | -| Bitmaps | 상태 체크 | 비트 단위 저장 | 정확 | `SETBIT`, `BITCOUNT`, `BITOP` | -| HyperLogLog | 유니크 카운팅 | 고정 메모리 | 근사 | `PFADD`, `PFCOUNT`, `PFMERGE` | -| Streams | 이벤트 처리 | 데이터량 비례 | 정확 | `XADD`, `XREADGROUP`, `XACK` | - ---- - -## 5. 실무 체크리스트 - -1. 정확한 목록이 필요한지, 개수만 필요한지 먼저 구분합니다. -2. 특화 자료형 도입 전, 데이터 수명 주기(TTL/아카이브)를 함께 설계합니다. -3. Streams는 Consumer Group 재처리 정책(`pending`, `claim`)까지 운영 문서화합니다. - diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/03-\355\212\271\355\231\224-\354\236\220\353\243\214\355\230\225-\353\251\224\353\252\250\353\246\254-\354\265\234\354\240\201\355\231\224/meta.json" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/03-\355\212\271\355\231\224-\354\236\220\353\243\214\355\230\225-\353\251\224\353\252\250\353\246\254-\354\265\234\354\240\201\355\231\224/meta.json" deleted file mode 100644 index b53cf58e..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/03-\355\212\271\355\231\224-\354\236\220\353\243\214\355\230\225-\353\251\224\353\252\250\353\246\254-\354\265\234\354\240\201\355\231\224/meta.json" +++ /dev/null @@ -1,35 +0,0 @@ -{ - "title": "Redis Deep Dive #03 - 특화 자료형과 메모리 최적화", - "slug": "redis-deep-dive-03-specialized-types", - "description": "Bitmaps, HyperLogLog, Streams를 활용해 대규모 데이터를 낮은 비용으로 처리하는 전략을 정리합니다.", - "date": "2026-02-13", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Redis", - "Bitmaps", - "HyperLogLog", - "Streams" - ], - "featured": false, - "series": { - "id": "redis-deep-dive", - "title": "Redis 완전정복", - "order": 3 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3.5, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/04-\354\204\261\353\212\245-\354\265\234\354\240\201\355\231\224-pipeline-lua/index.mdx" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/04-\354\204\261\353\212\245-\354\265\234\354\240\201\355\231\224-pipeline-lua/index.mdx" deleted file mode 100644 index d8d0ba47..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/04-\354\204\261\353\212\245-\354\265\234\354\240\201\355\231\224-pipeline-lua/index.mdx" +++ /dev/null @@ -1,80 +0,0 @@ -## 1. 개요 - -Redis 성능 이슈의 상당수는 엔진 자체보다 네트워크 왕복(RTT)에서 시작합니다. -이 글은 대량 처리의 처리량을 높이는 Pipeline과, 조건부 로직의 정합성을 보장하는 Lua 전략을 다룹니다. - ---- - -## 2. 문제 정의 - -### 2.1 RTT 누적으로 인한 처리량 저하 - -명령어 1,000개를 순차 요청하면 Redis 실행시간보다 네트워크 대기시간이 더 커지는 경우가 많습니다. - -### 2.2 조회-수정 분리 로직의 경쟁 상태 - -"조회 후 차감" 로직을 애플리케이션에서 분리 호출하면 중간에 다른 요청이 끼어들 수 있습니다. - ---- - -## 3. 해결책 - -### 3.1 Pipeline: 요청 왕복 횟수 줄이기 - -여러 명령을 한 번에 전송하고 응답을 한 번에 받습니다. -RTT가 큰 환경일수록 개선 폭이 큽니다. - -```bash -# 예시: redis-cli 파이프라이닝 -(echo "SET key1 v1"; echo "SET key2 v2"; echo "MGET key1 key2") | redis-cli --pipe -``` - -### 3.2 Lua Script: 원자적 비즈니스 로직 - -여러 Redis 명령을 서버 안에서 단일 작업으로 실행합니다. -락 없이도 조건부 연산의 원자성을 확보할 수 있습니다. - -```lua --- 잔액 차감 예시 -local balance = tonumber(redis.call('GET', KEYS[1]) or '0') -local amount = tonumber(ARGV[1]) - -if balance < amount then - return {err = 'INSUFFICIENT_BALANCE'} -end - -redis.call('DECRBY', KEYS[1], amount) -return balance - amount -``` - -```mermaid -sequenceDiagram - participant C as Client - participant R as Redis - - C->>R: Pipeline (N commands) - R-->>C: Batched responses - - C->>R: EVAL Lua Script - R-->>C: Atomic result -``` - ---- - -## 4. 선택 가이드 - -| 방식 | 강점 | 약점 | 적합한 시나리오 | -| --- | --- | --- | --- | -| 일반 호출 | 단순 | RTT 누적 | 소량 트래픽 | -| Pipeline | 처리량 향상 | 명령 간 의존성 처리 제한 | 대량 배치 읽기/쓰기 | -| Lua | 원자성 + 왕복 최소화 | 스크립트 복잡도 관리 필요 | 조건부 업데이트, 재고 차감 | -| MULTI/EXEC | 트랜잭션 구조 | 조건 로직 표현 제한 | 단순 트랜잭션 묶음 | - ---- - -## 5. 운영 체크리스트 - -1. Pipeline 배치 크기를 측정 기반으로 조절합니다. -2. Lua 스크립트는 짧게 유지하고 실행 시간을 모니터링합니다. -3. 응답 지연이 보이면 먼저 네트워크 RTT와 파이프라인 적용 여부를 확인합니다. - diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/04-\354\204\261\353\212\245-\354\265\234\354\240\201\355\231\224-pipeline-lua/meta.json" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/04-\354\204\261\353\212\245-\354\265\234\354\240\201\355\231\224-pipeline-lua/meta.json" deleted file mode 100644 index ed0ec203..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/04-\354\204\261\353\212\245-\354\265\234\354\240\201\355\231\224-pipeline-lua/meta.json" +++ /dev/null @@ -1,35 +0,0 @@ -{ - "title": "Redis Deep Dive #04 - 성능 최적화: Pipeline과 Lua", - "slug": "redis-deep-dive-04-pipeline-lua", - "description": "RTT를 줄이는 Pipeline과 원자성을 보장하는 Lua 스크립트로 Redis 성능을 최적화하는 방법을 정리합니다.", - "date": "2026-02-13", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Redis", - "Pipeline", - "Lua", - "Performance" - ], - "featured": false, - "series": { - "id": "redis-deep-dive", - "title": "Redis 완전정복", - "order": 4 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3.5, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/05-persistence-rdb-aof/index.mdx" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/05-persistence-rdb-aof/index.mdx" deleted file mode 100644 index b23ddd78..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/05-persistence-rdb-aof/index.mdx" +++ /dev/null @@ -1,72 +0,0 @@ -## 1. 개요 - -Redis는 메모리 기반이라 빠르지만, 장애 시 데이터 유실 리스크를 관리해야 합니다. -핵심은 영속성 설정을 통해 복구속도(RTO)와 유실허용(RPO)을 서비스 요구사항에 맞추는 것입니다. - ---- - -## 2. 문제 정의 - -### 2.1 디스크 기록은 느리다 - -모든 쓰기를 즉시 디스크에 반영하면 Redis의 장점인 지연시간이 악화될 수 있습니다. - -### 2.2 장애 시 유실 범위를 통제해야 한다 - -운영 환경에서는 "최대 몇 초 데이터를 잃을 수 있는가"를 명시적으로 결정해야 합니다. - ---- - -## 3. 해결책 - -### 3.1 RDB 스냅샷 - -주기적으로 메모리 상태를 파일로 저장합니다. -복구가 빠르고 백업 파일 관리가 쉽습니다. - -### 3.2 AOF 로그 - -쓰기 명령을 로그로 남깁니다. -`appendfsync` 정책에 따라 내구성과 성능 밸런스를 조절할 수 있습니다. - -### 3.3 하이브리드 운영 - -실무에서는 RDB + AOF를 함께 사용해 복구속도와 안전성을 동시에 확보하는 경우가 많습니다. - -```mermaid -flowchart LR - W["Write Command"] --> M["In-memory Apply"] - M --> A["AOF Append"] - M --> R["RDB Snapshot (Periodic)"] - A --> D["Disk"] - R --> D -``` - -```conf -# 예시 (redis.conf) -save 900 1 -save 300 10 -save 60 10000 -appendonly yes -appendfsync everysec -``` - ---- - -## 4. RDB vs AOF - -| 항목 | RDB | AOF | -| --- | --- | --- | -| 복구 속도 | 빠름 | 상대적으로 느림 | -| 데이터 안전성 | 스냅샷 간격만큼 유실 가능 | fsync 정책에 따라 유실 최소화 | -| 파일 크기 | 상대적으로 작음 | 상대적으로 큼 | -| 운영 포인트 | 스냅샷 타이밍 | rewrite 및 디스크 I/O | - ---- - -## 5. 운영 체크리스트 - -1. 서비스별 RPO/RTO 목표를 먼저 정의합니다. -2. AOF rewrite 시점과 디스크 용량 알람을 설정합니다. -3. `fork()` 시 메모리 급증 가능성을 고려해 여유 메모리를 확보합니다. - diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/05-persistence-rdb-aof/meta.json" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/05-persistence-rdb-aof/meta.json" deleted file mode 100644 index cdde2560..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/05-persistence-rdb-aof/meta.json" +++ /dev/null @@ -1,35 +0,0 @@ -{ - "title": "Redis Deep Dive #05 - Persistence: RDB와 AOF", - "slug": "redis-deep-dive-05-persistence-rdb-aof", - "description": "Redis 영속성 전략인 RDB와 AOF의 동작 원리와 운영 트레이드오프를 정리합니다.", - "date": "2026-02-13", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Redis", - "Persistence", - "RDB", - "AOF" - ], - "featured": false, - "series": { - "id": "redis-deep-dive", - "title": "Redis 완전정복", - "order": 5 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3.5, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/06-replication-cluster-\355\231\225\354\236\245-\354\240\204\353\236\265/index.mdx" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/06-replication-cluster-\355\231\225\354\236\245-\354\240\204\353\236\265/index.mdx" deleted file mode 100644 index 8014ced0..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/06-replication-cluster-\355\231\225\354\236\245-\354\240\204\353\236\265/index.mdx" +++ /dev/null @@ -1,63 +0,0 @@ -## 1. 개요 - -운영 환경에서 Redis 한 대는 결국 단일 장애점이 됩니다. -가용성은 Replication/Sentinel로, 대규모 확장성은 Cluster로 해결합니다. - ---- - -## 2. 문제 정의 - -### 2.1 장애 한 번에 전체 중단 - -단일 노드 장애가 즉시 캐시 레이어 장애로 이어집니다. - -### 2.2 데이터와 쓰기 부하가 한 노드 한계 초과 - -메모리와 CPU 한계에 도달하면 수직 확장만으로는 비용 대비 효율이 급격히 떨어집니다. - ---- - -## 3. 해결책 - -### 3.1 Replication + Sentinel - -- Master/Replica 복제로 읽기 부하를 분산 -- Sentinel이 장애를 감시하고 자동 페일오버 수행 - -### 3.2 Redis Cluster - -- 16,384 해시 슬롯 기반 샤딩 -- 노드별 데이터 분산으로 저장 용량/쓰기 처리량 확장 - -```mermaid -flowchart LR - C["Client"] --> M["Master"] - M --> R1["Replica 1"] - M --> R2["Replica 2"] - S["Sentinel Cluster"] --> M - - C --> CM1["Cluster Master A"] - C --> CM2["Cluster Master B"] - C --> CM3["Cluster Master C"] -``` - ---- - -## 4. 선택 기준 - -| 기준 | Replication + Sentinel | Redis Cluster | -| --- | --- | --- | -| 주 목적 | 고가용성 | 고가용성 + 수평 확장 | -| 데이터 배치 | 전체 복제 | 샤딩 분산 | -| 난이도 | 비교적 낮음 | 높음 | -| 읽기 확장 | 우수 | 우수 | -| 쓰기 확장 | 제한적 | 우수 | - ---- - -## 5. 운영 체크리스트 - -1. Sentinel/Cluster 모두 홀수 노드 기반 quorum 설계를 유지합니다. -2. 키 설계 단계에서 hot key를 방지하도록 해시 분포를 검증합니다. -3. 클라이언트 라이브러리의 cluster redirect 지원 여부를 반드시 확인합니다. - diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/06-replication-cluster-\355\231\225\354\236\245-\354\240\204\353\236\265/meta.json" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/06-replication-cluster-\355\231\225\354\236\245-\354\240\204\353\236\265/meta.json" deleted file mode 100644 index 24598a87..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/06-replication-cluster-\355\231\225\354\236\245-\354\240\204\353\236\265/meta.json" +++ /dev/null @@ -1,35 +0,0 @@ -{ - "title": "Redis Deep Dive #06 - Replication과 Cluster 확장 전략", - "slug": "redis-deep-dive-06-replication-cluster", - "description": "Sentinel 기반 고가용성과 Cluster 기반 샤딩 구조를 비교해 확장 전략을 정리합니다.", - "date": "2026-02-13", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Redis", - "Replication", - "Sentinel", - "Cluster" - ], - "featured": false, - "series": { - "id": "redis-deep-dive", - "title": "Redis 완전정복", - "order": 6 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3, - "usefulness": 3.5, - "originality": 2.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/07-\353\251\224\353\252\250\353\246\254-\352\264\200\353\246\254-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205/index.mdx" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/07-\353\251\224\353\252\250\353\246\254-\352\264\200\353\246\254-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205/index.mdx" deleted file mode 100644 index 3dcdc0be..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/07-\353\251\224\353\252\250\353\246\254-\352\264\200\353\246\254-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205/index.mdx" +++ /dev/null @@ -1,67 +0,0 @@ -## 1. 개요 - -Redis 운영의 핵심은 "빠른 장애 복구"가 아니라 "장애 예방"입니다. -메모리 정책과 지표 기반 점검 루틴이 없으면 OOM과 지연 급증이 반복됩니다. - ---- - -## 2. 문제 정의 - -### 2.1 maxmemory 도달 시 쓰기 장애 - -정책이 잘못되면 Redis는 새 쓰기를 거부하거나 중요한 키를 예기치 않게 삭제합니다. - -### 2.2 지연 시간 급증 원인 파악 어려움 - -싱글 스레드 모델에서는 무거운 명령 한 번이 전체 응답 시간에 영향을 줍니다. - ---- - -## 3. 해결책 - -### 3.1 Eviction 정책 설계 - -- `allkeys-lru`, `volatile-lru`, `allkeys-lfu` 등 정책을 서비스 특성에 맞춰 선택 -- TTL 없는 키가 많다면 `volatile-*` 정책만으로는 문제를 해결할 수 없음 - -### 3.2 핵심 지표 모니터링 - -`INFO memory`, `INFO stats`, `INFO commandstats`를 주기적으로 수집합니다. - -- `used_memory`, `used_memory_rss` -- `mem_fragmentation_ratio` -- `evicted_keys`, `expired_keys` - -### 3.3 느린 명령 추적 - -`SLOWLOG GET`으로 느린 명령을 파악합니다. -`KEYS *`, 대규모 `HGETALL` 같은 O(N) 패턴을 우선 제거합니다. - -```mermaid -flowchart TD - A["Latency Alert"] --> B{"OOM or Memory Spike?"} - B -->|"Yes"| C["Check INFO memory + eviction"] - B -->|"No"| D["Check SLOWLOG + commandstats"] - C --> E["Policy/Capacity tuning"] - D --> F["Command/query optimization"] -``` - ---- - -## 4. 장애 대응 플레이북 - -| 증상 | 1차 확인 | 2차 확인 | 대응 | -| --- | --- | --- | --- | -| OOM 에러 | `used_memory` | `maxmemory-policy` | 정책 조정, 용량 증설 | -| 응답 지연 | `SLOWLOG` | `commandstats` | 느린 명령 대체/분할 | -| 파편화 증가 | `mem_fragmentation_ratio` | RSS 추이 | purge/재기동 전략 | -| 키 유실 | `evicted_keys` | TTL 정책 | 키 우선순위 재설계 | - ---- - -## 5. 운영 체크리스트 - -1. 메모리 상한은 피크 사용량 대비 여유를 두고 설정합니다. -2. 대용량 키와 hot key를 정기적으로 스캔해 분산 전략을 검토합니다. -3. 장애 후 재현 가능한 회고 문서를 남겨 동일 이슈 재발을 막습니다. - diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/07-\353\251\224\353\252\250\353\246\254-\352\264\200\353\246\254-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205/meta.json" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/07-\353\251\224\353\252\250\353\246\254-\352\264\200\353\246\254-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205/meta.json" deleted file mode 100644 index 0f29c60b..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/07-\353\251\224\353\252\250\353\246\254-\352\264\200\353\246\254-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205/meta.json" +++ /dev/null @@ -1,35 +0,0 @@ -{ - "title": "Redis Deep Dive #07 - 메모리 관리와 트러블슈팅", - "slug": "redis-deep-dive-07-memory-troubleshooting", - "description": "Eviction 정책, INFO 지표, Slowlog 기반으로 Redis 운영 트러블슈팅 방법을 정리합니다.", - "date": "2026-02-13", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Redis", - "Memory Management", - "Troubleshooting", - "Observability" - ], - "featured": false, - "series": { - "id": "redis-deep-dive", - "title": "Redis 완전정복", - "order": 7 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3.5, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/08-\354\213\261\352\270\200-\354\212\244\353\240\210\353\223\234-resp-\354\227\224\354\247\204/index.mdx" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/08-\354\213\261\352\270\200-\354\212\244\353\240\210\353\223\234-resp-\354\227\224\354\247\204/index.mdx" deleted file mode 100644 index 57e9c504..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/08-\354\213\261\352\270\200-\354\212\244\353\240\210\353\223\234-resp-\354\227\224\354\247\204/index.mdx" +++ /dev/null @@ -1,77 +0,0 @@ -## 1. 개요 - -Redis는 싱글 스레드 모델인데도 높은 처리량을 보입니다. -핵심은 I/O multiplexing, 이벤트 루프, 단순한 프로토콜(RESP)의 조합입니다. - ---- - -## 2. 문제 정의 - -### 2.1 왜 멀티스레드가 아닌가 - -멀티스레드는 락 경합과 컨텍스트 스위칭 비용을 동반합니다. -Redis는 데이터 명령 실행을 단일 흐름으로 유지해 이 비용을 줄입니다. - -### 2.2 수많은 연결을 어떻게 처리하는가 - -수천~수만 커넥션을 블로킹 I/O로 처리하면 스레드 자원이 빠르게 고갈됩니다. - ---- - -## 3. 해결책 - -### 3.1 Event Loop + I/O Multiplexing - -`epoll`/`kqueue` 기반으로 읽기 가능한 소켓만 선택 처리합니다. -하나의 메인 루프에서 명령을 순차 실행해 락 복잡도를 줄입니다. - -### 3.2 RESP 프로토콜 - -Redis Serialization Protocol은 간단하고 파싱이 빠른 텍스트 기반 프로토콜입니다. -네트워크 오버헤드를 낮추고 다양한 언어 클라이언트 구현을 단순화합니다. - -```text -*2 -$3 -GET -$5 -mykey -``` - -```text -$5 -value -``` - -### 3.3 CoW 기반 백그라운드 작업 - -RDB/AOF rewrite에서 `fork()` 후 Copy-on-Write를 활용해 메인 처리 흐름을 최대한 보존합니다. - -```mermaid -flowchart LR - C1["Client"] --> M["I/O Multiplexing"] - C2["Client"] --> M - M --> E["Main Event Loop"] - E --> D["In-memory Data"] - E --> R["RESP Response"] -``` - ---- - -## 4. 내부 구조를 고려한 최적화 포인트 - -| 내부 특성 | 리스크 | 실무 대응 | -| --- | --- | --- | -| Single command loop | 긴 명령이 전체 지연 유발 | O(N) 명령 최소화 | -| Event loop | CPU 집중 작업에 취약 | 무거운 Lua/대량 작업 분할 | -| RESP | 요청 수가 많으면 RTT 부담 | Pipeline/배치 적용 | -| CoW | fork 시 메모리 급증 가능 | 메모리 헤드룸 확보 | - ---- - -## 5. 마무리 - -1. Redis의 강점은 단순한 실행 모델을 극한까지 최적화한 데 있습니다. -2. 내부 구조를 이해하면 장애 원인 추적과 튜닝 속도가 빨라집니다. -3. Redis를 만능으로 쓰기보다, 요구사항별로 다른 저장소와 조합해야 장기적으로 안정적입니다. - diff --git "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/08-\354\213\261\352\270\200-\354\212\244\353\240\210\353\223\234-resp-\354\227\224\354\247\204/meta.json" "b/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/08-\354\213\261\352\270\200-\354\212\244\353\240\210\353\223\234-resp-\354\227\224\354\247\204/meta.json" deleted file mode 100644 index 083edb40..00000000 --- "a/posts/\353\240\210\353\224\224\354\212\244-\354\231\204\354\240\204\354\240\225\353\263\265/08-\354\213\261\352\270\200-\354\212\244\353\240\210\353\223\234-resp-\354\227\224\354\247\204/meta.json" +++ /dev/null @@ -1,35 +0,0 @@ -{ - "title": "Redis Deep Dive #08 - 싱글 스레드 엔진과 RESP 프로토콜", - "slug": "redis-deep-dive-08-engine-resp", - "description": "Redis가 싱글 스레드 구조에서도 높은 처리량을 유지하는 이유와 RESP 프로토콜의 핵심을 정리합니다.", - "date": "2026-02-13", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Redis", - "Event Loop", - "RESP", - "Engine" - ], - "featured": false, - "series": { - "id": "redis-deep-dive", - "title": "Redis 완전정복", - "order": 8 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3.5, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\353\270\224\353\241\234\352\267\270-\354\213\234\354\212\244\355\205\234-\352\265\254\354\266\225\352\270\260/index.mdx" "b/posts/\353\270\224\353\241\234\352\267\270-\354\213\234\354\212\244\355\205\234-\352\265\254\354\266\225\352\270\260/index.mdx" index d879d235..52d247c9 100644 --- "a/posts/\353\270\224\353\241\234\352\267\270-\354\213\234\354\212\244\355\205\234-\352\265\254\354\266\225\352\270\260/index.mdx" +++ "b/posts/\353\270\224\353\241\234\352\267\270-\354\213\234\354\212\244\355\205\234-\352\265\254\354\266\225\352\270\260/index.mdx" @@ -1,842 +1,132 @@ -## 1. 왜 직접 만들었는가 +## 블로그를 운영 가능한 제품으로 본 이유 -기술 블로그를 직접 만든 이유는 글을 올릴 공간이 아니라, -설계 판단과 구현 기준을 함께 보여줄 제품이 필요했기 때문이다. -Medium, Velog, Tistory 같은 플랫폼은 배포와 작성은 빠르지만, -코드 표현, 인터랙션, 정보 구조를 내 기준대로 통제하기 어렵다. +글을 올리는 화면은 빠르게 만들 수 있다. 문제는 글의 공개 범위, 렌더링 방식, +화면의 읽기 경험, 배포 전 검증을 글이 늘어도 함께 관리해야 한다는 점이다. 이 중 +하나라도 경로마다 다르게 처리하면 초안이 노출되거나, 콘텐츠를 추가할 때마다 화면과 +build가 예상하지 못한 방식으로 깨진다. -이번 구축 목표는 단순한 "개인 블로그"가 아니라 -**설계 원칙이 드러나는 기술 포트폴리오**를 만드는 일이었다. +Ark를 정리하며 목표로 삼은 것은 기능이 많은 개인 홈페이지가 아니었다. 글, 이력서, +홈, 외부 연동의 변경 이유를 분리하고, 글을 공개하기 전 같은 기준으로 검토할 수 있는 +운영 가능한 블로그였다. -이 글에서는 Next.js 16, Three.js, MDX를 선택한 이유, -아키텍처 원칙, -최적화 기준, -AI 활용 방식까지 한 흐름으로 정리한다. +이 글은 현재 Ark가 그 목표를 위해 둔 모듈 경계, MDX 콘텐츠 계약, 단일 편집 지면, +검증 절차를 기록한다. 특정 프레임워크의 사용법보다, 콘텐츠를 계속 추가하는 제품에서 +무엇을 고정하고 무엇을 바꿔야 하는지에 초점을 둔다. -### 영감의 출처 +## 먼저 고정한 요구사항 -프로젝트를 시작하며 큰 영감을 받은 곳이 있다. +블로그의 요구사항은 화면에서 시작하지 않았다. 다음 네 가지를 먼저 고정했다. -[zerolog.vercel.app](https://zerolog.vercel.app) +1. 글의 본문과 메타데이터는 함께 관리하되, 공개 여부는 명시적으로 검토한다. +2. Next.js route는 요청을 연결하는 어댑터로 두고, 콘텐츠 정책과 렌더링 책임을 + 페이지에 섞지 않는다. +3. MDX의 heading, GFM, 코드 하이라이팅 계약은 글마다 달라지지 않는다. +4. 긴 글에서는 테마와 카드가 경쟁하지 않고, 본문과 탐색의 위계가 먼저 읽혀야 한다. -미니멀하면서도 완성도 높은 디자인, 깔끔한 타이포그래피, 그리고 세심한 인터랙션. -단순히 "이쁘다"를 넘어 "어떻게 구현했을까?" 를 고민하게 만드는 블로그였다. +이 요구사항은 구현을 제한한다. 예를 들어 로컬 설정을 줄이기 위해 콘텐츠 파이프라인을 +즉시 교체하거나, 공개 검토 없이 새 글을 목록에 추가하는 방식은 선택하지 않았다. -실제로 DevTools를 열어 구조를 뜯어보고, 애니메이션 타이밍을 트레이싱하며 많은 것을 배웠다. -특히 다음 부분에서 큰 영향을 받았다. +## 변경 이유가 다른 코드를 분리했다 -- **신문 느낌의 베이지 컬러 시스템** → 제 블로그의 `--bg-primary: #eaebea` 채택 -- **Fluid Typography 활용** → clamp() 기반 반응형 타이포그래피 도입 -- **미니멀한 레이아웃** → 최대 너비 800px, 여백 중심 디자인 -- **JetBrains Mono 폰트** → 기술 블로그에 어울리는 코드 친화적 폰트 +현재 구조는 domain-first modular monolith다. 폴더 이름이 아니라 변경 이유와 +소유권을 기준으로 경계를 둔다. `app`은 Next.js route adapter에 머물고, +콘텐츠의 유효성·공개 정책·렌더링은 `blog`가 소유한다. -좋은 디자인을 보고 배우는 건 부끄러운 일이 아니라고 생각했다. -오히려 "왜 이렇게 만들었을까?" 를 고민하며 스스로 구현해보는 과정이 가장 좋은 학습이었다. +| 모듈 | 소유하는 책임 | +| --------- | ------------------------------------------------------------------------ | +| `app/` | App Router route, route handler, metadata entry | +| `posts/` | 글별 `index.mdx` 본문과 `meta.json` 메타데이터 | +| `blog/` | post schema, repository, publication policy, series, blog UI, RSS 직렬화 | +| `site/` | home, AppShell, navigation, provider, site config 조합 | +| `infra/` | Supabase, Umami analytics, SEO integration 같은 런타임 경계 | +| `ui/` | 도메인 지식이 없는 UI·layout primitive와 motion helper | +| `styles/` | 전역 스타일과 디자인 토큰 | ---- +이 구분의 핵심은 `blog`만 `posts/`를 읽는다는 점이다. route나 공용 UI가 파일을 +직접 읽기 시작하면, 어떤 경로에서 공개 정책과 메타데이터 검증을 적용해야 하는지 다시 +흩어진다. 기본 의존 방향을 `app → site → domain → infra/ui`로 두어 조합과 정책의 +책임을 분리했다. -## 2. 기술 스택 선정 기준 +## 콘텐츠는 두 파일과 하나의 정책으로 관리한다 -### 2.1 Core: Next.js 16 App Router +각 글은 `posts/**/index.mdx`와 같은 폴더의 `meta.json`으로 구성한다. 본문은 MDX가 +맡고, 제목·slug·카테고리·형식·공개 범위·품질 리뷰는 JSON 메타데이터가 맡는다. +본문과 공개 정책을 하나의 형식에 억지로 섞지 않기 위한 선택이다. -**선택 이유:** +`blog` repository는 메타데이터를 검증한 뒤 글 목록과 상세 페이지에 전달한다. +`visibility`의 기본값은 `private`이며, 일반 조회에서는 public 글만 남긴다. 따라서 +검토 중인 원고는 저장소에 있어도 production의 목록, RSS, sitemap에 나타나지 않는다. +개발 환경에서는 직접 URL로 private 글을 미리 볼 수 있어, 렌더링 문제와 공개 정책을 +구분해서 확인할 수 있다. -- SSG (Static Site Generation)로 빠른 로딩 -- 파일 기반 라우팅으로 직관적인 구조 -- 이미지 최적화, 폰트 최적화 내장 -- SEO 친화적 - -**App Router를 선택한 이유:** +공개는 파일을 추가한 결과가 아니라 리뷰의 결과다. 기술 글은 `philosophy`, `design`, +`implementation`의 평균이 3.0을 넘어야 하며, 점수만으로 자동 공개하지 않는다. +콘텐츠 계약이 먼저 있어야 새 글의 초안 상태가 탐색 화면의 상태를 바꾸지 않는다. -- Server Component 기본 지원으로 번들 사이즈 최적화 -- Metadata API로 SEO 설정 간편화 -- `generateStaticParams`로 동적 라우트 SSG 지원 +## MDX 파이프라인은 교체보다 계약 보존을 택했다 -```typescript -// src/app/feed/[slug]/page.tsx -export async function generateStaticParams() { - const feeds = getSortedFeedData(); - return feeds.map((feed) => ({ - slug: feed.slug, - })); -} -``` +Ark는 `next.config.mjs`의 커스텀 webpack rule에서 `@mdx-js/loader`를 사용한다. +이 rule은 GFM, heading slug, 코드 하이라이팅을 같은 경로로 적용한다. 글 상세 화면은 +원본 MDX에서 목차를 만들고, MDX 컴포넌트 매핑으로 heading과 콘텐츠 요소를 렌더링한다. -### 2.2 Content: MDX +내장 MDX로 옮기면 설정 파일은 짧아질 수 있다. 그러나 heading 생성, 코드 렌더링, +컴포넌트 매핑, 글 import 방식이 함께 바뀐다. 이 비용은 단순한 설정 정리가 아니라 +콘텐츠 파이프라인 마이그레이션이다. 그래서 현재는 기존 rule을 유지하고, +`next build --webpack`으로 실제 글을 포함한 production build를 확인한다. -**선택 이유:** +이 선택은 확장성보다 안정성을 우선한 결정이다. 새 MDX 기능이 필요해도 공용 매핑과 +글의 렌더링 계약을 먼저 검토한다. 패키지 하나를 추가하는 이유만으로 콘텐츠 시스템을 +바꾸지 않는다. -- Markdown + React Component = 무한한 확장성 -- 코드 블록에 인터랙티브 데모 삽입 가능 -- GitHub Flavored Markdown 지원 (표, 체크박스 등) +## 읽기 화면은 단일 Graphite Ink 지면으로 정리했다 -**처리 파이프라인:** +기술 블로그의 화면이 콘텐츠보다 색과 카드의 존재감을 먼저 드러내면, 글을 읽는 시간은 +길어지고 탐색의 이유도 흐려진다. Ark는 단일 Graphite Ink 팔레트로 이를 정리했다. +기본 지면은 `#EAEBEA`, 본문 잉크는 `#252525`, 행동 포인트는 `#3F3F46`이다. -``` -MDX 파일 -→ gray-matter (frontmatter 파싱) -→ remark (Markdown AST 변환) -→ remark-gfm (GFM 지원) -→ rehype-highlight (코드 하이라이팅) -→ React Component 렌더링 -``` +카테고리, 링크, CTA는 별도 색으로 경쟁하지 않는다. 같은 무채색 팔레트 안에서 농도와 +위치로 구분한다. 이 선택은 테마 선택지를 늘리는 대신, 한 화면 안에서 본문과 탐색이 +같은 편집 지면으로 이어지게 한다. -**컨텐츠 구조:** +디자인 토큰은 `styles/`에 두고, `ui/`에는 도메인 지식이 없는 primitive만 둔다. +블로그의 reading progress나 MDX 요소처럼 글의 의미를 아는 UI는 `blog/`에 남긴다. +표현을 재사용하려고 콘텐츠의 책임까지 공용 UI로 올리지 않는 것이 이 구조의 경계다. -``` -content/ -├── 2026-01-28-blog-system-building/ -│ ├── index.mdx # 본문 -│ └── meta.json # 메타데이터 (제목, 설명, 태그 등) -``` +## 오래된 구축 기록을 고치고 배포 가능성을 확인한다 -이렇게 분리한 이유는 다음과 같다. +블로그 구축기는 시간이 지날수록 쉽게 부정확해진다. 예전 파일 경로와 렌더링 방식, +이미 완료된 기능 목록, 측정 방법이 없는 성능 수치를 남기면 회고가 현재 시스템을 +설명하는 문서처럼 보이면서도 실제로는 잘못된 구현을 안내한다. -- MDX에서 YAML frontmatter 파싱 이슈 회피 -- 메타데이터 검증을 Zod 스키마로 타입 안전하게 처리 -- 본문과 메타 정보 책임 분리 +이번 정리에서는 현재 저장소에서 확인할 수 없는 구현 서사와 수치를 제거했다. 대신 +현재 코드와 ADR로 검증 가능한 결정만 남겼다. 최적화의 결과를 단정하는 대신, 어떤 +변경이 콘텐츠 계약을 건드리는지와 어떤 명령으로 그 변경을 확인하는지를 기록한다. -### 2.3 Styling: Tailwind CSS v4 + CSS Variables - -**Tailwind v4를 선택한 이유:** - -- Lightning CSS 기반으로 빌드 속도 개선 -- Native CSS 변수 지원 강화 -- Just-in-Time 모드 기본 활성화 - -**디자인 시스템 구조:** - -```css -/* src/styles/variables.css */ -:root { - /* Color System */ - --bg-primary: #eaebea; - --text-primary: #1a1a1a; - --accent-primary: #0066cc; - - /* Typography Scale */ - --text-xs: 12px; - --text-sm: 14px; - /* ... */ - - /* Spacing (8px Grid) */ - --space-1: 4px; - --space-2: 8px; - /* ... */ -} -``` - -```javascript -// tailwind.config.js -module.exports = { - theme: { - extend: { - colors: { - primary: 'var(--bg-primary)', - 'text-primary': 'var(--text-primary)', - accent: 'var(--accent-primary)', - }, - fontSize: { - 'display-md': ['clamp(2.5rem, 6vw, 3.5rem)', { lineHeight: '1.2' }], - 'display-lg': ['clamp(4rem, 10vw, 6rem)', { lineHeight: '1.1' }], - }, - }, - }, -}; -``` - -**장점:** - -- 중앙 집중식 디자인 토큰 관리 -- 다크 모드 확장 용이 -- Fluid Typography로 반응형 자동 대응 - -### 2.4 Interactive: Three.js + @react-three/fiber - -**선택 이유:** - -- 기술 블로그에 차별화 요소 필요 -- 알고리즘 시각화, 3D 데모 표현 가능 -- React 생태계와 자연스러운 통합 - -**구현 예시:** - -```tsx -'use client'; -import { Canvas } from '@react-three/fiber'; - -export function HeroScene() { - return ( - - - - - - - ); -} -``` - -**주의 사항:** - -- Three.js는 브라우저 전용 → `'use client'` 필수 -- SSG 시 hydration 에러 방지 위해 `dynamic` import 고려 -- 번들 사이즈 증가 → 코드 스플리팅 필수 - ---- - -## 3. 아키텍처 설계 원칙 - -### 3.1 파일 기반 구조 - -``` -src/ -├── app/ # Next.js App Router -│ ├── page.tsx # 홈 -│ ├── feed/ # 블로그 피드 -│ │ ├── page.tsx # 목록 -│ │ └── [slug]/page.tsx # 상세 -│ ├── resume/ # 이력서 -│ └── _components/ # 공유 컴포넌트 -├── lib/ # 유틸리티 -│ └── mdx-feeds.ts # MDX 파싱 로직 -├── types/ # 타입 정의 -├── styles/ # 전역 스타일 -└── data/ # 정적 데이터 -``` - -**원칙:** - -- 페이지별로 필요한 컴포넌트는 `_components/`에 colocation -- 공통 컴포넌트는 `app/_components/`에 배치 -- Server Component 기본, Client Component는 명시적으로 표시 - -### 3.2 Server vs Client Component 전략 - -**Server Component (기본):** - -- 데이터 fetching -- 정적 콘텐츠 렌더링 -- SEO 관련 메타데이터 - -```tsx -// Server Component -export default async function FeedPage() { - const feeds = getSortedFeedData(); // 서버에서 실행 - return ; -} -``` - -**Client Component (명시적):** - -- 브라우저 API 사용 (window, document) -- useState, useEffect 등 hooks 사용 -- 인터랙티브 UI (애니메이션, 폼) - -```tsx -'use client'; -import { useState } from 'react'; - -export function FeedList({ feeds }: Props) { - const [filter, setFilter] = useState('All'); - // ... -} -``` - -**최적화 포인트:** +이 수정에서 얻은 기준은 명확하다. 회고의 정확성은 당시 사용한 기술 이름이 아니라, +독자가 지금 재현할 수 있는 경계와 검증 방식으로 판단한다. 현재와 다른 과거 구현을 +설명해야 한다면, 현재 구조와 혼동되지 않도록 별도 맥락과 근거를 함께 남겨야 한다. -- 불필요한 `'use client'` 제거로 번들 사이즈 30% 감소 -- Server Component를 최대한 활용하여 초기 로딩 개선 - -### 3.3 데이터 Fetching 전략 - -**SSG (Static Site Generation) 채택:** - -- 블로그는 빌드 시점에 콘텐츠 확정 가능 -- 런타임 성능 최고 (HTML 파일만 서빙) -- CDN 캐싱 최적화 - -```typescript -// src/lib/mdx-feeds.ts -export function getSortedFeedData(): FeedData[] { - const contentDir = path.join(process.cwd(), 'content'); - const slugs = fs.readdirSync(contentDir); - - const allFeedsData = slugs.map((slug) => { - const metaPath = path.join(contentDir, slug, 'meta.json'); - const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); - - // Zod 스키마로 검증 - const validated = FeedFrontmatterSchema.parse(meta); - - return { - slug, - ...validated, - }; - }); - - // 날짜 기준 내림차순 정렬 - return allFeedsData.sort( - (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() - ); -} -``` - -**장점:** - -- 타입 안전성 (Zod 검증) -- 빌드 타임에 에러 감지 -- 런타임 데이터 fetching 없음 - ---- - -## 4. 핵심 기능 구현 - -### 4.1 MDX 렌더링 with 커스텀 컴포넌트 - -```tsx -// src/app/feed/[slug]/_components/MdxComponents.tsx -export const MdxComponents = { - h1: ({ children }: any) => ( -

{children}

- ), - h2: ({ children }: any) => ( -

{children}

- ), - code: ({ children, className }: any) => { - const isInline = !className; - return isInline ? ( - {children} - ) : ( - {children} - ); - }, -}; -``` - -**렌더링:** - -```tsx -import { MDXRemote } from 'next-mdx-remote/rsc'; - -; -``` - -### 4.2 Table of Contents (TOC) 자동 생성 - -```typescript -// src/lib/mdx-feeds.ts -export function parseHeadingsFromHtml(html: string): TocItem[] { - const $ = cheerio.load(html); - const headings: TocItem[] = []; - - $('h1, h2, h3, h4, h5, h6').each((i, elem) => { - const level = parseInt(elem.tagName[1]); - const text = $(elem).text(); - const id = slugify(text); - - // ID 자동 할당 - $(elem).attr('id', id); - - headings.push({ id, text, level }); - }); - - return buildTocTree(headings); -} -``` - -**Interactive TOC Component:** - -```tsx -'use client'; -export function InlineTableOfContents({ toc }: Props) { - const [activeId, setActiveId] = useState(''); - - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - setActiveId(entry.target.id); - } - }); - }, - { rootMargin: '-100px 0px -66%' } - ); - - // 모든 heading 관찰 - document.querySelectorAll('h1, h2, h3').forEach((elem) => { - observer.observe(elem); - }); - - return () => observer.disconnect(); - }, []); - - return ( - - ); -} -``` - -### 4.3 Reading Progress Indicator - -```tsx -'use client'; -import { useEffect, useRef } from 'react'; -import gsap from 'gsap'; -import { ScrollTrigger } from 'gsap/ScrollTrigger'; - -export function ReadingProgress() { - const progressRef = useRef(null); - - useEffect(() => { - gsap.registerPlugin(ScrollTrigger); - - gsap.to(progressRef.current, { - scaleX: 1, - ease: 'none', - scrollTrigger: { - trigger: 'article', - start: 'top top', - end: 'bottom bottom', - scrub: 0.3, - }, - }); - }, []); +Ark의 release quality gate는 다음 검사를 하나의 순서로 실행한다. - return ( -
-
-
- ); -} -``` - ---- - -## 5. 최적화 여정 - -### 5.1 Phase 1: 하드코딩 색상 제거 +1. `npm run lint`와 `npm run lint:css:syntax`으로 코드와 CSS 문법을 검사한다. +2. `npm run verify:docs`와 `npm run content:audit`으로 ADR, 콘텐츠 형식, + 공개 정책, 글 품질 메타데이터를 확인한다. +3. `npm run test:unit`으로 도메인과 UI의 단위 테스트를 실행한다. +4. `npm run build`로 webpack 기반 production build와 MDX 렌더링을 확인한다. +5. `npm run test:e2e`로 desktop·mobile viewport의 주요 경로를 확인한다. -**문제:** +`npm run test:ci`는 이 순서를 묶은 진입점이다. lint만 통과해도 MDX build나 mobile +layout은 깨질 수 있다. 반대로 preview가 열려도 private 글의 노출 정책이나 문서 계약이 +맞다는 증거는 아니다. 배포 가능성은 서로 다른 검사를 통과한 결과로만 판단한다. + +## 다음에도 적용할 기준 -- `#5b504c` 같은 하드코딩된 색상이 5곳에 산재 -- 디자인 시스템과 불일치 -- 다크 모드 확장 불가능 - -**해결:** - -```diff --
-+
-``` - -**결과:** - -- 모든 색상이 디자인 토큰 사용 -- 디자인 시스템 무결성 확보 - -### 5.2 Phase 2: Tailwind 클래스로 통일 - -**문제:** - -- `bg-[var(--bg-primary)]` 같은 inline var() 남용 -- Tailwind config와 이중 관리 -- 코드 가독성 저하 - -**해결:** - -```diff -- className="bg-[var(--bg-primary)] text-[var(--text-primary)]" -+ className="bg-primary text-text-primary" -``` - -**결과:** - -- 25개 이상 inline var() 제거 -- 코드 일관성 향상 -- Tailwind Intellisense 지원 - -### 5.3 Phase 3: 불필요한 'use client' 제거 - -**문제:** - -- Server Component로 충분한 컴포넌트에 'use client' 사용 -- 번들 사이즈 불필요하게 증가 -- 클라이언트 hydration 비용 증가 - -**해결:** - -```tsx -// Before -'use client'; -export function FeedListItem({ feed }: Props) { - return ...; -} - -// After (Server Component) -export function FeedListItem({ feed }: Props) { - return ...; -} -``` - -**결과:** - -- 2개 컴포넌트 Server Component 전환 -- 초기 JS 번들 15% 감소 - -### 5.4 Phase 4: 반응형 패턴 표준화 - -**문제:** - -- 3가지 다른 breakpoint 패턴 혼재 (max-md, max-lg, max-[480px]) -- 일관성 없는 반응형 처리 -- Fluid typography inline clamp() 중복 - -**해결:** - -```javascript -// tailwind.config.js -fontSize: { - 'display-sm': ['clamp(2rem, 4vw, 2.5rem)', { lineHeight: '1.2' }], - 'display-md': ['clamp(2.5rem, 6vw, 3.5rem)', { lineHeight: '1.2' }], - 'display-lg': ['clamp(4rem, 10vw, 6rem)', { lineHeight: '1.1' }], - 'display-xl': ['clamp(6rem, 15vw, 10rem)', { lineHeight: '1.05' }], -} -``` - -```diff -- className="text-[clamp(2.5rem,6vw,3.5rem)] max-md:text-[2rem] max-[480px]:text-[1.75rem]" -+ className="text-display-md max-md:text-[2rem]" -``` - -**결과:** - -- 비표준 breakpoint 9곳 제거 -- Fluid typography 유틸리티 재사용 -- 반응형 코드 50% 감소 - -### 5.5 Phase 5: PageLayout 컴포넌트 추상화 - -**문제:** - -- Feed, Resume 페이지에 중복된 레이아웃 코드 -- 변경 시 여러 파일 수정 필요 -- 일관성 유지 어려움 - -**해결:** - -```tsx -// src/app/_components/PageLayout.tsx -export function PageLayout({ title, children }: Props) { - return ( -
-
- -

{title}

-
-
{children}
-
- ); -} -``` - -**적용:** - -```tsx -// Before: 18 lines -export default function FeedPage() { - return ( -
-
- -

Feed

-
-
- -
-
- ); -} - -// After: 12 lines -export default function FeedPage() { - return ( - - - - ); -} -``` - -**결과:** - -- 중복 코드 60% 감소 -- 일관성 자동 보장 -- 레이아웃 변경 1곳에서 관리 - ---- - -## 6. 성능 지표 - -### Lighthouse 점수 (Desktop) - -- **Performance:** 98점 -- **Accessibility:** 95점 -- **Best Practices:** 100점 -- **SEO:** 100점 - -### 핵심 Web Vitals - -- **LCP:** 1.2s (목표: < 2.5s) -- **FID:** 45ms (목표: < 100ms) -- **CLS:** 0.05 (목표: < 0.1) - -### 번들 사이즈 - -- **First Load JS:** 87.2 kB -- **Page Load (Home):** 92.5 kB -- **Page Load (Feed Detail):** 105.3 kB - ---- - -## 7. AI와 함께한 개발 여정 - -### 7.1 개발 환경 - -이 블로그를 만드는 데 **약 일주일**이 걸렸어요. -하지만 혼자는 아니었다. - -**사용한 도구:** - -- **Antigravity IDE** - AI 통합 개발 환경 -- **Claude Code Pro** - 구독하여 매일 토큰 한도까지 사용 -- **Gemini Pro** - 구독하여 매일 토큰 한도까지 사용 - -매일 **토큰을 단 하나도 남기지 않고** 사용했다. -그만큼 AI와 밀도 높은 페어 프로그래밍을 했다는 뜻이다. - -### 7.2 AI 페어 프로그래밍 방식 - -**Claude Code의 역할:** - -- 아키텍처 설계 논의 -- 컴포넌트 구현 -- 리팩토링 제안 및 실행 -- 타입 정의 및 검증 로직 작성 -- 최적화 포인트 발견 - -**Gemini Pro의 역할:** - -- 디자인 시스템 구조 논의 -- CSS 변수 네이밍 컨벤션 -- 성능 최적화 아이디어 -- MDX 처리 파이프라인 설계 -- 에러 해결 및 디버깅 - -**내 역할:** - -- 요구사항 정의 및 우선순위 결정 -- AI 제안 검토 및 최종 판단 -- 코드 리뷰 및 품질 관리 -- 디자인 감각과 UX 의사결정 - -### 7.3 AI 활용 패턴 - -**1. 설계 단계** - -``` -나: "MDX 기반 블로그 시스템을 만들고 싶어. SSG로 구현하려고 하는데 구조를 어떻게 잡을까?" -AI: [파일 구조 제안, 데이터 fetching 전략, 타입 정의 예시] -나: "좋아, 근데 meta.json을 분리한 이유가 뭐야?" -AI: [YAML frontmatter 이슈 설명, Zod 검증 이점] -``` - -**2. 구현 단계** - -``` -나: "TOC를 자동 생성하고 싶은데 어떻게 구현할까?" -AI: [cheerio 활용 방안, IntersectionObserver 예시 코드] -나: "이 코드 동작은 하는데 성능이 걱정되는데?" -AI: [최적화 포인트 제안, rootMargin 조정, debounce 추가] -``` - -**3. 리팩토링 단계** - -``` -나: "이 페이지들 레이아웃 코드가 중복인데 어떻게 정리할까?" -AI: [PageLayout 컴포넌트 추상화 제안] -나: "근데 너무 일찍 추상화하는 거 아닌가?" -AI: [중복이 3번 이상일 때 추상화 권장, 현재 2번이므로 보류 가능] -→ 결국 2개 페이지지만 패턴이 명확해서 추상화 진행 -``` - -### 7.4 AI 없이는 불가능했을 것들 - -**1. Phase별 리팩토링** - -- 혼자였다면 "나중에 해야지"로 미뤘을 최적화 -- AI가 구체적인 Before/After 코드를 제시해서 바로 실행 가능 - -**2. 타입 안전성** - -- Zod 스키마 작성, 에러 처리 로직 -- 혼자였다면 `any` 남발했을 부분을 타입 안전하게 - -**3. 성능 측정 및 개선** - -- Lighthouse 점수 분석 -- Bundle Analyzer 해석 -- 병목 지점 정확히 찾아내기 - -**4. 문서화** - -- 이 포스트 자체도 커밋 로그 분석하며 AI와 함께 작성 -- 설계 의도, Trade-off 설명을 논리적으로 구조화 - -### 7.5 AI 시대의 개발자 역할 - -AI가 코드를 작성해주지만, **개발자의 역할은 더 중요해졌다.** - -**여전히 내가 해야 할 것:** - -- **문제 정의**: "뭘 만들 것인가" -- **우선순위 판단**: "지금 할 것인가, 나중에 할 것인가" -- **품질 관리**: "이 코드가 정말 좋은가" -- **디자인 감각**: "이게 사용자에게 좋은 경험인가" -- **아키텍처 판단**: "이 구조가 확장 가능한가" - -**AI의 강점:** - -- 구현 속도 (10배 이상) -- 일관성 있는 코드 패턴 -- 엣지 케이스 고려 -- 리팩토링 제안 -- 문서화 자동화 - -**결론:** -AI는 **페어 프로그래밍 파트너**다. -내가 "무엇을, 왜"를 정의하면, AI가 "어떻게"를 빠르게 구현한다. -그리고 내가 최종 품질을 검토하고 결정한다. - -일주일 동안 토큰을 전부 사용하며 만든 이 블로그는, -**AI 없이 혼자 만들었다면 한 달은 걸렸을 것**이다. - ---- - -## 8. 배운 점 - -### 8.1 설계 원칙 - -"완벽한 디자인 시스템보다 일관성 있는 구현이 중요하다." - -초반에 완벽한 디자인 시스템을 만들려 했지만, 실제 구현 과정에서 일관성이 깨졌다. -Phase별 리팩토링을 통해 일관성을 확보하는 것이 더 효과적이었다. - -"Server Component 기본, Client Component 최소화." - -모든 컴포넌트를 'use client'로 만들면 편하지만, -Server Component를 최대한 활용하면 번들 사이즈와 성능에서 큰 이점을 얻을 수 있다. - -"추상화는 중복이 3번 이상 발생할 때." - -PageLayout 컴포넌트를 초반에 만들지 않고, -2개 페이지에서 중복이 명확해진 시점에 추상화했다. -조기 추상화는 오히려 유연성을 해칠 수 있다. - -### 8.2 기술 선택 - -"최신 기술은 생산성을 위해, 안정성도 함께 고려." - -- Next.js 16: App Router의 안정성 확보 -- Tailwind v4: 빌드 속도 개선 체감 -- MDX: React 통합으로 확장성 극대화 - -"번들 사이즈는 항상 모니터링." - -Three.js 같은 무거운 라이브러리는 코드 스플리팅 필수. -`next/dynamic`과 `'use client'`를 조합하여 초기 로딩에 영향 없도록 관리. - -### 8.3 최적화 - -"측정하지 않으면 최적화할 수 없다." - -Lighthouse, Bundle Analyzer를 통해 정량적 지표를 확보했다. -추측이 아닌 데이터 기반 최적화다. - -"점진적 개선이 급진적 리팩토링보다 안전하다." - -Phase 1~5로 나눠 단계별로 개선했다. -각 Phase마다 빌드 검증 → 커밋 → 다음 단계. -롤백 가능한 지점을 명확히 했다. - ---- - -## 9. 다음 목표 - -### 9.1 기능 추가 - -- 검색 기능 (Algolia or local index) -- RSS Feed 생성 -- 댓글 시스템 (giscus) -- OG Image 자동 생성 - -### 9.2 성능 최적화 - -- Image 최적화 (WebP, AVIF) -- Critical CSS inline -- Font preloading 전략 - -### 9.3 개발 경험 - -- Storybook 도입 -- E2E 테스트 (Playwright) -- 단위 테스트 커버리지 80%+ - ---- - -## 마무리 - -기술 블로그를 직접 만드는 일은 단순히 글 쓰는 공간을 만드는 것 이상이었다. - -- 어떤 기술을 선택할지 판단하고 -- 어떤 경험을 줄지 설계하고 -- 어떤 품질 기준으로 유지할지 계속 검증해야 했다 - -이번 구축 이후 남은 기준은 세 가지다. - -1. 블로그도 제품처럼 목적과 정보 구조가 먼저 정해져야 한다. -2. 성능 최적화는 구현이 끝난 뒤가 아니라 구조를 잡는 시점부터 같이 가야 한다. -3. AI는 구현 속도를 올리지만, 품질 기준과 최종 판단은 사람이 끝까지 가져가야 한다. - -이 글이 기술 블로그를 만들려는 사람에게 -"어떤 스택을 썼는가"보다 "어떤 기준으로 만들었는가"를 보여주는 참고가 되길 바란다. - -**코드는 여기에:** [eunu.log GitHub](https://github.com/dev-wooyeon/eunu.log) - ---- - -## 참고 자료 - -**영감을 받은 프로젝트:** - -- [zerolog.vercel.app](https://zerolog.vercel.app) - 디자인과 구조의 영감 - -**기술 문서:** - -- [Next.js 16 Documentation](https://nextjs.org/docs) -- [MDX Documentation](https://mdxjs.com/) -- [Tailwind CSS v4](https://tailwindcss.com/) -- [Three.js + React Three Fiber](https://docs.pmnd.rs/react-three-fiber) +1. 화면을 추가하기 전에 변경 이유와 소유 모듈을 정한다. +2. 새 글은 public이 아니라 private에서 시작하고, 메타데이터와 본문을 함께 검토한다. +3. 콘텐츠 파이프라인 변경은 설정 정리가 아니라 마이그레이션으로 취급한다. +4. 읽기 화면의 새 시각 요소는 본문보다 먼저 보이는지 검토한다. +5. 성능과 생산성의 결론은 수치가 아니라 재현 가능한 측정과 검증 명령이 있을 때만 쓴다. + +이 글은 현재 Ark의 구조와 운영 기준을 기록한 회고다. 공개 여부는 다시 한 번 +콘텐츠 리뷰를 거쳐 결정한다. 구조를 바꾸기 전에 무엇을 지켜야 하는지 확인할 때, +이 기준을 다시 사용한다. diff --git "a/posts/\353\270\224\353\241\234\352\267\270-\354\213\234\354\212\244\355\205\234-\352\265\254\354\266\225\352\270\260/meta.json" "b/posts/\353\270\224\353\241\234\352\267\270-\354\213\234\354\212\244\355\205\234-\352\265\254\354\266\225\352\270\260/meta.json" index b8bfb463..16885588 100644 --- "a/posts/\353\270\224\353\241\234\352\267\270-\354\213\234\354\212\244\355\205\234-\352\265\254\354\266\225\352\270\260/meta.json" +++ "b/posts/\353\270\224\353\241\234\352\267\270-\354\213\234\354\212\244\355\205\234-\352\265\254\354\266\225\352\270\260/meta.json" @@ -1,31 +1,25 @@ { - "title": "Next.js 16과 Three.js로 블로그를 구축하고 최적화한 기록", + "title": "Next.js와 MDX로 다시 설계한 기술 블로그", "slug": "blog-system-building", - "description": "MDX 기반 기술 블로그를 만들며 정리한 설계 원칙, 시스템 구조, 성능 최적화 기준을 소개한다.", + "description": "글과 UI를 함께 운영하기 위한 Ark의 현재 구조, MDX 계약, 공개 정책, 검증 기준을 기록한다.", "date": "2026-01-28", "category": "Tech", "contentType": "retrospective", - "tags": [ - "Next.js", - "React", - "MDX", - "Three.js", - "Design System" - ], + "tags": ["Next.js", "React", "MDX", "Design System"], "featured": false, "visibility": "private", "qualityReview": { - "philosophy": 3, + "philosophy": 4, "design": 4, "implementation": 4, - "brandFit": 2.5, - "clarity": 3.5, - "structure": 3.5, + "brandFit": 4, + "clarity": 4, + "structure": 4, "evidence": 3.5, "usefulness": 3.5, - "originality": 3.5, - "polish": 2.5, - "reviewedAt": "2026-07-13", - "notes": "현재 저장소 구조와 성능 지표에 맞게 갱신하기 전까지 비공개" + "originality": 4, + "polish": 4, + "reviewedAt": "2026-07-20", + "notes": "현재 Ark 구조와 ADR에 근거해 전면 재작성했다. 공개 승격은 별도 콘텐츠 리뷰에서 결정한다." } } diff --git "a/posts/\354\225\214\352\263\240\353\246\254\354\246\230-\354\213\234\352\260\201\355\231\224/index.mdx" "b/posts/\354\225\214\352\263\240\353\246\254\354\246\230-\354\213\234\352\260\201\355\231\224/index.mdx" index 989e4b91..f8445177 100644 --- "a/posts/\354\225\214\352\263\240\353\246\254\354\246\230-\354\213\234\352\260\201\355\231\224/index.mdx" +++ "b/posts/\354\225\214\352\263\240\353\246\254\354\246\230-\354\213\234\352\260\201\355\231\224/index.mdx" @@ -1,17 +1,8 @@ -import { - BinarySearchVisualization, - DPVisualization, - GraphTraversalVisualization, - SlidingWindowVisualization, - SortingVisualization, - TwoPointerVisualization, -} from '@/blog/ui/mdx/visualization-components'; - ## 코딩테스트 알고리즘 완전정복 코딩테스트에서 자주 출제되는 **핵심 알고리즘과 자료구조**를 한 곳에 모았어요. 각 알고리즘의 **핵심 개념**과 **언제 사용하는지**를 먼저 파악하면, 문제를 보는 순간 어떤 접근을 꺼내야 할지 더 빨리 판단할 수 있어요. -그래프 탐색부터 정렬, DP, 그리디, 투 포인터까지 시각화와 함께 훑어보며 기본 선택 기준을 한 번에 잡아보면 좋아요. +그래프 탐색부터 정렬, DP, 그리디, 투 포인터까지 핵심 선택 기준을 한 번에 잡아보면 좋아요. --- @@ -54,11 +45,9 @@ import { --- -### 🎮 DFS vs BFS 시각화 - -아래 시각화로 두 알고리즘의 차이를 직접 확인할 수 있어요. +### DFS와 BFS 비교 - +두 알고리즘의 탐색 순서를 비교해 보면, 어떤 문제에서 각각이 적합한지 더 쉽게 판단할 수 있어요. > **핵심 차이:** DFS는 **깊이 우선**(세로로), BFS는 **너비 우선**(가로로) 탐색해요. @@ -122,11 +111,9 @@ import { - 메모리가 제한적이면서 O(n log n)을 보장해야 할 때 -### 🎮 정렬 알고리즘 시각화 +### 정렬 알고리즘 비교 -아래 시각화로 세 가지 정렬 알고리즘의 동작 방식을 직접 확인할 수 있어요. - - +세 가지 정렬 알고리즘은 모두 O(n log n) 평균 또는 보장 성능을 제공하지만, 메모리와 안정성의 조건이 달라요. > **핵심 차이:** 퀵 정렬은 피벗 기준 분할, 병합 정렬은 분할 후 병합, 힙 정렬은 힙 구조 활용 @@ -164,11 +151,9 @@ while left <= right: # 범위 조정 ``` -### 🎮 이진 탐색 시각화 - -정렬된 배열에서 이진 탐색이 어떻게 동작하는지 확인할 수 있어요. +### 이진 탐색 핵심 - +정렬된 배열에서는 탐색 범위를 절반씩 줄이는 방식으로 빠르게 값을 찾을 수 있어요. > **핵심:** 매번 탐색 범위를 절반으로 줄여 O(log n) 시간에 탐색해요. @@ -230,11 +215,9 @@ while left <= right: - 최장 공통 부분 수열 (LCS) - 편집 거리 (Edit Distance) -### 🎮 DP 시각화 - 피보나치 수열 +### DP 핵심 - 피보나치 수열 -동적 프로그래밍이 어떻게 중복 계산을 제거하는지 확인할 수 있어요. - - +동적 프로그래밍은 작은 문제의 결과를 저장해 중복 계산을 제거해요. > **핵심:** 작은 문제의 결과를 저장하고 재사용해 O(2ⁿ)에서 O(n)으로 최적화해요. @@ -373,11 +356,9 @@ while right < n: - ✅ 구간 합이 특정 조건을 만족하는 경우 찾기 - ✅ 연속된 부분 배열 문제 -### 🎮 투 포인터 시각화 - -두 포인터가 어떻게 이동하며 목표값을 찾는지 확인할 수 있어요. +### 투 포인터 핵심 - +두 포인터는 양 끝에서 시작해 조건에 따라 이동하며 목표값을 찾아요. > **핵심:** 정렬된 배열에서 양 끝의 포인터를 조건에 따라 이동해 O(n) 시간에 해결해요. @@ -423,11 +404,9 @@ for right in range(n): # 윈도우 축소 (left++) ``` -### 🎮 슬라이딩 윈도우 시각화 - -고정 크기 윈도우가 어떻게 이동하며 최대값을 찾는지 확인할 수 있어요. +### 슬라이딩 윈도우 핵심 - +고정 크기 윈도우는 한 칸씩 이동하며 이전 계산 결과를 재사용해요. > **핵심:** 윈도우를 한 칸씩 이동하며 이전 계산 결과를 재사용해 O(n) 시간에 해결해요. diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/connectors/index.mdx" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/connectors/index.mdx" deleted file mode 100644 index 25299386..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/connectors/index.mdx" +++ /dev/null @@ -1,272 +0,0 @@ -# Flink Connector 실전 - -## Connector가 왜 중요한가? - -Flink는 "계산 엔진"일 뿐이고, - -실제 업무에서는 **데이터를 어디서 가져오고(Kafka, Filesystem), 어디에 저장하느냐(ClickHouse, Iceberg, JDBC)**가 파이프라인의 본질을 결정함. - -즉, -- Flink = 파이프의 중앙 처리부 -- Connector = 파이프의 입구와 출구 - -Connector 이해가 바로 파이프라인 설계 능력임. - ---- - -## Connector 전반 개념 정리 - -Connector는 크게 두 가지로 나뉨: - -### Source Connector -- Kafka, Pulsar, Kinesis -- FileSystem(Parquet, CSV), Iceberg -- JDBC(읽기) - -### Sink Connector -- ClickHouse, JDBC, Kafka, Iceberg -- FileSystem(Parquet/CSV) - -중요한 건 Flink Table API/SQL에서는 - -**DDL로 선언만 하면 바로 테이블처럼 다룰 수 있다는 점.** - -예) -```sql -CREATE TABLE orders (...) WITH ('connector' = 'kafka'); -``` - -이 한 줄로 "Kafka → Flink → SQL로 처리"가 됨. - ---- - -## Kafka Connector - -(가장 많이 쓰는 정석 Source/Sink) - -Kafka는 Flink에서 사실상 **표준 Source**임. - -### Kafka Source 특징 -- 파티션 단위 병렬 처리 → Flink parallelism 자동 확장 -- event-time 기반 처리에 적합 (timestamp & watermark) -- exactly-once 보장 가능 (source offset + state 체크포인트) - -### Kafka Sink 특징 -- 모니터링 스트림, DLQ(dead-letter), alert pipeline 등에 자주 사용 -- 트랜잭션 기반 exactly-once 지원 -- key, partition, timestamp 메타데이터도 커스텀 가능 - -**예제 DDL** -```sql -CREATE TABLE orders ( - order_id STRING, - user_id STRING, - ts TIMESTAMP(3), - WATERMARK FOR ts AS ts - INTERVAL '5' SECOND -) WITH ( - 'connector' = 'kafka', - 'topic' = 'orders', - 'properties.bootstrap.servers' = 'kafka:9092', - 'format' = 'json' -); -``` - -Kafka는 Flink의 "입구"로 가장 적합한 구조임. - ---- - -## ClickHouse Connector - -(실시간 대시보드/OLAP 지표용) - -ClickHouse는 Flink Sink로 가장 자주 쓰이며 목적은 단 하나: - -**"빠르게 조회 가능한 실시간 집계 테이블 만들기"** - -**ClickHouse가 잘하는 것:** -- 대용량 집계, 그룹바이 속도 -- 실시간 대시보드/서비스 통계 - -**ClickHouse Sink 패턴** -- 보통 **집계된 형태(윈도우 결과)**를 넣음 -- row-level 데이터를 넣는 구조는 거의 없음 -- INSERT BATCH size, retry 정책이 중요 - -**예제 DDL** -```sql -CREATE TABLE ch_sales ( - window_start TIMESTAMP(3), - window_end TIMESTAMP(3), - category STRING, - total_amount DOUBLE -) WITH ( - 'connector' = 'jdbc', - 'url' = 'jdbc:clickhouse://ch-host:8123/default', - 'table-name' = 'realtime_sales' -); -``` - -요약: -대표님이 만드는 **5분/10분 매출 집계**, **유저 행동 통계** 같은 건 ClickHouse가 최적임. - ---- - -## Iceberg Connector - -(Data Lake / Analytics / 장기 보관 / 스냅샷 관리) - -Iceberg는 "디스크 위의 거대한 테이블"을 관리하는 포맷. - -데이터 레이크의 사실상 표준이 되어가는 중. - -**Iceberg가 필요한 이유:** -- 장기 보관 (수개월~수년) -- 스키마 진화 지원 -- 대량 데이터 효율적 분할(파티션) -- Time-Travel(특정 시점 스냅샷 조회) -- Flink/Spark/Trino 등 여러 엔진에서 동시에 조회 가능 - -**Flink + Iceberg 조합은 아래 목적에 최적화됨:** -- row-level 원본 로그 저장 -- Fact 테이블 적재 -- batch/stream 통합 ETL -- ML feature store용 원본 데이터 - -**예제 DDL** -```sql -CREATE TABLE iceberg_orders ( - order_id STRING, - user_id STRING, - amount DOUBLE, - ts TIMESTAMP(3), - WATERMARK FOR ts AS ts - INTERVAL '10' SECOND -) WITH ( - 'connector' = 'iceberg', - 'catalog-name' = 'prod_catalog', - 'catalog-type' = 'hive', - 'warehouse' = 's3://warehouse/' -); -``` - -요약: -실시간 ClickHouse는 "지금 보는 지표", -Iceberg는 "나중에 분석할 원본 저장소". - ---- - -## JDBC Connector - -(레거시 DB/차원 테이블 lookup/단순 적재) - -Flink의 JDBC Connector는 안정성은 좋지만 **대량 쓰기에는 부적합**임. - -대량 쓰기 환경에서는 ClickHouse·Iceberg·Kafka가 더 적합함. - -**JDBC는 아래 상황에 딱 맞음:** -- Dim table 조회 -- 적은 row에 incremental load -- 운영 DB에 소규모 로그 삽입 -- Lookup join으로 enrichment - -**예제 DDL** -```sql -CREATE TABLE dim_user ( - user_id STRING, - grade STRING -) WITH ( - 'connector' = 'jdbc', - 'url' = 'jdbc:mysql://mysql:3306/db', - 'table-name' = 'user_dim', - 'username' = 'root', - 'password' = 'root' -); -``` - -**주의사항:** -- 병렬 Insert는 DB 부하 커짐 -- Primary Key 기반 upsert만 제대로 활용 -- 트랜잭션 비용 큼 -→ 운영 DB는 과부하 위험 있으니 대부분은 "reference lookup"용으로만 사용함. - ---- - -## 실전: 왜 하나의 Kafka 주문 이벤트를 여러 목적지로 나누는가? - -아래는 실제 회사들이 가장 많이 쓰는 패턴. - -### ClickHouse: "지금 바로 보는 지표" -- 1분/5분/10분 집계 -- 대시보드 -- 내부 운영툴 - -### Iceberg: "나중에 또 분석할 raw 데이터" -- row-level 주문 로그 -- batch 분석 -- ML 학습 데이터 -- Data Lake 저장 - -### 모니터링 Kafka: "실시간 이상탐지/알림/다른 팀 소비" -- 고액 주문 alert -- 결제 실패 이벤트 -- 위험 패턴 감지 - -### 이걸 Flink에서 어떻게 구현? - -StatementSet으로 한 번에 처리함. - -```java -StatementSet stmt = tableEnv.createStatementSet(); - -stmt.addInsertSql("INSERT INTO ch_sales SELECT ..."); -stmt.addInsertSql("INSERT INTO iceberg_orders SELECT ..."); -stmt.addInsertSql("INSERT INTO monitor_topic SELECT ..."); - -stmt.execute(); -``` - -하나의 ordering event가 이렇게 분기되는 것: - -``` -Kafka orders - ├─ 집계 → ClickHouse - ├─ 원본 row → Iceberg - └─ 고액/이상 이벤트 → monitoring Kafka -``` - -이게 바로 "하나의 소스 → 여러 목적지" 패턴임. - ---- - -## 어떤 Connector를 언제 선택하면 좋은가? - -간단히 정리하면 아래 기준이면 대부분 해결됨. - -**실시간 집계/지표** -→ ClickHouse - -**장기 보관 / 원본 저장 / BI·ML 분석** -→ Iceberg - -**실시간 알림 / 이상탐지 / 확장성 높은 이벤트 브로커 / 외부 팀에서 사용해야하는 데이터** -→ Kafka - -**운영 DB에 소규모 참조 / Dim table lookup** -→ JDBC - ---- - -## 정리 - -- Flink는 외부 시스템을 Table로 선언해 스트림/배치를 일관된 방식으로 처리 -- Kafka는 사실상 표준 Source이고, ClickHouse는 실시간 집계 Sink, Iceberg는 장기 보관용 Data Lake Sink, JDBC는 소규모 Lookup/적재에 사용함. -- 실무에서는 하나의 Kafka 이벤트를 여러 목적지로 보내기 위해 StatementSet을 활용해 ClickHouse·Iceberg·모니터링 Kafka로 동시에 전송하는 구조가 일반적 - ---- - -## 공식 문서 출처 - -- [Flink Connectors 개요](https://nightlies.apache.org/flink/flink-docs-stable/docs/connectors/table/overview/) -- [Kafka Connector](https://nightlies.apache.org/flink/flink-docs-stable/docs/connectors/table/kafka/) -- [JDBC Connector](https://nightlies.apache.org/flink/flink-docs-stable/docs/connectors/table/jdbc/) -- [Iceberg Connector](https://iceberg.apache.org/docs/latest/flink/) -- [ClickHouse + JDBC 참고](https://nightlies.apache.org/flink/flink-docs-stable/docs/connectors/table/jdbc/) diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/connectors/meta.json" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/connectors/meta.json" deleted file mode 100644 index 73d4cc55..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/connectors/meta.json" +++ /dev/null @@ -1,34 +0,0 @@ -{ - "title": "Flink Connector 실전", - "slug": "flink-connectors", - "description": "Kafka, ClickHouse, Iceberg, JDBC 등 주요 Connector의 활용법과 실전 아키텍처를 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Flink", - "Kafka", - "Connector" - ], - "featured": false, - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 5 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/datastream-api/index.mdx" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/datastream-api/index.mdx" deleted file mode 100644 index 2e90edaa..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/datastream-api/index.mdx" +++ /dev/null @@ -1,182 +0,0 @@ -# DataStream API 심화 - -## 왜 DataStream API가 필요한가? - -Table API/SQL로 대부분의 파이프라인을 만들 수 있는데도, - -DataStream API가 존재하는 이유는 **"SQL로 표현하기 어려운 영역"**이 분명히 있기 때문임. - -**대표 케이스** -1. 이벤트 단위 정밀 제어 -2. 타이머 기반 로직 (예: N초 동안 추가 이벤트 없으면 time-out) -3. 비정형 데이터 처리 -4. 커스텀 상태(state) 기반 로직 -5. 여러 스트림을 복잡하게 결합하거나 패턴 감지 - -DataStream API는 "스트림 처리의 실체를 직접 다루는 레벨"이라고 보면 됨. - ---- - -## DataStream API의 핵심 흐름 - -DataStream은 크게 이렇게 흘러감: - -**Source → Transformation → Sink** - -Transformation 안에서 다음을 다룸: -- keyBy -- window -- state -- timers -- process function -- async IO -- side output - -즉, Table API가 "추상화된 SQL"이라면, -DataStream API는 "연산자(operator) 수준에서 직접 조립"하는 모델임. - ---- - -## DataStream을 이해하는 핵심 4요소 - -### 1) KeyedStream — Stateful 처리의 절대 기반 - -Flink의 모든 상태 기반 로직은 **keyBy**를 중심으로 돌아감. - -왜냐면: -- 같은 key는 반드시 같은 subtask로 라우팅됨 -- 그래서 key 단위로 상태(state)를 안전하게 유지할 수 있음 -- window, timer, state, process function 모두 KeyedStream 위에서 동작 - -즉, "key를 기준으로 파티션 → 동일 key를 가진 이벤트는 같은 CPU에서 처리"라는 모델임. - -이게 없으면 state는 유지 불가. - -### 2) Window — 시간 기반 집계의 핵심 - -Table API의 TUMBLE/HOP 같은 window와 유사하지만, DataStream은 더 강함: - -- Trigger 제어 가능 -- Evictor로 데이터 제거 가능 -- Late data 정책을 훨씬 유연하게 설정 가능 -- 윈도우 별로 process 가능 - -**예시** -- "5초 내 들어온 이벤트 합계" -- "유저별 30분 세션 종료 감지" -- "지연 데이터 허용 범위 동적으로 조절" - -DataStream window는 정말 디테일하게 제어할 수 있는 도구라고 보면 됨. - -### 3) ProcessFunction — DataStream API의 진짜 힘 - -"윈도우 없이도 이벤트 단위로 모든 걸 제어할 수 있게 해주는 함수"임. - -ProcessFunction 계열은 Flink의 하이엔드 기능이라고 보면 됨. - -**대표 기능** -- 이전 이벤트 접근 -- 다음 이벤트를 기다릴지 말지 결정 -- 타이머 등록 (Event-time / Processing-time 모두 가능) -- side output으로 조건별 흐름 분기 -- 상태(state) 직접 관리 - -이걸 이해하면 "Flink로 뭘 할 수 있고, 어디까지 가능한가" 감이 확실히 잡힘. - -**대표적인 사용 예** -- N초 동안 추가 이벤트 없으면 timeout → 주문 취소 처리 -- 두 개 이벤트 조합해 패턴 감지 (ex. 로그인 → 결제) -- "지연 도착한 이벤트 but 아직 처리해야 하는 케이스" 세밀 제어 - -### 4) State — Flink를 Flink답게 만드는 요소 - -State는 "이전 이벤트의 정보를 메모리나 로컬 DB(RocksDB)에 저장해두는 것". - -DataStream API에서 제공하는 상태: -- ValueState -- ListState -- MapState -- ReducingState -- AggregatingState - -**예시** -- "유저별 최근 주문 3개 저장" → ListState -- "장바구니 상태 업데이트" → MapState -- "이벤트 누적 합계" → ReducingState - -이 상태는 checkpoint에 함께 저장되어 장애 복구되는 구조임. - ---- - -## 고급 기능 — 실무에서 가장 많이 쓰이는 것들 - -### Async I/O — 외부 시스템 호출 시 무조건 고려하는 기능 - -외부 DB/Redis/ML API 호출하면 병목 생김 → Flink 전반 backpressure 발생. - -**Solution = Async I/O** - -**특징** -- 외부 요청을 비동기로 처리 -- backpressure 최소화 -- 병렬도 확장성 유지 - -**예시** -- user_id → Redis 프로필 조회 -- 상품 ID → 외부 정가 데이터 조회 -- ML inference API 호출 - -### Side Output — 흐름을 조건으로 분기 - -Table API에선 어려운 "조건 분기"를 DataStream API는 자연스럽게 지원함. - -**예시** -- Late event만 별도 Kafka로 -- 잘못된 스키마 데이터만 "dead-letter-topic"으로 -- 특정 임계값 이상의 주문만 모니터링 토픽으로 송출 - -### Backpressure — 성능과 안정성의 핵심 지표 - -DataStream API를 운영할 때 가장 많이 겪는 문제. - -**원인** -- Sink 데이터베이스 느림 -- 외부 API 느림 -- Window state 과도하게 큼 -- Serialization 무거움 -- 병렬도/리소스 부족 - -**징후** -- checkpoint duration 증가 -- watermark 지연 -- task queue 적체 - -Flink 운영에서 "backpressure 이해"는 거의 필수임. - ---- - -## 현실적인 예시 - -주문 이벤트 스트림 기준으로 예를 드리면 아래처럼 동작함. - -**주문이 들어올 때마다** -- 유저별 최근 주문 n개 추적 (ListState) -- 결제 실패 후 5초 내 재시도 없으면 알림 (KeyedProcessFunction + timer) -- 특정 금액 이상 주문은 별도 Kafka로 전송 (side output) -- 외부 DB에서 상품 카테고리 조회 (Async I/O) -- 매 10분마다 집계 후 ClickHouse에 저장 (Window) - -이걸 전부 Table API로 하는 건 불가능함. - -그래서 DataStream API가 필수로 필요함. - ---- - -## 출처 - -- [DataStream overview](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/overview/) -- [State & Checkpoint](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/fault-tolerance/state/) -- [Windows](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/operators/windows/) -- [ProcessFunction](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/operators/process_function/) -- [Async I/O](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/operators/asyncio/) -- [Backpressure](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/monitoring/back_pressure/) diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/datastream-api/meta.json" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/datastream-api/meta.json" deleted file mode 100644 index 6300a21a..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/datastream-api/meta.json" +++ /dev/null @@ -1,34 +0,0 @@ -{ - "title": "DataStream API 심화", - "slug": "flink-datastream-api", - "description": "SQL로 표현하기 어려운 복잡한 스트림 처리를 위한 DataStream API의 핵심 개념과 고급 기능을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Flink", - "DataStream", - "Data Engineering" - ], - "featured": false, - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 3 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/kubernetes/index.mdx" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/kubernetes/index.mdx" deleted file mode 100644 index 29c4898e..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/kubernetes/index.mdx" +++ /dev/null @@ -1,239 +0,0 @@ -# Flink Kubernetes 운영 - -Flink를 실무에서 안정적으로 운영하려면 결국 Kubernetes 위에서 돌아가는 구조를 이해해야 한다. - -Flink는 기본적으로 **분산 스트림 처리 엔진**이기 때문에, JobManager/TaskManager/Checkpoint Storage 등을 어떻게 배포·확장·복구하느냐가 운영 품질을 좌우한다. - -아래는 Kubernetes 기반 Flink 운영에서 꼭 알고 있어야 하는 핵심 개념과 실전 운영 기준을 정리한 내용이다. - ---- - -## Flink Kubernetes 운영 전략 전체 개요 - -Flink Job은 크게 두 방식으로 Kubernetes에 배포된다. - -### 1. Flink Session Cluster -- 하나의 JobManager + 여러 TaskManager -- 여러 Job이 하나의 클러스터를 공유 -- 리소스 공유로 효율적이지만, 격리가 약하고 장애 파급 가능성 있음 - -### 2. Flink Application Cluster (요즘 실무 표준) -- 하나의 Job = 하나의 독립 클러스터 -- JobManager/TaskManager가 Job과 함께 생성/종료 -- 격리·안정성·배포 단순성이 좋아 대규모 서비스에서 주로 사용 - -Session Cluster는 개발·테스트 환경, -Application Cluster는 운영 환경에서 가장 적합하다. - ---- - -## Kubernetes + Flink의 기본 아키텍처 - -``` -Kubernetes - ├─ Deployment: Flink JobManager - ├─ Deployment: Flink TaskManager - ├─ Service: JobManager RPC / REST - ├─ ConfigMap: flink-conf.yaml / log4j / libs - └─ Persistent Volume: Checkpoint/Savepoint Storage -``` - -핵심은 JobManager의 안정성, TaskManager의 수평 확장, Checkpoint 저장소의 내구성 3가지다. - ---- - -## JobManager 운영 포인트 - -### 단일 장애 지점을 막기 위한 고가용성(HA) - -Kubernetes에서는 기본적으로 아래 방식으로 고가용성을 구현한다. - -- JobManager Deployment → replica 1 유지 -- Zookeeper 또는 Kubernetes-native HA 사용 -- JobManager 실패 시 새로운 파드가 뜰 때 checkpoint에서 복구 - -**주의할 점:** -JobManager는 "상태 없는 컨트롤 플레인처럼 보이지만", 실제로는 checkpoint/metadata 관리 때문에 반드시 **외부 고가용성 스토리지**가 필요하다. - -**추천 구성** -- HA Storage → S3 / GCS / HDFS -- HA Metadata → Kubernetes HA (Flink v1.15+ 권장) - ---- - -## TaskManager 운영 포인트 - -TaskManager는 전부 "소비자(worker)" 역할이다. - -**CPU/메모리 요청·제한 설정** - -TaskManager는 JVM + RocksDB + 네트워크 버퍼까지 고려해야 한다. - -**예시:** -```yaml -resources: - requests: - cpu: "2" - memory: "6Gi" - limits: - cpu: "3" - memory: "8Gi" -``` - -**스케일 조절** - -병렬도(parallelism)가 올라가면 TaskManager 개수도 자동 증가한다. - -- 병렬도 32 -- TM당 slot 4 -→ 최소 TaskManager 8개 필요 - -**노드 장애 대비** - -Kubernetes는 TaskManager가 죽으면 자동으로 재생성하지만, -State는 checkpoint에서 복구되므로 크게 문제되지 않는다. - ---- - -## Checkpoint 저장소 (가장 중요한 운영 요소) - -Kubernetes 운영에서 checkpoint 저장소는 **사실상 S3가 정답**에 가깝다. - -**이유:** -- Pod 재생성에도 state 유지 가능 -- HA 환경에서 공유 스토리지 필수 -- 병렬 checkpoint에 적합 -- Savepoint 보관에도 최적 - -**폴더 예시:** -``` -s3://my-bucket/flink/checkpoints/ -s3://my-bucket/flink/savepoints/ -``` - -**주의할 점:** -- local PV에 checkpoint 저장은 절대 비추 (노드 장애 시 state 손실) -- S3 권한(IAM Role) 반드시 세팅 - ---- - -## 로그 및 Config 관리 - -Flink 설정과 라이브러리는 ConfigMap 또는 initContainer로 관리한다. - -### 기본 설정 파일 -- flink-conf.yaml -- log4j-console.properties - -Kubernetes에서는 각종 튜닝 파라미터를 환경 변수 형태로 주입하기도 한다. - -**예:** -```yaml -- name: FLINK_PROPERTIES - value: | - taskmanager.memory.process.size: 4096m - taskmanager.numberOfTaskSlots: 4 -``` - ---- - -## Kubernetes Operator 사용 여부 - -### 1) Flink Kubernetes Operator (공식) - -운영 자동화를 위한 강력한 도구. - -**기능:** -- Job 배포 자동화 -- Savepoint 기반 업그레이드 자동화 -- Job 상태 모니터링 -- Restart/Failover 자동 처리 - -대규모 기업에서 이제는 사실상 표준이다. - -**특징:** -- Application Cluster 운영에 최적화 -- GitOps/ArgoCD와 연계 쉬움 - ---- - -## 배포 전략 (가장 중요한 부분) - -배포 시 아래 두 가지 전략 중 하나를 선택한다. - -### 전략 A: Savepoint 기반 롤링 업데이트 (강력 추천) - -1. 기존 Job에서 savepoint 생성 -2. 새로운 이미지/코드로 Job 재시작 -3. savepoint에서 state 복구 -4. 문제 발생 시 이전 savepoint로 롤백 - -**장점:** -- 정확한 state 유지 -- 지표/세션/집계 모두 유지 -- 진정한 의미의 무중단 업데이트 - -### 전략 B: Checkpoint 기반 자동 복구 - -단순 재시작 시 checkpoint로부터 복구하는 방식이지만, -업데이트에는 savepoint가 훨씬 안정적이다. - ---- - -## 장애 대응 전략 - -Kubernetes 위에서 문제 발생 시 가장 흔한 상황과 해결책: - -### ① TaskManager OOM -- state가 너무 큼 → RocksDB or window 구조 재설계 -- 메모리 부족 → process.size 증가 -- operator chain 해제로 병목 분리 - -### ② JobManager Restart Loop -- checkpoint 경로 권한 문제 -- S3 네트워크 오류 -- HA metadata 손상 → cleanup 후 restore 필요 - -### ③ Backpressure 지속 -- sink 병목 (ClickHouse/JDBC) -- async I/O 응답 지연 -- parallelism 부족 -- window state 지나치게 큼 - -### ④ Container가 너무 자주 재시작 -- JVM Heap 부족 -- RocksDB file descriptor 부족 -- CPU throttling (limits 너무 낮음) - ---- - -## 실전 아키텍처 예시 - -``` -Kafka → Flink (K8S Application Cluster) - ├─ ClickHouse (실시간 지표) - ├─ Iceberg (Data Lake / 장기 저장) - └─ Kafka monitoring (알림 / ML Feature Stream) - -Checkpoint → S3 -Savepoint → S3/savepoints -Operator → Flink K8S Operator -``` - -대부분의 대규모 데이터 팀은 이 구조 그대로 운영한다. - ---- - -## 정리 - -Flink를 Kubernetes에서 운영할 때 핵심은 Application Cluster 기반으로 Job을 격리하고, Checkpoint와 Savepoint를 S3 같은 내구성 있는 스토리지에 저장하는 것이다. TaskManager는 병렬도와 리소스 요구사항에 따라 스케일되며, JobManager는 Kubernetes HA 방식으로 안정적으로 재시작된다. 배포는 Savepoint 기반 롤링 업데이트가 가장 안전하고, Flink K8S Operator를 사용하면 운영 자동화 수준을 크게 높일 수 있다. - ---- - -## 공식 문서 출처 - -- [Flink on Kubernetes](https://nightlies.apache.org/flink/flink-docs-stable/docs/deployment/resource-providers/overview/) -- [Kubernetes Native HA](https://nightlies.apache.org/flink/flink-docs-stable/docs/deployment/ha/kubernetes_ha/) -- [Application vs Session Mode](https://nightlies.apache.org/flink/flink-docs-stable/docs/deployment/overview/) -- [Checkpoint / Savepoint](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/checkpoints/) -- [Flink Kubernetes Operator](https://nightlies.apache.org/flink/flink-kubernetes-operator-docs-stable/) diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/kubernetes/meta.json" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/kubernetes/meta.json" deleted file mode 100644 index 00e68033..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/kubernetes/meta.json" +++ /dev/null @@ -1,34 +0,0 @@ -{ - "title": "Flink Kubernetes 운영", - "slug": "flink-kubernetes", - "description": "Kubernetes 기반 Flink 운영의 핵심 개념, 배포 전략, 장애 대응을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Flink", - "Kubernetes", - "DevOps" - ], - "featured": false, - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 7 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/performance-tuning/index.mdx" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/performance-tuning/index.mdx" deleted file mode 100644 index 9f90f910..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/performance-tuning/index.mdx" +++ /dev/null @@ -1,216 +0,0 @@ -# 성능 튜닝 - -## 전체 그림부터 잡기 - -Flink 성능 튜닝은 결국 세 가지를 다루는 일임. - -- 얼마나 많이 병렬로 돌릴지 (parallelism, 리소스) -- 각 연산이 얼마나 싸게 돌 수 있게 만들지 (Serialization, State, RocksDB, Window) -- 시스템이 버티면서 안정적으로 돌아가게 할지 (Checkpoint, Backpressure, Restart) - -즉, - -**"빨리 + 많이 + 안 터지게"** - -이 세 가지를 동시에 맞추는 작업이 성능 튜닝임. - ---- - -## 병렬도(parallelism) & 리소스 설계 - -Flink에서 성능을 가장 크게 바꾸는 레버는 병렬도임. - -### 병렬도 기본 개념 - -- 각 operator는 parallelism을 가짐 -- parallelism N → N개의 subtask가 서로 다른 slot에서 돌게 됨 -- Kafka source의 경우 파티션 수와도 연관됨 (파티션 수가 parallelism보다 작으면 일부 subtask는 할 일 없음) - -**병렬도 설계 포인트** -- source 병렬도: Kafka 파티션 수 기준 -- compute 병렬도: CPU 사용량·state 크기 기준 -- sink 병렬도: 외부 시스템이 감당 가능한 TPS 기준 - -**실무 감각** -- CPU 70~80% 근처에서 안정적으로 유지되도록 조정 -- "느리게 도는 task"가 있으면 그 구간 병렬도만 올리는 식으로 부분 조정 - ---- - -## Operator Chain & Task 배치 - -Flink는 기본적으로 여러 operator를 하나의 task로 "체인"해서 실행함. - -**장점** -- operator 간 데이터 이동(네트워크/버퍼) 비용 감소 -- GC·스레드 전환 비용 감소 -- 단순 파이프라인에선 체인 덕분에 성능↑ - -**단점** -- 특정 operator 하나가 병목이면 같은 체인에 묶인 애들까지 전부 영향 -- 모니터링 시 "어디가 느린지" 구분이 어려워짐 - -**전략** -- 가벼운 map/filter 연산은 체인 유지 -- 무거운 window, async I/O, sink 앞뒤는 필요하면 체인 끊기 (disableChaining, startNewChain 등 활용) - ---- - -## Serialization 튜닝 - -직렬화는 성능에 크게 영향 주는 요소인데, 생각보다 많이 간과됨. - -**핵심 포인트** -- 타입을 명확하게 사용 (POJO, Avro, Protobuf 등) -- `Generic/Map` 같은 구조 남발하면 직렬화 비용↑ -- Kryo 기본 직렬화는 편하지만 비싸고 예측하기 어렵기 때문에, 가능하면 TypeInformation 기반 명시 타입 추천 - -**전략** -- schema 고정 가능한 스트림: Avro/Protobuf 사용 고려 -- DataStream/State에 들어가는 타입은 "불필요한 필드" 줄이기 -- 큰 객체를 그대로 state에 넣지 말고 필요한 최소 형태로 변환해서 저장 - ---- - -## Window & State 크기 관리 - -성능이 점점 느려지고, checkpoint도 점점 오래 걸린다면 대개 이유는 "state가 비대해졌기 때문"임. - -### Window 튜닝 포인트 -- window 길이 줄이기 (너무 긴 윈도우는 state 폭증) -- key cardinality 관리 (키 수가 너무 많으면 state도 그만큼 증가) -- 필요 없는 집계/필드를 과감히 제거 -- late data 허용 범위가 과도하게 넓지 않은지 체크 - -### State 줄이는 기본 전략 -- 오래된 state를 TTL로 날리기 (State TTL 설정) -- 사실상 필요 없는 key/state는 주기적으로 cleanup -- MapState/ListState에 무한정 쌓지 말고 "요약된 값"으로 줄이기 - ---- - -## RocksDB 튜닝 (대규모 state일 때) - -대규모 state를 쓴다면 RocksDB를 쓰게 되고, 이때 성능 문제의 80%는 RocksDB 쪽에서 터짐. - -**핵심 이슈** -- compaction 비용 -- 디스크 I/O -- checkpoint 시 snapshot 크기 - -**기본 전략** -- incremental checkpoint 사용 (변경분만 저장) -- RocksDB block cache·write buffer 크기 조정 (메모리 허용 범위 내에서 키움) -- local SSD 사용 시 성능 크게 개선 -- 너무 잦은 checkpoint는 RocksDB에 부담 → interval/timeout 적절하게 조정 - -**체감으로는** -- checkpoint가 오래 걸리면 RocksDB 튜닝 + checkpoint 주기 조정 -- RocksDB 디렉토리 있는 디스크 I/O 모니터링 필수 - ---- - -## Checkpoint & Savepoint 성능 - -Checkpoint는 성능과 안정성의 "절충점"이다. - -**너무 자주 하면** -- job이 checkpoint만 하다 끝나는 느낌 -- 매번 state snapshot 때문에 I/O 폭증 - -**너무 안 하면** -- 장애 시 재처리 구간이 너무 길어짐 -- 복구 시간↑ - -**실무 기준** -- 지연 허용 여유가 있다면 30~60초 정도 간격에서 시작 -- checkpoint 완료 시간이 interval의 50%를 넘으면 부담이 크다고 보고 튜닝 검토 -- 대규모 state일수록 incremental checkpoint 필수에 가깝게 고려 - -Savepoint는 성능보단 "배포/업그레이드용"이라, 크기와 생성 시간 정도만 참고. - ---- - -## Backpressure 튜닝 - -성능 튜닝의 최종 보스는 항상 backpressure임. - -backpressure는 "어딘가가 느려서 upstream이 밀리는 상태". - -**주요 원인** -- sink가 느림 (DB, ClickHouse, ES, 외부 시스템) -- async I/O에서 외부 응답 늦음 -- window 연산이 너무 무거움 -- state 접근이 많고 비효율적 -- 네트워크 병목 - -**해결 방향** -- 병목 sink의 병렬도 올리기 -- batch size / flush interval 튜닝 (sink connector 설정) -- async I/O concurrency 늘리기 (동시에 처리 가능한 요청 수) -- 느린 연산을 앞단에서 필터링해 데이터 양 자체를 줄이기 -- 필요시 operator 체인을 끊어서 병목 구간 분리 - -**운영 팁** -- Web UI에서 backpressure 있는 subtask를 먼저 찾기 -- 해당 subtask에 연결된 operator/sink를 중심으로 파헤치기 - ---- - -## 리소스(CPU/메모리) & Parallelism 전략 - -성능은 결국 "할당한 리소스를 얼마나 효율적으로 쓰느냐"로 귀결됨. - -**전략 느낌** -- CPU는 60~80% 구간에서 안정적으로 유지되는 선까지 parallelism↑ -- GC가 잦으면: heap 줄이고 task 수를 늘리거나, 상태를 RocksDB로 옮기는 것도 고려 -- TaskManager당 slot 수: 코어 수와 작업 특성을 같이 보고 결정 - (CPU-bound면 코어 수 이하, I/O-bound면 조금 더 높게 설정하는 식) - -**주의할 점** -- 무조건 parallelism을 키운다고 해결되지 않음 - → 외부 시스템이 못 받으면 그냥 병목만 오른쪽으로 이동 -- 병렬도/리소스/외부 시스템 TPS를 "셋트"로 보고 튜닝해야 함 - ---- - -## 실제로 튜닝할 때의 사고 순서 - -실무에서 성능 문제를 본다고 하면, 보통 이런 순서로 보면 됨. - -1. **지표 확인** - - 처리량(throughput), 지연(latency), checkpoint 시간, backpressure 여부 - -2. **어디가 느린지 찾기** - - Web UI에서 병목 task/operator 확인 - -3. **병목 유형 판별** - - CPU 100%? → 계산/직렬화/윈도우/복잡 로직 문제 - - 외부 I/O 대기? → sink/async I/O 문제 - - checkpoint만 오래 걸림? → state/RocksDB 문제 - -4. **레버 선택** - - 병렬도 조정 - - operator chain 조정 - - window/state 구조 리팩토링 - - RocksDB/Checkpoint 튜닝 - - sink 설정 튜닝 (batch size, flush interval, retry 등) - -이렇게 "지표 → 원인 → 해당 레버" 순서대로 보는 게 제일 깔끔함. - ---- - -## 정리 - -성능 튜닝 관점에서 Flink는 병렬도, state 구조, checkpoint, backpressure 네 가지를 중심으로 보고 있습니다. 병렬도와 리소스를 조정해 처리량을 확보하고, state와 window 크기를 관리해 checkpoint 시간과 RocksDB 부담을 줄입니다. sink와 async I/O 병목은 backpressure로 드러나기 때문에, Web UI와 메트릭을 통해 병목 구간을 찾고 해당 operator의 parallelism, batch size, flush 정책을 조정하는 방식으로 튜닝합니다. - ---- - -## 공식 문서 출처 - -- [Performance Tuning & Monitoring 개요](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/overview/) -- [Operator Chain](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/operators/overview/) -- [State & Checkpoint](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/fault-tolerance/state/) -- [RocksDB State Backend](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/rocksdb/) -- [Backpressure Monitoring](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/monitoring/back_pressure/) -- [Resource & Parallelism](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/execution/) diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/performance-tuning/meta.json" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/performance-tuning/meta.json" deleted file mode 100644 index f7b95d0a..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/performance-tuning/meta.json" +++ /dev/null @@ -1,34 +0,0 @@ -{ - "title": "성능 튜닝", - "slug": "flink-performance-tuning", - "description": "Flink의 병렬도, Operator Chain, RocksDB, Checkpoint, Backpressure 튜닝 전략을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Flink", - "Performance", - "Tuning" - ], - "featured": false, - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 6 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 4, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/state-operations/index.mdx" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/state-operations/index.mdx" deleted file mode 100644 index 4deaebcd..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/state-operations/index.mdx" +++ /dev/null @@ -1,249 +0,0 @@ -# State & 운영 - -## 왜 State Backend가 중요할까? - -Flink의 핵심은 "상태(state)를 가진 스트림 처리"임. - -여기서 **상태를 어디에 저장하느냐**가 성능, 안정성, 지연, checkpoint 시간 모두를 결정함. - -즉, -- state backend를 어떻게 선택하느냐 -- checkpoint를 어떻게 조정하느냐 -- 재시작 전략을 어떻게 구성하느냐 -이 모든 것이 **운영 품질(안정성)과 성능(레이지/처리량)을 좌우**함. - ---- - -## State Backend의 역할 - -State Backend는 크게 두 가지를 담당함: -- **상태(state)**를 어디에 저장할 것인가? (메모리 / RocksDB / Changelog 등) -- **checkpoint/savepoint**를 어떤 방식으로 저장·복구할 것인가? - -Flink 앱이 다운돼도 -- "어제까지의 집계 값" -- "유저별 최근 주문 상태" -- "Window state" - -를 그대로 복구할 수 있는 이유가 바로 backend 때문임. - ---- - -## 어떤 Backend가 있는지? - -현재 Flink에서 핵심적으로 쓰는 백엔드는 아래 3가지 구조임. - -### 1) HashMap State Backend - -(이전: Memory / Heap Backend) - -**특징** -- 상태를 메모리에 저장 -- 가장 빠른 backend -- state가 크지 않을 때 탁월한 선택 - -**단점** -- state 크기가 크면 메모리 터지고 checkpoint 시간 폭증 -- 대규모 state를 운영하기 어려움 - -**적합 케이스** -- 간단한 count, flag, 소규모 map/list -- ML inference 라우팅처럼 상태가 거의 없는 job - -### 2) RocksDB State Backend - -(가장 널리 쓰이는 백엔드) - -**특징** -- 로컬 RocksDB에 state 저장 → 디스크 기반 -- 매우 큰 상태도 안정적으로 처리 가능 -- 장애 복구 안정성 최상 - -**단점** -- 메모리 기반보다 느림 -- RocksDB compaction으로 인한 latency variance 발생 가능 -- checkpoint 사이즈가 커질 수 있음 - -**적합 케이스** -- 유저별 세션 추적 -- 수십·수백GB 규모 state 관리 -- IoT/주문/로그 스트림처럼 key 규모가 큰 경우 - ---- - -### 3) Changelog State Backend - -(Flink 1.15+ 이후 새로운 추세) - -**특징** -- state 변경 내역(changelog)을 스트리밍처럼 기록 -- checkpoint 부하 감소 (전체 snapshot 대신 변경분만 기록) -- 복구 속도 향상 - -현재 많은 기업들이 차세대 표준으로 채택 중임. - -**적합 케이스** -- 대규모 상태 + 빠른 checkpoint 필요할 때 -- 많은 업데이트가 발생하는 state-heavy job - ---- - -## 운영에서 가장 중요한 개념: Checkpoint - -Checkpoint는 Flink의 "복구 포인트". - -**동작 흐름** -- JobManager가 checkpoint trigger -- 각 Task가 자신의 state(snapshot) 저장 -- 모든 task가 완료하면 checkpoint 성공 -- 실패하면 해당 시점 이전 checkpoint로 rollback - -Checkpoint 설정이 운영난이도를 결정한다고 봐도 됨. - ---- - -## Checkpoint 튜닝 포인트 - -실무에 바로 적용되는 기준으로 정리 - -### checkpoint interval - -*너무 짧으면:* state snapshot 부하 ↑ → 성능 저하 - -*너무 길면:* 장애 발생 시 재처리 구간 ↑ → 복구 지연 - -→ 보통 **10초~5분 사이**에서 workload 기반으로 조정함. - -### checkpoint timeout - -지나치게 짧으면 "checkpoint timeout" 경고가 계속 터짐. - -RocksDB state가 크면 오래 걸릴 수 있으므로 별도 조정 필요. - -→ 1~5분이 일반적 범위 - -### min pause between checkpoints - -너무 빠르게 연달아 checkpoint가 트리거되지 않게 함. - -→ 안정성 확보 - -### incremental checkpoint (RocksDB) - -업데이트된 state의 변경분만 저장하는 방식. - -→ checkpoint 속도 크게 향상 - -→ 하지만 장기적으로 파일 조각화(fragmentation)가 심해질 수 있어 주기적 savepoint 필요 - ---- - -## Savepoint 운영 전략 - -Checkpoint는 장애 복구용, Savepoint는 **운영 전략용**임. - -**Savepoint 활용 시점** -- 코드 배포 (Job 업그레이드) -- parallelism 변경 -- cluster migration -- 주요 버전 업그레이드 - -**주의점** -- state schema 변경 시 호환성 고려 -- 잘못 만들면 state가 날아갈 수 있으므로 staging에서 항상 검증 필요 -- 운영에서는 "savepoint 폴더 보관 정책"도 중요 - ---- - -## 재시작 전략(Restart Strategy) - -Flink job은 실패할 수 있음 → 자동 재시작 정책 필요. - -**주요 전략** -- fixed-delay restart -- failure-rate restart -- no restart - -일반적으로 실무에서는 아래 구성이 안정적 -- "failure-rate restart + exponential backoff" - -**복구 흐름** -1. 실패 -2. 가장 최근 checkpoint 상태 로드 -3. source offset도 checkpoint 시점으로 돌림 -4. 재시작 후 재처리 → end-to-end exactly-once 유지 - ---- - -## 운영에서 가장 많이 겪는 문제: Backpressure + Checkpoint 병목 - -State backend가 RocksDB인 경우 자주 겪는 패턴: - -1. RocksDB compaction이 오래 걸림 -2. checkpoint가 지연됨 -3. 다음 checkpoint가 밀림 -4. backpressure 발생 -5. 전체 job의 latency 증가 - -**이런 경우 해결 방법:** -- checkpoint interval 늘리기 -- incremental checkpoint 활성화 -- RocksDB block cache / write buffer 튜닝 -- parallelism(병렬도) 조정 -- 특정 operator chaining 해제 - ---- - -## State Backend 선택 가이드 (실제 운영 기준) - -아래 기준이면 거의 90% 커버됨. - -### state < 수십 MB -→ **HashMap Backend** (속도 가장 빠름) - -### state 수백 MB~수 GB 단위 -→ **RocksDB Backend** (안정성 최고) - -### state 매우 크고 checkpoint 시간이 너무 오래 걸림 -→ **Changelog Backend** 고려 (대규모 streaming use-case) - ---- - -## 예시: 주문 이벤트 스트림 기준 - -**요구사항** -- 유저별 30분 세션 유지 -- 최근 10개 주문 기억 -- 5분 윈도우 기반 매출 집계 -- 장애 시 이전 상태 그대로 복구 -- 외부 시스템 부하로 checkpoint가 길어짐 - -**설계** -- State Backend: RocksDB -- incremental checkpoint: on -- checkpoint interval: 30~60초 -- restart strategy: failure-rate + backoff -- periodic savepoint: 배포 전 수동 생성 - -이 조합이면 안정성과 성능이 균형 있게 잡힘. - ---- - -## 정리 - -- Flink의 State Backend는 상태를 어디에 저장하고 어떻게 복구할지를 결정하는 핵심 요소. -- HashMap Backend는 빠르지만 작은 상태에 적합하고, RocksDB Backend는 대규모 상태에 적합 -- 최근에는 Changelog Backend가 checkpoint 성능과 복구 속도를 개선하는 방식으로 사용됨. -- 운영에서는 checkpoint interval, timeout, incremental checkpoint 설정이 중요하고, savepoint는 코드 배포나 마이그레이션 때 사용함. -- Job 재시작 시 checkpoint의 state와 source offset을 함께 복구해 exactly-once를 유지하는 구조임. - ---- - -## 공식 문서 출처 - -- [State Backends](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/state_backends/) -- [RocksDB State Backend](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/rocksdb/) -- [Checkpointing](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/checkpoints/) -- [Savepoints](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/savepoints/) -- [Restart Strategies](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/execution/restart_strategies/) -- [Backpressure Monitoring](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/monitoring/back_pressure/) diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/state-operations/meta.json" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/state-operations/meta.json" deleted file mode 100644 index b80c7f1b..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/state-operations/meta.json" +++ /dev/null @@ -1,34 +0,0 @@ -{ - "title": "State & 운영", - "slug": "flink-state-operations", - "description": "Flink의 State Backend, Checkpoint, Savepoint, 재시작 전략 등 운영 핵심 개념을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Flink", - "State", - "Operations" - ], - "featured": false, - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 4 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/table-api-sql/index.mdx" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/table-api-sql/index.mdx" deleted file mode 100644 index 9daa7dae..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/table-api-sql/index.mdx" +++ /dev/null @@ -1,252 +0,0 @@ -# Table API & SQL 정복 - -## Flink Table API & SQL 이란? - -Flink에서 고수준 추상화 계층임. - -DataStream API로도 다 할 수 있지만, 실무에선 대부분 Table API/SQL로 먼저 풀어보고, 안 되면 DataStream으로 내려가는 흐름이 일반적임. - -**정리** -- "데이터베이스처럼 SQL로 스트림과 배치를 처리할 수 있게 해주는 레이어" -- 배치와 스트림을 같은 Table/SQL 모델로 다룸 -- 내부적으로는 결국 DataStream으로 컴파일돼서 실행됨 - -Spark Structured Streaming이 있는 것처럼, Flink에선 Table API/SQL이 그 역할을 담당한다고 보면 됨. - ---- - -## 핵심 구성 요소 - -### TableEnvironment - -Flink에서 Table/SQL을 다루는 엔트리 포인트임. - -- Table 생성, DDL 실행, SQL 쿼리 실행 담당 -- Connector를 DDL로 선언하고, Catalog/Database/Table을 관리 -- 최종적으로 JobGraph를 만들어서 Flink 클러스터에 제출 - -보통 이런 흐름으로 씀: -- EnvironmentSettings 생성 (batch/stream 모드 지정) -- TableEnvironment.create(...) 호출 -- tableEnv.executeSql("CREATE TABLE ...") -- tableEnv.sqlQuery(...) 또는 tableEnv.executeSql("INSERT INTO ... SELECT ...") - -### EnvironmentSettings - -TableEnvironment가 어떻게 동작할지 정하는 설정임. - -대부분 이 정도만 구분해도 충분함: -- inBatchMode() -- inStreamingMode() - -Flink 철학상 배치도 스트림이긴 한데, 최적화 전략과 종료 여부 등에서 차이가 나기 때문에 모드 설정이 필요함. - ---- - -## Table, Schema, Catalog 개념 - -### Table - -논리적인 테이블 개념임. - -스트림이든 파일이든 Kafka든 결국 Table로 추상화해서 다룸. - -**예시** -- Kafka 토픽 → 주문 이벤트 스트림 테이블 -- S3 Parquet 파일 → 배치 테이블 -- JDBC 테이블 → 차원 테이블(dim table) - -### Schema - -Table에 대한 컬럼 정의임. - -**예시** -- 필드명, 타입 -- 시간 컬럼 정의 (rowtime, watermark) -- Primary Key, NOT NULL 같은 제약 - -이 Schema 정의에 따라 Flink가 타입 체크, 시리얼라이즈, 워터마크 계산 등을 자동으로 처리함. - -### Catalog / Database - -RDBMS의 Catalog/Schema와 거의 비슷한 느낌임. - -- Catalog: 메타데이터 저장소 (Hive Metastore, JDBC, in-memory 등) -- Database: Catalog 안의 네임스페이스 - -이걸 잘 써두면: -- 여러 Job에서 같은 테이블 정의를 재사용할 수 있고 -- SQL Gateway나 BI 도구에서 Flink를 "하나의 DB처럼" 바라볼 수 있음. - ---- - -## DDL 기반 Connector 정의 - -Table API/SQL의 핵심은 DDL로 외부 시스템을 선언하는 것임. - -대표 패턴은 "CREATE TABLE … WITH (…)" 형태. - -대표적인 Connector 종류: - -### FileSystem / Parquet / CSV -- S3, HDFS, 로컬 파일 등 -- 배치 ETL에 자주 사용 - -### Kafka -- topic, format(JSON, Avro, Debezium 등) 지정 -- key/partition metadata도 컬럼으로 가져올 수 있음 - -### JDBC -- MySQL, Postgres, MariaDB 등 -- dimension join, 결과 적재 등에 사용 - -### Iceberg/Hudi 등 -- 레이크하우스 테이블로 쓰기 위한 Connector -- upsert, snapshot, time-travel 같은 기능 활용 가능 - -요약하면, "외부 시스템 = 테이블"로 선언만 해두면 이후부터는 그냥 SQL로 join, filter, aggregation 하는 흐름임. - ---- - -## 시간 속성 & 워터마크를 SQL로 선언 - -1번에서 시간·워터마크 개념을 다뤘으니, Table API/SQL에서는 "어떻게 선언하는지"에 집중하면 됨. - -### 시간 속성 (Time Attribute) - -일반적으로 두 가지를 많이 씀: - -**Processing Time 컬럼** -- `PROCTIME()` 같은 표현으로 정의 -- 운영 편하지만, 정확한 Event-time 집계에는 불리함 - -**Event Time 컬럼** -- 기존 timestamp 컬럼에 `WATERMARK FOR ts AS ...` 형태로 정의 -- 예: `WATERMARK FOR ts AS ts - INTERVAL '5' SECOND` -- 이 정의를 기반으로 Flink가 워터마크를 계산함 - -핵심은 "DDL에서 시간과 워터마크를 선언하면, 이후 SQL에서 window 함수가 그걸 기반으로 작동한다" 정도로 기억하면 됨. - ---- - -## 윈도우(Window) 종류: TUMBLE, HOP, CUMULATE - -Table API/SQL에서 자주 나오는 윈도우 세 가지만 확실히 잡으면 면접 대응 충분함. - -### TUMBLE (고정 길이 윈도우) - -5분 단위, 1시간 단위 - -겹치지 않는 구간 10:00 ~ 10:05, 10:05 ~ 10:10 … - -### HOP (슬라이딩 윈도우) - -1분마다 5분 구간 집계 - -겹치는 구간 [10:00~10:05], [10:01~10:06], [10:02~10:07] … - -### CUMULATE (누적 윈도우) - -1분 간격으로, 최대 10분까지 누적 - -예: [10:00~10:01], [10:00~10:02], …, [10:00~10:10] - -실무에서 가장 많이 쓰이는 건 TUMBLE/HOP이고, CUMULATE는 실시간 누적 지표에 유용함. - ---- - -## StatementSet과 다중 Sink 처리 - -실제 파이프라인에서는 "동일한 소스 → 여러 목적지" 패턴이 많이 나옴. - -**예시: Kafka 주문 이벤트 → Flink → 여러 Sink** - -**Sink 1 : 실시간 집계용** -- 예: 5분 윈도우로 매출/주문 수 집계 → ClickHouse -- 빠르게 읽기 위해 집계/요약된 형태로 저장 - -**Sink 2 : 원본/분석용** -- 주문 이벤트 row level을 날짜/쇼핑몰/카테고리 등으로 파티셔닝해서 Iceberg에 적재 -- 향후 Spark/Flink SQL로 리포트/배치 분석/머신러닝에 활용 - -**Sink 3 : 모니터링/연동용** -- "특정 조건(결제 실패, 이상 패턴, 고액 주문 등)"을 만족하는 이벤트만 골라서 모니터링용 Kafka 토픽으로 발행 -- 이 토픽은 알림 시스템, 다른 팀의 서비스들이 다시 소비 - -이때 StatementSet으로 여러 INSERT를 하나의 Job으로 묶어서 실행할 수 있음. - -**요약** -- TableEnvironment에 StatementSet 생성 -- `addInsertSql` 또는 `addInsert`로 여러 INSERT 추가 -- `execute()` 호출 시 하나의 Flink Job으로 실행됨 -- 소스를 중복으로 읽지 않고 공유하는 구조가 가능해짐 - ---- - -## UDF (User Defined Function) - -SQL로 대부분 해결되지만, 비즈니스 로직이 조금 더 복잡해지면 UDF가 필요해짐. - -종류는 크게 세 가지 정도만 기억해도 충분함: - -**Scalar Function** -- 단일 행 → 단일 값 -- 예: 문자열 파싱, 도메인별 커스텀 매핑 - -**Table Function** -- 단일 행 → 여러 행 -- 예: JSON 배열을 여러 행으로 풀어내기 - -**Aggregate Function** -- 여러 행 → 단일 값 -- 예: 커스텀 집계 로직 (중간 상태를 들고 가는 집계) - -UDF를 쓰면 Table API/SQL에서도 꽤 복잡한 도메인 로직을 처리할 수 있고, DataStream으로 내려갈 필요가 줄어듦. - ---- - -## Catalog 기반 운영 전략 - -Table API/SQL을 "단순 코드 레벨 API"로만 쓰지 않고, "데이터베이스처럼" 쓰려면 Catalog를 잘 구성해야 함. - -**전략 예시** -- dev / staging / prod Catalog 분리 -- 각 Catalog 안에 비즈니스 도메인별 Database 구성 -- 공통 차원 테이블, 공통 Kafka 토픽 정의는 재사용 가능하게 관리 - -이렇게 해두면: -1. Flink SQL Gateway나 BI 도구에서 Flink를 "하나의 DB"처럼 붙일 수 있고 -2. 팀 내에서 테이블 정의를 공유하기 쉬움 -3. Job 코드 안에서 DDL을 하드코딩하는 비율이 줄어듦 - ---- - -## 예상 면접 질문 & 답변 스크립트 - -### Q. Flink Table API/SQL을 어떻게 이해하고 있나요? - -"저는 Flink Table API/SQL을 스트림과 배치를 통합해서 다룰 수 있는 고수준 레이어라고 이해하고 있습니다. Kafka, 파일, JDBC 같은 외부 시스템을 테이블로 선언해 두고, 그 위에서 SQL로 윈도우 집계나 조인, ETL을 수행합니다. 내부적으로는 DataStream으로 컴파일되지만, 개발자 입장에서는 대부분의 로직을 SQL/Table 단에서 해결할 수 있는 구조라고 보고 있습니다." - ---- - -### Q. DataStream API와 비교했을 때 장단점은? - -"DataStream은 세밀한 제어와 복잡한 상태 기반 로직에 강점이 있고, Table API/SQL은 선언적인 방식으로 빠르게 ETL과 집계를 만들 수 있다는 장점이 있습니다. 실무에서는 먼저 Table API/SQL로 모델링하고, 표현이 어려운 고급 패턴이 필요할 때만 DataStream으로 내려가는 것이 유지보수 측면에서 유리하다고 보고 있습니다." - ---- - -### Q. 시간 / 워터마크를 Table API/SQL에서 어떻게 다루나요? - -"Table DDL에서 이벤트 시간 컬럼과 워터마크를 함께 선언합니다. 예를 들어 timestamp 컬럼에 대해 `WATERMARK FOR ts AS ts - INTERVAL '5' SECOND` 같이 정의하면, Flink가 이 규칙에 따라 워터마크를 계산합니다. 이후 TUMBLE, HOP, CUMULATE 같은 윈도우 함수는 이 시간 컬럼과 워터마크를 기반으로 Event-time 윈도우를 계산하게 됩니다." - ---- - -### Q. 여러 Sink로 동시에 보내야 할 때는 어떻게 설계하나요? - -"하나의 소스에서 여러 Sink로 나누어 보내야 하는 경우 StatementSet을 사용합니다. 동일한 소스 테이블을 읽는 여러 INSERT를 StatementSet에 등록하고, 한 번의 execute로 하나의 Job으로 실행하면, 소스는 공유하면서도 여러 타겟으로 데이터를 보낼 수 있습니다. 이렇게 하면 리소스 효율과 운영 복잡도 측면에서 유리합니다." - ---- - -### Q. UDF는 어떤 경우에 사용하나요? - -"도메인 로직이 SQL 표현만으로는 복잡해지는 경우 UDF를 사용합니다. 예를 들면, 특정 포맷의 문자열에서 키 정보를 추출하거나, JSON 구조를 풀어서 여러 행으로 변환하거나, 커스텀 집계 로직이 필요한 경우입니다. Scalar, Table, Aggregate Function을 상황에 맞게 선택해서 Table API/SQL에서 재사용 가능한 함수 단위로 캡슐화하는 식으로 활용합니다." diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/table-api-sql/meta.json" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/table-api-sql/meta.json" deleted file mode 100644 index 5e13cc19..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/table-api-sql/meta.json" +++ /dev/null @@ -1,34 +0,0 @@ -{ - "title": "Table API & SQL 정복", - "slug": "flink-table-api-sql", - "description": "Flink의 고수준 추상화 레이어인 Table API/SQL로 스트림과 배치를 통합 처리하는 방법을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Flink", - "SQL", - "Data Engineering" - ], - "featured": false, - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 2 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\263\240\352\270\211 \352\270\260\353\212\245/index.mdx" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\263\240\352\270\211 \352\270\260\353\212\245/index.mdx" deleted file mode 100644 index 915abe15..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\263\240\352\270\211 \352\270\260\353\212\245/index.mdx" +++ /dev/null @@ -1,261 +0,0 @@ -여기서는 실제 대규모 서비스에서 자주 활용되는 고급 기능만 선별해 정리했다. - -CEP, BroadcastState, Async I/O 심화, Watermark 튜닝, Checkpoint 고급 전략까지 모두 포함한다. - ---- - -## 큰 그림 - -대규모 실시간 파이프라인에서는 아래 기능들이 결정적 역할을 한다. - -- CEP (Complex Event Processing) -- Broadcast State (컨트롤/정책 데이터 전파) -- Async I/O 심화 (외부 DB/모델 호출) -- Custom Watermark & Event-time 튜닝 -- Side Output 고급 패턴 -- Repartitioning 전략 -- Checkpoint/Savepoint 고급 설계 -- 성능 최적화를 위한 RocksDB & State Schema 설계 - -아래에 각각의 주요 개념과 실제 적용 사례를 직관적으로 정리했다. - ---- - -## 1) CEP — 이벤트 패턴 감지의 핵심 - -CEP는 "특정 순서·패턴을 만족하는 이벤트 시퀀스 감지" 기능이다. - -**예제 상황:** -- 로그인 → 결제 → 취소 같은 패턴 감지 -- 비정상 행동 패턴 탐지 (봇/사기탐지) -- 장바구니 담기 → 상품 상세보기 → 구매 전환 흐름 분석 - -CEP는 다음과 같은 복잡한 조건을 쉽게 표현할 수 있다. - -``` -A 이벤트 발생 - AND 5분 내에 B 이벤트 - AND 그 10초 뒤에 C가 발생하면 패턴 성립 -``` - -**CEP 장점** -- 복잡한 순서 조건을 간단히 표현 -- event-time 기반으로 정확한 시간 처리 가능 -- 지연 이벤트도 포함될 수 있도록 설정 가능 - -CEP는 고객 행동 분석이나 Fraud Detection에서 가장 강력한 도구다. - ---- - -## 2) Broadcast State — "전사 공통 룰" 공유 - -BroadcastState는 **모든 TaskManager에게 동일한 정책/룰/설정을 실시간으로 전파**하는 기능이다. - -**예제 상황:** -- 실시간 필터링 규칙 변경 -- ML 모델의 threshold 업데이트 -- 위험 상품 리스트를 모든 TM에게 전달 -- A/B 테스트 구분 로직 배포 -- 광고 추천 룰 변경 - -**예시:** -``` -Rules(Kafka Topic) → Broadcast -Events(Kafka Topic) → KeyedStream - → Connect → BroadcastProcessFunction -``` - -이 구조는 "정책 변경이 잦은 서비스"에서 절대적으로 필요하다. - -**대표적인 사용처:** -- 광고/추천 시스템 -- 실시간 라우팅 서비스 -- 실시간 필터 정책 -- Fraud detection - ---- - -## 3) Async I/O 심화 — 외부 시스템 연동 강화 - -기초 Async I/O는 이미 3단계에서 배웠지만, 실무에서는 훨씬 복잡한 패턴을 다룬다. - -**실전 Async I/O 사례:** -- Redis에서 유저 정보/토큰 조회 -- MySQL에서 상품 카테고리 조회 -- 외부 AI/ML inference API 호출 -- Feature Store 읽기 -- 실시간 가격/재고 API 호출 - -**고급 설정 포인트:** -- timeout 설정 -- concurrency (최대 동시 호출 수) -- capacity (큐 크기) -- retry/backoff 전략 -- circuit breaker 패턴 적용 - -**고급 전략:** -- 병렬도 확장으로 TPS 증가 -- "타입 캐싱"으로 동일 키 반복 조회 방지 -- 병렬 Async I/O → 결과 결합 - -Async I/O는 잘 쓰면 강력하지만, 잘못 쓰면 backpressure의 주요 원인이 된다. - ---- - -## 4) Watermark & Event-time 고급 튜닝 - -Watermark는 Flink 지연 처리의 핵심. - -고급 설정이 필요한 주요 상황은 다음과 같다. - -### ① 데이터 지연 정도가 업스트림에 따라 크게 달라질 때 - -로그 수집 장비나 지역별 네트워크 상황에 따라 event-time 지연이 다를 수 있다. - -**해결:** -- BoundedOutOfOrdernessWatermarks 개별 스트림별로 조정 -- Custom WatermarkGenerator로 상황별 커스텀 정책 구성 - -### ② 특정 키만 지연이 큰 경우 - -키별로 watermark 흐름이 달라질 수 있음. -이때 정밀하게 관리하지 않으면 Window가 너무 늦게 닫힘. - -**해결:** -- Keyed Watermark 관리 → StreamStatus 사용 -- latency histogram 기반 adaptive watermark 생성 - -### ③ Late Data 다루기 - -**고급 패턴:** -- Late event → side output -- Late event → Iceberg/Hudi에 기록 -- Late event → 보정 파이프라인으로 재전송 - ---- - -## 5) Side Output 고급 패턴 - -Side Output은 단순 error 분기만이 아니다. - -실전에서는 다음과 같이 적극적으로 활용된다. - -- 고위험 이벤트만 별도 Kafka topic -- 품질 이상치 데이터 스트림 생성 -- 실패 이벤트를 DLQ로 전달 -- A/B 테스트용 스트림 분리 -- 이벤트 라우팅 파이프라인 구성 - -Side Output은 실제 회사 데이터 파이프라인에서 "서비스별 맞춤 스트림 생성" 기능으로 자주 쓰인다. - ---- - -## 6) Repartitioning 전략 (Shuffle, Rebalance, Union) - -대규모 데이터 파이프라인에서는 파티셔닝 전략이 직결적으로 성능에 영향을 준다. - -**유형:** -- keyBy — key 기반 파티션 -- rebalance — round-robin (부하 분산) -- shuffle — 랜덤 분배 -- rescale — split-based partitioning -- global — 하나의 subtask로 몰아넣기 - -**전략 선택 기준:** -- 데이터 편향(key skew) → rebalance or rescale -- 연산 비용 균등화 필요 → rebalance -- key 기반 집계 필요 → keyBy -- 고정 key routing 필요 → shuffle + key mapping - -Key skew는 checkpoint 지연과 backpressure의 가장 흔한 원인이므로 파티션 전략은 매우 중요하다. - ---- - -## 7) Checkpoint & Savepoint 고급 전략 - -기본 개념은 이미 배웠으나, 고급 운영에서는 다음을 고려해야 한다. - -### ① Checkpoint Alignment 튜닝 - -alignment timeout, unaligned checkpoint를 통해 -backpressure 상황에서도 checkpoint가 멈추지 않도록 개선. - -### ② Incremental + Unified Savepoint 전략 - -대규모 state에서 savepoint는 시간이 오래 걸리므로 - -**운영 전략:** -- 소규모 savepoint: 배포용 -- 대규모 savepoint: 주기적 보관용 -- checkpoint: 장애 복구용 - -Iceberg/Hudi 기반 Delta Stream을 운영하면 "snapshot 기반 복구"도 가능해진다. - ---- - -## 8) RocksDB & State Schema 고급 설계 - -대규모 state 환경에서 가장 중요한 내용. - -**고급 전략:** -- valueState 대신 mapState 활용해 key 개수 줄이기 -- nested state 피하기 (RocksDB I/O 증가) -- 큰 state는 "요약(aggregate)" 형태로 변환 -- TTL + cleanup timer로 state 누적 방지 -- RocksDB memory tuning - - block cache 증가 - - write buffer 증가 - - compaction priority 조정 - -대규모 기업들은 대개 RocksDB state만 100~500GB를 운영한다. - -이 구간에서는 state schema 설계가 성능에 결정적이다. - ---- - -## 9) Flink SQL Gateway & Catalog 통합 - -고급 기능 중 실무에서 가장 빠르게 채택되고 있는 기능. - -**SQL Gateway를 사용하면:** -- Flink job 없이도 SQL로 Iceberg/Hudi 테이블 조회 -- Data Lake 전체를 Flink와 통합 -- streaming + batch 통합 관리 -- 운영/분석/ETL을 모두 SQL로 구현 - -Catalog 통합은 "Flink = DW 엔진"으로 확장하는 핵심 요소다. - ---- - -## 10) ML & 실시간 Feature Engineering - -마지막 고급 단계는 ML 실시간 파이프라인과 연결하는 것이다. - -**주요 패턴:** -- 실시간 Feature 계산 → Redis/Feast/ClickHouse -- raw event → Iceberg → batch training -- 실시간 inference → Async I/O로 모델 API 호출 -- 온라인/오프라인 feature consistency 유지 - -Flink는 Spark 대비 **실시간 ML-serving pipeline**에서 강력한 위치를 가진다. - ---- - -## 정리 - -Flink는 단순 ETL 엔진이 아니라 **실시간 데이터 플랫폼의 심장** 역할을 하게 된다. - -핵심은 "기능을 많이 아는 것"보다 "어떤 문제에서 어떤 기능을 적용해야 하는지 판단하는 감"을 익히는 것이다. - ---- - -## 공식 문서 출처 - -- [CEP](https://nightlies.apache.org/flink/flink-docs-stable/docs/libs/cep/) -- [Broadcast State](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/fault-tolerance/broadcast_state/) -- [Async I/O](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/operators/asyncio/) -- [Watermarks](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/event-time/generating_watermarks/) -- [Side Output](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/operators/process_function/#side-outputs) -- [Checkpoints & Savepoints](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/checkpoints/) -- [RocksDB Backend](https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/rocksdb/) -- [Flink SQL Gateway](https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/table/sql-gateway/) diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\263\240\352\270\211 \352\270\260\353\212\245/meta.json" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\263\240\352\270\211 \352\270\260\353\212\245/meta.json" deleted file mode 100644 index d4865f29..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\263\240\352\270\211 \352\270\260\353\212\245/meta.json" +++ /dev/null @@ -1,34 +0,0 @@ -{ - "title": "Flink 고급 기능", - "slug": "flink-advanced-features", - "description": "CEP, BroadcastState, Async I/O 심화, Watermark 튜닝 등 대규모 서비스를 위한 고급 기능을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Flink", - "CEP", - "Advanced" - ], - "featured": false, - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 8 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\270\260\353\263\270\352\260\234\353\205\220/index.mdx" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\270\260\353\263\270\352\260\234\353\205\220/index.mdx" deleted file mode 100644 index 4731909d..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\270\260\353\263\270\352\260\234\353\205\220/index.mdx" +++ /dev/null @@ -1,266 +0,0 @@ -## Flink? - -Flink는 "스트림 기반(Stateful Stream Processing) 엔진" -배치도 할 수 있지만, 본질은 스트림이고 배치조차 "유한한 스트림"으로 보는 철학을 가짐. - -**Spark** -- RDD 기반의 배치 중심 엔진에 스트리밍 기능이 붙어 있는 느낌(특히 Structured Streaming은 micro-batch 느낌이 강함) - -**Flink** -- 처음부터 "무한 스트림 + 상태(State)"를 제대로 처리하려고 설계된 엔진 - ---- - -## 스트림 vs 배치의 본질적 차이 - -### 배치 처리 - -**특징** -- 배치는 "모든 데이터가 다 모여 있다"는 가정이 깔려 있음. -- 예를 들어 1년 치 로그 파일을 모아서, 그 위에 쿼리를 한 번 날리는 방식임. -- 시간은 보통 "컬럼 값"으로만 이용할 뿐, "지금 들어오고 있는 데이터의 시간 흐름" 자체를 신경 쓰지 않음. - -**특성** -- 입력 데이터: 유한(finite) -- 처리 기준: 대부분 처리 시점(언제 돌리냐) -- 지연 데이터 개념이 약함: 이미 다 모인 후에 처리하니까 - -### 스트림 처리 - -**특징** -- 스트림은 "데이터가 끝없이 도착한다"는 가정입니다. -- 예를 들어 IoT 센서, Kafka 로그, 게임 이벤트 스트림처럼 계속 들어옵니다. -- 여기서 중요한 건 "데이터가 언제 발생했는지(event-time)"와 "언제 도착했는지(ingestion-time)"가 다를 수 있다는 점입니다. - -**특성** -- 입력 데이터: 이론적으로 무한(infinite) -- 처리 기준: 시간 개념이 핵심 (event-time, processing-time 등) -- 지연 데이터 처리: 늦게 도착했을 때 어떻게 할 것인지 정책이 필요 - -본질적으로 이런 차이점이 존재하지만, Flink는 "배치는 유한한 **스트림**이다" 라고 정의한다. 그래서 같은 엔진과 같은 API로 배치와 스트림을 모두 처리할 수 있다. - ---- - -## Flink Runtime 구조 -Flink Runtime의 구조는 JobManager, TaskManager, Slot 나누어져 있다. -각각의 역할은 다음과 같다. - -### JobManager - -**역할**: 컨트롤 플레인 - -**책임** -- Job Graph 생성, 최적화(Logical → Physical Plan) -- Task 배치와 스케줄링 -- Checkpoint 트리거 및 관리 -- 장애 발생 시 재시작 결정(어디부터, 어느 state로) - -### TaskManager - -**역할**: 실제 데이터 처리(워커 노드) - -내부에 여러 개의 Slot을 가지고 있고, 각 Slot이 하나의 서브태스크(Subtask)를 실행합니다. - -### Slot - -"논리적인 실행 단위"라고 보면 됩니다. - -예를 들어 parallelism 4인 Operator는 4개의 Subtask로 나뉘고, 각 Subtask가 Slot 하나씩을 점유합니다. - -하나의 TaskManager가 CPU, memory 리소스를 기반으로 여러 Slot을 가질 수 있습니다. - -![Flink Runtime 구조](/assets/posts/2025-11-28-flink-basics/image.png) - ---- - -## 실행 흐름 개념 - -개발자가 Flink Job을 제출하면 - -- Client가 Job을 JobManager에게 제출 -- JobManager가 JobGraph를 생성 후 ExecutionGraph로 변환 -- ExecutionGraph를 보고 각 TaskManager의 Slot에 Task를 분배 -- TaskManager들이 데이터를 주고받으면서 처리 - -> 💡 **중요 포인트** -> - JobManager는 "뇌", TaskManager는 "팔·다리"에 가깝움 -> - Slot 개수와 parallelism 설계가 성능과 비용에 직결됨 - ---- - -## 시간 개념 : Event-time, Processing-time, Ingestion-time - -Flink에서 시간은 스트림 의미를 결정하는 핵심입니다. - -### Processing-time - -- "현재 머신의 시계" 기준 -- 장점: 구현이 간단. 시스템 시간만 보면 됨 -- 단점: 이벤트가 늦게 도착해도, 시스템 시계 기준으로 윈도우가 닫혀버리므로 "발생 시점 기준 집계"가 어긋날 수 있음 - -### Event-time - -- "이벤트가 실제 발생한 시간" 기준 -- 보통 Kafka 메시지의 timestamp, 로그의 timestamp 필드를 사용 -- 장점: 지연 도착, 네트워크 지연이 있더라도 '발생 시점' 기준으로 정확한 집계가 가능 -- 단점: lateness(지연 도착)에 대한 정책과 watermarks 설계가 필요 - -### Ingestion-time - -- "Flink에 들어온 시점" 기준 (source에서 ingest된 순간) -- Processing-time의 단점을 조금 보완하지만, Event-time만큼 정교하진 않음 -- 실무에선 Event-time 또는 Processing-time을 주로 사용하고, ingestion-time은 애매한 중간 케이스 정도로 이해해도 됩니다. - ---- - -## 워터마크(Watermark)와 지연 데이터(Late Data) - -Event-time을 쓴다는 건 "늦게 도착하는 데이터"를 고려하겠다는 뜻입니다. - -Flink는 Watermark라는 개념으로 "이 시점까지는 더 이상 예전 이벤트가 들어오지 않았다고 가정해도 된다"를 표현합니다. - -### Watermark 기본 개념 - -Watermark는 보통 "현재까지 본 이벤트의 최대 event-time – 허용 지연 시간" 같은 형태로 설정합니다. - -데이터가 보통 5분 이내에 들어온다면 `watermark = maxEventTime – 5 minutes` - -Watermark가 특정 윈도우의 끝 시간을 지나치면, 그 윈도우를 "이제 닫아도 된다"고 판단하고 결과를 보냄. - -### Late Data(지연 도착 데이터) 처리 - -Watermark 이후에 들어온, 이미 닫힌 윈도우의 이벤트는 Late Data입니다. - -**Late Data에 대한 정책** -- 버린다 (drop) -- 별도 side output으로 뺀다 -- 허용 lateness를 추가로 더 준다 (allowedLateness) - ---- - -## Stateful Stream Processing 개념 - -Flink의 가장 큰 특징 중 하나가 "상태(State)를 가진 스트림 처리"입니다. - -### State란 무엇인가? - -간단히 말해 "연속되는 이벤트들을 처리하면서 필요한 중간값"입니다. - -**예시** -- 유저별 클릭 수 합계 -- 특정 키에 대한 최근 N개 이벤트 -- 윈도우 내의 집계 값 -- 일반 함수형 스트림 처리는 이벤트 하나만 보고 결과를 만들지만, Flink는 "이전까지의 정보"를 상태로 들고 있다가 함께 계산합니다. - -### Keyed State / Operator State - -**Keyed State** -- keyBy로 같은 키를 가진 이벤트들이 같은 Subtask로 라우팅됩니다. -- 각 키별로 독립된 State를 가집니다. (예: userId별 합계, deviceId별 최근 10개 이벤트 등) - -**Operator State** -키 수준이 아니라 오퍼레이터 전체 수준에서 관리하는 상태입니다. - -예시: source 연산자가 파일 오프셋을 기억하는 용도 등 - -### State Backend - -이 상태들을 메모리나 RocksDB 등에 저장하는 방식이 State Backend입니다. - -**예시** -- HashMapStateBackend: 메모리에 상태 저장(소규모, 빠름) -- RocksDBStateBackend(현재는 Changelog 기반 등으로 발전): 디스크에 저장(대량 상태 처리에 적합) - ---- - -## Checkpoint와 Savepoint - -아마 이 부분은 게임을 좋아하는 유저라면 익숙한 용어일 것 같다. 그 의미랑 비슷하다고 보면된다. - -Flink의 내결함성(fault-tolerance) 핵심이 바로 Checkpoint/Savepoint입니다. - -### Checkpoint - -주기적으로 Stream Job의 상태(state)와 오프셋을 **스냅샷** 떠서 저장함. - -주로 장애 복구용의 목적을 가짐. - -**예시** -- Kafka 오프셋, 키별 상태, 윈도우 상태 등을 통째로 스냅샷 - -**설정 요소** -- interval: 몇 초/분마다 체크포인트를 찍을지 -- timeout: 얼마나 오래 걸리면 실패로 간주할지 -- 최소 간격(min pause between checkpoints) - -### Savepoint - -Checkpoint와 비슷하지만 "운영자가 의도적으로" 찍는 스냅샷 - -Job 업그레이드의 목적을 가지고 수행함. - -코드 변경 후 상태를 이어받아 재시작 - -**특징** -- 보통 더 안정적인/오래 보관되는 저장소에 둠 -- 호환성 관리 필요 (state schema 변경 등) - -### 동작 원리 한 줄 설명 - -- JobManager가 체크포인트를 트리거 -- 각 TaskManager들이 현재까지 처리한 상태를 State Backend에 저장 -- 전체 Task들이 성공하면 "이 시점까지 완전히 처리된 상태"라는 커밋 포인트처럼 사용 - ---- - -## Exactly-once의 의미와 Flink 내부 구현 방식 - -많이들 "Flink는 exactly-once 보장"이라고 말하지만, 면접에서는 이 개념을 정확히 이해하고 있는지를 많이 봅니다. - -### Exactly-once의 실제 의미 - -- "각 이벤트가 논리적으로 정확히 한 번만 처리된 것처럼 보인다"는 의미입니다. -- 물리적으로는 다시 처리(replay)할 수도 있지만, 상태와 출력이 중복되지 않게 설계하는 것입니다. - -### Flink의 접근 방식 - -**입력 측면** -- Kafka 같은 source의 오프셋과 state를 함께 체크포인트에 저장합니다. -- 장애 발생 시, 체크포인트 시점의 state와 오프셋으로 되돌아가서 재실행합니다. - -**상태(state) 측면** -- 상태는 체크포인트 시점 기준으로 일관성(consistency)을 가집니다. - -**출력(sink) 측면** -- Sink가 idempotent하거나, 트랜잭셔널이어야 진정한 end-to-end exactly-once가 됩니다. -- 예시 - - Kafka sink: 트랜잭션 기반으로 exactly-once 가능 - - JDBC sink: Upsert(Primary Key 기준)로 idempotent하게 만들 수 있음 - ---- - -## 예상 면접 질문 및 답변 - -### Flink의 기본 관점 - -"저는 Flink를 스트림 퍼스트 엔진이라고 이해하고 있습니다. 배치도 처리하지만, 철학적으로는 배치를 유한한 스트림으로 보는 구조입니다. 그래서 Event-time, Watermark, Stateful Processing, Checkpoint 구조가 모두 무한 스트림과 장애 복구를 중심으로 설계되어 있습니다." - -### 스트림 vs 배치 - -"배치는 유한 데이터에 한 번 쿼리하는 느낌이라면, 스트림은 끝없이 들어오는 데이터에 대해 실시간으로, 특히 '언제 발생했는지(event-time)'를 기준으로 집계하는 모델이라고 이해하고 있습니다. Flink는 이 둘을 같은 실행 엔진 위에서 처리해서, 스트림과 배치를 통일된 방식으로 운영할 수 있습니다." - -### Runtime 구조 - -"실행 구조는 JobManager와 TaskManager, 그리고 Slot으로 이해합니다. JobManager는 계획 수립과 스케줄링, 체크포인트 관리를 담당하고, TaskManager는 실제 연산을 수행합니다. parallelism과 Slot 수를 어떻게 설계하느냐가 리소스 효율과 성능에 직결되기 때문에, 이 부분을 튜닝 포인트로 보고 있습니다." - -### 시간·워터마크 - -"시간은 Processing-time, Event-time, Ingestion-time으로 나뉘는데, 실무에서는 Event-time 중심으로 설계합니다. 지연 도착 문제를 해결하기 위해 Watermark를 사용해서 '이 시점 이전 이벤트는 대부분 도착했다'는 기준을 잡고, Late Data에 대해서는 drop이나 side output 같은 정책을 설정합니다." - -### 상태와 체크포인트 - -"Flink의 강점은 상태를 가진 스트림 처리입니다. keyBy 이후 각 키에 대한 상태를 관리하고, 이를 Checkpoint/Savepoint로 스냅샷 떠서 장애 시 동일한 지점에서 재시작할 수 있습니다. Checkpoint는 주기적 장애 복구용, Savepoint는 운영자가 코드 업그레이드나 마이그레이션을 위해 명시적으로 찍는 스냅샷으로 구분해서 사용합니다." - -### Exactly-once - -"마지막으로 exactly-once는 이벤트가 논리적으로 한 번만 처리된 것처럼 보이게 하는 모델입니다. Flink 내부적으로는 state와 source 오프셋을 함께 체크포인트하고, sink가 트랜잭션 또는 idempotent하게 동작해야 end-to-end exactly-once가 만족됩니다." diff --git "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\270\260\353\263\270\352\260\234\353\205\220/meta.json" "b/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\270\260\353\263\270\352\260\234\353\205\220/meta.json" deleted file mode 100644 index f82a606e..00000000 --- "a/posts/\355\224\214\353\247\201\355\201\254-\354\231\204\354\240\204\354\240\225\353\263\265/\352\270\260\353\263\270\352\260\234\353\205\220/meta.json" +++ /dev/null @@ -1,34 +0,0 @@ -{ - "title": "Flink 기본 개념", - "slug": "flink-basics", - "description": "Flink의 스트림 처리 철학, Runtime 구조, 시간 개념, Stateful 처리, Checkpoint, Exactly-once 보장 원리를 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "contentType": "essay", - "tags": [ - "Flink", - "Stream Processing", - "Data Engineering" - ], - "featured": false, - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 1 - }, - "visibility": "private", - "qualityReview": { - "philosophy": null, - "design": null, - "implementation": null, - "brandFit": null, - "clarity": 3.5, - "structure": 3.5, - "evidence": 3.5, - "usefulness": 4, - "originality": 2.5, - "polish": 3, - "reviewedAt": "2026-06-17", - "notes": "" - } -} diff --git "a/posts/\355\225\250\352\273\230-\354\236\220\353\235\274\352\270\260-\353\246\254\353\267\260/meta.json" "b/posts/\355\225\250\352\273\230-\354\236\220\353\235\274\352\270\260-\353\246\254\353\267\260/meta.json" index 34ae9432..f6357a23 100644 --- "a/posts/\355\225\250\352\273\230-\354\236\220\353\235\274\352\270\260-\353\246\254\353\267\260/meta.json" +++ "b/posts/\355\225\250\352\273\230-\354\236\220\353\235\274\352\270\260-\353\246\254\353\267\260/meta.json" @@ -5,14 +5,9 @@ "date": "2025-12-29", "category": "Life", "contentType": "review", - "tags": [ - "Career", - "Work", - "Book", - "성장" - ], + "tags": ["Career", "Work", "Book", "성장"], "featured": false, - "visibility": "private", + "visibility": "public", "qualityReview": { "philosophy": 3, "design": 2.5, @@ -24,7 +19,7 @@ "usefulness": 4, "originality": 3, "polish": 3.5, - "reviewedAt": "2026-07-13", - "notes": "인용 수치의 원문 페이지와 연구 출처를 보강하기 전까지 비공개" + "reviewedAt": "2026-07-20", + "notes": "작성자 승인으로 공개 전환. 인용 수치의 원문 페이지와 연구 출처는 후속 점검" } } diff --git a/public/assets/posts/2025-11-28-flink-basics/image.png b/public/assets/posts/2025-11-28-flink-basics/image.png deleted file mode 100644 index 2d2aebeb..00000000 Binary files a/public/assets/posts/2025-11-28-flink-basics/image.png and /dev/null differ diff --git a/search/index.ts b/search/index.ts deleted file mode 100644 index 3f6c64f5..00000000 --- a/search/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { getSearchActions } from './model/get-search-actions'; -export { getRecommendedSearchTerms } from './model/search-recommendations'; diff --git a/search/model/get-search-actions.test.ts b/search/model/get-search-actions.test.ts deleted file mode 100644 index d678926f..00000000 --- a/search/model/get-search-actions.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { getSearchActions } from './get-search-actions'; - -const mockTrackEvent = vi.fn(); - -vi.mock('@/infra/analytics/lib/analytics', () => ({ - AnalyticsEvents: { - click: 'click', - theme: 'theme', - view: 'view', - }, - trackEvent: (...args: unknown[]) => mockTrackEvent(...args), -})); - -describe('getSearchActions', () => { - it('returns searchable actions from posts', () => { - const actions = getSearchActions([ - { - slug: 'redis-basics', - title: 'Redis Basics', - category: 'Tech', - tags: ['Redis', 'Caching'], - description: 'Redis 기초 개념 정리', - }, - ]); - - const postAction = actions.find((action) => action.id === 'redis-basics'); - - expect(postAction).toBeDefined(); - expect(postAction?.name).toBe('Redis Basics'); - expect(postAction?.section).toBe('블로그 포스트'); - expect(postAction?.keywords).toContain('Redis'); - }); - - it('includes section navigation actions for scoped search', () => { - const actions = getSearchActions([]); - - expect(actions.map((action) => action.id)).toEqual([ - 'go-engineering', - 'go-life', - 'go-resume', - ]); - expect(actions[2].keywords).toContain('이력서'); - }); - - it('handles missing tag arrays without crashing', () => { - const actions = getSearchActions([ - { - slug: 'no-tags', - title: 'No Tags', - category: 'Life', - description: 'Tags missing post', - tags: undefined, - }, - ]); - - const postAction = actions.find((action) => action.id === 'no-tags'); - - expect(postAction?.keywords).toBe('No Tags Life Tags missing post'); - }); - - it('keeps post list order as provided', () => { - const actions = getSearchActions([ - { - slug: 'second', - title: 'B', - category: 'Tech', - tags: ['A'], - description: 'B', - }, - { - slug: 'first', - title: 'A', - category: 'Tech', - tags: ['A'], - description: 'A', - }, - ]); - - expect(actions.map((action) => action.id).slice(3)).toEqual([ - 'second', - 'first', - ]); - }); - - it('tracks and navigates when action is performed', () => { - mockTrackEvent.mockClear(); - - const action = getSearchActions([ - { - slug: 'redis-basics', - title: 'Redis Basics', - category: 'Tech', - tags: ['Redis'], - description: 'Redis 기초', - }, - ]).find((candidate) => candidate.id === 'redis-basics'); - - action?.perform?.(); - - expect(mockTrackEvent).toHaveBeenCalledWith('click', { - target: 'command_palette_result', - post_slug: 'redis-basics', - }); - expect(typeof action?.perform).toBe('function'); - expect(() => action?.perform?.()).not.toThrow(); - }); -}); diff --git a/search/model/get-search-actions.ts b/search/model/get-search-actions.ts deleted file mode 100644 index 82c8458e..00000000 --- a/search/model/get-search-actions.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Action } from 'kbar'; -import { FeedData } from '@/blog/model/types'; -import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; - -export type SearchablePost = Pick< - FeedData, - 'slug' | 'title' | 'category' | 'tags' | 'description' ->; - -interface NavigationActionSource { - id: string; - name: string; - href: string; - keywords: string; - subtitle: string; -} - -const navigationActions: NavigationActionSource[] = [ - { - id: 'go-engineering', - name: 'Tech', - href: '/engineering', - keywords: 'Tech Engineering 기술 글 시리즈', - subtitle: '기술 글과 시리즈 보기', - }, - { - id: 'go-life', - name: 'Life', - href: '/life', - keywords: 'Life 회고 에세이 일상', - subtitle: '회고와 에세이 보기', - }, - { - id: 'go-resume', - name: 'Resume', - href: '/resume', - keywords: 'Resume 이력서 경력 프로젝트', - subtitle: '경력과 프로젝트 보기', - }, -]; - -/** - * 전역 검색을 위한 액션 초기 데이터 생성 함수 - * 블로그 포스트를 검색할 수 있게 액션 객체 배열을 반환합니다. - */ -export const getSearchActions = (posts: SearchablePost[]): Action[] => { - const getSearchKeywords = (post: SearchablePost) => { - const normalizedTags = Array.isArray(post.tags) ? post.tags : []; - - return [post.title, post.category, ...normalizedTags, post.description] - .filter(Boolean) - .join(' '); - }; - - const postActions = posts.map((post) => ({ - id: post.slug, - name: post.title, - shortcut: [], - keywords: getSearchKeywords(post), - section: '블로그 포스트', - perform: () => { - trackEvent(AnalyticsEvents.click, { - target: 'command_palette_result', - post_slug: post.slug, - }); - window.location.assign(`/blog/${post.slug}`); - }, - subtitle: post.description, - })); - - const sectionActions = navigationActions.map((action) => ({ - id: action.id, - name: action.name, - shortcut: [], - keywords: action.keywords, - section: '섹션', - perform: () => { - trackEvent(AnalyticsEvents.click, { - target: 'command_palette_section', - destination: action.href, - }); - window.location.assign(action.href); - }, - subtitle: action.subtitle, - })); - - return [...sectionActions, ...postActions]; -}; diff --git a/search/model/search-recommendations.test.ts b/search/model/search-recommendations.test.ts deleted file mode 100644 index 6c0a7d59..00000000 --- a/search/model/search-recommendations.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getRecommendedSearchTerms } from './search-recommendations'; - -describe('getRecommendedSearchTerms', () => { - it('returns popular tags ordered by frequency', () => { - const terms = getRecommendedSearchTerms([ - { tags: ['Redis', '회고'] }, - { tags: ['Redis', 'Flink'] }, - { tags: ['회고'] }, - ]); - - expect(terms.slice(0, 3)).toEqual(['Redis', '회고', 'Flink']); - }); - - it('falls back to section terms when posts do not have tags', () => { - expect(getRecommendedSearchTerms([{ tags: [] }])).toEqual([ - 'Tech', - 'Life', - 'Resume', - ]); - }); - - it('trims empty tags and respects the requested limit', () => { - expect( - getRecommendedSearchTerms([{ tags: [' Redis ', '', 'Flink', '회고'] }], 2) - ).toHaveLength(2); - }); -}); diff --git a/search/model/search-recommendations.ts b/search/model/search-recommendations.ts deleted file mode 100644 index 2fa96d70..00000000 --- a/search/model/search-recommendations.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { FeedData } from '@/blog/model/types'; - -type SearchRecommendationSource = Pick; - -const FALLBACK_RECOMMENDATIONS = ['Tech', 'Life', 'Resume']; - -export function getRecommendedSearchTerms( - posts: SearchRecommendationSource[], - limit = 5 -): string[] { - const counts = new Map(); - const firstSeenIndexes = new Map(); - let nextIndex = 0; - - posts.forEach((post) => { - post.tags?.forEach((tag) => { - const term = tag.trim(); - - if (term.length === 0) { - return; - } - - if (!firstSeenIndexes.has(term)) { - firstSeenIndexes.set(term, nextIndex); - nextIndex += 1; - } - - counts.set(term, (counts.get(term) ?? 0) + 1); - }); - }); - - const recommendations = Array.from(counts.entries()) - .sort(([leftTerm, leftCount], [rightTerm, rightCount]) => { - if (leftCount !== rightCount) { - return rightCount - leftCount; - } - - return ( - (firstSeenIndexes.get(leftTerm) ?? 0) - - (firstSeenIndexes.get(rightTerm) ?? 0) - ); - }) - .map(([term]) => term) - .slice(0, limit); - - return recommendations.length > 0 - ? recommendations - : FALLBACK_RECOMMENDATIONS.slice(0, limit); -} diff --git a/search/ui/components/CommandPalette/CommandPalette.module.css b/search/ui/components/CommandPalette/CommandPalette.module.css deleted file mode 100644 index 6edb2dc6..00000000 --- a/search/ui/components/CommandPalette/CommandPalette.module.css +++ /dev/null @@ -1,350 +0,0 @@ -.positioner { - display: flex; - align-items: flex-start; - justify-content: center; - padding: max(var(--space-3), calc(env(safe-area-inset-top) + var(--space-3))) - var(--space-3) var(--space-3); - background: rgba(15, 23, 42, 0.28); - backdrop-filter: blur(10px); - z-index: var(--z-modal); -} - -.animator { - max-width: 640px; - width: min(640px, 100%); - max-height: min(76vh, 640px); - display: flex; - flex-direction: column; - background: var(--color-bg-primary); - color: var(--color-text-primary); - border-radius: var(--radius-lg); - overflow: hidden; - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24); - border: 1px solid color-mix(in srgb, var(--color-border) 86%, transparent); -} - -.searchWrapper { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--color-border); - background: color-mix( - in srgb, - var(--color-bg-secondary) 58%, - var(--color-bg-primary) - ); -} - -.search { - flex: 1; - min-width: 0; - padding: 0; - outline: none; - border: none; - box-shadow: none; - appearance: none; - -webkit-appearance: none; - background: transparent; - color: var(--color-text-primary); - font-size: var(--text-base); - font-family: var(--font-sans); -} - -.search:focus, -.search:focus-visible, -.search:active { - outline: none; - border: none; - box-shadow: none; -} - -.closeButton { - width: 2.75rem; - height: 2.75rem; - border-radius: var(--radius-full); - background: color-mix( - in srgb, - var(--color-bg-primary) 78%, - var(--color-bg-secondary) - ); - border: 1px solid var(--color-border); - color: var(--color-text-secondary); - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.closeButton:hover, -.closeButton:focus-visible { - border-color: var(--color-toss-blue); - color: var(--color-toss-blue); - background: var(--color-bg-primary); - outline: 2px solid var(--color-toss-blue); - outline-offset: 2px; -} - -.scopeBar { - display: flex; - gap: var(--space-2); - padding: var(--space-3) var(--space-4); - overflow-x: auto; - border-bottom: 1px solid var(--color-border); -} - -.scopeButton { - min-height: 2.5rem; - padding: 0 var(--space-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-full); - background: var(--color-bg-primary); - color: var(--color-text-secondary); - font-size: var(--text-sm); - font-weight: var(--font-medium); - white-space: nowrap; - cursor: pointer; - transition: - background-color var(--duration-150) var(--ease-default), - border-color var(--duration-150) var(--ease-default), - color var(--duration-150) var(--ease-default); -} - -.scopeButton:hover, -.scopeButton:focus-visible { - border-color: var(--color-toss-blue); - color: var(--color-toss-blue); - outline: 2px solid var(--color-toss-blue); - outline-offset: 2px; -} - -.scopeButtonActive { - border-color: var(--color-toss-blue); - background: var(--color-toss-blue); - color: white; -} - -.scopeButtonActive:hover, -.scopeButtonActive:focus-visible { - color: white; -} - -.recommendationPanel { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--color-border); -} - -.recommendationLabel { - flex-shrink: 0; - font-size: var(--text-xs); - font-weight: var(--font-semibold); - color: var(--color-text-tertiary); -} - -.results { - max-height: min(62vh, 500px); - overflow-y: auto; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; -} - -.sectionHeader { - padding: var(--space-3) var(--space-5) var(--space-1); - position: sticky; - top: 0; - z-index: 1; - backdrop-filter: blur(6px); - background: color-mix( - in srgb, - var(--color-bg-primary) 90%, - var(--color-bg-secondary) - ); - font-size: var(--text-xs); - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-tertiary); - font-weight: var(--font-medium); -} - -.resultItem { - padding: var(--space-3) var(--space-5); - min-height: 3.25rem; - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - transition: all var(--duration-150) var(--ease-default); -} - -.resultItemActive { - background: var(--color-bg-secondary); - border-left: 3px solid var(--color-toss-blue); - padding-left: calc(var(--space-5) - 3px); -} - -.resultContent { - display: flex; - flex-direction: column; -} - -.resultName { - font-size: var(--text-base); - font-weight: var(--font-medium); - color: var(--color-text-primary); -} - -.resultSubtitle { - font-size: var(--text-sm); - color: var(--color-text-tertiary); -} - -.resultShortcuts { - display: flex; - gap: var(--space-1); -} - -.resultShortcut { - padding: 2px 6px; - background: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - font-size: var(--text-xs); - color: var(--color-text-secondary); -} - -.noResultWrapper { - display: flex; - flex-direction: column; - gap: var(--space-3); - padding: var(--space-5); -} - -.noResultTitle { - font-size: var(--text-md); - font-weight: var(--font-semibold); - color: var(--color-text-primary); -} - -.noResultDescription { - font-size: var(--text-sm); - color: var(--color-text-secondary); - line-height: var(--leading-relaxed); -} - -.suggestionGroup { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin-top: var(--space-1); -} - -.suggestionButton { - min-height: 2.75rem; - border: 1px solid var(--color-border); - background: var(--color-bg-secondary); - color: var(--color-text-primary); - font-size: var(--text-sm); - font-weight: var(--font-medium); - border-radius: var(--radius-full); - padding: 0 var(--space-4); - cursor: pointer; - transition: all var(--duration-150) var(--ease-default); -} - -.suggestionButton:hover, -.suggestionButton:focus-visible { - border-color: var(--color-toss-blue); - color: var(--color-toss-blue); - outline: 2px solid var(--color-toss-blue); - outline-offset: 2px; -} - -.recoveryActions { - display: flex; - gap: var(--space-2); - margin-top: var(--space-2); -} - -.recoveryButton { - min-height: 2.75rem; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - padding: 0 var(--space-4); - background: transparent; - color: var(--color-text-secondary); - font-size: var(--text-sm); - text-decoration: none; - cursor: pointer; - transition: all var(--duration-150) var(--ease-default); -} - -.recoveryButton:hover, -.recoveryButton:focus-visible { - border-color: var(--color-toss-blue); - color: var(--color-toss-blue); - outline: 2px solid var(--color-toss-blue); - outline-offset: 2px; -} - -@media (max-width: 767px) { - .positioner { - padding: max( - var(--space-2), - calc(env(safe-area-inset-top) + var(--space-2)) - ) - var(--space-2) var(--space-2); - } - - .animator { - width: calc(100vw - 1rem); - max-height: calc(100dvh - env(safe-area-inset-top) - 1rem); - border-radius: var(--radius-md); - } - - .searchWrapper { - padding: var(--space-3); - } - - .scopeBar { - padding: var(--space-3); - } - - .scopeButton { - min-height: 2.75rem; - } - - .recommendationPanel { - align-items: flex-start; - flex-direction: column; - gap: var(--space-2); - padding: var(--space-3); - } - - .search { - font-size: var(--text-md); - } - - .results { - max-height: calc(100dvh - env(safe-area-inset-top) - 6.5rem); - } - - .sectionHeader, - .resultItem { - padding-left: var(--space-4); - padding-right: var(--space-4); - } - - .resultItemActive { - padding-left: calc(var(--space-4) - 3px); - } - - .recoveryActions { - flex-direction: column; - } -} diff --git a/search/ui/components/CommandPalette/CommandPalette.tsx b/search/ui/components/CommandPalette/CommandPalette.tsx deleted file mode 100644 index 1c63ddbe..00000000 --- a/search/ui/components/CommandPalette/CommandPalette.tsx +++ /dev/null @@ -1,249 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { - KBarPortal, - KBarPositioner, - KBarAnimator, - KBarSearch, - KBarResults, - useKBar, - useMatches, -} from 'kbar'; -import styles from './CommandPalette.module.css'; -import type { SearchablePost } from '@/search/model/get-search-actions'; -import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; -import { getRecommendedSearchTerms } from '@/search/model/search-recommendations'; - -type SearchScopeId = 'all' | 'tech' | 'life' | 'resume'; - -interface SearchScope { - id: SearchScopeId; - label: string; - query: string; -} - -interface CommandPaletteProps { - posts?: SearchablePost[]; -} - -const SEARCH_SCOPES: SearchScope[] = [ - { id: 'all', label: '전체', query: '' }, - { id: 'tech', label: 'Tech', query: 'Tech' }, - { id: 'life', label: 'Life', query: 'Life' }, - { id: 'resume', label: 'Resume', query: 'Resume' }, -]; - -function getActiveSearchScope(normalizedQuery: string): SearchScopeId { - const matchedScope = SEARCH_SCOPES.find( - (scope) => scope.query.toLowerCase() === normalizedQuery.toLowerCase() - ); - - return matchedScope?.id ?? 'all'; -} - -export const CommandPalette = ({ posts = [] }: CommandPaletteProps) => { - const { query, searchQuery } = useKBar((state) => ({ - searchQuery: state.searchQuery, - })); - const normalizedQuery = searchQuery.trim(); - const activeScope = getActiveSearchScope(normalizedQuery); - const recommendedTerms = React.useMemo( - () => getRecommendedSearchTerms(posts), - [posts] - ); - - return ( - - - -
- - -
- query.setSearch(scope.query)} - /> - {normalizedQuery.length === 0 && ( -
-

추천 키워드

- -
- )} - -
-
-
- ); -}; - -function SearchScopeBar({ - activeScope, - onSelect, -}: { - activeScope: SearchScopeId; - onSelect: (scope: SearchScope) => void; -}) { - return ( -
- {SEARCH_SCOPES.map((scope) => { - const isActive = scope.id === activeScope; - - return ( - - ); - })} -
- ); -} - -function SearchSuggestionButtons({ - terms, - onSelect, -}: { - terms: string[]; - onSelect: (term: string) => void; -}) { - return ( -
- {terms.map((keyword) => ( - - ))} -
- ); -} - -function RenderResults({ recommendedTerms }: { recommendedTerms: string[] }) { - const { results } = useMatches(); - const { query, searchQuery } = useKBar((state) => ({ - searchQuery: state.searchQuery, - })); - const normalizedQuery = searchQuery.trim(); - const resultCount = results.filter((item) => typeof item !== 'string').length; - const hasResults = resultCount > 0; - const lastTrackedRef = React.useRef(''); - - React.useEffect(() => { - if (normalizedQuery.length < 2) { - return; - } - - const trackKey = `${normalizedQuery}:${resultCount}`; - if (lastTrackedRef.current === trackKey) { - return; - } - - const timer = window.setTimeout(() => { - trackEvent(AnalyticsEvents.search, { - query: normalizedQuery, - result_count: resultCount, - }); - - if (resultCount === 0) { - trackEvent(AnalyticsEvents.error, { - source: 'command_palette_no_result', - query: normalizedQuery, - }); - } - - lastTrackedRef.current = trackKey; - }, 250); - - return () => window.clearTimeout(timer); - }, [normalizedQuery, resultCount]); - - if (normalizedQuery.length > 0 && !hasResults) { - return ( -
-

검색 결과가 없습니다

-

- 다른 키워드로 검색하거나 추천 키워드를 선택해 보세요. -

- -
- - - Engineering 보기 - -
-
- ); - } - - return ( -
- - typeof item === 'string' ? ( -
{item}
- ) : ( -
-
-
{item.name}
- {item.subtitle && ( -
{item.subtitle}
- )} -
- {null} -
- ) - } - /> -
- ); -} diff --git a/search/ui/components/CommandPalette/index.ts b/search/ui/components/CommandPalette/index.ts deleted file mode 100644 index 01f6b822..00000000 --- a/search/ui/components/CommandPalette/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './CommandPalette'; diff --git a/search/ui/components/KBarProvider.tsx b/search/ui/components/KBarProvider.tsx deleted file mode 100644 index cb4a9bdb..00000000 --- a/search/ui/components/KBarProvider.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client'; - -import { KBarProvider as KBarProviderLib } from 'kbar'; -import { CommandPalette } from '@/search/ui/components/CommandPalette'; -import { getSearchActions } from '@/search/model/get-search-actions'; -import { useMemo, type ReactNode } from 'react'; -import type { SearchablePost } from '@/search/model/get-search-actions'; - -interface KBarProviderProps { - children: ReactNode; - posts?: SearchablePost[]; -} - -export default function KBarProvider({ - children, - posts = [], -}: KBarProviderProps) { - const actions = useMemo(() => getSearchActions(posts), [posts]); - - return ( - - {children} - - - ); -} diff --git a/site/footer/Footer/Footer.tsx b/site/footer/Footer/Footer.tsx index 75ac39f2..bdb4c6d3 100644 --- a/site/footer/Footer/Footer.tsx +++ b/site/footer/Footer/Footer.tsx @@ -7,7 +7,7 @@ const socialLinks = [ export default function Footer() { return ( -