경계를 사람이 지키게 두지 마세요
린트와 타입이 지키는 경계
- #함수형 프로그래밍
- #AI 코딩
- #소프트웨어 설계
- #FCIS
- #DOP
이 글은 "함수형 프로그래밍, 실무에서 가능할까요?" 시리즈의 네 번째 글이에요. 앞선 글들이 왜 데이터와 동작을 갈라야 하는가를 논했다면, 이번 글은 그 가름을 무엇이 지켜주는가를 주문 처리 예시 코드로 보여드릴게요.
의지로 지킨 경계는 무너진다
지난 글은 질문 하나를 남겨뒀어요. 데이터와 동작의 분리를 사람의 의지가 아니라 도구가 강제하도록 굳히려면 어떻게 해야 할까요?
이게 왜 중요하냐면, 의지로 지키는 규율은 반드시 흐트러지기 때문이에요. 마감에 쫓긴 누군가가 순수해야 할 함수 안에서 Date.now()를 한 번 부르고, 리뷰어가 그걸 놓치고, AI가 그 패턴을 보고 따라 하면, "순수한 코어"는 슬그머니 무너집니다. 경계가 사람 머릿속에만 있으면 사람 수만큼, 그리고 바쁜 정도만큼 흔들려요.
FCIS와 DOP가 여느 "관심사를 분리하라"는 구호들과 갈리는 지점이 정확히 여기예요. 이들의 경계는 취향이나 토론거리가 아니라 예/아니오로 답하는 기준으로 떨어집니다 — "이 함수가 외부 상태를 건드리는가?", "이건 데이터인가 동작인가?". 그런 기준은 사람이 아니라 도구가 검사할 수 있어요. 경계가 그렇게 떨어지면, 그 기준은 린트가 됩니다.
주문 하나를 받아 처리하는 코드로 예를 들어볼게요.
1. 외부를 건드리는가 — 한 줄 기준으로 코어와 셸을 가른다
코어와 셸을 가르는 기준은 딱 한 줄이에요. 이 함수가 외부 상태를 읽거나 쓰는가? DB·결제·메일·시계처럼 함수 바깥의 세계를 건드리면 셸이고, 입력만 보고 값을 돌려주면 코어예요. 토론거리가 아니라 예/아니오로 답하는 기준이죠.
주문을 받아 처리하는 흐름을 이 기준으로 갈라볼게요. 먼저 코어 — 장바구니와 고객 정보를 받아 주문을 받아들일지, 받아들인다면 얼마를 청구할지를 결정하는 순수 함수예요.
// core/order.ts — 바깥세상을 건드리지 않는다. 입력 → 결정(값).
type Decision =
| { kind: "rejected"; reason: string }
| { kind: "accepted"; order: PricedOrder };
const decideOrder = (cart: Cart, customer: Customer, now: Date): Decision => {
if (cart.items.length === 0)
return { kind: "rejected", reason: "empty_cart" };
const priced = priceCart(cart, customer.tier); // 순수 가격 계산
return { kind: "accepted", order: { ...priced, placedAt: now } };
};decideOrder는 바깥의 무엇도 건드리지 않아요. new Date()조차 직접 부르지 않고 now를 인자로 받죠. 그래서 이 함수가 무슨 일을 하는지는 시그니처와 입력만으로 전부 설명됩니다. 돌려주는 것도 사이드 이펙트가 아니라 결정이에요 — "거절" 또는 "이 가격으로 승인"이라는 값.
그 결정을 실제로 실행하는 건 셸이에요.
// shell/placeOrder.ts — 외부를 모은다 → 순수 함수에 넘긴다 → 결정을 실행한다.
const placeOrder = async (cartId: string): Promise<void> => {
// 1) 외부에서 입력을 모은다
const cart = await db.loadCart(cartId);
const customer = await db.loadCustomer(cart.customerId);
// 2) 순수 함수에 넘겨 결정을 계산한다 (사이드 이펙트 없음)
const decision = decideOrder(cart, customer, new Date());
// 3) 결정을 실행한다 — 사이드 이펙트는 전부 여기
if (decision.kind === "rejected") {
await notify.rejected(customer.email, decision.reason);
return;
}
await db.saveOrder(decision.order);
await payments.charge(customer, decision.order.total);
await notify.confirmed(customer.email, decision.order);
};셸의 모양을 보세요. 양 끝이 바깥세상이고(입력을 모으는 load, 결정을 실행하는 save·charge·notify) 가운데 한 줄만 순수 함수 호출이에요. 사이드 이펙트는 전부 코어 바깥으로 밀려나 셸의 양 끝에 모입니다. decideOrder로 들어가는 것도 평범한 값(cart·customer·now)이고, 나오는 것도 평범한 값(decision)이죠.
이렇게 갈라두면 검증해야 할 것도 둘로 갈라져요. 가격 계산이 맞는지, 빈 장바구니를 제대로 거르는지 같은 비즈니스 규칙은 코어만 보면 됩니다 — DB도 결제 mock도 없이 입력과 기대 출력만 적으면 테스트가 끝나요. "저장한 뒤에 결제하는가" 같은 순서와 실행의 문제는 반대로 셸에서만 따지면 되고요.
문제는 이 경계가 지켜질 때만 효력이 있다는 거예요. 누군가 decideOrder 안에서 슬쩍 db.loadInventory()를 부르는 순간, 코어는 더 이상 코어가 아니에요. 그러면 이 기준을, 누가 지킬까요?
2. 그 경계를 사람이 아니라 도구가 지킨다
답은 — 사람이 아니에요. 규칙이에요.
§1에서 코어와 셸을 가른 기준을 다시 보세요. "이 함수가 외부 상태를 읽거나 쓰는가?" 이건 사람의 해석이 필요한 질문이 아니라, 코드만 들여다보면 예/아니오가 나오는 기준이에요. 외부를 건드린다는 건 코드에 흔적을 남기거든요 — 인프라 모듈을 import하거나, await로 비동기 I/O를 부르거나, Date.now()로 현재 시각을 읽거나. 이 흔적들은 전부 파서가 볼 수 있는 것들이에요. 사람이 매번 눈으로 지킬 게 아니라, 린터에게 맡기면 됩니다.
코어 디렉터리에만 적용되는 규칙으로 옮겨볼게요.
// eslint.config.js — src/core 안에서만 강제되는 규칙
export default [
{
files: ["src/core/**/*.ts"],
rules: {
// 코어는 인프라(DB·결제·메일…)를 import할 수 없다
"no-restricted-imports": [
"error",
{ patterns: ["**/infra/*", "**/shell/*"] },
],
// 코어 안에서는 비동기 I/O와 현재 시각 읽기를 금지한다
"no-restricted-syntax": [
"error",
{
selector: "AwaitExpression",
message: "코어는 순수해야 합니다. I/O는 셸로 옮기세요.",
},
{
selector:
"CallExpression[callee.object.name='Date'][callee.property.name='now']",
message: "현재 시각은 인자로 받으세요 (now: Date).",
},
],
},
},
];§1 마지막에 걱정했던 상황 — 누군가 decideOrder 안에서 슬쩍 재고를 조회하는 일 — 을 이 규칙이 어떻게 잡는지 보세요.
// ❌ src/core/order.ts — 코어 안에서 외부를 건드리면
const decideOrder = async (
cart: Cart,
customer: Customer,
): Promise<Decision> => {
const stock = await db.loadInventory(cart.items); // ← 인프라 접근 + await
// ...
};src/core/order.ts
2:17 error '**/infra/*' import is restricted no-restricted-imports
3:17 error 코어는 순수해야 합니다. I/O는 셸로 옮기세요 no-restricted-syntax
두 겹의 그물에 동시에 걸려요. 리뷰어가 깜빡해도, 작성자가 마감에 쫓겨도, 에디터에 빨간 줄이 그어지고 CI가 빌드를 막아요. 경계가 사람 머릿속이 아니라 규칙 파일에 적혀 있으니, 누가 코드를 쓰든 똑같이 강제됩니다.
이 대비는 다른 분리 원칙들과 비교하면 더 또렷해져요. 클린 아키텍처나 DDD가 그어온 경계 — "이건 유스케이스인가 도메인 서비스인가", "이 책임은 애플리케이션 레이어인가 인프라인가" — 는 어떤 린터로도 검사할 수 없어요. 답하려면 코드의 의미를 사람이 해석해야 하니까요. 반면 "이 파일이 인프라를 import하는가"는 파서가 1초 만에 답해요. FCIS의 경계가 하필 예/아니오로 답하는 기준 위에 놓인 건 우연이 아니에요. 기계가 검사할 수 있는 자리에 일부러 경계를 그은 거죠. 규율을 사람의 성실함이 아니라 도구의 무심함에 맡기려고요.
이게 AI 코딩 시대에 한 겹 더 중요해져요. 같은 기준을 AI 에이전트의 규칙 파일에도 그대로 적어둘 수 있거든요.
## 코어/셸 경계 (src/core, src/shell)
- `src/core/**`의 함수는 순수 함수다. 외부 상태를 읽거나 쓰지 않는다.
- 금지: `await`, DB·네트워크·파일 접근, `Date.now()`/`new Date()`, 난수 생성
- 현재 시각·ID 같은 자원은 인자로 받는다 (예: `now: Date`)
- I/O와 사이드 이펙트는 전부 `src/shell/**`에 둔다.
- 새 규칙은 먼저 코어에 순수 함수로 추가하고, 셸은 그 결과를 실행만 한다.린트가 사후에 잡는 것을, AI는 생성하는 순간 참고해요. 코어에 I/O를 끼워 넣으려다 스스로 셸로 옮기죠. 사람·린터·AI가 같은 한 줄 기준을 공유하니, 규율이 작성·리뷰·자동생성의 모든 길목에서 똑같이 작동해요.
다만 이 규칙이 보장하는 건 경계지 정답이 아니에요. 린트는 decideOrder가 순수하다는 건 보장하지만, 가격을 제대로 계산하는지는 모릅니다. 순수함과 올바름은 다른 문제고, 이 틈은 글 끝에서 다시 마주하게 돼요.
어쨌든 코어가 순수하다는 것만큼은 도구가 지켜줘요. 그런데 그 순수함은 공짜로 얻어진 게 아니에요.
3. 셸은 깨끗하지 않다 — 그게 비용이다
순수한 코어는 그냥 깨끗해진 게 아니에요. 코어에 있던 지저분한 것들이 어딘가로 옮겨간 거죠. 그 어딘가가 셸이에요.
§1의 placeOrder를 다시 보면, 사이드 이펙트가 한 군데도 사라지지 않았어요. DB 조회도, 결제도, 메일도 전부 그대로 있어요. 단지 코어 바깥으로 밀려나 셸에 모였을 뿐이에요. 코어가 순수해진 만큼, 정확히 그만큼의 지저분함이 셸에 쌓인 거죠. 분리는 복잡성을 없애지 않아요. 한쪽으로 모을 뿐이에요.
그러니 셸은 본질적으로 검증하기 까다로운 코드로 남아요. 순수 함수처럼 입력과 출력만 적어 테스트할 수가 없어요. DB를 띄우거나 mock을 세우고, 결제 게이트웨이를 가짜로 물리고, "저장에 성공했지만 결제가 실패하면?" 같은 순서와 실패의 조합을 일일이 따져야 하죠. 코어에서 누렸던 검증이 값싸다는 이점이 셸에는 없어요.
게다가 셸에는 코어가 원천적으로 막아주는 종류의 버그가 그대로 살아 있어요. 불변 데이터와 순수 함수는 공유 메모리를 동시에 읽고 쓰다 나는 버그를 없애주지만, 그건 메모리 얘기예요. DB의 같은 행을 두 요청이 동시에 갱신하거나, 결제는 됐는데 저장이 실패해 둘이 어긋나는 일 — 외부 자원을 둘러싼 경쟁은 코어가 손댈 수 없는 영역이에요. 이런 위험은 전부 셸에 남아요.
언뜻 이건 분리의 실패처럼 들려요. 지저분함을 없앤 게 아니라 옮기기만 했다면, 무슨 소용이냐고요. 하지만 옮겼다는 게 핵심이에요.
지저분함이 코어와 셸에 뒤섞여 있을 때는, 사이드 이펙트를 일으키는 위험한 코드가 어디에나 있을 수 있어요. 검증할 때 모든 함수를 의심해야 하죠. 분리하고 나면 위험은 셸이라는 주소를 갖게 돼요. "동시성 문제가 어디 있지?"의 답이 "셸을 보면 된다"로 좁혀지고, 코어는 의심 목록에서 통째로 빠져요. 까다로운 검증을 없앤 게 아니라, 그게 필요한 곳을 한 군데로 몰아준 거예요.
이게 셸이 치르는 비용의 정확한 정체예요. 셸은 여전히 어렵지만, 어려움이 한곳에 모여 주소를 가진 어려움이 됐어요. 흩어진 위험과 모인 위험은, 같은 양이어도 검증 비용이 전혀 달라요.
여기까지가 위치로 가르는 이야기예요. 사이드 이펙트를 일으키는가 — 이 한 줄 기준으로 코어와 셸을 나누고, 그 경계를 도구로 지키고, 셸에 모인 비용까지 인정했어요. 그런데 위치를 가르는 것만으로는 답이 안 나오는 질문이 남아요. 셸이 코어에 넘기는 그 주문이라는 데이터 — 그건 대체 어떻게 생겨야 할까요? 같은 코어 안에서도 데이터를 묶을지 풀지, 상태 변화를 어떻게 담을지는 위치 기준이 말해주지 않아요. 거기서부터는 다른 가름이 필요해요.
4. 주문을 레코드로, 규칙을 함수로
데이터를 모델링하는 첫 갈림길은 이거예요. 주문이라는 데이터에, 주문을 다루는 규칙을 함께 담을 것인가. DOP의 답은 아니요예요. 데이터는 누구나 들여다볼 수 있는 투명한 레코드로 두고, 규칙은 그 레코드를 받는 별도의 함수로 둬요.
// 데이터는 그냥 데이터 — 규칙을 들고 있지 않다
type Order = { items: Item[]; customerId: string; total: Money };
// 규칙은 데이터를 받는 함수 — 데이터 바깥에 따로 산다
const isFree = (o: Order): boolean => o.total.amount === 0;
const itemCount = (o: Order): number => o.items.length;이렇게 갈라두면 데이터를 키우는 일과 동작을 키우는 일이 서로 다른 축에서 따로 움직여요. 새 규칙(hasDiscount, weight…)을 더해도 Order 타입은 그대로고, 반대로 Order에 필드를 더하면 그걸 다루는 함수들이 영향을 받죠. 두 관심사가 다른 축에 있으니, 한쪽 변화가 다른 쪽으로 번질 때 그게 조용히 새지 않고 또렷한 자리에 드러나요. 타입에러라는 형태로요.
이 "또렷한 자리에 드러난다"가 단순한 편의가 아니라 DOP가 거는 진짜 승부예요. 데이터를 투명하게 둔 덕분에, 데이터의 모양 자체에 규칙을 새겨 넣고 그걸 컴파일러가 검사하게 만들 수 있거든요.
주문에는 상태가 있어요. 장바구니 단계인지, 주문이 확정됐는지, 결제가 끝났는지, 배송됐는지. 흔한 방식은 주문 하나에 상태 필드와 온갖 선택적 필드를 욱여넣는 거예요.
// 흔하지만 위험한 모양 — 불가능한 조합이 표현 가능하다
type Order = {
status: "draft" | "placed" | "paid" | "shipped";
items: Item[];
total?: Money;
paidAt?: Date;
paymentId?: string;
trackingNo?: string;
};이 타입은 말이 안 되는 주문을 허용해요. status: "draft"인데 trackingNo가 박혀 있는 주문, status: "shipped"인데 paymentId가 없는 주문 — 타입은 이걸 멀쩡하다고 받아들여요. 모든 필드가 선택적이라 어떤 조합이든 통과하니까요. 그래서 함수마다 if (order.paymentId) 같은 방어 코드를 흩뿌리게 되고, 빠뜨리면 런타임에 터지죠.
데이터를 그 자체로 올바른 모양으로 다시 그리면 이 위험이 통째로 사라져요. 각 상태가 가질 수 있는 필드만 정확히 갖도록, 유니온 타입으로 갈라요.
// 상태마다 가질 수 있는 데이터가 정확히 다르다
type Order =
| { status: "draft"; items: Item[] }
| { status: "placed"; items: Item[]; total: Money; placedAt: Date }
| {
status: "paid";
items: Item[];
total: Money;
placedAt: Date;
paymentId: string;
}
| {
status: "shipped";
items: Item[];
total: Money;
placedAt: Date;
paymentId: string;
trackingNo: string;
};이제 paymentId는 paid와 shipped에만 존재해요. "결제되지 않은 주문의 결제 ID"라는 말이 안 되는 상태는 타입으로 표현할 길이 아예 없어요. 방어 코드로 거르는 게 아니라, 잘못된 데이터가 만들어지는 것 자체가 불가능해진 거죠.
그 위에서 동작을 쓰면, 컴파일러가 빠진 경우를 짚어줘요.
const canCancel = (o: Order): boolean => {
switch (o.status) {
case "draft":
case "placed":
return true;
case "paid":
return true; // 환불 처리로 취소 가능
case "shipped":
return false; // 이미 떠난 주문은 취소 불가
}
};여기에 "refunded"라는 상태를 새로 추가한다고 해보세요. Order 유니온에 한 줄 더하는 순간, canCancel을 비롯해 o.status로 분기하던 모든 함수에서 컴파일 에러가 나요 — "이 경우를 처리하지 않았다"고요. 새 상태를 다뤄야 할 자리를 사람이 기억으로 찾아다니는 게 아니라, 컴파일러가 빠짐없이 목록으로 내밀어요.
이게 데이터와 동작을 가른 덕에 얻는 것이에요. 데이터를 투명한 값으로 둬서 그 모양에 규칙을 새길 수 있고, 그 모양을 컴파일러가 검사하니, 불가능한 상태는 만들 수조차 없고 빠진 처리는 빌드가 막아요. 복잡성이 사람이 지켜야 할 규칙에서 기계가 검사하는 타입으로 옮겨간 거죠.
다만 이 가름이 다루지 못한 게 하나 있어요. 방금 본 Order는 한 시점의 주문이에요. 그런데 같은 주문은 시간에 따라 draft에서 placed로, paid로, shipped로 옮겨가요. 같은 주문인데 상태가 달라지는 이 변화를, 데이터로 어떻게 담아야 할까요?
5. 같은 주문, 달라지는 상태
"같은 주문이 draft에서 paid로 바뀐다"는 문장에는 사실 두 가지가 뒤섞여 있어요. 바뀌지 않는 것과 바뀌는 것이요. 주문을 가리키는 정체성(이 주문이 그 주문이라는 사실)은 그대로인데, 그 주문의 상태는 시간에 따라 달라지죠. 데이터-동작을 갈랐듯, 이번엔 이 둘을 가릅니다.
정체성과 상태를 가른다
객체로 상태를 다루는 익숙한 방식은 이 둘을 한 덩어리로 묶어요. 주문 객체 하나가 정체성을 지닌 채로, 자기 안의 상태를 바꿔가죠.
const order = new Order(items);
order.place(); // 안에서 status가 바뀐다
order.pay(payment); // 또 바뀐다
// order는 내내 '같은 객체'지만, 직전 상태들은 사라졌다이 order는 언제나 지금만 알아요. pay()를 부른 순간 placed였다는 사실은 덮어쓰여 사라지죠. "이 주문이 언제 placed가 됐지?"를 물으면 답할 수 없어요. 현재 상태만 들고 있으니까요.
DOP는 정체성과 상태를 떼어놓아요. 정체성은 변하지 않는 ID로 박아두고, 상태는 그 ID에 묶인 불변 스냅샷으로 둬요. §4에서 Order라 부르던 그 상태 유니온을 여기서는 OrderState로 부르고, Order라는 이름은 "ID + 상태"를 묶은 바깥 껍데기에 다시 쓸게요. 상태가 바뀐다는 건 객체 속을 고쳐 쓰는 게 아니라, 새 스냅샷이 생기는 일이에요.
type OrderId = string;
// 정체성은 ID로 고정, 상태는 그 ID에 묶인 불변 스냅샷
type Order = {
id: OrderId;
state: OrderState; // §4의 유니온 — draft | placed | paid | shipped
};
// 상태 전이 = 새 스냅샷을 돌려주는 순수 함수 (원본은 그대로)
const pay = (o: Order, paymentId: string): Order => ({
id: o.id,
state: { ...assertPlaced(o.state), status: "paid", paymentId },
});pay는 o를 건드리지 않고 새 Order를 돌려줘요. 같은 id를 가졌으니 같은 주문이지만, 상태는 다른 값이죠. "같은 주문이 변한다"가 "같은 정체성을 가진 서로 다른 스냅샷들"로 표현된 거예요. 이건 §4에서 데이터를 투명한 값으로 둔 덕에 가능해요 — 상태가 객체 안에 숨어 있지 않고 평범한 값이라, 복사하고 교체하는 일이 자연스러워요. 두 분리가 맞물리는 지점이에요.
변화의 기록
스냅샷은 지금의 상태를 담아요. 그런데 그 주문이 거쳐온 길까지 담고 싶다면, 상태를 결과가 아니라 사건들의 기록으로 둘 수 있어요. 주문이 거쳐온 사건들을 순서대로 쌓아두는 거죠.
// 주문에 일어난 사건들 — 각 사건이 무엇을, 언제 바꿨는지
type OrderEvent =
| { type: "placed"; at: Date; items: Item[]; total: Money }
| { type: "paid"; at: Date; paymentId: string }
| { type: "shipped"; at: Date; trackingNo: string };이 사건 목록에서 "지금 이 주문이 어떤 상태인가"를 어떻게 얻을까요? 사건을 하나씩 적용해 상태를 갱신해 나가면 돼요. 그 "사건 하나를 적용하는" 일을 함수로 떼어낼게요. 현재 상태와 사건 하나를 받아, 다음 상태를 돌려주는 함수예요.
// 현재 상태 + 사건 하나 → 다음 상태
// §4의 상태 유니온(draft | placed | paid | shipped)을 그대로 쓴다
const apply = (state: OrderState, event: OrderEvent): OrderState => {
switch (event.type) {
case "placed":
return {
status: "placed",
items: event.items,
total: event.total,
placedAt: event.at,
};
case "paid":
return {
...assertPlaced(state),
status: "paid",
paymentId: event.paymentId,
};
case "shipped":
return {
...assertPaid(state),
status: "shipped",
trackingNo: event.trackingNo,
};
}
};apply가 하는 일은 §5 앞에서 본 pay와 똑같아요 — 상태 하나를 받아 다음 상태를 돌려줄 뿐, 원본을 건드리지 않죠. 이제 "현재 상태"는 별게 아니에요. 빈 상태에서 시작해 사건을 처음부터 하나씩 apply한 결과예요.
// 빈 상태에서 시작해, 사건을 하나씩 적용해 나간다
let state: OrderState = { status: "draft", items: [] };
for (const event of events) {
state = apply(state, event);
}
// state — 모든 사건을 반영한, 지금의 주문 상태이 "빈 값에서 시작해 하나씩 적용해 누적한다"는 패턴이 바로 reduce예요. 위 루프를 한 줄로 접으면 이렇게 돼요.
// 위 for 루프와 똑같은 일을 하는 한 줄
const currentState = (events: OrderEvent[]): OrderState =>
events.reduce(apply, { status: "draft", items: [] });reduce가 새로운 마법은 아니에요. 방금 본 루프 — 초깃값에서 출발해 원소마다 함수를 적용하며 값을 갱신하는 일 — 을 한 줄로 적은 것뿐이에요.
여기서 이력의 진짜 값이 나와요. 사건을 특정 시점까지만 걸러서 똑같이 접으면, 그때의 상태가 그대로 되살아나요.
// 시점 t까지의 사건만 걸러, 똑같이 접는다 → 그 시점의 주문 상태
const stateAt = (events: OrderEvent[], t: Date): OrderState =>
events.filter((e) => e.at <= t).reduce(apply, { status: "draft", items: [] });사건들의 목록 자체가 곧 이력이에요. 현재 상태는 전부 접어 얻고, 시점으로 한 번 거른 뒤 접으면 그때의 상태가 나와요. "결제 직전에 이 주문이 어떤 모습이었나"를 별도 로그 없이 데이터에서 바로 답할 수 있죠. 정체성-상태를 가른 덕에, 상태를 "최신 값"이 아니라 "변화의 누적"으로 두는 선택지가 열린 거예요.
이 대목이 함수형을 실무에 들일 때 가장 자주 발목을 잡던 곳이에요. 사용자, 주문, 계정처럼 같은 정체성을 유지한 채 시간에 따라 상태가 변해가는 엔티티를 불변 데이터로 어떻게 다루느냐 — 객체의 가변 상태에 익숙한 눈에는 영 어색한 숙제였죠. 정체성은 ID로, 상태는 불변 스냅샷의 연속으로 가르면, 그 숙제가 풀려요. 변화는 새 값을 만드는 일이 되고, 정체성은 ID가 들고 있고요.
물론 모든 주문을 사건 목록으로 둘 필요는 없어요. 이력이 중요한 도메인(주문, 결제, 계정 원장)에서는 사건 시퀀스가 제값을 하지만, 그저 현재 상태만 알면 충분한 데이터까지 전부 이렇게 다루면 과한 무게가 돼요. 스냅샷 하나로 충분한 곳엔 스냅샷을, 이력이 자산인 곳엔 사건 시퀀스를 — 어디까지 갈지는 도메인이 정해요. 정체성과 상태를 가른다는 원칙은 같고, 그 위에서 얼마나 멀리 갈지가 선택으로 열리는 거죠.
6. 도구가 닿지 않는 자리
지금까지의 그림은 이래요. 코어와 셸을 가르는 경계는 린트가 지키고, 데이터의 모양에 새긴 규칙은 컴파일러가 지켜요. 규율이 사람의 성실함에서 도구의 무심함으로 옮겨갔죠. 그런데 도구가 지켜주는 게 정확히 어디까지인지, 분명하게 선을 그을 차례예요.
린트는 decideOrder가 순수하다는 걸 보장해요. 외부를 건드리지 않고, 같은 입력에 같은 값을 낸다는 것까지요. 하지만 그 함수가 가격을 맞게 계산하는지는 전혀 몰라요. 할인을 두 번 적용하든, 세금을 빠뜨리든, 린트는 아무 말도 하지 않아요 — 그건 여전히 순수한 함수니까요. 타입도 마찬가지예요. total: Money라는 타입은 금액의 자리가 있다는 것만 보장하지, 그 값이 옳은지는 모르죠.
순수함과 올바름은 다른 문제예요. 도구가 옮겨준 건 검증의 비용이지 검증 자체가 아니에요. 순수 함수라서 테스트가 값싸진 건 맞지만, "어떤 입력에 어떤 출력이 옳은가"를 적는 일은 여전히 사람 몫이에요. 도구는 그 테스트를 쉽게 만들어줄 뿐, 대신 써주지는 않아요.
셸은 더 솔직해져야 해요. 사이드 이펙트가 모인 그곳은 애초에 린트가 순수함을 요구하지 않는 영역이에요. 저장한 뒤 결제하는 순서가 맞는지, 결제는 됐는데 저장이 실패하면 어떻게 회복하는지, 같은 주문에 두 요청이 동시에 닿으면 어떻게 되는지 — 이런 건 타입으로도 린트로도 잡히지 않아요. 통합 테스트로, 더러는 운영에서 겪고 나서야 알게 되죠. 경계를 그어 셸로 몰아준 덕에 위험이 어디 있는지는 분명해졌지만, 그 위험이 사라진 건 아니에요.
그러니 이 처방이 해결한 것과 손대지 못한 것을 분명히 해둘게요. 코어와 셸을 가르는 일은 I/O가 비즈니스 로직에 섞여드는 문제를 해결했어요. 정체성과 상태를 가르는 일은 같은 엔티티가 시간에 따라 변하는 도메인을 불변 데이터로 다루는 문제를 해결했고요. 함수형을 실무에 들일 때 자주 발목 잡던 두 지점이죠. 반면 이 처방이 건드리지도 못하는 것들이 있어요. 불변 자료구조의 성능 비용, 주류 프레임워크가 객체지향을 전제한다는 생태계의 비대칭, 그리고 이 스타일에 덜 익숙한 사람과 AI가 치르는 학습 비용 — 이것들은 경계를 긋는다고 사라지지 않아요. 도구로 강제하는 경계는 옳은 구조를 값싸게 지켜줄 뿐, 모든 문제를 푸는 건 아니에요.
마치며
좋은 경계란 사람이 성실하게 지키는 경계가 아니라, 지키지 않으면 도구가 막아서는 경계예요.
FCIS는 사이드 이펙트를 일으키는가라는 한 줄 기준으로 코어와 셸을 가르고, 그 경계를 린트가 지켜요. DOP는 데이터와 동작을, 그리고 정체성과 상태를 갈라, 잘못된 데이터가 만들어지는 것 자체를 타입으로 막죠. 둘 다 같은 일을 해요. 규율을 사람의 머릿속에서 꺼내, 기계가 검사할 수 있는 자리에 박아 넣는 일이요.
왜 오늘날 이 분리가 중요해졌을까요? 코드를 쓰는 손이 더는 사람만이 아니기 때문이에요. AI가 코드를 쏟아내는 시대에 우리는 작성자를 온전히 신뢰할 수 없게 됐고, 그래서 병목은 작성에서 검증으로 옮겨갔어요. 작성자를 믿을 수 없다면, 규율은 작성자의 선의가 아니라 작성자 바깥의 도구가 쥐고 있어야 해요. 사람이 짜든 AI가 짜든 똑같이 걸리는 그물 말이에요.
그렇다고 이게 만능은 아니에요. 앞에서 봤듯 도구는 경계를 지킬 뿐 코어 안 로직의 옳음까지 보증하진 않고, 사이드 이펙트가 모인 셸은 여전히 사람의 몫으로 남아요. 그러니 이걸 "함수형으로 갈아타라"는 말로 읽지는 말아요. 순수 함수형 언어로 옮겨가는 게 아니라, 멀티패러다임 언어 안에서 불변성·사이드 이펙트 격리·데이터와 동작의 분리라는 속성을, 의지가 아니라 도구로 강제하는 것 — 이 시리즈가 처음부터 가리켜온 방향이 거기예요.
어쩌면 이게 AI 시대에 설계를 보는 새 잣대일지도 몰라요. 어떤 구조가 좋은가를, 그 구조가 지키는 규율 중 얼마나 많은 부분을 기계에 넘길 수 있는가로 묻는 거죠. 사람만 읽던 시절의 코드는 사람이 지켰지만, AI도 함께 쓰는 코드는 도구가 지켜야 하니까요.