Skip to content
Published on

결정론적 시뮬레이션 테스트가 찾은 버그 — KAFKA-19880, 그리고 "버그 0건"을 읽는 법

공유하기
Authors

들어가며 — 재현이 안 되는 버그 앞에서

프로덕션에서 이상한 일이 한 번 일어났습니다. 로그에는 흔적이 남았는데, 같은 상황을 만들려고 아무리 애써도 다시 나오지 않습니다. 테스트를 100번 돌려도 초록불입니다. 결국 "일시적인 현상"으로 정리하고 티켓을 닫습니다. 반년 뒤 같은 일이 더 나쁜 형태로 돌아옵니다.

Aiven의 Ivan Yurchenko는 이런 버그를 heisenbug라고 부릅니다 — IO, 실행 스케줄링, 그 밖의 무작위성에서 비롯되는 비결정성이 만들어 내는 버그입니다. 네트워크는 패킷을 지연시키고, 잃어버리고, 순서를 바꿔 배달합니다. OS는 스레드를 제멋대로 스케줄링합니다. 디스크는 실패합니다. 그의 표현을 빌리면, 이런 버그가 프로덕션에서 터졌을 때 텔레메트리만으로 이해하는 것은 사실상 불가능합니다.

여기에 "테스트를 더 짜라"는 조언은 잘 듣지 않습니다. 당신이 상상하지 못한 것은 테스트할 수 없기 때문입니다. 이 글은 그 문제를 다른 각도에서 공격하는 기법 — 결정론적 시뮬레이션 테스트(DST) — 과, 그 기법이 2025년 11월에 실제로 찾아낸 Apache Kafka 버그 하나를 따라갑니다. 그리고 그 사례가 왜 홍보 자료보다 훨씬 흥미로운지 이야기합니다. 결과가 깔끔하지 않기 때문입니다.

결정론적 시뮬레이션 테스트란 무엇인가

Antithesis의 문서는 DST를 이렇게 정의합니다 — 테스트 대상 소프트웨어를 시뮬레이션된 결정론적 환경에 놓는 것. 핵심은 두 단어입니다.

시뮬레이션. 시스템의 일부 또는 전부를 실제 하드웨어·네트워크·OS 위가 아니라 시뮬레이터 위에서 돌립니다. 그러면 테스트 하네스가 시뮬레이션된 계층에서 벌어지는 일 — 언제 어떤 장애가 발생하는지 — 을 통제할 수 있습니다.

결정론. 클럭, 스레드 인터리빙, 시스템이 제공하는 난수처럼 비결정성이 흘러 들어오는 출처를 결정론적으로 만듭니다. 그래서 시드를 고정하면 똑같은 실행이 똑같이 재연됩니다. 이게 왜 중요한지는 앞 절의 시나리오가 말해 줍니다 — 버그를 찾는 것보다 다시 불러오는 게 어려웠으니까요.

문서에 따르면 이 접근의 실용적 채택은 2010년 무렵 FoundationDB와 AWS에서 각각 일어났고, 동시 발명에 가까웠습니다(아이디어 자체는 그보다 앞섭니다). AWS에서는 Al Vermeulen이 내부 락 서비스 초기 구현을 테스트하는 데 도입했고, 가장 이르게 기록된 논의 중 하나가 Will Wilson의 2014년 Strange Loop 발표입니다. FoundationDB가 만든 그 프레임워크가 나중에 Antithesis라는 회사의 기술적 근간이 됩니다.

구현 방법은 크게 둘로 갈립니다.

  1. FoundationDB 방식. 시스템을 설계할 때 비결정론적 컴포넌트를 전부 갈아 끼울 수 있게(pluggable) 만듭니다. 강력하지만, 시스템과 그 의존성 전체를 처음부터 DST를 염두에 두고 지어야 합니다. Antithesis 문서의 표현으로는 이미 프로덕션에 있는 시스템에는 일반적으로 비현실적입니다.
  2. 결정론적 하이퍼바이저 방식. 평범한 비결정론적 소프트웨어를 결정론적 하이퍼바이저 안에서 그대로 돌립니다. Antithesis가 파는 게 이것이고, 그래서 코드를 고치지 않아도 됩니다.

그리고 문서가 짚는, 자주 간과되는 대목이 있습니다. 사람들은 "완전히 결정론적인 시스템 만들기"를 주된 기술적 난제로 보지만, 실은 상태 공간을 충분히·효율적으로 탐색하는 일도 그만큼 복잡한 과제라는 것입니다. 대부분의 소프트웨어에서 상태 공간은 극단적으로 크니까요. DST가 속성 기반 테스트/퍼징 및 폴트 주입과 짝지어 다니는 이유가 이것입니다. (속성 기반 테스트 자체는 따로 다룬 적이 있습니다.)

한 가지 개념만 더. 시뮬레이터가 클럭을 소유하면, 코드가 time.Sleep()에 해당하는 일을 할 때 실제로 기다릴 필요가 없습니다. 그래서 벽시계 시간(wall clock)보다 훨씬 많은 논리 시간(logical/application time)을 압축해서 돌릴 수 있습니다. 아래 숫자들이 전부 이 단위입니다.

Aiven의 실험 — Diskless Kafka를 시뮬레이터에 넣기

2026년 3월 5일, Aiven이 그 과정을 공개했습니다. 대상은 Inkless — KIP-1150: Diskless Topics를 구현하기 위한 임시 Apache Kafka 포크입니다. 브로커 로컬 디스크 대신 오브젝트 스토리지를 주 저장소로 쓰는 기능이고, KIP 위키의 현재 상태는 Accepted입니다.

동기는 명확합니다. 기존 Kafka 코드에 새 코드 경로를 잔뜩 추가하는 중이고, 그것들이 스스로 올바른지도 확인해야 하지만 더 중요하게는 기존 Kafka 경로를 망가뜨리지 않는지 확인해야 합니다.

셋업은 이렇습니다. 새 하네스를 짜는 대신 이미 있는 Kafka의 Ducktape 시스템 테스트 스위트를 확장했습니다.

  • Docker Compose 안에 Ducktape 워커 14개(Inkless 코드로 브로커와 클라이언트를 모두 구동), Inkless 컨트롤 플레인용 PostgreSQL, MinIO 스토리지
  • Diskless 토픽을 만들어 계속 produce하고 전부 consume되는지 확인하는 시스템 테스트(inkless_produce_consume_test.py), 여기에 Antithesis SDK를 임포트해 추가 assert
  • 이미지를 레지스트리에 푸시하고 HTTP POST 한 방으로 실행

여기서 배울 점은 도구가 아니라 불변식입니다. 테스트가 계속 확인한 것은 셋뿐이었습니다.

  • 테스트가 절대 멈추지 않는다 (Antithesis의 reachable 연산자)
  • 쓴 메시지는 전부 읽힌다 (always_or_unreachable)
  • 나쁜 일이 일어나지 않는다 — 메시지가 순서를 어겨 읽히거나, 중복 오프셋이 나오거나, 파티션 셋이 달라지는 일 (unreachable)

DST는 마법이 아닙니다. 무엇이 "잘못"인지 당신이 말해 주지 않으면 아무것도 잡지 못합니다. 시뮬레이터는 이상한 상태를 만들어 줄 뿐이고, 그게 이상하다고 판정하는 건 이 세 줄입니다.

결과 1 — 2,200 논리 시간, 버그 0건

테스트는 Antithesis 슈퍼바이저 안에서 총 약 2,200 논리 시간 동안 Kafka 클러스터에 계속 produce하고 consume했습니다. 그리고 아무 문제도 찾지 못했습니다.

Aiven이 이 결과에 붙인 문장이 이 글에서 가장 값진 대목이라고 생각합니다.

통과한 테스트는 가장 의심스러운 종류의 테스트일 수 있다. 그건 그냥 당신이 아직 문제를 못 찾았다는 뜻일 수도 있다.

참고로 Aiven은 전체적으로 벽시계 기준 약 200시간을 돌렸고 그게 약 9,700 논리 시간(1.1년)에 해당했다고 밝힙니다. 앞의 2,200은 그중 이 Inkless 시스템 테스트에 해당하는 몫입니다.

"버그 0건"이 정확히 무엇을 증명하는지 짚고 넘어갑시다. 그건 Inkless가 옳다는 증명이 아닙니다. 이 워크로드로, 이 세 불변식으로, 이 시드들로는 위반을 찾지 못했다는 뜻입니다. 워크로드가 건드리지 않는 코드 경로, 불변식이 표현하지 않는 속성은 여전히 사각지대입니다.

결과 2 — 대조군에서 나온 KAFKA-19880

그래서 Aiven은 대조군을 돌렸습니다. 자기들의 가정을 재확인하려고, Inkless 포크가 아니라 업스트림 Apache Kafka 원본 코드에 Kafka의 원래 시스템 테스트 일부를 Antithesis에서 돌린 겁니다. 하네스가 실제로 버그를 잡을 수 있는 상태인지 보려는, 일종의 새너티 체크입니다.

그리고 거기서 KAFKA-19880이 나왔습니다. 제목은 이렇습니다.

First batch in producer epoch can be appended out-of-order even for idempotent producer

Aiven의 표현대로 "놀랍게도"였습니다. 자기 새 코드를 검증하려고 만든 장치가, 대조군으로 세워 둔 쪽에서 버그를 물어 온 것입니다. Aiven이 글의 결론에서 쓴 표현을 그대로 옮기면, Apache Kafka는 커뮤니티가 테스트와 설계에 늘 성실했던 매우 성숙하고 실전에서 단련된 소프트웨어입니다.

메커니즘은 이렇습니다. 프로듀서는 enable.idempotence=truemax.in.flight.requests.per.connection=5로 설정돼 있고, 갓 만든 빈 토픽에 여러 배치를 만들 만큼의 레코드를 보냅니다. 그런데 클러스터와 대상 브로커 사이에 메타데이터 전파에 영향을 주는 연결 문제가 있습니다.

프로듀서: enable.idempotence=true, max.in.flight.requests.per.connection=5
대상: 갓 만들어진 빈 토픽 input-topic-0

correlationId=21  base sequence 0     -> NotLeaderOrFollowerException  (브로커가 아직 자신이 리더인 줄 모름)
correlationId=22  base sequence 1458  -> NotLeaderOrFollowerException
correlationId=23  base sequence 2823  -> NotEnoughReplicasException    (ISR 크기 1 < min.isr 2)
correlationId=24  base sequence 4188  -> 성공 (!)
correlationId=25  base sequence 5553  -> ...

결과: 이 파티션에 처음으로 기록된 배치의 base sequence 가 0 이 아니라 4188.
      멱등성을 켰는데도 첫 배치가 논리적으로 순서를 어긴 채 수용됨.

이슈 본문의 표현으로는, correlationId 24에 이르러 요청이 성공하고, 그래서 처음 기록된 배치가 논리적으로 순서를 벗어났는데도 수용됩니다. 앞서 실패한 배치들은 나중에 새 프로듀서 에폭으로 재시도될 수 있지만 — 보고자의 사례에서 실제로 그랬습니다 — 그건 중요하지 않습니다. 의도한 레코드 순서가 이미 깨졌기 때문입니다.

조건도 명확합니다. 파티션이 비어 있을 필요는 없고, 프로듀서 상태가 비어 있고 그 에폭의 첫 요청들이 위처럼 실패하면 충분합니다. 반대로 에폭의 첫 요청이 성공하면 이 상황은 불가능합니다. r1이 성공하고 r2가 NotLeaderOrFollowerException으로 실패하면, r3는 OutOfOrderSequenceException으로 거부되니까요. 보고자는 이 차이를 브로커 쪽 ProducerAppendInfo.java의 검증 로직에 돌립니다.

이 사례에서 진짜 배울 점 — 재현 가능성

여기서 DST의 값이 드러납니다. 이슈 본문에서 보고자가 직접 쓴 문장입니다.

Antithesis 아래에서는 이걸 꽤 안정적으로 라이브 재현할 수 있다. 아마 이걸 괜찮은 빈도로 잡아내는 로컬 셋업을 만드는 건 쉽지 않을 것이다.

그런데 그는 거기서 멈추지 않고, 이걸 ReplicaManagerTest 안의 유닛 테스트 하나로 축소해 냈습니다.

// KAFKA-19880 이슈 본문의 재현 코드에서 발췌·요약
val r0 = MemoryRecords.withIdempotentRecords(
  Compression.NONE, producerId, producerEpoch, 0, new SimpleRecord("record 0".getBytes()))
val r1 = MemoryRecords.withIdempotentRecords(
  Compression.NONE, producerId, producerEpoch, 1, new SimpleRecord("record 1".getBytes()))

// val order = List(r0, r1)  // 정상 순서 -- 순서 위반 없음
val order = List(r1, r0)     // r1 이 순서를 어긴 채 수용됨
                             // r0 은 나중에 더 높은 epoch 으로 재시도되어 수용됨

이 흐름이 DST의 진짜 판매 포인트입니다. 시뮬레이터가 희귀한 인터리빙을 안정적으로 재생산해 주고 → 사람이 그걸 들여다보고 → 도구 없이도 돌아가는 결정론적 유닛 테스트로 압축한다. 마지막 산출물은 Antithesis 구독이 없어도 누구나 돌릴 수 있는 20줄짜리 Scala입니다. 도구는 사다리였지, 목적지가 아니었습니다.

그리고 결말은 깔끔하지 않다

여기서부터가 홍보 자료에는 안 나오는 부분입니다.

먼저 보고자 본인이 확신하지 않습니다. 이슈 본문의 마지막 질문은 이렇습니다 — 내가 문서를 읽은 바로는 이건 버그다, enable.idempotence가 이걸 막아 줄 거라고 기대했다, 내가 맞나 아니면 이게 수용 가능한 동작이고 문서를 개선해야 하는 건가? Aiven의 블로그 역시 같은 톤을 유지합니다 — 이게 문서화 부족인지 엄밀한 버그인지는 두고 봐야 하고, 커뮤니티의 답을 기다리는 중이라고요.

그래서 지금 이 이슈는 어떤 상태일까요. 2026년 7월 현재 JIRA를 조회해 보면 이렇습니다.

KAFKA-19880
  Summary:     First batch in producer epoch can be appended out-of-order
               even for idempotent producer
  Status:      Open
  Resolution:  Unresolved
  Priority:    Minor
  Reporter:    Ivan Yurchenko
  Created:     2025-11-12
  Affects:     4.0.1, 4.1.0
  Fix Version: (없음)
  Components:  core, producer
  댓글:         1개

보고된 지 8개월, 댓글은 딱 하나입니다. 2026년 3월 10일 Travis Bischel(Go용 Kafka 클라이언트 franz-go의 저자)이 남긴 한 줄 — "Likely related: KAFKA-14312".

KAFKA-14312를 열어 보면 제목이 이렇습니다. "Kraft + ProducerStateManager: produce requests to new partitions with a non-zero sequence number should be rejected." 새 파티션에 0이 아닌 시퀀스 번호로 오는 produce 요청은 거부해야 한다. 보고자는 같은 Travis Bischel, 보고일은 2022년 10월 18일, 우선순위 Major. 그리고 상태는 Resolved / Won't Fix입니다. 닫힌 날짜는 2024년 11월 12일입니다.

물론 Travis는 "likely related"라고만 했지 같은 이슈라고 하지 않았습니다. 그 판단은 아직 누구도 내리지 않았습니다. 하지만 그림은 이렇게 정리됩니다. 최첨단 시뮬레이터가 수백 시간을 돌려 이상 동작을 물어 왔고, 재현도 됐고, 유닛 테스트로 축소도 됐는데 — 그게 고쳐야 할 버그인지 문서가 부실한 사양인지는 인접한 형태로 이미 한 번 제기됐다가 "안 고침"으로 닫힌 적이 있는 질문이고, 지금도 열려 있습니다. 우선순위는 Minor입니다.

DST가 자동화해 주는 것은 발견입니다. 그게 버그인지 정하고, 우선순위를 매기고, 고칠 사람을 찾는 일은 여전히 사람과 조직의 몫입니다. 도구를 사면 앞쪽 절반이 해결되고, 뒤쪽 절반은 그대로 남습니다.

숫자가 더 좋아 보이는 사례 — 그리고 그걸 읽는 법

Aiven의 결과가 "0건"이라 심심하다면, 자주 인용되는 반대쪽 사례가 있습니다. WarpStream이 2024년 3월에 쓴 글입니다. 읽기 전에 출처를 분명히 해 둡시다 — WarpStream은 오브젝트 스토리지 위에 얹은 Kafka 대체제를 파는 회사이고(글 자체에 그 공시가 붙어 있습니다), 이 글은 Antithesis 고객이 Antithesis에 대해 쓴 글입니다. 아래 숫자는 전부 자사 보고 수치입니다.

  • Antithesis가 WarpStream 워크로드를 벽시계 6시간 돌리는 동안 280시간의 애플리케이션 시간을 시뮬레이션했습니다.
  • 그중 새로운 "동작"을 더 이상 발견하지 못하고 정체(stall)되기까지 약 160 애플리케이션 시간이 걸렸습니다. WarpStream이 여기서 끌어낸 결론이 정직합니다 — 160시간을 넘겨 더 돌리는 건 수익 체감이고, 대신 테스트 자체를 더 정교하게 만드는 데 투자해야 한다는 것.
  • 첫날 잡힌 것은 메트릭 계측 라이브러리의 데이터 레이스였습니다. 프로젝트 첫 달부터 있던 버그이고, 그때까지 CI에서 Go 레이스 디텍터를 켠 채 문자 그대로 수만 시간을 돌렸는데 한 번도 안 잡혔습니다. Antithesis는 실행 233초 만에 잡았습니다.
  • 더 흥미로운 건 데이터 손실 버그입니다. Agent는 S3 PUT 비용을 줄이려고 여러 클라이언트의 Produce 요청을 메모리에 약 250ms 모았다가 한 파일로 합쳐 오브젝트 스토리지에 flush합니다. flush에 speculative retry를 추가하는 리팩터링 중에 에러 처리를 미묘하게 깨뜨려서, 아주 짧은 순간 동안 flush에 실패한 파일이 성공한 것으로 간주됐습니다. 이걸 메타데이터로 커밋하는 백그라운드 고루틴은 5ms마다 폴링하고, 문제의 두 상태 전이 사이 간격은 보통 1마이크로초 미만입니다. 즉 네트워크 실패와 특정 스레드 인터리빙이라는 두 희귀 사건이 겹쳐야 합니다. 스테이징에서는 전혀 못 봤고, Antithesis 안에서는 벽시계 시간당 대략 한 번씩 났습니다. 지금은 고쳤다고 밝힙니다.

이 사례에서 가져갈 건 "233초 vs 수만 시간"이라는 극적인 대비가 아니라 그 아래 깔린 구조입니다. 레이스 디텍터를 켜고 수만 시간을 돌린 CI는 같은 스케줄링을 수만 번 반복한 것에 가깝습니다. 반복은 탐색이 아닙니다. DST가 파는 건 시간이 아니라 인터리빙의 다양성입니다.

같은 글에서 WarpStream은 Antithesis가 Jepsen보다 낫다고 여러 문단에 걸쳐 주장하는데, 이건 검증된 사실이 아니라 명백히 한쪽 당사자의 의견이므로 그대로 받아들이지 않는 게 좋겠습니다. (혼돈 공학 계열 접근 전반은 카오스 엔지니어링 편에서 따로 다뤘습니다.)

정직한 트레이드오프

Antithesis 자사 문서조차 한계를 이렇게 적어 둡니다. 벤더가 스스로 인정하는 제약이라는 점에서 오히려 신뢰할 만합니다.

셋업이 무겁습니다. 결정론적 시뮬레이션 환경을 세우는 일은 복잡하고 자원 집약적인 과업이라고 문서는 말합니다.

모든 시스템이 되는 건 아닙니다. 문서의 표현으로, 모든 시스템이 그 주위에 DST를 세울 수 있는 방식으로 설계될 수 있는 건 아닙니다.

외부 의존성은 결국 모킹해야 합니다. Antithesis 같은 플랫폼이 대부분의 소프트웨어를 DST로 테스트할 수 있게 해 주긴 하지만, 결정론을 보장하려면 외부 의존성은 여전히 모킹하거나 다른 방식으로 갈아 끼워야 합니다. 이건 생각보다 큰 제약입니다. Aiven의 셋업을 다시 보세요 — S3가 아니라 MinIO, 관리형 DB가 아니라 Postgres 컨테이너입니다. 즉 당신이 시뮬레이션하는 것은 프로덕션이 아니라 프로덕션의 모형이고, S3의 실제 일관성 동작이나 관리형 서비스의 실제 장애 양상은 모형의 충실도만큼만 반영됩니다.

상태 공간 탐색이 진짜 병목입니다. WarpStream의 160시간 정체 지점이 이걸 보여 줍니다. 어느 시점 이후로 시뮬레이터는 새 동작을 못 찾고, 그때부터 필요한 건 더 많은 시간이 아니라 더 나은 워크로드와 더 날카로운 불변식입니다. 이건 사람이 하는 설계 작업이고, 자동화되지 않습니다.

공짜가 아닙니다. Antithesis는 상용 제품입니다. 공개된 가격표를 확인하지 못했으므로 여기서 숫자를 지어내진 않겠지만, 24/7 시뮬레이션에 컴퓨트가 든다는 사실 자체는 분명합니다. 참고로 Aiven도 Antithesis를 쓰기 전에 이미 축소판 DST를 돌리고 있었고 — 프로듀서 요청 수, 메시지 도착 시각, 페이로드 모양 같은 입력을 무작위화하는 속성 기반 writer 테스트 — 그게 도움이 됐다고 밝힙니다. 다만 그런 테스트를 위해 꽤 많은 코드를 직접 쓰고 유지해야 했고 군데군데 어색했다고 덧붙입니다. 사다리에는 여러 칸이 있습니다.

그리고 결과가 사람 문제로 끝날 수 있습니다. KAFKA-19880 절에서 본 그대로입니다.

그래서, 당신은 써야 하나

값을 할 가능성이 높은 경우

  • 동시성·상태·조정이 본질인 시스템입니다. Antithesis 문서가 드는 예는 분산 데이터베이스, 금융 트랜잭션 엔진, 분산 인프라, 블록체인·합의 프로토콜, 비동기 워크플로입니다. (복식부기 원장처럼 불변식이 선명한 도메인이 특히 잘 맞습니다.)
  • 재현 안 되는 프로덕션 이상 현상이 이미 당신을 괴롭히고 있습니다.
  • 불변식을 문장으로 적을 수 있습니다. "쓴 건 읽힌다", "오프셋은 단조 증가한다" 같은 것이요. 이게 안 되면 DST를 켜도 잡을 게 없습니다.
  • 예제 기반 테스트와 결정론적 CI를 이미 잘 하고 있고, 그 위를 넘어서야 합니다.

과잉인 경우

  • 평범한 CRUD 애플리케이션과 대부분의 웹 서비스입니다. 여기서 나오는 버그는 대개 인터리빙 버그가 아니라 로직 버그이고, 로직 버그는 훨씬 싼 도구로 잡힙니다.
  • 아직 단위·통합 테스트 기본기가 없습니다. DST는 그 위에 얹는 층이지 대체재가 아닙니다.
  • 시스템의 핵심 가치가 외부 SaaS 호출에 있습니다. 결정론을 위해 그걸 전부 모킹하고 나면, 정작 테스트하려던 대상이 사라집니다.
  • 시간이 없어서 불변식을 대충 두 줄만 적을 참입니다. 그러면 "버그 0건"이 나올 텐데, 그건 Aiven이 경고한 바로 그 가장 의심스러운 결과입니다.

사다리를 낮은 칸부터 올라가는 걸 권합니다. 시드를 고정한 속성 기반 테스트 → 직접 만든 축소판 시뮬레이터나 결정론적 폴트 주입 → 그래도 못 잡는 게 남아 있고 그 버그의 기대 비용이 충분히 클 때 결정론적 하이퍼바이저. Aiven이 실제로 밟은 순서가 이것이었습니다.

마치며

정리하면 이렇습니다. DST는 비결정성의 출처를 시뮬레이터 안에 가두어, 희귀한 인터리빙을 의도적으로 탐색하고 찾은 것을 시드로 재현합니다. 값은 "버그를 더 많이 찾는다"보다 "찾은 버그를 다시 부를 수 있다"에 있습니다. KAFKA-19880이 결정론적 유닛 테스트 20줄로 축소된 게 그 증거입니다.

동시에 이 사례는 이 기술의 현재를 꽤 정직하게 보여 줍니다. 2,200 논리 시간을 돌린 자기 코드에서는 아무것도 안 나왔고, 대조군으로 돌린 성숙한 업스트림에서 하나가 나왔고, 그건 8개월째 Minor / Open이고, 인접한 계열의 이슈는 2022년에 보고돼 2024년에 Won't Fix로 닫혔습니다. 이게 실패담이라는 뜻이 아닙니다. 도구가 실제로 일하는 모습이 원래 이렇게 생겼다는 뜻입니다.

그러니 도구를 먼저 고르지 마세요. 당신 시스템에서 절대 일어나면 안 되는 일을 한 문장으로 적는 것부터 하세요. 그 문장이 안 나오면 어떤 시뮬레이터도 당신을 구해 주지 못하고, 그 문장이 나온다면 — 놀랍게도 이미 절반은 온 겁니다.

참고 자료