레거시 순수 PHP 서비스를 Laravel로 마이그레이션해달라는 요청을 받았다. 코드베이스를 열어보니:
- PHP 파일 2,000개 이상
- MySQL 서버 여러 대에 분산된 DB
- 테이블 1,000개 이상 (실제 사용은 80개, 나머지는 죽은 테이블)
- 인코딩이 EUC-KR
- Prepared statement 제로. SQL injection 천국
- 프레임워크 없음.
mysql_connect()에 문자열 결합 쿼리
“전체 마이그레이션”이라는 단어가 머릿속에 떠올랐다가 바로 지웠다.
전체 마이그레이션이 불가능한 이유
교과서적 마이그레이션은 이렇다:
1. 새 DB 스키마 설계
2. 기존 데이터를 새 스키마에 맞게 변환하는 스크립트 작성
3. 테스트 환경에서 검증
4. D-Day에 서비스 중단 → 데이터 이전 → 새 서비스 기동
이게 현실에서 안 되는 이유:
| 문제 | 왜 터지는가 |
|---|---|
| EUC-KR → UTF-8 일괄 변환 | 1,000개 테이블의 한글 데이터를 한 번에 변환. 깨지면 운영 사고. 깨진 걸 발견하는 데만 며칠 |
| 서비스 중단 | 운영 중인 서비스를 멈출 수 없다. “잠깐 점검”이 몇 시간이 될 수도 |
| 롤백 불가 | 새 DB에서 문제 발견 → 기존 DB로 돌아가야 하는데, 전환 중 쌓인 데이터는? |
| 팀 리소스 | 변환 스크립트만 수십 개. 테스트도 수십 개. 소규모 팀에서 감당 불가 |
| 분산 DB 통합 | 여러 MySQL 서버를 하나의 PostgreSQL로 합치는 건 스키마 복사가 아니라 아키텍처 재편 |
풀타임 시니어 엔지니어 3명이 집중해도 몇 달은 걸린다. 현실적인 팀 구성에서는 끝이 안 보인다.
Strangler Fig 패턴
마틴 파울러가 이름 붙인 이 패턴은 열대의 교살 무화과나무(Strangler Fig)에서 따왔다. 기존 나무를 감싸면서 자라다가, 결국 기존 나무가 죽고 새 나무만 남는다.
소프트웨어에 적용하면:
기존 시스템을 한 번에 교체하지 않는다.
새 시스템을 옆에 만들고, 기능 하나씩 옮기고,
기존 시스템은 자연스럽게 죽게 둔다.
레거시 PHP + MySQL → Laravel + PostgreSQL 전환에서 이게 이렇게 된다:
기존: MySQL (순수 PHP) ← 운영 트래픽
신규: PostgreSQL (Laravel) ← 새 기능 개발
전환: 기능 하나씩 새 시스템으로 이동
필요한 데이터만 그때그때 가져옴
기존 시스템은 자연스럽게 사라짐
Laravel에서 구현하기
1단계: 기존 MySQL을 읽기 전용으로 연결
Laravel의 멀티 DB 연결을 활용한다. config/database.php에 기존 MySQL 커넥션을 추가:
// config/database.php
'connections' => [
// 새 시스템 (쓰기 + 읽기)
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST'),
'database' => env('DB_DATABASE'),
'charset' => 'utf8',
// ...
],
// 기존 시스템 (읽기 전용)
'legacy' => [
'driver' => 'mysql',
'host' => env('LEGACY_DB_HOST'),
'database' => env('LEGACY_DB_DATABASE'),
'charset' => 'euckr', // EUC-KR 그대로 읽기
'collation' => 'euckr_korean_ci',
// ...
],
],
핵심 원칙: legacy 커넥션에서는 절대 쓰지 않는다. SELECT만. INSERT, UPDATE, DELETE 금지.
2단계: 새 시스템은 독립적으로 구축
새 Laravel 모델은 PostgreSQL에 자체 스키마로 만든다. 기존 테이블을 복사하는 게 아니다:
// 새 시스템의 회원 모델
class Member extends Authenticatable
{
protected $connection = 'pgsql';
protected $table = 'members';
protected $fillable = [
'user_id', 'nickname', 'email', 'phone',
'password', 'migrated_at',
];
}
migrated_at 컬럼이 포인트다. 기존 시스템에서 넘어온 데이터인지 추적할 수 있다.
3단계: 사용자 접근 시 자동 이전
전체 데이터를 한 번에 옮기는 대신, 사용자가 서비스를 이용할 때 한 건씩 자연스럽게 이전한다:
class AuthService
{
public function login(string $userId, string $password): Member
{
// 1. 새 DB에서 먼저 찾기
$member = Member::where('user_id', $userId)->first();
if ($member) {
return $this->authenticate($member, $password);
}
// 2. 없으면 기존 MySQL에서 조회
$legacy = DB::connection('legacy')
->table('members')
->where('id', $userId)
->first();
if (!$legacy) {
throw new AuthenticationException('회원 정보를 찾을 수 없습니다.');
}
// 3. 새 DB로 복사 (인코딩 변환 포함)
$member = Member::create([
'user_id' => $legacy->id,
'nickname' => mb_convert_encoding(
$legacy->nickname, 'UTF-8', 'EUC-KR'
),
'email' => $legacy->email,
'phone' => $legacy->phone,
'password' => Hash::make($password), // bcrypt로 재해싱
'migrated_at' => now(),
]);
return $member;
}
}
이 코드가 하는 일:
- 사용자가 로그인하면 새 DB에서 먼저 찾는다
- 없으면 기존 MySQL에서 조회한다
- 찾으면 새 DB에 복사하고, 이후로는 새 DB만 사용한다
마이그레이션 스크립트가 아니다. 서비스 이용 패턴에 따라 알아서 옮겨지는 것이다.
게시판에 적용하기
게시판 같은 읽기 중심 도메인은 더 간단하다. 기존 글을 옮길 필요가 없다:
새 글 → PostgreSQL에 저장
기존 글 → MySQL에서 읽기 전용 조회
레거시 게시판이 테이블-per-게시판 구조(free_board, humor_board, news_board…)이고 새 시스템이 단일 테이블 + 카테고리 컬럼이라면:
class BoardService
{
private array $legacyTableMap = [
'free' => 'free_board',
'humor' => 'humor_board',
'news' => 'news_board',
];
// 새 시스템 글 목록
public function getList(string $boardId, int $perPage = 20)
{
return Article::where('board_id', $boardId)
->latest()
->paginate($perPage);
}
// 기존 글 목록 (읽기 전용)
public function getLegacyList(string $boardId, int $page = 1)
{
$table = $this->legacyTableMap[$boardId] ?? null;
if (!$table) return collect();
return DB::connection('legacy')
->table($table)
->orderByDesc('id')
->forPage($page, 20)
->get()
->map(fn ($row) => $this->convertEncoding($row));
}
private function convertEncoding(object $row): object
{
foreach ($row as $key => $value) {
if (is_string($value)) {
$row->$key = mb_convert_encoding(
$value, 'UTF-8', 'EUC-KR'
);
}
}
return $row;
}
}
프론트에서는 “새 글”과 “기존 글”을 탭으로 분리하면 된다. 양쪽 데이터를 하나의 목록에 머지하려면 복잡도가 급증하니까 하지 않는다.
시간이 지나면 새 글이 쌓이고 기존 글 조회는 자연히 줄어든다. 기존 MySQL 조회가 0에 가까워지면 그때 서버를 끈다.
EUC-KR 변환 — 일괄 vs 건별
전체 마이그레이션에서 가장 위험한 게 인코딩 변환이다.
일괄 변환의 문제:
# 1,000개 테이블 × 수백만 행을 한 번에 변환
# 깨진 데이터가 있어도 모른다
# 발견했을 때는 이미 운영에 올라간 뒤
Strangler Fig에서의 건별 변환:
mb_convert_encoding($value, 'UTF-8', 'EUC-KR');
한 건씩 변환하면:
- 깨지면 그 건에서 바로 에러가 나고
- 해당 사용자에게만 영향이 있고
- 즉시 대응할 수 있다
비밀번호 처리
레거시 PHP 시스템의 비밀번호 해싱은 보통 세 가지 중 하나다:
- 평문 (최악, 하지만 실제로 있다)
- md5 (
md5($password)) - mysql
PASSWORD()함수
Laravel은 bcrypt를 쓴다. 이전 시 기존 해시를 검증한 뒤 새 해시로 교체:
// 기존 시스템이 md5를 쓴 경우
if (md5($inputPassword) === $legacy->password) {
// md5 검증 성공 → bcrypt로 재해싱해서 저장
$member = Member::create([
'password' => Hash::make($inputPassword),
// ...
]);
}
사용자가 로그인하는 그 순간에 비밀번호가 자연스럽게 bcrypt로 업그레이드된다.
언제 기존 시스템을 끌 수 있나
시작: 100% 기존 MySQL ↔ 0% 새 PostgreSQL
↓ 사용자들이 하나씩 이전됨
↓ 새 기능은 전부 새 시스템에
↓ 기존 시스템 조회량 감소
종료: 기존 MySQL 일일 조회 ≈ 0
→ 남은 데이터 배치 이전
→ MySQL 서버 off
강제 이전이 필요한 시점은 두 가지:
- MySQL 서버 비용이 아까울 때 — 남은 데이터만 배치로 옮기면 된다
- 기존 PHP 서비스를 완전히 끄고 싶을 때 — 같은 처리
어느 쪽이든 “1,000개 테이블 전체 이전”이 아니라 “아직 안 넘어온 활성 데이터만 이전”이다. 규모가 훨씬 작다.
전체 마이그레이션 vs Strangler Fig
| 항목 | 전체 마이그레이션 | Strangler Fig |
|---|---|---|
| 데이터 이전 | 한 번에 전체 | 필요할 때 한 건씩 |
| 인코딩 변환 | 일괄 (위험) | 건별 (안전) |
| 서비스 중단 | 필요 | 불필요 |
| 실패 시 | 전체 롤백 | 해당 건만 영향 |
| 마이그레이션 스크립트 | 필수 (수십 개) | 불필요 |
| 팀 부담 | 한 번에 큼 | 점진적으로 분산 |
| 기존 시스템 | D-Day에 끔 | 자연사 |
정리
레거시 PHP → Laravel 마이그레이션에서 가장 흔한 실수는 “깔끔하게 한 번에 옮기자”다. 깔끔한 전환은 존재하지 않는다. 특히 다음 조건이 하나라도 해당되면 전체 마이그레이션은 도박이다:
- 인코딩이 UTF-8이 아닌 경우
- DB 서버가 여러 대에 분산된 경우
- 운영 중 서비스를 중단할 수 없는 경우
- 마이그레이션에 집중할 수 있는 팀 리소스가 부족한 경우
Strangler Fig 패턴의 핵심은 단순하다:
새 시스템을 독립적으로 만들고, 기존 데이터는 건드리지 않고, 사용자가 올 때 한 명씩 옮긴다.
Laravel의 멀티 DB 연결이 이걸 가능하게 한다. config/database.php에 커넥션 하나 추가하는 것만으로 기존 시스템과 새 시스템이 공존할 수 있다.