PHP 프로젝트에 Rails식 fixture 테스트 녹이기
Rails의 편한 개발 흐름을 PHP에서도 쓰고 싶었다
카링 프로젝트를 만들 때 팀 안에는 PHP를 쓰더라도 Rails의 철학을 최대한 녹이고 싶다는 분위기가 있었습니다.
테이블 이름, 모델 이름, 필드 이름에 컨벤션을 두고, 반복되는 CRUD와 모델 코드는 직접 만든 도구로 줄이고, 테스트도 프레임워크가 알아서 해주는 것처럼 자연스럽게 돌리고 싶었습니다. 언어와 프레임워크는 PHP였지만, 개발 경험은 Rails에 가까웠으면 했습니다.
그런데 Rails가 제공하던 것들을 PHP 위에 직접 만들려면 당연히 빈 곳이 생겼습니다. 그중 하나가 테스트였습니다.
프로젝트 초반에는 개발을 하며 테스트를 거의 돌리지 않았습니다. 처음에는 속도가 나는 것처럼 보였습니다. 그런데 비즈니스 로직과 모델 단이 복잡해질수록, 기능을 고칠 때마다 마음 한쪽이 불안해졌습니다.
분명 방금 고친 건 키워드 연장 로직 하나인데, 관련된 결제 상태나 노출 기간까지 같이 흔들릴 수 있었습니다. 같은 상태에서 다시 확인할 수 있는 장치가 필요했습니다.
DB가 테스트를 한 번만 성공하게 했다
막힌 지점은 데이터베이스였습니다.
원하는 테스트는 실제 모델 코드와 비즈니스 로직을 타면서도, 언제 실행해도 같은 결과를 보장해야 했습니다. 그런데 모델은 데이터를 바꿉니다. Read 기능은 실패하면 코드를 고치고 다시 실행해도 됩니다. 데이터를 바꾸지 않기 때문입니다. 하지만 create, update, delete가 들어가는 순간부터는 이야기가 달라집니다.
카링에는 매장 유저가 기간 내 노출되는 키워드를 구매하고 연장하는 기능이 있었습니다.
예를 들어 기존에 있는 세차 키워드를 1달 연장했는데, 버그 때문에 2달이 연장됐다고 해보겠습니다. 이 버그를 확인하려고 코드를 한 번 실행하는 순간 데이터는 이미 바뀝니다. 버그를 못 잡고 다시 실행하려면 방금 전의 DB 상태를 되돌려야 합니다.
이 작업을 손으로 하다 보면 개발-테스트 이터레이션이 금방 무거워집니다. 기능을 고치는 시간보다, 같은 상태를 다시 만드는 시간이 더 피곤해집니다. 테스트를 돌려야 안심이 되는데, 테스트를 한 번 돌리면 다음 테스트 준비가 망가지는 이상한 상황이었습니다.
스키마를 만들고 fixture를 다시 넣었다
팀에서 생각한 방법은 단순했습니다. 로컬에 테스트 DB를 따로 두고, 매번 같은 순서로 DB 상태를 다시 만드는 것입니다.
“실DB에서 스키마만 테스팅 DB로 덤프 → fixture를 테스팅DB에 로드 → ci-phpunit-test 기반 테스트”
매 테스트마다 mysqldump export, import를 거치면 느리지 않을까 걱정했지만 스키마만 덤프하면 생각보다 빨랐습니다.
이 방식은 엄밀히 말하면 순수한 단위 테스트라기보다 DB까지 포함한 통합 테스트에 가깝습니다. 하지만 우리가 확인하고 싶었던 것은 “이 함수 하나가 독립적으로 맞는가”보다 “실제 모델과 DB를 탔을 때 비즈니스 규칙이 계속 맞는가”였습니다.
처음에는 이 방식이 조금 투박해 보였습니다. 테스트를 위해 DB를 다시 만든다니, 세련된 해법이라기보다는 힘으로 밀어붙이는 느낌도 있었습니다. 그런데 막상 돌려보니 마음이 편했습니다.
데이터를 바꾸는 테스트가 있어도 다음 실행에서는 다시 같은 상태에서 시작합니다. 조회 결과를 test double로 저장했다가 가져 쓰는 게 아니라 실제 DB까지 갔다 온 데이터이므로, mock을 실제 동작과 계속 맞춰야 하는 부담도 줄었습니다.
이때부터 테스트를 다시 실행하는 일이 덜 무서워졌습니다. 실패하면 DB를 손으로 되돌리는 게 아니라, fixture를 다시 로드하면 됐습니다.
Fixture Editor
위에서 언급한 yml 기반 fixture는 말 그대로 db에서 dump해와 파일로 따로 관리하는 모델 정보입니다.
f9 fixture import stores id=100
# SELECT * from stores where id = 100 을 통해 조건에 맞는 row들을 불러와 yml로 저장
# fixtures/stores/100.yml
id: 100
name: "송도카센터"
category_id: 34
phone_number: "01011112222"
이슈가 있다면 실DB에 스키마 변경이 있을 경우 해당 fixture들을 다시 로드해야 한다는 점이고, 한 눈에 픽스쳐들을 보기 힘들다는 점입니다.
fixture 파일이 늘어나자 다시 답답해졌습니다. 테스트는 편해졌는데, fixture를 고치는 일은 여전히 사람이 파일을 열어 눈으로 맞춰야 했습니다.
그리하여 이 fixture 자체를 관리할 수 있는 엑셀과 비슷한 형태의 UI를 form 형태로 간단히 만들었습니다. fixture를 조회하고 수정한 뒤 저장하면 import를 일괄적으로 수행해 스키마 싱크까지 맞추는 방식이었습니다. 거창한 도구는 아니었지만, 팀원이 fixture를 무서워하지 않게 만드는 데는 도움이 됐습니다.
한계점 발견
결국 스키마 싱크가 완전 자동화가 되지 않았고, fixture가 git을 통해 소스로 공유되다보니 permission 이슈, 혹은 충돌나는 이슈도 많았습니다. 정확히는 fixture 값을 수정하는 케이스보다는 스키마를 변경하고 fixture 싱크를 돌린 상황이 많습니다.
또한 relation이 있는 경우 해당 relation을 찾아가 import 해주는 과정이 번거롭습니다. 예를 들어 위 케이스의 경우 id=100인 stores만 import하면 테스트가 정상적으로 작동하지 않습니다. 왜냐하면 id=34인 categories 가 import 되지 않았을 수도 있기 때문입니다. 이런 경우 직접 찾아가며 해당하는 fixture를 모두 import 시켜주어야 한다는 이슈가 있습니다.
파일 fixture가 남긴 다음 문제
- diff 조회로 스키마 체크 개선
- relation scanning 기반 import 개선
비슷한 문제는 다른 프로젝트에서도 다시 나왔습니다.
레이블그룹에서 사용하던 기존 fixture도 파일 베이스였습니다. 파일 자체의 permission, git 충돌, DB schema 변경 시 관리 문제가 계속 있었습니다. 그렇다고 fixture를 DB 베이스로 옮기기에는 싱크 과정이 복잡할 것으로 판단했습니다.
이 문제는 은근히 사람을 지치게 했습니다. 테스트가 실패했을 때 코드가 틀린 건지, fixture가 낡은 건지, 로컬 파일 권한이 꼬인 건지부터 의심해야 했습니다.
이후 node.js로 플랫폼을 전환하면서, 기존 PHP script로 처리하던 fixture 흐름을 node.js 기반 fixture-manager로 다시 만드는 업무를 맡았습니다. 먼저 npm 기반 라이브러리를 찾아보다가 이런 라이브러리를 발견했습니다.
두 데이터베이스의 스키마를 조회하여 그 차이를 alter table 쿼리로 만들어 리턴하는 라이브러리였습니다.
var dbdiff = require('dbdiff')
dbdiff.describeDatabase(connString)
.then((schema) => {
// schema is a JSON-serializable object representing the database structure
})
var diff = new dbdiff.DbDiff()
// Compare two databases passing the connection strings
diff.compare(conn1, conn2)
.then(() => {
console.log(diff.commands('drop'))
})
// Compare two schemas
diff.compareSchemas(schema1, schema2)
console.log(diff.commands('drop'))
기존 방식은 두 데이터베이스를 mysqldump 를 통해 create table 쿼리를 만들어 두 해시값을 비교해 다를 경우 새로 dump하는 방식이었는데, 이 방식보다 훨씬 빠르게 스키마 체크 및 업데이트가 가능하게 되었습니다.
또한 소스 내에서 공유하는 방식 대신 서버에서 fixture를 관리할 수 있도록 했습니다. 서버에 <DB이름>_fixture 라는 새로운 DB를 만들고, 테스트 실행 시마다 db-diff로 schema를 체크하고 업데이트합니다.
새 필드가 추가되면 기본값으로 채워지고, 삭제된 필드는 삭제됩니다. 그 후 로컬 fixture DB에는 mysqldump로 완전 복사해서 캐싱한 뒤 import했습니다. 단계가 더 늘어난 것처럼 보이지만 실제로는 파일 fixture보다 안정적으로 관리할 수 있었습니다.
또한 relation 관련 import 이슈가 단순히 해결되지 않는 이유는 fk를 사용하지 않고 컨벤션에 의존하게 되어 자동화된 스키마 분석이 불가능하기 때문입니다. 그리하여 테이블, 필드명을 모두 가져와 컨벤션을 기반으로 파싱하여 스키마 탐색 결과를 확보했습니다.
여기가 제일 찝찝하면서도 재미있는 부분이었습니다. DB에는 FK가 없는데 팀의 머릿속과 코드에는 FK가 있었습니다. 그러면 도구도 그 암묵지를 읽을 수 있어야 했습니다.
class Store extends Model {
var $id;
var $category_id;
var $name;
}
class Category extends Model {
var $id;
var $name;
}
예를 들어 위 케이스의 경우 category_id는 Category 모델과 연결된 fk입니다. 실제로 fk는 걸지 않았지만 index만 걸고 ‘테이블단수명_id’로 끝나는 필드는 fk라는 컨벤션을 유지한 상황입니다.
이러한 정보를 바탕으로 Store에서 한 row를 import한 뒤, 그 row의 category_id 값으로 Category 테이블에서 추가 데이터를 가져옵니다. 즉 특정 모델에서 데이터를 import해도 many to one 관계에 있는 테이블을 recursive하게 따라가며 가져올 수 있도록 처리했습니다. 반대로 one to many 관계에 있는 데이터도 optional하게 가져올 수 있도록 처리했습니다.
배운 것
돌아보면 이 작업은 테스트 프레임워크를 도입했다기보다, PHP 코드베이스에 Rails식 개발 흐름을 직접 심으려는 시도에 가까웠습니다.
테스트를 자연스럽게 돌리려면 코드만 있으면 안 됐습니다. 같은 DB 상태를 빠르게 만들 수 있어야 했고, fixture가 스키마 변화와 relation을 따라갈 수 있어야 했고, 팀원이 손으로 고치기 쉬운 관리 화면도 필요했습니다.
물론 이 방식은 깔끔한 단위 테스트와는 거리가 있습니다. DB를 통째로 다루고, 스키마를 맞추고, fixture를 import합니다. 하지만 당시 팀이 겪던 문제는 순수 함수 하나를 검증하는 일이 아니라, 모델과 DB를 타는 비즈니스 로직을 안심하고 고치는 일이었습니다.
그 목적에는 꽤 맞는 방식이었습니다. 테스트를 돌릴 때마다 같은 상태로 돌아갈 수 있다는 것만으로도 개발자는 훨씬 대담해졌습니다.
무엇보다 좋았던 건 실패가 덜 무서워졌다는 점입니다. 테스트가 깨지면 DB를 다시 만질 생각부터 하는 게 아니라, 로직을 다시 볼 수 있었습니다. 그 작은 안도감이 반복되면서 테스트를 계속 돌릴 이유가 생겼습니다.
용어 짧게 정리
- fixture: 테스트를 시작하기 전에 DB에 넣어두는 기준 데이터입니다.
- 멱등성: 여러 번 실행해도 결과가 같게 유지되는 성질입니다.
- test double: 실제 객체나 외부 의존성을 대신하는 mock, stub 같은 테스트용 대역입니다.
- 스키마 덤프: 테이블 구조만 뽑아 테스트 DB에 다시 만드는 작업입니다.
- relation scanning: 한 row가 참조하는 다른 테이블의 row를 찾아 fixture에 함께 포함하는 작업입니다.
- Rails식 컨벤션: 설정을 많이 쓰기보다 이름과 구조의 약속으로 반복 작업을 줄이는 방식입니다.