러스트에서 패닉을 어떻게 바라보고 다룰 것인가에 대한 고찰: 패닉 없는 코드의 한계, 버그에서만 패닉하기, 패닉 처리 전략과 주의점.
사용자 관점에서 러스트 프로그램에서 잡히지 않은 패닉은 크래시다. 패닉은 스레드를 종료하고, 개발자가 특별히 신경 쓰지 않았다면 프로그램 종료로 이어진다. 이는 익스플로잇 가능한 크래시는 아니고 러스트는 보통 소멸자를 호출해 주지만, 프로그램은 여전히 크래시한다.
이는 관점에 따라 괜찮아 보일 수도, 정말 나빠 보일 수도 있다. 하지만 잡히지 않은 패닉이 좋은 사용자 경험은 결코 아니라는 점에는 모두 동의할 것이다. 러스트 개발자로서 우리는 패닉을 어떻게 생각해야 할까? 어떻게 하면 좋은 코드를 쓰고 사용자에게 좋은 경험을 줄 수 있을까?
옵션들을 살펴보기 전에, 어떤 패닉 접근법을 택하든 견고한 오류 처리 전략이 필요하다는 점을 짚고 넘어가자. 패닉은 결코 오류를 처리하는 주된 메커니즘이 되어서는 안 된다.
절대 패닉하지 않는 코드를 쓰는 것은 옳아 보인다. 하지만 이는 매우 어렵고, 사실상 불가능에 가깝다. 러스트에는 그런 일을 돕는 언어 기능(예: 어떤 함수를 호출하면 패닉이 발생할 수 있는지를 표기하는 이펙트 시스템)이 없고, 언어 자체, 표준 라이브러리, 많은 크레이트가 패닉이 괜찮다는 가정 위에 설계되어 있다(돌이켜 보면 최선의 결정이 아닐 수도 있지만, 현실은 그렇다).
구체적으로 말하면, 언어 자체는 정수 오버플로 시(디버그 빌드에서만)와 범위를 벗어난 인덱싱에서 패닉할 수 있고, 표준 라이브러리는 여러 곳에서 패닉할 수 있다. 포괄적인 목록을 찾지는 못했지만, 내가 아는 한도에서 정리하면 다음과 같다:
panic, unimplemented 등 명시적 패닉 매크로assert_eq 같은 어서션 매크로Option과 Result 같은 타입에서 예상치 못한 변형에 대해 패닉하는 unwrap, expect와 유사 메서드(서로스레드 락의 중독 상태를 처리하기 위한 lock().unwrap() 관용구는 많은 프로그램에서 빈번한 잠재적 패닉 원인이므로 특별히 언급할 가치가 있다)RefCell::borrow처럼 차용 불변식을 위반하면 패닉하는 RefCell, Cell 등의 메서드push 등 유사 메서드Iterator::step_by(0)no_std 빌드에서만; 정확한 규칙은 나도 확신하지 못한다)그리고 의존하는 어떤 크레이트도 잠재적으로는 어떤 함수에서든 패닉할 수 있으며, 새로운 릴리스에서 이 동작이 바뀌더라도 semver를 깨뜨리는 변경이라고 보기도 애매하다.
위의 모든 것을 피하는 코드를 쓰는 것은 가능하지만, 그다지 재미도 없고, 사소하지 않은 프로그램에서는 러스트다운 관용적 코드가 되기 어렵다.
안타깝게도 패닉을 제거·축소·격리하는 데 도움이 되는 것은 많지 않다. 러스트에는 패닉에 대한 이펙트 검사가 없고, Clippy 린트가 있더라도 얕은 검사만 하며, 중첩된 함수 호출에서의 패닉(그리고 모든 패닉 원천)을 다 다루지는 못한다. 링커 트릭을 써서 프로그램이 패닉하지 않게 보장하는 방법들이 있긴 하다(https://blog.aheymans.xyz/post/don_t_panic_rust/), 하지만 사용하기 번거롭다.
작고, 패닉하지 않는 것이 최우선 요구사항인 프로그램을 작성한다면, 패닉 없는 코드를 쓰는 것은 가능하고 비용이 정당화된다면 취할 만한 접근이다. 그러나 비용이 크고, 대부분의 프로그램에는 그만한 가치가 없다. 강조하자면, 명시적 패닉만 피하면서 패닉이 없는 코드를 쓰고 있다고 가장하는 것은 그냥 희망 사항일 뿐이다.
러스트 프로젝트의 공식 조언(https://doc.rust-lang.org/std/macro.panic.html#when-to-use-panic-vs-result)은 버그가 있는 경우가 아니면 패닉이 일어나서는 안 된다는 것이다. 안타깝게도 버그는 완전히 불가능하지 않다. 따라서 이 조언을 따르면 결국 프로덕션 코드에서 패닉이 발생할 수밖에 없고(추가적인 완화 없이) 이는 사용자 경험을 해친다.
달리 말하면, 코드의 잠재적 패닉이 절대 트리거되지 않을 것임을 아는 것(증명하는 것은 더 어렵다)은 매우 힘들다. 최소한 높은 수준의 프로그래밍 품질과 광범위한 테스트가 필요하다. 그렇다 하더라도 완전한 해결이 아니라 개선에 그칠 것이다.
유용한 구분은 로컬 불변식에 의존하는가, 비로컬 불변식에 의존하는가를 나누는 것이다. 로컬 불변식으로 그 불가능성을 보이면서 패닉을 사용하는 것은 수용 가능하지만, 비로컬 불변식에 의존하는 것은 위험이 너무 크다.
예를 들어, 다음과 같은 코드는 괜찮다(패닉 관점에서 그렇다는 것이지, 일반적으로 러스트다운 코드라는 뜻은 아니다):
if i < arr.len() {
// arr[i]는 패닉할 수 있지만, 위의 체크가 그걸 막아준다.
println!("{}", arr[i]);
}
하지만 다음과 같은 코드는 피하고 싶다(그래도 최소한 문서는 되어 있다):
/// 호출자는 `i < arr.len()`을 보장해야 함(그렇지 않으면 패닉)
pub fn foo<T>(arr: &[T], i: usize) -> &T {
&arr[i]
}
불가능한 패닉만 남기려는 노력은 좋은 출발점이라고 생각한다. 하지만 그렇게 하더라도 그 ‘불가능한’ 패닉이 실제로 일어났을 때를 처리해야 한다.
패닉하지 않도록 하는 것의 대안은, 프로그램이 패닉할 수 있음을 전제로 하고 그러한 패닉이 나쁜 사용자 경험으로 이어지지 않도록 처리하는 것이다. 패닉은 여러 위치에서 처리할 수 있다. 스레드 경계, 프로세스 경계(예: main 함수 등), 혹은 프로세스 외부(감독/감시되는 환경에서 프로그램을 실행)에서다. 그런 다음 과감하게 패닉을 사용하거나, 위에서 말한 대로 버그에서만 패닉하도록 할 수 있다. 다만 패닉을 일반적인 예외 메커니즘으로 쓰는 것은 강력히 비추천한다.
큰 단점은 프로그램이 패닉에서 복구할 수 있어야 한다는 점이다. 패닉 시 소멸자가 실행되므로 이론상으로는 프로그램 상태를 일관되게 유지할 수 있어야 하지만, 현실에서는 종종 그렇지 않아서 복구가 더 어렵거나 불가능해지기도 한다. 특히 위험한 경우는 프로그램이 복구했더라도 같은 이유로 다시 패닉하여, 패닉과 복구의 무한 루프에 빠지는 것이다.
그 밖의 잠재적 이슈로는 FFI 경계에서의 패닉에 유의해야 한다는 점(ABI의 -unwind 계열을 써야 하며, 패닉과 다른 언어의 예외 메커니즘 간 상호작용은 정의되지 않음), 이중 패닉(패닉 언와인딩 중 다시 패닉)이 발생하면 프로세스가 abort된다는 점, 그리고 프로그램을 panic=abort로 빌드하면 동작이 달라진다는 점이 있다.
완벽한 해답은 없다. 코드를 철저히 패닉 프리로 만드는 것은 가능하지만, 많은 노력과 비용이 들며 특정 상황에서만 현실적이다. 대부분의 코드에서는 패닉을 최소화하고 패닉을 처리하는 전략이 좋은 해법이지만, 사람들이 보통 생각하는 것보다 손이 많이 간다. 프로그램이 패닉하도록 내버려 두는 것이 괜찮은 상황도 있지만, 정말 괜찮은지 스스로를 속이는 건 아닌지 확인하고, 패닉 발생 빈도에 대해서도 현실적인 기대를 가져야 한다(‘절대 안 일어난다’가 아님).
패닉을 잊고 해피 패스만 생각하면 되는 해법은 없다. 패닉이 ‘안전하다’고는 하지만, 러스트로 프로그래밍할 때 여전히 패닉을 염두에 두어야 한다. 모든 진지한 프로젝트는 상위 수준 설계의 일부로서 항상 패닉 전략을 가져야 한다. 패닉은 암묵적인 엣지 케이스이며, 러스트 코드를 작성하거나 리뷰할 때 항상 마음에 새겨 두어야 한다.