+ );
+}
diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx
new file mode 100644
index 0000000..55a4678
--- /dev/null
+++ b/src/app/blog/page.tsx
@@ -0,0 +1,80 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import { ArrowUpRight, Rss } from 'lucide-react';
+import { getAllPosts } from '@/lib/blog';
+
+export const metadata: Metadata = {
+ title: 'Blog | oosu.dev',
+ description: '개발하면서 겪은 문제 해결 과정을 기록합니다.',
+ alternates: { canonical: 'https://oosu.dev/blog' },
+};
+
+export default async function BlogPage() {
+ const posts = await getAllPosts();
+
+ return (
+
+
+
+
+
+ Blog
+
+
+ 문제를 고친 흔적을 검색 가능한 글로 남깁니다.
+
+
+ macOS 자동화, AI 제품 개발, 배포 운영처럼 실제로 겪은 문제와 해결 과정을 기록합니다.
+
+
+
+
+ RSS
+
+
+
+
+ {posts.length > 0 ? (
+
+ {posts.map((post) => (
+
+
+
+
+
+
+
+
{post.title}
+
+
+
+
+ {post.description}
+
+
+ {post.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+ ))}
+
+ ) : (
+
아직 공개된 포스트가 없습니다.
+ )}
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 13b0ca3..d591e33 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -191,6 +191,111 @@ pre {
overflow-x: auto;
}
+.blog-prose {
+ color: var(--foreground);
+ max-width: none;
+}
+
+.blog-prose :where(h2) {
+ border-top: 1px solid var(--border);
+ font-size: 1.65rem;
+ font-weight: 750;
+ line-height: 1.25;
+ margin-top: 3rem;
+ padding-top: 2rem;
+}
+
+.blog-prose :where(h3) {
+ font-size: 1.25rem;
+ font-weight: 700;
+ line-height: 1.35;
+ margin-top: 2rem;
+}
+
+.blog-prose :where(p, li) {
+ color: var(--muted-foreground);
+ font-size: 1.02rem;
+ line-height: 1.85;
+}
+
+.blog-prose :where(a) {
+ color: var(--foreground);
+ font-weight: 600;
+ text-decoration: underline;
+ text-underline-offset: 4px;
+}
+
+.blog-prose :where(ul, ol) {
+ margin: 1rem 0 1.5rem;
+ padding-left: 1.4rem;
+}
+
+.blog-prose :where(blockquote) {
+ border-left: 3px solid var(--border);
+ color: var(--muted-foreground);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+}
+
+.blog-prose :where(:not(pre) > code) {
+ background: var(--muted);
+ border-radius: 0.35rem;
+ color: var(--foreground);
+ font-size: 0.92em;
+ padding: 0.15rem 0.35rem;
+ white-space: normal;
+}
+
+.blog-code-figure {
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ margin: 1.5rem 0;
+ overflow: hidden;
+ position: relative;
+}
+
+.blog-code-figure figcaption {
+ background: var(--muted);
+ border-bottom: 1px solid var(--border);
+ color: var(--muted-foreground);
+ font-size: 0.78rem;
+ font-weight: 600;
+ padding: 0.55rem 4.25rem 0.55rem 0.85rem;
+}
+
+.blog-code-figure pre {
+ background: color-mix(in oklab, var(--muted) 45%, transparent);
+ margin: 0;
+ padding: 1rem;
+}
+
+.blog-code-figure code {
+ counter-reset: line;
+ display: grid;
+ font-size: 0.88rem;
+ line-height: 1.75;
+ min-width: max-content;
+}
+
+.blog-code-copy {
+ background: var(--background);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ color: var(--foreground);
+ cursor: pointer;
+ font-size: 0.72rem;
+ font-weight: 700;
+ padding: 0.28rem 0.65rem;
+ position: absolute;
+ right: 0.7rem;
+ top: 0.48rem;
+ z-index: 1;
+}
+
+.blog-code-copy:hover {
+ background: var(--muted);
+}
+
@keyframes askoosu-jiggle {
0%,
100% {
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 8ddd385..da0722b 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -1,13 +1,24 @@
import type { MetadataRoute } from 'next';
+import { getAllPosts } from '@/lib/blog';
import { absoluteUrl, publicRoutes } from '@/lib/seo';
-export default function sitemap(): MetadataRoute.Sitemap {
+export default async function sitemap(): Promise {
const now = new Date();
+ const posts = await getAllPosts();
- return publicRoutes.map((route) => ({
+ const publicPages = publicRoutes.map((route) => ({
url: absoluteUrl(route),
lastModified: now,
- changeFrequency: route === '/' ? 'weekly' : 'monthly',
- priority: route === '/' ? 1 : route === '/chat' || route === '/ask' ? 0.9 : 0.7,
+ changeFrequency: route === '/' ? ('weekly' as const) : ('monthly' as const),
+ priority: route === '/' ? 1 : route === '/chat' || route === '/ask' ? 0.9 : route === '/blog' ? 0.8 : 0.7,
}));
+
+ const blogPosts = posts.map((post) => ({
+ url: absoluteUrl(`/blog/${post.slug}`),
+ lastModified: new Date(post.date),
+ changeFrequency: 'monthly' as const,
+ priority: 0.7,
+ }));
+
+ return [...publicPages, ...blogPosts];
}
diff --git a/src/components/blog/blog-cta.tsx b/src/components/blog/blog-cta.tsx
new file mode 100644
index 0000000..5d25a9f
--- /dev/null
+++ b/src/components/blog/blog-cta.tsx
@@ -0,0 +1,19 @@
+import Link from 'next/link';
+import { ArrowUpRight } from 'lucide-react';
+
+export function BlogCTA() {
+ return (
+
+ );
+}
diff --git a/src/components/blog/code-copy-enhancer.tsx b/src/components/blog/code-copy-enhancer.tsx
new file mode 100644
index 0000000..85cf0f3
--- /dev/null
+++ b/src/components/blog/code-copy-enhancer.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export function CodeCopyEnhancer() {
+ useEffect(() => {
+ const buttons = Array.from(
+ document.querySelectorAll('.blog-code-copy')
+ );
+
+ const cleanups = buttons.map((button) => {
+ const handleClick = async () => {
+ const figure = button.closest('.blog-code-figure');
+ const code = figure?.querySelector('pre')?.textContent ?? '';
+
+ if (!code) return;
+
+ try {
+ await navigator.clipboard.writeText(code.trimEnd());
+ button.textContent = 'Copied';
+ window.setTimeout(() => {
+ button.textContent = 'Copy';
+ }, 1600);
+ } catch {
+ button.textContent = 'Failed';
+ window.setTimeout(() => {
+ button.textContent = 'Copy';
+ }, 1600);
+ }
+ };
+
+ button.addEventListener('click', handleClick);
+ return () => button.removeEventListener('click', handleClick);
+ });
+
+ return () => {
+ cleanups.forEach((cleanup) => cleanup());
+ };
+ }, []);
+
+ return null;
+}
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
new file mode 100644
index 0000000..4cb9101
--- /dev/null
+++ b/src/lib/blog.ts
@@ -0,0 +1,198 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import matter from 'gray-matter';
+import rehypePrettyCode from 'rehype-pretty-code';
+import type { MDXRemoteProps } from 'next-mdx-remote/rsc';
+
+const blogDirectory = path.join(process.cwd(), 'content/blog');
+
+export type BlogPostMeta = {
+ title: string;
+ date: string;
+ tags: string[];
+ description: string;
+ slug: string;
+ published: boolean;
+};
+
+export type BlogPost = BlogPostMeta & {
+ body: string;
+};
+
+type FrontmatterRecord = Record;
+
+type RawBlogPostMeta = Omit & {
+ date: string | Date;
+};
+
+function isFrontmatterValid(data: FrontmatterRecord): data is RawBlogPostMeta {
+ return (
+ typeof data.title === 'string' &&
+ (typeof data.date === 'string' || data.date instanceof Date) &&
+ Array.isArray(data.tags) &&
+ data.tags.every((tag) => typeof tag === 'string') &&
+ typeof data.description === 'string' &&
+ typeof data.slug === 'string' &&
+ typeof data.published === 'boolean'
+ );
+}
+
+async function getBlogFilenames() {
+ try {
+ const entries = await fs.readdir(blogDirectory, { withFileTypes: true });
+ return entries
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.mdx'))
+ .map((entry) => entry.name);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return [];
+ }
+
+ throw error;
+ }
+}
+
+async function readPost(filename: string): Promise {
+ const fullPath = path.join(blogDirectory, filename);
+ const raw = await fs.readFile(fullPath, 'utf8');
+ const { data, content } = matter(raw);
+ const slugFromFilename = filename.replace(/\.mdx$/, '');
+
+ if (!isFrontmatterValid(data)) {
+ throw new Error(`Invalid blog frontmatter: ${filename}`);
+ }
+
+ if (data.slug !== slugFromFilename) {
+ throw new Error(
+ `Blog slug must match filename: ${filename} has slug "${data.slug}"`
+ );
+ }
+
+ return {
+ ...data,
+ date:
+ data.date instanceof Date
+ ? data.date.toISOString().slice(0, 10)
+ : data.date,
+ body: content,
+ };
+}
+
+export async function getAllPosts(): Promise {
+ const filenames = await getBlogFilenames();
+ const posts = await Promise.all(filenames.map((filename) => readPost(filename)));
+
+ return posts
+ .filter((post) => post.published)
+ .map(toPostMeta)
+ .sort((a, b) => b.date.localeCompare(a.date));
+}
+
+function toPostMeta(post: BlogPost): BlogPostMeta {
+ return {
+ title: post.title,
+ date: post.date,
+ tags: post.tags,
+ description: post.description,
+ slug: post.slug,
+ published: post.published,
+ };
+}
+
+export async function getPostBySlug(slug: string): Promise {
+ const filename = `${slug}.mdx`;
+ const filenames = await getBlogFilenames();
+
+ if (!filenames.includes(filename)) {
+ return null;
+ }
+
+ const post = await readPost(filename);
+ return post.published ? post : null;
+}
+
+export async function getRelatedPosts(
+ post: BlogPostMeta,
+ limit = 3
+): Promise {
+ const posts = await getAllPosts();
+ const tagSet = new Set(post.tags);
+
+ return posts
+ .filter((candidate) => candidate.slug !== post.slug)
+ .map((candidate) => ({
+ post: candidate,
+ score: candidate.tags.filter((tag) => tagSet.has(tag)).length,
+ }))
+ .filter(({ score }) => score > 0)
+ .sort((a, b) => b.score - a.score || b.post.date.localeCompare(a.post.date))
+ .slice(0, limit)
+ .map(({ post }) => post);
+}
+
+function addCopyButtonToCodeBlocks() {
+ return (tree: unknown) => {
+ const visit = (node: unknown) => {
+ if (!node || typeof node !== 'object') return;
+
+ const element = node as {
+ type?: string;
+ tagName?: string;
+ properties?: Record;
+ children?: unknown[];
+ };
+
+ if (
+ element.type === 'element' &&
+ element.tagName === 'figure' &&
+ element.properties?.['data-rehype-pretty-code-figure'] !== undefined
+ ) {
+ element.properties.className = [
+ ...toClassList(element.properties.className),
+ 'blog-code-figure',
+ ];
+ element.children = [
+ {
+ type: 'element',
+ tagName: 'button',
+ properties: {
+ type: 'button',
+ className: ['blog-code-copy'],
+ ariaLabel: 'Copy code block',
+ },
+ children: [{ type: 'text', value: 'Copy' }],
+ },
+ ...(element.children ?? []),
+ ];
+ }
+
+ element.children?.forEach(visit);
+ };
+
+ visit(tree);
+ };
+}
+
+function toClassList(value: unknown) {
+ if (Array.isArray(value)) return value.map(String);
+ if (typeof value === 'string') return value.split(/\s+/).filter(Boolean);
+ return [];
+}
+
+export const mdxOptions: MDXRemoteProps['options'] = {
+ mdxOptions: {
+ rehypePlugins: [
+ [
+ rehypePrettyCode as never,
+ {
+ theme: {
+ light: 'github-light',
+ dark: 'github-dark',
+ },
+ keepBackground: false,
+ },
+ ],
+ addCopyButtonToCodeBlocks as never,
+ ],
+ },
+};
diff --git a/src/lib/seo.ts b/src/lib/seo.ts
index ddbf88c..afe191f 100644
--- a/src/lib/seo.ts
+++ b/src/lib/seo.ts
@@ -27,6 +27,7 @@ export const publicRoutes = [
'/about',
'/projects',
'/projects/askoosu',
+ '/blog',
'/ai-director',
'/ai-era-developer',
'/faq/ai-competitiveness',
From e40b7a27c46920c4537764cbb964d2575086710d Mon Sep 17 00:00:00 2001
From: oosuhada
Date: Tue, 19 May 2026 17:08:41 +0900
Subject: [PATCH 02/12] Store ask event answer previews
---
db/migrations/009_create_ask_events.sql | 1 +
.../010_add_ask_event_answer_preview.sql | 2 ++
docs/analytics.md | 10 +++++++++
docs/home-server-deploy.md | 2 +-
src/app/api/chat/route.ts | 7 +++++++
src/lib/analytics/ask-events.ts | 21 +++++++++++++++++--
6 files changed, 40 insertions(+), 3 deletions(-)
create mode 100644 db/migrations/010_add_ask_event_answer_preview.sql
diff --git a/db/migrations/009_create_ask_events.sql b/db/migrations/009_create_ask_events.sql
index dba876e..4800683 100644
--- a/db/migrations/009_create_ask_events.sql
+++ b/db/migrations/009_create_ask_events.sql
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS ask_events (
language text CHECK (language IS NULL OR language IN ('ko', 'en')),
question text NOT NULL DEFAULT '',
question_redacted text NOT NULL DEFAULT '',
+ answer_preview text,
normalized_intent text,
answer_mode text NOT NULL DEFAULT 'unknown'
CHECK (answer_mode IN ('direct_cache', 'rag', 'fallback', 'smalltalk', 'safety', 'unknown')),
diff --git a/db/migrations/010_add_ask_event_answer_preview.sql b/db/migrations/010_add_ask_event_answer_preview.sql
new file mode 100644
index 0000000..07f007d
--- /dev/null
+++ b/db/migrations/010_add_ask_event_answer_preview.sql
@@ -0,0 +1,2 @@
+ALTER TABLE ask_events
+ ADD COLUMN IF NOT EXISTS answer_preview text;
diff --git a/docs/analytics.md b/docs/analytics.md
index 6518d28..e263b2d 100644
--- a/docs/analytics.md
+++ b/docs/analytics.md
@@ -24,6 +24,7 @@ pattern is already sufficient.
- anonymous `session_id`
- language
- sanitized `question` and `question_redacted`
+- sanitized `answer_preview`, a short redacted response excerpt for quality review
- normalized intent
- answer mode: `direct_cache`, `rag`, `fallback`, `smalltalk`, `safety`, `unknown`
- confidence
@@ -119,6 +120,15 @@ ORDER BY created_at DESC
LIMIT 50;
```
+Recent questions with answer previews:
+
+```sql
+SELECT created_at, language, question_redacted, answer_preview, normalized_intent, answer_mode, confidence
+FROM ask_events
+ORDER BY created_at DESC
+LIMIT 50;
+```
+
Questions with no source docs:
```sql
diff --git a/docs/home-server-deploy.md b/docs/home-server-deploy.md
index f6c2fa6..b793c61 100644
--- a/docs/home-server-deploy.md
+++ b/docs/home-server-deploy.md
@@ -147,7 +147,7 @@ This applies:
The script runs `psql` inside the `postgres` Compose service and reads `POSTGRES_USER` and `POSTGRES_DB` from `.env.production`.
-Migration `003` is required for `answer_cache`, `ai_provider_usage`, and `ai_provider_status`. Migration `004` is required for language-specific RAG search and sync locking. Migration `005` is required for short-lived RAG search result caching. Migration `006` adds provider usage metadata. Migration `007` adds entity/source-chunk indexes plus soft invalidation for `answer_cache`, and lets RAG sync record deleted chunks. Migration `008` adds persistent `rate_limit_buckets` for Postgres-backed chat and feedback request limits. Migration `009` adds privacy-conscious `ask_events` logging for sanitized AskOosu question analytics. If production was already deployed before these migrations existed, run `scripts/prod-migrate.sh` again after pulling the latest code.
+Migration `003` is required for `answer_cache`, `ai_provider_usage`, and `ai_provider_status`. Migration `004` is required for language-specific RAG search and sync locking. Migration `005` is required for short-lived RAG search result caching. Migration `006` adds provider usage metadata. Migration `007` adds entity/source-chunk indexes plus soft invalidation for `answer_cache`, and lets RAG sync record deleted chunks. Migration `008` adds persistent `rate_limit_buckets` for Postgres-backed chat and feedback request limits. Migration `009` adds privacy-conscious `ask_events` logging for sanitized AskOosu question analytics. Migration `010` adds a short sanitized `answer_preview` excerpt for answer-quality review. If production was already deployed before these migrations existed, run `scripts/prod-migrate.sh` again after pulling the latest code.
After a successful `/api/rag/sync`, AskOosu invalidates generated `answer_cache` rows by changed `matched_entity_ids` first. If changed chunks do not have entity ids, sync falls back to `source_chunk_ids` when possible and otherwise leaves stale rows to expire via `ASKOOSU_ANSWER_CACHE_TTL_HOURS`.
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index deac95d..f36b4b9 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -257,6 +257,7 @@ export async function POST(req: Request) {
req,
body,
question: orchestration.question,
+ answer: directAnswer.answer,
metadata: directMetadata,
latencyMs: Date.now() - requestStartedAt,
});
@@ -370,6 +371,7 @@ export async function POST(req: Request) {
req,
body,
question: orchestration.question,
+ answer: safeAnswer,
metadata: responseMetadata,
latencyMs: Date.now() - requestStartedAt,
});
@@ -430,6 +432,7 @@ export async function POST(req: Request) {
req,
body,
question: orchestration.question,
+ answer: generatedAnswer,
metadata: responseMetadata,
latencyMs: Date.now() - requestStartedAt,
});
@@ -486,6 +489,7 @@ export async function POST(req: Request) {
req,
body,
question: getLatestUserText(messages),
+ answer: buildModelUnavailableAnswer(fallbackMetadata.language),
metadata: fallbackMetadata,
latencyMs: Date.now() - requestStartedAt,
});
@@ -565,12 +569,14 @@ function scheduleAskEventLog({
req,
body,
question,
+ answer,
metadata,
latencyMs,
}: {
req: Request;
body: ValidatedChatRequestBody | null;
question: string;
+ answer?: string | null;
metadata: ChatAnswerMetadata;
latencyMs: number;
}) {
@@ -588,6 +594,7 @@ function scheduleAskEventLog({
sessionId: body.sessionId ?? body.conversationId,
language: metadata.language,
question,
+ answerPreview: answer,
normalizedIntent: getNormalizedIntentFromMetadata(metadata),
answerMode: getAnswerModeFromMetadata(metadata),
confidence: metadata.confidence,
diff --git a/src/lib/analytics/ask-events.ts b/src/lib/analytics/ask-events.ts
index 22e5fe1..69a88ab 100644
--- a/src/lib/analytics/ask-events.ts
+++ b/src/lib/analytics/ask-events.ts
@@ -25,6 +25,7 @@ export type AskEventContext = {
export type AskEventInput = AskEventContext & {
language?: ChatLanguage | null;
question: string;
+ answerPreview?: string | null;
normalizedIntent?: string | null;
answerMode: AskEventAnswerMode;
confidence?: number | null;
@@ -39,6 +40,7 @@ export type AskEventInput = AskEventContext & {
const MAX_SESSION_ID_LENGTH = 128;
const MAX_TEXT_LENGTH = 2000;
+const MAX_ANSWER_PREVIEW_LENGTH = 120;
const MAX_SHORT_TEXT_LENGTH = 500;
const MAX_ID_LENGTH = 160;
const MAX_IDS = 50;
@@ -61,6 +63,7 @@ export async function createAskEvent(input: AskEventInput) {
language,
question,
question_redacted,
+ answer_preview,
normalized_intent,
answer_mode,
confidence,
@@ -78,8 +81,8 @@ export async function createAskEvent(input: AskEventInput) {
os
)
VALUES (
- $1, $2, $3, $4, $5, $6, $7, $8::text[], $9, $10,
- $11, $12, $13, $14, $15, $16, $17, $18, $19
+ $1, $2, $3, $4, $5, $6, $7, $8, $9::text[], $10, $11,
+ $12, $13, $14, $15, $16, $17, $18, $19, $20
)
RETURNING id
`,
@@ -88,6 +91,7 @@ export async function createAskEvent(input: AskEventInput) {
event.language,
event.question,
event.questionRedacted,
+ event.answerPreview,
event.normalizedIntent,
event.answerMode,
event.confidence,
@@ -227,6 +231,7 @@ async function createAskEventsSchema() {
language text CHECK (language IS NULL OR language IN ('ko', 'en')),
question text NOT NULL DEFAULT '',
question_redacted text NOT NULL DEFAULT '',
+ answer_preview text,
normalized_intent text,
answer_mode text NOT NULL DEFAULT 'unknown'
CHECK (answer_mode IN ('direct_cache', 'rag', 'fallback', 'smalltalk', 'safety', 'unknown')),
@@ -249,6 +254,9 @@ async function createAskEventsSchema() {
os text
)
`);
+ await pool.query(
+ 'ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS answer_preview text'
+ );
await pool.query('CREATE INDEX IF NOT EXISTS ask_events_created_at_idx ON ask_events (created_at DESC)');
await pool.query('CREATE INDEX IF NOT EXISTS ask_events_session_id_idx ON ask_events (session_id)');
await pool.query('CREATE INDEX IF NOT EXISTS ask_events_answer_mode_idx ON ask_events (answer_mode)');
@@ -260,12 +268,14 @@ async function createAskEventsSchema() {
function normalizeAskEvent(input: AskEventInput) {
const redactedQuestion = redactSensitiveText(input.question, MAX_TEXT_LENGTH);
+ const answerPreview = createAnswerPreview(input.answerPreview);
return {
sessionId: truncateText(input.sessionId ?? '', MAX_SESSION_ID_LENGTH),
language: input.language ?? null,
question: redactedQuestion,
questionRedacted: redactedQuestion,
+ answerPreview,
normalizedIntent: normalizeOptionalText(input.normalizedIntent, MAX_ID_LENGTH),
answerMode: input.answerMode,
confidence: normalizeConfidence(input.confidence),
@@ -284,6 +294,13 @@ function normalizeAskEvent(input: AskEventInput) {
};
}
+function createAnswerPreview(value: string | null | undefined) {
+ const redactedAnswer = redactSensitiveText(value ?? '', MAX_TEXT_LENGTH);
+ const singleLineAnswer = redactedAnswer.replace(/\s+/g, ' ').trim();
+ if (!singleLineAnswer) return null;
+ return truncateText(singleLineAnswer, MAX_ANSWER_PREVIEW_LENGTH);
+}
+
function parseUserAgent(userAgent: string) {
const lowerUserAgent = userAgent.toLowerCase();
From 5400d70a8fa5cf2088c398c4aaa001cd0172a1da Mon Sep 17 00:00:00 2001
From: oosuhada
Date: Tue, 19 May 2026 17:37:27 +0900
Subject: [PATCH 03/12] Track visitor analytics with IP hashes
---
.env.example | 1 +
db/migrations/009_create_ask_events.sql | 11 +
...dd_visitor_events_and_identity_context.sql | 65 ++++++
docs/analytics.md | 116 ++++++++++-
docs/home-server-deploy.md | 4 +-
src/app/api/analytics/page-view/route.ts | 46 +++++
src/app/layout.tsx | 2 +
src/components/analytics/visitor-tracker.tsx | 45 +++++
src/lib/analytics/ask-events.ts | 155 +++++++-------
src/lib/analytics/request-context.ts | 164 +++++++++++++++
src/lib/analytics/visitor-events.ts | 190 ++++++++++++++++++
11 files changed, 721 insertions(+), 78 deletions(-)
create mode 100644 db/migrations/011_add_visitor_events_and_identity_context.sql
create mode 100644 src/app/api/analytics/page-view/route.ts
create mode 100644 src/components/analytics/visitor-tracker.tsx
create mode 100644 src/lib/analytics/request-context.ts
create mode 100644 src/lib/analytics/visitor-events.ts
diff --git a/.env.example b/.env.example
index 1d66f64..2f654d6 100644
--- a/.env.example
+++ b/.env.example
@@ -72,6 +72,7 @@ ASKOOSU_CHAT_MAX_REQUEST_BYTES=32768
ASKOOSU_RATE_LIMIT_STORE=postgres
ASKOOSU_CHAT_RATE_LIMIT_PER_MINUTE=60
ASKOOSU_CHAT_SESSION_RATE_LIMIT_PER_MINUTE=30
+ASKOOSU_IP_HASH_SALT=
ASKOOSU_FAQ_SEMANTIC_ROUTER_ENABLED=true
ASKOOSU_FAQ_SEMANTIC_DIRECT_MIN=0.88
ASKOOSU_FAQ_SEMANTIC_REWRITE_MIN=0.76
diff --git a/db/migrations/009_create_ask_events.sql b/db/migrations/009_create_ask_events.sql
index 4800683..ca0919e 100644
--- a/db/migrations/009_create_ask_events.sql
+++ b/db/migrations/009_create_ask_events.sql
@@ -24,7 +24,18 @@ CREATE TABLE IF NOT EXISTS ask_events (
utm_source text,
utm_medium text,
utm_campaign text,
+ ip_hash text,
+ cf_ray text,
country text,
+ geo_city text,
+ geo_region text,
+ geo_region_code text,
+ geo_postal_code text,
+ geo_timezone text,
+ geo_latitude numeric(9, 6),
+ geo_longitude numeric(9, 6),
+ user_agent_hash text,
+ accept_language text,
device_type text,
browser text,
os text
diff --git a/db/migrations/011_add_visitor_events_and_identity_context.sql b/db/migrations/011_add_visitor_events_and_identity_context.sql
new file mode 100644
index 0000000..87af3a7
--- /dev/null
+++ b/db/migrations/011_add_visitor_events_and_identity_context.sql
@@ -0,0 +1,65 @@
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+
+ALTER TABLE ask_events
+ ADD COLUMN IF NOT EXISTS ip_hash text,
+ ADD COLUMN IF NOT EXISTS cf_ray text,
+ ADD COLUMN IF NOT EXISTS geo_city text,
+ ADD COLUMN IF NOT EXISTS geo_region text,
+ ADD COLUMN IF NOT EXISTS geo_region_code text,
+ ADD COLUMN IF NOT EXISTS geo_postal_code text,
+ ADD COLUMN IF NOT EXISTS geo_timezone text,
+ ADD COLUMN IF NOT EXISTS geo_latitude numeric(9, 6),
+ ADD COLUMN IF NOT EXISTS geo_longitude numeric(9, 6),
+ ADD COLUMN IF NOT EXISTS user_agent_hash text,
+ ADD COLUMN IF NOT EXISTS accept_language text;
+
+CREATE INDEX IF NOT EXISTS ask_events_ip_hash_idx
+ ON ask_events (ip_hash);
+
+CREATE TABLE IF NOT EXISTS visitor_events (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ session_id text NOT NULL DEFAULT '' CHECK (char_length(session_id) <= 128),
+ ip_hash text,
+ event_type text NOT NULL DEFAULT 'page_view' CHECK (event_type IN ('page_view')),
+ path text,
+ referrer text,
+ utm_source text,
+ utm_medium text,
+ utm_campaign text,
+ cf_ray text,
+ country text,
+ geo_city text,
+ geo_region text,
+ geo_region_code text,
+ geo_postal_code text,
+ geo_timezone text,
+ geo_latitude numeric(9, 6),
+ geo_longitude numeric(9, 6),
+ user_agent_hash text,
+ accept_language text,
+ device_type text,
+ browser text,
+ os text,
+ screen_width integer CHECK (screen_width IS NULL OR screen_width >= 0),
+ screen_height integer CHECK (screen_height IS NULL OR screen_height >= 0),
+ viewport_width integer CHECK (viewport_width IS NULL OR viewport_width >= 0),
+ viewport_height integer CHECK (viewport_height IS NULL OR viewport_height >= 0),
+ client_timezone text,
+ client_language text
+);
+
+CREATE INDEX IF NOT EXISTS visitor_events_created_at_idx
+ ON visitor_events (created_at DESC);
+CREATE INDEX IF NOT EXISTS visitor_events_session_id_idx
+ ON visitor_events (session_id);
+CREATE INDEX IF NOT EXISTS visitor_events_ip_hash_idx
+ ON visitor_events (ip_hash);
+CREATE INDEX IF NOT EXISTS visitor_events_path_idx
+ ON visitor_events (path);
+CREATE INDEX IF NOT EXISTS visitor_events_referrer_idx
+ ON visitor_events (referrer);
+CREATE INDEX IF NOT EXISTS visitor_events_country_idx
+ ON visitor_events (country);
+CREATE INDEX IF NOT EXISTS visitor_events_utm_source_idx
+ ON visitor_events (utm_source);
diff --git a/docs/analytics.md b/docs/analytics.md
index e263b2d..e33d5b3 100644
--- a/docs/analytics.md
+++ b/docs/analytics.md
@@ -1,10 +1,11 @@
# AskOosu Analytics And Question Logging
-Last updated: 2026-05-13
+Last updated: 2026-05-19
## Storage Choice
-AskOosu uses the existing PostgreSQL database for custom AskOosu question logs.
+AskOosu uses the existing PostgreSQL database for custom AskOosu visitor and
+question logs.
This is the most natural option for this codebase because the project already
has:
@@ -19,9 +20,20 @@ pattern is already sufficient.
## What Is Logged
+`visitor_events` stores page-view and acquisition signals:
+
+- anonymous `session_id`
+- `ip_hash`, generated server-side with HMAC-SHA256
+- path, referrer without query string, UTM values
+- Cloudflare country and optional city/region/timezone/lat-lon headers
+- Cloudflare Ray ID
+- user-agent hash plus coarse device/browser/OS labels
+- screen, viewport, browser timezone, and browser language
+
`ask_events` stores privacy-conscious product-quality signals:
- anonymous `session_id`
+- `ip_hash`, so question behavior can be correlated with visits without raw IP storage
- language
- sanitized `question` and `question_redacted`
- sanitized `answer_preview`, a short redacted response excerpt for quality review
@@ -33,9 +45,19 @@ pattern is already sufficient.
- latency
- optional user feedback value
- page path, referrer without query string, UTM values
-- country code, device type, browser, OS
+- country code, optional city/region fields, device type, browser, OS
-Raw IP addresses and precise geolocation are not stored.
+Raw IP addresses are not stored. `ip_hash` is intended for repeat-visitor
+analysis, not identity proof. Set a stable production-only salt so the same IP
+hashes consistently over time:
+
+```bash
+ASKOOSU_IP_HASH_SALT=
+```
+
+City/region fields depend on Cloudflare request headers. In Cloudflare, enable
+visitor location headers if city/region-level reporting is needed. Location from
+IP is approximate and may reflect VPNs, carriers, or company networks.
## Cloudflare Web Analytics
@@ -81,6 +103,92 @@ WHERE created_at < now() - interval '180 days';
## Useful Read Queries
+Daily visitor stats:
+
+```sql
+SELECT
+ date_trunc('day', created_at) AS day,
+ count(*) AS page_views,
+ count(DISTINCT session_id) AS sessions,
+ count(DISTINCT ip_hash) FILTER (WHERE ip_hash IS NOT NULL) AS ip_hashes
+FROM visitor_events
+WHERE created_at >= now() - interval '30 days'
+GROUP BY day
+ORDER BY day DESC;
+```
+
+Top entry and content paths:
+
+```sql
+SELECT path, count(*) AS page_views, count(DISTINCT session_id) AS sessions
+FROM visitor_events
+WHERE created_at >= now() - interval '30 days'
+GROUP BY path
+ORDER BY page_views DESC
+LIMIT 30;
+```
+
+Referrer and campaign sources:
+
+```sql
+SELECT
+ coalesce(utm_source, '(direct)') AS utm_source,
+ coalesce(referrer, '(none)') AS referrer,
+ count(*) AS page_views,
+ count(DISTINCT session_id) AS sessions
+FROM visitor_events
+WHERE created_at >= now() - interval '30 days'
+GROUP BY utm_source, referrer
+ORDER BY page_views DESC
+LIMIT 30;
+```
+
+Repeat visitors by IP hash:
+
+```sql
+SELECT
+ ip_hash,
+ min(created_at) AS first_seen_at,
+ max(created_at) AS last_seen_at,
+ count(*) AS page_views,
+ count(DISTINCT session_id) AS sessions,
+ array_agg(DISTINCT path) FILTER (WHERE path IS NOT NULL) AS paths
+FROM visitor_events
+WHERE ip_hash IS NOT NULL
+GROUP BY ip_hash
+HAVING count(*) > 1
+ORDER BY last_seen_at DESC
+LIMIT 50;
+```
+
+Visitor timeline by session:
+
+```sql
+SELECT created_at, path, referrer, utm_source, utm_campaign, country, geo_region, geo_city
+FROM visitor_events
+WHERE session_id = ''
+ORDER BY created_at;
+```
+
+Question timeline with visitor context:
+
+```sql
+SELECT
+ created_at,
+ session_id,
+ ip_hash,
+ country,
+ geo_region,
+ geo_city,
+ page_path,
+ question_redacted,
+ answer_preview,
+ answer_mode
+FROM ask_events
+ORDER BY created_at DESC
+LIMIT 50;
+```
+
Total questions:
```sql
diff --git a/docs/home-server-deploy.md b/docs/home-server-deploy.md
index b793c61..1d99f5b 100644
--- a/docs/home-server-deploy.md
+++ b/docs/home-server-deploy.md
@@ -144,10 +144,12 @@ This applies:
- `db/migrations/007_add_answer_cache_invalidation.sql`
- `db/migrations/008_create_rate_limit_buckets.sql`
- `db/migrations/009_create_ask_events.sql`
+- `db/migrations/010_add_ask_event_answer_preview.sql`
+- `db/migrations/011_add_visitor_events_and_identity_context.sql`
The script runs `psql` inside the `postgres` Compose service and reads `POSTGRES_USER` and `POSTGRES_DB` from `.env.production`.
-Migration `003` is required for `answer_cache`, `ai_provider_usage`, and `ai_provider_status`. Migration `004` is required for language-specific RAG search and sync locking. Migration `005` is required for short-lived RAG search result caching. Migration `006` adds provider usage metadata. Migration `007` adds entity/source-chunk indexes plus soft invalidation for `answer_cache`, and lets RAG sync record deleted chunks. Migration `008` adds persistent `rate_limit_buckets` for Postgres-backed chat and feedback request limits. Migration `009` adds privacy-conscious `ask_events` logging for sanitized AskOosu question analytics. Migration `010` adds a short sanitized `answer_preview` excerpt for answer-quality review. If production was already deployed before these migrations existed, run `scripts/prod-migrate.sh` again after pulling the latest code.
+Migration `003` is required for `answer_cache`, `ai_provider_usage`, and `ai_provider_status`. Migration `004` is required for language-specific RAG search and sync locking. Migration `005` is required for short-lived RAG search result caching. Migration `006` adds provider usage metadata. Migration `007` adds entity/source-chunk indexes plus soft invalidation for `answer_cache`, and lets RAG sync record deleted chunks. Migration `008` adds persistent `rate_limit_buckets` for Postgres-backed chat and feedback request limits. Migration `009` adds privacy-conscious `ask_events` logging for sanitized AskOosu question analytics. Migration `010` adds a short sanitized `answer_preview` excerpt for answer-quality review. Migration `011` adds visitor page-view logging plus `ip_hash` and Cloudflare location context for visitor and question analytics. If production was already deployed before these migrations existed, run `scripts/prod-migrate.sh` again after pulling the latest code.
After a successful `/api/rag/sync`, AskOosu invalidates generated `answer_cache` rows by changed `matched_entity_ids` first. If changed chunks do not have entity ids, sync falls back to `source_chunk_ids` when possible and otherwise leaves stale rows to expire via `ASKOOSU_ANSWER_CACHE_TTL_HOURS`.
diff --git a/src/app/api/analytics/page-view/route.ts b/src/app/api/analytics/page-view/route.ts
new file mode 100644
index 0000000..1510a50
--- /dev/null
+++ b/src/app/api/analytics/page-view/route.ts
@@ -0,0 +1,46 @@
+import { z } from 'zod';
+import { createVisitorEvent } from '@/lib/analytics/visitor-events';
+import { logWarn, toLogError } from '@/lib/observability/logger';
+
+export const runtime = 'nodejs';
+
+const pageViewSchema = z.object({
+ sessionId: z.string().max(128).optional().nullable(),
+ path: z.string().max(500).optional().nullable(),
+ referrer: z.string().max(500).optional().nullable(),
+ utmSource: z.string().max(500).optional().nullable(),
+ utmMedium: z.string().max(500).optional().nullable(),
+ utmCampaign: z.string().max(500).optional().nullable(),
+ screenWidth: z.number().finite().nonnegative().optional().nullable(),
+ screenHeight: z.number().finite().nonnegative().optional().nullable(),
+ viewportWidth: z.number().finite().nonnegative().optional().nullable(),
+ viewportHeight: z.number().finite().nonnegative().optional().nullable(),
+ timezone: z.string().max(80).optional().nullable(),
+ language: z.string().max(80).optional().nullable(),
+});
+
+export async function POST(req: Request) {
+ let body: unknown;
+
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ ok: false, error: 'Invalid JSON body' }, { status: 400 });
+ }
+
+ const parsed = pageViewSchema.safeParse(body);
+ if (!parsed.success) {
+ return Response.json({ ok: false, error: 'Invalid page view payload' }, { status: 400 });
+ }
+
+ try {
+ await createVisitorEvent(req, parsed.data);
+ } catch (error) {
+ logWarn('analytics.page_view_write_failed', {
+ route: 'api/analytics/page-view',
+ error: toLogError(error),
+ });
+ }
+
+ return Response.json({ ok: true });
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 546b0c4..93a26f1 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,4 +1,5 @@
import { PreferenceSync } from '@/components/preference-sync';
+import { VisitorTracker } from '@/components/analytics/visitor-tracker';
import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner';
import {
@@ -174,6 +175,7 @@ export default function RootLayout({
>
+ {children}
diff --git a/src/components/analytics/visitor-tracker.tsx b/src/components/analytics/visitor-tracker.tsx
new file mode 100644
index 0000000..10ba31b
--- /dev/null
+++ b/src/components/analytics/visitor-tracker.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { usePathname, useSearchParams } from 'next/navigation';
+import { useEffect } from 'react';
+import { getOrCreateAnonymousSessionId } from '@/lib/analytics/client';
+
+export function VisitorTracker() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ if (!pathname || pathname.startsWith('/api')) return;
+
+ const params = new URLSearchParams(searchParams?.toString());
+ const payload = {
+ sessionId: getOrCreateAnonymousSessionId(),
+ path: `${pathname}${params.toString() ? `?${params.toString()}` : ''}`,
+ referrer: document.referrer || null,
+ utmSource: params.get('utm_source'),
+ utmMedium: params.get('utm_medium'),
+ utmCampaign: params.get('utm_campaign'),
+ screenWidth: window.screen?.width ?? null,
+ screenHeight: window.screen?.height ?? null,
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ language: navigator.language,
+ };
+ const body = JSON.stringify(payload);
+
+ if (navigator.sendBeacon) {
+ const blob = new Blob([body], { type: 'application/json' });
+ if (navigator.sendBeacon('/api/analytics/page-view', blob)) return;
+ }
+
+ void fetch('/api/analytics/page-view', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body,
+ keepalive: true,
+ }).catch(() => {});
+ }, [pathname, searchParams]);
+
+ return null;
+}
diff --git a/src/lib/analytics/ask-events.ts b/src/lib/analytics/ask-events.ts
index 69a88ab..e176b08 100644
--- a/src/lib/analytics/ask-events.ts
+++ b/src/lib/analytics/ask-events.ts
@@ -2,6 +2,14 @@ import type { ChatLanguage } from '@/lib/i18n/detect-language';
import type { AnswerRouteDecision, ChatAnswerMetadata } from '@/lib/chat/types';
import { getPostgresPool, hasPostgresDatabaseUrl } from '@/lib/db/postgres';
import { redactSensitiveText } from '@/lib/privacy/redact';
+import {
+ normalizeCountry,
+ normalizeOptionalText,
+ normalizePath,
+ normalizeReferrer,
+ parseRequestAnalyticsContext,
+ truncateText,
+} from './request-context';
export type AskEventAnswerMode =
| 'direct_cache'
@@ -33,6 +41,17 @@ export type AskEventInput = AskEventContext & {
modelProvider?: string | null;
latencyMs?: number | null;
country?: string | null;
+ ipHash?: string | null;
+ cfRay?: string | null;
+ geoCity?: string | null;
+ geoRegion?: string | null;
+ geoRegionCode?: string | null;
+ geoPostalCode?: string | null;
+ geoTimezone?: string | null;
+ geoLatitude?: number | null;
+ geoLongitude?: number | null;
+ userAgentHash?: string | null;
+ acceptLanguage?: string | null;
deviceType?: string | null;
browser?: string | null;
os?: string | null;
@@ -75,14 +94,26 @@ export async function createAskEvent(input: AskEventInput) {
utm_source,
utm_medium,
utm_campaign,
+ ip_hash,
+ cf_ray,
country,
+ geo_city,
+ geo_region,
+ geo_region_code,
+ geo_postal_code,
+ geo_timezone,
+ geo_latitude,
+ geo_longitude,
+ user_agent_hash,
+ accept_language,
device_type,
browser,
os
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9::text[], $10, $11,
- $12, $13, $14, $15, $16, $17, $18, $19, $20
+ $12, $13, $14, $15, $16, $17, $18, $19, $20,
+ $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31
)
RETURNING id
`,
@@ -103,7 +134,18 @@ export async function createAskEvent(input: AskEventInput) {
event.utmSource,
event.utmMedium,
event.utmCampaign,
+ event.ipHash,
+ event.cfRay,
event.country,
+ event.geoCity,
+ event.geoRegion,
+ event.geoRegionCode,
+ event.geoPostalCode,
+ event.geoTimezone,
+ event.geoLatitude,
+ event.geoLongitude,
+ event.userAgentHash,
+ event.acceptLanguage,
event.deviceType,
event.browser,
event.os,
@@ -195,14 +237,10 @@ export function getNormalizedIntentFromMetadata(metadata: ChatAnswerMetadata) {
}
export function parseRequestEventContext(req: Request, context?: AskEventContext) {
- const userAgent = req.headers.get('user-agent') ?? '';
- const parsedUserAgent = parseUserAgent(userAgent);
+ const requestContext = parseRequestAnalyticsContext(req);
return {
- country: normalizeCountry(req.headers.get('cf-ipcountry')),
- deviceType: parsedUserAgent.deviceType,
- browser: parsedUserAgent.browser,
- os: parsedUserAgent.os,
+ ...requestContext,
pagePath: normalizePath(context?.pagePath),
referrer: normalizeReferrer(context?.referrer),
utmSource: normalizeOptionalText(context?.utmSource, MAX_SHORT_TEXT_LENGTH),
@@ -248,7 +286,18 @@ async function createAskEventsSchema() {
utm_source text,
utm_medium text,
utm_campaign text,
+ ip_hash text,
+ cf_ray text,
country text,
+ geo_city text,
+ geo_region text,
+ geo_region_code text,
+ geo_postal_code text,
+ geo_timezone text,
+ geo_latitude numeric(9, 6),
+ geo_longitude numeric(9, 6),
+ user_agent_hash text,
+ accept_language text,
device_type text,
browser text,
os text
@@ -257,8 +306,20 @@ async function createAskEventsSchema() {
await pool.query(
'ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS answer_preview text'
);
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS ip_hash text');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS cf_ray text');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS geo_city text');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS geo_region text');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS geo_region_code text');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS geo_postal_code text');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS geo_timezone text');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS geo_latitude numeric(9, 6)');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS geo_longitude numeric(9, 6)');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS user_agent_hash text');
+ await pool.query('ALTER TABLE ask_events ADD COLUMN IF NOT EXISTS accept_language text');
await pool.query('CREATE INDEX IF NOT EXISTS ask_events_created_at_idx ON ask_events (created_at DESC)');
await pool.query('CREATE INDEX IF NOT EXISTS ask_events_session_id_idx ON ask_events (session_id)');
+ await pool.query('CREATE INDEX IF NOT EXISTS ask_events_ip_hash_idx ON ask_events (ip_hash)');
await pool.query('CREATE INDEX IF NOT EXISTS ask_events_answer_mode_idx ON ask_events (answer_mode)');
await pool.query('CREATE INDEX IF NOT EXISTS ask_events_language_idx ON ask_events (language)');
await pool.query('CREATE INDEX IF NOT EXISTS ask_events_user_feedback_idx ON ask_events (user_feedback)');
@@ -287,7 +348,18 @@ function normalizeAskEvent(input: AskEventInput) {
utmSource: normalizeOptionalText(input.utmSource, MAX_SHORT_TEXT_LENGTH),
utmMedium: normalizeOptionalText(input.utmMedium, MAX_SHORT_TEXT_LENGTH),
utmCampaign: normalizeOptionalText(input.utmCampaign, MAX_SHORT_TEXT_LENGTH),
+ ipHash: normalizeOptionalText(input.ipHash, 128),
+ cfRay: normalizeOptionalText(input.cfRay, 128),
country: normalizeCountry(input.country),
+ geoCity: normalizeOptionalText(input.geoCity, 120),
+ geoRegion: normalizeOptionalText(input.geoRegion, 120),
+ geoRegionCode: normalizeOptionalText(input.geoRegionCode, 40),
+ geoPostalCode: normalizeOptionalText(input.geoPostalCode, 40),
+ geoTimezone: normalizeOptionalText(input.geoTimezone, 80),
+ geoLatitude: normalizeCoordinate(input.geoLatitude, -90, 90),
+ geoLongitude: normalizeCoordinate(input.geoLongitude, -180, 180),
+ userAgentHash: normalizeOptionalText(input.userAgentHash, 128),
+ acceptLanguage: normalizeOptionalText(input.acceptLanguage, 200),
deviceType: normalizeOptionalText(input.deviceType, 80),
browser: normalizeOptionalText(input.browser, 80),
os: normalizeOptionalText(input.os, 80),
@@ -301,38 +373,6 @@ function createAnswerPreview(value: string | null | undefined) {
return truncateText(singleLineAnswer, MAX_ANSWER_PREVIEW_LENGTH);
}
-function parseUserAgent(userAgent: string) {
- const lowerUserAgent = userAgent.toLowerCase();
-
- return {
- deviceType: /mobile|iphone|android/.test(lowerUserAgent)
- ? 'mobile'
- : /ipad|tablet/.test(lowerUserAgent)
- ? 'tablet'
- : 'desktop',
- browser: /edg\//.test(lowerUserAgent)
- ? 'Edge'
- : /chrome|crios/.test(lowerUserAgent)
- ? 'Chrome'
- : /safari/.test(lowerUserAgent)
- ? 'Safari'
- : /firefox|fxios/.test(lowerUserAgent)
- ? 'Firefox'
- : 'Other',
- os: /iphone|ipad|ios/.test(lowerUserAgent)
- ? 'iOS'
- : /android/.test(lowerUserAgent)
- ? 'Android'
- : /mac os|macintosh/.test(lowerUserAgent)
- ? 'macOS'
- : /windows/.test(lowerUserAgent)
- ? 'Windows'
- : /linux/.test(lowerUserAgent)
- ? 'Linux'
- : 'Other',
- };
-}
-
function getRouteDecisionName(routeDecision: AnswerRouteDecision) {
if ('mode' in routeDecision) return routeDecision.mode;
return null;
@@ -366,41 +406,10 @@ function normalizeLatency(value: number | null | undefined) {
return Math.max(0, Math.round(value));
}
-function normalizeCountry(value: string | null | undefined) {
- const country = normalizeOptionalText(value, 8);
- if (!country || country === 'XX' || country === 'T1') return null;
- return country.toUpperCase();
-}
-
-function normalizePath(value: string | null | undefined) {
- const text = normalizeOptionalText(value, MAX_SHORT_TEXT_LENGTH);
- if (!text) return null;
-
- try {
- const url = new URL(text, 'https://oosu.dev');
- return url.pathname.slice(0, MAX_SHORT_TEXT_LENGTH);
- } catch {
- return text.startsWith('/') ? text : null;
- }
-}
-
-function normalizeReferrer(value: string | null | undefined) {
- const text = normalizeOptionalText(value, MAX_SHORT_TEXT_LENGTH);
- if (!text) return null;
-
- try {
- const url = new URL(text);
- return `${url.origin}${url.pathname}`.slice(0, MAX_SHORT_TEXT_LENGTH);
- } catch {
+function normalizeCoordinate(value: number | null | undefined, min: number, max: number) {
+ if (value === null || value === undefined || !Number.isFinite(value)) {
return null;
}
-}
-
-function normalizeOptionalText(value: string | null | undefined, max: number) {
- const text = truncateText(value ?? '', max);
- return text || null;
-}
-function truncateText(value: string, max: number) {
- return value.trim().replace(/\s+/g, ' ').slice(0, max);
+ return Math.max(min, Math.min(max, value));
}
diff --git a/src/lib/analytics/request-context.ts b/src/lib/analytics/request-context.ts
new file mode 100644
index 0000000..9cc8de6
--- /dev/null
+++ b/src/lib/analytics/request-context.ts
@@ -0,0 +1,164 @@
+import { createHmac } from 'node:crypto';
+
+export type RequestAnalyticsContext = {
+ ipHash: string | null;
+ cfRay: string | null;
+ country: string | null;
+ geoCity: string | null;
+ geoRegion: string | null;
+ geoRegionCode: string | null;
+ geoPostalCode: string | null;
+ geoTimezone: string | null;
+ geoLatitude: number | null;
+ geoLongitude: number | null;
+ userAgentHash: string | null;
+ acceptLanguage: string | null;
+ deviceType: string | null;
+ browser: string | null;
+ os: string | null;
+};
+
+const MAX_SHORT_TEXT_LENGTH = 500;
+
+export function parseRequestAnalyticsContext(req: Request): RequestAnalyticsContext {
+ const userAgent = req.headers.get('user-agent') ?? '';
+ const parsedUserAgent = parseUserAgent(userAgent);
+ const ipAddress = getClientIpAddress(req);
+
+ return {
+ ipHash: ipAddress ? hashText(ipAddress, 'ip') : null,
+ cfRay: normalizeOptionalText(req.headers.get('cf-ray'), 128),
+ country: normalizeCountry(req.headers.get('cf-ipcountry')),
+ geoCity: normalizeOptionalText(decodeHeaderValue(req.headers.get('cf-ipcity')), 120),
+ geoRegion: normalizeOptionalText(decodeHeaderValue(req.headers.get('cf-region')), 120),
+ geoRegionCode: normalizeOptionalText(req.headers.get('cf-region-code'), 40),
+ geoPostalCode: normalizeOptionalText(req.headers.get('cf-postal-code'), 40),
+ geoTimezone: normalizeOptionalText(req.headers.get('cf-timezone'), 80),
+ geoLatitude: normalizeCoordinate(req.headers.get('cf-iplatitude'), -90, 90),
+ geoLongitude: normalizeCoordinate(req.headers.get('cf-iplongitude'), -180, 180),
+ userAgentHash: userAgent ? hashText(userAgent, 'ua') : null,
+ acceptLanguage: normalizeOptionalText(req.headers.get('accept-language'), 200),
+ deviceType: parsedUserAgent.deviceType,
+ browser: parsedUserAgent.browser,
+ os: parsedUserAgent.os,
+ };
+}
+
+export function normalizeCountry(value: string | null | undefined) {
+ const country = normalizeOptionalText(value, 8);
+ if (!country || country === 'XX' || country === 'T1') return null;
+ return country.toUpperCase();
+}
+
+export function normalizePath(value: string | null | undefined) {
+ const text = normalizeOptionalText(value, MAX_SHORT_TEXT_LENGTH);
+ if (!text) return null;
+
+ try {
+ const url = new URL(text, 'https://oosu.dev');
+ return `${url.pathname}${url.search}`.slice(0, MAX_SHORT_TEXT_LENGTH);
+ } catch {
+ return text.startsWith('/') ? text : null;
+ }
+}
+
+export function normalizeReferrer(value: string | null | undefined) {
+ const text = normalizeOptionalText(value, MAX_SHORT_TEXT_LENGTH);
+ if (!text) return null;
+
+ try {
+ const url = new URL(text);
+ return `${url.origin}${url.pathname}`.slice(0, MAX_SHORT_TEXT_LENGTH);
+ } catch {
+ return null;
+ }
+}
+
+export function normalizeOptionalText(value: string | null | undefined, max: number) {
+ const text = truncateText(value ?? '', max);
+ return text || null;
+}
+
+export function truncateText(value: string, max: number) {
+ return value.trim().replace(/\s+/g, ' ').slice(0, max);
+}
+
+function getClientIpAddress(req: Request) {
+ const cloudflareIp = req.headers.get('cf-connecting-ip');
+ if (cloudflareIp) return cloudflareIp;
+
+ const forwardedFor = req.headers.get('x-forwarded-for');
+ if (!forwardedFor) return null;
+
+ return forwardedFor.split(',')[0]?.trim() || null;
+}
+
+function hashText(value: string, scope: string) {
+ return createHmac('sha256', getAnalyticsHashSalt())
+ .update(scope)
+ .update(':')
+ .update(value)
+ .digest('hex');
+}
+
+function getAnalyticsHashSalt() {
+ return (
+ process.env.ASKOOSU_IP_HASH_SALT ||
+ process.env.RAG_SYNC_SECRET ||
+ process.env.ASKOOSU_RAG_ADMIN_TOKEN ||
+ 'askoosu-local-development-ip-hash-salt'
+ );
+}
+
+function parseUserAgent(userAgent: string) {
+ const lowerUserAgent = userAgent.toLowerCase();
+
+ return {
+ deviceType: /mobile|iphone|android/.test(lowerUserAgent)
+ ? 'mobile'
+ : /ipad|tablet/.test(lowerUserAgent)
+ ? 'tablet'
+ : 'desktop',
+ browser: /edg\//.test(lowerUserAgent)
+ ? 'Edge'
+ : /chrome|crios/.test(lowerUserAgent)
+ ? 'Chrome'
+ : /safari/.test(lowerUserAgent)
+ ? 'Safari'
+ : /firefox|fxios/.test(lowerUserAgent)
+ ? 'Firefox'
+ : 'Other',
+ os: /iphone|ipad|ios/.test(lowerUserAgent)
+ ? 'iOS'
+ : /android/.test(lowerUserAgent)
+ ? 'Android'
+ : /mac os|macintosh/.test(lowerUserAgent)
+ ? 'macOS'
+ : /windows/.test(lowerUserAgent)
+ ? 'Windows'
+ : /linux/.test(lowerUserAgent)
+ ? 'Linux'
+ : 'Other',
+ };
+}
+
+function decodeHeaderValue(value: string | null) {
+ if (!value) return null;
+
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function normalizeCoordinate(value: string | null, min: number, max: number) {
+ if (!value) return null;
+
+ const coordinate = Number(value);
+ if (!Number.isFinite(coordinate) || coordinate < min || coordinate > max) {
+ return null;
+ }
+
+ return coordinate;
+}
diff --git a/src/lib/analytics/visitor-events.ts b/src/lib/analytics/visitor-events.ts
new file mode 100644
index 0000000..0faebc2
--- /dev/null
+++ b/src/lib/analytics/visitor-events.ts
@@ -0,0 +1,190 @@
+import { getPostgresPool, hasPostgresDatabaseUrl } from '@/lib/db/postgres';
+import {
+ normalizeOptionalText,
+ normalizePath,
+ normalizeReferrer,
+ parseRequestAnalyticsContext,
+ truncateText,
+} from './request-context';
+
+export type VisitorEventInput = {
+ sessionId?: string | null;
+ path?: string | null;
+ referrer?: string | null;
+ utmSource?: string | null;
+ utmMedium?: string | null;
+ utmCampaign?: string | null;
+ screenWidth?: number | null;
+ screenHeight?: number | null;
+ viewportWidth?: number | null;
+ viewportHeight?: number | null;
+ timezone?: string | null;
+ language?: string | null;
+};
+
+const MAX_SESSION_ID_LENGTH = 128;
+const MAX_SHORT_TEXT_LENGTH = 500;
+
+let visitorEventsSchemaReady: Promise | null = null;
+
+export { hasPostgresDatabaseUrl };
+
+export async function createVisitorEvent(req: Request, input: VisitorEventInput) {
+ if (!hasPostgresDatabaseUrl()) return null;
+
+ await ensureVisitorEventsSchema();
+
+ const requestContext = parseRequestAnalyticsContext(req);
+ const event = normalizeVisitorEvent(input);
+ const pool = await getPostgresPool();
+ const result = await pool.query<{ id: string }>(
+ `
+ INSERT INTO visitor_events (
+ session_id,
+ ip_hash,
+ path,
+ referrer,
+ utm_source,
+ utm_medium,
+ utm_campaign,
+ cf_ray,
+ country,
+ geo_city,
+ geo_region,
+ geo_region_code,
+ geo_postal_code,
+ geo_timezone,
+ geo_latitude,
+ geo_longitude,
+ user_agent_hash,
+ accept_language,
+ device_type,
+ browser,
+ os,
+ screen_width,
+ screen_height,
+ viewport_width,
+ viewport_height,
+ client_timezone,
+ client_language
+ )
+ VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
+ $11, $12, $13, $14, $15, $16, $17, $18, $19,
+ $20, $21, $22, $23, $24, $25, $26, $27
+ )
+ RETURNING id
+ `,
+ [
+ event.sessionId,
+ requestContext.ipHash,
+ event.path,
+ event.referrer,
+ event.utmSource,
+ event.utmMedium,
+ event.utmCampaign,
+ requestContext.cfRay,
+ requestContext.country,
+ requestContext.geoCity,
+ requestContext.geoRegion,
+ requestContext.geoRegionCode,
+ requestContext.geoPostalCode,
+ requestContext.geoTimezone,
+ requestContext.geoLatitude,
+ requestContext.geoLongitude,
+ requestContext.userAgentHash,
+ requestContext.acceptLanguage,
+ requestContext.deviceType,
+ requestContext.browser,
+ requestContext.os,
+ event.screenWidth,
+ event.screenHeight,
+ event.viewportWidth,
+ event.viewportHeight,
+ event.timezone,
+ event.language,
+ ]
+ );
+
+ return result.rows[0]?.id ?? null;
+}
+
+function ensureVisitorEventsSchema() {
+ if (!visitorEventsSchemaReady) {
+ visitorEventsSchemaReady = createVisitorEventsSchema();
+ }
+
+ return visitorEventsSchemaReady;
+}
+
+async function createVisitorEventsSchema() {
+ const pool = await getPostgresPool();
+
+ await pool.query('CREATE EXTENSION IF NOT EXISTS pgcrypto');
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS visitor_events (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ session_id text NOT NULL DEFAULT '' CHECK (char_length(session_id) <= 128),
+ ip_hash text,
+ event_type text NOT NULL DEFAULT 'page_view' CHECK (event_type IN ('page_view')),
+ path text,
+ referrer text,
+ utm_source text,
+ utm_medium text,
+ utm_campaign text,
+ cf_ray text,
+ country text,
+ geo_city text,
+ geo_region text,
+ geo_region_code text,
+ geo_postal_code text,
+ geo_timezone text,
+ geo_latitude numeric(9, 6),
+ geo_longitude numeric(9, 6),
+ user_agent_hash text,
+ accept_language text,
+ device_type text,
+ browser text,
+ os text,
+ screen_width integer CHECK (screen_width IS NULL OR screen_width >= 0),
+ screen_height integer CHECK (screen_height IS NULL OR screen_height >= 0),
+ viewport_width integer CHECK (viewport_width IS NULL OR viewport_width >= 0),
+ viewport_height integer CHECK (viewport_height IS NULL OR viewport_height >= 0),
+ client_timezone text,
+ client_language text
+ )
+ `);
+ await pool.query('CREATE INDEX IF NOT EXISTS visitor_events_created_at_idx ON visitor_events (created_at DESC)');
+ await pool.query('CREATE INDEX IF NOT EXISTS visitor_events_session_id_idx ON visitor_events (session_id)');
+ await pool.query('CREATE INDEX IF NOT EXISTS visitor_events_ip_hash_idx ON visitor_events (ip_hash)');
+ await pool.query('CREATE INDEX IF NOT EXISTS visitor_events_path_idx ON visitor_events (path)');
+ await pool.query('CREATE INDEX IF NOT EXISTS visitor_events_referrer_idx ON visitor_events (referrer)');
+ await pool.query('CREATE INDEX IF NOT EXISTS visitor_events_country_idx ON visitor_events (country)');
+ await pool.query('CREATE INDEX IF NOT EXISTS visitor_events_utm_source_idx ON visitor_events (utm_source)');
+}
+
+function normalizeVisitorEvent(input: VisitorEventInput) {
+ return {
+ sessionId: truncateText(input.sessionId ?? '', MAX_SESSION_ID_LENGTH),
+ path: normalizePath(input.path),
+ referrer: normalizeReferrer(input.referrer),
+ utmSource: normalizeOptionalText(input.utmSource, MAX_SHORT_TEXT_LENGTH),
+ utmMedium: normalizeOptionalText(input.utmMedium, MAX_SHORT_TEXT_LENGTH),
+ utmCampaign: normalizeOptionalText(input.utmCampaign, MAX_SHORT_TEXT_LENGTH),
+ screenWidth: normalizeInteger(input.screenWidth),
+ screenHeight: normalizeInteger(input.screenHeight),
+ viewportWidth: normalizeInteger(input.viewportWidth),
+ viewportHeight: normalizeInteger(input.viewportHeight),
+ timezone: normalizeOptionalText(input.timezone, 80),
+ language: normalizeOptionalText(input.language, 80),
+ };
+}
+
+function normalizeInteger(value: number | null | undefined) {
+ if (value === null || value === undefined || !Number.isFinite(value)) {
+ return null;
+ }
+
+ return Math.max(0, Math.round(value));
+}
From b948bc8d43e14f311ba8a0818b97616611850559 Mon Sep 17 00:00:00 2001
From: oosuhada
Date: Tue, 19 May 2026 17:47:37 +0900
Subject: [PATCH 04/12] Improve Cloudflare HTTPS and favicon handling
---
next.config.ts | 41 ++++++++++++++++++++++++++++++++++++
src/app/favicon.ico/route.ts | 11 ++++++++++
src/app/layout.tsx | 7 +++++-
3 files changed, 58 insertions(+), 1 deletion(-)
create mode 100644 src/app/favicon.ico/route.ts
diff --git a/next.config.ts b/next.config.ts
index 02d29dd..37491a9 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -4,6 +4,47 @@ const nextConfig = {
images: {
domains: ['images.unsplash.com', 'assets.aceternity.com'],
},
+ async headers() {
+ return [
+ {
+ source:
+ '/:path(favicon.ico|favicon.svg|apple-touch-icon.png|llms.txt|llms-full.txt|ai-profile.md|ai-projects.md|ai-faq.md)',
+ headers: [
+ {
+ key: 'Cache-Control',
+ value: 'public, max-age=14400, s-maxage=86400, must-revalidate',
+ },
+ ],
+ },
+ {
+ source: '/images/:path*',
+ headers: [
+ {
+ key: 'Cache-Control',
+ value: 'public, max-age=86400, s-maxage=604800, must-revalidate',
+ },
+ ],
+ },
+ {
+ source: '/oosu-projects/:path*',
+ headers: [
+ {
+ key: 'Cache-Control',
+ value: 'public, max-age=86400, s-maxage=604800, must-revalidate',
+ },
+ ],
+ },
+ {
+ source: '/oosu-avatar/:path*',
+ headers: [
+ {
+ key: 'Cache-Control',
+ value: 'public, max-age=86400, s-maxage=604800, must-revalidate',
+ },
+ ],
+ },
+ ];
+ },
eslint: {
// Ne bloque PAS le build en cas d'erreurs eslint
ignoreDuringBuilds: true,
diff --git a/src/app/favicon.ico/route.ts b/src/app/favicon.ico/route.ts
new file mode 100644
index 0000000..1d0e84b
--- /dev/null
+++ b/src/app/favicon.ico/route.ts
@@ -0,0 +1,11 @@
+export const dynamic = 'force-static';
+
+export function GET() {
+ return new Response(null, {
+ status: 308,
+ headers: {
+ Location: '/favicon.svg',
+ 'Cache-Control': 'public, max-age=14400, s-maxage=86400, must-revalidate',
+ },
+ });
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 93a26f1..52f4117 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -126,12 +126,16 @@ export const metadata: Metadata = {
},
icons: {
icon: [
+ {
+ url: '/favicon.ico',
+ sizes: 'any',
+ },
{
url: '/favicon.svg',
sizes: 'any',
},
],
- shortcut: '/favicon.svg?v=2',
+ shortcut: '/favicon.ico',
apple: '/apple-touch-icon.png?v=2',
},
};
@@ -157,6 +161,7 @@ export default function RootLayout({
return (
+
From fcbb4c1eba577a486b198bbb066edfc7ce822d7b Mon Sep 17 00:00:00 2001
From: Gabriel Jang
Date: Thu, 13 Aug 2026 17:08:58 +0900
Subject: [PATCH 05/12] docs: add DevSpace team onboarding post
---
content/blog/devspace-team-onboarding.mdx | 830 ++++++++++++++++++++++
1 file changed, 830 insertions(+)
create mode 100644 content/blog/devspace-team-onboarding.mdx
diff --git a/content/blog/devspace-team-onboarding.mdx b/content/blog/devspace-team-onboarding.mdx
new file mode 100644
index 0000000..ab32e34
--- /dev/null
+++ b/content/blog/devspace-team-onboarding.mdx
@@ -0,0 +1,830 @@
+---
+title: "DevSpace 팀 온보딩: ChatGPT 웹에서 로컬 코드를 직접 다루기"
+date: 2026-08-13
+tags: ["DevSpace", "ChatGPT", "MCP", "ngrok", "Tailscale", "Cloudflare Tunnel", "팀협업"]
+description: "4명이 그대로 따라 할 수 있도록 DevSpace 설치부터 고정 HTTPS 주소, ChatGPT MCP 연결, 첫 저장소 테스트와 보안 규칙까지 10~15분 실전 절차로 정리한다."
+slug: "devspace-team-onboarding"
+published: true
+---
+
+## TL;DR
+
+- DevSpace는 ChatGPT가 내 컴퓨터의 실제 프로젝트 폴더를 읽고, 수정하고, 테스트 명령까지 실행하게 해 주는 self-hosted MCP 서버다.
+- DevSpace 자체가 Codex 사용량이나 ChatGPT 토큰 한도를 늘려 주는 것은 아니다. 대신 코드를 계속 복사해서 붙여넣는 대신 필요한 파일만 직접 읽게 할 수 있어서 컨텍스트 낭비와 작업 전환 비용을 줄이는 데 유용하다.
+- 내 Mac mini는 `127.0.0.1:7676`에서 DevSpace를 실행하고 `https://devspace.oosu.dev`라는 고정 Cloudflare Tunnel 주소를 붙여 사용하고 있다.
+- 개인 도메인이 없는 팀원은 **ngrok 무료 계정의 자동 할당 dev domain**을 기본 선택으로 추천한다. 계정에 할당된 `*.ngrok-free.app` 주소를 재사용할 수 있어서 집, 교육장, 카페처럼 네트워크가 바뀌어도 ChatGPT 쪽 MCP URL을 계속 바꿀 필요가 없다.
+- 이미 Tailscale을 쓰고 있다면 **Tailscale Funnel**도 좋은 대안이다. `machine-name.tailnet-name.ts.net` 형태의 예측 가능한 주소를 계속 사용할 수 있다.
+- Cloudflare Quick Tunnel은 계정이나 도메인 없이 바로 쓸 수 있지만 실행할 때마다 랜덤 `*.trycloudflare.com` 주소가 만들어지므로 이번 목적에는 적합하지 않다.
+
+이 글은 우리 4명 팀이 각자 자신의 컴퓨터에 DevSpace를 설치하고, ChatGPT 웹에서 실제 로컬 저장소를 직접 다루기 위한 팀 온보딩 문서다.
+
+Node와 Git이 이미 설치돼 있다면 10~15분을 목표로 한다.
+
+---
+
+## 먼저 중요한 것: DevSpace가 해결하는 문제와 해결하지 않는 문제
+
+현재 팀원들은 Codex Desktop으로 작업하면서 큰 저장소를 오래 다룰 때 사용량과 컨텍스트 압박을 자주 겪고 있다.
+
+DevSpace를 쓰는 이유를 여기서 정확히 구분해야 한다.
+
+DevSpace는 Codex 요금제의 사용량을 늘려 주거나 제한을 우회하는 도구가 아니다.
+
+대신 이런 작업을 줄여 준다.
+
+- 파일 내용을 매번 채팅에 붙여넣기
+- 저장소 구조를 매 세션 설명하기
+- AI가 수정한 코드를 다시 로컬에 복사하기
+- 테스트 명령을 사람이 대신 실행한 뒤 결과를 다시 붙여넣기
+- 병렬 작업 때문에 checkout이 꼬이는 문제
+
+연결이 끝난 뒤에는 ChatGPT가 승인된 프로젝트 폴더 안에서 필요한 파일을 직접 읽고, 검색하고, 수정하고, 테스트를 실행할 수 있다.
+
+즉 목표는 다음 구조다.
+
+```text title="architecture.txt"
+ChatGPT Web
+ ↓ HTTPS
+고정 공개 주소
+ ↓
+DevSpace MCP Server
+ ↓
+내가 허용한 로컬 프로젝트 폴더
+ ↓
+Git / 테스트 / 빌드 / 로컬 도구
+```
+
+---
+
+## 0. 시작 전에 ChatGPT 권한부터 확인하기
+
+설치보다 먼저 해야 할 일이 있다.
+
+2026년 8월 13일 기준 OpenAI 공식 안내에서는 full MCP의 write/modify 기능이 ChatGPT Business, Enterprise, Edu에서 제공되고 있다. Pro는 developer mode에서 MCP read/fetch 연결을 사용할 수 있지만 full MCP 쓰기 권한은 공식적으로 같은 범위가 아니다.
+
+제품 제공 범위와 UI는 베타 기간에 바뀔 수 있으므로 각자 계정에서 먼저 확인한다.
+
+ChatGPT 웹에서 다음 메뉴를 확인한다.
+
+```text title="chatgpt-menu.txt"
+Settings
+→ Apps
+→ Advanced Settings
+→ Developer mode
+```
+
+또는 워크스페이스 관리자라면 다음 경로에서 custom app 생성을 확인한다.
+
+```text title="chatgpt-workspace-menu.txt"
+Workspace Settings
+→ Apps
+→ Create
+```
+
+쓰기 권한이 없는 계정에서는 DevSpace 설치 자체가 ChatGPT 플랜 제한을 우회해 주지 않는다.
+
+공식 안내:
+
+- [OpenAI - Developer mode and MCP apps in ChatGPT](https://help.openai.com/en/articles/12584461-developer-mode-and-full-mcp-connectors-in-chatgpt-beta)
+
+---
+
+## 1. 준비물 확인
+
+DevSpace 공식 요구사항은 다음과 같다.
+
+- Node `>=22.19 <27`
+- npm
+- Git
+- Bash
+- 외부에서 접근 가능한 HTTPS 주소
+
+macOS와 Linux는 일반적인 Bash 환경이면 된다.
+
+Windows는 PowerShell이나 `cmd.exe`만 있는 환경은 아직 지원 대상이 아니다. Git Bash, WSL, MSYS2, Cygwin Bash 중 하나를 준비한다. 가장 간단한 선택은 Git Bash다.
+
+먼저 확인한다.
+
+```bash title="terminal"
+node -v
+npm -v
+git --version
+bash --version
+```
+
+내 현재 Mac mini에서는 Node 24 계열과 DevSpace 1.0.7을 사용하고 있다.
+
+버전 숫자를 내 환경과 똑같이 맞출 필요는 없고 DevSpace 공식 범위 안이면 된다.
+
+---
+
+## 2. DevSpace 설치
+
+가장 단순한 설치 방법이다.
+
+```bash title="terminal"
+npm install -g @waishnav/devspace
+
+devspace --version
+```
+
+전역 설치가 싫다면 `npx`로도 사용할 수 있다.
+
+```bash title="terminal"
+npx @waishnav/devspace init
+npx @waishnav/devspace serve
+```
+
+이 글에서는 읽기 편하게 이후 명령을 `devspace` 기준으로 적는다.
+
+공식 문서:
+
+- [DevSpace Setup Guide](https://github.com/Waishnav/devspace/blob/main/docs/setup.md)
+
+---
+
+## 3. 제일 중요한 부분: 고정 HTTPS 주소 만들기
+
+처음 DevSpace를 쓸 때 내가 가장 불편했던 부분이 이 부분이었다.
+
+처음에는 임시 터널 주소를 사용했고, 집과 다른 장소를 오가거나 터널을 다시 띄울 때 주소가 달라졌다.
+
+DevSpace는 자신의 public URL을 기준으로 OAuth metadata와 Host allowlist를 만들기 때문에 공개 주소가 달라지면 설정과 인증 흐름을 다시 맞춰야 한다.
+
+그래서 지금 내 환경은 이렇게 고정해 두었다.
+
+```text title="my-current-devspace.txt"
+DevSpace local host: 127.0.0.1
+DevSpace local port: 7676
+Public base URL: https://devspace.oosu.dev
+
+Cloudflare Tunnel
+devspace.oosu.dev
+ → http://localhost:7676
+```
+
+이 방식의 장점은 집 Wi-Fi에서 교육장 Wi-Fi로 바뀌어 로컬 IP가 달라져도 외부에서 보는 주소는 계속 `devspace.oosu.dev`라는 것이다.
+
+하지만 팀원 모두가 개인 도메인을 가지고 있을 필요는 없다.
+
+### 추천 A. 개인 도메인이 없다면 ngrok 무료 dev domain
+
+우리 팀 기본값으로는 이 방법이 가장 간단하다.
+
+ngrok 무료 계정에는 자동으로 할당되는 dev domain 하나가 제공된다.
+
+예를 들면 이런 형태다.
+
+```text title="ngrok-domain.txt"
+https://example-name.ngrok-free.app
+```
+
+무료 플랜에서는 이름을 마음대로 고르거나 개인 도메인을 붙일 수는 없지만, 계정에 할당된 dev domain을 로컬 endpoint에 사용할 수 있다.
+
+### macOS
+
+```bash title="terminal"
+brew install ngrok
+
+ngrok config add-authtoken ""
+
+ngrok http 7676
+```
+
+### Windows
+
+ngrok 공식 Windows 다운로드 페이지에서 Microsoft Store, WinGet, Scoop 중 하나로 설치한 뒤 Git Bash에서 다음을 실행한다.
+
+```bash title="git-bash"
+ngrok config add-authtoken ""
+
+ngrok http 7676
+```
+
+처음 실행하면 다음처럼 HTTPS 주소가 출력된다.
+
+```text title="ngrok-output.txt"
+Forwarding
+https://example-name.ngrok-free.app
+→ http://localhost:7676
+```
+
+이 주소를 기억한다.
+
+중요한 포인트는 **DevSpace 설정에는 `/mcp`를 붙이지 않는 것**이다.
+
+```text title="correct-url.txt"
+DevSpace publicBaseUrl
+https://example-name.ngrok-free.app
+
+ChatGPT MCP endpoint
+https://example-name.ngrok-free.app/mcp
+```
+
+ngrok 무료 플랜 공식 설명:
+
+- [ngrok Free Plan Limits](https://ngrok.com/docs/pricing-limits/free-plan-limits)
+- [ngrok Share Localhost Quickstart](https://ngrok.com/docs/guides/share-localhost/quickstart)
+
+### 왜 Wi-Fi가 바뀌어도 괜찮나?
+
+ngrok agent는 집 공유기나 교육장 공유기에서 외부로 직접 포트를 여는 구조가 아니다.
+
+로컬 컴퓨터에서 ngrok cloud로 outbound 연결을 만들고, ngrok의 공개 주소가 그 연결을 `localhost:7676`으로 전달한다.
+
+따라서 네트워크가 바뀌었을 때 중요한 것은 로컬 IP 주소가 아니라 다음 두 가지다.
+
+- ngrok agent가 새 네트워크에서 인터넷에 다시 연결될 수 있는가
+- 같은 계정의 할당 dev domain을 계속 사용하는가
+
+이 두 조건을 만족하면 ChatGPT에 저장한 MCP URL은 그대로 둘 수 있다.
+
+### 컴퓨터 재부팅까지 자동 복구하고 싶다면
+
+ngrok은 macOS, Windows, Linux에서 OS background service로 설치할 수 있다.
+
+endpoint 설정을 `ngrok.yml`에 저장한 뒤 다음처럼 서비스로 등록할 수 있다.
+
+```bash title="terminal"
+ngrok service install --config /path/to/ngrok.yml
+ngrok service start
+```
+
+macOS에서는 `launchd`, Windows에서는 Windows Service로 등록된다.
+
+처음 팀 온보딩에서는 수동 `ngrok http 7676`으로 연결을 성공시킨 뒤, 필요할 때 background service까지 적용하는 것을 추천한다.
+
+공식 문서:
+
+- [ngrok Agent background service](https://ngrok.com/docs/agent)
+
+---
+
+## 4. 대안: 이미 Tailscale을 쓰고 있다면 Funnel
+
+Tailscale을 이미 설치해서 쓰는 팀원이라면 Funnel도 좋다.
+
+Funnel은 로컬 서비스를 인터넷에 HTTPS로 공개하면서 다음처럼 tailnet 기반 주소를 사용한다.
+
+```text title="tailscale-url.txt"
+https://my-mac.my-tailnet.ts.net
+```
+
+Tailscale 공식 문서에서도 Funnel DNS 이름을 predictable, stable한 주소로 설명한다.
+
+기본 흐름은 다음과 같다.
+
+```bash title="terminal"
+tailscale funnel 7676
+```
+
+환경에 따라 관리자 권한이 필요하면 다음처럼 실행한다.
+
+```bash title="terminal"
+sudo tailscale funnel 7676
+```
+
+출력 예시는 이런 형태다.
+
+```text title="tailscale-output.txt"
+Available on the internet:
+https://my-mac.my-tailnet.ts.net
+
+|-- / proxy http://127.0.0.1:7676
+```
+
+그다음 DevSpace에는 origin만 저장한다.
+
+```text title="tailscale-devspace.txt"
+publicBaseUrl
+https://my-mac.my-tailnet.ts.net
+
+ChatGPT endpoint
+https://my-mac.my-tailnet.ts.net/mcp
+```
+
+주의할 점도 있다.
+
+- Funnel은 MagicDNS와 HTTPS 설정이 필요하다.
+- Funnel용 tailnet policy 권한이 필요하다.
+- macOS는 Tailscale 배포 방식에 따라 CLI 사용 조건이 다르므로 공식 문서를 먼저 확인한다.
+
+공식 문서:
+
+- [Tailscale Funnel](https://tailscale.com/kb/1223/funnel)
+- [Tailscale Funnel examples](https://tailscale.com/docs/reference/examples/funnel)
+
+---
+
+## 5. Cloudflare Quick Tunnel은 왜 추천하지 않나?
+
+개인 도메인도 없고 계정도 만들기 싫다면 다음 명령으로 Cloudflare Quick Tunnel을 만들 수 있다.
+
+```bash title="terminal"
+cloudflared tunnel --url http://localhost:7676
+```
+
+문제는 주소다.
+
+```text title="quick-tunnel.txt"
+https://random-words.trycloudflare.com
+```
+
+Cloudflare 공식 문서상 Quick Tunnel은 실행할 때 랜덤 `*.trycloudflare.com` hostname을 만든다.
+
+프로세스를 새로 띄우면 URL이 달라질 수 있다.
+
+그러면 다음 값도 다시 맞춰야 한다.
+
+- DevSpace `publicBaseUrl`
+- ChatGPT custom MCP endpoint
+- 경우에 따라 OAuth 승인
+
+잠깐 테스트할 때는 매우 편하지만, 우리가 원하는 것은 "집에서도 교육장에서도 같은 ChatGPT MCP 연결을 계속 쓰는 것"이므로 팀 기본값으로는 맞지 않는다.
+
+공식 문서:
+
+- [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)
+
+개인 도메인이 이미 있다면 내 환경처럼 Named Cloudflare Tunnel을 쓰는 것이 좋다.
+
+---
+
+## 6. DevSpace 초기화
+
+고정 HTTPS 주소를 만들었으면 이제 DevSpace를 초기화한다.
+
+```bash title="terminal"
+devspace init
+```
+
+설정 중 중요한 항목은 세 가지다.
+
+### Project roots
+
+ChatGPT가 접근할 수 있는 로컬 폴더다.
+
+내 환경에서는 여러 프로젝트 때문에 허용 범위를 넓게 둔 적이 있지만, 팀원들에게는 처음부터 좁게 잡는 것을 추천한다.
+
+예를 들어 팀 저장소가 여기 모여 있다면:
+
+```text title="project-root-example.txt"
+~/Documents/team-repos
+```
+
+이 상위 폴더만 허용한다.
+
+이렇게 하면 아래 저장소들을 모두 열 수 있다.
+
+```text title="team-repos.txt"
+team-repos/
+├── ontology_dashboard
+└── gen_data
+```
+
+다음처럼 홈 전체를 허용하는 것은 피한다.
+
+```text title="avoid-roots.txt"
+~
+/
+C:\
+```
+
+### Local port
+
+기본값을 그대로 쓴다.
+
+```text title="port.txt"
+7676
+```
+
+### Public base URL
+
+ngrok을 쓴다면:
+
+```text title="public-base-url.txt"
+https://example-name.ngrok-free.app
+```
+
+Tailscale이라면:
+
+```text title="public-base-url-tailscale.txt"
+https://my-mac.my-tailnet.ts.net
+```
+
+여기에는 `/mcp`를 붙이지 않는다.
+
+초기화가 끝나면 Owner password가 출력된다.
+
+이 비밀번호는 다음 위치에도 저장된다.
+
+```text title="devspace-auth-file.txt"
+~/.devspace/auth.json
+```
+
+이 파일과 Owner password는 각자 자기 컴퓨터에서만 관리한다.
+
+팀 Slack, Discord, Notion, GitHub Issue에 올리지 않는다.
+
+---
+
+## 7. DevSpace 실행과 진단
+
+먼저 설정을 진단한다.
+
+```bash title="terminal"
+devspace doctor
+```
+
+문제가 없다면 서버를 실행한다.
+
+```bash title="terminal"
+devspace serve
+```
+
+기본 로컬 endpoint는 다음과 같다.
+
+```text title="local-mcp.txt"
+http://127.0.0.1:7676/mcp
+```
+
+하지만 ChatGPT는 내 컴퓨터의 `127.0.0.1`에 직접 접근할 수 없으므로 실제 ChatGPT 설정에는 공개 HTTPS endpoint를 사용한다.
+
+---
+
+## 8. ChatGPT에 DevSpace 연결
+
+ChatGPT 웹에서 Developer mode가 활성화돼 있다는 전제다.
+
+현재 공식 UI 기준으로 다음 경로에서 custom app을 만든다.
+
+```text title="chatgpt-create-app.txt"
+Settings
+→ Apps
+→ Create
+```
+
+또는 관리자 계정에서는:
+
+```text title="chatgpt-workspace-create-app.txt"
+Workspace Settings
+→ Apps
+→ Create
+```
+
+이름은 팀에서 통일하면 찾기 편하다.
+
+```text title="app-name.txt"
+devspace-codex
+```
+
+Endpoint에는 이번에는 `/mcp`까지 붙인다.
+
+```text title="mcp-endpoint.txt"
+https://example-name.ngrok-free.app/mcp
+```
+
+내 환경이라면:
+
+```text title="my-mcp-endpoint.txt"
+https://devspace.oosu.dev/mcp
+```
+
+그다음 현재 ChatGPT UI 기준으로:
+
+1. Authentication 설정을 확인한다.
+2. `Scan Tools`를 실행한다.
+3. DevSpace OAuth 승인 화면이 열리면 Owner password를 입력한다.
+4. tool scan이 끝날 때까지 기다린다.
+5. `Create`를 완료한다.
+
+여기서 가장 흔한 실수는 URL 두 개를 반대로 쓰는 것이다.
+
+```text title="url-rule.txt"
+DevSpace 설정
+https://host.example.com
+
+ChatGPT 설정
+https://host.example.com/mcp
+```
+
+DevSpace 공식 troubleshooting에도 별도 항목으로 나오는 실수다.
+
+---
+
+## 9. 처음에는 코드를 수정시키지 말고 연결만 확인하기
+
+첫 테스트는 읽기 전용으로 한다.
+
+ChatGPT 새 채팅에서 연결한 DevSpace app을 선택하거나 이름으로 호출한다.
+
+그리고 다음 프롬프트를 그대로 사용한다.
+
+```text title="first-test-prompt.txt"
+@devspace-codex
+
+내 로컬 팀 저장소를 checkout 모드로 열어줘.
+
+<내 절대경로>/team-repos/ontology_dashboard
+
+코드는 수정하지 말고 아래만 확인해줘.
+1. 현재 branch
+2. git status
+3. origin remote
+4. 최근 commit 3개
+```
+
+이 결과가 실제 로컬 저장소와 같으면 연결 성공이다.
+
+다음으로 `gen_data`도 같은 방식으로 확인한다.
+
+```text title="gen-data-test-prompt.txt"
+@devspace-codex
+
+<내 절대경로>/team-repos/gen_data 를 열고
+코드는 수정하지 말고 git status와 최근 commit만 확인해줘.
+```
+
+---
+
+## 10. 실제 팀 작업에서는 worktree를 기본으로 쓰기
+
+우리 팀은 같은 저장소를 여러 명, 여러 세션이 병렬로 작업할 일이 많다.
+
+기존 checkout branch를 직접 바꾸게 하면 다른 작업과 충돌하기 쉽다.
+
+그래서 실제 코드 작업을 맡길 때는 다음 문장을 기본으로 붙이는 것을 추천한다.
+
+```text title="worktree-prompt.txt"
+@devspace-codex
+
+기존 checkout branch는 변경하지 마.
+
+최신 origin 상태를 fetch한 뒤
+이번 작업용 독립 DevSpace worktree를 만들어서 작업해줘.
+
+작업 후에는
+1. 테스트
+2. git diff 확인
+3. git status 확인
+까지 진행해줘.
+```
+
+커밋과 push까지 맡길 작업이라면 그 부분을 명시적으로 추가한다.
+
+---
+
+## 11. Wi-Fi가 바뀌었는데 갑자기 연결이 안 될 때
+
+고정 주소를 제대로 썼다면 ChatGPT 설정부터 지우지 않는다.
+
+다음 순서로 확인한다.
+
+### 1. DevSpace 확인
+
+```bash title="terminal"
+devspace doctor
+```
+
+### 2. DevSpace 서버 확인
+
+```bash title="terminal"
+devspace serve
+```
+
+### 3. ngrok 확인
+
+```bash title="terminal"
+ngrok http 7676
+```
+
+출력되는 domain이 기존에 ChatGPT에 등록한 domain과 같은지 본다.
+
+### Tailscale 사용자라면
+
+```bash title="terminal"
+tailscale funnel status
+```
+
+고정 hostname이 그대로라면 ChatGPT app 설정은 그대로 둔다.
+
+---
+
+## 12. 정말 URL이 바뀐 경우
+
+Quick Tunnel 같은 임시 주소를 사용했다면 URL 자체가 바뀔 수 있다.
+
+그때는 DevSpace 설정부터 바꾼다.
+
+```bash title="terminal"
+devspace config set publicBaseUrl https://NEW-HOST.example.com
+```
+
+그리고 다시 실행한다.
+
+```bash title="terminal"
+devspace serve
+```
+
+ChatGPT custom MCP endpoint도 새 주소의 `/mcp`로 수정하거나 다시 만든다.
+
+```text title="new-chatgpt-url.txt"
+https://NEW-HOST.example.com/mcp
+```
+
+이 번거로움을 피하려고 처음부터 ngrok 할당 dev domain, Tailscale Funnel, Named Cloudflare Tunnel처럼 고정 hostname을 쓰는 것이다.
+
+---
+
+## 13. `unknown workspaceId`는 인증 실패가 아니다
+
+DevSpace를 재시작하거나 세션 상태가 바뀐 뒤 ChatGPT가 예전 `workspaceId`를 다시 사용하면 다음과 비슷한 오류가 날 수 있다.
+
+```text title="workspace-error.txt"
+unknown workspaceId
+```
+
+이 경우 Owner password나 tunnel 설정을 처음부터 다시 만들 필요는 없다.
+
+해당 프로젝트를 다시 `open_workspace` 하면 된다.
+
+`workspaceId`는 프로젝트 자체의 영구 ID가 아니라 작업 세션을 위한 식별자로 보는 편이 맞다.
+
+---
+
+## 14. 자주 막히는 문제
+
+### `devspace` command not found
+
+전역 npm bin 경로 문제일 수 있다.
+
+일단 `npx`로 확인한다.
+
+```bash title="terminal"
+npx @waishnav/devspace doctor
+```
+
+### Node version 오류
+
+```bash title="terminal"
+node --version
+```
+
+DevSpace 공식 범위인 `>=22.19 <27`인지 확인한다.
+
+### 403 또는 Host header 오류
+
+DevSpace의 `publicBaseUrl`과 실제 tunnel hostname이 같은지 본다.
+
+```bash title="terminal"
+devspace doctor
+```
+
+### ChatGPT에서 MCP tool scan 실패
+
+다시 한번 URL을 확인한다.
+
+```text title="scan-check.txt"
+잘못된 예
+DevSpace publicBaseUrl = https://host.example.com/mcp
+
+올바른 예
+DevSpace publicBaseUrl = https://host.example.com
+ChatGPT endpoint = https://host.example.com/mcp
+```
+
+### Owner password가 안 맞음
+
+자기 컴퓨터에서만 다음 파일을 확인한다.
+
+```text title="owner-password.txt"
+~/.devspace/auth.json
+```
+
+필요하면 setup을 다시 생성할 수 있다.
+
+```bash title="terminal"
+devspace init --force
+```
+
+공식 troubleshooting:
+
+- [DevSpace Gotchas](https://github.com/Waishnav/devspace/blob/main/docs/gotchas.md)
+
+---
+
+## 15. 보안 규칙은 꼭 지키기
+
+DevSpace는 단순한 파일 뷰어가 아니다.
+
+연결된 MCP 클라이언트는 승인된 workspace 안에서 파일을 수정할 수 있고, shell 명령도 실행할 수 있다.
+
+DevSpace 공식 문서도 이를 선택한 개발 머신에 대한 remote access로 취급하라고 안내한다.
+
+우리 팀에서는 다음 규칙을 지키는 것이 좋다.
+
+### Allowed root는 좁게
+
+좋은 예:
+
+```text title="good-roots.txt"
+~/Documents/team-repos
+~/work/Biz-CollabCraft
+```
+
+피해야 할 예:
+
+```text title="bad-roots.txt"
+~
+/
+C:\
+```
+
+### Owner password 공유 금지
+
+다음 파일은 Git에 올리지 않는다.
+
+```text title="private-file.txt"
+~/.devspace/auth.json
+```
+
+### `.env`, SSH key, cloud credential 주의
+
+가능하면 협업 저장소와 민감한 개인 credential 디렉터리를 같은 allowed root 안에 두지 않는다.
+
+### Shell 권한을 가볍게 보지 않기
+
+DevSpace file tool에는 경로 제한이 적용되지만 shell 명령은 로컬 사용자 권한으로 실행되는 강력한 기능이다.
+
+연결하는 ChatGPT 계정과 MCP 앱을 신뢰할 수 있는 코딩 파트너처럼 취급해야 한다.
+
+공식 보안 문서:
+
+- [DevSpace Security Model](https://github.com/Waishnav/devspace/security)
+
+---
+
+## 16. 우리 팀 권장 조합
+
+### 개인 도메인이 없는 대부분의 팀원
+
+```text title="recommended-default.txt"
+DevSpace
++ ngrok 무료 계정
++ 자동 할당 *.ngrok-free.app
++ ChatGPT custom MCP app
+```
+
+가장 설치가 빠르고 네트워크가 바뀌어도 공개 hostname을 유지하기 쉽다.
+
+### 이미 Tailscale을 쓰는 팀원
+
+```text title="recommended-tailscale.txt"
+DevSpace
++ Tailscale Funnel
++ *.ts.net
++ ChatGPT custom MCP app
+```
+
+### 개인 도메인을 이미 관리하는 경우
+
+```text title="recommended-cloudflare.txt"
+DevSpace
++ Named Cloudflare Tunnel
++ devspace.example.com
++ ChatGPT custom MCP app
+```
+
+이게 현재 내 Mac mini에서 사용하는 방식과 가장 가깝다.
+
+---
+
+## 설치 완료 체크리스트
+
+- Node, npm, Git, Bash 버전 확인
+- DevSpace 설치
+- `devspace doctor` 통과
+- 고정 HTTPS hostname 확보
+- DevSpace `publicBaseUrl`에는 `/mcp` 없이 저장
+- ChatGPT endpoint에는 `/mcp` 포함
+- Owner password로 본인 연결만 승인
+- `ontology_dashboard` 읽기 전용 테스트 성공
+- `gen_data` 읽기 전용 테스트 성공
+- allowed roots가 필요한 협업 폴더 범위로 제한됨
+
+여기까지 되면 팀원이 Codex Desktop만 사용하지 않고, ChatGPT 웹에서도 자신의 실제 로컬 Git 저장소를 직접 연결해서 작업할 수 있는 기본 환경이 준비된 것이다.
+
+---
+
+## 참고 문서
+
+- [DevSpace GitHub](https://github.com/Waishnav/devspace)
+- [DevSpace Setup Guide](https://github.com/Waishnav/devspace/blob/main/docs/setup.md)
+- [DevSpace Troubleshooting Gotchas](https://github.com/Waishnav/devspace/blob/main/docs/gotchas.md)
+- [DevSpace Security Model](https://github.com/Waishnav/devspace/security)
+- [OpenAI Developer mode and MCP apps](https://help.openai.com/en/articles/12584461-developer-mode-and-full-mcp-connectors-in-chatgpt-beta)
+- [ngrok Free Plan Limits](https://ngrok.com/docs/pricing-limits/free-plan-limits)
+- [ngrok Share Localhost Quickstart](https://ngrok.com/docs/guides/share-localhost/quickstart)
+- [ngrok Agent background service](https://ngrok.com/docs/agent)
+- [Tailscale Funnel](https://tailscale.com/kb/1223/funnel)
+- [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)
From 8026ca16e38adee9212fdd1b83031f6f128c14db Mon Sep 17 00:00:00 2001
From: Gabriel Jang
Date: Thu, 13 Aug 2026 17:24:31 +0900
Subject: [PATCH 06/12] docs: clarify DevSpace ChatGPT plugin setup
---
content/blog/devspace-team-onboarding.mdx | 179 ++++++++++++++++++++--
1 file changed, 167 insertions(+), 12 deletions(-)
diff --git a/content/blog/devspace-team-onboarding.mdx b/content/blog/devspace-team-onboarding.mdx
index ab32e34..2e00df5 100644
--- a/content/blog/devspace-team-onboarding.mdx
+++ b/content/blog/devspace-team-onboarding.mdx
@@ -60,11 +60,33 @@ Git / 테스트 / 빌드 / 로컬 도구
설치보다 먼저 해야 할 일이 있다.
-2026년 8월 13일 기준 OpenAI 공식 안내에서는 full MCP의 write/modify 기능이 ChatGPT Business, Enterprise, Edu에서 제공되고 있다. Pro는 developer mode에서 MCP read/fetch 연결을 사용할 수 있지만 full MCP 쓰기 권한은 공식적으로 같은 범위가 아니다.
+2026년 8월 13일 기준 OpenAI 공식 안내에서는 **Developer mode와 full MCP 지원이 ChatGPT Business, Enterprise, Edu의 ChatGPT 웹에서 제공**되고 있다. Plugin Directory 자체는 더 넓은 플랜에서 보일 수 있지만, custom MCP를 만들고 실제 write/modify tool을 쓰는 권한은 플랜, 워크스페이스 설정, 사용자 역할에 따라 달라진다.
제품 제공 범위와 UI는 베타 기간에 바뀔 수 있으므로 각자 계정에서 먼저 확인한다.
-ChatGPT 웹에서 다음 메뉴를 확인한다.
+### 왜 어떤 문서에는 Apps, 내 화면에는 Plugins라고 나오나?
+
+이 부분이 특히 헷갈린다.
+
+OpenAI는 **2026년 7월 9일 App Directory를 Plugin Directory로 변경**했다. 현재 Plugin은 workflow를 묶어 보여 주는 상위 단위이고, 실제 외부 MCP 서버에 연결되는 integration은 내부적으로 App으로 관리된다.
+
+그래서 다음 두 표현이 동시에 등장할 수 있다.
+
+```text title="plugin-vs-app.txt"
+현재 ChatGPT 화면
+Plugins / 새 플러그인
+
+OpenAI 공식 MCP 설정 문서
+Apps / Create
+```
+
+둘이 완전히 다른 기능이라는 뜻이 아니다. 우리 팀에서 실제로 DevSpace를 등록할 때는 **Plugins 메뉴의 새 플러그인 흐름**을 사용했고, 그 안에서 DevSpace MCP endpoint와 OAuth 인증을 연결했다.
+
+### Developer mode를 실제로 켜는 위치
+
+먼저 ChatGPT **웹**을 연다. 모바일 앱에서 설정하려고 하지 않는다.
+
+Business 워크스페이스의 관리자/소유자라면 다음 경로를 먼저 확인한다.
```text title="chatgpt-menu.txt"
Settings
@@ -73,7 +95,7 @@ Settings
→ Developer mode
```
-또는 워크스페이스 관리자라면 다음 경로에서 custom app 생성을 확인한다.
+화면 구성에 따라 아래 custom app 생성 화면에 들어가면서 Developer mode를 활성화할 수도 있다.
```text title="chatgpt-workspace-menu.txt"
Workspace Settings
@@ -81,11 +103,34 @@ Workspace Settings
→ Create
```
+Enterprise/Edu에서 일반 개발자 계정으로 사용하는 경우에는 관리자가 먼저 권한을 열어 줘야 한다.
+
+```text title="chatgpt-enterprise-admin.txt"
+관리자
+Workspace Settings
+→ Permissions & Roles
+→ Connected Data
+→ Developer mode / Create custom MCP connectors 허용
+```
+
+그다음 권한을 받은 사용자가 자신의 계정에서 켠다.
+
+```text title="chatgpt-enterprise-user.txt"
+사용자
+Settings
+→ Apps
+→ Advanced Settings
+→ Developer mode ON
+```
+
+메뉴가 안 보이면 DevSpace 설치 문제부터 의심하지 말고 먼저 **내 ChatGPT 플랜, 워크스페이스 역할, 관리자 정책**을 확인한다.
+
쓰기 권한이 없는 계정에서는 DevSpace 설치 자체가 ChatGPT 플랜 제한을 우회해 주지 않는다.
공식 안내:
- [OpenAI - Developer mode and MCP apps in ChatGPT](https://help.openai.com/en/articles/12584461-developer-mode-and-full-mcp-connectors-in-chatgpt-beta)
+- [OpenAI - Plugins in ChatGPT and Codex](https://help.openai.com/en/articles/20001256-plugins-in-codexOpenAI)
---
@@ -454,9 +499,24 @@ http://127.0.0.1:7676/mcp
## 8. ChatGPT에 DevSpace 연결
-ChatGPT 웹에서 Developer mode가 활성화돼 있다는 전제다.
+여기가 실제 연결에서 가장 중요한 부분이다.
+
+앞 단계에서 ChatGPT 웹의 Developer mode를 켰고, DevSpace 서버와 고정 HTTPS 주소가 모두 살아 있어야 한다.
+
+### 8-1. 현재 UI에서는 Plugins에서 시작한다
+
+우리 팀에서 실제로 연결할 때 확인한 흐름은 다음과 같다.
+
+```text title="chatgpt-plugin-create.txt"
+ChatGPT 웹 왼쪽 사이드바
+→ Plugins
+→ 새 플러그인(New plugin)
+```
+
+OpenAI 공식 도움말에는 같은 underlying MCP integration 설정을 `Settings → Apps → Create`라고 설명하는 부분도 있다. 2026년 7월 Plugin Directory 전환 이후 UI와 문서 용어가 같이 존재하고 있으므로, **현재 화면에 Plugins가 보이면 Plugins → 새 플러그인 흐름을 우선 사용**하면 된다.
+
+Plugins 메뉴가 보이지 않지만 Developer mode 권한이 있는 관리형 워크스페이스라면 아래 경로도 확인한다.
-현재 공식 UI 기준으로 다음 경로에서 custom app을 만든다.
```text title="chatgpt-create-app.txt"
Settings
@@ -472,7 +532,9 @@ Workspace Settings
→ Create
```
-이름은 팀에서 통일하면 찾기 편하다.
+### 8-2. DevSpace MCP 정보를 입력한다
+
+새 플러그인 또는 custom app 생성 화면에서 이름은 팀에서 통일하면 찾기 편하다.
```text title="app-name.txt"
devspace-codex
@@ -490,13 +552,50 @@ https://example-name.ngrok-free.app/mcp
https://devspace.oosu.dev/mcp
```
-그다음 현재 ChatGPT UI 기준으로:
+### 8-3. Authentication은 OAuth를 선택한다
+
+DevSpace는 MCP endpoint를 OAuth로 보호한다. 따라서 인증 방식 선택 항목이 나오면 **OAuth**를 선택한다.
+
+```text title="chatgpt-auth.txt"
+Authentication
+→ OAuth
+```
-1. Authentication 설정을 확인한다.
-2. `Scan Tools`를 실행한다.
-3. DevSpace OAuth 승인 화면이 열리면 Owner password를 입력한다.
-4. tool scan이 끝날 때까지 기다린다.
-5. `Create`를 완료한다.
+이 OAuth는 Google/GitHub 로그인 같은 별도 서비스 계정 인증이 아니다. DevSpace가 제공하는 OAuth 승인 페이지가 열리고, 여기서 `devspace init` 때 발급된 **Owner password**로 이 ChatGPT 연결을 승인하는 구조다.
+
+Owner password는 다음 위치에도 저장돼 있다.
+
+```text title="devspace-owner-password.txt"
+~/.devspace/auth.json
+```
+
+파일 전체를 팀 채팅에 올리거나 password를 다른 팀원과 공유하면 안 된다. 네 명이 각각 자신의 DevSpace를 설치했다면 각자 서로 다른 Owner password를 사용한다.
+
+### 8-4. 실제 OAuth 승인과 Tool Scan
+
+현재 ChatGPT UI에서는 대략 다음 순서로 진행된다.
+
+1. 플러그인 이름을 `devspace-codex`로 입력한다.
+2. MCP endpoint에 `https://고정주소/mcp`를 입력한다.
+3. Authentication을 `OAuth`로 선택한다.
+4. `Scan Tools` 또는 연결 검사를 실행한다.
+5. 브라우저에 DevSpace OAuth 승인 페이지가 열리면 **내 Owner password**를 입력한다.
+6. ChatGPT로 돌아와 tool scan이 끝날 때까지 기다린다.
+7. `Create`, `Save` 또는 현재 UI의 생성 완료 버튼을 누른다.
+8. 새 채팅에서 `@devspace-codex`가 선택되는지 확인한다.
+
+정상이라면 ChatGPT가 DevSpace의 `open_workspace`, `read`, `apply_patch`, `exec_command` 같은 MCP tool을 스캔해 사용할 수 있게 된다.
+
+OAuth 승인이 끝났는데 다시 인증을 반복해서 요구한다면 다음부터 확인한다.
+
+```text title="oauth-troubleshooting.txt"
+1. DevSpace publicBaseUrl이 현재 고정 hostname과 같은가?
+2. ChatGPT endpoint 끝에 /mcp가 있는가?
+3. DevSpace 서버를 재시작한 뒤 OAuth client 상태가 바뀌지 않았는가?
+4. Owner password를 내 ~/.devspace/auth.json 기준으로 입력했는가?
+```
+
+DevSpace 공식 문서상 MCP client가 연결될 때 Owner password 승인 페이지가 열리며, OAuth redirect host와 public URL도 서버 설정의 일부다.
여기서 가장 흔한 실수는 URL 두 개를 반대로 쓰는 것이다.
@@ -799,6 +898,61 @@ DevSpace
---
+## 17. 실제로 써보면 좋은 점: 코딩 화면이 한 컴퓨터에 묶이지 않는다
+
+DevSpace의 장점은 단순히 "ChatGPT가 로컬 파일을 읽는다"에서 끝나지 않는다.
+
+Codex Desktop만 사용할 때는 보통 코딩 작업과 대화가 그 컴퓨터의 데스크톱 앱을 중심으로 돌아간다. DevSpace를 ChatGPT 웹에 연결하면 **실제 코드는 내 컴퓨터에 그대로 두면서 ChatGPT 대화를 작업 제어 화면처럼 사용할 수 있다.**
+
+예를 들어 교육장에서 노트북으로 다음과 같이 작업할 수 있다.
+
+```text title="cross-device-example.txt"
+노트북의 DevSpace 서버
+ ↓
+ChatGPT 웹에서
+"PR 리뷰 반영 → 테스트 → commit → push까지 진행해줘"
+ ↓
+작업 결과와 대화가 ChatGPT 계정의 같은 conversation에 남음
+ ↓
+이동 중 휴대폰에서 같은 conversation을 열어
+진행 결과, diff 요약, 에러 내용을 확인
+ ↓
+후속 요구사항이나 다음 작업 방향을 바로 메시지로 남김
+```
+
+즉, 프로젝트 폴더를 휴대폰에 복사하거나 원격 데스크톱으로 작은 화면을 조작하지 않아도 **같은 ChatGPT 대화 흐름을 여러 기기에서 확인하고 이어갈 수 있다는 점**이 꽤 편하다.
+
+특히 우리 팀처럼 여러 PR과 저장소를 병렬로 다루는 경우에는 다음 장점이 있다.
+
+- 자리를 비운 뒤에도 휴대폰에서 작업 결과와 실패 원인을 확인하기 쉽다.
+- 팀 채팅에서 새 요구사항이 나오면 같은 ChatGPT conversation에 바로 추가해 다음 작업 컨텍스트로 남길 수 있다.
+- 데스크톱 Codex 앱의 특정 로컬 UI에만 작업 기록이 갇히지 않고 ChatGPT conversation을 기준으로 이어가기 쉽다.
+- 다음에 컴퓨터 앞에 돌아왔을 때 앞에서 무슨 작업을 했는지 다시 설명하는 비용이 줄어든다.
+
+다만 **2026년 8월 13일 현재 OpenAI 공식 안내에서 MCP apps는 web-only**다. Plugin Directory는 ChatGPT 웹과 데스크톱에서 사용할 수 있지만, 모바일 앱에서 DevSpace MCP tool을 직접 호출하는 흐름까지 공식 지원되는 것은 아니다.
+
+따라서 현재 기준으로는 이렇게 이해하는 게 정확하다.
+
+```text title="mobile-boundary.txt"
+모바일에서 잘하는 것
+→ 같은 ChatGPT conversation 열기
+→ 이전 작업 내용과 결과 확인
+→ 다음 요구사항과 수정 방향 전달
+→ 작업 지시를 대화 컨텍스트에 남기기
+
+현재 공식 지원을 전제로 하면 웹에서 하는 것
+→ DevSpace MCP를 실제 호출
+→ 로컬 파일 읽기/수정
+→ shell 명령 실행
+→ 테스트, commit, push
+```
+
+그리고 당연하지만 **DevSpace가 설치된 컴퓨터가 꺼져 있거나 잠자기 상태로 네트워크 연결이 끊기면 로컬 작업은 실행할 수 없다.** DevSpace 서버와 tunnel이 살아 있어야 ChatGPT 웹에서 그 컴퓨터에 접근할 수 있다.
+
+그래도 "로컬 코드는 내 컴퓨터에 두고, 작업 대화는 어디서든 확인한다"는 구조 자체가 기존 데스크톱 전용 코딩 에이전트와 비교했을 때 실사용에서 꽤 큰 차이다.
+
+---
+
## 설치 완료 체크리스트
- Node, npm, Git, Bash 버전 확인
@@ -823,6 +977,7 @@ DevSpace
- [DevSpace Troubleshooting Gotchas](https://github.com/Waishnav/devspace/blob/main/docs/gotchas.md)
- [DevSpace Security Model](https://github.com/Waishnav/devspace/security)
- [OpenAI Developer mode and MCP apps](https://help.openai.com/en/articles/12584461-developer-mode-and-full-mcp-connectors-in-chatgpt-beta)
+- [OpenAI Plugins in ChatGPT and Codex](https://help.openai.com/en/articles/20001256-plugins-in-codexOpenAI)
- [ngrok Free Plan Limits](https://ngrok.com/docs/pricing-limits/free-plan-limits)
- [ngrok Share Localhost Quickstart](https://ngrok.com/docs/guides/share-localhost/quickstart)
- [ngrok Agent background service](https://ngrok.com/docs/agent)
From ede8e40b0bc44bd3163a92960667c09e95f1a1b7 Mon Sep 17 00:00:00 2001
From: Gabriel Jang
Date: Thu, 13 Aug 2026 17:32:55 +0900
Subject: [PATCH 07/12] docs: add macOS sleep prevention tip
---
content/blog/devspace-team-onboarding.mdx | 25 +++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/content/blog/devspace-team-onboarding.mdx b/content/blog/devspace-team-onboarding.mdx
index 2e00df5..5f234ea 100644
--- a/content/blog/devspace-team-onboarding.mdx
+++ b/content/blog/devspace-team-onboarding.mdx
@@ -949,6 +949,31 @@ ChatGPT 웹에서
그리고 당연하지만 **DevSpace가 설치된 컴퓨터가 꺼져 있거나 잠자기 상태로 네트워크 연결이 끊기면 로컬 작업은 실행할 수 없다.** DevSpace 서버와 tunnel이 살아 있어야 ChatGPT 웹에서 그 컴퓨터에 접근할 수 있다.
+### macOS 팁: 이동 중에도 DevSpace 작업을 계속 돌리고 싶다면
+
+나는 MacBook을 들고 이동할 때도 DevSpace 작업이 중간에 끊기지 않도록 **시스템 잠자기를 임시로 비활성화**해서 사용한다. 배터리와 전원 연결 상태에 모두 적용하려면 Terminal에서 다음 명령을 실행한다.
+
+```bash title="macos-disable-sleep.sh"
+sudo pmset -a disablesleep 1
+```
+
+그다음 화면 밝기는 가능한 한 낮춰 두고, DevSpace 서버와 tunnel이 계속 실행되는 상태로 둔다. 이렇게 해 두면 이동 중에도 MacBook이 잠들어서 장시간 작업이나 테스트가 중간에 끊기는 상황을 줄일 수 있다.
+
+사용을 마쳤다면 **반드시 원래 설정으로 복구**한다.
+
+```bash title="macos-restore-sleep.sh"
+sudo pmset -a disablesleep 0
+```
+
+이 설정은 macOS 전체의 자동 잠자기를 막는 것이므로 상시 켜 두는 용도로 사용하지 않는 편이 좋다. 특히 다음은 꼭 주의한다.
+
+- 잠자기를 막아 두면 배터리 소모가 계속된다.
+- 빌드, 테스트, Docker 같은 작업이 돌아가는 동안에는 CPU 사용량과 발열이 올라갈 수 있다.
+- **작동 중인 MacBook을 백팩, 파우치, 서랍처럼 통풍이 거의 없는 밀폐 공간에 넣어 두지 않는다.** 덮개를 닫아 둘 때도 열이 빠질 수 있는 곳에 둔다.
+- 작업이 끝났거나 장시간 사용하지 않을 때는 `sudo pmset -a disablesleep 0`으로 되돌린다.
+
+즉, 이 방법은 "이동하는 동안 잠깐 원격 작업을 계속 돌려야 할 때" 쓰는 임시 운용 팁으로 생각하는 게 좋다.
+
그래도 "로컬 코드는 내 컴퓨터에 두고, 작업 대화는 어디서든 확인한다"는 구조 자체가 기존 데스크톱 전용 코딩 에이전트와 비교했을 때 실사용에서 꽤 큰 차이다.
---
From 32525b9e183e47445f3817649e9769941f5abdb2 Mon Sep 17 00:00:00 2001
From: Gabriel Jang
Date: Thu, 13 Aug 2026 17:49:50 +0900
Subject: [PATCH 08/12] fix: correct M1DDC post date
---
content/blog/m1ddc-monitor-brightness.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/content/blog/m1ddc-monitor-brightness.mdx b/content/blog/m1ddc-monitor-brightness.mdx
index 6fff19b..4fd3cc3 100644
--- a/content/blog/m1ddc-monitor-brightness.mdx
+++ b/content/blog/m1ddc-monitor-brightness.mdx
@@ -1,6 +1,6 @@
---
title: "한성 TFX173T 모니터 밝기 초기화 문제, m1ddc와 Swift로 완전 자동화하기"
-date: 2025-05-13
+date: 2026-05-13
tags: ["macOS", "m1ddc", "DDC/CI", "LaunchAgent", "Swift", "한성TFX173T", "모니터"]
description: "한성 포터블 모니터를 연결할 때마다 백라이트 밝기가 1로 초기화되는 문제를 터미널 명령어로 해결하고, Swift 이벤트 기반 데몬으로 자동화한 과정을 기록한다."
slug: "m1ddc-monitor-brightness"
From b1e8a292530722282d8b7c943642000ea1eb3fd6 Mon Sep 17 00:00:00 2001
From: oosuhada
Date: Thu, 20 Aug 2026 23:25:00 +0900
Subject: [PATCH 09/12] Add ChatGPT DevSpace RAM saver post
---
content/blog/chatgpt-devspace-ram-saver.mdx | 455 ++++++++++++++++++++
1 file changed, 455 insertions(+)
create mode 100644 content/blog/chatgpt-devspace-ram-saver.mdx
diff --git a/content/blog/chatgpt-devspace-ram-saver.mdx b/content/blog/chatgpt-devspace-ram-saver.mdx
new file mode 100644
index 0000000..cc37628
--- /dev/null
+++ b/content/blog/chatgpt-devspace-ram-saver.mdx
@@ -0,0 +1,455 @@
+---
+title: "ChatGPT + DevSpace 긴 대화가 RAM을 먹어치울 때, Tampermonkey로 버텨낸 기록"
+date: 2026-08-20
+tags: ["ChatGPT", "DevSpace", "Tampermonkey", "Brave", "macOS", "메모리최적화"]
+description: "ChatGPT 웹에 DevSpace를 연결해 어디서든 코딩 지시를 내리기 시작한 뒤, 길어진 대화 한 탭이 수 GB의 RAM과 swap을 잡아먹는 문제를 겪었다. 브라우저 변경과 여러 userscript 실험 끝에 오래된 DOM을 직접 비우는 RAM Saver v5.0에 정착한 과정을 기록한다."
+slug: "chatgpt-devspace-ram-saver"
+published: true
+---
+
+## TL;DR
+
+- ChatGPT 웹에 DevSpace를 연결하고 나니 노트북을 열지 않아도 스마트폰에서 코딩 작업을 지시하고 결과를 확인할 수 있어서 작업 방식이 꽤 많이 바뀌었다.
+- 대신 한 대화를 오래 이어 갈수록 브라우저 탭의 메모리가 계속 커졌다. 내 환경에서는 한 세션이 1GB를 넘기는 것은 흔했고, 길어진 세션은 3~4GB 이상까지 올라갔다.
+- Brave, Orion, Edge 같은 다른 브라우저와 메모리 절약 설정도 시험했지만 내가 원했던 수준의 차이는 없었다.
+- 저장해 둔 긴 대화 HTML을 확인했을 때 DevSpace 관련 iframe뿐 아니라 SVG, 이미지, 코드블록, 버튼 같은 DOM이 대량으로 남아 있었다.
+- 결국 Tampermonkey userscript에서 **DevSpace iframe을 비우고, 오래된 메시지를 가상화하고, 화면 밖 이미지·SVG·코드블록을 정리하는 방식**으로 방향을 바꿨고, 이 방식으로 실제 사용 중 메모리 압박을 관리할 수 있게 됐다.
+
+이 글은 일반적인 ChatGPT 성능 벤치마크가 아니라 **내가 실제로 ChatGPT 웹 + DevSpace를 장시간 사용하는 환경에서 겪은 문제와 해결 과정**을 기록한 글이다.
+
+---
+
+## DevSpace를 연결하고 나니 작업 방식이 정말 편해졌다
+
+ChatGPT 브라우저에 DevSpace를 연결한 뒤 가장 크게 달라진 것은 장소의 제약이었다.
+
+예전에는 코드를 수정하려면 결국 노트북을 열어야 했다. 하지만 DevSpace를 붙여 두고 나서는 출퇴근 지하철에서도 스마트폰으로 작업을 지시할 수 있었다.
+
+예를 들면 이런 흐름이다.
+
+```text title="mobile-dev-workflow.txt"
+스마트폰에서 ChatGPT 열기
+ ↓
+작업 지시
+ ↓
+DevSpace가 집/사무실 Mac의 실제 저장소 접근
+ ↓
+코드 수정 / 테스트 / Git 작업
+ ↓
+스마트폰에서 결과 확인
+```
+
+노트북을 가방에서 꺼내지 않아도 된다는 것만으로도 생각보다 큰 차이가 있었다.
+
+특히 여러 저장소를 동시에 다루거나, CI 결과를 보고 후속 수정을 지시하거나, 이동 중에 간단한 검증을 맡길 때 편했다.
+
+그런데 대화를 오래 이어가기 시작하면서 다른 문제가 생겼다.
+
+## 문제는 대화가 길어질수록 브라우저 RAM이 너무 커진다는 것
+
+처음에는 그냥 "ChatGPT 웹이 조금 무겁네" 정도였다.
+
+그런데 한 세션에서 DevSpace 호출을 계속 쌓아가며 몇 시간씩 작업하니 상황이 달라졌다.
+
+내 환경에서는 ChatGPT 한 탭이 1GB를 넘어가는 일이 흔했고, 대화가 충분히 길어지면 **3~4GB 이상**을 잡아먹는 경우도 생겼다.
+
+메인 작업 머신으로 쓰던 MacBook Air는 RAM이 24GB였는데, 다른 앱을 함께 띄우고 긴 ChatGPT 세션 몇 개를 유지하면 **swap이 5GB 이상** 생기는 상황도 반복됐다.
+
+64GB RAM의 MacBook Pro로 옮기면 해결될 것 같았지만 그쪽에는 또 다른 문제가 있었다. PR 자동 리뷰를 위해 로컬 LLM을 함께 돌리고 있었기 때문에, 브라우저가 몇 GB씩 가져가는 것이 여전히 부담이었다.
+
+즉 단순히 "RAM이 많은 컴퓨터를 쓰면 된다"로 끝낼 문제가 아니었다.
+
+## Brave와 Orion으로 바꿔 봤지만 드라마틱하지 않았다
+
+처음에는 브라우저 엔진 문제라고 생각했다.
+
+검색하면서 Brave, Orion 같은 브라우저를 후보로 잡았다. Brave는 Chromium 계열이라 무거운 웹앱을 오래 돌릴 때 상대적으로 버티지 않을까 기대했고, Orion은 WebKit 기반의 가벼운 탭 관리가 장점이라고 해서 시험해 봤다. Edge에서도 같은 긴 대화를 열어 비교해 봤다.
+
+브라우저 자체의 메모리 절약 기능, 하드웨어 가속 설정 변경, 백그라운드 탭 정리 같은 것도 함께 시도했다.
+
+하지만 내 사용 패턴에서는 브라우저만 바꾼다고 문제가 사라지지는 않았다.
+
+긴 대화에 들어가면 결국 메모리가 크게 올라갔다.
+
+이때부터 "브라우저가 어떤 엔진인가"보다 **한 대화 안에 무엇이 계속 남아 있는가**를 보기 시작했다.
+
+## 저장된 대화 HTML을 보니 iframe만의 문제가 아니었다
+
+처음 Tampermonkey 스크립트를 만들 때는 DevSpace가 띄우는 iframe이 주범이라고 생각했다.
+
+그래서 초기 버전에서는 DevSpace나 workspace 관련 iframe을 찾아 `about:blank`로 바꾸고 내부를 비우는 방식부터 시도했다.
+
+그런데 저장한 긴 대화 HTML을 확인해 보니 한 시점에 다음처럼 많은 요소가 쌓여 있었다.
+
+| 요소 | 확인한 개수 |
+| --- | ---: |
+| iframe | 288 |
+| SVG | 734 |
+| 이미지 | 290 |
+| 코드블록 | 163 |
+| 버튼 | 1,293 |
+
+이 숫자는 ChatGPT의 일반적인 고정값이 아니다. **내가 길게 사용한 특정 세션을 저장해서 확인한 스냅샷**이다.
+
+중요했던 건 iframe 하나만 지운다고 끝날 구조가 아니었다는 점이다.
+
+툴 호출 결과, 아이콘 SVG, 이미지, syntax highlighting을 위한 수많은 `span`, 과거 메시지 DOM까지 한 페이지에 계속 남아 있었다.
+
+그래서 방향을 바꿨다.
+
+## v1.5 → v2.0 → v3.0, 그리고 결국 v5.0
+
+Tampermonkey 스크립트도 한 번에 완성된 것은 아니었다.
+
+처음에는 React 화면을 완전히 깨뜨리지 않으려고 DOM 노드를 무작정 `remove()`하는 대신 iframe의 `src`를 `about:blank`로 바꾸고 컨테이너 내부를 비우는 방식으로 시작했다.
+
+그 뒤에도 메모리가 충분히 내려가지 않아 더 공격적인 정리를 시도했고, 정리가 실제로 얼마나 일어나고 있는지 확인하려고 우측 하단에 카운터 뱃지도 붙였다.
+
+마지막에는 iframe만이 아니라 다음을 모두 대상으로 삼았다.
+
+- DevSpace / workspace 관련 iframe 비우기
+- 생각 블록과 툴 메시지 내부 비우기
+- 화면 밖 SVG 제거
+- 화면 밖 이미지 `src` 언로드
+- 화면 밖 코드블록의 syntax highlighting용 하위 `span` 제거
+- 오래된 메시지의 전체 DOM을 짧은 preview placeholder로 교체
+- 애니메이션과 transition 비활성화
+- MutationObserver로 새 DOM이 생길 때마다 다시 정리
+
+핵심은 마지막 항목 중 **오래된 메시지 가상화**였다.
+
+최근 메시지 몇 개만 온전하게 남기고, 이미 화면 위로 지나간 오래된 대화는 내용 전체를 들고 있지 않게 만드는 방식이다.
+
+## 최종적으로 정착한 ChatGPT RAM Saver v5.0
+
+아래가 마지막에 정착해서 사용한 버전이다.
+
+```javascript title="chatgpt-ram-saver-v5.user.js"
+// ==UserScript==
+// @name ChatGPT RAM Saver v5.0 (Total Memory Purge)
+// @namespace http://tampermonkey.net
+// @version 5.0
+// @description iframe뿐 아니라 SVG, 이미지, 코드블록, 이벤트리스너, 애니메이션까지 전부 정리하여 ChatGPT RAM을 극한까지 절약합니다.
+// @author You
+// @match https://chatgpt.com/*
+// @grant none
+// @run-at document-end
+// ==/UserScript==
+
+(function() {
+ 'use strict';
+
+ // ─── 상태 ───
+ let stats = { iframes: 0, svgs: 0, imgs: 0, codeblocks: 0, toolmsgs: 0, thoughts: 0 };
+ let isProcessing = false;
+ const KEEP_LAST_N_MESSAGES = 4; // 최근 N개 메시지만 풀 DOM 유지
+
+ // ─── 1. CSS 주입: 애니메이션 전체 비활성화 + GPU 메모리 절약 ───
+ const injectCSS = () => {
+ if (document.getElementById('ram-saver-css')) return;
+ const style = document.createElement('style');
+ style.id = 'ram-saver-css';
+ style.textContent = `
+ /* 숨길 요소 */
+ div[data-testid="thought-block"],
+ span.group\\/tool-message {
+ display: none !important;
+ height: 0 !important;
+ overflow: hidden !important;
+ }
+
+ /* 모든 CSS 애니메이션/트랜지션 비활성화 → GPU 메모리 절약 */
+ *, *::before, *::after {
+ animation-duration: 0s !important;
+ animation-delay: 0s !important;
+ transition-duration: 0s !important;
+ transition-delay: 0s !important;
+ }
+
+ /* 로딩 shimmer 비활성화 */
+ .loading-results-shimmer {
+ display: none !important;
+ background: none !important;
+ }
+
+ /* 가상화된(비워진) 메시지 placeholder */
+ [data-virtualized="true"] {
+ min-height: 40px;
+ max-height: 40px;
+ overflow: hidden !important;
+ opacity: 0.3;
+ }
+
+ /* 뱃지 */
+ #ram-saver-badge {
+ position: fixed !important;
+ z-index: 2147483647 !important;
+ bottom: 16px !important;
+ right: 16px !important;
+ padding: 6px 10px !important;
+ background: rgba(0,0,0,0.8) !important;
+ color: #00FF88 !important;
+ border-radius: 8px !important;
+ font: 11px/1.4 'SF Mono', Menlo, monospace !important;
+ pointer-events: none !important;
+ white-space: pre-line !important;
+ box-shadow: 0 2px 12px rgba(0,0,0,0.4) !important;
+ display: block !important;
+ visibility: visible !important;
+ opacity: 1 !important;
+ }
+ `;
+ (document.head || document.documentElement).appendChild(style);
+ };
+
+ // ─── 2. UI 뱃지 ───
+ const updateBadge = () => {
+ let b = document.getElementById('ram-saver-badge');
+ if (!b) {
+ b = document.createElement('div');
+ b.id = 'ram-saver-badge';
+ document.documentElement.appendChild(b);
+ }
+ const total = Object.values(stats).reduce((a, c) => a + c, 0);
+ b.textContent = `🧹 RAM Saver v5.0\niframe:${stats.iframes} svg:${stats.svgs} img:${stats.imgs}\ncode:${stats.codeblocks} tool:${stats.toolmsgs} think:${stats.thoughts}\n총 정리: ${total}개`;
+ };
+
+ // ─── 3. iframe 청소 ───
+ const purgeIframes = () => {
+ document.querySelectorAll('iframe:not([data-purged="true"])').forEach(frame => {
+ const t = (frame.getAttribute('title') || '').toLowerCase();
+ const s = (frame.getAttribute('src') || '').toLowerCase();
+
+ // 시스템 필수 iframe 보호 (display:none인 숨겨진 인증용)
+ if (frame.style.display === 'none' && !t && !s.includes('devspace')) return;
+
+ const isTarget = t.includes('devspace') || t.includes('ui://') ||
+ t.includes('workspace') || s.includes('devspace') ||
+ s.includes('workspace-app') || s.includes('saved_resource');
+
+ if (isTarget) {
+ try { frame.contentWindow && frame.contentWindow.stop(); } catch(e) {}
+ frame.removeAttribute('src');
+ frame.removeAttribute('srcdoc');
+ frame.src = 'about:blank';
+ frame.setAttribute('data-purged', 'true');
+
+ const wrapper = frame.closest('.no-scrollbar') ||
+ frame.closest('[class*="overflow-hidden"]') ||
+ frame.parentElement;
+ if (wrapper && wrapper !== document.body) {
+ wrapper.innerHTML = '';
+ wrapper.style.cssText = 'display:none!important;height:0!important;';
+ }
+ stats.iframes++;
+ }
+ });
+ };
+
+ // ─── 4. 화면 밖 SVG 제거 ───
+ const purgeSVGs = () => {
+ // 대화 영역 안의 SVG만 대상 (사이드바/입력창 SVG는 보호)
+ const chatArea = document.querySelector('[class*="react-scroll-to-bottom"]') ||
+ document.querySelector('main');
+ if (!chatArea) return;
+
+ chatArea.querySelectorAll('svg:not([data-purged="true"])').forEach(svg => {
+ // 화면 밖에 있는 SVG만 제거
+ const rect = svg.getBoundingClientRect();
+ if (rect.bottom < -200 || rect.top > window.innerHeight + 200) {
+ // 부모에 빈 placeholder 남기고 SVG 제거
+ svg.setAttribute('data-purged', 'true');
+ svg.innerHTML = '';
+ svg.style.display = 'none';
+ stats.svgs++;
+ }
+ });
+ };
+
+ // ─── 5. 화면 밖 이미지 언로드 ───
+ const purgeImages = () => {
+ const chatArea = document.querySelector('[class*="react-scroll-to-bottom"]') ||
+ document.querySelector('main');
+ if (!chatArea) return;
+
+ chatArea.querySelectorAll('img:not([data-purged="true"])').forEach(img => {
+ const rect = img.getBoundingClientRect();
+ if (rect.bottom < -300 || rect.top > window.innerHeight + 300) {
+ if (img.src && !img.getAttribute('data-original-src')) {
+ img.setAttribute('data-original-src', img.src);
+ }
+ img.removeAttribute('src');
+ img.removeAttribute('srcset');
+ img.setAttribute('data-purged', 'true');
+ img.style.cssText = 'height:20px!important;width:20px!important;';
+ stats.imgs++;
+ }
+ });
+ };
+
+ // ─── 6. 코드블록 구문 강조 제거 (span 노드 대량 절약) ───
+ const simplifyCodeBlocks = () => {
+ const chatArea = document.querySelector('[class*="react-scroll-to-bottom"]') ||
+ document.querySelector('main');
+ if (!chatArea) return;
+
+ chatArea.querySelectorAll('pre:not([data-simplified="true"])').forEach(pre => {
+ const rect = pre.getBoundingClientRect();
+ // 화면 밖의 코드블록만 단순화
+ if (rect.bottom < -200 || rect.top > window.innerHeight + 200) {
+ const codeEl = pre.querySelector('code');
+ if (codeEl) {
+ const plainText = codeEl.textContent; // span 다 벗기고 순수 텍스트만
+ codeEl.innerHTML = '';
+ codeEl.textContent = plainText;
+ }
+ pre.setAttribute('data-simplified', 'true');
+ stats.codeblocks++;
+ }
+ });
+ };
+
+ // ─── 7. 생각 블록 & 툴 메시지 비우기 ───
+ const purgeThoughtsAndTools = () => {
+ document.querySelectorAll('div[data-testid="thought-block"]:not([data-purged="true"])').forEach(el => {
+ el.innerHTML = '';
+ el.setAttribute('data-purged', 'true');
+ stats.thoughts++;
+ });
+ document.querySelectorAll('span.group\\/tool-message:not([data-purged="true"])').forEach(el => {
+ el.innerHTML = '';
+ el.setAttribute('data-purged', 'true');
+ stats.toolmsgs++;
+ });
+ };
+
+ // ─── 8. 오래된 메시지 가상화 (핵심!) ───
+ const virtualizeOldMessages = () => {
+ const messages = document.querySelectorAll('[data-message-id]:not([data-virtualized="true"])');
+ if (messages.length <= KEEP_LAST_N_MESSAGES) return;
+
+ // 최근 N개를 제외한 오래된 메시지의 내부를 비움
+ const toVirtualize = Array.from(messages).slice(0, messages.length - KEEP_LAST_N_MESSAGES);
+ toVirtualize.forEach(msg => {
+ const rect = msg.getBoundingClientRect();
+ // 화면 밖에 있을 때만 가상화
+ if (rect.bottom < -100) {
+ // 텍스트 요약만 남기고 내부 DOM 비우기
+ const role = msg.getAttribute('data-message-author-role') || 'unknown';
+ const preview = msg.textContent.substring(0, 80).trim();
+ msg.innerHTML = `
[${role}] ${preview}...
`;
+ msg.setAttribute('data-virtualized', 'true');
+ }
+ });
+ };
+
+ // ─── 메인 청소 함수 ───
+ const fullPurge = () => {
+ if (isProcessing) return;
+ isProcessing = true;
+ try {
+ purgeIframes();
+ purgeThoughtsAndTools();
+ purgeSVGs();
+ purgeImages();
+ simplifyCodeBlocks();
+ virtualizeOldMessages();
+ updateBadge();
+ } finally {
+ isProcessing = false;
+ }
+ };
+
+ // ─── MutationObserver (디바운스) ───
+ let timer = null;
+ const observer = new MutationObserver((muts) => {
+ if (muts.every(m => m.target.id === 'ram-saver-badge' || m.target.id === 'ram-saver-css')) return;
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(fullPurge, 400);
+ });
+
+ // ─── 부팅 ───
+ injectCSS();
+ observer.observe(document.documentElement, { childList: true, subtree: true });
+
+ // 초기 실행 (페이지 로딩 직후 시차를 두고 3회)
+ setTimeout(fullPurge, 500);
+ setTimeout(fullPurge, 2000);
+ setTimeout(fullPurge, 5000);
+
+ // 백업: 5초마다 정기 청소 + 뱃지 복구
+ setInterval(() => {
+ fullPurge();
+ updateBadge();
+ }, 5000);
+
+ console.log('🚀 [ChatGPT RAM Saver v5.0] Total Memory Purge 가동!');
+})();
+```
+
+## 왜 이 방식이 효과가 있었나
+
+내 경우에는 브라우저를 바꾸는 것보다 **길어진 대화의 과거 UI를 계속 온전한 DOM으로 유지하지 않는 것**이 훨씬 직접적인 접근이었다.
+
+특히 DevSpace처럼 한 번의 대화에서 툴 호출이 많이 발생하는 사용 패턴에서는 단순 텍스트만 쌓이는 것이 아니다.
+
+각 호출의 UI, 아이콘, 상태 표시, 코드 결과, iframe, syntax highlighting 구조까지 함께 쌓일 수 있다.
+
+v5.0은 "브라우저 메모리 관리가 더 잘 되기를 기다리는 것" 대신, 내가 다시 볼 가능성이 낮은 과거 화면의 DOM을 아예 줄여 버린다.
+
+최근 네 개 메시지만 전체 DOM으로 유지하도록 한 것도 이 때문이다.
+
+```javascript title="important-setting.js"
+const KEEP_LAST_N_MESSAGES = 4;
+```
+
+이 숫자는 정답이 아니다. 과거 대화를 자주 위로 스크롤해서 확인한다면 8이나 12처럼 늘리는 편이 낫다.
+
+나는 장시간 코딩 세션에서 "지금 진행 중인 최근 문맥"을 보는 경우가 대부분이라 4로 두었다.
+
+## 대신 꽤 공격적인 스크립트다
+
+이 스크립트에는 분명한 대가가 있다.
+
+오래된 메시지의 내부 DOM을 직접 비우기 때문에, 위로 스크롤했을 때 원래의 완전한 메시지 UI를 그대로 다시 볼 수 없다. 이미지와 SVG도 unload하거나 비워 버리고, 코드블록의 syntax highlighting도 제거한다.
+
+또 ChatGPT 웹의 내부 `data-testid`, class 이름, 메시지 DOM 구조를 직접 참조한다. ChatGPT UI가 변경되면 selector가 더 이상 맞지 않거나 일부 기능이 깨질 수 있다.
+
+그래서 이 스크립트는 다음 전제로 쓰고 있다.
+
+1. 중요한 과거 대화는 필요하면 페이지를 새로고침하거나 별도로 기록한다.
+2. UI가 이상해지면 Tampermonkey 스크립트를 먼저 끄고 확인한다.
+3. ChatGPT 화면 구조가 바뀌면 selector를 다시 점검한다.
+4. 로그인·인증처럼 정체를 확실히 모르는 iframe은 무조건 삭제하지 않는다.
+
+즉 "모든 사람에게 추천하는 일반 최적화"라기보다, **한 세션에서 DevSpace 툴 호출을 아주 많이 쌓는 내 작업 방식에 맞춘 강제 가상화 스크립트**에 가깝다.
+
+## 결국 새 대화를 자주 여는 것이 가장 단순한 해결책이기도 하다
+
+이 과정에서 얻은 결론이 하나 더 있다.
+
+스크립트 없이 가장 확실하게 가벼운 상태를 유지하려면 한 세션을 끝없이 이어 가지 않는 것이 좋다.
+
+문제가 명확히 끝났다면 다음 작업은 새 대화에서 시작하고, 필요한 컨텍스트는 저장소의 문서나 Git 커밋, PR 설명에 남기는 방식이 훨씬 건강하다.
+
+하지만 실제 작업에서는 한 흐름을 끊기 어려울 때가 있다.
+
+특히 수정 → 테스트 → CI → 리뷰 → 재수정 과정을 한 대화에서 계속 이어갈 때는 세션을 유지하는 편이 편하다.
+
+그럴 때 이 userscript가 일종의 안전장치가 됐다.
+
+## 지금의 결론
+
+DevSpace를 ChatGPT에 연결한 장점은 포기하고 싶지 않았다.
+
+스마트폰에서 실제 개발 머신의 코드를 다루고, 출퇴근 중에도 작업을 이어갈 수 있다는 편리함은 너무 컸다.
+
+문제는 그 편리함을 오래 사용했을 때 브라우저 한 탭이 몇 GB씩 커지는 것이었다.
+
+Brave나 Orion으로 옮기는 것만으로는 내 환경에서 충분하지 않았다. 결국 내가 정착한 방법은 브라우저 선택이 아니라 **오래된 ChatGPT 화면을 적극적으로 버리는 것**이었다.
+
+Tampermonkey로 여러 버전을 만들며 실패하다가, iframe뿐 아니라 과거 메시지 DOM 전체까지 정리하는 v5.0으로 넘어간 뒤에야 24GB MacBook Air에서도 장시간 세션의 RAM과 swap 압박을 이전보다 관리 가능한 수준으로 유지할 수 있었다.
+
+아마 ChatGPT 웹의 DOM 구조는 계속 바뀔 것이다. 그래서 이 코드도 영구적인 해결책은 아니다.
+
+그래도 "AI 에이전트와 브라우저를 하루 종일 붙여 쓰는 시대에는 브라우저 탭 자체도 개발 환경의 일부로 관리해야 한다"는 점은 꽤 오래 남을 것 같다.
From 4810b6892b373bfb33af6ee0da5b72ab61768883 Mon Sep 17 00:00:00 2001
From: oosuhada <185910926+oosuhada@users.noreply.github.com>
Date: Fri, 28 Aug 2026 11:21:05 +0900
Subject: [PATCH 10/12] Add searchable Oosu author identity
---
src/app/about/page.tsx | 97 +++++++++++++++++------------
src/app/blog/[slug]/page.tsx | 42 ++++++++++---
src/app/blog/feed.xml/route.ts | 15 +++--
src/app/blog/layout.tsx | 22 +++++--
src/app/blog/page.tsx | 61 ++++++++++++++++--
src/app/intro/page.tsx | 64 +++++++++++++++++++
src/app/layout.tsx | 10 +--
src/app/sitemap.ts | 9 ++-
src/components/blog/blog-author.tsx | 46 ++++++++++++++
src/components/seo/json-ld.tsx | 13 +++-
src/components/welcome-modal.tsx | 17 ++---
src/lib/blog-author.ts | 7 +++
src/lib/i18n.ts | 11 ++--
src/lib/seo.ts | 84 ++++++++++++++++---------
14 files changed, 387 insertions(+), 111 deletions(-)
create mode 100644 src/app/intro/page.tsx
create mode 100644 src/components/blog/blog-author.tsx
create mode 100644 src/lib/blog-author.ts
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
index 48f71a1..b64fccb 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -1,52 +1,71 @@
import type { Metadata } from 'next';
import Link from 'next/link';
-import { PublicPageShell, TextSection } from '@/components/seo/public-page-shell';
-import { createPageMetadata } from '@/lib/seo';
+import {
+ PublicPageShell,
+ TextSection,
+} from '@/components/seo/public-page-shell';
+import { createPageMetadata, personJsonLd, siteUrl } from '@/lib/seo';
export const metadata: Metadata = createPageMetadata({
- title: 'About Oosu',
+ title: '장우수 소개 | Oosu · oosuhada 개발자',
description:
- 'Oosu is an AI-connected fullstack developer and product-minded builder focused on RAG portfolio systems, AI-assisted workflows, and trustworthy AI UX.',
+ '우수하다(oosuhada)라는 이름으로 활동하며 AI·RAG 서비스와 제품 중심 웹 애플리케이션을 만드는 AI·풀스택 개발자 장우수의 소개 페이지입니다.',
path: '/about',
keywords: ['About Oosu', 'oosuhada', 'AI-connected developer'],
});
+const profilePageJsonLd = {
+ '@context': 'https://schema.org',
+ '@type': 'ProfilePage',
+ '@id': `${siteUrl}/about#profile`,
+ url: `${siteUrl}/about`,
+ mainEntity: personJsonLd,
+};
+
export default function AboutPage() {
return (
-
-
-
- Oosu’s work focuses on connecting AI tools to real product contexts:
- planning, design, engineering, content, deployment, and operational
- feedback.
-
-
- The public portfolio avoids inflated claims. It presents a grounded
- profile: AI-connected fullstack developer, product-minded builder, and
- RAG portfolio system builder.
-
+ Oosu’s work focuses on connecting AI tools to real product contexts:
+ planning, design, engineering, content, deployment, and operational
+ feedback.
+
+
+ The public portfolio avoids inflated claims. It presents a grounded
+ profile: AI-connected fullstack developer, product-minded builder,
+ and RAG portfolio system builder.
+
- macOS 자동화, AI 제품 개발, 배포 운영처럼 실제로 겪은 문제와 해결
- 과정을 기록합니다.
+
) : (
- 아직 공개된 포스트가 없습니다.
+
)}
diff --git a/src/app/faq/ai-competitiveness/page.tsx b/src/app/faq/ai-competitiveness/page.tsx
index 159a80a..6b2a41a 100644
--- a/src/app/faq/ai-competitiveness/page.tsx
+++ b/src/app/faq/ai-competitiveness/page.tsx
@@ -17,50 +17,97 @@ export const metadata: Metadata = createPageMetadata({
const faqItems = [
{
- question: 'What can Oosu do that AI cannot replace?',
- answer:
- 'The safer answer is not that AI can never replace human work. Oosu focuses on judging AI output against real user problems, product context, UX quality, and trustworthy implementation.',
+ question: {
+ ko: 'AI가 대체하기 어려운 장우수의 역할은 무엇인가요?',
+ en: 'What can Oosu do that AI cannot replace?',
+ },
+ answer: {
+ ko: 'AI가 인간의 일을 절대 대체할 수 없다고 주장하기보다 AI 결과를 실제 사용자 문제, 제품 맥락, UX 품질, 신뢰할 수 있는 구현 기준으로 판단하는 데 집중합니다.',
+ en: 'The safer answer is not that AI can never replace human work. Oosu focuses on judging AI output against real user problems, product context, UX quality, and trustworthy implementation.',
+ },
},
{
- question: 'Does Oosu compete with AI?',
- answer:
- 'No. Oosu frames AI as an execution layer. His goal is to connect AI tools to real product contexts, not to compete with AI as if it were a person.',
+ question: {
+ ko: '장우수는 AI와 경쟁하나요?',
+ en: 'Does Oosu compete with AI?',
+ },
+ answer: {
+ ko: '아니요. AI를 실행 계층으로 보고 실제 제품 맥락에 연결합니다.',
+ en: 'No. Oosu frames AI as an execution layer. His goal is to connect AI tools to real product contexts, not to compete with AI as if it were a person.',
+ },
},
{
- question: 'How does Oosu use AI without becoming dependent on it?',
- answer:
- 'He defines the goal, scope, constraints, and validation criteria first, then reviews AI output against the actual codebase, UX flow, and security boundaries.',
+ question: {
+ ko: 'AI에 의존하지 않고 어떻게 활용하나요?',
+ en: 'How does Oosu use AI without becoming dependent on it?',
+ },
+ answer: {
+ ko: '목표, 범위, 제약, 검증 기준을 먼저 정의한 뒤 실제 코드베이스, UX 흐름, 보안 경계를 기준으로 AI 결과를 검토합니다.',
+ en: 'He defines the goal, scope, constraints, and validation criteria first, then reviews AI output against the actual codebase, UX flow, and security boundaries.',
+ },
},
{
- question: 'What does AI Director mean?',
- answer:
- 'AI Director is not a formal title. It is a working style for connecting planning, design, engineering, content, deployment, and feedback with AI as an execution partner.',
+ question: {
+ ko: 'AI Director는 무슨 뜻인가요?',
+ en: 'What does AI Director mean?',
+ },
+ answer: {
+ ko: '공식 직함이 아니라 AI를 실행 파트너로 삼아 기획, 디자인, 개발, 콘텐츠, 배포, 피드백을 연결하는 작업 방식입니다.',
+ en: 'AI Director is not a formal title. It is a working style for connecting planning, design, engineering, content, deployment, and feedback with AI as an execution partner.',
+ },
},
{
- question: 'How does Oosu review AI-generated code?',
- answer:
- 'He checks whether the output matches the existing file structure, uses the right API and UI fields, avoids secret exposure, and can be explained before it becomes product code.',
+ question: {
+ ko: 'AI 생성 코드를 어떻게 검토하나요?',
+ en: 'How does Oosu review AI-generated code?',
+ },
+ answer: {
+ ko: '기존 파일 구조와 맞는지, 올바른 API와 UI 필드를 사용하는지, 비밀정보를 노출하지 않는지, 제품 코드가 되기 전에 설명 가능한지를 확인합니다.',
+ en: 'He checks whether the output matches the existing file structure, uses the right API and UI fields, avoids secret exposure, and can be explained before it becomes product code.',
+ },
},
{
- question: 'Why does AskOosu use RAG and Wiki docs?',
- answer:
- 'AskOosu uses FAQ routing and Wiki/RAG documents so portfolio answers can stay grounded in source material instead of becoming generic chatbot responses.',
+ question: {
+ ko: 'AskOosu는 왜 RAG와 Wiki 문서를 사용하나요?',
+ en: 'Why does AskOosu use RAG and Wiki docs?',
+ },
+ answer: {
+ ko: '포트폴리오 답변이 일반적인 챗봇 응답이 되지 않고 원문 근거에 기반하도록 하기 위해서입니다.',
+ en: 'AskOosu uses FAQ routing and Wiki/RAG documents so portfolio answers can stay grounded in source material instead of becoming generic chatbot responses.',
+ },
},
];
export default function AiCompetitivenessFaqPage() {
return (
-
+
-
-
+
+
+
+ AI Director형 제품 빌더
+
+ ,{' '}
+
+ AI 시대 개발자 경쟁력
+
+ ,{' '}
+
+ AskOosu 프로젝트
+
+ 를 더 읽어보세요.
+
- 우수하다(oosuhada)라는 이름으로 활동하는 AI·풀스택 개발자 장우수에게
- 뭐든 물어보세요. 프로젝트가 궁금해도, 기술 스택이 궁금해도, 그냥 어떤
- 사람인지 궁금해도 — 스크롤 대신 대화로 알아가는 포트폴리오예요.
+
-
+
- 2025년 Portfoli-Oh!에서 장우수는 우수하다(Oosu)라는 이름과 인터랙션,
- 프론트엔드로 자신을 소개했어요. AskOosu는 그 다음 챕터 — 프론트엔드,
- 백엔드, AI를 하나로 연결한 시스템을 포트폴리오 자체로 증명합니다.
+
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index 8ab89da..82271d2 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -1,5 +1,8 @@
import type { Metadata } from 'next';
-import { PublicPageShell, TextSection } from '@/components/seo/public-page-shell';
+import {
+ PublicPageShell,
+ TextSection,
+} from '@/components/seo/public-page-shell';
import { createPageMetadata } from '@/lib/seo';
export const metadata: Metadata = createPageMetadata({
@@ -13,38 +16,45 @@ export const metadata: Metadata = createPageMetadata({
export default function PrivacyPage() {
return (
-
-
+
+
Oosu.dev publishes portfolio information, project summaries, public
contact links, and AI-readable summaries intended for search engines
and answer engines.
-
- Oosu.dev는 공개 포트폴리오 사이트입니다. 공개 프로젝트 설명, 공개
- 연락 링크, AI가 읽기 쉬운 요약 문서를 제공합니다.
+
+ Oosu.dev는 공개 포트폴리오 사이트입니다. 공개 프로젝트 설명, 공개 연락
+ 링크, AI가 읽기 쉬운 요약 문서를 제공합니다.
-
-
+
+
The site may use Cloudflare Web Analytics when the production
analytics token is configured. This is used to understand public page
traffic, referrers, approximate country-level traffic, and basic web
performance. Oosu.dev does not use this setup to store raw IP
addresses or precise geolocation in the application database.
-
- 운영 환경에서 Cloudflare Web Analytics 토큰이 설정된 경우, 공개
- 페이지 방문 흐름, referrer, 국가 단위의 대략적인 유입, 기본 성능
- 지표를 확인할 수 있습니다. 애플리케이션 DB에는 원본 IP 주소나
- 정밀 위치를 저장하지 않습니다.
+
+ 운영 환경에서 Cloudflare Web Analytics 토큰이 설정된 경우, 공개 페이지
+ 방문 흐름, referrer, 국가 단위의 대략적인 유입, 기본 성능 지표를
+ 확인할 수 있습니다. 애플리케이션 DB에는 원본 IP 주소나 정밀 위치를
+ 저장하지 않습니다.
-
-
+
+
AskOosu may store sanitized question events to improve portfolio UX,
answer quality, RAG/FAQ coverage, fallback detection, latency, and
feedback loops. Stored fields can include an anonymous session ID,
@@ -53,53 +63,61 @@ export default function PrivacyPage() {
value, page path, referrer without query string, UTM parameters,
country code, device type, browser, and operating system.
-
- AskOosu는 포트폴리오 UX 개선, 답변 품질 개선, RAG/FAQ 커버리지
- 보완, fallback 질문 탐지, 응답 속도 확인, 피드백 루프 개선을 위해
- 민감정보가 제거된 질문 이벤트를 저장할 수 있습니다. 저장 항목은
- 익명 세션 ID, 언어, redaction된 질문, intent, 답변 모드, confidence,
- source document ID, provider, latency, feedback 값, 페이지 경로,
- query string이 제거된 referrer, UTM, 국가 코드, 기기/브라우저/OS
- 정도로 제한됩니다.
+
+ AskOosu는 포트폴리오 UX 개선, 답변 품질 개선, RAG/FAQ 커버리지 보완,
+ fallback 질문 탐지, 응답 속도 확인, 피드백 루프 개선을 위해 민감정보가
+ 제거된 질문 이벤트를 저장할 수 있습니다. 저장 항목은 익명 세션 ID,
+ 언어, redaction된 질문, intent, 답변 모드, confidence, source document
+ ID, provider, latency, feedback 값, 페이지 경로, query string이 제거된
+ referrer, UTM, 국가 코드, 기기/브라우저/OS 정도로 제한됩니다.
-
-
+
+
Oosu.dev does not intentionally store raw IP addresses, precise
geolocation, passwords, API keys, bearer tokens, private keys, credit
card numbers, Korean resident registration number-like values, emails,
phone numbers, or secrets from user questions.
-
+
질문 저장 전에는 이메일, 전화번호, API key/token, bearer token,
private key, 긴 secret 형태의 문자열, 주민등록번호처럼 보이는 패턴,
카드번호처럼 보이는 패턴을 가능한 범위에서
[REDACTED_*] 형태로 치환합니다.
-
-
+
+
AskOosu can generate a random anonymous session ID in the browser and
store it in localStorage as askOosuSessionId. It is used
to understand repeated portfolio questions within the same browser. It
- is not tied to a login account, name, email address, or exact location.
+ is not tied to a login account, name, email address, or exact
+ location.
+
+
+ AskOosu는 브라우저에서 임의의 익명 세션 ID를 생성해 localStorage의{' '}
+ askOosuSessionId에 저장할 수 있습니다. 같은 브라우저의
+ 반복 질문 흐름을 이해하기 위한 값이며 로그인 계정, 이름, 이메일,
+ 정확한 위치와 연결되지 않습니다.
-
-
+
+
Sanitized AskOosu question logs are retained for product-quality
review unless they are manually deleted, aggregated, or no longer
needed for improving the portfolio experience. No automatic deletion
job is currently enabled.
-
- 민감정보가 제거된 질문 로그는 포트폴리오 UX와 답변 품질 개선을
- 위해 보관되며, 자동 삭제 작업은 현재 활성화되어 있지 않습니다. 필요
- 시 수동으로 삭제하거나 집계 데이터로 전환합니다.
+
+ 민감정보가 제거된 질문 로그는 포트폴리오 UX와 답변 품질 개선을 위해
+ 보관되며, 자동 삭제 작업은 현재 활성화되어 있지 않습니다. 필요 시
+ 수동으로 삭제하거나 집계 데이터로 전환합니다.
+ AskOosu를 통해 개인정보나 민감정보를 제출했다고 생각해 삭제를 원한다면
+ 대략적인 날짜와 질문 주제를 포함해{' '}
+
+ oosu.salon@gmail.com
+
+ 으로 연락해 주세요. 삭제 요청에는 비밀정보를 포함하지 마세요.
+
-
-
+
+
Public pages are indexable. API, admin, dashboard, private, and
preview-only paths are excluded from the sitemap and disallowed in the
robots policy.
+
+ 공개 페이지는 색인할 수 있습니다. API, 관리자, 대시보드, 비공개,
+ 미리보기 전용 경로는 sitemap에서 제외하고 robots 정책으로 차단합니다.
+
);
diff --git a/src/app/projects/askoosu/page.tsx b/src/app/projects/askoosu/page.tsx
index fb33a4c..025d9b9 100644
--- a/src/app/projects/askoosu/page.tsx
+++ b/src/app/projects/askoosu/page.tsx
@@ -5,6 +5,7 @@ import {
PublicPageShell,
TextSection,
} from '@/components/seo/public-page-shell';
+import { LocalizedText } from '@/components/localized-content';
import { oosuProjects } from '@/lib/oosu-profile';
import { createPageMetadata } from '@/lib/seo';
@@ -21,26 +22,53 @@ export const metadata: Metadata = createPageMetadata({
export default function AskOosuProjectPage() {
return (
-
-
{askoosu.description}
+
- AskOosu is designed as a product surface for trustworthy AI UX. It
- separates deterministic FAQ answers, RAG-backed evidence, and public
- source summaries so visitors can understand Oosu’s profile without
- relying on generic chatbot claims.
+
+
+
+
-
+
{askoosu.techStack.join(', ')}
-
-
+
+
+
+ AI 경쟁력 FAQ
+
+ ,{' '}
+
+ AI Director형 제품 빌더 노트
+
+ , 또는{' '}
+
+ AskOosu 채팅
+
+ 을 확인해 보세요.
+
+
Read the{' '}
AI competitiveness FAQ
@@ -57,18 +85,28 @@ export default function AskOosuProjectPage() {
-
+
diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx
index 5d158cc..8d6bb3a 100644
--- a/src/app/projects/page.tsx
+++ b/src/app/projects/page.tsx
@@ -1,7 +1,11 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { ArrowUpRight } from 'lucide-react';
-import { PublicPageShell, TextSection } from '@/components/seo/public-page-shell';
+import { LocalizedText } from '@/components/localized-content';
+import {
+ PublicPageShell,
+ TextSection,
+} from '@/components/seo/public-page-shell';
import { oosuProjects } from '@/lib/oosu-profile';
import { createPageMetadata } from '@/lib/seo';
@@ -13,14 +17,35 @@ export const metadata: Metadata = createPageMetadata({
keywords: ['Oosu projects', 'AskOosu project', 'RAG portfolio project'],
});
+const projectDescriptionsKo: Record = {
+ 'AskOosu 2026':
+ 'Mac mini 홈 서버에 배포한 대화형 AI 포트폴리오입니다. Next.js, FAQ 라우팅, Notion RAG, PostgreSQL 기반 근거를 연결해 프로젝트와 기술에 답합니다.',
+ Aigram:
+ '피드, 팔로우, 댓글, 백엔드 API 흐름을 구현한 풀스택 SNS 프로젝트입니다.',
+ 'Sticks & Stones Homepage':
+ '기존 WordPress 사이트를 TypeScript와 Vite 기반으로 이전·리뉴얼한 실제 기업 홈페이지 프로젝트입니다.',
+ 'Portfoli-Oh! 2025':
+ '인터랙션, 모션, UI/UX 실험을 통해 프로젝트와 이야기를 보여주는 2025년 프론트엔드 포트폴리오입니다.',
+ Pylingo:
+ '기초 문법부터 응용 문제까지 브라우저에서 실습하는 인터랙티브 Python 학습 웹 앱입니다.',
+ Javalingo:
+ '객체지향 개념과 코딩 테스트 학습을 단계별로 구성한 Java 학습 웹 앱입니다.',
+};
+
export default function ProjectsPage() {
return (
-
+
-
- {blogAuthor.displayName}
-
- 라는 이름으로 활동하는 AI·풀스택 개발자 장우수의 블로그입니다.
+
+
+ {blogAuthor.displayName}
+
+ 라는 이름으로 활동하는 AI·풀스택 개발자 장우수의 블로그입니다.
+
+
+ This is the blog of Jang Oosu, an AI-connected fullstack developer
+ working as{' '}
+
+ oosuhada
+
+ .
+
);
diff --git a/src/components/localized-content.tsx b/src/components/localized-content.tsx
new file mode 100644
index 0000000..2ea2e40
--- /dev/null
+++ b/src/components/localized-content.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from 'react';
+
+export type LocalizedValue = {
+ ko: ReactNode;
+ en: ReactNode;
+};
+
+export function LocalizedText({ ko, en }: LocalizedValue) {
+ return (
+ <>
+
+ {ko}
+
+
+ {en}
+
+ >
+ );
+}
+
+export function LocalizedBlock({ ko, en }: LocalizedValue) {
+ return (
+ <>
+