앞의 두 장이 그랬듯, 이 장도 “함수형 프로그래밍이란 무엇인가”를 사전처럼 정의하는 대신 그것이 무엇을 빼앗아 어떤 규율을 주는가를 묻는다. 구조적 프로그래밍은 goto를, 객체 지향은 함수 포인터를 향한 직접 접근을 빼앗았다. 함수형 프로그래밍이 빼앗는 것은 하나 더 근본적이다 — 할당(assignment). 변수에 값을 한 번 넣은 뒤 다시 바꾸지 않는다는 것, 즉 불변성이 이 패러다임의 전부다. 얼핏 문법적 취향처럼 들리는 이 제약이 왜 아키텍처의 문제가 되는가. 답은 한 문장으로 압축된다 — 모든 동시성 문제는 가변 상태에서 온다.
함수형은 할당을 빼앗는다
먼저 이 제약이 무엇인지 눈으로 보자. 1부터 25까지의 제곱을 구하는 흔한 코드를 떠올려 보라. 명령형 언어라면 대개 이렇게 짠다.
for (int i = 0; i < 25; i++) {
squares[i] = i * i; // i가 매 반복마다 다시 할당된다
}i라는 변수 하나가 0, 1, 2… 로 계속 값을 바꿔 가며 루프를 돈다. 이것이 할당이다. 함수형 언어에서는 이 재할당이 없다. i는 한 번 이름에 묶이면 끝까지 그 값이다. 대신 1부터 25까지의 목록을 만들고 각 원소에 “제곱” 함수를 씌워 새 목록을 얻는다. 원래 목록을 건드려 고치는 게 아니라, 입력으로부터 출력을 만들어 낼 뿐이다. 변수는 이름표이지 값을 담았다 비웠다 하는 상자가 아니다.
핵심은 함수형 언어에서 변수는 변하지 않는다는 것이다. 이름이 무색하게도, 함수형의 변수는 수학의 변수처럼 한 번 정해지면 고정된 값을 가리킨다. 마틴이 이 사소해 보이는 사실에 한 장을 통째로 쓰는 이유는, 아키텍트가 신경 써야 할 온갖 골치 아픈 문제가 바로 이 “변하는 변수”에서 비롯되기 때문이다.
판단 기준: 어떤 언어나 스타일이 “무엇을 빼앗는가”를 물으면 그것이 주는 규율이 보인다 — 함수형이 재할당을 빼앗는다는 사실을 붙들면, 그 대가로 얻는 것(동시성 안전)이 어디서 오는지가 드러난다. 함정: 불변성을 “값을 못 바꾸는 불편한 제약”으로만 받아들이면, 그것이 실은 한 무리의 버그를 통째로 불가능하게 만드는 설계 장치라는 점을 놓친다.
모든 동시성 문제는 가변 상태에서 온다
이 장의 심장이다. 여러 스레드가 동시에 도는 시스템을 괴롭히는 문제들을 늘어놓아 보자 — 두 스레드가 같은 자원을 놓고 다투는 경쟁 조건(race condition), 서로가 쥔 것을 기다리며 멈춰 버리는 교착(deadlock), 한쪽의 갱신이 다른 쪽의 갱신에 덮어써지는 동시 갱신 문제. 이것들은 병렬 프로그래밍을 악명 높게 만드는 3대 골칫거리다.
그런데 이 셋을 가만히 들여다보면 공통의 뿌리가 있다. 셋 다 여러 실행 흐름이 하나의 가변 변수를 동시에 읽고 쓰기 때문에 생긴다. 경쟁도, 교착도, 갱신 충돌도, 바꿀 수 있는 상태를 여럿이 공유할 때에만 성립하는 문제다. 만약 어떤 변수도 한 번 정해진 뒤 바뀌지 않는다면 — 다툴 대상이 없고, 잠글 대상이 없고, 덮어쓸 대상이 없다. 가변 상태가 사라지는 순간 이 문제들은 애초에 발생할 수 없는 것이 된다. 락도, 세마포어도, 정교한 동기화 프로토콜도 필요 없어진다. 그것들은 전부 “가변 상태를 여럿이 안전하게 공유하는 법”을 다루는 도구인데, 공유할 가변 상태가 없으면 다룰 것도 없기 때문이다.
이것이 함수형 프로그래밍이 아키텍처에 던지는 진짜 화두다. 멀티코어가 당연해지고 시스템이 점점 더 병렬로 도는 시대에, 동시성은 더 이상 특수한 관심사가 아니라 모든 시스템의 밑바탕 조건이 됐다. 그리고 그 조건에서 가장 위험한 코드는 언제나 가변 상태를 만지는 코드다.
판단 기준: 동시성 버그를 만났을 때 “어떤 락을 더 걸까”를 묻기 전에 “이 공유 상태가 꼭 가변이어야 하는가”를 먼저 물어라 — 불변으로 바꿀 수 있는 상태는 동기화 문제 자체를 소멸시킨다. 함정: 락과 동기화를 촘촘히 두르는 것을 동시성의 “해결”로 여기기 쉽다 — 그것은 가변 상태라는 병을 안고 사는 대증요법일 뿐, 근본 원인은 여전히 자리에 있다.
가변성의 분리 — 다 불변일 순 없다면 오염을 격리하라
물론 현실은 냉정하다. 완전히 불변인 시스템은 실용적이지 않다. 어딘가에는 상태가 바뀌어야 하고 — 화면은 갱신되고 잔고는 오르내린다 — 저장 공간과 처리 능력은 유한하다. 그래서 마틴이 제시하는 타협은 “전부 불변으로 만들라”가 아니라 가변성을 격리하라는 것이다.
방법은 시스템을 두 종류의 컴포넌트로 가르는 것이다. 상태를 바꾸지 않는 불변 컴포넌트와, 상태 변경을 감수해야 하는 가변 컴포넌트. 애플리케이션의 대부분을 불변 컴포넌트에 두고, 상태를 바꾸는 일은 가능한 한 작고 격리된 가변 컴포넌트 안으로 몰아넣는다. 그리고 이 가변 컴포넌트에서 벌어지는 위험한 상태 변경은 트랜잭션 메모리 같은 장치로 보호한다 — 트랜잭션 메모리는 메모리 변수를 마치 데이터베이스가 디스크를 다루듯 트랜잭션으로 다뤄, 동시 갱신과 경쟁으로부터 지켜 준다.
여기서 5장의 교훈이 다시 울린다. 우리는 이미 저수준 세부(DB, UI)를 인터페이스 뒤로 격리해 핵심 정책으로부터 떼어 놓는 법을 봤다. 가변성의 분리도 같은 사고의 연장이다 — 위험하고 다루기 까다로운 것(여기서는 가변 상태)을 좁은 경계 안에 가두고, 시스템의 넓은 나머지 영역은 그 위험으로부터 자유롭게 두는 것. 아키텍처란 결국 무엇을 무엇으로부터 격리할 것인가의 문제이고, 가변 상태는 반드시 격리해야 할 대상 목록의 맨 위에 있다.
판단 기준: 상태 변경이 불가피하다면 그것을 시스템 전역에 흩뿌리지 말고, 변경을 책임지는 최소한의 컴포넌트로 밀어 넣어 그 경계 안에서만 보호하라 — 가변 코드의 면적이 좁을수록 동시성 위험의 면적도 좁아진다. 함정: “어차피 상태는 바뀌어야 하니까”라며 가변성을 아무 데서나 허용하면, 격리라는 방어선 자체가 사라진다 — 불변을 기본값으로 두고 가변을 예외로 허가하는 것과, 그 반대는 시스템의 안전성이 정반대다.
이벤트 소싱 — 상태가 아니라 트랜잭션을 저장한다
가변성 격리를 극단까지 밀면 놀라운 결론에 이른다. 애초에 상태를 저장하지 않으면 어떨까. 이것이 이벤트 소싱의 발상이다.
은행 계좌를 생각해 보자. 보통은 계좌마다 “잔고”라는 필드 하나를 두고, 입금이나 출금이 있을 때마다 그 숫자를 읽어 고쳐 쓴다. 잔고는 전형적인 가변 상태다 — 여럿이 동시에 만지면 갱신 충돌이 나고, 그래서 락이 필요하다. 이벤트 소싱은 이 발상을 뒤집는다. 잔고라는 상태를 저장하지 않고, 입금·출금이라는 트랜잭션만 차곡차곡 쌓는다. 누군가 잔고를 알고 싶어 하면, 계좌 개설 이래의 모든 트랜잭션을 처음부터 훑어 그 자리에서 합산해 낸다.
// 상태를 저장하는 방식 (가변)
balance = balance - 100; // 기존 값을 덮어쓴다 — 갱신 충돌의 씨앗
// 이벤트를 저장하는 방식 (불변, append-only)
transactions.append(Withdrawal(100)); // 기록을 추가만 한다
long balance = transactions.stream() // 잔고는 그때그때 접어서 계산
.mapToLong(Transaction::signedAmount).sum();
무엇이 달라졌나. 트랜잭션 저장소에는 갱신(update)도 삭제(delete)도 없다. 오직 추가(create)와 조회(read)만 있다. CRUD에서 U와 D — 즉 기존 데이터를 바꾸고 지우는, 가변성을 낳는 바로 그 두 연산 — 이 사라진다. 데이터를 고치는 일이 없으니 동시 갱신도, 경쟁도 성립하지 않는다. 상태를 저장하지 않으니 가변 상태 자체가 존재하지 않는 것이다. 이것이 이벤트 소싱이 함수형 사고의 궁극을 보여 주는 지점이다.
여기엔 조건이 붙는다. 개설 이래 모든 트랜잭션을 쌓아 두고 매번 처음부터 접으려면, 저장 공간과 처리 능력이 사실상 무한하다는 가정이 필요하다. 마틴은 이 가정이 예전엔 터무니없었지만 오늘날엔 점점 현실이 되어 간다고 본다(물론 실무에서는 특정 시점의 잔고를 스냅샷으로 저장해 두고 그 뒤부터만 접는 절충을 쓴다). 그리고 사실 우리는 이미 이 방식에 익숙하다 — 소스 코드 버전 관리 시스템이 정확히 이렇게 동작한다. git은 파일의 “현재 상태”를 덮어쓰며 저장하지 않는다. 커밋이라는 변경(트랜잭션)을 append-only로 쌓고, 어느 시점의 파일이든 그 변경들을 접어 재구성한다. 그래서 어떤 과거로도 안전하게 되돌아갈 수 있고, 여러 사람이 동시에 작업해도 이력이 덮어써지지 않는다. 이벤트 소싱은 이 익숙한 원리를 애플리케이션 데이터 전체로 확장한 것이다.
판단 기준: 동시 갱신이 자주 충돌하는 데이터를 만나면 “상태를 덮어쓰는” 모델에서 “변경을 추가만 하는” 모델로 뒤집을 수 있는지 물어라 — append-only는 갱신 충돌이라는 문제의 범주 자체를 없앤다. 함정: 이벤트 소싱을 만능으로 여겨 모든 도메인에 들이대면, 조회할 때마다 전체 이력을 접는 비용과 무한 저장 가정의 대가를 잊는다 — 이것은 가변 상태의 위험이 그 비용을 지불할 만큼 큰 곳에 거는 선택이지, 공짜 우아함이 아니다.
세 패러다임이 그린 하나의 그림
이제 2부 전체를 한 발 물러서서 보면 대칭이 드러난다. 세 패러다임은 저마다 프로그래머에게서 무언가를 빼앗아 규율을 남겼다.
- 구조적 프로그래밍은 goto를 빼앗아 제어 흐름에 규율을 세웠다.
- 객체 지향은 함수 포인터를 향한 직접 접근을 빼앗아 의존성의 방향에 통제권을 주었다.
- 함수형은 할당을 빼앗아 상태의 가변성을 다스렸다.
셋 다 무언가를 더해 준 것이 아니라 덜어 냄으로써 힘을 주었다. 좋은 아키텍처가 흔히 “무엇을 허용할까”보다 “무엇을 금지할까”로 만들어지는 것과 같은 이치다. 함수형이 금지하는 것 — 변수를 두 번 쓰는 일 — 은 그중에서도 가장 근본적이다. 동시성이 모든 시스템의 기본 조건이 된 시대에, 가변 상태를 다스리는 규율은 더 이상 함수형 언어를 쓰는 소수의 취향이 아니라 아키텍트라면 누구나 챙겨야 할 무기가 됐다.
정리하면 이렇다.
- 함수형이 빼앗는 것은 할당이다 — 변수는 한 번 정해지면 바뀌지 않는다.
- 경쟁·교착·동시 갱신, 즉 모든 동시성 문제는 여럿이 공유하는 가변 상태에서 온다. 상태가 불변이면 이 문제들은 발생 자체가 불가능해진다.
- 전부 불변으로 만들 수 없다면 가변성을 최소한의 컴포넌트로 격리하고, 그 안에서만 트랜잭션 메모리 같은 장치로 보호한다.
- 이벤트 소싱은 상태 대신 트랜잭션을 append-only로 저장해 갱신·삭제를 없앤다 — 가변 상태 자체를 지워 버린다. git이 이미 그렇게 동작한다.
이로써 2부의 세 패러다임을 모두 지났다. 각 패러다임이 손에 쥐여 준 규율 — 흐름, 방향, 불변성 — 은 3부에서 다룰 설계 원칙(SOLID)의 밑돌이 된다. 다음 장부터는 이 도구들을 실제로 모듈과 컴포넌트에 어떻게 배치할지를, 단일 책임 원칙에서 시작해 하나씩 살펴본다.
다음장으로 7장