Skip to content

Commit b5836c3

Browse files
bbbang105claude
andcommitted
refactor: 멤버 블로그 마이그레이션 2단계 분리 (expand/contract)
운영 무중단 배포를 위해 expand/contract 분리: - migrate:member-blogs:expand — member_blogs 생성+백필, members 구컬럼 유지 (비파괴, 운영 중 안전) - migrate:member-blogs:contract — 멱등 재백필 보정 후 members 구컬럼 DROP (신코드 배포·검증 후) - 단일 backfill-member-blogs.ts 제거, package.json/CLAUDE.md 갱신 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent de5f7b3 commit b5836c3

4 files changed

Lines changed: 113 additions & 23 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
150150
| `packages/bot/src/lib/push-client.ts` | 봇→웹 내부 API 호출 래퍼 (reminder-push 등) |
151151
| `packages/bot/src/services/member-blog.service.ts` | RSS 폴링 대상 조회 (`getPollableBlogs`) + 멤버별 블로그 목록 (봇 전용) |
152152
| `packages/web/src/lib/member-blogs.ts` | 멀티 블로그 웹 헬퍼 (`validateBlogInputs`, `fetchMemberBlogs`, `createMemberBlogs`, `syncMemberBlogs`) |
153-
| `packages/shared/src/db/backfill-member-blogs.ts` | 1회성 마이그레이션 스크립트 (members→member_blogs 백필, `migrate:member-blogs` npm 스크립트) |
153+
| `packages/shared/src/db/migrate-member-blogs-expand.ts` | 멤버 블로그 마이그레이션 Phase1 (비파괴: member_blogs 생성+백필, 구컬럼 유지) — `migrate:member-blogs:expand` |
154+
| `packages/shared/src/db/migrate-member-blogs-contract.ts` | 멤버 블로그 마이그레이션 Phase2 (파괴적: 재백필 보정 후 members 구컬럼 DROP) — `migrate:member-blogs:contract`, 신코드 배포·검증 후 실행 |
154155
| `packages/web/src/app/(user)/profile/fines/page.tsx` | 벌금 상세 페이지 (내 벌금 내역 + 납부 완료) |
155156
| `packages/web/src/app/api/profile/fines/route.ts` | 내 벌금 목록 API |
156157
| `packages/web/src/app/api/fines/[id]/pay/route.ts` | 벌금 납부 완료 API (atomic update) |

packages/shared/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"db:generate": "drizzle-kit generate",
3939
"db:migrate": "drizzle-kit migrate",
4040
"db:migrate:run": "tsx src/db/migrate.ts",
41-
"migrate:member-blogs": "tsx src/db/backfill-member-blogs.ts",
41+
"migrate:member-blogs:expand": "tsx src/db/migrate-member-blogs-expand.ts",
42+
"migrate:member-blogs:contract": "tsx src/db/migrate-member-blogs-contract.ts",
4243
"db:push": "drizzle-kit push",
4344
"db:studio": "drizzle-kit studio"
4445
},
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* member_blogs 마이그레이션 — Phase 2 (CONTRACT, 파괴적)
3+
*
4+
* ❗ 신코드(member_blogs 사용)가 운영에 완전히 배포·검증된 뒤에만 실행.
5+
* 구버전 코드가 살아있는 상태에서 실행하면 members.blog_url 참조 쿼리가
6+
* 전부 깨진다.
7+
*
8+
* 1. (안전 가드) member_blogs 테이블이 없으면 중단 — expand 먼저 실행 요구
9+
* 2. 멱등 재백필 — expand 이후 구코드가 추가한 멤버를 누락 없이 보정
10+
* 3. members.blog_url/rss_url/rss_consent 컬럼 제거
11+
*
12+
* 재실행 안전 (구컬럼 이미 없으면 스킵).
13+
*
14+
* Usage: pnpm --filter @blog-study/shared migrate:member-blogs:contract
15+
*/
16+
17+
import postgres from 'postgres';
18+
import { config } from 'dotenv';
19+
import { resolve } from 'path';
20+
21+
config({ path: resolve(__dirname, '../../../../.env.local') });
22+
config({ path: resolve(__dirname, '../../../../.env') });
23+
24+
async function main() {
25+
const connectionString = process.env.DATABASE_URL;
26+
if (!connectionString) {
27+
console.error('❌ DATABASE_URL 환경변수가 설정되지 않았습니다.');
28+
process.exit(1);
29+
}
30+
31+
const sql = postgres(connectionString, { max: 1, prepare: false });
32+
33+
try {
34+
await sql.begin(async (tx) => {
35+
// 1. member_blogs 존재 확인 (expand 선행 강제)
36+
const tblCheck = (await tx.unsafe(`
37+
SELECT EXISTS (
38+
SELECT 1 FROM information_schema.tables
39+
WHERE table_name = 'member_blogs'
40+
) AS exists
41+
`)) as unknown as Array<{ exists: boolean }>;
42+
if (!tblCheck[0]?.exists) {
43+
throw new Error('member_blogs 테이블이 없습니다. 먼저 expand 를 실행하세요.');
44+
}
45+
46+
// 2. 구컬럼이 아직 있으면: 멱등 재백필 후 컬럼 제거
47+
const colCheck = (await tx.unsafe(`
48+
SELECT EXISTS (
49+
SELECT 1 FROM information_schema.columns
50+
WHERE table_name = 'members' AND column_name = 'blog_url'
51+
) AS exists
52+
`)) as unknown as Array<{ exists: boolean }>;
53+
const hasBlogUrl = colCheck[0]?.exists ?? false;
54+
55+
if (!hasBlogUrl) {
56+
console.log('ℹ️ members.blog_url 컬럼 없음 — 이미 contract 완료. 스킵');
57+
return;
58+
}
59+
60+
// expand~contract 사이 구코드가 추가한 멤버 보정 (멱등)
61+
const inserted = (await tx.unsafe(`
62+
INSERT INTO "member_blogs" ("member_id", "blog_url", "rss_url", "rss_consent", "sort_order")
63+
SELECT m."id", m."blog_url", m."rss_url", COALESCE(m."rss_consent", true), 0
64+
FROM "members" m
65+
WHERE m."blog_url" IS NOT NULL
66+
AND m."blog_url" <> ''
67+
AND NOT EXISTS (
68+
SELECT 1 FROM "member_blogs" mb WHERE mb."member_id" = m."id"
69+
)
70+
`)) as unknown as { count: number };
71+
console.log(`✅ 재백필 보정: ${inserted.count}개 신규 멤버 블로그 행 생성`);
72+
73+
// 3. 구컬럼 제거 (파괴적)
74+
await tx.unsafe(`ALTER TABLE "members" DROP COLUMN IF EXISTS "blog_url"`);
75+
await tx.unsafe(`ALTER TABLE "members" DROP COLUMN IF EXISTS "rss_url"`);
76+
await tx.unsafe(`ALTER TABLE "members" DROP COLUMN IF EXISTS "rss_consent"`);
77+
console.log('✅ members.blog_url/rss_url/rss_consent 컬럼 제거 완료');
78+
});
79+
80+
const countRows = (await sql.unsafe(
81+
`SELECT COUNT(*) AS count FROM "member_blogs"`
82+
)) as unknown as Array<{ count: string }>;
83+
console.log(`🎉 CONTRACT 완료 — member_blogs 총 ${countRows[0]?.count ?? '0'}행`);
84+
process.exit(0);
85+
} catch (error) {
86+
console.error('❌ CONTRACT 실패 (롤백됨):', error);
87+
process.exit(1);
88+
} finally {
89+
await sql.end();
90+
}
91+
}
92+
93+
main();

packages/shared/src/db/backfill-member-blogs.ts renamed to packages/shared/src/db/migrate-member-blogs-expand.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
11
/**
2-
* member_blogs 마이그레이션 + 백필 스크립트 (1회성)
2+
* member_blogs 마이그레이션 — Phase 1 (EXPAND, 비파괴)
33
*
4-
* 1. member_blogs 테이블 생성 (idempotent, drizzle 스키마와 동일 DDL)
4+
* 1. member_blogs 테이블 생성 (FK / index / UNIQUE 포함, idempotent)
55
* 2. 기존 members.blog_url/rss_url/rss_consent → member_blogs 1행 백필
6-
* 3. members 테이블의 blog_url/rss_url/rss_consent 컬럼 제거
76
*
8-
* 모든 작업을 단일 트랜잭션으로 처리 — 도중 실패 시 전체 롤백.
9-
* 재실행해도 안전 (CREATE TABLE IF NOT EXISTS, NOT EXISTS 가드, DROP COLUMN IF EXISTS).
7+
* ❗ members 의 구컬럼은 그대로 둔다 — 구버전 코드가 계속 동작하므로
8+
* 이 단계는 운영 중에 안전하게 실행 가능. 컬럼 제거는 Phase 2(contract).
109
*
11-
* SQL은 전부 정적 (사용자 입력 없음) 이므로 `.unsafe()` 사용 — postgres tagged-template
12-
* 제네릭 타입이 환경별로 다르게 추론되는 문제를 피하기 위함.
10+
* 재실행 안전 (CREATE TABLE IF NOT EXISTS, NOT EXISTS 백필 가드).
11+
* SQL은 전부 정적(사용자 입력 없음) → .unsafe() 사용 (postgres tagged-template
12+
* 제네릭이 환경별로 다르게 추론되는 문제 회피).
1313
*
14-
* Usage: pnpm --filter @blog-study/shared migrate:member-blogs
14+
* Usage: pnpm --filter @blog-study/shared migrate:member-blogs:expand
1515
*/
1616

1717
import postgres from 'postgres';
1818
import { config } from 'dotenv';
1919
import { resolve } from 'path';
2020

21-
// 루트 .env.local (shared/bot용) 우선 로드
2221
config({ path: resolve(__dirname, '../../../../.env.local') });
2322
config({ path: resolve(__dirname, '../../../../.env') });
2423

@@ -82,7 +81,7 @@ async function main() {
8281
END $$
8382
`);
8483

85-
// members.blog_url 컬럼이 아직 존재할 때만 백필 (재실행 안전)
84+
// members.blog_url 컬럼이 존재할 때만 백필 (구컬럼 유지 — 비파괴)
8685
const colCheck = (await tx.unsafe(`
8786
SELECT EXISTS (
8887
SELECT 1 FROM information_schema.columns
@@ -92,7 +91,6 @@ async function main() {
9291
const hasBlogUrl = colCheck[0]?.exists ?? false;
9392

9493
if (hasBlogUrl) {
95-
// 2. 백필: 멤버당 blog_url 1행 (이미 블로그가 있는 멤버는 스킵)
9694
const inserted = (await tx.unsafe(`
9795
INSERT INTO "member_blogs" ("member_id", "blog_url", "rss_url", "rss_consent", "sort_order")
9896
SELECT m."id", m."blog_url", m."rss_url", COALESCE(m."rss_consent", true), 0
@@ -103,25 +101,22 @@ async function main() {
103101
SELECT 1 FROM "member_blogs" mb WHERE mb."member_id" = m."id"
104102
)
105103
`)) as unknown as { count: number };
106-
console.log(`✅ 백필 완료: ${inserted.count}개 멤버 블로그 행 생성`);
107-
108-
// 3. members 컬럼 제거
109-
await tx.unsafe(`ALTER TABLE "members" DROP COLUMN IF EXISTS "blog_url"`);
110-
await tx.unsafe(`ALTER TABLE "members" DROP COLUMN IF EXISTS "rss_url"`);
111-
await tx.unsafe(`ALTER TABLE "members" DROP COLUMN IF EXISTS "rss_consent"`);
112-
console.log('✅ members 테이블 blog_url/rss_url/rss_consent 컬럼 제거 완료');
104+
console.log(`✅ 백필 완료: ${inserted.count}개 멤버 블로그 행 생성 (구컬럼 유지)`);
113105
} else {
114-
console.log('ℹ️ members.blog_url 컬럼 없음 — 백필/컬럼제거 스킵 (이미 마이그레이션됨)');
106+
console.log('ℹ️ members.blog_url 컬럼 없음 — 이미 contract 완료된 상태로 보임. 백필 스킵');
115107
}
116108
});
117109

118110
const countRows = (await sql.unsafe(
119111
`SELECT COUNT(*) AS count FROM "member_blogs"`
120112
)) as unknown as Array<{ count: string }>;
121-
console.log(`🎉 마이그레이션 완료 — member_blogs 총 ${countRows[0]?.count ?? '0'}행`);
113+
console.log(
114+
`🎉 EXPAND 완료 — member_blogs 총 ${countRows[0]?.count ?? '0'}행. ` +
115+
`구컬럼은 유지됨 (운영 안전). 신코드 배포 검증 후 contract 실행.`
116+
);
122117
process.exit(0);
123118
} catch (error) {
124-
console.error('❌ 마이그레이션 실패 (롤백됨):', error);
119+
console.error('❌ EXPAND 실패 (롤백됨):', error);
125120
process.exit(1);
126121
} finally {
127122
await sql.end();

0 commit comments

Comments
 (0)