데이터와 동작을 가르면 생기는 일
분리가 만드는 단순함과 검증 가능성
- #함수형 프로그래밍
- #AI 코딩
- #소프트웨어 설계
- #FCIS
- #DOP
이 글은 "함수형 프로그래밍, 실무에서 가능할까요?" 시리즈의 세 번째 글이에요. 데이터와 동작을 가르는 일이 어떻게 '단순함'을 만들고, 그 단순함이 어떻게 '검증 가능성'으로 이어지는지를 다뤄요.
'단순하다'는 모호한 칭찬
함수형 프로그래밍을 두고 흔히 "단순하다"고들 해요. 그런데 이 표현은 자주 오해돼요. 코드 줄 수가 적다거나 문법이 간결하다는 뜻으로요. 사실 그건 별로 맞는 말이 아니에요. 추상화를 몇 겹씩 쌓아 올린 Haskell 코드는 결코 "적게 쓴" 코드도, 문법적으로 간결한 코드도 아니거든요.
그러면 함수형의 '단순함'이란 대체 무엇일까요? 저는 그게 **'잘 분해되어 있음(well-factored)'**이라고 생각해요. 부분과 부분이 깔끔하게 나뉘어 있어서 한 부분을 들여다볼 때 다른 부분을 머릿속에 같이 이고 있지 않아도 되는 상태요.
OOP와 FP를 적당히 섞기보다 어디서 가르는지를 일관되게 정하는 게 낫다 — 여기까지는 우리가 함께 논해본 내용이에요. 그런데 한 가지 질문이 남아요. 왜 가르는 게 답일까요? 경계를 긋고 한쪽을 선택하는 일이 단지 코드를 깔끔하게 줄 세우는 정리정돈이라면, 그렇게까지 강조할 이유는 없을 거예요. 이 글의 주장은 이거예요 — 데이터와 동작을 가르는 일은 정리정돈이 아니라 단순함(잘 분해됨)을 만들어내는 메커니즘이고, 그 단순함이 AI 코딩 시대에 곧 검증 가능성으로 환산된다는 거예요.
1. 묶으면 얽힌다
객체지향은 데이터와 그 데이터를 다루는 동작을 한 객체로 묶어요. 이건 OOP의 정체성이기도 하죠. 그런데 묶는 순간 따라오는 결과가 하나 있어요. 두 객체 사이의 한 가지 관계가, 데이터에 관한 관심사와 동작에 관한 관심사를 동시에 떠안게 된다는 거예요.
"이 자료구조를 재사용하고 싶다"와 "이 행위를 재사용하고 싶다"를 깨끗하게 나눌 수가 없어요. 둘이 한 덩어리로 묶여 있으니까요. 그래서 OOP에는 객체를 엮는 방법이 유난히 많아요 — 합성(has-a), 상속(is-a), 믹스인, 데코레이터, 위임… 이 다양함의 밑바탕에는 데이터와 동작이 한 덩어리로 묶여 있다는 사정이 있어요. 결합 하나하나가 데이터 쪽 관심사와 동작 쪽 관심사를 서로 다른 비율로 함께 끌고 다니거든요.
// OOP: 데이터(width, height)와 동작(area, isSquare)이 한 캡슐 안에
class Rectangle {
constructor(
private width: number,
private height: number,
) {}
area(): number {
return this.width * this.height;
}
isSquare(): boolean {
return this.width === this.height;
}
}"상속보다 합성을 선호하라(favor composition over inheritance)"는 격언도 이 관점에서 읽으면 흥미로워요. 상속은 부모의 데이터와 동작을 자식에 통째로 끌어와 가장 강하게 결합시키는 도구죠. 그 결합을 좀 더 느슨한 합성으로 풀자는 게 이 격언의 한 측면이에요.
2. 데이터와 동작을 따로 두면?
함수형은 정반대로 가요. 데이터는 데이터로, 동작은 동작으로 따로 둬요. 데이터는 누구나 들여다볼 수 있는 투명한 값(평범한 객체나 유니온 타입)이고, 동작은 그 값을 받아 새 값을 돌려주는 함수예요.
// FP: 데이터는 그냥 데이터, 동작은 별도 함수
type Rectangle = { width: number; height: number };
const area = (r: Rectangle): number => r.width * r.height;
const isSquare = (r: Rectangle): boolean => r.width === r.height;이렇게 가르면 조립이 두 갈래로 나뉘어요. 한쪽은 데이터를 조립하는 축이에요 — 필드를 묶어 객체 타입({ width; height }, '이것 그리고 저것')을 만들거나, 유니온 타입(A | B, '이것 또는 저것')으로 여러 경우를 엮어 더 큰 데이터를 만들죠. 다른 한쪽은 동작을 조립하는 축이에요 — 작은 함수들을 합성해 더 큰 변환을 만들고요.
이때 두 방향은 대칭이 아니에요. 동작을 늘리는 건 데이터를 건드리지 않아요 — 둘레를 구하는 perimeter를 새로 더해도 Rectangle 타입도, 기존 함수들도 그대로죠. 하지만 데이터의 모양을 바꾸면 그걸 다루는 함수들(area·isSquare)은 따라 바뀌어야 해요.
그러니 '두 축이 서로 무관하다'는 건 아니에요. 핵심은 두 관심사가 다른 축에 나뉘어 있다는 거예요. 덕분에 한쪽의 변화가 다른 쪽으로 번지더라도, 그 영향이 조용히 새지 않고 또렷한 자리에 드러나죠. (예를 들면 타입에러가 발생하겠죠!)
한 가지는 분명히 해둘게요. 이렇게 따로 떼어놓는다고 해서 복잡성의 총량이 줄어드는 건 아니에요. 넓이를 구하고 정사각형인지 따지는 로직 자체가 사라지진 않으니까요. 다만 그 복잡성이 자리를 옮겨요. 객체지향은 복잡성을 런타임에 살아 움직이는 객체 그래프에 흩뿌리는 반면, 함수형은 타입과 데이터 구조 안으로 밀어 넣어요. 이 "어디로 옮기느냐"가 뒤에서 결정적인 차이를 만들어요.
3. 그런데 어떤 단순함일까: 국소 vs 합성
여기서 정직한 반론을 하나 마주해야 해요. "캡슐화도 단순함을 주지 않나?" 맞아요. 객체는 깔끔한 캡슐이에요. 내부 구현을 숨기고 잘 정의된 인터페이스만 노출하니, 객체 하나를 쓰는 입장에서는 분명 단순하죠. 그러니 "분리가 단순하다"는 주장은 자칫 공허해질 수 있어요. 묶음도 나름의 단순함을 주니까요.
둘이 주는 단순함은 종류가 달라요.
객체지향이 주는 단순함은 국소적이에요. 캡슐을 바깥에서 볼 때 성립하죠 — 인터페이스 뒤로 내부를 숨기니, 객체 하나를 쓰는 입장에서는 깔끔해 보여요. (정작 복잡성은 캡슐 안쪽에 담겨 있고요.) 하지만 이 깔끔함은 한 캡슐 단위에서만 성립해요. 캡슐들 사이의 관계로 시야를 넓히면, 숨겨뒀던 복잡성이 객체 간 결합으로 다시 새어 나오면서 단순함이 빠르게 무너지죠.
함수형이 주는 단순함은 합성적이에요. 부분을 바꿨을 때 그 파장이 또렷이 한정돼요. 함수 하나를 다른 구현으로 갈아 끼우면 옆 함수가 함께 흔들리지 않고, 데이터의 모양을 바꿔야 할 때도 그 영향이 어디까지 미치는지가 분명히 드러나요 — 바뀐 데이터를 다루는 지점들만 따라가면 되지, 시스템 전체를 한꺼번에 다시 들여다볼 필요는 없죠. 변경의 폭발 반경이 작고 예측 가능한 거예요.
국소적 단순함은 "부분 하나하나가 깔끔한가"를 묻고, 합성적 단순함은 "부분을 조립하고 바꿀 때 그 파장이 또렷이 한정되는가"를 물어요.
한 가지 덧붙이면, 변경의 파장이 또렷이 한정되는 것 자체가 여기서 말하는 단순함의 정의예요. 새 기능을 넣어도 그 영향이 코드베이스 전체로 조용히 번지는 게 아니라, 짚어낼 수 있는 자리에 머문다는 뜻이죠.
4. 왜 하필 지금 이 단순함의 값이 오를까
AI 코딩은 한 가지 역전을 만들어냈어요. 코드를 작성하는 비용은 빠르게 0에 수렴하고, 코드를 검증하고 변경의 파급 범위를 파악하는 비용이 새로운 병목이 됐죠. 합성적 단순함이 값을 갖는 이유가 정확히 여기예요. 그건 작성이 아니라 검증을 싸게 만드는 단순함이거든요.
세 갈래로 작동해요.
첫째, 지역적 추론이에요. 순수 함수는 입력에만 의존하니, 맞는지 확인하려면 그 함수만(부르는 함수가 있으면 거기까지) 보면 돼요 — 시그니처가 명세에 한층 가까워지죠. 전역 상태나 객체의 생애주기를 따라다닐 필요가 없어요.
둘째, 갇힌 폭발 반경이에요. 3번에서 본 합성적 단순함 덕분에, AI가 어떤 함수를 고쳤을 때 그 영향이 어디까지 미치는지가 한 축 안에서 예측 가능해요. "이 함수만 바꿔줘"라는 요청이 옆 동네를 무너뜨리지 않는다는 보장이, 사람이 그 변경을 리뷰하는 비용을 크게 낮춰요.
셋째, 그리고 이게 가장 중요한데 — 복잡성을 기계가 검사할 수 있는 자리에 둔다는 점이에요. 함수형은 복잡성을 타입과 데이터 구조 안으로 밀어 넣는다고 했죠. 그 자리는 컴파일러가 들여다볼 수 있는 자리예요. 유니온 타입에 새 경우를 하나 추가하면, 그 값을 다루는 모든 switch에서 "이 경우를 처리 안 했다"고 컴파일 에러가 나요. 불가능한 상태를 타입으로 막아두면 AI는 그 버그를 애초에 표현할 수조차 없죠. 반면 가변 상태가 런타임 객체 그래프 여기저기에 흩어져 있으면, 무엇이 언제 어떤 객체의 상태를 바꿨는지는 정적으로 다 드러나지 않아요 — 실행해봐야 알게 되는 부분이 남죠.
AI가 코드를 쓰는 시대의 본질은 이거예요 — 우리는 작성자를 완전히 신뢰할 수 없어요. 그렇다면 복잡성은 검사되는 곳에 쌓아두는 게 맞아요. 데이터와 동작을 이렇게 갈라놓는 구조는 복잡성을 정적으로 검사 가능한 자리로 옮겨주기 때문에, 바로 이 시대에 값이 오르는 거예요.
5. 반대편 무게추
여기까지만 보면 "그러니 함수형으로 가라"는 결론으로 직행할 것 같지만, 정직하려면 반대편 무게추도 올려놔야 해요.
- 학습 데이터의 편향 — 오늘날 LLM은 Haskell·OCaml 같은 코드보다 Python·Java·TypeScript의 명령형·객체지향 코드를 압도적으로 많이 학습했어요. 그래서 작성 단계의 유창함은 주류 명령형 스타일에서 더 높고, 순수 함수형 관용구는 덜 익어서 첫 출력의 오류율이 오히려 높을 수 있어요. "검증은 싸지만 애초에 덜 능숙하게 쓴다"는 상쇄가 실재해요.
- 이점의 진짜 정체 — 위에서 함수형의 공으로 돌린 것들(지역적 추론, 불변성, 명세로서의 타입)을 다시 보면, 사실 이건 '함수형'이라는 부족적 라벨의 공이 아니라 강한 정적 타입 + 불변성 + 지역성의 공이에요. (그 지역성은 데이터와 동작을 분리한 데서 나오고요.) 그래서 Rust나 잘 규율된 TypeScript에서도 상당 부분 얻을 수 있죠.
이 두 무게추가 가리키는 결론은 의외로 명확해요. "순수 함수형 언어로 갈아타라"가 아니에요. 그건 작성 유창성의 손해를 감수해야 하고, 애초에 이점의 본질도 언어가 아니라 속성에 있으니까요. 진짜 처방은 이거예요 — 그 속성들(불변성, 효과 격리, 데이터-동작 분리)을 멀티패러다임 언어 안에서 규율로 강제하라.
마치며
이 글의 주장을 한 문장으로 줄이면 이래요 — 단순함이란 코드를 적게 쓰는 게 아니라, 복잡성을 검사할 수 있는 곳에 두는 것이에요.
데이터와 동작을 가르면 두 축이 서로 간섭하지 않게 되고 그렇게 갈라놓은 구조가 복잡성을 타입과 데이터 구조 — 즉 기계가 검사 가능한 자리 — 로 옮겨줘요. 그래서 작성이 공짜가 된 시대에, 이 분리가 만드는 단순함은 곧 검증 가능성으로 환산되죠. 그렇다면 이 분리를 사람의 의지가 아니라 도구가 강제하도록 굳히려면 어떻게 해야 할까요?
다음 글에서는 그 답이 될 두 가지 설계 방식 — **FCIS(Functional Core, Imperative Shell)**와 DOP(Data-Oriented Programming) — 를 본격적으로 풀어볼게요. 각각이 데이터와 동작의 분리를 어떻게 규율로 굳히고 그 경계를 도구가 어떻게 강제하는지를 차근차근 다룰 예정이에요.