
솔직히 말해, 지난주 코드 리뷰를 하다가 혈압이 올랐습니다. 비즈니스 로직의 분기 처리를 위해 try-catch 블록을 if-else처럼 사용하는 코드를 발견했기 때문입니다. 주니어 개발자들이 흔히 하는 착각 중 하나가 "예외 처리를 꼼꼼하게 하면 안정적인 시스템"이라고 믿는 것입니다. 하지만 삼성전자 시절 SSD 펌웨어를 다룰 때나, 지금 NVIDIA에서 데이터센터 인프라를 설계할 때나 변하지 않는 진리가 하나 있습니다. 예외(Exception)는 말 그대로 '예외적인 상황'을 위해 존재하는 것이지, 당신의 프로그램 제어 흐름(Control Flow)을 위해 존재하는 것이 아닙니다. 예외가 발생할 때마다 일어나는 Stack Unwinding 비용과 Latency 스파이크를 고려하지 않는다면, 당신은 엔지니어가 아니라 그저 코더일 뿐입니다. 오늘은 C# 생태계에서 점차 주류로 자리 잡고 있는 Result 패턴, 즉 모나드(Monad)를 활용한 명시적 에러 처리에 대해 이야기하려 합니다.
우리가 흔히 작성하는 레거시 코드를 봅시다. C#에서 실패 가능성이 있는 로직을 짤 때 가장 흔한 패턴은 암묵적인 예외 처리입니다. 사용자 ID를 파싱하고, DB에서 조회하고, 상태를 변경하는 과정에서 각각 FormatException, InvalidOperationException 등을 던집니다. 이렇게 작성된 코드는 겉보기엔 깔끔해 보일지 몰라도, 호출하는 상위 계층에서는 어떤 예외가 튀어나올지 전혀 알 수 없습니다. 결국 상위 메서드는 거대한 try-catch 블록으로 방어적인 코딩을 하게 되고, 이는 디버깅을 지옥으로 만듭니다. 게다가 비즈니스 로직상 충분히 예상 가능한 실패(예: '사용자 없음', '이미 비활성화됨')를 시스템 에러인 예외로 처리하는 것은 리소스 낭비입니다. 예외 객체를 생성하고 스택 트레이스를 캡처하는 비용은 단순한 조건 분기보다 훨씬 비쌉니다. 초당 수만 건의 트랜잭션을 처리하는 고성능 서버에서 이런 방식은 Throughput을 갉아먹는 주범입니다.
그렇다고 해서 C언어 시절처럼 모든 함수가 에러 코드를 리턴하고, 호출할 때마다 if (result < 0) return error; 식의 가드 절(Guard Clause)을 남발하는 것이 정답일까요? 이것 역시 코드를 지저분하게 만들고 가독성을 해칩니다. 비즈니스 로직의 핵심이 에러 검사 코드에 파묻혀 보이지 않게 되기 때문입니다. 여기서 등장하는 것이 바로 함수형 프로그래밍의 개념을 차용한 Result 모나드입니다. 수학적인 정의나 카테고리 이론을 몰라도 상관없습니다. 핵심은 간단합니다. 성공 시의 값(Ok)과 실패 시의 에러(Fail)를 하나의 객체(Result<TSuccess, TError>)에 담아 리턴하는 것입니다. 그리고 Bind (또는 SelectMany) 연산자를 통해 이 결과들을 체인처럼 연결합니다.
이 패턴의 강력함은 '철도 지향 프로그래밍(Railway Oriented Programming)'이라 불리는 흐름 제어에서 나옵니다. Bind 메서드는 이전 단계가 성공했을 때만 다음 로직을 실행합니다. 만약 첫 번째 단계에서 실패(Fail)가 반환되면, 이후의 모든 로직은 자동으로 생략(Short-circuiting)되고 에러가 끝까지 전파됩니다. 개발자는 성공했을 때의 로직만 순차적으로 작성하면 됩니다. 실패 처리는 Bind 내부에서 알아서 분기해주기 때문입니다. 결과적으로 코드는 선형적으로 읽히며, 에러 처리는 구조적으로 격리됩니다. 마지막 경계(Boundary) 지점에서 Match 함수를 통해 성공과 실패를 명시적으로 분리하여 처리하면 끝입니다. 이는 코드를 작성하는 시점에 실패 가능성을 타입 시스템 수준에서 강제한다는 점에서, 런타임에 터져봐야 아는 예외 처리 방식보다 훨씬 안전합니다.
물론 맹목적인 적용은 금물입니다. 저는 하드웨어 리소스에 대한 이해 없이 무거운 라이브러리를 가져다 쓰는 것을 극도로 혐오합니다. 모든 예외를 Result로 바꿀 필요는 없습니다. 데이터베이스 연결이 끊어지거나 메모리가 부족한 것 같은 '인프라 레벨의 진짜 예외'는 여전히 Exception으로 처리하는 것이 맞습니다. 하지만 '유효하지 않은 입력', '조건 불만족' 같은 도메인 로직의 실패는 반드시 Result 패턴으로 처리해야 합니다. C#의 record 타입과 패턴 매칭을 활용하면 Result 타입을 매우 가볍고 직관적으로 구현할 수 있습니다. 이는 단순히 코드 스타일의 문제가 아니라, 예측 가능한 시스템을 만들기 위한 설계의 문제입니다.
결론적으로, try-catch 뒤에 숨지 마십시오. 당신이 만든 함수가 실패할 수 있다는 사실을 시그니처(Signature)에 명시적으로 드러내십시오. 그것이 호출하는 동료 개발자에 대한 예의이자, 새벽 3시에 장애 알림을 받지 않는 가장 확실한 방법입니다. 엔비디아에서도, 그리고 그 어떤 고성능 시스템에서도 '우연히 돌아가는 코드'는 없습니다. 실패를 명확하게 정의하고, 그 실패를 값(Value)으로 다루는 것. 그것이 시니어 엔지니어로 가는 첫걸음입니다. 지금 당장 당신의 코드에서 비즈니스 로직을 제어하고 있는 throw 문을 찾아보십시오. 그리고 그것을 Result.Fail()로 바꿀 수 있는지 고민해 보시길 바랍니다.


