I'm sorry, but you still have to think
미안하지만, 여전히 생각해야 합니다
AI 에이전트가 Ruby on Rails 앱을 Rust와 Elixir로 다시 쓴 사례를 비교하며, 코드를 읽지 않고 신뢰성 있는 시스템을 만들기 어렵다고 주장합니다. 부하 테스트 결과는 처리량뿐 아니라 오류율, 이벤트 전달 지연, 메모리 사용량까지 함께 봐야 하며, 각 언어와 설계의 트레이드오프를 따져야 합니다.
- 주제
AI 요약
DHH는 Ruby on Rails로 만든 Campfire Once를 Rust로 다시 작성했고, 이후 Elixir와 Go 버전도 만들었습니다. 글쓴이는 DHH가 Rust 코드를 직접 읽거나 쓰고 싶어 하지 않아 AI 코딩 에이전트에게 구현을 맡긴 점에 주목합니다. 이 사례는 프로그래밍 경험이 많은 사람도 코드 검토 없이 AI 에이전트를 쓰면 원하는 결과를 얻기 어렵다는 점을 보여준다고 설명합니다.
프롬프트에 적지 않은 요구사항도 결정됩니다
프롬프트가 구체적이지 않으면 AI 에이전트는 여러 선택지를 임의로 정합니다. 이전 버전과의 호환성을 유지할지, 지연 시간과 처리량 중 무엇을 우선할지, 부하 상황에서 메모리를 얼마나 쓸지, 서버가 재시작될 때 새 콘텐츠 알림을 잃어도 되는지 같은 질문에는 정답이 하나만 있지 않습니다. 사용자가 별도로 답하지 않아도 구현 과정에서 이런 결정이 내려집니다.
실제 재작성 결과도 서로 다릅니다. Rust 버전은 CSRF 토큰을 제거해 캐시를 쉽게 만들었고, 알림 처리 등에 쓰던 Redis를 프로세스 내부 큐로 대체했습니다. Elixir 버전은 Rails 원본에 더 가깝습니다. 이런 차이는 언어 성능 비교와 별개이므로, 두 구현의 결과만으로 어느 언어가 낫다고 말하기 어렵습니다.
비동기 처리와 잠금도 코드로 확인해야 합니다
Elixir 버전은 SQL 질의를 단일 프로세스가 순차 처리한다는 지적을 받았습니다. 읽기 쿼리처럼 동시에 실행해도 되는 작업까지 직렬화하면 성능에 불리할 수 있습니다. 글쓴이는 Rust 버전도 살펴봐야 한다고 말합니다. 일부 데이터베이스 작업이 비동기 처리되지 않고, 비동기 작업 안에서 블로킹 방식으로 실행되기 때문입니다.
Rust의 비동기 런타임은 협력적 스케줄링을 사용합니다. 작업이 실행 중 양보하지 않으면 같은 워커 스레드의 다른 작업은 기다립니다. 따라서 데이터베이스 응답을 기다리는 동안 런타임에 제어를 넘기는 비동기 I/O가 바람직합니다. 잠금도 마찬가지입니다. 동기식 잠금을 오래 잡으면 같은 스레드의 작업이 모두 대기합니다. 글쓴이는 코드 작성 자체가 해결된 게 아니며, 개발자가 구현을 이해하고 판단해야 한다고 강조합니다.
처리량 하나로 신뢰성을 판단하지 않습니다
처음 제시된 부하 테스트는 닫힌 루프(closed-loop) 방식입니다. N명의 클라이언트가 요청을 보내고 응답을 받자마자 다음 요청을 보냅니다. 이런 테스트는 일정 시간 동안 처리한 요청 수를 측정하지만, 여러 사용자가 동시에 요청을 보내는 상황과는 다를 수 있습니다. 실제 부하를 재현하려면 처리 속도에 상관없이 일정한 요청 도착률을 유지하는 테스트도 필요합니다.
Zach Daniels의 측정에서는 높은 부하에서 Rust 버전의 알림 전달 성공률이 1%였습니다. Rust는 알림 6,000~7,000건 가운데 1%를 전달했고, Elixir는 약 1,700건을 모두 전달했습니다. 글쓴이는 Elixir가 역압(backpressure)을 더 잘 다룬다는 점은 확인되지만, 요청이 시스템 한계를 넘지 않는 범위에 한정된 결과라고 짚습니다.
일정한 초당 100건의 요청을 보내자 Rust는 알림의 약 14%를 전달했고 HTTP 오류는 없었습니다. Elixir는 알림 약 60%를 전달했지만 POST 요청의 약 23%가 시간 초과됐습니다. Elixir의 최악 알림 전달 지연은 약 180초였습니다. Rust는 전달이 밀릴 때 클라이언트 연결을 끊습니다. 브라우저가 재연결하면서 최신 메시지를 가져오므로 누락된 알림의 영향이 줄어듭니다. 글쓴이는 조용히 재연결하는 방식과 새 메시지 알림을 3분 뒤에 받는 방식 중 무엇이 나은지 물으며, 단일 지표만으로 사용자 경험을 판단할 수 없다고 말합니다.
버퍼 크기는 처리율과 지연의 교환입니다
Rust 버전은 여러 연결에 이벤트를 보내는 데 tokio::sync::broadcast 채널을 씁니다. 이 채널은 정해진 용량을 넘으면 느린 수신자에게 RecvError::Lagged 오류를 알립니다. 기본 용량은 256입니다. Elixir의 GenServer는 이벤트를 처리하며 기본 메일박스에는 제한이 없습니다.
Rust의 stream_capacity를 256에서 16,384로 높이자 초당 100건 부하에서 전달률은 약 14%에서 약 90%로 올랐습니다. 하지만 최대 이벤트 전달 지연도 11초에서 130초 이상으로 늘었습니다. 글쓴이는 지연된 연결을 끊고 다시 연결하게 하는 편이 이벤트를 오래 기다리게 하는 것보다 나을 수 있다고 봅니다. 용량을 무제한으로 두는 것도 답이 아닙니다. 같은 테스트에서 Elixir는 메모리 1.8GB를 사용했습니다. 메시지를 일부 놓칠지, 메모리 부족으로 프로세스가 종료될지 역시 설계자가 정해야 하는 트레이드오프입니다.
글쓴이는 특정 언어가 동시성에 강하다는 평판만으로 우열을 단정하지 말라고 합니다. Elixir와 BEAM은 동시성 코드를 쓰기 쉬운 장점이 있지만, 메모리 사용량이나 저수준 제어, 속도 측면에서 비용이 따를 수 있습니다. 신뢰성 있는 시스템을 만들려면 실패 조건을 정하고, 측정하려는 특성에 맞춰 테스트하며, 결과를 만든 코드와 시스템의 동작을 함께 살펴야 합니다.
Lobsters 반응
- @elliotmorris — 이 분석과 글쓰기에 들어간 사람의 노력이, 분석 대상에 들어간 노력보다 더 많았을 수도 있다고 생각하니 씁쓸합니다.
- @facundoolano — “신뢰성 있는 시스템을 원한다면 언제 실패해야 하는지 알아야 합니다.” 제 생각에 AI에 많은 일을 맡기자고 주장하는 사람들의 주된 문제는 시스템 신뢰성에 별로 관심이 없거나, 코드와 시스템의 차이를 이해하지 못한다는 점입니다. 자동화된 검사를 통과하면 시스템도 신뢰할 만하다고 여깁니다. 제 경험상 신뢰성은 반복 가능한 절차 하나로 얻는 게 아닙니다. 시스템을 단순하게 유지해 이해하기 쉽게 만들고, 그다음 많이 따져봐야 합니다. AI 코딩 에이전트는 이 두 가지를 적극적으로 방해합니다.
원문: itsallaboutthebit.com / 번역·요약: Trawling