DocumentLibrary PostgreSQL 완전 전환 계획
작성자: Manus AI 작성일: 2026-08-26 대상: hohyun94/DocumentLibrary
1. 전환 목표와 현재 상태
DocumentLibrary는 현재 mysql2/promise pool, Drizzle mysqlTable, MySQL 전용 migration, ON DUPLICATE KEY UPDATE, beginTransaction()·PoolConnection에 의존한다. 따라서 DATABASE_URL만 PostgreSQL 주소로 바꾸는 방식은 지원하지 않는다. 목표는 애플리케이션 코드·스키마·migration·데이터·배포 설정을 모두 PostgreSQL 기준으로 전환하되, 검증 기간에는 기존 MySQL 운영을 보존하고 명확한 롤백 지점을 유지하는 것이다.
현재 schema의 핵심 테이블은 users, portal_state, organization_departments, managed_users, user_groups, user_group_departments, user_group_memberships, archive_posts, archive_files, archive_file_blobs, archive_file_versions, archive_post_files, archive_file_deletion_logs다. 실제 repository와 server/db.ts, server/archive-db.ts, server/setupBootstrap.ts, server/portalIntegrationConfig.ts가 직접 SQL을 실행하므로 Drizzle schema만 바꾸어서는 전환이 완료되지 않는다.
원칙: MySQL과 PostgreSQL을 동시에 지원해야 하는 기간에는 provider·schema·migration·query helper를 명시적으로 분리한다. 운영 DB에서 방언을 자동 추측하거나 한 dialect의 query builder를 다른 dialect에 재사용하지 않는다.
2. 권장 전환 전략
권장 방식은 expand → backfill → dual-read 검증 → 짧은 write freeze → cutover → observe → decommission 순서의 별도 PostgreSQL 전환이다. 기존 MySQL을 직접 변환하거나 애플리케이션에서 장기간 dual-write를 유지하는 방식은 두 데이터베이스의 일관성 문제가 커지므로 피한다.
| 단계 | 운영 DB | 애플리케이션 | 목적 |
|---|---|---|---|
| 개발 전환 | MySQL fixture + PostgreSQL fixture | provider별 테스트 | 변환 계약 확인 |
| 스테이징 | MySQL 원본 복제본과 PostgreSQL 이관본 | PostgreSQL candidate | 실제 데이터 검증 |
| Cutover 직전 | MySQL primary | write freeze 또는 maintenance | 최종 delta 추출 |
| Cutover 이후 | PostgreSQL primary | PostgreSQL only | 관찰·안정화 |
| 롤백 | 보존한 MySQL snapshot | 이전 MySQL deployment | 장애 시 즉시 복귀 |
3. 단계별 실행 계획
Phase 0 — 사전 조건과 기준선 고정
먼저 현재 main의 애플리케이션 버전, MySQL engine/version, timezone, character set/collation, row count, database size, 최대 파일·게시물·조직 데이터, 최근 30일 read/write 오류율을 기록한다. 운영 백업은 논리 덤프와 provider snapshot을 모두 생성하고, 복원 테스트가 실제로 성공하는지 확인한다.
다음 환경변수를 분리한다.
| 변수 | PostgreSQL 용도 |
|---|---|
DATABASE_DIALECT=postgresql | adapter·schema·migration 선택을 명시 |
DATABASE_URL=postgresql://... | PostgreSQL primary 연결 |
DATABASE_SSL_MODE=verify-full | Railway 등 외부 PostgreSQL TLS 정책 |
DATABASE_POOL_MAX | pool 상한 |
DATABASE_STATEMENT_TIMEOUT_MS | 쿼리 runaway 방지 |
DATABASE_MIGRATION_LOCK_KEY | migration 단일 실행 보장 |
.env와 실제 비밀값은 Git에 저장하지 않는다. 각 배포 환경에서 MySQL과 PostgreSQL connection string을 별도 secret으로 관리한다.
Phase 1 — Provider와 adapter 분리
server/db.ts를 다음 구조로 나눈다.
server/db/
index.ts # DATABASE_DIALECT 선택, 공통 API export
types.ts # DbClient, DbTransaction, DbDialect
mysql.ts # mysql2 pool, PoolConnection adapter
postgres.ts # pg Pool, PoolClient adapter
result.ts # rows, rowCount, insert id 정규화
transaction.ts # begin/commit/rollback 공통 wrapper공통 API는 databaseConfigured, configureDatabase, withTransaction, readPortalState, writePortalState, readDirectory, writeDirectory, archive read/write로 구성하되, SQL 문법은 provider 파일 안에 둔다. PostgreSQL의 pg.Pool·PoolClient를 MySQL 타입과 섞지 않는다.
PostgreSQL 연결은 pg의 Pool을 사용하고 애플리케이션 종료 시 pool.end()를 호출한다. 연결 검증은 SELECT 1 AS connected로 통일한다. transaction wrapper는 예외 발생 시 rollback을 시도하고 원래 예외를 보존한다. pool acquire timeout, idle timeout, statement timeout과 TLS 설정을 운영값으로 명시한다.
Phase 2 — PostgreSQL schema 작성
drizzle/schema.pg.ts를 새로 작성하고 기존 schema.ts는 명시적인 provider re-export로 바꾼다. MySQL schema를 그대로 타입 캐스팅하지 않는다.
| MySQL 표현 | PostgreSQL 표현 | 변환 규칙 |
|---|---|---|
mysqlTable | pgTable | 테이블 정의를 별도 파일로 작성 |
int().autoincrement() | integer().generatedAlwaysAsIdentity() | 기존 ID가 있는 경우 sequence를 데이터 최대값 이후로 재설정 |
tinyint/int boolean flag | boolean | 0/1을 false/true로 변환 |
json | jsonb | 유효 JSON만 적재, GIN index는 사용 패턴 확인 후 추가 |
timestamp().onUpdateNow() | timestamp().defaultNow() + 명시적 update | PostgreSQL에는 MySQL의 ON UPDATE가 없으므로 update code가 updatedAt을 설정 |
| backtick identifier | double-quote identifier 또는 lowercase name | camelCase 컬럼은 Drizzle column mapping으로 명시 |
ON DUPLICATE KEY UPDATE | ON CONFLICT (...) DO UPDATE | 반드시 conflict target과 unique constraint를 명시 |
? placeholder | $1, $2, ... | pg query builder/Drizzle parameterization 사용 |
insertId | INSERT ... RETURNING id | 생성 후 재조회로 추정하지 않음 |
affectedRows | rowCount | 공통 result helper에서 정규화 |
managed_users.is_admin, is_active, must_change_password, archive_files.is_deleted, archive_file_versions.is_current는 애플리케이션 계약을 먼저 확인한 뒤 PostgreSQL boolean으로 바꾸는 것이 바람직하다. 기존 외부 API가 숫자 0/1을 요구하면 DB는 boolean으로 보관하고 boundary에서만 변환한다.
portal_state.state와 portal_installation_config.settings는 jsonb NOT NULL로 정의한다. JSON object인지, 필수 키가 존재하는지는 Zod 또는 서버 validation에서 검증한다. JSON 내부 구조를 query하지 않는다면 GIN index는 추가하지 않는다.
Phase 3 — Migration 체계 구축
기존 MySQL migration 폴더와 분리해 drizzle/postgres-migrations를 사용한다.
drizzle/
schema.mysql.ts
schema.pg.ts
schema.ts
migrations/ # MySQL 전용 보존
postgres-migrations/ # PostgreSQL 전용
drizzle.config.mysql.ts
drizzle.config.postgres.ts초기 PostgreSQL baseline은 빈 DB에서 한 번에 재현 가능해야 한다. 이후 변경은 PostgreSQL migration만으로 추가한다. migration runner는 DATABASE_DIALECT를 확인해 맞는 디렉터리만 실행하고, 다른 dialect의 SQL을 실행하면 즉시 실패해야 한다.
Migration 순서는 다음과 같다.
- 독립 테이블(
users,portal_state,user_groups,archive_posts,archive_files,archive_file_blobs)을 생성한다. - 자기 참조가 있는
organization_departments를 생성하고 parent foreign key를 추가한다. managed_users를 생성한 뒤 조직 foreign key를 추가한다.- junction table과 archive foreign key를 생성한다.
- unique/index를 생성한다.
- portal installation/config tables를 생성한다.
- migration ledger와 health-check table을 생성한다.
모든 migration은 idempotent 실행 여부를 명확히 한다. 운영에서는 IF NOT EXISTS를 무분별하게 사용하지 말고 migration ledger와 checksum으로 이미 적용된 변경을 식별한다.
Phase 4 — 데이터 이관
MySQL 원본에서 테이블별로 추출하고 PostgreSQL staging에 적재한다. 파일 바이너리는 DB가 아니라 기존 S3/object storage를 유지하며, DB에는 storage_key, checksum, size, MIME type과 참조관계만 이관한다.
이관 순서는 foreign key 의존성 순서를 따른다.
users
portal_state
organization_departments
managed_users
user_groups
archive_posts
archive_files
archive_file_blobs
archive_file_versions
user_group_departments
user_group_memberships
archive_post_files
archive_file_deletion_logs
portal_installation_config각 테이블에 대해 다음 검증을 수행한다.
| 검증 | 기준 |
|---|---|
| 행 수 | source와 target이 테이블별로 일치 |
| primary key | null·중복·누락 없음 |
| unique key | dept_code, hr_user_key, knox_id, group name, blob sha256 충돌 없음 |
| foreign key | orphan 0건 |
| JSON | 모든 portal_state·settings가 유효한 JSON object |
| 시간 | timezone 정책을 적용하고 UTC 저장·표시 계약 확인 |
| boolean | 0/1 외 값 없음, target boolean 값과 의미 일치 |
| archive | file/version/blob/post link 수와 checksum 일치 |
| sequence | identity sequence가 각 numeric PK 최대값보다 큼 |
이관 스크립트는 재실행 가능하도록 checkpoint와 source primary key 기반 upsert를 사용한다. 실패한 batch만 재시도할 수 있어야 하며, 원문 값과 변환 후 값의 hash를 비교하는 audit manifest를 생성한다.
Phase 5 — 애플리케이션 query 전환
server/db.ts, server/archive-db.ts, server/portalIntegrationConfig.ts, server/setupBootstrap.ts의 raw SQL을 provider별로 분리한다. PostgreSQL 경로에서는 $1 placeholder, jsonb, RETURNING, ON CONFLICT, pg result shape를 사용한다.
핵심 수정 대상은 다음과 같다.
| 파일/영역 | 필요한 전환 |
|---|---|
server/db.ts | pool·transaction·portal state·directory read/write provider 분리 |
server/archive-db.ts | archive transaction과 모든 insert/query를 PostgreSQL adapter로 전환 |
server/portalIntegrationConfig.ts | settings jsonb 저장과 PostgreSQL upsert |
server/setupBootstrap.ts | SHOW TABLES, MySQL DDL, ON DUPLICATE KEY 제거; provider별 bootstrap |
| repository 전반 | insertId를 returning()으로 변경 |
| result handling | [rows, fields]와 { rows, rowCount }를 공통 API로 변환 |
| boolean boundary | DB boolean과 기존 API 숫자 flag의 변환 위치를 고정 |
| timestamps | updatedAt을 모든 update set에서 명시 |
PostgreSQL에서는 identifier alias를 camelCase로 반환할 때 double quote 또는 Drizzle mapping을 사용한다. SELECT dept_code AS deptCode처럼 unquoted alias를 사용하면 PostgreSQL이 소문자로 접어 deptcode가 될 수 있으므로 금지한다.
Phase 6 — 테스트와 검증 자동화
테스트는 MySQL과 PostgreSQL 각각에 대해 동일한 계약을 실행한다. SQLite를 PostgreSQL 대체품으로 사용하지 않는다.
필수 테스트 영역은 다음과 같다.
- schema baseline을 빈 PostgreSQL DB에 적용한다.
- setup bootstrap을 두 번 실행해 중복 생성 없이 성공하는지 확인한다.
- portal state와 installation config의 create/update/read를 확인한다.
- directory parent-child·group·membership transaction을 확인한다.
- archive post/file/blob/version/link/deletion log를 확인한다.
- unique 충돌은 409 또는 domain conflict로 분류하고 일반 DB 오류는 500으로 오인하지 않는지 확인한다.
- rollback 중 부분 데이터가 남지 않는지 확인한다.
- PostgreSQL
rowCount,RETURNING,jsonb, boolean, timestamp를 확인한다. - migration을 빈 DB와 기존 이관 DB에 각각 적용한다.
pnpm check,pnpm test,pnpm build를 CI 필수 단계로 둔다.
Phase 7 — Staging cutover
Staging에서 production과 동일한 PostgreSQL major version, TLS, pool, environment variables를 사용한다. MySQL snapshot을 복원한 뒤 이관하고, 실제 애플리케이션을 PostgreSQL candidate로 연결한다. 주요 화면과 API를 read/write 모두 점검한다.
Cutover 승인 기준은 다음과 같다.
| 기준 | 통과 조건 |
|---|---|
| 데이터 parity | 모든 테이블 row count·checksum·FK 검증 통과 |
| 기능 | setup, login, directory, archive, upload metadata, portal state 통과 |
| 오류 | PostgreSQL SQLSTATE가 domain error로 정확히 매핑 |
| 성능 | 대표 query p95가 기존 기준 대비 허용 범위 |
| 안정성 | transaction rollback·pool 재연결·재시작 통과 |
| 관측 | DB error, slow query, pool usage, migration version 로그 존재 |
| 복구 | MySQL snapshot 복원과 이전 deployment 재기동 성공 |
Phase 8 — Production cutover과 롤백
- PostgreSQL 최종 snapshot과 MySQL 최종 logical dump를 생성한다.
- 애플리케이션을 maintenance mode 또는 write freeze로 전환한다.
- MySQL에서 마지막 delta를 추출하고 PostgreSQL에 반영한다.
- row count·checksum·FK·sequence 검증을 다시 실행한다.
- PostgreSQL migration ledger를 확인한다.
DATABASE_DIALECT=postgresql과 PostgreSQLDATABASE_URL을 설정한다.- 새 deployment를 배포하고 health/readiness probe를 확인한다.
- 관리자 login, 조직도, 문서 목록, 업로드·다운로드 metadata, portal state를 smoke test한다.
- 오류율·latency·pool·connection·deadlock을 관찰한다.
- 안정화 기간 동안 MySQL을 read-only 보존한다.
롤백 조건은 migration 실패, parity 불일치, 지속적인 5xx 증가, 데이터 쓰기 실패, archive 참조 손상, 인증 장애다. 롤백 시 신규 PostgreSQL write를 차단하고, 마지막 MySQL snapshot과 cutover 전 deployment로 복귀한다. cutover 이후 PostgreSQL에 생성된 데이터가 있다면 먼저 별도 export해 유실 원인을 보존한 뒤 복귀한다.
4. 구현 순서와 커밋 분리
커밋은 다음 단위로 분리한다.
| 커밋 | 내용 |
|---|---|
| 1 | schema.pg.ts, provider 타입, config skeleton |
| 2 | PostgreSQL migration baseline |
| 3 | PostgreSQL pool·transaction·result adapter |
| 4 | portal state·installation config 전환 |
| 5 | directory read/write 전환 |
| 6 | archive DB 전환 |
| 7 | bootstrap·setup 전환 |
| 8 | cross-dialect contract tests |
| 9 | migration/data transfer tooling |
| 10 | deployment·runbook·rollback documentation |
각 커밋은 독립적으로 타입 검사 가능하게 유지하며, 운영 DB 변경과 애플리케이션 배포를 한 커밋에 섞지 않는다.
5. 완료 정의
전환은 다음 조건을 모두 만족할 때 완료로 판정한다.
- PostgreSQL 빈 DB에서 baseline과 모든 migration이 성공한다.
DATABASE_DIALECT=postgresql로 setup부터 archive 기능까지 동작한다.- MySQL provider 테스트도 계속 통과한다.
- MySQL 전용 SQL은 MySQL provider 파일 외에 남아 있지 않다.
insertId,affectedRows,[rows, fields],ON DUPLICATE KEY,SHOW TABLES,?placeholder의 PostgreSQL 경로 의존성이 없다.- source·target 데이터 parity와 foreign key 검증이 통과한다.
- PostgreSQL sequence와 timezone 정책이 검증된다.
- smoke test와 rollback rehearsal가 완료된다.
- CI에서 check·test·build·migration validation이 필수화된다.
- 운영 runbook과 secret rotation·backup·restore 절차가 저장소에 문서화된다.
6. 현실적인 우선순위
가장 먼저 해야 할 일은 PostgreSQL adapter와 schema baseline을 만드는 것이 아니라, 현재 MySQL 데이터의 실제 규모와 archive object storage 참조 무결성을 측정하는 것이다. 데이터 양과 파일 참조 상태가 확정되어야 downtime과 cutover 방식을 결정할 수 있다.
그 다음 P0는 server/setupBootstrap.ts와 server/db.ts의 MySQL 고정 경로를 분리하는 것이다. 이 두 영역이 PostgreSQL 경로로 안전하게 전환되지 않으면 migration이 성공해도 애플리케이션은 시작 또는 setup 단계에서 실패한다. P1은 archive transaction과 portal state, P2는 성능 최적화·index tuning·MySQL decommission이다.