레거시 순수 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;
    }
}

이 코드가 하는 일:

  1. 사용자가 로그인하면 새 DB에서 먼저 찾는다
  2. 없으면 기존 MySQL에서 조회한다
  3. 찾으면 새 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 시스템의 비밀번호 해싱은 보통 세 가지 중 하나다:

  1. 평문 (최악, 하지만 실제로 있다)
  2. md5 (md5($password))
  3. 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

강제 이전이 필요한 시점은 두 가지:

  1. MySQL 서버 비용이 아까울 때 — 남은 데이터만 배치로 옮기면 된다
  2. 기존 PHP 서비스를 완전히 끄고 싶을 때 — 같은 처리

어느 쪽이든 “1,000개 테이블 전체 이전”이 아니라 “아직 안 넘어온 활성 데이터만 이전”이다. 규모가 훨씬 작다.


전체 마이그레이션 vs Strangler Fig

항목전체 마이그레이션Strangler Fig
데이터 이전한 번에 전체필요할 때 한 건씩
인코딩 변환일괄 (위험)건별 (안전)
서비스 중단필요불필요
실패 시전체 롤백해당 건만 영향
마이그레이션 스크립트필수 (수십 개)불필요
팀 부담한 번에 큼점진적으로 분산
기존 시스템D-Day에 끔자연사

정리

레거시 PHP → Laravel 마이그레이션에서 가장 흔한 실수는 “깔끔하게 한 번에 옮기자”다. 깔끔한 전환은 존재하지 않는다. 특히 다음 조건이 하나라도 해당되면 전체 마이그레이션은 도박이다:

  • 인코딩이 UTF-8이 아닌 경우
  • DB 서버가 여러 대에 분산된 경우
  • 운영 중 서비스를 중단할 수 없는 경우
  • 마이그레이션에 집중할 수 있는 팀 리소스가 부족한 경우

Strangler Fig 패턴의 핵심은 단순하다:

새 시스템을 독립적으로 만들고, 기존 데이터는 건드리지 않고, 사용자가 올 때 한 명씩 옮긴다.

Laravel의 멀티 DB 연결이 이걸 가능하게 한다. config/database.php에 커넥션 하나 추가하는 것만으로 기존 시스템과 새 시스템이 공존할 수 있다.