diff --git a/blog/model/heading.ts b/blog/model/heading.ts new file mode 100644 index 0000000..7a79ccc --- /dev/null +++ b/blog/model/heading.ts @@ -0,0 +1,64 @@ +const MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\([^)]*\)/g; +const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\([^)]*\)/g; +const INLINE_CODE_REGEX = /(`+)([^`]*?)\1/g; +const HTML_TAG_REGEX = /<\/?[A-Za-z][^>]*>/g; +const ESCAPED_CHARACTER_REGEX = /\\([\\`*_[\]{}()#+\-.!<>|])/g; +const TRAILING_HEADING_MARKER_REGEX = /\s+#+\s*$/; + +export function normalizeHeadingText(text: string): string { + let normalizedText = text.trim(); + + if (!normalizedText) { + return ''; + } + + normalizedText = normalizedText + .replace(MARKDOWN_IMAGE_REGEX, '$1') + .replace(MARKDOWN_LINK_REGEX, '$1') + .replace(INLINE_CODE_REGEX, '$2') + .replace(HTML_TAG_REGEX, '') + .replace(TRAILING_HEADING_MARKER_REGEX, ''); + + let previousText: string | null = null; + while (normalizedText !== previousText) { + previousText = normalizedText; + normalizedText = normalizedText + .replace(/(\*\*|__)(.*?)\1/g, '$2') + .replace(/(\*|_)(.*?)\1/g, '$2') + .replace(/~~(.*?)~~/g, '$1'); + } + + return normalizedText + .replace(ESCAPED_CHARACTER_REGEX, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +function buildHeadingSlug(text: string): string { + return normalizeHeadingText(text) + .toLowerCase() + .replace( + /[^\w\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uD7B0-\uD7FF\s-]/g, + '' + ) + .trim() + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); +} + +export function createHeadingIdGenerator() { + const idCounts: Record = {}; + + return (text: string): string | null => { + const baseId = buildHeadingSlug(text); + + if (!baseId) { + return null; + } + + const count = idCounts[baseId] ?? 0; + idCounts[baseId] = count + 1; + + return count === 0 ? baseId : `${baseId}-${count}`; + }; +} diff --git a/blog/services/markdown-parser.test.ts b/blog/services/markdown-parser.test.ts index d8f7ba4..ad6bb34 100644 --- a/blog/services/markdown-parser.test.ts +++ b/blog/services/markdown-parser.test.ts @@ -29,6 +29,38 @@ describe('parseHeadingsFromMdx', () => { }); }); + it('normalizes markdown-heavy headings and skips non-renderable cases', () => { + const mdx = ` +### **๐Ÿš€ ์„ฑ๋Šฅ ์ฆ๋ช… (๋กœ์ปฌ ๋ฒค์น˜๋งˆํฌ)** +### [Redis](https://redis.io) \`Pipeline\` +### **๐ŸŽฏ** +\`\`\`md +## code block heading +\`\`\` +### **๐Ÿš€ ์„ฑ๋Šฅ ์ฆ๋ช… (๋กœ์ปฌ ๋ฒค์น˜๋งˆํฌ)** +`; + + const headings = parseHeadingsFromMdx(mdx); + + expect(headings).toEqual([ + { + id: '์„ฑ๋Šฅ-์ฆ๋ช…-๋กœ์ปฌ-๋ฒค์น˜๋งˆํฌ', + text: '๐Ÿš€ ์„ฑ๋Šฅ ์ฆ๋ช… (๋กœ์ปฌ ๋ฒค์น˜๋งˆํฌ)', + level: 3, + }, + { + id: 'redis-pipeline', + text: 'Redis Pipeline', + level: 3, + }, + { + id: '์„ฑ๋Šฅ-์ฆ๋ช…-๋กœ์ปฌ-๋ฒค์น˜๋งˆํฌ-1', + text: '๐Ÿš€ ์„ฑ๋Šฅ ์ฆ๋ช… (๋กœ์ปฌ ๋ฒค์น˜๋งˆํฌ)', + level: 3, + }, + ]); + }); + it('returns an empty array for invalid content', () => { expect(parseHeadingsFromMdx('')).toEqual([]); }); diff --git a/blog/services/markdown-parser.ts b/blog/services/markdown-parser.ts index b43a0cf..5346b15 100644 --- a/blog/services/markdown-parser.ts +++ b/blog/services/markdown-parser.ts @@ -1,20 +1,11 @@ +import { + createHeadingIdGenerator, + normalizeHeadingText, +} from '@/blog/model/heading'; import { getFolderSlug, type TocItem } from '@/blog/services/post-repository'; import fs from 'fs'; import path from 'path'; -// ํ—ค๋”ฉ ํ…์ŠคํŠธ๋ฅผ ID๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํ•จ์ˆ˜ -function generateHeadingId(text: string): string { - return text - .toLowerCase() - .replace( - /[^\w\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uD7B0-\uD7FF\s-]/g, - '' - ) // ํ•œ๊ธ€, ์˜๋ฌธ, ์ˆซ์ž, ๊ณต๋ฐฑ, ํ•˜์ดํ”ˆ ์ œ์™ธ ์ œ๊ฑฐ (ํŠน์ˆ˜๋ฌธ์ž/์ด๋ชจ์ง€ ์ œ๊ฑฐ) - .trim() - .replace(/\s+/g, '-') // ๊ณต๋ฐฑ์„ ํ•˜์ดํ”ˆ์œผ๋กœ - .replace(/-+/g, '-'); // ์—ฐ์†๋œ ํ•˜์ดํ”ˆ ํ•˜๋‚˜๋กœ -} - // MDX ์†Œ์Šค์—์„œ ํ—ค๋”ฉ ํŒŒ์‹ฑ (๋ Œ๋”๋ง๋œ HTML์ด ์•„๋‹Œ ์›๋ณธ MDX์—์„œ) export function parseHeadingsFromMdx(mdxContent: string): TocItem[] { try { @@ -23,29 +14,51 @@ export function parseHeadingsFromMdx(mdxContent: string): TocItem[] { return []; } - // Regex to match markdown headings (## Heading, ### Heading, etc.) - const headingRegex = /^(#{1,6})\s+(.+)$/gm; const tocItems: TocItem[] = []; - const idCounts: Record = {}; + const nextHeadingId = createHeadingIdGenerator(); + const lines = mdxContent.split(/\r?\n/); + let activeFence: { marker: '`' | '~'; length: number } | null = null; + + for (const line of lines) { + const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line); - let match; - while ((match = headingRegex.exec(mdxContent)) !== null) { - const level = match[1].length; // Number of # symbols - const text = match[2].trim(); + if (fenceMatch) { + const fenceMarker = fenceMatch[1][0] as '`' | '~'; + const fenceLength = fenceMatch[1].length; + + if (!activeFence) { + activeFence = { + marker: fenceMarker, + length: fenceLength, + }; + } else if ( + activeFence.marker === fenceMarker && + fenceLength >= activeFence.length + ) { + activeFence = null; + } + + continue; + } + + if (activeFence) { + continue; + } + + const headingMatch = /^(#{1,6})\s+(.+)$/.exec(line); + + if (!headingMatch) { + continue; + } - if (!text) continue; + const level = headingMatch[1].length; + const text = normalizeHeadingText(headingMatch[2]); + const id = nextHeadingId(text); - // Generate unique ID - let id = generateHeadingId(text); - if (idCounts[id] !== undefined) { - const count = idCounts[id]; - idCounts[id] = count + 1; - id = `${id}-${count}`; - } else { - idCounts[id] = 1; + if (!text || !id) { + continue; } - // Flat structure - just push all headings tocItems.push({ id, text, diff --git a/blog/ui/components/TableOfContents.test.tsx b/blog/ui/components/TableOfContents.test.tsx index 2c56d52..c299188 100644 --- a/blog/ui/components/TableOfContents.test.tsx +++ b/blog/ui/components/TableOfContents.test.tsx @@ -1,19 +1,14 @@ import { fireEvent, render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { setupScrollToMock } from '@/shared/testing/dom-mocks'; import { TableOfContents } from './TableOfContents'; describe('TableOfContents', () => { - it('renders items and scrolls/hashes when clicked', () => { - setupScrollToMock(); - + it('renders native hash links and observes headings', () => { const item = { id: 'section-1', text: '์ฒซ๋ฒˆ์งธ ์„น์…˜', level: 2 }; const header = document.createElement('h2'); header.id = item.id; - header.getBoundingClientRect = () => ({ top: 320 } as DOMRect); document.body.appendChild(header); - window.history.replaceState = vi.fn(); const observeSpy = vi.fn(); const disconnectSpy = vi.fn(); @@ -29,23 +24,19 @@ describe('TableOfContents', () => { window.IntersectionObserver = MockIntersectionObserver as unknown as typeof window.IntersectionObserver; - const { getByRole } = render(); + const { container, getByRole } = render(); - const button = getByRole('button', { name: '์ฒซ๋ฒˆ์งธ ์„น์…˜' }); - fireEvent.click(button); + const link = getByRole('link', { name: '์ฒซ๋ฒˆ์งธ ์„น์…˜' }); + fireEvent.click(link); - expect(button).toHaveStyle({ + expect(container.querySelector('nav')).toBeNull(); + expect(document.body.querySelector('nav')).not.toHaveClass('bottom-8'); + expect(document.body.querySelector('nav > div')).not.toHaveClass('h-full'); + expect(document.body.querySelector('ul')).toHaveClass('m-0', 'p-0'); + expect(link).toHaveAttribute('href', `#${item.id}`); + expect(link).toHaveStyle({ fontFamily: 'var(--font-sans-emoji)', }); expect(observeSpy).toHaveBeenCalledWith(header); - expect(window.history.replaceState).toHaveBeenCalledWith( - null, - '', - `#${item.id}` - ); - expect(window.scrollTo).toHaveBeenCalledWith({ - top: 220, - behavior: 'smooth', - }); }); }); diff --git a/blog/ui/components/TableOfContents/TableOfContents.tsx b/blog/ui/components/TableOfContents/TableOfContents.tsx index 0164c0f..6f07677 100644 --- a/blog/ui/components/TableOfContents/TableOfContents.tsx +++ b/blog/ui/components/TableOfContents/TableOfContents.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; import { clsx } from 'clsx'; interface TocItem { @@ -15,8 +16,17 @@ interface TableOfContentsProps { export default function TableOfContents({ items }: TableOfContentsProps) { const [activeId, setActiveId] = useState(''); + const [portalRoot, setPortalRoot] = useState(null); useEffect(() => { + setPortalRoot(document.body); + }, []); + + useEffect(() => { + if (items.length === 0) { + return; + } + const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { @@ -38,35 +48,23 @@ export default function TableOfContents({ items }: TableOfContentsProps) { return () => observer.disconnect(); }, [items]); - const handleClick = (id: string) => { - const element = document.getElementById(id); - if (element) { - const yOffset = -100; - const y = - element.getBoundingClientRect().top + window.pageYOffset + yOffset; - window.scrollTo({ top: y, behavior: 'smooth' }); - - // Update URL hash without jumping and without adding to history - window.history.replaceState(null, '', `#${id}`); - } - }; - - if (items.length === 0) return null; + if (items.length === 0 || !portalRoot) return null; - return ( + return createPortal( + , + portalRoot ); } diff --git a/blog/ui/mdx/components.test.tsx b/blog/ui/mdx/components.test.tsx new file mode 100644 index 0000000..cb2b1af --- /dev/null +++ b/blog/ui/mdx/components.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { getMDXComponents } from './components'; + +describe('getMDXComponents', () => { + it('assigns normalized heading ids from rendered text', () => { + const { h3: H3, h4: H4 } = getMDXComponents({}); + + if (!H3 || !H4) { + throw new Error('Expected heading components to be defined'); + } + + render( + <> +

+ + ๐Ÿš€ ์„ฑ๋Šฅ ์ฆ๋ช… k6 + +

+

๐Ÿš€ ์„ฑ๋Šฅ ์ฆ๋ช… k6

+

ํ•˜์œ„ ์„น์…˜

+ + ); + + const headings = screen.getAllByRole('heading', { + name: '๐Ÿš€ ์„ฑ๋Šฅ ์ฆ๋ช… k6', + }); + + expect(headings).toHaveLength(2); + expect(headings[0]).toHaveAttribute('id', '์„ฑ๋Šฅ-์ฆ๋ช…-k6'); + expect(headings[1]).toHaveAttribute('id', '์„ฑ๋Šฅ-์ฆ๋ช…-k6-1'); + expect(screen.getByRole('heading', { name: 'ํ•˜์œ„ ์„น์…˜' })).toHaveAttribute( + 'id', + 'ํ•˜์œ„-์„น์…˜' + ); + }); +}); diff --git a/blog/ui/mdx/components.tsx b/blog/ui/mdx/components.tsx index b73d0a7..7f629de 100644 --- a/blog/ui/mdx/components.tsx +++ b/blog/ui/mdx/components.tsx @@ -1,3 +1,4 @@ +import { createHeadingIdGenerator } from '@/blog/model/heading'; import type { MDXComponents } from 'mdx/types'; import { isValidElement, @@ -55,31 +56,60 @@ function extractText(node: ReactNode): string { return ''; } +function mergeClassName(baseClassName: string, className?: string): string { + return className ? `${baseClassName} ${className}` : baseClassName; +} + export function getMDXComponents(components: MDXComponents): MDXComponents { + const nextHeadingId = createHeadingIdGenerator(); + const createHeading = ( + tag: T, + baseClassName: string + ) => { + const Heading = (props: ComponentPropsWithoutRef) => { + const Tag = tag; + const generatedId = nextHeadingId(extractText(props.children)); + + return ( + + {props.children} + + ); + }; + + Heading.displayName = `MdxHeading(${tag})`; + + return Heading; + }; + return { - h1: (props) => ( -

- {props.children} -

+ h1: createHeading( + 'h1', + 'text-3xl font-bold mt-16 mb-8 text-[var(--color-grey-900)]' ), - h2: (props) => ( -

- {props.children} -

+ h2: createHeading( + 'h2', + 'mt-12 mb-6 text-2xl font-bold text-[var(--color-grey-900)]' ), - h3: (props) => ( -

- {props.children} -

+ h3: createHeading( + 'h3', + 'mt-8 mb-4 text-xl font-bold text-[var(--color-grey-900)]' + ), + h4: createHeading( + 'h4', + 'mt-8 mb-4 text-lg font-bold text-[var(--color-grey-900)]' + ), + h5: createHeading( + 'h5', + 'mt-8 mb-4 text-base font-bold text-[var(--color-grey-900)]' + ), + h6: createHeading( + 'h6', + 'mt-8 mb-4 text-base font-bold text-[var(--color-grey-900)]' ), p: (props) => (

Redis -> API ๊ฒฝ๋กœ์—์„œ ๋ถˆํ•„์š”ํ•œ ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™” ๋น„์šฉ์ด ๊ณ„์† ๋ฐœ์ƒํ–ˆ๋‹ค. -๊ฐ€๋งŒํžˆ ์ƒ๊ฐํ•ด ๋ณด๋‹ˆ API๋ฅผ ์กฐํšŒํ•˜๋Š” ์ฃผ์š” ๊ณ ๊ฐ์„ ML ํŒ€์œผ๋กœ ๊ฐ€์ •ํ–ˆ๋Š”๋ฐ, ๋งŒ์•ฝ ์‚ฌ๋‚ด ํŒ€์—์„œ ClickHouse๋กœ ์ง์ ‘ ์กฐํšŒํ•˜๋Š” ๊ฒƒ์ด ๊ฐ€๋Šฅํ•˜๋‹ค๋ฉด Redis์™€ Serving API๋ฅผ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ๊ฒ ๋‹ค๋Š” ํŒ๋‹จ์ด ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. +API๋ฅผ ์กฐํšŒํ•˜๋Š” ์ฃผ์š” ์‚ฌ์šฉ์ž๋ฅผ ML ํŒ€์œผ๋กœ ๊ฐ€์ •ํ–ˆ๋Š”๋ฐ, ์‚ฌ๋‚ด ํŒ€์—์„œ ClickHouse๋กœ ์ง์ ‘ ์กฐํšŒํ•˜๋Š” ๊ฒƒ์ด ๊ฐ€๋Šฅํ•˜๋‹ค๋ฉด Redis์™€ Serving API๋ฅผ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ๊ฒ ๋‹ค๊ณ  ํŒ๋‹จํ–ˆ๋‹ค. -์ด ์ƒ๊ฐ์„ ๊ฐ€์ง€๊ณ  ์žˆ๋˜ ์ค‘, ๋Œ€๊ธฐ์—…์—์„œ ๊ทผ๋ฌดํ•˜๋Š” ์นœ๊ตฌ์—๊ฒŒ ๊ตฌ์ถ•ํ•œ ์‹œ์Šคํ…œ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์„ ๊ธฐํšŒ๊ฐ€ ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. ๊ทธ ๊ณผ์ •์—์„œ Redis์™€ Serving API๊ฐ€ ๊ผญ ํ•„์š”ํ•œ์ง€, ์ด ์‹œ์Šคํ…œ์—์„œ ๊ณ ๋ คํ•˜๋Š” ์ƒํ’ˆ ์ˆ˜๋Š” ์–ผ๋งˆ์ธ์ง€, Redis HashTable ํŠน์„ฑ์ƒ ๋ฆฌ์‚ฌ์ด์ง• ์ž‘์—…์ด๋‚˜ ํ‚ค ์—…๋ฐ์ดํŠธ๊ฐ€ ์ผ์–ด๋‚  ๋•Œ ์กฐํšŒ ์š”์ฒญ์ด ๋™์‹œ์— ๋ฐœ์ƒํ•œ๋‹ค๋ฉด ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ์ƒ๊ธธ ์ˆ˜ ์žˆ๋Š”์ง€ ๋“ฑ ์—ฌ๋Ÿฌ ์งˆ๋ฌธ์„ ํ†ตํ•ด Redis์˜ ์กด์žฌ ์ž์ฒด์— ๋Œ€ํ•œ ๊นŠ์€ ๊ณ ๋ฏผ์„ ํ•  ์ˆ˜ ์žˆ์—ˆ๋˜ ์‹œ๊ฐ„์„ ๊ฐ€์กŒ์Šต๋‹ˆ๋‹ค. +์ด ์ƒ๊ฐ์„ ๊ฐ€์ง€๊ณ  ์žˆ๋˜ ์ค‘, ๋Œ€๊ธฐ์—…์—์„œ ๊ทผ๋ฌดํ•˜๋Š” ์นœ๊ตฌ์—๊ฒŒ ๊ตฌ์ถ•ํ•œ ์‹œ์Šคํ…œ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์„ ๊ธฐํšŒ๊ฐ€ ์ƒ๊ฒผ๋‹ค. ๊ทธ ๊ณผ์ •์—์„œ Redis์™€ Serving API๊ฐ€ ๊ผญ ํ•„์š”ํ•œ์ง€, ์ด ์‹œ์Šคํ…œ์—์„œ ๊ณ ๋ คํ•˜๋Š” ์ƒํ’ˆ ์ˆ˜๋Š” ์–ผ๋งˆ์ธ์ง€, Redis HashTable ํŠน์„ฑ์ƒ ๋ฆฌ์‚ฌ์ด์ง• ์ž‘์—…์ด๋‚˜ ํ‚ค ์—…๋ฐ์ดํŠธ๊ฐ€ ์ผ์–ด๋‚  ๋•Œ ์กฐํšŒ ์š”์ฒญ์ด ๋™์‹œ์— ๋ฐœ์ƒํ•˜๋ฉด ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ์ƒ๊ธธ ์ˆ˜ ์žˆ๋Š”์ง€ ์งˆ๋ฌธ์„ ๋ฐ›์•˜๋‹ค. ์ด ํ”ผ๋“œ๋ฐฑ์„ ๊ณ„๊ธฐ๋กœ Redis์˜ ์กด์žฌ ์ด์œ ๋ฅผ ๋‹ค์‹œ ๊ฒ€ํ† ํ–ˆ๋‹ค. -์ œ๊ฐ€ ์„ค๊ณ„ํ•œ ์˜๋„์— ๋Œ€ํ•ด ์„œ๋กœ ๋…ผ์˜ํ–ˆ๊ณ , ๊ฒฐ๋ก ์ ์œผ๋กœ ๋‘ ๊ฐ€์ง€ ๋Œ€์•ˆ์„ ์ œ์‹œํ•ด ์ฃผ์—ˆ์Šต๋‹ˆ๋‹ค. +์„ค๊ณ„ ์˜๋„๋ฅผ ํ•จ๊ป˜ ๋…ผ์˜ํ–ˆ๊ณ , ๊ฒฐ๋ก ์ ์œผ๋กœ ๋‘ ๊ฐ€์ง€ ๋Œ€์•ˆ์ด ๋‚˜์™”๋‹ค. 1. **์ง€์—ฐ ์—†์ด ์ ์žฌ ๊ฐ€๋Šฅํ•œ ์Šคํ† ๋ฆฌ์ง€ ํฌ๋งท ์‚ฌ์šฉ** (์˜ˆ: Iceberg) 2. **ClickHouse์˜ Materialized View ํ™œ์šฉ** (์•ฝ์–ด๋กœ MV๋ผ๊ณ  ๋งŽ์ด ์‚ฌ์šฉํ•จ.) --- -## 2. ์˜ˆ์ธก๊ณผ ์„ค๊ณ„ : Redis + Serving API๋ฅผ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ๊ฒ ๋‹ค! +## 2. ๊ฐ€์„ค๊ณผ ์„ค๊ณ„: Redis + Serving API๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค -๋ฌผ๋ก  Redis๋Š” 1ms ์ˆ˜์ค€์œผ๋กœ ๋น ๋ฅด์ง€๋งŒ, ์ œ ํ”„๋กœ์ ํŠธ์˜ ๋ชฉํ‘œ์™€ ์ œ์•ฝ์„ ๋‹ค์‹œ ์ƒ๊ฐํ•ด๋ดค์Šต๋‹ˆ๋‹ค. +๋ฌผ๋ก  Redis๋Š” 1ms ์ˆ˜์ค€์œผ๋กœ ๋น ๋ฅด์ง€๋งŒ, ํ”„๋กœ์ ํŠธ์˜ ๋ชฉํ‘œ์™€ ์ œ์•ฝ์„ ๋‹ค์‹œ ์ƒ๊ฐํ–ˆ๋‹ค. - ๋ชฉํ‘œ: ์ดˆ๋‹น 10๋งŒ๊ฑด์˜ ์ด๋ฒคํŠธ๋ฅผ ์œ ์‹ค ์—†์ด ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ์‹œ์Šคํ…œ ๊ตฌ์ถ• - ์ œ์•ฝ: MacBook Air M1, 8GB ๊ธฐ๋ฐ˜์˜ ๋กœ์ปฌ ๊ฐœ๋ฐœํ™˜๊ฒฝ, ๋ฆฌ์†Œ์Šค ์ตœ์†Œํ™” -- ๊ฐ€์„ค: Redis ๋Œ€์‹  ๋Œ€์•ˆ์„ ์‚ฌ์šฉํ•ด๋„ ์‹ค์‹œ๊ฐ„์„ฑ์„ ์ถฉ๋ถ„ํžˆ ๋ณด์žฅํ•˜๋ฉด์„œ๋„ ๋ณต์žก๋„๋ฅผ ๋‚ฎ์ถœ ์ˆ˜ ์žˆ๋‹ค. +- ๊ฐ€์„ค: Redis ๋Œ€์‹  ๋‹ค๋ฅธ ์ €์žฅ ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉํ•ด๋„ ์‹ค์‹œ๊ฐ„์„ฑ์„ ์ถฉ๋ถ„ํžˆ ๋ณด์žฅํ•˜๋ฉด์„œ ๋ณต์žก๋„๋ฅผ ๋‚ฎ์ถœ ์ˆ˜ ์žˆ๋‹ค. -๋˜ํ•œ ์•„ํ‚คํ…์ฒ˜ ๊ด€์ ์—์„œ ๋ณด๋ฉด, Serving Layer์™€ Storage Layer๋ฅผ ๋ถ„๋ฆฌํ•˜๋Š” ๊ด€ํ–‰์ด ์˜คํžˆ๋ ค ์‹œ์Šคํ…œ ์ดˆ๊ธฐ ๋‹จ๊ณ„์—์„œ๋Š” ๋ถˆํ•„์š”ํ•˜๊ฒŒ ๊ฒฐํ•ฉ๋„๋งŒ ๋†’์ธ๋‹ค๋Š” ์ƒ๊ฐ์ด ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. +๋˜ํ•œ ์•„ํ‚คํ…์ฒ˜ ๊ด€์ ์—์„œ ๋ณด๋ฉด, Serving Layer์™€ Storage Layer๋ฅผ ๋ถ„๋ฆฌํ•˜๋Š” ๊ด€ํ–‰์ด ์‹œ์Šคํ…œ ์ดˆ๊ธฐ ๋‹จ๊ณ„์—์„œ๋Š” ๋ถˆํ•„์š”ํ•˜๊ฒŒ ๊ฒฐํ•ฉ๋„๋งŒ ๋†’์ผ ์ˆ˜ ์žˆ๋‹ค๊ณ  ๋ดค๋‹ค. --- -## 3. ๋Œ€์•ˆ ์„ ํƒ : Materialized VIew ํ™œ์šฉ +## 3. ๋Œ€์•ˆ ์„ ํƒ: ClickHouse Materialized View -์ƒˆ๋กœ์šด ์Šคํ† ๋ฆฌ์ง€ ํฌ๋งท์„ ๋„์ž…ํ•˜๋Š” ๊ฒƒ๋„ ์ข‹์€ ๋ฐฉ๋ฒ•์ด์ง€๋งŒ, ์ œ์•ฝ ์กฐ๊ฑด์„ ํ•ด๊ฒฐํ•˜๊ณ ์ž ํ•˜๋Š” ๋งˆ์Œ์ด ์ปธ๊ธฐ์— ClickHouse๋ฅผ ํ™œ์šฉํ•˜๊ธฐ๋กœ ๊ฒฐ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. +์ƒˆ๋กœ์šด ์Šคํ† ๋ฆฌ์ง€ ํฌ๋งท์„ ๋„์ž…ํ•˜๋Š” ๊ฒƒ๋„ ์ข‹์€ ๋ฐฉ๋ฒ•์ด์ง€๋งŒ, ์šฐ์„  ๋กœ์ปฌ ์ œ์•ฝ์„ ์ค„์ด๋Š” ๊ฒƒ์ด ๋” ์ค‘์š”ํ–ˆ๋‹ค. ๊ทธ๋ž˜์„œ ๊ธฐ์กด ๊ตฌ์„ฑ์— ์ด๋ฏธ ํฌํ•จ๋œ ClickHouse๋ฅผ ํ™œ์šฉํ•˜๊ธฐ๋กœ ๊ฒฐ์ •ํ–ˆ๋‹ค. -ClickHouse์˜ ๊ฐ•๋ ฅํ•œ ๊ธฐ๋Šฅ์ธ `Materialized View`๋ฅผ ํ™œ์šฉํ•˜๋ฉด, ๋ณ„๋„์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋กœ์ง ์—†์ด๋„ ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด์˜ค๋Š” ์ˆœ๊ฐ„ โ€œ๋ฏธ๋ฆฌ ๊ณ„์‚ฐ๋œ ์ƒํƒœโ€๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ฆ‰, **ClickHouse ์ž์ฒด๊ฐ€ ์บ์‹œ ์„œ๋ฒ„ ์—ญํ• ๊นŒ์ง€ ์ˆ˜ํ–‰ํ•˜๊ฒŒ ๋˜๋Š” ๊ฒƒ**์ž…๋‹ˆ๋‹ค. +ClickHouse์˜ `Materialized View`๋ฅผ ํ™œ์šฉํ•˜๋ฉด ๋ณ„๋„ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋กœ์ง ์—†์ด๋„ ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด์˜ค๋Š” ์ˆœ๊ฐ„ โ€œ๋ฏธ๋ฆฌ ๊ณ„์‚ฐ๋œ ์ƒํƒœโ€๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค. ์ฆ‰, **ClickHouse ์ž์ฒด๊ฐ€ ์บ์‹œ ์„œ๋ฒ„ ์—ญํ• ๊นŒ์ง€ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ตฌ์กฐ**๊ฐ€ ๋œ๋‹ค. ### ๋ฐ์ดํ„ฐ ํ๋ฆ„ ![](/images/posts/๋งฅ๋ถ์—์–ด-m1-์ƒํ™œ/97d2416a9a38.png) -### **์ ์šฉํ•œ 3๊ฐ€์ง€ View ์ „๋žต** +### ์ ์šฉํ•œ 3๊ฐ€์ง€ View ์ „๋žต 1. **`ctr_latest_view`**: Redis์˜ Key-Value ์กฐํšŒ๋ฅผ ๋Œ€์ฒด. ํ•ญ์ƒ ์ตœ์‹  CTR ์ƒํƒœ๋งŒ ์œ ์ง€ (`ReplacingMergeTree`). 2. **`ctr_ml_view`**: ๋ณต์žกํ•œ ์ง‘๊ณ„ ์ฟผ๋ฆฌ๋ฅผ ๋Œ€์ฒด. 1๋ถ„ ๋‹จ์œ„๋กœ ๋ฏธ๋ฆฌ ํ•ฉ๊ณ„๋ฅผ ๊ณ„์‚ฐํ•ด ๋‘  (`AggregatingMergeTree`). @@ -70,13 +70,13 @@ ClickHouse์˜ ๊ฐ•๋ ฅํ•œ ๊ธฐ๋Šฅ์ธ `Materialized View`๋ฅผ ํ™œ์šฉํ•˜๋ฉด, ๋ณ„๋„์˜ ## 4. ๊ฐ€์„ค ๊ฒ€์ฆ -์‹ค์ œ๋กœ Redis์™€ Serving API ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  ClickHouse๋กœ ํ†ตํ•ฉํ•ด ๋ณธ ๊ฒฐ๊ณผ๋Š” ์•„์ฃผ ๋งค์šฐ ๋งŒ์กฑ์Šค๋Ÿฌ์› ์Šต๋‹ˆ๋‹ค. +์‹ค์ œ๋กœ Redis์™€ Serving API ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  ClickHouse๋กœ ํ†ตํ•ฉํ•˜์ž ๋กœ์ปฌ ์‹คํ–‰ ์•ˆ์ •์„ฑ๊ณผ ์ฒ˜๋ฆฌ๋Ÿ‰์ด ํ•จ๊ป˜ ๊ฐœ์„ ๋๋‹ค. ![](/images/posts/๋งฅ๋ถ์—์–ด-m1-์ƒํ™œ/b0ef8cfa7bfd.png) -### **๐Ÿš€ ์„ฑ๋Šฅ ์ฆ๋ช… (๋กœ์ปฌ ๋ฒค์น˜๋งˆํฌ)** +### ์„ฑ๋Šฅ ๊ฒ€์ฆ: ๋กœ์ปฌ ๋ฒค์น˜๋งˆํฌ -์„ฑ๋Šฅ์„ ์ธก์ •ํ•˜๊ธฐ ์œ„ํ•ด ํ…Œ์ด๋ธ” ์บ์‹œ๋ฅผ ์˜ˆ์—ดํ•œ ๋’ค 10ํšŒ ์กฐํšŒํ•˜์—ฌ ํ‰๊ท ์น˜๋ฅผ ๊ณ„์‚ฐํ–ˆ์Šต๋‹ˆ๋‹ค. (TABiX ์‚ฌ์šฉ) +์„ฑ๋Šฅ์„ ์ธก์ •ํ•˜๊ธฐ ์œ„ํ•ด ํ…Œ์ด๋ธ” ์บ์‹œ๋ฅผ ์˜ˆ์—ดํ•œ ๋’ค 10ํšŒ ์กฐํšŒํ•˜์—ฌ ํ‰๊ท ์น˜๋ฅผ ๊ณ„์‚ฐํ–ˆ๋‹ค. ์ธก์ • ๋„๊ตฌ๋Š” TABiX๋ฅผ ์‚ฌ์šฉํ–ˆ๋‹ค. ```sql ex. SELECT count() FROM ctr_results_raw; @@ -85,15 +85,15 @@ ex. SELECT count() FROM ctr_results_raw; - **๋‹จ๊ฑด ์กฐํšŒ**: 0.00 ~ 0.01s (Redis์˜ 1ms๋ณด๋‹ค๋Š” ๋А๋ฆฌ์ง€๋งŒ, ๋Œ€์‹œ๋ณด๋“œ์šฉ์œผ๋กœ๋Š” ์ถฉ๋ถ„ํžˆ ๋น ๋ฆ„) - **์ง‘๊ณ„ ์กฐํšŒ**: 0.02 ~ 0.05s (๊ธฐ์กด Python API์—์„œ ์ง์ ‘ ์ง‘๊ณ„ํ•  ๋•Œ๋ณด๋‹ค ์˜คํžˆ๋ ค 10๋ฐฐ ์ด์ƒ ๋น ๋ฆ„) -์ •ํ™•ํ•˜๊ฒŒ ms ๋‹จ์œ„๊นŒ์ง€ ํ™•์ธํ•˜๊ณ  ์‹ถ์—ˆ์ง€๋งŒ ํˆด์˜ ํ•œ๊ณ„๋กœ 10ms ๋‹จ์œ„๊นŒ์ง€๋งŒ ํ™•์ธ ๊ฐ€๋Šฅํ–ˆ์Šต๋‹ˆ๋‹ค.. +์ •ํ™•ํ•œ ๋ฐ€๋ฆฌ์ดˆ ๋‹จ์œ„๊นŒ์ง€ ํ™•์ธํ•˜๊ณ  ์‹ถ์—ˆ์ง€๋งŒ, ๋„๊ตฌ์˜ ํ•œ๊ณ„๋กœ 10ms ๋‹จ์œ„๊นŒ์ง€๋งŒ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. -### **๐Ÿงฑ ์•„ํ‚คํ…์ฒ˜ ๋‹จ์ˆœํ™” ํšจ๊ณผ** +### ์•„ํ‚คํ…์ฒ˜ ๋‹จ์ˆœํ™” ํšจ๊ณผ ![](/images/posts/๋งฅ๋ถ์—์–ด-m1-์ƒํ™œ/646e42689b31.png) -- Resource : Redis์™€ API ์„œ๋ฒ„๊ฐ€ ๋จน๋˜ ์•ฝ 1GB์˜ ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ํ™•๋ณดํ•ด Flink์— ๋” ํ• ๋‹นํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. -- SQL ์ค‘์‹ฌ : ๋ชจ๋“  ๋ฐ์ดํ„ฐ๋ฅผ SQL ํ•˜๋‚˜๋กœ ์กฐํšŒํ•  ์ˆ˜ ์žˆ์–ด ๋””๋ฒ„๊น…์ด ํ›จ์”ฌ ์‰ฌ์›Œ์กŒ์Šต๋‹ˆ๋‹ค. -- Deployment: `docker-compose.yml` ์™€ ํ”„๋กœ์ ํŠธ ๋‚ด๋ถ€์— ์ฝ”๋“œ๊ฐ€ ์ค„์–ด๋“ค์–ด ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ์šฉ์ดํ•ด์กŒ์Šต๋‹ˆ๋‹ค. +- Resource: Redis์™€ API ์„œ๋ฒ„๊ฐ€ ์‚ฌ์šฉํ•˜๋˜ ์•ฝ 1GB์˜ ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ํ™•๋ณดํ•ด Flink์— ๋” ํ• ๋‹นํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. +- SQL ์ค‘์‹ฌ: ๋ชจ๋“  ๋ฐ์ดํ„ฐ๋ฅผ SQL ํ•˜๋‚˜๋กœ ์กฐํšŒํ•  ์ˆ˜ ์žˆ์–ด ๋””๋ฒ„๊น… ๊ฒฝ๋กœ๊ฐ€ ๋‹จ์ˆœํ•ด์กŒ๋‹ค. +- Deployment: `docker-compose.yml`๊ณผ ํ”„๋กœ์ ํŠธ ๋‚ด๋ถ€ ์ฝ”๋“œ๊ฐ€ ์ค„์–ด๋“ค์–ด ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ์‰ฌ์›Œ์กŒ๋‹ค. --- @@ -108,14 +108,14 @@ ex. SELECT count() FROM ctr_results_raw; | ์ง€์—ฐ ์‹œ๊ฐ„ | 1~3ms | 10~30ms | | ์ฒ˜๋ฆฌ๋Ÿ‰ | ~4k | ํ‰๊ท  20k, ์ŠคํŒŒ์ดํฌ 55k | -## 6. ๋А๋‚€์  +## 6. ๋‚จ์€ ๊ธฐ์ค€ -์‚ฌ์‹ค ์ฒ˜์Œ์—๋Š” ๋‚ด ๋งฅ๋ถ์˜ ์‚ฌ์–‘์ด ๋‚ฎ์•„์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ๋ผ๊ณ  ์ƒ๊ฐํ–ˆ์Šต๋‹ˆ๋‹ค. ์ดˆ๋‹น 833๊ฑด์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š”๋ฐ๋„ ๋ฒ„๋ฒ…์ด๋ฉฐ ํŒฌ์ด ์—„์ฒญ๋‚œ ๊ต‰์Œ์„ ๋ƒˆ๊ธฐ ๋•Œ๋ฌธ์ด์ฃ . +์ฒ˜์Œ์—๋Š” MacBook Air M1 ยท 8GB RAM์ด๋ผ๋Š” ํ™˜๊ฒฝ ๋•Œ๋ฌธ์— ์ƒ๊ธด ๋ฌธ์ œ๋ผ๊ณ  ์ƒ๊ฐํ–ˆ๋‹ค. ์ดˆ๋‹น 833๊ฑด์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ์ƒํ™ฉ์—์„œ๋„ ๋ฐœ์—ด๊ณผ ๋ฉ”๋ชจ๋ฆฌ ์••๋ฐ•์ด ์ปธ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. -ํ•˜์ง€๋งŒ ๋” ๋‚˜์€ ์‹œ์Šคํ…œ์„ ๊ตฌ์ถ•ํ•˜๊ธฐ ์œ„ํ•ด ๊ณ„์† ๊ณ ๋ฏผํ•˜๋ฉฐ ์ตœ์ ํ™”๋ฅผ ํ•˜๋‹ค ๋ณด๋‹ˆ ์ฒ˜๋ฆฌ๋Ÿ‰์€ ์ดˆ๋‹น 4์ฒœ ๊ฑด๊นŒ์ง€ ๋Š˜์–ด๋‚ฌ๊ณ , Redis + Serving API๋ฅผ ์ œ๊ฑฐํ•ด ๋ฆฌ์†Œ์Šค๋ฅผ ํ™•๋ณดํ•œ ๋’ค Flink ์•ฑ์— ์ž์›์„ ๋” ํ• ๋‹นํ•˜์ž ํ‰๊ท  1~2๋งŒ ๊ฑด์„ ์ฒ˜๋ฆฌํ•˜๋ฉฐ ์•ฝ 5.5๋งŒ ๊ฑด์˜ ์ŠคํŒŒ์ดํฌ ํŠธ๋ž˜ํ”ฝ๋„ ์•ˆ์ •์ ์œผ๋กœ ์†Œํ™”ํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. +ํ•˜์ง€๋งŒ ๊ตฌ์กฐ๋ฅผ ๋‹ค์‹œ ์‚ดํŽด๋ณด๋ฉฐ ์ตœ์ ํ™”๋ฅผ ์ง„ํ–‰ํ•˜์ž ์ฒ˜๋ฆฌ๋Ÿ‰์€ ์ดˆ๋‹น 4์ฒœ ๊ฑด๊นŒ์ง€ ๋Š˜์–ด๋‚ฌ๋‹ค. ์ดํ›„ Redis + Serving API๋ฅผ ์ œ๊ฑฐํ•ด ๋ฆฌ์†Œ์Šค๋ฅผ ํ™•๋ณดํ•˜๊ณ  Flink ์•ฑ์— ์ž์›์„ ๋” ํ• ๋‹นํ•˜์ž ํ‰๊ท  1~2๋งŒ ๊ฑด์„ ์ฒ˜๋ฆฌํ–ˆ๊ณ , ์•ฝ 5.5๋งŒ ๊ฑด์˜ ์ŠคํŒŒ์ดํฌ ํŠธ๋ž˜ํ”ฝ๋„ ์•ˆ์ •์ ์œผ๋กœ ์†Œํ™”ํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. ![](/images/posts/๋งฅ๋ถ์—์–ด-m1-์ƒํ™œ/9b51c74eac5a.png) -๊ณผ์—ฐ ์ตœ์‹  ๋ฒ„์ „์˜ ๋งฅ๋ถ์„ ์‚ฌ์šฉํ•ด ์ฒ˜์Œ๋ถ€ํ„ฐ ์ž์›์ด ๋„‰๋„‰ํ–ˆ๋‹ค๋ฉด, ์ด๋ ‡๊ฒŒ๊นŒ์ง€ ์ตœ์ ํ™”๋ฅผ ์‹œ๋„ํ–ˆ์„๊นŒ? ํ•˜๋Š” ์ƒ๊ฐ๋„ ๋“ญ๋‹ˆ๋‹ค. ์˜คํžˆ๋ ค ์ด๋Ÿฐ ์ œํ•œ๋œ ํ™˜๊ฒฝ ๋•๋ถ„์— Flink๋ฅผ ๋” ๊นŠ์ด ์‚ดํŽด๋ณด๊ณ , ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ์—ฌ์ •์„ ๊ฒฝํ—˜ํ•  ์ˆ˜ ์žˆ์—ˆ๋˜ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. +์ฒ˜์Œ๋ถ€ํ„ฐ ์ž์›์ด ๋„‰๋„‰ํ–ˆ๋‹ค๋ฉด ์ด ์ •๋„๋กœ ๊ตฌ์กฐ๋ฅผ ์˜์‹ฌํ•˜์ง€ ์•Š์•˜์„ ๊ฐ€๋Šฅ์„ฑ์ด ๋†’๋‹ค. ์ œํ•œ๋œ ๋กœ์ปฌ ํ™˜๊ฒฝ์€ ๋‹จ์ˆœํ•œ ๋ถˆํŽธํ•จ์ด ์•„๋‹ˆ๋ผ, Flink ๋ณ‘๋ ฌ๋„์™€ ์ƒํƒœ ๊ด€๋ฆฌ, ์ €์žฅ์†Œ ์„ ํƒ ๊ธฐ์ค€์„ ๋” ๊ตฌ์ฒด์ ์œผ๋กœ ๊ฒ€์ฆํ•˜๊ฒŒ ๋งŒ๋“  ์ œ์•ฝ์ด์—ˆ๋‹ค. -์•„์ง ์ด ์‹œ์Šคํ…œ์ด ์™„๋ฒฝํ•˜๋‹ค๊ณ ๋Š” ์ƒ๊ฐํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ตœ์†Œ 10๋งŒ ๊ฑด์˜ ์ŠคํŒŒ์ดํฌ ํŠธ๋ž˜ํ”ฝ์„ ๊ฒฌ๋”œ ์ˆ˜ ์žˆ๋„๋ก ๋” ์ตœ์ ํ™”ํ•˜๋ฉฐ, ์ง€์†์ ์œผ๋กœ ์„ฑ์žฅ์‹œ์ผœ ๋ณด๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค. +์•„์ง ์ด ์‹œ์Šคํ…œ์ด ์™„์„ฑ๋๋‹ค๊ณ  ๋ณด์ง€๋Š” ์•Š๋Š”๋‹ค. ๋‹ค์Œ ๊ธฐ์ค€์€ ์ตœ์†Œ 10๋งŒ ๊ฑด์˜ ์ŠคํŒŒ์ดํฌ ํŠธ๋ž˜ํ”ฝ์„ ๊ฒฌ๋”œ ์ˆ˜ ์žˆ๋„๋ก ๋ณ‘๋ชฉ์„ ๋” ์ขํžˆ๊ณ , ์„ฑ๋Šฅ ๊ฐœ์„ ์ด ์–ด๋–ค ๊ตฌ์กฐ์  ์„ ํƒ์—์„œ ๋‚˜์™”๋Š”์ง€ ๊ณ„์† ์„ค๋ช… ๊ฐ€๋Šฅํ•œ ์ƒํƒœ๋กœ ์œ ์ง€ํ•˜๋Š” ๊ฒƒ์ด๋‹ค. diff --git "a/posts/\353\247\245\353\266\201\354\227\220\354\226\264-m1-\354\203\235\355\231\234/meta.json" "b/posts/\353\247\245\353\266\201\354\227\220\354\226\264-m1-\354\203\235\355\231\234/meta.json" index 8dd709f..b07a827 100644 --- "a/posts/\353\247\245\353\266\201\354\227\220\354\226\264-m1-\354\203\235\355\231\234/meta.json" +++ "b/posts/\353\247\245\353\266\201\354\227\220\354\226\264-m1-\354\203\235\355\231\234/meta.json" @@ -1,7 +1,7 @@ { - "title": "๋กœ์ปฌ CTR ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์กฐ ๋‹จ์ˆœํ™”์™€ ์„ฑ๋Šฅ ๊ฐœ์„ ", + "title": "์‹ค์‹œ๊ฐ„ CTR ํŒŒ์ดํ”„๋ผ์ธ ์„ฑ๋Šฅ๊ฐœ์„ ๊ธฐ", "slug": "macbook-air-m1-life", - "description": "๋กœ์ปฌ CTR ํŒŒ์ดํ”„๋ผ์ธ์—์„œ Redis์™€ API๋ฅผ ๋œ์–ด๋‚ด๊ณ  ClickHouse ์ค‘์‹ฌ์œผ๋กœ ์„ฑ๋Šฅ์„ ๊ฐœ์„ ํ•œ ๊ณผ์ •์„ ์ •๋ฆฌํ•œ๋‹ค.", + "description": "์‹ค์‹œ๊ฐ„ CTR ํŒŒ์ดํ”„๋ผ์ธ์—์„œ Redis์™€ API๋ฅผ ๋œ์–ด๋‚ด๊ณ  ClickHouse ์ค‘์‹ฌ์œผ๋กœ ์„ฑ๋Šฅ์„ ๊ฐœ์„ ํ•œ ๊ณผ์ •์„ ์ •๋ฆฌํ•œ๋‹ค.", "date": "2025-01-20", "category": "Tech", "tags": [ diff --git "a/posts/\354\213\244\354\213\234\352\260\204-CTR-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225\352\270\260/meta.json" "b/posts/\354\213\244\354\213\234\352\260\204-CTR-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225\352\270\260/meta.json" index 5627f2d..2880824 100644 --- "a/posts/\354\213\244\354\213\234\352\260\204-CTR-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225\352\270\260/meta.json" +++ "b/posts/\354\213\244\354\213\234\352\260\204-CTR-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225\352\270\260/meta.json" @@ -1,7 +1,7 @@ { - "title": "์‹ค์‹œ๊ฐ„ CTR ์ง‘๊ณ„ ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์ถ• ๊ธฐ๋ก", + "title": "์‹ค์‹œ๊ฐ„ CTR ์ง‘๊ณ„ ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์ถ•๊ธฐ", "slug": "ctr-pipeline", - "description": "CTR ์ง‘๊ณ„๋ฅผ ์šด์˜ ๊ฐ€๋Šฅํ•œ ์‹œ์Šคํ…œ์œผ๋กœ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ์›Œํ„ฐ๋งˆํฌ, ์œˆ๋„์šฐ, ์ง€์—ฐ ํ—ˆ์šฉ, ๊ฒ€์ฆ ์ „๋žต์„ ์–ด๋–ป๊ฒŒ ์„ค๊ณ„ํ–ˆ๋Š”์ง€ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค.", + "description": "CTR ์ง‘๊ณ„๋ฅผ ์šด์˜ ๊ฐ€๋Šฅํ•œ ์‹œ์Šคํ…œ์œผ๋กœ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ์›Œํ„ฐ๋งˆํฌ, ์œˆ๋„์šฐ, ์ง€์—ฐ ํ—ˆ์šฉ, ๊ฒ€์ฆ ์ „๋žต์„ ์–ด๋–ป๊ฒŒ ์„ค๊ณ„ํ–ˆ๋Š”์ง€ ์ •๋ฆฌํ•œ๋‹ค.", "date": "2025-01-20", "category": "Tech", "tags": [ diff --git a/styles/globals.css b/styles/globals.css index b1e7de9..dca9abc 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -318,6 +318,7 @@ line-height: var(--leading-tight); margin-top: 3rem; margin-bottom: 1.125rem; + scroll-margin-top: 6rem; } .prose h1 {