Deterministic Core, Non-Deterministic Shell
결정론적 코어, 비결정론적 셸
Functional Core/Imperative Shell 구조를 결정론적 코어와 비결정론적 셸로 확장해 설명합니다. 외부 세계와 맞닿은 코드를 셸에 모으고 비즈니스 로직을 결정론적으로 분리하면, 순수 함수형 프로그래밍을 쓰지 않아도 테스트 범위와 신뢰도를 높일 수 있다고 제안합니다.
- 주제
AI 요약
Gary Bernhardt가 14년 전 제시한 Functional Core/Imperative Shell 구조는 코드를 두 영역으로 나눕니다. Functional Core는 입출력과 파괴적 상태 변경 없이 애플리케이션의 비즈니스 로직을 처리합니다. Imperative Shell은 상태를 관리하고 외부 의존성을 조정하며 데이터베이스, 네트워크, 운영체제, GUI처럼 바깥 세계와 상호작용합니다. 셸은 값을 코어에 전달하고, 코어가 블랙박스처럼 반환한 결정 결과를 사용해 외부 시스템을 갱신합니다.
■ Functional Core와 Imperative Shell
두 영역은 실행 방식도 다릅니다. 코어는 여러 분기와 의사결정을 담당하고 외부 환경에서 격리됩니다. 셸은 상대적으로 선형적인 실행 흐름을 가지며 의존성을 조정합니다. 코어가 순수 함수로 작성되면 같은 입력에 늘 같은 결과가 나오고, 외부 환경을 직접 참조하지 않으므로 mock이나 stub이 필요하지 않습니다. 복잡한 비즈니스 규칙이 코어에 모이면 테스트가 프로그램의 동작을 상당히 넓게 설명합니다.
글에서는 이 구조를 Functional Core/Imperative Shell보다 넓은 개념인 Deterministic Core/Non-Deterministic Shell로 다시 설명합니다. 순수 함수의 장점은 결정론에서 나옵니다. 입력값의 흐름이 같으면 출력값의 흐름도 반복해서 같아집니다. 다만 결정론이 반드시 순수 함수형 프로그래밍을 요구하지는 않습니다.
■ 명령형 상태 머신도 결정론적입니다
글에서는 두 가지 덧셈 예시를 제시합니다. add(ns) 함수는 배열의 값을 순회하며 합계를 계산하고, [1, 2, 3]을 입력하면 6을 반환합니다. 이 함수는 상태를 외부에 노출하지 않는 순수 함수이므로 동작을 추론하기 쉽습니다.
반면 AddMachine 클래스는 내부 상태를 0으로 시작한 뒤 transition(input)이 호출될 때마다 입력값을 상태에 더합니다. transition(1), transition(2), transition(3)을 같은 순서로 호출하면 최종 상태는 역시 6입니다. 클래스가 명령형 방식으로 상태를 변경해도, 같은 호출 순서에 같은 결과를 내놓는다면 결정론적입니다. 순수 함수형 프로그래밍을 그대로 적용하기 어려운 언어나 성능 조건에서도 결정론을 기준으로 코어를 설계하면 테스트 가능성을 유지할 수 있습니다. 글에서는 특히 C에서 순수 함수형 방식을 시도하고 싶지는 않다고 설명합니다.
■ 비결정론적 동작은 셸에 둡니다
반복할 때 결과가 달라질 수 있는 동작은 비결정론적 셸에 속합니다. 시드 없이 난수 생성기를 호출하는 일, 비동기 및 멀티스레드 작업, 네트워크 통신, 다른 프로세스와의 통신, 로컬 저장소 읽기와 쓰기, 데이터베이스 상호작용, 운영체제에 날짜와 시간을 묻는 일이 여기에 포함됩니다.
이런 동작이 비즈니스 로직 안에 섞여 있으면 분리 지점으로 삼습니다. 비결정론적 동작을 기준으로 함수를 둘로 나누거나, 한 계층 위로 올린 뒤 결과를 매개변수로 주입합니다. 셸은 애플리케이션의 바깥을 둘러싼다는 비유도 함께 제시합니다. 셸이 외부 환경에서 필요한 값을 얻고, 코어에 전달해 판단을 받은 다음 다시 외부 세계와 상호작용하는 구조입니다.
■ 기존 코드에서 결정론을 모읍니다
모든 시스템이 처음부터 코어와 셸을 명확히 분리해 작성되지는 않습니다. 글은 FoundationDB처럼 처음부터 이런 구분을 실천하는 프로젝트만 있는 것이 아니며, 실제 코드베이스 대부분은 결정론과 비결정론이 강하게 뒤섞여 있다고 말합니다. 레거시 코드나 정리되지 않은 코드에서도 완벽한 구조를 한 번에 만들 필요는 없다고 설명합니다.
평범한 코드베이스에는 결정론적 코어가 흩어져 있습니다. 파일 전체일 수도 있고, 클래스 하나일 수도 있으며, 함수 안의 몇 줄일 수도 있습니다. 글은 이 조각들을 찾아 모으는 과정을 오래된 Windows의 Disk Defragmenter에 빗댑니다. 과거 디스크 조각 모음 도구가 하드 디스크 여러 위치에 흩어진 파일을 연속된 영역으로 옮겼듯이, 코드 안에 흩어진 결정론적 로직을 찾아 하나의 테스트 가능한 덩어리로 모으자는 제안입니다.
결정론적 코드가 한곳에 더 많이 모일수록 테스트하기 쉬운 기능이 늘어납니다. 반대로 테스트하기 어려운 비결정론적 코드의 범위는 줄어듭니다. 큰 코드베이스에서 결정론적 코어 하나만 남기는 일은 현실적으로 어렵지만, 수천 개로 흩어진 조각을 수백 개 수준으로 줄이는 것만으로도 차이가 있다고 설명합니다.
■ 상태 머신을 찾아냅니다
글의 마지막 제안은 복잡하고 지저분한 코드베이스 안에서도 결정론적인 상태 머신을 찾으라는 것입니다. 상태 전이와 입력에 따른 결과를 분리하면, 외부 시스템과의 상호작용 없이 내부 동작을 테스트할 영역이 드러납니다. 그런 부분을 조금씩 분리하면 소프트웨어의 변경과 테스트가 쉬워지고, 동작의 신뢰도를 높이는 작업을 단계적으로 진행할 수 있다고 설명합니다.
■ Lobsters 반응
- @eugeny — sans-io라고도 부릅니다. 또 async의 ‘function color’ 자체가 본질적으로 비결정론의 지표이기도 합니다. 물론 cooperative multitasking은 예외일 수도 있습니다.
원문: Outdata / 번역·요약: Trawling