Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[PyCon KR 2019] 테스트에 걸리는 시간을 *92%* 줄이기

[PyCon KR 2019] 테스트에 걸리는 시간을 *92%* 줄이기

견고한 소프트웨어 개발을 위해서 테스트를 돌리는 것은 아주 중요합니다. 소스를 수정하고 어딘가 잘못되었다는 것을 빠르게 알아차릴수록 발생한 버그를 더욱 쉽게 고칠 수 있습니다. 이를 위해서는 테스트 시간이 오래 걸리지 않아야 합니다. 테스트에 걸리는 시간이 짧으면 짧을수록 개발자의 생산성은 올라갑니다.

회사의 사업이 빠르게 성장하면서 피플펀드의 시스템에서 지원해야 하는 기능도 많아졌습니다. 이에 자연스럽게 테스트 케이스의 개수도 크게 증가하며 전체 테스트 케이스에 엄청난 시간이 걸리게 되었습니다. 개발자들의 생산성이 크게 저하되었습니다. 코드를 긴급하게 수정 및 배포해야 하는 경우 모든 테스트를 돌리지도 못하고 운영 서버에 배포하기도 하였습니다. 저는 이런 상황을 이대로 방치할 수는 없었습니다.

온갖 수단을 사용하여 테스트에 걸리는 시간을 줄였습니다. 테스트 옵션을 수정하는 것에서 그치지 않았습니다. 심지어는 Django의 소스 코드를 수정하기도 하였습니다. 그 결과, 약 40분에서 50분이 걸리던 테스트를 3분 30초 만에 끝내도록 최적화 할 수 있었습니다. 이 과정에서 사용한 여러 가지 테스트 시간을 줄이는 데 도움이 될 만한 노하우를 PyCon에서 공유하고자 합니다.

238329ed0e83737a026f1688f6dd33b9?s=128

Youngmin Koo

August 16, 2019
Tweet

Transcript

  1. PyCon Korea 2019 테스트에 걸리는 시간을 *92%* 줄이기 구영민 <youngminz.kr@gmail.com>

  2. 깜짝 퀴즈: 아래 테스트는 얼마나 걸릴까요? 설명: 100명의 사용자를 만드는

    테스트입니다.
  3. 100명을 만드는 데 20초가 걸렸다?!

  4. 정답: 비밀번호 해시 처리가 느려서 100명을 만드는 데 20초가 걸렸다?!

  5. 1. Password Hashing

  6. 비밀번호의 저장 • 비밀번호는 평문으로 저장해서는 안 되고, 복호화하지 못하도록

    처리하여 저장 • 일반적으로 해시 함수를 이용하여 처리함 • * 해시 함수: 입력된 값의 복호화가 불가능하게 암호화하는 단방향 암호화 • 하지만, 해시 함수를 한 번만 사용하는 것으로는 충분하지 않음 • 빠른 속도 ➔ 무차별 대입에 취약
  7. 비밀번호의 해시: 키 스트레칭 • 키 스트레칭: 비밀번호가 맞는지 확인하는

    데 어느 정도(약 0.2초 이상) 걸리게 하는 것 • 예시 구현: Hash(Hash(Hash(…(Hash("plain text"))…))) 15만 번 정도 반복 • Django는 따로 지정하지 않으면 기본적으로 PBKDF2_SHA256 사용(느림) • 웹 서비스의 보안을 늘리는 데는 좋으나, 테스트 시간을 늘리는 1등 공신!
  8. 한 명당 0.2초 걸리니 100명이면 20초!

  9. 비밀번호 해시 알고리즘 변경 테스트 환경에서는 비밀번호 해싱 알고리즘을 빠른

    것으로 사용하여 속도 개선 [경고] 프로덕션 환경에서는 **절대** 사용하지 마세요! MD5는 공격에 아주 취약합니다!!
  10. 설정 변경 후 다시 테스트

  11. 2. Mocking

  12. Mocking Object • 메소드 호출에 대해 고정된 응답을 반환하고 (Mocks)

    • 실제 객체처럼 동작하며 (Stubs) • 함수 호출 파라미터, 리턴 값 등을 검증하는 (Spies) 객체!
  13. 사례: API 쓰로틀링 로직 • 대량으로 호출해야 하는 외부 API에

    쓰로틀링을 걸어야 하는 경우가 있음 (갑 회사에서 요청…) • 이런 경우, time.sleep를 유용하게 사용할 수 있음 • 운영 환경에서는 필요하나, 유닛 테스트 환경에서는 시간만 소모
  14. 테스트 케이스 작성 Logic Test • 테스트를 통과하기는 하지만, 불필요하게

    10초 간 아무것도 하지 않음
  15. time.sleep mocking

  16. time.sleep mocking • 테스트 결과: 테스트가 실행 즉시 완료되기는 하지만

    실패
  17. [라이브러리 소개] freezegun - Python의 시간여행자 • 이 라이브러리를 이용하면,

    시간이 흐르지 않고도 실제로 시간이 흐른 것처럼 처리할 수 있음 • https://tech.peoplefund.co.kr/2017/02/10/python-time-traveler-freezegun.html
  18. time.sleep mocking

  19. [꿀팁] mocking한 함수 파라미터 검증

  20. 3. Don't use TransactionTestCase

  21. 테스트는 다른 테스트에 영향 받지 않아야 함 • 테스트 케이스에서

    생성한 데이터의 삭제 필요 • Django에서는 DB 데이터를 삭제하는 동일한 로직을 다른 방법으로 구현해 놓음 • TestCase • TransactionTestCase
  22. TestCase • TestCase: 클래스와 테스트 함수를 시작할 때 각각 트랜잭션으로

    감쌈.  BEGIN TRANSACTION;  SAVEPOINT S1;  ROLLBACK TO SAVEPOINT S1;  SAVEPOINT S2;  ROLLBACK TO SAVEPOINT S2;  ROLLBACK;
  23. TransactionTestCase • TransactionTestCase: 테스트가 끝날 때마다 TRUNCATE TABLE 사용하여 데이터

    삭제.  TRUNCATE TABLE A; * Table 수  TRUNCATE TABLE A; * Table 수 https://github.com/django/django/blob/8590726a/django/db/backends/mysql/operations.py#L171
  24. Performance • 테스트 환경: 피플펀드 시스템. 약 230개의 테이블. DB는

    MySQL 5.7. • TestCase: 약 0.00058s (1000번 수행 시 0.580s) ➔ 1600개의 테스트 케이스 수행 시 롤백에만 걸리는 시간 0.928s • TransactionTestCase: 1.726s ➔ 1600개의 테스트 케이스 수행 시 롤백에만 걸리는 시간 46m 1s ➔ TestCase에 비해 TransactionTestCase는 3000배 가까이 느림 0 0.2 0.4 0.6 0.8 1 1.2 1.4 1.6 1.8 2 TestCase TransactionTestCase Rollback Time Rollback Time
  25. TransactionManagementError • SELECT … FOR UPDATE(aka LOCK)는 Transaction 내부에서 실행되어야

    함 • 이를 지키지 않을 경우, Django는 TransactionManagementError를 발생시킴 • 앞에서 설명한 TestCase와 TransactionTestCase의 동작 차이로 인하여, TestCase에서는 Transaction 외부에서 select_for_update 사용 시에도 오류가 발생하지 않음 • DB Lock 관련 로직을 수정하는 경우 개발 환경에서 충분한 테스트 후 배포 중
  26. on_commit으로 트랜잭션 롤백 대비하기 ➔ 예외가 발생해서 트랜잭션이 롤백되어도, 문자

    메시지는 전송됨 ➔ 예외가 발생해서 트랜잭션이 롤백되면 문자 메시지도 전송되지 않음 (on_commit)
  27. on_commit • on_commit: 트랜잭션이 성공했을 때 실행할 함수 등록 •

    SMS 발송 등의 실패는 데이터베이스 롤백을 일으키면 안 됨 • 오류 발생으로 데이터베이스는 롤백되었는데 SMS 발송이 되면 안 됨 • 트랜잭션 내부에서 생성한 객체를 트랜잭션이 완료되기 전, Celery 등의 비동기 워커로 바로 보내면 안 됨 • Transaction Isolation 때문에, COMMIT 전에 Task 실행 시 DoesNotExist 발생 • COMMIT 이후 Worker으로 보내야 함 • TestCase에서는 테스트 성공 여부에 상관없이 항상 트랜잭션 롤백
  28. on_commit 테스트하기 • on_commit: 트랜잭션이 성공했을 때 실행할 함수 등록

    • TestCase에서는 테스트 성공 여부에 상관없이 항상 트랜잭션 롤백 • Django 문서: on_commit() 콜백의 결과를 테스트하는 게 필요하다면, TransactionTestCase를 사용하세요. https://docs.djangoproject.com/en/2.2/topics/db/transactions/#use-in-tests
  29. TestCase에서 on_commit 테스트하기 • Django 내부에서 트랜잭션이 완료되었을 때 실행하는

    함수를 강제로 실행 • mock을 이용하여 내부의 validate_no_atomic_block 함수를 우회할 수 있음 https://medium.com/gitux/speed-up-django-transaction-hooks-tests-6de4a558ef96
  30. 4. Parallel Test

  31. None
  32. Django parallel test • Django 1.9 이상을 사용하고 있으면 별도의

    설정 없이 바로 사용 가능 • python manage.py test --parallel
  33. 이 테스트는 어떻게 깨질까요? (1.11 이하)

  34. • 남아 있는 테스트가 돌지 않고 테스트 프로세스가 강제로 종료됨

  35. 친절한 에러 메시지 분석 • 테스트 도중 django.contrib.aut.models.User.DoesNotExist 예외가 발생하여

    실패하였습니다. • 발생한 예외는 pickle 할 수 없기 때문에, parallel test runner가 깔끔하게 처리할 수 없습니다. • 병렬 옵션을 끄고 다시 돌리면, 올바른 traceback를 볼 수 있습니다. • 이후 test runner 크래시
  36. Parallel Test (Django Internal) https://github.com/django/django/pull/4761/commits/cd9fcd4e

  37. 문제점 • 동적으로 생성되는 DoesNotExist, MultipleObjectReturned 예외가 pickle 실패 •

    테스트에서는 충분히 자주 발생할 수 있는 예외들. 매번 병렬 설정 변경하는 것은 불편.
  38. 동적으로 생성되는 모델 예외 클래스를 pickle 할 수 있도록 처리

    • Django 2.1 이상만 패치 되었음. Django 1.11 에서는 미반영. • Idea: Django 2.1에서 적용된 패치를 Django 1.11에 적용하면 잘 되지 않을까? https://code.djangoproject.com/ticket/28575
  39. Apply backport to 1.11.x • Idea: Django 2.1에서 적용된 패치의

    diff를 1.11에 적용하면 되지 않을까? • 해당 패치에서 변경된 소스 코드를 1.11에 동일하게 반영 • Python 3.3에 추가된 __qualname__를 수정하는 패치이기에 Python 2.7 에서는 동작하지 않음 • Python 3.4, 3.6 에서 정상 동작 확인 • Django 내부 소스 코드 직접 수정이기에 운영, 개발 환경에서는 미사용, 테스트 환경에서만 사용 중 https://github.com/peoplefund-tech/django/commit/565f436d
  40. 5. Keepdb with mysqldump

  41. Fresh migrations is terribly slow! • 마이그레이션할 테이블의 개수가 많은

    경우, 초기 마이그레이션이 오래 걸림 • 대략 230개 정도의 테이블을 가진 프로젝트의 마이그레이션 속도는 4-5분 정도 소요 • 테스트를 돌릴 때마다 처음부터 수행하면 불필요한 시간 소모
  42. Django keepdb • 최초 테스트 데이터베이스를 마이그레이션한 후 삭제하지 않음

    • 다음 테스트에서는 새로 추가된 마이그레이션만 수행하고 테스트 수행 • ➔ 마이그레이션 수행에 걸리는 시간 단축
  43. Django keepdb + git-flow • 여러 개의 피쳐 브랜치가 같은

    테스트 데이터베이스를 공유하기 때문에 문제 발생
  44. mysqldump + git-flow • IDEA: develop 브랜치에 합쳐진 커밋은 거의

    롤백되지 않음 • develop 기준으로 데이터베이스를 덤프 떠 놓고 그것을 mysqldump로 가져오기
  45. Performance • Django fresh migration : 4-5 min • mysqldump

    : 5-10 sec
  46. 6. Disable linter

  47. Python Linter: 성능 오버헤드 • Linter: 코딩 컨밴션과 에러를 체크해주는

    툴 • 오버헤드: 경험 상 대략 30% 정도 느려짐 • 버그 발생 가능: pylint의 경우, 알 수 없는 무한 루프가 발생하여 적용 해제. (약 1년 반 전)
  48. 선택적으로 필요할 때만 사용 권고 • 코드 품질을 생각한다면 lint

    및 커버리지 확인 툴을 항상 켜야 하나, 테스트 시간이 오래 걸리는 것은 트레이드 오프 • (개발자가 완료되기 기다리는) Pull Request / Push 등의 이벤트: Lint 해제 • (개발자가 자고 있을 시간의) 일일 새벽 정기 빌드 등: Lint 및 커버리지 테스트 추가
  49. 테스트 시간 42~44분 ➔ 3.5분 (약 92% 감소!) 그래서 얼마나

    줄였는데요???
  50. None