데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로

한 주 만에 다시 모인 두 번째 스터디는 지난 모임의 질문을 한 걸음 더 밀어 보는 자리였습니다. 첫 모임에서 데이터와 동작을 분리해 바라봤다면, 이번에는 그렇게 분리한 데이터를 어떤 모습으로 표현하고 어떻게 바꿀 것인지 살펴봤습니다.

이번 모임에서는 두 차례 발제가 있었고, 저는 두 번째 발제에서 3장과 4장을 다뤘습니다. 이번 후기에서는 데이터 표현과 불변 상태 관리가 하나의 흐름으로 이어지는 과정을 중심으로 정리했습니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로
3장은 원칙 #2인 ‘범용 자료구조로 데이터 개체 표현하기’를, 4장은 원칙 #3인 ‘데이터는 불변이다’를 구체적으로 다룹니다.

지난 모임의 질문에서 출발했습니다

첫 모임에서는 데이터와 동작을 분리하면 클래스 관계와 변경 범위를 단순하게 바라볼 수 있다는 점을 배웠습니다. 동시에 데이터 구조가 새로운 계약이 된다면 필드의 의미와 형태를 어디에서 보장할지, 실제 상태 변경과 사이드 이펙트는 어느 경계에 둘지라는 질문도 남았습니다.

이번 발제는 이 질문을 두 갈래로 이어 갔습니다. 3장에서는 데이터를 특정 클래스에 묶지 않고 범용 자료구조로 표현하는 방법을, 4장에서는 그 데이터를 직접 변경하지 않으면서 새로운 상태로 나아가는 방법을 다뤘습니다. 앞의 원칙이 데이터를 코드의 형식에서 풀어 준다면, 뒤의 원칙은 그렇게 자유로워진 데이터를 예측 가능하게 다루는 규율을 더합니다.

레코드를 클래스가 아닌 맵으로 바라봤습니다

3장의 핵심은 데이터 개체를 전용 클래스의 인스턴스가 아니라 문자열 키를 쓰는 이종 맵으로 표현하는 데 있습니다. 도서관 예제에서 책과 저자, 회원은 각각 레코드입니다. 여러 값이나 식별자를 위치에 따라 담을 때는 배열이나 리스트 같은 순차 컬렉션을 쓰고, ISBN·저자 ID·회원 이메일처럼 식별 키로 레코드에 접근할 때는 색인을 둡니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 2
레코드는 하나의 데이터 개체를, 순차 컬렉션은 위치에 따른 나열을 나타냅니다. 이 예제의 색인은 식별 키에서 같은 종류의 레코드로 이어지는 조회 구조이며, 키 기반 접근이 필요한 곳에서 선택합니다.

도서관 예제의 Book.authorIds는 저자 ID를 담은 순차 컬렉션입니다. Catalog.authorsById 색인에서는 각 ID를 조회 키로 써 해당 저자 레코드에 접근합니다. Catalog.booksByIsbn은 ISBN을 키로 책 레코드에 접근하는 색인입니다. 이 예제의 각 색인은 같은 종류의 레코드를 값으로 갖는 동종 문자열 맵이며, authorIds와 authorsById를 조합해 책과 저자 사이의 간접 관계를 표현하고 탐색합니다. 반면 library.catalog처럼 직접 포함되는 관계는 레코드를 중첩해 표현합니다. 컬렉션과 색인은 DOP만의 장치도, 모든 DOP 설계에 필요한 구성요소도 아닙니다. 원칙 #2의 핵심은 데이터 개체를 클래스 인스턴스가 아니라 범용 자료구조로 표현하는 데 있습니다. 따라서 색인은 키 기반 접근이 필요한 곳에서 선택하면 됩니다.

교재에서 객체 지향 설계자인 테오가 받은 충격을, 저는 클래스 수의 감소보다 내려놓을 수 있는 설계 장치의 범위로 해석했습니다. 데이터의 종류마다 클래스 계층과 전용 접근 메서드를 먼저 만들지 않아도 get, filter, merge 같은 범용 함수를 여러 데이터에 적용할 수 있기 때문입니다. 업무 규칙은 사라지지 않습니다. 대출 가능 여부처럼 도메인의 의미를 알아야 하는 계산은 여전히 필요하지만, 그 규칙을 특정 객체 안에 숨기지 않고 입력 데이터를 받아 결과를 만드는 함수로 드러낼 수 있습니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 3
발제에서는 클래스 수보다 데이터 표현을 클래스 계층에서 범용 맵으로 옮겼을 때 무엇을 내려놓고 무엇을 남기는지에 주목했습니다.

정보 경로가 값이 되면 조작도 조합할 수 있습니다

발제에서 특히 흥미로웠던 개념은 ‘정보 경로’를 일급 값으로 다루는 방식이었습니다. 객체의 중첩 필드에 접근하는 문법을 코드에 직접 박아 넣는 대신, 책 제목까지 이어지는 경로를 문자열 배열 같은 데이터로 표현합니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 4
필드 접근 경로를 코드 문법이 아니라 값으로 만들면 함수에 전달하고 저장하거나 다른 경로와 조합할 수 있습니다.

경로가 값이 되면 함수의 인자로 넘기거나 설정에 저장하고, 실행 중에 새 경로를 만들 수 있습니다. 범용 함수와 결합하면 데이터마다 비슷한 접근 코드를 반복하는 대신 작은 조작을 조합해 조회와 변환을 표현합니다.

이 대목은 예전에 참여한 클린 아키텍처 스터디에서 접한 험블 오브젝트와도 연결해 봤습니다. 여기서 중요한 것은 객체의 생명주기보다, 복잡하고 빠르게 변하는 현실 세계의 세부를 핵심 객체 모델 안에 모두 담아내려는 시도를 내려놓는 데 있었습니다. 변화하기 쉬운 정보는 경계에서 데이터로 받아들이고, 핵심 계산은 그 데이터를 입력으로 다루게 하면 현실 세계의 세부와 핵심 로직 사이의 결합을 줄일 수 있습니다.

그렇다고 모든 접근을 문자열 경로로 바꿔야 한다는 뜻은 아닙니다. 오타를 리팩터링 도구가 놓치고 유효한 경로를 코드를 따라가며 확인해야 할 때도 있습니다. 정보 경로를 값으로 만드는 자유가 필요한 부분과 명시적인 구조가 더 읽기 쉬운 부분을 나눠야 합니다.

유연성의 비용은 다른 경계에서 회수해야 합니다

범용 맵은 필드를 추가하거나 제거하고 같은 조작을 여러 데이터에 적용하기 쉽습니다. 대신 고정된 클래스 정의나 정적 타입이 제공하던 필드 발견성과 자동완성, 오류 검출의 일부를 다른 장치로 보완해야 합니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 5
이 도식은 범용 구조가 넓힌 유연성과, 별도 스키마가 입력 경계에서 맡는 실행 시점 검증을 구분해 보여 줍니다.

발제에서는 TypeScript가 범용 자료구조를 다룰 때 생기는 개발 경험의 빈틈을 일부 보완할 수 있다고 봤습니다. 인터페이스나 타입 별칭으로 예상 형태를 기술하면, 구조적 타입 검사는 특정 클래스의 인스턴스를 요구하지 않으면서 필요한 필드와 자료형의 호환성을 확인하고 자동완성과 정적 진단을 제공합니다. 다만 레코드마다 고정 인터페이스를 강하게 적용하면 데이터의 모양을 다시 코드의 타입 정의에 묶을 수 있고, TypeScript의 타입은 실행 시 사라지므로 외부 JSON이나 데이터베이스 행의 유효성은 보장하지 않습니다. 따라서 TypeScript는 DOP가 내준 안전성을 모두 회수하는 해답이라기보다 개발 시점에 선택할 수 있는 보조 장치에 가깝습니다.

교재가 중심에 두는 해법은 TypeScript 자체가 아니라 데이터 표현과 스키마의 분리입니다. 데이터는 맵과 배열로 유지하되, 검증이 필요한 경계에서는 JSON Schema 같은 별도 스키마로 형태와 제약을 실행 중에 확인합니다. 정적 타입 검사는 개발 시점의 코드 작성을 돕고, 외부 데이터의 계약은 런타임 검증이 맡습니다.

발제에서는 데이터베이스 행과 API의 JSON이 이미 범용 구조에 가까우므로 깊은 객체 계층으로 다시 옮기지 않으면 매핑 코드를 줄일 수 있다고 연결했습니다. 교재와 다시 대조한 뒤에는 입력 경계라는 단서를 보탰습니다. 외부 구조를 그대로 흘려보낼 범위와 내부 불변 조건을 지키기 위해 검증하거나 변환할 범위를 의식적으로 나눠야 합니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 6
발제 장표는 클래스를 없앴을 때의 불편, TypeScript와 DOP의 경계, 필드명 오타의 비용을 세 가지 질문으로 남겼습니다.

변경을 계산과 커밋으로 나눴습니다

4장은 DOP의 원칙 #3, ‘데이터는 불변이다’를 상태 관리로 풀어냅니다. 기존 값을 제자리에서 고치는 대신 먼저 다음 버전을 계산하고, 계산이 끝난 뒤 현재 상태를 가리키는 참조를 새 버전으로 교체합니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 7
새 버전을 만드는 계산과 실제 참조를 바꾸는 커밋을 분리하면 사이드 이펙트가 일어나는 지점이 선명해집니다.

이 구분은 상태 변경을 없애는 방법이 아닙니다. 사용자의 요청을 처리하려면 현재 상태는 결국 달라집니다. 핵심은 새 값을 만드는 계산과 시스템 상태의 참조를 계산된 버전으로 전진시키는 순간을 한 덩어리로 섞지 않는 데 있습니다. 계산은 독립적으로 테스트하고, 현재 참조를 확인해 새 버전으로 옮기는 과정과 동시성·실패 처리는 커밋 경계에 모을 수 있습니다.

불변 데이터를 쓴다고 매번 전체 상태를 복사할 필요도 없습니다. 구조적 공유는 달라진 값까지 이어지는 경로의 노드만 새로 만들고, 바뀌지 않은 부분은 이전 버전과 같은 객체를 가리키게 합니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 8
도식의 화살표는 모두 상위 레코드의 필드나 맵 항목이 하위 값을 참조한다는 뜻입니다. Library → Catalogcatalog 필드의 직접 포함 관계이고, 구조적 공유는 두 버전의 경로가 같은 회색 노드로 모이는 모습으로 표시했습니다.

공유된 값이 이후에도 제자리에서 바뀌지 않기 때문에 두 버전은 같은 하위 구조를 안전하게 재사용합니다. 참조를 보관한 과거 버전은 그대로 남아 있어 비교와 되돌리기가 단순해집니다. 구조적 공유가 모든 비용을 없애지는 않습니다. 데이터의 깊이와 변경 빈도, 언어와 라이브러리에 따라 복사·메모리 관리 비용이 달라지므로 실제 규모에서는 측정이 필요합니다. 그래도 “불변이면 전체를 매번 복사해야 한다”는 걱정에는 분명한 답을 줍니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 9
되돌리기는 과거 데이터를 역으로 계산하는 작업보다, 현재 상태의 참조를 이미 존재하는 이전 버전으로 옮기는 일로 설명할 수 있습니다.

익숙한 도구에서 같은 원리를 다시 읽었습니다

4장의 설명을 React와 Git에 연결하자 불변 버전과 참조 이동이라는 말이 훨씬 구체적으로 다가왔습니다.


React에서는 새 상태를 만들어 전달합니다

React의 상태는 각 렌더에 고정된 스냅샷처럼 다뤄집니다. 상태 설정 함수를 호출해도 현재 렌더가 보고 있는 값이 즉시 바뀌는 것은 아니며, 다음 렌더에 사용할 상태 변경을 요청합니다. 객체 상태도 기존 객체를 직접 수정하기보다 새 객체를 만들어 상태 설정 함수에 전달합니다. React는 새 상태와 현재 상태를 Object.is로 비교하므로 같은 객체를 다시 전달하면 렌더링을 건너뛸 수 있습니다.

중첩된 상태를 갱신할 때는 변경된 위치까지 이어지는 객체만 새로 만들고, 바뀌지 않은 값의 참조는 재사용할 수 있습니다. 전개 문법이나 Immer 같은 도구가 이 작업을 돕습니다. 다만 useState가 영속 자료구조나 구조적 공유를 자동으로 구현하는 것은 아닙니다. 어느 범위를 복사하고 어떤 참조를 재사용할지는 애플리케이션 코드와 도구가 결정합니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 10
새 최상위 참조와 공유된 하위 참조는 React가 자동으로 만든 결과가 아니라, 애플리케이션 코드가 변경 경로만 복사해 구성한 결과입니다.


Git에서는 스냅샷을 쌓고 참조를 옮깁니다

Git과의 연결은 제가 과거에 저장소 구조를 관계형 데이터베이스의 ERD로 직접 설계해 본 경험에서 출발했습니다. 이번에는 그 모델을 Blob, Tree, Commit, Branch의 관계로 풀어내며 4장을 다시 읽었습니다.

Git의 커밋은 변경 명령 목록 자체보다 그 시점의 루트 Tree를 가리키는 스냅샷입니다. 루트 Tree 아래에는 디렉터리 구조를 나타내는 Tree와 파일 내용을 담은 Blob이 이어집니다. diff는 커밋 안에 저장된 변경 목록을 읽는 작업이 아니라 두 스냅샷의 Tree를 비교해 계산한 결과로 볼 수 있습니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 11
커밋이 가리키는 루트 Tree와 두 스냅샷 사이에서 계산되는 diff의 위치를 함께 볼 수 있습니다.

Blob과 Tree, Commit은 객체의 종류와 내용을 바탕으로 계산한 식별자를 갖습니다. 파일 하나가 바뀌면 그 Blob과 이를 포함하는 Tree 경로는 새 객체가 되지만, 관계없는 Blob과 하위 Tree는 이전 커밋과 같은 객체를 계속 참조합니다. 자식 객체의 식별자가 달라지면 그 값을 담은 부모 Tree와 루트까지 새 식별자가 이어집니다. 반대로 Tree 식별자가 같다면 그 아래 구조도 같다고 판단할 수 있습니다. Git의 논리적 스냅샷에서도 바뀌지 않은 객체를 재사용하고 달라진 경로만 새로 연결하는 구조적 공유를 확인할 수 있습니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 12
파일 이름과 모드는 Tree entry에, 내용은 Blob에 나뉘어 놓입니다. 장표 제목을 정확히 풀면 ‘같은 객체 종류와 같은 내용’일 때 같은 식별자가 됩니다.

브랜치는 커밋 목록 전체를 직접 품지 않습니다. 현재 끝에 있는 커밋을 가리키는 참조를 가지고, 저장소의 이력은 그 커밋에서 부모 관계를 따라가며 계산합니다. 새 커밋을 만들 때는 기존 커밋을 고치는 대신 새 객체를 만든 뒤 브랜치 참조를 옮깁니다. 불변 데이터인 커밋 그래프와 현재 상태를 나타내는 브랜치 참조가 분리되어 있는 셈입니다.

일반적인 커밋은 새 객체를 계산한 다음 브랜치 참조를 옮기는 과정이고, fast-forward는 이미 존재하는 후손 커밋으로 참조만 이동하는 경우입니다. rebase와 cherry-pick은 과거 커밋을 제자리에서 수정하지 않고 선택한 변경을 바탕으로 식별자가 다른 새 커밋을 만듭니다.

물론 Git이 곧 DOP의 구현이라는 뜻은 아닙니다. Git은 packfile과 델타 압축 같은 물리적 최적화를 사용하며, 버전 관리와 애플리케이션 상태 관리의 목적도 다릅니다. 그래도 논리적 수준에서 불변 객체를 만들고, 바뀌지 않은 구조를 공유하며, 현재 버전을 참조로 선택한다는 흐름은 4장을 익숙한 시스템으로 이해하게 해 주었습니다. Git 객체와 각 명령의 참조 변화를 실제 저장소에서 추적하는 일은 후속 글로 확장해 볼 만한 주제로 남겨 두었습니다.

발제에서 남긴 질문을 다음 학습으로 이어갑니다

발제 자료가 남긴 질문은 다음 학습의 출발점으로 이어졌습니다.

다음 주제로 예고된 JSON Schema는 첫 번째 질문과 직접 연결됩니다. 데이터 표현은 범용 구조로 열어 두면서도 필요한 경계에서는 형태와 제약을 명시적으로 검사하는 방법을 더 살펴보려 합니다.

결국 남은 것은 어디에 경계를 둘지였습니다

이번 발제의 두 원칙은 하나의 흐름으로 이어졌습니다. 범용 자료구조는 데이터를 특정 코드의 속박에서 풀어 주고, 불변성은 그렇게 자유로워진 데이터를 안전하게 다루는 규율을 제공합니다.

한쪽의 장점만 남지는 않습니다. 클래스와 전용 메서드를 줄이면 데이터의 변경과 재사용이 쉬워지지만, 필드의 발견성과 유효성 검증을 다른 곳에서 회수해야 합니다. 상태를 새 버전으로 다루면 추론과 되돌리기가 단순해지지만, 실제 커밋 지점의 동시성과 실패 처리는 여전히 설계해야 합니다.

결국 중요한 질문은 맵과 클래스 중 무엇이 항상 더 좋은가가 아닙니다. 변화하기 쉬운 정보를 어디까지 데이터로 열어 둘지, 그 자유가 위험해지는 경계에 어떤 타입과 스키마, 커밋 규칙을 둘지 선택하는 일입니다. 다음 모임에서도 이 질문을 조금 더 구체적인 도구와 코드로 이어가 보겠습니다.

발표를 준비하고 질문을 이어 가며 두 번째 모임을 함께 만들어 주신 모든 분께 감사드립니다.

데이터 지향 프로그래밍 2주차 후기 - 데이터는 맵으로, 상태는 참조로 - 이미지 13




작성 참고