Claude Code로 클린룸 Z80 / ZX Spectrum 에뮬레이터 구현하기
Anthropic은 최근 Opus의 최신 버전인 4.6에게 “클린룸” 환경에서 Rust로 C 컴파일러를 작성하도록 지시한 실험에 대한 설명을 담은 블로그 포스트를 공개했습니다.
실험 방법론을 보고 그들이 어떤 점을 주장하려는 것인지 의문이 들었습니다. 왜 에이전트에게 ISA 문서를 제공하지 않았을까요? 왜 하필 Rust일까요? C 컴파일러를 작성하는 일은 그야말로 거대한 그래프 조작 작업입니다. Rust로 작성하기가 더 어려운 종류의 프로그램이죠. 또한 클린룸 실험이라면 에이전트는 최적화 컴파일러와 관련된 잘 정립된 컴퓨터 과학적 성과에 대한 모든 정보에 접근할 수 있어야 합니다. 마크다운 파일 몇 개로 쉽게 정리할 수 있는 논문들이 수없이 많습니다. SSA, 레지스터 할당, 명령어 선택과 스케줄링 같은 것들입니다. 그런 것들은 전제 조건으로서 *먼저* 연구되었어야 했고, 그렇게 해도 구현은 여전히 “클린룸”이라 할 수 있습니다.
에이전트가 인터넷이나 다른 컴파일러 소스 코드에 접근하지 못하도록 한 것은 확실히 올바른 결정이었습니다. 덜 이해되는 부분은 거의 제로에 가까운 스티어링 원칙이지만, 목표가 대규모 프로젝트를 완전히 자율적으로 작성하는 모습을 보여주는 것이었다면 이는 특정 종류의 실험으로서는 일관성이 있습니다. 하지만 우리는 이것이 대부분의 경우 코딩 에이전트가 실제로 사용되는 방식이 아니라는 것을 잘 알고 있습니다. 코딩 에이전트를 집중적으로 사용해 본 사람이라면 코드를 직접 건드리지 않더라도 여기저기서 몇 번의 개입만으로 결과물의 품질이 완전히 달라진다는 것을 아주 잘 알고 있습니다.
Z80 실험
비슷한 실험을 직접 한 번 시도해 볼 때가 됐다고 생각했습니다. 길어야 한두 시간이 걸리고 제 Claude Code Max 플랜으로도 가능한 실험이었죠. 저는 제가 생각하기에 “클린룸” 설정으로 더 말이 되는 조건에서 Z80 에뮬레이터를, 그리고 이어서 ZX Spectrum 에뮬레이터를 (나아가 CP/M 에뮬레이터까지, 자세한 내용은 후술합니다) 작성하기로 했습니다. 결과물은 여기에서 확인할 수 있습니다: https://github.com/antirez/ZOT.
내가 사용한 과정
원하는 작업에 대한 사양을 담은 마크다운 파일을 작성했습니다. 그냥 영어로, 구현할 Z80 에뮬레이터의 범위에 대한 높은 수준의 아이디어들이었습니다. 예를 들어 이런 내용을 적었습니다. 에뮬레이터는 단일 클록 단계가 아니라 한 번에 하나의 명령어 전체를 실행해야 한다. 이 에뮬레이터는 RP2350 같은 자원이 제한적인 하드웨어에서도 구동될 수 있어야 하기 때문이다. 에뮬레이터는 경과된 클록 사이클을 정확히 추적해야 하며 (이 기능은 나중에 메모리 접근 중 ULA와의 ZX Spectrum 컨텐션을 구현하는 데 활용할 수 있다고 명시했습니다), 메모리 접근 콜백을 제공해야 하고, Z80의 알려진 모든 공식 및 비공식 명령어를 에뮬레이트해야 한다.
후속 단계로 진행한 Spectrum 구현을 위해서는 마크다운 파일에 훨씬 더 많은 정보를 담았습니다. 예를 들어 RGB 버퍼에서 원하는 렌더링 방식, 그리고 임베디드 장치가 ST77xx 디스플레이(또는 유사한 디스플레이)로 전송하면서 스캔라인을 직접 렌더링할 수 있도록 이 기능이 선택 사항이어야 한다는 점, 카세트 로딩을 매우 사실적으로 시뮬레이션하기 위해 I/O 포트를 통해 EAR 비트를 설정할 수 있어야 한다는 점, 그리고 에뮬레이터에 대해 제가 바라는 여러 다른 요구사항들이었습니다.
이 파일에는 에이전트가 따라야 할 규칙들도 포함되어 있었습니다. 예를 들면 다음과 같습니다.
- 인터넷 접근은 금지되지만, ./z80-specs 안에 넣어 둔 사양 및 테스트 벡터 파일은 사용할 수 있다.
- 코드는 단순하고 깔끔해야 하며, 절대 과도하게 복잡하게 만들지 않는다.
- 확실한 진전이 있을 때마다 git 저장소에 커밋한다.
- 커밋하기 전에 생성한 결과물이 고품질이며 정상적으로 동작하는지 테스트한다.
- 기능을 추가할 때마다 상세한 테스트 스위트를 작성한다. 테스트는 중요한 변경이 있을 때마다 다시 실행해야 한다.
- 코드에는 주석을 매우 잘 달아야 한다. Z80이나 Spectrum 내부 구조의 특정 세부 사항에 익숙하지 않은 사람도 이해할 수 있는 수준으로 설명해야 한다.
- 프롬프트를 기다리며 멈추지 않는다. 사용자는 자리를 비웠다.
- 이 파일의 끝에 진행 상황 로그를 만들고, 이미 완료한 것과 아직 부족한 것을 기록한다. 이 로그는 항상 업데이트한다.
- 컨텍스트가 압축될 때마다 이 파일을 다시 읽는다.
그다음 Claude Code 세션을 시작해 Z80에 관한 인터넷상의 유용한 문서들을 모두 가져오도록 하고 (나중에는 Spectrum에 대해서도 동일하게 했습니다), 유용한 사실 정보만을 마크다운 파일로 추출하도록 했습니다. 또한 Z80용으로 가장 까다로운 테스트 벡터의 바이너리 파일, ZX Spectrum ROM, 그리고 에뮬레이터가 실제로 코드를 올바르게 실행하는지 테스트하는 데 사용할 수 있는 몇 가지 다른 바이너리 파일도 제공했습니다. 이 모든 정보가 수집되면 (저장소의 일부이므로 생성된 내용을 직접 확인할 수 있습니다) 검색 과정에서 본 소스 코드로 인한 오염이 전혀 없도록 Claude Code 세션을 완전히 삭제했습니다.
새 세션을 시작해 사양 마크다운 파일과 사용 가능한 모든 문서를 확인한 뒤 Z80 에뮬레이터 구현을 시작하도록 했습니다. 규칙은 어떤 이유로도 인터넷에 접속하지 않는 것 (코드를 구현하는 동안 에이전트를 감독하여 그런 일이 일어나지 않도록 했습니다), 그리고 이것이 “클린룸” 구현인 만큼 디스크에서 유사한 소스 코드를 검색하지 않는 것이었습니다.
Z80 구현에서는 스티어링을 전혀 하지 않았습니다. Spectrum 구현에서는 TAP 로딩을 구현하는 과정에서 광범위한 스티어링을 했습니다. 에이전트에 대한 제 피드백에 대해서는 글 후반부에서 더 자세히 다루겠습니다.
마지막 단계로 저장소를 /tmp에 복사하고 “.git” 저장소 파일을 완전히 제거한 뒤, 새로운 Claude Code(및 Codex) 세션을 시작해 해당 구현이 다른 사람의 작업을 도용했거나 너무 강하게 참고했을 가능성이 있다고 주장했습니다. 과제는 주요 Z80 구현들과 비교해 도용의 증거가 있는지 확인하는 것이었습니다. 에이전트들(Codex와 Claude Code 모두)은 광범위한 검색 끝에 저작권 문제의 증거를 전혀 찾지 못했습니다. 유일하게 유사한 부분은 잘 정립된 에뮬레이션 패턴과 Z80 고유의, 달리 구현할 수 없는 부분에 관한 것이었고, 구현은 다른 모든 구현과 유의미한 수준에서 뚜렷이 구분되었습니다.
결과
Claude Code는 총 20~30분 정도 작업하여 ZEXDOC와 ZEXALL을 통과하는 Z80 에뮬레이터를 만들어 냈습니다. 매우 읽기 쉽고 주석이 잘 달린 1,200줄의 C 코드(주석과 공백 포함 시 1,800줄)였습니다. 에이전트는 구현 도중 프롬프트를 한 번도 받지 않았으며, 완전히 혼자서 동작했습니다. 인터넷에 접속한 적도 없으며, 에뮬레이터를 구현하는 데 사용한 과정은 지속적인 테스트의 연속이었습니다. ZEXDOC와 ZEXALL을 구현하는 CP/M 바이너리와 상호작용하고, 화면에 출력을 생성하는 데 필요한 CP/M 시스템 콜만을 작성했습니다. 여러 차례에 걸쳐 Spectrum ROM과 사용 가능한 다른 바이너리를 활용하거나, 에뮬레이터가 올바르게 동작하는지 확인하기 위해 처음부터 직접 만든 바이너리를 사용하기도 했습니다. 요컨대 구현은 가중치로부터 전체 구현을 “압축 해제”하듯 한 번에 출력한 것이 아니라, 인간 프로그래머가 하는 방식과 매우 유사한 방식으로 수행되었습니다. 대신 여러 종류의 명령어를 점진적으로 구현했고, 통합 테스트와 디버깅 세션, 덤프, printf 호출 등을 통해 버그를 수정해 나갔습니다.
다음 단계: ZX Spectrum
과정을 다시 한 번 반복했습니다. 문서 수집 세션에 인터넷에서 찾아오길 원하는 세부 정보의 종류를 매우 정확하게 지시했습니다. 특히 RAM 접근과 ULA의 상호작용, 키보드 매핑, I/O 포트, 카세트 테이프의 동작 방식과 사용된 PWM 인코딩의 종류, 그리고 그것이 TAP이나 TZX 파일로 어떻게 인코딩되는지 같은 내용이었습니다.
앞서 말했듯이 이번에는 설계 노트가 매우 상세했습니다. 이 에뮬레이터를 임베디드 시스템을 위해 특별히 설계하고 싶었기 때문입니다. 그래서 48K 에뮬레이션만 지원하고, 프레임버퍼 렌더링은 선택 사항으로 하며, 추가 메모리 사용을 매우 적게 하고(ULA/Z80 접근 컨텐션용 큰 룩업 테이블 없음), 추가 16K 메모리를 사용하지 않도록 ROM을 RAM에 복사하지 않고 초기화 시에만 참조하며(그래서 실행 파일 안에만 하나의 복사본이 존재합니다), 기타 등등의 내용들이었습니다.
에이전트는 ZX Spectrum 내부 구조에 대한 매우 상세한 문서를 만들 수 있었습니다. 실제 소프트웨어로 실제 환경에서 에뮬레이터를 테스트할 수 있도록 몇 개의 .z80 게임 이미지도 제공했습니다. 다시 세션을 삭제하고 새로 시작했습니다. 에이전트는 작업을 시작해 10분 만에 끝냈습니다. 그 과정은 저를 정말 매료시켰는데, 아마 여러분도 잘 알고 있을 겁니다. 에이전트가 다양한 기술을 사용해 작업하는 모습을 보게 된다는 점입니다. 에이전트는 프로그래밍과 관련된 모든 것에 능통하기 때문에, 에뮬레이터를 구현하면서 Z80이 단계별로 무엇을 하고 있는지, 그리고 그것이 Spectrum 에뮬레이션 상태를 어떻게 바꾸는지 “들여다보기” 위한 상세한 계측 코드를 즉시 작성할 수 있었습니다. 이런 점에서 저는 자동 프로그래밍이 이미 초인적이라고 생각합니다. 현재 인간이 만들 수 없는 코드를 생성할 수 있다는 의미가 아니라, 서로 다른 프로그래밍 언어와 시스템 프로그래밍 기법, DSP 관련 지식, 운영체제 트릭, 수학 등 결과에 가장 빠르게 도달하는 데 필요한 모든 것을 동시에 활용한다는 의미에서 그렇습니다.
작업이 끝나면 간단한 SDL 기반 통합 예제를 작성해 달라고 요청했습니다. 에뮬레이터는 Jetpac 게임을 즉시 아무 문제 없이 실행할 수 있었고, 사운드도 정상적으로 동작했으며, 제 느린 Dell Linux 머신에서도 CPU 사용량이 매우 적었습니다(SDL 렌더링 포함 단일 코어 사용량 8%).
기본적인 기능이 동작하게 된 뒤에는 카세트 로딩을 시뮬레이션하면서 TAP 파일을 직접 로드하고 싶었습니다. 이때 에이전트가 처음으로 몇 가지를 놓쳤는데, 특히 Spectrum 로딩 루틴이 기대하는 타이밍에 관한 부분이었습니다. 여기서부터는 LLM의 효율이 떨어지기 시작하는 영역입니다. SDL 에뮬레이터를 직접 실행해 데이터가 수신될 때 보더가 바뀌는 모습을 보는 등의 일을 쉽게 할 수 없기 때문입니다. 저는 Claude Code에게 zx_tick()을 직접 호출할 수 있도록, 그리고 zx_frame()의 일부가 아니도록 리팩터링하고, zx_frame()은 단순한 래퍼가 되게 해 달라고 요청했습니다. 이렇게 하니 에이전트가 구현했던 콜백이나 잘못된 추상화 없이도 EAR를 기대하는 값에 맞춰 동기화하는 것이 훨씬 간단해졌습니다. 그런 변경 후 몇 분 뒤 에뮬레이터는 카세트를 에뮬레이션하며 TAP 파일을 문제없이 로드할 수 있었습니다.
현재는 이렇게 동작합니다:
do {
zx_set_ear(zx, tzx_update(&tape, zx->cpu.clocks));
} while (!zx_tick(zx, 0));이후에도 키 바인딩을 더 유용하게 만드는 등 몇 가지 개선을 위해 Claude Code에 계속 프롬프트를 전달했습니다.
CP/M
제가 정말 흥미롭다고 느낀 것 중 하나는 LLM이 Z80용 ZEXALL / ZEXCOM 테스트의 COM 파일을 검사해 사용된 CP/M 시스템 콜(총 세 개)을 쉽게 찾아내고, 확장된 z80 테스트(make fulltest로 실행)용으로 이를 구현하는 능력이었습니다. 그렇다면 이 시점에서 완전한 CP/M 환경을 구현하지 않을 이유가 있을까요? 같은 과정을 다시 거쳤고, 몇 분 만에 동일하게 좋은 결과를 얻었습니다. 이번에는 VT100 / ADM3 터미널 이스케이프 변환 부분에서 조금 더 상호작용했고, 처음에 WordStar에서 동작하지 않던 부분들을 보고했으며, 몇 분 만에 제가 테스트한 모든 것이 충분히 잘 동작했습니다(다만 수정할 부분은 남아 있습니다. 예를 들어 2Mhz 클록을 시뮬레이션해야 하는데, 현재는 최대 속도로 실행되어 CP/M 게임을 사용할 수 없습니다).
여기서 얻을 수 있는 교훈은 무엇일까요?
당연한 교훈은 에이전트에게 앞으로 할 일에 대한 설계 힌트와 방대한 문서를 항상 제공하라는 것입니다. 그런 문서는 에이전트 스스로가 얻을 수도 있습니다. 그리고 에이전트가 코딩 작업을 수행하는 방법에 대한 규칙과, 자신이 하고 있는 일에 대한 추적 기록이 담긴 마크다운 파일을 가지고 있고, 그 기록이 자주 업데이트되고 다시 읽히도록 하라는 것입니다.
하지만 이런 요령들은 최근 몇 달간 자동 프로그래밍을 집중적으로 다뤄 본 사람이라면 누구나 꽤 명확하게 알고 있다고 생각합니다. “인간에게 무엇이 필요할까”라는 관점에서 생각하는 것이 종종 가장 좋은 방법이며, 여기에 컨텍스트 압축 이후의 망각 문제나 올바른 방향으로 가고 있는지 지속적으로 검증할 수 있는 능력 같은 LLM 특유의 몇 가지 요소가 더해집니다.
Anthropic의 컴파일러 시도로 돌아가 보겠습니다. 에이전트가 실패한 단계 중 하나는 사전 학습 세트에 있는 내용을 암기한다는 아이디어와 더 강하게 관련된 단계였습니다. 바로 어셈블러였죠. 방대한 문서가 제공된다면 Claude Code가 (그리고 제 경험상 복잡한 작업에서 더욱 유능한 GPT-5.3-codex는 더더욱) 동작하는 어셈블러를 만드는 데 실패할 이유를 전혀 찾을 수 없습니다. 어셈블러는 꽤 기계적인 과정이기 때문입니다. 이는 LLM이 전체 학습 세트를 암기하고 본 것을 압축 해제한다는 생각과는 모순된다고 생각합니다. LLM은 과도하게 많이 등장한 특정 문서나 코드를 암기할 수 있지만, 그렇게 하도록 프롬프트를 받으면 해당 코드의 축어적인 부분을 추출할 수는 있어도, 학습 세트에서 본 모든 것에 대한 복사본을 가지고 있는 것도 아니고, 일반적인 동작에서 이미 본 코드의 복사본을 자발적으로 내놓지도 않습니다. 우리는 대체로 LLM에게 자신이 가진 서로 다른 지식을 조합해야 하는 작업을 만들도록 요구하며, 그 결과는 대개 알려진 기법과 패턴을 사용하지만 기존 코드의 복사본이 아닌 새로운 코드입니다.
한 가지 더 언급할 가치가 있는 점은, 인간은 이 블로그 포스트에서 자세히 설명한 클린룸 규칙보다 훨씬 덜 엄격한 과정을 따르는 경우가 많다는 것입니다. 즉, 인간은 자신이 달성하려는 것과 관련된 여러 구현의 코드를 다운로드해 꼼꼼히 읽어본 뒤, 그대로 복사하는 것은 피하려 하지만 종종 강한 영감을 받는다는 것이죠. 저는 이 과정이 충분히 용인될 수 있다고 생각하지만, 인간이 작성한 코드의 현실에서 실제로 일어나는 일을 염두에 두는 것이 중요합니다. 결국 정보 기술이 그토록 빠르게 발전할 수 있었던 것도 이런 대규모 교차 수분 효과 덕분이었습니다.
이러한 모든 이유로, 저는 자동 프로그래밍을 이용해 코드를 구현할 때 이번 Z80 프로젝트에서 그랬던 것처럼 MIT 라이선스로 공개하는 데 아무런 문제가 없다고 생각합니다. 그리고 이 코드베이스는 오픈 웨이트 모델을 포함한 차세대 LLM 학습을 위한 양질의 입력이 될 것입니다.
다음 단계
제 실험을 더 설득력 있게 만들려면, 에이전트에게 아무 문서도 제공하지 않고 Z80 및 ZX Spectrum 에뮬레이터를 구현해 본 뒤 그 결과를 비교해 봐야 할 것입니다. 저는 그럴 시간을 찾지 못했지만, 꽤 유익한 시도가 될 수 있을 것입니다.
글을 무작위로 읽기