원문 캡처 · coderhapsody.ai
원문 캡처 · coderhapsody.ai

빌 콕스(Bill Cox)는 복잡한 소프트웨어에서 각 사실을 한곳에 두고 객체 간 관계를 양방향으로 완전하게 표현해야 한다고 주장한다. 그는 사람 개발자를 위해 수십 년간 적용해 온 이 원칙을 자율적으로 코드를 작성하는 AI에도 적용하며, 현재 작업에는 C#이 자신의 아키텍처를 가장 직접적으로 표현하는 실용적인 선택이라고 설명한다.

이 글은 통제된 프로그래밍 언어나 코딩 에이전트 벤치마크가 아니라 빌 콕스의 엔지니어링 입장문이다. 전자설계자동화(EDA) 소프트웨어를 만들며 얻은 경험과 아키텍처에 대한 판단, 프로젝트 사례를 바탕으로 한다. 그는 C·C++·Go·Rust의 설계 압력이 자신의 데이터 모델과 어떻게 충돌하는지 설명하고, Go 작업을 감독하며 겪은 사례와 C#으로 바꾼 이유를 제시한다. 글에 나온 성능 수치는 특정 프로젝트의 보고이며 보편적인 성능 보장은 아니다.

복잡한 시스템에서 지키려는 데이터 모델

콕스가 말하는 핵심은 네 가지다. 각 사실은 하나의 권위 있는 표현만 가져야 하고, 객체 간 관계는 명시해야 하며, 생성과 삭제는 그 관계를 완결해야 한다. 알고리즘 모듈은 서로의 구현에 직접 의존하지 않고 공유 어휘를 사용해야 한다. 여기서 공유 어휘란 관계형 데이터베이스 서버가 아니라 시스템의 알고리즘들이 함께 다루는 객체와 사실, 관계의 공통 모델이다. 콕스는 이를 과거에는 database, 현재는 Common이라고 부른다.

그의 비판은 언어가 그래프를 구현할 수 없다는 뜻이 아니다. C가 구조체 배열 외의 표현을 구현할 수 없거나, 모든 C++ 프로그램이 상속을 써야 하거나, Go와 Rust에서 그래프를 만들 수 없다고 주장하지도 않는다. 쟁점은 원하는 구조를 얼마나 직접 표현할 수 있는지, 애플리케이션 문제를 풀기보다 언어의 제약을 우회하는 데 얼마나 많은 노력이 드는지다. 이 글의 범위는 EDA 도구나 Ensemble처럼 한 컴퓨터에서 실행되는 크고 유지보수 가능한 시스템이며, 분산 시스템을 위한 처방은 아니다.

콕스가 문제로 꼽는 언어별 설계 압력은 다음과 같다.

  • C에서는 객체의 논리적 모델과 메모리의 물리적 표현이 얽히기 쉽다. C 호환 인터페이스를 통해 표현 방식이 다른 언어와 세대의 코드로 퍼지면, 데이터 저장 방식을 바꾸는 일이 접근 방식까지 바꾸는 일이 된다.
  • C++에서는 상속 계층을 객체 관계의 대안처럼 다루는 경향을 비판한다. 복잡한 시스템의 부모·자식·협력 객체로 이뤄진 그래프는 타입 계층과 다르며, 알고리즘별 임시 데이터를 공유 객체에 추가하는 문제도 상속만으로 해결되지 않는다.
  • Go에서는 메서드가 수신자 기본 타입을 선언한 패키지에 속해야 하고 패키지 간 순환 임포트가 금지된다는 점이 문제다. 공유 타입을 한곳에서 선언하고 기능 모듈에서 메서드를 구현하려는 구조를 직접 표현하기 어렵다.
  • Rust에서는 여러 부모와 역방향 참조가 있는 가변 그래프를 표현할 때 소유권·대여·참조 카운팅·약한 참조·핸들 등의 장치를 다뤄야 한다고 본다. 그래프 구현이 불가능하다는 주장이 아니라, 관계를 표현하기 전에 메모리 소유 구조를 별도로 설계해야 하는 부담에 대한 비판이다.

콕스는 이런 관행이 1960년대의 희소하고 비싼 메모리와 기계에 가까운 프로그래밍 환경에서 비롯됐다고 본다. C는 1969~1973년에 개발됐으며, 데니스 리치(Dennis Ritchie)는 1972년을 개발의 가장 창의적인 시기로 꼽았다.[1] C는 어셈블리에서 벗어나면서도 메모리 제어와 효율적인 실행을 가능하게 한 중요한 성과였다. 다만 구체적인 구조체 표현이 바이너리 인터페이스를 넘어 다른 코드에 노출되면, 바이트를 배치하는 선택이 이후 설계의 제약으로 굳어질 수 있다. 콕스가 때때로 C 링커를 “모든 악의 근원”이라고 부르는 것도 링커가 모든 대안적 배치를 막는다는 뜻이 아니라, C 호환 인터페이스를 통해 표현 계약이 이어지는 문제를 가리킨다. 그와 동료들은 데이터 접근을 추상화한 덕분에 C에서도 다른 표현을 구현할 수 있었다고 회고한다.

그가 정규화에서 가져온 교훈은 같은 사실의 사본을 서로 독립적으로 유지하지 말라는 것이다. 이는 제3정규형의 형식적 정의를 이 글에서 적용한다는 뜻이 아니라, 중복된 권위 데이터를 만들지 말라는 아키텍처 원칙이다. 널 값, 메모리 회수, 부모에서 자식으로만 내려가고 자식에서 부모로 돌아오는 길이 없는 구조도 서로 관련은 있지만 같은 문제는 아니다. 널 가능성을 명시하거나 가비지 컬렉션을 사용해도, 자식이 삭제될 때 모든 부모에게 알리고 각 관계를 정리하는 일은 자동으로 해결되지 않는다. “누가 자식이 삭제될 때 모든 부모에게 알리는가?”라는 질문에 “프로그램의 다른 부분이 기억할 것”이라고 답한다면 데이터 모델이 불완전하다는 것이 콕스의 주장이다.

QuickLogic에서 배운 허브와 스포크 구조

콕스는 경력 초기에 FPGA의 배치·배선 알고리즘을 다뤘고, QuickLogic에서 Cadence 출신 프로그래머가 가져온 아키텍처를 사용했다고 회고한다. 모든 알고리즘이 조작하는 객체와 사실, 관계를 공통 모델에 모으고, 각 기능 모듈은 그 모델에 의존하되 서로의 구현에는 직접 의존하지 않는 방식이다. 최상위 프로그램은 GUI나 명령줄 인터페이스를 제공하면서 모듈을 구성하고 조정한다. 새 프로그래머가 공통 데이터 구조를 이해하면 시스템 전체의 어휘를 익힐 수 있었다고 한다. 1990년부터 1995년 무렵까지는 기반 구조를 거듭 다시 설계하기보다 알고리즘에 집중할 수 있었다고 설명한다.

공유 모델은 데이터가 특정 모듈의 실행 뒤에도 남거나 여러 모듈이 이해해야 할 때 그 표현을 맡는다. 라우터와 타이밍 분석기가 같은 배선에 대해 서로 다른 권위 데이터를 만들어서는 안 된다. 종속성은 기능 모듈에서 공통 모듈을 향하고, 공통 모듈은 기능 모듈을 임포트하지 않는다. 이는 코드 의존성의 방향이며, 실행 중 객체의 소유 관계와는 별개다.

콕스의 데이터 모델은 트리가 아니라 그래프다. 엣지는 출발 노드와 도착 노드라는 두 부모 관계를 가지며, 생성자는 두 노드와 양쪽의 관계를 설정한다. 관계를 표현하는 객체에서 A가 관계의 일부로 B를 가리킨다면 B에도 A로 향하는 대응 관계가 있어야 한다. 이는 선택적인 최적화가 아니라 공통 관계 모델의 일부다. 값 객체는 뒤에서 설명하는 명시적 예외다.

엣지 생성자는 출발점과 도착점을 받아 해당 노드의 컬렉션에 엣지를 등록한다. 엣지의 논리적 소멸자는 양쪽 컬렉션에서 자신을 제거한다. 노드가 삭제될 때는 모델에 따라 입력·출력 엣지를 정리한 뒤 자신을 소유 그래프에서 제거한다. 공유된 순회 경로 때문에 같은 엣지를 두 번 삭제하지 않아야 하며, 논리적으로 제거된 객체를 부모가 계속 관계로 보유해서도 안 된다. 객체가 부모를 하나만 가져야 한다는 뜻은 아니다. 객체는 모든 부모를 알고 각 부모에게서 분리되며, 강한 소유 관계에 있는 자식은 그 관계의 의미에 따라 삭제한다.

책임은 나뉜다. 자식은 알릴 부모를 알고, 부모에 선언된 연산을 호출한다. 각 부모는 자신의 관계 컬렉션을 수정한다. 자식이 임의로 부모의 컬렉션을 직접 고쳐서는 안 되고, 다른 코드가 나중에 관계를 찾아 복구하리라 기대해서도 안 된다. Go나 C# 같은 가비지 컬렉션 언어에서 논리적 삭제는 메모리 회수와 다르다. 가비지 컬렉터는 도달할 수 없는 메모리를 회수할 수 있지만 애플리케이션의 관계 정리를 수행하지 않는다. 이 글에서 “소멸자”는 명시적인 논리적 삭제 연산을 뜻하며 C# 파이널라이저를 뜻하지 않는다.

역방향 참조는 삭제뿐 아니라 필요한 데이터와 서비스를 찾는 데도 쓰인다. 객체만 받은 함수가 기록할 상황을 만나면 객체의 실제 부모 관계를 따라 애플리케이션 맥락과 로거를 찾아야 한다는 것이 콕스의 제안이다. 로거 필드를 복사하거나 전역 검색을 하거나 콜백 묶음을 추가하거나, 누락된 관계 때문에 모든 중간 함수에 새 컨텍스트 인자를 전달하는 방식은 기본 해결책이 되어서는 안 된다. 그렇다고 모든 프로그램에서 전역 변수를 금지한다는 뜻은 아니다. 접근이 어렵다면 호출자에 배선을 덧붙이기 전에 데이터 모델과 선언된 연산부터 살펴야 한다고 말한다.

벡터·행렬·심벌처럼 다른 객체가 참조하는 모든 곳을 알 필요가 없는 값 객체는 예외다. Lyric에서는 참조 카운팅으로, Go나 C#에서는 가비지 컬렉션으로 관리할 수 있다고 설명한다. 값 객체 타입은 주석으로 그 역할을 명시해야 한다. 컴파일러 표현식처럼 트리를 이루면서 문장 등 다른 구조에서도 참조되는 타입은 관계 객체로 모델링해 역방향 참조와 명시적 삭제를 둘 수도 있고, 관리되는 회수를 사용하는 값 객체로 명시적으로 선택할 수도 있다. 어느 쪽이든 일관되게 결정해야 하며, 언어가 가비지 컬렉션을 제공한다는 이유만으로 관계 객체가 값 객체가 되는 것은 아니다.

별도로, 알고리즘이 실행되는 동안에만 쓰이고 그 알고리즘이 사용하는 객체가 삭제될 수 없는 임시 캐시는 공통 모델의 지속적 관계가 아닐 수 있다. 이는 알고리즘의 국소적인 수명 조건이지, 공유 관계의 역방향 링크를 생략하거나 공유 상태의 권위 사본을 하나 더 만드는 허가가 아니다.

표현 방식과 성능 수치의 범위

QuickLogic에서는 관계를 침입형 연결 리스트로 표현했다. 링크를 별도 리스트 노드에 두지 않고 자식 객체 안에 두는 방식이며, 여러 관계에 참여하는 객체에는 필요한 링크 필드가 들어갔다. 콕스는 해당 시스템에서 측정한 실제 작업 부하를 근거로 이 방식이 효율적이었다고 회고한다. 배열이나 리스트 중 하나가 항상 빠르다는 보편적 가정이 근거는 아니다.

그가 기억하는 QuickLogic 공통 구조에서는 메모리의 약 40%가 포인터 표현에 쓰였다. 설계가 32비트 주소 공간의 편안한 한계를 넘어서면서 64비트 포인터로 옮기자 부담이 늘었다. 아키텍처를 도입한 프로그래머는 데이터 접근을 추상화하도록 했고, C 매크로가 그 분리를 유지했다. 덕분에 알고리즘마다 물리적 배치에 의존하지 않은 채 배열의 배열(AoS)에서 구조체의 배열(SoA)로 표현을 바꿀 수 있었다.

AoS는 한 객체의 필드를 모아 저장하고, SoA는 각 필드에 해당하는 값을 객체들끼리 모아 저장한다. 또한 객체 공간이 허용되는 경우 64비트 포인터 대신 32비트 정수 핸들을 사용했다. 콕스는 이 방식으로 여러 도구에서 평균 약 10%의 속도 향상과 20%의 메모리 절감을 측정했다고 보고한다. 참조가 작아 메모리 압력이 줄었고, 한 필드를 순회하는 알고리즘이 관련 데이터를 캐시에 더 많이 가져오면서 무관한 필드까지 함께 불러오는 일이 줄었다는 설명이다.

더 최근의 별도 사례는 Lyric 저장소와 책에 기록돼 있다. 2026년 6월 12일자 부트스트랩 결과에서, Lyric 컴파일러를 --soa를 사용해 AoS에서 SoA로 바꾸자 실행이 10% 빨라지고 메모리 사용량이 14% 줄었다고 보고한다. README가 명시한 작업은 MacBook Air M2에서 Lyric 컴파일러가 자신의 소스를 컴파일하는 것이다.[3] 해당 구현에서 AoS는 연속된 객체 슬랩을 가리키는 포인터 핸들을 사용하고, SoA는 필드별 병렬 배열을 가리키는 32비트 인덱스를 사용한다. 따라서 비교에는 배치 방식과 핸들 표현의 효과가 함께 들어가며 캐시 효과만 따로 측정한 결과는 아니다. QuickLogic의 과거 결과와 Lyric의 결과는 서로 다른 측정이고, 메모리 감소 수치인 20%와 14%를 하나로 합쳐서는 안 된다. 콕스의 결론은 모든 구조를 SoA로 바꾸라는 것이 아니라, 논리적 어휘가 호출자들을 불필요하게 하나의 물리적 표현에 묶지 않아야 한다는 것이다.

이 글은 특정 작업 부하의 보고 수치이지 모든 프로그램에 대한 성능 약속이 아니다. 콕스는 성능을 일반화하지 않기 위해 NetworkManager 사례도 든다. 코드 양을 대략 절반으로 줄였지만 실행 시간은 사실상 개선되지 않았고, 작업의 대부분이 와이파이 검색 같은 외부 작업에 쓰였다고 한다. 코드가 단순해진 것은 유지보수에는 도움이 됐지만 사용자가 중요하게 여기는 작업 시간을 줄이지는 못했다. 따라서 규칙은 “코드가 짧을수록 항상 빠르다”가 아니라 불필요한 것을 제거하고 설계를 직접적으로 만들며 중요한 동작을 측정하라는 것이다.

상속과 언어별 우회 장치에 대한 비판

엣지가 두 노드에 속하는 일은 하위 클래스 문제라기보다 관계 문제다. 타입 계층을 늘려도 엣지가 유지해야 할 양쪽 관계가 생기지는 않는다. 타이밍 분석기는 배선의 저항·용량이나 임시 계산값이 필요할 수 있지만, 이 정보가 라우터를 비롯한 모든 모듈이 공유해야 할 배선 어휘에 속하지 않을 수 있다. 모든 임시 필드를 공통 배선 타입에 넣으면 공유 모델이 오염되고, 알고리즘 조합마다 상속 계층을 만드는 것도 해법이 아니다. AoS에서는 불투명 포인터 같은 확장 방식을 썼고, 핸들 기반 SoA에서는 각 모듈이 같은 핸들로 인덱싱하는 로컬 배열을 둘 수 있다. 추가 필드의 의미와 수명은 해당 모듈이 맡고, 공유 사실은 공통 모델에, 임시 작업은 모듈 내부에 둔다는 원칙이다.

Go에서 콕스가 문제로 부르는 “Go stupidity”는 두 제약의 결합이다. 패키지 임포트는 순환할 수 없고, 메서드의 수신자 기본 타입은 그 메서드와 같은 패키지에서 정의돼야 한다.[6] 흔히 “메서드는 구조체를 선언한 곳에서 정의해야 한다”고 말하지만 정확한 경계는 파일이나 Go 모듈이 아니라 패키지다. 파일 두 개를 같은 패키지로 옮길 수는 있지만, 그러면 그가 원한 패키지 분리를 포기하게 된다.

콕스가 원하는 구조에서는 Common이 객체·지속 데이터·관계와 모듈 경계를 넘는 연산을 선언하고 기능 모듈이 그 연산을 구현한다. 기능 모듈은 Common에 의존하지만 서로 직접 의존하지 않으며, 최상위 애플리케이션이 전체를 구성한다. Node가 Common에 선언돼 있다면 그래프 구현에서 같은 객체의 Node.RemoveEdge를 제공하고 싶다는 것이다. Go에서는 별도 패키지가 Common의 타입에 메서드를 추가할 수 없다.

가능한 우회에는 비용이 따른다. 메서드를 Common에 두면 알고리즘 구현이 선언 모듈에 쌓인다. 기능 패키지의 함수로 대체하면 기능 패키지 사이에 구현 의존성이 생길 수 있고, 충분히 얽힌 시스템에서는 순환 임포트 금지에 걸릴 수도 있다. 인터페이스·구현 객체·팩토리·전달 메서드를 도입하면 비순환 의존성 그래프를 유지할 수 있지만, 선언과 구현을 연결하려는 런타임 장치가 추가된다. 모든 코드를 한 Go 패키지에 두는 것도 가능하지만 기능별 소스 모듈을 별도 패키지 경계로 표현할 수 없다. 콕스는 어느 방법이든 항상 잘못이라고 말하는 것이 아니라, 자신이 원하는 선언과 구현의 분리를 위해서만 우회 장치를 선택해야 한다는 점을 비판한다.

그는 순환 임포트를 요구하는 것이 아니라고 강조한다. 객체 관계 그래프는 데이터에 있고, 코드 의존성은 허브와 스포크 구조로 유지한다. 노드는 엣지를 알고 엣지는 양 끝점을 알며, 객체는 여러 부모를 가질 수 있다. 선언은 이 공통 어휘에 모으고 기능별 구현은 별도로 둔다. Go의 인터페이스 우회 방식으로 실제 시스템을 만들 수 있고, 콕스도 그렇게 해봤다고 한다. 다만 구체 데이터의 소유자가 누구인지, 연산 구현을 들고 있는 객체가 무엇인지, 인터페이스가 실제 애플리케이션 추상화인지 연결 장치인지, 상태가 복사됐는지, 정리가 실제 관계를 따르는지 같은 질문이 추가된다. 숙련된 프로그래머는 일관된 답을 만들 수 있지만, 그 질문들이 언어의 제약을 우회하면서 생겼다는 것이 그의 지적이다.

Rust에 대한 “Rust stupidity”라는 표현 역시 콕스 자신의 가치 판단이다. 그의 애플리케이션에서는 객체가 역방향 참조를 가지고 여러 부모를 가질 수 있으며, 삭제 때 모든 부모에서 제거해야 한다. 엣지에서 양 끝점으로 가거나, 자식에서 소유 애플리케이션으로 가거나, 양방향 연결 리스트에서 이전·다음 노드로 가는 일은 예외적인 구조가 아니라 데이터 모델의 일부다. Rust의 소유권·대여 규칙은 자식 삭제가 소유 자식으로 전파되는 단일 소유 계층에는 자연스럽게 맞을 수 있다. 문제는 애플리케이션이 그런 계층이 아닐 때다.

콕스는 애플리케이션 관계 그래프와 메모리 소유 구조를 구분한다. 전자는 엣지의 양 끝점, 자식을 포함하는 객체, 연결 리스트의 이전·다음 노드, 애플리케이션과 로거에 접근할 부모를 묻는다. 후자는 무엇이 할당된 메모리를 살려 두는지, 지금 누가 변경할 수 있는지, 언제 회수할 수 있는지를 묻는다. 그는 애플리케이션 관계를 직접적이고 완전하게 유지하고 싶으며, 메모리 소유 구조를 편리하게 만들기 위해 관계를 단일 소유 트리로 왜곡하고 싶지 않다고 말한다. Rust가 그래프를 표현할 수 있는지는 쟁점이 아니며, 표현 사이에 얼마나 많은 장치와 제약이 놓이는지가 쟁점이다.

참조 카운팅은 한 방법이지만, 가변 공유 데이터에서는 Rc와 RefCell을 조합하는 경우가 있고 상황에 따라 다른 동기화·소유권 선택이 필요하다. 강한 참조 카운팅 포인터의 순환은 외부 경로가 사라져도 참조 횟수가 0이 되지 않아 메모리를 붙잡을 수 있다. Rust 문서는 참조 순환을 설명하면서 부모를 계속 살려 두지 않아야 하는 링크에 Weak를 사용하는 예를 든다.[7] 이는 유효한 메모리 관리 기법이지만, 소유 링크와 비소유 링크를 구분하고 약한 참조를 만들고 승격하며 대상이 이미 사라졌을 가능성을 처리하고 선택한 표현의 변경 규칙을 지켜야 한다. 아레나와 핸들은 참조 카운팅 순환을 피할 수 있지만 저장소를 통한 조회가 필요하고, 객체를 제거한 뒤 슬롯을 재사용한다면 세대 번호 같은 유효성 규칙이 필요할 수 있다. 수명이 맞으면 대여 참조 방식이 가능하고, unsafe 코드는 추가 표현을 구현할 수 있지만 안전성을 증명할 부담이 따른다.

이 선택지가 있다는 사실이 그의 비판을 없애지는 않는다. 그래프와 양방향 연결 리스트는 평범한 데이터 모델이어야 하며, 애플리케이션을 다루기 전에 관계를 별도의 소유권 공학 과제로 바꾸고 싶지 않다는 주장이다. 수명 검사는 잘못된 메모리 접근을 막을 수 있지만 논리적으로 삭제한 엣지를 양쪽 끝점의 컬렉션에서 제거하지 않는다. 참조 카운팅과 C# 가비지 컬렉션도 마찬가지다. 논리적 정리는 여전히 선언하고 구현하고 검토해야 한다. Rust의 저장 표현을 정한 뒤에도 관계 연산은 필요하며, 그 주변 제약이 추가된다고 콕스는 본다. 특정 요구 때문에 Rust를 선택하는 프로그래머는 그 절충을 받아들일 수 있지만, 이 글에서 말하는 시스템과 자율 작업에서는 역참조와 여러 부모를 표현하려는 이유만으로 그 비용을 지불하고 싶지 않다고 한다.

감독된 Go 작업과 자율 구현은 다르다

콕스는 CodeRhapsody에서 Go를 사용하고 변경을 감독한다. Claude의 추론을 따라가다가 아키텍처에서 벗어나기 시작하면 바로잡는다. 그 감독은 결과가 작동하도록 하는 개발 과정의 일부다. 반면 에이전트 코드북은 AI가 장별 지침을 따라 시스템을 구현하고, 기능을 추가하고, 테스트하고, 앞서 세운 불변 조건을 유지하도록 하는 작업이다. 이때 전문가는 모든 지역적 선택을 곁에서 계속 고쳐 주지 않는다. 따라서 지속적인 전문가 수정 아래에서 잘 작동하는 언어가 그 수정이 없을 때도 좋은 선택이라고 단정할 수 없다는 것이 콕스의 논점이다.

그의 보고에 따르면 Astra가 21장 작업을 약 30시간 동안 완료하지 못한 뒤 Go로 작성한 21개 장을 폐기했다. 코드는 설계가 좋지 않았고, 어시스턴트는 콕스가 요구한 아키텍처를 일관되게 구현하기보다 관용적인 Go 방식으로 되돌아갔다. 이는 성공한 구현을 단지 스타일이 마음에 들지 않아 버린 사례가 아니라, 장을 쓸 수 있는 상태로 완성하지 못한 사례라고 설명한다.

콕스는 지침을 설정한 방식에도 문제가 있었다고 밝혔다. 어시스턴트는 규칙이 담긴 파일을 보았지만, 해당 설정에서 그 규칙은 시스템 프롬프트의 지배적인 지침으로 설치되지 않았다. 즉 규칙을 제공했지만 의도한 권한을 부여하지 못했다. 그는 진단을 “Go로는 할 수 없다”로 단순화하지 않는다. 어시스턴트가 아키텍처를 유지하지 못한 점과 지침 설정이 익숙한 기본 관행보다 규칙을 우선하도록 만들지 못한 점이 함께 있었다고 말한다. 설정 실수가 자신의 책임이라는 사실이 산출물을 좋은 코드로 만들지는 않지만, 무엇이 잘못됐고 무엇을 바꿨는지를 설명한다.

규칙을 읽을 수 있게 제공하는 것과 규칙이 작업을 지배하게 만드는 것은 다르다. 아키텍처는 찾아볼 수 있는 문서에 그치지 않고, 장 사이·위임 작업·재개한 세션에서도 계속 적용되는 지배적 지침이어야 한다. 구현을 맡은 하위 에이전트도 해당 규칙을 받아야 한다. 다만 시스템 프롬프트를 설정한다고 AI가 틀리지 않는다는 뜻은 아니며, 결과 코드를 검사하고 동작을 테스트해야 한다. 명시된 아키텍처 요구 대신 “Go답다”는 이유로 다른 설계를 택해도 된다고 여겨서는 안 된다는 것이 운영상 교훈이다.

지침 설정을 고쳐도 앞서 설명한 언어 제약은 사라지지 않는다. 아키텍처를 따르려면 인터페이스·래퍼·관례의 특별한 조합을 유지해야 할 경우, 자율 구현자는 모든 변경에서 그 조합을 보존해야 한다. 익숙한 대안은 계속 유혹적인 선택지로 남고, 지역적으로 편리한 일탈이 쌓이면 다른 시스템이 될 수 있다. 원하는 구조를 직접 표현하는 언어는 이런 결정의 수를 줄이지만 성공을 보장하지는 않는다. 콕스는 지침 설정과 구현 언어를 모두 바꿨으며, 이 사례만으로 두 변경의 개별 기여도를 분리할 수는 없다고 밝힌다. 경험한 실패와 선택한 변경의 이유를 보여주는 사례이지, 통제 실험은 아니다.

그가 묻는 질문은 “최고의 언어는 무엇인가?”보다 구체적이다. 전문가가 아키텍처 이탈을 바로잡는 일을 최소화하면서 AI가 이 아키텍처를 자율적으로 실행하게 해 주는 언어는 무엇인가?

C#을 선택한 이유와 적용 조건

콕스는 C#이 완벽해서가 아니라 필요한 작업에 실용적으로 맞기 때문에 선호한다고 설명한다. 코딩 어시스턴트에 익숙한 언어이고, 필요한 일을 위한 성숙한 라이브러리가 있으며, 원하는 아키텍처에 불필요한 장벽을 두지 않는다는 판단이다. C# 객체는 부모를 일반 참조로 직접 가리킬 수 있고 엣지는 양 끝점 참조를 가질 수 있다. 여러 부모와 순환이 있다는 이유만으로 별도의 저장·소유 표현을 도입할 필요가 없다. 가비지 컬렉터는 도달할 수 없는 순환 객체의 메모리를 회수할 수 있지만 논리적 삭제를 대신하지 않는다. 부모가 삭제된 자식을 계속 보유한다면 메모리 접근이 유효해도 모델 오류다. 프로그램은 관계의 의미를 유지하고 런타임은 도달할 수 없는 관리 메모리를 회수한다는 역할 분리를 원한다.

콕스가 관계를 “약하다”고 부를 때는 부모를 삭제해도 자식까지 연쇄 삭제하지 않고 관계만 분리한다는 뜻이다. 이것이 C#의 WeakReference<T>를 써야 한다는 뜻은 아니다. 애플리케이션의 소유 규칙을 특정 메모리 관리 API와 혼동하지 말아야 한다고 설명한다.

C#의 부분 타입(partial type)을 사용하면 같은 타입의 여러 부분을 다른 소스 파일에 둘 수 있고, 부분 멤버로 한쪽에서 연산을 선언하고 다른 쪽에서 구현할 수 있다. 컴파일러가 두 부분을 결합하며, 선언과 구현을 연결하기 위해 래퍼 객체나 런타임 디스패치가 생기지 않는다.[4] C# 14는 부분 생성자도 지원한다.[5] 콕스는 이 구현에 C# 14와 .NET 10, 또는 그보다 새로운 호환 버전을 사용한다.

그가 제안하는 구성은 Common에 공유 타입 선언, 지속 필드와 관계, 부분 연산·생성자 선언을 두고, Graph 같은 기능 디렉터리에 이에 대응하는 부분 타입과 구현을 두는 방식이다. 다른 기능 모듈도 자신이 맡은 선언된 연산을 구현하고, Application이 생성·연결·조정을 맡는다. 예를 들어 Common은 부분 Node 타입과 필드, public partial Node(Graph parent); 같은 생성자 시그니처를 선언하고 그래프 모듈은 다른 부분에서 생성자 본문을 제공한다. 그 본문은 부모가 선언한 추가 연산을 통해 관계를 설정한다. Node는 하나이고 필드 집합도 하나이며, 생성자 본문이 다른 디렉터리에 있다는 이유로 두 번째 연산 인터페이스를 전달하지 않는다.

이 설계의 경계는 모든 부분이 같은 어셈블리와 컴파일된 모듈에 들어가야 한다는 것이다.[4] 디렉터리는 별도 DLL이 아니며, 기존 클래스에 독립적으로 컴파일되는 확장 모듈을 배포하려는 구조가 아니다. 또한 컴파일러가 디렉터리 단위 의존성 규칙을 전부 강제하지는 않는다. 공통 네임스페이스로 Common을 이용할 수 있어도 한 구현 모듈이 다른 모듈의 구현 전용 도우미에 의존해도 된다는 뜻은 아니다. 그 규칙은 지침과 검토로 지켜야 한다.

호출 가능한 부분 연산은 접근 수준을 명시해야 하며 구현을 빼먹으면 컴파일 오류가 나야 한다. 구현이 없으면 호출이 사라질 수 있는 선택적 후크로 두는 방식은 원하지 않는다.[4] Common은 알고리즘을 쌓는 장소가 아니라 타입·데이터·관계와 모듈 경계를 넘는 연산을 선언하는 곳이고, 기능 모듈이 본문을 제공한다. 실제 요구가 없는 한 연산은 구체적이고 직접적이어야 한다.

애플리케이션에는 HTTP, 웹소켓, JSON, 프로세스, 취소, 비동기 I/O, 동시성 같은 기반 시설도 필요하다. 콕스는 .NET 라이브러리에 이를 위한 시설이 있다고 말한다.[8][9] C#에는 언어 모델이 참고할 문서와 기존 코드도 많다. 이는 언어 모델이 모든 C#을 정확히 생성한다는 측정된 보장이 아니라, 생태계를 처음부터 가르쳐야 하는 낯선 언어를 피하는 실용적 이유다. 아키텍처 지침과 결과 검토는 여전히 필요하다.

C#을 선택해도 Lyric의 생성된 SoA 레이아웃이나 보고된 벤치마크 결과를 얻는 것은 아니다. 일반 C# 참조 객체는 고유한 런타임 표현과 비용이 있으며, 데이터 배치 요구는 별도로 해결해야 한다. 빌드 성공만으로 애플리케이션의 정확성, AI가 모든 관계를 보존했는지, 실제 외부 공급자가 작동하는지도 입증되지 않는다. 콕스의 주장은 더 제한적이다. 원하는 아키텍처를 표현할 수 있고 필요한 라이브러리가 있으며 구현을 맡은 AI에 익숙하기 때문에 C#을 선택한다. 언어 우회법을 가르치기보다 시스템의 동작을 가르치는 데 노력을 쓰고 싶다는 것이다. 아키텍처가 먼저였고 C#은 현재의 구현 도구다.

구현과 검토에 요구하는 규칙

콕스는 이 글의 두 번째 부분에서 사람 개발자에게 요구해 온 규칙을 AI 협업자에게도 적용한다. 공유 어휘와 아키텍처 변경은 사람과 합의해야 하지만, 합의한 구조 안의 일상적인 구현마다 허가를 받을 필요는 없다는 전제다.

  • 허브와 스포크를 지킨다. 하나의 Common 모듈이 공유 타입, 관계, 다른 모듈에서 호출할 모든 연산을 선언한다. Common은 기능 모듈을 임포트하지 않는 잎 모듈이다. 기능 모듈은 서로의 구현이 아니라 Common을 사용하고, 선언된 연산을 구현한다. 최상위 애플리케이션은 모듈을 구성하고 연결할 수 있다. Common을 알고리즘 구현의 집합으로 만들거나 전체 경로 이름·전역 임포트로 기능 의존성을 숨기지 않는다.
  • 각 사실의 권위 있는 위치를 하나로 둔다. 접근하기 쉽도록 권위 데이터를 복제하지 않는다. 필드·컬렉션·상태 변수마다 기준이 되는 위치를 식별할 수 있어야 한다. 이는 객체 소유자가 하나뿐이어야 한다는 뜻이 아니다. 여러 부모와 소유자가 있을 수 있으며 실제 관계를 표현해야 한다. 순방향과 역방향 링크는 같은 관계를 양쪽에서 유지하는 것이지 서로 모순되는 사실의 사본을 허용하는 것이 아니다.
  • 모든 관계의 양쪽을 갱신한다. 관계 객체는 모든 부모에 대한 역참조를 유지한다. 부모 컬렉션은 외부에서 직접 고치지 말고 부모의 선언된 메서드를 호출한다. 부모의 추가 메서드는 자식을 더하고 자식의 역참조도 설정하며, 제거 메서드는 컬렉션에서 자식을 빼고 대응 역참조를 지운다. 각 연산은 반환 전에 양쪽을 갱신한다. 변경이 거부될 수 있다면 양쪽을 수정하기 전에 제안을 검증해 거부된 변경이 어느 쪽에도 남지 않게 한다. 한쪽은 부모 메서드가, 다른 쪽은 호출자가 나중에 처리하는 식으로 나누지 않는다.
  • 강한 소유 부모를 생성자에 전달한다. 생성자는 새 객체를 강하게 소유하는 부모의 참조를 받아 부모의 추가 메서드로 관계를 설정한다. 방향 그래프 엣지라면 fromNode와 toNode를 받고, 생성 성공 뒤 어떤 컬렉션과 역참조가 있는지 계약을 설명한다. C#에서는 안전하지 않은 포인터가 아니라 일반 객체 참조를 쓴다. 부모를 통해 필요한 접근을 할 수 있다면 별도 연산 인터페이스·콜백 묶음·복사된 서비스를 생성자에 추가하지 않는다. 여러 관계를 설정하다 실패해 반쯤 등록된 객체를 남기지 말고, 검증과 정리를 구현에서 이해할 수 있게 한다.
  • 관계를 따라 데이터와 서비스에 접근한다. 로깅을 포함해 필요한 데이터와 연산은 객체에서 명료하게 접근할 수 있어야 한다. Common에 필요한 경로와 연산을 선언하고 부모 관계를 따라간다. 접근이 어렵다면 모델이나 선언된 연산을 고친다. 중복 필드, 전역 검색, 식별자 재구성, 런타임 타입 탐색, 콜백 배선으로 대신하지 않는다. 모든 함수가 애플리케이션 전체를 돌아야 한다는 뜻은 아니며, 객체가 매번 무관한 호출자에게 맥락을 다시 구성하도록 기대하지 말라는 뜻이다.
  • 논리적 삭제를 명시적이고 완전하게 한다. 모든 부모의 제거 메서드를 호출해 관계에서 빠지고, 부모 메서드가 자식 역참조를 지우게 한다. 강하게 소유한 자식은 재귀적으로 삭제하고, 약한 자식 관계는 자식을 삭제하지 않은 채 분리한다. 다중 소유 자식을 두 번 삭제하지 않으며 모든 분리는 양방향 관계 연산을 사용한다. 예를 들어 와이어를 삭제하면 핀에서 와이어를 떼지만, 패키지가 소유한 핀까지 삭제하지 않는다. 핀은 연결이 없는 상태로 남는다. 와이어에서 핀만 빼고 핀 컬렉션에는 와이어를 남기는 부분 분리는 완전한 삭제가 아니다. 이는 C# WeakReference<T> 사용 지시가 아니다. 가비지 컬렉션은 도달할 수 없는 관리 메모리를 회수하지만 논리적 정리를 수행하지 않으며 파이널라이저에 모델 정리를 맡겨서는 안 된다. 실제 구성 요소에 작업과 외부 자원이 있다면 그 수명과 정리 순서도 고려해야 한다. 자식의 부모 참조를 지운다고 아직 그 자식을 사용하는 작업이 안전해지는 것은 아니다.
  • 값처럼 쓰는 클래스를 명시한다. 벡터·심벌·행렬 같은 값 유사 클래스에는 보유 객체에 대한 역참조가 필요하지 않으며 도달할 수 없게 되면 C# 가비지 컬렉션에 맡긴다. 이를 위해서만 부모 추적과 삭제 장치를 추가하지 않는다. 값 유사 역할을 주석 등으로 명시하며, 클래스인지 C# 값 형식인지는 별개의 선택이다. 이 예외로 관계 객체나 권위 있는 가변 상태의 중복을 숨겨서는 안 된다. 짧은 알고리즘 참조가 자동으로 지속 관계가 되지는 않지만, 소유 추적이 번거롭다는 이유만으로 지속 공유 상태가 임시 데이터가 되지는 않는다.
  • C#에 규칙을 직접 대응시킨다. 부분 생성자 선언까지 분리하려면 C# 14와 .NET 10 또는 이후 호환 버전을 쓴다. Common에는 공유 클래스·구조체, 지속 필드와 관계, 다른 모듈이 호출하는 모든 연산을 선언한다. 호출 가능한 메서드와 생성자는 접근성을 명시한 부분 멤버와 세미콜론으로 선언하고 본문이나 알고리즘을 넣지 않는다. 기능 모듈에는 대응하는 부분 타입과 멤버 본문을 둔다. 각 선언에는 구현을 하나만 둔다. 모든 부분은 네임스페이스·타입 이름·제네릭 매개변수가 같아야 하고, partial과 필요한 접근성·수정자를 맞춰야 한다. 이들은 이름만 같은 별도 타입이 아니다. 소스 모듈을 하나의 어셈블리와 컴파일된 모듈로 함께 빌드하며, 디렉터리는 별도 DLL이나 Go식 패키지가 아니다. 구현은 Common의 어휘를 쓰되 다른 기능 모듈의 구현 전용 타입이나 도우미에 의존하지 않는다. 컴파일러가 부분을 결합하므로 인터페이스 필드·래퍼 객체·델리게이트·팩토리·타입 변환을 연결 장치로 추가하지 않는다. 실제 요구가 없으면 구체 타입과 직접적인 비가상 메서드를 사용하고 포함 관계를 상속으로 모델링하지 않는다.
  • KISS와 YAGNI를 적용한다. KISS는 단순하게 유지하라는 뜻이고, YAGNI는 아직 필요하지 않은 것을 만들지 말라는 뜻이다. 요구된 동작을 이 규칙을 지키는 가장 단순하고 명료한 설계로 구현한다. 불필요한 간접 계층·중복 장치·데이터만 전달하는 계층을 없애고 적합한 기존 코드를 재사용한다. 현재 요구가 아닌 상상 속 미래를 위해 프레임워크·레지스트리·설정 옵션·확장점·범용 추상화를 추가하지 않는다. 다만 단순성을 이유로 부모 관계 누락, 불완전한 삭제, 사실 중복, 허브와 스포크 위반을 허용하지 않는다. 줄 수만 줄었다고 더 단순한 설계인 것은 아니다. 비자명한 결정에 관한 유용한 설명은 남긴다.
  • 목 대신 페이크를 사용한다. 실제 구성 요소와 그 동작을 테스트한다. 외부 의존성을 대체해야 한다면 테스트에 필요한 프로토콜과 실패 동작을 포함해 계약을 구현하는 작고 동작 가능한 페이크를 쓴다. 테스트 대상 구현 대신 목을 쓰거나 내부 호출의 예정된 순서를 재생해 통과를 만들지 않는다. 의미 있는 동작·상태·결과를 확인한다. 관계 테스트는 양쪽을, 삭제 테스트는 사라진 것과 남은 것을, 실패 테스트는 거부된 변경 뒤 모델이 그대로인지 확인한다. 페이크는 경계에 충실하면서 단순하게 유지한다. 페이크 통과만으로 실제 외부 서비스가 작동한다고 입증되지는 않는다.
  • 가르칠 수 있는 코드를 쓴다. 학생이 결과만 재현하는 것이 아니라 데이터 모델을 따라가고 프로그램이 작동하는 이유를 설명할 수 있어야 한다. 각 모듈의 책임과 공유 타입의 역할, 호출 가능한 연산의 전제 조건·결과·실패 동작·상태 변경을 문서화한다. 생성자와 관계 메서드는 소유권과 양방향 사후 조건을 설명하고 값 유사 타입은 그 역할을 명시한다. 비자명한 알고리즘·프로토콜 변환·순서·자원 수명·의도적으로 배제한 대안을 설명하되 당연한 문법을 서술하거나 필요한 설명을 멀리 있는 README로 미루지 않는다. 의미 있는 연산은 한 줄씩 쓰고, 비단순한 대입과 변환은 목적을 보여주는 중간 변수로 나눈다. 대입을 연쇄하거나 여러 문장을 한 줄에 넣거나 검증·변경·정리를 압축 표현 안에 숨기지 않는다. 조건문·반복문·생성자·오류 및 정리 경로에는 중괄호와 읽기 쉬운 여러 줄 본문을 사용한다. 한 필드를 반환하는 것처럼 정말 명백한 경우에만 짧은 식 본문을 쓴다. 공유 필드는 각각 별도 줄에 두고 의미와 소유권을 설명한다. 긴 매개변수 목록·객체 초기화·JSON 구성도 논리적 단위로 나눈다. 짧게 만들기 위한 전달 전용 함수나 추상화는 만들지 않는다. 장을 완료하기 전 새 학생의 관점에서 선언과 주요 실행 경로를 읽어 권위 데이터, 부모 경로, 양쪽 관계 변경, 실패와 정리를 추측 없이 이해할 수 있는지 검토한다. 예제와 테스트에도 같은 기준을 적용한다.
  • 실제 산출물을 검사한다. 생성된 요약은 구현 코드가 아니며, 빌드 성공은 관계가 올바르다는 증거가 아니다. 테스트 결과는 실제로 시험한 동작에 대해서만 증거가 된다. 지속 상태가 Common에 남았는지, 자식이 부모를 이용하는 대신 서비스 필드를 추가했는지, 구현 모듈 사이에 의존성이 생겼는지, 삭제가 불완전한 분리 절차가 됐는지, 인터페이스가 실제 요구 때문에 생겼는지 익숙한 관행으로 되돌아가며 나타났는지 살핀다. 실패가 있으면 설정 오류·제품 결함·테스트 한계를 구별하고 유용한 실패 증거를 보존한다. 아키텍처 요구를 조용히 다시 정의해 실패를 성공으로 바꾸지 않는다.

자율 실행은 어시스턴트가 아키텍처를 새로 발명해도 된다는 뜻이 아니다. 사람은 공유 어휘와 관계, 책임에 동의하고, 어시스턴트는 그 합의 안에서 일상적인 구현을 수행한다. 모델이나 아키텍처의 실제 변경은 논의 대상이지, 통과하는 패치 속에 숨겨 넣을 재설계가 아니다. 코드북의 선언은 교육 자료이기도 하다. 학생은 권위 있는 사실을 찾고 객체에서 부모와 서비스로 가는 경로를 따라가며 생성과 삭제가 하는 일을 이해할 수 있어야 한다.

콕스는 마지막으로 테스트를 강조한다. 요구를 위반하는 구조를 테스트가 통과했다는 이유만으로 정당화할 수는 없다. 아직 테스트가 해당 링크를 따라가지 않았더라도 논리적으로 삭제된 끝점을 가리키는 와이어는 잘못됐다. 반대로 그림에서 우아해 보이는 설계도 실제로 작동해야 한다. 실제 구성 요소를 테스트하고 의미 있는 상태를 살피며 요구가 중요하게 만드는 실패·정리 경로를 실행해야 한다.

주장 범위와 참고 자료

콕스는 사람을 코드베이스의 전문가로 둔다. 자율 구현을 이유로 AI가 아키텍처를 결정하지 않으며, 공유 어휘와 구조 변경에는 합의가 필요하다. 코드북의 선언은 학생이 사실·관계·책임을 배울 수 있도록 읽기 쉬워야 한다. 이 글의 결론은 모든 언어의 보편적 순위를 매기거나 상용 코딩 에이전트의 능력을 비교하는 것이 아니다. C·C++·Go·Rust·C#·TypeScript의 보편적인 성능 순위나 코드 크기에 따른 에이전트 능력 순위를 입증하지 않는다고 명시한다.

QuickLogic, 64비트 전환, 이후의 타이밍 구현, NetworkManager, 감독하의 CodeRhapsody 개발, 폐기한 Go 장에 대한 설명은 콕스의 회고와 프로젝트 보고이며 독립적으로 감사된 측정으로 제시되지 않는다. 통제된 자율 신뢰성 주장을 하려면 비교 가능한 작업, 문서화된 지침 전달, 정의된 완료 기준, 사람의 개입 기록이 필요하다. 21장 사례는 방향을 바꾼 이유를 설명하지만 그런 실험을 대신하지 않는다. 콕스가 내세우는 주장은 해당 모델과 작업 흐름에서 의도한 설계를 직접 지원하는 언어가 불필요한 장치를 줄이고 자율 구현자가 유지해야 할 사항을 줄인다는 실용적·아키텍처적 판단이다.

콕스는 C#을 철학 자체가 아니라 현재 그 철학을 구현하기 위해 선택한 도구라고 맺는다. 복잡한 시스템에서 모델을 이해하고, 각 사실을 한곳에 두고, 모든 관계를 완전하게 표현하며, 다른 사람이 이해할 코드를 쓰는 원칙은 AI가 등장하기 전부터 필요했다. 자율 구현에서는 전문가가 매번 지역적 선택을 고쳐 주지 않으므로 그 원칙을 지키는 일이 더 중요해진다는 것이 그의 결론이다.

부록: 원문 예시

text
main / application composition
 ├── placement module ──→ common
 ├── routing module ────→ common
 ├── timing module ─────→ common
 └── other modules ─────→ common
text
object 0: field A, field B, field C
object 1: field A, field B, field C
text
field A: object 0's A, object 1's A, ...
field B: object 0's B, object 1's B, ...
field C: object 0's C, object 1's C, ...
text
Common/
 Shared type declarations
 Persistent fields and relationships
 Partial operation and constructor declarations

Graph/
 Matching partial types
 Graph operation and constructor implementations

OtherFunctionalModules/
 Implementations of their declared operations

Application/
 Construction, connection, and orchestration