From 1b46510bd4e688ac7ecd839aef707abcc93e7828 Mon Sep 17 00:00:00 2001 From: dev-wooyeon Date: Mon, 13 Apr 2026 15:21:03 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(blog):=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EA=B3=BC=20=ED=92=88=EC=A7=88=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 글 공개/비공개 정책과 품질 리뷰 메타데이터를 추가했어요.\n피드, 사이트맵, 저장소 계층에서 비공개 글을 기본 제외하도록 정리했어요.\n읽기 시간 계산과 새 글 스캐폴드도 현재 정책에 맞게 보강했어요. --- internal/scripts/posts/new-post.js | 16 ++- src/app/feed.xml/route.test.ts | 13 ++ src/app/sitemap.test.ts | 15 +++ .../post/model/frontmatter-schema.test.ts | 62 ++++++++++ src/domains/post/model/frontmatter-schema.ts | 19 +++ src/domains/post/model/types.ts | 13 ++ .../blog/services/post-repository.test.ts | 58 ++++++++- src/features/blog/services/post-repository.ts | 115 +++++++++++++++--- .../blog/services/publication-policy.test.ts | 88 ++++++++++++++ 9 files changed, 376 insertions(+), 23 deletions(-) create mode 100644 src/app/feed.xml/route.test.ts create mode 100644 src/app/sitemap.test.ts create mode 100644 src/domains/post/model/frontmatter-schema.test.ts create mode 100644 src/features/blog/services/publication-policy.test.ts diff --git a/internal/scripts/posts/new-post.js b/internal/scripts/posts/new-post.js index 798b8ce9..db4c5a20 100644 --- a/internal/scripts/posts/new-post.js +++ b/internal/scripts/posts/new-post.js @@ -38,12 +38,8 @@ async function main() { name: 'category', message: 'Select a category:', choices: [ - { title: 'Dev', value: 'Dev' }, + { title: 'Tech', value: 'Tech' }, { title: 'Life', value: 'Life' }, - { title: 'Travel', value: 'Travel' }, - { title: 'Review', value: 'Review' }, - { title: 'Insight', value: 'Insight' }, - { title: 'Essay', value: 'Essay' }, ], initial: 0, }, @@ -81,10 +77,20 @@ async function main() { // Create meta.json const metaData = { title, + slug, description, date, category, + visibility: 'public', tags: tags.map((t) => t.trim()).filter(Boolean), + qualityReview: { + philosophy: null, + design: null, + implementation: null, + brandFit: null, + reviewedAt: '', + notes: '', + }, }; await fs.writeFile( diff --git a/src/app/feed.xml/route.test.ts b/src/app/feed.xml/route.test.ts new file mode 100644 index 00000000..911ca641 --- /dev/null +++ b/src/app/feed.xml/route.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { GET } from './route'; + +describe('/feed.xml', () => { + it('excludes private posts from the generated RSS feed', async () => { + const response = await GET(); + const xml = await response.text(); + + expect(xml).toContain('ctr-pipeline'); + expect(xml).not.toContain('fixed-ai-dev-environment'); + expect(xml).not.toContain('algorithm-visualization'); + }); +}); diff --git a/src/app/sitemap.test.ts b/src/app/sitemap.test.ts new file mode 100644 index 00000000..51293265 --- /dev/null +++ b/src/app/sitemap.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import sitemap from './sitemap'; + +const URL = 'https://eunu-log.vercel.app'; + +describe('sitemap', () => { + it('excludes private posts from sitemap entries', () => { + const entries = sitemap(); + const urls = entries.map((entry) => entry.url); + + expect(urls).toContain(`${URL}/blog/ctr-pipeline`); + expect(urls).not.toContain(`${URL}/blog/fixed-ai-dev-environment`); + expect(urls).not.toContain(`${URL}/blog/algorithm-visualization`); + }); +}); diff --git a/src/domains/post/model/frontmatter-schema.test.ts b/src/domains/post/model/frontmatter-schema.test.ts new file mode 100644 index 00000000..e2641df5 --- /dev/null +++ b/src/domains/post/model/frontmatter-schema.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { FeedFrontmatterSchema } from './frontmatter-schema'; + +describe('FeedFrontmatterSchema', () => { + it('defaults visibility to public', () => { + const parsed = FeedFrontmatterSchema.parse({ + title: 'Example', + slug: 'example', + description: 'desc', + date: '2026-04-13', + category: 'Tech', + }); + + expect(parsed.visibility).toBe('public'); + }); + + it('accepts empty quality review scaffolds', () => { + const parsed = FeedFrontmatterSchema.parse({ + title: 'Example', + slug: 'example', + description: 'desc', + date: '2026-04-13', + category: 'Tech', + qualityReview: { + philosophy: null, + design: null, + implementation: null, + brandFit: null, + }, + }); + + expect(parsed.qualityReview?.philosophy).toBeNull(); + }); + + it('rejects scores outside the allowed range or increment', () => { + const baseInput = { + title: 'Example', + slug: 'example', + description: 'desc', + date: '2026-04-13', + category: 'Tech' as const, + }; + + expect(() => + FeedFrontmatterSchema.parse({ + ...baseInput, + qualityReview: { + philosophy: 3.3, + }, + }) + ).toThrow(/0\.5 increments/); + + expect(() => + FeedFrontmatterSchema.parse({ + ...baseInput, + qualityReview: { + philosophy: 5.5, + }, + }) + ).toThrow(); + }); +}); diff --git a/src/domains/post/model/frontmatter-schema.ts b/src/domains/post/model/frontmatter-schema.ts index ec76e89e..9466aaf6 100644 --- a/src/domains/post/model/frontmatter-schema.ts +++ b/src/domains/post/model/frontmatter-schema.ts @@ -1,17 +1,36 @@ import { z } from 'zod'; +const QualityScoreSchema = z + .number() + .min(1) + .max(5) + .refine((value) => Number.isInteger(value * 2), { + message: 'Quality scores must use 0.5 increments', + }); + export const FeedFrontmatterSchema = z.object({ title: z.string(), slug: z.string(), description: z.string(), date: z.string(), category: z.enum(['Tech', 'Life']), + visibility: z.enum(['public', 'private']).default('public'), tags: z.array(z.string()).optional(), image: z.string().optional(), readingTime: z.number().optional(), featured: z.boolean().optional(), updated: z.string().optional(), transliteratedTitle: z.string().optional(), + qualityReview: z + .object({ + philosophy: QualityScoreSchema.nullable().optional(), + design: QualityScoreSchema.nullable().optional(), + implementation: QualityScoreSchema.nullable().optional(), + brandFit: QualityScoreSchema.nullable().optional(), + reviewedAt: z.string().optional(), + notes: z.string().optional(), + }) + .optional(), series: z .object({ id: z.string(), diff --git a/src/domains/post/model/types.ts b/src/domains/post/model/types.ts index 9ce9cd9e..f04065da 100644 --- a/src/domains/post/model/types.ts +++ b/src/domains/post/model/types.ts @@ -1,6 +1,17 @@ import type { MDXProps } from 'mdx/types'; export type PostCategory = 'Tech' | 'Life'; +export type PostVisibility = 'public' | 'private'; +export type QualityScore = number | null; + +export interface QualityReview { + philosophy?: QualityScore; + design?: QualityScore; + implementation?: QualityScore; + brandFit?: QualityScore; + reviewedAt?: string; + notes?: string; +} export interface FeedFrontmatter { title: string; @@ -9,11 +20,13 @@ export interface FeedFrontmatter { date: string; updated?: string; category: PostCategory; + visibility?: PostVisibility; tags?: string[]; image?: string; readingTime?: number; featured?: boolean; transliteratedTitle?: string; + qualityReview?: QualityReview; series?: { id: string; title: string; diff --git a/src/features/blog/services/post-repository.test.ts b/src/features/blog/services/post-repository.test.ts index 0895cb3f..a4b702d6 100644 --- a/src/features/blog/services/post-repository.test.ts +++ b/src/features/blog/services/post-repository.test.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { describe, expect, it, vi } from 'vitest'; import { + calculateReadingTime, getFeedData, getFolderSlug, getSeriesPosts, @@ -12,6 +13,32 @@ import { const postsDirectory = path.join(process.cwd(), 'posts'); describe('post repository utilities', () => { + it('ignores fenced code blocks and hidden details when calculating reading time', () => { + const proseOnly = '가'.repeat(1400); + const content = `${proseOnly} + +\`\`\`text +${'x'.repeat(5000)} +\`\`\` + +
+ hidden + ${'y'.repeat(5000)} +
`; + + expect(calculateReadingTime(content)).toBe(2); + }); + + it('weights markdown tables lighter than prose when calculating reading time', () => { + const proseContent = '가'.repeat(1000); + const tableContent = `| column | +| --- | +| ${'가'.repeat(1000)} |`; + + expect(calculateReadingTime(proseContent)).toBe(2); + expect(calculateReadingTime(tableContent)).toBe(1); + }); + it('returns posts sorted by date in descending order', () => { const posts = getSortedFeedData(); @@ -32,7 +59,9 @@ describe('post repository utilities', () => { }); it('returns series posts sorted by order', () => { - const redisPosts = getSeriesPosts('redis-deep-dive'); + const redisPosts = getSeriesPosts('redis-deep-dive', { + includePrivate: true, + }); expect(redisPosts.length).toBeGreaterThan(0); const orders = redisPosts.map((post) => post.series?.order ?? 0); @@ -50,6 +79,33 @@ describe('post repository utilities', () => { expect(slugs.every((item) => item.slug.length > 0)).toBe(true); }); + it('filters private posts out of the default listing and static params', () => { + const publicPosts = getSortedFeedData(); + const allPosts = getSortedFeedData({ includePrivate: true }); + const publicSlugs = new Set(publicPosts.map((post) => post.slug)); + const privatePost = allPosts.find((post) => post.visibility === 'private'); + + expect(privatePost).toBeDefined(); + expect(publicSlugs.has(privatePost!.slug)).toBe(false); + + const publicStaticSlugs = new Set(getAllFeedSlugs().map((item) => item.slug)); + expect(publicStaticSlugs.has(privatePost!.slug)).toBe(false); + }); + + it('returns private posts only when includePrivate is true', async () => { + const privateSlug = 'fixed-ai-dev-environment'; + + expect(getFolderSlug(privateSlug)).not.toBeNull(); + 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/src/features/blog/services/post-repository.ts b/src/features/blog/services/post-repository.ts index 1e860b44..18be5c1e 100644 --- a/src/features/blog/services/post-repository.ts +++ b/src/features/blog/services/post-repository.ts @@ -10,6 +10,10 @@ const isProduction = process.env.NODE_ENV === 'production'; const slugToFolderCache = new Map(); let cachedSortedFeedData: FeedData[] | null = null; +export interface FeedQueryOptions { + includePrivate?: boolean; +} + // TOC item type export interface TocItem { id: string; @@ -96,14 +100,66 @@ function validateFeedFrontmatter( return result.data; } +const PROSE_CHARACTERS_PER_MINUTE = 700; +const TABLE_READING_WEIGHT = 0.35; +const FENCED_CODE_BLOCK_REGEX = /```[\s\S]*?```/g; +const DETAILS_BLOCK_REGEX = //gi; +const IMAGE_LINK_REGEX = /!\[.*?\]\(.*?\)/g; +const MARKDOWN_LINK_REGEX = /\[(.*?)\]\(.*?\)/g; +const HTML_TAG_REGEX = /<\/?[^>]+>/g; +const MARKDOWN_DECORATION_REGEX = /[#*_`]/g; +const LEADING_MARKER_REGEX = /^\s*[>\-+]\s?/gm; +const TABLE_DELIMITER_REGEX = /^\|?[\s:-|]+\|?$/; + +function normalizeReadableLine(line: string): string { + return line + .replace(HTML_TAG_REGEX, ' ') + .replace(MARKDOWN_DECORATION_REGEX, ' ') + .replace(LEADING_MARKER_REGEX, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function weightedReadingLength(content: string): number { + const withoutHiddenContent = content + .replace(DETAILS_BLOCK_REGEX, ' ') + .replace(FENCED_CODE_BLOCK_REGEX, ' ') + .replace(IMAGE_LINK_REGEX, ' ') + .replace(MARKDOWN_LINK_REGEX, '$1'); + + const lines = withoutHiddenContent.split('\n'); + let proseLength = 0; + let tableLength = 0; + + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (!line) { + continue; + } + + if (line.startsWith('|')) { + if (TABLE_DELIMITER_REGEX.test(line)) { + continue; + } + + const normalizedTableLine = normalizeReadableLine( + line.replace(/\|/g, ' ') + ); + tableLength += normalizedTableLine.length; + continue; + } + + proseLength += normalizeReadableLine(line).length; + } + + return proseLength + Math.ceil(tableLength * TABLE_READING_WEIGHT); +} + // Calculate reading time from MDX content -function calculateReadingTime(content: string): number { - const cleanContent = content - .replace(/!\[.*?\]\(.*?\)/g, '') // Remove image links - .replace(/\[.*?\]\(.*?\)/g, '$1') // Keep text of links - .replace(/[#*`]/g, ''); // Remove basic markdown syntax - const length = cleanContent.length; - return Math.ceil(length / 500) || 1; // 500 characters per minute, min 1 min +export function calculateReadingTime(content: string): number { + const length = weightedReadingLength(content); + return Math.max(1, Math.ceil(length / PROSE_CHARACTERS_PER_MINUTE)); } const CONTENT_IMAGE_SOURCE_REGEX = @@ -183,6 +239,21 @@ function loadMetadata(folderPath: string): FeedFrontmatter | null { } } +function isPublicPost(post: FeedData): boolean { + return (post.visibility ?? 'public') === 'public'; +} + +function filterVisiblePosts( + posts: FeedData[], + options: FeedQueryOptions = {} +): FeedData[] { + if (options.includePrivate) { + return posts; + } + + return posts.filter(isPublicPost); +} + // Get folder path from slug (using cache or scanning) export function getFolderSlug(slug: string): string | null { // Check cache first @@ -191,7 +262,7 @@ export function getFolderSlug(slug: string): string | null { } // Populate slug cache by loading full feed index first - getSortedFeedData(); + getSortedFeedData({ includePrivate: true }); if (slugToFolderCache.has(slug)) { return slugToFolderCache.get(slug)!; } @@ -214,14 +285,14 @@ export function getFolderSlug(slug: string): string | null { } // Get all feed slugs for static generation -export function getAllFeedSlugs() { - return getSortedFeedData().map((feed) => ({ slug: feed.slug })); +export function getAllFeedSlugs(options: FeedQueryOptions = {}) { + return getSortedFeedData(options).map((feed) => ({ slug: feed.slug })); } // Get sorted feed data for listing pages -export function getSortedFeedData(): FeedData[] { +export function getSortedFeedData(options: FeedQueryOptions = {}): FeedData[] { if (isProduction && cachedSortedFeedData) { - return cachedSortedFeedData; + return filterVisiblePosts(cachedSortedFeedData, options); } if (!safeExists(postsDirectory)) { @@ -257,11 +328,14 @@ export function getSortedFeedData(): FeedData[] { cachedSortedFeedData = sortedFeedData; } - return sortedFeedData; + return filterVisiblePosts(sortedFeedData, options); } // Get single feed data with MDX component -export async function getFeedData(slug: string): Promise { +export async function getFeedData( + slug: string, + options: FeedQueryOptions = {} +): Promise { const folderPath = getFolderSlug(slug); if (!folderPath) { @@ -276,6 +350,10 @@ export async function getFeedData(slug: string): Promise { return null; } + if (!options.includePrivate && metadata.visibility === 'private') { + return null; + } + try { // Dynamic import of MDX file using folder path const mdxModule = await import(`@/../posts/${folderPath}/index.mdx`); @@ -291,10 +369,13 @@ export async function getFeedData(slug: string): Promise { } // Get all posts in a series, sorted by order -export function getSeriesPosts(seriesId: string): FeedData[] { - const allPosts = getSortedFeedData(); +export function getSeriesPosts( + seriesId: string, + options: FeedQueryOptions = {} +): FeedData[] { + const allPosts = getSortedFeedData(options); return allPosts - .filter(post => post.series?.id === seriesId) + .filter((post) => post.series?.id === seriesId) .sort((a, b) => (a.series?.order ?? 0) - (b.series?.order ?? 0)); } diff --git a/src/features/blog/services/publication-policy.test.ts b/src/features/blog/services/publication-policy.test.ts new file mode 100644 index 00000000..4866e675 --- /dev/null +++ b/src/features/blog/services/publication-policy.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import type { FeedData, QualityReview } from '@/domains/post/model/types'; +import { getSortedFeedData } from './post-repository'; + +const FEATURED_SLUGS = [ + 'ctr-pipeline', + 'db-outage-index-root-cause', + 'payment-system-design', + 'settlement-automation', + 'msa-domain-workspace-submodule', +]; + +function readCoreAverage(review: QualityReview | undefined): number | null { + const scores = [ + review?.philosophy, + review?.design, + review?.implementation, + ]; + + if (scores.some((score) => typeof score !== 'number')) { + return null; + } + + const [philosophy, design, implementation] = scores as number[]; + return (philosophy + design + implementation) / 3; +} + +function describePost(post: FeedData): string { + return `${post.slug} (${post.title})`; +} + +describe('publication policy', () => { + it('keeps all series posts private', () => { + const publicSeriesPosts = getSortedFeedData().filter((post) => post.series); + + expect(publicSeriesPosts).toEqual([]); + }); + + it('requires public tech posts to stay above the minimum review threshold', () => { + const offenses = getSortedFeedData() + .filter((post) => post.category === 'Tech') + .flatMap((post) => { + const average = readCoreAverage(post.qualityReview); + + if (average === null) { + return [`${describePost(post)}: qualityReview core scores are incomplete`]; + } + + if (average <= 3) { + return [`${describePost(post)}: core average ${average.toFixed(2)} <= 3.0`]; + } + + return []; + }); + + expect(offenses).toEqual([]); + }); + + it('requires featured posts to meet branding thresholds', () => { + const featuredPosts = getSortedFeedData().filter((post) => post.featured); + const offenses = featuredPosts + .flatMap((post) => { + const brandFit = post.qualityReview?.brandFit; + const currentOffenses: string[] = []; + + if (post.category !== 'Tech') { + currentOffenses.push(`${describePost(post)}: featured posts must be Tech`); + } + + if (post.series) { + currentOffenses.push(`${describePost(post)}: featured posts must not be series`); + } + + if (typeof brandFit !== 'number' || brandFit < 4) { + currentOffenses.push( + `${describePost(post)}: brandFit must be >= 4.0` + ); + } + + return currentOffenses; + }); + + expect(featuredPosts.map((post) => post.slug).sort()).toEqual( + [...FEATURED_SLUGS].sort() + ); + expect(offenses).toEqual([]); + }); +}); From 9c89c27dc7459e4a2f811da89a11f7124c835a7f Mon Sep 17 00:00:00 2001 From: dev-wooyeon Date: Mon, 13 Apr 2026 15:21:11 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore(content):=20=EA=B8=80=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=EB=B2=94=EC=9C=84=EC=99=80=20=EB=A9=94=ED=83=80=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시리즈 글과 저점수 엔지니어링 글의 공개 범위를 재조정했어요.\n품질 리뷰 점수와 featured 기준을 메타데이터에 반영했어요.\n라이프 글은 기존 공개 전략을 유지하면서 탐색 구조만 정리했어요. --- .../meta.json" | 3 +- .../meta.json" | 12 ++++++- .../meta.json" | 23 +++++++++++++ .../meta.json" | 11 +++++- .../meta.json" | 11 +++++- .../meta.json" | 11 +++++- .../meta.json" | 11 +++++- .../meta.json" | 11 +++++- .../meta.json" | 4 ++- .../meta.json" | 4 ++- .../meta.json" | 4 ++- .../meta.json" | 4 ++- .../05-persistence-rdb-aof/meta.json" | 4 ++- .../meta.json" | 4 ++- .../meta.json" | 4 ++- .../meta.json" | 4 ++- .../meta.json" | 11 +++++- .../meta.json" | 4 ++- .../meta.json" | 4 ++- .../meta.json" | 12 ++++++- .../meta.json" | 15 ++++++-- .../meta.json" | 11 +++++- .../meta.json" | 12 ++++++- .../meta.json" | 3 +- .../meta.json" | 12 ++++++- .../meta.json" | 12 ++++++- .../meta.json" | 3 +- .../meta.json" | 3 +- .../meta.json" | 11 +++++- .../connectors/meta.json" | 34 ++++++++++--------- .../datastream-api/meta.json" | 34 ++++++++++--------- .../kubernetes/meta.json" | 34 ++++++++++--------- .../performance-tuning/meta.json" | 34 ++++++++++--------- .../state-operations/meta.json" | 34 ++++++++++--------- .../table-api-sql/meta.json" | 34 ++++++++++--------- .../meta.json" | 34 ++++++++++--------- .../meta.json" | 34 ++++++++++--------- .../meta.json" | 3 +- 38 files changed, 354 insertions(+), 159 deletions(-) create mode 100644 "posts/ai-\354\213\234\353\214\200-\354\212\244\355\202\254-\354\204\244\352\263\204/meta.json" diff --git "a/posts/2025\353\205\204-\355\232\214\352\263\240/meta.json" "b/posts/2025\353\205\204-\355\232\214\352\263\240/meta.json" index c501254b..d33695d1 100644 --- "a/posts/2025\353\205\204-\355\232\214\352\263\240/meta.json" +++ "b/posts/2025\353\205\204-\355\232\214\352\263\240/meta.json" @@ -8,5 +8,6 @@ "Career", "Work" ], - "featured": false + "featured": false, + "visibility": "public" } 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 6eda3da1..2fdb4fca 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" @@ -11,5 +11,15 @@ "Codex", "ChatGPT", "Atlas" - ] + ], + "visibility": "private", + "featured": false, + "qualityReview": { + "philosophy": 3.5, + "design": 3, + "implementation": 2.5, + "brandFit": 2.5, + "notes": "비공개", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/ai-\354\213\234\353\214\200-\354\212\244\355\202\254-\354\204\244\352\263\204/meta.json" "b/posts/ai-\354\213\234\353\214\200-\354\212\244\355\202\254-\354\204\244\352\263\204/meta.json" new file mode 100644 index 00000000..d2bf82c5 --- /dev/null +++ "b/posts/ai-\354\213\234\353\214\200-\354\212\244\355\202\254-\354\204\244\352\263\204/meta.json" @@ -0,0 +1,23 @@ +{ + "title": "AI 시대, 테스트 코드 기준을 스킬로 고정하기", + "slug": "designing-skills-for-ai-workflows", + "description": "Codex에 테스트 코드 표준화 스킬을 넣은 뒤 무엇이 달라졌는지, 어떤 기준과 과정으로 팀의 테스트 문화를 고정했는지 정리해요.", + "date": "2026-04-02", + "category": "Tech", + "tags": [ + "AI Workflow", + "Codex", + "Testing", + "Developer Experience" + ], + "featured": false, + "visibility": "public", + "qualityReview": { + "philosophy": 4.5, + "design": 4, + "implementation": 3.5, + "brandFit": 4, + "notes": "보조 유지", + "reviewedAt": "2026-04-13" + } +} diff --git "a/posts/ai-\354\213\234\353\214\200-\355\225\231\354\212\265\353\262\225/meta.json" "b/posts/ai-\354\213\234\353\214\200-\355\225\231\354\212\265\353\262\225/meta.json" index 4f428184..25cdb662 100644 --- "a/posts/ai-\354\213\234\353\214\200-\355\225\231\354\212\265\353\262\225/meta.json" +++ "b/posts/ai-\354\213\234\353\214\200-\355\225\231\354\212\265\353\262\225/meta.json" @@ -10,5 +10,14 @@ "Career", "Work" ], - "featured": false + "featured": false, + "visibility": "private", + "qualityReview": { + "philosophy": 3.5, + "design": 2.5, + "implementation": 2, + "brandFit": 2, + "notes": "비공개", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/meta.json" "b/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/meta.json" index 4896b5af..68349e09 100644 --- "a/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/meta.json" +++ "b/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/meta.json" @@ -11,5 +11,14 @@ "Index", "Troubleshooting" ], - "featured": false + "featured": true, + "visibility": "public", + "qualityReview": { + "philosophy": 4, + "design": 4.5, + "implementation": 4.5, + "brandFit": 4.5, + "notes": "핵심 유지", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/waveterm-\353\262\210\354\227\255-oss-\352\270\260\354\227\254/meta.json" "b/posts/waveterm-\353\262\210\354\227\255-oss-\352\270\260\354\227\254/meta.json" index 5207eaa2..c170d9fa 100644 --- "a/posts/waveterm-\353\262\210\354\227\255-oss-\352\270\260\354\227\254/meta.json" +++ "b/posts/waveterm-\353\262\210\354\227\255-oss-\352\270\260\354\227\254/meta.json" @@ -10,5 +10,14 @@ "Technical Writing", "Developer Experience" ], - "featured": false + "featured": false, + "visibility": "private", + "qualityReview": { + "philosophy": 2.5, + "design": 2.5, + "implementation": 2, + "brandFit": 2, + "notes": "비공개", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/\353\215\260\354\235\264\355\204\260\353\266\204\354\204\235-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-poc/meta.json" "b/posts/\353\215\260\354\235\264\355\204\260\353\266\204\354\204\235-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-poc/meta.json" index 7754b77d..802dacc3 100644 --- "a/posts/\353\215\260\354\235\264\355\204\260\353\266\204\354\204\235-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-poc/meta.json" +++ "b/posts/\353\215\260\354\235\264\355\204\260\353\266\204\354\204\235-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-poc/meta.json" @@ -9,5 +9,14 @@ "Engineering", "Review" ], - "featured": false + "featured": false, + "visibility": "public", + "qualityReview": { + "philosophy": 3.5, + "design": 3.5, + "implementation": 3, + "brandFit": 4, + "notes": "재작성 후 유지", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/\353\217\204\353\251\224\354\235\270-\354\233\214\355\201\254\354\212\244\355\216\230\354\235\264\354\212\244-\354\204\234\353\270\214\353\252\250\353\223\210-\353\217\204\354\236\205\352\270\260/meta.json" "b/posts/\353\217\204\353\251\224\354\235\270-\354\233\214\355\201\254\354\212\244\355\216\230\354\235\264\354\212\244-\354\204\234\353\270\214\353\252\250\353\223\210-\353\217\204\354\236\205\352\270\260/meta.json" index 11e30c27..ee5fdd21 100644 --- "a/posts/\353\217\204\353\251\224\354\235\270-\354\233\214\355\201\254\354\212\244\355\216\230\354\235\264\354\212\244-\354\204\234\353\270\214\353\252\250\353\223\210-\353\217\204\354\236\205\352\270\260/meta.json" +++ "b/posts/\353\217\204\353\251\224\354\235\270-\354\233\214\355\201\254\354\212\244\355\216\230\354\235\264\354\212\244-\354\204\234\353\270\214\353\252\250\353\223\210-\353\217\204\354\236\205\352\270\260/meta.json" @@ -10,5 +10,14 @@ "AI Engineering", "Developer Experience" ], - "featured": false + "featured": true, + "visibility": "public", + "qualityReview": { + "philosophy": 4, + "design": 4, + "implementation": 3.5, + "brandFit": 4, + "notes": "핵심 유지", + "reviewedAt": "2026-04-13" + } } 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" index 1b9e866b..2adcaaba 100644 --- "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" @@ -14,5 +14,7 @@ "id": "redis-deep-dive", "title": "Redis 완전정복", "order": 1 - } + }, + "visibility": "private", + "featured": false } 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" index 5d928993..c1666b2b 100644 --- "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" @@ -14,5 +14,7 @@ "id": "redis-deep-dive", "title": "Redis 완전정복", "order": 2 - } + }, + "visibility": "private", + "featured": false } 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" index 9b8c711a..15e2394e 100644 --- "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" @@ -14,5 +14,7 @@ "id": "redis-deep-dive", "title": "Redis 완전정복", "order": 3 - } + }, + "visibility": "private", + "featured": false } 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" index 546f1265..7334046d 100644 --- "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" @@ -14,5 +14,7 @@ "id": "redis-deep-dive", "title": "Redis 완전정복", "order": 4 - } + }, + "visibility": "private", + "featured": false } 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" index 22e42982..6676a4ba 100644 --- "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" @@ -14,5 +14,7 @@ "id": "redis-deep-dive", "title": "Redis 완전정복", "order": 5 - } + }, + "visibility": "private", + "featured": false } 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" index a9ebf07b..b04b1fae 100644 --- "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" @@ -14,5 +14,7 @@ "id": "redis-deep-dive", "title": "Redis 완전정복", "order": 6 - } + }, + "visibility": "private", + "featured": false } 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" index 0a8a4135..08a639b0 100644 --- "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" @@ -14,5 +14,7 @@ "id": "redis-deep-dive", "title": "Redis 완전정복", "order": 7 - } + }, + "visibility": "private", + "featured": false } 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" index a10daa65..6ea6aba8 100644 --- "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" @@ -14,5 +14,7 @@ "id": "redis-deep-dive", "title": "Redis 완전정복", "order": 8 - } + }, + "visibility": "private", + "featured": false } 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 c55384c9..f645bcb2 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" @@ -11,5 +11,14 @@ "Redis", "Data Pipeline" ], - "featured": true + "featured": false, + "visibility": "public", + "qualityReview": { + "philosophy": 3.5, + "design": 4, + "implementation": 4, + "brandFit": 4, + "notes": "핵심 유지", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/\353\262\240\355\212\270\353\202\250-\354\227\254\355\226\211-\355\233\204\352\270\260/meta.json" "b/posts/\353\262\240\355\212\270\353\202\250-\354\227\254\355\226\211-\355\233\204\352\270\260/meta.json" index a884c124..8073457b 100644 --- "a/posts/\353\262\240\355\212\270\353\202\250-\354\227\254\355\226\211-\355\233\204\352\270\260/meta.json" +++ "b/posts/\353\262\240\355\212\270\353\202\250-\354\227\254\355\226\211-\355\233\204\352\270\260/meta.json" @@ -10,5 +10,7 @@ "DaNang", "Insight", "Engineering" - ] + ], + "visibility": "public", + "featured": false } diff --git "a/posts/\353\266\200\354\236\254\354\244\221-\354\240\204\355\231\224-\354\235\274\352\263\261-\355\206\265/meta.json" "b/posts/\353\266\200\354\236\254\354\244\221-\354\240\204\355\231\224-\354\235\274\352\263\261-\355\206\265/meta.json" index 6629352f..39ab716b 100644 --- "a/posts/\353\266\200\354\236\254\354\244\221-\354\240\204\355\231\224-\354\235\274\352\263\261-\355\206\265/meta.json" +++ "b/posts/\353\266\200\354\236\254\354\244\221-\354\240\204\355\231\224-\354\235\274\352\263\261-\355\206\265/meta.json" @@ -8,5 +8,7 @@ "Family", "Reflection", "Memento Mori" - ] + ], + "visibility": "public", + "featured": false } 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 8b902afc..91d0d3af 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" @@ -10,5 +10,15 @@ "MDX", "Three.js", "Design System" - ] + ], + "visibility": "public", + "featured": false, + "qualityReview": { + "philosophy": 3, + "design": 4, + "implementation": 4, + "brandFit": 2.5, + "notes": "보조 유지", + "reviewedAt": "2026-04-13" + } } 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 15d8dd43..4f682dc5 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 집계가 왜 까다로운지부터 Flink 기반 파이프라인 설계와 테스트 전략까지 정리해요.", + "description": "CTR 집계를 운영 가능한 시스템으로 만들기 위해 워터마크, 윈도우, 지연 허용, 검증 전략을 어떻게 설계했는지 정리합니다.", "date": "2025-01-20", "category": "Tech", "tags": [ @@ -11,5 +11,14 @@ "Redis", "Data Pipeline" ], - "featured": true + "featured": true, + "visibility": "public", + "qualityReview": { + "philosophy": 3.5, + "design": 4.5, + "implementation": 4, + "brandFit": 4.5, + "notes": "파일럿 리라이트", + "reviewedAt": "2026-04-13" + } } 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/meta.json" "b/posts/\354\225\214\352\263\240\353\246\254\354\246\230-\354\213\234\352\260\201\355\231\224/meta.json" index d86c2515..dd8183f3 100644 --- "a/posts/\354\225\214\352\263\240\353\246\254\354\246\230-\354\213\234\352\260\201\355\231\224/meta.json" +++ "b/posts/\354\225\214\352\263\240\353\246\254\354\246\230-\354\213\234\352\260\201\355\231\224/meta.json" @@ -11,5 +11,14 @@ "Interview Prep", "Complete Guide" ], - "featured": true + "featured": false, + "visibility": "private", + "qualityReview": { + "philosophy": 1.5, + "design": 1.5, + "implementation": 1.5, + "brandFit": 1, + "notes": "비공개", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/\354\232\264\354\230\201-\354\236\220\353\217\231\355\231\224-\355\232\214\352\263\240/meta.json" "b/posts/\354\232\264\354\230\201-\354\236\220\353\217\231\355\231\224-\355\232\214\352\263\240/meta.json" index 99713074..02bc12fb 100644 --- "a/posts/\354\232\264\354\230\201-\354\236\220\353\217\231\355\231\224-\355\232\214\352\263\240/meta.json" +++ "b/posts/\354\232\264\354\230\201-\354\236\220\353\217\231\355\231\224-\355\232\214\352\263\240/meta.json" @@ -9,5 +9,15 @@ "Automation", "Performance", "Backoffice" - ] + ], + "visibility": "public", + "featured": false, + "qualityReview": { + "philosophy": 3.5, + "design": 3.5, + "implementation": 3, + "brandFit": 4, + "notes": "재작성 후 유지", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/\354\235\264\353\240\245\354\204\234\354\227\220-\353\214\200\355\225\234-\352\263\240\354\260\260/meta.json" "b/posts/\354\235\264\353\240\245\354\204\234\354\227\220-\353\214\200\355\225\234-\352\263\240\354\260\260/meta.json" index 782cd838..24347f69 100644 --- "a/posts/\354\235\264\353\240\245\354\204\234\354\227\220-\353\214\200\355\225\234-\352\263\240\354\260\260/meta.json" +++ "b/posts/\354\235\264\353\240\245\354\204\234\354\227\220-\353\214\200\355\225\234-\352\263\240\354\260\260/meta.json" @@ -7,5 +7,6 @@ "tags": [ "이력서" ], - "featured": true + "featured": false, + "visibility": "public" } diff --git "a/posts/\354\240\225\354\202\260-\354\236\220\353\217\231\355\231\224-\355\232\214\352\263\240/meta.json" "b/posts/\354\240\225\354\202\260-\354\236\220\353\217\231\355\231\224-\355\232\214\352\263\240/meta.json" index e78e497c..a8dce93c 100644 --- "a/posts/\354\240\225\354\202\260-\354\236\220\353\217\231\355\231\224-\355\232\214\352\263\240/meta.json" +++ "b/posts/\354\240\225\354\202\260-\354\236\220\353\217\231\355\231\224-\355\232\214\352\263\240/meta.json" @@ -9,5 +9,15 @@ "Payment", "Settlement", "Batch" - ] + ], + "visibility": "public", + "featured": true, + "qualityReview": { + "philosophy": 4, + "design": 4, + "implementation": 3.5, + "brandFit": 4.5, + "notes": "핵심 유지", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/\354\247\200\352\270\211\353\214\200\355\226\211-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" "b/posts/\354\247\200\352\270\211\353\214\200\355\226\211-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" index eba605a5..5d270d34 100644 --- "a/posts/\354\247\200\352\270\211\353\214\200\355\226\211-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" +++ "b/posts/\354\247\200\352\270\211\353\214\200\355\226\211-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" @@ -8,5 +8,15 @@ "Architecture", "Payment", "Domain Driven Design" - ] + ], + "visibility": "public", + "featured": true, + "qualityReview": { + "philosophy": 4, + "design": 4.5, + "implementation": 3.5, + "brandFit": 4.5, + "notes": "핵심 유지", + "reviewedAt": "2026-04-13" + } } diff --git "a/posts/\354\262\253-\355\232\214\354\202\254-\355\232\214\352\263\240/meta.json" "b/posts/\354\262\253-\355\232\214\354\202\254-\355\232\214\352\263\240/meta.json" index 85b04684..7ea51fb1 100644 --- "a/posts/\354\262\253-\355\232\214\354\202\254-\355\232\214\352\263\240/meta.json" +++ "b/posts/\354\262\253-\355\232\214\354\202\254-\355\232\214\352\263\240/meta.json" @@ -10,5 +10,6 @@ "Retrospective", "Work" ], - "featured": false + "featured": false, + "visibility": "public" } diff --git "a/posts/\355\216\230\354\226\264-\355\224\204\353\241\234\352\267\270\353\236\230\353\260\215-\352\263\240\354\260\260/meta.json" "b/posts/\355\216\230\354\226\264-\355\224\204\353\241\234\352\267\270\353\236\230\353\260\215-\352\263\240\354\260\260/meta.json" index 07f56f10..dac6f1b5 100644 --- "a/posts/\355\216\230\354\226\264-\355\224\204\353\241\234\352\267\270\353\236\230\353\260\215-\352\263\240\354\260\260/meta.json" +++ "b/posts/\355\216\230\354\226\264-\355\224\204\353\241\234\352\267\270\353\236\230\353\260\215-\352\263\240\354\260\260/meta.json" @@ -7,5 +7,6 @@ "tags": [ "페어 프로그래밍" ], - "featured": true + "featured": false, + "visibility": "public" } diff --git "a/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" "b/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" index 6c819182..fca2f0bd 100644 --- "a/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" +++ "b/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" @@ -9,5 +9,14 @@ "Engineering", "Review" ], - "featured": false + "featured": false, + "visibility": "public", + "qualityReview": { + "philosophy": 3.5, + "design": 4, + "implementation": 2.5, + "brandFit": 4, + "notes": "재작성 후 유지", + "reviewedAt": "2026-04-13" + } } 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" index 62b87dc1..1c238231 100644 --- "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" @@ -1,17 +1,19 @@ { - "title": "Flink Connector 실전", - "slug": "flink-connectors", - "description": "Kafka, ClickHouse, Iceberg, JDBC 등 주요 Connector의 활용법과 실전 아키텍처를 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "tags": [ - "Flink", - "Kafka", - "Connector" - ], - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 5 - } -} \ No newline at end of file + "title": "Flink Connector 실전", + "slug": "flink-connectors", + "description": "Kafka, ClickHouse, Iceberg, JDBC 등 주요 Connector의 활용법과 실전 아키텍처를 정리했습니다.", + "date": "2025-11-28", + "category": "Tech", + "tags": [ + "Flink", + "Kafka", + "Connector" + ], + "series": { + "id": "flink-mastery", + "title": "Flink 완전 정복", + "order": 5 + }, + "visibility": "private", + "featured": false +} 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" index 2860b4c5..af485726 100644 --- "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" @@ -1,17 +1,19 @@ { - "title": "DataStream API 심화", - "slug": "flink-datastream-api", - "description": "SQL로 표현하기 어려운 복잡한 스트림 처리를 위한 DataStream API의 핵심 개념과 고급 기능을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "tags": [ - "Flink", - "DataStream", - "Data Engineering" - ], - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 3 - } -} \ No newline at end of file + "title": "DataStream API 심화", + "slug": "flink-datastream-api", + "description": "SQL로 표현하기 어려운 복잡한 스트림 처리를 위한 DataStream API의 핵심 개념과 고급 기능을 정리했습니다.", + "date": "2025-11-28", + "category": "Tech", + "tags": [ + "Flink", + "DataStream", + "Data Engineering" + ], + "series": { + "id": "flink-mastery", + "title": "Flink 완전 정복", + "order": 3 + }, + "visibility": "private", + "featured": false +} 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" index efc59f8b..393322ce 100644 --- "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" @@ -1,17 +1,19 @@ { - "title": "Flink Kubernetes 운영", - "slug": "flink-kubernetes", - "description": "Kubernetes 기반 Flink 운영의 핵심 개념, 배포 전략, 장애 대응을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "tags": [ - "Flink", - "Kubernetes", - "DevOps" - ], - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 7 - } -} \ No newline at end of file + "title": "Flink Kubernetes 운영", + "slug": "flink-kubernetes", + "description": "Kubernetes 기반 Flink 운영의 핵심 개념, 배포 전략, 장애 대응을 정리했습니다.", + "date": "2025-11-28", + "category": "Tech", + "tags": [ + "Flink", + "Kubernetes", + "DevOps" + ], + "series": { + "id": "flink-mastery", + "title": "Flink 완전 정복", + "order": 7 + }, + "visibility": "private", + "featured": false +} 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" index 4ed5fd9a..fe17c959 100644 --- "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" @@ -1,17 +1,19 @@ { - "title": "성능 튜닝", - "slug": "flink-performance-tuning", - "description": "Flink의 병렬도, Operator Chain, RocksDB, Checkpoint, Backpressure 튜닝 전략을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "tags": [ - "Flink", - "Performance", - "Tuning" - ], - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 6 - } -} \ No newline at end of file + "title": "성능 튜닝", + "slug": "flink-performance-tuning", + "description": "Flink의 병렬도, Operator Chain, RocksDB, Checkpoint, Backpressure 튜닝 전략을 정리했습니다.", + "date": "2025-11-28", + "category": "Tech", + "tags": [ + "Flink", + "Performance", + "Tuning" + ], + "series": { + "id": "flink-mastery", + "title": "Flink 완전 정복", + "order": 6 + }, + "visibility": "private", + "featured": false +} 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" index 89aaa8a6..d9644c8c 100644 --- "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" @@ -1,17 +1,19 @@ { - "title": "State & 운영", - "slug": "flink-state-operations", - "description": "Flink의 State Backend, Checkpoint, Savepoint, 재시작 전략 등 운영 핵심 개념을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "tags": [ - "Flink", - "State", - "Operations" - ], - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 4 - } -} \ No newline at end of file + "title": "State & 운영", + "slug": "flink-state-operations", + "description": "Flink의 State Backend, Checkpoint, Savepoint, 재시작 전략 등 운영 핵심 개념을 정리했습니다.", + "date": "2025-11-28", + "category": "Tech", + "tags": [ + "Flink", + "State", + "Operations" + ], + "series": { + "id": "flink-mastery", + "title": "Flink 완전 정복", + "order": 4 + }, + "visibility": "private", + "featured": false +} 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" index c37c29d6..5c51ff6d 100644 --- "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" @@ -1,17 +1,19 @@ { - "title": "Table API & SQL 정복", - "slug": "flink-table-api-sql", - "description": "Flink의 고수준 추상화 레이어인 Table API/SQL로 스트림과 배치를 통합 처리하는 방법을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "tags": [ - "Flink", - "SQL", - "Data Engineering" - ], - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 2 - } -} \ No newline at end of file + "title": "Table API & SQL 정복", + "slug": "flink-table-api-sql", + "description": "Flink의 고수준 추상화 레이어인 Table API/SQL로 스트림과 배치를 통합 처리하는 방법을 정리했습니다.", + "date": "2025-11-28", + "category": "Tech", + "tags": [ + "Flink", + "SQL", + "Data Engineering" + ], + "series": { + "id": "flink-mastery", + "title": "Flink 완전 정복", + "order": 2 + }, + "visibility": "private", + "featured": false +} 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" index 5807a71a..eb1d16b3 100644 --- "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" @@ -1,17 +1,19 @@ { - "title": "Flink 고급 기능", - "slug": "flink-advanced-features", - "description": "CEP, BroadcastState, Async I/O 심화, Watermark 튜닝 등 대규모 서비스를 위한 고급 기능을 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "tags": [ - "Flink", - "CEP", - "Advanced" - ], - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 8 - } -} \ No newline at end of file + "title": "Flink 고급 기능", + "slug": "flink-advanced-features", + "description": "CEP, BroadcastState, Async I/O 심화, Watermark 튜닝 등 대규모 서비스를 위한 고급 기능을 정리했습니다.", + "date": "2025-11-28", + "category": "Tech", + "tags": [ + "Flink", + "CEP", + "Advanced" + ], + "series": { + "id": "flink-mastery", + "title": "Flink 완전 정복", + "order": 8 + }, + "visibility": "private", + "featured": false +} 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" index 25c28fe5..0c732ff6 100644 --- "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" @@ -1,17 +1,19 @@ { - "title": "Flink 기본 개념", - "slug": "flink-basics", - "description": "Flink의 스트림 처리 철학, Runtime 구조, 시간 개념, Stateful 처리, Checkpoint, Exactly-once 보장 원리를 정리했습니다.", - "date": "2025-11-28", - "category": "Tech", - "tags": [ - "Flink", - "Stream Processing", - "Data Engineering" - ], - "series": { - "id": "flink-mastery", - "title": "Flink 완전 정복", - "order": 1 - } -} \ No newline at end of file + "title": "Flink 기본 개념", + "slug": "flink-basics", + "description": "Flink의 스트림 처리 철학, Runtime 구조, 시간 개념, Stateful 처리, Checkpoint, Exactly-once 보장 원리를 정리했습니다.", + "date": "2025-11-28", + "category": "Tech", + "tags": [ + "Flink", + "Stream Processing", + "Data Engineering" + ], + "series": { + "id": "flink-mastery", + "title": "Flink 완전 정복", + "order": 1 + }, + "visibility": "private", + "featured": false +} 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 8634b515..cf922e84 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" @@ -9,5 +9,6 @@ "Work", "Book" ], - "featured": false + "featured": false, + "visibility": "public" } From 2346a5e89539834a9cc705cab7fa4cfb959f316d Mon Sep 17 00:00:00 2001 From: dev-wooyeon Date: Mon, 13 Apr 2026 15:21:21 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs(blog):=20CTR=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=9F=BF=20=ED=8F=AC=EC=8A=A4=ED=8C=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CTR 파일럿 포스팅을 포트폴리오 관점으로 다시 구성했어요.\n트레이드오프, 운영 비용, 학습 과정의 기준 변화가 드러나도록 본문을 정리했어요.\n같은 기준을 재사용할 수 있도록 블로그 품질 가이드도 추가했어요. --- docs/blog-quality-guide.md | 79 +++ .../index.mdx" | 630 +++++++++--------- 2 files changed, 392 insertions(+), 317 deletions(-) create mode 100644 docs/blog-quality-guide.md diff --git a/docs/blog-quality-guide.md b/docs/blog-quality-guide.md new file mode 100644 index 00000000..3d852b09 --- /dev/null +++ b/docs/blog-quality-guide.md @@ -0,0 +1,79 @@ +# 블로그 품질 가이드 + +## 브랜드 문장 + +새로운 비즈니스 확장으로 복잡해지는 내부 시스템을 정리하고, 레거시를 구조적으로 개선하는 플랫폼 엔지니어 + +## 기본 원칙 + +- 기술 글은 감상보다 판단 기준이 먼저 보여야 한다. +- 구현 자랑보다 문제 정의와 대안 비교가 먼저 나와야 한다. +- 글 하나가 읽히고 끝나면 안 된다. 같은 문제를 다시 만났을 때 재사용 가능한 기준이 남아야 한다. +- 공개 글은 포트폴리오다. 초안이나 메모 수준의 글은 `private`로 둔다. + +## 기술 글 톤 + +- 기본 문체는 단정형 `하다체`로 쓴다. +- 도입과 마무리만 제한적으로 부드럽게 쓸 수 있다. +- "배웠다", "느꼈다"는 문장만으로 끝내지 않는다. 무엇이 바뀌었는지까지 적는다. +- 추상 표현 대신 수치, 조건, 실패 사례를 우선한다. + +## 공개용 엔지니어링 글 템플릿 + +### 1. 문제와 비즈니스 맥락 + +- 왜 이 문제가 운영 또는 비즈니스 비용으로 이어졌는가 +- 누가 이 결과를 소비하는가 + +### 2. 시스템 요구사항과 제약 + +- 성능, 정확성, 복구, 운영 조건 +- 리소스 한계나 조직 제약 + +### 3. 대안 비교와 선택 근거 + +- 실제로 검토한 대안 2개 이상 +- 버린 대안과 이유 + +### 4. 실제 아키텍처와 구현 + +- 데이터 흐름 +- 핵심 상태 모델 +- 중요한 설정값과 선택 이유 + +### 5. 실패와 수정 + +- 장애, 병목, 잘못된 가정 +- 어떻게 관측했고 어떻게 수정했는가 + +### 6. 검증과 결과 + +- 단위 테스트 +- 통합 테스트 +- 운영 확인 방식 +- 수치 결과 + +### 7. 재사용 가능한 판단 기준 + +- 다음에도 그대로 가져갈 기준 +- 아직 남은 한계 + +## 공개 정책 + +- `visibility` 기본값은 `public`이다. +- 시리즈는 기본적으로 공개 자산이 아니라 학습 자산으로 보고, 공개 필요성이 생기기 전까지 `private`로 둔다. +- 엔지니어링 글은 `philosophy`, `design`, `implementation` 평균이 `3.0` 이하이면 `private`로 둔다. +- `featured`는 아래 조건을 모두 만족할 때만 허용한다. + - 엔지니어링 글 + - 시리즈 아님 + - `brandFit >= 4.0` +- `featured`는 점수만으로 자동 결정하지 않는다. 현재 단계에서는 수동 큐레이션 목록으로 관리한다. + +## 리뷰 체크리스트 + +- 첫 세 문단 안에 문제와 비용이 드러나는가 +- 대안 비교가 실제로 존재하는가 +- 설정값과 구조가 왜 그렇게 됐는지 설명하는가 +- 장애 또는 실패 장면이 포함되어 있는가 +- 결과가 수치 또는 운영 변화로 확인되는가 +- 읽고 나면 한 문장으로 재사용 가능한 기준이 남는가 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/index.mdx" "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/index.mdx" index 93e934aa..7ee0ca39 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/index.mdx" +++ "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/index.mdx" @@ -1,167 +1,289 @@ -# 1. 프롤로그 +데이터 엔지니어링 역량을 강화하는 과정에서 1BRC 같은 배치 중심 실험을 해봤지만, 기술이 돌아가는지만 확인하는 방식으로는 부족하다고 느꼈다. +핵심은 결과 자체보다 설계부터 운영 관측, 병목 분석, 복구 가능성까지 포함해 하나의 시스템을 끝까지 책임지며 현재 시스템에 맞는 기준을 스스로 세워보는 데 있다고 봤다. -데이터 엔지니어링에 관심을 갖고 10억 행 챌린지(1BRC)를 해보면서, 단순히 기술이 돌아가는지만 확인하는 방식으로는 부족하다는 걸 느꼈어요. +그 과정에서 특히 끌린 주제가 스트리밍 처리였다. +정확한 집계, 지연 이벤트 처리, 상태 보존, 운영 안정성을 한 번에 검증할 수 있기 때문이다. 그래서 스트림 처리의 핵심 문제가 비교적 압축적으로 드러나는 CTR 집계를 개인 프로젝트 주제로 잡았다. -핵심은 결과 자체보다 설계부터 운영까지 책임지며, 현재 시스템에 맞는 기준을 스스로 세울 수 있어야 한다는 점이었어요. +실시간 CTR 집계는 단순한 카운터 문제가 아니었다. +조회와 클릭 이벤트는 같은 시점에 도착하지 않았고, 파티션 분포도 고르지 않았으며, 집계 결과는 운영 화면과 분석 저장소가 동시에 신뢰할 수 있어야 했다. -그 과정에서 스트리밍 데이터를 다루는 구조에 특히 끌렸어요. -정확한 집계, 지연 이벤트 처리, 운영 안정성까지 함께 다뤄야 했기 때문이에요. +이 글은 Flink 기반 CTR 파이프라인을 개인 프로젝트로 설계하면서 어떤 제약을 먼저 정의했고, 워터마크와 윈도우를 어떤 기준으로 결정했으며, 실제 병목을 어떻게 확인하고 수정했는지 정리한 기록이다. -그래서 광고 도메인의 CTR을 주제로 잡았어요. -이 글에서는 CTR 집계가 왜 스트림 처리에서 까다로운지, 어떤 기준으로 설계와 운영 결정을 내렸는지, 실제 구성과 테스트 전략까지 순서대로 정리해요. +![CTR 파이프라인 전체 구조](/images/posts/실시간-CTR-파이프라인-구축기/c6caa47f9482.png) -## 1.1 기능 파악 +프로젝트 저장소: [ctr-pipeline](https://github.com/dev-wooyeon/ctr-pipeline) -CTR은 조회수와, 클릭의 각 데이터가 카프카 이벤트가 들어오면 서버에서 수신하고 데이터를 가공해요. +## 왜 CTR 집계가 운영 문제인가 -그러나 이벤트는 항상 시간 순서대로 도착하지 않고, 네트워크 지연과 파티션 편향 문제도 존재한다는 점을 파악했어요. +CTR은 보통 아래 두 이벤트를 합쳐 계산한다. -따라서 정확한 윈도우 집계, 지연 이벤트 처리, 높은 처리량·낮은 지연을 동시에 만족하는 것이 중요하다고 판단했어요. +- `impression`: 노출 +- `click`: 클릭 -## 1.2 아키텍처 설계 +문제는 이 두 이벤트가 항상 같은 순서로, 같은 지연으로 들어오지 않는다는 점이다. -![](/images/posts/실시간-CTR-파이프라인-구축기/c6caa47f9482.png) +- 네트워크 지연으로 클릭이 더 늦게 도착할 수 있다. +- Kafka 파티션 분포가 한쪽으로 쏠릴 수 있다. +- 집계는 짧은 윈도우 단위로 안정적으로 잘려야 한다. +- 중복 또는 누락은 CTR 오차로 바로 이어진다. -## 1.3 프로젝트 저장소 +즉, 이 시스템의 핵심은 "숫자를 빨리 계산하는 것"이 아니라 아래 조건을 동시에 만족시키는 것이었다. -https://github.com/dev-wooyeon/demo-flink-product +- 이벤트 순서가 어긋나도 결과를 설명할 수 있어야 한다. +- 지연 이벤트를 받되, 레이턴시는 통제해야 한다. +- 운영 API와 분석 저장소가 같은 결과를 기준으로 움직여야 한다. +- 병목이 생겼을 때 Flink UI와 로그만으로 원인을 좁힐 수 있어야 한다. ---- +## 잘못 설계하면 어떤 비용이 생기는가 -# 2. 핵심 개념 +개인 프로젝트였지만, 의미 있는 검증을 하려면 운영 문제를 함께 가정해야 했다. -## 2.1 CTR 계산이 스트림 처리에서 어려운 이유 +- 지연 클릭을 충분히 흡수하지 못하면 저성과 상품과 고성과 상품이 뒤바뀐 것처럼 보일 수 있다. +- 집계 결과가 서빙 API와 분석 저장소에서 다르게 보이면, 숫자 자체보다 시스템 신뢰가 먼저 무너진다. +- 파티션 분포와 병렬도가 맞지 않으면 처리량이 떨어지는 것보다 원인 추적 비용이 더 커진다. +- 체크포인트가 불안정하면 장애 이후 어디까지 안전하게 처리됐는지 설명할 수 없게 된다. -1. Impression·Click 간의 시계열 상관성이 필요해요. +결국 CTR 오차는 단순한 비율 계산 실수가 아니라, 잘못된 운영 판단과 디버깅 비용으로 이어질 수 있다. +그래서 이 프로젝트에서는 빠른 데모보다 설명 가능한 결과와 복구 가능한 상태를 우선순위에 뒀다. -2. 이벤트가 지연되거나 순서가 뒤바뀌어 도착할 수 있어요. +## 시스템 요구사항과 제약 -3. CTR 계산은 짧은 시간 창(window) 단위로 이루어져야 해요. +| 항목 | 기준 | +| --- | --- | +| 입력 소스 | Kafka `impression`, `click` 토픽 | +| 집계 기준 | 상품 단위 CTR | +| 목표 처리 | 초당 수천 건 이상 안정 처리 | +| 정확성 | 중복/누락에 민감하므로 Exactly-once 우선 | +| 지연 허용 | 늦게 도착한 이벤트를 일부 흡수해야 함 | +| 결과 소비처 | Redis, ClickHouse, DuckDB, FastAPI | +| 실험 환경 | MacBook Air M1, 메모리 제약이 있는 로컬 환경 | -4. Out-of-order 이벤트를 배제하면 정확성이 떨어지고, 과도하게 허용하면 지연(latency)이 증가해요. +여기서 중요한 제약은 "운영 수준의 판단을 로컬 환경에서도 검증해야 한다"는 점이었다. +리소스가 넉넉한 환경에서만 성립하는 구조는 초기에 잘못된 자신감을 만들 수 있다고 봤다. -## 2.2 이벤트 시간(Event Time) 기반 처리 +### 실험 환경 상세 -Flink는 Processing Time 대신 Event Time 기반 처리를 권장해요. +초기 실험은 아래 조합으로 진행했다. -본 파이프라인은 아래 기준으로 설계했어요. +- Kafka 3 broker +- Flink JobManager 1, TaskManager 1 +- Redis + RedisInsight +- ClickHouse +- FastAPI serving layer +- Superset -1. 워터마크(Watermark): 최대 5초 지연을 허용 +즉, "단순히 Flink job 하나만 돌린다"가 아니라, 실제로 결과를 읽고 확인하는 운영 구성까지 함께 올린 상태에서 검증했다. -2. 텀블링 윈도우(Tumbling Window): 10초 +## 설계 결정 요약 -3. Allowed Lateness: 추가 5초 허용 +| 의사결정 | 선택 | 이유 | +| --- | --- | --- | +| 시간 기준 | Event Time | 실제 발생 시각 기준으로 집계해야 정확도를 설명할 수 있기 때문이다. | +| 윈도우 | 10초 Tumbling Window | 5초는 변동성이 컸고, 30초는 응답성이 너무 늦었다. | +| 워터마크 | Out-of-Order 5초 | 정확도와 레이턴시를 동시에 맞춘 실험값이었다. | +| 추가 지연 허용 | Allowed Lateness 5초 | 늦게 도착한 이벤트를 일정 수준 흡수하기 위해 필요했다. | +| 상태 보존 | Checkpoint + Exactly-once | CTR 오차를 작게 보지 않기 위해 기본 전제로 잡았다. | +| 싱크 전략 | Redis + ClickHouse + DuckDB | 서빙, 분석, 로컬 검증 목적을 분리했다. | +| 병렬도 기준 | Kafka 파티션 수와 정합 유지 | Backpressure를 줄이기 위해 처리 단계의 균형을 맞췄다. | -이 조합의 근거는 뒤의 Decision 섹션에서 상세히 설명해요. +이 프로젝트에서 중요한 점은 "Flink를 썼다"가 아니었다. +각 파라미터가 어떤 운영 문제를 해결하기 위해 존재하는지 설명할 수 있게 만드는 것이 더 중요했다. -## 2.3 상태(State) 기반 집계 +## 시스템 구성 -CTR 계산의 기초 단위는 EventCount(Impression, Click 누적)예요. +### 전체 흐름 -윈도우마다 EventCount 상태가 만들어지고 업데이트돼요. +```text +Impressions / Clicks + -> Kafka + -> Flink + -> Redis / ClickHouse / DuckDB + -> FastAPI +``` -코드는 Reference 섹션에서 제공해요. +운영 확인도 함께 하기 위해 Kafka UI와 RedisInsight를 붙였다. +스트림 시스템은 내부 상태를 보지 못하면 장애 재현이 어려워지기 때문에, 초기부터 관측 도구를 같이 두는 편이 낫다고 판단했다. -## 2.4 Exactly-Once보장 +### 애플리케이션 구조 -CTR은 작은 누락이나 중복도 큰 오차가 되므로 Exactly-Once 처리 모드는 필수예요. +```text +flink-app/src/main/kotlin/com/example/ctr/ +├── domain/ +│ ├── model/ +│ │ ├── Event.kt +│ │ ├── EventCount.kt +│ │ └── CTRResult.kt +│ └── service/ +│ ├── EventCountAggregator.kt +│ └── CTRResultWindowProcessFunction.kt +├── application/ +│ └── CtrJobService.kt +├── infrastructure/ +│ ├── flink/ +│ │ ├── source/ +│ │ └── sink/ +│ └── config/ +└── CtrApplication.kt +``` -본 프로젝트는 다음을 기반으로 Exactly-Once를 보장해요. +구조는 의도적으로 세 층으로 나눴다. -1. Kafka offset을 체크포인트에 포함 +- `domain`: 순수 집계 규칙 +- `application`: Flink job orchestration +- `infrastructure`: Kafka, Redis, ClickHouse, DuckDB 연동 -2. Flink CheckpointingMode EXACTLY_ONCE +이렇게 나누면 집계 규칙 검증과 외부 시스템 검증을 분리할 수 있다. -3. RETAIN_ON_CANCELLATION으로 안전한 상태 보존 +## 데이터 흐름과 상태 관리 -![](/images/posts/실시간-CTR-파이프라인-구축기/8dd7886a1d3c.png) +파이프라인의 기본 흐름은 아래와 같다. ---- +1. `impression`과 `click` 이벤트를 Kafka로 수집한다. +2. Flink가 이벤트 시간을 기준으로 토픽을 소비한다. +3. 상품 단위로 `keyBy` 한 뒤 10초 단위로 집계한다. +4. 결과를 Redis, ClickHouse, DuckDB에 각각 쓴다. +5. FastAPI는 Redis를 통해 최신 결과를 제공한다. -# 3. 설계 의사결정 +### 이벤트 시간과 윈도우 상태 -## 3.1 윈도우 길이 선택 +CTR 계산은 윈도우마다 아래 상태를 유지하는 방식으로 구현했다. -아래는 실제 실험 기반 의사결정이에요. +```kotlin +data class EventCount( + val productId: String, + val impressions: Long, + val clicks: Long, + val windowStart: Long, + val windowEnd: Long +) +``` + +이 모델은 단순하지만 중요한 장점이 있었다. + +- 집계 단위를 명확하게 표현할 수 있다. +- 결과 저장소가 달라도 동일한 계약으로 전달할 수 있다. +- 테스트에서 순수 로직 검증이 쉬워진다. -1. 5초 +### Exactly-once 경계 - 노이즈가 크고 CTR 값 변동이 지나치게 민감했어요. +CTR은 "한두 건 정도 차이"를 허용하기 어려운 지표였다. +그래서 초기부터 아래 조합을 기본값으로 잡았다. -2. 10초 +```kotlin +env.enableCheckpointing(10_000) +env.checkpointConfig.checkpointingMode = CheckpointingMode.EXACTLY_ONCE +env.checkpointConfig.externalizedCheckpointCleanup = + CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION +``` - 지연·정확성·계산 안정성 비교적 균형적이여서 최종 선택했어요. +![Exactly-once 보장 구성](/images/posts/실시간-CTR-파이프라인-구축기/8dd7886a1d3c.png) -3. 30초 +이 선택은 처리량보다 복구 가능성과 재현 가능성을 우선한 결정이었다. +체크포인트를 보존해두면 장애 후에도 "어디까지 안전하게 처리됐는가"를 판단할 수 있다. - 응답성이 너무 낮고 실시간 모니터링 용도로 부적합해 보였어요. +## 파라미터를 이렇게 결정했다 -## 3.2 Allowed Lateness 5초 +### 10초 윈도우 -지연 이벤트를 수용하지 않으면 CTR 정확도가 떨어졌어요. +실험 초기에 5초, 10초, 30초를 비교했다. -5초는 실제 네트워크 지연 분포에서 95% 지점에 해당하여 선택했어요. +- 5초: CTR 값이 지나치게 흔들려 운영 지표로 보기 어려웠다. +- 10초: 응답성과 안정성의 균형이 가장 좋았다. +- 30초: 지표 안정성은 좋았지만 실시간 모니터링 용도로는 너무 느렸다. -## 3.3 워터마크 Out-of-Order 5초 +여기서 배운 점은 분명했다. +윈도우 길이는 기술 파라미터가 아니라 "누가 어떤 속도로 결과를 소비하는가"에 맞춰 정해야 한다. -워터마크 지연을 늘리면 정확도는 증가하지만 레이턴시는 증가해요. +### 워터마크 5초 -본 파이프라인은 Throughput–Latency 트레이드오프 상 최적값을 5초로 설정했어요. +워터마크를 너무 짧게 두면 늦게 도착한 클릭을 버리게 되고, 너무 길게 두면 결과가 너무 늦어진다. +이번 실험에서는 5초가 가장 현실적인 균형점이었다. -## 3.4 멀티 싱크 전략 선택 +- 5초 미만: 지연 이벤트 손실이 눈에 띄게 늘었다. +- 5초: 정확도 저하를 줄이면서 결과 반영 속도도 유지했다. +- 5초 초과: 레이턴시 증가 대비 체감 이득이 크지 않았다. -싱크는 Redis, ClickHouse, DuckDB의 세 가지로 분리하는 방식으로 전략을 설정하였습니더. +### Allowed Lateness 5초 -1. Redis +워터마크만으로는 늦게 온 이벤트를 충분히 흡수하기 어려웠다. +그래서 추가로 5초를 더 허용해 늦은 클릭 일부를 집계에 반영했다. - 서빙(실시간 API)용. 빠른(밀리초) 응답. +핵심은 "무한히 기다리는 것"이 아니라 "운영상 설명 가능한 선에서만 기다리는 것"이었다. -2. ClickHouse +## 트레이드오프를 비교하며 정리한 선택 기준 - OLAP 및 히스토리 분석용. (MergeTree Engine을 사용) +이번 글에서 적은 최종 구성은 처음부터 정답으로 정해져 있던 것이 아니었다. +현재 남아 있는 기록 기준으로, 당시에는 여러 선택지를 비교하며 어떤 트레이드오프를 감수할지 먼저 정리해야 했다. -3. DuckDB +| 선택지 | 고민한 트레이드오프 | 최종 판단 | +| --- | --- | --- | +| 5초 윈도우 | CTR 값 변동이 지나치게 커서 운영 지표로 보기 어려웠다. | 10초로 늘려 응답성과 안정성의 균형을 맞췄다. | +| 30초 윈도우 | 지표는 더 안정적이지만 실시간 모니터링 용도로는 반응이 너무 늦었다. | 10초 윈도우를 유지했다. | +| 더 짧은 워터마크 | 늦게 도착한 클릭 손실이 늘어 정확도 설명이 어려워졌다. | Out-of-Order 5초를 택했다. | +| Processing Time 중심 집계 | 네트워크 지연이나 시스템 부하에 따라 늦은 이벤트가 쉽게 제외될 수 있었다. | Event Time 기준으로 고정했다. | +| 단일 결과 저장소 | 빠른 조회는 가능해도 분석, 교차 검증, 로컬 디버깅을 한 번에 만족시키기 어려웠다. | Redis, ClickHouse, DuckDB로 역할을 분리했다. | +| 파티션-병렬도 불일치 | Backpressure가 생겨도 어느 단계에서 막히는지 읽기가 어려웠다. | Kafka 파티션과 Flink, 싱크 병렬도를 정합시켰다. | - 로컬 개발, 디버깅용 +## 운영 중 실제로 부딪힌 병목 -## 3.5 병렬도(Parallelism)와 파티션 대응 +### 1. Backpressure -Kafka partitions = Flink 병렬도 = Sink 병렬도 +가장 먼저 드러난 문제는 Backpressure였다. +Kafka 파티션, Flink 병렬도, 싱크 병렬도의 균형이 깨지면 특정 구간에서 처리량이 급격히 떨어졌다. -이 정합이 깨지는 순간 Backpressure가 발생한다는 점을 알 수 있었어요. +![Flink Backpressure 확인 화면](/images/posts/실시간-CTR-파이프라인-구축기/7697dd66f3bb.png) -실험 결과로, +당시 로컬에서 확인한 대표 컨테이너 상태는 아래와 같았다. -Parallelism 1에서는 Redis·ClickHouse가 포화되어 지연 급증했어요. +| 이름 | CPU | 메모리 | +| --- | --- | --- | +| `kafka1` | 77.39% | 345.4MiB | +| `kafka2` | 127.08% | 330.2MiB | +| `kafka3` | 41.49% | 293.9MiB | +| `flink-taskmanager` | 55.83% | 756.4MiB | +| `flink-jobmanager` | 1.89% | 767.4MiB | +| `clickhouse` | 8.72% | 310.9MiB | +| `ctr-api` | 0.60% | 23.31MiB | -하드웨어는 `Macbook Air M1`, `Memory 16GB` 였어요. +로컬 환경에서도 이미 TaskManager와 JobManager가 각각 700MiB 이상을 쓰고 있었고, Kafka broker 3개까지 같이 떠 있는 상태였다. +즉, 단순히 "맥북이라 느리다"가 아니라, 실제로 전체 파이프라인 구성이 자원 압박을 만들고 있다는 점이 먼저 확인됐다. -### 3.5.1 당시 도커 컨테이너 상태 +Flink UI 기준으로는 초당 약 812건 처리, 25분 실행 시 약 120만 건 처리가 가능했다. +문제는 평균 처리량보다도 특정 구간에서 병목이 몰릴 때 전체 파이프라인이 급격히 흔들린다는 점이었다. -| CONTAINER ID | NAME | CPU % | MEM USAGE / LIMIT | MEM % | NET I/O | BLOCK I/O | -| ------------ | ----------------- | ------- | ------------------- | ------ | --------------- | --------------- | -| 4e796f1fb8cb | kafka-ui | 0.16% | 307.5MiB / 3.827GiB | 7.85% | 1.14MB / 2.85MB | 186MB / 117MB | -| cfef347c1c0b | kafka2 | 127.08% | 330.2MiB / 3.827GiB | 8.43% | 859MB / 929MB | 33.2MB / 319MB | -| 4288ee2e1646 | kafka3 | 41.49% | 293.9MiB / 3.827GiB | 7.50% | 698MB / 552MB | 34.4MB / 318MB | -| fbf6b8aa4bc4 | kafka1 | 77.39% | 345.4MiB / 3.827GiB | 8.81% | 811MB / 817MB | 81.9MB / 312MB | -| fb27f07ba10e | superset | 1.26% | 101.1MiB / 3.827GiB | 2.58% | 53.9kB / 2.3MB | 391MB / 163MB | -| 678f5e428704 | ctr-api | 0.60% | 23.31MiB / 3.827GiB | 0.59% | 261kB / 272kB | 60.9MB / 43.4MB | -| 45b518fceab4 | flink-taskmanager | 55.83% | 756.4MiB / 3.827GiB | 19.30% | 686MB / 262MB | 175MB / 332MB | -| 46ebe361fcda | redisinsight | 0.00% | 26.65MiB / 3.827GiB | 0.68% | 1.03MB / 65.8kB | 151MB / 67.2MB | -| c207c6890b09 | redis | 0.51% | 1.832MiB / 3.827GiB | 0.05% | 264kB / 252kB | 19.5MB / 2.98MB | -| d9bd0b068af2 | flink-jobmanager | 1.89% | 767.4MiB / 3.827GiB | 19.58% | 168MB / 168MB | 458MB / 459MB | -| ab8c8cfa95c5 | clickhouse | 8.72% | 310.9MiB / 3.827GiB | 7.93% | 293kB / 209kB | 558MB / 212MB | -| 5f09c7acfd18 | zookeeper | 7.05% | 80.93MiB / 3.827GiB | 2.07% | 228kB / 203kB | 70.2MB / 36.2MB | +이 시점의 대응은 두 단계였다. -### 3.5.2 Flink BackPressure 확인 +1. 파티션 수를 3에서 6, 다시 12까지 늘렸다. +2. Flink와 싱크 병렬도를 파티션 수와 맞췄다. -![](/images/posts/실시간-CTR-파이프라인-구축기/7697dd66f3bb.png) +이 변경 이후, 병목은 "Flink가 느리다"가 아니라 "정합이 깨진 단계가 있다"는 식으로 더 정확히 읽히기 시작했다. -Flink UI를 확인 했을 때 초당 812건의 데이터를 처리할 수 있는 것을 확인할 수 있었고, 25분 정도 돌려 보았을 때 120만건 정도 처리할 수 있었어요. +### 2. Serving API 병목 -### 3.5.3 K6 Serving API 부하 테스트 +로컬 부하 테스트에서는 집계보다 서빙 계층이 먼저 흔들렸다. +부하 테스트 조건은 아래와 같았다. + +- 대상: FastAPI 기반 CTR 조회 API +- 가상 사용자 수: 최대 20 VUs +- 목표: `p(95) < 1000ms` +- 실행 시간: 35초 + +요약 수치만 보면 아래와 같다. + +```text +p(95) = 6.41s +avg = 1.89s +dropped_iterations = 127 +http_req_failed = 0.00% ``` + +즉, 오류는 없었지만 느려서 목표 SLO를 전혀 맞추지 못한 상태였다. + +
+K6 raw output + +```text █ THRESHOLDS http_req_duration @@ -179,15 +301,11 @@ Flink UI를 확인 했을 때 초당 812건의 데이터를 처리할 수 있는 checks_succeeded...: 100.00% 450 out of 450 checks_failed......: 0.00% 0 out of 450 - ✓ status is 200 - ✓ json body is present - CUSTOM response_time..................: avg=1.9s min=6ms med=1.12s max=8.42s p(90)=5.37s p(95)=6.41s HTTP http_req_duration..............: avg=1.89s min=6.09ms med=1.12s max=8.42s p(90)=5.37s p(95)=6.41s - { expected_response:true }...: avg=1.89s min=6.09ms med=1.12s max=8.42s p(90)=5.37s p(95)=6.41s http_req_failed................: 0.00% 0 out of 225 http_reqs......................: 225 6.02747/s @@ -196,177 +314,65 @@ Flink UI를 확인 했을 때 초당 812건의 데이터를 처리할 수 있는 iteration_duration.............: avg=1.9s min=6.78ms med=1.13s max=8.43s p(90)=5.38s p(95)=6.41s iterations.....................: 225 6.02747/s vus............................: 20 min=0 max=20 - vus_max........................: 20 min=6 max=20 - - NETWORK - data_received..................: 194 kB 5.2 kB/s - data_sent......................: 18 kB 482 B/s running (0m37.3s), 00/20 VUs, 225 complete and 0 interrupted iterations ctr_load ✓ [======================================] 00/20 VUs 35s 10.00 iters/s ``` -로컬 환경이라 서빙 API의 응답이 1초내로 동작하는 것을 기대했지만 노트북이 혹사당하여 응답 자체가 지연된다는 점을 확인할 수 있었어요. - ---- - -# 4. 시스템 구성 - -![](/images/posts/실시간-CTR-파이프라인-구축기/a5cac3289908.png) - -## 4.1 전체 데이터 흐름 - -Impressions/Clicks → Kafka → Flink → Redis/ClickHouse/DuckDB → FastAPI - -![](/images/posts/실시간-CTR-파이프라인-구축기/99dd367bd82c.png) - -Flink는 웹 UI를 제공하고, Kafka랑 redis는 UI를 제공하지 않기 때문에, 직접 눈으로 보고 확인 할 수 있도록 각 provectuslabs/kafka-ui와 redis/redisinsight를 사용하여 확인 할 수 있었어요. - -## 4.2 Flink 파이프라인 단계 - -![](/images/posts/실시간-CTR-파이프라인-구축기/bfc4e38df326.png) - -이벤트 생성부터 API 응답까지의 전체 흐름은, - -Impression과 Click 이벤트가 각각 0.001초, 0.002초 간격으로 생성되어 Kafka 토픽으로 전송돼요. - -이후 Flink가 두 토픽의 데이터를 소비해서 10초 윈도우 단위로 CTR을 계산하고, 결과를 Redis에 저장해요. - -저장된 Redis의 결과는 FastAPI를 통해 Redis에서 데이터를 읽어 REST API로 제공하는 흐름이에요. - -## 4.3 Flink 구성 - -### 4.3.1 핵심 설정 - -- 윈도우: 10초 Tumbling Window -- 시간 기준: Event Time -- 워터마크: 2초 -- Allowed Lateness: 5초 - -CTR 같이 집계하는 비지니스는 이벤트 발생 시간으로 집계하는 것이 맞다고 판단했어요. - -Processing TIme을 사용하면 네트워크 지연이나 시스템 부하로인해 늦게 도착하는 이벤트들이 제외되거나 예외 사항이 발생할 수 있기 때문이에요. - -Event Time을 사용하였으므로, 집계 시작 시간을 지정하기 위해 WaterMark를 2초로 설정하였고, Allowed Lateness는 5초를 할당했어요. - -### 4.3.2 디렉토리 구조 - -```bash -flink-app/src/main/kotlin/com/example/ctr/ -├── domain/ # 순수 비즈니스 로직 -│ ├── model/ -│ │ ├── Event.kt # 이벤트 도메인 모델 -│ │ ├── EventCount.kt # 집계 상태 -│ │ └── CTRResult.kt # CTR 계산 결과 -│ └── service/ -│ ├── EventCountAggregator.kt # 이벤트 집계 -│ └── CTRResultWindowProcessFunction.kt # 윈도우 처리 -├── application/ # 애플리케이션 서비스 -│ └── CtrJobService.kt # Flink Job 오케스트레이션 -├── infrastructure/ # 외부 시스템 연동 -│ ├── flink/ -│ │ ├── source/ -│ │ │ ├── KafkaSourceFactory.kt -│ │ │ └── deserializer/ -│ │ │ └── EventDeserializationSchema.kt -│ │ └── sink/ -│ │ ├── RedisSink.kt -│ │ ├── ClickHouseSink.kt -│ │ └── DuckDBSink.kt -│ └── config/ # 설정 -│ ├── CtrJobProperties.kt -│ ├── KafkaProperties.kt -│ └── RedisProperties.kt -└── CtrApplication.kt -``` - -## 4.4 인프라 구성 - -**로컬 개발 환경과 프로덕션 환경의 일관성을 보장하기 위해 Docker Compose 기반으로 작성했어요.** - -```yaml -services: - # Kafka 클러스터 (3 브로커) - kafka1: - image: confluentinc/cp-kafka:7.4.0 - environment: - KAFKA_BROKER_ID: 1 - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3 - - # Flink 클러스터 - flink-jobmanager: - image: flink:1.18-scala_2.12-java17 - volumes: - - ./flink-app/build/libs:/opt/flink/usrlib # ShadowJar로 Flink에 배포 가능한 단일 JAR 생성. - - # Redis (서빙 레이어) - redis: - image: redis:7.0-alpine - command: redis-server --appendonly yes - - # ClickHouse (분석 DB) - clickhouse: - image: clickhouse/clickhouse-server:23.8 -``` - ---- - -# 5. 운영 및 장애 대응 +
-## 5.1 Backpressure 모니터링 +원인은 단순했다. -![](/images/posts/실시간-CTR-파이프라인-구축기/4a2f8371c29f.png) +- Redis 조회 자체보다 API 계층과 직렬화 비용이 컸다. +- 로컬 환경에서 불필요한 계층이 전체 실험 속도를 잡아먹고 있었다. -**실험 결과** +이 문제는 이후 성능 개선 단계에서 Redis와 Serving API 구조를 다시 점검하는 계기가 됐다. +즉, 초기 아키텍처는 "기능적으로 돌아가는가"를 증명했지만, 운영 경로를 최적화하려면 서빙 계층 자체를 다시 설계해야 했다. -1. Kafka 소비 지연 증가 +### 3. 체크포인트 장애와 수정 과정 -2. Redis RTT 증가 +기존 기록에는 배포 직후 체크포인트 타임아웃이 연속으로 발생했다는 내용이 남아 있다. +정확한 timeout 수치까지 보존되지는 않았지만, 당시 핵심 문제는 로컬 자원 제약 환경에서 체크포인트 주기, 병렬도, 윈도우 상태 크기가 함께 맞물리며 복구 경계가 불안정해졌다는 점이었다. -3. ClickHouse INSERT 지연 +당시에는 아래 세 가지 방향으로 조정했다. -**조치 방법** +1. 체크포인트 주기와 timeout을 완화했다. +2. 병렬도를 다시 맞춰 특정 단계에 부하가 몰리지 않게 조정했다. +3. 윈도우 상태를 줄여 체크포인트가 잡아야 할 상태 크기를 줄였다. -1. 병렬도 증가 +이 사례가 중요한 이유는 성능 문제가 아니라 신뢰성 문제였기 때문이다. +Exactly-once를 선언하는 것과 실제로 복구 가능한 상태를 유지하는 것은 별개의 문제였고, 체크포인트가 흔들리면 그 차이가 바로 드러났다. -2. Redis pipeline 적용 +### 4. 파티션 Skew -3. ClickHouse batch size 조정. +`productId` 분포가 고르지 않으면 특정 파티션에 lag가 몰렸다. +이런 Skew 문제는 스트림 처리에서 자주 보이지만, 실제로 겪고 나서야 "키 설계도 운영 설계"라는 점이 선명해졌다. -## 5.2 체크포인트 장애 +이번 단계에서는 파티션 수 확장과 병렬도 정합으로 대응했지만, 장기적으로는 아래 두 가지가 필요하다고 판단했다. -**문제** +- 키 분포 분석 자동화 +- 특정 상품군 쏠림을 고려한 샤딩 전략 -배포 직후 체크포인트 타임아웃 연속 발생. +## 변경 전후 비교표 -**해결** +현재 소스에 남아 있는 기록만 기준으로, 주요 조정 전후를 정리하면 아래와 같다. +일부 항목은 정확한 수치 대신 당시 관찰 결과와 조정 방향을 기록했다. -1. 주기·타임아웃 완화 +| 항목 | 변경 전 | 변경 후 | 관찰 결과 | +| --- | --- | --- | --- | +| 윈도우 길이 | 5초 | 10초 | 5초에서 보이던 변동성이 줄고 운영 지표로 읽기 쉬워졌다. | +| 윈도우 길이 | 30초 | 10초 | 응답이 늦어지던 구성을 버리고 실시간 모니터링에 맞췄다. | +| 이벤트 시간 처리 | Processing Time에 가까운 단순 처리 가정 | Event Time + Watermark + Allowed Lateness | 지연 이벤트를 설명 가능한 범위 안에서 반영할 수 있게 됐다. | +| 파티션/병렬도 | Parallelism 1과 정합이 깨진 상태 | 파티션 3 -> 6 -> 12, 병렬도 정합 유지 | Backpressure 원인을 더 정확히 읽을 수 있게 됐다. | +| 체크포인트 | 배포 직후 timeout 연속 발생 | 주기/timeout 완화, 병렬도 조정, 상태 축소 | 실험이 체크포인트 실패에 계속 막히지 않도록 안정화했다. | +| 서빙 API | `p(95) < 1000ms` 목표 대비 `p95 = 6.41s` | 병목 원인을 API 계층과 직렬화 비용으로 식별 | 이후 성능 개선 단계에서 서빙 구조를 재검토하는 기준이 생겼다. | -2. 병렬도 조정 +## 검증 방법 -3. 상태 크기 감소(윈도우 상태 최소화) +### 단위 테스트 -## 5.3 파티션 skew - -**문제** - -productId 분포 불균형으로 특정 파티션만 lag 증가. - -**해결** - -1. 파티션 3 → 6 → 12 증가 -2. 병렬도 정합 유지 - ---- - -# 6. 테스트 전략 - -## 6.1 단위 테스트 - -CTR 계산 테스트 (EventCountAggregator, CTRResult) - -도메인 모델이 순수 로직이라 테스트 용이. +핵심 계산은 순수 도메인 로직으로 유지했다. +그래서 0 나눗셈, 클릭 수 반영, 윈도우 경계 같은 기본 규칙을 빠르게 검증할 수 있었다. ```kotlin class CTRResultTest { @@ -382,18 +388,13 @@ class CTRResultTest { assertThat(result.ctr).isEqualTo(0.1) } - - @Test - fun `CTR 계산 - impression이 0인 경우`() { - val result = CTRResult.calculate("product1", 0, 0, 0L, 10000L) - assertThat(result.ctr).isEqualTo(0.0) - } } ``` -## 6.2 통합 테스트 +### 통합 테스트 -fromCollection → keyBy → window → aggregate 흐름 전체 검증. +실제 검증 포인트는 `fromCollection -> keyBy -> window -> aggregate` 전체 흐름이었다. +윈도우와 이벤트 시간 처리는 코드 한 줄이 아니라 흐름 단위로 깨지는 경우가 많기 때문이다. ```kotlin @Test @@ -402,8 +403,8 @@ fun `EventCountAggregator 통합 테스트`() { env.setParallelism(1) val events = listOf( - Event(eventType = "impression", productId = "p1", ...), - Event(eventType = "click", productId = "p1", ...) + Event(eventType = "impression", productId = "p1", eventTime = 1_000L), + Event(eventType = "click", productId = "p1", eventTime = 2_000L) ) val result = env.fromCollection(events) @@ -417,34 +418,21 @@ fun `EventCountAggregator 통합 테스트`() { } ``` ---- +### 수동 검증 -# 7. API (Serving Layer) +테스트만으로는 부족했다. 아래 경로는 눈으로도 검증했다. -## 7.1 FastAPI 엔드포인트 +- Flink UI에서 Backpressure와 처리량 확인 +- Kafka UI로 토픽 적재 상태 확인 +- Redis 결과와 ClickHouse 결과를 비교 +- FastAPI 응답과 원시 이벤트 수를 샘플 대조 -```python -# serving-api/main.py -@app.get("/ctr/latest", summary="Get Latest CTR for All Products") -def get_all_latest_ctr(redis: Redis = Depends(get_redis)): - ctr_hash = redis.hgetall("ctr:latest") - if not ctr_hash: - return {} - result = {key: json.loads(value) for key, value in ctr_hash.items()} - return result +운영 화면에서 보는 최신 CTR 값과 ClickHouse 집계 결과가 어긋나지 않는지 확인하는 과정이 특히 중요했다. +결과 저장소가 둘 이상일 때는 "둘 다 동작한다"보다 "둘이 같은 계약을 보고 있는가"를 먼저 확인해야 한다. -@app.get("/ctr/{product_id}") -def get_ctr_by_product_id(product_id: str, redis: Redis = Depends(get_redis)): - latest_json = redis.hget("ctr:latest", product_id) - previous_json = redis.hget("ctr:previous", product_id) +### 서빙 API 응답 예시 - return { - "latest": json.loads(latest_json) if latest_json else None, - "previous": json.loads(previous_json) if previous_json else None - } -``` - -## 7.2 API 응답 예시 +실제 API는 아래 형태로 최신 CTR 상태를 제공했다. ```json { @@ -455,77 +443,85 @@ def get_ctr_by_product_id(product_id: str, redis: Redis = Depends(get_redis)): "ctr": 0.0803, "windowStart": 1701360000000, "windowEnd": 1701360010000 - }, - "product2": { - "productId": "product2", - "impressions": 387, - "clicks": 15, - "ctr": 0.0388, - "windowStart": 1701360000000, - "windowEnd": 1701360010000 } } ``` ---- +이 응답 형태를 유지한 이유는 두 가지였다. + +- 운영 화면에서 바로 읽기 쉬워야 한다. +- 이전 윈도우와 현재 윈도우를 비교하는 확장이 가능해야 한다. -# 8. 기술 선택 이유 +스트림 시스템은 "테스트가 있으니 안전하다"보다 "관측 가능한 상태를 만들었는가"가 더 중요하다는 점을 이 단계에서 분명히 배웠다. -## 8.1 Flink vs Spark Streaming +## 기술 선택 이유 -1. 낮은 지연 +### Flink를 택한 이유 -2. 정교한 이벤트 시간 처리 +CTR 집계에서는 아래 세 가지가 중요했다. -3. 풍부한 상태 관리 +- 낮은 지연 +- 이벤트 시간 처리 +- 상태 기반 집계 - Spark는 마이크로배치가 강점이지만 CTR 같은 실시간 지표에는 부적합해 보였어요. +Spark Streaming의 마이크로배치 모델보다 Flink의 이벤트 시간 처리와 상태 관리가 이 문제에 더 맞는다고 판단했다. -## 8.2 Spring Boot +### ClickHouse와 DuckDB를 같이 둔 이유 -Spring Boot는 과거 경험으로 익숙하고 Flink와 호환성이 좋아 선택했어요. +- ClickHouse: 집계 결과를 장기적으로 분석하기 위한 OLAP 저장소 +- DuckDB: 로컬 디버깅과 가벼운 검증 -특히 Flink 1.18 버전부터 JDK 17 지원했기에 더 적합하다고 판단했어요. +실험 단계에서 운영용 분석 저장소와 개발용 확인 저장소를 분리해두면, 쿼리 검증 속도가 훨씬 빨라진다. -## 8.3 ClickHouse vs Snowflake/BigQuery +## 이 판단 기준이 다른 플랫폼 문제에도 적용되는 이유 -오픈소스로 설치하여 사용하는 경우 서버 비용만 내면 되기 때문에 장점이라고 판단하였고, +이 프로젝트는 CTR 집계를 다뤘지만, 여기서 고정한 기준은 특정 도메인에만 묶이지 않는다. -많은 대기업들에서 채택하여 Production환경에 사용될 만큼 안정적인 서비스라는 판단이 되었고 +- 이벤트 순서와 지연을 어떤 기준으로 흡수할지 정하는 문제 +- 여러 소비처가 같은 결과를 보도록 계약을 맞추는 문제 +- 장애 이후 어디까지 안전하게 처리됐는지 설명할 수 있어야 하는 문제 +- 병목이 생겼을 때 코드가 아니라 처리 단계 단위로 원인을 좁히는 문제 -홈페이지 문서를 읽어보니 비교적 쉬운 접근성으로 판단되어 선택했어요. +이 네 가지는 스트림 집계뿐 아니라 CDC 파이프라인, IoT 이벤트 처리, 정산 집계처럼 비즈니스가 커질수록 내부가 복잡해지는 시스템에서 반복해서 등장한다. +그래서 이번 프로젝트의 가치는 CTR 수치를 계산했다는 사실보다, 복잡한 내부를 어떤 기준으로 정리하고 설명 가능한 상태로 만들지 실험했다는 데 있다. -## 8.4 DuckDB 활용 이유 +## 한계와 다음 단계 -ClickHouse는 다른 부서에서 사용할 수 있음으로 운영 중에 데이터를 건드리는 것은 위험하다고 판단했고, +이번 구현은 파이프라인의 핵심 문제를 드러내는 데는 충분했지만, 아직 운영 수준이라고 보기는 어렵다. -저비용 OLAP 및 디버깅 용도로 활용 가능할 것 같고, Observerlity를 고려했을 때 도입하는 것이 적당하다고 판단했어요. +- 단일 노트북 기반 검증이라 다중 TaskManager 환경 성능이 부족하다. +- `productId` 외 다차원 집계는 아직 설계하지 않았다. +- 멀티 리전 수준의 지연 분포를 반영한 워터마크 재조정은 하지 못했다. +- 장기적으로는 Lakehouse 계층과의 결합도 검토해야 한다. ---- +다음 단계는 아래 순서로 보는 것이 맞다고 판단했다. -# 9. 한계와 다음 단계 +1. 클라우드 환경에서 병렬도와 체크포인트 안정성 재검증 +2. 다차원 집계 모델링 +3. 서빙 계층 단순화 또는 재설계 +4. 저장소 계층을 Lakehouse까지 확장할지 판단 -1. 단일 노트북 기반 실험으로 실제 M개 TaskManager 환경 성능 검증 부족 - → 클라우드 기반 서버 구축 해보기 -2. productId 외의 다차원 기준(광고그룹, 캠페인 등) 집계는 미지원 +## 남긴 판단 기준 - → 광고 도메인에 대한 이해도 부족 +이 프로젝트를 지나며 남은 기준은 세 가지다. -3. 멀티 리전 또는 WAN 환경에서 워터마크 설정 재검토 필요 +- 스트림 처리 파라미터는 교과서값이 아니라 운영 소비 방식으로 정해야 한다. +- Exactly-once는 비용이 들더라도, 설명할 수 없는 오차보다 싸다. +- 병목은 코드 한 줄보다 처리 단계의 균형이 먼저 깨질 때 더 자주 발생한다. - → 실제 업무 경험 필요해 보임 +### 학습 과정에서 바뀐 기준 -4. S3 기반 Lakehouse(Hudi·Iceberg) + ClickHouse Materialized View로 고도화 가능 +이 프로젝트는 Flink API를 익히는 데서 끝나지 않았다. +오히려 "기술이 돌아간다"와 "운영 가능한 기준을 설명할 수 있다" 사이의 차이를 확인하는 과정에 가까웠다. - → minio 사용하여 로컬에서 테스트 해보기 +1BRC를 하면서도 느꼈지만, 결과가 나온다는 사실만으로는 충분하지 않았다. +왜 10초 윈도우를 택했는지, 왜 워터마크를 5초로 잡았는지, 그 판단이 어떤 부작용을 만들 수 있는지를 스스로 설명할 수 있어야 했다. ---- +이 과정에서 특히 크게 배운 점은 공식 문서와 이론의 무게였다. +워터마크, Allowed Lateness, 체크포인트 같은 설정은 겉으로는 단순해 보여도 실제 동작 방식과 장애 양상을 이해하지 못하면 적절한 값을 잡기 어렵다. 설정 하나가 결과 정확도와 레이턴시에 직접 영향을 준다는 점도 더 분명해졌다. -## 10. 느낀점 +또 하나 남은 기준은 도구와 책임의 경계였다. +Code Agent 같은 도구는 구현과 탐색 속도를 높여주지만, 어떤 기준으로 설계할지 결정하고 결과를 검증하고 최종 책임을 지는 주체는 결국 사람이다. 이 프로젝트를 개인 포트폴리오로 남기는 이유도, 단순히 Flink를 사용했다는 사실보다 그런 판단과 검증의 과정을 증명하고 싶었기 때문이다. -단순 호기심에 시작했지만 스트리밍 데이터 파이프라인을 구축해보며 많은 학습을 통해 역량을 쌓을 수 있었는데요. -전에는 결과 중심적으로 학습을 이어가다 보니 왜 이렇게 설정해야하고 다르게 설정했을 떈 어떤 결과가 예측될지에 대한 이해가 많이 부족했다는 점을 깨달았어요. -이번 경험을 통해 이론이나 특히 공식문서의 중요성을 한번 더 깨달았고, 간단한 설정 하나가 시스템에 어마무시한 영향을 끼친다는 점을 알 수 있었어요. -앞으로는 이론 중심의 학습을 이어나가며 지속적으로 성장 가능한 시스템을 구축하기 위해 노력하는 과정을 익히려고 해요. -Code Agent를 활용하여 도움을 받긴 했지만 학습을 하지 않았더라면 정확하게 지시를 하지 못한다는 사실도 깨달았고 결국 검증하고 책임지는건 사람이라는것을 한번 더 깨달을 수 있었던 프로젝트였어요. -언젠가 치열하게 살아오며 쌓아온 역량들을 실무에서 사용할 수 있는 날을 기대하며, 포스팅을 마무리할게요. +결국 이 프로젝트의 목적은 Flink 기능 시연이 아니었다. +실시간 지표를 운영 가능한 시스템으로 바꾸려면 어떤 판단을 먼저 고정해야 하는지, 그리고 그 판단을 어디까지 검증해야 하는지 확인하는 과정이었다. From e1af3c65ed5e3c2ec0ff691d90e03ab649ef06cb Mon Sep 17 00:00:00 2001 From: dev-wooyeon Date: Thu, 16 Apr 2026 15:00:21 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(analytics):=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=2024=EC=8B=9C=EA=B0=84=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EB=B0=A9=EC=A7=80=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조회수 집계가 새로고침과 반복 요청으로 증가하는 문제를 줄이기 위해\n서버 지문 기반 dedupe 로직을 추가했어요.\n\nincrement_view RPC에 viewer fingerprint와 dedupe window 인자를 추가하고\nview_unique_visitors 테이블 기반으로 24시간 내 중복 증가를 차단했어요.\n헤더 식별 실패 시 fallback visitor cookie를 사용하도록 보완했고\n구버전 함수 시그니처 환경에서도 동작하도록 호환 경로를 유지했어요.\n\nDB 스키마 문서와 env 예시를 최신화하고 테스트를 보강했어요. --- .env.example | 3 + docs/database/db-schema.md | 30 ++++-- docs/database/supabase-view-count.sql | 105 ++++++++++++++++++-- src/app/actions/view.test.ts | 137 ++++++++++++++++++++++++- src/app/actions/view.ts | 138 +++++++++++++++++++++++++- src/shared/integrations/supabase.ts | 2 + 6 files changed, 393 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 6165ef43..5367b667 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key SUPABASE_URL=https://your-project-ref.supabase.co SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# Optional: salt for server-side view fingerprint hashing +VIEW_FINGERPRINT_SALT=replace-with-random-secret + # Umami Analytics (self-hosted) NEXT_PUBLIC_UMAMI_URL=https://your-umami.vercel.app NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id diff --git a/docs/database/db-schema.md b/docs/database/db-schema.md index 3a304736..ca597ff8 100644 --- a/docs/database/db-schema.md +++ b/docs/database/db-schema.md @@ -5,7 +5,7 @@ Generated from: - `docs/database/supabase-view-count.sql` - `src/shared/integrations/supabase.ts` -Last updated: 2026-02-26 +Last updated: 2026-04-16 ## Table: `public.views` @@ -16,26 +16,42 @@ Last updated: 2026-02-26 | `created_at` | `timestamptz` | no | `now()` | Insert time | | `updated_at` | `timestamptz` | no | `now()` | Update time | +## Table: `public.view_unique_visitors` + +| Column | Type | Null | Default | Notes | +| --- | --- | --- | --- | --- | +| `slug` | `text` | no | - | PK part, post identifier | +| `viewer_fingerprint` | `text` | no | - | PK part, hashed viewer key | +| `last_viewed_at` | `timestamptz` | no | `now()` | Last accepted view time | +| `created_at` | `timestamptz` | no | `now()` | Insert time | +| `updated_at` | `timestamptz` | no | `now()` | Update time | + ## RLS and Grants - RLS enabled on `public.views`. +- RLS enabled on `public.view_unique_visitors`. - Policy: read access allowed for all (`select` using `true`). - Grants: - `select` on `public.views` to `anon`, `authenticated`. - - execute on `increment_view(text)` to `anon`, `authenticated`. + - execute on `increment_view(text, text, integer)` to `anon`, `authenticated`. -## Function: `public.increment_view(slug_input text)` +## Function: `public.increment_view(slug_input text, viewer_fingerprint_input text default null, dedupe_window_seconds_input integer default 86400)` - Language: `plpgsql` - Security: `security definer` - Behavior: - 1. Insert slug with `count=1`. - 2. On conflict, increment count and update `updated_at`. - 3. Return latest count (`bigint`). + 1. Normalize slug and fingerprint input. + 2. If fingerprint exists, enforce dedupe window by `view_unique_visitors.last_viewed_at`. + 3. Increment `views.count` only when outside dedupe window. + 4. Return latest count (`bigint`). ## Type Mapping (App) `src/shared/integrations/supabase.ts` defines: - Table row type for `views`. -- RPC signature: `increment_view.Args.slug_input: string`, `Returns: number`. +- RPC signature: + - `increment_view.Args.slug_input: string` + - `increment_view.Args.viewer_fingerprint_input?: string` + - `increment_view.Args.dedupe_window_seconds_input?: number` + - `Returns: number` diff --git a/docs/database/supabase-view-count.sql b/docs/database/supabase-view-count.sql index 5f8656b8..7e9601e9 100644 --- a/docs/database/supabase-view-count.sql +++ b/docs/database/supabase-view-count.sql @@ -5,7 +5,17 @@ create table if not exists public.views ( updated_at timestamptz not null default now() ); +create table if not exists public.view_unique_visitors ( + slug text not null, + viewer_fingerprint text not null, + last_viewed_at timestamptz not null default now(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (slug, viewer_fingerprint) +); + alter table public.views enable row level security; +alter table public.view_unique_visitors enable row level security; drop policy if exists "Allow read view counts" on public.views; create policy "Allow read view counts" @@ -13,26 +23,99 @@ create policy "Allow read view counts" for select using (true); -create or replace function public.increment_view(slug_input text) +drop function if exists public.increment_view(text); +drop function if exists public.increment_view(text, text, integer); +create function public.increment_view( + slug_input text, + viewer_fingerprint_input text default null, + dedupe_window_seconds_input integer default 86400 +) returns bigint language plpgsql security definer set search_path = public as $$ declare + normalized_slug text; + normalized_fingerprint text; + dedupe_window interval; + tracked_last_viewed_at timestamptz; + should_increment boolean := true; updated_count bigint; begin - insert into public.views (slug, count) - values (slug_input, 1) - on conflict (slug) - do update set - count = public.views.count + 1, - updated_at = now() - returning count into updated_count; - - return updated_count; + normalized_slug := btrim(slug_input); + if normalized_slug is null or normalized_slug = '' then + return 0; + end if; + + normalized_fingerprint := nullif(btrim(viewer_fingerprint_input), ''); + dedupe_window := make_interval( + secs => greatest(coalesce(dedupe_window_seconds_input, 86400), 0) + ); + + if normalized_fingerprint is not null and dedupe_window > interval '0 seconds' then + insert into public.view_unique_visitors ( + slug, + viewer_fingerprint, + last_viewed_at, + created_at, + updated_at + ) + values ( + normalized_slug, + normalized_fingerprint, + now(), + now(), + now() + ) + on conflict do nothing; + + if not found then + select last_viewed_at + into tracked_last_viewed_at + from public.view_unique_visitors + where slug = normalized_slug + and viewer_fingerprint = normalized_fingerprint + for update; + + if tracked_last_viewed_at is null then + should_increment := true; + elsif now() - tracked_last_viewed_at >= dedupe_window then + update public.view_unique_visitors + set + last_viewed_at = now(), + updated_at = now() + where slug = normalized_slug + and viewer_fingerprint = normalized_fingerprint; + should_increment := true; + else + update public.view_unique_visitors + set updated_at = now() + where slug = normalized_slug + and viewer_fingerprint = normalized_fingerprint; + should_increment := false; + end if; + end if; + end if; + + if should_increment then + insert into public.views (slug, count) + values (normalized_slug, 1) + on conflict (slug) + do update set + count = public.views.count + 1, + updated_at = now() + returning count into updated_count; + else + select count + into updated_count + from public.views + where slug = normalized_slug; + end if; + + return coalesce(updated_count, 0); end; $$; grant select on public.views to anon, authenticated; -grant execute on function public.increment_view(text) to anon, authenticated; +grant execute on function public.increment_view(text, text, integer) to anon, authenticated; diff --git a/src/app/actions/view.test.ts b/src/app/actions/view.test.ts index b9ca3796..3e72155b 100644 --- a/src/app/actions/view.test.ts +++ b/src/app/actions/view.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { cookies, headers } from 'next/headers'; import { getSupabaseServerClient } from '@/shared/integrations/supabase'; import { getPopularViewsInRecentDays, @@ -11,6 +12,11 @@ vi.mock('@/shared/integrations/supabase', () => ({ getSupabaseServerClient: vi.fn(), })); +vi.mock('next/headers', () => ({ + cookies: vi.fn(), + headers: vi.fn(), +})); + interface RpcResult { data: unknown; error: { message: string } | null; @@ -29,6 +35,15 @@ type SupabaseLike = { }; }; +interface CookieStoreLike { + get: ReturnType; + set: ReturnType; +} + +interface HeaderStoreLike { + get: ReturnType; +} + function createQueryMock(payload: { data: unknown; error: { message: string } | null; @@ -61,10 +76,41 @@ function createSupabaseMock(options: { } const mockedGetSupabase = vi.mocked(getSupabaseServerClient); +const mockedCookies = vi.mocked(cookies); +const mockedHeaders = vi.mocked(headers); + +function createCookieStoreMock(visitorId: string | null = null): CookieStoreLike { + return { + get: vi + .fn() + .mockReturnValue( + visitorId + ? { name: 'view_visitor_id', value: visitorId } + : null + ), + set: vi.fn(), + }; +} + +function createHeaderStoreMock( + entries: Record +): HeaderStoreLike { + return { + get: vi.fn((name: string) => entries[name] ?? null), + }; +} describe('view actions', () => { beforeEach(() => { vi.clearAllMocks(); + mockedCookies.mockResolvedValue(createCookieStoreMock()); + mockedHeaders.mockResolvedValue( + createHeaderStoreMock({ + 'x-forwarded-for': '203.0.113.10', + 'user-agent': 'Vitest Browser', + 'accept-language': 'ko-KR', + }) + ); }); it('increments view when slug is valid and client exists', async () => { @@ -166,9 +212,14 @@ describe('view actions', () => { const count = await trackView('my-post'); - expect(client.rpc).toHaveBeenCalledWith('increment_view', { - slug_input: 'my-post', - }); + expect(client.rpc).toHaveBeenCalledWith( + 'increment_view', + expect.objectContaining({ + slug_input: 'my-post', + viewer_fingerprint_input: expect.any(String), + dedupe_window_seconds_input: 86400, + }) + ); expect(count).toBe(9); }); @@ -184,6 +235,86 @@ describe('view actions', () => { expect(count).toBe(15); }); + it('includes fingerprint and dedupe window when tracking view', async () => { + const client = createSupabaseMock({ + queryPayload: { data: { count: 1 }, error: null }, + rpcPayload: { data: 5, error: null }, + }); + mockedGetSupabase.mockReturnValue(client); + + await trackView('my-post'); + + expect(client.rpc).toHaveBeenCalledWith( + 'increment_view', + expect.objectContaining({ + slug_input: 'my-post', + viewer_fingerprint_input: expect.any(String), + dedupe_window_seconds_input: 86400, + }) + ); + }); + + it('falls back to legacy increment signature when rpc argument mismatch occurs', async () => { + const client = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi + .fn() + .mockResolvedValueOnce({ data: { count: 12 }, error: null }), + }), + rpc: vi + .fn() + .mockResolvedValueOnce({ + data: null, + error: { + message: + 'Could not find the function public.increment_view(slug_input, viewer_fingerprint_input, dedupe_window_seconds_input)', + }, + }) + .mockResolvedValueOnce({ data: 12, error: null }), + }; + mockedGetSupabase.mockReturnValue(client as unknown as SupabaseLike); + + const count = await trackView('my-post'); + + expect(client.rpc).toHaveBeenNthCalledWith( + 1, + 'increment_view', + expect.objectContaining({ + slug_input: 'my-post', + viewer_fingerprint_input: expect.any(String), + dedupe_window_seconds_input: 86400, + }) + ); + expect(client.rpc).toHaveBeenNthCalledWith(2, 'increment_view', { + slug_input: 'my-post', + }); + expect(count).toBe(12); + }); + + it('uses fallback visitor cookie when request headers are unavailable', async () => { + const client = createSupabaseMock({ + queryPayload: { data: { count: 1 }, error: null }, + rpcPayload: { data: 2, error: null }, + }); + const cookieStore = createCookieStoreMock(); + mockedGetSupabase.mockReturnValue(client); + mockedHeaders.mockResolvedValue(createHeaderStoreMock({})); + mockedCookies.mockResolvedValue(cookieStore); + + await trackView('my-post'); + + expect(cookieStore.set).toHaveBeenCalledTimes(1); + expect(client.rpc).toHaveBeenCalledWith( + 'increment_view', + expect.objectContaining({ + slug_input: 'my-post', + viewer_fingerprint_input: expect.any(String), + }) + ); + }); + it('fetches popular views with recent-day filter and descending count order', async () => { const client = createSupabaseMock({ queryPayload: { diff --git a/src/app/actions/view.ts b/src/app/actions/view.ts index fffbb4bb..eade91c8 100644 --- a/src/app/actions/view.ts +++ b/src/app/actions/view.ts @@ -1,7 +1,15 @@ 'use server'; +import { createHash, randomUUID } from 'node:crypto'; +import { cookies, headers } from 'next/headers'; import { getSupabaseServerClient } from '@/shared/integrations/supabase'; +const VIEW_DEDUPE_WINDOW_SECONDS = 60 * 60 * 24; +const VIEW_FINGERPRINT_SALT = + process.env.VIEW_FINGERPRINT_SALT ?? 'eunu-log-view-fingerprint-v1'; +const VIEW_FALLBACK_VISITOR_COOKIE = 'view_visitor_id'; +const VIEW_FALLBACK_VISITOR_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; + function normalizeSlug(slug: string): string | null { const value = slug.trim(); return value.length > 0 ? value : null; @@ -28,6 +36,104 @@ function normalizePositiveInt(value: number, fallback: number): number { return Math.max(1, Math.floor(value)); } +function readHeaderValue( + headerStore: Awaited>, + names: string[] +): string { + for (const name of names) { + const value = headerStore.get(name); + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + + return ''; +} + +function normalizeIpAddress(rawValue: string): string { + if (!rawValue) { + return ''; + } + + const [first] = rawValue.split(','); + return first?.trim() ?? ''; +} + +function getOrCreateFallbackVisitorId( + cookieStore: Awaited> +): string { + const existingId = cookieStore.get(VIEW_FALLBACK_VISITOR_COOKIE)?.value; + if (existingId && existingId.trim().length > 0) { + return existingId; + } + + const visitorId = randomUUID(); + cookieStore.set({ + name: VIEW_FALLBACK_VISITOR_COOKIE, + value: visitorId, + maxAge: VIEW_FALLBACK_VISITOR_COOKIE_MAX_AGE_SECONDS, + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }); + + return visitorId; +} + +async function createViewerFingerprint(): Promise { + const headerStore = await headers(); + const cookieStore = await cookies(); + + const ipAddress = normalizeIpAddress( + readHeaderValue(headerStore, [ + 'x-forwarded-for', + 'x-real-ip', + 'cf-connecting-ip', + 'fly-client-ip', + 'true-client-ip', + ]) + ); + const userAgent = readHeaderValue(headerStore, ['user-agent']); + const acceptLanguage = readHeaderValue(headerStore, ['accept-language']); + const secChUa = readHeaderValue(headerStore, ['sec-ch-ua']); + const secChUaPlatform = readHeaderValue(headerStore, ['sec-ch-ua-platform']); + + const signatureParts = [ + ipAddress, + userAgent, + acceptLanguage, + secChUa, + secChUaPlatform, + ].filter((value) => value.length > 0); + + const signatureSource = + signatureParts.length > 0 + ? signatureParts.join('|') + : `visitor:${getOrCreateFallbackVisitorId(cookieStore)}`; + + return createHash('sha256') + .update(`${VIEW_FINGERPRINT_SALT}|${signatureSource}`) + .digest('hex'); +} + +function isLegacyIncrementViewSignatureError(error: { + message: string; + details?: string; + hint?: string; +}): boolean { + const fullText = `${error.message} ${error.details ?? ''} ${error.hint ?? ''}` + .trim() + .toLowerCase(); + + return ( + fullText.includes('increment_view') && + (fullText.includes('function') || + fullText.includes('signature') || + fullText.includes('matches')) + ); +} + export interface PopularViewEntry { slug: string; count: number; @@ -102,14 +208,44 @@ export async function trackView(slug: string): Promise { return null; } + const viewerFingerprint = await createViewerFingerprint(); const { data, error } = await supabase.rpc< 'increment_view', - { slug_input: string } + { + slug_input: string; + viewer_fingerprint_input?: string; + dedupe_window_seconds_input?: number; + } >('increment_view', { slug_input: normalizedSlug, + viewer_fingerprint_input: viewerFingerprint, + dedupe_window_seconds_input: VIEW_DEDUPE_WINDOW_SECONDS, }); if (error) { + if (isLegacyIncrementViewSignatureError(error)) { + const legacyResult = await supabase.rpc< + 'increment_view', + { + slug_input: string; + } + >('increment_view', { + slug_input: normalizedSlug, + }); + + if (legacyResult.error) { + console.error('Error incrementing view count:', legacyResult.error); + return readViewCount(normalizedSlug); + } + + const legacyCount = toCount(legacyResult.data); + if (legacyCount !== null) { + return legacyCount; + } + + return readViewCount(normalizedSlug); + } + console.error('Error incrementing view count:', error); return readViewCount(normalizedSlug); } diff --git a/src/shared/integrations/supabase.ts b/src/shared/integrations/supabase.ts index b396af2c..0045eb3c 100644 --- a/src/shared/integrations/supabase.ts +++ b/src/shared/integrations/supabase.ts @@ -33,6 +33,8 @@ export type SupabaseDatabase = { increment_view: { Args: { slug_input: string; + viewer_fingerprint_input?: string; + dedupe_window_seconds_input?: number; }; Returns: number; }; From ba0439d7de0d70717a88ea036e948e751682de68 Mon Sep 17 00:00:00 2001 From: dev-wooyeon Date: Fri, 17 Apr 2026 18:23:07 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(blog):=20IoT=20=EC=A0=9C=EB=AA=A9=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 플랫폼 시스템 회고 글 제목의 IoT 표기를 바로잡았어요. 본문과 저장소 내 다른 기술 용어 표기와 일관되게 맞췄어요. --- .../meta.json" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" "b/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" index fca2f0bd..a33260bf 100644 --- "a/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" +++ "b/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" @@ -1,5 +1,5 @@ { - "title": "ioT 시스템을 글로벌 플랫폼으로 도약하기 위한 회고", + "title": "IoT 시스템을 글로벌 플랫폼으로 도약하기 위한 회고", "slug": "platform-system-review", "description": "로컬 IoT 시스템을 글로벌 플랫폼으로 바꾸기 위해 어떤 구조와 기준을 고민했는지 돌아봐요.", "date": "2024-08-18", From 91a4ae986772435128fe59a3c98b7e0f44b054ac Mon Sep 17 00:00:00 2001 From: dev-wooyeon Date: Fri, 17 Apr 2026 18:39:28 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs(blog):=20=EC=A0=80=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=EC=99=80=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=ED=9A=8C=EA=B3=A0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README와 docs 인덱스를 현재 저장소 구조에 맞게 정리했어요. 플랫폼 시스템 회고 글의 구조와 메타 설명을 기술 회고 기준에 맞춰 보강했어요. 빌드와 markdownlint로 문서 변경을 검증했어요. --- README.md | 164 ++++++------ docs/README.md | 22 +- .../index.mdx" | 247 +++++++++++------- .../meta.json" | 8 +- 4 files changed, 259 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index 9a1df700..502a40d0 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,95 @@ -
- # eunu.log -[![Next.js](https://img.shields.io/badge/Next.js-16+-black?style=flat-square&logo=next.js)](https://nextjs.org/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) - -개인 블로그입니다. - [라이브 데모](https://eunu-log.vercel.app) -
- -## 🛠 기술 스택 - - - - - - - - - -
-Next.js -
Next.js -
-React -
React -
-TypeScript -
TypeScript -
-Three.js -
Three.js -
-CSS -
Tailwind -
+`eunu.log`는 장문 기술 글과 실무 회고를 다루는 Next.js 기반 개인 블로그다. +MDX 콘텐츠 파이프라인, 시리즈형 글 구조, 분석 이벤트 추적, 이력서와 +검색 경험까지 한 저장소에서 관리한다. -**코어 스택:** +## 핵심 구성 -- **프레임워크:** Next.js 16+ (App Router, SSG/SSR) -- **언어:** TypeScript (Strict Mode) -- **스타일링:** Tailwind CSS + CSS Variables (필요한 영역에만 CSS Modules 사용) +- **런타임:** Next.js App Router, React 19, TypeScript strict mode +- **콘텐츠:** `posts/**/index.mdx + meta.json`, 커스텀 webpack MDX 로더 +- **UI:** Tailwind CSS, CSS variables, 필요한 구간만 CSS Modules 사용 +- **시각화:** Three.js, `@react-three/fiber`, `@react-three/drei`, + Framer Motion +- **품질:** ESLint, Prettier, Vitest, Playwright +- **데이터/분석:** Supabase 기반 조회수 집계, Umami 이벤트 추적 -**애니메이션:** +## 개발 시작 -- **3D:** Three.js + @react-three/fiber + @react-three/drei -- **모션:** Framer Motion +```bash +npm install +npm run dev +``` -**콘텐츠 처리:** +기본 개발 서버는 `internal/scripts/dev-with-agentation.mjs`를 통해 실행된다. +순수 Next.js 개발 서버가 필요하면 `npm run dev:next`를 사용한다. -- **포맷:** MDX + `meta.json` (폴더 기반 콘텐츠 구조) -- **파이프라인:** `@mdx-js/loader` + remark/rehype + syntax highlighting +## 자주 쓰는 명령어 -
+```bash +npm run dev +npm run dev:next +npm run build +npm run lint +npm run lint:css:syntax +npm run test:unit +npm run test:components +npm run test:e2e +``` -## 🏗 시스템 아키텍처 +## 콘텐츠 모델 -현재 운영 기준 아키텍처는 아래와 같습니다. +블로그 글은 폴더 단위로 관리한다. +중첩 디렉터리를 지원하므로 시리즈 글도 같은 규칙으로 다룬다. -![flow](/public/flow.png) - -핵심 포인트: - -- 블로그 앱(`eunu.log`)과 분석 대시보드(`Umami`)는 각각 Vercel에 분리 배포합니다. -- 블로그 코드에서는 `NEXT_PUBLIC_UMAMI_URL`, `NEXT_PUBLIC_UMAMI_WEBSITE_ID`만 설정하면 Umami 스크립트가 자동 로드됩니다. -- Umami 커스텀 이벤트는 스크립트 초기화 전 큐에 적재되고, 로드 완료 후 자동으로 flush됩니다. - -
- -## 📂 프로젝트 구조 +```text +posts/ +├── 어떤-글/ +│ ├── index.mdx +│ └── meta.json +└── 시리즈/ + └── 에피소드/ + ├── index.mdx + └── meta.json +``` + +- 메타데이터 스키마: `src/domains/post/model/frontmatter-schema.ts` +- 콘텐츠 로더: `src/features/blog/services/post-repository.ts` +- MDX 파이프라인: `next.config.mjs` + +## 프로젝트 구조 ```text -eunu.log/ -├── 📁 src/ -│ ├── 📁 app/ # 라우트 엔트리 전용 (Next App Router) -│ ├── 📁 core/ # 앱 전역 설정/프로바이더 조합 -│ ├── 📁 domains/ # 도메인 계약/타입/스키마 -│ ├── 📁 features/ # 기능 모듈(ui/model/services) -│ │ ├── 📁 blog/ -│ │ ├── 📁 resume/ -│ │ ├── 📁 search/ -│ │ └── 📁 home/ -│ ├── 📁 shared/ # 공용 모듈(analytics/integrations/layout/seo/testing/ui/types) -│ ├── 📁 components/ -│ │ └── 📁 visualization/ # 인터랙티브 알고리즘 시각화 전용 -│ └── 📁 styles/ # 전역 스타일과 토큰 -├── 📁 tests/ -│ └── 📁 e2e/ # Playwright E2E 테스트 -├── 📁 internal/ -│ ├── 📁 config/ # 내부 lint/spell 설정 -│ └── 📁 scripts/ # 내부 자동화/유틸 스크립트 -├── 📁 posts/ # 블로그 글(MDX + 메타데이터) -│ └── 📁 [slug]/ # 글 단위 폴더 -│ ├── index.mdx # 글 본문 -│ └── meta.json # 글 메타데이터 -├── 📁 public/ # 정적 에셋 -└── 📁 docs/ # 문서 -``` \ No newline at end of file +src/ +├── app/ # App Router 엔트리 +├── core/ # 전역 설정과 provider 조합 +├── domains/ # 도메인 계약, 타입, 스키마 +├── features/ # blog, home, resume, search +├── shared/ # analytics, layout, seo, ui, testing +├── components/visualization/# 시각화 전용 컴포넌트 +└── styles/ # 글로벌 스타일과 디자인 토큰 + +internal/ +├── config/ # lint, spell, markdown 설정 +└── scripts/ # 개발/콘텐츠 자동화 스크립트 + +tests/e2e/ # Playwright 시나리오 +docs/ # 설계, 운영, 품질 문서 +``` + +## 문서 + +- 저장소 개요와 아키텍처: `ARCHITECTURE.md` +- 문서 인덱스: `docs/README.md` +- 프런트엔드 기준: `docs/FRONTEND.md` +- 디자인 기준: `docs/DESIGN.md` +- 블로그 품질 기준: `docs/blog-quality-guide.md` + +## 작업 원칙 + +- 콘텐츠 구조는 `posts/**/index.mdx + meta.json` 형태를 유지한다. +- MDX는 `next.config.mjs`의 커스텀 webpack 규칙을 유지한다. +- 시각화가 무거운 UI는 `src/components/visualization/`에 둔다. +- `any`와 임의 Tailwind 값은 추가하지 않는다. diff --git a/docs/README.md b/docs/README.md index 8af51371..684987b3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,15 @@ # Docs Index -Last updated: 2026-02-28 +Last updated: 2026-04-17 + +This index tracks the active documentation that should stay aligned with the +current codebase. If a document is missing here, add it when the document +becomes part of the maintained workflow. + +## Repository-Level Docs + +- `AGENTS.md` +- `ARCHITECTURE.md` ## Active Documentation @@ -10,19 +19,27 @@ Last updated: 2026-02-28 - `docs/RELIABILITY.md` - `docs/SECURITY.md` - `docs/QUALITY_SCORE.md` + - `docs/blog-quality-guide.md` - Delivery/process: - `docs/PLANS.md` - `docs/exec-plans/active/README.md` - `docs/exec-plans/completed/README.md` - `docs/exec-plans/tech-debt-tracker.md` + - `docs/guides/agentation-workflow.md` - `docs/guides/pr-workflow.md` - `docs/guides/testing-guide.md` - `docs/guides/ai-collaboration.md` + - `docs/guides/ui-components-guide.md` - Product/domain: - `docs/PRODUCT_SENSE.md` - `docs/product-specs/index.md` - - `docs/analytics/analytics-ga4-schema.md` + - `docs/product-specs/new-user-onboarding.md` + - `docs/design-docs/index.md` + - `docs/design-docs/core-beliefs.md` +- Data/analytics: + - `docs/analytics/analytics-kpi-weekly-template.md` - `docs/database/db-schema.md` + - `docs/database/supabase-view-count.sql` ## Reference / Research @@ -41,3 +58,4 @@ Last updated: 2026-02-28 2. Update `Last updated` when editing policy/process docs. 3. Move stale auto-generated reports to `docs/archive/` instead of deleting context. 4. Keep commands copy-pastable from repository root. +5. Remove or replace stale links when a referenced file no longer exists. diff --git "a/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/index.mdx" "b/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/index.mdx" index 619b0975..44e517a5 100644 --- "a/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/index.mdx" +++ "b/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/index.mdx" @@ -1,157 +1,226 @@ -## 1. 왜 이 문제를 해결해야 했는가 +제주파크 전용으로 설계된 IoT 시스템은 한 파크를 안정적으로 운영하는 데에는 충분했다. +문제는 사업 확장이 시작된 뒤였다. +새 파크가 늘어날수록 코드 복제, 조건 분기, 운영 규칙 차이가 함께 늘어났고, +"이번에는 빨리 붙이자"는 선택이 다음 확장의 비용으로 돌아오기 시작했다. + +당시 핵심 문제는 기능 부족이 아니었다. +기존 구조가 한 파크를 빠르게 운영하는 데 최적화되어 있었고, +여러 파크를 공통 플랫폼으로 묶는 데 필요한 책임 분리와 변경 경계가 없었다. -기존 시스템은 **제주파크 전용으로 설계된 맞춤형 구조**였어요. -하나의 파크를 안정적으로 운영하는 데에는 문제가 없었지만, -사업이 확장되기 시작하면서 한계가 분명해졌어요. +이 글에서는 그 상황을 단순한 리팩터링 과제가 아니라 +"멀티파크 확장을 감당할 수 있는 구조를 다시 정의하는 일"로 어떻게 바라봤는지, +어떤 대안을 검토했고 왜 도메인 중심 구조와 Hexagonal Architecture를 선택했는지, +그리고 실제 전환 과정에서 무엇이 가장 어려웠는지 정리한다. -- 새로운 파크가 추가될 때마다 시스템 복제 필요 (비용 더블) -- 파크별 규칙이 점점 조건문으로 누적 -- 특정 기능을 수정하면 다른 파크에 영향이 갈 가능성 존재 -- 특정 기능이 다른 파크에 사용할지 모름 +--- -결과적으로 -**"지금은 잘 돌아가지만, 다음 파크를 추가할 때가 유리하지 않은 상태"**였어요. +## 1. 왜 이 문제를 해결해야 했는가 -이 문제는 기능 부족의 문제가 아니라, -**시스템이 확장을 전제로 설계되지 않았다는 구조적 문제**라고 판단했어요. -이 글에서는 그 한계를 어떻게 구조 문제로 정의했고, 어떤 기준으로 글로벌 플랫폼 방향의 재설계를 시작했는지 정리해요. +기존 시스템은 제주파크 운영 요구에 맞춰 빠르게 성장한 구조였다. +그 덕분에 초기에는 개발 속도와 운영 대응 속도가 모두 괜찮았다. +하지만 파크가 하나 더 늘어나는 순간부터 비용 구조가 달라졌다. ---- +- 새로운 파크를 붙일 때마다 기존 로직을 복제하거나 조건문을 추가해야 했다 +- 파크별 운영 규칙이 비즈니스 로직이 아니라 곳곳의 분기문으로 흩어졌다 +- 특정 기능을 수정할 때 다른 파크까지 영향이 갈 가능성을 항상 같이 검토해야 했다 +- 외부 장비나 API 차이가 도메인 규칙과 같은 레이어에 섞여 들어왔다 -## 2. 문제를 어떻게 정의했는가 +이 상태는 "지금은 동작한다"는 의미에서는 안전했지만, +"다음 확장을 싸게 만들 수 있는가"라는 질문에는 그렇지 않았다. -처음에는 단순히 "하드코딩이 많다", "유지보수가 어렵다"는 표현으로만 논의되었어요. -하지만 실제로 코드를 분석해보니 문제는 더 명확했어요. +그래서 문제를 기능 개발 속도가 아니라 +**확장할수록 비용이 커지는 구조적 부채**로 보기 시작했다. +핵심 질문은 하나였다. -• 도메인 경계가 불분명 -• 비즈니스 규칙과 인프라 코드가 강하게 결합 -• 테스트가 어려워 변경이 곧 리스크로 이어짐 +> 새로운 파크가 추가돼도 기존 도메인을 거의 건드리지 않고 확장할 수 있는가? -결국 -**'어디까지가 비즈니스 로직이고, 어디부터가 구현 세부사항인지'가 드러나지 않는 구조**였어요. +--- -그래서 문제를 이렇게 재정의했어요. +## 2. 시스템 요구사항과 제약을 다시 정리했다 -> "새로운 파크가 추가되더라도, 기존 도메인을 거의 건드리지 않고 확장할 수 있는 구조를 만들 수 없는가?" +구조를 바꾸기 전에 먼저 이번 전환이 만족해야 할 조건을 분리해서 봤다. +이 단계를 거치지 않으면 아키텍처 논의가 취향 싸움으로 흐르기 쉬웠다. + +| 요구사항 | 왜 필요했는가 | 당시 제약 | +| ---------------- | -------------------------------------------------------- | ----------------------------------------------- | +| 파크별 정책 분리 | 파크가 늘어날수록 조건문 복제를 줄여야 했다 | 이미 운영 중인 로직을 한 번에 끊을 수 없었다 | +| 외부 연동 격리 | 장비, API, 저장소 차이가 도메인 규칙을 오염시키고 있었다 | 외부 인터페이스 스펙이 완전히 안정적이지 않았다 | +| 점진 전환 가능성 | 전체 시스템을 한 번에 갈아엎을 수 없었다 | 운영 중단 없이 부분 전환해야 했다 | +| 테스트 가능성 | 변경이 곧 리스크가 되는 구조를 끊어야 했다 | 기존 코드베이스에는 테스트 기반이 거의 없었다 | + +이렇게 정리하고 나니 문제 정의도 더 선명해졌다. +이번 전환의 목표는 "코드를 더 예쁘게 정리한다"가 아니라 +**파크 추가 비용과 변경 리스크를 구조적으로 낮추는 것**이었다. --- ## 3. 선택 가능한 대안들은 무엇이었는가 -문제를 정의한 뒤, 몇 가지 접근 방법을 검토했죠. +문제를 다시 정의한 뒤에는 세 가지 방향을 비교했다. +중요했던 건 "무엇이 더 멋져 보이는가"가 아니라 +현재 제약에서 어떤 선택이 가장 오래 버티는가였다. + +### 1) 기존 구조 유지 + 분기 최소화 -### ① 기존 구조 유지 + 조건 분기 최소화 +- 단기적으로 가장 빠르고 안전해 보였다 +- 현재 운영 흐름을 거의 건드리지 않아도 됐다 +- 팀이 새 개념을 크게 학습하지 않아도 됐다 -• 단기적으로 가장 안전한 선택 -• 개발 비용이 적음 -• 하지만 파크가 늘어날수록 복잡도는 계속 증가 +하지만 이 방식은 문제를 해결하지 않고 뒤로 미루는 선택에 가까웠다. +파크가 늘수록 조건문과 예외 처리가 다시 쌓일 수밖에 없었기 때문이다. -→ **확장 문제를 '미루는 선택'이라고 판단** +### 2) 프레임워크 중심 구조 개편 -### ② 프레임워크 중심의 구조 개편 +- 패키지 구조나 레이어 규칙을 표준화하기 좋았다 +- 코드를 일정한 형태로 정리하는 효과는 기대할 수 있었다 -• 일정 수준의 구조 표준화 가능 -• 하지만 프레임워크에 설계 의도가 묻힘 -• 도메인 자체의 책임은 여전히 불명확 +반면 이 접근은 **무엇이 도메인 규칙이고 무엇이 구현 세부사항인지**를 +충분히 드러내지 못했다. +겉보기 구조는 바뀌어도, 책임 경계가 그대로면 확장 비용은 다시 누적된다. -→ 구조는 바뀌지만, 문제 인식은 그대로 남는다고 판단했어요 +### 3) 도메인 중심 구조 재설계 -### ③ 도메인 중심으로 구조 재설계 +- 도메인 경계를 먼저 정의하고 책임을 다시 배치할 수 있었다 +- 외부 연동을 포트와 어댑터로 분리해 변경 영향을 제한할 수 있었다 +- 테스트 가능한 핵심 규칙을 별도 레이어로 끌어올릴 수 있었다 -• 도메인별 책임과 역할을 명확히 정의 -• 외부 의존성과 비즈니스 로직 분리 가능 -• 테스트 가능한 구조로 전환 가능 +가장 시간이 걸리는 선택이었지만, +멀티파크 확장을 전제로 보면 가장 정직한 선택이기도 했다. -→ **시간은 걸리지만, 장기적으로 가장 안전한 선택** +**결론적으로 단기 생산성보다 장기 확장 비용을 줄이는 쪽이 더 중요하다고 판단했다.** --- ## 4. 그래서 어떤 선택을 했는가 -결론적으로 **도메인을 재정의하고, Hexagonal Architecture를 적용**하기로 결정했죠. +최종적으로는 **도메인 중심 구조를 다시 세우고, +도메인 바깥의 의존성을 Hexagonal Architecture로 감싸는 방식**을 선택했다. + +핵심은 기술 용어 자체가 아니라 경계를 어디에 두느냐였다. + +- 메타데이터, 상태, 기록, 로그, 제어 같은 핵심 개념을 도메인 기준으로 다시 나눴다 +- 비즈니스 규칙은 도메인 레이어에 두고, 외부 시스템 연동은 포트와 어댑터로 밀어냈다 +- "어떤 장비를 쓰는가"보다 "도메인이 어떤 입력과 출력을 기대하는가"를 먼저 보게 만들었다 -핵심 선택은 다음과 같았죠. +구조를 간단히 표현하면 아래와 비슷했다. -• 메타데이터, 상태, 기록, 로그, 제어 등 핵심 도메인 경계 재정의 -• 비즈니스 로직을 도메인 계층에 집중 -• DB, 외부 API와의 의존성을 포트/어댑터로 분리하여 테스트에 용이한 구조 수용 +```text +Inbound Adapter + -> Application Service + -> Domain Model / Domain Service + -> Port + -> Outbound Adapter +``` -이 선택의 목적은 단순했죠. +이렇게 두면 파크별 차이와 외부 시스템 차이는 어댑터 레이어에서 흡수하고, +핵심 비즈니스 규칙은 도메인에서 유지할 수 있다. -> "비즈니스 규칙이 바뀌어도, 시스템이 흔들리지 않게 만들자." +이 선택의 목적은 단순했다. + +> 비즈니스 규칙이 바뀌더라도 시스템 전체가 같이 흔들리지 않게 만들자. --- -## 5. 구현에서 가장 중요했던 포인트 +## 5. 구현에서 부딪힌 문제와 어떻게 수정했는가 -### ① 기존 시스템의 구조를 먼저 '보이게' 만들기 +이 전환에서 어려웠던 지점은 아키텍처 패턴 자체보다 +"기존 시스템의 무엇을 먼저 고정해야 하는가"를 판단하는 일이었다. -아키텍처를 바꾸기 전에 -**기존 ERD와 Inbound / Outbound 흐름을 문서화**했어요. +### 1) 구조를 바꾸기 전에 먼저 구조를 보이게 만들었다 -어디가 섞여 있는지, -어디서 책임이 흐려지는지를 팀 전체가 공유하지 않으면 -설계 변경은 공감받기 어렵다고 판단했어요. +처음에는 곧바로 패키지 분리와 레이어 이동부터 하고 싶었다. +하지만 그렇게 접근하면 팀마다 현재 문제를 다르게 이해하고 있다는 사실이 바로 드러났다. ---- +그래서 코드 변경보다 먼저 아래 자료를 만들었다. -### ② 작은 도메인부터 점진적으로 적용 +- 기존 ERD +- 주요 Inbound / Outbound 흐름 +- 파크별 규칙이 섞여 있는 지점 +- 외부 연동 의존성이 도메인 로직에 침투한 지점 -한 번에 모든 도메인을 바꾸지 않았어요. +이 작업을 하고 나서야 "어디서 책임이 흐려지는가"를 팀이 같은 그림으로 보기 시작했다. +결국 설계 전환의 첫 단계는 코드 이동이 아니라 +**문제를 같은 언어로 설명할 수 있게 만드는 일**이었다. -• 티켓 도메인부터 적용 -• 효과 검증 후 다른 도메인으로 확장 +### 2) 한 번에 다 바꾸려는 시도가 가장 먼저 실패했다 -이를 통해 -"이 구조가 왜 필요한지"를 코드로 설명할 수 있었어요. +처음에는 여러 도메인을 같이 정리하려고 했다. +그런데 범위가 커지자 논의가 쉽게 추상화로 올라갔고, +"지금 무엇을 바꾸는지"보다 "이상적인 구조가 무엇인지"를 말하는 시간이 길어졌다. ---- +그래서 방향을 바꿨다. + +- 티켓 도메인처럼 영향 범위가 비교적 선명한 영역부터 시작했다 +- 작은 성공 사례를 먼저 만든 뒤 다른 도메인으로 확장했다 +- 구조의 필요성을 문서가 아니라 코드와 테스트로 설명하려고 했다 + +이 수정 덕분에 아키텍처 논의가 이론에서 실행으로 내려왔다. -### ③ 테스트 가능한 구조를 우선 확보 +### 3) 테스트는 마지막 검증이 아니라 전환 조건이었다 -기존 코드베이스에는 테스트 코드가 거의 없었어요. -그래서 아키텍처 전환과 함께 **테스트 전략을 동시에 도입**했어요. +기존 코드베이스에는 테스트 코드가 거의 없었다. +처음에는 구조를 먼저 바꾸고 테스트를 나중에 붙여도 된다고 생각했다. +하지만 그렇게 하면 전환 자체가 너무 불안정해졌다. -• 도메인 계층 단위 테스트 -• 외부 의존성은 Mock 또는 Adapter로 분리 -• 핵심 도메인 기준 커버리지 목표 설정 +그래서 접근 순서를 다시 잡았다. -테스트는 품질을 위한 장치이기도 했지만, -**팀이 안심하고 코드를 수정할 수 있는 안전장치**이기도 했어요. +- 도메인 계층에서 먼저 테스트 가능한 단위를 만들었다 +- 외부 의존성은 Mock이나 Adapter로 분리했다 +- 핵심 규칙은 도메인 테스트로 고정하고, 연동 차이는 어댑터 테스트로 분리했다 + +이때부터 테스트는 품질 장치라기보다 +**팀이 안심하고 구조를 옮길 수 있게 만드는 안전장치**가 됐다. --- -## 6. 결과는 무엇이 달라졌는가 +## 6. 어떻게 검증했고 무엇이 달라졌는가 + +정량 수치를 충분히 남기지 못한 점은 분명한 한계였다. +다만 전환 이후에는 운영과 변경 과정에서 몇 가지 변화가 분명하게 보였다. + +| 확인한 변화 | 전환 전 | 전환 후 | +| ------------------- | ---------------------------------------- | ------------------------------------------------------- | +| 파크 추가 논의 방식 | 코드 복제와 예외 분기부터 떠올렸다 | 설정, 도메인 확장 포인트, 어댑터 차이부터 본다 | +| 변경 영향 범위 파악 | 여러 파크와 외부 연동을 함께 훑어야 했다 | 도메인 경계와 포트 기준으로 영향 범위를 좁혀 볼 수 있다 | +| 테스트 기반 수정 | 변경 자체가 부담이 컸다 | 핵심 규칙은 도메인 테스트로 먼저 검증할 수 있다 | -구조 전환 이후 변화는 점진적이지만 분명했죠. +무엇보다 체감이 컸던 변화는 심리적 비용이었다. +예전에는 "이 코드를 고치면 다른 파크가 깨지지 않을까?"가 먼저 떠올랐다. +전환 이후에는 적어도 **어디까지를 먼저 확인해야 하는지**가 훨씬 분명해졌다. -• 새로운 파크 추가 시 설정 중심으로 대응 가능 -• 도메인별 책임과 역할이 드러나 코드 가독성 향상 -• 테스트 기반 변경으로 배포 안정성 확보 +반대로 남은 한계도 있었다. -무엇보다 -**"이 코드를 고쳐도 괜찮을까?"라는 불안이 크게 줄었어요.** +- 파크 추가 리드타임 같은 정량 지표를 초기에 수집하지 못했다 +- 일부 레거시 경로는 여전히 완전히 분리되지 않았다 +- 팀 합의 문서가 코드 변경 속도를 따라오지 못하는 구간이 있었다 + +다음에는 구조 전환과 동시에 +리드타임, 변경 실패율, 테스트 커버 범위 같은 지표를 함께 남길 생각이다. --- -## 7. 이 경험을 통해 얻은 판단 기준 +## 7. 이 경험 이후로 남은 판단 기준 -이 프로젝트를 통해 명확해진 기준이 있죠. +이번 일을 지나며 몇 가지 기준이 더 선명해졌다. -• 확장을 고려하지 않은 구조는 결국 발목을 잡아요 -• 아키텍처는 기술 선택이 아니라 책임 분리의 문제예요 -• 테스트 가능한 구조는 개발 문화까지 바꿔요 +- 확장을 전제로 하지 않은 구조는 기능이 늘수록 반드시 비용으로 돌아온다 +- 아키텍처의 핵심은 프레임워크 선택이 아니라 책임과 변경 경계를 어떻게 자르느냐에 있다 +- 테스트 가능한 구조가 있어야 팀이 구조를 바꾸는 결정을 실제로 할 수 있다 +- 점진 전환이 필요한 시스템일수록 "완벽한 최종 구조"보다 "작게 옮길 수 있는 경계"가 더 중요하다 -구조는 코드 품질뿐 아니라 -**팀의 심리적 안정감과 생산성에 직접적인 영향을 준다**는 점을 체감했죠. +결국 좋은 구조는 코드가 예뻐 보이는 상태가 아니라, +**새 요구사항이 들어왔을 때 어느 부분을 바꾸고 어느 부분을 지켜야 하는지가 드러나는 상태**라고 본다. --- ## 8. 다음에 다시 한다면 -초기에는 아키텍처 개념에 대한 팀원 간 이해도 차이가 있었어요. -다음에는 코드 변경 전에 -**도메인 모델과 책임과 역할을 더 가볍게 합의하는 단계**를 먼저 가져가고 싶어요. +다시 같은 일을 한다면 코드보다 먼저 아래 두 가지를 더 빠르게 고정할 것이다. + +1. 도메인 모델과 책임 경계를 팀 단위로 먼저 짧게 합의한다 +2. 전환 효과를 보여줄 지표를 초기부터 같이 수집한다 -아키텍처 전환은 기술 과제가 아니라 -**사람을 설득하는 과정**이라는 점도 다시 느꼈죠. +이번 경험을 지나며 더 분명해진 것은, +아키텍처 전환이 기술 과제이면서 동시에 설득 과제라는 점이다. +좋은 구조는 혼자 설계해서 끝나는 것이 아니라, +팀이 같은 기준으로 변경을 이어갈 수 있을 때 비로소 유지된다. diff --git "a/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" "b/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" index a33260bf..310458c3 100644 --- "a/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" +++ "b/posts/\355\224\214\353\236\253\355\217\274-\354\213\234\354\212\244\355\205\234-\355\232\214\352\263\240/meta.json" @@ -1,14 +1,10 @@ { "title": "IoT 시스템을 글로벌 플랫폼으로 도약하기 위한 회고", "slug": "platform-system-review", - "description": "로컬 IoT 시스템을 글로벌 플랫폼으로 바꾸기 위해 어떤 구조와 기준을 고민했는지 돌아봐요.", + "description": "제주파크 맞춤 IoT 시스템을 멀티파크 플랫폼으로 전환하기 위해 도메인 경계, Hexagonal Architecture, 점진 전환 전략을 어떻게 정했는지 정리해요.", "date": "2024-08-18", "category": "Tech", - "tags": [ - "Project", - "Engineering", - "Review" - ], + "tags": ["Project", "Engineering", "Review"], "featured": false, "visibility": "public", "qualityReview": {