php artisan make:migration은 쉽다. 어려운 건 그 다음이다.
- 마이그레이션 하나에 테이블 3개를 넣을까, 나눌까?
- 프로덕션에서 컬럼 타입을 바꿔야 하는데, 서비스를 멈출 수 없다.
- 스키마 변경과 데이터 이전을 같은 파일에서 해도 될까?
이 글은 “마이그레이션 문법”이 아니라 **“마이그레이션 설계 판단”**을 다룬다.
1. 쪼개기 — 1 마이그레이션 = 1 논리적 변경
// ❌ 한 파일에 전부
public function up()
{
Schema::create('boards', ...);
Schema::create('articles', ...);
Schema::create('comments', ...);
Schema::table('users', fn ($t) => $t->addColumn(...));
}
롤백하면? 전부 날아간다. boards만 수정하고 싶어도 articles, comments까지 같이 롤백된다.
// ✅ 논리적 단위로 분리
create_boards_table.php
create_articles_table.php
create_comments_table.php
add_nickname_to_users_table.php
판단 기준은 롤백 단위다. “이 마이그레이션을 롤백했을 때 의미 있는 단위인가?” — 그게 쪼개기의 기준이다.
- 새 테이블 → 테이블당 1파일
- 기존 테이블 수정 → 변경 목적당 1파일
- 컬럼 추가와 인덱스 추가가 같은 목적이면 합쳐도 된다
2. 프로덕션 변경 — 순서가 전부다
개발 환경에서는 migrate:fresh로 밀어버리면 그만이다. 프로덕션에서는 한 번의 실수가 서비스 장애다.
컬럼 추가: 안전하다
Schema::table('articles', fn ($t) => $t->string('slug')->nullable());
nullable()로 추가하면 기존 row에 영향 없다. 코드 배포 후 데이터를 채우면 된다.
컬럼 삭제: 2단계로
1단계: 코드에서 해당 컬럼 참조 전부 제거 → 배포
2단계: 마이그레이션으로 컬럼 삭제 → 배포
순서를 뒤집으면 — 배포 직후, 아직 구 코드가 남은 서버가 삭제된 컬럼을 읽으려다 500 에러를 뿜는다. 롤링 배포 환경에서는 구 코드와 신 코드가 동시에 존재하는 시간이 있다.
컬럼 타입 변경: 가장 위험하다
// ❌ 직접 변경 — 대형 테이블에서 락 걸림
$table->string('price')->change(); // int → string
100만 row 테이블에서 ->change()를 실행하면, MySQL이 테이블 전체를 복사하면서 락을 건다. 그 시간 동안 INSERT/UPDATE 불가. 서비스 다운.
Expand-Contract 패턴으로 해결한다:
1. Expand: 새 컬럼 추가 (price_new string nullable)
2. Migrate: 데이터 복사 (price → price_new 변환)
3. Switch: 코드가 price_new를 읽도록 전환 → 배포
4. Contract: 구 컬럼(price) 삭제
각 단계가 별도 배포다. 한 번에 하지 않는다.
테이블 이름 변경도 동일하다
Schema::rename()은 간단해 보이지만, 프로덕션에서는 코드가 구 이름을 참조하는 순간이 생긴다. 동일하게 새 테이블 생성 → 데이터 복사 → 코드 전환 → 구 테이블 삭제.
3. 데이터 마이그레이션 — 스키마와 섞지 않는다
// ❌ 마이그레이션 안에서 데이터 조작
public function up()
{
Schema::table('articles', fn ($t) => $t->string('status')->default('draft'));
DB::table('articles')
->where('published', true)
->update(['status' => 'published']);
}
왜 안 되는가:
- 멱등성 문제 — 마이그레이션이 중간에 실패하고 재실행되면, 데이터 조작이 두 번 실행될 수 있다
- 타임아웃 — row가 100만 개면 UPDATE가 몇 분 걸린다. 마이그레이션은 그렇게 오래 걸리면 안 된다
- 롤백 불가 —
down()에서 “published 상태를 원래대로 돌려놓기”는 불가능하다. 원래 값을 모르니까
분리한다:
1. 스키마 마이그레이션: 컬럼 추가 (nullable)
2. 커맨드로 데이터 이전: php artisan app:migrate-article-status
3. 스키마 마이그레이션: nullable 제거, 기본값 설정
커맨드는 청크 단위로 처리하고, 중간에 멈춰도 이어서 실행할 수 있게 만든다.
// app:migrate-article-status
Article::where('status', null)
->chunkById(1000, function ($articles) {
foreach ($articles as $article) {
$article->update([
'status' => $article->published ? 'published' : 'draft',
]);
}
});
예외: 소규모 데이터(enum 초기값, 설정 테이블 시딩 등)는 마이그레이션에 넣어도 괜찮다. 판단 기준은 “row가 몇 개인가”.
면접 질문 하나로 전부 확인하기
이 3가지 원칙을 하나의 시나리오로 확인할 수 있다:
“프로덕션에
articles테이블이 100만 row 있는데,status컬럼을 integer에서 string enum으로 바꿔야 합니다. 기존 데이터도 변환해야 하고요. 어떻게 마이그레이션을 설계하시겠어요?”
이 질문에 대한 답에서 Expand-Contract 패턴, 데이터 마이그레이션 분리, 프로덕션 안전 순서가 전부 드러난다.
마이그레이션은 “테이블 만들기”가 아니라 **“프로덕션에서 안전하게 스키마를 진화시키기”**다. make:migration은 시작일 뿐이다.