데이터베이스 migration을 위한 script 만들기
migration script 소개
데이터를 마이그레이션하는 방법에는 여러가지가 있지만 핏펫은 서비스 다운타임을 고려한 신규 버전 런칭을 진행했습니다. 다운타임 내에 DB를 완전히 이전해야 했고 이 작업은 실수로 인한 데이터의 오염을 최대한 방지해야 했습니다.
저희는 script 실행으로 데이터 마이그레이션을 계획했고 table 모델의 정형화를 위해 django orm을 활용한 script를 작성했습니다.
django orm을 꼭 도입하지 않고 python의 db connect 라이브러리를 사용해도 script 작성은 가능하지만 그다지 추천드리진 않습니다.
한 가지 예를 들어보겠습니다.
이렇게 하면 간단하고 쉽게 DB에 접근하는 script를 작성할 수 있지만 서비스의 규모가 조금이라도 커진다면 관리하기 어려울 가능성이 있습니다.
이렇게 특정 field에 접근하기 위해 row의 column index를 지정해주어야 하는 불편함이 있습니다. dictionary property를 설정해주면 가독성이 좋아지긴 하지만 오타 등 사람의 실수를 대비해서 문자열을 따로 관리해야 하는 등의 번거로움을 예상할 수 있습니다.
이런 경우 django orm의 model class를 미리 설정해두면 좀 더 관리하기 쉬운 스크립트를 작성할 수 있습니다.
하지만 어떻게 매번 table마다 model class를 작성해주냐고 생각하실 수 있습니다. 이 부분에 대해서 django inspectdb라는 편리한 기능을 소개하겠습니다. 우선 기초적인 세팅부터 알아보겠습니다.
settings.py, DB router 설정
pyenv. 혹은 anaconda 환경에 대한 설명은 생략하겠습니다.(공식링크 참조) 다음 명령어를 입력해서 django 설치 및 project를 생성합니다. (개인적으로 config 라는 이름의 project 생성을 선호해서 해당 이름으로 생성하겠습니다.)
# pip install django# django-admin startproject config
그리고 기존의 레거시 DB를 새로운 DB로 옮겨주기 위해 multiple db router를 세팅하겠습니다.
config 하위에 db_router.py를 작성해줍니다. 레거시 DB의 이름은 old_db로 가정하겠습니다.
이후 settings.py 를 수정해줍니다. DATABASE_ROUTERS
가 추가되고 DATABASES
에 old_db 정보를 입력해줍니다. 핏펫의 레거시 DB는 mysql, postgresql을 같이 사용했습니다. 이번 포스트에서는 mysql -> mysql로 마이그레이션하는 스크립트를 예로 들겠습니다.
또한 DATABASE_ROUTERS
와DATABASES
에 또 다른 레거시 DB의 정보를 추가해서 사용할 수 있습니다. 실제로 핏펫 V3 런칭에는 3개의 레거시 DB를 통합하는 작업을 진행했습니다.
Inspectdb (레거시 DB의 모델화)
# python manage.py inspectdb > legacy/models.py --database=old_db
이렇게 명령을 inspectdb
명령을 실행함으로 기존 레거시 DB table의 구조를 django model로 구현 가능합니다. 아래 이미지는 실제로 핏펫 런칭 준비 시점의 커머스 솔루션 주문 테이블입니다.
기본적인 script import setting
django는 기본적으로 web framework 입니다. template engine web을 구축하거나 api server를 구축하기도 하는데 기본적으로 웹사이트를 구동하기 위해 개발된 오픈소스입니다.
python 파일을 실행시키는건 단순히 python의 환경만 실행하기 때문에 django의 기능을 활용하도록 import 해보겠습니다. 그리고 프로젝트의 루트 경로에서 하위 폴더의 script를 실행하기 위해 sys path도 추가해보겠습니다.
script 파일이 많아지면 루트 경로가 상당히 지저분해질 수 있기 때문에 script 파일들의 위치를 하위 폴더로 정리해줍니다. 그리고 기본적인 공통 코드를 작성해보겠습니다.
1번을 통해 프로젝트 루트 경로에서 하위 폴더의 script 파일을 실행 할 수 있습니다. 또한 2번을 통해 script 파일 내에서 자유롭게 django의 기능을 실행 가능하도록 설정합니다.
migration script 예시
이제 아주 간단한 스크립트를 작성하고 실행해보겠습니다. 가장 데이터가 적은 편인 카테고리를 예를 들어 작성해보겠습니다.
기본적인 필드만 migration 진행해보았습니다. v2 데이터를 django queryset으로 반복하면서 새로운 v3 instance로 저장하는 형식입니다.
이제 script를 실행해보겠습니다. 기존 legacy DB의 데이터가 새로운 DB의 table로 옮겨 온 것을 확인할 수 있습니다.
# python legacy/scripts/migrate_category.py
image migration
레거시 서비스가 AWS S3를 사용중이고 S3 버킷을 변경할 예정이 없다면 이미지 마이그레이션의 필요성이 없지만, 핏펫의 V3 서비스는 새로운 S3 버킷을 준비하기도 했고 무엇보다 커머스 솔루션의 기존 업로드 된 이미지들을 가져와야 했기에 이미지 마이그레이션이 필수였습니다.
다음은 간단한 이미지 마이그레이션 구성도입니다.
django에서 제공하는 class 중에는 이미지 처리에 유용한 Image Field가 있기에 local로 기존 이미지를 다운로드 받고 django의 Image Field를 통한 s3 업로드를 계획했습니다. 이제 기존 마이그레이션 코드를 수정해보겠습니다.
os.path.isfile() 을 통해 이미지 다운 여부를 확인하고 local에 이미지가 없으면 새로 다운로드합니다. 다운 받은 이미지를 open메서드와 django files를 통해 File 객체로 변환합니다.
django의 Image Field는 File 객체가 지정되면 이미지 파일 업로드를 진행하고 경로 문자열이 지정되면 CharField처럼 문자열만 저장합니다.
이렇게 이미지와 데이터를 모두 이전하는 스크립트를 작성해봤는데 문제가 하나 있습니다. 매번 이미지 다운로드, 업로드를 반복하기 때문에 불필요한 시간 소요가 발생합니다.
서비스 다운타임을 고려한 마이그레이션이기 때문에 다운타임이 길어질수록 서비스가 입는 손해는 늘어납니다. 또한 런칭 전까지 최대한 많이 실행해보고 오류와 실수를 줄여야 하는 입장에서 이미지를 매번 옮기기엔 시간이 부족하죠. 마지막으로 e-commerce 특성상 많은 이미지가 있기 때문에 시간 절약은 필수입니다.
저희는 JSON 파일을 활용하여 이미 다운로드, 업로드를 진행했던 이미지는 다시 실행하지 않도록 구성해봤습니다.
레거시 이미지의 url을 key로, 신규 이미지 경로를 value로 지정해두었습니다. read_image_json_file, dump_image_json_file 메서드를 통해 json 데이터를 관리하였고, 마지막으로 이미 저장된 이미지면 문자열만, 신규 이미지면 새로 다운로드 & 업로드를 하도록 구성했습니다.
이렇게 JSON 파일을 활용하여 마이그레이션에 소요되는 시간을 대폭 단축 시켰습니다. 하지만 아직 시간이 소요되는 문제점들이 있습니다.
ORM query 최적화
- bulk 활용
쿼리셋을 반복하면서 매번 save 메서드를 호출하면 그만큼의 insert query가 발생합니다. 1000의 row data가 있으면 1000번의 insert 가 발생하고 이는 DB에 부담을 주게 되며 마이그레이션 속도를 상당히 저하시킵니다.
2. loop 안에 select query 피하기
select query도 마찬가지로 loop안에 두지 않는 편이 시간 절약에 도움이 됩니다. 위에서는 카테고리의 code를 key, id를 value로 갖는 dict 데이터를 미리 세팅해두어서 select query의 발생을 줄였습니다.
3. objects queryset에서 필요한 최소한의 필드만 지정
objects.all() 쿼리셋의 반복문을 순회하면 기본적으로 모든 필드의 데이터를 매핑합니다. field 수가 적은 table이라면 확연한 차이는 나지 않지만 field가 많은 table은 속도에 어느 정도 영향을 미칠 수 있습니다. text field가 많은 table 이면 더욱 느려질 수 있습니다. 위는 objects.values() 를 활용해 필요한 필드의 데이터만 select 하도록 작성했습니다.
이제 기존 코드에서 DB 쿼리를 고려한 Category, Product의 데이터를 옮겨오는 스크립트를 작성해보겠습니다.
Category는 기존 코드에서 bulk 부분만 적용했습니다. 다만 새로운 이미지가 있는 Category는 이미지 업로드를 위해 save(), 기존 이미지가 있는 Category는 bulk insert를 타도록 분기를 했습니다.
Product는 text를 비롯한 table field가 많다고 가정했습니다. 따라서 values를 활용해서 필요한 필드만 select 하도록 작성했고, Category의 code데이터는 loop 이전에 미리 준비해서 많은 수의 select query를 피했습니다.
이렇게 django와 script 파일을 활용한 레거시 DB migration에 대해 알아봤습니다. migration이란게 정답은 없으니 참고만 해주시고 조금이나마 도움이 됐으면 좋겠습니다. 감사합니다.
번외 — limit, offset의 함정
핏펫 레거시 DB의 쿠폰 테이블은 약 7천만 개의 row가 있었습니다. 상당히 많은 데이터의 테이블을 조회해야 하는 경우였는데 이 때 겪었던 이슈를 공유하겠습니다.
너무 많은 row 데이터로 migration 시간을 줄이려 limit, offset으로 query 범위를 줄여서 여러번 반복하게 구성해봤습니다. 시간 절약을 위해 계획했지만 결과적으로 훨씬 더 많은 시간을 소요했습니다.
offset, limit가 변경되어도 매번 같은 속도의 결과를 예상했지만, offset의 수가 커지면 커질수록 시간 소요도 비례했습니다.
offset이 0일 때는 2초도 걸리지 않았던 쿼리가 offset이 5천만이면 1분 30초 가량 소요됩니다. 시간은 offset이 증가할수록 비례해서 많이 소요됩니다.
위의 마이그레이션 스크립트는 while 문을 통해서 offset이 10만 단위로 증가하면서 select query를 실행하기 때문에 스크립트가 실행되는 시간이 지나면 지날수록 쿼리 속도는 현저하게 느려집니다.
원인은 offset 입니다. 일반적인 order by, offset ~ limit 조회 쿼리는 인덱스를 태우지 않고 데이터 블록으로 접근합니다. 위의 이미지대로 쿼리가 1분 30초나 걸렸던 이유는 결국 처음부터 row를 읽기 때문에 발생했습니다. offset 50000000, limit 500의 쿼리는 결국 50000500개의 row를 읽고 50000000개의 row는 버립니다.
pagination 성능 개선에는 여러 방법이 있지만 대표적으로 No Offset, 커버링 인덱스가 있습니다. 저희는 No offset으로 변경해보겠습니다.
인덱스 튜닝된 필드(여기서는 pk로 지정했습니다.)의 where 조건을 붙이고 offset을 제거함으로 다시 2초도 걸리지 않는 결과를 보여줍니다.
이제 다시 Coupon migration 스크립트를 수정해보겠습니다.
이처럼 많은 수의 row data에 접근할 때 No offset 혹은 커버링 인덱스 같이 페이지 성능을 고려한 스크립트 작성을 추천드립니다. 혹 레거시 DB의 마이그레이션을 고려하시는 분들께 조금이나마 도움이 되었기를 바라겠습니다.
감사합니다.
written by Bale (Haeseok Kang)
email: hs.kang@fitpet.co.kr