RicoCheesethe studio log · v2.0
Live · KRRead posts
목록으로
뉴스PUBLISHED · 2026년 7월 14일·17 MIN READ

Rust 핫패스를 27배 빠르게 만든 방법, 그리고 병합하지 않은 AI 수정 하나

기계식 키보드 소리 앱 KeyEcho를 1.0으로 다시 만들며 오디오 핫패스를 27배 빠르게 튜닝한 개발자의 기록. AI 에이전트에게 코드를 맡기되, CI가 초록불이던 의존성 변경 하나를 거절한 이유를 짚는다.

#rust#ai#programming#backend#tutorial
How I made a Rust hot path 27x faster, and the AI fix I refused to merge

개요 #

키를 누르는 순간 기계식 키보드 소리를 내주는 데스크톱 앱 KeyEcho가 2년 만에 1.0을 냈다. 파일 130개, 추가 11,405줄, 삭제 9,292줄이 담긴 PR 하나였다. 핵심은 오디오 처리 경로를 통째로 다시 짠 것으로, 캐시된 조회 마이크로벤치마크가 키당 1184.07 ns에서 43.50 ns로 줄었다. 평균 슬라이스 기준 27배, 가장 큰 슬라이스 기준 38배 빨라진 수치다.

개발자 Zach는 이 작업의 상당 부분을 AI 에이전트에게 맡겼다고 했다. 다만 그 과정에서 에이전트가 제안한 "깔끔해 보이는" 수정 하나를 병합하지 않고 거절한 이야기가 이 글의 진짜 알맹이다.

매 키 입력마다 반복되던 비용 #

KeyEcho의 지연에 민감한 경로는 짧다. 전역 키보드 훅이 키 이벤트마다 발생하고, 각 입력의 첫 key-down이 크기가 제한된 큐에 들어간다. 오디오 스레드가 큐에서 값을 꺼내 해당 키를 선택된 사운드팩의 오디오 슬라이스에 매핑한 뒤 재생한다.

경로가 짧다는 건, 그 안에 남아 있는 모든 작업이 키 입력마다 그대로 청구된다는 뜻이다. 디코딩, 메모리 할당, 샘플 복사, 락 — 무엇이 됐든 키를 누를 때마다 비용이 붙는다. 그래서 이 작업의 목표는 하나였다. 키 하나당 경로에서 이런 것들을 전부 걷어내는 것.

v0.0.5도 느린 앱은 아니었다 #

Zach는 이전 버전의 공을 먼저 인정한다. v0.0.5는 Tauri + Rust로 만든 가볍고 빠른 앱이었다. 빌드 크기가 작고 메모리를 적게 썼으며, 플랫폼별 API에 직접 짠 네이티브 키보드 리스너는 unsafe를 거의 쓰지 않았다. 압축 해제 단계가 없는 무손실 WAV 오디오를 썼고, 디코딩한 오디오를 LRU 캐시에 담아 같은 키를 두 번 눌러도 두 번 디코딩하지 않았다. 2년 동안 수백 명이 문제없이 썼다.

그런데 "이미 빠르다"와 "더 짜낼 여지가 없다"는 다른 이야기다. 캐시가 적중해도 키를 누를 때마다 샘플을 복사했고(벤치마크 기준 66.84 KiB), 팩 전환·볼륨 조정과 공유하는 전역 뮤텍스를 매번 거쳤다. 캐시가 빗나가면 그 자리에서 디코딩했다. 1.0이 없앤 게 정확히 이 세 가지다. 그 복사, 그 락, 그리고 캐시가 빗나가는 상황 자체다.

다시 짠 네 가지 #

팩을 고를 때 한 번만 디코딩한다 #

사운드팩은 sound.oggconfig.json이 든 폴더다. config는 각 키를 오디오의 한 구간(key -> [start_ms, duration_ms])에 매핑한다. 팩을 선택하는 순간 모든 슬라이스를 미리, 딱 한 번 디코딩한다. 키를 누르는 동안에는 아무것도 디코딩하지 않는다.

같은 슬라이스는 한 번만 디코딩한다 #

여러 키가 같은 슬라이스를 가리키는 경우가 많다. 키마다 디코딩하면 같은 오디오를 몇 번씩 반복해서 푸는 셈이다. 빌더는 (start_ms, duration_ms) 쌍을 키로 하는 맵을 두고, 고유한 슬라이스마다 한 번만 디코딩한 뒤 공유한다.

untitled
rust
// 고유한 슬라이스만 한 번씩 디코딩하고, 같은 슬라이스는 버퍼 하나를 공유한다.
let mut slice_sources: HashMap<[u64; 2], AudioSource> = HashMap::new();
let mut key_sources: HashMap<Key, AudioSource> = HashMap::new();

for (key, slice) in defines {
    let source = slice_sources
        .entry(slice)
        .or_insert_with(|| {
            let [start_ms, duration_ms] = slice;
            let samples = decode(start_ms, duration_ms); // Vec<f32>, 한 번만
            AudioSource::new(Arc::from(samples), channels, sample_rate)
        })
        .clone(); // 샘플 복사가 아니라 Arc 클론
    key_sources.insert(key, source);
}

복사 없는 재생 #

디코딩한 샘플은 Arc<[f32]>에 담긴다. 키 조회는 이 Arc의 클론을 돌려주는데, 이건 샘플 복사가 아니라 참조 카운트를 하나 올리는 동작이다. 키당 복사 비용이 0바이트가 됐다.

전역 뮤텍스를 없앤다 #

예전 경로는 현재 사운드와 볼륨을 뮤텍스로 지켰다. 새 재생 핸들은 현재 사운드를 ArcSwapOption<KeySound>에, 볼륨을 (f32::to_bits로 변환해) AtomicU32에 담는다. 키 조회는 락 없는 로드로 끝난다.

untitled
rust
struct PlaybackSoundpack {
    current_sound: Arc<ArcSwapOption<KeySound>>,
    volume_bits: Arc<AtomicU32>,
}

fn source_for_key(&self, key: Key) -> Option<(AudioSource, f32)> {
    let sound = self.current_sound.load();          // 락 없음
    let source = sound.as_ref()?.key_source(key)?;  // 맵 조회 + Arc 클론
    let volume = f32::from_bits(self.volume_bits.load(Ordering::Relaxed));
    Some((source, volume))
}

미리 디코딩하는 방식은 작업을 앞당기는 대신 RAM을 쓴다. 그래서 두 가지 안전장치가 붙는다. 키 이벤트는 크기가 제한된 큐를 거치므로, 입력이 몰려도 메모리가 무한정 늘지 않고 역압(backpressure)이 걸린다. 또 팩 하나는 디코딩 후 샘플 예산 10 MiB 안에 들어와야 한다. 로드 전에 고유 슬라이스 길이로 디코딩 크기를 추정해, 초과하면 아예 거부한다. 미리 디코딩하는 방식은 경계가 있을 때만 안전하다.

숫자 #

릴리스 빌드로 측정한 조회 경로 마이크로벤치마크다.

경로v0.0.5v1.0변화
캐시 적중, 평균 슬라이스1184.07 ns/op; 66.84 KiB 복사43.50 ns/op; 0바이트 복사27.2배
캐시 적중, 최대 슬라이스1638.57 ns/op; 98.88 KiB 복사43.10 ns/op; 0바이트 복사38.0배
입력/해제 게이트58.82 ns/tap; 메시지 2개54.69 ns/tap; 메시지 1개메시지 절반

Zach는 이 수치가 조회 경로만 측정한 마이크로벤치마크이고, 스피커까지 도달하는 실제 지연은 OS와 하드웨어에 달렸다고 못 박는다. 측정 방법과 메모리 예산은 저장소의 docs/performance.md에 있고, 벤치마크도 함께 들어 있다. 못 믿겠으면 pnpm run bench:audio를 돌려보라고 했다.

Rust라서 빠르게 밀어붙여도 안전했다 #

Zach는 이 diff의 대부분을 에이전트에게 맡기고도 안심했던 이유로 컴파일러를 꼽는다.

빌림 검사기(borrow checker)와 타입 시스템이 AI가 짠 코드의 첫 번째 리뷰어 역할을 한다. 잘못된 코드는 대부분 cargo check를 통과하지 못한다. 공유 오디오 버퍼를 Arc<[f32]>로 옮기고 뮤텍스를 걷어내는 건 실수가 곧 앨리어싱 오류나 Send/Sync 오류가 되는 지점인데, Rust는 이런 걸 컴파일 단계에서, 사람 리뷰나 사용자에게 닿기 전에 걸러낸다. 에이전트가 diff를 많이 쓸수록 이 안전장치의 가치는 더 커진다.

에이전트가 실제로 한 일 #

"에이전트가 앱을 짰다"는 아니다. 실제로 유용했던 일은 더 좁고, 솔직히 더 값졌다.

  • 마이그레이션 도우미. Tauri 1에서 2로 넘어가면 설정, 권한, 업데이터, 모든 플러그인 경계가 걸린다. 마이그레이션 문서를 다 읽은 파트너가 있으면 몇 시간을 아낀다.
  • 핫패스 감사관. 예전 재생 경로를 한 줄씩 함께 훑으며 위의 미리 디코딩·버퍼 공유·뮤텍스 제거 작업을 끌고 갔다.
  • 백로그 분류. 열려 있는 모든 이슈를 읽고 1.0에 들어갈 것 기준으로 정렬시켰다. 그중 하나가 10개월 묵은 요청이었는데, 특정 사운드를 원하며 돈을 내겠다던 사람이었다. 1.0 이후 답장을 보낸 게 이 프로젝트의 첫 유료 고객으로 이어졌다. 백로그는 Zach가 읽기를 멈춘 수요였던 셈이다.
  • 벤치마크와 문서 규율. 성능 노트를 결론부터, 재현 가능하게, 숫자가 무엇을 측정하고 무엇을 측정하지 않는지 솔직하게 유지하도록 도왔다.

Zach는 단계별 지시를 거의 하지 않았다고 한다. "키 경로에서 샘플 복사를 0으로 만들어라", "백로그를 1.0에 들어갈 것 기준으로 정렬해라" 같은 목표를 주고 중간 체크포인트에서 검토했다. 속도 향상 자체는 미리 디코딩, 버퍼 공유, 뮤텍스 제거, 제한된 큐 같은 재작업에서 나왔다. 에이전트는 그 찾고-하고-검증하는 고리 전체를 실제로 돌아갈 만큼 빠르게 만들어준 도구였다.

깔끔해 보였지만 아니었던 수정 #

Zach가 다른 개발자에게 그대로 가져가라고 권하는 대목이다.

리눅스 armv7 빌드의 CI가 QEMU 아래에서 실패했다. libgit2가 git 의존성을 받아오지 못한 것이다. 에이전트의 수정은 깔끔했다. 오디오 크레이트의 git 핀을 떼고 crates.io 릴리스를 쓰라는 것이었다. CI는 초록불이 됐다.

Zach는 이걸 거절했다. 그 핀은 코드 어디에도 적혀 있지 않은 이유로 존재했다. 핀은 cpal을 0.18에 묶어두는데, 이 버전에는 기본 장치 재라우팅(과 그 DeviceChanged 알림)이 들어 있다. 헤드폰을 뽑거나 블루투스 스피커로 바꿀 때 소리가 따라오게 해주는 기능이다. crates.io 버전으로 되돌리면 cpal이 0.17.3으로 조용히 내려가면서 이 장치 추적 기능이 사라진다. 이슈 #41과 #20이 정확히 그 동작에 관한 것이었다. "장치를 뽑으면 소리가 따라온다"를 검증하는 테스트가 없었으니, 핀이 왜 거기 있는지 아는 것 말고는 이 회귀를 잡을 방법이 없었다.

진짜 해법은 cargo vendor로 의존성을 벤더링하고 QEMU 안에서 오프라인으로 빌드하는 것이었다. 핀은 그대로 뒀다.

Zach가 이제 규칙으로 삼는 교훈 두 가지다.

  1. 모든 핀, 임시방편, 매직 넘버 옆에 "왜"를 적어라. 주석이든 프로젝트의 AI 규칙 파일이든 상관없다. 에이전트에게는 그걸 거기 넣게 만든 사건의 기억이 없다. 재능은 있지만 프로젝트 맥락은 0인 엔지니어다.
  2. 요청하지 않은 의존성 버전 변경은, CI가 초록불이어도 받아들이지 마라. 파이프라인이 초록이라는 건 테스트가 통과했다는 증거일 뿐, 지금 잃으려는 걸 그 테스트가 검사한다는 증거는 아니다.

잘라낸 것 #

armv7 빌드는 에뮬레이션 아래에서 두 시간쯤 걸렸고, 32비트 ARM 리눅스를 쓸 뚜렷한 사용자도 없었다. Zach는 1.0에서 이 타깃을 뺐다.

에이전트에게는 매몰 비용이나 기회 비용 감각이 없다. 놔두면 특정 빌드 타깃을 영원히 최적화한다. "완성"에 얼마든지 가까이 갈 수는 있어도, 멈추기로 결정하는 건 사람의 몫이다.

작업을 어떻게 굴렸나 #

출시 전에 Zach는 git worktree 안에서 AI 리뷰를 돌렸다. 여러 에이전트가 메인 트리를 건드리지 않고 병렬로 코드를 훑으며 수정을 제안하게 한 것이다. 각 에이전트의 결과물은 제안일 뿐이고, 검토를 거쳐야만 병합됐다. 탐색은 깔끔하게 병렬화되지만, 종합은 결국 한 사람의 머릿속에서 일어나야 한다. 에이전트는 저마다 자기 몫만 보기 때문이다.

모델들이 특화되기 시작했다는 관찰도 덧붙인다. Claude Fable은 틀을 벗어나 생각하고 놓친 각도를 계속 짚어내는 엔지니어처럼 느껴졌고, GPT-5.6은 지금껏 써본 것 중 최고의 실행자여서 정해진 계획을 넘기면 거의 완벽한 코드가 돌아왔다고 한다. 한 사람은 생각하고, 한 사람은 만들고, 자신은 결정하고 서명하는 2인 팀을 돌리는 느낌이었다는 것이다.

1.0은 무엇인가 #

여전히 무료, 여전히 오픈소스다. AGPL-3.0, 작은 네이티브 설치 파일, 계정 없음, 분석 없음. Tauri 2 + SolidJS로 다시 지었다. 서명된 윈도우 빌드와 공증받은 macOS 빌드로 미서명 앱 경고와 백신 오탐을 줄였다. 리눅스 패키지는 x64와 ARM64로 나온다.

내려받지 않고도 써볼 수 있다. keyecho.app을 열고 타이핑하면 브라우저에서 바로 소리가 난다. 벤치마크와 측정 방법은 저장소에 있다.


이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.