Implementing a clear room Z80 / ZX Spectrum emulator with Claude Code

Salvatore Sanfilippo

Claude Code로 클린룸 Z80 / ZX Spectrum 에뮬레이터 구현하기

원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기

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.

내가 사용한 과정

  1. 나는 내가 하고 싶은 일에 대한 명세가 담긴 마크다운 파일을 작성했다. 그냥 영어로, 구현할 Z80 에뮬레이터의 범위에 대한 개략적인 아이디어였다. 예를 들어 이런 내용을 적었다. 이 에뮬레이터는 RP2350 같은 제한적인 하드웨어에서도 동작해야 하므로 클록 한 틱 단위가 아니라 한 번에 하나의 명령어 전체를 실행해야 한다고. 에뮬레이터는 경과한 클록 사이클을 정확히 추적해야 하고(이 기능은 나중에 메모리 접근 중 ULA와의 ZX Spectrum 경합을 구현하는 데 활용할 수 있다고 명시했다), 메모리 접근 콜백을 제공해야 하며, Z80의 알려진 공식 및 비공식 명령어를 모두 에뮬레이트해야 한다고.

    Spectrum 구현은 후속 단계로 진행했는데, 마크다운 파일에 훨씬 더 많은 정보를 담았다. 예를 들어 RGB 버퍼에서 원하는 렌더링 방식과, 임베디드 기기가 스캔라인을 ST77xx 디스플레이(혹은 유사한 디스플레이)로 전송하면서 바로 렌더링할 수 있도록 이 기능은 선택 사항이어야 한다는 점, 카세트 로딩을 매우 사실적으로 시뮬레이션하기 위해 I/O 포트를 통해 EAR 비트를 설정할 수 있어야 한다는 점, 그리고 에뮬레이터에 대해 내가 가진 다른 많은 요구 사항들 같은 것들이다.

    이 파일에는 에이전트가 따라야 할 규칙도 포함됐다. 예를 들면 다음과 같다:

    • 인터넷 접속은 금지되지만, ./z80-specs 안에 넣어둔 명세와 테스트 벡터 파일은 사용할 수 있다.
    • 코드는 단순하고 깔끔해야 하며, 절대 과도하게 복잡하게 만들지 않는다.
    • 의미 있는 진전이 있을 때마다 git 저장소에 커밋한다.
    • 커밋하기 전에 자신이 만든 결과물이 고품질이며 정상 동작하는지 테스트한다.
    • 기능을 추가하면서 상세한 테스트 스위트를 작성한다. 큰 변경이 있을 때마다 테스트를 다시 실행해야 한다.
    • 코드에는 주석을 매우 잘 달아야 한다. Z80이나 Spectrum 내부 구조에 익숙하지 않은 사람도 이해할 수 있는 방식으로 설명해야 한다.
    • 프롬프트를 기다리며 멈추지 않는다. 사용자는 키보드 앞에 없다.
    • 이 파일 끝에 진행 중인 작업 로그를 만들고, 이미 한 일과 남은 일을 기록한다. 항상 이 로그를 업데이트한다.
    • 컨텍스트 압축이 일어날 때마다 이 파일을 다시 읽는다.
  2. 그런 다음 Claude Code 세션을 시작해 인터넷에서 Z80에 관한 유용한 문서(나중에 Spectrum에 대해서도 동일하게 했다)를 모두 가져와 유용한 사실 정보만 마크다운 파일로 추출하도록 요청했다. 또한 Z80용으로 가장 까다로운 테스트 벡터들의 바이너리 파일과 ZX Spectrum ROM, 그리고 에뮬레이터가 코드를 제대로 실행하는지 테스트하는 데 사용할 수 있는 몇 가지 다른 바이너리도 제공했다. 이 모든 정보가 수집되면(저장소에 포함되어 있으니 직접 생성된 내용을 확인할 수 있다) 검색 중에 본 소스 코드로 인한 오염이 없도록 Claude Code 세션을 완전히 삭제했다.

  3. 새 세션을 시작해 명세 마크다운 파일과 사용 가능한 모든 문서를 확인한 뒤 Z80 에뮬레이터 구현을 시작하도록 요청했다. 규칙은 어떤 이유로도 인터넷에 접속하지 않는 것(구현 중에 에이전트를 감독하며 실제로 그러지 않는지 확인했다)과, 이것이 “클린룸” 구현인 만큼 디스크에서 유사한 소스 코드를 검색하지 않는 것이었다.

  4. Z80 구현에서는 스티어링을 전혀 하지 않았다. Spectrum 구현에서는 TAP 로딩을 구현할 때 광범위한 스티어링을 사용했다. 에이전트에 대한 내 피드백에 대해서는 글 뒤에서 더 자세히 다루겠다.

  5. 마지막 단계로 저장소를 /tmp에 복사한 뒤 “.git” 저장소 파일을 완전히 삭제하고, 새로운 Claude Code(와 Codex) 세션을 시작해 이 구현이 아마도 다른 사람의 작업을 도용했거나 너무 강하게 참고한 것 같다고 주장했다. 과제는 주요 Z80 구현들과 비교해 도용의 증거가 있는지 확인하는 것이었다. 에이전트들(Codex와 Claude Code 모두)은 광범위한 검색 끝에 저작권 문제의 증거를 전혀 찾지 못했다. 유사한 부분은 이미 잘 정립된 에뮬레이션 패턴과 Z80 특유의, 다르게 만들 수 없는 부분들뿐이었고, 구현은 다른 모든 구현과 유의미하게 구별되는 것으로 보였다.

결과

Claude Code는 총 20~30분 정도 작업해 1,200줄의 매우 가독성 높고 주석이 잘 달린 C 코드(주석과 공백 포함 1,800줄)로 ZEXDOC와 ZEXALL을 통과하는 Z80 에뮬레이터를 만들어냈다. 에이전트는 구현 중에 프롬프트를 한 번도 받지 않았고, 완전히 혼자 동작했다. 인터넷에 접속한 적도 없으며, 에뮬레이터를 구현한 과정은 가중치에서 완성된 구현을 “압축 해제”하듯 한 번에 출력한 것이 아니라, 인간 프로그래머가 하는 방식과 매우 유사한 지속적인 테스트 과정이었다. 다양한 종류의 명령어가 점진적으로 구현되었고, 통합 테스트와 디버깅 세션, 덤프, printf 호출 등을 통해 버그가 수정되었다.

다음 단계: ZX Spectrum

나는 같은 과정을 다시 반복했다. 에이전트가 인터넷에서 검색하길 원하는 세부 사항의 종류, 특히 메모리 접근 시 ULA와의 상호작용, 키보드 매핑, I/O 포트, 카세트 테이프의 동작 방식과 사용된 PWM 인코딩 방식, 그리고 그것이 TAP나 TZX 파일로 어떻게 인코딩되는지에 대해 문서 수집 세션에 매우 정확하게 지시했다.

말했듯이 이번에는 이 에뮬레이터가 임베디드 시스템을 위해 특별히 설계되길 원했기 때문에 설계 노트가 매우 상세했다. 그래서 48K 에뮬레이션만 지원하고, 프레임버퍼 렌더링은 선택 사항으로 하며, 추가 메모리 사용을 최소화하고(ULA/Z80 접근 경합을 위한 큰 룩업 테이블 없음), 추가 16KB 메모리를 쓰지 않도록 ROM을 RAM에 복사하지 않고 초기화 시에만 참조하도록(그래서 실행 파일에만 사본이 존재한다) 하는 식이었다.

에이전트는 ZX Spectrum 내부 구조에 대한 매우 상세한 문서를 만들 수 있었다. 나는 에뮬레이터를 실제 소프트웨어가 있는 실제 환경에서 테스트할 수 있도록 게임의 .z80 이미지 몇 개를 제공했다. 다시 세션을 삭제하고 새로 시작했다. 에이전트는 작업을 시작해 10분 만에 끝냈는데, 그 과정이 정말 매혹적이었고, 아마 여러분도 잘 알고 있을 것이다. 요점은 에이전트가 다양한 기술을 활용해 작업하는 것을 보는 것이다. 프로그래밍과 관련된 모든 것에 능통하기 때문에, 에뮬레이터를 구현하면서 Z80이 단계별로 무엇을 하고 있는지, 그리고 이것이 Spectrum 에뮬레이션 상태를 어떻게 바꾸는지 “살펴보기” 위한 상세한 계측 코드를 즉시 작성할 수 있었다. 이런 점에서 나는 자동 프로그래밍이 이미 초인적이라고 생각한다. 현재 인간이 만들 수 없는 코드를 만들어낸다는 의미에서의 초인적이 아니라, 결과에 가장 빠르게 도달하기 위해 서로 다른 프로그래밍 언어, 시스템 프로그래밍 기법, DSP 관련 지식, 운영체제 트릭, 수학 등 필요한 모든 것을 동시에 활용한다는 의미에서 그렇다.

작업이 끝나면 SDL 기반 통합 예제를 작성해달라고 요청했다. 에뮬레이터는 즉시 별다른 문제 없이 Jetpac 게임을 실행할 수 있었고, 사운드도 정상 동작했으며, 느린 Dell Linux 머신에서도 SDL 렌더링을 포함해 단일 코어 사용량이 8%에 불과할 정도로 CPU 사용량도 매우 적었다.

기본적인 부분이 동작하게 된 뒤에는 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에서 동작하지 않던 부분들을 보고했으며, 몇 분 만에 내가 테스트한 모든 것이 충분히 잘 동작하게 됐다(다만, 수정해야 할 부분은 남아 있다. 예를 들어 지금은 풀 스피드로 동작하므로 CP/M 게임을 사용할 수 없게 만드는 2Mhz 클록 시뮬레이션 같은 것들이다).

여기서 얻을 수 있는 교훈은 무엇인가?

명백한 교훈은 에이전트에게 하려는 일에 대한 설계 힌트와 광범위한 문서를 항상 제공하라는 것이다. 이런 문서는 에이전트 스스로 얻을 수도 있다. 그리고 에이전트가 코딩 작업을 수행하는 방법에 대한 규칙과, 무엇을 하고 있는지에 대한 추적 기록이 담긴 마크다운 파일을 갖추고, 이를 꽤 자주 다시 읽고 업데이트하도록 하라는 것이다.

하지만 이런 요령들은 최근 몇 달간 자동 프로그래밍을 광범위하게 다뤄본 사람이라면 누구에게나 꽤 명백하다고 생각한다. “인간에게 무엇이 필요할까”라는 관점에서 생각하는 것이 종종 최선의 방법이며, 여기에 컨텍스트 압축 후 망각 문제나 자신이 올바른 방향으로 가고 있는지 지속적으로 검증할 수 있는 능력 같은 LLM 특유의 몇 가지를 더하면 된다.

다시 Anthropic의 컴파일러 시도로 돌아가 보면, 에이전트가 실패한 단계 중 하나는 사전 학습 세트에 있는 내용을 암기한다는 아이디어와 가장 강하게 연결된 부분이었다. 바로 어셈블러였다. 광범위한 문서가 있었다면 Claude Code(그리고 내 경험상 복잡한 작업에서는 더 유능한 GPT5.3-codex)가 동작하는 어셈블러를 만드는 데 실패할 이유가 전혀 보이지 않는다. 어셈블러는 상당히 기계적인 과정이기 때문이다. 이는 LLM이 전체 학습 세트를 암기하고 본 것을 압축 해제해 그대로 내뱉는다는 생각과 모순된다고 생각한다. LLM은 과도하게 많이 등장한 특정 문서와 코드를 암기할 수 있지만, 그렇게 하도록 프롬프트될 때 해당 코드의 verbatim 부분을 추출할 수는 있어도, 학습 중에 본 모든 것의 복사본을 가지고 있지도 않고, 정상적인 동작 중에 이미 본 코드의 복사본을 자발적으로 내뱉지도 않는다. 우리는 대부분 LLM에게 그들이 가진 서로 다른 지식을 조합해야 하는 작업을 만들도록 요청하고, 그 결과는 대개 알려진 기법과 패턴을 사용하지만 기존 코드의 복사본이 아닌 새로운 코드가 된다.

인간이 종종 이 블로그 포스트에 자세히 설명된 클린룸 규칙보다 덜 엄격한 과정을 따른다는 점도 주목할 만하다. 즉, 인간은 자신이 하려는 것과 관련된 여러 구현의 코드를 자주 다운로드해 꼼꼼히 읽은 뒤, 그대로 베끼는 것을 피하려고 노력하면서도 종종 강한 영감을 받는다. 나는 이것이 완벽히 용인될 수 있는 과정이라고 생각하지만, 인간이 작성한 코드의 현실에서 실제로 일어나는 일이 무엇인지 염두에 두는 것이 중요하다. 결국 정보 기술이 그토록 빠르게 발전할 수 있었던 것도 이러한 대규모 교차 수분 효과 덕분이었다.

위와 같은 모든 이유로, 나는 자동 프로그래밍을 이용해 구현한 코드를 이번 Z80 프로젝트처럼 MIT 라이선스로 공개하는 데 아무런 문제가 없다고 생각한다. 이 코드베이스는 오픈 가중치 모델을 포함한 다음 LLM 학습을 위한 양질의 입력이 될 것이다.

다음 단계

내 실험을 더 설득력 있게 만들려면, 에이전트에게 아무 문서도 제공하지 않고 Z80과 ZX Spectrum 에뮬레이터를 구현해보게 한 뒤 그 구현 결과를 비교해야 할 것이다. 그럴 시간을 찾지 못했지만, 꽤 유익한 실험이 될 수 있을 것이다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글