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/README.md b/README.md
index 9a1df700..502a40d0 100644
--- a/README.md
+++ b/README.md
@@ -1,101 +1,95 @@
-
-
# eunu.log
-[](https://nextjs.org/)
-[](https://www.typescriptlang.org/)
-
-개인 블로그입니다.
-
[라이브 데모](https://eunu-log.vercel.app)
-
-
-## 🛠 기술 스택
-
-
-
-
-
- Next.js
- |
-
-
- React
- |
-
-
- TypeScript
- |
-
-
- Three.js
- |
-
-
- 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
+```
-## 🏗 시스템 아키텍처
+## 콘텐츠 모델
-현재 운영 기준 아키텍처는 아래와 같습니다.
+블로그 글은 폴더 단위로 관리한다.
+중첩 디렉터리를 지원하므로 시리즈 글도 같은 규칙으로 다룬다.
-
-
-핵심 포인트:
-
-- 블로그 앱(`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/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/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/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/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/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 집계가 왜 스트림 처리에서 까다로운지, 어떤 기준으로 설계와 운영 결정을 내렸는지, 실제 구성과 테스트 전략까지 순서대로 정리해요.
+
-## 1.1 기능 파악
+프로젝트 저장소: [ctr-pipeline](https://github.com/dev-wooyeon/ctr-pipeline)
-CTR은 조회수와, 클릭의 각 데이터가 카프카 이벤트가 들어오면 서버에서 수신하고 데이터를 가공해요.
+## 왜 CTR 집계가 운영 문제인가
-그러나 이벤트는 항상 시간 순서대로 도착하지 않고, 네트워크 지연과 파티션 편향 문제도 존재한다는 점을 파악했어요.
+CTR은 보통 아래 두 이벤트를 합쳐 계산한다.
-따라서 정확한 윈도우 집계, 지연 이벤트 처리, 높은 처리량·낮은 지연을 동시에 만족하는 것이 중요하다고 판단했어요.
+- `impression`: 노출
+- `click`: 클릭
-## 1.2 아키텍처 설계
+문제는 이 두 이벤트가 항상 같은 순서로, 같은 지연으로 들어오지 않는다는 점이다.
-
+- 네트워크 지연으로 클릭이 더 늦게 도착할 수 있다.
+- 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으로 안전한 상태 보존
+## 데이터 흐름과 상태 관리
-
+파이프라인의 기본 흐름은 아래와 같다.
----
+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
+```
- 지연·정확성·계산 안정성 비교적 균형적이여서 최종 선택했어요.
+
-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가 발생한다는 점을 알 수 있었어요.
+
-실험 결과로,
+당시 로컬에서 확인한 대표 컨테이너 상태는 아래와 같았다.
-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와 싱크 병렬도를 파티션 수와 맞췄다.
-
+이 변경 이후, 병목은 "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. 시스템 구성
-
-
-
-## 4.1 전체 데이터 흐름
-
-Impressions/Clicks → Kafka → Flink → Redis/ClickHouse/DuckDB → FastAPI
-
-
-
-Flink는 웹 UI를 제공하고, Kafka랑 redis는 UI를 제공하지 않기 때문에, 직접 눈으로 보고 확인 할 수 있도록 각 provectuslabs/kafka-ui와 redis/redisinsight를 사용하여 확인 할 수 있었어요.
-
-## 4.2 Flink 파이프라인 단계
-
-
-
-이벤트 생성부터 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 모니터링
+원인은 단순했다.
-
+- 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 기능 시연이 아니었다.
+실시간 지표를 운영 가능한 시스템으로 바꾸려면 어떤 판단을 먼저 고정해야 하는지, 그리고 그 판단을 어디까지 검증해야 하는지 확인하는 과정이었다.
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/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 6c819182..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,13 +1,18 @@
{
- "title": "ioT 시스템을 글로벌 플랫폼으로 도약하기 위한 회고",
+ "title": "IoT 시스템을 글로벌 플랫폼으로 도약하기 위한 회고",
"slug": "platform-system-review",
- "description": "로컬 IoT 시스템을 글로벌 플랫폼으로 바꾸기 위해 어떤 구조와 기준을 고민했는지 돌아봐요.",
+ "description": "제주파크 맞춤 IoT 시스템을 멀티파크 플랫폼으로 전환하기 위해 도메인 경계, Hexagonal Architecture, 점진 전환 전략을 어떻게 정했는지 정리해요.",
"date": "2024-08-18",
"category": "Tech",
- "tags": [
- "Project",
- "Engineering",
- "Review"
- ],
- "featured": false
+ "tags": ["Project", "Engineering", "Review"],
+ "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"
}
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/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([]);
+ });
+});
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;
};