3 minute read

“이거 그냥 새로 만들면 안 되나요?” 처음에 나도 그렇게 생각했다. 10년 넘게 쌓인 PHP 코드를 보면 누구나 한 번은 그 생각을 한다.

배경

학원 운영관리 ERP를 맡고 있었다. 고객 학원 400여 곳, 사용자 1만 명이 매일 쓰는 서비스다. 등·하원 시간대에 요청이 몰리는 구조라, 그 시간에 멈추면 바로 전화가 온다.

코드베이스는 10년 넘은 PHP였다. 그 위에 기능이 계속 쌓이고 있었고, 새 기능을 붙일 때마다 비용이 올라갔다. 차세대 시스템을 Java/Spring으로 만들기로 방향이 정해졌다.

전면 재작성을 포기한 이유

처음 떠오른 안은 새 시스템을 따로 다 만들고 어느 날 한 번에 바꿔 끼우는 것이었다. 계산해보고 접었다.

첫째, 기능 범위를 다 모른다. 10년치 PHP에는 아무도 기억하지 못하는 분기가 있다. “이 학원만 쓰는 예외 처리”가 코드 곳곳에 박혀 있고, 그걸 전부 찾아내지 못한 상태로 전환하면 전환 당일에 모르는 기능이 사라진다.

둘째, 그동안 기존 서비스가 멈추지 않는다. 새 시스템을 만드는 몇 달 동안 PHP에도 계속 기능이 추가된다. 완성 시점에 두 시스템의 기능이 어긋나고, 그 차이를 다시 메워야 한다.

셋째, 되돌릴 방법이 없다. 한 번에 바꾸면 문제가 생겼을 때 돌아갈 곳이 전날 백업뿐이다. 400개 학원이 쓰는 서비스에서 감당할 수 없는 리스크다.

택한 방식: 호환 API를 먼저 만들고 하나씩 바꿔 끼우기

결론은 레거시를 살려둔 채로 안쪽을 교체하는 것이었다. 순서는 이렇다.

  1. 신규 Java API를 만든다. 단, 레거시 PHP가 하던 동작과 응답을 그대로 호환하게 만든다.
  2. PHP 코드에서 DB를 직접 조회하던 지점을 찾는다.
  3. 그 지점을 신규 Java API 호출로 바꿔 끼운다.
  4. 문제가 생기면 그 지점만 되돌린다.

핵심은 1번에서 “개선”을 하지 않는 것이다. 응답 형식을 더 깔끔하게 바꾸고 싶은 유혹이 계속 생기는데, 그러면 PHP 쪽을 같이 고쳐야 하고 교체 단위가 커진다. 교체 단위가 커지면 되돌리기도 어려워진다.

바꿔 끼우기 전

PHP가 DB를 직접 봤다.

// 레거시: PHP가 직접 사용자 조회
$sql = "SELECT user_id, name, academy_id, role
        FROM users WHERE user_id = ?";
$stmt = $db->prepare($sql);
$stmt->execute([$userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

이런 코드가 한 곳이 아니라 여기저기 흩어져 있었다.

신규 API를 호환 형태로

Java 쪽에 같은 데이터를 같은 키 이름으로 돌려주는 엔드포인트를 만들었다.

@GetMapping("/api/v2/user/info")
public UserInfoResponse getUserInfo(@RequestParam Long userId) {
    User user = userService.findById(userId);
    // 키 이름을 레거시 PHP가 쓰던 배열 키와 동일하게 맞춘다
    return UserInfoResponse.builder()
            .userId(user.getId())
            .name(user.getName())
            .academyId(user.getAcademyId())
            .role(user.getRole().getLegacyCode())  // 레거시 코드값 유지
            .build();
}

role을 그대로 쓰지 않고 getLegacyCode()를 거치는 부분이 중요하다. Java에서는 enum으로 깔끔하게 정리했지만, 밖으로 나가는 값은 PHP가 기대하는 옛 코드값이어야 한다. 여기서 “이왕 바꾸는데 값도 정리하자”고 했다가 PHP 쪽 분기 수십 곳을 같이 고쳐야 했던 적이 있다.

바꿔 끼우기

PHP는 DB 대신 API를 부른다.

// 전환 후: PHP가 신규 Java API 호출
$user = $javaApiClient->get('/api/v2/user/info', ['userId' => $userId]);

호출부의 반환 형태가 전과 같으니, 이 함수를 쓰는 코드는 손대지 않아도 된다. 교체 단위가 한 줄이고, 되돌리는 것도 한 줄이다.

인증을 먼저 옮긴 이유

어디부터 옮길지 정하는 게 실제로 가장 중요한 결정이었다. 인증·사용자 조회를 1번으로 골랐다.

  • 모든 기능이 거쳐 가는 공통 지점이라, 여기가 신규 시스템을 타기 시작하면 이후 이관이 쉬워진다
  • 반대로 가장 위험한 지점이기도 하다. 로그인이 깨지면 전체가 멈춘다. 그래서 가장 집중해서 볼 수 있는 시기에 먼저 했다
  • 응답 형태가 단순해서 호환을 맞추기 쉬웠다

위험한 걸 나중으로 미루면, 쉬운 것만 옮겨놓고 정작 어려운 지점에서 프로젝트가 멈춘다.

배운 것

1. 호환이 먼저, 개선은 나중이다. 이관 중에 설계를 개선하고 싶어지는 건 자연스럽다. 그런데 이관과 개선을 같이 하면 문제가 생겼을 때 원인이 둘 중 무엇인지 알 수 없다. 일단 똑같이 동작하게 옮기고, 다 옮긴 뒤에 고치는 게 빠르다.

2. 되돌릴 수 있는 크기로 쪼갠다. 교체 단위가 “한 줄”이면 배포 당일 문제가 생겨도 그 한 줄만 원복하면 된다. 이게 전면 재작성과의 가장 큰 차이다.

3. 두 시스템이 동시에 도는 기간을 받아들인다. 중복이 생기고, 같은 로직이 두 곳에 있고, 그 기간이 몇 달 간다. 깔끔하지 않다. 그래도 서비스가 멈추지 않는다.

마무리

레거시를 보면 다 지우고 새로 쓰고 싶다. 하지만 그 레거시는 지금 돈을 벌고 있고, 10년 동안 현실의 예외를 흡수하면서 그 모양이 된 것이다. 코드가 못생긴 것과 그 코드가 모르는 걸 알고 있는 건 다른 문제다.

새로 만드는 게 아니라, 돌아가는 걸 멈추지 않고 안쪽을 바꿔 끼우는 일이었다. 지루하고 오래 걸리지만, 400개 학원이 매일 출결을 찍는 서비스에서는 이 방법밖에 없었다.