‘간접 참조에는 비용이 있다’는 말이 왜 보통 인라인의 근거로는 틀렸는지, 그리고 실제로 무엇이 비용을 발생시키는지 Rust async 코드를 통해 살펴본다.
Conceptual Stream by Sebastian Sastre>>>
‘간접 참조에는 비용이 있다’는 말이 보통 인라인의 이유로는 왜 틀렸고, 실제로 무엇이 비용을 발생시키는지.
![]()
March 7, 2026

우리 모두 이런 경고를 들어본 적이 있다. “함수 호출을 한 번 더 하면 오버헤드가 붙는다. 인라인해!” Rust async 코드에서는, 그 걱정이 거의 항상 사실이 아니다.
저 arm의 크기를 보자:
async fn handle_event(&self, event: Event) -> Result<()> {
match event.kind {
EventKind::Suspend => {
// … > 20 lines of app behavior …
}
// … other arms …
}
}
작성자는 이 코드를 쓸 당시 머릿속에 ‘뜨거운 문맥’을 갖고 있었고, 이제 여러분은 그가 그걸 정당화하려는 편향을 보게 될 것이다. 그리고 다른 사람도 정당화하도록 만든다 — 팀원, 기여자, 그리고 미래의 자기 자신이 그 비용을 받아들이길 기대하면서 말이다: 가독성과 유지보수성을 잃는 비용. 문맥이 식고 나면 구체적인 이득이 전혀 없는데도, 그 뜨거운 문맥을 만족시키기 위해서.
Suspend arm이 몇 줄 수준을 넘어 커지자, 누군가 분리하자고 제안한다. 호출 지점은 깔끔해지고, 로직은 이름 붙은 함수 안으로 들어간다:
async fn handle_event(&self, event: Event) -> Result<()> {
match event.kind {
EventKind::Suspend => self.handle_suspend(event).await,
// … other arms …
}
}
async fn handle_suspend(&self, event: Event) -> Result<()> {
// … > 20 lines of app behavior …
}
그러자 팀의 누군가가 눈썹을 치켜올린다. “그럼 함수 호출이 하나 더 생기는 거잖아? 간접 참조에는 비용이 있지.” 다른 멤버가 재빨리 고개를 끄덕인다.
원칙적으로는 틀린 말이 아니다. 하지만 실무에서는 맞는 말일까?
컴파일러는 이걸 어떻게 볼까? 신중히 생각해보고, 이를 측정해보자.
먼저, 기술적으로는 맞는 추상적 우려: 코드를 async 함수로 추출한다는 것은 다음을 의미한다.
이것들은 모두 _존재_한다. 질문은, 다른 모든 것에 비해 이게 중요하냐는 것이다.
우리 예시에서 Suspend arm은 handle_suspend가 하는 일을 한다. 그리고 match 자체가 이미 분기다. enum에 대한 match는 점프 테이블이나 비교 체인으로 컴파일된다. 즉, 분기 비용은 이미 지불했다. 그 arm 안에 함수 호출을 추가하는 것은 대략 간접 점프 하나와 프레임 설정 하나를 더하는 정도다 — Suspend가 실제로 해야 하는 작업에 비하면 잡음이다.
async 함수를 await할 때, 컴파일러가 반드시 새 객체를 힙에 할당하거나 동적 디스패치를 도입하는 것은 아니다(미래값을 Box로 감싸는 경우는 드물다). 실제로는, 컴파일러가 callee의 상태를 부모 Future의 상태 머신에 병합한다. 이 때문에 추상화가 진짜로 공짜가 될 수 있다: async future가 callee의 상태 머신으로 평탄화된다. 여러분이 걱정하는 그 추가 await 지점은, 인라인 버전이 어차피 만들어냈을 상태 전이와 동일하게 컴파일될 수도 있다.
코드 설계에서 잘못된 디테일이 지배하도록 두고 있지는 않은가?
시스템이 가장 크게 영향을 받는 것은, 실제로 무엇을 달성하게 만들려 하느냐에 달려 있다. Suspend 경로에 I/O, 락, 혹은 어떤 형태로든 할당이 들어간다면, 여러분은 나노초 수준의 잡음을 마이크로초 수준의 신호와 비교해 재고 있는 셈이다. 함수 호출 경계의 비용은 몇 사이클 정도다. syscall은 수천 사이클이다. 이 둘을 같은 종류의 비용처럼 취급하지 마라. 여러분도 이들이 같은 대화에 있을 수 없다는 걸 알고 있다.
릴리스 모드에서 최적화를 켜면, 컴파일러는 작은 추출 함수들을 종종 자동으로 인라인한다. 두 버전 — 인라인과 추출 — 이 _동일한 어셈블리_를 만들어낼 수도 있다.
직접 확인해볼 수 있다.
/// Work done entirely inside this function (no extra call).
#[no_mangle]
fn do_the_work_inlined() -> u64 {
let mut acc = 0u64;
for i in 1..=10 {
acc = acc.wrapping_mul(i).wrapping_add(12345);
}
acc
}
/// Same work, but behind an extracted helper (one extra function call).
#[no_mangle]
fn do_the_work_extracted() -> u64 {
do_some_work()
}
#[no_mangle]
fn do_some_work() -> u64 {
let mut acc = 0u64;
for i in 1..=10 {
acc = acc.wrapping_mul(i).wrapping_add(12345);
}
acc
}
로컬에서 어셈블리를 뽑자:
cargo rustc --release -- --emit asm
그리고 출력물을 보라(target/release/deps에 asm .s 파일이 생길 것이다). do_the_work_inlined와 do_the_work_extracted의 어셈블리가 동일하다면, 논쟁은 시작하기도 전에 끝난 것이다.
사람들은 컴파일을 거치면 살아남지 못하는 구분을 두고 다투고 있다.
이제 논지가 분명해졌다: 간접 참조는 성능의 적이 아니다 — 엔지니어들이 보이지 않는 이득을 쫓게 만드는 _오해_다.
완전성을 위해, 실험적으로 감(차원감)을 잡아보며 어떤 숫자가 나오는지 보자.
cargo bench
Criterion은 평균 실행 시간과 신뢰 구간을 제공한다. 오차 막대가 겹친다면, 그 차이는 실재하지 않으며, 팀은 잡음에 지배당한 토론을 하고 있는 셈이다.
그 상황에서 성능 회귀를 진지하게 다뤄야 한다면, 마이크로벤치마크가 사람을 속일 수 있다는 점을 기억해야 한다. 더 정직한 그림을 위해, 실제 애플리케이션을 프로파일러로 돌려라:
valgrind --tool=callgrindperf record 또는 perf report, 혹은 flamegraphdtrace 또는 Instruments(Time Profiler)시간이 실제로 어디에 쓰이는지 보라. handle_suspend가 핫스팟으로 나타나지 않거나 — 나타나더라도 1%도 안 되는 수준이라면 — 대화는 끝이다.
간접 참조가 실제로 중요한 경우는?
호출 오버헤드를 생각해볼 가치가 있는 경우가 있다:
dyn Trait — 동적 디스패치는 실제 간접 참조다: 컴파일러가 그 너머로 인라인할 수 없는 vtable 조회.모두, CPU 집약적 블로킹 작업에서 조심하지 않으면 다른 작업들을 굶길 수 있는 경우들이다.
하지만 이 중 어떤 것도, 개별 이벤트를 처리하는 async 함수의 match arm에는 해당되지 않는다. 만약 Suspend arm이 촘촘한 루프에서 초당 천만 번 돌아간다면, 그 전에 걱정해야 할 다른 것들이 있을 것이다.
정직해지자. 정말로 마이크로 최적화에 무게가 실리는 시스템 레벨 프로그램을 만들고 있는가, 아니면 시스템 레벨 동작이 지배하는 애플리케이션을 만들고 있는가? 때로는 우리가 최적화하려는 문제 자체가 아직 안정적이지도 않다.
그렇다면 런타임에서 지불되지 않는 비용은?
그 측정값이 프로파일러에 안 나온다고 해서, 덜 실재적인 것은 아니다. 이런 인간 중심의 비용은 시간이 지나며 누적되고, 실행에서의 몇 나노초보다 훨씬 크게 불어난다.
그 함수 열어보는 개발자마다 이해세(comprehension tax)를 낸다.
인지 부하는 공짜가 아니다. 모든 코드 리뷰가 더 오래 걸린다. 모든 미래의 변경은, 머릿속에 들고 있어야 할 문맥이 더 어려워지기 때문에 버그를 넣을 위험이 더 커진다. 이런 비용은 몇 달 동안 누적되며, 함수 호출 오버헤드 몇 나노초가 결코 따라잡지 못한다.
그리고 거기에 다음 코드 줄들이 더해지면 어떨까? 그 Suspend arm에서 규칙과 시스템 동작의 파급을 이해하기가 더 쉬워질까, 더 어려워질까?
Rust의 설계 철학은 이 점을 명시적으로 말한다: 깨끗한 추상화를 쓰고, 옵티마이저를 신뢰하며, 실제 문제가 측정으로 확인되었고 더 나은 선택지가 없을 때에만 #[inline] 또는 #[inline(always)]를 꺼내라.
이게 좋은 엔지니어링이 다루는 바다.
실제로는, 추가 호출 비용은 릴리스 빌드에서는 0이고 대부분의 경우 통계적으로 유의미하지도 않다. 간접 참조를 제거하는 진짜 비용은 명확성, 테스트 용이성, 개발자 생산성의 손실이며, 이는 ‘잠깐의 개인적 편의’를 정당화하려고 몇 나노초를 들먹이며 치르는 값이다.
그러니 다음에 누군가 인라인할지 추출할지 묻거든, 릴리스 벤치마크의 숫자로 답하고 성능 이득은 I/O의 초 단위에서 결정되지, 단일 호출 하나에서 결정되지 않는다는 걸 상기시켜라. 코드는 읽기 좋게 유지하라. 나머지는 컴파일러가 처리하게 두라.
게다가, 그 이벤트에 대한 시스템 반응은 유닛 테스트가 가능한가? 그 동작을 캡슐화하는 메서드 하나 없이, 어떻게 명확하게 테스트할 것인가?
함수를 분리해라. 이름을 잘 지어라. 거기에 의미를 부여하라. 그렇게 하면, 시스템이 그 케이스에 반응해야 할 때 적용되는 규칙에 대한 코멘트를 추가할 자리도 생긴다. 가장 가치 있는 주석은 시스템 동작을 빠르게 이해하도록 도와주는 주석이다.
그리고 걱정된다면 측정해라. 프로파일러가 특정 호출이 병목이라고 말하는 날이 오면, 트레이드오프를 정당화할 데이터가 있을 것이다. 그 전까지는 코드를 읽는 사람을 위해 최적화하라. 그건 네가 그 동작을 소유해야 할 때의 너 자신도 포함한다. 그리고 덧붙이자면, AI 에이전트들도 그로부터 똑같이 혹은 더 큰 혜택을 받을 것이다.
유지보수성과 이해 가능성은 의도적으로 다룰 때만 드러난다. 잘 이름 붙인 함수로 의미를 추출하는 것이 그 연습이다. 코드 미학은 기능이며, 팀과 에이전트 기반 코딩의 성능에 영향을 준다. 다만 런타임에서 측정하는 종류의 성능은 아니다.
그리고 경고하자. 어떤 사람들은 이를 거부하고, 자신이 가진 현재의 정신적 문맥의 편의에 굴복해서, 자신이 어떻게 했는지 “기억할” 거라고 베팅한다. 시간은 그 베팅을 나쁘게 만든다. 2026년이다 — 다른 AI 에이전트들은 이미 실행 루프 안에 있고, 그보다 더 규율 있게 더 좋은 코드를 쓰도록 훈련되어 있다.
그리고 코딩 실행이 급격히 싸졌을 때, 시스템 동작이 무엇에 의해 주도된다고 생각하는가? 이 새로운 코딩 경제에서, 시스템 동작을 빠르게 이해하는 것의 무게는 얼마인가?
그러니 나는 당신을 모르지만, “간접 참조를 하나 덜 이겼다”는 제단 위에 의미를 바치는 일은 절대 없을 것이다.
리더에서 새 글을 받아보세요 — 계정도, 뉴스레터도 필요 없습니다.
© 2026 Selective Creativity · 왜 RSS인가?