목차8개 항목
출처와 참고
Scott Wlaschin의 「Thirteen ways of looking at a turtle」 연재와 부록(2015, F#)을 바탕으로 언어 중립적으로 재구성한 노트다.
왜 같은 문제를 여러 번 푸나
터틀 그래픽은 「move 100, turn 90, penUp, penDown, setColor」만 있는 아주 작은 문제다. 그래서 문제의 복잡성을 빼고 설계 선택만 비교할 수 있다. 열다섯 가지 구현은 두 질문에 대한 서로 다른 답이다.
- 상태(위치, 각도, 펜 상태)를 누가 쥐는가? 객체, 호출자, API 경계, 에이전트, 이벤트 저장소.
- 사용하는 쪽을 구현에서 어떻게 떼어 내는가? 떼지 않음, 인터페이스, 함수 매개변수, 데이터(커맨드), 인터프리터.
열다섯 가지 설계
상태를 어디에 두나
| # | 설계 | 상태를 쥐는 곳 | 장점 | 단점 |
|---|---|---|---|---|
| 1 | 객체 지향 클래스 | 객체 안의 가변 필드 | 만들고 이해하기 가장 쉽다 | 테스트하려면 객체를 알려진 상태로 맞춰야 한다. 사용자가 구현 클래스에 묶인다 |
| 2 | 불변 상태 + 순수 함수 모듈 | 호출자. 함수는 상태를 받아 새 상태를 돌려준다 | 준비 없이 테스트된다. 전역 상태가 없어 다른 맥락에서 재사용된다 | 호출자가 상태를 매번 넘겨야 한다. 상태 내부가 공개돼 사용자가 필드에 기댈 수 있다 |
| 14 | 추상 자료형(ADT) | 호출자가 들고 다니지만 안을 볼 수 없는 불투명 타입 | 1의 캡슐화와 2의 불변성을 함께 얻는다. 구현을 안전하게 바꿀 수 있다 | 호출자가 여전히 상태를 관리한다. 구현을 갈아 끼울(주입) 수 없다 |
| 3 | 객체 지향 코어 + API | API 뒤의 상태 있는 객체. API는 "move 100" 같은 문자열을 검증해 넘긴다 | 사용자가 구현을 모른다. 경계에서 검증한다 | 코어가 상태를 가져 테스트가 어렵다 |
| 4 | 함수형 코어 + API | API 경계의 가변 변수 하나. 코어는 2의 순수 함수 | 상태는 경계에만 있고 코어는 무상태라 테스트가 쉽다 | API가 여전히 한 구현에 묶인다 |
| 5 | 에이전트(액터) + 메시지 큐 | 메시지를 처리하는 재귀 루프의 매개변수. 가변 변수가 아예 없다 | 락 없이 상태를 보호한다. 본래 비동기이고 수평 확장이 쉽다. 큐의 메시지 타입이 양 끝을 떼어 놓는다 | 에이전트도 결국 상태를 가진 객체라 추론·테스트가 어렵다. 에이전트끼리 의존 그물이 생기기 쉽고, 감독(supervisor)·하트비트·배압까지 갖추면 복잡하다 |
4번은 흔히 함수형 코어, 명령형 셸(functional core, imperative shell) 이라 부르는 구조다. 부수 효과와 상태를 바깥 껍질에 몰고 안쪽은 순수하게 둔다.
구현과 떼어 내기
| # | 설계 | 결합을 끊는 수단 | 장점 | 단점 |
|---|---|---|---|---|
| 6 | 인터페이스로 의존성 주입 | 인터페이스(또는 함수 레코드)를 생성자에 넘긴다 | 구현과 분리된다. 함수 레코드는 복제가 쉽고 무상태다 | 인터페이스는 한 덩어리라 관계없는 메서드가 붙기 쉽다(인터페이스 분리 원칙 위반). 조합이 안 된다. 기존 클래스를 고쳐 인터페이스를 붙여야 한다 |
| 7 | 함수로 의존성 주입 | 쓰는 지점에서 함수를 매개변수로 넘긴다. 부분 적용으로 미리 묶어 둔다 | 함수 매개변수 하나가 그대로 「메서드 하나짜리 인터페이스」다. 의존성이 눈앞에 보여(in your face) 함부로 늘지 않는다. IoC 컨테이너가 필요 없다 | 의존 함수가 넷을 넘으면 매개변수가 번거롭다. 이때는 함수 하나에 커맨드 타입을 넘긴다 |
| 15 | 능력 기반(capability-based) | 현재 상태에서 할 수 있는 동작만 함수로 돌려준다. 막힌 방향으로는 move 함수 자체가 없다 | API를 잘못 쓸 수 없다. 기능을 없애도 사용자 코드가 깨지지 않는다(REST의 HATEOAS와 같은 생각) | 구현이 복잡하다. 사용자는 매번 함수가 있는지 확인해야 한다. 직렬화가 어렵다 |
일괄 처리와 이벤트
| # | 설계 | 방식 | 장점 | 단점 |
|---|---|---|---|---|
| 8 | 상태 모나드 | 상태 전달을 숨긴 「터틀 워크플로」 안에서 명령형처럼 쓴다 | 불변성을 지키면서 명령형 코드처럼 읽힌다. 워크플로끼리 조합된다 | 상태를 직접 넘기는 것보다 복잡하다. 모나드를 여러 겹 쌓으면 다루기 어렵다 |
| 9 | 커맨드 객체 일괄 처리 | 즉시 호출하지 않고 Move 100, Turn 90 같은 커맨드 데이터의 목록을 만들어 한 번에 실행한다 | 모나드보다 단순하다. 실행하는 함수 하나만 구현에 묶이고 나머지는 데이터만 만든다 | 일괄 처리 전용이다. 앞 명령의 결과에 따라 다음 명령이 달라지면 쓸 수 없다 |
| 10 | 이벤트 소싱 | 현재 상태를 저장하지 않고 지난 이벤트 목록을 다시 적용해 만든다. 커맨드 → 검증 → 이벤트 → 상태 | 전체 이력이 남는다. 이벤트를 다시 재생해 과거 상태나 새 뷰를 만든다 | 이벤트 스키마를 바꾸기 어렵고, 재생 비용이 든다(이벤트 기반 아키텍처) |
| 11 | 스트림 처리 | 이벤트 스트림에 반응하는 처리기가 새 결정을 내린다 | 비즈니스 로직을 과거 이벤트에 대한 반응으로 쓴다 | 흐름이 코드에 드러나지 않는다 |
| 12 | 모나드 제어 흐름 | 명령이 실패할 수 있을 때(벽에 부딪힘) 결과를 보고 다음 명령을 고른다 | 실패를 값으로 다루며 분기한다 | 결과 타입과 상태 워크플로를 겹쳐야 해 복잡해진다 |
| 13 | 인터프리터 | 프로그램을 「명령 + 그 결과를 받아 다음을 정하는 함수」라는 데이터로 만들고, 실행은 인터프리터가 한다. 거의 프리 모나드(free monad)다 | 터틀 프로그램과 구현이 완전히 분리된다. 같은 프로그램을 실제 그리기, 로그, 테스트용 가짜 인터프리터로 돌린다 | 가장 복잡하다. 언어 지원이 없으면 코드가 장황하다 |
행동이 아니라 데이터로 떼어 내기
에이전트(5), 함수 주입(7), 커맨드 일괄 처리(9), 이벤트 소싱(10), 인터프리터(13)는 모두 API 호출 하나마다 경우 하나를 가진 커맨드 타입(합 타입, discriminated union)을 쓴다. 우연이 아니다.
| 관점 | 무엇으로 결합을 끊나 | 웹 서비스에 비유하면 |
|---|---|---|
| 객체 지향 | 캡슐화된 행동 묶음(인터페이스)을 공유한다 | RPC |
| 함수형 | 양쪽이 합의한 공통 데이터 타입을 공유한다. 그 타입을 내는 함수와 받는 함수는 그냥 합성하면 이어진다 | 메시지 기반 API |
데이터로 떼어 내면 다음이 쉬워진다.
- 조합이 간단하다. 데이터를 내는 함수와 받는 함수를 합성하면 끝이다. 인터페이스는 조합하기 어렵다.
- 나중에 끼워 넣을 필요가 없다. 모든 함수가 이미 떼어진 상태다. 리팩터링 때 인터페이스를 덧붙이지 않고, 기껏해야 데이터 변환 함수 하나를 더한다.
- 원격으로 옮기기 쉽다. 데이터는 바로 직렬화되므로 코드를 별도 서비스로 나눌 때 그대로 메시지가 된다.
- 안전하게 진화한다. 커맨드를 하나 더하거나 빼면 그 타입을 쓰는 모든 곳이 컴파일 오류로 알려 준다(망라 검사). 기존 코드를 깨지 않으려면 protobuf처럼 버전에 강한 형식을 쓴다.
같은 생각이 프런트엔드의 리듀서·액션 객체, 백엔드의 커맨드 패턴과 메시지 큐에도 있다.
어떻게 고르나
| 상황 | 출발점 |
|---|---|
| 작은 스크립트, 혼자 쓰는 코드 | 1 또는 2. 그 이상은 과설계다 |
| 테스트하기 쉬운 업무 로직 | 4(함수형 코어 + 경계의 상태), 의존성은 7(함수 주입) |
| 동시 접근하는 상태를 락 없이 | 5(에이전트·액터) |
| 이력·감사가 필요하다 | 10(이벤트 소싱) |
| 같은 로직을 실제·테스트·시뮬레이션으로 돌려야 한다 | 13(인터프리터) 또는 그보다 단순한 9(커맨드 목록) |
| 상태에 따라 허용 동작이 바뀌는 공개 API | 15(능력 기반) |
정리
- 설계 선택은 「상태를 누가 쥐나」와 「구현과 어떻게 떼어 내나」로 정리된다.
- 상태는 경계로 밀어내고 코어는 순수하게 두면 테스트가 쉬워진다.
- 의존성은 쓰는 자리에서 함수로 넘기면 눈에 보여 덜 늘어난다.
- 행동(인터페이스)보다 데이터(커맨드 타입)로 떼어 내면 조합·직렬화·진화가 쉽다.
- 단순한 문제에 복잡한 설계를 쓰지 않는다. 각 설계는 특정 요구가 생겼을 때의 답이다.
관련 노트: 프로그래밍 패러다임, 소프트웨어 복잡성, 함수형 프로그래밍, 이벤트 기반 아키텍처, GoF 디자인 패턴, 책임 주도 설계