예산을 넘는 첫 거래
요구사항
이번 주 카드 피드를 도착 순서대로 훑어 예산 100을 넘는 첫 거래를 보고하고 — 그 뒤로는 더 찾지 마세요. 검색이 짧게 끊겼음을 증명하기 위해, 실제로 검사한 거래 수도 출력합니다. 데이터는 아래 코드에 있으며, 두 버전 모두 예상 출력 아래에 표시된 줄들을 출력해야 합니다.
예상 출력
First over budget: T-04 (128) Examined 4 of 8 transactions
나란히 보기
RxDart
FxDart
차이가 나는 이유
여기서는 양쪽 모두, 각자의 방언으로, 진짜 지연 평가입니다. RxDart의
firstWhere는 첫 일치에서 future를 확정하고
구독을 취소합니다 — 남은 네 건의 거래는 결코 전달되지
않습니다. FxDart의 find는 그저 끌어오기를
멈춥니다 — 남은 네 건의 거래는 결코 요구되지 않습니다. 취소와
수요는 같은 경제성을 가리키는 두 모델 각각의 단어이고,
"Examined 4 of 8" 줄은 양쪽에서 동일하게 나옵니다.
배울 점이 있는 차이는 카운트가 어디에 사는가입니다.
스트림에는 "사이"가 있습니다: doOnData가 연산자 사이의
파이프를 톡톡 두드리므로, rx 술어는 순수하게 남고 탭이 트래픽을
관찰합니다. pull 체인에는 사이가 없습니다 — 수요의 순간이 곧 술어
호출 그 자체이므로, FxDart 쪽은 술어 안에서 셉니다. 어느 표기가 더
낫다고 할 수 없습니다; 각각 push와 pull에 고유한 관찰 관용구입니다.
판정: 무승부 — 연산자 하나씩, 그리고 둘 다 정확히 옳은 순간에
멈춥니다.
벤치마크 격차가 이렇게 큰 이유
아래 막대가 재는 것은 지연 평가가 아닙니다. 벤치마크 규모에서 예산을 넘는 첫 거래는 100만 건 중 900,001번째에 있고, 양쪽 모두 정확히 900,001건을 검사합니다 — 체크섬이 이를 증명합니다. 막대가 재는 것은 원소 하나가 각 모델을 통과하는 값입니다: pull 쪽은 약 1ns, push 쪽은 약 88ns.
이것은 RxDart의 결함이 아니고, 연산자를 더 잘 고르면 해결되는 표기의 문제도 아닙니다. 같은 데이터셋에서 대안들을 측정했습니다 — 900,001개 원소, 동일한 결과:
firstWhere대신where().first: 50ns (찾아낸 가장 빠른 연산자 조합)doOnData탭 대신 술어 안에서 세기 — 변환 계층 하나가 사라집니다: 62ns- 연산자 없이
await for+break: 227ns — 가장 빠른 쪽이 아니라 가장 느린 쪽입니다 - 손으로 구동하는 동기
StreamController— 더 이상 관용적인 Rx는 아니지만 push 모델의 하한선: 20ns
그 하한선조차 pull 체인의 20배입니다. 이유는 구조적입니다.
Stream은 전달 메커니즘입니다: 각 값은 체인에 있는
변환 계층을 모두 거쳐 구독으로 건네지며, 스트림을 공유하고 일시 정지하고
취소하고 비동기 경계를 넘어 합성해도 안전하게 만드는 이벤트 루프 규율을
따릅니다. List 위의 FxDart find는 원소마다
클로저 하나를 호출하고 반환하는 인덱스 루프로 컴파일됩니다 — 전달도,
구독도, 스케줄링도 없습니다. 여기에 실제로 비동기인 것이 하나도 없기
때문입니다.
트리거 위치가 결과를 조작한 것 아닌가요?
타당한 질문이고, 이 사례에서 유일하게 판단이 개입된 지점입니다. 검색이 조기 종료되기 때문에 작업량은 데이터가 결정합니다: 벤치마크는 예산을 초과한 첫 거래를 전체의 90% 지점에 두었고, 그래서 100만 개짜리 실행은 900,001개를 검사합니다. 위치를 옮기면 양쪽 모두 그만큼 덜 일합니다. 이 격차가 그 선택이 만들어낸 착시라면, 트리거를 앞으로 당겼을 때 격차가 좁혀져야 합니다.
좁혀지지 않습니다. 유휴 상태의 머신에서 각각 5라운드씩 연달아 측정했고, 노이즈를 보이기 위해 90% 데이터셋은 두 번 돌렸습니다:
| 규모 | 트리거 | 검사한 개수 | RxDart | FxDart | 배율 |
|---|---|---|---|---|---|
| 100 | 90% | 91 | 10 µs | 286 ns | 35× |
| 100 | 90% (반복) | 91 | 11 µs | 280 ns | 39× |
| 100 | 50% | 51 | 7.5 µs | 256 ns | 29× |
| 1,000,000 | 90% | 900,001 | 79.1 ms | 861 µs | 92× |
| 1,000,000 | 90% (반복) | 900,001 | 78.9 ms | 901 µs | 88× |
| 1,000,000 | 50% | 500,001 | 44.2 ms | 374 µs | 118× |
작업량을 절반으로 줄이면 양쪽 모두 절반이 되고, 배율은 오히려 커집니다 — 88×에서 118×로. 100만 개 행을 검사한 개수로 나눠 보면 이유가 분명해집니다: RxDart는 세 번의 실행에서 원소당 87.7, 87.9, 88.4 ns로, 트리거 위치와 무관하게 평평합니다. 반면 FxDart는 1.00, 0.96, 0.75 ns로, 스캔이 짧을수록 원소당 비용이 오히려 조금 더 쌉니다. 트리거를 앞으로 당기는 것은 오히려 FxDart에 유리합니다. 100개짜리 행은 원소당 비용이 아니라 고정 셋업 비용이 지배하기 때문에 배율이 더 작고 노이즈도 큽니다 — 헤드라인 규모가 따로 있는 이유이기도 합니다.
그러니 90%는 무언가를 부풀리려고 둔 것이 아닙니다. 헤드라인이 시작 비용이 아니라 당겨오는 일을 재도록 두는 위치이고, 우리가 시도한 위치들 중 FxDart에 가장 불리한 위치입니다. 위치가 바꿀 수 없는 것은 원소당 가격이고, 격차는 전부 거기서 나옵니다.
그래서 이 막대의 정직한 해석은 좁습니다: 소스가 이미 메모리에 있고 질문이 동기적이라면, 그것을 스트림으로 흘려보내는 일은 순수한 오버헤드입니다. 소스를 진짜 비동기적인 것으로 — 소켓, 웹소켓, 페이지네이션 API로 — 바꾸면 그 원소당 비용은 스트림이 관리하도록 설계된 I/O 아래로 묻힙니다. 바로 Part 4가 다루는 영역입니다. 코드 판정은 무승부 그대로입니다: 양쪽 모두 같은 단락 평가를 연산자 하나로 표현하고, 둘 다 옳은 순간에 멈춥니다.
벤치마크
N = 100
시간 무승부
최대 메모리 무승부
N = 1,000,000
시간 FxDart 승
최대 메모리 FxDart 승
막대는 사이드별로 새 프로세스에서 반복 측정한 중앙값입니다(작은 N은 타이머 해상도를 위해 배치 처리). 두 사이드가 서로 5% 이내이거나 — 사람이 지각할 수 없는 차이인 0.6ms 이내이면 — 무승부로 칩니다. 상대 차이가 근소한 경우는 최대 5회까지 다시 측정합니다. 앱에서는 어느 막대가 짧든 몇 밀리초 이하의 차이는 사용자에게 보이지 않습니다. 메모리는 프로세스 최대 RSS입니다. Dart VM과 데이터셋은 양쪽이 동일하므로, 두 막대의 차이가 곧 파이프라인 자체가 붙들고 있는 양입니다.