diff --git a/.gitignore b/.gitignore index d06da34..3efa0fc 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,4 @@ next-env.d.ts .husky/ .sisyphus/ .claude/ -/.agentation/ .idea diff --git a/AGENTS.md b/AGENTS.md index 6d46d5a..76e7395 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,15 +6,17 @@ - 현재 MDX 파이프라인을 보존한다. MDX는 `next.config.mjs`의 `@mdx-js/loader` 기반 커스텀 webpack rule에 남겨둔다. 전체 콘텐츠 파이프라인을 의도적으로 마이그레이션하지 않는 한 Next.js 내장 MDX로 바꾸지 않는다. -- 시각화 중심 UI는 `shared/visualization/`에 둔다. 블로그 MDX가 재사용하는 - 시각화 컴포넌트는 도메인 코드가 아니라 shared 시각화 모듈로 관리한다. +- 시각화 중심 UI는 `blog/ui/visualization/`에 둔다. 블로그 MDX가 재사용하는 + 시각화 컴포넌트는 블로그 콘텐츠 렌더링 책임으로 관리한다. - `any`를 사용하지 않는다. 구체 타입을 쓰거나 `unknown`과 narrowing을 사용한다. - `p-[13px]` 같은 arbitrary Tailwind value를 사용하지 않는다. 표준 utility, - shared token, 기존 스타일 패턴을 사용한다. + ui token, 기존 스타일 패턴을 사용한다. - DDD의 전략적 설계 개념을 차용한 domain-first modular monolith 구조를 - 유지한다. 주요 도메인은 `src`와 같은 최상위 모듈로 분리하고, `src/app`은 - Next.js route adapter로 제한한다. + 유지한다. `app/`은 Next.js route adapter로 제한하고, 주요 도메인은 + 저장소 최상위 모듈로 분리한다. +- `src/` 디렉터리를 재도입하지 않는다. 새 코드 경계는 `app/`, 도메인 모듈, + `site/`, `infra/`, `ui/`, `styles/` 중 하나에 둔다. - 블로그 콘텐츠 구조는 `posts/**/index.mdx`와 주변 `meta.json`으로 유지한다. 중첩된 series 디렉터리도 보존한다. - 변경이 아래 ADR 작성 조건에 걸리면 반드시 ADR을 작성하거나 갱신한다. @@ -39,19 +41,18 @@ ADR 작성 기준: ## 프로젝트 구조 -- `src/app/`: Next.js App Router route adapter, route handler, metadata entry +- `app/`: Next.js App Router route adapter, route handler, metadata entry - `blog/`: 글 도메인. post schema, repository, publication policy, series, - blog UI, view-count use case + blog UI, RSS feed serialization, view-count use case - `resume/`: 이력서 도메인. resume data, ordering, resume UI - `search/`: 검색 도메인. command palette, search action, recommendation - `site/`: 도메인 조합 layer. home, AppShell, navigation, provider, site config -- `platform/`: 외부/런타임 인프라. Supabase, Umami analytics, SEO helper, - devtool integration -- `shared/`: 도메인 지식 없는 UI primitive, motion helper, testing helper, - visualization widget +- `infra/`: 외부/런타임 인프라. Supabase, Umami analytics, SEO helper, + integration adapter +- `ui/`: 도메인 지식 없는 UI primitive, layout primitive, motion helper - `styles/`: design token과 global style - `posts/`: 중첩 가능한 `index.mdx`와 `meta.json` 기반 블로그 콘텐츠 -- `tests/`: Playwright E2E 테스트 +- `tests/`: Playwright E2E 테스트와 Vitest support helper - `tooling/`: script와 tool configuration ## 개발 명령 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 4cfb7ea..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,139 +0,0 @@ -# ARCHITECTURE - -Last updated: 2026-05-07 - -## System Summary - -`eunu.log` is a Next.js App Router blog platform with: - -- File-based MDX content under `posts/**`. -- Static generation for blog routes. -- Server-side view counting via Supabase RPC. -- Client-side analytics via Umami trackers. -- Token-driven UI styling (Tailwind + CSS variables). - -## Runtime Topology - -### Web Runtime - -- Framework: Next.js (`next@16.1.4`) with App Router. -- React: `react@19.2.3`. -- Rendering mix: - - Static prerendered route params via `generateStaticParams` for post pages. - - Server route handlers for feed and OG image. - - Domain-owned server actions for view counting. - -### Edge Runtime - -- `src/app/api/og/route.tsx` runs at the edge and returns social images via `ImageResponse`. - -## High-Level Modules - -### App Layer (`src/app`) - -- `layout.tsx` composes global providers, metadata, JSON-LD, analytics, and dual roots (`#app-root`, `#overlay-root`). -- Main pages: - - `/` home (`src/app/page.tsx`) - - `/blog` listing (`src/app/blog/page.tsx`) - - `/blog/[slug]` post detail (`src/app/blog/[slug]/page.tsx`) - - `/resume` and `/series` -- Route handlers: - - `/feed.xml` (`src/app/feed.xml/route.ts`) - - `/api/og` (`src/app/api/og/route.tsx`) -- `src/app` imports page adapters from top-level domain modules and does not own - domain policy. - -### Content Layer (`posts/**` + `blog/services`) - -- `blog/services/post-repository.ts` recursively discovers valid post folders (`index.mdx` + `meta.json`). -- `meta.json` is validated with Zod (`FeedFrontmatterSchema`). -- MDX is loaded by dynamic import per folder path. -- Reading time is auto-derived from MDX when metadata omits it. -- Slug-to-folder cache accelerates lookups. - -### Markdown/MDX Processing - -- Custom webpack rule in `next.config.mjs` for `.mdx` with: - - `remark-gfm` - - `rehype-slug` - - `rehype-pretty-code` -- `blog/services/markdown-parser.ts` parses MDX headings for TOC data. -- `blog/ui/mdx/components.tsx` maps MDX nodes to UI components and interactive visualization widgets. - -### Domain-first Modular Monolith - -- `blog/`: post schema, repository, publication policy, series, blog UI, view-count use case. -- `resume/`: resume data, ordering, resume UI. -- `search/`: command palette, search action, search recommendation. -- `site/`: home composition, AppShell, navigation, providers, site config. -- `platform/`: Supabase integration, Umami analytics, SEO helper, devtools. -- `shared/`: domain-agnostic UI, layout primitive, motion helper, testing helper, visualization widget. -- `styles/`: design tokens, global base styles, local font CSS. -- Theming via `next-themes` provider in `site/providers`. - -### Data and Integrations - -- Supabase client setup in `platform/integrations/supabase.ts`. -- View count data model: - - table: `public.views` - - rpc: `increment_view(slug_input text) -> bigint` -- SQL provisioning script: `docs/database/supabase-view-count.sql`. - -### Analytics and SEO - -- Umami event helpers in `platform/analytics/lib/analytics.ts`. -- Trackers in `platform/analytics/components/*`. -- Structured data via `JsonLd` component in layout and post page. - -## Request/Data Flows - -### Blog Post Render Flow - -1. `generateStaticParams()` builds route list from posts metadata. -2. Request to `/blog/[slug]` resolves post via `getFeedData(slug)`. -3. MDX source is parsed for heading structure (TOC). -4. MDX component renders with mapped custom components. -5. Client tracker records post view; `blog/api/view.ts` can persist counter in Supabase. - -### Feed Flow - -1. `/feed.xml` route reads sorted metadata. -2. XML string is generated server-side. -3. Response is cached with `s-maxage=3600`. - -### View Count Flow - -1. Client triggers view tracking. -2. Server action normalizes slug and calls Supabase RPC. -3. RPC upserts/increments `views.count`. -4. Latest count is returned/fetched. - -## Testing and Quality - -- Unit/component tests: Vitest + Testing Library across top-level modules and `src/app`. -- E2E tests: Playwright mobile-focused projects (`tests/e2e/**/*.spec.ts`). -- Linting/formatting: ESLint, Prettier, markdownlint, cspell. -- Coverage focus includes `blog`, `resume`, `search`, `site`, `platform`, - `shared`, `styles`, and selected route adapters. - -## Current Architecture Risks - -1. Docs/runtime drift: - - AGENTS and README mention older stack assumptions; package versions are newer. -2. Provider boundary drift: - - Route/layout changes can reintroduce duplicated tracker mounts if `AppProviders` is bypassed. -3. Boundary enforcement drift: - - Module boundaries are physical and documented, but lint-level enforcement is not yet configured. -4. SEO endpoint mismatch risk: - - Post JSON-LD image URL differs from the actual OG route path/domain conventions. - -## Decisions to Preserve - -복원한 아키텍처 결정의 상세 기록은 `docs/adr/README.md`에 둔다. - -1. Keep folder-based content (`posts/**`) with `meta.json + index.mdx`. -2. Keep Zod schema validation in content ingestion path. -3. Keep token-first styling and avoid one-off visual constants where possible. -4. Keep route-level separation for feed, OG, and view-count concerns. -5. Keep Umami analytics separate from Supabase-backed public view counts. -6. Keep top-level domain modules as documented in ADR 0011. diff --git a/README.md b/README.md index 5697df5..b76ffc3 100644 --- a/README.md +++ b/README.md @@ -74,17 +74,17 @@ ```text eunu.log/ -├── 📁 src/ -│ └── 📁 app/ # Next.js route adapter +├── 📁 app/ # Next.js route adapter ├── 📁 blog/ # 글 도메인 ├── 📁 resume/ # 이력서 도메인 ├── 📁 search/ # 검색 도메인 ├── 📁 site/ # 홈, AppShell, provider, site config -├── 📁 platform/ # Supabase, Umami, SEO, devtool integration -├── 📁 shared/ # 도메인 지식 없는 UI/모션/테스트/시각화 +├── 📁 infra/ # Supabase, Umami, SEO integration +├── 📁 ui/ # 도메인 지식 없는 UI/레이아웃/모션 ├── 📁 styles/ # 전역 스타일과 토큰 ├── 📁 tests/ -│ └── 📁 e2e/ # Playwright E2E 테스트 +│ ├── 📁 e2e/ # Playwright E2E 테스트 +│ └── 📁 support/ # Vitest support helper ├── 📁 tooling/ │ ├── 📁 config/ # lint/spell 설정 │ └── 📁 scripts/ # 자동화/유틸 스크립트 diff --git a/src/app/api/og/route.tsx b/app/api/og/route.tsx similarity index 100% rename from src/app/api/og/route.tsx rename to app/api/og/route.tsx diff --git a/src/app/blog/[slug]/error.tsx b/app/blog/[slug]/error.tsx similarity index 82% rename from src/app/blog/[slug]/error.tsx rename to app/blog/[slug]/error.tsx index f80a81e..3051c36 100644 --- a/src/app/blog/[slug]/error.tsx +++ b/app/blog/[slug]/error.tsx @@ -1,8 +1,8 @@ 'use client'; import { useEffect } from 'react'; -import { RouteError } from '@/shared/ui'; -import { AnalyticsEvents, trackEvent } from '@/platform/analytics/lib/analytics'; +import { RouteError } from '@/ui'; +import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; interface BlogPostErrorProps { error: Error & { digest?: string }; diff --git a/src/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx similarity index 100% rename from src/app/blog/[slug]/page.tsx rename to app/blog/[slug]/page.tsx diff --git a/src/app/blog/error.tsx b/app/blog/error.tsx similarity index 81% rename from src/app/blog/error.tsx rename to app/blog/error.tsx index 87e75f4..f637988 100644 --- a/src/app/blog/error.tsx +++ b/app/blog/error.tsx @@ -1,8 +1,8 @@ 'use client'; import { useEffect } from 'react'; -import { RouteError } from '@/shared/ui'; -import { AnalyticsEvents, trackEvent } from '@/platform/analytics/lib/analytics'; +import { RouteError } from '@/ui'; +import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; interface BlogErrorProps { error: Error & { digest?: string }; diff --git a/src/app/blog/page.tsx b/app/blog/page.tsx similarity index 100% rename from src/app/blog/page.tsx rename to app/blog/page.tsx diff --git a/src/app/engineering/page.tsx b/app/engineering/page.tsx similarity index 100% rename from src/app/engineering/page.tsx rename to app/engineering/page.tsx diff --git a/src/app/engineering/series/[seriesId]/page.tsx b/app/engineering/series/[seriesId]/page.tsx similarity index 100% rename from src/app/engineering/series/[seriesId]/page.tsx rename to app/engineering/series/[seriesId]/page.tsx diff --git a/src/app/error.tsx b/app/error.tsx similarity index 81% rename from src/app/error.tsx rename to app/error.tsx index 17eefd0..43dc6aa 100644 --- a/src/app/error.tsx +++ b/app/error.tsx @@ -1,8 +1,8 @@ 'use client'; import { useEffect } from 'react'; -import { RouteError } from '@/shared/ui'; -import { AnalyticsEvents, trackEvent } from '@/platform/analytics/lib/analytics'; +import { RouteError } from '@/ui'; +import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; interface RootErrorProps { error: Error & { digest?: string }; diff --git a/src/app/icon.png b/app/icon.png similarity index 100% rename from src/app/icon.png rename to app/icon.png diff --git a/src/app/layout.tsx b/app/layout.tsx similarity index 97% rename from src/app/layout.tsx rename to app/layout.tsx index 034d7e7..87a5d9f 100644 --- a/src/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,7 @@ import { AppShell } from '@/site/shell/AppShell'; import { SITE_AUTHOR, SITE_DESCRIPTION, + SITE_FEED_PATH, SITE_NAME, SITE_URL, } from '@/site/config/site'; @@ -47,7 +48,7 @@ export const metadata: Metadata = { }, alternates: { types: { - 'application/rss+xml': '/feed.xml', + 'application/rss+xml': SITE_FEED_PATH, }, }, }; diff --git a/src/app/life/page.tsx b/app/life/page.tsx similarity index 100% rename from src/app/life/page.tsx rename to app/life/page.tsx diff --git a/src/app/page.tsx b/app/page.tsx similarity index 100% rename from src/app/page.tsx rename to app/page.tsx diff --git a/src/app/resume/error.tsx b/app/resume/error.tsx similarity index 82% rename from src/app/resume/error.tsx rename to app/resume/error.tsx index c416587..e9396ac 100644 --- a/src/app/resume/error.tsx +++ b/app/resume/error.tsx @@ -1,8 +1,8 @@ 'use client'; import { useEffect } from 'react'; -import { RouteError } from '@/shared/ui'; -import { AnalyticsEvents, trackEvent } from '@/platform/analytics/lib/analytics'; +import { RouteError } from '@/ui'; +import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; interface ResumeErrorProps { error: Error & { digest?: string }; diff --git a/src/app/resume/page.tsx b/app/resume/page.tsx similarity index 100% rename from src/app/resume/page.tsx rename to app/resume/page.tsx diff --git a/src/app/robots.ts b/app/robots.ts similarity index 100% rename from src/app/robots.ts rename to app/robots.ts diff --git a/src/app/feed.xml/route.test.ts b/app/rss.xml/route.test.ts similarity index 95% rename from src/app/feed.xml/route.test.ts rename to app/rss.xml/route.test.ts index 603d98e..0fe64e8 100644 --- a/src/app/feed.xml/route.test.ts +++ b/app/rss.xml/route.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { getSortedFeedData } from '@/blog/services/post-repository'; import { GET } from './route'; -describe('/feed.xml', () => { +describe('/rss.xml', () => { it('excludes private posts from the generated RSS feed', async () => { const response = await GET(); const xml = await response.text(); diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts new file mode 100644 index 0000000..59bf61a --- /dev/null +++ b/app/rss.xml/route.ts @@ -0,0 +1,26 @@ +import { getSortedFeedData } from '@/blog/services/post-repository'; +import { createRssFeed } from '@/blog/services/rss-feed'; +import { + SITE_DESCRIPTION, + SITE_FEED_URL, + SITE_NAME, + SITE_URL, +} from '@/site/config/site'; + +export async function GET() { + const allPosts = getSortedFeedData(); + const rss = createRssFeed({ + posts: allPosts, + siteUrl: SITE_URL, + siteName: SITE_NAME, + siteDescription: SITE_DESCRIPTION, + feedUrl: SITE_FEED_URL, + }); + + return new Response(rss, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=1800', + }, + }); +} diff --git a/src/app/series/page.tsx b/app/series/page.tsx similarity index 100% rename from src/app/series/page.tsx rename to app/series/page.tsx diff --git a/src/app/sitemap.test.ts b/app/sitemap.test.ts similarity index 81% rename from src/app/sitemap.test.ts rename to app/sitemap.test.ts index e5453a9..39b55bc 100644 --- a/src/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -2,10 +2,9 @@ import { describe, expect, it } from 'vitest'; import { getSortedFeedData } from '@/blog/services/post-repository'; +import { SITE_URL } from '@/site/config/site'; import sitemap from './sitemap'; -const URL = 'https://eunu-log.vercel.app'; - describe('sitemap', () => { it('excludes private posts from sitemap entries', () => { const entries = sitemap(); @@ -14,11 +13,11 @@ describe('sitemap', () => { (post) => post.visibility === 'private' ); - expect(urls).toContain(`${URL}/blog/ctr-pipeline`); + expect(urls).toContain(`${SITE_URL}/blog/ctr-pipeline`); expect(privatePosts.length).toBeGreaterThan(0); for (const post of privatePosts) { expect(urls, `${post.slug} leaked to sitemap`).not.toContain( - `${URL}/blog/${post.slug}` + `${SITE_URL}/blog/${post.slug}` ); } }); diff --git a/src/app/sitemap.ts b/app/sitemap.ts similarity index 64% rename from src/app/sitemap.ts rename to app/sitemap.ts index 2c7b700..db80b78 100644 --- a/src/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,24 +1,23 @@ import { MetadataRoute } from 'next'; import { getSortedFeedData } from '@/blog/services/post-repository'; - -const URL = 'https://eunu-log.vercel.app'; +import { SITE_FEED_PATH, SITE_URL } from '@/site/config/site'; export default function sitemap(): MetadataRoute.Sitemap { const feeds = getSortedFeedData(); const feedEntries = feeds.map((feed) => ({ - url: `${URL}/blog/${feed.slug}`, + url: `${SITE_URL}/blog/${feed.slug}`, lastModified: feed.date, // Use actual post date changeFrequency: 'weekly' as const, priority: 0.7, })); - const routes = ['', '/engineering', '/life', '/resume', '/feed.xml'].map( + const routes = ['', '/engineering', '/life', '/resume', SITE_FEED_PATH].map( (route) => ({ - url: `${URL}${route}`, + url: `${SITE_URL}${route}`, lastModified: new Date().toISOString().split('T')[0], changeFrequency: - route === '/feed.xml' ? ('daily' as const) : ('monthly' as const), + route === SITE_FEED_PATH ? ('daily' as const) : ('monthly' as const), priority: 1, }) ); diff --git a/src/app/template.tsx b/app/template.tsx similarity index 100% rename from src/app/template.tsx rename to app/template.tsx diff --git a/blog/api/view.test.ts b/blog/api/view.test.ts index 11cb5c1..026ee8d 100644 --- a/blog/api/view.test.ts +++ b/blog/api/view.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cookies, headers } from 'next/headers'; -import { getSupabaseServerClient } from '@/platform/integrations/supabase'; +import { getSupabaseServerClient } from '@/infra/integrations/supabase'; import { getPopularViewsInRecentDays, getViewCount, @@ -8,7 +8,7 @@ import { trackView, } from './view'; -vi.mock('@/platform/integrations/supabase', () => ({ +vi.mock('@/infra/integrations/supabase', () => ({ getSupabaseServerClient: vi.fn(), })); diff --git a/blog/api/view.ts b/blog/api/view.ts index 642ca0b..24ce4de 100644 --- a/blog/api/view.ts +++ b/blog/api/view.ts @@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { cookies, headers } from 'next/headers'; -import { getSupabaseServerClient } from '@/platform/integrations/supabase'; +import { getSupabaseServerClient } from '@/infra/integrations/supabase'; const VIEW_DEDUPE_WINDOW_SECONDS = 60 * 60 * 24; const VIEW_FINGERPRINT_SALT = @@ -97,7 +97,9 @@ async function createViewerFingerprint(): Promise { 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 secChUaPlatform = readHeaderValue(headerStore, [ + 'sec-ch-ua-infrastructure', + ]); const signatureParts = [ ipAddress, diff --git a/blog/services/rss-feed.test.ts b/blog/services/rss-feed.test.ts new file mode 100644 index 0000000..d7e7d32 --- /dev/null +++ b/blog/services/rss-feed.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import type { FeedData } from '@/blog/model/types'; +import { createRssFeed } from './rss-feed'; + +const post: FeedData = { + title: 'RSS 테스트', + slug: 'rss-test', + description: 'RSS 설명', + date: '2026-05-08', + category: 'Tech', + visibility: 'public', + tags: ['rss'], +}; + +describe('createRssFeed', () => { + it('serializes posts into an RSS 2.0 feed', () => { + const xml = createRssFeed({ + posts: [post], + siteUrl: 'https://example.com', + siteName: 'Example', + siteDescription: 'Example feed', + buildDate: new Date('2026-05-08T00:00:00.000Z'), + }); + + expect(xml).toContain('Example'); + expect(xml).toContain( + '' + ); + expect(xml).toContain('https://example.com/blog/rss-test'); + expect(xml).toContain('<![CDATA[RSS 테스트]]>'); + expect(xml).toContain( + 'Fri, 08 May 2026 00:00:00 GMT' + ); + }); +}); diff --git a/blog/services/rss-feed.ts b/blog/services/rss-feed.ts new file mode 100644 index 0000000..a0ae3d9 --- /dev/null +++ b/blog/services/rss-feed.ts @@ -0,0 +1,72 @@ +import type { FeedData } from '@/blog/model/types'; + +interface CreateRssFeedOptions { + posts: FeedData[]; + siteUrl: string; + siteName: string; + siteDescription: string; + feedUrl?: string; + buildDate?: Date; +} + +function normalizeSiteUrl(siteUrl: string): string { + return siteUrl.replace(/\/$/, ''); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function toCdata(value: string): string { + return `', ']]]]>')}]]>`; +} + +export function createRssFeed({ + posts, + siteUrl, + siteName, + siteDescription, + feedUrl, + buildDate = new Date(), +}: CreateRssFeedOptions): string { + const normalizedSiteUrl = normalizeSiteUrl(siteUrl); + const escapedSiteName = escapeXml(siteName); + const escapedSiteUrl = escapeXml(normalizedSiteUrl); + const escapedFeedUrl = escapeXml(feedUrl ?? `${normalizedSiteUrl}/rss.xml`); + const copyright = `Copyright ${buildDate.getFullYear()}, ${escapedSiteName}`; + const feedItems = posts + .map((post) => { + const postUrl = `${normalizedSiteUrl}/blog/${post.slug}`; + const pubDate = new Date(post.date).toUTCString(); + + return ` + + ${toCdata(post.title)} + ${escapeXml(postUrl)} + ${escapeXml(postUrl)} + ${pubDate} + ${toCdata(post.description)} + ${toCdata(post.category)} + `; + }) + .join(''); + + return ` + + + ${escapedSiteName} + ${escapedSiteUrl} + ${escapeXml(siteDescription)} + ko-KR + ${copyright} + ${buildDate.toUTCString()} + + ${feedItems} + +`; +} diff --git a/blog/ui/components/PostCard/PostCard.tsx b/blog/ui/components/PostCard/PostCard.tsx index a811f94..e75758a 100644 --- a/blog/ui/components/PostCard/PostCard.tsx +++ b/blog/ui/components/PostCard/PostCard.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; import { clsx } from 'clsx'; import { FeedData } from '@/blog/model/types'; -import { CategoryIcon } from '@/shared/ui/icons/AppSectionIcon'; +import { CategoryIcon } from '@/ui/icons/AppSectionIcon'; interface PostCardProps { post: FeedData; diff --git a/blog/ui/components/PostList/PostList.tsx b/blog/ui/components/PostList/PostList.tsx index 457eb52..fe04dc9 100644 --- a/blog/ui/components/PostList/PostList.tsx +++ b/blog/ui/components/PostList/PostList.tsx @@ -3,12 +3,12 @@ import { motion } from 'framer-motion'; import type { Variants } from 'framer-motion'; import { PostCard } from '../PostCard'; -import { EmptyState } from '@/shared/ui'; +import { EmptyState } from '@/ui'; import type { FeedData } from '@/blog/model/types'; import { useEffectiveMotionMode, type EffectiveMotionMode, -} from '@/shared/motion/model/motion-mode'; +} from '@/ui/motion/model/motion-mode'; interface PostListProps { posts: FeedData[]; diff --git a/blog/ui/components/ReadingProgress/ReadingProgress.test.tsx b/blog/ui/components/ReadingProgress/ReadingProgress.test.tsx index 29773fb..c444fb3 100644 --- a/blog/ui/components/ReadingProgress/ReadingProgress.test.tsx +++ b/blog/ui/components/ReadingProgress/ReadingProgress.test.tsx @@ -1,6 +1,6 @@ import { act, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { resetDomState, setWindowScrollY } from '@/shared/testing/dom-mocks'; +import { resetDomState, setWindowScrollY } from '@/tests/support/dom-mocks'; import ReadingProgress from './ReadingProgress'; vi.mock('framer-motion', () => ({ diff --git a/blog/ui/components/ReadingProgress/ReadingProgress.tsx b/blog/ui/components/ReadingProgress/ReadingProgress.tsx index 518177d..29f9323 100644 --- a/blog/ui/components/ReadingProgress/ReadingProgress.tsx +++ b/blog/ui/components/ReadingProgress/ReadingProgress.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { motion, useScroll, useSpring } from 'framer-motion'; -import { useEffectiveMotionMode } from '@/shared/motion/model/motion-mode'; +import { useEffectiveMotionMode } from '@/ui/motion/model/motion-mode'; export default function ReadingProgress() { const [isVisible, setIsVisible] = useState(false); diff --git a/blog/ui/components/ScrollWorkflow.test.tsx b/blog/ui/components/ScrollWorkflow.test.tsx index 3922f6c..e4d9443 100644 --- a/blog/ui/components/ScrollWorkflow.test.tsx +++ b/blog/ui/components/ScrollWorkflow.test.tsx @@ -1,6 +1,6 @@ import { act, render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { resetDomState, setWindowScrollY } from '@/shared/testing/dom-mocks'; +import { resetDomState, setWindowScrollY } from '@/tests/support/dom-mocks'; import { ScrollWorkflow } from './ScrollWorkflow'; function createSection(id: string, top: number) { diff --git a/blog/ui/components/SeriesTrackedLink.test.tsx b/blog/ui/components/SeriesTrackedLink.test.tsx index 1ad4c5f..63cae68 100644 --- a/blog/ui/components/SeriesTrackedLink.test.tsx +++ b/blog/ui/components/SeriesTrackedLink.test.tsx @@ -30,7 +30,7 @@ vi.mock('next/link', () => ({ }, })); -vi.mock('@/platform/analytics/lib/analytics', () => ({ +vi.mock('@/infra/analytics/lib/analytics', () => ({ AnalyticsEvents: { click: 'click', }, diff --git a/blog/ui/components/SeriesTrackedLink.tsx b/blog/ui/components/SeriesTrackedLink.tsx index 00694c2..7738289 100644 --- a/blog/ui/components/SeriesTrackedLink.tsx +++ b/blog/ui/components/SeriesTrackedLink.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import type { MouseEvent, ReactNode } from 'react'; import { clsx } from 'clsx'; -import { AnalyticsEvents, trackEvent } from '@/platform/analytics/lib/analytics'; +import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; type SeriesTrackedTarget = 'series_hub_start' | 'series_hub_episode'; diff --git a/blog/ui/components/TableOfContents.test.tsx b/blog/ui/components/TableOfContents.test.tsx index 2c56d52..cbbb145 100644 --- a/blog/ui/components/TableOfContents.test.tsx +++ b/blog/ui/components/TableOfContents.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { setupScrollToMock } from '@/shared/testing/dom-mocks'; +import { setupScrollToMock } from '@/tests/support/dom-mocks'; import { TableOfContents } from './TableOfContents'; describe('TableOfContents', () => { diff --git a/blog/ui/mdx/components.tsx b/blog/ui/mdx/components.tsx index b73d0a7..4282c7a 100644 --- a/blog/ui/mdx/components.tsx +++ b/blog/ui/mdx/components.tsx @@ -14,32 +14,32 @@ import { // Dynamic imports for visualization components (code splitting) const BinarySearchVisualization = dynamic(() => - import('@/shared/visualization').then((mod) => ({ + import('@/blog/ui/visualization').then((mod) => ({ default: mod.BinarySearchVisualization, })) ); const DPVisualization = dynamic(() => - import('@/shared/visualization').then((mod) => ({ + import('@/blog/ui/visualization').then((mod) => ({ default: mod.DPVisualization, })) ); const GraphTraversalVisualization = dynamic(() => - import('@/shared/visualization').then((mod) => ({ + import('@/blog/ui/visualization').then((mod) => ({ default: mod.GraphTraversalVisualization, })) ); const SlidingWindowVisualization = dynamic(() => - import('@/shared/visualization').then((mod) => ({ + import('@/blog/ui/visualization').then((mod) => ({ default: mod.SlidingWindowVisualization, })) ); const SortingVisualization = dynamic(() => - import('@/shared/visualization').then((mod) => ({ + import('@/blog/ui/visualization').then((mod) => ({ default: mod.SortingVisualization, })) ); const TwoPointerVisualization = dynamic(() => - import('@/shared/visualization').then((mod) => ({ + import('@/blog/ui/visualization').then((mod) => ({ default: mod.TwoPointerVisualization, })) ); diff --git a/blog/ui/pages/BlogListPage.tsx b/blog/ui/pages/BlogListPage.tsx index 874c5fa..5af1369 100644 --- a/blog/ui/pages/BlogListPage.tsx +++ b/blog/ui/pages/BlogListPage.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import { getSortedFeedData } from '@/blog/services/post-repository'; -import { Container } from '@/shared/layout'; +import { Container } from '@/ui/layout'; import BlogListClient from './BlogListClient'; export const metadata: Metadata = { diff --git a/blog/ui/pages/BlogPostPage.tsx b/blog/ui/pages/BlogPostPage.tsx index ca5a933..30403a4 100644 --- a/blog/ui/pages/BlogPostPage.tsx +++ b/blog/ui/pages/BlogPostPage.tsx @@ -9,7 +9,7 @@ import { getMdxSource, parseHeadingsFromMdx, } from '@/blog/services/markdown-parser'; -import { Container } from '@/shared/layout'; +import { Container } from '@/ui/layout'; import { ReadingProgress, TableOfContents, @@ -17,10 +17,10 @@ import { SeriesNavigation, ViewCounter, } from '@/blog/ui/components'; -import PostViewTracker from '@/platform/analytics/components/PostViewTracker'; -import DwellTimeTracker from '@/platform/analytics/components/DwellTimeTracker'; -import ScrollDepthTracker from '@/platform/analytics/components/ScrollDepthTracker'; -import JsonLd from '@/platform/seo/JsonLd'; +import PostViewTracker from '@/infra/analytics/components/PostViewTracker'; +import DwellTimeTracker from '@/infra/analytics/components/DwellTimeTracker'; +import ScrollDepthTracker from '@/infra/analytics/components/ScrollDepthTracker'; +import JsonLd from '@/infra/seo/JsonLd'; import { getMDXComponents } from '@/blog/ui/mdx/components'; import { SITE_URL } from '@/site/config/site'; diff --git a/blog/ui/pages/EngineeringPage.test.tsx b/blog/ui/pages/EngineeringPage.test.tsx index 768282e..4531641 100644 --- a/blog/ui/pages/EngineeringPage.test.tsx +++ b/blog/ui/pages/EngineeringPage.test.tsx @@ -6,7 +6,7 @@ import EngineeringPage from './EngineeringPage'; const mockGetSortedFeedData = vi.fn<() => FeedData[]>(() => []); -vi.mock('@/shared/layout', () => ({ +vi.mock('@/ui/layout', () => ({ Header: () =>
, Container: ({ children }: { children: ReactNode }) =>
{children}
, })); diff --git a/blog/ui/pages/EngineeringPage.tsx b/blog/ui/pages/EngineeringPage.tsx index bfcba87..62806cc 100644 --- a/blog/ui/pages/EngineeringPage.tsx +++ b/blog/ui/pages/EngineeringPage.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import { Suspense } from 'react'; import { getSortedFeedData } from '@/blog/services/post-repository'; -import { Container } from '@/shared/layout'; +import { Container } from '@/ui/layout'; import EngineeringPageClient from './EngineeringPageClient'; export const metadata: Metadata = { diff --git a/blog/ui/pages/EngineeringSeriesPage.test.tsx b/blog/ui/pages/EngineeringSeriesPage.test.tsx index a970ed9..bcd9048 100644 --- a/blog/ui/pages/EngineeringSeriesPage.test.tsx +++ b/blog/ui/pages/EngineeringSeriesPage.test.tsx @@ -9,7 +9,7 @@ const mockNotFound = vi.fn(() => { throw new Error('NOT_FOUND'); }); -vi.mock('@/shared/layout', () => ({ +vi.mock('@/ui/layout', () => ({ Header: () =>
, Container: ({ children }: { children: ReactNode }) =>
{children}
, })); diff --git a/blog/ui/pages/EngineeringSeriesPage.tsx b/blog/ui/pages/EngineeringSeriesPage.tsx index 3f8ab9e..a967e64 100644 --- a/blog/ui/pages/EngineeringSeriesPage.tsx +++ b/blog/ui/pages/EngineeringSeriesPage.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import { getSeriesSummaries } from '@/blog/model/series-group'; import { getSortedFeedData } from '@/blog/services/post-repository'; -import { Container } from '@/shared/layout'; +import { Container } from '@/ui/layout'; interface EngineeringSeriesPageProps { seriesId: string; diff --git a/blog/ui/pages/LifePage.test.tsx b/blog/ui/pages/LifePage.test.tsx index bb8ec5d..90203dd 100644 --- a/blog/ui/pages/LifePage.test.tsx +++ b/blog/ui/pages/LifePage.test.tsx @@ -6,7 +6,7 @@ import LifePage from './LifePage'; const mockGetSortedFeedData = vi.fn<() => FeedData[]>(() => []); -vi.mock('@/shared/layout', () => ({ +vi.mock('@/ui/layout', () => ({ Header: () =>
, Container: ({ children }: { children: ReactNode }) =>
{children}
, })); diff --git a/blog/ui/pages/LifePage.tsx b/blog/ui/pages/LifePage.tsx index f032b0e..089a38e 100644 --- a/blog/ui/pages/LifePage.tsx +++ b/blog/ui/pages/LifePage.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import { getSortedFeedData } from '@/blog/services/post-repository'; -import { Container } from '@/shared/layout'; +import { Container } from '@/ui/layout'; import { PostList } from '@/blog/ui/components'; export const metadata: Metadata = { diff --git a/blog/ui/pages/SeriesPage.test.tsx b/blog/ui/pages/SeriesPage.test.tsx index c3418d1..9cb03e6 100644 --- a/blog/ui/pages/SeriesPage.test.tsx +++ b/blog/ui/pages/SeriesPage.test.tsx @@ -23,7 +23,7 @@ vi.mock('next/link', () => ({ ), })); -vi.mock('@/shared/layout', () => ({ +vi.mock('@/ui/layout', () => ({ Header: () =>
, Container: ({ children }: { children: ReactNode }) =>
{children}
, })); @@ -86,7 +86,7 @@ describe('SeriesPage', () => { ); }); - it('renders shared empty state when no series exists', () => { + it('renders ui empty state when no series exists', () => { mockGetSortedFeedData.mockReturnValue([]); mockGetSeriesSummaries.mockReturnValue([]); diff --git a/blog/ui/pages/SeriesPage.tsx b/blog/ui/pages/SeriesPage.tsx index 53e04f6..fb4763e 100644 --- a/blog/ui/pages/SeriesPage.tsx +++ b/blog/ui/pages/SeriesPage.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import { getSortedFeedData } from '@/blog/services/post-repository'; -import { Container } from '@/shared/layout'; -import { EmptyState } from '@/shared/ui'; +import { Container } from '@/ui/layout'; +import { EmptyState } from '@/ui'; import { getSeriesSummaries } from '@/blog/model/series-group'; import { SeriesHubList } from '@/blog/ui/components'; diff --git a/shared/visualization/BinarySearchVisualization.tsx b/blog/ui/visualization/BinarySearchVisualization.tsx similarity index 100% rename from shared/visualization/BinarySearchVisualization.tsx rename to blog/ui/visualization/BinarySearchVisualization.tsx diff --git a/shared/visualization/DPVisualization.tsx b/blog/ui/visualization/DPVisualization.tsx similarity index 100% rename from shared/visualization/DPVisualization.tsx rename to blog/ui/visualization/DPVisualization.tsx diff --git a/shared/visualization/GraphTraversalVisualization.tsx b/blog/ui/visualization/GraphTraversalVisualization.tsx similarity index 100% rename from shared/visualization/GraphTraversalVisualization.tsx rename to blog/ui/visualization/GraphTraversalVisualization.tsx diff --git a/shared/visualization/SlidingWindowVisualization.tsx b/blog/ui/visualization/SlidingWindowVisualization.tsx similarity index 100% rename from shared/visualization/SlidingWindowVisualization.tsx rename to blog/ui/visualization/SlidingWindowVisualization.tsx diff --git a/shared/visualization/SortingVisualization.tsx b/blog/ui/visualization/SortingVisualization.tsx similarity index 100% rename from shared/visualization/SortingVisualization.tsx rename to blog/ui/visualization/SortingVisualization.tsx diff --git a/shared/visualization/TwoPointerVisualization.tsx b/blog/ui/visualization/TwoPointerVisualization.tsx similarity index 100% rename from shared/visualization/TwoPointerVisualization.tsx rename to blog/ui/visualization/TwoPointerVisualization.tsx diff --git a/shared/visualization/index.ts b/blog/ui/visualization/index.ts similarity index 100% rename from shared/visualization/index.ts rename to blog/ui/visualization/index.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md deleted file mode 100644 index 2528f9b..0000000 --- a/docs/DESIGN.md +++ /dev/null @@ -1,22 +0,0 @@ -# DESIGN - -Last updated: 2026-05-07 - -## Source of Truth - -1. Tokens: `styles/tokens.css` -2. Global style behavior: `styles/globals.css` -3. Reusable component patterns: `shared/ui/**`, `shared/layout/**` - -## Design Guardrails - -1. Prefer token usage over hardcoded color/spacing values. -2. Preserve readability-first typography and spacing rhythm. -3. Keep dark-mode behavior aligned with token inversion rules. -4. Validate mobile safe-area behavior when changing navigation or footer patterns. - -## Validation Checklist - -1. Visual regression on key pages (`/`, `/blog`, `/blog/[slug]`). -2. No layout shift regressions in mobile navigation/header. -3. Color contrast remains accessible for primary text/actions. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md deleted file mode 100644 index 6186d69..0000000 --- a/docs/FRONTEND.md +++ /dev/null @@ -1,32 +0,0 @@ -# FRONTEND - -Last updated: 2026-05-07 - -## Stack - -- Next.js App Router -- React + TypeScript -- Tailwind CSS + CSS variables -- MDX + custom webpack loader pipeline - -## Frontend Architecture - -1. Route adapter layer: `src/app/**` -2. Domain modules: `blog/**`, `resume/**`, `search/**` -3. Composition layer: `site/**` -4. Runtime/integration layer: `platform/**` -5. Domain-agnostic primitives: `shared/**` -6. Styles/tokens: `styles/**` - -## Frontend Rules - -1. Keep boundaries clear between domain modules (`blog`, `resume`, `search`) and composition/infrastructure modules (`site`, `platform`, `shared`). -2. Avoid duplicated global trackers/providers in root layout. -3. Keep domain contracts inside the owning domain module, for example `blog/model/types.ts`. -4. Keep MDX custom component mappings centralized in `blog/ui/mdx/components.tsx`. - -## Test Expectations - -1. Unit/component behavior covered with Vitest. -2. Critical mobile flows covered with Playwright specs. -3. Ensure no runtime errors on static prerender paths. diff --git a/docs/PLANS.md b/docs/PLANS.md deleted file mode 100644 index 5695e7e..0000000 --- a/docs/PLANS.md +++ /dev/null @@ -1,16 +0,0 @@ -# PLANS - -Last updated: 2026-02-26 - -## Planning Model - -1. Create active plans in `exec-plans/active/`. -2. Track residual debt in `exec-plans/tech-debt-tracker.md`. -3. Move delivered plans to `exec-plans/completed/` with validation notes. - -## Required Fields for Any Plan - -1. Clear scope and non-goals. -2. Observable acceptance criteria. -3. Test and rollback strategy. -4. Ownership and ETA assumptions. diff --git a/docs/PRODUCT_SENSE.md b/docs/PRODUCT_SENSE.md deleted file mode 100644 index 8d7074c..0000000 --- a/docs/PRODUCT_SENSE.md +++ /dev/null @@ -1,19 +0,0 @@ -# PRODUCT_SENSE - -Last updated: 2026-02-26 - -## Product Intent (Current) - -`eunu.log` serves long-form technical content and practical retrospectives with strong readability and discoverability. - -## Decision Criteria - -1. Improves reader comprehension. -2. Improves content discoverability and return usage. -3. Preserves performance and mobile usability. - -## Priority Themes - -1. Faster first-session value communication on home page. -2. Better post discovery and series navigation. -3. Measurable engagement instrumentation with low noise. diff --git a/docs/QUALITY_SCORE.md b/docs/QUALITY_SCORE.md deleted file mode 100644 index a2340cc..0000000 --- a/docs/QUALITY_SCORE.md +++ /dev/null @@ -1,29 +0,0 @@ -# QUALITY_SCORE - -Last updated: 2026-02-26 - -## Scoring Rubric (0-5 each) - -1. Correctness -2. Reliability -3. Performance -4. Accessibility -5. Test Coverage -6. Documentation Freshness - -## Current Snapshot (Estimated) - -| Dimension | Score | Notes | -| --- | --- | --- | -| Correctness | 4 | Strong typed codebase and tests, but a few consistency risks remain. | -| Reliability | 3 | Core flows stable; tracker duplication and doc drift exist. | -| Performance | 4 | Static generation + lightweight route handlers. | -| Accessibility | 3 | Base focus/reduced-motion handling exists; needs periodic audits. | -| Test Coverage | 4 | Broad Vitest + targeted Playwright coverage. | -| Documentation Freshness | 2 | Significant drift was identified and partially corrected. | - -## How to Improve Score - -1. Remove instrumentation duplication and standardize SEO URL configuration. -2. Keep architecture/docs synchronized with dependency/runtime upgrades. -3. Add CI checks for lint + unit + smoke e2e (if adopted). diff --git a/docs/README.md b/docs/README.md index 2929d31..146f203 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,50 +1,32 @@ -# Docs Index +# 문서 인덱스 -Last updated: 2026-05-07 +Last updated: 2026-05-08 -This index tracks the smallest set of documentation that must stay aligned with -the current codebase. It is not a full file inventory. +이 인덱스는 현재 코드베이스와 함께 유지해야 하는 문서만 추적한다. 계속 +업데이트할 문서가 아니라면 삭제하거나, 오래 남겨야 하는 결정만 ADR로 옮긴다. -Prefer updating an existing ADR, guide, exec plan, or README before adding a new -standalone document. +새 독립 문서를 추가하기 전에 기존 ADR, guide, README로 흡수할 수 있는지 먼저 +검토한다. -## Repository-Level Docs +## 저장소 기준 문서 - `AGENTS.md` -- `ARCHITECTURE.md` - -## Active Documentation - -- Decisions: - - `docs/adr/README.md` - - `docs/adr/*.md` -- Planning: - - `docs/PLANS.md` - - `docs/exec-plans/active/README.md` - - `docs/exec-plans/tech-debt-tracker.md` -- Maintained guides: - - `docs/guides/**` -- Domain references: - - `docs/database/**` - - `docs/product-specs/**` - - `docs/design-docs/**` - -## Reference / Research - -- `docs/references/**` -- `docs/research/benchmark/**` -- `docs/research/toss/**` -- `docs/tds-rebuild/**` - -## Archive - -- `docs/archive/research-generated/**` - -## Maintenance Rules - -1. Use repository-relative paths only in docs (no absolute local paths). -2. Update `Last updated` when editing policy/process docs. -3. Treat this file as a maintained-doc boundary, not a complete docs inventory. -4. Move stale auto-generated reports to `docs/archive/` instead of deleting context. -5. Keep commands copy-pastable from repository root. -6. Remove or replace stale links when a referenced file no longer exists. +- `README.md` + +## 유지 대상 문서 + +- `docs/adr/README.md` +- `docs/adr/*.md` +- `docs/blog-quality-guide.md` +- `docs/database/db-schema.md` +- `docs/database/supabase-view-count.sql` +- `docs/guides/testing-guide.md` + +## 관리 규칙 + +1. 문서에서는 저장소 기준 상대 경로만 사용한다. +2. 정책과 프로세스 문서를 수정하면 `Last updated`를 갱신한다. +3. 명령어는 저장소 루트에서 바로 실행할 수 있게 작성한다. +4. 참조 파일이 사라지면 stale link를 제거하거나 교체한다. +5. 글쓰기 또는 유지보수 흐름에서 쓰지 않는 일회성 research, 오래된 계획, + 생성 리포트는 삭제한다. diff --git a/docs/RELIABILITY.md b/docs/RELIABILITY.md deleted file mode 100644 index 7061e85..0000000 --- a/docs/RELIABILITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# RELIABILITY - -Last updated: 2026-02-26 - -## Reliability Goals - -1. No broken render paths for published posts. -2. Graceful degradation when external systems (Supabase, GA) are unavailable. -3. Predictable feed and metadata behavior across deploys. - -## Current Reliability Mechanisms - -1. Safe file read helpers and metadata validation in `src/features/blog/services/post-repository.ts`. -2. Null-safe Supabase client creation and fallback behavior in server actions. -3. Static route param generation for blog paths. - -## Known Gaps - -1. Provider composition drift can reintroduce duplicated tracker mounts if `AppProviders` is bypassed. -2. Canonical/OG URL consistency is not centrally enforced. -3. No repository-native CI workflow for continuous reliability checks. diff --git a/docs/SECURITY.md b/docs/SECURITY.md deleted file mode 100644 index 80f2554..0000000 --- a/docs/SECURITY.md +++ /dev/null @@ -1,26 +0,0 @@ -# SECURITY - -Last updated: 2026-02-26 - -## Scope - -Application-level security posture for `eunu.log`. - -## Current Controls - -1. Supabase credentials loaded from environment variables. -2. Server-side Supabase client for view count mutations. -3. No direct client write path for view-count table without RPC/policies. - -## Hardening Checklist - -1. Ensure service-role keys are never exposed in client bundles. -2. Review RLS policies periodically for least privilege. -3. Add dependency vulnerability scanning in CI (if CI is introduced). -4. Validate user-controlled inputs for all route handlers and actions. -5. Standardize security headers and CSP strategy as deployment matures. - -## Immediate Follow-Ups - -1. Confirm production env separation and secret rotation policy. -2. Add a simple threat model section for analytics + content ingestion paths. diff --git a/docs/adr/0001-use-nextjs-app-router.md b/docs/adr/0001-use-nextjs-app-router.md index b131910..3067b60 100644 --- a/docs/adr/0001-use-nextjs-app-router.md +++ b/docs/adr/0001-use-nextjs-app-router.md @@ -21,7 +21,7 @@ Next.js 애플리케이션으로 만든다는 선택이었다. ## 결과 - 정적 블로그 페이지는 `generateStaticParams`를 사용할 수 있다. -- 라우트 핸들러로 `feed.xml`, OG 이미지, 통합 엔드포인트를 제공할 수 +- 라우트 핸들러로 `rss.xml`, OG 이미지, 통합 엔드포인트를 제공할 수 있다. - 조회수 집계 같은 변경 작업은 서버 액션이 소유할 수 있다. - 라우트 파일에 기능 구현이 과하게 쌓이지 않도록 경계를 유지해야 한다. diff --git a/docs/adr/0006-isolate-visualization-heavy-components.md b/docs/adr/0006-isolate-visualization-heavy-components.md index 15dc8ee..d07c668 100644 --- a/docs/adr/0006-isolate-visualization-heavy-components.md +++ b/docs/adr/0006-isolate-visualization-heavy-components.md @@ -4,7 +4,7 @@ Date: 2026-01-28 Status: Accepted, path amended by [0011](0011-adopt-frontend-modular-monolith.md) Note: ADR 0011 moves the physical visualization path from -`src/components/visualization/**` to `shared/visualization/**` while preserving +`src/components/visualization/**` to `blog/ui/visualization/**` while preserving the isolation decision. ## 배경 @@ -14,7 +14,7 @@ the isolation decision. canvas나 SVG 스타일 렌더링, 상호작용 타이밍, 더 무거운 클라이언트 동작이 포함된다. -이후 구조 개편에서도 이 컴포넌트들은 generic shared UI로 흡수되지 않고 +이후 구조 개편에서도 이 컴포넌트들은 generic ui UI로 흡수되지 않고 전용 위치에 보존됐다. ## 결정 @@ -25,15 +25,15 @@ canvas나 SVG 스타일 렌더링, 상호작용 타이밍, 더 무거운 클라 ## 결과 -- 시각화 코드는 shared UI 추상화를 오염시키지 않고 발전할 수 있다. +- 시각화 코드는 ui UI 추상화를 오염시키지 않고 발전할 수 있다. - 블로그 MDX 렌더링은 풍부한 인터랙티브 위젯을 import할 명확한 위치를 가진다. -- shared UI는 재사용 가능한 인터페이스 primitive에 집중한다. +- ui UI는 재사용 가능한 인터페이스 primitive에 집중한다. - 시각화 코드를 옮기려면 구체적인 아키텍처 이유가 필요하다. ## 검토한 대안 -- `src/shared/ui` 아래에 두기: post-specific하고 무거운 동작을 reusable UI +- `src/ui` 아래에 두기: post-specific하고 무거운 동작을 reusable UI primitive처럼 보이게 만든다. - 각 post 옆에 두기: locality는 좋아지지만 패턴이 중복되고 테스트가 어려워진다. diff --git a/docs/adr/0009-use-app-shell-for-primary-navigation.md b/docs/adr/0009-use-app-shell-for-primary-navigation.md index 2f028f5..c563446 100644 --- a/docs/adr/0009-use-app-shell-for-primary-navigation.md +++ b/docs/adr/0009-use-app-shell-for-primary-navigation.md @@ -3,7 +3,7 @@ Date: 2026-04-21 Status: Accepted, path amended by [0011](0011-adopt-frontend-modular-monolith.md) -Note: ADR 0011 moves AppShell from `src/shared/layout/AppShell/**` to +Note: ADR 0011 moves AppShell from `src/ui/layout/AppShell/**` to `site/shell/AppShell/**` while preserving the single app-shell navigation decision. @@ -18,7 +18,7 @@ motion toggle이 제거됐다. ## 결정 -앱 경험의 주요 레이아웃과 탐색 프레임으로 `src/shared/layout/AppShell/**`을 +앱 경험의 주요 레이아웃과 탐색 프레임으로 `src/ui/layout/AppShell/**`을 사용한다. feature page는 navigation chrome을 다시 만들지 않고 이 shell 안에 조합된다. diff --git a/docs/adr/0010-use-targeted-documentation-harness.md b/docs/adr/0010-use-targeted-documentation-harness.md index 4b11663..622a888 100644 --- a/docs/adr/0010-use-targeted-documentation-harness.md +++ b/docs/adr/0010-use-targeted-documentation-harness.md @@ -3,6 +3,9 @@ Date: 2026-05-07 Status: Accepted +참고: ADR 0013에서 `ARCHITECTURE.md`를 제거했다. 현재 harness는 `AGENTS.md`, +`docs/README.md`, `docs/adr/*.md`를 검사한다. + ## 배경 ADR 작성 조건이 `AGENTS.md`와 `docs/adr/README.md`에 추가되면서, 문서 규칙도 @@ -23,8 +26,7 @@ markdownlint 이슈 때문에, 현재 상태에서는 문서 변경 검증용 ha - `AGENTS.md`와 `docs/adr/README.md`가 같은 ADR 작성 조건을 포함하는지 확인한다. - `docs/adr/*.md` 파일이 ADR 인덱스에 등록되어 있는지 확인한다. -- `AGENTS.md`, `ARCHITECTURE.md`, `docs/README.md`, `docs/adr/*.md`에 - markdownlint를 실행한다. +- `AGENTS.md`, `docs/README.md`, `docs/adr/*.md`에 markdownlint를 실행한다. `npm run test:ci`에도 `npm run verify:docs`를 포함한다. diff --git a/docs/adr/0011-adopt-frontend-modular-monolith.md b/docs/adr/0011-adopt-frontend-modular-monolith.md index aa49a4d..78a10f7 100644 --- a/docs/adr/0011-adopt-frontend-modular-monolith.md +++ b/docs/adr/0011-adopt-frontend-modular-monolith.md @@ -3,6 +3,10 @@ Date: 2026-05-07 Status: Accepted +참고: ADR 0012는 route adapter를 `src/app/`에서 `app/`으로 이동했고, ADR 0013은 +이 domain-first modular monolith 결정을 유지하면서 모호한 지원 모듈 이름을 +정리했다. + ## 배경 기존 구조는 `src/features`, `src/domains`, `src/core`, `src/shared` 안에서 @@ -22,34 +26,33 @@ DDD의 전략적 설계 개념을 차용한 domain-first modular monolith를 사 최상위 모듈 경계는 다음과 같이 둔다. -- `src/app/`: Next.js App Router route adapter, route handler, metadata entry. +- `app/`: Next.js App Router route adapter, route handler, metadata entry. - `posts/`: 블로그 원문 콘텐츠 저장소. `index.mdx`와 `meta.json` 구조를 유지한다. - `blog/`: 글 도메인. post schema, repository, publication policy, series, - blog UI, view-count use case를 소유한다. + blog UI, RSS feed serialization, view-count use case를 소유한다. - `resume/`: 이력서 도메인. resume data, ordering, resume UI를 소유한다. - `search/`: 검색 도메인. command palette, search action, recommendation을 소유한다. - `site/`: 도메인 조합 layer. home, AppShell, navigation, provider, site config를 소유한다. -- `platform/`: 외부/런타임 인프라. Supabase, Umami analytics, SEO helper, - devtool integration을 소유한다. -- `shared/`: 도메인 지식이 없는 UI primitive, motion helper, testing helper, - visualization widget만 둔다. +- `infra/`: 외부/런타임 인프라. Supabase, Umami analytics, SEO helper를 + 소유한다. +- `ui/`: 도메인 지식이 없는 UI primitive, layout primitive, motion helper만 둔다. - `styles/`: design token과 global style을 둔다. -의존 방향은 `src/app -> site -> domain -> platform/shared`를 기본으로 한다. +의존 방향은 `app -> site -> domain -> infra/ui`를 기본으로 한다. `blog`는 `posts/`를 읽을 수 있는 유일한 도메인 모듈이다. `site`는 여러 도메인을 조합할 수 있지만, 도메인 내부 정책을 대신 구현하지 않는다. ## 결과 - 저장소 최상단에서 주요 도메인과 조합/인프라 책임이 구분된다. -- `src/app`은 Next.js route adapter 역할에 집중한다. +- `app`은 Next.js route adapter 역할에 집중한다. - 기존 `src/features`, `src/domains`, `src/core`, `src/shared` 경계는 이 결정으로 대체된다. - 단순 폴더 이동만으로 경계가 보장되지는 않는다. import 경계는 public API와 테스트로 우선 관리하고, lint 기반 강제는 별도 결정으로 추가할 수 있다. -- `shared`로 올리는 코드는 도메인 언어를 포함하지 않아야 한다. +- `ui`로 올리는 코드는 도메인 언어를 포함하지 않아야 한다. ## 검토한 대안 diff --git a/docs/adr/0012-use-root-app-route-adapter.md b/docs/adr/0012-use-root-app-route-adapter.md new file mode 100644 index 0000000..7add34d --- /dev/null +++ b/docs/adr/0012-use-root-app-route-adapter.md @@ -0,0 +1,56 @@ +# 0012. Next.js route adapter를 루트 `app/`에 둔다 + +Date: 2026-05-08 +Status: Accepted + +## 배경 + +ADR 0011에서 주요 도메인 모듈을 저장소 최상위로 올렸지만, Next.js App Router +진입점은 `src/app/`에 남겨 두었다. 그 결과 `src/` 아래에는 실질적으로 route +adapter만 남았고, 빈 `src/shared/` 하위 디렉터리도 로컬 작업 트리에 남아 있었다. + +팀 차원에서 경계를 명확히 유지하려면 애매한 중간 그룹을 두기보다 파편화된 +책임 경계를 먼저 드러내고, 필요할 때 합치는 편이 유지보수에 유리하다. +`src/app/`은 프레임워크 진입점이라는 의미는 있었지만, 현재 구조에서는 `src/`가 +새로운 코드 경계처럼 오해될 수 있다. + +## 결정 + +Next.js App Router route adapter를 `src/app/`에서 루트 `app/`으로 이동한다. +빈 `src/` 디렉터리는 제거하고, 새 코드 경계로 재도입하지 않는다. + +저장소 최상위 경계는 다음 기준을 따른다. + +- `app/`: Next.js route adapter, route handler, metadata entry. +- `blog/`, `resume/`, `search/`: 주요 도메인 모듈. +- `site/`: 도메인 조합과 앱 shell. +- `infra/`: 외부/런타임 인프라. +- `ui/`: 도메인 지식 없는 재사용 코드. +- `styles/`: 전역 스타일과 디자인 토큰. +- `posts/`: 앱 코드와 수명주기가 다른 콘텐츠. + +`apps/web` 같은 monorepo형 앱 패키지 구조는 지금 도입하지 않는다. 현재 저장소는 +단일 Next.js 앱이며, 여러 앱을 운영해야 할 때 별도 ADR로 전환한다. + +## 결과 + +- 저장소 루트에서 앱 진입점, 도메인, 조합, 인프라, 콘텐츠 경계가 모두 보인다. +- `src/`라는 빈 중간 계층이 사라져 새 기여자가 코드를 어디에 둬야 하는지 덜 + 헷갈린다. +- Next.js의 표준 `app/` 탐색 경로를 그대로 사용한다. +- `vitest`와 `tailwind` 탐색 범위는 `app/**`와 최상위 모듈 기준으로 갱신한다. +- 향후 여러 앱이 생기면 `apps/web`로 옮기는 변경은 별도 구조 결정이 된다. + +## 검토한 대안 + +- `src/app/` 유지: Next.js 표준 중 하나지만 현재는 `src/`에 다른 책임이 없어 + 중간 디렉터리의 의미가 약하다. +- `apps/web/app/` 도입: 블로그 시스템 템플릿이나 monorepo에는 자연스럽지만, + 현재 단일 앱 저장소에는 과한 패키지 계층을 만든다. +- 모든 도메인을 `apps/web/**` 아래로 이동: 앱과 콘텐츠 분리는 명확해지지만, + ADR 0011에서 의도한 최상위 도메인 경계가 다시 앱 패키지 내부로 숨는다. + +## 관련 히스토리 + +- `docs/adr/0011-adopt-frontend-modular-monolith.md`: 최상위 도메인 모듈 구조를 + 도입한 결정. 이 ADR은 그 구조에서 route adapter 위치만 구체화한다. diff --git a/docs/adr/0013-clarify-support-boundaries-and-docs.md b/docs/adr/0013-clarify-support-boundaries-and-docs.md new file mode 100644 index 0000000..10e1d8f --- /dev/null +++ b/docs/adr/0013-clarify-support-boundaries-and-docs.md @@ -0,0 +1,99 @@ +# 0013. 지원 모듈, 유지 문서, 공개 route 경계를 명확히 한다 + +Date: 2026-05-08 +Status: Accepted + +## 배경 + +ADR 0011과 ADR 0012로 주요 도메인과 Next route adapter는 최상위에 드러났다. +하지만 `platform/`, `shared/`, `docs/`, `tooling/scripts/`에는 아직 의미가 +넓거나 사용 여부가 불명확한 항목이 남아 있었다. + +팀 단위 유지보수에서는 이름만 보고 소유권과 생명주기를 예측할 수 있어야 한다. +`platform`은 제품 플랫폼 도메인처럼 읽힐 수 있지만 실제로는 Supabase, Umami, +SEO 같은 외부 런타임 연동을 담고 있었다. `shared`는 바운디드 컨텍스트가 아니라 +공용 덤프가 되기 쉬운 이름이었다. 문서와 스크립트도 글 작성/검증 파이프라인에 +직접 쓰이지 않는다면 다음 기여자에게 맥락 비용을 만든다. + +추가로 `app/` 아래 route handler는 프레임워크가 찾는 진입점이지만, 실제 기능 +책임까지 같이 들어가면 도메인 경계가 다시 흐려진다. RSS는 `feed.xml` 이름으로 +제공되고 있어 구현이 RSS인지 탐색하기 어려웠고, OG image route는 `app/api/og` +아래에 남아 있어 삭제 가능 여부를 별도로 판단해야 했다. + +## 결정 + +- 루트 디렉터리는 바운디드 컨텍스트와 지원 영역을 함께 드러낸다. + - 바운디드 컨텍스트: `blog/`, `resume/`, `search/` + - route adapter: `app/` + - 사이트 조합 layer: `site/` + - 외부 연동 adapter: `infra/` + - 도메인 없는 UI primitive: `ui/` + - 콘텐츠 저장소: `posts/` + - 검증과 자동화: `tests/`, `tooling/` +- `site/`는 infra가 아니다. home, AppShell, navigation, provider 조합, + site metadata처럼 여러 도메인과 adapter를 하나의 웹사이트로 조립하는 + composition layer로 둔다. +- `app/`은 Next.js route adapter로 제한한다. URL, metadata, request/response + 진입점만 맡고, 도메인 정책과 직렬화 로직은 외부 모듈에 위임한다. +- 빈 `app/components/**` 계층은 제거한다. `app` 아래에 프레임워크 진입점이 + 아닌 코드 배치 공간을 만들지 않는다. +- `platform/`은 `infra/`로 이름을 바꾼다. + - `infra`는 `ui`처럼 통용되는 짧은 약어라 루트 이름으로 사용한다. +- `shared/`는 제거하고 책임별로 나눈다. + - 도메인 없는 UI primitive, layout primitive, motion helper는 `ui/`에 둔다. + - 블로그 MDX 시각화 컴포넌트는 `blog/ui/visualization/`에 둔다. + - Vitest support helper는 `tests/support/`에 둔다. +- Agentation overlay, route handler, dev script, dependency, guide는 제거한다. +- OG 이미지는 `next/og`의 `ImageResponse`를 사용한다. 직접 import하지 않는 + `@vercel/og` dependency는 제거한다. `app/api/og`는 글 상세 metadata와 + JSON-LD가 참조하므로 유지한다. +- Next.js 내장 MDX adapter인 `@next/mdx`는 사용하지 않으므로 제거한다. MDX는 + ADR 0002에 따라 `next.config.mjs`의 `@mdx-js/loader` 기반 custom webpack + pipeline으로 유지한다. +- RSS는 구현 포맷이 드러나도록 canonical route를 `/rss.xml`로 둔다. + - route adapter: `app/rss.xml/route.ts` + - RSS XML 직렬화: `blog/services/rss-feed.ts` + - legacy `/feed.xml` route는 유지하지 않는다. 현재 구독자 호환 요구가 없고, + 남겨두면 다시 탐색 비용을 만든다. +- `tooling/scripts`에는 현재 글 작성/퇴고/검증 파이프라인에서 쓰는 스크립트만 + 남긴다. + - 유지: `new-post`, `content:audit`, `localize:images`, CSS/doc 검증 + - 제거: Agentation dev server, 일회성 research corpus generation +- `docs/`는 계속 갱신될 문서만 남긴다. + - 유지: ADR, 글 품질 가이드, DB 스키마/SQL, 테스트 가이드 + - 제거: 오래된 실행 계획, research dump, archive dump, product/design 초안, + Agentation guide, 중복 PR workflow +- `ARCHITECTURE.md`는 삭제한다. 사람에게는 `README.md`와 `docs/adr/**`를, + AI 작업자에게는 `AGENTS.md`를 기준 문서로 둔다. + +## 결과 + +- 지원 모듈 이름이 실제 책임에 가까워진다. +- `shared`라는 모호한 루트 경계가 사라진다. +- `app/`이 다시 일반 코드 배치 공간처럼 쓰일 가능성이 줄어든다. +- Agentation은 더 이상 런타임, 개발 서버, 의존성에 남지 않는다. +- 글별 OG image는 유지되지만, 미사용 OG dependency는 사라진다. +- RSS 기능은 `/rss.xml`과 `blog/services/rss-feed.ts`에서 더 직접적으로 + 발견된다. +- `/feed.xml`은 더 이상 제공하지 않는다. 호환성보다 구조 단순성과 탐색성을 + 우선한다. +- 문서 수가 줄고, 유지해야 하는 문서의 기준이 `docs/README.md`에 명시된다. +- 일회성 research 산출물을 다시 추가하려면 유지 주체와 사용 경로를 먼저 + 설명해야 한다. + +## 검토한 대안 + +- `site/`를 infra로 취급: provider 조합과 navigation, home은 외부 시스템 연동이 + 아니라 웹사이트 composition 책임이므로 제외했다. +- `platform/` 유지: 이름은 짧지만 제품 플랫폼 도메인과 인프라 연동 책임이 + 섞여 보인다. +- `shared/` 유지: import 경로는 적게 바뀌지만 공용 덤프가 되기 쉽고, 바운디드 + 컨텍스트 기준 점검에 실패한다. +- `app/api/og` 제거: 단순해지지만 글 상세 OpenGraph image와 JSON-LD image가 + 깨진다. 동적 OG 이미지는 공유 품질에 영향을 주므로 유지한다. +- `/feed.xml` redirect 유지: 기존 구독자를 보호할 수 있지만 현재 호환 요구가 + 없고, `feed`와 `rss` 이름이 공존해 다시 혼란을 만든다. +- 문서를 archive로 이동: 삭제보다 안전하지만, 유지되지 않는 문서를 계속 + 탐색하게 만드는 비용이 남는다. +- Agentation을 feature flag로 보존: 더 이상 사용하지 않는 도구라면 dependency와 + route surface를 유지할 이유가 없다. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7ed8058..81a3b14 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -1,6 +1,6 @@ # Architecture Decision Records -Last updated: 2026-05-07 +Last updated: 2026-05-08 이 디렉터리는 커밋 히스토리에서 복원한 아키텍처 의사결정을 기록한다. ADR은 AI 협업 가이드와 별개의 문서다. 사람이 결정했든 AI가 초안을 @@ -11,8 +11,8 @@ ADR은 AI 협업 가이드와 별개의 문서다. 사람이 결정했든 AI가 - 검토한 히스토리: 2026-01-20 `521eae7`부터 2026-04-28 `939601a`까지. - 글 발행, 문장 수정, 단일 버그 수정은 오래 유지될 시스템 제약을 만들지 않는 한 ADR로 승격하지 않는다. -- 현재도 유효한 결정인지 확인하기 위해 `ARCHITECTURE.md`, `AGENTS.md`, - 기존 문서, 현재 소스 구조를 함께 대조했다. +- 현재도 유효한 결정인지 확인하기 위해 `AGENTS.md`, `README.md`, 기존 문서, + 현재 소스 구조를 함께 대조했다. ## 기록 @@ -29,6 +29,8 @@ ADR은 AI 협업 가이드와 별개의 문서다. 사람이 결정했든 AI가 | [0009](0009-use-app-shell-for-primary-navigation.md) | Accepted | 주요 탐색과 레이아웃에 AppShell을 사용한다 | | [0010](0010-use-targeted-documentation-harness.md) | Accepted | 핵심 문서 검증에 targeted documentation harness를 사용한다 | | [0011](0011-adopt-frontend-modular-monolith.md) | Accepted | Domain-first Modular Monolith를 사용한다 | +| [0012](0012-use-root-app-route-adapter.md) | Accepted | Next.js route adapter를 루트 `app/`에 둔다 | +| [0013](0013-clarify-support-boundaries-and-docs.md) | Accepted | 지원 모듈, 유지 문서, 공개 route 경계를 명확히 한다 | ## 작성 조건 diff --git a/docs/analytics/analytics-kpi-weekly-template.md b/docs/analytics/analytics-kpi-weekly-template.md deleted file mode 100644 index 0bbbff3..0000000 --- a/docs/analytics/analytics-kpi-weekly-template.md +++ /dev/null @@ -1,44 +0,0 @@ -# GA4 KPI Dashboard / Weekly Report Template - -## KPI 정의 - -- 홈 -> 글 클릭률: `click(target=home_blog_cta)` / `view(page_path=/)` -- 검색 결과 클릭률: `click(target=command_palette_result)` / `search` -- 에러율: `error` / `view` -- 테마 전환율: `theme` / `view` - -## 대시보드 구성 제안 - -- 기간: 최근 7일, 최근 28일 비교 -- 분해 기준: `device`, `page_path`, `theme` -- 필수 차트 - - 일자별 `view` 추이 - - CTA 클릭률 추이 - - 검색 시도 대비 결과 클릭률 - - 에러 이벤트 추이 - -## 주간 리포트 템플릿 - -```md -# 주간 KPI 리포트 (YYYY-MM-DD ~ YYYY-MM-DD) - -## 1) 이번 주 요약 - -- 핵심 변화 3줄 - -## 2) KPI 현황 - -- 홈 -> 글 클릭률: xx% (전주 대비 +x.x%p) -- 검색 결과 클릭률: xx% (전주 대비 +x.x%p) -- 에러율: xx% (전주 대비 -x.x%p) -- 테마 전환율: xx% (전주 대비 +x.x%p) - -## 3) 원인 분석 - -- 상승/하락의 주요 화면과 이벤트 - -## 4) 다음 액션 - -- 액션 1 (담당/기한) -- 액션 2 (담당/기한) -``` diff --git a/docs/archive/README.md b/docs/archive/README.md deleted file mode 100644 index 536f799..0000000 --- a/docs/archive/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Archive Policy - -Use this directory for documentation that is no longer an active source of truth -but should be retained for historical context. - -## What Belongs Here - -1. Auto-generated analysis snapshots. -2. One-off investigation outputs. -3. Obsolete drafts superseded by active docs. - -## What Should Not Be Archived - -1. Current operating guides. -2. Product specs in active use. -3. Build/test/security baseline documents. diff --git a/docs/archive/research-generated/unknown.md b/docs/archive/research-generated/unknown.md deleted file mode 100644 index 38bdc04..0000000 --- a/docs/archive/research-generated/unknown.md +++ /dev/null @@ -1,7 +0,0 @@ -# Unknowns - -This file is intentionally singular for quick lookup. - -See the full generated report at: - -- `docs/archive/research-generated/unknowns.md` diff --git a/docs/archive/research-generated/unknowns.md b/docs/archive/research-generated/unknowns.md deleted file mode 100644 index d5e2510..0000000 --- a/docs/archive/research-generated/unknowns.md +++ /dev/null @@ -1,36 +0,0 @@ -# Project Unknowns (Auto-Generated) - -Generated on: 2026-02-26 -Method: static repo inspection (`package.json`, `src/`, `docs/`, `AGENTS.md`, `README.md`) - -## Confirmed Mismatches - -1. Stack version drift: - - Docs describe Next.js 14+/React 18+, but current dependencies are Next.js 16.1.4 and React 19.2.3. -2. Component structure drift: - - `AGENTS.md` references `src/components/animations/`, but current tree has no such folder. -3. Layout instrumentation duplication: - - `src/app/layout.tsx` mounts `GoogleAnalytics` and `PageViewTracker` more than once. -4. SEO path/domain inconsistency: - - Post JSON-LD image points to `https://eunu.log/og?...`, while OG endpoint is `/api/og` and canonical domain usage elsewhere is `https://eunu-log.vercel.app`. -5. Documentation tree drift: - - Prior docs set did not include the engineering scaffold requested here. - -## Open Questions - -1. Canonical domain: - - Should canonical metadata move to `https://eunu.log` or stay on `https://eunu-log.vercel.app`? -2. Animation ownership: - - Should animation and algorithm-visualization components stay merged under `visualization`, or be split into an explicit `animations` domain? -3. Analytics architecture: - - Is dual tracker mount intentional for compatibility, or accidental duplicate event risk? -4. MDX pipeline strategy: - - Keep custom webpack MDX loader, or migrate to modern Next.js-native MDX integration? -5. Operational baseline: - - Is GitHub Actions intentionally absent, or should minimal CI be added for lint/unit smoke checks? - -## High-Leverage Next Clarifications - -1. Choose one canonical site URL and apply everywhere. -2. Confirm desired component domain boundaries (`visualization` vs `animations`). -3. Decide whether to enforce CI for `lint + unit + e2e smoke`. diff --git a/docs/database/db-schema.md b/docs/database/db-schema.md index afece86..a92ea63 100644 --- a/docs/database/db-schema.md +++ b/docs/database/db-schema.md @@ -3,9 +3,9 @@ Generated from: - `docs/database/supabase-view-count.sql` -- `platform/integrations/supabase.ts` +- `infra/integrations/supabase.ts` -Last updated: 2026-05-07 +Last updated: 2026-05-08 ## Table: `public.views` @@ -47,7 +47,7 @@ Last updated: 2026-05-07 ## Type Mapping (App) -`platform/integrations/supabase.ts` defines: +`infra/integrations/supabase.ts` defines: - Table row type for `views`. - RPC signature: diff --git a/docs/design-docs/core-beliefs.md b/docs/design-docs/core-beliefs.md deleted file mode 100644 index 0e557e7..0000000 --- a/docs/design-docs/core-beliefs.md +++ /dev/null @@ -1,8 +0,0 @@ -# Core Beliefs - -1. Content readability comes first. -2. Interaction should support comprehension, not distract from text. -3. The visual system should be token-driven and consistently reusable. -4. Motion is optional and should degrade safely for reduced-motion users. -5. Mobile ergonomics are first-class, including safe-area-aware spacing. -6. Design decisions must be testable with visual or behavioral checks. diff --git a/docs/design-docs/index.md b/docs/design-docs/index.md deleted file mode 100644 index 80d9aa2..0000000 --- a/docs/design-docs/index.md +++ /dev/null @@ -1,13 +0,0 @@ -# Design Docs Index - -This folder tracks design direction and rationale for `eunu.log`. - -## Files - -- `core-beliefs.md`: non-negotiable UX/UI beliefs. - -## Related - -- `../DESIGN.md` -- `../FRONTEND.md` -- `../tds-rebuild/README.md` diff --git a/docs/exec-plans/active/2026-04-17-home-feed-layout-review.md b/docs/exec-plans/active/2026-04-17-home-feed-layout-review.md deleted file mode 100644 index 5ed21eb..0000000 --- a/docs/exec-plans/active/2026-04-17-home-feed-layout-review.md +++ /dev/null @@ -1,167 +0,0 @@ -# 홈 메인 피드형 레이아웃 전환 검토 - -## Goal - -메인 페이지를 큰 히어로 중심 소개형 랜딩에서, 콘텐츠 접근성이 높은 -피드형 홈으로 전환하기 위한 방향과 판단 근거를 정리한다. - -이번 문서는 실제 코드 구현보다 앞선 실행 메모 성격의 문서다. 홈의 역할을 -`자기소개 우선`에서 `콘텐츠 발견 우선`으로 재정의하고, 어떤 구조와 원칙으로 -전환할지 결정 완료 상태로 남긴다. - -## Scope - -- 현재 홈 구조의 한계와 전환 배경 정리 -- TO-BE 레이아웃과 정보 구조 정리 -- 프로필 이미지 없이도 성립하는 브랜딩 방식 정리 -- 외부 참고 서비스 관점의 평가 정리 -- 구현 시 바로 참고할 수 있는 메모 정리 - -이번 범위에는 실제 화면 구현, 컴포넌트 수정, 라우팅 변경은 포함하지 않는다. - -## Constraints - -- 기존 코드 구조는 유지한다. - - `src/features/home` - - `src/features/blog` - - `src/shared` -- 메인 홈은 피드 중심으로 재구성하되, 블로그 전체를 평범한 게시판처럼 - 보이게 만들면 안 된다. -- 우측 브랜딩 영역은 프로필 이미지 의존성을 두지 않는다. -- 첫 버전에서 보조 위젯은 최소화한다. -- 상단 탐색 컨트롤은 `검색 + 정렬 + 카테고리`까지 포함한다. -- 모바일에서는 1컬럼 중심으로 자연스럽게 접혀야 한다. - -## Milestones - -### 1. 배경 - -현재 홈은 큰 히어로를 중심으로 구성되어 있고, 사용자가 먼저 보게 되는 것은 -소개와 브랜딩이다. 이 구조는 처음 방문한 사람에게 정체성을 전달하는 데는 -유리하지만, 글을 바로 읽고 싶은 사용자 입장에서는 첫 클릭까지 거리가 있다. - -블로그 글이 충분히 쌓인 시점에는 "내가 누구인가"보다 "무엇을 꾸준히 쓰는가"를 -빠르게 보여주는 편이 더 설득력 있다. 따라서 홈의 중심축을 소개에서 피드로 -옮기는 것이 적절하다고 판단했다. - -### 2. AS-IS 진단 - -- 브랜딩 전달은 강하지만 콘텐츠 탐색이 느리다. -- 홈의 중심이 소개형 섹션이라 최신 글 소비 흐름이 약하다. -- 글이 늘어날수록 홈의 정보 구조가 확장성보다는 랜딩 성격에 머무를 수 있다. -- 대표 글, 시리즈, 이력 요약이 분리 섹션으로 존재해 한 화면에서의 집중도가 - 분산된다. - -### 3. TO-BE 방향 - -메인 홈은 3컬럼 구조로 재구성한다. - -- 좌측: 탐색 - - 카테고리 또는 핵심 진입점 - - 데스크톱에서는 고정 또는 스티키 - - 모바일에서는 상단으로 접거나 가로 스크롤 형태로 축약 -- 중앙: 피드 메인 - - 작은 인트로 스트립 - - 검색 입력 - - 정렬 컨트롤 - - 카테고리 필터 - - 썸네일, 제목, 날짜, 요약, 태그를 포함한 포스트 피드 -- 우측: 브랜딩 - - 이미지 없는 텍스트형 프로필 카드 - - 이름, 한 줄 소개, 역할/관심사 - - 이력서, GitHub, 이메일 등 핵심 링크 - -핵심 원칙은 `중앙 피드가 주인공`이어야 한다는 점이다. 좌우 영역은 메인을 -보조해야 하며, 메인보다 더 튀거나 더 많은 판단을 요구하면 안 된다. - -### 4. 텍스트 기반 브랜딩 카드 원칙 - -우측 카드는 프로필 이미지를 넣지 않아도 매력적으로 보여야 한다. - -- 얼굴 이미지 대신 문장과 타이포로 인상을 만든다. -- 이름보다 "무엇을 쓰는 사람인가"가 더 빨리 읽혀야 한다. -- 링크는 많기보다 선명해야 한다. -- 카드 자체가 화려하기보다 중앙 피드의 신뢰도를 높이는 역할을 해야 한다. - -즉, 브랜딩을 제거하는 것이 아니라 압축하는 방향이다. - -### 5. 외부 관점 평가 - -아래 평가는 공개된 제품 패턴과 문서를 바탕으로 정리한 추론이다. - -#### 토스 관점 - -토스 계열의 UX 감각에서는 탐색 진입점의 명확성과 사용자의 이동 비용 감소가 -긍정적으로 평가될 가능성이 높다. 큰 소개보다 바로 읽을 수 있는 글 피드를 -앞세우는 방향은 서비스형 탐색 경험과 잘 맞는다. - -다만 주변 위젯이 많아지거나 좌우 정보가 과도하면, 메인 액션이 흐려진다는 -지적을 받을 수 있다. - -#### Medium / Substack 관점 - -콘텐츠 플랫폼 관점에서는 메인 화면의 주인공이 콘텐츠여야 한다는 점에서 -방향성이 맞다. 특히 최신 글, 발견, 추천의 흐름이 홈에 직접 드러나는 구조는 -자연스럽다. - -반대로 모든 카드가 같은 무게로만 쌓이면 에디토리얼 위계가 약해 보일 수 있다. -첫 번째 대표 글 또는 고정 글에 대한 강조가 추후 필요할 수 있다. - -#### LinkedIn / SNS 크리에이터 관점 - -작성자 정체성은 남겨야 하지만, 압축된 형태여야 한다는 평가가 예상된다. -프로필 이미지를 빼는 선택은 가능하지만, 대신 한 줄 소개와 역할 정의가 더 -정확해야 한다. - -잘 되면 성숙한 텍스트 기반 브랜딩으로 보이지만, 약하면 익명성 높은 목록형 -페이지처럼 보일 위험이 있다. - -### 6. 최종 의사결정 - -- 큰 히어로는 줄인다. -- 홈의 중심은 중앙 피드로 바꾼다. -- 우측은 이미지 없는 텍스트형 프로필 카드로 간다. -- 첫 버전은 보조 위젯을 넣지 않는다. -- 상단 컨트롤은 `검색 + 정렬 + 카테고리`를 포함한다. -- 홈은 소개형 랜딩보다 콘텐츠 접근성이 우선인 구조로 전환한다. - -### 7. 구현 메모 - -- `HomePage`는 기존의 큰 `HeroSection` 의존을 줄이고, 홈 전용 레이아웃을 - 중심으로 재구성한다. -- 글 데이터는 기존 블로그 도메인과 리스트/카드 컴포넌트를 최대한 재사용한다. -- 리스트 UI는 현재 `PostList`와 `PostCard`의 `list` 변형을 기반으로 조정한다. -- 모바일에서는 좌우 컬럼을 접고 중앙 피드 우선 구조를 유지한다. -- 첫 구현에서는 `Friends`, `Old Blog` 같은 부가 위젯은 제외한다. - -## Verification - -- 홈의 목적이 `소개 우선`에서 `콘텐츠 발견 우선`으로 명확히 전환되었는지 - 문서 수준에서 설명 가능해야 한다. -- 좌측 탐색, 중앙 피드, 우측 브랜딩의 역할이 중복 없이 분리되어 있어야 한다. -- 프로필 이미지 없이도 브랜딩 카드가 성립하는 이유가 문서에 드러나야 한다. -- 외부 참고 서비스 관점에서의 긍정 포인트와 리스크가 함께 정리되어 있어야 - 한다. -- 구현자가 이 문서만 읽고 홈 구조 개편의 의도와 우선순위를 이해할 수 있어야 - 한다. - -## Rollback - -실제 구현 단계에서 아래 신호가 보이면 피드형 전환 강도를 낮춘다. - -- 좌우 영역 때문에 메인 피드 집중도가 떨어지는 경우 -- 검색/정렬/카테고리가 한 화면에서 과밀하게 느껴지는 경우 -- 텍스트형 브랜딩 카드가 충분한 존재감을 만들지 못하는 경우 -- 전체 홈이 generic한 블로그 목록처럼 보이는 경우 - -이 경우 롤백 방향은 `완전한 피드형 폐기`가 아니라, 상단 compact intro를 조금 -키우거나 대표 글 1개를 더 강하게 강조하는 방식으로 위계를 보정하는 것이다. - -## References - -- [Toss Navigation Score 사례](https://toss.tech/article/Toss_Navigation_Score) -- [Toss Engineering 카테고리](https://toss.tech/category/engineering) -- [Medium homepage help](https://help.medium.com/hc/en-us/articles/115012586467-Your-homepage) -- [Substack app home guide](https://support.substack.com/hc/en-us/articles/19291693034004-Getting-started-on-the-Substack-app) -- [Substack homepage layouts](https://support.substack.com/hc/en-us/articles/360039015892-How-do-I-switch-my-publication-s-homepage-to-a-different-layout) -- [LinkedIn articles/newsletters guide](https://members.linkedin.com/content/dam/me/members/en-us/pdf/articlesnewsletters.pdf) diff --git a/docs/exec-plans/active/2026-04-22-public-content-cleanup.md b/docs/exec-plans/active/2026-04-22-public-content-cleanup.md deleted file mode 100644 index a3c2a5a..0000000 --- a/docs/exec-plans/active/2026-04-22-public-content-cleanup.md +++ /dev/null @@ -1,69 +0,0 @@ -# 공개 글 정리 인벤토리 - -작성일: 2026-04-22 - -## 기준 - -- 기준 문서: `docs/blog-quality-guide.md` -- 우선 범위: `visibility = public` -- 정리 순서: 메타 정리 -> 대표 글 구조 보강 -> 문장/톤 정리 - -## 공개 글 분류 - -### 핵심 유지 - -- `db-outage-analysis-retrospective` -- `msa-domain-workspace-submodule` -- `settlement-automation` -- `payment-system-design` -- `ctr-pipeline` - -### 보강 필요 - -- `operation-automation` -- `blog-system-building` -- `data-analysis-pipeline-poc` -- `platform-system-review` -- `review-2025` -- `seven-missed-calls` -- `vietnam-travel` -- `resume-feat-sheff` -- `first-company-review` -- `pair-programming` -- `with-grow` - -## 1차 정리 대상 - -- `db-outage-analysis-retrospective` -- `msa-domain-workspace-submodule` -- `operation-automation` -- `blog-system-building` -- `vietnam-travel` - -## private 유지 후보 - -- `fixed-ai-dev-environment` -- `ai-wild-learning-style` -- `oss-readme-translation-lead-time` -- `알고리즘-시각화` - -## 다음 라운드 메모 - -- 라이프 글은 `상황 -> 남은 장면 -> 해석 -> 지금의 기준` 흐름으로 통일 -- 기술 글은 도입 3문단 안에 문제와 비용을 먼저 드러내기 -- `featured`는 현재 큐레이션을 유지하고, 본문 보강 이후 재검토 - -## 2026-05-06 진행 메모 - -- 감사 결과 `revise`였던 공개 Life 글 4개를 2차 보강했다. -- 대상: `review-2025`, `seven-missed-calls`, `vietnam-travel`, `with-grow` -- 보강 방향: 도입 hook, 제목/태그 구체성, H2 위계, 적용 기준 섹션 -- 추가 보강: 같은 판단 흐름의 문장 연결, 메모체 문장 정리, 문체 통일 -- 결과: `npm run content:audit` 기준 4개 글 모두 `ready` - -## 2026-05-06 추가 진행 메모 - -- 6W 1H 기준을 품질 가이드에 추가하고 공개 Life 글 3개를 추가 보강했다. -- 대상: `first-company-review`, `resume-feat-sheff`, `pair-programming` -- 보강 방향: 제목 구체화, 도입부 독자 타깃 명시, 적용 기준 섹션 강화 -- 결과: `npm run content:audit` 기준 3개 글 모두 `ready` diff --git a/docs/exec-plans/active/README.md b/docs/exec-plans/active/README.md deleted file mode 100644 index bc283ec..0000000 --- a/docs/exec-plans/active/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Active Execution Plans - -Place in-flight execution plans in this folder. - -## Naming - -Use `YYYY-MM-DD-short-name.md` for each active plan. - -## Expected Sections - -1. Goal -2. Scope -3. Constraints -4. Milestones -5. Verification -6. Rollback diff --git a/docs/exec-plans/completed/2026-03-11-refactor-to-standard-nextjs.md b/docs/exec-plans/completed/2026-03-11-refactor-to-standard-nextjs.md deleted file mode 100644 index 6419abb..0000000 --- a/docs/exec-plans/completed/2026-03-11-refactor-to-standard-nextjs.md +++ /dev/null @@ -1,44 +0,0 @@ -# Refactor to Standard Next.js Structure - -Status: superseded on 2026-03-30 - -## 1. Goal - -This plan proposed flattening the current feature-first structure into a more -generic Next.js layout. It is no longer active because the repository guidance -now explicitly preserves `src/features`, `src/shared`, `src/domains`, and -`src/core` by responsibility. - -## 2. Scope - -- Do not execute the flattening migration described in the original draft. -- Keep the existing feature-first layout intact unless the repository - guidelines are intentionally changed. -- Preserve the current MDX pipeline and visualization component placement. -- Retain this file only as a record of a rejected direction. - -## 3. Constraints - -- Repository guidelines take precedence over this draft. -- No directory flattening should happen while `AGENTS.md` requires the - feature-first structure. -- Future structure work must start from an updated execution plan. - -## 4. Milestones - -1. Mark the draft as superseded. -2. Move it out of `docs/exec-plans/active/`. -3. Revisit only if repository guidelines change. - -## 5. Verification - -- Confirm no active execution plan in this repository asks for structure - flattening. -- Keep the active plan folder reserved for work that still matches current - repository rules. - -## 6. Rollback - -- Restore the file to `docs/exec-plans/active/` only if the repository - guidelines are deliberately changed and the plan is rewritten to match the - new rules. diff --git a/docs/exec-plans/completed/README.md b/docs/exec-plans/completed/README.md deleted file mode 100644 index cc38c0e..0000000 --- a/docs/exec-plans/completed/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Completed Execution Plans - -Move finalized plans here after validation is complete. - -## Naming - -Keep the original file name from `active/`. - -## Completion Checklist - -1. Tests passed or explicitly waived. -2. Risks and follow-up items documented. -3. Linked PR/commit noted in the file. diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md deleted file mode 100644 index eaed0a6..0000000 --- a/docs/exec-plans/tech-debt-tracker.md +++ /dev/null @@ -1,23 +0,0 @@ -# Tech Debt Tracker - -Last reviewed: 2026-02-28 - -## Open Items - -| ID | Area | Issue | Impact | Suggested Fix | -| --- | --- | --- | --- | --- | -| TD-001 | Docs drift | Runtime versions in docs differ from `package.json` (`next@16`, `react@19`). | Medium | Align AGENTS/README/architecture docs with actual stack. | -| TD-002 | Analytics | `layout.tsx` mounts GA and page-view trackers more than once. | Medium | Keep single source of truth per tracker in root layout. | -| TD-003 | SEO | JSON-LD OG URL path/domain may not match OG route conventions. | Low | Standardize canonical site URL + OG endpoint helper. | -| TD-004 | Repo hygiene | `.DS_Store` exists under `src/app`. | Low | Remove and block with `.gitignore`. | - -## Tracking Rules - -1. Add issue IDs in PR descriptions when resolving debt. -2. Move resolved rows to a changelog section with date and commit reference. - -## Resolved Items - -| ID | Area | Resolution | Date | -| --- | --- | --- | --- | -| TD-005 | Structure docs | Removed stale `src/components/animations` references from active docs. | 2026-02-28 | diff --git a/docs/guides/agentation-workflow.md b/docs/guides/agentation-workflow.md deleted file mode 100644 index f4028d6..0000000 --- a/docs/guides/agentation-workflow.md +++ /dev/null @@ -1,109 +0,0 @@ -# Agentation 좌표 기반 작업 가이드 - -이 가이드는 Agentation 오버레이와 Agentation MCP를 함께 써서 -화면 좌표 기반으로 UI 변경점을 빠르게 전달하는 흐름을 정리해요. - -## 1. 프로젝트에서 오버레이 켜기 - -개발 서버를 실행하면 Next dev 서버와 Agentation 서버를 같이 띄워요. -Agentation 서버가 정상 응답할 때만 화면 오른쪽 아래에 툴바가 보여요. - -```bash -npm run dev -``` - -기본 엔드포인트는 same-origin 프록시인 `/api/agentation-sync`예요. -Next 개발 서버가 내부에서 `http://127.0.0.1:4747`로 전달해서 CORS 없이 써요. -필요하면 환경변수로 바꿔서 쓸 수 있어요. - -```bash -NEXT_PUBLIC_AGENTATION_ENDPOINT=/api/agentation-sync -``` - -Agentation 서버를 별도로만 띄우고 싶으면 아래 스크립트를 써요. - -```bash -npm run dev:agentation -``` - -같이 자동 실행하지 않으려면 아래처럼 끌 수 있어요. - -```bash -AGENTATION_AUTOSTART=false npm run dev -``` - -## 2. Codex MCP 서버 등록 - -아래 명령으로 Codex에 Agentation MCP를 등록해요. - -```bash -npx -y add-mcp@latest agentation-mcp -a codex -g -y -``` - -등록 후 `~/.codex/config.toml`에 `mcp_servers.agentation` 항목이 생겼는지 확인해요. - -```toml -[mcp_servers.agentation] -command = "npx" -args = ["-y", "agentation-mcp", "server"] -``` - -## 3. Agentation MCP 서버 실행 - -아래 명령으로 HTTP + MCP 서버를 같이 실행해요. - -```bash -npx agentation-mcp server -``` - -기본 포트는 `4747`이에요. - -## 4. 실제 작업 흐름 - -1. 웹 화면에서 Agentation 툴바를 켜요. -2. 수정하고 싶은 UI 요소를 클릭하거나 영역을 드래그해요. -3. 코멘트를 남기고 전송해요. -4. Codex에서 Agentation MCP 도구로 새 annotation을 받아요. -5. 코드 수정 후 annotation을 resolve 처리해요. - -## 4-1. Webhook 자동 실행 흐름 - -개발 환경에서는 코멘트를 등록하면 webhook으로 자동 실행을 바로 트리거해요. - -- 기본 webhook URL: `/api/agentation/webhook` -- 기본 동작: `annotation.add` 또는 `submit` 이벤트가 들어오면 `codex exec`를 한 번 실행하고 종료해요. webhook에 담긴 코멘트, 세션, URL 같은 정보도 함께 프롬프트에 넣어서 바로 처리해요. -- 중복 실행 방지: `.agentation/autorun.lock.json` 락 파일로 한 번에 한 프로세스만 실행해요. -- stale 실행 정리: 기본 120초를 넘기거나 45초 동안 로그가 멈춘 autorun은 다음 webhook 요청이 들어오면 정리하고 새로 실행해요. -- 로그 경로: `.agentation/autorun.log` - -환경변수로 동작을 조정할 수 있어요. - -```bash -NEXT_PUBLIC_AGENTATION_WEBHOOK_URL=/api/agentation/webhook -AGENTATION_AUTORUN_ENABLED=true -AGENTATION_AUTORUN_COMMAND="codex exec --full-auto -C /Users/noah/workspace/personal/eunu.log '방금 등록된 annotation을 처리해줘'" -AGENTATION_AUTORUN_MAX_AGE_MS=120000 -AGENTATION_AUTORUN_IDLE_TIMEOUT_MS=45000 -``` - -`AGENTATION_AUTORUN_COMMAND`를 지정하지 않으면 기본 `codex exec --full-auto` 명령을 사용해요. - -실행 전 연결만 확인하고 싶으면 `dryRun=1`로 호출해요. - -```bash -curl -X POST 'http://localhost:3000/api/agentation/webhook?dryRun=1' \ - -H 'Content-Type: application/json' \ - -d '{"event":"annotation.add","annotation":{"id":"demo"}}' -``` - -## 5. 자주 쓰는 점검 - -- 오버레이가 안 보이면 개발 환경인지 확인해요. -- annotation이 전송되지 않으면 `agentation-mcp server` 실행 상태를 확인해요. -- MCP가 안 잡히면 Codex를 재시작해요. - -## 6. 권장 운영 방식 - -- 한 annotation에는 한 작업만 담아요. -- "무엇을 어떻게"를 짧게 적어요. -- 수정 완료 후 resolve 메시지에 변경 파일 경로를 남겨요. diff --git a/docs/guides/pr-workflow.md b/docs/guides/pr-workflow.md deleted file mode 100644 index db3b392..0000000 --- a/docs/guides/pr-workflow.md +++ /dev/null @@ -1,19 +0,0 @@ -# PR Workflow - -## 기본 규칙 - -- PR 작성 시 `.github/pull_request_template.md` 템플릿을 그대로 사용합니다. -- `변경 내용 / 의도 / 영향 범위 / 검증`을 모두 채웁니다. - -## 필수 체크 - -- [ ] `npm run build` -- [ ] (필요 시) `npm test` -- [ ] 다크/라이트 모드 확인 -- [ ] 모바일/데스크톱 확인 - -## 운영 규칙 - -- 브랜치: `codex/` 형식 사용 -- 커밋: `: <한국어 설명>` 형식 사용 -- 관련 이슈/PR 링크를 본문에 명시 diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index eada71c..e31bbd3 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -17,7 +17,7 @@ ### lib -- `platform/analytics/lib/analytics.test.ts` +- `infra/analytics/lib/analytics.test.ts` - GA ID 부재/부재 조건에서 이벤트 미발송 - trackEvent, trackPageView 실 발송 가드 @@ -43,7 +43,7 @@ - 아이템 수, active 상태, 토큰 기반 class, search action, analytics tracking - `blog/ui/components/*.test.tsx` - PostCard/PostList/CategoryFilter 상태별 렌더/이벤트 -- `shared/ui/*.test.tsx` +- `ui/**/*.test.tsx` - Button/EmptyState/Route state 상태 검증 ### styles @@ -63,8 +63,7 @@ 2. `AGENTS.md`와 `docs/adr/README.md`가 같은 ADR 작성 조건을 포함하는지 확인한다. 3. `docs/adr/*.md` 파일이 ADR 인덱스에 등록되어 있는지 확인한다. -4. `AGENTS.md`, `ARCHITECTURE.md`, `docs/README.md`, `docs/adr/*.md`에 - markdownlint를 실행한다. +4. `AGENTS.md`, `docs/README.md`, `docs/adr/*.md`에 markdownlint를 실행한다. 문서 변경이 ADR, 저장소 규칙, 문서 인덱스에 닿으면 최소 검증으로 `npm run verify:docs`를 실행한다. diff --git a/docs/guides/ui-components-guide.md b/docs/guides/ui-components-guide.md deleted file mode 100644 index 01f99cf..0000000 --- a/docs/guides/ui-components-guide.md +++ /dev/null @@ -1,104 +0,0 @@ -# UI Component Guide - -## Button - -### Variants - -- `primary`: 핵심 CTA -- `secondary`: 보조 액션 -- `tertiary`: 텍스트 버튼 - -### Sizes - -- `sm`, `md`, `lg` - -### States - -- `loading` -- `disabled` -- `fullWidth` - -## EmptyState - -### Variants - -- `default`: 일반 빈 상태 -- `search`: 검색 빈 결과 -- `error`: 오류 안내 - -### Sizes - -- `sm`, `md` - -### Action - -- 클릭 액션: `action.onClick` -- 링크 액션: `action.href` - -## Mobile Navigation Regression Check (TDS) - -### Scope - -- `src/shared/layout/Header/Header.tsx` -- `src/shared/layout/Header/MobileBottomNav.tsx` -- `src/shared/layout/Header/useScrollVisibility.ts` -- `src/styles/globals.css` -- `src/styles/tokens.css` - -### Smoke scenarios (모바일 768px 이하) - -#### 1. 홈 상단 진입(0~40px) - -1. Navigate to `/`. -2. Verify header stays visible and bottom navigation is visible on first paint. -3. Verify all 5 bottom items are selectable and focusable. -4. Check bottom area gap is not overallocated (content should not look detached from nav). - -#### 2. 홈 중간 스크롤(41~200px) - -1. Scroll down from 홈 to around `41px`. -2. Verify top header hides on scroll-down and shows on small upward scroll. -3. Verify bottom nav can remain visible/hide according to the scroll direction rule and does not disappear on initial touch jitter. -4. Scroll from `41px` to `200px` and back; ensure behavior is stable and not flickering. - -#### 3. 라우트 전환 복원성 - -1. From 홈, 이동 to `/blog`, `/blog/[slug]`, `/series`, `/resume` and back. -2. Verify 홈 재진입 시 하단 네비게이션은 다시 접근 가능한 상태로 시작. -3. Verify deep link 복귀 시 상단 헤더/바텀바가 과도하게 유지되지 않는지 확인. - -#### 4. 블로그 글 상세 스크롤 UX (`/blog/*`) - -1. Open any `/blog/...` 페이지. -2. Scroll down while header/bottom nav are shown -> confirm bottom nav hides on downward movement. -3. Scroll up -> confirm bottom nav reappears. -4. Confirm top header follows same hide/show rule. - -#### 5. 다크 / 라이트 토글 상태 - -1. Toggle theme while on 모바일 뷰. -2. Verify nav active item bg/border and focus ring contrast is readable in both themes. -3. Verify active text and idle text colors are distinct and not washed out. - -#### 6. 안전영역 / 여백 검증 - -1. Use iOS 시뮬레이터 or mobile device with home-indicator inset. -2. Verify bottom reserved space uses `env(safe-area-inset-bottom)` and does not double-apply when bottom nav is hidden. - -#### 7. 저장소 접근 제한 대응 - -1. Simulate blocked or private mode with storage restrictions. -2. Confirm nav visibility still works without runtime errors. -3. Confirm no layout reserve gap remains when 바텀바가 닫힘 상태일 때. - -## Automated QA Mapping - -- `npm run test:unit` - - Layout hook: `/`와 `/blog/*`에서 홈/스크롤 동작 회귀 -- `npm run test:components` - - 모바일 바텀 네비, Header, 버튼/빈상태/필터 컴포넌트의 상태/클래스 회귀 -- `npm run test:e2e` - - `tests/e2e/smoke/mobile-nav.smoke.spec.ts` - - `tests/e2e/regression/theme-regression.spec.ts` - - `tests/e2e/layout/safe-area.spec.ts` - - `tests/e2e/resilience/session-storage-fallback.spec.ts` diff --git a/docs/product-specs/index.md b/docs/product-specs/index.md deleted file mode 100644 index 577c4b9..0000000 --- a/docs/product-specs/index.md +++ /dev/null @@ -1,12 +0,0 @@ -# Product Specs Index - -This folder contains product-level feature specs and acceptance criteria. - -## Current Specs - -- `new-user-onboarding.md` - -## Related - -- `../PRODUCT_SENSE.md` -- `../PLANS.md` diff --git a/docs/product-specs/new-user-onboarding.md b/docs/product-specs/new-user-onboarding.md deleted file mode 100644 index be8199b..0000000 --- a/docs/product-specs/new-user-onboarding.md +++ /dev/null @@ -1,52 +0,0 @@ -# Spec: New User Onboarding - -Status: draft -Last updated: 2026-02-26 - -## Problem - -First-time visitors may not quickly discover the core value of the site (technical depth + practical retrospectives) and may bounce before reading a full post. - -## Goals - -1. Help first-time users understand site focus in under 10 seconds. -2. Increase first-session post click-through from home page. -3. Preserve fast load and readability. - -## Scope - -In scope: - -- Home hero messaging clarity. -- Featured/recent post entry points. -- Lightweight first-session guidance. - -Out of scope: - -- Account creation or user auth flows. -- Personalization by logged-in identity. - -## Functional Requirements - -1. Home page must communicate site focus and audience in visible hero copy. -2. At least one high-signal call-to-action should lead to blog list or featured post. -3. Recent posts should expose category and reading-time hints. -4. Mobile interaction must remain safe-area-aware and non-blocking. - -## Non-Functional Requirements - -1. No regression to Core Web Vitals baseline. -2. Must support Korean content and metadata. -3. Must be testable with existing unit/e2e stack. - -## Success Metrics - -1. Home -> blog click-through rate. -2. Average first-session engaged time. -3. Post detail entry rate from first session. - -## Suggested Implementation Notes - -1. Keep copy changes in posts/internal config-driven locations where possible. -2. Reuse existing home section components before adding new primitives. -3. Track CTA clicks through existing analytics utility. diff --git a/docs/references/design-system-reference-llms.txt b/docs/references/design-system-reference-llms.txt deleted file mode 100644 index a5e5535..0000000 --- a/docs/references/design-system-reference-llms.txt +++ /dev/null @@ -1,18 +0,0 @@ -Design system references for LLM-assisted engineering in this repo: - -1) Primary token source: -- src/styles/tokens.css - -2) Global application styles: -- src/styles/globals.css -- src/styles/tossface.css - -3) UI component examples: -- src/shared/ui/** -- src/shared/layout/** -- docs/guides/ui-components-guide.md -- docs/tds-rebuild/** - -Notes: -- Prefer existing CSS variables over introducing new one-off values. -- Keep mobile safe-area handling intact. diff --git a/docs/references/nixpacks-llms.txt b/docs/references/nixpacks-llms.txt deleted file mode 100644 index 7eb7bfc..0000000 --- a/docs/references/nixpacks-llms.txt +++ /dev/null @@ -1,12 +0,0 @@ -Nixpacks reference notes (project-specific): - -1) Current deployment assumption appears Vercel-based (no existing Nixpacks config in repo root). -2) If Nixpacks adoption is needed, start with: -- node provider -- npm install -- npm run build -- npm run start - -Suggested baseline checks before adopting Nixpacks: -- Ensure Next.js standalone/server output target is compatible with runtime. -- Validate env vars for Supabase and GA in target hosting environment. diff --git a/docs/references/uv-llms.txt b/docs/references/uv-llms.txt deleted file mode 100644 index fe9814f..0000000 --- a/docs/references/uv-llms.txt +++ /dev/null @@ -1,13 +0,0 @@ -`uv` reference notes for this repository: - -1) Current project toolchain is Node/TypeScript-first. -2) Python is used only for specific analysis scripts: - -- tooling/scripts/research/build_nekaracuba_corpus.py -- tooling/scripts/research/generate_toss_analysis.py - -If standardizing Python tooling with `uv`: - -- pin Python version -- create a minimal `pyproject.toml` for analysis scripts -- document commands under `docs/` and CI (if CI is introduced) diff --git a/docs/research/benchmark/benchmark-nekaracuba-corpus-2026.jsonl b/docs/research/benchmark/benchmark-nekaracuba-corpus-2026.jsonl deleted file mode 100644 index 342f899..0000000 --- a/docs/research/benchmark/benchmark-nekaracuba-corpus-2026.jsonl +++ /dev/null @@ -1,100 +0,0 @@ -{"company": "NAVER", "source": "D2", "title": "FE News 25년 12월 소식을 전해드립니다!", "link": "https://d2.naver.com/news/3740852", "date": "2026-02-04T14:28:38+00:00", "topic": "other", "tags": ["news"], "pattern": "", "evidence": "title=FE News 25년 12월 소식을 전해드립니다!"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "장애 대응의 성패를 가르는 First Action: 우아한형제들의 장애 관리 라이프사이클", "link": "https://techblog.woowahan.com/25189", "date": "2026-02-03T16:05:15+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=25189"} -{"company": "NAVER", "source": "D2", "title": "스마트스토어센터 Oracle에서 MySQL로의 무중단 전환기", "link": "https://d2.naver.com/helloworld/6512234", "date": "2026-01-21T17:17:24+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=스마트스토어센터 Oracle에서 MySQL로의 무중단 전환기"} -{"company": "NAVER", "source": "D2", "title": "네이버 통합검색 AIB 도입과 웹 성능 변화 분석", "link": "https://d2.naver.com/helloworld/4241703", "date": "2026-01-19T11:04:21+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=네이버 통합검색 AIB 도입과 웹 성능 변화 분석"} -{"company": "NAVER", "source": "D2", "title": "FE News 26년 1월 소식을 전해드립니다!", "link": "https://d2.naver.com/news/5798505", "date": "2026-01-14T10:57:09+00:00", "topic": "other", "tags": ["news"], "pattern": "", "evidence": "title=FE News 26년 1월 소식을 전해드립니다!"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "끊김 없는 사용 경험을 위하여 : 카카오톡 선물함 속 교환권을 배달의민족 주문으로 연결한 여정", "link": "https://techblog.woowahan.com/25049", "date": "2025-12-26T15:00:42+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=25049"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "WOOWACON 2025 미니게임 WOOWA POP!", "link": "https://techblog.woowahan.com/24999", "date": "2025-12-23T13:20:40+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24999"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "Delivering the Future: 글로벌 해커톤 2025, 준비부터 운영까지", "link": "https://techblog.woowahan.com/24940", "date": "2025-12-19T11:23:51+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24940"} -{"company": "NAVER", "source": "D2", "title": "비용, 성능, 안정성을 목표로 한 지능형 로그 파이프라인 도입", "link": "https://d2.naver.com/helloworld/0004394", "date": "2025-12-17T17:34:06+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=비용, 성능, 안정성을 목표로 한 지능형 로그 파이프라인 도입"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "배달의민족 주문접수 채널에 Flutter를 도입하며 고민한 것", "link": "https://techblog.woowahan.com/24337", "date": "2025-12-16T11:22:34+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24337"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "“함께 구매하면 좋은 상품” 추천 모델 고도화", "link": "https://techblog.woowahan.com/24434", "date": "2025-12-11T15:59:43+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24434"} -{"company": "NAVER", "source": "D2", "title": "[인턴십] 2026 NAVER AI CHALLENGE를 소개합니다.", "link": "https://d2.naver.com/news/7477295", "date": "2025-12-10T19:41:17+00:00", "topic": "other", "tags": ["news"], "pattern": "", "evidence": "title=[인턴십] 2026 NAVER AI CHALLENGE를 소개합니다."} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "우리는 코드처럼 문화도 리팩토링한다", "link": "https://techblog.woowahan.com/24820", "date": "2025-12-10T16:35:09+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24820"} -{"company": "NAVER", "source": "D2", "title": "디자인시스템이 AI를 만났을 때: FE 개발 패러다임의 변화", "link": "https://d2.naver.com/helloworld/3442203", "date": "2025-12-09T14:42:01+00:00", "topic": "design", "tags": ["hello world"], "pattern": "", "evidence": "title=디자인시스템이 AI를 만났을 때: FE 개발 패러다임의 변화"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "잃어버린 접근성을 찾아서", "link": "https://techblog.woowahan.com/24605", "date": "2025-12-05T15:20:00+00:00", "topic": "uiux", "tags": [], "pattern": "", "evidence": "postId=24605"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "기획부터 개발까지 전부 직접 했습니다 – 우테코 7기 크루 서비스 론칭!", "link": "https://techblog.woowahan.com/24251", "date": "2025-12-05T11:00:26+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24251"} -{"company": "NAVER", "source": "D2", "title": "LLM이지만 PDF는 읽고 싶어: 복잡한 PDF를 LLM이 이해하는 방법", "link": "https://d2.naver.com/helloworld/9036125", "date": "2025-12-04T17:26:22+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=LLM이지만 PDF는 읽고 싶어: 복잡한 PDF를 LLM이 이해하는 방법"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "AI와 함께하는 테스트 자동화: 플러그인 개발기", "link": "https://techblog.woowahan.com/24568", "date": "2025-12-04T15:41:45+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24568"} -{"company": "NAVER", "source": "D2", "title": "VLOps:Event-driven MLOps & Omni-Evaluator", "link": "https://d2.naver.com/helloworld/0931890", "date": "2025-12-03T16:05:10+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=VLOps:Event-driven MLOps & Omni-Evaluator"} -{"company": "NAVER", "source": "D2", "title": "사용자의 목소리를 AI로 재현하다: LLM기반 Multi Agent UX플랫폼 개발기", "link": "https://d2.naver.com/helloworld/2678553", "date": "2025-12-02T16:52:17+00:00", "topic": "uiux", "tags": ["hello world"], "pattern": "", "evidence": "title=사용자의 목소리를 AI로 재현하다: LLM기반 Multi Agent UX플랫폼 개발기"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "우아한형제들이 장애를 놓치지 않고 탐지하는 방법", "link": "https://techblog.woowahan.com/24488", "date": "2025-12-02T11:26:34+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24488"} -{"company": "NAVER", "source": "D2", "title": "웹툰 창작 생태계 보호를 위한 연구", "link": "https://d2.naver.com/helloworld/4571155", "date": "2025-12-02T11:20:27+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=웹툰 창작 생태계 보호를 위한 연구"} -{"company": "NAVER", "source": "D2", "title": "Iceberg Low-Latency Queries with Materialized Views (feat. 실시간 거래 리포트)", "link": "https://d2.naver.com/helloworld/9290684", "date": "2025-12-01T15:26:47+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=Iceberg Low-Latency Queries with Materialized Views (feat. 실시간 거래 리포트)"} -{"company": "NAVER", "source": "D2", "title": "[DAN25] 기술세션 영상이 모두 공개되었습니다.", "link": "https://d2.naver.com/news/9333656", "date": "2025-11-28T17:10:19+00:00", "topic": "other", "tags": ["news"], "pattern": "", "evidence": "title=[DAN25] 기술세션 영상이 모두 공개되었습니다."} -{"company": "NAVER", "source": "D2", "title": "경험이 쌓일수록 똑똑해지는 네이버 통합검색 LLM Devops Agent", "link": "https://d2.naver.com/helloworld/4199466", "date": "2025-11-27T15:29:43+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=경험이 쌓일수록 똑똑해지는 네이버 통합검색 LLM Devops Agent"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "100만 TPS 로그 시스템, KEDA를 이용한 오토스케일링 적용기", "link": "https://techblog.woowahan.com/24405", "date": "2025-11-27T15:00:10+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24405"} -{"company": "NAVER", "source": "D2", "title": "@RequestCache: HTTP 요청 범위 캐싱을 위한 커스텀 애너테이션 개발기", "link": "https://d2.naver.com/helloworld/7610642", "date": "2025-11-26T14:51:32+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=@RequestCache: HTTP 요청 범위 캐싱을 위한 커스텀 애너테이션 개발기"} -{"company": "NAVER", "source": "D2", "title": "AI와 함께하는 프로젝트 자동화 : 더 빠르고, 더 스마트하게", "link": "https://d2.naver.com/helloworld/3691494", "date": "2025-11-26T14:50:43+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=AI와 함께하는 프로젝트 자동화 : 더 빠르고, 더 스마트하게"} -{"company": "NAVER", "source": "D2", "title": "이건 첫 번째 클릭! 히트맵 같이 보기", "link": "https://d2.naver.com/helloworld/0957098", "date": "2025-11-25T14:34:13+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=이건 첫 번째 클릭! 히트맵 같이 보기"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "장시간 비동기 작업, Kafka 대신 RDB 기반 Task Queue로 해결하기", "link": "https://techblog.woowahan.com/23625", "date": "2025-11-25T11:22:02+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23625"} -{"company": "NAVER", "source": "D2", "title": "DBT, Airflow를 활용한 데이터 계보 중심 파이프라인 만들기", "link": "https://d2.naver.com/helloworld/8992409", "date": "2025-11-24T12:26:12+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=DBT, Airflow를 활용한 데이터 계보 중심 파이프라인 만들기"} -{"company": "NAVER", "source": "D2", "title": "서비스 조직에서 Kafka를 사용할 때 알아 두어야 할 것들 (5)", "link": "https://d2.naver.com/helloworld/3974242", "date": "2025-11-20T16:13:06+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=서비스 조직에서 Kafka를 사용할 때 알아 두어야 할 것들 (5)"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "우리 팀엔 자바스크립트 상차만 하는 프런트엔드가 있었다", "link": "https://techblog.woowahan.com/24066", "date": "2025-11-20T11:27:01+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24066"} -{"company": "NAVER", "source": "D2", "title": "API 호출식 웜업의 부작용을 넘어서 : 라이브러리만 데우는 JVM 웜업", "link": "https://d2.naver.com/helloworld/1580651", "date": "2025-11-19T16:04:42+00:00", "topic": "other", "tags": ["hello world"], "pattern": "", "evidence": "title=API 호출식 웜업의 부작용을 넘어서 : 라이브러리만 데우는 JVM 웜업"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "Delivering the Future, WOOWACON 2025", "link": "https://techblog.woowahan.com/24289", "date": "2025-11-19T11:30:43+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24289"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "웹과 네이티브, 조화로운 공존은 가능한가? 플로팅웹뷰 도입으로 찾은 희망", "link": "https://techblog.woowahan.com/24165", "date": "2025-11-18T15:35:31+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24165"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "Vite에서 CSS 우선순위를 지키는 법: 우아한공방의 문제 해결기", "link": "https://techblog.woowahan.com/23866", "date": "2025-11-14T16:07:17+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23866"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "[공지] 우아한형제들 기술블로그 개인정보처리방침 일부 변경에 관한 안내", "link": "https://techblog.woowahan.com/24005", "date": "2025-11-12T10:02:03+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=24005"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "안녕하세요! AI UX라이터 제민희입니다. 무엇을 도와드릴까요?", "link": "https://techblog.woowahan.com/23836", "date": "2025-11-11T15:33:52+00:00", "topic": "uiux", "tags": [], "pattern": "", "evidence": "postId=23836"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "복잡한 LLM 연동, GenAI SDK 하나로 끝내기", "link": "https://techblog.woowahan.com/23667", "date": "2025-11-06T14:13:55+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23667"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "이제 Redis를 멈춰보겠습니다: @CacheEvict 파헤치기", "link": "https://techblog.woowahan.com/23138", "date": "2025-11-04T14:56:34+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23138"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "코드 리뷰 봇으로 시작된 팀 문화의 변화", "link": "https://techblog.woowahan.com/23639", "date": "2025-10-30T11:30:43+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23639"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "Server-Sent Events로 실시간 알림 전달하기", "link": "https://techblog.woowahan.com/23199", "date": "2025-10-24T17:33:16+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23199"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "우아한 디버깅 툴 2부: 좀 더 편하게 일하기 위한 개선들", "link": "https://techblog.woowahan.com/23457", "date": "2025-10-23T16:52:32+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23457"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "우아한 디버깅 툴 1부: 웹뷰/웹페이지 원격으로 디버깅하기", "link": "https://techblog.woowahan.com/23343", "date": "2025-10-23T16:52:30+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23343"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "Jira Automation으로 반복 업무를 효율적으로 관리해보자", "link": "https://techblog.woowahan.com/23377", "date": "2025-10-21T15:00:36+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23377"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "AI 데이터 분석가 ‘물어보새’- 3부. Agent로 더 넓고 깊은 지식 공유하기", "link": "https://techblog.woowahan.com/23273", "date": "2025-10-15T17:44:26+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23273"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "Redis New Connection 증가 이슈 돌아보기", "link": "https://techblog.woowahan.com/23121", "date": "2025-10-14T13:41:40+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23121"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "LLMOps로 확장하는 AI플랫폼 2.0", "link": "https://techblog.woowahan.com/22839", "date": "2025-09-29T16:51:43+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=22839"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "우아한 Cloud FinOps 여정", "link": "https://techblog.woowahan.com/22855", "date": "2025-09-26T15:03:38+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=22855"} -{"company": "BAEMIN", "source": "Woowahan Tech Blog", "title": "[마감] WOOWACON 2025에 지금 등록하세요!", "link": "https://techblog.woowahan.com/23037", "date": "2025-09-25T18:04:22+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "postId=23037"} -{"company": "KAKAO", "source": "if(kakao)", "title": "화면을 이해하고 행동하는 AI - GUI Agent 개발기", "link": "https://if.kakao.com/session/51", "date": "2025-09-22T15:00:00+00:00", "topic": "uiux", "tags": ["AI", "Multimodal", "Tech", "Session"], "pattern": "", "evidence": "sessionId=51"} -{"company": "KAKAO", "source": "if(kakao)", "title": "필요한 순간 먼저 말을 걸어주는 온디바이스 AI", "link": "https://if.kakao.com/session/23", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["AI", "Agent", "Tech", "Product", "Session"], "pattern": "", "evidence": "sessionId=23"} -{"company": "KAKAO", "source": "if(kakao)", "title": "크리에이터 데뷔 무대가 전국민의 카톡이라면?", "link": "https://if.kakao.com/session/9", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["Content", "Business", "Product", "Session"], "pattern": "", "evidence": "sessionId=9"} -{"company": "KAKAO", "source": "if(kakao)", "title": "코드로 해두면 편해요! 실수를 줄이는 개발 환경과 절차", "link": "https://if.kakao.com/session/13", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["General", "Tech", "Product", "Session"], "pattern": "", "evidence": "sessionId=13"} -{"company": "KAKAO", "source": "if(kakao)", "title": "커뮤니티로 진화한 오픈채팅, AI로 슬기롭게 연결하다", "link": "https://if.kakao.com/session/11", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["Community", "AI", "Product", "Tech", "Session"], "pattern": "", "evidence": "sessionId=11"} -{"company": "KAKAO", "source": "if(kakao)", "title": "카카오톡에서 만나는 ‘카나나 모델 패밀리’", "link": "https://if.kakao.com/session/8", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["Keynote"], "pattern": "", "evidence": "sessionId=8"} -{"company": "KAKAO", "source": "if(kakao)", "title": "카카오톡 말풍선, Universal 말풍선 세대의 시작", "link": "https://if.kakao.com/session/15", "date": "2025-09-22T15:00:00+00:00", "topic": "uiux", "tags": ["UX/UI", "Tech", "Product", "Session"], "pattern": "", "evidence": "sessionId=15"} -{"company": "KAKAO", "source": "if(kakao)", "title": "카카오톡 AI 에이전트를 위한 온디바이스 모델 최적화 및 적용", "link": "https://if.kakao.com/session/52", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["AI", "LLM", "Tech", "Session"], "pattern": "", "evidence": "sessionId=52"} -{"company": "KAKAO", "source": "if(kakao)", "title": "카카오 커머스 마케팅의 진화: AI, CRM을 만나다", "link": "https://if.kakao.com/session/17", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["Data", "AI", "Tech", "Product", "Session"], "pattern": "", "evidence": "sessionId=17"} -{"company": "KAKAO", "source": "if(kakao)", "title": "카카오 데이터와 AI로 완성하는 광고 최적화", "link": "https://if.kakao.com/session/20", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["AI", "Agent", "Tech", "Product", "Session"], "pattern": "", "evidence": "sessionId=20"} -{"company": "KAKAO", "source": "if(kakao)", "title": "카카오 Observability 도입 여정: OpenTelemetry와 ClickHouse 기반의 새로운 표준", "link": "https://if.kakao.com/session/56", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["Observability", "Data", "Tech", "Session"], "pattern": "", "evidence": "sessionId=56"} -{"company": "KAKAO", "source": "if(kakao)", "title": "카나나 앱 메이트 개발기", "link": "https://if.kakao.com/session/24", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["AI", "Agent", "Tech", "Product", "Session"], "pattern": "", "evidence": "sessionId=24"} -{"company": "KAKAO", "source": "if(kakao)", "title": "지금 이 순간, 재고는 줄고 있다: 실시간 UI 경험을 위한 SSE 여정", "link": "https://if.kakao.com/session/26", "date": "2025-09-22T15:00:00+00:00", "topic": "frontend", "tags": ["Backend", "Frontend", "Tech", "Product", "Session"], "pattern": "", "evidence": "sessionId=26"} -{"company": "KAKAO", "source": "if(kakao)", "title": "자율주행 AI 실차 적용기 : 서비스를 위해 우리가 만들고 있는 자율주행", "link": "https://if.kakao.com/session/43", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["AI", "Tech", "Session"], "pattern": "", "evidence": "sessionId=43"} -{"company": "KAKAO", "source": "if(kakao)", "title": "이모티콘, 창작자와 함께 만드는 지속가능한 생태계", "link": "https://if.kakao.com/session/16", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["Content", "Business", "Product", "Session"], "pattern": "", "evidence": "sessionId=16"} -{"company": "KAKAO", "source": "if(kakao)", "title": "음악과 감정을 배우는 AI 모델의 여정: DJ 말랑이", "link": "https://if.kakao.com/session/41", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["AI", "Multimodal", "Tech", "Session"], "pattern": "", "evidence": "sessionId=41"} -{"company": "KAKAO", "source": "if(kakao)", "title": "웹툰 크리에이터들과의 상생을 위한 숏츠 생성 AI Agent - 헬릭스 숏츠", "link": "https://if.kakao.com/session/10", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["AI", "Content", "Tech", "Product", "Session"], "pattern": "", "evidence": "sessionId=10"} -{"company": "KAKAO", "source": "if(kakao)", "title": "안전한 AI를 위한 카카오의 노력", "link": "https://if.kakao.com/session/31", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["Keynote"], "pattern": "", "evidence": "sessionId=31"} -{"company": "KAKAO", "source": "if(kakao)", "title": "실시간 경로탐색에 Multi-armed Bandit 기반 강화학습 도입하기 (feat. SCI급 논문게재)", "link": "https://if.kakao.com/session/44", "date": "2025-09-22T15:00:00+00:00", "topic": "other", "tags": ["AI", "Tech", "Session"], "pattern": "", "evidence": "sessionId=44"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "Accelerating Coupang’s AI Journey with LLMs", "link": "https://medium.com/coupang-engineering/accelerating-coupangs-ai-journey-with-llms-2817d55004d3", "date": "2024-10-14T16:25:07+00:00", "topic": "other", "tags": [], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "쿠팡의 머신러닝 플랫폼을 통한 ML 개발 가속화", "link": "https://medium.com/coupang-engineering/%EC%BF%A0%ED%8C%A1%EC%9D%98-%EB%A8%B8%EC%8B%A0%EB%9F%AC%EB%8B%9D-%ED%94%8C%EB%9E%AB%ED%8F%BC%EC%9D%84-%ED%86%B5%ED%95%9C-ml-%EA%B0%9C%EB%B0%9C-%EA%B0%80%EC%86%8D%ED%99%94-de29804148bb", "date": "2023-11-23T05:07:33+00:00", "topic": "other", "tags": ["엔지니어링", "인공지능", "머신러닝", "머신러닝-파이프라인", "플랫폼"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "Meet Coupang’s Machine Learning Platform", "link": "https://medium.com/coupang-engineering/meet-coupangs-machine-learning-platform-cd00e9ccc172", "date": "2023-09-08T06:21:33+00:00", "topic": "other", "tags": ["machine-learning", "ml-model", "ml-pipeline", "ai", "technology"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "쿠팡 로켓배송: 공간 색인 기반의 새로운 배송 영역 관리 시스템", "link": "https://medium.com/coupang-engineering/%EC%BF%A0%ED%8C%A1-%EB%A1%9C%EC%BC%93%EB%B0%B0%EC%86%A1-%EA%B3%B5%EA%B0%84-%EC%83%89%EC%9D%B8-%EA%B8%B0%EB%B0%98%EC%9D%98-%EB%B0%B0%EC%86%A1-%EC%98%81%EC%97%AD-%EA%B4%80%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-a59006bc4b6e", "date": "2023-04-18T04:51:37+00:00", "topic": "other", "tags": ["로켓배송", "지도", "모바일", "시각화", "테크"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "Coupang Rocket Delivery’s spatial index-based delivery management system", "link": "https://medium.com/coupang-engineering/coupang-rocket-deliverys-spatial-index-based-delivery-management-system-26940eaaee63", "date": "2023-04-17T00:10:08+00:00", "topic": "other", "tags": ["technology", "geospatial", "maps", "mobile", "h3"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "클라우드 서비스 사용량 관리를 통한 운영 비용 최적화", "link": "https://medium.com/coupang-engineering/%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%82%AC%EC%9A%A9%EB%9F%89-%EA%B4%80%EB%A6%AC%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%9A%B4%EC%98%81-%EB%B9%84%EC%9A%A9-%EC%B5%9C%EC%A0%81%ED%99%94-1521565c64ec", "date": "2023-03-27T01:40:09+00:00", "topic": "other", "tags": ["클라우드", "최적화", "테크", "aws", "인프라스트럭처"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "Cloud expenditure optimization for cost efficiency", "link": "https://medium.com/coupang-engineering/cloud-expenditure-optimization-for-cost-efficiency-44e9bea3d91b", "date": "2023-03-21T06:03:32+00:00", "topic": "other", "tags": ["optimization", "aws", "infrastructure", "cloud", "technology"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "기계 학습 모델을 활용한 물류 입고 프로세스 최적화", "link": "https://medium.com/coupang-engineering/%EA%B8%B0%EA%B3%84-%ED%95%99%EC%8A%B5-%EB%AA%A8%EB%8D%B8%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%AC%BC%EB%A5%98-%EC%9E%85%EA%B3%A0-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EC%B5%9C%EC%A0%81%ED%99%94-fe4490e44514", "date": "2023-03-13T01:33:09+00:00", "topic": "other", "tags": ["인공지능", "테크", "머신러닝", "물류", "최적화"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "Optimizing the inbound process with a machine learning model", "link": "https://medium.com/coupang-engineering/optimizing-the-inbound-process-with-a-machine-learning-model-2db48bbbc304", "date": "2023-03-09T01:37:49+00:00", "topic": "other", "tags": ["logistics", "optimization", "machine-learning", "ai", "technology"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "쿠팡 SCM 워크플로우: 효율적이고 확장 가능한 low-code, no-code 플랫폼 개발", "link": "https://medium.com/coupang-engineering/%EC%BF%A0%ED%8C%A1-scm-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9D%B4%EA%B3%A0-%ED%99%95%EC%9E%A5-%EA%B0%80%EB%8A%A5%ED%95%9C-low-code-no-code-%ED%94%8C%EB%9E%AB%ED%8F%BC-%EA%B0%9C%EB%B0%9C-7d997644d14", "date": "2022-12-12T00:45:29+00:00", "topic": "other", "tags": ["데이터", "로우코드", "테크", "워크플로우", "노코드"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "Coupang SCM Workflow: Building a low-code, no-code platform", "link": "https://medium.com/coupang-engineering/coupang-scm-workflow-building-a-low-code-no-code-platform-3d035c1fa37a", "date": "2022-10-28T00:14:03+00:00", "topic": "uiux", "tags": ["no-code-platform", "workflow-automation", "low-code-platform", "technology", "data"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering/tagged/technology"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "Building in-house map service at Eats", "link": "https://medium.com/coupang-engineering/building-in-house-map-service-at-eats-268d5c8913f6", "date": "2022-10-13T23:56:36+00:00", "topic": "uiux", "tags": ["software-engineering", "infrastructure", "optimization", "technology", "maps"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering/tagged/technology"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "Rocket Growth’s ML platform: Handling high traffic and more than 20 models cost-efficiently", "link": "https://medium.com/coupang-engineering/simplifying-large-scale-ml-training-with-an-orchestration-layer-9b90d8d28e39", "date": "2022-09-23T00:32:51+00:00", "topic": "other", "tags": ["software-engineering", "ai", "machine-learning", "technology", "automation"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering/tagged/technology"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "A look at Coupang engineering’s mentorship program", "link": "https://medium.com/coupang-engineering/a-look-at-coupang-engineerings-mentorship-program-51175d3cac4b", "date": "2022-09-16T00:02:34+00:00", "topic": "other", "tags": ["mentorship", "coupang", "leadership", "engineering-culture", "technology"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering/tagged/technology"} -{"company": "COUPANG", "source": "Coupang Engineering (Medium)", "title": "Eats data platform: Services for machine learning and audience segmentation", "link": "https://medium.com/coupang-engineering/daas-accelerating-food-delivery-business-at-eats-65e4ccce6470", "date": "2022-09-02T00:33:27+00:00", "topic": "other", "tags": ["data-science", "machine-learning", "data", "technology", "software-engineering"], "pattern": "", "evidence": "feed=https://medium.com/feed/coupang-engineering/tagged/technology"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "z part team", "link": "https://engineering.linecorp.com/en/blog/z-part-team", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=z-part-team"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "wrapping a native sdk for unity", "link": "https://engineering.linecorp.com/en/blog/wrapping-a-native-sdk-for-unity", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=wrapping-a-native-sdk-for-unity"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "working as a new graduate engineer a december to remember", "link": "https://engineering.linecorp.com/en/blog/working-as-a-new-graduate-engineer-a-december-to-remember", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=working-as-a-new-graduate-engineer-a-december-to-remember"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "web styling with reactjs", "link": "https://engineering.linecorp.com/en/blog/web-styling-with-reactjs", "date": "1970-01-01T00:00:00+00:00", "topic": "frontend", "tags": ["en"], "pattern": "", "evidence": "slug=web-styling-with-reactjs"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "web development trends", "link": "https://engineering.linecorp.com/en/blog/web-development-trends", "date": "1970-01-01T00:00:00+00:00", "topic": "frontend", "tags": ["en"], "pattern": "", "evidence": "slug=web-development-trends"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "vueconf to 2018 recap", "link": "https://engineering.linecorp.com/en/blog/vueconf-to-2018-recap", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=vueconf-to-2018-recap"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "vue3 migration with improved line music performance", "link": "https://engineering.linecorp.com/en/blog/vue3-migration-with-improved-line-music-performance", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=vue3-migration-with-improved-line-music-performance"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "vue memory leak analysis", "link": "https://engineering.linecorp.com/ko/blog/vue-memory-leak-analysis", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["ko"], "pattern": "", "evidence": "slug=vue-memory-leak-analysis"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "visualizing test automation with elk grafana", "link": "https://engineering.linecorp.com/ko/blog/visualizing-test-automation-with-elk-grafana", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["ko"], "pattern": "", "evidence": "slug=visualizing-test-automation-with-elk-grafana"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "verda reliability engineering team", "link": "https://engineering.linecorp.com/ko/blog/verda-reliability-engineering-team", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["ko"], "pattern": "", "evidence": "slug=verda-reliability-engineering-team"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "verda platform team", "link": "https://engineering.linecorp.com/en/blog/verda-platform-team", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=verda-platform-team"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "verda at cloudnative openstack days 2019 2 2", "link": "https://engineering.linecorp.com/en/blog/verda-at-cloudnative-openstack-days-2019-2-2", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=verda-at-cloudnative-openstack-days-2019-2-2"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "verda at cloudnative openstack days 2019 1 2", "link": "https://engineering.linecorp.com/en/blog/verda-at-cloudnative-openstack-days-2019-1-2", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=verda-at-cloudnative-openstack-days-2019-1-2"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "using user story points for smarter project management feat line pay dev team", "link": "https://engineering.linecorp.com/en/blog/using-user-story-points-for-smarter-project-management-feat-line-pay-dev-team", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=using-user-story-points-for-smarter-project-management-feat-line-pay-dev-team"} -{"company": "LINE", "source": "LINE Engineering Blog", "title": "untangle the threads between rxswift and combine", "link": "https://engineering.linecorp.com/en/blog/untangle-the-threads-between-rxswift-and-combine", "date": "1970-01-01T00:00:00+00:00", "topic": "other", "tags": ["en"], "pattern": "", "evidence": "slug=untangle-the-threads-between-rxswift-and-combine"} diff --git a/docs/research/benchmark/benchmark-nekaracuba-summary-2026.md b/docs/research/benchmark/benchmark-nekaracuba-summary-2026.md deleted file mode 100644 index fe7b3d5..0000000 --- a/docs/research/benchmark/benchmark-nekaracuba-summary-2026.md +++ /dev/null @@ -1,41 +0,0 @@ -# NEKARACUBA Benchmark Summary - -- generated_at_utc: `2026-02-12T04:33:10.925566+00:00` -- target_count: `100` -- selected_count: `100` -- min_per_company_target: `15` - -## Coverage (Collected) - -- NAVER: 20 -- KAKAO: 53 -- LINE: 239 -- COUPANG: 26 -- BAEMIN: 504 - -## Coverage (Selected) - -- NAVER: 20 -- KAKAO: 19 -- LINE: 15 -- COUPANG: 15 -- BAEMIN: 31 - -## Topic Distribution (Selected) - -- other: 89 -- uiux: 7 -- frontend: 3 -- design: 1 - -## Data Files - -- `docs/research/benchmark/benchmark-nekaracuba-corpus-2026.jsonl` -- `docs/research/benchmark/benchmark-nekaracuba-summary-2026.md` - -## Notes - -- `LINE`은 페이지 크롤링 기반이라 일부 문서는 날짜 정보가 없고, `1970-01-01`로 표준화됩니다. -- `KAKAO`는 `if.kakao` 공개 API(`/api/v1/contents`)에서 세션 메타데이터를 수집합니다. -- `COUPANG`은 Medium publication feed + tagged feed를 결합합니다. -- 모든 Medium 링크는 query string 제거 후 dedupe 처리합니다. diff --git a/docs/research/toss/toss-ai-engineering-gap-analysis.md b/docs/research/toss/toss-ai-engineering-gap-analysis.md deleted file mode 100644 index d8f66ab..0000000 --- a/docs/research/toss/toss-ai-engineering-gap-analysis.md +++ /dev/null @@ -1,79 +0,0 @@ -# Toss-Inspired AI Engineering Gap Analysis - -## Baseline Snapshot - -- codebase files scanned: 68 (`src/**/*.{ts,tsx,js,jsx,css}`) -- corpus processed: 100 articles -- `use client` files: 24 -- app route directories: 4 -- route-level `loading.tsx`: 0 -- route-level `error.tsx`: 0 -- test files: 2 - -## Priority Findings - -### P0 - -1. **`any` 타입 사용 남아있음** - -- risk: 검색/SEO 경계에서 타입 안정성이 약해져 회귀 가능성 증가 -- evidence: - - `src/features/search/providers/KBarProvider.tsx:10` - - `src/components/seo/JsonLd.tsx:1` - - `src/features/search/lib/get-search-actions.ts:7` -- action: `PostSummary`/`JsonLdData` 타입 정의 후 `any` 제거 - -2. **상태 완결 경계 부족 (`loading.tsx`/`error.tsx` 부재)** - -- risk: 네트워크/렌더링 실패 시 사용자 복구 경로가 약함 -- evidence: - - `src/app` -- action: `blog`, `resume`, `home`에 route-level loading/error 경계 추가 - -### P1 - -3. **임의값 기반 스타일 다수 존재** - -- risk: 디자인 시스템 확장 시 일관성/유지보수성 저하 -- evidence: - - `src/features/blog/mdx/components.tsx:77` - - `src/features/blog/mdx/components.tsx:90` - - `src/app/resume/page.tsx:154` -- action: 반복되는 `w-[2px]`, `rounded-[16px]`, `h-[500px]`를 토큰/유틸 클래스로 치환 - -4. **하드코딩 색상 분산** - -- risk: 다크모드/브랜드 컬러 변경 시 영향 범위 예측 어려움 -- evidence: - - `src/app/api/og/route.tsx:30` - - `src/components/visualization/TwoPointerVisualization.tsx:47` -- action: 시각화/OG 전용 색상 토큰 분리 (`--color-og-*`, `--color-viz-*`) - -### P2 - -5. **테스트 커버리지가 낮음** - -- risk: 리팩토링 속도 대비 회귀 탐지력이 약함 -- evidence: - - `repository root` 내 test 파일 2개 -- action: 검색 액션 생성, 날짜/기간 계산, 토큰 매핑 로직 우선 단위 테스트 추가 - -6. **접근성 체계 점검 자동화 부족** - -- risk: UI 변경 누적 시 접근성 품질 편차 발생 -- evidence: - - aria attribute 사용은 있으나 체계적 a11y 테스트 부재 -- action: axe 기반 스모크 테스트와 PR 체크리스트 추가 - -## Recommended Roadmap - -1. `P0` 주간: 타입 안전성 + route 경계 구축 -2. `P1` 주간: 토큰화 리팩토링 + 색상 체계 분리 -3. `P2` 주간: 테스트 확장 + 접근성 자동 검사 도입 - -## Done Definition (for each refactor) - -- `npm run build` 통과 -- 관련 테스트 통과 또는 신규 테스트 추가 -- 다크/라이트, 모바일/데스크톱 영향 검토 -- 변경 근거와 영향 범위가 PR 본문에 기록됨 diff --git a/docs/research/toss/toss-uiux-fe-ds-analysis-data.jsonl b/docs/research/toss/toss-uiux-fe-ds-analysis-data.jsonl deleted file mode 100644 index f86473f..0000000 --- a/docs/research/toss/toss-uiux-fe-ds-analysis-data.jsonl +++ /dev/null @@ -1,100 +0,0 @@ -{"url": "https://toss.tech/article/1st_ux_research", "title": "리서치를 하고 싶어하는 사람을 리서치하세요", "word_count": 1053, "category_scores": {"uiux": 4, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["리서치를", "그래서", "인사이트를", "같아요", "거예요", "어떻게", "내용을", "이렇게", "인터뷰", "아무도", "researcher", "열심히"], "excerpt": "리서치를 하고 싶어하는 사람을 리서치하세요 Research 김서연 토스증권 Researcher 토스 Researcher 어떤 일을 하나요 제품을 만드는 모든 팀에서 사용자에 대한 고민이 생기면 고민을 같이 풀어나가는 일을 해요 예를 들어 알고 열심히 만들었는데 사람들이 쓸까요 같은 질문이기도 하고요 사장님의 불편을 해소하는 제품을 만들고 싶은데 어디서부터 시작해야 할까요 같은 질문이기도 해요 제품 팀이 가지고 있는 사용자에 대한 막연한 고민을 초반부터 같이 싱크해나가면서 고민에 대한 답이 되는 인사이트를 도출할 있도록 그에 맞는 리서치 방향을 제시하고 진행하는 일을 해요 Researcher 업무 범위는 어떻게 정하게 됐나요 혼자서 하기보다는 팀원들을 자연스럽게 참여시켰어요 초반엔 제가 유일한 리서처로 입사했으니 리서치의 A-Z 까지 끝낸 뒤에 결과만 공유해야지 라는 생각이 있었어요 그래서 혼자 인터뷰를 진행하고 정리하고 있었는데 어떤 분이 지나가면서 진행되고 있는 리서치는 어디서 있냐고 물어보시더라고요 그래서 진행 상황도 궁금하겠구나"} -{"url": "https://toss.tech/article/2022-product-designer-challenge", "title": "디자이너의, 디자이너에 의한, 디자이너를 위한 채용 설계하기", "word_count": 869, "category_scores": {"uiux": 4, "frontend": 1, "design_system": 1, "quality": 1}, "top_keywords": ["있도록", "디자인", "프로덕트", "디자이너", "포트폴리오", "때문에", "시간을", "그리고", "문제를", "지원자의", "지원자분들이", "과제를"], "excerpt": "디자이너의 디자이너에 의한 디자이너를 위한 채용 설계하기 Product Design 윤지영 토스 Head Platform 지난 토스 디자인 챕터는 프로덕트 디자이너 챌린지 열었어요 새로운 채용 형식에 많은 분이 관심을 가져주신 덕분에 좋은 분들을 모실 있었는데요 토스가 디자이너 채용으로 챌린지를 열게 되었는지 이야기해보려고 해요 지원자 입장에서 생각하기 이전에도 토스 디자인 챕터는 디자이너 채용에 새로운 방식을 시도했어요 바로 년부터 시작한 포트폴리오 없는 디자이너 채용 전형 이에요 채용의 단계에서 포트폴리오를 제출할 필요 없이 문제 해결에 대한 가지 질문과 장의 첨부 이미지로 지원할 있어요 디자이너는 이직이나 취업을 준비할 포트폴리오가 당락을 결정하는 경우가 대부분이에요 때문에 포트폴리오 제작에 가장 많은 시간을 쏟죠 좋은 프로젝트를 만나지 못한 경우에는 역량과는 무관하게 포트폴리오 구성이 힘들어요 아무리 좋은 프로젝트 경험이 있다고 해도 포트폴리오가 준비되지 않은 상태라면 지원을 미루는 경우도 생기죠 저희는 지원자분들의"} -{"url": "https://toss.tech/article/27052", "title": "SLASH 23", "word_count": 264, "category_scores": {"uiux": 2, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["서비스", "소개합니다", "day", "server", "다양한", "토스의", "토스코어", "developer", "gateway", "data", "kafka", "매끄러운"], "excerpt": "SLASH 세션 소개 카드 만들기 궁금증 남기기 Data Kafka 이중화로 다양한 장애 상황 완벽 대처하기 다양한 장애 상황에도 사용자에게 매끄러운 서비스 경험을 제공하는 방법은 뭘까요 견고하고 편리한 증권 서비스 구축을 위한 토스증권의 Kafka IDC 이중화 과정을 소개합니다 강병수 토스증권 Data Engineer 발표자료 다운로드 세션 공유하기 프라이빗 네트워킹 신청하기 강연에 사용된 자료 출처는 발표 자료에서 참고해 주세요 토스가 다루는 모든 개인정보는 고객에게 동의를 받은 후에 처리되고 있으며 접근 권한이 분리되어 있습니다 개발자는 모든 데이터가 아닌 담당 영역에 한하여 접근 이용할 있습니다 세션 보기 DAY DAY DAY DAY Opening 토스의 간편함 이면에 있는 안정성과 보안성 그리고 이를 향한 끊임없는 기술적 도전과 성취를 소개합니다 이형석 토스코어 Head Technology 함께 가짜 신분증 찾아내기 비대면 서비스의 편리함을 악용하는 금융 범죄 똑똑하게 대처하는 법은 없을까요 가짜"} -{"url": "https://toss.tech/article/27752", "title": "드래그 앤 드롭은 사실 편한 UX가 아니다?", "word_count": 1037, "category_scores": {"uiux": 5, "frontend": 2, "design_system": 1, "quality": 1}, "top_keywords": ["접근성", "div", "접근성을", "드래그", "스크린", "label", "button", "arialiveref", "aria", "요소의", "순서를", "다양한"], "excerpt": "드래그 드롭은 사실 편한 아니다 Frontend 황수재 토스뱅크 Frontend Developer 스마트폰에서 드래그 드롭 Drag Drop 많이 사용해 보셨을 텐데요 리스트에 있는 요소의 순서를 변경할 드래그 드롭은 많은 사용자에게 편하고 직관적인 보이죠 하지만 장애를 가진 사용자에게는 불편한 되기도 해요 특히 터치스크린은 정밀한 조작이 필요하기 때문에 운동장애가 있는 사용자들은 정확한 위치로 요소를 끌어다 놓기 어려워요 또한 시각 장애가 있는 사용자는 순서가 바뀌었다는 시각적인 피드백만으로는 이해하기 어려울 있어요 토스뱅크에는 용도에 따라 계좌를 생성하여 돈을 나눠서 모을 있는 나눠모으기 통장 서비스가 있는데요 서비스를 사용하는 모든 고객이 편하게 계좌의 순서를 바꿀 있도록 접근성을 굉장히 많이 신경썼어요 오늘은 나눠모으기 통장 서비스의 접근성 설계 단계부터 개발 과정까지 소개드리고 접근성을 쉽게 챙길 있는 방법을 알려드릴게요 접근성이 고려된 완성된 형태의 순서 변경 기능 디자이너 컨설턴트 개발자간 우당탕탕 커뮤니케이션 토스뱅크에서는"} -{"url": "https://toss.tech/article/34481", "title": "캐시를 적용하기 까지의 험난한 길 (TPS 1만 안정적으로 서비스하기)", "word_count": 1275, "category_scores": {"uiux": 2, "frontend": 2, "design_system": 1, "quality": 3}, "top_keywords": ["cache", "thread", "evict", "commit", "event", "캐시를", "termsagreement", "kafka", "database", "문제가", "fun", "circuit"], "excerpt": "캐시를 적용하기 까지의 험난한 TPS 안정적으로 서비스하기 Server 김경윤 토스뱅크 Server Developer 안녕하세요 토스뱅크 Server Developer 김경윤 입니다 토스 커뮤니티 그리고 토스뱅크에서는 하루에 백번의 라이브 배포가 일어나고 있으며 다양한 개선점과 신규 제품들이 빠르게 출시되고 있어요 이렇게 많은 배포를 기반으로 토스뱅크가 성장하면서 토스뱅크를 이용하는 사용자도 많아졌는데요 이로 인해서 TPS 평균 최대 만까지 늘어난 약관 terms 서버에 안정적인 서비스 제공을 위해 캐시를 적용한 이야기를 들려드리려고 해요 Database 이상 버틸 없다 약관 서버는 사용자가 동의 또는 철회한 약관 동의서 동의 여부를 기록하고 조회할 있는 서비스예요 사용자가 동의 그리고 철회하는 경우는 그렇게 많지 않지만 동의 여부에 따라서 개인 정보를 자에게 제공해도 되는지 토스뱅크가 사용해도 되는지 다양한 비지니스에서 약관 동의서 동의 여부를 확인해요 앞으로는 약관 동의서 간단히 약관 이라고 부를게요 어느 토스뱅크에서 새로운 서비스를 배포하면서"} -{"url": "https://toss.tech/article/35921", "title": "Simplicity 4 : 한 번쯤 이상을 꿈꿔본 모두에게", "word_count": 409, "category_scores": {"uiux": 6, "frontend": 1, "design_system": 2, "quality": 0}, "top_keywords": ["design", "이상을", "디자인", "simplicity", "어떻게", "research", "꿈꿔본", "이야기", "있어요", "새로운", "만들기", "들려드려요"], "excerpt": "Simplicity 번쯤 이상을 꿈꿔본 모두에게 유아란 토스 디자인 컨퍼런스 번째 이야기 Simplicity 일하면서 번쯤 이상을 꿈꿔본 적이 있나요 빠른 문제해결이 중요한 토스에서도 너머의 이상을 그리는 사람들이 있어요 디자이너 리서처 라이터 엔지니어링 다양한 분야에서 정교하고 아름다운 실현하기 위해 새로운 시도를 이어가고 있죠 하지만 현실은 쉽지만은 않아요 이번 시즌에서는 일하는 누구나 꿈꿔본 이상을 현실로 만들기 위해 고군분투한 토스팀의 여정을 들려드려요 우리는 어떤 고민을 했고 어떻게 답을 찾았을까요 그래픽 인터랙션 엔지니어링 라이팅 리서치 플랫폼 디자인까지 개의 이야기가 준비되어 있어요 잠깐 들여다보실래요 토스다움을 완성하는 순간들 이토록 아름다운 새로고침 Graphic Design 화면을 아래로 당기면 나오는 그래픽 Pull Refresh 짧은 찰나에 아름다움을 담기 위한 고민을 들려드려요 인터랙션으로 첫인상 만들기 Interaction Design 토스를 처음 써보는 외국인에게 토스를 어떻게 소개해야 할까 모두가 감탄한 온보딩 중심에는 인터랙션이 있었어요 토스"} -{"url": "https://toss.tech/article/36945", "title": "세션을 대표하는 키비주얼 그래픽 | Simplicity 4 제작기 #4", "word_count": 549, "category_scores": {"uiux": 5, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["그래픽", "simplicity", "했어요", "세션을", "그래서", "하나의", "키워드를", "세션의", "자연스럽게", "직관적으로", "진행했어요", "디자인"], "excerpt": "세션을 대표하는 키비주얼 그래픽 Simplicity 제작기 디자인 그래픽디자인 김경태 김영하 안녕하세요 Simplicity 에서 그래픽 디자인을 맡은 디자이너 김경태 김영하입니다 이번 시즌 그래픽 작업을 진행하면서 느낀 고민과 시행착오 그리고 과정 속에서 얻은 인사이트들을 공유해보려 해요 이번 시즌의 Simplicity 홈페이지의 초안은 개의 세션 카드로 구성된 레이아웃으로 이루어져 있어요 세션 카드들이 비교적 스케일로 나열되어 있는 형태이기 때문에 기존처럼 하나의 키비주얼로 전체를 묶는 방식은 적절하지 않다는 생각이 들었어요 그래서 이번 시즌에서는 개의 키비주얼이 각각의 세션을 대표하면서 개의 그래픽이 모여있는 모습을 통해 컨퍼런스 전체의 인상을 전달하기로 했어요 먼저 많은 세션들을 관통하는 키워드인 Vision-Driven-Design 드러내기 위해 해당 키워드를 가장 표현할 있는 룩앤필 Look feel 찾아야했어요 룩앤필을 찾아가는 과정에서 너무 많은 시간을 할당하다보면 작업에 매몰되는 경우가 많아요 그래서 탐색의 단계에서는 짧은 시간 안에 많은 효과적인 시도를 있도록"} -{"url": "https://toss.tech/article/37325", "title": "아름답고 이해하기 쉬운 세션 자료 만들기 | Simplicity 4 제작기 #5", "word_count": 494, "category_scores": {"uiux": 2, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["simplicity", "했어요", "자료를", "시각적", "자연스럽게", "만들기", "그래서", "자료는", "아니라", "단순히", "정보를", "콘텐츠를"], "excerpt": "아름답고 이해하기 쉬운 세션 자료 만들기 Simplicity 제작기 윤가빈 Simplicity 세션 즐기셨나요 이번 글에서는 Simplicity 세션 시각 자료 제작기를 이야기해보려 해요 처음에는 단순히 관련 화면을 캡처하거나 녹화해 보여주면 충분할 거라고 생각했어요 하지만 막상 작업에 들어가 보니 시청 흐름과 정보 전달까지 고려해야 정말 많더라고요 Simplicity 짧고 몰입도 높은 숏폼 형식의 세션으로 구성되었어요 길이가 짧은 만큼 짧은 문장에 많은 정보를 담아야 했고 연사의 말만으로는 충분히 설명되지 않는 경우도 많았죠 그래서 세션 자료는 단순히 재미나 심미성을 위한 장치가 아니라 텍스트에 담기지 않은 맥락까지 설명해주는 시각적 해설자 역할을 해야 했어요 단순히 텍스트를 보조하는 것이 아니라 동등한 수준으로 메시지를 전하는 시각적 콘텐츠가 필요했던 거죠 좋은 세션 자료의 가지 조건 숏폼 콘텐츠에 어울리는 시각적 흐름 만들기 우리가 숏폼 콘텐츠를 자막보다 이미지나 시각적 흐름에 먼저 반응 하잖아요"} -{"url": "https://toss.tech/article/37777", "title": "토스페이먼츠 결제 시스템 연동을 돕는 MCP 서버 구현기", "word_count": 2342, "category_scores": {"uiux": 2, "frontend": 5, "design_system": 1, "quality": 1}, "top_keywords": ["this", "const", "mcp", "string", "return", "chunk", "number", "문서를", "토스페이먼츠", "llm", "private", "chunks"], "excerpt": "토스페이먼츠 결제 시스템 연동을 돕는 MCP 서버 구현기 MCP 김용성 토스페이먼츠 Node Developer 안녕하세요 토스페이먼츠 김용성입니다 지난주 토스페이먼츠에서 업계 최초로 MCP 소개 하면서 많은 분들이 관심가져 주셨는데요 글을 통해 MCP 서버 구현 과정과 안에서 얻은 러닝을 공유하고자 해요 요약 토스페이먼츠의 API 연동을 조금이라도 쉽고 빠르게 하기 위해 기반 코딩 도구 활용시 도움이 있도록 토스페이먼츠 연동 MCP 서버를 제공합니다 자세한 내용은 토스페이먼츠 연동 가이드 참고하세요 토스페이먼츠 연동 MCP 서버를 활용하면 기반 코딩 도구를 단독으로 사용할 때보다 연동 코드 생성 정확도가 높아집니다 토스페이먼츠 연동 MCP 서버는 길드 guild 작은 논의에서 시작된 아이디어로부터 구현되어 작성 시점에 모델에게 제공되는 지원되는 맥락 정보가 부족할 있습니다 토스페이먼츠의 API 연동 경험이 나아질 있도록 계속해서 개선해 나가 볼게요 글에는 코드가 많이 첨부되어 있어 내용이 길어요 자세한 내용이 궁금하신"} -{"url": "https://toss.tech/article/42673", "title": "레거시 인프라 작살내고 하이브리드 클라우드 만든 썰", "word_count": 2550, "category_scores": {"uiux": 1, "frontend": 3, "design_system": 2, "quality": 2}, "top_keywords": ["openstack", "aws", "클라우드", "퍼블릭", "인프라", "네트워크", "클러스터", "저희가", "프라이빗", "있었습니다", "k8s", "서비스"], "excerpt": "레거시 인프라 작살내고 하이브리드 클라우드 만든 박명순 정상현 안녕하세요 토스페이먼츠 Infra Team Leader 정상현 DevOps Engineer 박명순입니다 결제 산업 혁신을 목표로 출범한 토스페이먼츠 하지만 처음부터 새롭게 시작한 것이 아니라 넘게 운영되던 사업을 인수하며 시작했습니다 그리고 인프라에는 상상을 초월하는 레거시가 기다리고 있었죠 이번 글에서는 오픈소스 기반 OpenStack 프라이빗 클라우드를 직접 구축해 퍼블릭 AWS Active-Active 하이브리드 클라우드 다중 Pod 클러스터 운영하며 자동화 모니터링 고가용성을 확보해 어느 클라우드인지 몰라도 배포 가능한 환경을 만들었습니다 이라는 숫자의 비밀 조금 뜬금없지만 여기 이라는 숫자가 있습니다 숫자가 어떤 의미인지 혹시 짐작이 가실까요 힌트는 서버와 관련되어 있지만 서버에서 흔하게 있는 숫자는 아닙니다 라우팅은 네트워크 장비가 하는 아니었나요 숫자를 설명하려면 네트워크 라우팅에 대한 얘기를 해야 합니다 일반적으로 서버 동일한 네트워크 장비에 연결되어 있고 원격지 네트워크에 각각 서버 서버 있는"} -{"url": "https://toss.tech/article/44291", "title": "토스의 새로운 얼굴 만들기", "word_count": 634, "category_scores": {"uiux": 3, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["토스의", "그래서", "인상을", "그래픽이", "그래픽은", "자연스럽게", "정돈된", "유지하되", "중립적인", "얼굴을", "그래픽을", "하지만"], "excerpt": "토스의 새로운 얼굴 만들기 그래픽디자인 Graphic design 정서현 토스코어 Graphic Designer 토스에서 이런 얼굴을 마주한 적이 있나요 안내 문구나 고객센터처럼 사용자와 직접 마주하는 화면에는 서비스가 어떤 태도와 성격을 가진 존재인지를 전해주는 시각적 요소가 필요해요 말투와 분위기 인상을 대신 전달해주는 역할이죠 많은 서비스나 은행이 대표 캐릭터를 사용하는 것도 이런 이유예요 표정과 제스쳐만으로도 감정과 상황을 빠르게 전달할 있기 때문이에요 토스는 대표 캐릭터 대신 인물 형태의 그래픽을 활용해왔어요 캐릭터만큼 강하게 드러나지는 않지만 상황을 부드럽게 설명하고 사용자와의 거리감을 줄이는 데에는 충분했죠 하지만 토스 앱이 성장하면서 그래픽이 해내야 역할도 분명해졌어요 번째 토스다움을 또렷하게 전달하고 싶었어요 기존 그래픽은 작은 이모지 환경을 기준으로 만들어졌어요 그래서 화면이 커질수록 밀도가 낮아 보였죠 무엇보다 아이 같은 인상과 공허한 눈빛이 토스가 지향하는 신뢰감을 충분히 전달하지 못했어요 어떤 화면에 놓이더라도 완성도가 유지되는"} -{"url": "https://toss.tech/article/44377", "title": "개발자는 AI에게 대체될 것인가", "word_count": 2089, "category_scores": {"uiux": 3, "frontend": 2, "design_system": 0, "quality": 2}, "top_keywords": ["있습니다", "합니다", "type", "위임하기", "https", "입니다", "추상화가", "우리는", "아니라", "우리가", "하지만", "것입니다"], "excerpt": "개발자는 에게 대체될 것인가 정세훈 토스 Node Developer 안녕하세요 토스 Node Developer 정세훈입니다 요즘 너무나 많은 분들이 고민하고 토론하고 계신 주제가 바로 개발자는 과연 에게 대체될 것인가 라는 질문이죠 아무도 질문에 쉽게 대답하진 못하지만 개발자로서 마냥 모른척 하기도 어렵습니다 또한 마찬가지였는데요 여러 뉴스나 사례 연구를 보면서 생각이 어느정도 정리되어 글로 써봤습니다 저의 개인적인 생각이니 가볍게 읽어주시길 바라며 댓글로 여러분의 생각을 공유해 주시면 좋을 같습니다 우리는 버블을 보고 있습니다 아마라의 법칙 Amara law 있습니다 기술의 효과는 단기적으로 과대평가되고 장기적으로 과소평가되는 경향이 있다 우리는 개발자로서 하이프가 생기는 모든 과정을 직접 겪었습니다 그리고 지금 버블의 한가운데 서있습니다 숫자가 증명합니다 Magnificent 년간 관련 설비투자로 달러를 쏟아 부었습니다 결과 만들어진 관련 매출은 고작 달러 불균형입니다 MIT Media Lab 조사에 따르면 생성형 도입의 실패했고 Atlassian 조사에서도"} -{"url": "https://toss.tech/article/44539", "title": "소프트웨어 3.0 시대를 맞이하며", "word_count": 1638, "category_scores": {"uiux": 3, "frontend": 4, "design_system": 3, "quality": 1}, "top_keywords": ["skill", "claude", "code", "mcp", "llm", "sub-agent", "review", "context", "software", "harness", "있습니다", "skills"], "excerpt": "소프트웨어 시대를 맞이하며 김용성 토스페이먼츠 Node Developer 소프트웨어 시대란 Andrej Karpathy Combinator Startup School 에서 흥미로운 발표를 했습니다 그는 소프트웨어의 진화를 단계로 구분했습니다 Software 우리가 수십 년간 해온 방식입니다 Python Java 명시적인 로직을 작성합니다 if-else 분기하고 for 반복하고 함수로 추상화합니다 어떻게 How 해야 하는지를 코드로 작성하는 시대입니다 Software 년대 딥러닝의 부상과 함께 시작됐습니다 이상 규칙을 직접 작성하지 않습니다 데이터를 모으고 모델을 학습시키면 신경망의 가중치가 프로그램이 됩니다 Tesla Autopilot 에서 수많은 코드가 신경망으로 대체된 것처럼요 Software 지금 우리가 진입하고 있는 시대입니다 LLM 에게 자연어로 무엇을 What 원하는지 말하면 됩니다 프롬프트가 프로그램입니다 Karpathy 이렇게 말합니다 Software eating 새로운 패러다임이 기존의 것을 집어삼키고 있다고요 Andrej Karpathy Software Changing Again Combinator Startup School 발표 Harness LLM 쓸모있게 만드는 그런데 현실은 다릅니다 ChatGPT 우리 서비스의"} -{"url": "https://toss.tech/article/44737", "title": "마케팅 문구 클릭률을 올리는 6가지 원칙", "word_count": 792, "category_scores": {"uiux": 3, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["winner", "loser", "있어요", "마케팅", "무조건", "행동을", "문구를", "클릭률이", "나왔어요", "단어를", "구체적으로", "포인트"], "excerpt": "마케팅 문구 클릭률을 올리는 가지 원칙 Writing 토스 마케팅 문구 토스 문구 유아란 배경 토스는 사용자를 속이지 않는 문구를 쓰기 위한 여러가지 원칙들이 있어요 클릭을 유도하기 위해 과장하거나 불안을 자극하지 않고 모두가 이해할 있는 말로만 쓰죠 사용자의 상황을 단정하거나 추측하는 표현은 물론이고 유행어나 특정 집단만 아는 은어도 지양해요 마케팅 문구를 때에도 마찬가지죠 그럼 대체 써요 이렇게 제약이 많은데 어떻게 성과를 있을까요 저도 이런 질문을 자주 받았어요 라이터로서 규칙 지키면서도 클릭률을 높일 있어요 라고 말하려면 증명이 필요했죠 답을 찾기 위해 수십 개의 서비스에서 수백 번의 문구 테스트를 돌렸고 사용자가 반응하는 문구의 패턴을 조금씩 찾아냈어요 이번 글에서는 수많은 제약 안에서 찾아낸 여섯 가지 원칙을 공유할게요 글에서 다루는 예시들은 토스 앱에 들어왔을 가장 먼저 보이는 광고 배너에 쓰였어요 Winner 실제로 노출 수와 클릭률"} -{"url": "https://toss.tech/article/MSA-observability", "title": "토스증권의 수 천개 실시간 데이터 파이프라인 운영방법 #2: MSA 환경 Observability 높이기", "word_count": 2279, "category_scores": {"uiux": 1, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["kafka", "request", "api", "log", "broker", "metadata", "있습니다", "consumer", "client", "서비스", "topic", "clickhouse"], "excerpt": "토스증권의 천개 실시간 데이터 파이프라인 운영방법 MSA 환경 Observability 높이기 안녕하세요 토스증권 Realtime Data Team 강병수입니다 이전 에서 개의 실시간 데이터 파이프라인 리니지 시각화라는 주제를 다뤘는데요 이번 글에서는 개의 파이프라인 조금 특별한 것이 있어 내용을 상세하게 소개해 보려고 합니다 특별한 요소는 바로 Kafka Producer Consumer Client Kafka Broker 연결 관계를 파악하는 입니다 Kafka Producer Consumer Client Kafka Broker 연결을 맺는 경우는 대부분의 경우 토스증권 서비스 서버입니다 서비스에서 직접 활용하는 만큼 중요도와 장애 민감도가 매우 높다는 성격을 갖고 있는데요 토스증권에서는 개의 서비스 Pod 개의 Topic 사용 중입니다 따라서 어떤 서비스가 어떤 Topic 으로 데이터를 발생시키는지 그리고 어떤 서비스가 어떤 Consumer Group 으로 Topic 메시지를 소비 중인지 회사 시스템을 전체적으로 있는 지도를 만드는 일이 필요했어요 토스증권 시스템의 가시성을 높이는데 서비스 서버와 Kafka"} -{"url": "https://toss.tech/article/Marketing_Writing", "title": "마케팅 문구 클릭률을 올리는 6가지 원칙", "word_count": 792, "category_scores": {"uiux": 3, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["winner", "loser", "있어요", "마케팅", "무조건", "행동을", "문구를", "클릭률이", "나왔어요", "단어를", "구체적으로", "포인트"], "excerpt": "마케팅 문구 클릭률을 올리는 가지 원칙 Writing 토스 마케팅 문구 토스 문구 유아란 배경 토스는 사용자를 속이지 않는 문구를 쓰기 위한 여러가지 원칙들이 있어요 클릭을 유도하기 위해 과장하거나 불안을 자극하지 않고 모두가 이해할 있는 말로만 쓰죠 사용자의 상황을 단정하거나 추측하는 표현은 물론이고 유행어나 특정 집단만 아는 은어도 지양해요 마케팅 문구를 때에도 마찬가지죠 그럼 대체 써요 이렇게 제약이 많은데 어떻게 성과를 있을까요 저도 이런 질문을 자주 받았어요 라이터로서 규칙 지키면서도 클릭률을 높일 있어요 라고 말하려면 증명이 필요했죠 답을 찾기 위해 수십 개의 서비스에서 수백 번의 문구 테스트를 돌렸고 사용자가 반응하는 문구의 패턴을 조금씩 찾아냈어요 이번 글에서는 수많은 제약 안에서 찾아낸 여섯 가지 원칙을 공유할게요 글에서 다루는 예시들은 토스 앱에 들어왔을 가장 먼저 보이는 광고 배너에 쓰였어요 Winner 실제로 노출 수와 클릭률"} -{"url": "https://toss.tech/article/ai-driven-ui-test-automation", "title": "세금 환급 자동화 : AI-driven UI 테스트 자동화 일지", "word_count": 1554, "category_scores": {"uiux": 6, "frontend": 4, "design_system": 1, "quality": 5}, "top_keywords": ["테스트", "page", "await", "자동화", "react", "selector", "코드를", "claude", "그래서", "서비스", "스크래핑", "아니라"], "excerpt": "세금 환급 자동화 AI-driven 테스트 자동화 일지 정수호 안녕하세요 토스인컴 Manager 정수호입니다 토스인컴의 세금환급 서비스는 복잡합니다 연말정산 현금영수증 세금비서 숨은환급찾기 가지 서비스가 각각 다른 인증 방식을 사용하고 공제 항목만 해도 의료비 인적공제 전월세 주담대 중소기업 감면 수십 가지가 넘어요 여기에 실험 그룹 약관 종류 홈택스 스크래핑 서버 상태까지 더해지면 테스트해야 플로우는 금세 수십 가지 조합으로 늘어나죠 저는 모든 것을 혼자 커버해야 하는 상황에 놓였습니다 자동화가 필요하다는 모두가 알고 있었지만 현실적인 문제가 남았어요 E2E 테스트 하나를 만드는 시간이 걸리고 개를 만들려면 시간이 필요했습니다 정책이 바뀔 때마다 테스트를 다시 손봐야 했고 혼자 만들고 운영하고 회고까지 해야 했죠 그래서 저는 조금 다른 질문을 던졌습니다 코드를 직접 치지 않아도 된다면 어떨까 진짜 팀원처럼 활용해서 맡기면 어떨까 글은 질문을 월부터 월까지 진짜 서비스 환경에서 개월"} -{"url": "https://toss.tech/article/ai-prompting", "title": "휴리봇 이야기 #2: AI가 사람처럼 말하게 만드는 5가지 프롬프트", "word_count": 985, "category_scores": {"uiux": 3, "frontend": 1, "design_system": 0, "quality": 2}, "top_keywords": ["답변을", "프롬프트", "사람처럼", "ocr", "휴리봇", "말하는", "있어요", "말하게", "만드는", "실제로", "사용자", "프롬프트를"], "excerpt": "휴리봇 이야기 사람처럼 말하게 만드는 가지 프롬프트 Research 최정은 토스 Research Operation Manager 저번 아티클에서는 실무에서 실제로 활용하기 위해 필요한 것들을 소개해 드렸어요 이번 글에서는 사용자 처럼 말하는 휴리봇을 만들며 얻은 프롬프팅 팁을 공유해보려 해요 휴리봇 이란 토스 사용자처럼 학습된 봇으로 디자이너들이 가볍고 빠르게 시안의 사용성을 점검하기 위한 목적으로 만들어졌어요 실제 사용자를 대상으로 하듯 토스앱 화면을 보여주고 사용성과 관련된 질문을 하면 사용자와 흡사한 의견을 제공해줘요 휴리 이름은 휴리스틱 이슈를 빠르고 쉽게 확인할 있다는 의미로 지어졌어요 여러분은 프롬프트 엔지니어링 경험이 있으신가요 봇을 만들기 위한 프롬프팅은 쉽게 말해 페르소나를 만드는 과정이라고 생각하시면 돼요 너는 OOO 역할이고 상황에서 라고 이야기하고 이런 식으로 내가 원하는 상을 에게 주입 시키는 거죠 프롬프트 prompt 생성형 특정 작업을 수행하도록 지시하는 자연어 텍스트로 고품질의 아웃풋을 생성하기 위해 프롬프트를"} -{"url": "https://toss.tech/article/ast-funnel-visualization", "title": "AST로 Outdated 없는 퍼널 문서 만들기", "word_count": 1526, "category_scores": {"uiux": 1, "frontend": 1, "design_system": 2, "quality": 0}, "top_keywords": ["const", "페이지", "push", "navigation", "edge", "코드를", "string", "tsx", "router", "ast", "applicant", "funnel"], "excerpt": "AST Outdated 없는 퍼널 문서 만들기 조성륜 안녕하세요 토스코어 Business Onboarding Team 프론트엔드 개발자 조성륜입니다 토스팀에 합류하고 처음 받은 과제는 판매자 입점 퍼널을 이해하는 것이었습니다 판매자 입점 퍼널은 판매자가 토스페이에 가입하는 과정 말해요 페이지 다음에 어디로 가요 조건에 따라 달라요 코드 보시면 개의 페이지 파일을 하나씩 열어보며 며칠을 보냈습니다 파일을 때마다 이런 코드가 나왔어요 applicant tsx 신청자 정보 페이지 신청자 대표자 사업자형태 개인사업자 router push 결제수단 페이지 else 신청자 대리인 router push 위임정보 페이지 else 많은 분기들 페이지가 개의 서로 다른 조건으로 연결되어 있었습니다 페이지에서 평균 이상의 분기가 있는 셈이죠 판매자 본인이 직접 신청하는지 대표자 위임받은 사람이 신청하는지 대리인 개인사업자인지 법인사업자인지 본인인증 결과가 어떤지에 따라 경로가 달라졌습니다 그리고 생각했어요 어차피 코드에 있는 정보인데 자동으로 문서를 만들 없을까 글에서는 코드를 분석해서"} -{"url": "https://toss.tech/article/business-customer-data", "title": "사업자 데이터 리터러시 높이기: BC Monthly Report 발행기", "word_count": 911, "category_scores": {"uiux": 1, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["데이터", "사업자", "business", "monthly", "data", "데이터를", "리포트", "report", "team", "있도록", "지표를", "만들기"], "excerpt": "사업자 데이터 리터러시 높이기 Monthly Report 발행기 안녕하세요 토스 Business Data Team Leader Data Analyst 김윤아입니다 토스는 금융 혁신을 이끌어가는 과정에서 수많은 사용자 User 데이터를 기반으로 의사결정을 해왔습니다 하지만 작년까지만 해도 우리에게 안정적인 매출을 가져다주는 하나의 핵심 고객인 사업자 고객 Business Customer 대한 데이터적 이해는 높지 않았어요 이번 우리에게 매출을 발생시킨 Business Customer 개인가요 간단한 질문에 누구도 자신 있게 답할 수가 없었고 문제를 해결하기 위해 하반기에 Business Data Team 생겨났습니다 글에서는 Business Data Team 구축한 데이터 마트와 전사적 지표 정의 그리고 Monthly Report 발행 여정에 대해 이야기해 보려고 해요 혼돈을 넘어 하나의 진실을 찾아서 사업자 데이터 마트 구축 사업부에 갇힌 데이터를 하나의 언어로 통합하기 기존 토스 내에서는 사용자 User 지표에 대한 기준은 명확했지만 사업자 데이터는 사업부 쇼핑 광고 페이 별로"} -{"url": "https://toss.tech/article/cache-traffic-tip", "title": "캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁", "word_count": 787, "category_scores": {"uiux": 2, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["있어요", "캐시를", "데이터베이스", "트래픽을", "부하를", "redis", "하지만", "시간을", "데이터베이스의", "캐시가", "해결안", "합니다"], "excerpt": "캐시 문제 해결 가이드 과부하 방지 실전 Data 김신 토스 Server Developer 데이터베이스는 시스템을 확장하기 어려워요 주로 샤딩과 복제를 통해 어렵게 확장해야 하는데다가 과정에서 일관성 가용성 분할 내성 셋을 모두 만족시킬 없다는 점이 널리 알려져 있죠 CAP 이론 그래서 데이터베이스를 확장할 때는 신중해야 돼요 가급적 데이터베이스의 부하를 최소화하여 확장 필요성을 줄이는 것이 바람직한데요 이를 위한 기본적인 접근법은 데이터베이스 조회 이전에 캐시를 먼저 확인하는 것입니다 높은 캐시 히트율을 유지하면 데이터베이스 확장 없이도 상당한 트래픽을 처리할 있어요 Redis Memcached 같은 인메모리 저장소로 캐시 시스템을 많이 구축합니다 사용하기 쉬운 데다가 응답 속도가 빠르기 때문인데요 특히 Redis 온라인에서 다양한 활용 사례를 쉽게 찾을 있어서 안정적인 운영이 가능하기도 하고요 하지만 대용량 트래픽 환경에서 캐시를 사용하려면 가지 주의해야 상황이 있어요 글은 캐시를 사용해도 데이터베이스 부하로 인해"} -{"url": "https://toss.tech/article/cdc_pipeline", "title": "대규모 CDC Pipeline 운영을 위한 Debezium 개선 여정", "word_count": 1432, "category_scores": {"uiux": 1, "frontend": 0, "design_system": 0, "quality": 1}, "top_keywords": ["cdc", "pipeline", "event", "connector", "source", "debezium", "데이터를", "target", "데이터", "kafka", "string", "있습니다"], "excerpt": "대규모 CDC Pipeline 운영을 위한 Debezium 개선 여정 Data 김용우 토스증권 Data Engineer 안녕하세요 토스증권 Realtime Data Team 김용우 입니다 Change Data Capture 이하 CDC 쉽게 말하면 정적인 데이터를 동적인 형태로 복제하는 것인데요 데이터베이스 안에서 일어나는 변경사항들을 감지하고 변경사항을 이벤트로 변환해서 이벤트 스트림으로 전송할 있게 해줘요 CDC 장점은 데이터베이스의 변경사항을 실시간으로 받을 있다는 점입니다 이런 데이터를 새로운 데이터베이스에 저장하거나 새로운 기능을 만들거나 데이터 후속처리할 사용할 있어요 토스증권에서는 이미 다양한 데이터들에 대해 CDC 기술을 도입하고 있습니다 Data Analyst 분들이 사용하는 분석계 데이터 Engineer 분들이 학습용 데이터 토스 앱에서 사용되는 서비스용 데이터 다양한 분야에서 CDC 도입하여 실시간으로 데이터를 제공하고 있었습니다 특히 CDC 오픈소스로 제공하는 Debezium 적극 도입하여 사용 중에 있습니다 문제 없이 여러 데이터들을 CDC 통해 제공하던 어느 근본적인 질문이 하나 떠오릅니다"} -{"url": "https://toss.tech/article/datalake-iceberg", "title": "입수는 Datalake로! (feat. Iceberg)", "word_count": 2636, "category_scores": {"uiux": 2, "frontend": 1, "design_system": 0, "quality": 3}, "top_keywords": ["데이터", "iceberg", "있습니다", "데이터를", "table", "파티션", "spark", "있었습니다", "sql", "성능을", "write", "합니다"], "excerpt": "입수는 Datalake feat Iceberg Data 조승완 토스 Data Engineer 안녕하세요 오늘은 토스 데이터 플랫폼팀에서 데이터 효율성을 높이기 위해 도입한 Iceberg 대해 이야기해 보려고 합니다 Iceberg 대한 기본적인 정보는 다른 곳에서도 쉽게 찾아볼 있지만 저는 특히 유지보수와 운영 측면에 집중해서 이야기하려 합니다 최근 데이터의 양과 다양성이 급격히 증가하면서 효율적인 데이터 파이프라인 구축의 중요성이 어느 때보다 커졌는데요 특히 실시간 데이터 조회와 수정 운영 비용 절감 스키마 진화의 간소화 쿼리 성능 최적화 같은 도전 과제들이 주요 이슈로 떠오르고 있습니다 이러한 문제를 해결하기 위해 토스 데이터 플랫폼 팀은 작년 하반기부터 올해 상반기까지 Iceage 프로젝트 진행하며 DataLake Iceberg 포맷을 도입해 효율적인 데이터 파이프라인 구축했습니다 이번 글에서는 Iceberg 도입을 통해 얻은 경험을 바탕으로 유지보수와 운영 과정에서의 실질적인 팁과 인사이트를 공유하려 합니다 글에 공유된 모든 예시는 Spark"} -{"url": "https://toss.tech/article/engineering-note-2", "title": "null 리턴은 왜 나쁠까?", "word_count": 1223, "category_scores": {"uiux": 1, "frontend": 0, "design_system": 0, "quality": 2}, "top_keywords": ["user", "null", "코드를", "findbyname", "김토스", "userrepository", "val", "있어요", "코드가", "거예요", "name", "사람이"], "excerpt": "null 리턴은 나쁠까 Server 나재은 토스페이먼츠 Server Developer 엔지니어링 노트 코드 복잡성 관리하기 엔지니어링 노트 시리즈는 토스페이먼츠 개발자들이 제품을 개발하면서 겪은 기술적 문제와 해결 방법을 직접 다룹니다 번째로 코드 복잡성을 관리하는 방법을 소개합니다 개발자의 고객은 누구라고 생각하시나요 우리 제품을 사용하는 사용자 End-user 그런데 다른 고객이 있어요 컴파일 타임의 고객 바로 동료 개발자입니다 복잡하고 나쁜 코드는 사용자 고객에게는 버그와 장애를 개발자 고객에게는 낮은 생산성을 줍니다 이번 시리즈에서는 사용자 고객뿐 아니라 개발자 고객을 위한 코드 복잡성 관리에 대해 이야기해 볼게요 먼저 코드가 얼마나 복잡한지 체크리스트로 확인해 볼게요 코드는 얼마나 복잡할까 코드를 읽고 있을 누군가 말을 걸면 어디까지 읽었는지 놓쳐서 처음부터 다시 읽어야 한다 코드 줄을 바꾸기 위해 바꿔야 다른 코드가 많다 새로운 사람이 팀에 합류하면 사람이 내내 프로젝트 코드를 읽을 시간을 확보해야"} -{"url": "https://toss.tech/article/engineering-note-3", "title": "Feign 코드 분석과 서버 성능 개선", "word_count": 1138, "category_scores": {"uiux": 2, "frontend": 0, "design_system": 0, "quality": 1}, "top_keywords": ["feign", "java", "http", "httpclient", "net", "sun", "www", "문제가", "keepalivecache", "https", "apache", "httpurlconnection"], "excerpt": "Feign 코드 분석과 서버 성능 개선 Server 김성두 토스페이먼츠 Server Developer 엔지니어링 노트 Feign 코드 분석과 서버 성능 개선 엔지니어링 노트 시리즈는 토스페이먼츠 개발자들이 제품을 개발하면서 겪은 기술적 문제와 해결 방법을 직접 다룹니다 Feign 다중 스레드를 사용하는 과정에서 생긴 문제를 이해하고 성능 개선까지 경험을 공유해요 얼마 토스페이먼츠 서버 모니터링 시스템을 통해 성능 저하 문제를 발견했어요 정도의 데이터를 내외로 처리해야 하는 요구사항이 있어 다중 스레드를 활용했는데 여기서 예상치 못하게 동시성 문제가 생겼습니다 문제의 핵심은 HTTP 클라이언트 인터페이스인 Feign 내부 구조에 숨어있었어요 보통 Feign 기본 설정을 그대로 사용하기 때문에 여기서 문제가 생길 거라고 예상하지 못했어요 그래서 이번 문제를 해결하면서 직접 Feign 내부를 들여다보았습니다 내용과 문제 해결 과정을 공유드릴게요 문제가 감지된 당시에 사용하던 서버 환경은 Sprinig Boot 그리고 JDK 입니다 단계 문제 이해하기"} -{"url": "https://toss.tech/article/engineering-note-4", "title": "인자가 많은 메서드는 왜 나쁠까?", "word_count": 1556, "category_scores": {"uiux": 3, "frontend": 1, "design_system": 0, "quality": 0}, "top_keywords": ["string", "maildomainfilterservice", "mail", "this", "null", "mailretryservice", "send", "fun", "phonefallback", "title", "body", "param"], "excerpt": "인자가 많은 메서드는 나쁠까 Server 나재은 토스페이먼츠 Server Developer 엔지니어링 노트 인자가 많은 메서드는 나쁠까 엔지니어링 노트 시리즈는 토스페이먼츠 개발자들이 제품을 개발하면서 겪은 기술적 문제와 해결 방법을 직접 다룹니다 이번에는 인자가 많은 메서드를 함께 리팩토링 하면서 코드 사용자 입장에서 코드 복잡성을 관리하는 방법을 알아봅니다 이전 null 나쁠까 핵심은 코드를 읽는 사람의 입장을 생각하자는 거였어요 이번 글에서는 코드를 사용하는 사람 입장에 관해 이야기해 볼게요 재전송 메일 수신자 필터링 SMS 전송 fallback 다양한 기능을 제공하는 메일 발송 기능이 있다고 상상해 볼게요 기능을 하는 메서드의 인자가 정도 있다면 어떨까요 class Mail fun send phoneFallback Boolean phoneNumber String isForceSend Boolean recipient String Long mailDomainFilterService MailDomainFilterService mailRetryService MailRetryService title String body String param Map Any Any reservedAt Instant 메서드를 사용하기 위해 다음과 같이 호출 준비를"} -{"url": "https://toss.tech/article/engineering-note-5", "title": "프론트엔드 로깅 신경 안 쓰기", "word_count": 1696, "category_scores": {"uiux": 3, "frontend": 5, "design_system": 4, "quality": 2}, "top_keywords": ["button", "params", "const", "return", "logscreen", "function", "error", "props", "toast", "message", "logclick", "logger"], "excerpt": "프론트엔드 로깅 신경 쓰기 Frontend 최진영 토스페이먼츠 Frontend Developer 엔지니어링 노트 프론트엔드 로깅 신경 쓰기 엔지니어링 노트 시리즈는 토스페이먼츠 개발자들이 제품을 개발하면서 겪은 기술적 문제와 해결 방법을 직접 다룹니다 이번에는 프론트엔드 개발자라면 번쯤 고민해봤을 클라이언트 로깅 개선 과정을 공유합니다 제품을 개발하다 보면 사용자가 어떻게 제품을 사용하는지 제품을 사용할 어떤 행동을 했는지 알아야 때가 있어요 그래서 우리는 로그 데이터를 사용해요 로그를 기록하는 것을 로깅 이라고 하는데요 로깅으로 수집한 데이터로 사용자의 행동을 분석하거나 테스트의 결과를 확인하거나 재현하기 어려운 환경에서 디버깅 있어요 이번 글에서는 토스페이먼츠 프론트엔드 챕터에서 로깅 방법을 개선한 과정을 소개해 볼게요 들어가기 전에 로깅을 어떻게 개선했는지 소개하기 전에 기존 방식을 살펴보려고 해요 다음은 카드를 등록하는 페이지의 코드예요 import Button useToaster from tossteam tds const REGISTER_CARD_SCREEN_LOG_ID const REGISTER_CARD_CLICK_LOG_ID const REGISTER_CARD_POPUP_LOG_ID const PAGE_TITLE"} -{"url": "https://toss.tech/article/engineering-note-7", "title": "Spring JDBC 성능 문제, 네트워크 분석으로 파악하기", "word_count": 1413, "category_scores": {"uiux": 1, "frontend": 0, "design_system": 1, "quality": 0}, "top_keywords": ["null", "jdbc", "this", "preparedstatement", "parametermetadata", "insert", "int", "sqltype", "sqlexception", "spring", "batchupdate", "쿼리를"], "excerpt": "Spring JDBC 성능 문제 네트워크 분석으로 파악하기 Server 강민주 토스페이먼츠 Server Developer 엔지니어링 노트 Spring JDBC 성능 문제 네트워크 분석으로 파악하기 엔지니어링 노트 시리즈는 토스페이먼츠 개발자들이 제품을 개발하면서 겪은 기술적 문제와 해결 방법을 직접 다룹니다 이번에는 토스페이먼츠 정산 플랫폼에서 많은 양의 정산 데이터 처리 과정에서 생긴 지연 이슈를 처리한 방법을 소개해요 토스페이먼츠 정산 플랫폼에서는 가맹점의 모든 정산 거래 건을 처리하고 있는데요 많은 양의 정산 데이터 처리를 위해 스프링 배치 Spring Batch JDBC Java Database Connectivity 사용해요 최근 신규 정산 시스템을 구현하는 과정에서 문제가 있었는데요 스프링 배치 내에서 JDBC 대량의 데이터 insert 이루어질 속도가 지연되는 현상이었어요 문제 현상의 원인을 찾고 해결한 과정을 공유합니다 bulk insert 성능 저하 현상 발견 JDBC 템플릿은 스프링에서 제공하는 데이터베이스 연결 작업을 쉽게 있도록 하는 도구인데요 템플릿에서"} -{"url": "https://toss.tech/article/engineering-note-9", "title": "프론트엔드 배포 시스템의 진화 (1) - 결제 SDK에 카나리 배포 적용하기", "word_count": 1249, "category_scores": {"uiux": 1, "frontend": 5, "design_system": 0, "quality": 2}, "top_keywords": ["카나리", "canary", "sdk", "stable", "버전을", "weight", "있어요", "lambda", "edge", "cloudfront", "request", "캐시를"], "excerpt": "프론트엔드 배포 시스템의 진화 결제 SDK 카나리 배포 적용하기 Frontend 라웅배 토스페이먼츠 Frontend Developer 엔지니어링 노트 프론트엔드 배포 시스템의 진화 결제 SDK 카나리 배포 적용하기 엔지니어링 노트 시리즈는 토스페이먼츠 개발자들이 제품을 개발하면서 겪은 기술적 문제와 해결 방법을 직접 다룹니다 이번에는 토스페이먼츠 SDK 팀에서 카나리 배포를 프론트엔드 제품에 적용하면서 개발자들의 배포 경험을 개선한 사례를 편에 걸쳐 소개합니다 토스페이먼츠의 결제는 JavaScript SDK 에서 시작돼요 그래서 안정적인 SDK 배포는 무척 중요하죠 중요도 만큼 SDK 기능 제품 출시에 대한 팀원들의 부담과 피로도도 높았는데요 SDK 변경 사항이 모든 결제 가맹점에 동시에 전파되는 배포 방식 때문이었어요 그래서 SDK 배포 방식을 카나리 배포 Canary Release 방식으로 바꿔보기로 했어요 백엔드 엔지니어링에서 주로 사용되는 카나리 배포 기법을 SDK 같은 정적 리소스 환경에 알맞게 변형시켜 안전한 배포 시스템을 만든 거죠"} -{"url": "https://toss.tech/article/firesidechat_frontend_10", "title": "무엇이든 물어보세요 (feat. 프론트엔드 코드, 디렉토리 관리) | EP.10 캠프파이어 특집 상편", "word_count": 174, "category_scores": {"uiux": 2, "frontend": 5, "design_system": 1, "quality": 1}, "top_keywords": ["프론트엔드", "frontend", "캠프파이어", "모닥불", "무엇이든", "물어보세요", "feat", "시청자", "추상화", "어디까지", "함수형", "어떻게"], "excerpt": "무엇이든 물어보세요 feat 프론트엔드 코드 디렉토리 관리 캠프파이어 특집 상편 Frontend 토스 프론트엔드 챕터 모닥불 특집 캠프파이어 에피소드 이번 모닥불은 특별히 시청자 여러분과 함께하는 시간으로 준비했어요 폴더 구조를 설계할 지켜야 원칙은 컴포넌트 추상화 어디까지 해야 효율적일까요 함수형 프로그래밍은 언제 사용하는 좋을까요 사전에 접수된 시청자 여러분의 다양한 사연과 질문 그리고 코드 리뷰까지 토스 개발자들이 실무 경험에서 얻은 노하우와 인사이트를 아낌없이 공유합니다 지금 바로 확인해보세요 타임스탬프 인트로 번째 사연 폴더 구조는 어떻게 정리해야 협업이 쉬울까요 번째 질문 컴포넌트를 어떻게 분리하고 추상화는 어디까지 해야 할까요 변경 사항을 예측해 추상화 하는데 어려움을 겪고 있다면 번째 질문 유효성 검사는 에러로 보는 것이 맞을까요 번째 질문 함수형 프로그래밍이 필요한가요 출연진 박서진 토스 Head Frontend 문동욱 토스 Frontend Developer 박건영 토스증권 Frontend Developer Frontend Fundamentals 변경하기 쉬운"} -{"url": "https://toss.tech/article/firesidechat_frontend_10a", "title": "무엇이든 물어보세요 (feat. 테스트 코드, ESLint Rule) | EP.10 캠프파이어 특집 하편", "word_count": 171, "category_scores": {"uiux": 1, "frontend": 6, "design_system": 0, "quality": 1}, "top_keywords": ["테스트", "캠프파이어", "frontend", "프론트엔드", "모닥불", "rule", "이렇게", "무엇이든", "물어보세요", "feat", "eslint", "시청자"], "excerpt": "무엇이든 물어보세요 feat 테스트 코드 ESLint Rule 캠프파이어 특집 하편 Frontend 토스 프론트엔드 챕터 모닥불 특집 캠프파이어 에피소드 이번 모닥불은 특별히 시청자 여러분과 함께하는 시간으로 준비했어요 좋은 테스트 코드라고 하는 것은 무엇일까요 ESLint Rule 꺼도 괜찮을까요 좋은 해결책은 없을까요 useEffect dependency 배열 이렇게 설정해보세요 사전에 접수된 시청자 여러분의 다양한 사연과 질문 그리고 코드 리뷰까지 토스 개발자들이 실무 경험에서 얻은 노하우와 인사이트를 아낌없이 공유합니다 지금 바로 확인해보세요 타임스탬프 인트로 번째 코드 리뷰 좋은 테스트 코드의 기준은 절에 어떤 내용을 담아야 할까요 단독으로 테스트 하기 어려운 경우 번째 코드 리뷰 Lint rule 항상 지켜야만 하는 걸까요 useEffect dependency 배열이 고민이라면 모닥불 특집 캠프파이어 에피소드를 마무리하며 출연진 박서진 토스 Head Frontend 문동욱 토스 Frontend Developer 박건영 토스증권 Frontend Developer Slash 라이브러리 토스에서 사용하는"} -{"url": "https://toss.tech/article/firesidechat_frontend_11", "title": "토스의 디자인 편집기 ‘데우스’, 이렇게 만들었어요! | EP.11", "word_count": 178, "category_scores": {"uiux": 2, "frontend": 5, "design_system": 0, "quality": 1}, "top_keywords": ["데우스", "deus", "디자인", "프론트엔드", "편집기", "기술적", "frontend", "토스의", "캠프파이어", "무엇이든", "물어보세요", "feat"], "excerpt": "토스의 디자인 편집기 데우스 이렇게 만들었어요 프론트엔드 데우스 Deus 토스 프론트엔드 챕터 토스의 디자인 편집기 데우스 Deus 이번 모닥불에서는 토스의 자체 디자인 편집기 데우스 Deus 프로젝트를 소개합니다 시장에 이미 훌륭한 디자인 툴이 있는데 토스는 자체 편집기를 만들었을까요 데우스 Deus 성능 최적화를 위해 어떠한 기술적 도전들이 있었고 어떻게 해결 했을까요 디자인과 개발 사이의 경계를 넘나드는 새로운 가능성을 만나보세요 데우스 Deus 풀어낸 기술적 도전과 여정을 통해 우리가 함께 만들어갈 미래를 이야기합니다 타임스탬프 인트로 데우스 Deus 무엇인가요 토스가 디자인 편집기 데우스 Deus 직접 만든 이유 지난 개발 과정에서 마주한 수많은 기술적 도전과 과제들 상태 관리를 위해 MobX 선택한 기술적 배경은 데우스 Deus 무한한 가능성 그리고 앞으로의 목표 출연진 박서진 토스 Head Frontend 임지훈 토스 Frontend Engineer 조유성 토스 Frontend Engineer 토스 Frontend Developer"} -{"url": "https://toss.tech/article/firesidechat_frontend_12", "title": "코드 리뷰할 시간이 어딨어요? 모닥불 | EP.12", "word_count": 153, "category_scores": {"uiux": 1, "frontend": 5, "design_system": 0, "quality": 1}, "top_keywords": ["frontend", "프론트엔드", "캠프파이어", "무엇이든", "물어보세요", "feat", "모닥불", "고맥락자", "워킹그룹", "가독성", "위원회", "그리고"], "excerpt": "코드 리뷰할 시간이 어딨어요 모닥불 frontend 토스 프론트엔드 챕터 빠르게 쌓이는 느리게 달리는 리뷰 결국 남는 LGTM Looks Good 뿐이라면 이번 모닥불에서는 토스 프론트엔드 챕터가 어떻게 코드 리뷰 문화를 활성화했는지 알려드릴게요 고맥락자 코드 리뷰 부터 코드스멜 워킹그룹 가독성 위원회 그리고 코드 리뷰 배틀 까지 실전에 적용해 있는 다양한 코드 리뷰 꿀팁들 지금 바로 공개합니다 타임스탬프 인트로 코드 리뷰 필요한가요 저맥락자 고맥락자 리뷰 코드 스멜 워킹그룹 그리고 가독성 위원회 좋은 코드의 가지 기준 코드 리뷰 배틀 Ssul 출연진 박서진 토스 Head Frontend 진유림 토스 Frontend Developer 전환오 토스 Frontend Developer Frontend Fundamentals 변경하기 쉬운 프론트엔드 코드를 위한 지침서 바로가기 https frontend-fundamentals com 다른 모닥불 회차 보러가기 캠프파이어 특집 상편 무엇이든 물어보세요 feat 프론트엔드 코드 디렉토리 관리 캠프파이어 특집 하편 무엇이든 물어보세요"} -{"url": "https://toss.tech/article/firesidechat_frontend_5", "title": "토스 개발자는 개발만 잘해도 될까 | EP.5 모닥불", "word_count": 131, "category_scores": {"uiux": 1, "frontend": 5, "design_system": 0, "quality": 1}, "top_keywords": ["프론트엔드", "개발만", "모닥불", "frontend", "토스증권", "개발자는", "잘하는", "잘하면", "이정연", "최지민", "코드는", "good"], "excerpt": "토스 개발자는 개발만 잘해도 될까 모닥불 Frontend 토스 프론트엔드 챕터 토스에서 말하는 잘하는 개발자의 기준은 무엇일까요 좋은 코드도 제품이 망하면 의미가 없다 개발자가 개발만 잘하면 천만에 명의 메이커로서 기여하는 것이 필요해요 토스증권 이정연 최지민 님이 직접 전하는 잘하는 개발자란 바로 이런 함께 들어보세요 타임스탬프 인트로 제품은 실패했지만 코드는 Good 코드는 별로인데 서비스가 Good 어떤 요구사항이든지 들어주는 개발자 요구사항을 정의하는 개발자 실제 업무 적용 꿀팁 개발자는 정말 개발만 잘하면 돼나 출연진 박서진 토스 Head Frontend 이정연 토스증권 Frontend Developer 최지민 토스증권 Server Developer 다른 모닥불 회차 보러가기 함수형 프로그래밍 프론트엔드 개발에 진짜 도움 될까 프론트엔드 개발에서 테스트 자동화 해야 할까 오픈소스에 기여하고 토스에 합격한 ssul 공유하기 토스 프론트엔드 챕터 님의 다른 코드 리뷰할 시간이 어딨어요 모닥불 토스의 디자인 편집기 데우스 이렇게"} -{"url": "https://toss.tech/article/frontend-apply-without-resume", "title": "토스 프론트엔드에 이력서 없이 리포지토리 링크로 지원하세요 (~5/31)", "word_count": 607, "category_scores": {"uiux": 1, "frontend": 4, "design_system": 1, "quality": 1}, "top_keywords": ["프론트엔드", "있어요", "리포지토리", "좋아요", "고민한", "코드를", "리포지토리면", "여러분의", "typescript", "주세요", "이력서", "링크로"], "excerpt": "토스 프론트엔드에 이력서 없이 리포지토리 링크로 지원하세요 박서진 토스 Head Frontend 안녕하세요 토스 프론트엔드 엔지니어링 헤드 박서진입니다 토스에서 이력서 없이 GitHub 리포지토리 링크로 지원하기 메뉴가 열렸어요 이력서 작성에 고민해보신 적이 있나요 이력서를 다듬고 최신화하는 것이 쉽지 않죠 토스에는 여러분의 고민한 내용이 남긴 GitHub 저장소 링크 하나만 제출하면 돼요 저희 프론트엔드 리더들은 오직 코드를 기준으로 평가할게요 새로운 전형은 까지 열려 있어요 이력서가 아니라 코드만으로 평가받고 싶으신 분은 이번 기회를 놓치지 마세요 이런 리포지토리를 찾아요 토스가 중요하게 생각하는 요소들을 다음 리포지토리 제출 가이드 담았어요 리포지토리 제출 가이드 모듈화 코드 품질을 지키기 위해 고민한 리포지토리면 좋아요 명확한 디렉토리 구조로 코드 파일을 찾고 수정하기 쉽도록 고민한 리포지토리면 좋아요 TypeScript 정확하게 사용하기 위해 고민한 리포지토리면 좋아요 제품의 완성도 성능 또는 사용 경험 끌어올리기 위해 고민한"} -{"url": "https://toss.tech/article/frontend-diving-club", "title": "놀러오세요! 프론트엔드 다이빙 클럽", "word_count": 569, "category_scores": {"uiux": 0, "frontend": 2, "design_system": 0, "quality": 0}, "top_keywords": ["프론트엔드", "다양한", "있습니다", "분들이", "다이빙", "frontend", "커뮤니티", "오프라인", "주제에", "발표를", "developer", "프다클"], "excerpt": "놀러오세요 프론트엔드 다이빙 클럽 Frontend 진유림 토스 Frontend Developer 안녕하세요 토스 프론트엔드 개발자 진유림입니다 저는 개발을 처음 배울 때부터 커뮤니티 안에서 성장해왔는데요 GDG Facebook Developer Circle 다양한 오프라인 커뮤니티에서 각양각색의 개발자를 만나며 업계에 대한 애정을 키우고 지식은 나눌수록 커진다는 것을 깨달았어요 그러나 코로나 이후로는 오프라인 모임이 중단되었고 개발 이야기를 나눌 있는 사람들이 회사 내로 한정되어 버린 것이 아쉬웠어요 그래서 시작했습니다 다양한 회사와 다양한 연차의 개발자들이 모여 노하우를 나누는 프론트엔드 커뮤니티 프론트엔드 다이빙 클럽 을요 프론트엔드 다이빙 클럽 커뮤니티 이름으로부터 유추할 있듯이 프론트엔드 다이빙 클럽 이하 프다클 프론트엔드에 관한 깊은 이야기를 나눌 있는 공간입니다 격월로 소규모 오프라인 모임을 개최하며 이상 참석한 사람들은 프라이빗 슬랙에 가입하여 온라인으로도 소통을 이어갈 있습니다 모임마다 주제가 바뀌고 해당 주제에 관심이 많은 분들이 참가하여 다양하고 깊은 의견을"} -{"url": "https://toss.tech/article/iceberg-cdc-1", "title": "토스증권 Iceberg 적용기 #1: CDC 환경은 왜 제대로 동작하지 않을까?", "word_count": 2535, "category_scores": {"uiux": 1, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["delete", "iceberg", "commit", "file", "position", "data", "update", "row", "있습니다", "equality", "worker", "데이터"], "excerpt": "토스증권 Iceberg 적용기 CDC 환경은 제대로 동작하지 않을까 김용우 토스증권 Data Engineer 안녕하세요 토스증권 Realtime Data Team 김용우입니다 Iceberg 최근 데이터 레이크 환경에서 가장 주목받는 테이블 포맷 하나입니다 특히 Update Delete 네이티브하게 지원 한다는 점이 가장 매력입니다 Hive Parquet 기반의 테이블 포맷이 사실상 Append-only 구조에 머물렀던 것과 비교하면 Iceberg 이러한 특성은 데이터 웨어하우스와 레이크의 경계를 허물어주는 강력한 무기입니다 이러한 강점 덕분에 토스 에서도 Iceberg 적극적으로 활용하며 운영 노하우를 공유하고 있고 저희 토스증권 또한 다양한 데이터 파이프라인에 Iceberg 도입해 활용하고 있습니다 하지만 모든 것이 완벽하지는 않습니다 Iceberg 실제 CDC Capture Data Change 파이프라인에 적용해보면 기대했던 만큼 매끄럽게 동작하지 않는 경우가 많습니다 단순히 데이터를 Insert 하는 것과는 달리 CDC 환경에서는 Key 대해 빠르게 연속적인 Update Delete 발생하고 과정에서 예상치 못한 데이터 정합성"} -{"url": "https://toss.tech/article/income-qa-e2e-automation", "title": "토스인컴 세금 환급 서비스 : 빠른 속도에서 품질을 지키기 위한 E2E 자동화 여정", "word_count": 1810, "category_scores": {"uiux": 5, "frontend": 4, "design_system": 0, "quality": 5}, "top_keywords": ["page", "await", "pom", "currentpage", "테스트", "페이지", "button", "click", "context", "자동화", "테스트를", "functional"], "excerpt": "토스인컴 세금 환급 서비스 빠른 속도에서 품질을 지키기 위한 E2E 자동화 여정 정수호 안녕하세요 토스인컴 Manager 정수호입니다 토스인컴의 단순히 테스트를 수행하는 아니에요 우리는 제품팀의 구성원으로서 품질을 함께 설계하고 실행하는 동료 입니다 특히 제가 맡고 있는 세금 환급 서비스 토스인컴을 대표하는 도메인입니다 사용자 유형이 다양하고 세무 로직과 정책 예외 흐름이 많죠 변화가 잦고 조건이 복잡한 서비스일수록 품질을 초기에 설계 하고 실행을 단순화 하는 일이 중요합니다 메뉴얼 검증으로는 빠르게 실험하고 빠르게 배포하는 토스의 속도를 따라갈 없습니다 버튼 하나 문구 하나가 바뀌어도 이상의 시나리오를 다시 확인해야 했고 작은 누락이 전체 배포를 지연시키기도 했습니다 그래서 우리는 결심했습니다 누구나 실행할 있고 언제나 신뢰할 있는 자동화 시스템 만들자고 핵심은 Functional Page Object Model POM 이었습니다 토스인컴 일하는 방식 토스인컴의 중앙 기능조직 이면서 동시에 서비스의 사일로 제품팀"} -{"url": "https://toss.tech/article/income-qa-platform", "title": "토스인컴 QA Platform: ‘누구나 테스트할 수 있는’ 도구의 시작", "word_count": 987, "category_scores": {"uiux": 3, "frontend": 1, "design_system": 0, "quality": 3}, "top_keywords": ["api", "platform", "swagger", "테스트", "test", "아니라", "phase", "실행할", "테스트를", "누구나", "필요한", "테스트가"], "excerpt": "토스인컴 Platform 누구나 테스트할 있는 도구의 시작 정수호 노치현 안녕하세요 토스인컴 Manager 정수호 Assistant 노치현입니다 업무를 하다 보면 하루에도 번씩 비슷한 질문을 받습니다 테스트 데이터는 어떻게 만들어요 API Swagger 에서 어떻게 호출하나요 상태를 바로 확인할 방법은 없을까요 질문을 받을 때마다 저는 자연스럽게 Swagger 열었어요 필요한 API 찾고 request body 설명하며 여기에는 값을 넣고 이건 true 바꿔야 해요 라고 말하곤 했죠 팀에서는 익숙한 일이었습니다 하지만 같은 설명을 반복할수록 가지 생각이 들었습니다 테스트 API 이렇게 어렵게 쓰고 있는 걸까 Swagger 에는 이미 모든 있었습니다 사실 테스트에 필요한 API 이미 모두 존재했습니다 Swagger 문서 안에 정리된 형태로요 문제는 API 유무가 아니라 경험 이었습니다 어떤 API 써야 하는지 알아야 하고 어떤 값을 넣어야 하는지 이해해야 하고 여러 API 정해진 순서로 호출해야만 비로소 하나의 테스트"} -{"url": "https://toss.tech/article/interaction_simplicity", "title": "인터랙션으로 만드는 몰입 경험 | Simplicity 4 제작기 #3", "word_count": 833, "category_scores": {"uiux": 4, "frontend": 1, "design_system": 0, "quality": 2}, "top_keywords": ["인터랙션", "simplicity", "그래서", "데스크탑", "모바일", "있었어요", "만드는", "구성했어요", "자연스럽게", "자막이", "심플리시티를", "단순히"], "excerpt": "인터랙션으로 만드는 몰입 경험 Simplicity 제작기 백송은 안녕하세요 Simplicity 에서 인터랙션 디자인을 맡은 Interaction Designer 백송은입니다 이번 심플리시티를 디자인하면서 고민한 것들을 인터랙션 디자이너의 시선에서 하나씩 나눠보려고 해요 사실 저는 온라인 심플리시티 프로젝트에 어시스턴트로 참여했었어요 그때는 단순히 돕는 역할 정도였는데 입사 만에 메인 인터랙션 디자이너로 전사 프로젝트에 참여하게 됐어요 인터랙션 전반의 방향을 직접 이끌어야 하는 상황이었는데 가장 먼저 이런 생각이 들었어요 온라인에서도 오프라인처럼 몰입하게 하려면 어떻게 설계해야할까 이번 시즌의 인터랙션은 이런 고민에서 시작했죠 Simplicity 핵심 인터랙션 온라인 컨퍼런스에서 가장 중요한 무엇일까요 저는 지루하지 않게 끝까지 있는 환경을 만드는 것이라고 생각해요 오프라인 공간도 연사자 얼굴도 없이 오직 화면과 소리만으로 몰입을 유지해야 하니까요 사실 지난 Simplicity 에서는 중간 이탈률이 높았는데요 그건 사용자가 콘텐츠에 집중하지 못했다는 의미였어요 가지가 원인이라고 생각했죠 화면에 콘텐츠가 너무 많아"} -{"url": "https://toss.tech/article/isomorphic-javascript", "title": "환경 고민없이 개발하기", "word_count": 694, "category_scores": {"uiux": 3, "frontend": 3, "design_system": 2, "quality": 0}, "top_keywords": ["div", "name", "렌더링", "html", "클라이언트", "환경에서", "location", "return", "사이드", "ssr", "에러를", "있어요"], "excerpt": "환경 고민없이 개발하기 Frontend 김동현 토스 Frontend Developer 토스 프론트엔드 챕터는 유저가 경험하는 로딩 시간을 줄이기 위해 지속적으로 노력하고 있습니다 특히 Slash 통해 서버 사이드 렌더링 SSR 이용한 개선 사례를 소개드린 적이 있는데요 이번 아티클에서는 Next 도입 과정에서 마주한 문제와 해결 방법을 소개할게요 서버 사이드 렌더링 서버 사이드 렌더링 SSR 렌더링 작업 일부를 서버에 위임하는 방식이에요 브라우저에게 완성된 HTML 전달하는 것이죠 사용자는 빠르게 서비스를 이용할 있고 서비스는 검색 엔진 최적화 SEO 많은 노출 기회를 얻을 있습니다 하지만 서버 사이드 렌더링 SSR 위해서는 별도의 서버를 운영해야 돼요 프레임워크를 사용하는 경우 서버 구축 운영 등의 문제에는 벗어날 있지만 렌더링 과정에 서버가 개입되면서 window not defined 같은 생소한 에러를 경험하게 됩니다 단순하게 생각해보면 서버에서 제공한 HTML 이용한 것뿐인데 이런 에러를 경험하게 되는걸까요 서버"} -{"url": "https://toss.tech/article/kafka-distribution-1", "title": "토스증권 Apache Kafka 데이터센터 이중화 구성 #1", "word_count": 1259, "category_scores": {"uiux": 0, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["kafka", "데이터센터", "이중화", "active-active", "consumer", "offset", "데이터", "서비스", "있습니다", "stretched", "cluster", "합니다"], "excerpt": "토스증권 Apache Kafka 데이터센터 이중화 구성 Data 강병수 토스증권 Realtime Data Team Leader 안녕하세요 토스증권 Realtime Data Team 강병수 입니다 토스증권은 데이터센터 장애 상황에도 유저에게 정상적으로 서비스를 제공하기 위해 대부분의 시스템을 이중화했습니다 Kafka 이중화에 대해서는 토스 개발자 컨퍼런스 SLASH23 에서 차례 발표 있는데요 이번 아티클에서는 부작으로 구성해 보다 상세하게 설명드리려고 합니다 글인 부에서는 Kafka 이중화 구성에 대한 개요를 담았습니다 부에서는 토스증권에서 채택한 Active-Active 구성에서 중요한 부분인 양방향 데이터 미러링 대해 소개하고 부에서는 Active-Active 구성을 채택 했을 운영적으로 높은 난이도를 요구하는 양방향 Consumer Group Offset Sync 대해 설명 하는 순서로 이어집니다 Kafka 이중화의 필요성 Kafka 이중화에 대한 고민과 설계는 데이터센터 장애 상황에도 정상적으로 서비스를 동작시키기 위해 시작됐습니다 한쪽 데이터센터에 장애가 발생하여 동작하지 않는 상황에도 서비스를 제공할 있으려면 어떻게 해야 할까요 답은"} -{"url": "https://toss.tech/article/kafka-distribution-2", "title": "토스증권 Apache Kafka 데이터센터 이중화 구성 #2: 데이터 미러링", "word_count": 969, "category_scores": {"uiux": 2, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["데이터", "kafka", "미러링", "양방향", "connector", "데이터를", "메트릭", "mm2", "source", "동일한", "이러한", "dlq"], "excerpt": "토스증권 Apache Kafka 데이터센터 이중화 구성 데이터 미러링 Data 송지수 토스증권 Data Engineer 안녕하세요 토스증권 Realtime Data Team 송지수입니다 Kafka 데이터센터 이중화 에서 소개된 것처럼 토스증권은 현재 Active-Active 구성으로 Kafka 운영하고 있습니다 오늘은 Active-Active 유지하기 위해 필요한 양방향 데이터 미러링에 대해 소개하려고 합니다 양방향 데이터 미러링 오픈소스 Apache Kafka 에서 제공하는 MirrorMaker2 MM2 다양한 오픈소스 데이터 미러링 도구가 있습니다 이러한 도구들은 클러스터 데이터 복제를 손쉽게 구현할 있도록 설계되었습니다 MM2 Kafka Connect 프레임워크 Source Connector 기반으로 동작하며 가지 주요 기능을 제공합니다 데이터 전송 Source Cluster 에서 발생한 데이터를 Target Cluster 복제 오프셋 관리 오프셋 차이를 Source Cluster MM2 내부 토픽으로 저장 MM2 사용하면 복제된 토픽 이름에 Source Cluster alias 접두사 자동으로 추가됩니다 예를 들어 클러스터의 topic test 클러스터에서 topic test 미러링됩니다"} -{"url": "https://toss.tech/article/kafka-distribution-3", "title": "토스증권 Apache Kafka 데이터센터 이중화 구성 #3: Offset Sync", "word_count": 3079, "category_scores": {"uiux": 1, "frontend": 1, "design_system": 0, "quality": 2}, "top_keywords": ["offset", "cluster", "sync", "consumer", "topic", "kafka", "target", "timestamp", "source", "됩니다", "mirror", "있습니다"], "excerpt": "토스증권 Apache Kafka 데이터센터 이중화 구성 Offset Sync Data 김용우 토스증권 Data Engineer 안녕하세요 토스증권 Realtime Data Team 김용우입니다 이번 글은 토스증권은 Apache Kafka 이중화 구성에 대한 마지막 편으로 Apache Kafka 이중화 구성에 대한 개요 Active-Active 구성을 기반으로한 양방향 데이터 미러링 대해 소개해드렸습니다 마지막으로 부에서는 Active-Active 구성에서의 consumer offset Sync 대해서 설명드리고자 합니다 Offset 이란 Apache Kafka 에서 Offset Partition 메시지의 순서를 나타내는 고유 번호로 메시지가 들어오는 순서대로 증가합니다 이는 Kafka 메시지의 순서를 유지하고 consumer 데이터를 정확히 처리할 있도록 돕는 중요한 메커니즘입니다 Consumer 마지막으로 읽은 Offset 저장한 이후에 데이터를 처리하며 이를 통해 중복 처리와 데이터 손실을 방지합니다 만약 consumer Offset 정보를 잃어버리거나 처음 Kafka 연결된다면 auto offset reset 설정에 따라 데이터를 처음부터 earliest 또는 마지막부터 latest 읽게 됩니다 Offset 관리는"} -{"url": "https://toss.tech/article/ksqldb-realtime-data", "title": "ksqlDB를 활용한 증권사의 실시간 데이터 처리하기", "word_count": 1483, "category_scores": {"uiux": 2, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["ksqldb", "실시간", "kafka", "있습니다", "데이터", "job", "실시간으로", "join", "order_stream", "account_ktable", "토스증권", "ksql"], "excerpt": "ksqlDB 활용한 증권사의 실시간 데이터 처리하기 Data 강병수 토스증권 Realtime Data Team Leader 안녕하세요 토스증권 Realtime Data Team 리더 강병수입니다 토스증권 Realtime Data Team 에서는 Kafka Kafka Connect Clickhouse ksqlDB 같은 서비스에서 발생하는 실시간 이벤트를 다루는 플랫폼을 운영하고 있는데요 이번 글에서 토스증권 실시간 데이터 프로세싱에 활용 중인 ksqlDB 대해서 소개하려고 합니다 여는 데이터 처리는 처음에는 대부분 배치 Batch 작업으로 시작하지만 서비스가 고도화되면서 주기적인 배치 성격보다 빠른 결과를 원하게 됩니다 시점이 되면 실시간 데이터 처리 대한 요구가 시작되는데요 토스증권은 서비스 오픈 전부터 토스에서 쌓아온 플랫폼 구성 운영 노하우를 이어받아 시작했습니다 이유로 초기부터 빅데이터 플랫폼이 갖춰져 있었고 실시간 처리도 충분히 있었습니다 다만 플랫폼을 운영하는 사람은 명이었다는 문제였는데요 이러한 배경에서 실시간 데이터 프로세싱 플랫폼 도입을 고민하던 ksqlDB 선택하게 됐습니다 토스증권은 서비스 오픈부터 계좌개설"} -{"url": "https://toss.tech/article/ksqldb-realtime-data-2", "title": "ksqlDB 실시간 Join으로 뉴스 추천 만들기", "word_count": 1869, "category_scores": {"uiux": 2, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["join", "kafka", "topic", "ksqldb", "파티션", "ktable", "key", "uid", "있습니다", "때문에", "kstream", "실시간으로"], "excerpt": "ksqlDB 실시간 Join 으로 뉴스 추천 만들기 Data 강병수 토스증권 Realtime Data Team Leader 안녕하세요 토스증권 Realtime Data Team 리더 강병수입니다 이전 아티클 에서는 토스증권의 ksqlDB 활용사례를 소개했는데요 오늘은 ksqlDB 강력한 Join 기능을 활용해서 토스증권의 다양한 로그를 실시간으로 조합하여 중요한 비즈니스 문제를 해결한 사례를 소개하려고 합니다 MAB 이용한 실시간 뉴스 추천 토스증권은 MAB Multi-Armed Bandits 알고리즘 이용해서 수많은 국내 해외 뉴스 유저가 흥미를 느낄만한 아티클을 찾고 제공하고 있습니다 글은 이나 추천 과정 자세히 기술하는 글은 아닙니다 뉴스 추천이라는 멋진 요리를 만들기 위해서는 신선한 재료가 중요한데요 신선한 재료를 어떻게 제공하는지에 관한 이야기입니다 정확하고 최신성을 유지하는 뉴스 추천 MAB 알고리즘을 위해서는 실시간으로 발생하는 유저의 뉴스 방문과 뉴스 클릭 로그가 필요한데요 유저의 실시간 뉴스탭 활동 로그 score 갱신하는 피드백으로 사용하고 있기 때문이에요 Score"} -{"url": "https://toss.tech/article/lightning-talks-package-manager", "title": "패키지 매니저의 과거, 토스의 선택, 그리고 미래", "word_count": 1844, "category_scores": {"uiux": 2, "frontend": 4, "design_system": 1, "quality": 1}, "top_keywords": ["react", "패키지", "npm", "node_modules", "pnp", "yarn", "import", "pnpm", "javascript", "의존성을", "버전을", "이렇게"], "excerpt": "패키지 매니저의 과거 토스의 선택 그리고 미래 Frontend 박서진 토스 Head Frontend 토스 기술 조직의 챕터는 라이트닝 토크에서 다양한 주제에 대한 인사이트와 아이디어를 자유롭게 공유합니다 기록을 통해 생생한 라이트닝 토크 현장을 함께 느껴보세요 이번 라이트닝 토크에서 다루는 내용 패키지 매니저란 패키지 매니저가 동작하는 가지 단계 npm pnpm Yarn PnP 에서 패키지를 설치하는 방법의 차이 토스가 Yarn 선택한 이유와 앞으로의 방향성 번외 브라우저에서 표준으로 패키지를 관리하는 방법 오늘 다룰 내용은 많아요 사실 이렇게 많을 몰랐어요 주제는 패키지 매니저 인데요 어떤 기술을 선택했는지 배경을 이해하려면 먼저 개념을 명확히 알아야 해요 그래서 JavaScript 패키지 매니저가 무엇인지 먼저 다룬 뒤에 패키지 매니저가 동작하는 가지 단계를 설명할게요 그리고 npm pnpm 그리고 Yarn 가지 패키지 매니저가 어떻게 다른지 서로 다른 패키지 매니저의 특징을 살펴보고 마지막으로 토스는"} -{"url": "https://toss.tech/article/llm-serving", "title": "LLM 쉽고 빠르게 서빙하기", "word_count": 1216, "category_scores": {"uiux": 1, "frontend": 2, "design_system": 2, "quality": 1}, "top_keywords": ["llm", "있습니다", "모델을", "필요한", "다양한", "docker", "vllm", "triton", "서버를", "서비스에", "cache", "gpu"], "excerpt": "LLM 쉽고 빠르게 서빙하기 Machine Learning 김민규 토스증권 Machine Learning Engineer 요즘은 LLM Large Language Model 시대입니다 년에 ChatGPT 처음 서비스된 이후로 하루가 다르게 빠르고 정확하고 심지어 이미지나 비디오 음성 등을 이해하고 생성하는 모델들이 등장하고 있습니다 아래 그림처럼 들어서 상용 LLM 오픈소스 LLM 경쟁적으로 등장하며 시장을 빠르게 변화시키고 있습니다 토스증권 역시 이런 흐름에 발맞춰 LLM 활용한 서비스 혁신에 적극 나서고 있습니다 투자 데이터를 이해하고 고객들에게 보다 나은 금융 서비스를 제공하기 위해 우리는 자체적으로 LLM 모델을 학습시키고 이를 서비스에 적용하려는 집중하고 있습니다 구슬이 말이라도 꿰어야 보배라는 말처럼 좋은 LLM 학습시키는 것도 중요하지만 이걸 서비스에 적용하지 못하면 의미가 없습니다 하지만 일정 수준의 이상의 성능을 지원하는 LLM 서비스에 적용할 크게 가지의 어려움이 있습니다 번째는 속도 문제입니다 중간 사이즈의 LLM Llama3 모델을 일반적인 Transformers"} -{"url": "https://toss.tech/article/monitoring-traffic", "title": "서버 증설 없이 처리하는 대규모 트래픽", "word_count": 1359, "category_scores": {"uiux": 0, "frontend": 1, "design_system": 1, "quality": 1}, "top_keywords": ["api", "redis", "포인트", "합니다", "라이브", "있어요", "유저가", "요청을", "요청이", "트래픽이", "data", "있습니다"], "excerpt": "서버 증설 없이 처리하는 대규모 트래픽 Server 함종현 토스 Server Developer 안녕하세요 저는 토스의 광고 제품과 플랫폼을 개발하는 서버 개발자 함종현입니다 저는 토스에서 라이브 쇼핑 보기 서비스를 담당하고 있어요 라이브 쇼핑 보기 서비스란 라이브 쇼핑 보기는 토스의 혜택 탭에 있는 서비스예요 라이브 쇼핑 보기를 통해서 유저는 상품을 구매하면 포인트를 적립 받을 있고 광고주는 빠르게 상품 물량을 소진시킬 있어요 라이브 쇼핑 보기 서비스를 론칭하는 예상했던 것보다 굉장히 많은 유저가 들어왔어요 후에도 매일 신규 유저가 늘었고 서비스의 성장이 눈에 띄게 보였죠 그러면서 자연스럽게 라이브 방송하는 광고주도 많아졌습니다 급격하게 성장하는 서비스가 겪는 문제 이렇게 라이브 쇼핑 보기 서비스는 피크 시간대 동시 접속자 수는 분당 수십만 포인트 지급 요청 API 요청은 초당 수십만 건이 오는 서비스로 성장했는데요 급격히 늘어난 트래픽은 성장하는 서비스 서버에 치명적일"} -{"url": "https://toss.tech/article/neurIPS-FedLPA", "title": "토스의 AI 기술력, 세계 최고 권위 NeurIPS 2025에서 인정받다: FedLPA 연구", "word_count": 1264, "category_scores": {"uiux": 2, "frontend": 0, "design_system": 0, "quality": 1}, "top_keywords": ["데이터", "fedlpa", "새로운", "토스의", "neurips", "있습니다", "비즈니스", "learning", "글로벌", "federated", "하지만", "사용자의"], "excerpt": "토스의 기술력 세계 최고 권위 NeurIPS 에서 인정받다 FedLPA 연구 이진우 안녕하세요 토스 Face Modeling Team Engineer 이진우입니다 영광스럽게도 분야의 세계 최고 권위 학회인 NeurIPS 연구가 게재 되었습니다 저의 연구 내용과 연구가 토스에 미칠 있는 비즈니스 임팩트 그리고 학회 참여에 대한 소회를 공유하고자 합니다 NeurIPS 어떤 곳인가요 NeurIPS Neural Information Processing Systems 매년 개최되는 세계 최대 규모의 기계학습 Machine Learning 학회 입니다 세계 연구자들이 동안 이룬 최고의 성과들이 모이는 곳으로 이곳에 논문이 채택되었다는 것은 해당 기술이 세계적으로 인정받았다는 가장 확실한 증거가 됩니다 토스는 이번 연구를 통해 글로벌 테크 기업들과 어깨를 나란히 하는 기술력을 증명했습니다 서울대 비전랩과의 시너지 이번 성과는 혼자만의 힘으로 이룬 것이 아닙니다 서울대학교 Computer Vision Lab 한보형 교수님 팀과 긴밀히 협업한 결과물인데요 한보형 교수님과는 이전 Neural Architecture Search"} -{"url": "https://toss.tech/article/new-uxresearch-invest", "title": "투자를 모르는데 어떻게 증권 UX 리서처를 해요?", "word_count": 1519, "category_scores": {"uiux": 3, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["모르는", "같아요", "투자를", "경험이", "이렇게", "나스닥", "때문에", "되었어요", "리서치", "그런데", "인터뷰를", "무엇인지"], "excerpt": "투자를 모르는데 어떻게 증권 리서처를 해요 Research 리서치 이예슬 토스증권 Researcher 증권 투자 분야를 몰라서 적응하는데 어려움이 많지 않을까요 인터뷰가 끝나고 파트너분과 입사할 계열사를 논의하면서 제가 했던 질문이었어요 증권 이라고 하면 투자에 대한 전문성이 있거나 은행 증권사에서의 근무 경험이 있어야만 것만 같은 불안한 마음에 이런 질문을 드렸었죠 오늘은 증권 까막눈이었던 제가 토스증권의 리서처로 합류하며 지난 개월간 겪었던 어려움과 러닝들을 솔직하게 이야기해 보려고 해요 토스증권에 입사하기 전과 달라진 생각이 있었나요 일단 대부분의 팀원이 금융 직종에서의 경험이 있을 거라고 생각했어요 증권 서비스를 만드는 회사니까 당연히 기존에 증권사 경험이 있을 거라고 생각했던 같아요 증권사 경험이 없다면 최소 투자를 전문적으로 하는 분들일 것이라는 선입견이 있었어요 예를 들면 각종 지표를 본인의 투자 원칙에 맞춰 해석한다거나 거시 경제의 흐름을 읽고 소위 말하는 무릎에서 사서 어깨에서 파는"} -{"url": "https://toss.tech/article/node-modules-and-yarn-berry", "title": "node_modules로부터 우리를 구원해 줄 Yarn Berry", "word_count": 1630, "category_scores": {"uiux": 1, "frontend": 5, "design_system": 0, "quality": 0}, "top_keywords": ["yarn", "node_modules", "의존성을", "berry", "있습니다", "의존성", "npm", "pnp", "node", "zip", "users", "때문에"], "excerpt": "node_modules 로부터 우리를 구원해 Yarn Berry Frontend 박서진 토스 Head Frontend 토스 프론트엔드 챕터에서는 지난해부터 의존성을 관리하기 위해 Yarn Berry 도입했습니다 처음에는 일부 레포지토리부터 시작하여 현재는 대부분의 레포지토리에 Yarn Berry 적용되어 있는데요 토스팀이 새로운 패키지 관리 시스템을 도입하게 배경과 사용하면서 좋았던 점을 테크 블로그를 통해 공유합니다 Yarn Berry Yarn Berry Node 위한 새로운 패키지 관리 시스템으로 Yarn 주요 개발자인 Nison 씨가 만들었습니다 일부터 정식 버전 출시되어 현재는 Babel 같은 오픈소스 레포지토리에서도 채택하고 있습니다 Yarn Berry GitHub yarnpkg berry 레포지토리에서 소스코드가 관리되고 있습니다 Yarn Berry 기존의 깨져 있는 NPM 패키지 관리 시스템을 혁신적으로 개선합니다 NPM 문제점 NPM Node 설치 시에 기본으로 제공되어 범용적으로 사용되고 있으나 비효율적이거나 깨져 있는 부분이 많습니다 비효율적인 의존성 검색 NPM 파일 시스템을 이용하여 의존성을 관리합니다 익숙한 node_modules"} -{"url": "https://toss.tech/article/nodejs-security-contribution", "title": "Node.js url.parse() 취약점 컨트리뷰션", "word_count": 997, "category_scores": {"uiux": 1, "frontend": 1, "design_system": 0, "quality": 0}, "top_keywords": ["url", "hostname", "node", "parse", "api", "code", "whatwg", "const", "request", "https", "toss", "node-fetch"], "excerpt": "Node url parse 취약점 컨트리뷰션 Security Node 표상영 토스 Security Researcher 토스 보안기술팀 Security Tech 에서는 개발 서비스 외에도 회사에서 사용하는 프레임워크나 Third-party 시스템의 취약점을 연구하고 있어요 이번 아티클에서는 Node Built-in API 하나인 url parse Hostname Spoofing 취약점을 발견하고 안전한 코드로 패치될 있도록 컨트리뷰션 했던 과정을 다뤄보려 합니다 https github com nodejs node pull url parse 취약점 발생 원인 Node url parse WHATWG URL API 아닌 자체적인 스펙으로 개발된 함수에요 WHATWG URL API WHATWG Web Hypertext Application Technology Working Group 약어로 국제 표준화 그룹을 뜻해요 WHATWG URL API 국제 표준 스펙으로 URL Uniform Resource Locator 다룰 있도록 제공되는 API 입니다 WHATWG URL API 등장하기 전에 자체적으로 개발된 URL 파싱 함수로 보이는데요 표준 스펙이 아니다 보니 다른 파서 parser 결과가 다르고"} -{"url": "https://toss.tech/article/overseas-securities-server", "title": "우리는 어떻게 해외주식 서비스 안정화를 이뤘는가", "word_count": 1686, "category_scores": {"uiux": 3, "frontend": 1, "design_system": 1, "quality": 2}, "top_keywords": ["있어요", "브로커", "tps", "때문에", "val", "데이터", "파티션", "message", "api", "ratelimiter", "mongodb", "어떻게"], "excerpt": "우리는 어떻게 해외주식 서비스 안정화를 이뤘는가 Server 김광훈 토스증권 Server Developer 토스증권 해외주식 서비스 소개 안녕하세요 토스증권 Server Developer 김광훈입니다 제가 근무하고 있는 해외주식 플랫폼 팀은 미국 주식을 중심으로 해외 주식 원장을 담당하고 있어요 원장이란 증권 서비스에서 가장 주요한 영역 하나이며 금융거래를 기록하는 장부를 말해요 저희 팀에서는 고객의 주문 자산 권리 그리고 종목 정보 관리와 환전까지 해외주식 서비스 제공에 필요한 모든 거래와 정보들을 원장에 기록하는 개발과 운영을 하고 있어요 이번 글에서는 저희 팀이 외부 브로커와 통신하고 있는 해외 주식 서비스를 안전하게 운영하기 위해 고민했던 내용을 공유하려고 합니다 토스증권 미국 주식 매매 아키텍처 먼저 미국 주식 매매 아키텍처를 같이 살펴볼게요 사용자가 토스증권에서 매매 요청을 하면 현지 브로커로 요청을 보내고 브로커는 현지 미국 거래소에서 매매를 체결하고 응답을 보내는 구조입니다 브로커라는 용어가 생소할"} -{"url": "https://toss.tech/article/payments-legacy-1", "title": "20년 레거시를 넘어 미래를 준비하는 시스템 만들기", "word_count": 2273, "category_scores": {"uiux": 2, "frontend": 6, "design_system": 0, "quality": 3}, "top_keywords": ["있습니다", "토스페이먼츠", "토스페이먼츠는", "데이터", "서비스를", "시스템을", "서비스", "인프라", "시스템", "msa", "레거시", "토스페이먼츠의"], "excerpt": "레거시를 넘어 미래를 준비하는 시스템 만들기 하태호 토스페이먼츠 Head Technology 당신도 모르게 사용하고 있는 토스페이먼츠 온라인에서 무언가를 결제할 때마다 여러분은 토스페이먼츠 만나고 있을 가능성이 높습니다 온라인에서 앱을 구매할 때도 쇼핑몰에서 옷을 구매할 때도 음식 배달 애플리케이션에서 저녁을 구매할 때도 퇴근 자기계발을 위해 온라인 강의를 구매할 때도 모든 순간에서 여러분은 토스페이먼츠 통해 결제를 하고 있을지 모릅니다 여러분이 결제 버튼을 클릭하는 순간 보이지 않는 곳에서 토스페이먼츠가 거래를 안전하고 빠르게 처리하고 있습니다 어떤 쇼핑몰인지 어떤 결제 수단을 사용하는지와 상관없이 말이죠 수만여 개의 가맹점과 연결되어 있는 토스페이먼츠는 그대로 대한민국의 보이지 않는 결제 Backbone 입니다 우리가 일상에서 당연하게 생각하는 결제 라는 행위 뒤에서 복잡하고 정교한 시스템들이 거래를 안전하게 처리하고 있습니다 토스페이먼츠만의 제품 혁신 결제 산업 혁신을 목표로 출범한 토스페이먼츠 토스페이먼츠는 출범 이후 새로운 결제 경험과"} -{"url": "https://toss.tech/article/payments-legacy-10", "title": "경계 보안부터 제로트러스트 보안까지, 고도화 여정", "word_count": 2047, "category_scores": {"uiux": 3, "frontend": 0, "design_system": 0, "quality": 1}, "top_keywords": ["컨테이너", "프로세스", "있습니다", "container", "security", "런타임", "모니터링", "제로트러스트", "aws", "idc", "트래픽", "falco"], "excerpt": "경계 보안부터 제로트러스트 보안까지 고도화 여정 김영재 조성현 안녕하세요 토스페이먼츠 Security Engineer 김영재 조성현입니다 여러분이 토스페이먼츠의 서비스를 사용할 때마다 보이지 않는 곳에서는 수많은 보안 시스템이 여러분을 보호하고 있습니다 악의적인 트래픽을 차단하고 이상 행위를 탐지하며 가맹점과 함께 보안 수준을 높여가는 토스페이먼츠 보안팀은 모든 것을 시간 지켜보고 있어요 하지만 처음부터 이런 체계를 갖춘 것은 아니었습니다 기존 시스템 인수 당시 토스페이먼츠가 마주한 보안 환경은 매우 열악했죠 암호화된 트래픽조차 분석할 없었고 단일 방어선에 의존하는 구조였습니다 글은 그런 환경에서 출발해 IDC AWS 하이브리드 환경에서 경계보안부터 제로트러스트 까지 단계별로 보안 체계를 구축한 년간의 여정을 담고 있습니다 우리가 풀어야 했던 가지 보안 과제 서비스 보안과 단말 보안을 모두 강화하는 포괄적인 보안 체계를 만드는 것이 우리의 목표였습니다 토스페이먼츠 보안팀이 집중한 영역은 크게 가지였는데요 저희는 단일 방어선의 한계를 인식하고"} -{"url": "https://toss.tech/article/payments-legacy-2", "title": "가맹점은 변함없이, 결제창 시스템 전면 재작성하기", "word_count": 1780, "category_scores": {"uiux": 1, "frontend": 3, "design_system": 2, "quality": 1}, "top_keywords": ["레거시", "파라미터", "request", "params", "비즈니스", "api", "새로운", "openapi", "기능을", "sessioncreationrequest", "val", "string"], "excerpt": "가맹점은 변함없이 결제창 시스템 전면 재작성하기 황성우 토스페이먼츠 Server Developer 가맹점의 변경 없이도 결제 시스템은 진화할 있을까 토스페이먼츠는 오래된 시스템을 인수한 이후 단계적으로 진정성 있게 시스템을 개선해왔습니다 이번 글에서는 년간 유지되어 레거시 결제창 시스템을 변화에 유연하게 진화할 있는 새로운 결제 시스템으로 재탄생시킨 여정을 공유합니다 결제창이라는 단어가 생소하실 수도 있지만 온라인에서 결제를 번이라도 해보셨다면 분명 만나보셨을 겁니다 상품을 구매하고 결제 버튼을 눌렀을 나타나는 바로 창이죠 토스페이먼츠의 결제창은 국내 모든 카드사 대부분의 해외 카드사 그리고 토스페이와 같은 주요 간편결제사를 모두 지원합니다 겉으로 보기에는 작동하는 것처럼 보이지만 새로운 결제 방법을 추가하거나 기능을 개선하려 때마다 우리는 벽에 부딪혀 왔습니다 레거시 시스템의 한계 절차지향적 코드의 레거시 시스템의 가장 문제는 절차지향적으로 작성된 코드 구조였습니다 하나의 Java Class Method 에서 모든 분기를 처리하고 있었는데 단순히 결제수단에 따라"} -{"url": "https://toss.tech/article/payments-legacy-3", "title": "100년 가는 프론트엔드 코드, SDK", "word_count": 2951, "category_scores": {"uiux": 3, "frontend": 6, "design_system": 1, "quality": 5}, "top_keywords": ["sdk", "가맹점의", "있었습니다", "layer", "return", "결제를", "const", "interface", "가맹점", "요구사항을", "비즈니스", "도메인"], "excerpt": "가는 프론트엔드 코드 SDK 최진영 토스페이먼츠 Frontend Developer 만약 여러분이 결제를 연동하는 개발자라면 어떤 해야한다고 생각하시나요 실제로 결제창을 띄우고 사에 결제를 요청하려면 번거로운 작업이 필요합니다 구현 보안을 위한 인증 흐름 HTML Form 결제 요청을 위한 비동기 처리 그리고 다양한 예외 처리까지 고려해야 하죠 과정에서 많은 개발자가 어려움을 겪습니다 토스페이먼츠는 이러한 문제를 해결하기 위해 결제 SDK 만들었습니다 개발자가 보다 쉽게 결제를 연동할 있도록 번거로움을 줄이는 것이 목표였죠 그렇다면 토스페이먼츠 결제 SDK 사용하면 실제로 결제를 어떻게 구현할 있을까요 SDK 로드 초기화 const tossPayments loadTossPayment clientKey const payment tossPayment payment customerKey 결제 정보와 함께 결제 요청 await payment requestPayment 카드 amount orderId sample-order-id orderName 토스 티셔츠 customerName 김토스 successUrl http localhost success failUrl http localhost fail 줄로 결제 화면을 띄우고 사로 결제를 요청하는"} -{"url": "https://toss.tech/article/payments-legacy-4", "title": "토스페이먼츠의 Open API 생태계", "word_count": 3549, "category_scores": {"uiux": 4, "frontend": 2, "design_system": 0, "quality": 4}, "top_keywords": ["api", "있습니다", "토스페이먼츠는", "open", "테스트", "합니다", "개발자가", "개발자", "tosspayments", "기능을", "하지만", "생태계"], "excerpt": "토스페이먼츠의 Open API 생태계 허지훈 안녕하세요 토스페이먼츠 Server Developer 허지훈입니다 온라인에서 구매하기 버튼을 누르는 순간 사용자는 만에 결제가 완료되는 경험을 합니다 하지만 짧은 순간 동안 서버와 서버 사이에서는 수많은 API 호출이 오가며 결제를 처리하고 있죠 결제창을 띄우는 것부터 승인 요청 결제 완료 정산 그리고 금액 검증까지 모든 과정은 API 통해 연결됩니다 토스페이먼츠는 이런 흐름의 중심에서 가맹점이 결제 서비스를 연동할 있도록 Open API 제공합니다 현재 토스페이먼츠의 Open API 이상의 가맹점에서 사용되고 있습니다 수치는 단순히 규모를 넘어 API 얼마나 오랫동안 안정적으로 운영되어야 하는지를 보여줍니다 연동된 API 수년 혹은 수십 동안 유지될 있고 잘못 설계된 API 하나는 그만큼의 시간 동안 불편과 유지 비용을 낳습니다 그래서 토스페이먼츠는 Open API 단순히 동작하는 것을 넘어 앞으로 수십 년간 안전하게 운영될 인프라라는 관점에서 설계하고 관리하고 있습니다 API"} -{"url": "https://toss.tech/article/payments-legacy-5", "title": "레거시 결제 원장을 확장 가능한 시스템으로", "word_count": 1410, "category_scores": {"uiux": 2, "frontend": 1, "design_system": 0, "quality": 3}, "top_keywords": ["데이터", "있었습니다", "불일치", "이벤트를", "mysql", "데이터를", "있습니다", "이벤트", "서비스", "되었습니다", "비동기", "토스페이먼츠는"], "excerpt": "레거시 결제 원장을 확장 가능한 시스템으로 박순현 양권성 안녕하세요 토스페이먼츠 Server Developer 박순현 양권성입니다 토스페이먼츠는 온라인 거래의 중심에 있습니다 사용자가 쇼핑몰에서 결제하기 누르는 순간 보이지 않는 곳에서 수많은 결제가 안정적으로 처리됩니다 하지만 우리의 시작은 완전히 새로웠던 것이 아닙니다 기존 사업을 인수하면서 토스페이먼츠가 출범했습니다 이전 시스템은 이상 운영된 레거시 구조였고 우리가 개선해야 기술적 과제는 만만치 않았습니다 과정에서 가장 복잡하면서도 핵심이었던 해결 과제는 결제 원장 Ledger 결제의 모든 내역이 저장되는 중심 시스템이었습니다 결제 원장의 역할과 한계 원장 회계 용어에서 유래한 개념으로 모든 거래 내역을 기록하는 장부를 의미합니다 결제 원장은 고객의 결제 취소 환불 내역이 기록되는 핵심 테이블로 정산과 회계의 기초 데이터로 사용됩니다 하지만 원장이 오랜 기간 운영되며 여러 문제가 누적되었습니다 문제 일관성 없는 데이터 구조 레거시 원장은 결제수단별로 테이블 구조가 모두 달랐습니다"} -{"url": "https://toss.tech/article/payments-legacy-6", "title": "레거시 정산 개편기: 신규 시스템 투입 여정부터 대규모 배치 운영 노하우까지", "word_count": 2947, "category_scores": {"uiux": 1, "frontend": 2, "design_system": 0, "quality": 2}, "top_keywords": ["val", "시스템", "있었습니다", "데이터를", "jenkins", "job", "저희는", "시스템을", "레거시", "문제가", "transaction", "데이터"], "excerpt": "레거시 정산 개편기 신규 시스템 투입 여정부터 대규모 배치 운영 노하우까지 강민주 박진현 안녕하세요 토스페이먼츠 Server Developer 강민주 박진현입니다 정산 시스템을 개편하나요 토스페이먼츠에서 정산 시스템을 운영하며 가장 많이 받았던 질문이에요 하지만 년이 넘은 레거시 시스템을 운영하다 보니 저희가 반드시 극복해야 명확한 한계가 있었습니다 지금부터 토스페이먼츠 정산 플랫폼팀이 레거시 정산 시스템을 어떻게 개편했는지 여정을 공유합니다 정산이란 무엇인가 먼저 정산이 무엇인지부터 짚어볼게요 정산은 쉽게 말해 고객이 결제한 돈을 에서 상점에 정확히 전달하는 과정 입니다 하지만 과정이 생각보다 간단하지 않아요 상점별로 계약 조건에 따라 수수료 계산 방식이 다르고 돈을 지급하는 일자를 조정하는 매우 정밀한 작업들이 필요하기 때문이죠 토스페이먼츠 정산 시스템은 년이라는 기간 동안 이런 정밀한 작업들을 수행해오고 있었습니다 참고 https docs tosspayments com resources glossary settlement 레거시 시스템을 개편해야 했나 년간 운영해온 시스템을"} -{"url": "https://toss.tech/article/payments-legacy-7", "title": "고객은 절대 기다려주지 않는다: 빠른 데이터 서빙으로 고객 만족도를 수직 상승 시키는 법", "word_count": 2406, "category_scores": {"uiux": 2, "frontend": 2, "design_system": 0, "quality": 2}, "top_keywords": ["데이터", "druid", "starrocks", "데이터를", "성능을", "도메인", "실시간", "최적화", "index", "테이블", "있었어요", "다음과"], "excerpt": "고객은 절대 기다려주지 않는다 빠른 데이터 서빙으로 고객 만족도를 수직 상승 시키는 이세찬 안녕하세요 토스페이먼츠 Data Engineer 이세찬입니다 사업을 인수하며 출범한 토스페이먼츠는 이후 가파른 성장세를 보였습니다 대비 거래 건수 기준으로 증가했죠 하지만 이런 폭발적인 성장은 우리에게 새로운 도전을 안겨주었습니다 바로 어떻게 하면 늘어나는 데이터를 안정적이고 빠르게 서빙할 있을까 라는 문제였어요 모놀리식에서 MSA 번째 도전 이후 토스페이먼츠는 나은 개발 환경을 위해 MSA Microservices Architecture 환경으로 전환을 시작했습니다 하지만 애플리케이션 레벨의 분리는 이루어졌어도 레벨의 분리까지는 도달하지 못했습니다 모놀리식 구조에서는 모든 도메인이 하나의 시스템과 에서 돌아갔습니다 결제 정산 도메인별 원장 조회는 크게 문제되지 않았어요 내부에서 원장 JOIN 으로 해결하거나 원장 자체가 통합 관리되어 데이터 조회가 단순한 쿼리로 가능했기 때문이에요 하지만 MSA 환경으로 전환하면서 상황은 완전히 달라졌습니다 Elasticsearch 해결한 번째 검색 니즈 가맹점의 거래내역"} -{"url": "https://toss.tech/article/payments-legacy-8", "title": "수천 개의 API/BATCH 서버를 하나의 설정 체계로 관리하기", "word_count": 2173, "category_scores": {"uiux": 1, "frontend": 4, "design_system": 0, "quality": 2}, "top_keywords": ["yaml", "dev", "values", "live", "api", "있습니다", "jenkins", "common", "토스페이먼츠", "jvm_option", "인프라", "env"], "excerpt": "수천 개의 API BATCH 서버를 하나의 설정 체계로 관리하기 나재은 토스페이먼츠 Server Developer 오타를 찾아보세요 설정 env JVM_OPTION Xmx1024m UseG1GC G1HeapRegionSize 설정 env JVM_OPTION Xmx2048m UseG1GC G1HeapRegionSiez 여기 개의 설정이 있습니다 중에서 다른 점을 찾아보세요 금방 찾으셨나요 정답은 설정 G1HeapRegion Siez 부분이었습니다 만약 이런 설정이 개가 넘는다면 어떨까요 실수하기 너무 좋은 상황이 같습니다 여러분은 배치 서버를 개까지 운영해 보셨나요 사실 각각의 문자열은 배치 설정이었고 설정 오타가 존재하는 잘못 설정된 배치였습니다 문제는 오타 설정을 가진 배치가 무려 규모의 돈을 정산하는 배치 였다는 점입니다 설정 중복은 인프라 장애로 이어진다 안녕하세요 토스페이먼츠 서버플랫폼 리더 나재은입니다 저희 서버플랫폼 팀은 Kubernetes AWS 사내 운영 도구 개발자와 가까운 곳에서 정책을 만들고 인프라를 운영하고 있습니다 매일 수천억 원을 정산하는 수천 개의 배치 설정과 수천 개의 API 서버"} -{"url": "https://toss.tech/article/payments-legacy-9", "title": "레거시 인프라 작살내고 하이브리드 클라우드 만든 썰", "word_count": 2550, "category_scores": {"uiux": 1, "frontend": 3, "design_system": 2, "quality": 2}, "top_keywords": ["openstack", "aws", "클라우드", "퍼블릭", "인프라", "네트워크", "클러스터", "저희가", "프라이빗", "있었습니다", "k8s", "서비스"], "excerpt": "레거시 인프라 작살내고 하이브리드 클라우드 만든 박명순 정상현 안녕하세요 토스페이먼츠 Infra Team Leader 정상현 DevOps Engineer 박명순입니다 결제 산업 혁신을 목표로 출범한 토스페이먼츠 하지만 처음부터 새롭게 시작한 것이 아니라 넘게 운영되던 사업을 인수하며 시작했습니다 그리고 인프라에는 상상을 초월하는 레거시가 기다리고 있었죠 이번 글에서는 오픈소스 기반 OpenStack 프라이빗 클라우드를 직접 구축해 퍼블릭 AWS Active-Active 하이브리드 클라우드 다중 Pod 클러스터 운영하며 자동화 모니터링 고가용성을 확보해 어느 클라우드인지 몰라도 배포 가능한 환경을 만들었습니다 이라는 숫자의 비밀 조금 뜬금없지만 여기 이라는 숫자가 있습니다 숫자가 어떤 의미인지 혹시 짐작이 가실까요 힌트는 서버와 관련되어 있지만 서버에서 흔하게 있는 숫자는 아닙니다 라우팅은 네트워크 장비가 하는 아니었나요 숫자를 설명하려면 네트워크 라우팅에 대한 얘기를 해야 합니다 일반적으로 서버 동일한 네트워크 장비에 연결되어 있고 원격지 네트워크에 각각 서버 서버 있는"} -{"url": "https://toss.tech/article/payments-legacy-intro", "title": "멈춰있던 PG의 시간, 토스페이먼츠가 다시 흐르게 합니다", "word_count": 275, "category_scores": {"uiux": 0, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["토스페이먼츠", "시스템을", "레거시", "엔지니어들이", "시스템", "저희는", "어떻게", "멈춰있던", "토스페이먼츠가", "흐르게", "합니다", "하태호"], "excerpt": "멈춰있던 시간 토스페이먼츠가 다시 흐르게 합니다 하태호 토스페이먼츠 Head Technology 토스테크 독자 여러분 안녕하세요 토스페이먼츠 Head Technology 하태호입니다 글을 시작으로 토스페이먼츠가 이상 운영된 레거시 시스템을 인수한 이후 시스템을 단계적으로 개편해 기술블로그 시리즈를 연재하고자 합니다 저희 엔지니어들이 만들어가고 있는 특별한 기술 여정을 공유하게 되어 무척 기쁜 마음입니다 시리즈가 지난 년간 토스페이먼츠 엔지니어들이 쏟아부은 치열한 고민과 노력의 기록이자 대한민국 결제 시장의 기술적 성장을 위한 작은 씨앗이 되기를 바랍니다 오랫동안 시장에는 이런 인식이 있었습니다 결제는 원래 불편하고 어려운거야 결제 시스템 연동은 복잡하고 어려워 전자결제 산업은 기술적으로 낙후되어 있어 개선되기 어렵다 고정관념이죠 저희는 굳어진 생각에 정면으로 도전장을 내밉니다 결제 기술과 생태계는 편리해질 있고 안전해질 있으며 고객의 기대를 뛰어넘는 경험을 제공해야 한다고 믿기 때문입니다 발전이 모든 산업의 지형을 바꾸고 있는 지금 기술의 변화 속도에 맞추는"} -{"url": "https://toss.tech/article/persona", "title": "토스의 새로운 얼굴 만들기", "word_count": 634, "category_scores": {"uiux": 3, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["토스의", "그래서", "인상을", "그래픽이", "그래픽은", "자연스럽게", "정돈된", "유지하되", "중립적인", "얼굴을", "그래픽을", "하지만"], "excerpt": "토스의 새로운 얼굴 만들기 그래픽디자인 Graphic design 정서현 토스코어 Graphic Designer 토스에서 이런 얼굴을 마주한 적이 있나요 안내 문구나 고객센터처럼 사용자와 직접 마주하는 화면에는 서비스가 어떤 태도와 성격을 가진 존재인지를 전해주는 시각적 요소가 필요해요 말투와 분위기 인상을 대신 전달해주는 역할이죠 많은 서비스나 은행이 대표 캐릭터를 사용하는 것도 이런 이유예요 표정과 제스쳐만으로도 감정과 상황을 빠르게 전달할 있기 때문이에요 토스는 대표 캐릭터 대신 인물 형태의 그래픽을 활용해왔어요 캐릭터만큼 강하게 드러나지는 않지만 상황을 부드럽게 설명하고 사용자와의 거리감을 줄이는 데에는 충분했죠 하지만 토스 앱이 성장하면서 그래픽이 해내야 역할도 분명해졌어요 번째 토스다움을 또렷하게 전달하고 싶었어요 기존 그래픽은 작은 이모지 환경을 기준으로 만들어졌어요 그래서 화면이 커질수록 밀도가 낮아 보였죠 무엇보다 아이 같은 인상과 공허한 눈빛이 토스가 지향하는 신뢰감을 충분히 전달하지 못했어요 어떤 화면에 놓이더라도 완성도가 유지되는"} -{"url": "https://toss.tech/article/react-native-2024", "title": "토스가 꿈꾸는 React Native 기술의 미래", "word_count": 1215, "category_scores": {"uiux": 3, "frontend": 6, "design_system": 3, "quality": 0}, "top_keywords": ["react", "native", "있어요", "javascript", "서비스를", "ios", "프론트엔드", "android", "토스에서는", "webview", "속도를", "있었어요"], "excerpt": "토스가 꿈꾸는 React Native 기술의 미래 Frontend 박서진 토스 Head Frontend 안녕하세요 토스 프론트엔드 엔지니어링 헤드 박서진입니다 토스에서는 최고의 사용자 경험이 필요한 곳은 Native 매일매일 실험으로 제품을 개선하는 제품은 React Native WebView 구성하고 있어요 토스 프론트엔드 챕터는 지난 월부터 React Native 기술에 투자하고 있는데요 이번 기술 블로그 아티클에서는 React Native 고려하고 있는지 현재 어느 정도까지 사용하고 있는지 그리고 앞으로의 계획이 어떻게 되는지에 대해서 소개드리려고 합니다 React Native 인가 토스는 React Native 매끄러운 사용자 경험과 높은 개발 생산성을 제공하여 모바일 서비스를 만드는 새로운 표준을 제시하고자 해요 React Native 서비스를 개발하면 WebView 서비스를 만들 때보다 사용자 경험을 크게 개선할 있어요 React Native 기본적으로 파일 시스템에서 JavaScript 파일을 읽어오기 때문에 WebView 다르게 네트워크로 인한 로딩 속도를 없앨 있기 때문이죠 한국처럼 대부분의 사용자가 이후에"} -{"url": "https://toss.tech/article/react-native-without-cocoapods", "title": "CocoaPods 없이 React Native 개발하기", "word_count": 1190, "category_scores": {"uiux": 1, "frontend": 4, "design_system": 0, "quality": 0}, "top_keywords": ["react", "native", "cocoapods", "의존성", "swift", "라이브러리", "srcroot", "ios", "xcframework", "project", "frameworks", "package"], "excerpt": "CocoaPods 없이 React Native 개발하기 React Native iOS 오진성 토스 iOS Developer 프로젝트에 React Native 도입할 크게 가지 방식이 있습니다 프로젝트를 처음부터 React Native 개발한다 현재 프로젝트에 부분적으로 React Native 도입한다 베트남의 토스앱을 개발하던 때는 운영되는 서비스를 잠시 멈추고 React Native 새로 개발했었습니다 시점에 앱의 기능이 그렇게 많지 않았기 때문에 가능했었습니다 하지만 한국에서 서비스 하고 있는 토스앱은 그렇지 않았습니다 토스의 React Native 도입 배경 토스앱은 사실 크로스 플랫폼을 활용하기 위해 React Native 도입했던 것은 아니었습니다 코로나가 극심할 식당이나 카페등 가게에 출입하기 위해 인증을 했던 것을 기억하실것입니다 인증을 위해 나라에서 지정한 업체의 SDK 사용해야 했습니다 그런데 업체가 React Native SDK 전달해주었고 그렇게 토스앱에 React Native 포함되었습니다 이후 토스 내부에서도 웹서비스의 화면들을 빠르게 로드해야 한다는 요구사항과 겹쳐서 React 다루는 Frontend 개발자들이 앱에"} -{"url": "https://toss.tech/article/research-platform-ai", "title": "휴리봇 이야기 #1: 토스는 AI 봇에게 사용자 인터뷰를 한다", "word_count": 1306, "category_scores": {"uiux": 6, "frontend": 2, "design_system": 0, "quality": 2}, "top_keywords": ["사용자를", "사용성을", "휴리봇의", "빠르게", "있어요", "프롬프트", "프롬프팅", "디자이너가", "휴리봇", "때문에", "제품을", "디자이너분들이"], "excerpt": "휴리봇 이야기 토스는 봇에게 사용자 인터뷰를 한다 Research 최정은 토스 Research Operation Manager 사용자는 페이지를 어떻게 이해할까 화면이 복잡해 보이진 않을까 그래픽은 어떤 의미로 느껴질까 제품을 만들다 보면 사용자에게 궁금한 점이 많이 생기는데요 내가 원할 때마다 사용자를 만나 궁금한 점을 물어볼 있다면 얼마나 좋을까요 토스에서는 디자이너분들이 자주 빠르게 제품의 사용성을 점검할 있게 토스 사용자처럼 학습된 휴리봇 만들었어요 휴리봇에게 내가 디자인한 화면을 보여주고 사용성과 관련된 질문을 하면 토스 사용자와 흡사한 의견을 줍니다 초만 에요 혹시 제품을 만들기 위해 그동안 프롬프트 엔지니어링 신경 쓰고 계셨다면 이번 글을 통해 실무에서 실제로 활용하기 위해 프롬프팅 외에도 필요한 것들을 프롬프팅 단계 별로 알려드릴게요 이해하기 쉽게 휴리봇을 제품화 하는 과정을 함께 설명해 드리면서요 프롬프트 prompt 생성형 특정 작업을 수행하도록 지시하는 자연어 텍스트로 고품질의 아웃풋을 생성하기 위해"} -{"url": "https://toss.tech/article/research_process", "title": "업무 효율화, 작은 단계부터 다시 보기", "word_count": 968, "category_scores": {"uiux": 3, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["인터뷰", "리서치", "있어요", "일정을", "과정을", "방법을", "진행자", "영향을", "하지만", "그래서", "메시지", "기준을"], "excerpt": "업무 효율화 작은 단계부터 다시 보기 서소희 문은진 토스 팀원들이 사용자를 효율적으로 만날 있는 환경을 조성하는 리서치 플랫폼팀 서소희 문은진입니다 지금 과정 효율적으로 없을까 라는 고민 누구나 번쯤 해보셨을 거예요 하지만 막상 효율화라고 하면 너무 거창하게 느껴지거나 어디서부터 손대야 할지 막막해지는 경우도 많죠 오늘은 저희가 리서치 과정을 효율화했던 방법을 소개해 드릴게요 방법은 리서치 아니라 다양한 업무에서 효율화를 고민할 참고해 있어요 토스가 다양한 사업으로 빠르게 확장되면서 리서치 수요도 크게 늘었어요 B2B 고객이나 외국인 사용자처럼 다양한 유저를 섭외하면서도 기존과 같은 속도로 리서치를 진행할 있는 환경을 만드는 것이 중요했죠 그래서 먼저 지금의 프로세스가 어떻게 흘러가는지 정확히 점검하는 일부터 시작했어요 현황 파악 액션 별로 정리하기 프로세스를 정리할 단순히 단계만 나열하면 문제를 정확히 파악하기 어려워요 액션 별로 쪼개야 어디에서 어떤 문제가 생기는지 있어요 누가 어디에서"} -{"url": "https://toss.tech/article/restructuring", "title": "달리는 기차의 바퀴 교체하기 2. Restructuring", "word_count": 1391, "category_scores": {"uiux": 2, "frontend": 7, "design_system": 2, "quality": 3}, "top_keywords": ["테스트", "코드를", "의존성", "리팩토링", "page", "brandpayclient", "있습니다", "의존하고", "client", "어떻게", "합니다", "return"], "excerpt": "달리는 기차의 바퀴 교체하기 Restructuring Frontend 한재엽 토스페이먼츠 Frontend Developer 앞선 Planning 에선 문제를 어떻게 정의하는지에 대해 다뤘어요 구체적인 내용을 기약하고 글을 마무리했는데요 글에선 구체적으로 어떤 작업들을 진행했는지 소개해요 재구조화 앞선 글에서 소개했듯이 재구조화 라는 단어는 리팩토링보다 거시적인 관점에서의 개선을 뜻해요 이번 프로젝트에서는 테스트 코드의 리팩토링 내성 가장 먼저 개선하려고 했는데요 이야기를 자세히 해볼게요 리팩토링 내성 리팩토링은 다음과 같이 정의할 있습니다 소프트웨어 공학에서 결과를 변경하지 않고 코드의 구조를 재조정하는 그리고 리팩토링에 내성이 있다 라는 말은 이렇게 있어요 제품 코드를 리팩토링 테스트 코드를 변경하지 않고도 가능하며 테스트 코드 실행 결과가 달라지지 않는다 리팩토링을 제대로 했다면 기능 변경이 없어야 해요 테스트 코드 결과도 동일해야 합니다 결과 우리는 테스트 코드 실행 결과를 통해 기존에 동작하고 있던 기능이 그대로 동작하고 있음을 보장할 있습니다 테스트"} -{"url": "https://toss.tech/article/restructuring-planning", "title": "달리는 기차의 바퀴 교체하기 1. Planning", "word_count": 1550, "category_scores": {"uiux": 1, "frontend": 4, "design_system": 2, "quality": 1}, "top_keywords": ["테스트", "코드를", "제품을", "기능을", "작업을", "제품의", "있어요", "제대로", "브랜드페이", "제품이", "리소스를", "때문에"], "excerpt": "달리는 기차의 바퀴 교체하기 Planning Frontend 한재엽 토스페이먼츠 Frontend Developer 이미 운영 중인 제품을 전반적으로 다시 만들거나 리팩토링 하는 경험을 해볼 있는 기회는 흔치 않은데요 좋게 공감대가 형성되어 여러 팀원과 하나의 제품을 온전히 개선해 있었어요 글에서는 구조 개선에 앞서 어떻게 프로젝트를 플래닝하고 진행했는지 소개할게요 브랜드페이 토스페이먼츠의 결제 제품 중에는 브랜드페이 라는 화이트 라벨 white label 결제 제품이 있어요 가맹점 간편 결제를 만들 있도록 해주는 제품이죠 브랜드페이를 사용하면 가맹점 어디나 본인만의 브랜드를 입힌 간편결제 서비스를 고객 대상으로 제공할 있어요 가맹점은 우리가 제공하는 SDK 통해 결제 수단 등록 관리 퍼널 결제 화면 등에 브랜드명 색상 결제 화면 구성 다양한 요소를 커스터마이징할 있어요 그래서 결제하는 소비자 입장에서는 브랜드페이를 통해 가맹점의 자체 간편결제 서비스를 이용하는 것처럼 느낄 있어요 제품이 마주한 문제 브랜드페이를 도입하려는 회사의"} -{"url": "https://toss.tech/article/rethinking-design-system", "title": "디자인 시스템 다시 생각해보기", "word_count": 1167, "category_scores": {"uiux": 4, "frontend": 3, "design_system": 3, "quality": 0}, "top_keywords": ["card", "디자인", "children", "title", "api", "flat", "button", "header", "시스템", "compound", "tds", "function"], "excerpt": "디자인 시스템 다시 생각해보기 김민수 안녕하세요 토스의 디자인 시스템 TDS 만들고 있는 Frontend Engineer 김민수입니다 디자인 시스템을 만들고 운영하다 보면 항상 같은 질문과 마주하게 됩니다 어떻게 하면 많은 팀이 우리 시스템을 사용하게 있을까 오늘은 TDS 질문에 답하기 위해 고민했던 과정을 공유하려고 해요 디자인 시스템은 점점 사용하기 어려워질까 디자인 시스템은 명확한 약속을 가지고 출발합니다 일관된 빠른 개발 속도 효율적인 협업 설계된 시스템은 디자이너와 개발자 사이의 커뮤니케이션 비용을 줄이고 반복되는 의사결정에서 팀을 자유롭게 하며 여러 플랫폼에서 동일한 사용자 경험을 제공하는 기반이 돼요 하지만 조직이 성장하고 제품이 다양해지면서 견고해 보이던 시스템에도 균열이 생기기 시작합니다 버튼 컴포넌트에 배지를 함께 넣을 있나요 디자인은 업데이트됐는데 개발 패키지는 언제 반영되나요 우리 상황에 맞게 살짝만 수정하고 싶은데 제품팀은 시스템의 제약 안에서 해결책을 찾다가 결국 시스템을 우회합니다 Figma 컴포넌트를"} -{"url": "https://toss.tech/article/rn-toss-bedrock", "title": "React Native에서 타입 안전한 파일 기반 라우팅 구현하기", "word_count": 1439, "category_scores": {"uiux": 1, "frontend": 4, "design_system": 2, "quality": 0}, "top_keywords": ["route", "export", "import", "from", "react", "granite", "return", "useparams", "const", "native", "params", "타입을"], "excerpt": "React Native 에서 타입 안전한 파일 기반 라우팅 구현하기 Frontend 강선규 토스 Frontend Platform Engineer 안녕하세요 React Native Framework Team 속한 Frontend Platform Engineer 강선규입니다 오늘 소개해 드릴 이야기는 토스에서 만드는 React Native Framework 기능에 관한 이야기에요 Granite 이란 토스는 이전 토스가 꿈꾸는 React Native 기술의 미래 에서 자체적인 React Native 프레임워크를 개발한다고 공개했어요 글을 통해서 해당 프레임워크 Granite 처음 공개해요 Granite React Native 프레임워크로 단단한 화강암을 의미하는 이름처럼 프로젝트 개발에 견고한 기초를 제공해요 Granite 안정적이고 신뢰할 있는 구조를 바탕으로 다양한 플랫폼에서 일관된 성능과 사용자 경험을 제공해요 아직은 토스 내부 일부 협력사만 사용할 있지만 추후에 공개를 목표로 오픈소스를 목표로 하고 있어요 일자로 Granite 외부 공개 되었어요 https github com toss granite 기존 React Native 생태계에는 이미 Expo 같은 프레임워크가 존재하며"} -{"url": "https://toss.tech/article/secure-efficient-ai", "title": "토스뱅크가 AI로 보안과 효율도 챙기는 방법", "word_count": 904, "category_scores": {"uiux": 1, "frontend": 1, "design_system": 0, "quality": 2}, "top_keywords": ["신분증", "false", "신분증을", "신분증이", "positive", "모델을", "있어요", "고객이", "negative", "문제가", "토스뱅크", "모델의"], "excerpt": "토스뱅크가 보안과 효율도 챙기는 방법 Server Machine Learning 김경윤 토스뱅크 Server Developer 안녕하세요 토스뱅크 Server Developer 김경윤입니다 은행 창구에서 본인확인을 위해 요구할까요 바로 신분증입니다 비대면 은행인 토스뱅크에서도 서비스를 사용하려면 신분증이 가장 먼저 필요해요 비대면 은행은 스마트폰으로 촬영된 신분증 이미지를 확인해서 위조 신분증인지 아닌지 혹은 주요한 정보들이 보이는지 확인할 의무가 있는데요 작업은 사람이 모든 건을 일일이 확인하며 판단해야 해요 토스뱅크는 다양한 곳에서 기술을 활용하고 있어요 오늘은 신분증 검증 과정에서 모델로 어떻게 수기 작업을 최소화했는지 알려드릴게요 토스뱅크 모델의 진화 신분증을 제출하면 신분증을 검증한 다음에 계좌가 개설되는 것이 일반적이에요 하지만 토스뱅크에서는 빠르고 편리한 고객 경험을 위해 계좌를 먼저 개설하고 이후에 신분증을 검사하는 사후 검증 방식 선택했어요 하지만 방식에는 신분증 위조나 금융 범죄가 일어날 있어요 토스뱅크는 이런 모델을 적극적으로 활용하며 이런 사고를 방지하고 있어요"} -{"url": "https://toss.tech/article/securities_llm_1", "title": "고성능 GPU 클러스터 도입기 #1: 요리하라고 해서 왔는데 프라이팬이 없어요", "word_count": 1457, "category_scores": {"uiux": 2, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["gpu", "llm", "이러한", "데이터를", "데이터", "고성능", "다양한", "cpu", "있어요", "때문에", "있습니다", "클러스터"], "excerpt": "고성능 GPU 클러스터 도입기 요리하라고 해서 왔는데 프라이팬이 없어요 Machine Learning 김진웅 토스증권 Machine Learning Engineer 좋은 투자 위해서는 무엇이 필요할까요 많은 것들이 필요하겠지만 그중에서도 주가 변동 거래량 경제 지표 뉴스 기사 방대하고 어마어마한 양의 실시간 데이터를 종합하여 분석해서 가치 있는 정보를 추출하는 것이 필수 입니다 보통 애널리스트 이런 작업을 하는데요 애널리스트가 되기 위해선 수년 수십 년간의 훈련과 경험을 통해 전문성을 쌓아야 합니다 하지만 토스증권은 투자를 위한 분석 작업을 인공지능이 대신해주면 어떨까 라는 생각을 하게 됩니다 목표를 향해서 대용량 금융 증권 데이터를 효과적으로 취합하고 분석하여 사용자에게 가치 있는 정보를 제공하기 위한 연구를 계속해왔는데요 이러한 작업을 가장 수행할 있는 도구로 최근에는 LLM 대형 언어 모델 Large Language Model 각광받고 있죠 오늘은 토스증권은 자체 LLM 개발하기로 결정했는지 그리고 고성능 자체 GPU 클러스터가"} -{"url": "https://toss.tech/article/securities_llm_2", "title": "고성능 GPU 클러스터 도입기 #2: 이주하는 데이터", "word_count": 1697, "category_scores": {"uiux": 0, "frontend": 0, "design_system": 1, "quality": 1}, "top_keywords": ["gpu", "있습니다", "nvlink", "pcie", "인피니밴드", "이러한", "cpu", "합니다", "때문에", "데이터를", "데이터", "하지만"], "excerpt": "고성능 GPU 클러스터 도입기 이주하는 데이터 Machine Learning 김진웅 토스증권 Machine Learning Engineer 이전 포스트 에서는 토스증권 내에서 자체 LLM Large Language Model 개발하기로 했는지 그리고 고성능 GPU 클러스터가 필요한지 공유했는데요 이번 포스트에서는 토스증권 에서 어떻게 고성능 GPU 클러스터를 구축했는지 자세히 공유 하겠습니다 끊임없이 이동하는 데이터 어떻게 하면 고성능 GPU 클러스터를 구축할 있을까요 단순히 최신의 비싼 GPU 사서 연결하면 될까요 아예 틀린 답은 아닙니다 하지만 LLM 같은 매우 모델은 하나의 GPU 카드에 수용할 없는 경우가 많습니다 따라서 수십 수백 GPU 연결하여 클러스터를 구축해야 됩니다 이런 클러스터 환경에서는 학습 추론 다수의 GPU 동시에 처리를 하는 경우가 일반적입니다 그렇기 때문에 데이터가 끊임없이 이동하게 되고 이러한 통신 비용 전체 성능에 영향을 많이 미칩니다 통신 비용은 모델의 종류 크기 학습 방법 다양한 요소들에 의해"} -{"url": "https://toss.tech/article/simplicity4-frontend-engineering", "title": "뒤에 개발자 있어요 | Simplicity 4 제작기 #2", "word_count": 558, "category_scores": {"uiux": 2, "frontend": 5, "design_system": 1, "quality": 1}, "top_keywords": ["simplicity", "있었어요", "템플릿의", "script", "safari", "했어요", "모바일", "필요한", "frontend", "박은식", "프로젝트를", "재사용"], "excerpt": "뒤에 개발자 있어요 Simplicity 제작기 frontend simplicity 박은식 이예서 안녕하세요 Simplicity 프로젝트에서 프론트엔드 개발을 맡은 Frontend Engineer 박은식 이예서입니다 이번 프로젝트를 진행할 기술적으로 고민했던 내용들을 공유하고자 해요 재사용 가능한 컨퍼런스 만들기 이번 Simplicity 프로젝트는 다음 시즌에도 재사용 가능하도록 구조화 하는데 초점을 맞췄어요 그로 인해 세션 화면을 컴포넌트 단위로 템플릿화하고 세션 정보를 시트에 입력받아 누구나 쉽게 세션 내용을 수정할 있도록 만들어야 하는 요구사항이 있었죠 중에서도 세션 화면이 인터렉션을 통해 자연스럽게 연결되게 하는 것이 무척 어려웠는데요 화면 템플릿마다 플랫폼마다 인터렉션 스펙이 전부 달랐기에 고민해야 지점이 많았어요 그래서 저희는 세션 템플릿의 구성 요소를 분할하고 공통된 인터렉션 흐름을 정의했어요 사진과 같이 Simplicity 세션은 템플릿의 조합으로 진행돼요 모바일과 데스크탑까지 고려해야하는 만큼 중복을 줄이고 패턴화시켜 구현해야할 필요가 있었어요 저희는 템플릿들의 요소들을 분리해서 크게 가지로 분류했는데요 연사자의"} -{"url": "https://toss.tech/article/simplicity_behind", "title": "AI 아바타가 발표하는 온라인 컨퍼런스 | Simplicity 4 제작기 #1", "word_count": 696, "category_scores": {"uiux": 4, "frontend": 1, "design_system": 0, "quality": 2}, "top_keywords": ["디자인", "아바타가", "그래서", "simplicity", "가능한", "모바일", "컨퍼런스", "심플리시티", "만들기", "아니라", "음성을", "발표하는"], "excerpt": "아바타가 발표하는 온라인 컨퍼런스 Simplicity 제작기 디자인 디자인컨퍼런스 심플리시티 윤지영 토스 Head Platform Simplicity 나은 사용자 경험을 만들기 위해 토스가 치열하게 고민해온 과정을 나누는 디자인 컨퍼런스 예요 우리의 실험과 시도들이 사용자 경험을 고민하는 다른 디자이너들에게도 영감이 되기를 바라며 시작했어요 년부터 토스 디자인 챕터가 정기적으로 개최하고 있죠 내용뿐 아니라 메시지를 어떻게 전달할지도 중요하게 여겼기 때문에 년부터는 유튜브 영상이 아닌 기반 인터랙티브 사이트로 전환해 운영하고 있어요 지속 가능한 컨퍼런스 만들기 디자인 챕터에게 심플리시티는 거의 종합 예술에 가까운 프로젝트예요 운영 디자인 개발 대본 촬영 녹음 홍보까지 모든 과정이 고도화된 협업으로 이루어지죠 번으로 끝나는 행사가 아니라 매년 반복되는 시즌제 컨퍼런스 이기 때문에 지속 가능성 정말 중요한 주제였어요 매년 퀄리티와 지속 가능성 사이의 균형 고민해야 했죠 과정에서 많은 부분을 효율적으로 만들어왔지만 촬영만큼은 어려운 과제 였어요"} -{"url": "https://toss.tech/article/slash23-corebanking", "title": "은행 최초 코어뱅킹 MSA 전환기 (feat. 지금 이자 받기)", "word_count": 1785, "category_scores": {"uiux": 0, "frontend": 2, "design_system": 1, "quality": 3}, "top_keywords": ["코어뱅킹", "모놀리식", "redis", "마이크로", "api", "때문에", "msa", "토스뱅크", "서비스", "lock", "서비스를", "시스템의"], "excerpt": "은행 최초 코어뱅킹 MSA 전환기 feat 지금 이자 받기 SLASH23 Server 장세경 조서희 토스뱅크 Server Developer 토스뱅크는 기존의 공급자 중심의 뱅킹 서비스를 고객 중심으로 변화시키기 위해 많은 노력을 기울이고 있어요 그러나 기존의 전통적인 뱅킹 시스템을 구현하는 방식으로는 안정적인 고객 중심 뱅킹 서비스 제공에 여러 기술적 한계가 있었죠 이번 아티클에서는 토스뱅크가 어떤 방식으로 기술적 한계를 극복했고 어떤 기술로 고객 중심의 뱅킹 서비스를 제공해 드리고 있는지에 대해 소개해 드릴게요 현재 은행 시스템에 대한 소개 채널계와 코어뱅킹 계정계 먼저 일반적인 은행 시스템의 아키텍처에 대해 알아볼게요 은행에는 크게 고객의 요청을 코어뱅킹 서버로 전달하는 채널계와 금원과 관련된 메인 비즈니스 로직을 처리하는 코어뱅킹 계정계 라고 하는 개의 서버를 중심으로 하는 아키텍처로 구성되어 있어요 여기에 코어뱅킹 서버는 대부분의 은행에서 거대한 모놀리식 아키텍처로 구성되어 있죠 코어뱅킹 시스템 아키텍처"} -{"url": "https://toss.tech/article/slash23-data", "title": "대규모 로그 처리도 OK! Elasticsearch 클러스터 개선기", "word_count": 1377, "category_scores": {"uiux": 0, "frontend": 0, "design_system": 0, "quality": 3}, "top_keywords": ["elasticsearch", "마스터", "있습니다", "fielddata", "로그를", "클러스터가", "데이터", "인덱스", "하나의", "vector", "logstash", "때문에"], "excerpt": "대규모 로그 처리도 Elasticsearch 클러스터 개선기 SLASH23 Data 이준환 토스증권 Data Engineer 로그 수집 현황 토스증권이 운영하는 서비스와 인프라에서는 매일 수많은 로그들이 생성되고 있고 이를 Elasticsearch 클러스터로 수집하여 로그를 검색하고 분석하고 있습니다 이러한 로그들은 개의 로그 파이프라인을 통해 하루 기준으로 테라 바이트 건의 로그를 인덱싱하고 있는데요 서비스가 커질수록 수집되는 로그는 더욱 늘어나기 때문에 수평적으로 확장하고 안정적으로 운영하기 위해 지속적으로 클러스터의 개선이 필요합니다 실제로 SLASH23 발표를 준비하던 시기에는 피크 시간에 초당 만의 인덱싱과 하루 정도의 로그를 처리하였지만 개월이 지난 지금은 로그가 이상 늘어나서 피크 시간에 초당 이상의 인덱싱 하루 기준으로 건의 로그를 처리하고 있습니다 토스증권 Elasticsearch 클러스터는 온프레미스로 운영하고 있어 클러스터가 커질수록 상면 공간과 관리 부담이 있기 때문에 가능한 효율적으로 구성을 하는 것이 필요한데요 거의 대부분의 로그 검색과 분석은 최근 보름"} -{"url": "https://toss.tech/article/slash23-iOS", "title": "레고처럼 조립하는 토스 앱", "word_count": 1036, "category_scores": {"uiux": 4, "frontend": 2, "design_system": 3, "quality": 2}, "top_keywords": ["example", "모듈을", "feature", "ios", "서비스가", "이렇게", "interface", "microfeature", "microfeatures", "tuist", "testing", "home"], "excerpt": "레고처럼 조립하는 토스 SLASH23 iOS 이준석 송범근 토스 iOS Developer 이게 뭐냐고요 바로 토스 iOS 앱의 코드량 입니다 토스팀은 사용자에게 가치를 전달하기 위해 끊임없이 서비스를 개발해왔어요 지금 토스 안에는 수백 개의 서비스가 들어있습니다 그렇게 성장해오는 동안 토스 iOS 앱도 Swift 줄이 넘는 거대한 프로젝트로 자라났습니다 글을 읽고 계신 iOS 개발자분들에게 질문을 드려볼게요 이렇게 프로젝트가 크고 복잡해지면 해야 할까요 바로 모듈 분리입니다 앱을 하나의 Xcode 프로젝트로 관리하는 대신 여러 개의 작은 모듈로 나눕니다 그리고 모듈 간의 적절한 구조를 설계하는 거죠 코드 베이스가 커지면 모듈 분리도 점점 중요해지죠 그래서 토스 iOS 챕터도 모듈화에 대한 많은 고민을 했는데요 글에서는 저희가 어떻게 슈퍼 토스의 모듈을 관리하고 있는지 살펴볼게요 먼저 기존 토스 앱의 구조를 알아봐야겠죠 기존 토스 앱은 가장 일반적인 계층 구조로 이루어져 있었어요 책임과"} -{"url": "https://toss.tech/article/slash23-security", "title": "금융사 최초의 Zero Trust 아키텍처 도입기", "word_count": 992, "category_scores": {"uiux": 1, "frontend": 0, "design_system": 1, "quality": 1}, "top_keywords": ["device", "application", "iam", "zero", "trust", "네트워크", "management", "access", "ztna", "network", "role", "sase"], "excerpt": "금융사 최초의 Zero Trust 아키텍처 도입기 SLASH23 Security 정연우 토스 Security Engineer 전통적인 환경의 보안 아키텍처는 방화벽과 같은 경계를 기준으로 신뢰와 비신뢰를 나누어서 운영이 되고 있었는데요 신뢰 구간에서의 추가적인 보안 통제가 없으면서 신뢰의 크기가 커진다면 그만큼 보안의 Risk 들도 증가하게 된다는 한계점들이 있었고 다원화 Identity 관리 보안 솔루션 관리 재택근무 환경과 오피스 환경의 이원화된 환경을 관리함으로써 보안 가시성 확보 관리의 어려움 팀원들의 업무의 불편함들을 해결하기 위해 토스에서는 제로트러스트 보안 아키텍처를 도입하게 되었는데요 어떤 과정으로 도입하였고 어떻게 운영 중인지 살펴보려고 합니다 Zero Trust 고정된 네트워크 경계를 방어하는 것에서 사용자 자산 자원 중심의 방어로 변경하는 발전적인 사이버 보안 패러다임입니다 제로 트러스트는 물리적 위치 네트워크 위치 자산 소유권을 기준으로 자산 또는 사용자 계정에 부여된 암묵적인 신뢰는 없다고 가정합니다 Zero Trust 도입하기 위한 고민"} -{"url": "https://toss.tech/article/slash23-server", "title": "토스는 Gateway 이렇게 씁니다", "word_count": 1165, "category_scores": {"uiux": 3, "frontend": 3, "design_system": 2, "quality": 3}, "top_keywords": ["gateway", "있습니다", "정보를", "요청을", "passport", "로직을", "istio", "route", "됩니다", "저희는", "이러한", "있으며"], "excerpt": "토스는 Gateway 이렇게 씁니다 SLASH23 Server 최준우 토스 Server Developer 안녕하세요 토스에서 Gateway 개발하고 있는 서버플랫폼팀 최준우입니다 토스에서는 목적에 맞는 다양한 Gateway 사용하고 있는데요 저는 이번 글에서 이러한 Gateway 아키텍처를 통해 토스가 누리고 있는 장점들과 이를 위해 어떠한 노력을 하고 있는지에 대해 간단히 소개하려고 합니다 Gateway 우선 Gateway 대해 간단히 알아보겠습니다 Gateway 라우팅 프로토콜 변환을 담당하며 마이크로 서비스의 중개자 역할을 하는 서버입니다 이를 통해 서비스는 클라이언트와 독립적으로 확장할 있으며 보안 모니터링을 위한 단일 제어 지점을 제공합니다 Netflix Zuul 통해 알려졌으며 현재는 Saas 플랫폼으로도 사용할 있게 대중화되었습니다 그림을 통해 예를 들어 보겠습니다 서비스가 적고 트래픽이 적다면 클라이언트에서 서비스를 직접 호출하고 각각의 서비스에서 모든 로직을 처리해도 부담이 되지는 않습니다 그러나 스케일이 커지면 공통의 로직을 모든 서버에 적용하고 배포하는 것도 큰일이 됩니다 서버가"} -{"url": "https://toss.tech/article/spark-analyzer", "title": "Spark Job 성능 모니터링과 최적화를 위한 Spark Analyzer 개발기", "word_count": 812, "category_scores": {"uiux": 1, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["spark", "job", "history", "server", "메트릭을", "문제를", "작업이", "dataflint", "있습니다", "파티션", "task", "data"], "excerpt": "Spark Job 성능 모니터링과 최적화를 위한 Spark Analyzer 개발기 Data 김문수 토스 Data Analytics Engineer 안녕하세요 토스 코어 Data Warehouse 팀의 김문수입니다 Spark 작업이 효율적으로 실행되는지 모니터링하기 위해서 Spark Analyzer 만든 경험을 공유하려고 합니다 저희와 비슷한 고민을 가진 분들께 도움이 되길 바랍니다 Spark Job 많아 관리가 어려워지고 어디서부터 성능을 개선해야 할지 막막한 Spark 클러스터 비용이 많이 나오거나 리소스가 부족해서 고통받는 많은 유저들이 만들어 Spark Job 직접 고칠 있게 돕고 싶은 일단 돌아가게 만든 작업들이 모이면 비싸고 느린 데이터 파이프라인이 됩니다 Spark 굉장히 강력한 도구이지만 잘못 사용하면 비효율적으로 동작하여 시스템에 부담을 있습니다 토스코어에서는 하루 평균 이상의 Spark 작업이 매일 또는 특정 주기로 실행되고 있는데요 여러 팀에서 각기 다른 수준과 방식으로 작업을 추가하고 운영하다 보니 효율성이 떨어지는 작업이 종종 추가되기도 합니다 그리고"} -{"url": "https://toss.tech/article/tds-color-system-update", "title": "달리는 기차 바퀴 칠하기: 7년만의 컬러 시스템 업데이트", "word_count": 2349, "category_scores": {"uiux": 6, "frontend": 6, "design_system": 8, "quality": 2}, "top_keywords": ["디자인", "tds", "시스템을", "token", "color", "blue", "있었어요", "동일한", "다양한", "가능한", "했어요", "있어요"], "excerpt": "달리는 기차 바퀴 칠하기 년만의 컬러 시스템 업데이트 윤민석 권윤 안녕하세요 토스 디자인 플랫폼팀 Engineer 윤민석 Platform Designer 권윤입니다 디자인 시스템은 변화하는 서비스 속에서도 일관성과 확장성을 지켜주는 보이지 않는 인프라예요 토스팀도 미세한 균형 속에서 넓은 세상으로 확장할 준비를 해왔는데요 여정의 출발점이자 변화를 위한 기반이 것이 바로 컬러 시스템 개편 이었어요 이번 글에서는 TDS Toss Design System 핵심 하나인 컬러 시스템을 만에 전면 개편하며 마주했던 문제들과 이를 해결하기 위해 토큰 시스템을 처음부터 다시 만들어간 과정을 공유하려고 해요 년간 외면해온 문제들 TDS 컬러 시스템은 디자인 시스템이 처음 만들어졌을 때부터 변화 없이 유지되어 왔어요 동안 컬러 토큰을 사용하면서 여러 문제를 마주했는데요 특히 컬러 팔레트에서 발견한 문제가 많았어요 번째 문제는 컬러끼리 명도가 달랐다는 거예요 같은 인데 Grey Blue Red 명도가 달라서 리스트에서 같이 썼을"} -{"url": "https://toss.tech/article/toss-frontend-ai-docs", "title": "토스 프론트엔드 개발자들이 더 이상 문서를 찾지 않는 이유", "word_count": 637, "category_scores": {"uiux": 1, "frontend": 2, "design_system": 0, "quality": 1}, "top_keywords": ["있어요", "문서를", "정보를", "프론트엔드", "개발자들이", "문서의", "지식을", "자연스럽게", "지식이", "문서가", "동료에게", "개발자의"], "excerpt": "토스 프론트엔드 개발자들이 이상 문서를 찾지 않는 이유 Frontend Technical Writing 한주연 토스 Technical Writer 새로운 기능을 개발하다 보면 이런 순간이 찾아와요 모니터에는 수십 개의 탭이 열려있고 내부 도구 사용법을 찾아보려는데 문서가 어디 있는지조차 기억나지 않죠 결국 동료에게 메시지를 보내고 답장을 기다리면서 개발 흐름이 끊기고 맙니다 익숙한 상황이죠 토스 프론트엔드 챕터도 비슷한 문제를 경험하고 있었는데요 문제를 조금 다르게 풀어 봤습니다 문서를 써서 방문하게 하는 대신 문서가 직접 찾아가는 방식으로요 문서의 경로의존성 깨기 문서의 경로의존성은 사용자가 원하는 정보를 찾기 위해 정해진 경로를 따라가야 하는 제약을 뜻해요 기존의 기술 문서는 생산자 입장에서 구조화되어 있어서 사용자 입장에서 원하는 정보를 찾으려면 여러 번의 탐색 혹은 검색이 필요하죠 이런 문서의 경로의존성을 깨기 위해 먼저 개발자들이 어떻게 일하는지 관찰하고 인터뷰했어요 개발자들이 지식을 얻기 위해 주로 사용하는"} -{"url": "https://toss.tech/article/toss-next-ml-challenge", "title": "토스 Next ML Challenge - 광고 클릭 예측(PCTR) ML 경진대회 출제 후기", "word_count": 1022, "category_scores": {"uiux": 1, "frontend": 0, "design_system": 1, "quality": 0}, "top_keywords": ["리더보드", "next", "challenge", "데이터를", "feature", "데이터", "engineering", "toss", "문제를", "모델을", "있었습니다", "sequence"], "excerpt": "토스 Next Challenge 광고 클릭 예측 PCTR 경진대회 출제 후기 박재휘 안녕하세요 토스 광고 플랫폼에서 Engineer 일하고 있는 박재휘입니다 지난 월부터 월까지 토스와 데이콘이 함께 Toss Next Challenge 개최했습니다 저는 이번 대회에 출제 위원으로 참여했는데요 문제를 기획하고 준비하면서 느꼈던 경험과 참가자분들의 신선한 문제 해결 방식을 공유하고 싶어 이렇게 글을 쓰게 되었습니다 Toss Next Challenge 열었나요 토스는 기술을 통해 예측의 정확도와 의사결정의 수준을 높여가고 있습니다 특히 광고 도메인에서는 머신러닝이 핵심 기술로 자리잡고 있죠 이런 배경에서 토스는 엔지니어들이 현업에서 풀고 있는 실제 기술 문제를 공개하고 우수 인재를 발굴하기 위해 Toss Next Challenge 개최했습니다 이번 챌린지의 주제는 광고 클릭 예측 Click-Through Rate CTR 모델 개발 이었습니다 실제 토스 광고 데이터를 활용해 가상의 사용자가 어떤 광고를 클릭할 것인지에 대한 확률을 빠르고 정확하게 예상하는 알고리즘을"} -{"url": "https://toss.tech/article/tosspayments-mcp", "title": "토스페이먼츠 결제 시스템 연동을 돕는 MCP 서버 구현기", "word_count": 2342, "category_scores": {"uiux": 2, "frontend": 5, "design_system": 1, "quality": 1}, "top_keywords": ["this", "const", "mcp", "string", "return", "chunk", "number", "문서를", "토스페이먼츠", "llm", "private", "chunks"], "excerpt": "토스페이먼츠 결제 시스템 연동을 돕는 MCP 서버 구현기 MCP 김용성 토스페이먼츠 Node Developer 안녕하세요 토스페이먼츠 김용성입니다 지난주 토스페이먼츠에서 업계 최초로 MCP 소개 하면서 많은 분들이 관심가져 주셨는데요 글을 통해 MCP 서버 구현 과정과 안에서 얻은 러닝을 공유하고자 해요 요약 토스페이먼츠의 API 연동을 조금이라도 쉽고 빠르게 하기 위해 기반 코딩 도구 활용시 도움이 있도록 토스페이먼츠 연동 MCP 서버를 제공합니다 자세한 내용은 토스페이먼츠 연동 가이드 참고하세요 토스페이먼츠 연동 MCP 서버를 활용하면 기반 코딩 도구를 단독으로 사용할 때보다 연동 코드 생성 정확도가 높아집니다 토스페이먼츠 연동 MCP 서버는 길드 guild 작은 논의에서 시작된 아이디어로부터 구현되어 작성 시점에 모델에게 제공되는 지원되는 맥락 정보가 부족할 있습니다 토스페이먼츠의 API 연동 경험이 나아질 있도록 계속해서 개선해 나가 볼게요 글에는 코드가 많이 첨부되어 있어 내용이 길어요 자세한 내용이 궁금하신"} -{"url": "https://toss.tech/article/undercover-silo-3", "title": "“토스 참 쪼잔하다”는 유저 말에 1억을 태운 이유 | 언더커버 사일로 비하인드 2화: 만보기 사일로", "word_count": 1097, "category_scores": {"uiux": 2, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["광고를", "만보기", "사일로", "만보기는", "그래서", "사용자를", "서비스를", "저희가", "만보기를", "사용자에게", "언더커버", "비하인드"], "excerpt": "토스 쪼잔하다 유저 말에 억을 태운 이유 언더커버 사일로 비하인드 만보기 사일로 언더커버사일로 박세진 김태성 언더커버사일로 비하인드 토크 재미있게 보셨나요 토스 쪼잔하다 유저의 한마디에 억을 태웠던 만보기 사일로의 코멘터리 돌아왔습니다 지난 편에서 가장 뜨거운 반응을 얻었던 바로 이야기 지금부터 깊게 파고들어 보겠습니다 토스 만보기 전국민의 사랑을 받던 서비스가 겪은 가장 위기 만보기는 년에 시작됐어요 당시 토스의 가장 숙제는 MAU 월간 활성 사용자를 늘리는 거였죠 지금은 전연령이 쓰는 토스가 되었지만 당시에는 사이의 사용자가 상대적으로 적었어요 해당 연령대의 데이터를 살펴보니 그분들이 만보기 앱을 많이 사용하시더라고요 이거다 싶었죠 토스가 만보기 서비스를 오픈한 유저 수를 늘리기 위한 여러 차례의 실험이 진행됐어요 처음엔 친구 추가 기능으로 바이럴을 일으켜 사용자를 키웠어요 근데 바이럴 효과가 떨어지면서 올리브영 가면 줄게 같은 개인 미션을 만들었죠 건강도 챙겨드리고 싶었고요 그런데"} -{"url": "https://toss.tech/article/undercover-silo-4", "title": "“왜 아무도 에러 메시지를 읽지 않을까?” | 언더커버 사일로 비하인드 3화: 페이스페이 사일로", "word_count": 768, "category_scores": {"uiux": 4, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["페이스페이", "그래서", "저희가", "저희는", "사일로", "새로운", "언더커버", "했습니다", "얼마나", "어색함", "하지만", "얼굴을"], "excerpt": "아무도 에러 메시지를 읽지 않을까 언더커버 사일로 비하인드 페이스페이 사일로 언더커버사일로 박세진 김태성 체험형 과제 페이스페이 언더커버 사일로에서 페이스페이 편은 처음으로 단일 과제 그리고 체험형 문제가 출제된 회차였어요 이전의 인플로우나 만보기 사일로는 특정 서비스나 기능을 맞히는 문제였던 반면 이번에는 챌린저들이 직접 만들어야 했죠 그래서 챌린저 일부는 페이스페이를 써봤음에도 불구하고 처음부터 가설을 세워서 접근해야만 했습니다 과정을 보시는 시청자분들도 나라면 어떻게 만들었을까 함께 고민해보시면 우리가 일상에서 무심코 쓰는 얼마나 깊은 고민이 담겨 있는지 발견하는 재미를 느끼실 있을 겁니다 페이스페이는 많은 고객분들이 낯설어하는 결제 방식입니다 유저분들은 개인정보 제공에 민감하게 반응할 수밖에 없고 솔직히 서비스를 처음 제공한다고 했을 내에서도 의견이 정말 많았습니다 번째 질문은 이거 정말 편리한 수단 맞아 였고 번째는 많은 카페나 식당에 어떻게 디바이스를 보급할 건데 예산이 얼마나 드는 거야 같은 내부적인"} -{"url": "https://toss.tech/article/undercover-silo-5", "title": "성공이 가장 큰 위기일 때, 문제 없는 서비스 성장시키기 | 언더커버 사일로 비하인드 4화: 고양이 사일로", "word_count": 780, "category_scores": {"uiux": 2, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["고양이", "사일로", "키우기", "저희는", "저희의", "방문하는", "아이템을", "언더커버", "문제가", "리텐션을", "사용자가", "사용자"], "excerpt": "성공이 가장 위기일 문제 없는 서비스 성장시키기 언더커버 사일로 비하인드 고양이 사일로 언더커버사일로 김서연 김태성 지난 만보기 사일로 편의 치열한 생존기 재미있게 보셨나요 존폐의 위기에서 서비스를 구해야 했던 만보기와 달리 이번 고양이 사일로 편에서는 이미 많은 사랑을 받고 있어 문제가 없어 보이는 서비스의 성장 이야기를 다룹니다 사용자 특성을 파악해 리텐션을 끌어올리기 위한 가설과 설계해야 했던 이번 과제 겉보기엔 건강하기만 고양이 키우기 저희는 어떻게 성장시켰을까요 다른 차원의 고민이 담긴 비하인드 스토리를 지금부터 시작합니다 고양이 어떻게 토스의 무기가 되었나 본격적인 이야기에 앞서 많은 분들이 궁금해하시는 질문부터 답해 드릴게요 대체 토스는 고양이를 키우게 걸까요 당시 저희의 목표는 토스페이 서비스의 결제 전환율을 높이는 것이었습니다 하지만 사용자가 토스 앱에서 결제까지 이어지기에는 여러 허들이 있었죠 그래서 처음에는 실험 으로 사용자의 참여 인게이지먼트 높이기 위한 여러 방안을"} -{"url": "https://toss.tech/article/undercover-silo-6", "title": "1,000만 명이 들어와도 999만 명이 나가는 문제, 어떻게 해결했을까 | 언더커버 사일로 비하인드 5화: 계좌 사일로", "word_count": 1082, "category_scores": {"uiux": 3, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["저희는", "사일로", "저희가", "언더커버", "마지막", "있었습니다", "수많은", "정답을", "문제를", "계좌를", "전환율이", "비하인드"], "excerpt": "명이 들어와도 명이 나가는 문제 어떻게 해결했을까 언더커버 사일로 비하인드 계좌 사일로 언더커버사일로 배효진 김태성 챌린저스 여러분 언더커버 사일로의 마지막 여정 계좌 사일로 편에 오신 것을 환영합니다 이전의 가지 사일로가 모든 서비스가 겪는 보편적인 성장통에 대한 이야기였다면 이번 편은 토스는 금융 앱이다 라는 본질적인 질문에서 출발합니다 수많은 규제와 제약 속에서 토스마저 아직 정답을 찾지 못한 문제를 함께 풀어보는 시간이 텐데요 토스의 가장 근본적인 고민이 담긴 마지막 과제 비하인드 스토리를 지금부터 시작합니다 가장 토스다운 문제 가장 어려웠던 출제 언더커버사일로의 마지막 화는 계좌 사일로 였습니다 기획 단계부터 토스는 금융 앱이니까 금융의 본질을 다루는 사일로 하나는 넣어야겠다고 생각했어요 그런데 문제가 있었습니다 금융 기능은 관련된 사전 지식이 너무 많이 필요했어요 화였던 인플로우 사일로는 신규 유저 확보 라는 보편적인 과제였고 만보기는 비용과 지속가능성 페이스페이는 사용성"} -{"url": "https://toss.tech/article/ux-research-partner", "title": "UX 리서처, 신입은 어디에서 경력을 쌓나요?", "word_count": 848, "category_scores": {"uiux": 3, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["리서치", "리서치를", "리서처", "되었어요", "문제를", "과정을", "분들을", "과정에서", "있고요", "내용을", "지원자분들의", "research"], "excerpt": "리서처 신입은 어디에서 경력을 쌓나요 Research 박지희 토스 Researcher 배경 유저 리서치 User Research Team 에서는 작년 하반기에 처음으로 신입 리서처를 채용했어요 리서치 파트너 Research Partner 라는 이름으로요 리서처 Researcher 되기를 희망하는 분들을 만나보면 학부에서 리서치를 배웠지만 실제 사용자를 만나기 어려운 실무 프로젝트에서 리서치를 진행할 기회가 적다는 점을 많이 이야기해 주셨어요 그리고 부분은 저희 리서치팀도 크게 공감이 되었어요 유저 리서치 팀에서는 신입 리서처가 되기를 희망하는 분들의 어려움을 조금이라도 개선하여 리서치 생태계의 성장과 확장을 바라는 마음이 있었어요 이에 새로운 채용 형식을 설계하여 역량 있는 신입 분들을 모셨는데요 과정에 대해 상세하게 설명드릴게요 문제 신입 리서처들이 겪고 있는 어려움들에 대해 접하고보니 이런 부분들을 어떻게 해결할수 있을까 고민했어요 팀에서 함께 논의하며 경험이 없어도 리서처로서 잠재력이 있다면 금방 성장할 있지 않을까 실무 경험이 필요한 걸까"} -{"url": "https://toss.tech/article/ux-research-platform", "title": "효율적인 유저 리서치 환경을 만드는 리서치 플랫폼 팀, 들어보셨나요?", "word_count": 1257, "category_scores": {"uiux": 4, "frontend": 1, "design_system": 0, "quality": 1}, "top_keywords": ["리서치", "플랫폼", "제품을", "만드는", "사용자를", "있어요", "리서치를", "프로세스를", "사용자", "같아요", "효율적인", "의견을"], "excerpt": "효율적인 유저 리서치 환경을 만드는 리서치 플랫폼 들어보셨나요 Research 최정은 토스 Research Operation Manager 지난 심플리시티 에서 많은 분이 리서치 특히 사용자를 어떻게 만나는지 대해 궁금해하셨더라고요 Simplicity23 무엇이든 물어보세요 토스팀에서 팀원들이 사용자를 쉽고 빠르고 편하게 만날 있도록 고군분투하고 있는 리서치 플랫폼 대해 소개해 보려고 해요 리서치 플랫폼 팀은 어떤 일을 하나요 효율적인 리서치 환경을 구축하는 일을 하고 있어요 리서처뿐만 아니라 토스에서 제품을 만드는 모든 팀원들이 리서치를 보다 효율적으로 수행할 있도록 프로세스를 구축하고 필요한 리서치 교육이나 프로그램 리서치 도구 개발 등을 맡고 있어요 리서처 리서치 오퍼레이터 그리고 여러 명의 어시스턴트 분들로 구성된 팀이에요 빌딩 당시 작성했던 목표 일부 리서치 오퍼레이터는 어떤 역할인가요 리서치 오퍼레이팅 업무 라고 하면 리서치 과정을 단순 운영하는 직군이라고 생각하기 쉬워요 리서처 대신 리크루팅부터 인터뷰 운영 사례비 지급에"} -{"url": "https://toss.tech/article/uxr-survival", "title": "누구나 리서치 하는 시대, UX리서처의 생존법", "word_count": 1672, "category_scores": {"uiux": 5, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["아니라", "제품이", "우리가", "있어요", "그래서", "사용자의", "중요한", "토스증권", "문제를", "가치를", "정보를", "있을까"], "excerpt": "누구나 리서치 하는 시대 리서처의 생존법 이예슬 토스증권 Researcher 누구나 쉽게 유저를 만나고 인터뷰까지 대신해주는 시대에 리서처는 있을까 이미 해외 뿐만 아니라 국내에도 모더레이터가 유저와 공감하며 인터뷰를 진행하거나 대규모 글로벌 리서치를 자동으로 수행하는 도구들이 등장하고 있어요 게다가 synthetic user 합성 유저 같은 개념이 보편화되면서 프롬프트 몇자만 적으면 실제 유저를 리크루팅 하지 않아도 정량조사 심지어 그룹 인터뷰까지 진행할 있는 세상이 되었어요 실제로 최근 보고서에 따르면 조직 내에서 리서처가 아닌 디자이너 기획자 들이 직접 리서치를 수행하는 회사의 비율이 달한다고 해요 리서치는 이상 특수한 역할 아닌 모두가 쓰는 도구 것이죠 그렇다면 이런 변화 속에서 리서처의 존재 이유는 무엇일까요 오늘은 질문에 대한 생각을 제품 개발의 단계로 나눠 이야기해보려고 해요 아이디어 단계 퍼즐의 테두리부터 맞추기 여러분도 퍼즐을 맞춰보신 있으시죠 퍼즐일수록 막막하지만 테두리 조각을 맞추면 전체"} -{"url": "https://toss.tech/article/uxresearch-method", "title": "토스 UX 리서처는 어떤 방법론을 사용할까?", "word_count": 1137, "category_scores": {"uiux": 4, "frontend": 1, "design_system": 1, "quality": 1}, "top_keywords": ["리서치", "리서치를", "러닝쉐어", "있어요", "방법론을", "하지만", "리서처는", "어떻게", "리서처가", "있다면", "다양한", "처음부터"], "excerpt": "토스 리서처는 어떤 방법론을 사용할까 Research 김서연 토스증권 Researcher 리서치에 관심 있는 분들과 커피챗을 하면 듣게 되는 공통적인 질문이 있어요 토스 리서처는 어떤 리서치 방법론을 쓰나요 토스 리서처는 리서치를 처음부터 끝까지 혼자서 하는 구조인가요 질문을 들었을 잘라 답을 드리기 어려우면서도 언젠간 시원하게 풀어보고 싶다는 생각이 있었어요 글을 통해 토스의 리서처는 어떤 방법론을 쓰는지 리서치를 처음부터 끝까지 혼자서 도대체 어떻게 해나가는지 알려드릴게요 토스 리서처가 쓰는 리서치 방법론 글을 읽고 계신 독자님은 어떤 방법론을 알고 계신가요 아마도 조사방법으로는 IDI In-Depth Interview FGI Focused Group Interview Usability Testing 혹은 Diary Study 많이 언급되는 같고 분석 방법론으로는 Affinity Diagram 이나 Persona 들어보셨을 거에요 User Journey Map 자주 언급되는 같고요 중에서 토스가 선택한 방법론은 바로 러닝쉐어에요 사실 리서치 방법론은 취사선택하는 도구 라기보다는 토대 가깝다고 생각해요"} -{"url": "https://toss.tech/article/uxresearcher-meets-investor", "title": "증권 UX 리서처가 투자의 비결을 알게 되기까지", "word_count": 1437, "category_scores": {"uiux": 4, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["종목을", "있어요", "관심종목에", "투자를", "투자자의", "매수를", "루틴을", "떨어지길", "같아요", "어떻게", "종목이", "주식을"], "excerpt": "증권 리서처가 투자의 비결을 알게 되기까지 Research 리서치 김서연 토스증권 Researcher 토스증권에서 리서치의 시작 주식 하는 사람 이라면 어떤 이미지가 떠오르세요 저는 전만 해도 주식 투자하는 사람이라고 하면 일단 나랑은 다른 세계 사람 이라고 생각했던 같아요 뭔가 주식을 잘한다는 복잡한 경제 용어들도 알고 글로벌 뉴스나 정치 세상 돌아가는 일에 빠삭하고 그런 이미지였거든요 쉽게 보고 뛰어들었다가는 어렵게 돈을 잃기가 쉽다는 조언도 주변에서 들었던 같고요 모르고 시작하면 손해 보기 쉬운데 그렇다고 알아보려니 어디서부터 어떻게 시작해야 할지 몰라 나랑 다른 세계 이야기 치부하는게 마음 편했던 같아요 토스증권팀에서는 서비스 출시를 준비하고 있었어요 저처럼 주식을 어려워하던 사람도 쉽게 이용할 있는 증권 서비스를 만들겠다는 목표를 가지고요 그런 목표를 가지고 전인 토스증권 서비스가 오픈됐어요 바람대로 토스증권에서 투자를 시작한 분들도 계셨지만 여전히 어려워하는 분들도 계셨어요 계좌만 만들고 투자를"} -{"url": "https://toss.tech/article/vulnerability-analysis-automation-1", "title": "LLM을 이용한 서비스 취약점 분석 자동화 #1", "word_count": 1075, "category_scores": {"uiux": 0, "frontend": 1, "design_system": 1, "quality": 1}, "top_keywords": ["llm", "취약점", "있었어요", "model", "소스코드를", "취약점을", "mcp", "codeql", "gpt-oss", "qwen3", "실제로", "sast"], "excerpt": "LLM 이용한 서비스 취약점 분석 자동화 표상영 토스 Security Researcher 안녕하세요 토스 Security Researcher 표상영입니다 요즘 LLM 정말 다양한 분야에서 활용되고 있죠 토스에서도 여러 업무에 LLM 접목하며 효율성을 높이고 있는데요 그중에서도 LLM 이용해 서비스 취약점 분석을 자동화한 경험 공유해 보려고 합니다 이번 글에서는 프로젝트를 진행하면서 마주했던 문제점과 그에 대한 해결책을 소개해 드리고 이어서 편에서 실제로 어떻게 구현했는지를 공유할 예정이에요 Google Project Naptime 구글에는 재미있는 프로젝트가 하나 있습니다 LLM 이용해 취약점 분석을 자동화하고 낮잠 시간을 만들자 라는 목적의 Naptime 낮잠 프로젝트가 바로 그것인데요 https googleprojectzero blogspot com project-naptime html Naptime architecture 당시 LLM 자율적으로 소스코드를 분석하고 취약점을 찾는다 라는 것은 분석가들 머릿속에만 있는 상상 속의 무언가 였습니다 또한 그런 생각을 갖고있던 찰나 구체적인 아이디어와 함께 실제로 유의미한 결과를 도출한 Project Naptime"} -{"url": "https://toss.tech/article/what-is-research-ops", "title": "UX리서치 조직에서 리옵매란?", "word_count": 1092, "category_scores": {"uiux": 3, "frontend": 0, "design_system": 0, "quality": 0}, "top_keywords": ["리서치", "리크루팅", "인터뷰", "있어요", "효율화", "필요한", "스크리닝", "프로세스를", "같아요", "지금은", "시스템을", "만드는"], "excerpt": "리서치 조직에서 리옵매란 Research 도승희 토스 Research Platform Team Leader 지난 아티클 통해 토스 리서치 플랫폼팀 이하 리플팀 만들어진 계기와 팀이 하는 일을 소개 드렸는데요 리플팀은 토스팀 누구나 리서치를 효율적으로 있게끔 시스템을 구축하고 프로세스를 만드는 팀으로 유저 리크루팅 인터뷰 운영 효율화 리서치 도구 개발 리서치 교육 리서치 데이터 관리 등을 맡고 있어요 올해 부쩍 대기업과 스타트업을 비롯 여러 조직에서 토스팀 리서치 운영 효율화 방식에 관심을 갖고 미팅콜을 요청해주셨는데요 업계에 리서치 시장이 성숙할수록 리서치 운영 효율화에 대한 니즈가 커질거라고 했던 피부로 닿는 느낌이에요 지난 아티클 효율적인 유저 리서치 환경을 만드는 리서치 플랫폼 들어보셨나요 리플팀에서 토스팀의 리서치 운영 효율화에 집중하고 있는 리서치 옵스 매니저 이하 리옵매 리서치를 수행하는 필요한 운영 전반을 전담하고 있어요 유저 인터뷰 예로 들자면 리서치 목적에 맞는 사용자를 대상으로"} diff --git a/docs/research/toss/toss-uiux-fe-ds-analysis-summary.json b/docs/research/toss/toss-uiux-fe-ds-analysis-summary.json deleted file mode 100644 index 03ab719..0000000 --- a/docs/research/toss/toss-uiux-fe-ds-analysis-summary.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "generated_at_utc": "2026-02-11T08:49:17.612031+00:00", - "article_count": 100, - "category_totals": { - "uiux": 226, - "frontend": 192, - "design_system": 66, - "quality": 118 - }, - "avg_word_count": 1324.83, - "max_word_count": 3549, - "min_word_count": 131 -} diff --git a/docs/research/toss/toss-uiux-fe-ds-article-corpus.md b/docs/research/toss/toss-uiux-fe-ds-article-corpus.md deleted file mode 100644 index cc4f7e8..0000000 --- a/docs/research/toss/toss-uiux-fe-ds-article-corpus.md +++ /dev/null @@ -1,114 +0,0 @@ -# Toss UIUX/Frontend/Design System Corpus (100) - -- generated_at: 2026-02-11T17:43:12.441410 -- source: https://toss.tech article pages crawl -- discovered_articles: 106 -- selected: 100 (keyword-matched: 78) - -## Selection Rule - -- priority: keyword match on frontend/uiux/design-system -- fallback: fill to 100 with adjacent product-engineering posts - -## Articles - -1. [디자인 시스템 다시 생각해보기](https://toss.tech/article/rethinking-design-system) - frontend, uiux, design-system -2. [달리는 기차 바퀴 칠하기: 7년만의 컬러 시스템 업데이트](https://toss.tech/article/tds-color-system-update) - uiux, design-system -3. [드래그 앤 드롭은 사실 편한 UX가 아니다?](https://toss.tech/article/27752) - frontend, uiux -4. [토스가 꿈꾸는 React Native 기술의 미래](https://toss.tech/article/react-native-2024) - frontend, uiux -5. [Simplicity 4 : 한 번쯤 이상을 꿈꿔본 모두에게](https://toss.tech/article/35921) - uiux -6. [React Native에서 타입 안전한 파일 기반 라우팅 구현하기](https://toss.tech/article/rn-toss-bedrock) - frontend, uiux -7. [리서치를 하고 싶어하는 사람을 리서치하세요](https://toss.tech/article/1st_ux_research) - uiux -8. [UX 리서처, 신입은 어디에서 경력을 쌓나요?](https://toss.tech/article/ux-research-partner) - uiux -9. [효율적인 유저 리서치 환경을 만드는 리서치 플랫폼 팀, 들어보셨나요?](https://toss.tech/article/ux-research-platform) - uiux -10. [누구나 리서치 하는 시대, UX리서처의 생존법](https://toss.tech/article/uxr-survival) - uiux -11. [토스 UX 리서처는 어떤 방법론을 사용할까?](https://toss.tech/article/uxresearch-method) - uiux -12. [UX리서치 조직에서 리옵매란?](https://toss.tech/article/what-is-research-ops) - uiux -13. [프론트엔드 로깅 신경 안 쓰기](https://toss.tech/article/engineering-note-5) - frontend, uiux, design-system -14. [패키지 매니저의 과거, 토스의 선택, 그리고 미래](https://toss.tech/article/lightning-talks-package-manager) - frontend, uiux -15. [node_modules로부터 우리를 구원해 줄 Yarn Berry](https://toss.tech/article/node-modules-and-yarn-berry) - frontend, uiux -16. [Node.js url.parse() 취약점 컨트리뷰션](https://toss.tech/article/nodejs-security-contribution) - frontend, uiux -17. [100년 가는 프론트엔드 코드, SDK](https://toss.tech/article/payments-legacy-3) - frontend, uiux -18. [토스 프론트엔드 개발자들이 더 이상 문서를 찾지 않는 이유](https://toss.tech/article/toss-frontend-ai-docs) - frontend, uiux -19. [디자이너의, 디자이너에 의한, 디자이너를 위한 채용 설계하기](https://toss.tech/article/2022-product-designer-challenge) - uiux -20. [세션을 대표하는 키비주얼 그래픽 | Simplicity 4 제작기 #4](https://toss.tech/article/36945) - uiux -21. [마케팅 문구 클릭률을 올리는 6가지 원칙](https://toss.tech/article/44737) - uiux -22. [마케팅 문구 클릭률을 올리는 6가지 원칙](https://toss.tech/article/Marketing_Writing) - uiux -23. [휴리봇 이야기 #2: AI가 사람처럼 말하게 만드는 5가지 프롬프트](https://toss.tech/article/ai-prompting) - uiux -24. [투자를 모르는데 어떻게 증권 UX 리서처를 해요?](https://toss.tech/article/new-uxresearch-invest) - uiux -25. [CocoaPods 없이 React Native 개발하기](https://toss.tech/article/react-native-without-cocoapods) - frontend -26. [휴리봇 이야기 #1: 토스는 AI 봇에게 사용자 인터뷰를 한다](https://toss.tech/article/research-platform-ai) - uiux -27. [업무 효율화, 작은 단계부터 다시 보기](https://toss.tech/article/research_process) - uiux -28. [증권 UX 리서처가 투자의 비결을 알게 되기까지](https://toss.tech/article/uxresearcher-meets-investor) - uiux -29. [프론트엔드 배포 시스템의 진화 (1) - 결제 SDK에 카나리 배포 적용하기](https://toss.tech/article/engineering-note-9) - frontend, uiux -30. [토스의 디자인 편집기 ‘데우스’, 이렇게 만들었어요! | EP.11](https://toss.tech/article/firesidechat_frontend_11) - frontend, uiux -31. [환경 고민없이 개발하기](https://toss.tech/article/isomorphic-javascript) - frontend, uiux -32. [뒤에 개발자 있어요 | Simplicity 4 제작기 #2](https://toss.tech/article/simplicity4-frontend-engineering) - frontend, uiux -33. [토스는 Gateway 이렇게 씁니다](https://toss.tech/article/slash23-server) - frontend, uiux -34. [LLM을 이용한 서비스 취약점 분석 자동화 #1](https://toss.tech/article/vulnerability-analysis-automation-1) - uiux, design-system -35. [SLASH 23](https://toss.tech/article/27052) - uiux -36. [세금 환급 자동화 : AI-driven UI 테스트 자동화 일지](https://toss.tech/article/ai-driven-ui-test-automation) - uiux -37. [무엇이든 물어보세요 (feat. 테스트 코드, ESLint Rule) | EP.10 캠프파이어 특집 하편](https://toss.tech/article/firesidechat_frontend_10a) - frontend -38. [인터랙션으로 만드는 몰입 경험 | Simplicity 4 제작기 #3](https://toss.tech/article/interaction_simplicity) - uiux -39. [AI 아바타가 발표하는 온라인 컨퍼런스 | Simplicity 4 제작기 #1](https://toss.tech/article/simplicity_behind) - uiux -40. [성공이 가장 큰 위기일 때, 문제 없는 서비스 성장시키기 | 언더커버 사일로 비하인드 4화: 고양이 사일로](https://toss.tech/article/undercover-silo-5) - uiux -41. [캐시를 적용하기 까지의 험난한 길 (TPS 1만 안정적으로 서비스하기)](https://toss.tech/article/34481) - uiux -42. [아름답고 이해하기 쉬운 세션 자료 만들기 | Simplicity 4 제작기 #5](https://toss.tech/article/37325) - uiux -43. [토스페이먼츠 결제 시스템 연동을 돕는 MCP 서버 구현기](https://toss.tech/article/37777) - uiux -44. [토스의 새로운 얼굴 만들기](https://toss.tech/article/44291) - uiux -45. [토스증권의 수 천개 실시간 데이터 파이프라인 운영방법 #2: MSA 환경 Observability 높이기](https://toss.tech/article/MSA-observability) - frontend -46. [사업자 데이터 리터러시 높이기: BC Monthly Report 발행기](https://toss.tech/article/business-customer-data) - uiux -47. [대규모 CDC Pipeline 운영을 위한 Debezium 개선 여정](https://toss.tech/article/cdc_pipeline) - uiux -48. [입수는 Datalake로! (feat. Iceberg)](https://toss.tech/article/datalake-iceberg) - uiux -49. [null 리턴은 왜 나쁠까?](https://toss.tech/article/engineering-note-2) - uiux -50. [Feign 코드 분석과 서버 성능 개선](https://toss.tech/article/engineering-note-3) - frontend -51. [인자가 많은 메서드는 왜 나쁠까?](https://toss.tech/article/engineering-note-4) - uiux -52. [Spring JDBC 성능 문제, 네트워크 분석으로 파악하기](https://toss.tech/article/engineering-note-7) - frontend -53. [무엇이든 물어보세요 (feat. 프론트엔드 코드, 디렉토리 관리) | EP.10 캠프파이어 특집 상편](https://toss.tech/article/firesidechat_frontend_10) - frontend -54. [코드 리뷰할 시간이 어딨어요? 모닥불 | EP.12](https://toss.tech/article/firesidechat_frontend_12) - frontend -55. [토스 개발자는 개발만 잘해도 될까 | EP.5 모닥불](https://toss.tech/article/firesidechat_frontend_5) - frontend -56. [토스 프론트엔드에 이력서 없이 리포지토리 링크로 지원하세요 (~5/31)](https://toss.tech/article/frontend-apply-without-resume) - frontend -57. [놀러오세요! 프론트엔드 다이빙 클럽](https://toss.tech/article/frontend-diving-club) - frontend -58. [토스인컴 세금 환급 서비스 : 빠른 속도에서 품질을 지키기 위한 E2E 자동화 여정](https://toss.tech/article/income-qa-e2e-automation) - uiux -59. [토스인컴 QA Platform: ‘누구나 테스트할 수 있는’ 도구의 시작](https://toss.tech/article/income-qa-platform) - uiux -60. [토스증권 Apache Kafka 데이터센터 이중화 구성 #2: 데이터 미러링](https://toss.tech/article/kafka-distribution-2) - uiux -61. [ksqlDB 실시간 Join으로 뉴스 추천 만들기](https://toss.tech/article/ksqldb-realtime-data-2) - uiux -62. [토스의 AI 기술력, 세계 최고 권위 NeurIPS 2025에서 인정받다: FedLPA 연구](https://toss.tech/article/neurIPS-FedLPA) - uiux -63. [우리는 어떻게 해외주식 서비스 안정화를 이뤘는가](https://toss.tech/article/overseas-securities-server) - uiux -64. [경계 보안부터 제로트러스트 보안까지, 고도화 여정](https://toss.tech/article/payments-legacy-10) - uiux -65. [토스페이먼츠의 Open API 생태계](https://toss.tech/article/payments-legacy-4) - uiux -66. [레거시 결제 원장을 확장 가능한 시스템으로](https://toss.tech/article/payments-legacy-5) - uiux -67. [토스의 새로운 얼굴 만들기](https://toss.tech/article/persona) - uiux -68. [달리는 기차의 바퀴 교체하기 2. Restructuring](https://toss.tech/article/restructuring) - frontend -69. [달리는 기차의 바퀴 교체하기 1. Planning](https://toss.tech/article/restructuring-planning) - frontend -70. [고성능 GPU 클러스터 도입기 #1: 요리하라고 해서 왔는데 프라이팬이 없어요](https://toss.tech/article/securities_llm_1) - uiux -71. [레고처럼 조립하는 토스 앱](https://toss.tech/article/slash23-iOS) - uiux -72. [금융사 최초의 Zero Trust 아키텍처 도입기](https://toss.tech/article/slash23-security) - uiux -73. [Spark Job 성능 모니터링과 최적화를 위한 Spark Analyzer 개발기](https://toss.tech/article/spark-analyzer) - uiux -74. [토스 Next ML Challenge - 광고 클릭 예측(PCTR) ML 경진대회 출제 후기](https://toss.tech/article/toss-next-ml-challenge) - uiux -75. [토스페이먼츠 결제 시스템 연동을 돕는 MCP 서버 구현기](https://toss.tech/article/tosspayments-mcp) - uiux -76. [“토스 참 쪼잔하다”는 유저 말에 1억을 태운 이유 | 언더커버 사일로 비하인드 2화: 만보기 사일로](https://toss.tech/article/undercover-silo-3) - uiux -77. [“왜 아무도 에러 메시지를 읽지 않을까?” | 언더커버 사일로 비하인드 3화: 페이스페이 사일로](https://toss.tech/article/undercover-silo-4) - uiux -78. [1,000만 명이 들어와도 999만 명이 나가는 문제, 어떻게 해결했을까 | 언더커버 사일로 비하인드 5화: 계좌 사일로](https://toss.tech/article/undercover-silo-6) - uiux -79. [레거시 인프라 작살내고 하이브리드 클라우드 만든 썰](https://toss.tech/article/42673) - adjacent -80. [개발자는 AI에게 대체될 것인가](https://toss.tech/article/44377) - adjacent -81. [소프트웨어 3.0 시대를 맞이하며](https://toss.tech/article/44539) - adjacent -82. [AST로 Outdated 없는 퍼널 문서 만들기](https://toss.tech/article/ast-funnel-visualization) - adjacent -83. [캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁](https://toss.tech/article/cache-traffic-tip) - adjacent -84. [토스증권 Iceberg 적용기 #1: CDC 환경은 왜 제대로 동작하지 않을까?](https://toss.tech/article/iceberg-cdc-1) - adjacent -85. [토스증권 Apache Kafka 데이터센터 이중화 구성 #1](https://toss.tech/article/kafka-distribution-1) - adjacent -86. [토스증권 Apache Kafka 데이터센터 이중화 구성 #3: Offset Sync](https://toss.tech/article/kafka-distribution-3) - adjacent -87. [ksqlDB를 활용한 증권사의 실시간 데이터 처리하기](https://toss.tech/article/ksqldb-realtime-data) - adjacent -88. [LLM 쉽고 빠르게 서빙하기](https://toss.tech/article/llm-serving) - adjacent -89. [서버 증설 없이 처리하는 대규모 트래픽](https://toss.tech/article/monitoring-traffic) - adjacent -90. [20년 레거시를 넘어 미래를 준비하는 시스템 만들기](https://toss.tech/article/payments-legacy-1) - adjacent -91. [가맹점은 변함없이, 결제창 시스템 전면 재작성하기](https://toss.tech/article/payments-legacy-2) - adjacent -92. [레거시 정산 개편기: 신규 시스템 투입 여정부터 대규모 배치 운영 노하우까지](https://toss.tech/article/payments-legacy-6) - adjacent -93. [고객은 절대 기다려주지 않는다: 빠른 데이터 서빙으로 고객 만족도를 수직 상승 시키는 법](https://toss.tech/article/payments-legacy-7) - adjacent -94. [수천 개의 API/BATCH 서버를 하나의 설정 체계로 관리하기](https://toss.tech/article/payments-legacy-8) - adjacent -95. [레거시 인프라 작살내고 하이브리드 클라우드 만든 썰](https://toss.tech/article/payments-legacy-9) - adjacent -96. [멈춰있던 PG의 시간, 토스페이먼츠가 다시 흐르게 합니다](https://toss.tech/article/payments-legacy-intro) - adjacent -97. [토스뱅크가 AI로 보안과 효율도 챙기는 방법](https://toss.tech/article/secure-efficient-ai) - adjacent -98. [고성능 GPU 클러스터 도입기 #2: 이주하는 데이터](https://toss.tech/article/securities_llm_2) - adjacent -99. [은행 최초 코어뱅킹 MSA 전환기 (feat. 지금 이자 받기)](https://toss.tech/article/slash23-corebanking) - adjacent -100. [대규모 로그 처리도 OK! Elasticsearch 클러스터 개선기](https://toss.tech/article/slash23-data) - adjacent diff --git a/docs/research/toss/toss-uiux-fe-ds-deep-analysis.md b/docs/research/toss/toss-uiux-fe-ds-deep-analysis.md deleted file mode 100644 index 35c1a99..0000000 --- a/docs/research/toss/toss-uiux-fe-ds-deep-analysis.md +++ /dev/null @@ -1,149 +0,0 @@ -# Toss UIUX/Frontend/Design System Deep Analysis - -## Scope - -- source corpus: `docs/research/toss/toss-uiux-fe-ds-article-corpus.md` -- structured data: `docs/research/toss/toss-uiux-fe-ds-analysis-data.jsonl` -- summary stats: `docs/research/toss/toss-uiux-fe-ds-analysis-summary.json` -- analyzed set: 100 articles (`https://toss.tech/article/*`) - -## Method - -- 1. 100개 아티클 URL 수집 및 본문 텍스트 추출 -- 2. UIUX/Frontend/Design System/Quality 키워드 분류 -- 3. 분류 상위 문서와 대표 시리즈를 교차 검토 -- 4. 실행 가능한 규칙으로 패턴 변환 - -## Core Patterns (18) - -1. **행동이 명확한 문구를 우선한다** - -- 의미: 버튼/제목/상태 메시지가 즉시 행동을 유도한다. -- evidence: - - https://toss.tech/article/Marketing_Writing - - https://toss.tech/article/undercover-silo-4 - -2. **문제 설명과 복구 행동을 같이 제공한다** - -- 의미: 에러를 보여주는 것에서 끝내지 않고 다음 행동을 제시한다. -- evidence: - - https://toss.tech/article/undercover-silo-4 - - https://toss.tech/article/income-qa-platform - -3. **큰 흐름은 단계로 분해한다** - -- 의미: 복잡한 과업은 점진 노출로 부담을 줄인다. -- evidence: - - https://toss.tech/article/undercover-silo-6 - - https://toss.tech/article/research_process - -4. **리서치 기반으로 UI 의사결정을 한다** - -- 의미: 감이 아닌 사용자 관찰/검증으로 결정한다. -- evidence: - - https://toss.tech/article/uxresearch-method - - https://toss.tech/article/ux-research-platform - -5. **시각 효과보다 이해 속도를 우선한다** - -- 의미: 화려함보다 인지 부담이 낮은 인터랙션을 채택한다. -- evidence: - - https://toss.tech/article/27752 - - https://toss.tech/article/interaction_simplicity - -6. **디자인 시스템은 토큰 중심으로 운영한다** - -- 의미: 컴포넌트별 임시 스타일보다 의미 기반 토큰으로 통일한다. -- evidence: - - https://toss.tech/article/rethinking-design-system - - https://toss.tech/article/tds-color-system-update - -7. **컬러 시스템은 운영 가능한 체계로 관리한다** - -- 의미: 색상 추가보다 기준/이행 전략을 먼저 만든다. -- evidence: - - https://toss.tech/article/tds-color-system-update - -8. **컴포넌트는 조립 가능한 단위로 설계한다** - -- 의미: 재사용 가능한 작은 단위로 변경 비용을 낮춘다. -- evidence: - - https://toss.tech/article/slash23-iOS - - https://toss.tech/article/firesidechat_frontend_11 - -9. **프론트엔드 품질은 린트/테스트/리뷰 습관으로 만든다** - -- 의미: 개발자 역량에 의존하지 않고 절차로 품질을 보장한다. -- evidence: - - https://toss.tech/article/firesidechat_frontend_10a - - https://toss.tech/article/firesidechat_frontend_12 - -10. **배포는 점진 배포/관측 중심으로 설계한다** - -- 의미: 한번에 크게 배포하지 않고 리스크를 나눠서 배포한다. -- evidence: - - https://toss.tech/article/engineering-note-9 - - https://toss.tech/article/payments-legacy-3 - -11. **로깅/관측 가능성은 기능의 일부다** - -- 의미: 장애를 빨리 찾는 구조를 기본 요구사항으로 본다. -- evidence: - - https://toss.tech/article/engineering-note-5 - - https://toss.tech/article/MSA-observability - -12. **문서 접근성도 개발 생산성의 핵심이다** - -- 의미: 정보 탐색 비용을 줄이는 문서 체계에 투자한다. -- evidence: - - https://toss.tech/article/toss-frontend-ai-docs - -13. **QA 자동화는 속도와 안정성을 동시에 만든다** - -- 의미: 빠른 기능 개발일수록 자동화된 회귀 방어선이 필요하다. -- evidence: - - https://toss.tech/article/ai-driven-ui-test-automation - - https://toss.tech/article/income-qa-e2e-automation - -14. **플랫폼 전환은 단계적 구조개편으로 진행한다** - -- 의미: 대규모 리라이트보다 계획-분리-이행으로 안정성을 확보한다. -- evidence: - - https://toss.tech/article/restructuring-planning - - https://toss.tech/article/restructuring - -15. **장기 유지보수 관점에서 인터페이스를 설계한다** - -- 의미: 단기 구현보다 변경 가능한 경계를 우선한다. -- evidence: - - https://toss.tech/article/payments-legacy-1 - - https://toss.tech/article/payments-legacy-3 - -16. **다중 플랫폼(웹/RN)에서도 규칙 일관성을 유지한다** - -- 의미: 플랫폼별 구현은 달라도 품질 기준은 동일해야 한다. -- evidence: - - https://toss.tech/article/react-native-2024 - - https://toss.tech/article/rn-toss-bedrock - -17. **성능은 체감 지연을 줄이는 방향으로 최적화한다** - -- 의미: 사용자 대기 시간을 먼저 줄이고 내부 효율화는 그다음이다. -- evidence: - - https://toss.tech/article/34481 - - https://toss.tech/article/monitoring-traffic - -18. **운영 중 학습(incident learning)을 제품 개선으로 연결한다** - -- 의미: 장애/실수/실험 결과를 규칙과 도구 개선으로 환류한다. -- evidence: - - https://toss.tech/article/undercover-silo-5 - - https://toss.tech/article/slash23-security - -## Translation To Rules - -- 문구: 쉬운 단어, 한 문장 한 메시지, 복구 행동 포함 -- UI 상태: 기본/로딩/성공/실패/빈 상태를 항상 정의 -- 스타일: 토큰 우선, 하드코딩 예외화 -- 품질: 점진 배포 + 로깅 + 자동화 테스트를 기본 절차로 강제 -- 협업: 코드 변경과 함께 문서/검증 근거를 반드시 남김 diff --git a/docs/tds-rebuild/README.md b/docs/tds-rebuild/README.md deleted file mode 100644 index e187722..0000000 --- a/docs/tds-rebuild/README.md +++ /dev/null @@ -1,340 +0,0 @@ -# TDS 기반 블로그 리빌드 프로젝트 - -> 작성일: 2025-01-29 -> 브랜치: `refactor-toss-design-system` -> 상태: ✅ 완료 - -## 프로젝트 개요 - -기존 신문지 베이지 컬러 기반의 커스텀 디자인 시스템을 **TDS(토스 디자인 시스템)** 기반으로 완전히 재구축한 프로젝트입니다. - -### 주요 변경 사항 - -**제거:** - -- Three.js 3D 애니메이션 (HeroScene, TextParticleScene) -- GSAP 애니메이션 라이브러리 -- Geist 폰트 (Pretendard로 교체) -- 기존 커스텀 디자인 시스템 (variables.css) - -**추가:** - -- TDS 디자인 토큰 시스템 -- 8개 Core UI Components -- 3개 Layout Components -- 5개 Blog Components -- 새로운 페이지 구조 (/blog) - ---- - -## Phase별 구현 내용 - -### Phase 1: Foundation (기반 설정) - -**파일:** - -- `src/styles/tokens.css` - TDS 디자인 토큰 -- `src/styles/globals.css` - 글로벌 스타일 -- `src/app/layout.tsx` - Root layout - -**핵심 토큰:** - -- **색상**: toss-blue (#3182f6), grey-900~50 (10단계) -- **타이포**: Pretendard, Minor Third Scale (24/32/40px) -- **스페이싱**: 8pt grid (2~96px) -- **Radius**: 8/16/24px -- **Transition**: cubic-bezier(0.4, 0, 0.2, 1), 150-400ms - -[📖 상세 문서](./phase-1-foundation.md) - ---- - -### Phase 2: Core UI Components - -8개 컴포넌트 구현: - -| 컴포넌트 | 주요 기능 | -| --------------- | ------------------------------------------------ | -| **Button** | primary/secondary/tertiary, loading, polymorphic | -| **Card** | hover 효과, compound components | -| **Input** | label, error, validation, leftIcon/rightIcon | -| **Skeleton** | pulse animation, Text/Avatar/Card 프리셋 | -| **Toast** | Spring animation, auto-dismiss, 4가지 type | -| **EmptyState** | icon, title, description, action | -| **Modal** | Portal, ESC 닫기, focus trap | -| **BottomSheet** | Drag to dismiss, Spring animation | - -**TDS 인터랙션 적용:** - -- Hover: opacity 0.8 -- Active: scale 0.98 -- Transition: 150ms cubic-bezier -- 접근성: WCAG 2.1 AA 준수 - -[📖 상세 문서](./phase-2-core-ui-components.md) - ---- - -### Phase 3: Layout Components - -3개 컴포넌트 구현: - -- **Header**: 반응형 네비게이션, 모바일 햄버거 메뉴, Active indicator (layoutId) -- **Footer**: 저작권, 소셜 링크 -- **Container**: sm/md/lg/xl size, 반응형 패딩 - -[📖 상세 문서](./phase-3-layout-components.md) - ---- - -### Phase 4: Blog Components - -5개 컴포넌트 구현: - -- **PostCard**: default/featured variant, 카테고리 태그 -- **PostList**: stagger 애니메이션, EmptyState -- **CategoryFilter**: All/Dev/Life, layoutId 애니메이션 -- **TableOfContents**: IntersectionObserver, 스크롤 추적 -- **ReadingProgress**: Spring 기반 진행률 바 - ---- - -### Phase 5: Pages - -4개 페이지 구현: - -| 페이지 | 경로 | 구성 | -| --------------- | -------------- | ----------------------------------- | -| **홈** | `/` | Hero + 최근 글 | -| **블로그 목록** | `/blog` | CategoryFilter + PostList | -| **블로그 상세** | `/blog/[slug]` | ReadingProgress + TOC + MDX Content | -| **이력서** | `/resume` | 간단한 프로필 + 경력 | - ---- - -### Phase 6: Migration - -**제거:** - -- `@react-three/drei`, `@react-three/fiber`, `three` -- `gsap` -- `geist` -- `src/components/visualization/` -- `src/app/_components/` (Visualization 컴포넌트) - -**리다이렉트:** - -- `/feed` → `/blog` -- `/feed/[slug]` → `/blog/[slug]` - ---- - -### Phase 7: Verification - -**빌드 성공:** - -``` -✓ Compiled successfully -✓ Generating static pages (22) -✓ Finalizing page optimization -``` - -**생성된 페이지:** - -- 22개 정적 페이지 (SSG) -- 14개 블로그 포스트 -- 홈, 블로그 목록, 이력서 - ---- - -## 파일 구조 - -``` -eunu.log/ -├── src/ -│ ├── app/ -│ │ ├── layout.tsx # TDS 기반 root layout -│ │ ├── page.tsx # 홈 (Hero + 최근 글) -│ │ ├── blog/ -│ │ │ ├── page.tsx # 블로그 목록 -│ │ │ ├── BlogListClient.tsx # 클라이언트 필터링 -│ │ │ └── [slug]/page.tsx # 블로그 상세 -│ │ ├── resume/page.tsx # 이력서 -│ │ └── feed/ # 리다이렉트만 (레거시 지원) -│ ├── components/ -│ │ ├── ui/ # 8개 Core UI -│ │ │ ├── Button/ -│ │ │ ├── Card/ -│ │ │ ├── Input/ -│ │ │ ├── Skeleton/ -│ │ │ ├── Toast/ -│ │ │ ├── EmptyState/ -│ │ │ ├── Modal/ -│ │ │ └── BottomSheet/ -│ │ ├── layout/ # 3개 Layout -│ │ │ ├── Header/ -│ │ │ ├── Footer/ -│ │ │ └── Container/ -│ │ └── blog/ # 5개 Blog -│ │ ├── PostCard/ -│ │ ├── PostList/ -│ │ ├── CategoryFilter/ -│ │ ├── TableOfContents/ -│ │ └── ReadingProgress/ -│ ├── styles/ -│ │ ├── tokens.css # TDS 디자인 토큰 ✨ -│ │ └── globals.css # TDS 기반 글로벌 스타일 ✨ -│ ├── lib/ # 기존 유지 -│ ├── types/ # 기존 유지 -│ └── data/ # 기존 유지 -└── posts/ # 14개 MDX 포스트 (기존 유지) -``` - ---- - -## TDS 규칙 준수 사항 - -### 1. 색상 시스템 - -```css -/* Primary */ ---color-toss-blue: #3182f6; - -/* Grey Scale (10단계) */ ---color-grey-900: #191f28; /* 메인 텍스트 */ ---color-grey-700: #4e5968; /* 서브 텍스트 */ ---color-grey-600: #6b7684; /* 설명 텍스트 */ ---color-grey-200: #e5e8eb; /* 보더 */ ---color-grey-100: #f2f4f6; /* 디바이더 */ ---color-grey-50: #f9fafb; /* 서브 배경 */ -``` - -### 2. 타이포그래피 - -- **폰트**: Pretendard (한글 최적화) -- **Scale**: Minor Third (1.2 ratio) -- **크기**: 24/32/40px (Headline), 15-16px (Body) -- **Line Height**: 1.6 (기본), 1.8 (긴 글) - -### 3. 스페이싱 (8pt Grid) - -``` -2px, 4px, 8px, 12px, 16px, 24px, 32px, 48px, 64px, 96px -``` - -### 4. Border Radius - -- **8px**: 버튼, 입력 필드 -- **16px**: 카드 -- **24px**: 모달, 바텀시트 - -### 5. 인터랙션 - -```css -/* Hover */ -opacity: 0.8; - -/* Active */ -scale: 0.98; - -/* Transition */ -cubic-bezier(0.4, 0, 0.2, 1), 150ms; -``` - -### 6. 접근성 - -- WCAG 2.1 AA 준수 -- 색상 대비 4.5:1 이상 -- 키보드 네비게이션 -- 터치 타겟 44px 이상 -- ARIA 속성 적용 - ---- - -## 성과 - -### 번들 크기 감소 - -- Three.js 제거: **~500KB 감소** -- GSAP 제거: **~50KB 감소** -- Geist 폰트 제거: **~100KB 감소** - -### 컴포넌트 재사용성 - -- 8개 Core UI Components -- 3개 Layout Components -- 5개 Blog Components -- **총 16개** 재사용 가능 컴포넌트 - -### 코드 품질 - -- TypeScript strict mode -- TDS 토큰 100% 적용 (하드코딩 0%) -- 일관된 컴포넌트 패턴 -- 접근성 준수 - ---- - -## 다음 단계 - -### 추천 개선 사항 - -1. **Dark Mode 지원** - - ```css - @media (prefers-color-scheme: dark) { - --color-text-primary: #f9fafb; - --color-bg-primary: #191f28; - } - ``` - -2. **애니메이션 최적화** - - Reduced Motion 감지 - - Intersection Observer 활용 - - GPU 가속 확인 - -3. **SEO 강화** - - JSON-LD 구조화 데이터 - - Open Graph 이미지 생성 - - Sitemap 자동 생성 - -4. **테스트 추가** - - Vitest 단위 테스트 - - Playwright E2E 테스트 - - Storybook + Chromatic - -5. **성능 모니터링** - - Lighthouse CI 통합 - - Core Web Vitals 추적 - - Bundle Analyzer 정기 실행 - ---- - -## 참고 자료 - -### TDS 관련 - -- [TDS 컬러 시스템](https://toss.im/career/article/tds-color-system) -- [토스 UX Writing 가이드](https://toss.im/career/article/ux-writing) - -### 기술 문서 - -- [Next.js 16 Documentation](https://nextjs.org/docs) -- [Tailwind CSS v4](https://tailwindcss.com/docs) -- [Framer Motion](https://www.framer.com/motion/) - -### 접근성 - -- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/) -- [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/) - ---- - -## 프로젝트 완료 - -**커밋:** `feat: TDS 기반 블로그 리빌드 완료` -**변경 파일:** 78개 (4199 추가, 4437 삭제) -**빌드 상태:** ✅ 성공 -**배포 준비:** ✅ 완료 - -모든 Phase가 성공적으로 완료되었습니다! 🎉 diff --git a/docs/tds-rebuild/phase-1-foundation.md b/docs/tds-rebuild/phase-1-foundation.md deleted file mode 100644 index f63c5e1..0000000 --- a/docs/tds-rebuild/phase-1-foundation.md +++ /dev/null @@ -1,413 +0,0 @@ -# Phase 1: Foundation - TDS 디자인 토큰 시스템 구축 - -> 작성일: 2025-01-29 -> 작성자: Eunu (TDS 기반 블로그 리빌드 프로젝트) - -## 목표 - -기존 신문지 베이지 컬러 기반의 커스텀 디자인 시스템을 **TDS(토스 디자인 시스템)** 기반으로 전환하기 위한 기초 작업을 수행합니다. - -### 핵심 과제 - -1. TDS 디자인 토큰을 CSS Variables로 정의 -2. 글로벌 스타일을 TDS 규칙에 맞게 재구성 -3. Root Layout에 overlay 포털 추가 - ---- - -## 배경 - -### 기존 시스템의 문제점 - -기존 eunu.log는 다음과 같은 디자인 시스템을 사용했습니다: - -```css -/* 기존 variables.css */ ---bg-primary: #eaebea; /* 신문지 베이지 */ ---text-primary: #1a1a1a; ---accent-primary: #0066cc; /* 클래식 블루 */ -``` - -**문제점:** - -1. **일관성 부족**: 커스텀 컬러 시스템으로 인해 명확한 위계가 없음 -2. **확장성 제한**: Grey scale이 3-4단계로 제한적 -3. **토스 철학 부재**: TDS의 "간결하고 명확한" 원칙이 반영되지 않음 - -### TDS를 선택한 이유 - -TDS는 다음과 같은 장점을 제공합니다: - -1. **체계적인 Grey Scale**: 900~50까지 10단계의 명확한 위계 -2. **검증된 컬러 시스템**: 접근성(4.5:1 대비)을 보장하는 컬러 팔레트 -3. **8pt Grid**: 일관된 스페이싱으로 시각적 리듬 확보 -4. **Minor Third Scale**: 타이포그래피 조화를 위한 수학적 비율 - ---- - -## 구현 내용 - -### 1. TDS 디자인 토큰 정의 - -**파일: `src/styles/tokens.css`** - -#### 1.1 색상 시스템 - -```css -@theme { - /* Primary */ - --color-toss-blue: #3182f6; - --color-toss-blue-light: #4a9eff; - --color-toss-blue-dark: #1b64da; - - /* Grey Scale (10단계) */ - --color-grey-900: #191f28; /* 가장 어두움 - 메인 텍스트 */ - --color-grey-800: #333d4b; - --color-grey-700: #4e5968; /* 서브 텍스트 */ - --color-grey-600: #6b7684; /* 설명 텍스트 */ - --color-grey-500: #8b95a1; - --color-grey-400: #b0b8c1; /* Disabled 상태 */ - --color-grey-300: #d1d6db; - --color-grey-200: #e5e8eb; /* 보더 */ - --color-grey-100: #f2f4f6; /* 디바이더 */ - --color-grey-50: #f9fafb; /* 서브 배경 */ -} -``` - -**선택 근거:** - -- **Grey-900 (#191f28)**: WCAG AA 기준(4.5:1)을 만족하는 메인 텍스트 컬러 -- **Grey-700 (#4e5968)**: 화이트 배경에서 3:1 이상 대비를 유지하는 서브 텍스트 -- **Grey-100/50**: 미묘한 배경 차이로 섹션을 구분하되 과하지 않음 - -#### 1.2 타이포그래피 (Minor Third Scale) - -```css -@theme { - /* Font Family */ - --font-sans: 'Pretendard', -apple-system, BlinkMacSystemFont, ...; - --font-mono: 'JetBrains Mono', ui-monospace, ...; - - /* Font Sizes - Minor Third Scale (1.2 ratio) */ - --text-xs: 0.75rem; /* 12px */ - --text-sm: 0.875rem; /* 14px */ - --text-base: 0.9375rem; /* 15px - 본문 기본 */ - --text-md: 1rem; /* 16px */ - --text-lg: 1.125rem; /* 18px */ - --text-xl: 1.25rem; /* 20px */ - --text-2xl: 1.5rem; /* 24px - Headline SM */ - --text-3xl: 2rem; /* 32px - Headline MD */ - --text-4xl: 2.5rem; /* 40px - Headline LG */ -} -``` - -**Minor Third Scale을 선택한 이유:** - -Minor Third는 1.2 비율로, 각 단계가 이전 크기의 120%입니다. 이는: - -- **시각적 조화**: 급격하지 않으면서도 명확한 위계 -- **읽기 편의성**: 15px 본문과 24px 헤드라인의 조화 -- **반응형 대응**: 모바일에서도 과하지 않은 크기 - -참고: Major Third(1.25)는 너무 크고, Perfect Fourth(1.333)는 모바일에서 과함. - -#### 1.3 스페이싱 (8pt Grid) - -```css -@theme { - --space-0: 0; - --space-0.5: 0.125rem; /* 2px */ - --space-1: 0.25rem; /* 4px */ - --space-2: 0.5rem; /* 8px - 기본 단위 */ - --space-3: 0.75rem; /* 12px */ - --space-4: 1rem; /* 16px */ - --space-6: 1.5rem; /* 24px */ - --space-8: 2rem; /* 32px */ - --space-12: 3rem; /* 48px */ - --space-16: 4rem; /* 64px */ -} -``` - -**8pt Grid를 사용하는 이유:** - -1. **디바이스 대응**: 대부분의 화면은 8의 배수로 나뉨 (1920, 1280, 768) -2. **시각적 리듬**: 일관된 간격으로 정돈된 느낌 -3. **디자이너-개발자 협업**: 피그마 등 디자인 툴의 기본 그리드 - -**실제 적용 예시:** - -```css -/* 관련 요소 간 */ -padding: var(--space-4); /* 16px */ - -/* 섹션 간 */ -margin-bottom: var(--space-8); /* 32px */ - -/* 큰 섹션 구분 */ -padding-block: var(--space-16); /* 64px */ -``` - -#### 1.4 Border Radius - -```css -@theme { - --radius-sm: 0.5rem; /* 8px - 버튼, 작은 카드 */ - --radius-md: 1rem; /* 16px - 메인 카드 */ - --radius-lg: 1.5rem; /* 24px - 큰 섹션, 모달 */ -} -``` - -**토스 디자인의 Radius 철학:** - -- **8px**: 부드러우면서도 명확한 경계 -- **16px**: 카드의 시각적 독립성 강조 -- **24px**: 모달/시트 등 독립적 컨테이너 - -### 2. 글로벌 스타일 재구성 - -**파일: `src/styles/globals.css`** - -#### 2.1 Base Layer - -```css -@layer base { - body { - font-family: var(--font-sans); - font-size: var(--text-base); /* 15px */ - line-height: var(--leading-relaxed); /* 1.6 */ - color: var(--color-text-primary); - background-color: var(--color-bg-primary); - } -} -``` - -**15px를 본문 기본 크기로 선택한 이유:** - -- **가독성**: 14px보다 읽기 편하지만 16px보다 콤팩트 -- **정보 밀도**: 기술 블로그 특성상 코드와 텍스트가 많아 밀도 확보 -- **토스 앱**: 토스 앱도 15px를 본문 기본으로 사용 - -#### 2.2 Prose 스타일 (MDX 콘텐츠) - -```css -.prose { - font-size: var(--text-base); /* 15px */ - line-height: var(--leading-loose); /* 1.8 */ -} - -.prose p { - margin-bottom: 1.5rem; - color: var(--color-grey-700); -} - -.prose h2 { - font-size: var(--text-2xl); /* 24px */ - margin-top: 2.5rem; - margin-bottom: 1rem; -} -``` - -**Prose 스타일링 원칙:** - -1. **본문 Line Height 1.8**: 긴 글을 읽을 때 눈의 피로 감소 -2. **Heading 간격**: 상단 2.5rem, 하단 1rem으로 시각적 그룹핑 -3. **Grey-700 텍스트**: Grey-900보다 부드러워 장시간 독서에 적합 - -#### 2.3 접근성 (Accessibility) - -```css -:focus-visible { - outline: 2px solid var(--color-toss-blue); - outline-offset: 2px; -} - -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - transition-duration: 0.01ms !important; - } -} -``` - -**WCAG 준수:** - -- **Focus outline**: 키보드 네비게이션 사용자를 위한 명확한 표시 -- **Reduced motion**: 전정 장애가 있는 사용자 배려 -- **2px outline**: WCAG 2.2에서 권장하는 최소 두께 - -### 3. Root Layout 수정 - -**파일: `src/app/layout.tsx`** - -#### 3.1 Overlay Portal 추가 - -```tsx -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - -
{children}
-
{/* Modal, Toast 포털 */} - - - ); -} -``` - -**Overlay Portal이 필요한 이유:** - -1. **Z-index 관리**: Modal, Toast가 항상 최상단에 렌더링 -2. **CSS Isolation**: 부모 컴포넌트의 `overflow: hidden` 등에 영향받지 않음 -3. **접근성**: ARIA 구조에서 Dialog를 body 직계 자식으로 권장 - -#### 3.2 Metadata 개선 - -```tsx -export const metadata: Metadata = { - title: { - default: 'eunu.log', - template: '%s | eunu.log', // 페이지별 타이틀 자동 생성 - }, - description: '데이터와 시스템, 창의적인 것들을 만듭니다', - keywords: ['개발', '블로그', '기술', 'Next.js', 'React'], - // ... -}; -``` - -**SEO 최적화:** - -- **title.template**: 블로그 상세 페이지에서 "글 제목 | eunu.log" 자동 생성 -- **keywords**: 검색엔진 최적화 (Google은 무시하지만 Naver/Daum은 참고) -- **OpenGraph**: SNS 공유 시 메타데이터 - ---- - -## 검증 결과 - -### 색상 대비 테스트 - -| 조합 | 대비 | WCAG | 용도 | -| ----------------- | ------ | ---- | ----------- | -| Grey-900 / White | 16.7:1 | AAA | 메인 텍스트 | -| Grey-700 / White | 7.2:1 | AAA | 서브 텍스트 | -| Grey-600 / White | 5.1:1 | AA | 설명 텍스트 | -| Toss-Blue / White | 4.6:1 | AA | 링크 | - -모든 텍스트 조합이 **WCAG AA 이상** 충족. - -### 타이포그래피 조화 - -``` -40px (Headline LG) ÷ 1.25 = 32px (Headline MD) -32px (Headline MD) ÷ 1.33 = 24px (Headline SM) -24px (Headline SM) ÷ 1.6 = 15px (Body) -``` - -각 단계가 시각적으로 명확히 구분되면서도 과하지 않음. - -### 스페이싱 일관성 - -``` -카드 내부 패딩: 16px (space-4) -카드 간 간격: 24px (space-6) -섹션 간 간격: 32px (space-8) -페이지 상하 패딩: 64px (space-16) -``` - -모든 간격이 8의 배수로 일관성 확보. - ---- - -## 학습 포인트 - -### 1. CSS Variables vs Tailwind Tokens - -TDS 토큰을 `@theme` 블록에 정의한 이유: - -```css -/* ✅ Tailwind v4 방식 */ -@theme { - --color-toss-blue: #3182f6; -} - -/* ❌ 구버전 방식 */ -:root { - --color-toss-blue: #3182f6; -} -``` - -Tailwind v4는 `@theme` 블록의 변수를 자동으로 유틸리티 클래스로 변환: - -- `bg-toss-blue` → `background-color: var(--color-toss-blue)` -- `text-grey-700` → `color: var(--color-grey-700)` - -### 2. Pretendard vs System Fonts - -```css -font-family: - 'Pretendard', - -apple-system, - BlinkMacSystemFont, - ...; -``` - -**Pretendard를 선택한 이유:** - -- **한글 최적화**: 받침 균형, 자간 조정이 우수 -- **Variable Font**: 400-700 weight를 하나의 파일로 제공 -- **오픈소스**: 상업적 사용 무료 - -**Fallback 순서:** - -1. Pretendard (CDN) -2. -apple-system (iOS/macOS) -3. BlinkMacSystemFont (Chrome on macOS) -4. Segoe UI (Windows) - -### 3. Line Height의 과학 - -```css -/* 본문 */ -line-height: 1.6; /* 15px × 1.6 = 24px */ - -/* 긴 글 */ -line-height: 1.8; /* 15px × 1.8 = 27px */ -``` - -**1.6을 기본으로 선택한 이유:** - -- **가독성 연구**: Robert Bringhurst의 "The Elements of Typographic Style"에서 권장 -- **눈의 움직임**: 다음 줄로 이동할 때 충분한 공간 확보 -- **한글 특성**: 영어보다 글자 높이가 높아 조금 더 넓은 행간 필요 - ---- - -## 다음 단계 - -Phase 2에서는 이 토큰을 활용한 Core UI Components를 구현합니다: - -- **Button**: primary/secondary/tertiary variant -- **Card**: hover 효과, compound components -- **Input**: label, error, validation -- **Modal/BottomSheet**: overlay pattern -- **Toast**: feedback system - -모든 컴포넌트는: - -1. TDS 토큰만 사용 (하드코딩 금지) -2. 접근성 준수 (ARIA, keyboard navigation) -3. 반응형 (mobile-first) -4. 성능 최적화 (불필요한 리렌더링 방지) - ---- - -## 참고 자료 - -- [TDS 컬러 시스템](https://toss.im/career/article/tds-color-system) -- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/) -- [Tailwind CSS v4 Documentation](https://tailwindcss.com/docs) -- [Pretendard Font](https://github.com/orioncactus/pretendard) diff --git a/docs/tds-rebuild/phase-2-core-ui-components.md b/docs/tds-rebuild/phase-2-core-ui-components.md deleted file mode 100644 index 0ffcdfb..0000000 --- a/docs/tds-rebuild/phase-2-core-ui-components.md +++ /dev/null @@ -1,874 +0,0 @@ -# Phase 2: Core UI Components - TDS 기반 컴포넌트 시스템 구축 - -> 작성일: 2025-01-29 -> 작성자: Eunu (TDS 기반 블로그 리빌드 프로젝트) - -## 목표 - -TDS 디자인 토큰을 활용하여 재사용 가능한 Core UI 컴포넌트를 구축합니다. 모든 컴포넌트는: - -1. **TDS 토큰만 사용** (하드코딩 금지) -2. **접근성 준수** (WCAG 2.1 AA) -3. **반응형 디자인** (mobile-first) -4. **성능 최적화** (불필요한 리렌더링 방지) - ---- - -## 배경 - -### 왜 컴포넌트 시스템인가? - -UI 컴포넌트는 디자인 시스템의 **구현체**입니다. 토큰(Token)이 "재료"라면, 컴포넌트는 "요리"입니다. - -**컴포넌트 시스템의 목표:** - -1. **일관성**: 동일한 패턴을 반복해서 사용 -2. **생산성**: 복잡한 UI를 빠르게 조합 -3. **유지보수성**: 변경 사항이 전체에 자동 반영 -4. **접근성**: 한 번 구현하면 모든 곳에서 보장 - -### TDS 컴포넌트 철학 - -토스 디자인 시스템의 컴포넌트는 다음 원칙을 따릅니다: - -1. **미니멀**: 필요한 기능만 제공 -2. **명확함**: API가 직관적 -3. **조합 가능**: 작은 컴포넌트를 조합해 복잡한 UI 구성 -4. **접근성 우선**: ARIA, 키보드, 스크린 리더 지원 - ---- - -## 구현 컴포넌트 - -### 1. Button - 행동의 시작점 - -**파일:** `src/shared/ui/Button/Button.tsx` - -#### 1.1 설계 결정 - -**Variant (3가지):** - -| Variant | 용도 | 스타일 | -| ------------- | -------------------------- | --------------------------- | -| **primary** | 주요 액션 (화면당 1개) | 토스 블루 배경, 흰색 텍스트 | -| **secondary** | 보조 액션 | 그레이 배경, 검정 텍스트 | -| **tertiary** | 텍스트 버튼 (취소, 더보기) | 투명 배경, 블루 텍스트 | - -```tsx - - - -``` - -**왜 3가지인가?** - -- **2가지는 부족**: Primary와 Secondary만으로는 계층이 명확하지 않음 -- **4가지는 과함**: 선택지가 많으면 일관성 유지 어려움 -- **3가지가 최적**: 명확한 위계 + 충분한 유연성 - -#### 1.2 TDS 인터랙션 구현 - -```tsx -const variantStyles: Record = { - primary: - 'bg-[var(--color-toss-blue)] text-white hover:opacity-80 active:scale-[0.98]', - // ... -}; -``` - -**Hover: opacity 0.8** - -- 이유: 색상 변경보다 자연스러움 -- 효과: "눌러도 된다"는 시각적 피드백 - -**Active: scale 0.98** - -- 이유: 실제로 "눌린다"는 물리적 피드백 -- 효과: 터치/클릭 반응이 명확함 - -**Transition: 150ms cubic-bezier(0.4, 0, 0.2, 1)** - -- 이유: 너무 빠르면(< 100ms) 지각하기 어렵고, 느리면(> 200ms) 답답함 -- 150ms는 반응성과 부드러움의 균형점 - -#### 1.3 Size 체계 - -```tsx -const sizeStyles: Record = { - sm: 'h-9 px-3 text-sm', // 36px height - md: 'h-11 px-4 text-base', // 44px height - lg: 'h-[52px] px-6 text-lg', // 52px height -}; -``` - -**왜 이 크기들인가?** - -- **sm (36px)**: 모바일 최소 터치 영역 (32px)보다 여유있게 -- **md (44px)**: Apple HIG 권장 터치 타겟 크기 -- **lg (52px)**: 주요 CTA, 엄지로 쉽게 닿는 크기 - -#### 1.4 Polymorphic Component Pattern - -```tsx -type ButtonAsButton = /* button 타입 */; -type ButtonAsLink = /* a 타입 */; -type ButtonProps = ButtonAsButton | ButtonAsLink; -``` - -**왜 Polymorphic인가?** - -```tsx -// ❌ 나쁜 방법: 버튼처럼 보이는 링크를 div로 만듦 -
router.push('/blog')} className="button"> - 블로그 보기 -
- -// ✅ 좋은 방법: 의미론적으로 올바른 HTML - -``` - -**접근성 이득:** - -- 스크린 리더가 "링크"로 인식 -- `Cmd+Click`으로 새 탭에서 열기 가능 -- SEO에도 유리 (크롤러가 링크로 인식) - -#### 1.5 Loading State - -```tsx -{loading ? ( - -) : ( - /* 원래 콘텐츠 */ -)} -``` - -**왜 스피너를 직접 그렸는가?** - -- **번들 크기**: 외부 라이브러리 불필요 -- **커스터마이징**: 컬러가 자동으로 버튼 텍스트 색상 따라감 -- **CSS 애니메이션**: GPU 가속으로 60fps 유지 - -**UX 원칙:** - -- 로딩 중에는 `disabled` 처리 -- 버튼 크기 변하지 않음 (레이아웃 시프트 방지) -- `pointer-events-none`으로 중복 클릭 차단 - ---- - -### 2. Card - 정보의 컨테이너 - -**파일:** `src/shared/ui/Card/Card.tsx` - -#### 2.1 Compound Components Pattern - -```tsx - - - 제목 - 설명 - - {/* 콘텐츠 */} - {/* 액션 */} - -``` - -**왜 Compound Components인가?** - -**장점:** - -1. **유연성**: 원하는 부분만 조합 -2. **타입 안정성**: 자동완성과 타입 체크 -3. **명확한 의도**: 구조가 명시적 - -**단점:** - -- 약간 더 많은 코드 - -**비교:** - -```tsx -// ❌ Props Hell - - -// ✅ Compound Components - - - 제목 - - -``` - -#### 2.2 Hover 효과 - -```tsx -hover && [ - 'transition-all duration-[var(--duration-200)]', - 'hover:shadow-[var(--shadow-md)] hover:border-[var(--color-grey-300)]', - 'hover:-translate-y-0.5', -]; -``` - -**3가지 변화:** - -1. **Shadow 증가**: 카드가 "떠오르는" 느낌 -2. **Border 진해짐**: 경계가 명확해짐 -3. **위로 이동 (0.5 = 2px)**: 물리적으로 올라오는 피드백 - -**왜 2px만 올리나?** - -- **4px 이상**: 너무 극적해서 어지러움 -- **1px**: 변화가 미세해서 잘 안 보임 -- **2px**: 명확하지만 부드러움 - -#### 2.3 Border Radius 선택 - -```tsx -rounded-[var(--radius-md)] // 16px -``` - -**TDS의 Radius 철학:** - -- **8px (sm)**: 버튼, 입력 필드 - 경계가 명확 -- **16px (md)**: 카드 - 독립적인 컨테이너 -- **24px (lg)**: 모달, 바텀시트 - 독립적 레이어 - -**과학적 근거:** - -연구에 따르면 16px radius는: - -- 부드러우면서도 과하지 않음 -- 대부분의 모니터 크기에서 조화로움 -- 콘텐츠와 경계의 균형 - ---- - -### 3. Input - 사용자 입력의 관문 - -**파일:** `src/shared/ui/Input/Input.tsx` - -#### 3.1 Focus State Management - -```tsx -const [isFocused, setIsFocused] = useState(false); - -onFocus={(e) => { - setIsFocused(true); - props.onFocus?.(e); -}} -``` - -**왜 State로 관리하나?** - -CSS `:focus`만으로는 부족한 경우: - -1. **Ring (외곽 빛)**: focus일 때만 표시 -2. **Label 색상 변경**: focus일 때 강조 -3. **아이콘 색상**: focus에 반응 - -**주의사항:** - -- 원래 `onFocus` prop도 호출 (호환성) -- `onBlur`에서 `setIsFocused(false)` 필수 - -#### 3.2 Error vs Focus State - -```tsx -error - ? 'border-[var(--color-error)] focus:ring-[var(--color-error)]/20' - : isFocused - ? 'border-[var(--color-toss-blue)] ring-2 ring-[var(--color-toss-blue)]/20' - : 'border-[var(--color-grey-200)]'; -``` - -**우선순위:** - -1. **Error 최우선**: 사용자에게 문제 알림 -2. **Focus 두 번째**: 현재 입력 중 -3. **Default 마지막**: 대기 상태 - -**Ring 투명도 20%인 이유:** - -- **100%**: 너무 강해서 텍스트 가독성 떨어짐 -- **50%**: 여전히 강함 -- **20%**: 존재감은 있지만 방해되지 않음 - -#### 3.3 Icon 배치 - -```tsx -leftIcon && 'pl-11'; // 44px padding -``` - -**44px인 이유:** - -- 아이콘 크기: 20px -- 좌측 여백: 16px -- 아이콘과 텍스트 간격: 8px -- 합계: 16 + 20 + 8 = 44px - -**왜 정확히 계산하나?** - -``` -┌─16px─┬─20px─┬─8px─┐ 텍스트... -│ │ 🔍 │ │ -``` - -텍스트가 아이콘 뒤에 숨으면 안 되므로 정확한 계산 필요. - ---- - -### 4. Skeleton - 로딩의 예술 - -**파일:** `src/shared/ui/Skeleton/Skeleton.tsx` - -#### 4.1 Pulse Animation - -```css -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} -``` - -**왜 Pulse인가?** - -**비교:** - -| 애니메이션 | 장점 | 단점 | -| ----------- | ---------------- | -------------------- | -| **Shimmer** | 세련됨 | 구현 복잡, 성능 비용 | -| **Pulse** | 단순, 60fps 보장 | 덜 화려함 | -| **Static** | 성능 최고 | 지루함, "멈춘" 느낌 | - -**Pulse를 선택한 이유:** - -- 구현 간단 (CSS만으로 가능) -- GPU 가속 (opacity는 composite layer) -- 브라우저 호환성 우수 - -#### 4.2 Preset Components - -```tsx -Skeleton.Text({ lines: 3 }); -Skeleton.Avatar({ size: 40 }); -Skeleton.Card(); -``` - -**왜 Preset이 필요한가?** - -```tsx -// ❌ 반복적인 코드 -
- - - -
- -// ✅ Preset 사용 - -``` - -**생산성 향상:** - -- 3줄 텍스트 스켈레톤: 1줄 코드 -- 일관성 보장: 모든 곳에서 동일한 패턴 - -#### 4.3 aria-hidden="true" - -```tsx - -``` - -**왜 숨기나?** - -스켈레톤은 **시각적 장식**일 뿐, 스크린 리더에게는: - -- 의미 없는 "빈 박스" -- 사용자를 혼란스럽게 함 - -**올바른 패턴:** - -```tsx -
- -
-``` - ---- - -### 5. Toast - 피드백의 순간 - -**파일:** `src/shared/ui/Toast/Toast.tsx` - -#### 5.1 Spring Animation - -```tsx -transition={{ - type: 'spring', - stiffness: 300, - damping: 30, -}} -``` - -**Spring vs Tween:** - -| 속성 | Spring | Tween | -| -------------- | --------------- | ------------- | -| **자연스러움** | 물리 시뮬레이션 | 선형/곡선 | -| **중단 가능** | 즉시 방향 전환 | 끊김 | -| **사용 사례** | UI 인터랙션 | 애니메이션 쇼 | - -**Stiffness 300, Damping 30인 이유:** - -- **Stiffness 300**: 빠른 반응 (높을수록 빠름) -- **Damping 30**: 적절한 바운스 (낮을수록 많이 튐) - -**실험 결과:** - -- Stiffness 200 + Damping 20: 너무 느리고 많이 튐 -- Stiffness 400 + Damping 40: 너무 빠르고 딱딱함 -- Stiffness 300 + Damping 30: **최적** - -#### 5.2 Auto-dismiss - -```tsx -useEffect(() => { - if (isVisible && duration > 0) { - const timer = setTimeout(() => { - onClose?.(); - }, duration); - return () => clearTimeout(timer); - } -}, [isVisible, duration, onClose]); -``` - -**Duration 3000ms (3초)인 이유:** - -UX 연구에 따르면: - -- **< 2초**: 읽기 어려움 -- **2-3초**: 읽고 이해하기 적절 -- **> 5초**: 방해되는 느낌 - -**주의사항:** - -- `duration: 0`이면 자동 닫기 비활성화 -- Timer cleanup으로 메모리 누수 방지 - -#### 5.3 Type별 아이콘 - -```tsx -const icons = { - success: , - error: , - warning: , - info: , -}; -``` - -**왜 아이콘이 필요한가?** - -**인지 과학:** - -- 인간은 **형태**를 **색상**보다 빠르게 인식 -- 색맹 사용자도 구분 가능 -- 시각적 계층 형성 - -**조합의 힘:** - -- 색상 (배경) + 아이콘 = 즉각적 인식 -- 텍스트는 세 번째 보강 수단 - ---- - -### 6. Modal - 집중의 공간 - -**파일:** `src/shared/ui/Modal/Modal.tsx` - -#### 6.1 Focus Trap - -```tsx -useEffect(() => { - if (isOpen) { - const previousActiveElement = document.activeElement as HTMLElement; - modalRef.current?.focus(); - - return () => { - previousActiveElement?.focus(); - }; - } -}, [isOpen]); -``` - -**왜 Focus Trap인가?** - -**문제:** - -``` -사용자 → Tab 키 → Modal 벗어남 → 뒤의 버튼 선택 🚫 -``` - -**해결:** - -``` -사용자 → Tab 키 → Modal 내부 순환 ✅ -``` - -**구현:** - -1. Modal 열릴 때: `previousActiveElement` 저장 -2. Modal에 focus -3. Modal 닫힐 때: 원래 요소로 focus 복귀 - -#### 6.2 ESC Key & Overlay Click - -```tsx -useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - - if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - document.body.style.overflow = 'hidden'; - } - - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.body.style.overflow = ''; - }; -}, [isOpen, onClose]); -``` - -**3가지 닫기 방법:** - -1. ESC 키 -2. Overlay 클릭 (optional) -3. X 버튼 - -**Body Scroll Lock:** - -- Modal 열릴 때: `overflow: hidden` -- Modal 닫힐 때: `overflow` 복구 - -**왜 필요한가?** - -Modal이 뒤의 콘텐츠와 함께 스크롤되면: - -- 혼란스러움 -- 모바일에서 터치 이벤트 겹침 - -#### 6.3 Portal to overlay-root - -```tsx -const overlayRoot = document.getElementById('overlay-root'); -return createPortal(/* ... */, overlayRoot); -``` - -**왜 Portal인가?** - -```html - -
- - -
- - - -
...
-
- - -
- -``` - -**이점:** - -- CSS Isolation -- Z-index 충돌 없음 -- 접근성 구조 (Dialog는 body 직계 자식) - ---- - -### 7. BottomSheet - 모바일의 진수 - -**파일:** `src/shared/ui/BottomSheet/BottomSheet.tsx` - -#### 7.1 Drag to Dismiss - -```tsx -const dragControls = useDragControls(); - -const handleDragEnd = (_: any, info: PanInfo) => { - if (info.offset.y > 100 || info.velocity.y > 500) { - onClose(); - } -}; -``` - -**2가지 조건:** - -| 조건 | 값 | 의미 | -| -------------- | --------- | ---------------------- | -| **offset.y** | > 100px | 충분히 많이 드래그 | -| **velocity.y** | > 500px/s | 빠르게 스와이프 (플릭) | - -**왜 OR 조건인가?** - -``` -천천히 많이 드래그 (100px+) → 닫기 -빠르게 조금 드래그 (500px/s+) → 닫기 -천천히 조금 드래그 → 원위치 -``` - -사용자의 **의도**를 감지하는 것이 핵심. - -#### 7.2 Drag Handle - -```tsx -
dragControls.start(e)} -> -
-
-``` - -**Handle 디자인:** - -- **너비 40px, 높이 4px**: iOS/Android 네이티브 앱 표준 -- **Grey-300**: 존재감은 있지만 방해되지 않음 -- **Rounded-full**: 부드러운 느낌 - -**cursor 변화:** - -- `grab`: 잡을 수 있음을 암시 -- `grabbing`: 현재 드래그 중 - -#### 7.3 Height 옵션 - -```tsx -height?: 'auto' | 'half' | 'full' - -const heightStyles = { - auto: 'max-h-[80vh]', // 콘텐츠 크기에 맞춤, 최대 80% - half: 'h-[50vh]', // 화면의 절반 - full: 'h-[90vh]', // 거의 전체 (상단 10% 남김) -}; -``` - -**80vh vs 90vh vs 100vh:** - -- **100vh**: 전체 화면 → 답답함 -- **90vh**: 뒤가 조금 보임 → "닫을 수 있다" 암시 -- **80vh (auto)**: 콘텐츠에 맞춤 → 최적 - ---- - -## 공통 패턴 및 Best Practices - -### 1. forwardRef 사용 - -```tsx -const Button = forwardRef( - function Button(props, ref) { - return ; -``` - -**주의사항:** - -- `forwardRef` 안에 함수 이름 명시 (디버깅 용이) -- Generic 타입 지정 (타입 안정성) - -### 2. CSS Variables 참조 - -```tsx -// ✅ 좋음: CSS Variables -className = 'bg-[var(--color-toss-blue)]'; - -// ❌ 나쁨: 하드코딩 -className = 'bg-[#3182f6]'; -``` - -**이유:** - -1. **일관성**: 모든 컴포넌트가 동일한 색상 -2. **변경 용이**: 토큰만 수정하면 전체 반영 -3. **다크 모드**: 변수 오버라이드로 간단히 구현 - -### 3. Tailwind 임의값 최소화 - -```tsx -// ✅ 가능하면 표준 클래스 -className = 'rounded-lg'; - -// ⚠️ 필요할 때만 임의값 -className = 'rounded-[var(--radius-lg)]'; -``` - -**임의값을 쓰는 경우:** - -- TDS 토큰 참조 (`var(--...)`) -- 매우 특수한 케이스 (`w-[52px]`) - -### 4. 접근성 필수 속성 - -```tsx -// 버튼 - - -// 입력 필드 - - -// 로딩 -
...
- -// 모달 -
...
-``` - ---- - -## 성능 최적화 - -### 1. 불필요한 리렌더링 방지 - -```tsx -// ❌ 매번 새 객체 생성 - - -// ✅ className 사용 - -``` - -### 2. 애니메이션 최적화 - -```tsx -// ✅ transform, opacity만 애니메이션 -transition: transform 150ms, opacity 150ms; - -// ❌ width, height 애니메이션 (리플로우 발생) -transition: width 150ms, height 150ms; -``` - -**GPU 가속 속성:** - -- `transform` -- `opacity` -- `filter` - -**리플로우 발생 속성 (피하기):** - -- `width`, `height` -- `top`, `left` -- `margin`, `padding` - -### 3. Framer Motion 최적화 - -```tsx -// ✅ type: 'spring' (물리 기반, 중단 가능) -transition={{ type: 'spring' }} - -// ❌ type: 'tween' (중단 시 끊김) -transition={{ type: 'tween' }} -``` - ---- - -## 테스트 가이드 - -### 접근성 체크리스트 - -각 컴포넌트별 필수 테스트: - -**Button:** - -- [ ] 키보드로 focus 가능 -- [ ] Enter/Space로 클릭 -- [ ] aria-label (아이콘만 있을 때) -- [ ] disabled 상태에서 클릭 불가 - -**Input:** - -- [ ] label과 연결 (id/htmlFor) -- [ ] error 시 aria-invalid -- [ ] placeholder는 보조 수단 - -**Modal:** - -- [ ] ESC로 닫기 -- [ ] focus trap -- [ ] overlay 클릭 닫기 -- [ ] 열릴 때 body scroll lock - -**Toast:** - -- [ ] role="alert" -- [ ] aria-live="polite" -- [ ] 자동 닫힘 (3초) - -### 시각적 회귀 테스트 - -```bash -# Storybook + Chromatic 사용 (권장) -npm run storybook -``` - ---- - -## 다음 단계 - -Phase 3에서는 이 Core UI Components를 활용한 Layout Components를 구현합니다: - -- **Header**: 반응형 네비게이션, 모바일 메뉴 -- **Footer**: 링크, 저작권 -- **Container**: 반응형 너비, 패딩 - -모든 레이아웃 컴포넌트는: - -1. Core UI 재사용 (Button, etc.) -2. 반응형 (mobile-first) -3. 접근성 (Skip link, Landmark) - ---- - -## 참고 자료 - -- [React forwardRef 공식 문서](https://react.dev/reference/react/forwardRef) -- [Framer Motion Spring 애니메이션](https://www.framer.com/motion/transition/) -- [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) -- [Compound Components Pattern](https://kentcdodds.com/blog/compound-components-with-react-hooks) diff --git a/docs/tds-rebuild/phase-3-layout-components.md b/docs/tds-rebuild/phase-3-layout-components.md deleted file mode 100644 index 81b90a5..0000000 --- a/docs/tds-rebuild/phase-3-layout-components.md +++ /dev/null @@ -1,76 +0,0 @@ -# Phase 3: Layout Components - 페이지 구조의 기초 - -> 작성일: 2025-01-29 -> 작성자: Eunu (TDS 기반 블로그 리빌드 프로젝트) - -## 목표 - -Core UI Components를 활용하여 모든 페이지에서 공통으로 사용할 Layout Components를 구축합니다. - ---- - -## 구현 내용 - -### 1. Header - 네비게이션의 중심 - -**핵심 기능:** - -- 로고 링크 (eunu.log) -- 데스크톱 네비게이션 (Home, Blog, Resume) -- 모바일 햄버거 메뉴 -- Active indicator (Framer Motion layoutId) - -**TDS 적용:** - -- Sticky Header: `backdrop-blur-md`로 부드러운 반투명 -- Active 색상: toss-blue -- Hover 효과: grey-600 → grey-900 transition - -**모바일 UX:** - -- 44px 최소 터치 영역 -- 메뉴 열릴 때 애니메이션 (height: 0 → auto) -- 메뉴 항목 클릭 시 자동 닫힘 - ---- - -### 2. Footer - 정보와 링크 - -**구성:** - -- 저작권 표시 -- 소셜 링크 (GitHub, Email) - -**TDS 적용:** - -- Grey-50 배경으로 섹션 구분 -- 링크 hover시 toss-blue -- 반응형: 모바일은 세로, 데스크톱은 가로 - ---- - -### 3. Container - 콘텐츠 너비 관리 - -**Size 옵션:** - -- sm: 640px (좁은 텍스트) -- md: 800px (블로그 포스트) -- lg: 1000px (일반 페이지) -- xl: 1200px (대시보드) - -**반응형 패딩:** - -- 모바일: 24px (`px-6`) -- 데스크톱: 32px (`md:px-8`) - ---- - -## 다음 단계 - -Phase 4에서는 블로그 전용 컴포넌트를 구현합니다: - -- **PostCard**: default/featured variant -- **PostList**: stagger 애니메이션 -- **CategoryFilter**: All/Dev/Life 필터링 -- **TableOfContents**: 스크롤 추적 목차 -- **ReadingProgress**: 진행률 바 diff --git a/platform/analytics/components/DwellTimeTracker.test.tsx b/infra/analytics/components/DwellTimeTracker.test.tsx similarity index 92% rename from platform/analytics/components/DwellTimeTracker.test.tsx rename to infra/analytics/components/DwellTimeTracker.test.tsx index 8060020..8b0a868 100644 --- a/platform/analytics/components/DwellTimeTracker.test.tsx +++ b/infra/analytics/components/DwellTimeTracker.test.tsx @@ -1,9 +1,9 @@ import { act, render } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import DwellTimeTracker from './DwellTimeTracker'; -import { trackEvent } from '@/platform/analytics/lib/analytics'; +import { trackEvent } from '@/infra/analytics/lib/analytics'; -vi.mock('@/platform/analytics/lib/analytics', () => ({ +vi.mock('@/infra/analytics/lib/analytics', () => ({ trackEvent: vi.fn(), })); diff --git a/platform/analytics/components/DwellTimeTracker.tsx b/infra/analytics/components/DwellTimeTracker.tsx similarity index 97% rename from platform/analytics/components/DwellTimeTracker.tsx rename to infra/analytics/components/DwellTimeTracker.tsx index f029643..7bb2899 100644 --- a/platform/analytics/components/DwellTimeTracker.tsx +++ b/infra/analytics/components/DwellTimeTracker.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef } from 'react'; -import { trackEvent } from '@/platform/analytics/lib/analytics'; +import { trackEvent } from '@/infra/analytics/lib/analytics'; interface DwellTimeTrackerProps { slug: string; diff --git a/platform/analytics/components/PostViewTracker.tsx b/infra/analytics/components/PostViewTracker.tsx similarity index 87% rename from platform/analytics/components/PostViewTracker.tsx rename to infra/analytics/components/PostViewTracker.tsx index 8da54f6..bfac078 100644 --- a/platform/analytics/components/PostViewTracker.tsx +++ b/infra/analytics/components/PostViewTracker.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { trackEvent } from '@/platform/analytics/lib/analytics'; +import { trackEvent } from '@/infra/analytics/lib/analytics'; interface PostViewTrackerProps { slug: string; diff --git a/platform/analytics/components/ScrollDepthTracker.tsx b/infra/analytics/components/ScrollDepthTracker.tsx similarity index 96% rename from platform/analytics/components/ScrollDepthTracker.tsx rename to infra/analytics/components/ScrollDepthTracker.tsx index 5bfa8f9..495e805 100644 --- a/platform/analytics/components/ScrollDepthTracker.tsx +++ b/infra/analytics/components/ScrollDepthTracker.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef, useCallback } from 'react'; -import { trackEvent } from '@/platform/analytics/lib/analytics'; +import { trackEvent } from '@/infra/analytics/lib/analytics'; interface ScrollDepthTrackerProps { slug: string; diff --git a/platform/analytics/components/UmamiAnalytics.tsx b/infra/analytics/components/UmamiAnalytics.tsx similarity index 85% rename from platform/analytics/components/UmamiAnalytics.tsx rename to infra/analytics/components/UmamiAnalytics.tsx index cec40dd..db1a56d 100644 --- a/platform/analytics/components/UmamiAnalytics.tsx +++ b/infra/analytics/components/UmamiAnalytics.tsx @@ -1,7 +1,7 @@ 'use client'; import Script from 'next/script'; -import { flushQueuedUmamiEvents } from '@/platform/analytics/lib/analytics'; +import { flushQueuedUmamiEvents } from '@/infra/analytics/lib/analytics'; const UMAMI_URL = process.env.NEXT_PUBLIC_UMAMI_URL; const UMAMI_WEBSITE_ID = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID; diff --git a/platform/analytics/lib/analytics.test.ts b/infra/analytics/lib/analytics.test.ts similarity index 100% rename from platform/analytics/lib/analytics.test.ts rename to infra/analytics/lib/analytics.test.ts diff --git a/platform/analytics/lib/analytics.ts b/infra/analytics/lib/analytics.ts similarity index 100% rename from platform/analytics/lib/analytics.ts rename to infra/analytics/lib/analytics.ts diff --git a/platform/index.ts b/infra/index.ts similarity index 100% rename from platform/index.ts rename to infra/index.ts diff --git a/platform/integrations/supabase.test.ts b/infra/integrations/supabase.test.ts similarity index 100% rename from platform/integrations/supabase.test.ts rename to infra/integrations/supabase.test.ts diff --git a/platform/integrations/supabase.ts b/infra/integrations/supabase.ts similarity index 100% rename from platform/integrations/supabase.ts rename to infra/integrations/supabase.ts diff --git a/platform/seo/JsonLd.tsx b/infra/seo/JsonLd.tsx similarity index 100% rename from platform/seo/JsonLd.tsx rename to infra/seo/JsonLd.tsx diff --git a/next.config.mjs b/next.config.mjs index 027ebab..598dc29 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -12,9 +12,6 @@ const prettyCodeOptions = { defaultLang: 'plaintext', }; -const agentationProxyTarget = - process.env.AGENTATION_PROXY_TARGET ?? 'http://127.0.0.1:4747'; - /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, @@ -36,18 +33,6 @@ const nextConfig = { }, ]; }, - async rewrites() { - if (process.env.NODE_ENV === 'production') { - return []; - } - - return [ - { - source: '/api/agentation-sync/:path*', - destination: `${agentationProxyTarget}/:path*`, - }, - ]; - }, async headers() { return [ { diff --git a/package-lock.json b/package-lock.json index cd73985..982edb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,10 @@ "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@next/bundle-analyzer": "^16.1.4", - "@next/mdx": "^16.1.5", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@supabase/supabase-js": "^2.95.3", "@tailwindcss/typography": "^0.5.19", - "@vercel/og": "^0.8.6", "cheerio": "^1.1.2", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -53,8 +51,6 @@ "@types/three": "^0.170.0", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.18", - "agentation": "^2.2.1", - "agentation-mcp": "^1.2.0", "autoprefixer": "^10.4.23", "chalk": "^5.6.2", "cspell": "^9.6.4", @@ -1837,19 +1833,6 @@ "react-dom": "^16 || ^17 || ^18 || ^19" } }, - "node_modules/@hono/node-server": { - "version": "1.19.10", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz", - "integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2557,71 +2540,6 @@ "langium": "3.3.1" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.27.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", - "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@monogrid/gainmap-js": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", @@ -2668,36 +2586,6 @@ "glob": "10.3.10" } }, - "node_modules/@next/mdx": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-16.1.5.tgz", - "integrity": "sha512-TYzfGfZiXtf6HXZpqJoKq+2DRB1FjY9BR1HWhfl7WoSW/BAEr6X+WmdrdrCtqNpkY8VSoWHVWP0KNbyTqY7ZTA==", - "license": "MIT", - "dependencies": { - "source-map": "^0.7.0" - }, - "peerDependencies": { - "@mdx-js/loader": ">=0.15.0", - "@mdx-js/react": ">=0.15.0" - }, - "peerDependenciesMeta": { - "@mdx-js/loader": { - "optional": true - }, - "@mdx-js/react": { - "optional": true - } - } - }, - "node_modules/@next/mdx/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, "node_modules/@next/swc-darwin-arm64": { "version": "16.1.4", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.4.tgz", @@ -3082,15 +2970,6 @@ } } }, - "node_modules/@resvg/resvg-wasm": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz", - "integrity": "sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==", - "license": "MPL-2.0", - "engines": { - "node": ">= 10" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -3527,28 +3406,6 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, - "node_modules/@shuding/opentype.js": { - "version": "1.4.0-beta.0", - "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", - "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", - "license": "MIT", - "dependencies": { - "fflate": "^0.7.3", - "string.prototype.codepointat": "^0.2.1" - }, - "bin": { - "ot": "bin/ot" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/@shuding/opentype.js/node_modules/fflate": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", - "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", - "license": "MIT" - }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -5084,19 +4941,6 @@ "react": ">= 16.8.0" } }, - "node_modules/@vercel/og": { - "version": "0.8.6", - "resolved": "https://registry.npmjs.org/@vercel/og/-/og-0.8.6.tgz", - "integrity": "sha512-hBcWIOppZV14bi+eAmCZj8Elj8hVSUZJTpf1lgGBhVD85pervzQ1poM/qYfFUlPraYSZYP+ASg6To5BwYmUSGQ==", - "license": "MPL-2.0", - "dependencies": { - "@resvg/resvg-wasm": "2.4.0", - "satori": "0.16.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/@vitejs/plugin-react": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", @@ -5265,20 +5109,6 @@ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==" }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5319,53 +5149,6 @@ "node": ">= 14" } }, - "node_modules/agentation": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/agentation/-/agentation-2.2.1.tgz", - "integrity": "sha512-yV9P1DggI7M3SRaRwLwt+xqE5lXqg5l8xtqCr8KzEkbnH8Wa6eRATU97uKnD7cC8FrsJP62Mmw0Xf5Xi5KV50Q==", - "dev": true, - "license": "PolyForm-Shield-1.0.0", - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/agentation-mcp": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/agentation-mcp/-/agentation-mcp-1.2.0.tgz", - "integrity": "sha512-BRHVm/YRyAHzJFM8i+ZbgpLgpnVptyKeUyauYd2pkOBX/oVFYGOjjLU+YtpNZD1Np+5/jJAp9xFbv1SE112Wwg==", - "dev": true, - "license": "PolyForm-Shield-1.0.0", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "better-sqlite3": "^12.6.2", - "zod": "^3.23.0" - }, - "bin": { - "agentation-mcp": "dist/cli.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/agentation-mcp/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5382,48 +5165,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -5803,21 +5544,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/better-sqlite3": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -5826,95 +5552,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -6000,16 +5637,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -6066,15 +5693,6 @@ "node": ">=6" } }, - "node_modules/camelize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", - "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/camera-controls": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", @@ -6264,13 +5882,6 @@ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "license": "MIT" }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC" - }, "node_modules/clear-module": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz", @@ -6483,30 +6094,6 @@ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "license": "MIT" }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6514,26 +6101,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -6541,24 +6108,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -6807,36 +6356,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/css-background-parser": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", - "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==", - "license": "MIT" - }, - "node_modules/css-box-shadow": { - "version": "1.0.0-3", - "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", - "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==", - "license": "MIT" - }, - "node_modules/css-color-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", - "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", - "license": "ISC", - "engines": { - "node": ">=4" - } - }, - "node_modules/css-gradient-parser": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.16.tgz", - "integrity": "sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -6852,17 +6371,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/css-to-react-native": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", - "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", - "license": "MIT", - "dependencies": { - "camelize": "^1.0.0", - "css-color-keywords": "^1.0.0", - "postcss-value-parser": "^4.0.2" - } - }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -7544,32 +7052,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7619,16 +7101,6 @@ "robust-predicates": "^3.0.2" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -7779,13 +7251,6 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -7798,25 +7263,6 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, - "node_modules/emoji-regex-xs": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", - "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -7829,16 +7275,6 @@ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -8144,12 +7580,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -8714,16 +8144,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -8731,39 +8151,6 @@ "dev": true, "license": "MIT" }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -8774,69 +8161,6 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -8911,23 +8235,6 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -8971,13 +8278,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8991,28 +8291,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -9080,16 +8358,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -9130,23 +8398,6 @@ } } }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9332,13 +8583,6 @@ "lit": "^3.2.1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT" - }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -9875,34 +9119,12 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hex-rgb": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", - "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/hls.js": { "version": "1.6.15", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", "license": "Apache-2.0" }, - "node_modules/hono": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", - "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -9958,27 +9180,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -10180,26 +9381,6 @@ "node": ">=12" } }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -10798,16 +9979,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -10912,13 +10083,6 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -11383,25 +10547,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/linebreak": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", - "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", - "license": "MIT", - "dependencies": { - "base64-js": "0.0.8", - "unicode-trie": "^2.0.0" - } - }, - "node_modules/linebreak/node_modules/base64-js": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", - "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -12273,29 +11418,6 @@ "dev": true, "license": "MIT" }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -13111,55 +12233,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, "engines": { "node": ">=18" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -13204,13 +12286,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT" - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -13279,13 +12354,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, - "license": "MIT" - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -13307,16 +12375,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/next": { "version": "16.1.4", "resolved": "https://registry.npmjs.org/next/-/next-16.1.4.tgz", @@ -13407,19 +12465,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -13569,19 +12614,6 @@ ], "license": "MIT" }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -13702,12 +12734,6 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, - "node_modules/pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", - "license": "MIT" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13720,16 +12746,6 @@ "node": ">=6" } }, - "node_modules/parse-css-color": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", - "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.1.4", - "hex-rgb": "^4.1.0" - } - }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -13806,16 +12822,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -13870,17 +12876,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/path-type": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", @@ -13930,16 +12925,6 @@ "node": ">=0.10" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -14076,34 +13061,6 @@ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", "license": "ISC" }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -14211,31 +13168,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -14255,22 +13187,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -14291,82 +13207,6 @@ } ] }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -14417,21 +13257,6 @@ } } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/recma-build-jsx": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", @@ -14933,30 +13758,6 @@ "points-on-path": "^0.2.1" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -15011,27 +13812,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -15070,28 +13850,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/satori": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/satori/-/satori-0.16.0.tgz", - "integrity": "sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ==", - "license": "MPL-2.0", - "dependencies": { - "@shuding/opentype.js": "1.4.0-beta.0", - "css-background-parser": "^0.1.0", - "css-box-shadow": "1.0.0-3", - "css-gradient-parser": "^0.0.16", - "css-to-react-native": "^3.0.0", - "emoji-regex-xs": "^2.0.1", - "escape-html": "^1.0.3", - "linebreak": "^1.1.0", - "parse-css-color": "^0.2.1", - "postcss-value-parser": "^4.2.0", - "yoga-layout": "^3.2.1" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -15134,53 +13892,6 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15227,13 +13938,6 @@ "node": ">= 0.4" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -15404,53 +14108,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -15614,16 +14271,6 @@ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -15644,16 +14291,6 @@ "node": ">= 0.4" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -15729,12 +14366,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/string.prototype.codepointat": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", - "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", - "license": "MIT" - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -16028,36 +14659,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -16102,12 +14703,6 @@ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", "license": "MIT" }, - "node_modules/tiny-inflate": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", - "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", - "license": "MIT" - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -16187,16 +14782,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -16327,19 +14912,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/tunnel-rat": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", @@ -16401,21 +14973,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -16547,16 +15104,6 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, - "node_modules/unicode-trie": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", - "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", - "license": "MIT", - "dependencies": { - "pako": "^0.2.5", - "tiny-inflate": "^1.0.0" - } - }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", @@ -16665,16 +15212,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -16785,16 +15322,6 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -17463,12 +15990,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoga-layout": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", - "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", - "license": "MIT" - }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", @@ -17478,16 +15999,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, "node_modules/zustand": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", diff --git a/package.json b/package.json index 1a35e8c..2197e3a 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "node tooling/scripts/dev-with-agentation.mjs", - "dev:next": "next dev --webpack", - "dev:agentation": "agentation-mcp server", + "dev": "next dev --webpack", "build": "next build --webpack", "start": "next start", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", @@ -15,11 +13,11 @@ "analyze": "ANALYZE=true next build --webpack", "test": "vitest", "test:unit": "vitest run", - "test:components": "vitest run blog resume search shared site platform styles", + "test:components": "vitest run blog resume search ui site infra styles", "test:coverage": "vitest run --coverage", "test:ci": "npm run lint && npm run lint:css:syntax && npm run verify:docs && npm run test:unit && npm run test:e2e", "test:e2e": "npx playwright test", - "test:smoke": "vitest run shared/**/*.test.ts shared/**/*.test.tsx blog/api/*.test.ts && npx playwright test --grep @smoke", + "test:smoke": "vitest run ui/**/*.test.ts ui/**/*.test.tsx blog/api/*.test.ts && npx playwright test --grep @smoke", "new-post": "node tooling/scripts/posts/new-post.js", "content:audit": "node tooling/scripts/posts/audit-post-quality.mjs", "localize:images": "node tooling/scripts/posts/localize-post-images.mjs", @@ -47,12 +45,10 @@ "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@next/bundle-analyzer": "^16.1.4", - "@next/mdx": "^16.1.5", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@supabase/supabase-js": "^2.95.3", "@tailwindcss/typography": "^0.5.19", - "@vercel/og": "^0.8.6", "cheerio": "^1.1.2", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -85,8 +81,6 @@ "@types/three": "^0.170.0", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.18", - "agentation": "^2.2.1", - "agentation-mcp": "^1.2.0", "autoprefixer": "^10.4.23", "chalk": "^5.6.2", "cspell": "^9.6.4", diff --git a/platform/devtools/AgentationOverlay.test.tsx b/platform/devtools/AgentationOverlay.test.tsx deleted file mode 100644 index 4b0243d..0000000 --- a/platform/devtools/AgentationOverlay.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import AgentationOverlay from './AgentationOverlay'; - -vi.mock('agentation', () => ({ - Agentation: ({ - endpoint, - webhookUrl, - }: { - endpoint: string; - webhookUrl: string; - }) => ( -
- ), -})); - -describe('AgentationOverlay', () => { - const originalFetch = global.fetch; - - beforeEach(() => { - vi.stubEnv('NODE_ENV', 'development'); - }); - - afterEach(() => { - global.fetch = originalFetch; - vi.unstubAllEnvs(); - vi.restoreAllMocks(); - }); - - it('does not render the overlay when health check fails', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: false, - json: async () => ({ ok: false }), - } as Response); - - global.fetch = fetchMock; - - render(); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith('/api/agentation/health', { - cache: 'no-store', - }); - }); - - expect(screen.queryByTestId('agentation-overlay')).not.toBeInTheDocument(); - }); - - it('renders the overlay when health check succeeds', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ ok: true }), - } as Response); - - global.fetch = fetchMock; - - render(); - - const overlay = await screen.findByTestId('agentation-overlay'); - - expect(overlay).toHaveAttribute( - 'data-endpoint', - '/api/agentation-sync' - ); - expect(overlay).toHaveAttribute( - 'data-webhook-url', - '/api/agentation/webhook' - ); - }); - - it('passes through a custom endpoint when configured', async () => { - vi.stubEnv( - 'NEXT_PUBLIC_AGENTATION_ENDPOINT', - 'https://agentation.example.com' - ); - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ ok: true }), - } as Response); - - global.fetch = fetchMock; - - render(); - - const overlay = await screen.findByTestId('agentation-overlay'); - - expect(overlay).toHaveAttribute( - 'data-endpoint', - 'https://agentation.example.com' - ); - }); -}); diff --git a/platform/devtools/AgentationOverlay.tsx b/platform/devtools/AgentationOverlay.tsx deleted file mode 100644 index 78ab1f8..0000000 --- a/platform/devtools/AgentationOverlay.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Agentation } from 'agentation'; - -const DEFAULT_AGENTATION_ENDPOINT = '/api/agentation-sync'; -const DEFAULT_AGENTATION_WEBHOOK_URL = '/api/agentation/webhook'; -const AGENTATION_HEALTHCHECK_URL = '/api/agentation/health'; -const HEALTHCHECK_INTERVAL_MS = 30000; - -type AgentationHealthResponse = { - ok?: boolean; -}; - -export default function AgentationOverlay() { - const isDevelopment = process.env.NODE_ENV === 'development'; - const isAutomation = - typeof navigator !== 'undefined' && navigator.webdriver === true; - const [isAvailable, setIsAvailable] = useState(false); - - useEffect(() => { - if (!isDevelopment || isAutomation) { - setIsAvailable(false); - return; - } - - let isMounted = true; - - async function checkAvailability() { - try { - const response = await fetch(AGENTATION_HEALTHCHECK_URL, { - cache: 'no-store', - }); - const data = (await response.json()) as AgentationHealthResponse; - - if (!isMounted) { - return; - } - - setIsAvailable(response.ok && data.ok === true); - } catch { - if (isMounted) { - setIsAvailable(false); - } - } - } - - void checkAvailability(); - - const intervalId = window.setInterval(() => { - void checkAvailability(); - }, HEALTHCHECK_INTERVAL_MS); - - return () => { - isMounted = false; - window.clearInterval(intervalId); - }; - }, [isAutomation, isDevelopment]); - - if (!isDevelopment || isAutomation || !isAvailable) { - return null; - } - - return ( - - ); -} diff --git a/resume/ui/pages/ResumePage.tsx b/resume/ui/pages/ResumePage.tsx index 84d5f0f..f542dbf 100644 --- a/resume/ui/pages/ResumePage.tsx +++ b/resume/ui/pages/ResumePage.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import type { ReactNode } from 'react'; -import { Container } from '@/shared/layout'; +import { Container } from '@/ui/layout'; import { activities, certifications, diff --git a/search/model/get-search-actions.test.ts b/search/model/get-search-actions.test.ts index 31345d1..d678926 100644 --- a/search/model/get-search-actions.test.ts +++ b/search/model/get-search-actions.test.ts @@ -3,7 +3,7 @@ import { getSearchActions } from './get-search-actions'; const mockTrackEvent = vi.fn(); -vi.mock('@/platform/analytics/lib/analytics', () => ({ +vi.mock('@/infra/analytics/lib/analytics', () => ({ AnalyticsEvents: { click: 'click', theme: 'theme', diff --git a/search/model/get-search-actions.ts b/search/model/get-search-actions.ts index 39c15bf..8032056 100644 --- a/search/model/get-search-actions.ts +++ b/search/model/get-search-actions.ts @@ -1,6 +1,6 @@ import { Action } from 'kbar'; import { FeedData } from '@/blog/model/types'; -import { AnalyticsEvents, trackEvent } from '@/platform/analytics/lib/analytics'; +import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; type SearchablePost = Pick< FeedData, diff --git a/search/ui/components/CommandPalette/CommandPalette.tsx b/search/ui/components/CommandPalette/CommandPalette.tsx index 93d222f..4c43786 100644 --- a/search/ui/components/CommandPalette/CommandPalette.tsx +++ b/search/ui/components/CommandPalette/CommandPalette.tsx @@ -12,7 +12,7 @@ import { } from 'kbar'; import styles from './CommandPalette.module.css'; import type { FeedData } from '@/blog/model/types'; -import { AnalyticsEvents, trackEvent } from '@/platform/analytics/lib/analytics'; +import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; import { getRecommendedSearchTerms } from '@/search/model/search-recommendations'; type SearchScopeId = 'all' | 'tech' | 'life' | 'resume'; diff --git a/shared/index.ts b/shared/index.ts deleted file mode 100644 index b76d748..0000000 --- a/shared/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Container, type ContainerProps } from './layout'; -export { Button, EmptyState, RouteError } from './ui'; diff --git a/site/config/site.ts b/site/config/site.ts index ecc77b5..43e7d4a 100644 --- a/site/config/site.ts +++ b/site/config/site.ts @@ -1,6 +1,8 @@ export const SITE_URL = 'https://eunu-log.vercel.app'; export const SITE_NAME = 'eunu.log'; export const SITE_DESCRIPTION = '데이터와 시스템, 창의적인 것들을 만듭니다'; +export const SITE_FEED_PATH = '/rss.xml'; +export const SITE_FEED_URL = `${SITE_URL}${SITE_FEED_PATH}`; export const SITE_AUTHOR = { name: 'dev-wooyeon', diff --git a/site/home/ui/pages/HomePageClient.test.tsx b/site/home/ui/pages/HomePageClient.test.tsx index af131ed..b6cf488 100644 --- a/site/home/ui/pages/HomePageClient.test.tsx +++ b/site/home/ui/pages/HomePageClient.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { FeedData } from '@/blog/model/types'; import HomePageClient from './HomePageClient'; -vi.mock('@/shared/layout', () => ({ +vi.mock('@/ui/layout', () => ({ Container: ({ children, className, diff --git a/site/home/ui/pages/HomePageClient.tsx b/site/home/ui/pages/HomePageClient.tsx index fd73608..c4b1c6f 100644 --- a/site/home/ui/pages/HomePageClient.tsx +++ b/site/home/ui/pages/HomePageClient.tsx @@ -14,7 +14,7 @@ import { type HomePopularView, type HomeSortOrder, } from '@/site/home/model/home-feed'; -import { Container } from '@/shared/layout'; +import { Container } from '@/ui/layout'; interface HomePageClientProps { posts: FeedData[]; diff --git a/site/layout/PageTransition.tsx b/site/layout/PageTransition.tsx index fb94cce..96ecf4d 100644 --- a/site/layout/PageTransition.tsx +++ b/site/layout/PageTransition.tsx @@ -3,7 +3,7 @@ import { motion } from 'framer-motion'; import { usePathname } from 'next/navigation'; import { useRef, useEffect, useState } from 'react'; -import { useMotionMode } from '@/shared/motion/model/motion-mode'; +import { useMotionMode } from '@/ui/motion/model/motion-mode'; // 라우트 깊이 계산 (홈 = 0, /feed = 1, /feed/[slug] = 2) function getRouteDepth(pathname: string): number { diff --git a/site/navigation/MobileBottomNav.test.tsx b/site/navigation/MobileBottomNav.test.tsx index 817297a..6bcb007 100644 --- a/site/navigation/MobileBottomNav.test.tsx +++ b/site/navigation/MobileBottomNav.test.tsx @@ -20,7 +20,7 @@ vi.mock('next/link', () => ({ ), })); -vi.mock('@/platform/analytics/lib/analytics', () => { +vi.mock('@/infra/analytics/lib/analytics', () => { return { AnalyticsEvents: { click: 'click', diff --git a/site/navigation/MobileBottomNav.tsx b/site/navigation/MobileBottomNav.tsx index 8c46cbb..1f7724c 100644 --- a/site/navigation/MobileBottomNav.tsx +++ b/site/navigation/MobileBottomNav.tsx @@ -3,8 +3,9 @@ import { useEffect, useMemo } from 'react'; import Link from 'next/link'; import { clsx } from 'clsx'; -import { AnalyticsEvents, trackEvent } from '@/platform/analytics/lib/analytics'; -import { AppSectionIcon } from '@/shared/ui/icons/AppSectionIcon'; +import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; +import { SITE_FEED_PATH } from '@/site/config/site'; +import { AppSectionIcon } from '@/ui/icons/AppSectionIcon'; interface MobileBottomNavProps { pathname: string; @@ -201,7 +202,7 @@ export default function MobileBottomNav({ onNavigate={() => onOpenChange?.(false)} /> onOpenChange?.(false)} /> diff --git a/site/providers/AppProviders.tsx b/site/providers/AppProviders.tsx index 035bf93..d981452 100644 --- a/site/providers/AppProviders.tsx +++ b/site/providers/AppProviders.tsx @@ -1,11 +1,10 @@ import type { ReactNode } from 'react'; import ThemeProvider from '@/site/providers/ThemeProvider'; import KBarProvider from '@/search/ui/components/KBarProvider'; -import UmamiAnalytics from '@/platform/analytics/components/UmamiAnalytics'; +import UmamiAnalytics from '@/infra/analytics/components/UmamiAnalytics'; import { getSortedFeedData } from '@/blog/services/post-repository'; -import JsonLd from '@/platform/seo/JsonLd'; +import JsonLd from '@/infra/seo/JsonLd'; import { SITE_AUTHOR, SITE_NAME, SITE_URL } from '@/site/config/site'; -import AgentationOverlay from '@/platform/devtools/AgentationOverlay'; interface AppProvidersProps { children: ReactNode; @@ -32,10 +31,7 @@ export default function AppProviders({ children }: AppProvidersProps) { }} /> - - {children} - - + {children} ); diff --git a/site/shell/AppShell/AppShell.tsx b/site/shell/AppShell/AppShell.tsx index a53bf65..7c746e9 100644 --- a/site/shell/AppShell/AppShell.tsx +++ b/site/shell/AppShell/AppShell.tsx @@ -8,10 +8,11 @@ import { useKBar } from 'kbar'; import { clsx } from 'clsx'; import type { FeedData } from '@/blog/model/types'; import { personalInfo } from '@/resume/model/resume-data'; +import { SITE_FEED_PATH } from '@/site/config/site'; import MobileBottomNav from '@/site/navigation/MobileBottomNav'; -import { AppSectionIcon } from '@/shared/ui/icons/AppSectionIcon'; -import ThemeToggle from '@/shared/ui/ThemeToggle'; -import ThemeTransitionWash from '@/shared/ui/ThemeTransitionWash'; +import { AppSectionIcon } from '@/ui/icons/AppSectionIcon'; +import ThemeToggle from '@/ui/ThemeToggle'; +import ThemeTransitionWash from '@/ui/ThemeTransitionWash'; type AppSection = 'home' | 'engineering' | 'life' | 'resume'; @@ -102,7 +103,7 @@ const EXTERNAL_LINKS: ExternalLinkItem[] = [ ), }, { - href: '/feed.xml', + href: SITE_FEED_PATH, label: 'RSS', icon: ( {item.icon} diff --git a/src/app/api/agentation/health/route.ts b/src/app/api/agentation/health/route.ts deleted file mode 100644 index d377b3e..0000000 --- a/src/app/api/agentation/health/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { NextResponse } from 'next/server'; - -export const runtime = 'nodejs'; - -const DEFAULT_AGENTATION_TARGET = 'http://127.0.0.1:4747'; -const DEFAULT_AGENTATION_ENDPOINT = '/api/agentation-sync'; -const AGENTATION_PROXY_TARGET = - process.env.AGENTATION_PROXY_TARGET ?? DEFAULT_AGENTATION_TARGET; -const AGENTATION_ENDPOINT = - process.env.NEXT_PUBLIC_AGENTATION_ENDPOINT ?? DEFAULT_AGENTATION_ENDPOINT; -const HEALTHCHECK_TIMEOUT_MS = 1500; - -function buildHealthUrl(): string { - if ( - AGENTATION_ENDPOINT.startsWith('http://') || - AGENTATION_ENDPOINT.startsWith('https://') - ) { - return new URL('/health', AGENTATION_ENDPOINT).toString(); - } - - try { - return new URL('/health', AGENTATION_PROXY_TARGET).toString(); - } catch { - return new URL('/health', DEFAULT_AGENTATION_TARGET).toString(); - } -} - -function jsonResponse(ok: boolean, status: number) { - return NextResponse.json( - { ok }, - { - status, - headers: { - 'Cache-Control': 'no-store', - }, - } - ); -} - -export async function GET() { - if (process.env.NODE_ENV !== 'development') { - return jsonResponse(false, 404); - } - - try { - const response = await fetch(buildHealthUrl(), { - method: 'GET', - cache: 'no-store', - signal: AbortSignal.timeout(HEALTHCHECK_TIMEOUT_MS), - }); - - return jsonResponse(response.ok, response.ok ? 200 : 503); - } catch { - return jsonResponse(false, 503); - } -} diff --git a/src/app/api/agentation/webhook/route.ts b/src/app/api/agentation/webhook/route.ts deleted file mode 100644 index 8ffffc1..0000000 --- a/src/app/api/agentation/webhook/route.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { spawn } from 'node:child_process'; -import { closeSync, existsSync, openSync, statSync } from 'node:fs'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { NextResponse } from 'next/server'; - -export const runtime = 'nodejs'; - -type AgentationWebhookPayload = { - event?: string; - annotation?: { - id?: string; - comment?: string; - sessionId?: string; - timestamp?: number; - url?: string; - element?: string; - elementPath?: string; - nearbyText?: string; - selectedText?: string; - }; - timestamp?: number; - url?: string; -}; - -type AutorunLock = { - pid: number; - startedAt: string; - event: string; - annotationId?: string; -}; - -const AUTO_EVENTS = new Set(['annotation.add', 'submit']); -const AUTORUN_DIR = path.join(process.cwd(), '.agentation'); -const LOCK_PATH = path.join(AUTORUN_DIR, 'autorun.lock.json'); -const LOG_PATH = path.join(AUTORUN_DIR, 'autorun.log'); -const AUTORUN_MAX_AGE_MS = Number( - process.env.AGENTATION_AUTORUN_MAX_AGE_MS ?? '120000' -); -const AUTORUN_IDLE_TIMEOUT_MS = Number( - process.env.AGENTATION_AUTORUN_IDLE_TIMEOUT_MS ?? '45000' -); -const DEFAULT_PROMPT = - '방금 등록된 Agentation annotation 한 건을 처리해줘. webhook에 담긴 코멘트와 위치 정보를 기준으로 관련 코드만 최소 수정하고, 필요한 테스트만 검증한 뒤 resolve 처리하고 바로 종료해줘. 브라우저를 새로 열거나 추가 입력을 기다리거나 watch mode로 머물지 말아줘. pending 조회에서 바로 안 보여도 아래 정보를 기준으로 해당 요청을 찾아 처리해줘.'; - -function buildAutorunPrompt(payload: AgentationWebhookPayload): string { - const annotation = payload.annotation; - const details = [ - annotation?.id ? `- webhook annotation id: ${annotation.id}` : null, - annotation?.comment ? `- comment: ${annotation.comment}` : null, - annotation?.sessionId ? `- sessionId: ${annotation.sessionId}` : null, - annotation?.url || payload.url - ? `- url: ${annotation?.url ?? payload.url}` - : null, - annotation?.element ? `- element: ${annotation.element}` : null, - annotation?.elementPath ? `- elementPath: ${annotation.elementPath}` : null, - annotation?.nearbyText ? `- nearbyText: ${annotation.nearbyText}` : null, - annotation?.selectedText - ? `- selectedText: ${annotation.selectedText}` - : null, - annotation?.timestamp - ? `- annotation timestamp: ${annotation.timestamp}` - : null, - payload.timestamp ? `- event timestamp: ${payload.timestamp}` : null, - ].filter((value): value is string => Boolean(value)); - - if (details.length === 0) { - return DEFAULT_PROMPT; - } - - return `${DEFAULT_PROMPT}\n\n다음 정보를 참고해줘:\n${details.join('\n')}`; -} - -function isPidRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function getLockAgeMs(lock: AutorunLock): number { - const startedAt = new Date(lock.startedAt).getTime(); - - if (Number.isNaN(startedAt)) { - return Number.POSITIVE_INFINITY; - } - - return Date.now() - startedAt; -} - -function getAutorunIdleMs(): number { - try { - return Date.now() - statSync(LOG_PATH).mtimeMs; - } catch { - return Number.POSITIVE_INFINITY; - } -} - -async function readLockFile(): Promise { - if (!existsSync(LOCK_PATH)) { - return null; - } - - try { - const raw = await readFile(LOCK_PATH, 'utf8'); - const parsed = JSON.parse(raw) as AutorunLock; - if (typeof parsed.pid !== 'number') { - return null; - } - return parsed; - } catch { - return null; - } -} - -async function clearStaleLock(lock: AutorunLock | null): Promise { - if (!lock) { - return; - } - - const isRunning = isPidRunning(lock.pid); - const isExpired = getLockAgeMs(lock) > AUTORUN_MAX_AGE_MS; - const isIdle = getAutorunIdleMs() > AUTORUN_IDLE_TIMEOUT_MS; - - if (isRunning && !isExpired && !isIdle) { - return; - } - - if (isRunning && (isExpired || isIdle)) { - try { - process.kill(lock.pid, 'SIGTERM'); - } catch {} - } - - if (existsSync(LOCK_PATH)) { - await rm(LOCK_PATH, { force: true }); - } -} - -async function writeLockFile(lock: AutorunLock): Promise { - await writeFile(LOCK_PATH, JSON.stringify(lock, null, 2), 'utf8'); -} - -function runAutorunCommand(payload: AgentationWebhookPayload) { - const customCommand = process.env.AGENTATION_AUTORUN_COMMAND?.trim(); - const logFd = openSync(LOG_PATH, 'a'); - try { - if (customCommand) { - return spawn(customCommand, { - cwd: process.cwd(), - shell: true, - detached: true, - stdio: ['ignore', logFd, logFd], - env: process.env, - }); - } - - const autoPrompt = buildAutorunPrompt(payload); - - return spawn( - 'codex', - ['exec', '--full-auto', '-C', process.cwd(), autoPrompt], - { - cwd: process.cwd(), - detached: true, - stdio: ['ignore', logFd, logFd], - env: process.env, - } - ); - } finally { - closeSync(logFd); - } -} - -export async function POST(request: Request) { - if (process.env.NODE_ENV !== 'development') { - return NextResponse.json( - { ok: false, reason: 'development only' }, - { status: 403 } - ); - } - - const enabled = process.env.AGENTATION_AUTORUN_ENABLED !== 'false'; - if (!enabled) { - return NextResponse.json({ - ok: true, - triggered: false, - reason: 'autorun disabled', - }); - } - - let payload: AgentationWebhookPayload; - try { - payload = (await request.json()) as AgentationWebhookPayload; - } catch { - return NextResponse.json( - { ok: false, reason: 'invalid json payload' }, - { status: 400 } - ); - } - - const event = payload.event ?? ''; - if (!AUTO_EVENTS.has(event)) { - return NextResponse.json({ - ok: true, - triggered: false, - reason: 'event ignored', - event, - }); - } - - const dryRun = new URL(request.url).searchParams.get('dryRun') === '1'; - if (dryRun) { - return NextResponse.json({ - ok: true, - triggered: true, - dryRun: true, - event, - annotationId: payload.annotation?.id ?? null, - }); - } - - await mkdir(AUTORUN_DIR, { recursive: true }); - - const lock = await readLockFile(); - await clearStaleLock(lock); - - const activeLock = await readLockFile(); - if (activeLock && isPidRunning(activeLock.pid)) { - return NextResponse.json({ - ok: true, - triggered: false, - reason: 'autorun already running', - pid: activeLock.pid, - event, - }); - } - - const child = runAutorunCommand(payload); - if (!child.pid) { - return NextResponse.json( - { ok: false, reason: 'failed to spawn autorun process' }, - { status: 500 } - ); - } - - await writeLockFile({ - pid: child.pid, - startedAt: new Date().toISOString(), - event, - annotationId: payload.annotation?.id, - }); - - child.unref(); - - return NextResponse.json({ - ok: true, - triggered: true, - pid: child.pid, - event, - annotationId: payload.annotation?.id ?? null, - }); -} diff --git a/src/app/feed.xml/route.ts b/src/app/feed.xml/route.ts deleted file mode 100644 index 7d5cc0a..0000000 --- a/src/app/feed.xml/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { getSortedFeedData } from '@/blog/services/post-repository'; - -const SITE_URL = 'https://eunu-log.vercel.app'; - -export async function GET() { - const allPosts = getSortedFeedData(); - - const feedItems = allPosts - .map((post) => { - const postUrl = `${SITE_URL}/blog/${post.slug}`; - const pubDate = new Date(post.date).toUTCString(); - - return ` - - ${post.title} - ${postUrl} - ${postUrl} - ${pubDate} - ${post.description} - ${post.category} - `; - }) - .join(''); - - const rss = ` - - - eunu.log - ${SITE_URL} - 데이터와 시스템, 창의적인 것들을 만듭니다 - ko-KR - Copyright ${new Date().getFullYear()}, eunu.log - ${new Date().toUTCString()} - - ${feedItems} - -`; - - return new Response(rss, { - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=1800', - }, - }); -} diff --git a/tailwind.config.js b/tailwind.config.js index 49c69d0..fc216b9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,10 +3,15 @@ import typography from '@tailwindcss/typography'; /** @type {import('tailwindcss').Config} */ const config = { content: [ - './src/pages/**/*.{js,ts,jsx,tsx,mdx}', - './src/components/**/*.{js,ts,jsx,tsx,mdx}', - './src/app/**/*.{js,ts,jsx,tsx,mdx}', - './feeds/**/*.{md,mdx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + './blog/**/*.{js,ts,jsx,tsx,mdx}', + './infra/**/*.{js,ts,jsx,tsx,mdx}', + './resume/**/*.{js,ts,jsx,tsx,mdx}', + './search/**/*.{js,ts,jsx,tsx,mdx}', + './ui/**/*.{js,ts,jsx,tsx,mdx}', + './site/**/*.{js,ts,jsx,tsx,mdx}', + './styles/**/*.{css,js,ts}', + './posts/**/*.{md,mdx}', ], theme: { extend: { diff --git a/shared/testing/dom-mocks.ts b/tests/support/dom-mocks.ts similarity index 100% rename from shared/testing/dom-mocks.ts rename to tests/support/dom-mocks.ts diff --git a/shared/testing/server-only.ts b/tests/support/server-only.ts similarity index 100% rename from shared/testing/server-only.ts rename to tests/support/server-only.ts diff --git a/tooling/scripts/check-css-syntax.mjs b/tooling/scripts/check-css-syntax.mjs index cd5d1be..f310390 100644 --- a/tooling/scripts/check-css-syntax.mjs +++ b/tooling/scripts/check-css-syntax.mjs @@ -3,7 +3,16 @@ import path from 'path'; import postcss from 'postcss'; const ROOT = process.cwd(); -const TARGET_DIRS = ['src', 'blog', 'search', 'shared', 'site', 'styles']; +const TARGET_DIRS = [ + 'app', + 'blog', + 'infra', + 'resume', + 'search', + 'ui', + 'site', + 'styles', +]; async function collectCssFiles(dirPath) { const entries = await fs.readdir(dirPath, { withFileTypes: true }); diff --git a/tooling/scripts/dev-with-agentation.mjs b/tooling/scripts/dev-with-agentation.mjs deleted file mode 100644 index 54d3ab0..0000000 --- a/tooling/scripts/dev-with-agentation.mjs +++ /dev/null @@ -1,102 +0,0 @@ -import { spawn } from 'node:child_process'; - -const isWindows = process.platform === 'win32'; -const nextCommand = isWindows ? 'next.cmd' : 'next'; -const agentationCommand = isWindows ? 'agentation-mcp.cmd' : 'agentation-mcp'; -const shouldStartAgentation = - process.env.AGENTATION_AUTOSTART !== 'false'; - -let isShuttingDown = false; -let nextExited = false; - -function spawnProcess(command, args) { - return spawn(command, args, { - stdio: 'inherit', - env: process.env, - }); -} - -const nextProcess = spawnProcess(nextCommand, ['dev', '--webpack']); - -const agentationProcess = shouldStartAgentation - ? spawnProcess(agentationCommand, ['server']) - : null; - -function stopProcess(child) { - if (!child || child.killed) { - return; - } - - try { - child.kill('SIGTERM'); - } catch {} -} - -function shutdown(exitCode = 0) { - if (isShuttingDown) { - return; - } - - isShuttingDown = true; - stopProcess(agentationProcess); - - if (!nextExited) { - stopProcess(nextProcess); - } - - process.exit(exitCode); -} - -if (agentationProcess) { - agentationProcess.on('exit', (code, signal) => { - if (isShuttingDown) { - return; - } - - if (code === 0 || signal === 'SIGTERM') { - return; - } - - console.warn( - `[dev] agentation-mcp server exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}). Next dev server will keep running.` - ); - }); - - agentationProcess.on('error', (error) => { - if (isShuttingDown) { - return; - } - - console.warn( - `[dev] failed to start agentation-mcp server: ${error.message}` - ); - }); -} - -nextProcess.on('exit', (code, signal) => { - nextExited = true; - - if (isShuttingDown) { - process.exit(code ?? 0); - } - - stopProcess(agentationProcess); - - if (signal) { - process.kill(process.pid, signal); - return; - } - - process.exit(code ?? 0); -}); - -nextProcess.on('error', (error) => { - console.error(`[dev] failed to start next dev server: ${error.message}`); - shutdown(1); -}); - -for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => { - shutdown(0); - }); -} diff --git a/tooling/scripts/research/build_nekaracuba_corpus.py b/tooling/scripts/research/build_nekaracuba_corpus.py deleted file mode 100644 index 738b98a..0000000 --- a/tooling/scripts/research/build_nekaracuba_corpus.py +++ /dev/null @@ -1,482 +0,0 @@ -#!/usr/bin/env python3 -"""Build NEKARACUBA benchmark corpus for frontend/UIUX/design analysis.""" - -from __future__ import annotations - -import json -import re -from collections import Counter, defaultdict -from dataclasses import dataclass -from datetime import datetime, timezone -from email.utils import parsedate_to_datetime -from pathlib import Path -from typing import Any -from urllib.parse import urlparse, urlunparse -from xml.etree import ElementTree as ET - -import requests -from bs4 import BeautifulSoup - -ROOT = Path(__file__).resolve().parents[2] -OUT_JSONL = ROOT / "docs/research/benchmark/benchmark-nekaracuba-corpus-2026.jsonl" -OUT_SUMMARY_MD = ROOT / "docs/research/benchmark/benchmark-nekaracuba-summary-2026.md" - -TARGET_COUNT = 100 -MIN_PER_COMPANY = 15 -TIMEOUT = 20 -USER_AGENT = "Mozilla/5.0 (compatible; eunu-log-corpus-bot/1.0)" - -COMPANY_ORDER = ["NAVER", "KAKAO", "LINE", "COUPANG", "BAEMIN"] - -TOPIC_KEYWORDS = { - "frontend": [ - "frontend", - "front-end", - "프론트엔드", - "web", - "react", - "next.js", - "javascript", - "typescript", - "ui 개발", - "client", - ], - "uiux": [ - "ux", - "ui", - "사용자 경험", - "접근성", - "a11y", - "인터랙션", - "usability", - "research", - ], - "design": [ - "design", - "디자인", - "design system", - "디자인 시스템", - "token", - "토큰", - "컴포넌트", - "component", - ], -} - -DEFAULT_HEADERS = {"User-Agent": USER_AGENT} - - -@dataclass -class Record: - company: str - source: str - title: str - link: str - date: str - topic: str - tags: list[str] - evidence: str - - -def normalize_url(url: str) -> str: - parsed = urlparse(url.strip()) - netloc = parsed.netloc.lower() - path = re.sub(r"/+", "/", parsed.path).rstrip("/") - if not path: - path = "/" - return urlunparse((parsed.scheme, netloc, path, "", "", "")) - - -def to_iso_datetime(value: str | None) -> str: - if not value: - return "1970-01-01T00:00:00+00:00" - value = value.strip() - if not value: - return "1970-01-01T00:00:00+00:00" - try: - dt = datetime.fromisoformat(value.replace("Z", "+00:00")) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc).isoformat() - except ValueError: - pass - try: - dt = parsedate_to_datetime(value) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc).isoformat() - except (TypeError, ValueError): - return "1970-01-01T00:00:00+00:00" - - -def infer_topic(title: str, tags: list[str]) -> str: - haystack = f"{title} {' '.join(tags)}".lower() - scores: dict[str, int] = {} - for topic, keywords in TOPIC_KEYWORDS.items(): - scores[topic] = sum(1 for kw in keywords if kw in haystack) - best_topic = max(scores, key=scores.get) - if scores[best_topic] == 0: - return "other" - return best_topic - - -def get_json(url: str) -> Any: - response = requests.get(url, headers=DEFAULT_HEADERS, timeout=TIMEOUT) - response.raise_for_status() - return response.json() - - -def get_text(url: str) -> str: - response = requests.get(url, headers=DEFAULT_HEADERS, timeout=TIMEOUT) - response.raise_for_status() - return response.text - - -def fetch_naver_records() -> list[Record]: - xml_text = get_text("https://d2.naver.com/d2.atom") - root = ET.fromstring(xml_text) - ns = {"atom": "http://www.w3.org/2005/Atom"} - records: list[Record] = [] - for entry in root.findall("atom:entry", ns): - title = (entry.findtext("atom:title", default="", namespaces=ns) or "").strip() - link_node = entry.find("atom:link", ns) - link = (link_node.attrib.get("href", "").strip() if link_node is not None else "") - updated = entry.findtext("atom:updated", default="", namespaces=ns) - category = entry.find("atom:category", ns) - tags = [category.attrib.get("term", "").strip()] if category is not None else [] - tags = [t for t in tags if t] - if not title or not link: - continue - topic = infer_topic(title, tags) - records.append( - Record( - company="NAVER", - source="D2", - title=title, - link=normalize_url(link), - date=to_iso_datetime(updated), - topic=topic, - tags=tags, - evidence=f"title={title}", - ) - ) - return records - - -def fetch_kakao_records() -> list[Record]: - contents = get_json("https://if.kakao.com/api/v1/contents") - event = get_json("https://if.kakao.com/api/v1/events/2025") - event_start = event.get("data", {}).get("event", {}).get("startDate", "2025-01-01") - content_map = contents.get("data", {}).get("contentMap", {}) - records: list[Record] = [] - for slot_items in content_map.values(): - if not isinstance(slot_items, list): - continue - for item in slot_items: - seq = item.get("seq") - title = (item.get("title") or "").strip() - tags = [str(tag.get("name", "")).strip() for tag in item.get("tags", []) if tag.get("name")] - category_names = [ - str(category.get("name", "")).strip() - for category in item.get("categories", []) - if category.get("name") - ] - merged_tags = [*tags, *category_names, item.get("typeOptionName", "")] - merged_tags = [t for t in merged_tags if t] - if not seq or not title: - continue - link = normalize_url(f"https://if.kakao.com/session/{seq}") - topic = infer_topic(title, merged_tags) - records.append( - Record( - company="KAKAO", - source="if(kakao)", - title=title, - link=link, - date=to_iso_datetime(f"{event_start}T00:00:00+09:00"), - topic=topic, - tags=merged_tags, - evidence=f"sessionId={seq}", - ) - ) - return records - - -def extract_line_links(html: str, locale: str) -> list[str]: - pattern = rf'href="/{locale}/blog/([^"/]+)/"' - slugs = set(re.findall(pattern, html)) - filtered = sorted( - slug - for slug in slugs - if not slug.startswith(("author", "tag", "page")) - and slug not in {"blog", "culture", "opensource", "careers"} - ) - return [normalize_url(f"https://engineering.linecorp.com/{locale}/blog/{slug}") for slug in filtered] - - -def fetch_line_records() -> list[Record]: - records: list[Record] = [] - seen_links: set[str] = set() - for locale in ("en", "ko"): - for page in range(1, 13): - url = ( - f"https://engineering.linecorp.com/{locale}/blog" - if page == 1 - else f"https://engineering.linecorp.com/{locale}/blog/page/{page}" - ) - html = get_text(url) - links = extract_line_links(html, locale) - for link in links: - if link in seen_links: - continue - seen_links.add(link) - slug = link.rstrip("/").split("/")[-1] - title = slug.replace("-", " ").strip() - topic = infer_topic(title, []) - records.append( - Record( - company="LINE", - source="LINE Engineering Blog", - title=title, - link=link, - date="1970-01-01T00:00:00+00:00", - topic=topic, - tags=[locale], - evidence=f"slug={slug}", - ) - ) - return records - - -def fetch_coupang_records() -> list[Record]: - feed_urls = [ - "https://medium.com/feed/coupang-engineering", - "https://medium.com/feed/coupang-engineering/tagged/technology", - "https://medium.com/feed/coupang-engineering/tagged/machine-learning", - "https://medium.com/feed/coupang-engineering/tagged/infrastructure", - "https://medium.com/feed/coupang-engineering/tagged/backend", - "https://medium.com/feed/coupang-engineering/tagged/ai", - ] - records: list[Record] = [] - for feed_url in feed_urls: - xml_text = get_text(feed_url) - soup = BeautifulSoup(xml_text, "xml") - channel = soup.find("channel") - if channel is None: - continue - for item in channel.find_all("item"): - title = (item.find("title").text if item.find("title") else "").strip() - link = (item.find("link").text if item.find("link") else "").strip() - pub_date = (item.find("pubDate").text if item.find("pubDate") else "").strip() - categories = [ - (category.text or "").strip() for category in item.find_all("category") - ] - categories = [c for c in categories if c] - if not title or not link: - continue - clean_link = normalize_url(link.split("?")[0]) - topic = infer_topic(title, categories) - records.append( - Record( - company="COUPANG", - source="Coupang Engineering (Medium)", - title=title, - link=clean_link, - date=to_iso_datetime(pub_date), - topic=topic, - tags=categories, - evidence=f"feed={feed_url}", - ) - ) - return records - - -def fetch_baemin_records() -> list[Record]: - records: list[Record] = [] - page = 1 - while True: - url = ( - "https://techblog.woowahan.com/wp-json/wp/v2/posts" - "?per_page=100&page=" - f"{page}&_fields=id,date,link,title" - ) - response = requests.get(url, headers=DEFAULT_HEADERS, timeout=TIMEOUT) - if response.status_code != 200: - break - data = response.json() - if not data: - break - for item in data: - title = (item.get("title", {}).get("rendered", "") or "").strip() - link = (item.get("link") or "").strip() - date = (item.get("date") or "").strip() - if not title or not link: - continue - topic = infer_topic(title, []) - records.append( - Record( - company="BAEMIN", - source="Woowahan Tech Blog", - title=title, - link=normalize_url(link), - date=to_iso_datetime(date), - topic=topic, - tags=[], - evidence=f"postId={item.get('id')}", - ) - ) - page += 1 - if page > 10: - break - return records - - -def dedupe_records(records: list[Record]) -> list[Record]: - seen: set[str] = set() - deduped: list[Record] = [] - for record in records: - key = record.link - if key in seen: - continue - seen.add(key) - deduped.append(record) - return deduped - - -def sort_records(records: list[Record]) -> list[Record]: - return sorted(records, key=lambda r: (r.date, r.title), reverse=True) - - -def select_records(company_map: dict[str, list[Record]]) -> list[Record]: - selected: list[Record] = [] - selected_links: set[str] = set() - - for company in COMPANY_ORDER: - candidates = company_map.get(company, []) - taken = 0 - for record in candidates: - if record.link in selected_links: - continue - selected.append(record) - selected_links.add(record.link) - taken += 1 - if taken >= MIN_PER_COMPANY: - break - - if len(selected) >= TARGET_COUNT: - return selected[:TARGET_COUNT] - - extras: list[Record] = [] - for company in COMPANY_ORDER: - for record in company_map.get(company, []): - if record.link in selected_links: - continue - extras.append(record) - - extras = sort_records(extras) - for record in extras: - if len(selected) >= TARGET_COUNT: - break - if record.link in selected_links: - continue - selected.append(record) - selected_links.add(record.link) - - return selected[:TARGET_COUNT] - - -def write_jsonl(records: list[Record]) -> None: - with OUT_JSONL.open("w", encoding="utf-8") as file: - for record in records: - file.write( - json.dumps( - { - "company": record.company, - "source": record.source, - "title": record.title, - "link": record.link, - "date": record.date, - "topic": record.topic, - "tags": record.tags, - "pattern": "", - "evidence": record.evidence, - }, - ensure_ascii=False, - ) - + "\n" - ) - - -def write_summary( - all_company_counts: dict[str, int], - selected_records: list[Record], -) -> None: - selected_company_counts = Counter(record.company for record in selected_records) - selected_topic_counts = Counter(record.topic for record in selected_records) - timestamp = datetime.now(timezone.utc).isoformat() - - lines: list[str] = [] - lines.append("# NEKARACUBA Benchmark Summary") - lines.append("") - lines.append(f"- generated_at_utc: `{timestamp}`") - lines.append(f"- target_count: `{TARGET_COUNT}`") - lines.append(f"- selected_count: `{len(selected_records)}`") - lines.append(f"- min_per_company_target: `{MIN_PER_COMPANY}`") - lines.append("") - lines.append("## Coverage (Collected)") - lines.append("") - for company in COMPANY_ORDER: - lines.append(f"- {company}: {all_company_counts.get(company, 0)}") - lines.append("") - lines.append("## Coverage (Selected)") - lines.append("") - for company in COMPANY_ORDER: - lines.append(f"- {company}: {selected_company_counts.get(company, 0)}") - lines.append("") - lines.append("## Topic Distribution (Selected)") - lines.append("") - for topic, count in sorted(selected_topic_counts.items(), key=lambda item: item[1], reverse=True): - lines.append(f"- {topic}: {count}") - lines.append("") - lines.append("## Data Files") - lines.append("") - lines.append(f"- `{OUT_JSONL}`") - lines.append(f"- `{OUT_SUMMARY_MD}`") - lines.append("") - lines.append("## Notes") - lines.append("") - lines.append("- `LINE`은 페이지 크롤링 기반이라 일부 문서는 날짜 정보가 없고, `1970-01-01`로 표준화됩니다.") - lines.append("- `KAKAO`는 `if.kakao` 공개 API(`/api/v1/contents`)에서 세션 메타데이터를 수집합니다.") - lines.append("- `COUPANG`은 Medium publication feed + tagged feed를 결합합니다.") - lines.append("- 모든 Medium 링크는 query string 제거 후 dedupe 처리합니다.") - - OUT_SUMMARY_MD.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def main() -> None: - source_records = { - "NAVER": dedupe_records(sort_records(fetch_naver_records())), - "KAKAO": dedupe_records(sort_records(fetch_kakao_records())), - "LINE": dedupe_records(sort_records(fetch_line_records())), - "COUPANG": dedupe_records(sort_records(fetch_coupang_records())), - "BAEMIN": dedupe_records(sort_records(fetch_baemin_records())), - } - - all_company_counts = {company: len(records) for company, records in source_records.items()} - selected_records = select_records(source_records) - selected_records = dedupe_records(sort_records(selected_records)) - selected_records = selected_records[:TARGET_COUNT] - - write_jsonl(selected_records) - write_summary(all_company_counts, selected_records) - - selected_company_counts = Counter(record.company for record in selected_records) - print("saved:", OUT_JSONL) - print("saved:", OUT_SUMMARY_MD) - print("selected:", len(selected_records)) - for company in COMPANY_ORDER: - print(f"{company}: {selected_company_counts.get(company, 0)} / collected {all_company_counts.get(company, 0)}") - - -if __name__ == "__main__": - main() diff --git a/tooling/scripts/research/generate_toss_analysis.py b/tooling/scripts/research/generate_toss_analysis.py deleted file mode 100644 index cab0f70..0000000 --- a/tooling/scripts/research/generate_toss_analysis.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env python3 -"""Build a structured dataset from the Toss article corpus list.""" - -from __future__ import annotations - -import json -import re -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Iterable - -import requests -from bs4 import BeautifulSoup - -ROOT = Path(__file__).resolve().parents[2] -CORPUS_MD = ROOT / "docs/research/toss/toss-uiux-fe-ds-article-corpus.md" -OUT_JSONL = ROOT / "docs/research/toss/toss-uiux-fe-ds-analysis-data.jsonl" -OUT_SUMMARY = ROOT / "docs/research/toss/toss-uiux-fe-ds-analysis-summary.json" - -KEYWORDS = { - "uiux": [ - "ux", - "ui", - "사용자", - "인터랙션", - "리서치", - "문구", - "에러", - "리옵스", - "디자인", - "접근성", - ], - "frontend": [ - "frontend", - "프론트엔드", - "react", - "react native", - "web", - "배포", - "테스트", - "eslint", - "sdk", - "코드 리뷰", - ], - "design_system": [ - "design system", - "디자인 시스템", - "token", - "토큰", - "tds", - "컬러 시스템", - "컴포넌트", - "component", - ], - "quality": [ - "품질", - "테스트", - "qa", - "e2e", - "안정화", - "신뢰성", - "회귀", - "로깅", - "모니터링", - ], -} - - -@dataclass -class ArticleRecord: - url: str - title: str - word_count: int - category_scores: dict[str, int] - top_keywords: list[str] - excerpt: str - - -def extract_urls(markdown: str) -> list[str]: - return sorted(set(re.findall(r"https://toss\.tech/article/[A-Za-z0-9_-]+", markdown))) - - -def tokenize(text: str) -> list[str]: - # Keep English words and Korean blocks. - return re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}|[가-힣]{2,}", text) - - -def fetch_article(url: str) -> ArticleRecord | None: - try: - resp = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"}) - except requests.RequestException: - return None - if resp.status_code != 200: - return None - - soup = BeautifulSoup(resp.text, "html.parser") - title = "" - if soup.find("h1"): - title = soup.find("h1").get_text(" ", strip=True) - elif soup.title: - title = soup.title.get_text(" ", strip=True) - - article_node = soup.find("article") - full_text = article_node.get_text(" ", strip=True) if article_node else soup.get_text(" ", strip=True) - normalized = re.sub(r"\s+", " ", full_text).strip() - words = tokenize(normalized) - lowered = normalized.lower() - - scores: dict[str, int] = {} - for category, kws in KEYWORDS.items(): - scores[category] = sum(1 for kw in kws if kw in lowered) - - token_freq: dict[str, int] = {} - for token in words: - key = token.lower() - if len(key) < 3: - continue - token_freq[key] = token_freq.get(key, 0) + 1 - - top_keywords = [k for k, _ in sorted(token_freq.items(), key=lambda item: item[1], reverse=True)[:12]] - excerpt = " ".join(words[:120]) - - return ArticleRecord( - url=url, - title=title, - word_count=len(words), - category_scores=scores, - top_keywords=top_keywords, - excerpt=excerpt, - ) - - -def generate(records: Iterable[ArticleRecord]) -> tuple[list[dict], dict]: - rows = [r.__dict__ for r in records] - category_totals: dict[str, int] = {key: 0 for key in KEYWORDS} - for row in rows: - for cat, score in row["category_scores"].items(): - category_totals[cat] += score - - summary = { - "generated_at_utc": datetime.now(timezone.utc).isoformat(), - "article_count": len(rows), - "category_totals": category_totals, - "avg_word_count": round(sum(r["word_count"] for r in rows) / max(1, len(rows)), 2), - "max_word_count": max((r["word_count"] for r in rows), default=0), - "min_word_count": min((r["word_count"] for r in rows), default=0), - } - return rows, summary - - -def main() -> None: - corpus = CORPUS_MD.read_text(encoding="utf-8") - urls = extract_urls(corpus) - print(f"input URLs: {len(urls)}") - - records: list[ArticleRecord] = [] - with ThreadPoolExecutor(max_workers=16) as executor: - for result in executor.map(fetch_article, urls): - if result: - records.append(result) - - rows, summary = generate(records) - rows.sort(key=lambda row: row["url"]) - - with OUT_JSONL.open("w", encoding="utf-8") as f: - for row in rows: - f.write(json.dumps(row, ensure_ascii=False) + "\n") - - OUT_SUMMARY.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") - - print(f"saved: {OUT_JSONL}") - print(f"saved: {OUT_SUMMARY}") - print(f"processed: {summary['article_count']}") - - -if __name__ == "__main__": - main() diff --git a/tooling/scripts/verify-docs.mjs b/tooling/scripts/verify-docs.mjs index a2e71bc..85ecc83 100644 --- a/tooling/scripts/verify-docs.mjs +++ b/tooling/scripts/verify-docs.mjs @@ -16,7 +16,6 @@ const adrTriggerRules = [ const markdownlintTargets = [ 'AGENTS.md', - 'ARCHITECTURE.md', 'docs/README.md', ...fs .readdirSync(ADR_DIR) diff --git a/shared/ui/Button.test.tsx b/ui/Button/Button.test.tsx similarity index 96% rename from shared/ui/Button.test.tsx rename to ui/Button/Button.test.tsx index 37a1fe5..4260913 100644 --- a/shared/ui/Button.test.tsx +++ b/ui/Button/Button.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; + import { Button } from '.'; describe('Button', () => { @@ -15,7 +16,10 @@ describe('Button', () => { ); - expect(screen.getByRole('link', { name: '블로그' })).toHaveAttribute('href', '/blog'); + expect(screen.getByRole('link', { name: '블로그' })).toHaveAttribute( + 'href', + '/blog' + ); }); it('renders anchor variant for external links', () => { @@ -64,7 +68,9 @@ describe('Button', () => { ); - expect(screen.getByRole('button', { name: '전체폭' })).toHaveClass('w-full'); + expect(screen.getByRole('button', { name: '전체폭' })).toHaveClass( + 'w-full' + ); }); it('renders left and right icons', () => { diff --git a/shared/ui/Button/Button.tsx b/ui/Button/Button.tsx similarity index 100% rename from shared/ui/Button/Button.tsx rename to ui/Button/Button.tsx diff --git a/shared/ui/Button/Button.types.ts b/ui/Button/Button.types.ts similarity index 100% rename from shared/ui/Button/Button.types.ts rename to ui/Button/Button.types.ts diff --git a/shared/ui/Button/index.ts b/ui/Button/index.ts similarity index 100% rename from shared/ui/Button/index.ts rename to ui/Button/index.ts diff --git a/shared/ui/EmptyState.test.tsx b/ui/EmptyState/EmptyState.test.tsx similarity index 94% rename from shared/ui/EmptyState.test.tsx rename to ui/EmptyState/EmptyState.test.tsx index b816ea9..e764c7c 100644 --- a/shared/ui/EmptyState.test.tsx +++ b/ui/EmptyState/EmptyState.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; + import { EmptyState } from '.'; describe('EmptyState', () => { @@ -37,7 +38,9 @@ describe('EmptyState', () => { /> ); - expect(screen.getByText('오류 발생')).toHaveClass('text-[var(--color-grey-900)]'); + expect(screen.getByText('오류 발생')).toHaveClass( + 'text-[var(--color-grey-900)]' + ); }); it('renders action link when href is provided', () => { @@ -73,4 +76,3 @@ describe('EmptyState', () => { expect(onClick).toHaveBeenCalledTimes(1); }); }); - diff --git a/shared/ui/EmptyState/EmptyState.tsx b/ui/EmptyState/EmptyState.tsx similarity index 100% rename from shared/ui/EmptyState/EmptyState.tsx rename to ui/EmptyState/EmptyState.tsx diff --git a/shared/ui/EmptyState/index.ts b/ui/EmptyState/index.ts similarity index 100% rename from shared/ui/EmptyState/index.ts rename to ui/EmptyState/index.ts diff --git a/shared/ui/RouteError.test.tsx b/ui/RouteError.test.tsx similarity index 100% rename from shared/ui/RouteError.test.tsx rename to ui/RouteError.test.tsx diff --git a/shared/ui/RouteState/RouteError.tsx b/ui/RouteState/RouteError.tsx similarity index 94% rename from shared/ui/RouteState/RouteError.tsx rename to ui/RouteState/RouteError.tsx index c1966a7..42f9478 100644 --- a/shared/ui/RouteState/RouteError.tsx +++ b/ui/RouteState/RouteError.tsx @@ -1,7 +1,7 @@ 'use client'; import { clsx } from 'clsx'; -import { EmptyState } from '@/shared/ui/EmptyState'; +import { EmptyState } from '@/ui/EmptyState'; interface RouteErrorProps { title?: string; diff --git a/shared/ui/RouteState/index.ts b/ui/RouteState/index.ts similarity index 100% rename from shared/ui/RouteState/index.ts rename to ui/RouteState/index.ts diff --git a/shared/ui/ThemeToggle.test.tsx b/ui/ThemeToggle.test.tsx similarity index 96% rename from shared/ui/ThemeToggle.test.tsx rename to ui/ThemeToggle.test.tsx index b5f1c35..ddf2267 100644 --- a/shared/ui/ThemeToggle.test.tsx +++ b/ui/ThemeToggle.test.tsx @@ -53,11 +53,11 @@ vi.mock('framer-motion', () => ({ AnimatePresence: ({ children }: { children: ReactNode }) => children, })); -vi.mock('@/shared/motion/model/motion-mode', () => ({ +vi.mock('@/ui/motion/model/motion-mode', () => ({ useEffectiveMotionMode: () => 'full', })); -vi.mock('@/platform/analytics/lib/analytics', () => ({ +vi.mock('@/infra/analytics/lib/analytics', () => ({ AnalyticsEvents: { click: 'click', theme: 'theme', diff --git a/shared/ui/ThemeToggle.tsx b/ui/ThemeToggle.tsx similarity index 97% rename from shared/ui/ThemeToggle.tsx rename to ui/ThemeToggle.tsx index 9f181e9..b080d5a 100644 --- a/shared/ui/ThemeToggle.tsx +++ b/ui/ThemeToggle.tsx @@ -3,8 +3,8 @@ import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; -import { AnalyticsEvents, trackEvent } from '@/platform/analytics/lib/analytics'; -import { useEffectiveMotionMode } from '@/shared/motion/model/motion-mode'; +import { AnalyticsEvents, trackEvent } from '@/infra/analytics/lib/analytics'; +import { useEffectiveMotionMode } from '@/ui/motion/model/motion-mode'; export const THEME_TRANSITION_ORIGIN_EVENT = 'theme-transition-origin'; diff --git a/shared/ui/ThemeTransitionWash.tsx b/ui/ThemeTransitionWash.tsx similarity index 98% rename from shared/ui/ThemeTransitionWash.tsx rename to ui/ThemeTransitionWash.tsx index 7516096..0fbdd8d 100644 --- a/shared/ui/ThemeTransitionWash.tsx +++ b/ui/ThemeTransitionWash.tsx @@ -3,8 +3,8 @@ import { AnimatePresence, motion } from 'framer-motion'; import { useTheme } from 'next-themes'; import { useEffect, useRef, useState } from 'react'; -import { useEffectiveMotionMode } from '@/shared/motion/model/motion-mode'; -import { THEME_TRANSITION_ORIGIN_EVENT } from '@/shared/ui/ThemeToggle'; +import { useEffectiveMotionMode } from '@/ui/motion/model/motion-mode'; +import { THEME_TRANSITION_ORIGIN_EVENT } from '@/ui/ThemeToggle'; interface ThemeOriginDetail { x: number; diff --git a/shared/ui/icons/AppSectionIcon.tsx b/ui/icons/AppSectionIcon.tsx similarity index 100% rename from shared/ui/icons/AppSectionIcon.tsx rename to ui/icons/AppSectionIcon.tsx diff --git a/shared/ui/index.ts b/ui/index.ts similarity index 79% rename from shared/ui/index.ts rename to ui/index.ts index 5809de7..b0d5a58 100644 --- a/shared/ui/index.ts +++ b/ui/index.ts @@ -1,4 +1,6 @@ -// Core UI Components +export { Container } from './layout'; +export type { ContainerProps } from './layout'; + export { Button } from './Button'; export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'; diff --git a/shared/layout/Container/Container.test.tsx b/ui/layout/Container/Container.test.tsx similarity index 100% rename from shared/layout/Container/Container.test.tsx rename to ui/layout/Container/Container.test.tsx diff --git a/shared/layout/Container/Container.tsx b/ui/layout/Container/Container.tsx similarity index 100% rename from shared/layout/Container/Container.tsx rename to ui/layout/Container/Container.tsx diff --git a/shared/layout/Container/index.ts b/ui/layout/Container/index.ts similarity index 100% rename from shared/layout/Container/index.ts rename to ui/layout/Container/index.ts diff --git a/shared/layout/index.ts b/ui/layout/index.ts similarity index 100% rename from shared/layout/index.ts rename to ui/layout/index.ts diff --git a/shared/motion/model/motion-mode.test.ts b/ui/motion/model/motion-mode.test.ts similarity index 100% rename from shared/motion/model/motion-mode.test.ts rename to ui/motion/model/motion-mode.test.ts diff --git a/shared/motion/model/motion-mode.ts b/ui/motion/model/motion-mode.ts similarity index 100% rename from shared/motion/model/motion-mode.ts rename to ui/motion/model/motion-mode.ts diff --git a/shared/motion/ui/StaggerReveal.tsx b/ui/motion/ui/StaggerReveal.tsx similarity index 95% rename from shared/motion/ui/StaggerReveal.tsx rename to ui/motion/ui/StaggerReveal.tsx index 2b88dfd..d76df24 100644 --- a/shared/motion/ui/StaggerReveal.tsx +++ b/ui/motion/ui/StaggerReveal.tsx @@ -2,7 +2,7 @@ import { Children, type ReactNode } from 'react'; import { motion } from 'framer-motion'; -import { useEffectiveMotionMode } from '@/shared/motion/model/motion-mode'; +import { useEffectiveMotionMode } from '@/ui/motion/model/motion-mode'; interface StaggerRevealProps { children: ReactNode; diff --git a/vitest.config.ts b/vitest.config.ts index d812491..c6d3496 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ '@': path.resolve(__dirname, '.'), 'server-only': path.resolve( __dirname, - './shared/testing/server-only.ts' + './tests/support/server-only.ts' ), }, }, @@ -20,34 +20,34 @@ export default defineConfig({ include: [ 'blog/**/*.test.ts', 'blog/**/*.test.tsx', - 'platform/**/*.test.ts', - 'platform/**/*.test.tsx', + 'infra/**/*.test.ts', + 'infra/**/*.test.tsx', 'resume/**/*.test.ts', 'resume/**/*.test.tsx', 'search/**/*.test.ts', 'search/**/*.test.tsx', - 'shared/**/*.test.ts', - 'shared/**/*.test.tsx', + 'ui/**/*.test.ts', + 'ui/**/*.test.tsx', 'site/**/*.test.ts', 'site/**/*.test.tsx', - 'src/**/*.test.ts', - 'src/**/*.test.tsx', + 'app/**/*.test.ts', + 'app/**/*.test.tsx', 'styles/**/*.test.ts', ], coverage: { provider: 'v8', include: [ 'blog/**/*.{ts,tsx}', - 'platform/**/*.{ts,tsx}', + 'infra/**/*.{ts,tsx}', 'resume/**/*.{ts,tsx}', 'search/**/*.{ts,tsx}', - 'shared/**/*.{ts,tsx}', + 'ui/**/*.{ts,tsx}', 'site/**/*.{ts,tsx}', - 'src/app/**/*.{ts,tsx}', + 'app/**/*.{ts,tsx}', 'styles/**/*.{ts,tsx}', ], exclude: [ - 'shared/testing/**', + 'tests/support/**', '**/*.types.ts', '**/index.ts', '**/index.tsx',