메인 콘텐츠로 건너뛰기
Sonamu는 Entity 변경 사항을 감지하여 자동으로 Migration 파일을 생성합니다.

생성 워크플로우

Entity 수정

JSON 파일 편집필드, 타입, 인덱스

Sonamu UI

Generate 버튼자동 비교 및 생성

Migration 확인

src/migrations/*.tsup/down 검토

실행

pnpm sonamu migrateDB 적용

Sonamu UI에서 생성하기

1. Entity 수정

먼저 Entity JSON 파일을 수정합니다:
// src/application/user/user.entity.json
{
  "id": "User",
  "table": "users",
  "props": [
    { "name": "id", "type": "integer" },
    { "name": "email", "type": "string", "length": 255 },
    { "name": "username", "type": "string", "length": 255 },
    { "name": "phone", "type": "string", "length": 20, "nullable": true }  // ← 새 필드 추가
  ]
}

2. Sonamu UI 열기

pnpm sonamu ui
Sonamu UI 메인 화면

3. Generate 버튼 클릭

Entity 탭에서 Generate 버튼을 클릭합니다.
Generate 버튼

4. Migration 확인

자동으로 생성된 Migration 파일을 확인합니다:
// src/migrations/20251220143022_alter_users_add1.ts
import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
  await knex.schema.alterTable("users", (table) => {
    table.string("phone", 20).nullable();
  });
}

export async function down(knex: Knex): Promise<void> {
  await knex.schema.alterTable("users", (table) => {
    table.dropColumns("phone");
  });
}
Sonamu는 Entity와 현재 DB 스키마를 비교하여 차이점만 Migration으로 생성합니다.

생성되는 Migration 유형

새 테이블 생성

Entity 추가:
// src/application/post/post.entity.json
{
  "id": "Post",
  "table": "posts",
  "props": [
    { "name": "id", "type": "integer" },
    { "name": "title", "type": "string", "length": 200 },
    { "name": "content", "type": "string" }
  ]
}
생성된 Migration:
// 20251220143100_create__posts.ts
export async function up(knex: Knex): Promise<void> {
  await knex.schema.createTable("posts", (table) => {
    table.increments().primary();
    table.string("title", 200).notNullable();
    table.text("content").notNullable();
  });
}

export async function down(knex: Knex): Promise<void> {
  return knex.schema.dropTable("posts");
}

컬럼 추가

Entity 변경:
{
  "props": [
    // ... 기존 필드
    { "name": "bio", "type": "string", "nullable": true }  // ← 추가
  ]
}
생성된 Migration:
// 20251220143200_alter_users_add1.ts
export async function up(knex: Knex): Promise<void> {
  await knex.schema.alterTable("users", (table) => {
    table.text("bio").nullable();
  });
}

export async function down(knex: Knex): Promise<void> {
  await knex.schema.alterTable("users", (table) => {
    table.dropColumns("bio");
  });
}

컬럼 삭제

Entity 변경:
{
  "props": [
    { "name": "id", "type": "integer" },
    { "name": "email", "type": "string" }
    // "old_field" 제거됨
  ]
}
생성된 Migration:
// 20251220143300_alter_users_drop1.ts
export async function up(knex: Knex): Promise<void> {
  await knex.schema.alterTable("users", (table) => {
    table.dropColumns("old_field");
  });
}

export async function down(knex: Knex): Promise<void> {
  await knex.schema.alterTable("users", (table) => {
    // 롤백 시 재추가 (타입 정보 필요)
    table.string("old_field", 100).nullable();
  });
}
컬럼 삭제 주의!롤백 시 데이터가 복구되지 않습니다. 프로덕션에서는 신중하게 진행하세요.

컬럼 타입 변경

Entity 변경:
{
  "name": "age",
  "type": "integer"  // string에서 integer로 변경
}
생성된 Migration:
// 20251220143400_alter_users_modify1.ts
export async function up(knex: Knex): Promise<void> {
  await knex.raw(
    `ALTER TABLE users ALTER COLUMN age TYPE integer USING age::integer`
  );
}

export async function down(knex: Knex): Promise<void> {
  await knex.raw(
    `ALTER TABLE users ALTER COLUMN age TYPE varchar(255)`
  );
}

인덱스 추가

Entity 변경:
{
  "indexes": [
    { "type": "unique", "column": "email" },
    { "type": "index", "column": "username" }  // ← 추가
  ]
}
생성된 Migration:
// 20251220143500_alter_users_index1.ts
export async function up(knex: Knex): Promise<void> {
  await knex.schema.alterTable("users", (table) => {
    table.index(["username"], "users_username_index");
  });
}

export async function down(knex: Knex): Promise<void> {
  await knex.schema.alterTable("users", (table) => {
    table.dropIndex(["username"], "users_username_index");
  });
}

외래 키 추가

Entity 변경:
{
  "props": [
    {
      "type": "relation",
      "name": "department",
      "with": "Department",
      "relationType": "BelongsTo"
    }
  ]
}
생성된 Migration:
// 20251220143600_foreign__employees__department_id.ts
export async function up(knex: Knex): Promise<void> {
  return knex.schema.alterTable("employees", (table) => {
    table
      .foreign("department_id")
      .references("departments.id")
      .onUpdate("CASCADE")
      .onDelete("SET NULL");
  });
}

export async function down(knex: Knex): Promise<void> {
  return knex.schema.alterTable("employees", (table) => {
    table.dropForeign(["department_id"]);
  });
}

복합 변경

여러 변경 사항이 있으면 여러 Migration이 생성됩니다: Entity 변경:
{
  "props": [
    { "name": "phone", "type": "string", "nullable": true },  // 추가
    { "name": "bio", "type": "string", "nullable": true }     // 추가
  ],
  "indexes": [
    { "type": "index", "column": "phone" }  // 추가
  ]
}
생성된 Migrations:
20251220143700_alter_users_add1.ts      (컬럼 2개 추가)
20251220143701_alter_users_index1.ts    (인덱스 추가)
Sonamu는 변경 유형별로 Migration을 분리하여 관리하기 쉽게 합니다.

Migration 파일 이름 규칙

{타임스탬프}_{작업}_{테이블명}[_{번호}].ts
  • 타임스탬프: YYYYMMDDHHmmss
  • 작업: create, alter, foreign, drop
  • 테이블명: 언더스코어로 구분
  • 번호: 같은 작업이 여러 개면 add1, add2
예시:
20251220143022_create__users.ts
20251220143100_alter_users_add1.ts
20251220143101_foreign__users__department_id.ts

생성 전 확인 사항

1. DB 연결 확인

# DB가 실행 중인지 확인
psql -U postgres -h localhost -p 5432 -d mydb

2. 기존 Migration 상태 확인

# 실행된 Migration 확인
pnpm sonamu migrate status

3. Entity 검증

# Entity 문법 검증
pnpm sonamu validate

수동 수정

자동 생성된 Migration을 수동으로 수정할 수 있습니다:
// 20251220143022_alter_users_add1.ts
export async function up(knex: Knex): Promise<void> {
  await knex.schema.alterTable("users", (table) => {
    table.string("phone", 20).nullable();
    
    // 수동 추가: 기본값 설정
    table.string("country_code", 3).notNullable().defaultTo("+82");
  });
}
수동 수정 주의사항:
  • down 함수도 함께 수정
  • 다음 Generate 시 덮어씌워질 수 있음
  • 복잡한 로직은 별도 Migration으로 분리

특수 케이스

Generated 컬럼

// 직접 raw SQL 사용
export async function up(knex: Knex): Promise<void> {
  await knex.raw(
    `ALTER TABLE "users" 
     ADD COLUMN "full_name" varchar(510) 
     GENERATED ALWAYS AS (first_name || ' ' || last_name) 
     STORED`
  );
}

데이터 마이그레이션

export async function up(knex: Knex): Promise<void> {
  // 1. 컬럼 추가
  await knex.schema.alterTable("users", (table) => {
    table.string("status", 20).nullable();
  });
  
  // 2. 데이터 이전
  await knex("users").update({
    status: knex.raw("CASE WHEN is_active THEN 'active' ELSE 'inactive' END"),
  });
  
  // 3. nullable → notNullable
  await knex.raw(`ALTER TABLE users ALTER COLUMN status SET NOT NULL`);
}

조건부 실행

export async function up(knex: Knex): Promise<void> {
  const hasColumn = await knex.schema.hasColumn("users", "phone");
  
  if (!hasColumn) {
    await knex.schema.alterTable("users", (table) => {
      table.string("phone", 20).nullable();
    });
  }
}

다음 단계