Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion content/posts/ko/spring-ai-guide-02-multi-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public BedrockProxyChatModel bedrockChatModel() {
}
```

`DefaultCredentialsProvider`는 AWS의 기본 인증 체인(환경변수, EC2 인스턴스 프로파일, ECS 태스크 역할 등)을 따른다. 로컬에서는 `~/.aws/credentials`, 배포 환경에서는 IAM 역할을 자동으로 사용한다. 타임아웃 설정은 4편에서 다룬다.
`DefaultCredentialsProvider`는 AWS의 기본 인증 체인(환경변수, EC2 인스턴스 프로파일, ECS 태스크 역할 등)을 따른다. 로컬에서는 `~/.aws/credentials`, 배포 환경에서는 IAM 역할을 자동으로 사용한다. 연결 및 소켓 타임아웃 설정은 [Spring AI 실전 적용기](/posts/spring-ai-pipeline-real-world/)에서 다룬다.

## 프로바이더별 옵션 제어

Expand Down
27 changes: 27 additions & 0 deletions docs/search-console/2026-07-12-triage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Search Console 색인 생성 분류 기록

확인일: 2026-07-12

## 확인된 현황

- 색인 생성됨: 111개
- 색인 생성되지 않음: 239개
- 제출된 사이트맵: `https://devy1540.dev/sitemap.xml` (성공, 72개 페이지 발견)

| 우선순위 | 항목 | 근거 | 조치 |
| --- | --- | --- | --- |
| P0 | 미발행 Spring AI 4편 내부 링크 | `/posts/spring-ai-guide-04-production`이 404이고 현재 글에서 링크됨 | `spring-ai-pipeline-real-world` 글 링크로 교체 완료 |
| P1 | 크롤링/발견됐지만 미색인 13개 | Search Console의 `크롤링됨` 8개와 `발견됨` 5개 | URL 목록을 내보내어 콘텐츠별 검토 |
| P2 | 다른 표준 URL을 선택한 중복 31개 | Google 표준 선택 보고서 | 예시 URL의 canonical 및 내부 링크 대조 |
| 모니터링 | 리디렉션 108개, canonical 대체 83개 | trailing slash·태그 쿼리 URL | 현재 301/canonical 동작을 유지 |

## 404 세부 항목

- `/posts/spring-ai-guide-04-production`: 내부 Markdown 링크가 원인이었으며 수정했다.
- `/cdn-cgi/l/email-protection`: 현재 404이나 블로그 콘텐츠에서 직접 링크하는지 추가 확인이 필요하다.

## 비범위

- Google Search Console의 색인 생성 요청 또는 수정 결과 확인 버튼 실행
- 호스팅/CDN 계층의 5xx 원인 수정
- 미발행 Spring AI 4편 작성
214 changes: 214 additions & 0 deletions docs/superpowers/plans/2026-07-12-search-console-link-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
# Search Console 내부 링크 무결성 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (\`- [ ]\`) syntax for tracking.

**Goal:** 미발행 Spring AI 4편 링크를 실제 글로 교체하고, 존재하지 않는 \`/posts/:slug\` Markdown 내부 링크를 CI 또는 로컬에서 검출한다.

**Architecture:** \`scripts/check-internal-post-links.mjs\`는 Markdown 파일명에서 게시글 slug 집합을 만들고, Markdown 링크 목적지의 \`/posts/:slug\`만 비교한다. 순수 검사 함수를 내보내 Node 내장 테스트로 검증하고, CLI 실행은 \`package.json\` 스크립트로 노출한다.

**Tech Stack:** Node.js ESM, Node 내장 \`node:test\`, TypeScript/Vite 빌드, Markdown 콘텐츠.

## Global Constraints

- 외부 링크, 앵커 링크, 쿼리 문자열, \`/posts/\` 이외의 내부 라우트는 검사하지 않는다.
- 누락된 링크는 파일 경로와 원래 링크 대상 전체를 모두 출력하고 종료 코드 1로 실패한다.
- 기존 미추적 \`AGENTS.md\`는 스테이징하거나 수정하지 않는다.
- Search Console에서 확인된 리디렉션·canonical 제외 항목을 코드로 자동 수정하지 않는다.

---

### Task 1: Markdown 게시글 링크 검사기와 회귀 테스트

**Files:**

- Create: \`scripts/check-internal-post-links.mjs\`
- Create: \`scripts/check-internal-post-links.test.mjs\`
- Modify: \`package.json:6-12\`

**Interfaces:**

- Consumes: \`content/posts/*.md\`의 파일명과 Markdown 본문.
- Produces: \`findMissingPostLinks(posts)\` 및 \`npm run check:internal-links\`.

- [ ] **Step 1: 누락 slug를 찾는 실패 테스트 작성**

\`\`\`js
import test from "node:test"
import assert from "node:assert/strict"
import { findMissingPostLinks } from "./check-internal-post-links.mjs"

test("존재하지 않는 게시글 slug를 내부 링크 오류로 보고한다", () => {
const missing = findMissingPostLinks([
{ path: "content/posts/existing.md", content: "[누락 글](/posts/not-published)" },
{ path: "content/posts/other.md", content: "[존재 글](/posts/existing)" },
])

assert.deepEqual(missing, [{
path: "content/posts/existing.md",
href: "/posts/not-published",
slug: "not-published",
}])
})
\`\`\`

- [ ] **Step 2: 실패를 확인한다**

Run: \`node --test scripts/check-internal-post-links.test.mjs\`

Expected: \`ERR_MODULE_NOT_FOUND\` 또는 \`findMissingPostLinks\` export 누락으로 실패한다.

- [ ] **Step 3: 최소 검사기를 구현한다**

\`\`\`js
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"

const postLinkPattern = /\[[^\]]*\]\(\/posts\/([^/?#)]+)(?:[?#][^)]*)?\)/g

export function findMissingPostLinks(posts) {
const slugs = new Set(posts.map((post) => path.basename(post.path, ".md")))
const missing = []

for (const post of posts) {
for (const match of post.content.matchAll(postLinkPattern)) {
const slug = match[1]
if (!slugs.has(slug)) missing.push({
path: post.path,
href: match[0].slice(match[0].lastIndexOf("(") + 1, -1),
slug,
})
}
}

return missing
}

function run() {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const postsDir = path.join(root, "content/posts")
const posts = fs.readdirSync(postsDir)
.filter((file) => file.endsWith(".md"))
.map((file) => {
const filePath = path.join(postsDir, file)
return { path: path.relative(root, filePath), content: fs.readFileSync(filePath, "utf8") }
})
const missing = findMissingPostLinks(posts)

if (missing.length > 0) {
console.error("Missing internal post links:")
for (const item of missing) console.error("- " + item.path + ": " + item.href)
process.exitCode = 1
return
}

console.log("Validated " + posts.length + " posts with no missing internal post links.")
}

if (process.argv[1] === fileURLToPath(import.meta.url)) run()
\`\`\`

Add this \`package.json\` script:

\`\`\`json
"check:internal-links": "node scripts/check-internal-post-links.mjs"
\`\`\`

- [ ] **Step 4: 테스트 통과를 확인한다**

Run: \`node --test scripts/check-internal-post-links.test.mjs\`

Expected: 1 test passed, 0 failed.

- [ ] **Step 5: 실제 콘텐츠 전체를 검사한다**

Run: \`npm run check:internal-links\`

Expected: 현재 4편 링크 때문에 \`spring-ai-guide-02-multi-provider.md: /posts/spring-ai-guide-04-production\`을 출력하고 종료 코드 1로 실패한다.

- [ ] **Step 6: 커밋한다**

\`\`\`bash
git add scripts/check-internal-post-links.mjs scripts/check-internal-post-links.test.mjs package.json
git commit -m "test: detect missing internal post links"
\`\`\`

### Task 2: Spring AI 4편 404 링크 수정

**Files:**

- Modify: \`content/posts/spring-ai-guide-02-multi-provider.md:118\`

**Interfaces:**

- Consumes: \`spring-ai-pipeline-real-world.md\` slug와 Bedrock 타임아웃 설정 설명.
- Produces: 존재하는 글만 가리키는 Spring AI 멀티 프로바이더 글.

- [ ] **Step 1: 수정 전 실패 상태를 재현한다**

Run: \`npm run check:internal-links\`

Expected: \`spring-ai-guide-02-multi-provider.md\`의 \`/posts/spring-ai-guide-04-production\` 누락 링크 때문에 실패한다.

- [ ] **Step 2: 링크를 실제 타임아웃 설명 글로 변경한다**

118행의 문장을 아래로 변경한다.

\`\`\`md
\`DefaultCredentialsProvider\`는 AWS의 기본 인증 체인(환경변수, EC2 인스턴스 프로파일, ECS 태스크 역할 등)을 따른다. 로컬에서는 \`~/.aws/credentials\`, 배포 환경에서는 IAM 역할을 자동으로 사용한다. 연결 및 소켓 타임아웃 설정은 [Spring AI 실전 적용기](/posts/spring-ai-pipeline-real-world/)에서 다룬다.
\`\`\`

- [ ] **Step 3: 링크 검사 통과를 확인한다**

Run: \`npm run check:internal-links\`

Expected: 종료 코드 0과 \`no missing internal post links\` 메시지.

- [ ] **Step 4: 전체 품질 검증을 실행한다**

Run: \`npm run type-check && npm run lint && npm run build && git diff --check\`

Expected: 모든 명령이 종료 코드 0으로 완료된다.

- [ ] **Step 5: 커밋한다**

\`\`\`bash
git add content/posts/spring-ai-guide-02-multi-provider.md
git commit -m "fix: replace missing Spring AI post link"
\`\`\`

### Task 3: Search Console 후속 점검 기록

**Files:**

- Create: \`docs/search-console/2026-07-12-triage.md\`

**Interfaces:**

- Consumes: Search Console 페이지 색인 보고서의 원인별 수와 확인된 URL.
- Produces: 다음 진단 시 재사용할 수 있는 조치 우선순위 기록.

- [ ] **Step 1: 조치 항목을 기록한다**

\`\`\`md
| 우선순위 | 항목 | 근거 | 조치 |
| --- | --- | --- | --- |
| P0 | 미발행 Spring AI 4편 내부 링크 | \`/posts/spring-ai-guide-04-production\`이 404이고 현재 글에서 링크됨 | 실제 운영 글 링크로 교체 |
| P1 | 크롤링/발견됐지만 미색인 13개 | Search Console의 \`크롤링됨\` 8개와 \`발견됨\` 5개 | URL 목록을 내보내어 콘텐츠별 검토 |
| P2 | 다른 표준 URL을 선택한 중복 31개 | Google 표준 선택 보고서 | 예시 URL의 canonical 및 내부 링크 대조 |
| 모니터링 | 리디렉션 108개, canonical 대체 83개 | trailing slash·태그 쿼리 URL | 현재 301/canonical 동작을 유지 |
\`\`\`

- [ ] **Step 2: 변경 범위를 확인한다**

Run: \`git diff --check && git status --short\`

Expected: 링크 검사기, 콘텐츠 링크, 진단 문서만 추적 대상 변경으로 보이며 기존 \`AGENTS.md\`는 미추적으로 유지된다.

- [ ] **Step 3: 커밋한다**

\`\`\`bash
git add docs/search-console/2026-07-12-triage.md
git commit -m "docs: record Search Console triage"
\`\`\`

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Search Console 내부 링크 무결성 설계

## 목표

존재하지 않는 블로그 글을 가리키는 내부 링크를 제거하고, 이후 Markdown 글의 `/posts/:slug` 링크가 실제 게시 글과 일치하는지 빌드 전에 검증한다.

## 범위

- `spring-ai-guide-02-multi-provider.md`의 미발행 4편 링크를, 실제 타임아웃 설정을 다루는 `spring-ai-pipeline-real-world` 글 링크로 바꾼다.
- `content/posts/*.md`의 상대 사이트 내부 `/posts/:slug` 링크만 검사한다.
- 외부 링크, 앵커 링크, 쿼리 문자열, 다른 라우트 및 배포 인프라의 리디렉션은 검사 대상이 아니다.

## 설계

### 링크 수정

Spring AI 멀티 프로바이더 글은 Bedrock 타임아웃 설정의 후속 설명으로 실제 존재하는 `spring-ai-pipeline-real-world` 글을 참조한다. 이 글에는 `BedrockProxyChatModel`의 연결 및 소켓 타임아웃 설정이 포함돼 있어 원래 문맥과 일치한다.

### 링크 검사기

Node ESM 스크립트가 `content/posts`에서 Markdown 파일명을 읽어 유효 slug 집합을 만든다. 각 Markdown 본문에서 Markdown 링크 목적지 중 `/posts/:slug` 형식만 수집하고, slug가 집합에 없으면 파일명과 링크를 출력하고 비정상 종료한다.

검사기는 인수 없이 실행되며, 성공 시 검사한 파일 수와 내부 글 링크 수를 출력한다. `package.json`의 `check:internal-links` 스크립트로 노출하고, `npm run build` 전에 독립적으로 실행할 수 있게 한다.

## 오류 처리

- 누락된 slug가 하나라도 있으면 모든 누락 항목을 출력하고 종료 코드 1을 반환한다.
- 게시글 디렉터리를 읽지 못하면 원본 오류를 표시하고 종료한다.

## 검증

1. 존재하지 않는 `/posts/__missing-post__` 링크를 포함한 임시 Markdown 파일로 검사기가 실패하는지 확인한다.
2. 실제 문서의 링크만 남긴 뒤 검사기가 통과하는지 확인한다.
3. 타입 검사, lint, production build를 실행한다.

## 비범위

- Search Console에서 발견한 리디렉션·canonical 제외 URL의 자동 교정
- 새 Spring AI 4편 작성
- CDN 또는 호스팅 제공자의 5xx 원인 수정
- Google 색인 요청 또는 Search Console의 검증 버튼 실행
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs",
"preview": "vite preview",
"lint": "eslint .",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"check:internal-links": "node scripts/check-internal-post-links.mjs"
},
"dependencies": {
"@giscus/react": "^3.1.0",
Expand Down
56 changes: 56 additions & 0 deletions scripts/check-internal-post-links.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"

const postLinkPattern = new RegExp(
String.raw`\[[^\]]*\]\(/posts/([^/?#)]+)/?(?:[?#][^)]*)?\)`,
"g",
)

export function findMissingPostLinks(posts) {
const slugs = new Set(posts.map((post) => path.basename(post.path, ".md")))
const missing = []

for (const post of posts) {
for (const match of post.content.matchAll(postLinkPattern)) {
const href = match[0].slice(match[0].lastIndexOf("(") + 1, -1)
const slug = match[1]

if (!slugs.has(slug)) {
missing.push({ path: post.path, href, slug })
}
}
}

return missing
}

function run() {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const postsDir = path.join(root, "content/posts")
const posts = fs.readdirSync(postsDir, { recursive: true })
.filter((file) => file.endsWith(".md"))
.map((file) => {
const filePath = path.join(postsDir, file)
return {
path: path.relative(root, filePath),
content: fs.readFileSync(filePath, "utf8"),
}
})
const missing = findMissingPostLinks(posts)

if (missing.length > 0) {
console.error("Missing internal post links:")
for (const item of missing) {
console.error(`- ${item.path}: ${item.href}`)
}
process.exitCode = 1
return
}

console.log(`Validated ${posts.length} posts with no missing internal post links.`)
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
run()
}
24 changes: 24 additions & 0 deletions scripts/check-internal-post-links.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from "node:assert/strict"
import test from "node:test"
import { findMissingPostLinks } from "./check-internal-post-links.mjs"

test("존재하지 않는 게시글 slug를 내부 링크 오류로 보고한다", () => {
const missing = findMissingPostLinks([
{
path: "content/posts/existing.md",
content: "[누락 글](/posts/not-published)",
},
{
path: "content/posts/other.md",
content: "[존재 글](/posts/existing)",
},
])

assert.deepEqual(missing, [
{
path: "content/posts/existing.md",
href: "/posts/not-published",
slug: "not-published",
},
])
})
Loading