deep-dives

19 posts

아키텍처 다이어그램은 이미 거짓말이다

아키텍처 문서는 저장하는 순간부터 썩기 시작한다. 코드로부터 다이어그램을 생성하고, ADR을 남기며, 자동화된 아키텍처 테스트로 문서를 정직하게 유지하는 방법을 알아본다.

위키에서 본 모든 아키텍처 다이어그램은 틀렸다. 극적으로 틀린 건 아니다. 조용히, 점진적으로 틀린 것이다. "Auth"라고 적힌 서비스는 6개월 전에 세 개의 microservice로 쪼개졌다. "sync call"이라고 표시된 화살표는 이제 queue를 통해 async로 동작한다.…

재시도 루프는 첫 번째 요청이 실패했다고 가정합니다. 아마도 그렇지 않았을 겁니다.

타임아웃이나 크래시가 API 요청이 사라졌다는 뜻은 아닙니다. idempotency key가 재시도를 안전하게 만드는 방식, 그리고 실제로 중복을 방지하는 저장소 패턴을 소개합니다.

요청을 처리하던 중 서비스가 크래시됩니다. 클라이언트는 타임아웃을 보고 재시도합니다. 이제 두 건의 결제가 생겼습니다. 고객은 화가 났습니다. 데이터베이스는 일관적입니다. 비즈니스 로직은 그렇지 않습니다. 이건 엣지 케이스가 아닙니다. 분산 시스템의 기본 동작입니다. 네트워크는 패킷을…

프로세스보다 오래 사는 락: 분산 리스가 실제로 어떻게 동작하는지

서버를 재시작하면 인메모리 뮤텍스는 사라진다. 펜싱 토큰과 TTL을 갖춘 분산 리스가 크래시 이후 중복 작업을 어떻게 막는지, 그리고 여전히 물러서는 지점은 어디인지 설명한다.

는 를 견디지 못한다. OOM, 배포 롤아웃, 노드 재부팅에서도 살아남지 못한다. 프로세스가 종료되는 순간 락은 사라진다. 그 락이 예약된 잡, 데이터 마이그레이션, 리더 선출을 보호하고 있었다면, 이제 두 프로세스가 각자가 유일하게 실행 중이라고 믿게 된다. 이건 뮤텍스의 버그가…

goroutine도, timer도, background overhead도 없는 circuit breaker

대부분의 circuit breaker 라이브러리는 복구를 probe하기 위해 background thread를 생성한다. 그럴 필요 없다. 이 글에서는 correctness를 희생하지 않으면서 모든 background overhead를 제거하는 request-driven 설계를 소개한다.

내가 리뷰한 모든 프로덕션 circuit breaker는 결국 background thread를 생성한다. Go의 goroutine일 수도, Java의 일 수도, Rust의 tokio task일 수도 있다. 하는 일은 항상 같다: 몇 초마다 깨어나 downstream service가…

웹 서비스에 그레이스풀 셧다운 경로가 있다. 그게 버그다.

크래시 온리 소프트웨어는 모든 실패를 크래시로, 모든 시작을 복구로 취급한다. 웹 서비스에 적용하면, 셧다운 로직을 삭제하고 kill -9를 견디는 상태를 설계하는 것을 의미한다.

웹 서비스에는 셧다운 핸들러가 있다. 버퍼를 플러시하고, 연결을 닫고, 체크포인트를 기록한다. 한 번쯤 테스트했을지도 모른다. 프로덕션에서는 계획된 배포 중 1년에 한 번 정도 실행될 것이다. 나머지 시간에는 서비스가 OOM 킬, 노드 축출, 정전, 또는 타임아웃으로 SIGKILL을…

Mutant가 무엇을 바꿨는지 모를 때 Surviving Mutant를 죽이는 방법

Mutation testing이 survivor를 찾았는데, 그 mutation이 도대체 무엇을 하는지 전혀 모르겠다. Mutant를 먼저 이해하지 않고도 올바른 테스트를 작성하는 단계별 방법이다.

Mutation testing 리포트는 survivor로 가득 차 있고, 그중 최소 하나는 도저히 이해할 수 없는 상태다. 도구는 47번째 줄의 를 로 뒤집었거나, 전체 조걸문 블록을 로 바꿨거나, 테스트 대상인지도 몰랐던 문자열 리터럴을 mutate했다고 말한다. diff를 세…

인증 코드는 90% mutation coverage가 필요합니다. 문자열 유틸리티는 아닙니다.

전체 코드베이스에 단일 mutation score를 강제하는 것이 왜 실수인지, 그리고 실제 리스크에 맞는 모듈별 임계값을 설정하는 방법.

전체 코드베이스에 단일 mutation score를 강제하는 것은 팀이 테스트를 싫어하게 만드는 최고의 방법입니다. 일반적인 저장소에 PIT이나 Stryker를 실행하면 같은 패턴이 보입니다: 인증 모듈은 40%를 기록하고, 문자열 유틸리티는 95%를 찍으며, ORM 계층은 60%대…

테스트는 통과한다. mutation score는 40%다. surviving mutant가 실제로 말해주는 것

code coverage는 안전하다고 말한다. mutation testing은 테스트가 대부분 장식이라고 말한다. surviving mutant가 이 간극을 드러내는 방법과 그것을 메우는 방법을 알아보자.

테스트는 통과한다. coverage 리포트는 87%라고 한다. 하지만 mutation score는 40%이고, 절반의 mutant가 여전히 살아있다. 그 40%는 코드가 망가졌다는 뜻이 아니다. 테스트가 망가졌다는 뜻이다. coverage는 테스트 실행 중 어떤 줄이 실행되었는지를…

Rust에서 뮤테이션 테스트는 통하지만, 컴파일 시간이 복수한다

cargo-mutants는 코드를 검증하는 척만 하는 테스트를 찾아낸다. Rust에서 뮤테이션 테스트가 어떻게 작동하는지, 무엇을 잡아내는지, 그리고 컴파일 시간 비용이 감당할 만한 가치가 있는지 알아본다.

라인 커버리지는 100%다. 모든 분기를 커버한다. 모든 함수를 호출한다. 그런데 누군가 가격 책정 로직에서 를 로 바꾸고 테스트를 돌리면, 테스트가 모두 통과한다. 이것은 이론적인 문제가 아니다. 테스트가 코드를 실행하지만 실제로는 동작을 검증하지 않을 때 벌어지는 일이다.…

뮤테이션 테스트는 4시간이 걸린다. 팀은 실제로 CI에서 어떻게 사용할까?

대부분의 팀은 매 커밋마다 전체 뮤테이션 테스트 스위트를 실행하지 않는다. 엔지니어링 팀이 뮤테이션 테스트를 CI에 통합하면서도 빌드 파이프라인을 망가뜨리지 않는 실제 방법을 소개한다.

뮤테이션 테스트 스위트가 4시간이나 걸린다면, 축하한다. 모두가 이미 의심하던 사실을 증명한 셈이다. 당신의 테스트에는 구멍이 있다. 매 푸시마다 CI에서 이걸 돌릴 생각은 하지 마라. 그런 팀은 없다. 문제는 한 커밋당 4시간을 감당할 수 있느냐가 아니다. 테스트는 통과하는데…

유닛 테스트는 통과하는데, 데이터는 여전히 사라진다

모의 데이터베이스 테스트는 SQL 문법은 검증하지만, 행이 크래시나 동시 쓰기, 스키마 불일치에서 살아남는지는 검증하지 않는다. 여기 지속성을 진짜로 테스트하는 방법이 있다.

테스트에서 데이터베이스를 모킹하면, 리포지토리 레이어가 올바른 메서드를 호출하는지 검증하는 것이다. 데이터가 크래시에서 살아남는지, 고유 제약 조건이 실제로 중복을 차단하는지, 또는 트랜잭션이 실패할 때 롤백되는지는 테스트하지 않는다. 이 차이는 중요하다. 모킹된 는 지시한 대로…

모든 mock action에 매몰리지 않고 Redux 테스트하기

모든 Redux action을 mock하면 테스트가 변경 로그 검증기가 된다. 대신 실제 상태 전환으로 store를 테스트하는 방법.

가 정확한 payload 형태로 호출되었는지 검증하는 테스트를 작성한 적이 있다면, 누군가 상수 이름을 바꿀 때마다 깨지는 테스트를 작성한 것이다. 이건 상태 로직을 테스트하는 게 아니다. 손가락이 올바른 문자열을 입력했는지 테스트하는 것이다. Redux 테스트 튜토리얼은 종종…

100번 테스트 실행은 거짓말이다: Property-Based Test를 실제로 사이징하는 법

Property-based testing의 기본값인 100개 예제는 통계 전략이 아닌 사회적 타협이다. 자신의 신뢰도 요구사항과 CI 예산에 맞는 실행 횟수를 선택하는 방법을 알아보자.

property-based test를 기본값 100개 예제로 실행하고 있다면, 양쪽 모두 최악의 상황을 겪고 있는 것이다. CI는 필요 이상으로 느리고, 여전히 중요한 버그는 놓치고 있다. 이 숫자에 마법 같은 건 없다. Hypothesis를 포함한 대부분의 라이브러리가 100을…

Rust의 Property-Based Test가 당신의 Unit Test가 놓치는 버그를 찾아낸다

Example-based testing은 당신이 생각해낸 입력만 커버한다. Property-based testing은 무작위 데이터를 생성하고, invariant를 검증하며, 실패를 최소한의 counterexample로 축소한다.

당신은 함수를 작성했다. 과 로 테스트했다. 통과했다. 배포했다. 한 사용자가 한 개의 원소를 가진 slice를 넘겼다. 당신의 함수는 그것을 무시하고 지나쳤다. 이슈가 열렸다. 당신은 테스트 파일을 응시하며 이렇게 뻔한 것을 어떻게 놓쳤는지 의아해한다. 놓친 이유는…

단위 테스트는 통과했지만, 프로덕션 코드는 여전히 망가져 있다.

코드 커버리지 지표는 허위 안전감을 조성한다. 단위 테스트가 실제로 잠을 설치게 하는 버그를 놓치는 이유, 그리고 대신 무엇을 테스트해야 하는지 알아보자.

코드 커버리지가 90%인데도 새벽 2시에 호출을 받았다. 단위 테스트는 통과했다. CI는 초록불이었다. 그런데 버그는 어김없이 프로덕션에 올라갔다. 커버리지가 거짓말을 한 건 아니지만, 진실을 말해준 것도 아니다. 커버리지는 어떤 라인이 실행됐는지 쟀을 뿐, 어떤 동작이 실제로…

Rust 런타임 contracts는 릴리스 빌드에서 오버헤드 없이 사용할 수 있지만, 컴파일러가 대신 해주지는 않는다

Rust는 디버그 assertions를 자동으로 제거하지만, 진정한 design-by-contract는 debug_assert! 이상의 것이 필요하다. 릴리스 바이너리에서 완전히 사라지는 zero-cost runtime contracts를 만드는 방법을 소개한다.

Rust는 개발 환경에서 runtime contracts를 강제할 수 있고, 릴리스 빌드에서는 완전히 지워버릴 수 있다. 전제 조건은 이 언어가 contract를 first-class 개념으로 다루지 않는다는 것이다. 필요한 구성 요소는 주어지지만, 직접 연결해야 한다. 는 가장 먼저…

0개, 1개, 아니면 12개: 프로덕션 함수에 실제로 필요한 assertion의 개수

개발자들은 assertion을 색종이처럼 뿌리거나 아예 사용하지 않는다. 유용한 invariant와 프로덕션 crash를 유발하는 요인을 구분하는 결정 프레임워크를 소개한다.

대부분의 프로덕션 codebase는 두 진영 중 하나에 속한다. A 진영은 를 장식용 조미료처럼 다루어 함수가 편집증적인 변호사가 쓴 법률 계약서처럼 읽힐 때까지 한 줄 걸러 뿌린다. B 진영은 assertion을 개발 중에만 쓰는 보조 바퀴로 여겨 빌드 시점에 모두 제거하고,…

검증 계층이 비즈니스 로직보다 더 커지는 경우

수동 검증은 codebase를 부풀리고 여전히 엣지 케이스를 놓친다. 선언적 스키마로 runtime contracts를 간섭 없이 강제하는 방법을 알아본다.

API가 요청을 받을 때마다 검증한다. 함수가 외부 시스템으로부터 인자를 받을 때마다 확인한다. 이를 수동으로 하면, 단일 엔드포인트에 비즈니스 로직보다 더 많은 검증 코드가 쌓일 수 있다. 이것이 runtime contracts의 숨겨진 비용이다. 타입 시스템이 거짓말을 하기 때문에…

TypeScript strictNullChecks는 컴파일 타임 가드일 뿐, 런타임 방패가 아니다

strict mode는 내가 작성한 null만 잡아낼 뿐, API나 DOM 쿼리, JSON.parse를 통해 런타임에 유입되는 null은 잡지 못한다. 타입 시스템의 한계가 끝나는 지점과 진짜 방어가 시작되는 지점을 살펴 본다.

에서 를 켰다. 모든 빨간 물결표시를 고쳤다. 과 는 이제 해결된 문제라는 자신감을 안고 프로덕션에 배포했다. 그런데 백엔드 응답 구조가 바뀌고, DOM 쿼리가 아무것도 반환하지 않았고, TypeScript가 안전하다고 말한 바로 그 코드에서 이 을 던지며 죽었다. 무슨 일이 일어난…