설계 동기
풀고자 하는 문제
섹션 제목: “풀고자 하는 문제”엔터프라이즈 .NET 애플리케이션을 개발하면서 반복적으로 마주치는 세 가지 구조적 문제가 있습니다. 이 문제들은 프로젝트 초기에는 잘 드러나지 않지만, 서비스가 성장하고 팀이 확장되면서 비용으로 전환됩니다.
1. 도메인 로직에 예외와 암묵적 사이드 이펙트가 섞여 있다
섹션 제목: “1. 도메인 로직에 예외와 암묵적 사이드 이펙트가 섞여 있다”다음과 같은 서비스 코드를 작성하고 있다면, 이 문제에 해당합니다.
public OrderResult PlaceOrder(OrderCommand cmd){ var product = _productRepo.GetById(cmd.ProductId) ?? throw new NotFoundException("상품을 찾을 수 없습니다.");
if (product.Stock < cmd.Quantity) throw new BusinessException("재고가 부족합니다.");
var order = new Order(cmd.ProductId, cmd.Quantity, product.Price); _orderRepo.Save(order); _logger.LogInformation("주문 생성: {OrderId}", order.Id);
return new OrderResult(order.Id);}이 코드는 기능적으로 동작합니다. 단위 테스트도 통과하고, 코드 리뷰에서도 특별한 지적을 받지 않습니다.
그러나 서비스가 성장하고, 팀이 확장되고, 유스케이스가 늘어나면 다음과 같은 문제가 나타납니다.
예외가 흐름 제어에 사용됩니다. “상품 없음”과 “재고 부족”은 예외적 상황이 아니라 비즈니스 규칙의 실패입니다. 그런데 예외로 처리되면 호출자는 try-catch로 분기해야 하고, 어떤 예외가 던져지는지 시그니처만으로는 알 수 없습니다. 실패 경로가 타입에 드러나지 않으므로, 새로운 팀원이 코드를 읽을 때 “이 메서드가 실패할 수 있는가?”라는 질문에 구현을 열어봐야 답할 수 있습니다.
검증 로직이 흩어집니다. 할인 정책, 배송 제한, 회원 등급 검증이 추가될 때마다 if-throw 블록이 서비스 메서드 곳곳에 삽입됩니다. 동일한 검증이 여러 유스케이스에서 반복되고, 검증 순서에 따라 결과가 달라지는 미묘한 버그가 발생합니다. 재고 검증을 할인 검증보다 먼저 실행하면 할인 불가 상품인데도 “재고 부족” 오류가 먼저 반환되는 식입니다.
합성이 불가능합니다. 두 개의 검증을 조합하거나, 실패 시 대안 경로를 선택하는 구조를 만들려면, 예외 기반 코드에서는 중첩된 try-catch가 필요합니다. 이는 읽기 어렵고 테스트하기 어렵습니다. 검증 A와 B를 모두 실행하여 실패를 한꺼번에 수집하고 싶어도, 첫 번째 예외에서 실행이 중단되므로 불가능합니다.
사이드 이펙트가 암묵적으로 발생합니다. 위 코드에서 _orderRepo.Save(order)와 _logger.LogInformation()은 외부 상태를 변경하는 사이드 이펙트입니다. 그러나 메서드 시그니처 OrderResult PlaceOrder(OrderCommand cmd)만 보면 순수 함수처럼 보입니다. 이 메서드가 데이터베이스에 쓰기를 수행하는지, 외부 API를 호출하는지는 구현을 읽어야만 알 수 있습니다.
이러한 문제들은 코드의 양이 적을 때는 관리 가능합니다. 그러나 유스케이스가 수십 개로 늘어나고, 각 유스케이스마다 5~10개의 검증 규칙이 존재하는 규모에서는 예외 기반 흐름 제어가 시스템의 예측 가능성을 구조적으로 저해합니다.
Functorium은 이 문제를 Fin<T>로 해결합니다. 성공과 실패를 타입 수준에서 명시하고, from ... in ... select LINQ 합성으로 예외 없이 도메인 흐름을 조립합니다. 검증 규칙은 ValidationRules<T>로 선언적으로 합성되어, 흩어진 if-throw 블록을 하나의 파이프라인으로 통합합니다. FinT<IO, T>는 사이드 이펙트를 타입 수준에서 추적하여, 순수 함수와 부수 효과를 가진 함수를 시그니처만으로 구분할 수 있게 합니다.
2. 개발 언어와 운영 언어가 분리되어 있다
섹션 제목: “2. 개발 언어와 운영 언어가 분리되어 있다”주문 시스템에서 상태를 관리하는 코드가 있다고 가정합니다.
public enum OrderStatus{ Pending, Confirmed, Shipped, Delivered, Cancelled}개발팀은 OrderStatus.Confirmed를 사용합니다. 그런데 운영팀의 장애 대응 매뉴얼에는 “주문확정” 또는 “확정 상태”라고 기재되어 있고, CS팀의 고객 응대 스크립트에는 “결제 완료”라고 되어 있습니다. 데이터베이스에는 숫자 코드 1로 저장됩니다. 하나의 도메인 개념에 네 가지 표현이 존재합니다.
이 상태에서도 시스템은 정상 동작합니다. 각 팀은 자신의 맥락에서 올바른 용어를 사용하고 있고, 일상적인 업무에서는 문제가 드러나지 않습니다.
그러나 장애가 발생했을 때 이 차이가 비용으로 전환됩니다.
로그 검색이 실패합니다. 운영팀이 Seq에서 “주문확정”을 검색하면 결과가 없습니다. 코드에서는 Confirmed라는 영문 열거형 값으로 기록되기 때문입니다. “그 로그 키워드가 뭐였지?”라는 질문이 Slack에 올라오고, 개발자가 코드를 확인해서 답변하기까지 대응이 지연됩니다. 새벽 장애 상황에서 이 지연은 MTTR(Mean Time To Recovery)에 직접 반영됩니다.
오류 코드 체계가 이중으로 관리됩니다. API는 INSUFFICIENT_STOCK을 반환하고, 운영 위키에는 “에러코드 E-1042: 재고 부족”이라고 정리되어 있습니다. 새로운 오류가 추가될 때 API 코드는 갱신되지만 위키는 갱신되지 않아, 두 문서 사이의 간극이 점점 벌어집니다. 반년이 지나면 운영 위키를 신뢰하는 사람은 없지만, 대안도 없어 문서는 방치됩니다.
도메인 개념의 해석이 분기합니다. “주문 취소”가 Cancelled 상태만을 의미하는지, Refunded까지 포함하는지에 대해 개발팀과 운영팀의 해석이 다릅니다. 이런 해석 차이는 코드 리뷰에서 발견되지 않고, 운영 사고 보고서에서 비로소 드러납니다. “취소 건수” 메트릭이 팀마다 다른 숫자를 보여주고, 어느 쪽이 맞는지 확인하는 데 또다시 시간이 소모됩니다.
이 문제의 근본 원인은 코드와 문서, 운영 지표가 서로 다른 언어 체계를 사용하는 데 있습니다. 언어를 통일하자는 합의는 쉽지만, 실제로 유지하기는 어렵습니다. 코드가 변경될 때 문서가 자동으로 따라오지 않기 때문입니다.
Functorium은 단일 도메인 언어(Ubiquitous Language)를 코드에 내재화하여 해결합니다. Bounded Context를 명확히 정의하고, 도메인 개념이 코드, 문서, 운영 지표에 일관되게 반영되는 구조를 제공합니다. 오류 코드는 타입에서 자동 생성되므로, 별도의 문서 동기화가 필요하지 않습니다.
3. Observability가 사후 보완으로 추가된다
섹션 제목: “3. Observability가 사후 보완으로 추가된다”릴리스 후 일주일이 지나 특정 시간대에 응답 시간이 급격히 증가하는 현상이 보고됩니다. 어떤 유스케이스에서 병목이 발생하는지 확인하려 하지만, 대부분의 API에는 응답 시간을 기록하는 로그가 없습니다. 분산 추적(Distributed Tracing)도 설정되어 있지 않아, 요청이 어떤 서비스를 거쳐 어디에서 지연되는지 파악할 수 없습니다.
개발 일정에서 Observability는 항상 후순위였습니다. “기능 먼저 완성하고, 모니터링은 나중에”라는 판단이 반복되었고, “나중에”는 장애가 발생한 후에야 도래합니다.
이때 흔히 발생하는 대응 패턴은 다음과 같습니다.
public async Task<OrderResult> PlaceOrder(OrderCommand cmd){ var sw = Stopwatch.StartNew(); _logger.LogInformation("PlaceOrder 시작: {@Command}", cmd);
// ... 기존 비즈니스 로직 ...
sw.Stop(); _logger.LogInformation("PlaceOrder 완료: {Elapsed}ms", sw.ElapsedMilliseconds); return result;}긴급 패치로 Stopwatch와 로그를 삽입하고, 다시 배포합니다. 당장의 문제는 파악할 수 있게 되지만, 이 패턴이 반복되면서 코드에 구조적 문제가 누적됩니다.
로깅 코드가 비즈니스 로직을 잠식합니다. 각 유스케이스마다 시작/종료 로그, 경과 시간 측정, 입력값 직렬화 코드가 반복 삽입됩니다. 비즈니스 로직 10줄에 관측 코드가 15줄 붙는 상황이 드물지 않습니다. 코드 리뷰에서 “이 메서드의 핵심 로직이 무엇인가?”라는 질문에 답하기가 점점 어려워집니다.
관측 범위가 불균일합니다. 긴급 패치로 추가된 로깅은 문제가 발생한 API에만 적용됩니다. 장애가 다른 API에서 재발하면 같은 작업을 반복합니다. 어떤 API에는 상세한 로그가 있고, 어떤 API에는 아무것도 없는 비대칭 상태가 고착됩니다. 장애 원인이 로그가 없는 API에 있으면, 원인 파악을 위해 먼저 로그를 추가하고 재배포해야 하는 이중 작업이 발생합니다.
사후 보완은 구조적으로 누락을 허용합니다. “모든 유스케이스에 로깅을 추가하자”는 합의가 이루어져도, 새로 작성되는 유스케이스에서 로깅이 빠지는 것을 컴파일러가 잡아주지 않습니다. 코드 리뷰에 의존하는 관측 품질은 리뷰어의 피로도에 비례하여 저하됩니다. 결과적으로 관측 가능성은 “있을 수도 있고 없을 수도 있는” 불확실한 속성이 됩니다.
Functorium은 Observability를 설계 단계부터 내재화하여 이 문제를 해결합니다. OpenTelemetry 기반의 Logging, Metrics, Tracing이 Mediator Pipeline에 자동 적용됩니다. 유스케이스를 파이프라인에 등록하는 것만으로 관측 정보가 수집되므로, 개별 메서드에 로깅 코드를 삽입할 필요가 없습니다. 새로운 유스케이스가 추가되어도 관측 범위에 공백이 생기지 않으며, 관측 가능성이 “있거나 없는” 속성이 아니라 프레임워크가 보장하는 기본 속성이 됩니다.
문제 돌파 방향
섹션 제목: “문제 돌파 방향”위의 세 가지 문제는 각각 독립적이지 않습니다. 도메인 로직이 예외로 표현되면 로그에 일관된 도메인 언어를 반영하기 어렵고, Observability가 사후에 추가되면 도메인 흐름과 관측 정보가 따로 놀게 됩니다. Functorium은 이 세 문제를 하나의 아키텍처로 통합하여 돌파합니다.
함수형 아키텍처로 도메인 로직을 순수하게 유지한다
섹션 제목: “함수형 아키텍처로 도메인 로직을 순수하게 유지한다”예외 대신 타입으로 실패를 표현합니다.
Fin<T>는 성공(Succ)과 실패(Fail)를 하나의 타입으로 표현합니다. 메서드 시그니처만으로 “이 연산은 실패할 수 있다”는 사실이 드러나며, 호출자는 패턴 매칭이나 LINQ 합성으로 실패를 처리합니다. 예외는 진짜 예외적 상황(네트워크 장애, 메모리 부족)에만 사용되고, 비즈니스 규칙의 실패는 값으로 전달됩니다.
FinT<IO, T>는 여기에 사이드 이펙트 추적을 더합니다. IO 작업(데이터베이스 조회, 외부 API 호출)이 타입에 명시되므로, 순수 함수와 부수 효과를 가진 함수가 시그니처 수준에서 구분됩니다. IO 모나드는 Timeout, Retry(지수 백오프), Fork(병렬 실행), Bracket(리소스 생명주기 관리) 등 고급 기능을 기본 제공하여, 외부 서비스 호출의 장애 내성을 타입 안전하게 구성할 수 있습니다. 이를 통해 도메인 로직은 순수하게 유지되고, 사이드 이펙트는 파이프라인의 경계에서 관리됩니다.
단일 도메인 언어(Ubiquitous Language)로 통합한다
섹션 제목: “단일 도메인 언어(Ubiquitous Language)로 통합한다”코드, 로그, 메트릭이 같은 언어를 사용합니다.
Bounded Context를 명확히 정의하고, 도메인 개념을 코드의 타입, 로그 메시지, 운영 메트릭에 일관되게 반영합니다. 오류 코드는 타입에서 파생되므로 별도의 오류 코드 문서가 필요하지 않습니다. 코드가 곧 문서이고, 로그가 곧 운영 언어입니다.
이 접근은 단순한 네이밍 컨벤션이 아닙니다. 도메인 타입이 로그 구조와 메트릭 레이블을 결정하는 구조를 만들어, 코드를 변경하면 운영 언어가 자동으로 따라오게 합니다.
Observability를 설계 단계부터 내재화한다
섹션 제목: “Observability를 설계 단계부터 내재화한다”관측 코드를 작성하지 않아도 모든 유스케이스가 관측됩니다.
OpenTelemetry 기반의 Logging, Metrics, Tracing이 Mediator Pipeline에 자동 적용됩니다. 유스케이스를 등록하면 관측 정보가 함께 수집되고, 도메인 흐름과 관측 정보가 동일한 구조 안에서 설계됩니다. 새로운 유스케이스가 추가되어도 관측 범위에 공백이 생기지 않으며, 개발자가 관측 코드를 직접 작성하지 않아도 됩니다.
파이프라인이 유스케이스의 시작과 종료, 성공과 실패, 실행 시간을 자동으로 기록하므로, 관측 가능성은 개발자의 의지가 아니라 아키텍처가 보장하는 속성이 됩니다.
이 세 가지 문제를 돌파하기 위한 구체적인 설계 원칙은 설계 철학에서 다룹니다.