기존 사용자에 영향을 주지 않고 Column Datatype을 변경하는 방법 (Postgresql)
Source: Dev.to

당신이 데이팅 앱을 만든다고 상상해 보세요. 이제 백만 명의 사용자라는 큰 이정표에 도달했습니다. 축하합니다!
초기 단계에서는 단순하게 처리했습니다. 사용자가 아이가 있는지를 추적하기 위해 kids 라는 단일 컬럼에 문자열을 저장했었습니다. 예를 들면:
const updatePreferencesSchema = Joi.object({
kids: Joi.string().valid(
"Yes- they live with me",
"Yes- they don't live with me",
"no"
).optional()
});
나중에 제품 팀은 더 풍부한 데이터가 필요하다고 요구했습니다. 사용자가 아이가 있는지, 그 아이가 집에 사는지, 그리고 사용자의 아이에 대한 의향을 모두 알아야 합니다. 새로운 스키마는 다음과 같습니다:
const updatePreferencesSchema = Joi.object({
kids: Joi.object({
hasKids: Joi.boolean().required(),
liveAtHome: Joi.boolean().required(),
wantsKids: Joi.string().valid("Yes", "No", "Not Decided")
}).optional()
});
간단한 ALTER TABLE을 실행하고 새 코드를 배포하면, 아직 오래된 문자열 값을 가지고 있는 기존 행이 깨집니다. 애플리케이션은 "no" 라는 문자열이 저장된 행에서 user.kids.hasKids 를 읽으려 할 때 TypeError 를 발생시킵니다.
중간 상태를 처리하기 위해 Expand and Contract Pattern을 사용할 수 있습니다.
Phase 1: 확장 단계 (안전 브리지)
새 형식의 데이터를 저장할 새로운 열을 추가하고 기존 열은 그대로 유지합니다.
-- Step 1: Add the new column (defaulting to an empty object)
ALTER TABLE users ADD COLUMN kids_v2 JSONB DEFAULT '{}';
이중 쓰기 로직
애플리케이션을 업데이트하여 두 열에 모두 데이터를 기록하도록 합니다. 이렇게 하면 새 데이터가 기존 코드(kids를 계속 읽음)와 새 코드(kids_v2를 읽음) 모두와 호환됩니다.
const oldValue = req.body.kids; // e.g., "yes- they live with me."
const updateData = {
kids: oldValue,
kids_v2: {
hasKids: oldValue.toLowerCase().includes("yes"),
liveAtHome: oldValue === "yes- they live with me",
wantsKids: "Not Decided" // default for the new requirement
}
};
Phase 2: 마이그레이션 (과거 데이터 채우기)
이제 kids_v2가 단순히 {}인 행이 백만 개 정도 있습니다. 한 번에 모두 업데이트하면 테이블이 잠길 수 있으므로, 데이터를 배치 단위로 처리하세요.
안전한 배치 업데이트 스크립트 (Node.js)
const { Pool } = require('pg');
const pool = new Pool();
async function backfillKidsData() {
const batchSize = 5000;
let hasMore = true;
while (hasMore) {
const res = await pool.query(`
UPDATE users
SET kids_v2 = jsonb_build_object(
'hasKids', CASE WHEN kids ILIKE 'yes%' THEN true ELSE false END,
'liveAtHome', CASE WHEN kids = 'yes- they live with me' THEN true ELSE false END,
'wantsKids', 'Not Decided'
)
WHERE id IN (
SELECT id FROM users
WHERE kids_v2 = '{}' AND kids IS NOT NULL
LIMIT ${batchSize}
)
RETURNING id;
`);
if (res.rowCount === 0) hasMore = false;
console.log(`Migrated ${res.rowCount} rows...`);
// Give the DB and event loop a short breather
await new Promise(r => setTimeout(r, 100));
}
}
이 스크립트를 더 이상 업데이트할 행이 없다고 보고될 때까지 실행하세요.
3단계: 계약 (클린 컷)
모든 행에 kids_v2에 유효한 객체가 포함되면 스키마 패리티를 달성한 것입니다.
- API 전환 – 코드를
kids_v2만 읽도록 업데이트합니다. - 모니터링 – 며칠 동안 로그에서 “undefined property” 오류가 있는지 확인합니다.
- 삭제 – 기존 컬럼을 삭제하고 이중 쓰기 로직을 제거합니다.
-- Final cleanup
ALTER TABLE users DROP COLUMN kids;
결론
Expand and Contract 패턴을 사용하면 컬럼의 데이터 타입을 다운타임이나 데이터 무결성 문제 없이 진화시킬 수 있습니다. 스키마를 확장하고, 데이터를 배치로 안전하게 마이그레이션한 다음, 다시 축소(정리)함으로써, 런타임 오류를 일으킬 수 있는 공백을 피할 수 있습니다.