1. 객체 지향 프로그래밍의 복잡성
이 챕터는 OOP를 비판하기 위한 것이 아니라, OOP가 시스템 복잡성을 증가시키는 경향이 있음을 인식하게 하고, 이를 줄이는 대안 패러다임인 DOP(Data-Oriented Programming) 를 탐색하도록 동기를 부여하기 위한 것이다.
배경: Klafim 프로젝트 요구사항
- 도서 소셜 네트워크 스타트업 Klafim의 글로벌 도서관 관리 시스템(GLMS) 프로토타입 개발
- 두 가지 사용자 유형:
Member(회원)/Librarian(사서) - 핵심 기능 목록
| 기능 | 대상 |
|---|---|
| 이메일 + 비밀번호 로그인 | 모두 |
| 도서 대출 | Member |
| 제목 / 저자로 도서 검색 | 모두 |
| 회원 차단 / 차단 해제 | Librarian |
| 특정 회원의 대출 도서 목록 조회 | Librarian |
| 다수의 도서 복사본 관리 | 시스템 |
| 도서의 소속 도서관 관리 | 시스템 |
1.1 OOP 설계: 고전적 방식
1.1.1 설계 단계
- Theo(개발자)는 모든 비즈니스 엔티티를 클래스로 표현하는 OOP 설계 접근 방식을 채택
- UML 클래스 다이어그램을 먼저 작성한 후 코딩에 진입
식별된 주요 클래스
Library: 시스템의 중심 루트 클래스Book: 도서 엔티티BookItem: 도서의 개별 복사본BookLending: 대출 발생 시 생성되는 대출 정보 객체Member: 도서관 회원Librarian: 사서User:Librarian과Member의 기반 클래스Catalog: 도서 목록 관리Author: 저자
1.1.2 UML 기초 (UML 101)
UML 화살표 종류 및 의미
| 화살표 종류 | 표기 | 의미 |
|---|---|---|
| 합성 (Composition) | 실선 + 채워진 다이아몬드 | 부모 객체가 죽으면 자식 객체도 함께 죽음 |
| 연관 (Association) | 실선 + 빈 다이아몬드 | 각 객체가 독립적인 생명주기를 가짐 |
| 사용 (Usage) | 점선 화살표 | 한 클래스가 다른 클래스의 메서드를 사용 |
| 상속 (Inheritance) | 실선 + 빈 삼각형 | 클래스 계층 관계, 화살표는 슈퍼클래스를 가리킴 |
핵심 인사이트: 합성(Composition)은 생명주기가 연동되고, 연관(Association)은 각자 독립적 생명주기를 가진다.
1.1.3 클래스 다이어그램 각 요소 설명
Library 클래스
- 시스템의 루트 역할, 자체 동작 없이 소유 객체에 모두 위임
- 소유 관계 (합성)
Member다수Librarian다수Catalog1개
User / Member / Librarian 클래스
User: 공통 기반 클래스- 데이터:
id,email,password - 메서드:
login()
- 데이터:
Member:User를 상속- 추가 데이터: 없음
- 메서드:
checkout(),returnBook(),block(),unblock(),isBlocked() BookLending다수 소유
Librarian:User를 상속- 추가 데이터: 없음
- 메서드:
blockMember(),unblockMember(),getBookLendingsOfMember(),addBookItem()
Catalog 클래스
- 도서 관리 책임
- 메서드:
search(),addBookItem() Book다수 소유
Book / BookItem / BookLending 클래스
Book- 데이터:
id,title Author와 다대다 연관 관계 (독립적 생명주기)BookItem다수 소유
- 데이터:
BookItem: 도서의 개별 복사본- 데이터:
id,libId - 메서드:
checkout() BookLending다수 소유
- 데이터:
BookLending: 대출 이벤트 객체- 데이터:
id,lendingDate,dueDate - 메서드:
isLate(),returnBook()
- 데이터:
1.2 복잡성의 원인
OOP 시스템이 복잡해지는 근본 원인은 다음 네 가지로 요약된다.
| 원인 | 복잡성에 미치는 영향 |
|---|---|
| 코드와 데이터의 혼합 | 클래스가 많은 관계에 얽히게 됨 |
| 객체의 가변성 (Mutable) | 코드 동작 예측이 어려워짐 |
| 객체의 가변성 (Mutable) | 멀티스레드 환경에서 명시적 동기화 필요 |
| 데이터가 객체 안에 갇힘 | 직렬화가 단순하지 않음 |
| 코드가 클래스 안에 갇힘 | 클래스 계층 구조가 복잡해짐 |
참고: Ben Moseley & Peter Marks의 논문 “Out of the Tar Pit”(2006)에서 정의한 개념, 즉 “시스템을 이해하기 어렵게 만드는 것”을 의미한다. 실행 자원 소비량과는 무관하다.
1.2.1 클래스 간 관계의 과다
문제: 코드와 데이터가 클래스 안에 혼합되면, 클래스는 코드 관계와 데이터 관계를 동시에 가져 관계가 폭발적으로 증가한다.
예시 - Member 클래스의 관계
flowchart TD
Library -->|has many| Member
Member -->|has many| BookLending
Member -->|extends| User
Librarian -->|uses| Member
Member -->|uses| BookItem
Member클래스 하나가 5개의 관계에 연루됨- 데이터 관계 2개: Library, BookLending
- 코드 관계 3개: User(상속), Librarian(사용됨), BookItem(사용)
해결 사고 실험: Member를 MemberCode와 MemberData로 분리하면?
flowchart LR
subgraph Data Side
Library2[Library] -->|has many| MemberData
MemberData -->|has many| BookLending2[BookLending]
end
subgraph Code Side
Librarian2[Librarian] -->|uses| MemberCode
MemberCode -->|extends| UserCode[UserCode]
MemberCode -->|uses| BookItem2[BookItem]
end
- 두 독립적인 서브시스템으로 분리됨
- 각 부분을 별도로, 순서 무관하게 이해 가능
핵심 인사이트: 코드와 데이터가 분리된 시스템은 혼합된 시스템보다 단순하다. 단순한 독립 부분 여러 개로 구성된 시스템이, 하나의 복잡한 덩어리보다 덜 복잡하다.
1.2.2 예측 불가능한 코드 동작
Case 1: 지역 변수에 할당 후 두 번 출력 (예측 가능)
class Member {
isBlocked: boolean;
displayBlockedStatusTwice(): void {
const isBlocked = this.isBlocked; // 값을 로컬 변수에 복사
console.log(isBlocked); // true
console.log(isBlocked); // 항상 true (예측 가능)
}
}
boolean은 원시 타입(primitive), 값이 복사됨- 결과 예측 가능: 두 번 모두 동일한 값 출력
Case 2: 객체 멤버를 직접 두 번 접근 (예측 불가능)
class Member {
isBlocked: boolean;
displayBlockedStatusTwice(): void {
console.log(this.isBlocked); // true 일 수 있음
// --- 컨텍스트 스위치 발생 가능 ---
// 다른 스레드 또는 비동기 코드가 this.isBlocked = false 로 변경할 수 있음
console.log(this.isBlocked); // false 일 수도 있음
}
}
- 싱글 스레드 환경: 동일 값 출력 (안전해 보임)
- 멀티 스레드 환경: 두
console.log사이에 컨텍스트 스위치가 발생하면 값이 바뀔 수 있음 - JavaScript/TypeScript에서도 비동기 코드에 의해 동일한 문제 발생 가능
OOP에서의 해결책과 그 비용
mutex등 동시성 보호 메커니즘 사용- 성능 저하 발생
- 데드락(deadlock) 위험 증가
핵심 인사이트: 데이터가 가변적(mutable)이면 코드는 예측 불가능하다. DOP는 원시 타입과 컬렉션 타입 모두를 불변(immutable) 값으로 동일하게 취급함으로써 이 문제를 해결한다.
1.2.3 복잡한 데이터 직렬화
시나리오: /search REST 엔드포인트를 JSON으로 구현한다고 가정
요청 JSON
{
"searchCriteria": "author",
"query": "albert"
}
응답 JSON
[
{
"title": "The world as I see it",
"authors": [{ "fullName": "Albert Einstein" }]
},
{
"title": "The Stranger",
"authors": [{ "fullName": "Albert Camus" }]
}
]
OOP 방식의 구현 구조
flowchart LR
SearchController -->|creates| SearchQuery
SearchController -->|calls| Catalog
SearchController -->|creates| SearchResult
Catalog -->|returns| BookList["List<Book>"]
SearchQuery: JSON 문자열 → 데이터 객체 역직렬화SearchController: 흐름 조율SearchResult: 데이터 객체 → JSON 문자열 직렬화
문제점 - 엔드포인트마다 반복
| 엔드포인트 | 필요한 클래스 |
|---|---|
/search |
SearchQuery, SearchController, SearchResult |
/add-book-item |
BookItemQuery, AddBookItemController, BookItemResult |
| (추가 엔드포인트마다) | (동일 패턴 반복) |
- 직렬화/역직렬화 로직이 매번 새로운 클래스에 중복 작성됨
- 본질적으로 모든 직렬화는 동일한 작업: 필드명과 값을 순회하며 연결
- OOP에서는 데이터가 클래스 멤버에 갇혀있어 범용적 접근이 불가능
핵심 인사이트: OOP에서 데이터는 클래스 멤버 안에 갇혀 있어(locked in), 범용적인 직렬화가 어렵다. 대부분의 OOP 언어는 리플렉션(reflection)이나 코드 장황함(verbosity)으로 이를 부분적으로 완화하지만, 둘 다 복잡성을 수반한다.
1.2.4 복잡한 클래스 계층 구조
문제: 코드가 클래스 메서드에 갇혀있어(locked in), 새 요구사항 발생 시 계층 구조를 재편해야 한다.
시나리오 1: VIP Member 추가 요구
VIPMember는 회원이면서(Member) 도서 아이템 추가 권한(Librarian의 기능)도 필요Librarian::addBookItem코드를 재사용할 방법이 없음 - 클래스에 갇혀있기 때문
해결책: 중간 기반 클래스 UserWithBookItemRight 추가
classDiagram
User <|-- UserWithBookItemRight
UserWithBookItemRight <|-- Librarian
UserWithBookItemRight <|-- VIPMember
User <|-- Member
VIPMember --|> Member : also extends
addBookItem을UserWithBookItemRight로 이동Librarian과VIPMember모두UserWithBookItemRight를 상속VIPMember가Member와UserWithBookItemRight를 동시에 상속 -> 다중 상속 문제 발생
시나리오 2: Super Member 추가 요구
SuperMember는 회원이면서 특정 Librarian 권한(대출 목록 조회)도 필요- 계층 구조에
UserWithBlockMemberRight까지 추가 필요
최종 결과
classDiagram
User <|-- UserWithBookItemRight
User <|-- UserWithBlockMemberRight
UserWithBookItemRight <|-- VIPMember
UserWithBlockMemberRight <|-- VIPMember
UserWithBookItemRight <|-- Librarian
UserWithBlockMemberRight <|-- Librarian
User <|-- Member
Member <|-- VIPMember
Member <|-- SuperMember
UserWithBookItemRight <|-- SuperMember
- “Deadly Diamond of Death” 패턴이 3개 발생
- 클래스 D가 B와 C를 상속하고, B와 C가 모두 A를 상속하는 모호성
- 시스템이 너무 복잡해져 데드라인 내 구현 실패
핵심 인사이트: OOP에서는 코드가 클래스에 갇혀있어 새 요구사항이 생길 때마다 클래스 계층 전체를 재설계해야 한다. 클래스 상속보다 합성(Composition over Inheritance) 을 선호해야 한다.
챕터 1 요약
용어 정의
복잡성(Complexity): 이해하기 어려운 것 (실행 자원과 무관)DOP: Data-Oriented ProgrammingOOP: Object-Oriented ProgrammingFP: Functional Programming
OOP의 복잡성 증가 원인 4가지
- 코드와 데이터 혼합 - 클래스가 코드 관계와 데이터 관계를 동시에 가져 관계가 복잡해짐
- 객체 가변성 - 코드 동작 예측 불가, 멀티스레드 환경에서 명시적 동기화 필요
- 데이터가 객체에 갇힘 - 범용적 데이터 직렬화/역직렬화가 어려움
- 코드가 클래스에 갇힘 - 새 요구사항마다 복잡한 클래스 계층 재설계 필요
DOP가 제시하는 방향
- 코드와 데이터를 분리
- 데이터를 불변(immutable) 으로 취급
- 데이터에 범용적으로 접근 가능하도록 설계
- DOP는 OOP와도, FP와도 호환 가능
핵심 요약: OOP의 복잡성 증가는 특정 언어의 문법 문제가 아니라 “객체 안에 상태와 메서드를 함께 묶는다”는 OOP의 근본 철학에서 비롯된다. 코드와 데이터를 분리하는 것만으로도 시스템은 눈에 띄게 단순해진다.
2. 코드와 데이터의 분리
DOP의 첫 번째 원칙: 코드는 함수 안에, 데이터는 함수의 컨텍스트 밖에 존재해야 한다. 함수의 동작이 함수 내부에 어떤 식으로든 캡슐화된 데이터에 의존해서는 안 된다.
2.1 DOP 시스템의 두 가지 구성 요소
- DOP 시스템은 명확히 두 파트로 분리된다
- 데이터 엔티티(Data Entities): 정보를 보유하는 구조체. 멤버(필드)만 가짐
- 코드 모듈(Code Modules): 기능을 수행하는 순수 함수들의 집합. 상태를 가지지 않음
- DOP 원칙은 언어에 종속되지 않는다 — OOP 언어, 함수형 언어 모두에서 적용 가능
- DOP는 데이터 캡슐화에 반대한다
- OOP에서 데이터와 코드가 하나의 객체에 혼재하는 것이 복잡성과 경직성의 주요 원인
flowchart LR
subgraph DOP 시스템
DE[데이터 엔티티\n멤버만 존재\n코드 없음]
CM[코드 모듈\n상태 없는 함수들\n멤버 없음]
end
CM -->|데이터를 인자로 받아 사용| DE
| 구성 요소 | 엔티티 제약 | 관계 제약 |
|---|---|---|
| 데이터 엔티티 | 멤버(필드)만 존재, 코드 없음 | 연관(Association), 합성(Composition) |
| 코드 모듈 | 상태 없는 함수만 존재, 멤버 없음 | 사용(Usage)만 허용, 상속 없음 |
2.2 데이터 엔티티 (Data Entities)
- 데이터 엔티티: 시스템에서 정보를 보유하는 부분
- 데이터 엔티티를 발견하는 방법: 요구사항에서 명사와 명사구를 찾아라
도서관 관리 시스템 예시 (Library Management System)
요구사항에서 추출한 명사(데이터 엔티티):
users,members,librarians→ 사용자 관리(User Management) 그룹books,authors,book copies,book lendings→ 카탈로그(Catalog) 그룹
mindmap
root((Library Data))
Catalog
Books
Authors
Book Items
Book Lendings
User Management
Users
Members
Librarians
TIP: 데이터 엔티티를 발견한 후, 중첩 목록이나 마인드맵으로 고수준 그룹으로 분류하라.
2.3 코드 모듈 (Code Modules)
- 코드 모듈: 기능(함수)의 집합체. DOP에서는 모든 함수가 상태를 가지지 않는다
- 기능을 발견하는 방법: 요구사항에서 동사구를 찾아라
모듈 설계 원칙
- 외부에 노출되는 함수들을 하나의 모듈로 묶어라
- 모듈 내 모든 함수는 정적(static) 이며 상태가 없다
- 함수가 조작할 데이터는 첫 번째 인자로 명시적으로 전달한다
- 고수준 모듈은 고수준 데이터 엔티티에 대응한다
OOP vs DOP 비교
기존 OOP 방식 — 데이터가 암묵적 인자(implicit argument)로 전달됨:
class Library {
catalog: Catalog;
userManagement: UserManagement;
getBookLendings(userId: string, memberId: string) {
// this.catalog, this.userManagement 를 통해 상태에 접근
}
}
DOP 방식 — 데이터가 명시적 인자(explicit argument)로 전달됨:
class Library {
static getBookLendings(
libraryData: LibraryData,
userId: string,
memberId: string
) {
// libraryData를 통해 데이터에 접근
}
}
핵심 인사이트: 전통적인 OOP에서 객체의 상태는 메서드의 암묵적 인자다. DOP에서는 이를 명시적 인자로 전달한다.
도서관 시스템의 최종 모듈 구조
flowchart TD
subgraph lib["Library 모듈"]
L1["searchBook(libraryData, searchQuery)"]
L2["addBookItem(libraryData, bookItemInfo)"]
L3["blockMember(libraryData, memberId)"]
L4["unblockMember(libraryData, memberId)"]
L5["login(libraryData, loginInfo)"]
L6["getBookLendings(libraryData, userId)"]
L7["checkoutBook(libraryData, userId, bookItemId)"]
L8["returnBook(libraryData, userId, bookItemId)"]
end
subgraph cat["Catalog 모듈"]
C1["searchBook(catalogData, searchQuery)"]
C2["addBookItem(catalogData, bookItemInfo)"]
C3["checkoutBook(catalogData, bookItemId)"]
C4["returnBook(catalogData, bookItemId)"]
C5["getBookLendings(catalogData, userId)"]
end
subgraph um["UserManagement 모듈"]
U1["blockMember(userManagementData, memberId)"]
U2["unblockMember(userManagementData, memberId)"]
U3["login(userManagementData, loginInfo)"]
U4["isLibrarian(userManagementData, userId)"]
end
lib -->|사용| cat
lib -->|사용| um
Library모듈의 함수들이 따르는 공통 패턴:libraryData를 인자로 받는다Catalog함수 호출 시libraryData.catalog를 전달한다UserManagement함수 호출 시libraryData.userManagement를 전달한다
TIP: 고수준 DOP 모듈은 고수준 데이터 엔티티에 대응한다. TIP: DOP 모듈 간의 유일한 관계는 사용(Usage) 관계다.
2.4 DOP 시스템은 이해하기 쉽다
왜 이해하기 쉬운가?
- 시스템이 코드 모듈과 데이터 엔티티 두 파트로 명확히 분리되어 있기 때문
- 데이터 엔티티를 이해할 때: 그것을 조작하는 코드의 세부사항을 알 필요가 없다
- 코드 모듈을 이해할 때: 조작되는 데이터 엔티티의 세부사항을 알 필요가 없다
- 관심사의 명확한 분리(Clear Separation of Concerns)
OOP 클래스 다이어그램 vs DOP 모듈 다이어그램
| 비교 항목 | OOP 클래스 다이어그램 | DOP 모듈 다이어그램 |
|---|---|---|
| 함수/메서드 | 인스턴스 메서드 (상태 있음) | 정적 함수 (상태 없음) |
| 모듈 간 관계 | 연관, 합성, 상속 등 다양함 | 사용(Usage)만 존재 |
| 복잡도 | 높음 | 낮음 |
- DOP 모듈 다이어그램이 더 단순한 이유: 제약(Constraints) 이 있기 때문
- 모든 함수는 정적(stateless)
- 모듈 간 관계는 오직 사용(Usage) 뿐 — 연관도, 합성도, 상속도 없다
참고: DOP에서 다형성(Polymorphism)은 클래스 상속이 아닌 다른 메커니즘으로 구현한다 (13장에서 다룸).
2.5 DOP 시스템은 유연하다
유연성의 근거
- 코드와 데이터가 분리되어 있기 때문에, 요구사항이 바뀌어도 시스템 설계 변경 없이 적응 가능한 경우가 많다
예시: Super Member 요구사항 추가
새 요구사항:
- Super Member: 다른 멤버의 대출 목록 조회 가능
- VIP Member: 도서 아이템 추가 가능
기존 코드:
class Library {
static getBookLendings(
libraryData: LibraryData,
userId: string,
memberId: string
): BookLending[] {
if (UserManagement.isLibrarian(libraryData.userManagement, userId)) {
return Catalog.getBookLendings(libraryData.catalog, memberId);
} else {
throw new Error("Not allowed to get book lendings");
}
}
}
Super Member 지원 추가 후 — 설계 변경 없이 함수 하나만 추가:
class Library {
static getBookLendings(
libraryData: LibraryData,
userId: string,
memberId: string
): BookLending[] {
const isAllowed =
UserManagement.isLibrarian(libraryData.userManagement, userId) ||
UserManagement.isSuperMember(libraryData.userManagement, userId);
if (isAllowed) {
return Catalog.getBookLendings(libraryData.catalog, memberId);
} else {
throw new Error("Not allowed to get book lendings");
}
}
}
class UserManagement {
static isLibrarian(userManagementData: UserManagementData, userId: string): boolean {
// 구현 예정
return false;
}
// 새로 추가된 함수 — 기존 설계 변경 없음
static isSuperMember(userManagementData: UserManagementData, userId: string): boolean {
// 구현 예정
return false;
}
}
VIP Member 지원 추가 — 동일한 패턴 반복:
class Library {
static addBookItem(
libraryData: LibraryData,
userId: string,
bookItemInfo: BookItemInfo
): void {
const isAllowed =
UserManagement.isLibrarian(libraryData.userManagement, userId) ||
UserManagement.isVIPMember(libraryData.userManagement, userId);
if (isAllowed) {
Catalog.addBookItem(libraryData.catalog, bookItemInfo);
} else {
throw new Error("Not allowed to add a book item");
}
}
}
class UserManagement {
static isLibrarian(userManagementData: UserManagementData, userId: string): boolean {
return false;
}
static isVIPMember(userManagementData: UserManagementData, userId: string): boolean {
return false;
}
}
TIP: DOP 시스템은 유연하다. 요구사항 변경에 대해 시스템 설계 변경 없이 적응하는 경우가 많다.
챕터 2 요약
- DOP의 첫 번째 원칙은 코드와 데이터의 분리다
- DOP 원칙은 언어에 종속되지 않는다 (OOP, 함수형 언어 모두 적용 가능)
- DOP는 데이터 캡슐화에 반대한다
- 데이터 엔티티: 정보를 보유하는 시스템의 구성 요소. 코드 없이 멤버(필드)만 존재
- 코드 모듈: 상태 없는(stateless) 함수들의 집합체. 멤버(필드) 없음
- 데이터 엔티티를 발견하려면 요구사항의 명사를, 코드 모듈을 발견하려면 동사구를 찾아라
- 전통적 OOP에서 객체 상태는 메서드의 암묵적 인자. DOP에서는 명시적 인자로 전달
- DOP 모듈 간의 유일한 관계는 사용(Usage) 관계 (연관, 합성, 상속 없음)
- 데이터 엔티티 간의 관계는 연관(Association) 과 합성(Composition) 만 존재
- 고수준 코드 모듈은 고수준 데이터 엔티티에 대응한다
- 코드와 데이터의 분리는 시스템을 이해하기 쉽게 만들고 유연하게 만든다
- 다형성(Polymorphism)은 클래스 상속 없이도 구현 가능 (13장에서 상세 설명)
3. 기본 데이터 조작
DOP에서는 OOP의 경직된 클래스 계층 구조 대신, 데이터를 맵(map)과 배열(array)의 유연한 조합으로 표현한다. 모든 정보는 “정보 경로(information path)“를 통해 접근 가능하며, 이것이 DOP의 두 번째 원칙의 핵심이다.
DOP 원칙 #2
핵심 인사이트: 데이터 엔티티를 범용 자료구조(generic data structures)로 표현하라.
3.1 데이터 모델 설계 (Designing a Data Model)
핵심 개념
- DOP에서 데이터 설계는 코드와 완전히 분리하여 독립적으로 진행한다
- 데이터 간의 관계는 오직 두 가지만 고려한다
- 연관(Association): 점선 + 빈 다이아몬드, 간접 참조
- 합성(Composition): 실선 + 채운 다이아몬드, 직접 포함
- 관계의 다중성: 일대일, 일대다, 다대다
데이터 집합의 세 가지 종류
| 종류 | 설명 | 표기법 | 예시 |
|---|---|---|---|
| 레코드(Record) | 서로 다른 타입의 필드를 묶은 데이터 구조 | C EntityName |
Book, Author |
| 순서있는 컬렉션(Positional Collection) | 요소가 순서대로 나열된 컬렉션 (배열, 리스트) | [String] |
authorIds: [String] |
| 인덱스(Index) | 키로 값에 접근하는 컬렉션 (해시맵, 딕셔너리) | {Book} |
booksByIsbn: {Book} |
인덱스의 키는 항상 문자열(string)이다.
도서관 관리 시스템 데이터 모델
erDiagram
Library {
string name
string address
}
Catalog {
object booksByIsbn
object authorsById
}
Book {
string isbn
string title
number publicationYear
array authorIds
}
Author {
string id
string name
array bookIsbns
}
BookItem {
string id
string libId
string purchaseDate
boolean isLent
}
BookLending {
string bookItemId
string bookIsbn
string lendingDate
}
UserManagement {
object librariansByEmail
object membersByEmail
}
Member {
string email
string encryptedPassword
boolean isBlocked
array bookLendings
}
Librarian {
string email
string encryptedPassword
}
Library ||--|| Catalog : contains
Library ||--|| UserManagement : contains
Catalog ||--o{ Book : booksByIsbn
Catalog ||--o{ Author : authorsById
Book ||--o{ BookItem : contains
Member ||--o{ BookLending : has
UserManagement ||--o{ Member : membersByEmail
UserManagement ||--o{ Librarian : librariansByEmail
Book과Author의 관계는 다대다 간접 연관:Book은authorIds(ID 배열)를 가지고,Author는bookIsbns(ISBN 배열)를 가진다- 실제
Author객체는Catalog의authorsById인덱스를 통해 조회한다
3.2 레코드를 맵으로 표현하기 (Representing Records as Maps)
DOP의 데이터 표현 방식
| 데이터 종류 | DOP 표현 방식 |
|---|---|
| 순서있는 컬렉션 | 배열(Array) |
| 인덱스 | 동질적 문자열 맵(Homogeneous string map) |
| 레코드 | 이질적 문자열 맵(Heterogeneous string map) |
- 동질적 맵(Homogeneous map): 모든 값이 같은 타입 (
{[key: string]: Book}) - 이질적 맵(Heterogeneous map): 값이 서로 다른 타입 (
{ isbn: string, title: string, publicationYear: number, ... })
핵심 인사이트: DOP에서 레코드는 이질적 문자열 맵으로 표현한다.
OOP 방식 vs DOP 방식 비교
OOP 방식 (클래스 기반)
class BookItem {
constructor(
public id: string,
public libId: string,
public isLent: boolean
) {}
}
class Book {
constructor(
public isbn: string,
public title: string,
public publicationYear: number,
public authors: string[],
public bookItems: BookItem[]
) {}
}
const watchmenBook = new Book(
"978-1779501127",
"Watchmen",
1987,
["alan-moore", "dave-gibbons"],
[
new BookItem("book-item-1", "nyc-central-lib", true),
new BookItem("book-item-2", "nyc-central-lib", false),
]
);
DOP 방식 (맵 기반)
const watchmenBook = {
isbn: "978-1779501127",
title: "Watchmen",
publicationYear: 1987,
authorIds: ["alan-moore", "dave-gibbons"],
bookItems: [
{ id: "book-item-1", libId: "nyc-central-lib", isLent: true },
{ id: "book-item-2", libId: "nyc-central-lib", isLent: false },
],
};
안전성 vs 유연성 트레이드오프
| 항목 | OOP | DOP |
|---|---|---|
| 안전성(Safety) | 높음 | 낮음 |
| 유연성(Flexibility) | 낮음 | 높음 |
| 범용성(Genericity) | 낮음 | 높음 |
OOP의 안전성이 가져오는 비용 (예시)
- 검색 결과에 저자 이름을 포함하고 책 아이템 정보를 제외하려면?
- OOP:
BookInSearchResults라는 새 클래스와 변환 생성자를 별도로 작성해야 함 - DOP: 런타임에 필드를 자유롭게 추가/삭제/변경 가능, 별도 클래스 불필요
DOP가 제공하는 두 가지 핵심 이점
- 유연성: 런타임에 레코드 필드를 자유롭게 추가, 삭제, 변경할 수 있다
- 범용성(JSON 직렬화 무료 제공): 레코드가 맵이므로
JSON.stringify()한 줄로 직렬화된다. 별도의 직렬화 로직(리플렉션 등) 불필요
DOP는 데이터 안전성을 일부 포기하는 대신, 유연성과 범용성을 얻는다.
3.3 범용 함수로 데이터 조작하기 (Manipulating Data with Generic Functions)
Catalog 레코드 인스턴스
import _ from "lodash";
const catalogData = {
booksByIsbn: {
"978-1779501127": {
isbn: "978-1779501127",
title: "Watchmen",
publicationYear: 1987,
authorIds: ["alan-moore", "dave-gibbons"],
bookItems: [
{ id: "book-item-1", libId: "nyc-central-lib", isLent: true },
{ id: "book-item-2", libId: "nyc-central-lib", isLent: false },
],
},
},
authorsById: {
"alan-moore": { name: "Alan Moore", bookIsbns: ["978-1779501127"] },
"dave-gibbons": { name: "Dave Gibbons", bookIsbns: ["978-1779501127"] },
},
};
정보 경로 (Information Path)
- DOP에서 모든 데이터 조각은 정보 경로(information path)를 통해 접근할 수 있다
- 정보 경로는 파일 시스템 경로와 유사하며, 중첩된 엔티티의 키들로 구성된다
- 정보 경로는 1급 시민(first-class citizen): 변수에 저장하고, 인자로 전달할 수 있다
// Watchmen 책의 제목에 대한 정보 경로
const path = ["booksByIsbn", "978-1779501127", "title"];
_.get(catalogData, path); // → "Watchmen"
// Alan Moore의 첫 번째 책 ISBN에 대한 정보 경로
_.get(catalogData, ["authorsById", "alan-moore", "bookIsbns", 0]);
// → "978-1779501127"
_.get의 직접 구현
function get(m: Record<string, any>, path: (string | number)[]): any {
let res = m;
for (const key of path) {
res = res[key];
}
return res;
}
정보 경로 방식의 차별점
- OOP에서는
catalogData.booksByIsbn["978-1779501127"].title처럼 특정 타입에 의존하는 전용 코드가 필요하다 - DOP에서는 경로를 변수로 만들어 범용 함수에 인자로 전달할 수 있다
- 정적 언어(Java 등)에서는 맵의 값 타입이
Map<String, Object>가 되어 꺼낼 때 타입 캐스팅이 필요하지만, 대부분의 경우 데이터를 그냥 전달(pass-around)만 하므로 큰 문제가 되지 않는다
참고: 맵에서 필드를 접근하는 것은 클래스 멤버 접근보다 미세하게 느리지만, 대부분의 경우 유의미한 성능 차이는 없다.
3.4 검색 결과 계산하기 (Calculating Search Results)
목표: 책 제목으로 검색 후 BookInfo 형태로 반환
// 반환 목표 형태
const bookInfo = {
title: "Watchmen",
isbn: "978-1779501127",
authorNames: ["Alan Moore", "Dave Gibbons"],
};
핵심 범용 함수들
_.map 직접 구현
function map<T, R>(coll: T[], f: (item: T) => R): R[] {
const res: R[] = [];
for (let i = 0; i < coll.length; i++) {
res[i] = f(coll[i]);
}
return res;
}
_.filter 직접 구현
function filter<T>(coll: T[], f: (item: T) => boolean): T[] {
const res: T[] = [];
for (const item of coll) {
if (f(item)) res.push(item);
}
return res;
}
Catalog 모듈 구현
import _ from "lodash";
class Catalog {
// 책의 authorIds를 author 이름 배열로 변환
static authorNames(catalogData: Record<string, any>, book: Record<string, any>): string[] {
const authorIds: string[] = _.get(book, "authorIds");
return _.map(authorIds, (authorId: string) =>
_.get(catalogData, ["authorsById", authorId, "name"])
);
}
// Book 레코드 → BookInfo 레코드 변환 (클래스 생성 불필요)
static bookInfo(catalogData: Record<string, any>, book: Record<string, any>) {
return {
title: _.get(book, "title"),
isbn: _.get(book, "isbn"),
authorNames: Catalog.authorNames(catalogData, book),
};
}
// 제목 기반 검색: Book 레코드들을 필터링하여 BookInfo 배열 반환
static searchBooksByTitle(catalogData: Record<string, any>, query: string) {
const allBooks = _.values(_.get(catalogData, "booksByIsbn"));
const matchingBooks = _.filter(allBooks, (book) =>
_.get(book, "title").includes(query)
);
return _.map(matchingBooks, (book) => Catalog.bookInfo(catalogData, book));
}
}
Library 모듈 구현
class Library {
// JSON 직렬화는 JSON.stringify 한 줄로 끝 (범용성의 핵심)
static searchBooksByTitleJSON(libraryData: Record<string, any>, query: string): string {
const catalogData = _.get(libraryData, "catalog");
const results = Catalog.searchBooksByTitle(catalogData, query);
return JSON.stringify(results);
}
}
사용 예시
const libraryData = {
catalog: catalogData, // 위에서 정의한 catalogData
};
Library.searchBooksByTitleJSON(libraryData, "Wat");
// → '[{"title":"Watchmen","isbn":"978-1779501127","authorNames":["Alan Moore","Dave Gibbons"]}]'
핵심 인사이트
bookInfo를 위한 별도 클래스를 만들 필요가 없다- 코드의 대부분은 단순한 데이터 조작(data manipulation) 이며, 불필요한 추상화가 없다
_.filter에 맵을 전달하면 맵의 값(values) 들을 순회한다
3.5 서로 다른 타입의 레코드 처리하기 (Handling Records of Different Types)
“레코드 타입을 어떻게 구분하는가?”
DOP의 놀라운 답: 대부분의 경우, 레코드 타입을 알 필요가 없다.
- 중요한 것은 필드의 값이다
- 예를 들어
Catalog.authorNames는Book레코드를 받지만, 실제로는authorIds필드의 값만 관심을 갖는다
타입 구분이 필요한 경우의 두 가지 패턴
패턴 1: 인덱스로 구분 (Librarian vs Member처럼 명확히 구분되는 경우)
import _ from "lodash";
function isLibrarian(userManagement: Record<string, any>, email: string): boolean {
return _.has(_.get(userManagement, "librariansByEmail"), email);
}
const userManagementData = {
librariansByEmail: {
"franck@gmail.com": {
email: "franck@gmail.com",
encryptedPassword: "bXlwYXNzd29yZA==",
},
},
membersByEmail: {
"samantha@gmail.com": {
email: "samantha@gmail.com",
encryptedPassword: "c2VjcmV0",
isBlocked: false,
bookLendings: [
{
bookItemId: "book-item-1",
bookIsbn: "978-1779501127",
lendingDate: "2020-04-23",
},
],
},
},
};
isLibrarian(userManagementData, "franck@gmail.com"); // → true
패턴 2: 피처 필드(Feature Field)로 구분 (한 레코드가 여러 타입 특성을 가질 수 있는 경우)
// isVIP, isSuper 같은 boolean 피처 필드로 타입 특성을 표현
function isVIPMember(userManagement: Record<string, any>, email: string): boolean {
return _.get(userManagement, ["membersByEmail", email, "isVIP"]) === true;
}
function isSuperMember(userManagement: Record<string, any>, email: string): boolean {
return _.get(userManagement, ["membersByEmail", email, "isSuper"]) === true;
}
두 패턴의 선택 기준
- 인덱스로 구분: 레코드 간 데이터 구조가 명확히 다를 때 (Librarian은 bookLendings 없음)
- 피처 필드로 구분: 한 레코드가 동시에 여러 특성을 가질 수 있을 때 (VIP이면서 동시에 Super일 수 있음)
types: ["VIP", "Super"]처럼 타입 컬렉션을 유지하는 것보다, 각 기능마다isVIP,isSuper같은 boolean 피처 필드를 두는 것이 더 단순하다.
최종 UserManagement 모듈
class UserManagement {
static isLibrarian(userManagement: Record<string, any>, email: string): boolean {
return _.has(_.get(userManagement, "librariansByEmail"), email);
}
static isVIPMember(userManagement: Record<string, any>, email: string): boolean {
return _.get(userManagement, ["membersByEmail", email, "isVIP"]) === true;
}
static isSuperMember(userManagement: Record<string, any>, email: string): boolean {
return _.get(userManagement, ["membersByEmail", email, "isSuper"]) === true;
}
}
챕터 3 요약
DOP 원칙 #2 핵심 정리
- 레코드는 이질적 문자열 맵으로 표현한다
- 컬렉션은 배열, 인덱스는 동질적 문자열 맵으로 표현한다
- 데이터 조작은
_.get,_.map,_.filter등의 범용 함수로 수행한다 - JSON 직렬화는
JSON.stringify한 줄로 무료 제공된다 - 모든 정보는 정보 경로(information path) 를 통해 접근할 수 있다
- 레코드 타입 정보는 대부분 불필요하며, 필요시 피처 필드로 표현한다
- 데이터와 코드의 약한 의존성 덕분에 요구사항 변경에 유연하게 대응할 수 있다
이 챕터에서 사용된 Lodash 함수 정리
| 함수 | 설명 |
|---|---|
_.get(map, path) |
path를 따라 map에서 값을 가져온다 |
_.has(map, path) |
map에 해당 path의 필드가 존재하는지 확인한다 |
_.merge(mapA, mapB) |
mapA와 mapB를 재귀적으로 병합한 새 맵을 반환한다 |
_.values(map) |
map의 값들로 구성된 배열을 반환한다 |
_.filter(coll, pred) |
pred를 만족하는 요소들만 걸러낸 배열을 반환한다 |
_.map(coll, f) |
coll의 각 요소를 f로 변환한 새 배열을 반환한다 |
4. 상태 관리
DOP Principle #3: Data is immutable.
핵심 개념 요약
- DOP에서 뮤테이션(mutation) 이란 시스템의 상태를 변경하는 연산이다.
- 상태 관리의 핵심은 데이터를 직접 변경하지 않고, 다중 버전(multi-version) 으로 관리하는 것이다.
- 뮤테이션은 두 단계로 분리된다.
| 단계 | 역할 | 상태 | 구현 방식 |
|---|---|---|---|
| 계산(Calculation) | 다음 버전의 시스템 데이터 계산 | Stateless | 뮤테이션별 고유 구현 |
| 커밋(Commit) | 시스템 상태를 다음 버전으로 전진 | Stateful | 모든 뮤테이션 공통 |
4.1 다중 버전 시스템 데이터 (Multiple Versions of the System Data)
핵심 아이디어
- Git의 커밋 히스토리처럼, 시스템 데이터를 여러 버전으로 유지한다.
- 특정 시점의 시스템 상태는 특정 버전의 시스템 데이터를 참조(reference) 한다.
- 뮤테이션 실행 후, 참조를 다음 버전으로 이동시킨다.
핵심 구분
핵심 인사이트: 데이터는 불변(immutable)이지만, 상태 참조는 가변(mutable)이다.
[Mutation A] --> Data V10 [Mutation B] --> Data V11 <-- System State (before C) [Mutation C] --> Data V12 <-- System State (after C)
이점
- 이전 버전의 데이터는 가비지 컬렉터에 의해 자동 수거되거나
- 의도적으로 히스토리 참조를 유지하면 Git처럼 이전 상태로 복원 가능
4.2 구조적 공유 (Structural Sharing)
핵심 아이디어
- 새로운 버전의 데이터를 만들 때, 변경된 부분만 새로 생성하고 나머지는 공유한다.
- 깊은 복사(deep copy) 없이 효율적으로 새 버전 생성이 가능하다.
핵심 인사이트: 구조적 공유는 두 버전 간에 공통된 부분을 재귀적으로 공유함으로써 메모리와 연산 측면에서 효율적으로 새 버전의 데이터를 생성한다.
동작 원리 시각화
Watchmen의 publicationYear를 1987 -> 1986으로 변경하는 경우:
flowchart TD
subgraph "Next Library"
NL[Library] --> NUP[UserManagement - 공유]
NL --> NC[Catalog - 새로 생성]
NC --> NAB[authorsById - 공유]
NC --> NBI[booksByIsbn - 새로 생성]
NBI --> NW[Watchmen - 새로 생성]
NW --> NY[publicationYear: 1986 - 새로 생성]
end
subgraph "Current Library"
CL[Library] --> CUP[UserManagement]
CL --> CC[Catalog]
CC --> CAB[authorsById]
CC --> CBI[booksByIsbn]
CBI --> CW[Watchmen]
CW --> CY[publicationYear: 1987]
end
UserManagement: 변경 없음 -> 공유authorsById: 변경 없음 -> 공유Library,Catalog,booksByIsbn,Watchmen,publicationYear: 새로 생성
Lodash FP 모듈을 이용한 불변 함수 사용
기본 Lodash의 _.set은 원본을 직접 변경(mutate)하므로, Lodash FP 모듈로 불변 버전을 사용해야 한다.
import * as fp from 'lodash/fp';
import _ from 'lodash';
// Lodash를 불변 모드로 설정 (시그니처는 기존 Lodash와 동일하게 유지)
const immutable_ = fp.convert({
cap: false,
curry: false,
fixed: false,
immutable: true,
rearg: false,
});
// publicationYear를 1986으로 변경한 새로운 libraryData 반환 (원본 불변)
const nextLibraryData = immutable_.set(
libraryData,
['catalog', 'booksByIsbn', '978-1779501127', 'publicationYear'],
1986
);
불변 함수(immutable function) 란, 데이터를 직접 변경하는 대신 변경된 새 버전의 데이터를 반환하는 함수이다.
멤버 추가 뮤테이션 코드 예시
// UserManagement 레이어: 멤버 추가
function addMember(userManagement: object, member: object): object {
const email = immutable_.get(member, 'email') as string;
const infoPath = ['membersByEmail', email];
if (immutable_.has(userManagement, infoPath)) {
throw new Error('Member already exists.');
}
// 멤버가 추가된 새로운 버전의 userManagement 반환
return immutable_.set(userManagement, infoPath, member);
}
// Library 레이어: UserManagement에 위임
function addMemberToLibrary(library: object, member: object): object {
const currentUserManagement = immutable_.get(library, 'userManagement') as object;
const nextUserManagement = addMember(currentUserManagement, member);
// userManagement가 갱신된 새로운 버전의 library 반환
return immutable_.set(library, 'userManagement', nextUserManagement);
}
- 모든 함수는 정적(static) 이며, 조작할 데이터를 인자로 받는다.
- 함수는 상태를 변경하지 않고 새 버전의 데이터를 반환한다.
4.3 구조적 공유 구현 (Implementing Structural Sharing)
구조적 공유의 핵심 구현은 단 11줄의 코드로 표현 가능하다.
function setImmutable(
map: Record<string, unknown>,
path: string[],
value: unknown
): Record<string, unknown> {
let modifiedNode: unknown = value;
const k = path[0];
const restOfPath = path.slice(1);
if (restOfPath.length > 0) {
modifiedNode = setImmutable(
map[k] as Record<string, unknown>,
restOfPath,
value
);
}
// 현재 레벨의 맵을 얕은 복사(shallow clone)하고 해당 키만 교체
const result = Object.assign({}, map);
result[k] = modifiedNode;
return result;
}
동작 핵심
Object.assign({}, map)으로 현재 레벨만 얕은 복사- 변경 경로 상의 노드만 새로 생성하고, 나머지는 참조 공유
- 재귀적으로 경로를 따라 내려가며 처리
4.4 데이터 안전성 (Data Safety)
공유 데이터 변조 위험
구조적 공유로 두 버전이 동일한 참조를 공유하고 있을 때, 네이티브 setter로 직접 변경하면 양쪽 버전 모두 영향을 받는다.
const books = {
'978-1779501127': {
isbn: '978-1779501127',
title: 'Watchmen',
publicationYear: 1987,
authorIds: ['alan-moore', 'dave-gibbons'],
},
};
const nextBooks = immutable_.set(books, ['978-1779501127', 'publicationYear'], 1986);
console.log('Before:', nextBooks['978-1779501127']['authorIds'][1]); // dave-gibbons
// 네이티브 setter로 직접 변경 -> 공유 참조이므로 양쪽 모두 오염
books['978-1779501127']['authorIds'][1] = 'dave-chester-gibbons';
console.log('After:', nextBooks['978-1779501127']['authorIds'][1]); // dave-chester-gibbons
핵심 인사이트: 모든 데이터 조작은 불변 함수를 통해서만 해야 한다. 네이티브 hash map setter 사용은 금지된다.
해결책: 영속적 자료구조 (Persistent Data Structures)
| 비교 항목 | 불변 함수 (Immutable Functions) | 영속적 자료구조 (Persistent Data Structures) |
|---|---|---|
| 불변 보장 수준 | 개발자 규율에 의존 | 자료구조 레벨에서 강제 보장 |
| 효율성 | 구조적 공유로 충분히 효율적 | 내부 구조상 더 효율적 |
| 네이티브 여부 | 네이티브 자료구조 사용 가능 | 네이티브 <-> 영속적 변환 비용 발생 |
| 추천 용도 | 학습/실험용 | 프로덕션 애플리케이션 권장 |
주요 라이브러리 목록:
- JavaScript:
Immutable.js - Java:
Paguro - C#:
Immutable Collections - Python:
Pyrsistent - Ruby:
Hamster
4.5 뮤테이션의 커밋 단계 (The Commit Phase of a Mutation)
계산 단계와 커밋 단계의 분리
- 계산 단계(
Library.addMember등)는 새 버전의 데이터를 반환할 뿐, 시스템 상태를 직접 변경하지 않는다. - 상태 변경은 오직 커밋 단계(commit phase) 에서만 이루어진다.
[커밋 전] System State -> State(member 1명)
[커밋 후] System State -> State(member 2명)
구현 구조
두 개의 싱글톤 클래스로 구성된다:
System: 각 뮤테이션을 구현하는 클래스SystemState: 시스템 상태(데이터 참조)를 관리하는 클래스
// 시스템 상태를 관리하는 싱글톤 클래스 (Stateful)
class SystemState {
private systemData: object = {};
get(): object {
return this.systemData;
}
commit(previous: object, next: object): void {
this.systemData = next;
}
}
const systemState = new SystemState();
// 뮤테이션을 구현하는 싱글톤 클래스
class System {
addMember(member: object): void {
const previous = systemState.get();
// 계산 단계: 새 버전의 데이터 계산 (Stateless)
const next = addMemberToLibrary(previous, member);
// 커밋 단계: 시스템 상태를 다음 버전으로 전진 (Stateful)
systemState.commit(previous, next);
}
}
핵심 인사이트: 계산 단계는 Stateless다. 커밋 단계는 Stateful이다.
4.6 시스템 상태 무결성 보장 (Ensuring System State Integrity)
OOP 방식의 한계
- OOP에서는 데이터를 소유한 클래스의 메서드만이 데이터를 조작하여 무결성을 보호한다.
- 하지만 이 경우 검증 로직이 여러 클래스에 분산된다.
DOP 방식의 장점
- 커밋 단계의 코드가 모든 뮤테이션에 공통으로 사용되므로, 데이터 검증을 중앙화할 수 있다.
- 커밋 직전에 새 버전의 데이터가 유효한지 검증하고, 유효하지 않으면 커밋을 거부한다.
class SystemState {
private systemData: object = {};
get(): object {
return this.systemData;
}
commit(previous: object, next: object): void {
// 커밋 전 전체 시스템 데이터 유효성 검증 (Git의 커밋 훅과 유사)
if (!SystemValidity.validate(previous, next)) {
throw new Error('The system data to be committed is not valid!');
}
this.systemData = next;
}
}
previous와next를 모두 전달하는 이유: 변경된 부분만 선택적으로 검증하여 연산 효율 최적화 가능
핵심 인사이트: DOP에서는 시스템 데이터를 전체로서 검증한다. 데이터 검증은 데이터 조작과 분리된다.
4.7 이전 상태 복원 (Restoring Previous States)
구조적 공유와 메모리 효율
- 구조적 공유 덕분에 여러 버전의 시스템 상태를 유지해도 메모리가 폭발하지 않는다.
- 대부분의 데이터가 버전 간에 공유되기 때문이다.
핵심 인사이트: 구조적 공유 덕분에 메모리를 폭발시키지 않고 시스템 상태의 많은 버전을 유지할 수 있다.
단일 Undo 구현
SystemState에 previousSystemData 참조를 추가하여 한 단계 전으로 복원한다.
stateDiagram-v2
direction LR
state "V12 (현재)" as V12
state "V13 (다음)" as V13
state "V12 (Undo 후)" as UNDO
V12 --> V13 : commit (addMember)
note right of V12
systemData: V12
prevSystemData: V11
end note
note right of V13
systemData: V13
prevSystemData: V12
end note
V13 --> UNDO : undoLastMutation()
note right of UNDO
systemData: V12
prevSystemData: V12
end note
classDiagram
class SystemState {
-object systemData
-object previousSystemData
+get() object
+commit(previous, next) void
+undoLastMutation() void
}
- 여러 단계의 Undo가 필요하다면 단순 참조 2개가 아닌 버전 참조 스택(stack) 으로 확장하면 된다.
챕터 4 요약
DOP 상태 관리 원칙
- 데이터는 불변, 하지만 상태 참조는 가변
- 모든 데이터 조작은 불변 함수 를 통해서만 수행
- 네이티브 setter 직접 사용 금지
뮤테이션의 2단계 구조
flowchart LR
A[뮤테이션 호출] --> B{계산 단계\nStateless}
B --> |새 버전의 데이터 반환| C{커밋 단계\nStateful}
C --> |유효성 검증 통과| D[시스템 상태 전진]
C --> |유효성 검증 실패| E[커밋 거부 / 예외]
구조적 공유의 핵심 가치
| 가치 | 설명 |
|---|---|
| 메모리 효율 | 변경되지 않은 부분은 버전 간 공유 |
| 연산 효율 | 변경 경로 상의 노드만 새로 생성 |
| 히스토리 추적 | 여러 버전을 메모리 폭발 없이 유지 가능 |
| 시간 여행 | 이전 상태 참조만으로 손쉬운 Undo/복원 |
무결성 보장의 중앙화
- 커밋 단계가 모든 뮤테이션에 공통이므로 검증 로직을 한 곳에 집중 가능
- Git의 커밋 훅(commit hook)과 유사한 역할
previous와next모두 전달하여 변경분만 선택 검증하는 최적화 가능
이 챕터에서 사용된 Lodash 함수
| 함수 | 설명 |
|---|---|
_.set(map, path, value) |
path에 해당하는 필드가 value로 설정된 새로운 맵을 반환 (불변 모드) |
5. 기본 동시성 제어
핵심 요약: DOP에서 동시성 제어는 락(lock) 없이 낙관적 동시성 제어(Optimistic Concurrency Control) 전략으로 처리한다. 상태가 불변(immutable) 해시맵으로 표현되기 때문에 이 전략이 효율적으로 작동한다.
5.1 Optimistic Concurrency Control
개념
- 낙관적 동시성 제어(OCC) 란 “허락을 구하는 대신 용서를 구한다”는 전략이다.
- 뮤테이션(mutation)은 마치 자신이 유일하게 실행 중인 것처럼 계산을 수행하고, 커밋 단계에서 충돌을 해소한다.
- 락(lock) 기반 전략과 달리 데드락 위험이 없고 읽기/쓰기 처리량(throughput)이 높다.
- Elasticsearch 같은 고확장성 데이터베이스가 이 전략을 사용한다.
뮤테이션의 두 단계
| 단계 | 역할 | 상태 | 구현 성격 |
|---|---|---|---|
| 계산(Calculation) 단계 | 다음 상태를 격리된 환경에서 계산 | 무상태(Stateless) | 시스템 특화(Specific) |
| 커밋(Commit) 단계 | 동시 뮤테이션을 조정(reconcile)하고 상태 갱신 | 유상태(Stateful) | 범용(Common) |
핵심 포인트
- 계산 단계의 코드는 변경이 없다. 동시성 처리를 위한 코드 변경은 오직 커밋 단계에만 집중된다.
- 커밋 단계의 조정(reconciliation) 로직 구현은 범용적이다. 시스템 데이터가 불변 해시맵으로 표현된다면 어떤 DOP 시스템에서도 동일한 코드를 재사용할 수 있다.
OCC 로직 흐름
flowchart TD
A[계산 단계: 현재 상태 캡처] --> B[다음 버전 계산]
B --> C{동시 뮤테이션 발생?}
C -- No --> D[상태 업데이트: Fast-forward]
C -- Yes --> E{충돌 여부 확인}
E -- No --> F[상태 업데이트: 3-way Merge]
E -- Yes --> G[뮤테이션 중단: Abort]
5.2 Reconciliation between Concurrent Mutations
세 가지 버전의 상태
커밋 단계가 시작될 때, 세 가지 버전의 시스템 상태가 존재한다.
| 버전 | 설명 |
|---|---|
previous |
계산 단계가 기준으로 삼았던 상태 버전 |
current |
커밋 단계 실행 시점의 현재 상태 |
next |
계산 단계가 반환한 다음 상태 |
current !== previous인 경우 = 다른 뮤테이션이 동시에 실행되었음을 의미한다.
조정(Reconciliation) 세 가지 시나리오
Git의 브랜치 머지와 개념적으로 동일하다.
| 시나리오 | 조건 | 처리 방식 |
|---|---|---|
| Fast-forward | current === previous |
next로 상태를 그대로 갱신 |
| Three-way merge | current !== previous 이고 충돌 없음 |
previous→next diff를 current에 적용 |
| Abort | current !== previous 이고 충돌 있음 |
뮤테이션 중단, 사용자에게 재시도 요청 |
DOP와 Git의 비유
| DOP 개념 | Git 개념 |
|---|---|
| 동시 뮤테이션 | 서로 다른 브랜치 |
| 시스템 데이터의 한 버전 | 커밋(commit) |
| 상태(State) | 참조(reference) |
| 계산 단계 | 브랜칭(branching) |
| 검증(Validation) | Pre-commit hook |
| 조정(Reconciliation) | 머지(merge) |
| Fast-forward | Fast-forward |
| Three-way merge | Three-way merge |
| Abort | 수동 충돌 해결 |
| 해시맵(Hash map) | 트리(Tree / 폴더) |
| 데이터 필드 | 코드 한 줄 |
실사용 시스템에서 충돌하는 동시 뮤테이션은 매우 드물다. 따라서 Abort 후 재시도를 요청하는 방식은 실용적으로 충분히 수용 가능하다.
5.3 Reducing Collections
diff 알고리즘 구현의 핵심 도구인 reduce를 이해하는 단계다.
reduce 개념
- 컬렉션의 모든 요소를 순회하며 누적값(accumulator)을 만들어 최종 단일 값을 반환한다.
reduce(collection, f, initVal)형태로 동작한다.
TypeScript 직접 구현
function reduce<T, R>(
coll: T[],
f: (acc: R, elem: T) => R,
initVal: R
): R {
let currentRes = initVal;
for (let i = 0; i < coll.length; i++) {
currentRes = f(currentRes, coll[i]);
}
return currentRes;
}
// 사용 예시: 배열 합산
reduce([1, 2, 3], (res, elem) => res + elem, 0);
// → 6
로직 전개 시각화
initVal → f(initVal, a[0]) → f(결과1, a[1]) → f(결과2, a[2]) → 최종값
5.4 Structural Difference
이 섹션은 diff 알고리즘의 구체적인 구현을 다룬다. 재귀(recursion) 안에 reduce가 포함되는 비자명한(non-trivial) 코드이므로 집중이 필요하다.
구조적 diff(Structural Diff)란?
- 두 해시맵 간의 구조적 차이를 계산한다.
- 필드 순서를 무시하고 구조를 기준으로 비교한다.
diff의 종류 (중첩 필드 없는 맵)
| 종류 | 첫 번째 맵 | 두 번째 맵 | diff 결과 |
|---|---|---|---|
| 대체(Replacement) | {"a": 1} |
{"a": 2} |
{"a": 2} |
| 추가(Addition) | {"a": 1} |
{"a": 1, "b": 2} |
{"b": 2} |
| 삭제(Deletion) | {"a": 1, "b": 2} |
{"a": 1} |
미지원 |
diff의 종류 (중첩 필드 있는 맵)
| 종류 | 첫 번째 맵 | 두 번째 맵 | diff 결과 |
|---|---|---|---|
| 대체 | {"a": {"x": 1}} |
{"a": {"x": 2}} |
{"a": {"x": 2}} |
| 추가 | {"a": {"x": 1}} |
{"a": {"x": 1, "y": 2}} |
{"a": {"y": 2}} |
| 삭제 | 미지원 |
diff의 종류 (배열)
- 배열은 인덱스 순서 기준으로 비교한다.
- 동일하면
null, 다르면 두 번째 배열의 값을 반환한다.
| 종류 | 첫 번째 배열 | 두 번째 배열 | diff 결과 |
|---|---|---|---|
| 대체 | [1] |
[2] |
[2] |
| 추가 | [1] |
[1, 2] |
[null, 2] |
| 삭제 | 미지원 |
구조적 diff 알고리즘 구현 (TypeScript)
import _ from "lodash";
function diffObjects(
data1: Record<string, unknown> | unknown[],
data2: Record<string, unknown> | unknown[]
): Record<string, unknown> | unknown[] {
const emptyObject = _.isArray(data1) ? [] : {};
// 두 값이 동일한 참조면 즉시 빈 객체 반환 (구조적 공유 덕분에 효율적)
if (data1 === data2) {
return emptyObject;
}
const keys = _.union(_.keys(data1), _.keys(data2));
return _.reduce(
keys,
(acc: Record<string, unknown>, k: string) => {
const res = diff(_.get(data1, k), _.get(data2, k));
// 차이가 없으면 누적값에 추가하지 않음
if ((_.isObject(res) && _.isEmpty(res)) || res === "no-diff") {
return acc;
}
return _.set(acc, [k], res);
},
emptyObject as Record<string, unknown>
);
}
function diff(data1: unknown, data2: unknown): unknown {
// 둘 다 객체(맵 또는 배열)이면 재귀적으로 구조 비교
if (_.isObject(data1) && _.isObject(data2)) {
return diffObjects(
data1 as Record<string, unknown>,
data2 as Record<string, unknown>
);
}
// 값이 다르면 두 번째 값 반환
if (data1 !== data2) {
return data2;
}
// 값이 같으면 특수 마커 반환
return "no-diff";
}
핵심 구현 포인트:
diffObjects내부에서reduce안에diff를 재귀 호출한다."no-diff"는 두 값이 동일함을 나타내는 마커 문자열이다._.isArray로 배열인지 맵인지 판별해 빈 초기값([]vs{})을 결정한다.
구조적 공유(Structural Sharing)와 성능
- 불변 데이터에서 새 버전은 이전 버전과 대부분의 노드를 공유(structural sharing) 한다.
diffObjects진입 시data1 === data2이면 즉시 반환되므로, 실질적으로 변경된 경로만 탐색한다.- 결론: 두 상태 버전의 diff 계산은 전체 트리를 순회하지 않고 매우 효율적으로 처리된다.
Information Path 추출
충돌 감지를 위해 중첩 맵의 모든 리프 노드 경로(information path)를 추출해야 한다.
function informationPaths(
obj: Record<string, unknown>,
path: string[] = []
): string[][] {
return _.reduce(
obj,
(acc: string[][], v: unknown, k: string) => {
if (_.isObject(v)) {
return _.concat(
acc,
informationPaths(v as Record<string, unknown>, _.concat(path, k))
);
}
return _.concat(acc, [_.concat(path, k)]);
},
[]
);
}
두 diff 간 충돌 감지
function havePathInCommon(
diff1: Record<string, unknown>,
diff2: Record<string, unknown>
): boolean {
return !_.isEmpty(
_.intersection(informationPaths(diff1), informationPaths(diff2))
);
}
informationPaths(diff1)과informationPaths(diff2)의 교집합이 비어있으면 충돌 없음.- 교집합이 존재하면 같은 필드를 수정하는 충돌 상태.
패치(Patch) 적용
비충돌 상태에서 previous → next의 변경사항을 current에 적용하는 방법:
// current에 diff(previous, next)를 재귀 병합
_.merge(current, diff(previous, next));
5.5 Implementing the Reconciliation Algorithm
SystemState 클래스
커밋 단계만 변경되며, SystemConsistency.reconcile에 조정 로직을 위임한다.
class SystemState {
private systemData: Record<string, unknown>;
get(): Record<string, unknown> {
return this.systemData;
}
set(_systemData: Record<string, unknown>): void {
this.systemData = _systemData;
}
commit(
previous: Record<string, unknown>,
next: Record<string, unknown>
): void {
const nextSystemData = SystemConsistency.reconcile(
this.systemData,
previous,
next
);
if (!SystemValidity.validate(previous, nextSystemData)) {
throw new Error("The system data to be committed is not valid!");
}
this.systemData = nextSystemData;
}
}
SystemConsistency 클래스 (핵심)
class SystemConsistency {
static threeWayMerge(
current: Record<string, unknown>,
previous: Record<string, unknown>,
next: Record<string, unknown>
): Record<string, unknown> {
const previousToCurrent = diff(previous, current);
const previousToNext = diff(previous, next);
// 두 diff의 information path에 공통 경로가 없으면 안전하게 병합 가능
if (!havePathInCommon(previousToCurrent, previousToNext)) {
return _.merge(current, previousToNext) as Record<string, unknown>;
}
throw new Error("Conflicting concurrent mutations.");
}
static reconcile(
current: Record<string, unknown>,
previous: Record<string, unknown>,
next: Record<string, unknown>
): Record<string, unknown> {
// 참조 비교: 불변 데이터이므로 참조가 같으면 값도 반드시 같다
if (current === previous) {
return next; // Fast-forward
}
return SystemConsistency.threeWayMerge(current, previous, next);
}
}
참조 비교(Reference Equality)의 중요성
불변 데이터에서는 참조 비교가 안전하다. 참조가 같으면 값도 반드시 동일하다.
current === previous를 값 비교(deep equal) 로 처리하면 중첩 해시맵 전체를 순회해야 하므로 비용이 크다.- 불변 데이터에서는 참조 비교(===) 가 O(1)로 동일한 보장을 제공한다.
스레드 안전성 주의
- 위
SystemConsistency구현은 단일 스레드(JavaScript 이벤트 루프) 환경에서는 정상 동작한다. - 멀티스레드 환경에서는
reconcile의 상태 확인과systemData갱신 사이에 컨텍스트 스위칭이 발생할 수 있어 스레드 안전하지 않다. - 멀티스레드 환경에서의 스레드 안전 구현은 Chapter 8에서 다룬다.
챕터 5 요약
- OCC는 락 없이 동작 하여 높은 읽기/쓰기 처리량을 지원한다.
- 불변 데이터 + OCC = 초고효율. 구조적 공유 덕분에 diff 계산과 참조 비교 모두 빠르다.
- 계산 단계는 변경 없음. 동시성 처리 로직은 커밋 단계에만 집중된다.
- 조정 알고리즘은 Git의 머지 전략과 동일한 개념 (Fast-forward / Three-way merge / Abort)이다.
- 커밋 단계의 조정 코드는 범용적 이어서 어느 DOP 시스템에서도 재사용 가능하다.
- 실사용 시스템에서 충돌하는 동시 뮤테이션은 매우 드물기 때문에 Abort 전략은 실용적으로 충분하다.
- 구조적 diff 알고리즘은 대체(Replacement)와 추가(Addition)를 지원하며, 삭제(Deletion)는 미지원 이다.
6. 단위 테스트
데이터 지향 시스템에서 코드는 대부분 데이터 조작을 다룬다. 따라서 함수의 입력과 출력이 모두 데이터이고, 이는 단위 테스트를 매우 단순하게 만든다.
이 챕터가 다루는 것
- 테스트 케이스를 위한 최소 데이터 입력 생성 방법
- 함수 출력과 기대 출력의 비교 방법
- 테스트 케이스의 품질과 수량에 대한 가이드
6.1 데이터 지향 테스트 케이스의 단순함
핵심 원칙
- DOP(Data-Oriented Programming) 시스템의 코드는 대부분 데이터 조작을 다룬다
- 데이터를 다루는 함수의 테스트 케이스는 데이터 입력 생성 + 기대 출력 생성 + 비교가 전부다
- DOP에서는 대부분의 경우 Mock 함수가 필요 없다
테스트 케이스의 3단계 패턴
1. 데이터 입력(dataIn) 생성
2. 기대 출력(dataOut) 생성
3. f(dataIn)의 결과와 dataOut 비교
데이터 컬렉션 비교: _.isEqual (Lodash)
- 원시 값(string, number)은
===로 비교 가능 - 맵(객체), 배열 같은 데이터 컬렉션은 재귀적 비교가 필요
- Lodash의
_.isEqual을 사용하면 재귀적 값 비교를 간단히 처리할 수 있다
import _ from "lodash";
// 같은 데이터 → true
_.isEqual(
{ name: "Alan Moore", bookIsbns: ["978-1779501127"] },
{ name: "Alan Moore", bookIsbns: ["978-1779501127"] }
);
// → true
// 다른 데이터 → false
_.isEqual(
{ name: "Alan Moore", bookIsbns: ["978-1779501127"] },
{ name: "Alan Moore", bookIsbns: ["bad-isbn"] }
);
// → false
테스트 케이스의 일반적인 패턴
const dataIn = {
// 입력 데이터
};
const dataOut = {
// 기대 출력 데이터
};
_.isEqual(f(dataIn), dataOut); // true이면 테스트 통과
핵심 인사이트: 데이터 조작 코드에 대한 단위 테스트를 작성하는 것은 매우 단순하다.
6.2 데이터 조작 코드에 대한 단위 테스트
예제 시스템: 도서관 검색 쿼리
class Catalog {
static authorNames(catalogData: object, authorIds: string[]): string[] {
return _.map(authorIds, (authorId) =>
_.get(catalogData, ["authorsById", authorId, "name"])
);
}
static bookInfo(catalogData: object, book: object): object {
return {
title: _.get(book, "title"),
isbn: _.get(book, "isbn"),
authorNames: Catalog.authorNames(catalogData, _.get(book, "authorIds")),
};
}
static searchBooksByTitle(catalogData: object, query: string): object[] {
const allBooks = _.get(catalogData, "booksByIsbn");
const matchingBooks = _.filter(allBooks, (book) =>
_.get(book, "title").includes(query)
);
return _.map(matchingBooks, (book) => Catalog.bookInfo(catalogData, book));
}
}
class Library {
static searchBooksByTitleJSON(libraryData: object, query: string): string {
const catalogData = _.get(libraryData, "catalog");
const results = Catalog.searchBooksByTitle(catalogData, query);
return JSON.stringify(results);
}
}
6.2.1 함수 호출 트리 (Tree of Function Calls)
개념
- 단위 테스트를 작성하기 전에 함수 호출 트리를 시각화하면 테스트의 품질과 수량을 결정하는 데 도움이 된다
- 루트: 테스트 대상 함수
- 자식 노드: 해당 함수가 호출하는 함수들
- 리프 노드: 애플리케이션 코드베이스에 속하지 않는 함수 (예: Lodash의
_.get,_.map등)
Library.searchBooksByTitleJSON의 함수 호출 트리
flowchart TD
A["Library.searchBooksByTitleJSON"]
A --> B["_.get"]
A --> C["JSON.stringify"]
A --> D["Catalog.searchBooksByTitle"]
D --> E["_.get"]
D --> F["_.map"]
D --> G["_.filter"]
D --> H["Catalog.bookInfo"]
H --> I["_.get"]
H --> J["Catalog.authorNames"]
J --> K["_.get"]
J --> L["_.map"]
함수 호출 트리가 주는 인사이트
| 트리에서의 깊이 | 데이터 복잡도 | 테스트 케이스 수 |
|---|---|---|
| 낮을수록 (리프에 가까울수록) | 높다 | 많다 |
| 높을수록 (루트에 가까울수록) | 낮다 | 적다 |
핵심 인사이트: 트리의 아래쪽 함수일수록 더 많은 테스트 케이스가 필요하지만, 데이터는 더 단순하다.
6.2.2 트리 하위 함수의 단위 테스트
최소 데이터(Minimal Data)를 사용하라
- 테스트에서 실제 전체 데이터를 사용할 필요는 없다
- 데이터의 유효성은 컨텍스트에 따라 달라진다
- 해당 함수가 접근하는 필드만 포함한 최소 데이터를 사용하면 조작이 훨씬 쉬워진다
// 전체 catalogData (불필요하게 크다)
const fullCatalogData = {
booksByIsbn: { /* ... */ },
authorsById: {
"alan-moore": { name: "Alan Moore", bookIsbns: ["978-1779501127"] },
"dave-gibbons": { name: "Dave Gibbons", bookIsbns: ["978-1779501127"] },
},
};
// Catalog.authorNames에 필요한 최소 catalogData
const minimalCatalogData = {
authorsById: {
"alan-moore": { name: "Alan Moore" },
"dave-gibbons": { name: "Dave Gibbons" },
},
};
핵심 인사이트: 데이터가 작을수록 조작하기 쉽다.
Catalog.authorNames 테스트 케이스
const catalogData = {
authorsById: {
"alan-moore": { name: "Alan Moore" },
"dave-gibbons": { name: "Dave Gibbons" },
},
};
// 빈 배열
_.isEqual(Catalog.authorNames(catalogData, []), []);
// → true
// 단일 저자 ID
_.isEqual(Catalog.authorNames(catalogData, ["alan-moore"]), ["Alan Moore"]);
// → true
// 복수 저자 ID
_.isEqual(
Catalog.authorNames(catalogData, ["alan-moore", "dave-gibbons"]),
["Alan Moore", "Dave Gibbons"]
);
// → true
// 존재하지 않는 ID → undefined 반환
_.isEqual(Catalog.authorNames({}, ["alan-moore"]), [undefined]);
// → true
_.isEqual(
Catalog.authorNames(catalogData, ["alan-moore", "albert-einstein"]),
["Alan Moore", undefined]
);
// → true
- 리프에 가까운 함수일수록 테스트 케이스의 데이터는 단순하지만 케이스 수는 많아진다
- 다양한 엣지 케이스(빈 배열, 존재하지 않는 ID, 빈 catalogData 등)를 모두 커버해야 한다
6.2.3 트리 상위 노드의 단위 테스트
Catalog.bookInfo 테스트 케이스
- 이미
Catalog.authorNames에 대한 단위 테스트가 작성되어 있으므로, 하위 함수들이 올바르게 동작한다고 가정한다 - 따라서 최소한의 테스트 케이스만 작성하면 된다
const catalogData = {
authorsById: {
"alan-moore": { name: "Alan Moore" },
"dave-gibbons": { name: "Dave Gibbons" },
},
};
const book = {
isbn: "978-1779501127",
title: "Watchmen",
publicationYear: 1987,
authorIds: ["alan-moore", "dave-gibbons"],
};
const expectedResult = {
authorNames: ["Alan Moore", "Dave Gibbons"],
isbn: "978-1779501127",
title: "Watchmen",
};
_.isEqual(Catalog.bookInfo(catalogData, book), expectedResult);
// → true
핵심 인사이트: 함수에 대한 단위 테스트를 작성할 때, 해당 함수가 호출하는 하위 함수들은 이미 단위 테스트로 검증되었다고 가정한다. 이는 테스트 케이스의 수를 크게 줄여준다.
6.3 쿼리(Query)에 대한 단위 테스트
Library.searchBooksByTitleJSON 테스트 시 주의점
이 함수는 결과를 JSON 문자열로 반환한다. 문자열 비교는 위험하다.
// 두 문자열은 다르지만, 직렬화하는 데이터는 동일하다!
const stringA = '{"title":"Watchmen","publicationYear":1987}';
const stringB = '{"publicationYear":1987,"title":"Watchmen"}';
stringA === stringB; // → false (잘못된 비교!)
_.isEqual(JSON.parse(stringA), JSON.parse(stringB)); // → true (올바른 비교!)
핵심 인사이트: 데이터를 다루는 함수의 단위 테스트에서 문자열 비교는 피하라. JSON 문자열을 반환하는 경우
JSON.parse로 역직렬화한 후 데이터로 비교하라.
Library.searchBooksByTitleJSON 테스트 케이스
const libraryData = {
catalog: {
booksByIsbn: {
"978-1779501127": {
isbn: "978-1779501127",
title: "Watchmen",
publicationYear: 1987,
authorIds: ["alan-moore", "dave-gibbons"],
},
},
authorsById: {
"alan-moore": { name: "Alan Moore", bookIsbns: ["978-1779501127"] },
"dave-gibbons": { name: "Dave Gibbons", bookIsbns: ["978-1779501127"] },
},
},
};
const bookInfo = {
isbn: "978-1779501127",
title: "Watchmen",
authorNames: ["Alan Moore", "Dave Gibbons"],
};
// 매칭되는 쿼리
_.isEqual(
JSON.parse(Library.searchBooksByTitleJSON(libraryData, "Watchmen")),
[bookInfo]
);
// → true
// 매칭되지 않는 쿼리
_.isEqual(
JSON.parse(Library.searchBooksByTitleJSON(libraryData, "Batman")),
[]
);
// → true
Catalog.searchBooksByTitle 테스트 케이스 + 버그 발견
- 이 함수는 JSON 문자열 대신 데이터를 직접 반환하므로,
JSON.parse불필요 libraryData로 감쌀 필요 없이catalogData만 사용 가능
// 소문자 쿼리 테스트 → 버그 발견!
_.isEqual(
Catalog.searchBooksByTitle(catalogData, "watchmen"),
[bookInfo]
);
// → false (버그!)
버그 수정: 대소문자 무시(Case-insensitive) 검색
static searchBooksByTitle(catalogData: object, query: string): object[] {
const allBooks = _.get(catalogData, "booksByIsbn");
const queryLowerCased = query.toLowerCase(); // 쿼리를 소문자로
const matchingBooks = _.filter(allBooks, (book) =>
_.get(book, "title").toLowerCase().includes(queryLowerCased) // 타이틀도 소문자로
);
return _.map(matchingBooks, (book) => Catalog.bookInfo(catalogData, book));
}
단위 테스트의 목적은 버그를 발견하는 것이다. 소문자 쿼리 테스트 케이스가 실제 버그를 찾아냈다.
6.4 뮤테이션(Mutation)에 대한 단위 테스트
쿼리 vs 뮤테이션 테스트의 차이
쿼리(Query):
입력: 시스템 데이터 + 인자
출력: 응답 데이터
뮤테이션(Mutation):
입력: 현재 시스템 상태 + 인자
출력: 다음 시스템 상태
핵심 인사이트: 뮤테이션의 주요 함수에 대한 단위 테스트 작성은 쿼리보다 더 많은 노력이 필요하다. 입력과 기대 출력 모두 시스템 상태를 나타내는 맵이기 때문이다.
예제 시스템: 회원 추가 뮤테이션
class System {
static addMember(systemState: SystemState, member: object): void {
const previous = systemState.get();
const next = Library.addMember(previous, member);
systemState.commit(previous, next);
}
}
class Library {
static addMember(library: object, member: object): object {
const currentUserManagement = _.get(library, "userManagement");
const nextUserManagement = UserManagement.addMember(
currentUserManagement,
member
);
return _.set(library, "userManagement", nextUserManagement);
}
}
class UserManagement {
static addMember(userManagement: object, member: object): object {
const email = _.get(member, "email");
const infoPath = ["membersByEmail", email];
if (_.has(userManagement, infoPath)) {
throw "Member already exists.";
}
return _.set(userManagement, infoPath, member);
}
}
System.addMember의 함수 호출 트리
flowchart TD
A["System.addMember"]
A --> B["SystemState.get"]
A --> C["SystemState.commit"]
A --> D["Library.addMember"]
D --> E["_.get"]
D --> F["_.set"]
D --> G["UserManagement.addMember"]
G --> H["_.has"]
G --> I["_.set"]
UserManagement.addMember 테스트 케이스
정상 케이스 1: 빈 상태에서 회원 추가
const member = {
email: "jessie@gmail.com",
password: "my-secret",
};
const userManagementStateBefore = {};
const expectedUserManagementStateAfter = {
membersByEmail: {
"jessie@gmail.com": {
email: "jessie@gmail.com",
password: "my-secret",
},
},
};
const result = UserManagement.addMember(userManagementStateBefore, member);
_.isEqual(result, expectedUserManagementStateAfter);
// → true
정상 케이스 2: 기존 회원이 있는 상태에서 추가
const jessie = { email: "jessie@gmail.com", password: "my-secret" };
const franck = { email: "franck@gmail.com", password: "my-top-secret" };
const userManagementStateBefore = {
membersByEmail: {
"franck@gmail.com": { email: "franck@gmail.com", password: "my-top-secret" },
},
};
const expectedUserManagementStateAfter = {
membersByEmail: {
"jessie@gmail.com": { email: "jessie@gmail.com", password: "my-secret" },
"franck@gmail.com": { email: "franck@gmail.com", password: "my-top-secret" },
},
};
_.isEqual(
UserManagement.addMember(userManagementStateBefore, jessie),
expectedUserManagementStateAfter
);
// → true
부정적 케이스(Negative Test Case): 이미 존재하는 회원 추가 시 예외 발생
const jessie = { email: "jessie@gmail.com", password: "my-secret" };
const userManagementStateBefore = {
membersByEmail: {
"jessie@gmail.com": { email: "jessie@gmail.com", password: "my-secret" },
},
};
const expectedException = "Member already exists.";
let exceptionInMutation: string | undefined;
try {
UserManagement.addMember(userManagementStateBefore, jessie);
} catch (e) {
exceptionInMutation = e as string;
}
_.isEqual(exceptionInMutation, expectedException);
// → true
핵심 인사이트: 부정적 테스트 케이스(실패 케이스)를 잊지 마라.
Library.addMember 테스트 케이스
UserManagement.addMember는 이미 검증되었다고 가정하므로, 최소한의 케이스만 작성
const libraryStateBefore = {
userManagement: {
membersByEmail: {
"franck@gmail.com": { email: "franck@gmail.com", password: "my-top-secret" },
},
},
};
const expectedLibraryStateAfter = {
userManagement: {
membersByEmail: {
"jessie@gmail.com": { email: "jessie@gmail.com", password: "my-secret" },
"franck@gmail.com": { email: "franck@gmail.com", password: "my-top-secret" },
},
},
};
_.isEqual(Library.addMember(libraryStateBefore, jessie), expectedLibraryStateAfter);
// → true
System.addMember 테스트 케이스
System.addMember는 반환값이 없다. 대신 시스템 상태를 직접 변경한다. 따라서 뮤테이션 실행 후 시스템 상태를 꺼내어 기대값과 비교한다.
class SystemState {
private state: object = {};
get(): object {
return this.state;
}
commit(_previous: object, next: object): void {
this.state = next;
}
}
// 테스트
const systemState = new SystemState();
systemState.commit({}, libraryStateBefore); // 초기 상태 설정
System.addMember(systemState, jessie); // 뮤테이션 실행
_.isEqual(systemState.get(), expectedLibraryStateAfter);
// → true
핵심 인사이트: 시스템 상태도 결국 맵(객체)이다. 뮤테이션 실행 후
_.isEqual로 기대 상태와 비교할 수 있다.
챕터 6 요약
| 주제 | 핵심 내용 |
|---|---|
| DOP와 테스트 | DOP 시스템의 코드 대부분은 데이터 조작이므로 단위 테스트 작성이 단순하다 |
| 테스트 패턴 | 입력 데이터 생성 → 기대 출력 생성 → _.isEqual로 비교 |
| 재귀 비교 | _.isEqual을 사용해 데이터 컬렉션을 재귀적으로 비교한다 |
| 최소 데이터 | 데이터의 유효성은 컨텍스트에 따라 다르다. 테스트에는 최소한의 데이터를 사용하라 |
| 함수 호출 트리 | 테스트의 품질과 수량을 결정하는 데 함수 호출 트리 시각화가 유용하다 |
| 트리 깊이와 테스트 | 깊은 곳(리프에 가까울수록) = 데이터 단순 + 테스트 케이스 多, 얕은 곳(루트에 가까울수록) = 데이터 복잡 + 테스트 케이스 少 |
| 하위 함수 신뢰 | 상위 함수 테스트 시 하위 함수는 이미 검증되었다고 가정하여 케이스 수를 줄인다 |
| 문자열 비교 금지 | JSON 문자열 반환 함수는 JSON.parse 후 데이터로 비교하라 |
| 부정적 케이스 | 실패 케이스(예외, 에러) 테스트를 반드시 포함하라 |
| 쿼리 vs 뮤테이션 | 뮤테이션 테스트는 쿼리보다 복잡하다. 입출력 모두 시스템 상태 맵이다 |
| 시스템 상태 비교 | 시스템 상태도 맵이므로 _.isEqual로 뮤테이션 전후 상태를 비교할 수 있다 |
| Mock 불필요 | DOP의 데이터 조작 코드는 대부분 Mock 없이 테스트 가능하다 |
7. 기본 데이터 검증
DOP에서 데이터 검증은 불가능하거나 모순된 개념이 아니다. 오히려 시스템 경계에서의 데이터 검증은 권장 사항이다.
7.1 DOP에서의 데이터 검증 (Data validation in DOP)
핵심 원칙
- DOP Principle #4: 데이터 스키마(schema)와 데이터 표현(representation)을 분리하라
- DOP는 데이터 검증을 강제하지 않지만, 막지도 않는다
- 스키마는 독립적으로 존재하고, 데이터 표현도 독립적으로 존재한다
- 검증은 원하는 시점에 원하는 방식으로 자유롭게 수행할 수 있다
OOP vs DOP의 데이터 검증 비교
| 구분 | OOP | DOP |
|---|---|---|
| 타입 보장 방식 | 클래스 인스턴스화 시 멤버 필드 타입 강제 | 스키마를 통해 경계에서 명시적으로 검증 |
| 스키마 위치 | 데이터 표현(클래스)에 내장 | 데이터 표현과 완전히 분리 |
| 유연성 | 낮음 | 높음 |
| 언어 의존성 | 특정 언어에 종속 | 언어 독립적 (JSON Schema) |
시스템 경계(System Boundaries)란
시스템 경계는 시스템이 데이터를 교환하는 영역으로 정의된다.
Client (Web Browser)
| (Data)
Web Server
/ \
(Data) (Data)
DB Web Service
현대 웹 서버 기준의 3가지 경계:
- 클라이언트 경계 - 웹 브라우저 등 클라이언트와의 데이터 교환
- 데이터베이스 경계 - DB와의 데이터 교환
- 외부 서비스 경계 - 외부 웹 서비스와의 데이터 교환
두 가지 데이터 검증의 종류
| 종류 | 목적 | 환경 |
|---|---|---|
| 경계(Boundaries) 검증 | 유효하지 않은 데이터의 유입/유출 방지, 상세한 오류 메시지 제공 | 프로덕션 |
| 내부(Inside) 검증 | 코드베이스가 커질 때 개발 편의성 향상 | 개발 |
- 경계 검증이 더 중요하다 - 경계에서 데이터가 유효하다면 시스템 내부에서 유효하지 않은 데이터가 등장할 가능성은 매우 낮다
- 경계 검증이 완료된 데이터는 내부에서 재검증할 필요가 없다
7.2 JSON Schema 개요 (JSON Schema in a nutshell)
JSON Schema란
- 데이터 표현과 독립적으로 데이터 스키마를 표현하는 언어 독립적 스키마 언어
- 공식 문서:
https://json-schema.org(버전 2020-12 기준) - 대부분의 프로그래밍 언어에서 검증 라이브러리를 사용할 수 있다
주요 JSON Schema 검증 라이브러리
| 언어 | 라이브러리 |
|---|---|
| JavaScript / TypeScript | Ajv |
| Java | Snow |
| C# | JSON.net Schema |
| Python | jschon |
| Ruby | JSONSchemer |
기본 스키마 구조
검색 요청 데이터 예시:
const searchBooksRequest = {
title: "habit",
fields: ["title", "weight", "number_of_pages"]
};
스키마 작성 단계별 설명:
- 최상위 타입 정의 (맵 =
object)
const schema = {
type: "object",
properties: { /* ... */ }
};
- 각 필드의 타입 정의
const schema = {
type: "object",
properties: {
title: { type: "string" },
fields: { type: "array" }
}
};
- 배열 요소의 타입 정의 (
items)
const schema = {
type: "object",
properties: {
title: { type: "string" },
fields: {
type: "array",
items: { type: "string" }
}
}
};
- 열거형 값 지정 (
enum)
const schema = {
type: "object",
properties: {
title: { type: "string" },
fields: {
type: "array",
items: {
enum: [
"publishers",
"number_of_pages",
"weight",
"physical_format",
"subjects",
"publish_date",
"physical_dimensions"
]
}
}
}
};
- 필수 필드 지정 (
required)
const searchBooksRequestSchema = {
type: "object",
properties: {
title: { type: "string" },
fields: {
type: "array",
items: {
enum: [
"publishers",
"number_of_pages",
"weight",
"physical_format",
"subjects",
"publish_date",
"physical_dimensions"
]
}
}
},
required: ["title", "fields"]
};
JSON Schema 사용 이유
- 언어 독립성: 어떤 언어에서도 동일한 스키마 사용 가능
- 높은 표현력: 범위 검증, 정규식 매칭 등 클래스로 표현하기 어려운 검증 조건을 지원
- 구성 가능성: 스키마 자체가 일반 맵(객체)이므로 변수로 저장하고 자유롭게 조합 가능
7.3 스키마의 유연성과 엄격성 (Schema flexibility and strictness)
선택 필드(Optional Fields) 처리
- JSON Schema에서 맵의 필드는 **기본적으로 선택(optional)**이다
- 필수 필드는 반드시
required배열에 명시해야 한다
검색 응답 스키마 예시 (중첩된 스키마를 변수로 분리):
const bookInfoSchema = {
type: "object",
required: ["title", "available"],
properties: {
title: { type: "string" },
available: { type: "boolean" },
subtitle: { type: "string" },
number_of_pages: { type: "integer" },
subjects: {
type: "array",
items: { type: "string" }
},
isbn: { type: "string" },
isbn_13: { type: "string" }
}
};
const searchBooksResponseSchema = {
type: "array",
items: bookInfoSchema // 스키마를 변수로 재사용
};
- 중첩된 스키마를 변수로 분리하면 가독성과 재사용성이 높아진다
- 클래스 정의와 달리
nullable여부를 일일이 명시하지 않아도 된다
추가 필드 허용 여부 (additionalProperties)
- 기본적으로 스키마에 명시되지 않은 필드도 허용된다
- 엄격하게 제한하려면
additionalProperties: false를 설정한다
const booksFromDBSchema = {
type: "array",
items: {
type: "object",
required: ["title", "isbn", "available"],
additionalProperties: false, // 명시되지 않은 필드 불허
properties: {
title: { type: "string" },
available: { type: "boolean" },
isbn: { type: "string" }
}
}
};
Postel의 견고성 원칙 (Robustness Principle)
“Be conservative in what you send, be liberal in what you accept.” 보내는 데이터에는 엄격하게, 받는 데이터에는 유연하게 대처하라.
| 방향 | 권장 전략 | 이유 |
|---|---|---|
| 서버가 보내는 응답 | additionalProperties: false (엄격) |
서버가 응답에 대해 책임을 진다 |
| 클라이언트로부터 받는 요청 | 추가 필드 허용 (유연) | 클라이언트가 완벽하지 않아도 최대한 서비스해야 한다 |
| 외부 DB/서비스에서 받는 데이터 | additionalProperties: false (엄격) |
예상치 못한 데이터 유입 방지 권장 |
7.4 스키마 합성 (Schema composition)
스키마 합성이란
- 여러 스키마를 논리 연산자처럼 조합하는 기능
- 클래스 기반으로는 표현하기 어려운 복잡한 검증 조건을 표현 가능
합성 키워드
| JSON Schema 키워드 | 논리 연산 | 의미 |
|---|---|---|
allOf |
AND | 모든 스키마를 만족해야 함 |
anyOf |
OR | 하나 이상의 스키마를 만족해야 함 |
oneOf |
XOR | 정확히 하나의 스키마만 만족해야 함 |
실제 사례: isbn_10 또는 isbn_13 필수 조건
- 2007년 이전 출판 도서:
isbn_10보유 - 2007년 이후 출판 도서:
isbn_13보유 - 두 조건 모두 만족하는 도서도 존재
- 조건:
basicBookInfoSchema AND (mandatoryIsbn13 OR mandatoryIsbn10)
const basicBookInfoSchema = {
type: "object",
required: ["title"],
properties: {
title: { type: "string" },
publishers: { type: "array", items: { type: "string" } },
number_of_pages: { type: "integer" },
weight: { type: "string" },
physical_format: { type: "string" },
subjects: { type: "array", items: { type: "string" } },
isbn_13: { type: "array", items: { type: "string" } },
isbn_10: { type: "array", items: { type: "string" } },
publish_date: { type: "string" },
physical_dimensions: { type: "string" }
}
};
const mandatoryIsbn13 = {
type: "object",
required: ["isbn_13"]
};
const mandatoryIsbn10 = {
type: "object",
required: ["isbn_10"]
};
const bookInfoSchema = {
allOf: [
basicBookInfoSchema,
{
anyOf: [mandatoryIsbn13, mandatoryIsbn10]
}
]
};
스키마 합성의 장점
- JSON Schema는 단순한 맵(객체)이므로 변수에 저장하고 자유롭게 재사용/조합 가능
- 클래스 정의보다 훨씬 높은 표현력
- 검증 로직이 명확하게 분리되어 읽기 쉽다
7.5 데이터 검증 실패 상세 정보 (Details about data validation failures)
검증 실패 시 상세 정보 얻기
- JSON Schema 검증은 단순한
true/false이분법이 아니다 - 유효하지 않을 경우, 왜 유효하지 않은지 상세한 정보를 얻을 수 있다
Ajv 사용법 (TypeScript)
import Ajv from "ajv";
const searchBooksRequestSchema = {
type: "object",
properties: {
title: { type: "string" },
fields: {
type: "array",
items: { type: "string" }
}
},
required: ["title", "fields"]
};
const invalidRequest = {
myTitle: "habit", // title 대신 myTitle로 오타
fields: ["title", "weight", "number_of_pages"]
};
const ajv = new Ajv();
ajv.validate(searchBooksRequestSchema, invalidRequest);
// 에러 배열 구조
// ajv.errors =>
// [
// {
// instancePath: "",
// schemaPath: "#/required",
// keyword: "required",
// params: { missingProperty: "title" },
// message: "must have required property 'title'"
// }
// ]
사람이 읽기 쉬운 형태로 변환
ajv.errorsText(ajv.errors);
// => "data must have required property 'title'"
다중 검증 실패 캐치
- 기본적으로 Ajv는 첫 번째 오류만 감지하고 중단한다 (성능 최적화)
- 모든 오류를 감지하려면
allErrors: true옵션을 생성자에 전달한다
import Ajv from "ajv";
const invalidRequest = {
myTitle: "habit", // title 누락
fields: [1, 2] // 숫자 배열 (문자열이어야 함)
};
const ajv = new Ajv({ allErrors: true }); // 모든 오류 감지 모드
ajv.validate(searchBooksRequestSchema, invalidRequest);
ajv.errorsText(ajv.errors);
// => "data must have required property 'title',
// data/fields/0 must be string,
// data/fields/1 must be string"
JSON Schema 치트 시트
스키마 전체 구조 예시
const exampleSchema = {
type: "array", // 최상위 타입: 배열
items: {
type: "object", // 배열의 각 요소: 객체(맵)
properties: {
myNumber: { type: "number" },
myString: { type: "string" },
myEnum: { enum: ["myVal", "yourVal"] },
myBool: { type: "boolean" }
},
required: ["myNumber", "myString"], // 필수 필드 명시
additionalProperties: false // 명시되지 않은 필드 불허
}
};
유효한 데이터 예시
const validData = [
{
myNumber: 42,
myString: "Hello",
myEnum: "myVal",
myBool: true
},
{
myNumber: 54,
myString: "Happy"
// myEnum, myBool은 optional이므로 생략 가능
}
];
타입 키워드 정리
| 키워드 | 설명 |
|---|---|
type: "object" |
맵(키-값 쌍) |
type: "array" |
배열 |
type: "string" |
문자열 |
type: "number" |
숫자 (정수 + 실수) |
type: "integer" |
정수 |
type: "boolean" |
불리언 |
enum: [...] |
열거형 (특정 값들 중 하나) |
items: {...} |
배열 요소의 스키마 |
required: [...] |
필수 필드 목록 |
additionalProperties: false |
명시되지 않은 필드 불허 |
allOf: [...] |
모든 스키마 만족 (AND) |
anyOf: [...] |
하나 이상의 스키마 만족 (OR) |
oneOf: [...] |
정확히 하나의 스키마 만족 (XOR) |
챕터 7 요약
- DOP Principle #4: 데이터 스키마와 데이터 표현을 분리하라
- 시스템 경계(클라이언트, DB, 외부 서비스)에서의 검증이 가장 중요하다
- 경계 검증이 완료된 데이터는 내부에서 재검증할 필요가 없다
- JSON Schema는 언어 독립적이며 표현력이 높다
- JSON Schema는 단순한 맵(객체)이므로 변수로 저장하고 자유롭게 조합 가능하다
- 맵 필드는 기본적으로 선택(optional)이며, 필수 필드는
required로 명시해야 한다 - 외부 데이터 소스로부터 받은 데이터는 반드시 검증하는 것이 좋다
- 보내는 데이터에는 엄격하게(
additionalProperties: false), 받는 데이터에는 유연하게 대처하라 - TypeScript/JavaScript에서는 Ajv 라이브러리를 사용한다
- Ajv는 기본적으로 첫 번째 오류만 감지하며, 모든 오류를 감지하려면
allErrors: true를 사용한다 - 고급 검증(범위, 정규식 등)은 Chapter 12에서 다룬다
8. 고급 동시성 제어
핵심 인사이트: 불변 데이터(Immutable Data)와 Atom을 조합하면, 뮤텍스(Mutex) 없이도 데드락 없는 안전한 동시성 제어가 가능하다.
8.1 The Complexity of Locks
전통적인 동시성 제어의 문제점
- 멀티스레드 환경에서 공유 데이터를 보호하기 위해 뮤텍스(Mutex) 같은 락(Lock) 메커니즘을 사용
- 락은 읽기(read)와 쓰기(write) 접근 모두를 보호해야 하는데, 이것이 시스템 복잡도를 크게 높임
- 락을 잘못 관리하면 데드락(Deadlock) 이 발생 – 예외 발생 시 락 해제를 빠뜨리거나, 두 락을 잘못된 순서로 해제하는 경우
왜 읽기에도 락이 필요한가?
- 락이 없으면 읽기 도중 다른 스레드에서 쓰기가 발생해 논리적으로 불일치한 데이터를 읽게 됨
- 이를 방지하기 위해 읽기 전에 데이터를 복제(clone)하는 방법도 있으나, 데이터가 크면 비용이 너무 커서 확장성이 없음
TIP: 읽기 락을 피하기 위해 데이터를 복제하는 방식은 확장성이 없다.
DOP에서 읽기가 항상 안전한 이유
- DOP에서는 데이터가 불변(immutable) 이기 때문에, 읽기 도중 다른 스레드에서 쓰기가 발생해도 읽고 있는 데이터 자체가 변경되지 않음
- 즉, 읽기는 항상 데이터 스냅샷(snapshot) 위에서 동작하는 것과 동일
TIP: 데이터가 불변이면, 읽기는 항상 안전하다.
8.2 Thread-Safe Counter with Atoms
Atom이란?
- Atom은 락 없이(lock-free) 동시성을 관리하는 메커니즘
- 데드락이 원천적으로 발생하지 않음
TIP: Atom을 사용하면 데드락은 절대 발생하지 않는다.
Atom의 3가지 메서드
| 메서드 | 설명 |
|---|---|
get() |
Atom의 현재 값을 반환 |
set(value) |
Atom의 현재 값을 덮어씀 |
swap(fn) |
함수를 받아, 현재 값에 함수를 적용한 결과로 값을 업데이트 |
뮤텍스 방식 vs Atom 방식 비교
뮤텍스 방식 (기존)
// 가상의 멀티스레드 JavaScript/TypeScript 환경 가정
const mutex = new Mutex();
let counter = 0;
function dbAccess() {
mutex.lock();
counter = counter + 1;
mutex.unlock();
// DB 접근 로직
}
function logCounter() {
mutex.lock();
console.log(`Number of database accesses: ${counter}`);
mutex.unlock();
}
Atom 방식 (DOP)
const counter = new Atom<number>();
counter.set(0);
function dbAccess() {
counter.swap((x) => x + 1); // x는 현재 값
// DB 접근 로직
}
function logCounter() {
console.log(`Number of database accesses: ${counter.get()}`);
}
swap에 전달하는 함수의 인자x는counter.get()과 동일한 현재 값- 읽기(
logCounter)에는 아무런 락도 필요 없음
swap이 스레드 세이프한 원리
swap은 내부적으로 CAS(Compare-And-Set/Swap) 연산을 사용함
flowchart TD
A["스냅샷 저장<br/>snapshot = state"] --> B["다음 값 계산<br/>nextState = f(snapshot)"]
B --> C{"state == snapshot?"}
C -- "Yes" --> D["state = nextState<br/>업데이트 성공"]
C -- "No" --> A
- 현재 상태를 스냅샷으로 저장
- 스냅샷에 함수
f를 적용해 다음 상태 계산 - 현재 상태가 스냅샷과 동일한지 원자적으로(atomically) 비교
- 동일하면 업데이트 성공, 다르면 처음부터 재시도
Atom 클래스 구현
class Atom<T> {
private state!: T;
get(): T {
return this.state;
}
set(state: T): void {
this.state = state;
}
swap(f: (current: T) => T): T {
while (true) {
const stateSnapshot = this.state;
const nextState = f(stateSnapshot);
// atomicCompareAndSet: state가 stateSnapshot과 같을 때만 nextState로 교체
if (atomicCompareAndSet(this.state, stateSnapshot, nextState)) {
return nextState;
}
// 실패 시 재시도
}
}
}
atomicCompareAndSet은 CPU 수준의 원자적 연산으로, 락 없이도 스레드 안전성을 보장- Java에서는
java.util.concurrent.atomic.AtomicReference의compareAndSet()이 이에 해당
Starvation(기아 현상) 가능성
- 이론상: 수천 개의 스레드가 Atom만 계속 swap하면 특정 스레드가 영원히 성공하지 못하는 기아 현상이 발생할 수 있음
- 실제로는: Atom을 swap한 후 스레드는 DB 접근, I/O 등 실제 작업을 수행하므로, 다른 스레드가 성공할 기회가 생김 – 실무에서 기아 현상은 거의 발생하지 않음
8.3 Thread-Safe Cache with Atoms
인메모리 캐시 개요
- DB 쿼리 결과를 메모리에 저장해 응답 속도를 향상시키는 패턴
- 문자열 맵(String Map) 으로 표현하는 것이 일반적 – key: 쿼리, value: 결과
TIP: 인메모리 캐시는 문자열 맵으로 표현하는 것이 일반적이다.
뮤텍스 방식 vs Atom 방식 비교
뮤텍스 방식 (기존)
const mutex = new Mutex();
let cache: Record<string, unknown> = {};
function dbAccessCached(query: string): unknown {
const resultFromCache = cache[query];
if (resultFromCache != null) {
return resultFromCache;
}
const result = dbAccess(query);
mutex.lock();
cache = { ...cache, [query]: result }; // 불변 방식으로 업데이트
mutex.unlock();
return result;
}
Atom 방식 (DOP)
const cache = new Atom<Record<string, unknown>>();
cache.set({});
function dbAccessCached(query: string): unknown {
const resultFromCache = cache.get()[query];
if (resultFromCache != null) {
return resultFromCache;
}
const result = dbAccess(query);
// swap: oldCache를 받아 query-result 쌍이 추가된 새 맵을 반환
cache.swap((oldCache) => ({
...oldCache,
[query]: result,
}));
return result;
}
불변 데이터와 참조 비교(Reference Comparison)
swap내부의atomicCompareAndSet이 맵을 비교할 때, 불변 데이터는 참조(reference)로 비교하므로 매우 빠름- 가변 데이터였다면 값의 동등성(deep equality)을 확인해야 해서 비용이 큼
TIP: 데이터가 불변이면, 참조 비교는 안전하고(safe) 빠르다(fast).
8.4 State Management with Atoms
기존 SystemState 클래스의 문제점
class SystemState {
private systemData: unknown;
get(): unknown {
return this.systemData;
}
set(systemData: unknown): void {
this.systemData = systemData;
}
commit(previous: unknown, next: unknown): void {
// 이 코드가 여러 스레드에서 동시에 실행될 수 있어 스레드 안전하지 않음
this.systemData = SystemConsistency.reconcile(
this.systemData,
previous,
next
);
}
}
commit내부의reconcile로직이 락으로 보호되지 않아, 두 스레드가 동시에 실행하면 데이터 불일치 발생 가능
Atom으로 스레드 안전하게 개선
class SystemState {
private systemData: Atom<unknown>;
constructor() {
this.systemData = new Atom();
}
get(): unknown {
return this.systemData.get();
}
set(data: unknown): void {
this.systemData.set(data);
}
commit(previous: unknown, next: unknown): void {
// reconcile을 swap 안에 감싸서 원자적으로 실행
this.systemData.swap((current) =>
SystemConsistency.reconcile(current, previous, next)
);
}
}
swap에reconcile을 감쌈으로써, CAS 메커니즘이 충돌을 감지하고 자동으로 재시도- 락 없이 전체 시스템 상태를 스레드 안전하게 관리할 수 있음
챕터 8 요약
| 구분 | Lock(Mutex) | Atom |
|---|---|---|
| 데드락 가능성 | 있음 | 없음 |
| 읽기 보호 필요 | 있음 | 없음 (불변 데이터) |
| 코드 복잡도 | 높음 | 낮음 |
| 내부 메커니즘 | OS/언어 수준 락 | CPU의 CAS 연산 |
| 복합 데이터 지원 | 가능 | 가능 (참조 비교로 빠름) |
- 불변 데이터 + Atom의 조합은 DOP에서 동시성을 다루는 핵심 패턴
- Atom은 단순 카운터(Primitive 타입)뿐 아니라 복합 데이터(맵, 객체 등)에도 동일하게 적용 가능
- 전체 시스템 상태를 하나의 Atom 안에 보관함으로써, DOP의 고확장성 상태 관리 방식을 스레드 안전하게 만들 수 있음
9. 영속적 자료구조
거인의 어깨 위에 서서 (Standing on the Shoulders of Giants)
이 챕터는 데이터 불변성(Immutability)을 안전하고 확장 가능하게 유지하는 방법으로서 영속적 자료구조(Persistent Data Structures) 를 소개한다. Part 1에서 구조적 공유(Structural Sharing)와 불변 함수로 상태를 관리하는 방법을 다뤘다면, 이 챕터에서는 그 한계를 극복하는 실질적인 방법론을 다룬다.
9.1 영속적 자료구조가 필요한 이유 (The Need for Persistent Data Structures)
역사적 배경
- 2001년: Phil Bagwell이 영속적 자료구조의 효율적 구현 기반을 발견
- 2007년: Rich Hickey(Clojure 창시자)가 이를 Clojure 언어의 핵심 기능으로 채택
- Phil Bagwell은 2012년 타계했으며, 이 개념은 그의 중요한 학문적 유산
불변성을 보장하는 “순진한 방법” (Naive Way)
언어별로 불변 컬렉션을 제공하는 기본 방법이 있다.
Java의 경우
var myImmutableList = List.of(myList.toArray());
var myImmutableMap = Collections.unmodifiableMap(myMap);
// 수정 시도 시 → UnsupportedOperationException 발생
JavaScript / TypeScript의 경우
Object.freeze()를 사용하지만 얕은(shallow) 동결만 수행한다.
const a = [1, 2, 3];
Object.freeze(a);
const b = { foo: 1 };
Object.freeze(b);
// strict mode: TypeError 발생
// non-strict mode: 조용히 실패 (silent fail)
중첩 객체까지 동결하려면 재귀적 deepFreeze가 필요하다.
function deepFreeze<T extends object>(obj: T): Readonly<T> {
const propNames = Object.getOwnPropertyNames(obj);
for (const name of propNames) {
const value = (obj as Record<string, unknown>)[name];
if (value && typeof value === "object") {
deepFreeze(value as object);
}
}
return Object.freeze(obj);
}
핵심 인사이트: 수동으로 불변성을 보장하는 것은 가능하지만 번거롭다.
순진한 구조적 공유(Naive Structural Sharing)의 한계
| 문제 | 설명 |
|---|---|
| 안전성 | 불변 함수를 사용해도 데이터가 실수로 수정될 수 있음 |
| 메모리 | 10만 개 항목의 얕은 복사(shallow copy) → 약 100KB 메모리 추가 소비 |
| 연산 | 10만 개 항목 컬렉션의 얕은 복사 → 최대 50ms 소요 |
핵심 인사이트: 대규모에서 순진한 구조적 공유는 메모리와 연산 모두에서 성능 문제를 유발한다.
핵심 인사이트: 불변 컬렉션(Immutable Collections)과 영속적 자료구조(Persistent Data Structures)는 다르다. 불변 컬렉션은 새 버전을 효율적으로 생성하는 방법을 제공하지 않는다.
9.2 영속적 자료구조의 효율성 (The Efficiency of Persistent Data Structures)
영속적 자료구조란?
- 정의: 수정될 때 항상 이전 버전을 보존하는 자료구조
- 불변성 + 효율적인 새 버전 생성을 동시에 달성
핵심 인사이트: 영속적 자료구조는 수정될 때 항상 이전 버전을 보존한다.
구조적 공유가 안전한 이유
핵심 인사이트: 데이터가 불변이면 공유해도 안전하다.
연결 리스트(Linked List)로 이해하는 구조적 공유:
[새 리스트]
0 → [기존 리스트의 head를 가리킴]
[기존 리스트]
1 → 2 → 3 → 4 → 5
- 기존 리스트가 불변임이 보장될 때, 새 head가 기존 head를 가리키면 됨
- 이 연산의 효율성은 리스트 크기와 무관하게 O(1)
트리 기반 구조: 효율적인 업데이트
리스트를 트리로 표현하면 대부분의 노드를 두 버전 간에 공유할 수 있다.
단계적 분할 전략
10만 개 항목의 리스트에서 단일 요소 업데이트 시 연산 비용 비교:
| 분할 방식 | 공유 연산 | 복사 연산 | 총 연산 수 |
|---|---|---|---|
| 분할 없음 | 0 | 100,000 | 100,000 |
| 2분할 1회 | 1 | 50,000 | 50,001 |
| 2분할 2회 | 2 | 25,000 | 25,002 |
| 반복 분할 (size=2) | log₂N | 2 | ~log₂N |
결론: 반복 분할 시 복잡도 = O(log₂N)
10만 항목에서 log₂(100,000) ≈ 16.6
flowchart TD
Root["List (0~99,999)"]
L1["List 1 (0~49,999)"]
L2["List 2 (50,000~99,999)"]
L2_1["List 2.1 (50,000~74,999)"]
L2_2["List 2.2 (75,000~99,999) NEW"]
L2_2_shared["List 2.2 (75,000~99,999) OLD (shared)"]
Root --> L1
Root --> L2
L2 --> L2_1
L2 --> L2_2
L2_2 -.->|structural sharing| L2_2_shared
분기 계수(Branching Factor) 최적화: 2 → 32
| 분기 계수 | 10억 항목 기준 접근 연산 수 | 특징 |
|---|---|---|
| 2 | log₂(10B) ≈ 33 | 이론적 최소 깊이 |
| 32 | log₃₂(10B) ≈ 6.64 | 실용적 최적값 |
| 64 | log₆₄(10B) ≈ 5.5 | CPU 캐시 효율 저하 |
핵심 인사이트: 분기 계수 32를 사용하면 영속 리스트의 요소 접근이 더욱 효율적이다.
현대 CPU 아키텍처와 캐시 라인 최적화
- 현대 CPU는 캐시 라인(Cache Line) 단위(32~64바이트)로 메모리를 읽고 씀
- 크기 32의 배열 복사 = 단 1쌍의 캐시 접근(읽기 1 + 쓰기 1)
- 크기 2인 배열 16개 복사 = 16쌍의 캐시 접근 (각각 다른 트리 레벨)
핵심 인사이트: 현대 CPU 아키텍처에서 영속 리스트 업데이트 성능은 각 레벨의 노드 수보다 트리 깊이에 훨씬 더 많은 영향을 받는다.
실용적 관점: 사실상 O(1)
- 현실에서 리스트 항목 수의 상한선을 고려
- 10억 개 항목 = 약 4GB 메모리 (4bytes/항목 기준)
- 100억 개 항목 기준: log₃₂(10B) ≈ 6.64
- 최대 7번의 연산으로 모든 항목에 접근 가능 → 사실상 상수 시간
핵심 인사이트: 영속 리스트는 사실상 상수 시간(near constant time)으로 조작할 수 있다.
9.3 영속적 자료구조 라이브러리 (Persistent Data Structures Libraries)
언어별 라이브러리 현황
| 언어 | 라이브러리 |
|---|---|
| JavaScript / TypeScript | Immutable.js |
| Java | Paguro |
| C# | 언어 내장 제공 |
| Python | Pyrsistent |
| Ruby | Hamster |
| Clojure, Scala | 언어 내장 제공 |
Java: Paguro
- 영속적 맵:
java.util.Map의 읽기 전용 메서드(get, containsKey)만 구현 - 쓰기 메서드(put, remove) 호출 시 →
UnsupportedOperationException발생 - Java 스트림 인터페이스 완전 지원
- 새 버전 생성 메서드: 벡터는
replace(), 맵은assoc()
// 벡터 수정 버전 생성
var myVec = PersistentVector.ofIter(List.of(10, 2, 3));
var myNextVec = myVec.replace(0, 42);
// 맵 수정 버전 생성
var myMap = PersistentHashMap.of(Map.of("aa", 1, "bb", 2).entrySet());
var myNextMap = myMap.assoc("aa", 42);
핵심 인사이트: Paguro 컬렉션은 Java 컬렉션 인터페이스의 읽기 전용 부분을 구현하므로, 데이터를 변경하지 않는 모든 Java 메서드에 그대로 전달할 수 있다.
JavaScript / TypeScript: Immutable.js
JavaScript 객체/배열은 인터페이스를 노출하지 않으므로, Immutable.js는 자체 API를 제공한다.
초기화 및 변환
import Immutable from "immutable";
// 중첩 객체를 재귀적으로 불변 컬렉션으로 변환
const libraryData = Immutable.fromJS({
catalog: {
booksByIsbn: {
"978-1779501127": {
isbn: "978-1779501127",
title: "Watchmen",
publicationYear: 1987,
authorIds: ["alan-moore", "dave-gibbons"],
},
},
authorsById: {
"alan-moore": { name: "Alan Moore", bookIsbns: ["978-1779501127"] },
"dave-gibbons": { name: "Dave Gibbons", bookIsbns: ["978-1779501127"] },
},
},
});
데이터 접근
// 직접 필드 접근
Immutable.get(libraryData, "catalog");
// 중첩 필드 접근
Immutable.getIn(libraryData, [
"catalog",
"booksByIsbn",
"978-1779501127",
"title",
]);
// → "Watchmen"
새 버전 생성 (수정)
// 중첩 필드를 수정한 새 버전 반환 (원본 불변)
const updated = Immutable.setIn(
libraryData,
["catalog", "booksByIsbn", "978-1779501127", "publicationYear"],
1988
);
주의사항
- JavaScript의 점(
.) 또는 괄호([]) 표기법으로는 필드에 접근 불가 → 내부 표현에 접근하게 됨 - Lodash 등 외부 라이브러리와 직접 혼용 불가 → 필요 시
toJS()메서드로 네이티브 객체로 변환
9.4 영속적 자료구조 실전 활용 (Persistent Data Structures in Action)
9.4.1 쿼리 작성 (Writing Queries)
Lodash의 함수를 Immutable.js로 포팅하는 방법:
import Immutable from "immutable";
// Lodash _.map → Immutable.map
const immutableMap = (coll: Immutable.Collection<unknown, unknown>, f: (v: unknown) => unknown) =>
coll.map(f);
// Lodash _.filter → Immutable.filter (Map과 List 모두 지원)
const immutableFilter = (
coll: Immutable.Collection<unknown, unknown>,
f: (v: unknown) => boolean
) => {
if (Immutable.isMap(coll)) {
return (coll as Immutable.Map<unknown, unknown>).valueSeq().filter(f);
}
return coll.filter(f);
};
// Lodash _.isEqual → Immutable.is
const immutableIsEqual = Immutable.is;
도서 검색 쿼리 구현
import Immutable from "immutable";
class Catalog {
static authorNames(
catalogData: Immutable.Map<string, unknown>,
authorIds: Immutable.List<string>
): Immutable.List<string> {
return authorIds.map((authorId) =>
Immutable.getIn(catalogData, ["authorsById", authorId, "name"]) as string
);
}
static bookInfo(
catalogData: Immutable.Map<string, unknown>,
book: Immutable.Map<string, unknown>
): Immutable.Map<string, unknown> {
return Immutable.Map({
title: Immutable.get(book, "title"),
isbn: Immutable.get(book, "isbn"),
authorNames: Catalog.authorNames(
catalogData,
Immutable.get(book, "authorIds") as Immutable.List<string>
),
});
}
static searchBooksByTitle(
catalogData: Immutable.Map<string, unknown>,
query: string
): Immutable.Collection<unknown, unknown> {
const allBooks = Immutable.get(
catalogData,
"booksByIsbn"
) as Immutable.Map<string, Immutable.Map<string, unknown>>;
const queryLowerCased = query.toLowerCase();
const matchingBooks = allBooks.filter((book) =>
(Immutable.get(book, "title") as string)
.toLowerCase()
.includes(queryLowerCased)
);
return matchingBooks.map((book) => Catalog.bookInfo(catalogData, book));
}
}
테스트
const catalogData = Immutable.fromJS({
booksByIsbn: {
"978-1779501127": {
isbn: "978-1779501127",
title: "Watchmen",
publicationYear: 1987,
authorIds: ["alan-moore", "dave-gibbons"],
},
},
authorsById: {
"alan-moore": { name: "Alan Moore", bookIsbns: ["978-1779501127"] },
"dave-gibbons": { name: "Dave Gibbons", bookIsbns: ["978-1779501127"] },
},
});
Immutable.is(
Catalog.searchBooksByTitle(catalogData, "Watchmen"),
Immutable.fromJS([
{
isbn: "978-1779501127",
title: "Watchmen",
authorNames: ["Alan Moore", "Dave Gibbons"],
},
])
);
// → true
Immutable.is(
Catalog.searchBooksByTitle(catalogData, "Batman"),
Immutable.fromJS([])
);
// → true
TIP:
_를Immutable로 교체하는 것만으로 대부분의 코드가 동작한다.
9.4.2 변이(Mutation) 작성
멤버 추가 뮤테이션도 동일한 전략으로 포팅 가능:
import Immutable from "immutable";
const addMember = (
userManagement: Immutable.Map<string, unknown>,
member: Immutable.Map<string, unknown>
): Immutable.Map<string, unknown> => {
const email = Immutable.get(member, "email") as string;
const infoPath = ["membersByEmail", email];
if (Immutable.hasIn(userManagement, infoPath)) {
throw new Error("Member already exists.");
}
return Immutable.setIn(userManagement, infoPath, member);
};
테스트
const stateBefore = Immutable.fromJS({
membersByEmail: {
"franck@gmail.com": {
email: "franck@gmail.com",
password: "my-top-secret",
},
},
});
const jessie = Immutable.fromJS({
email: "jessie@gmail.com",
password: "my-secret",
});
const expectedStateAfter = Immutable.fromJS({
membersByEmail: {
"jessie@gmail.com": { email: "jessie@gmail.com", password: "my-secret" },
"franck@gmail.com": { email: "franck@gmail.com", password: "my-top-secret" },
},
});
Immutable.is(addMember(stateBefore, jessie), expectedStateAfter);
// → true
9.4.3 직렬화 및 역직렬화 (Serialization and Deserialization)
직렬화 (Serialization)
const bookInfo = Immutable.fromJS({
isbn: "978-1779501127",
title: "Watchmen",
authorNames: ["Alan Moore", "Dave Gibbons"],
});
JSON.stringify(bookInfo);
// → {"isbn":"978-1779501127","title":"Watchmen","authorNames":["Alan Moore","Dave Gibbons"]}
JSON.stringify()는 대상 객체에.toJSON()메서드가 있으면 이를 호출- Immutable.js 컬렉션은
.toJSON()을 구현하므로 별도 처리 없이 직렬화 가능
역직렬화 (Deserialization)
직접 구현이 필요하며, 2단계로 처리한다:
const parseJSON = (jsonString: string): Immutable.Collection<unknown, unknown> => {
return Immutable.fromJS(JSON.parse(jsonString));
};
- JSON 문자열 → 네이티브 JavaScript 객체 (
JSON.parse) - 네이티브 객체 → 불변 컬렉션 (
Immutable.fromJS)
9.4.4 구조적 차이(Structural Diff) 계산
복잡한 데이터 조작에도 Lodash → Immutable.js 포팅 전략이 동일하게 적용된다.
추가로 필요한 함수 포팅
import Immutable from "immutable";
const immutableReduce = <T>(
coll: Immutable.Collection<unknown, unknown>,
reducer: (acc: T, val: unknown) => T,
initial: T
): T => coll.reduce(reducer, initial) as T;
const immutableIsEmpty = (coll: Immutable.Collection<unknown, unknown>): boolean =>
coll.isEmpty();
const immutableKeys = (coll: Immutable.Collection<unknown, unknown>) =>
(coll as Immutable.Map<unknown, unknown>).keySeq();
const immutableIsObject = (coll: unknown): boolean =>
Immutable.Map.isMap(coll);
const immutableIsArray = Immutable.isIndexed;
const immutableUnion = (...lists: Immutable.Collection<unknown, unknown>[]) =>
Immutable.Set.union(lists as Immutable.SetLike<unknown>[]);
구조적 차이 계산 구현
function diffObjects(
data1: Immutable.Map<unknown, unknown>,
data2: Immutable.Map<unknown, unknown>
): Immutable.Map<unknown, unknown> | Immutable.List<unknown> {
const emptyObject = immutableIsArray(data1)
? Immutable.fromJS([])
: Immutable.fromJS({});
if (data1 === data2) return emptyObject;
const keys = immutableUnion(immutableKeys(data1), immutableKeys(data2));
return immutableReduce(
keys,
(acc, k) => {
const res = diff(
Immutable.get(data1, k),
Immutable.get(data2, k)
);
if (
(immutableIsObject(res) && immutableIsEmpty(res as Immutable.Collection<unknown, unknown>)) ||
res === "data-diff:no-diff"
) {
return acc;
}
return Immutable.set(acc as Immutable.Map<unknown, unknown>, k, res);
},
emptyObject
);
}
function diff(data1: unknown, data2: unknown): unknown {
if (immutableIsObject(data1) && immutableIsObject(data2)) {
return diffObjects(
data1 as Immutable.Map<unknown, unknown>,
data2 as Immutable.Map<unknown, unknown>
);
}
if (data1 !== data2) return data2;
return "data-diff:no-diff";
}
테스트
const data1 = Immutable.fromJS({ g: { c: 3 }, x: 2, y: { z: 1 }, w: [5] });
const data2 = Immutable.fromJS({ g: { c: 3 }, x: 2, y: { z: 2 }, w: [4] });
Immutable.is(
diff(data1, data2),
Immutable.fromJS({ w: [4], y: { z: 2 } })
);
// → true
챕터 9 요약
핵심 개념 정리
- 수동 불변성 보장은 가능하지만 번거롭다
- 불변 컬렉션 ≠ 영속적 자료구조: 불변 컬렉션은 효율적인 새 버전 생성 방법을 제공하지 않는다
- 대규모에서 순진한 구조적 공유는 메모리와 연산 모두에서 성능 문제를 유발한다
- 영속적 자료구조는 데이터를 변이로부터 보호하며 효율적인 새 버전 생성을 제공한다
영속적 자료구조의 원리
- 데이터가 불변이면 공유해도 안전하다
- 내부적으로 분기 계수 32의 트리 구조를 사용
- 업데이트 성능은 각 레벨의 노드 수보다 트리 깊이에 훨씬 더 많은 영향을 받는다
- 실용적으로 사실상 상수 시간(near O(1)) 으로 조작 가능 (100억 개 항목에서도 최대 7번 연산)
라이브러리 통합 전략
- 대부분 언어에서 서드파티 라이브러리로 구현체 제공
- Immutable.js 포팅 핵심:
_를Immutable로 교체하는 것만으로 대부분 동작 JSON.stringify()는 Immutable.js 객체와 바로 호환 (.toJSON()자동 호출)- 역직렬화는
JSON.parse()+Immutable.fromJS()2단계 조합으로 처리
10. 데이터베이스 연산
핵심 철학
“The best way to manipulate data is to represent data as data.”
- DOP에서는 데이터베이스에서 가져온 데이터도 애플리케이션 내 모든 데이터와 동일하게 제네릭 데이터 컬렉션으로 표현한다
- OOP처럼 디자인 패턴이나 복잡한 객체 계층 구조가 필요 없다
- 관계형 DB에서 가져온 데이터는 “리스트 of 맵(list of maps)” 으로 표현한다
- 이 접근법은 NoSQL 데이터베이스에도 동일하게 적용 가능하다
10.1 Fetching Data from the Database
기본 개념
- DB에서 가져온 데이터를 특정 클래스 인스턴스에 가두지 않는다
node-postgres와 같은 드라이버는 쿼리 결과를 이미 리스트 of 맵 형태로 반환한다- 제네릭 함수로 조작 가능하므로 어떤 데이터 엔티티에도 동일한 코드를 재사용할 수 있다
예제 DB 스키마
테이블 구조
| 테이블 | 컬럼 |
|---|---|
books |
isbn VARCHAR(32), title VARCHAR(64), publication_year INTEGER |
authors |
id VARCHAR(64), name VARCHAR(64) |
book_authors |
book_isbn VARCHAR(32), author_id VARCHAR(64) |
books와authors는 다대다(M:N) 관계book_authors가 두 테이블을 연결하는 조인 테이블
erDiagram
books {
string isbn PK
string title
int publication_year
}
authors {
string id PK
string name
}
book_authors {
string book_isbn FK
string author_id FK
}
books ||--o{ book_authors : ""
authors ||--o{ book_authors : ""
SQL 쿼리 결과를 리스트 of 맵으로 표현
SQL 쿼리:
SELECT title, isbn, publication_year
FROM books
WHERE title LIKE '%habit%';
결과를 DOP 방식으로 표현하면:
const searchResults = [
{
title: "7 Habits of Highly Effective People",
isbn: "978-1982137274",
publication_year: 1989,
},
{
title: "The Power of Habit",
isbn: "978-0812981605",
publication_year: 2012,
},
];
JSON Schema 검증
- DB에서 반환된 데이터의 형태를 JSON Schema로 명시적으로 검증한다
- 예상치 못한 DB 결과로부터 시스템을 보호하는 방어 코드 역할을 한다
const dbSearchResultSchema = {
type: "array",
items: {
type: "object",
required: ["title", "isbn", "publication_year"],
properties: {
title: { type: "string" },
isbn: { type: "string" },
publication_year: { type: "integer" },
},
},
};
전체 데이터 흐름
flowchart LR
A[Database] --> B[Database Driver]
B --> C["Data (list of maps)"]
C --> D[Data Manipulation]
D --> E[JSON Serialize]
E --> F[Response]
- 핵심 포인트: DB 드라이버에서 JSON 직렬화까지 전 과정을 제네릭 데이터 컬렉션만으로 처리
실제 검색 구현 예시 (node-postgres 기준)
import { Client } from "pg";
import Ajv from "ajv";
const dbClient = new Client();
const ajv = new Ajv({ allErrors: true });
async function searchBooks(title: string): Promise<string> {
const matchingBooksQuery = `
SELECT title, isbn, publication_year
FROM books
WHERE title LIKE $1
`;
const result = await dbClient.query(matchingBooksQuery, [`%${title}%`]);
const books = result.rows; // 이미 리스트 of 맵 형태
if (!ajv.validate(dbSearchResultSchema, books)) {
const errors = ajv.errorsText(ajv.errors);
throw new Error(`Internal error: Unexpected result from the database: ${errors}`);
}
return JSON.stringify(books);
}
참고: 데이터 타입을 가능한 한 늦게까지 처리한다.
publication_year가 숫자인지,title이 문자열인지는 JSON 직렬화 시점에 라이브러리가 알아서 처리한다. 그 전까지는 타입에 신경 쓰지 않고 자유롭게 데이터를 흘려보낸다.
10.2 Storing Data in the Database
기본 원칙
- 데이터를 저장할 때도 동일하게 맵(map) 으로 표현된 데이터를 다룬다
- SQL 파라미터 바인딩을 통해 보안(SQL Injection 방어)을 유지한다
SQL INSERT와 맵 데이터 연동
INSERT INTO members (email, encrypted_password)
VALUES ($1, $2);
기본적인 TypeScript 연동:
interface Member {
email: string;
encryptedPassword: string;
isBlocked: boolean;
}
async function addMember(member: Member): Promise<void> {
const addMemberQuery = `
INSERT INTO members (email, encrypted_password)
VALUES ($1, $2)
`;
await dbClient.query(addMemberQuery, [
member.email,
member.encryptedPassword,
]);
}
_.at을 활용한 간결한 필드 추출
- Lodash의
_.at(map, keyList)함수는 맵에서 여러 키의 값을 순서대로 배열로 반환한다 - 파라미터가 많아질수록 코드가 더욱 간결해진다
import _ from "lodash";
const member = {
email: "samantha@gmail.com",
encryptedPassword: "c2VjcmV0",
isBlocked: false,
};
// ["samantha@gmail.com", "c2VjcmV0"]
_.at(member, ["email", "encryptedPassword"]);
_.at을 적용한 DB 저장:
class CatalogDB {
static async addMember(member: Record<string, unknown>): Promise<void> {
const addMemberQuery = `
INSERT INTO members (email, encrypted_password)
VALUES ($1, $2)
`;
await dbClient.query(
addMemberQuery,
_.at(member as any, ["email", "encryptedPassword"])
);
}
}
핵심 인사이트: 해시 맵에서 필드를 동적으로 접근하는 것이 클래스 인스턴스의 멤버에 직접 접근하는 것보다 더 유연하다. 필드 이름이 문자열이기 때문에 어떤 맵에도 재사용 가능한 제네릭 코드 작성이 가능해진다.
10.3 Simple Data Manipulation
문제 상황: 컬럼 이름 불일치
- DB 컬럼은 보통
snake_case컨벤션을 따른다 (publication_year) - 애플리케이션/JSON 레이어에서는
camelCase또는 다른 이름을 선호한다 (publicationYear,bookTitle) - SQL
AS절로 해결할 수 있지만, 애플리케이션 코드와 DB 쿼리가 불필요하게 결합된다
해결책 1: SQL AS 절 (권장하지 않음)
SELECT
title AS bookTitle,
isbn,
publication_year AS publicationYear
FROM books
WHERE title LIKE '%habit%';
- 단점: MongoDB 같은 NoSQL에서는 동일하게 적용하기 어렵다
- 단점: DB 쿼리가 애플리케이션 네이밍 컨벤션에 종속된다
해결책 2: 제네릭 renameResultKeys 함수 (DOP 권장)
핵심 인사이트: DOP에서 필드명은 그냥 문자열이다. 따라서 필드명을 인자로 받아 어떤 리스트 of 맵에도 동작하는 제네릭 함수를 만들 수 있다.
import _ from "lodash";
type KeyMap = Record<string, string>;
function renameKeys(
map: Record<string, unknown>,
keyMap: KeyMap
): Record<string, unknown> {
return _.reduce(
keyMap,
(result, newKey, oldKey) => {
const value = _.get(map, oldKey);
const withNewKey = _.set({ ...result }, newKey, value);
return _.omit(withNewKey, oldKey);
},
map
);
}
function renameResultKeys(
results: Record<string, unknown>[],
keyMap: KeyMap
): Record<string, unknown>[] {
return _.map(results, (result) => renameKeys(result, keyMap));
}
사용 예시
const bookResults = [
{ title: "7 Habits of Highly Effective People", isbn: "978-1982137274", publication_year: 1989 },
{ title: "The Power of Habit", isbn: "978-0812981605", publication_year: 2012 },
];
// title -> bookTitle, publication_year -> publicationYear
// isbn은 언급하지 않으면 그대로 유지됨
const renamed = renameResultKeys(bookResults, {
title: "bookTitle",
publication_year: "publicationYear",
});
/*
결과:
[
{ bookTitle: "7 Habits of Highly Effective People", isbn: "978-1982137274", publicationYear: 1989 },
{ bookTitle: "The Power of Habit", isbn: "978-0812981605", publicationYear: 2012 },
]
*/
유닛 테스트
import _ from "lodash";
const input = [
{ title: "7 Habits of Highly Effective People", isbn: "978-1982137274", publication_year: 1989 },
{ title: "The Power of Habit", isbn: "978-0812981605", publication_year: 2012 },
];
const expected = [
{ bookTitle: "7 Habits of Highly Effective People", isbn: "978-1982137274", publicationYear: 1989 },
{ bookTitle: "The Power of Habit", isbn: "978-0812981605", publicationYear: 2012 },
];
const actual = renameResultKeys(input, {
title: "bookTitle",
publication_year: "publicationYear",
});
console.assert(_.isEqual(expected, actual), "renameResultKeys 테스트 실패");
핵심 인사이트:
renameResultKeys는 책 데이터, 유저 데이터, 주문 데이터 등 어떤 엔티티의 SQL 결과에도 동일하게 사용 가능하다. 이것이 데이터를 데이터로 표현하는 DOP의 핵심 이점이다.
10.4 Advanced Data Manipulation
문제 상황: 조인 쿼리와 데이터 재구조화
- 한 책에 여러 저자가 있을 경우, SQL 조인 결과는 같은 책 정보가 저자 수만큼 반복된다
- 하지만 클라이언트가 원하는 형태는 저자 이름이 배열로 집계된 단일 레코드이다
SQL 조인 쿼리
SELECT
title,
isbn,
authors.name AS author_name
FROM books
INNER JOIN book_authors ON books.isbn = book_authors.book_isbn
INNER JOIN authors ON book_authors.author_id = authors.id
WHERE books.title LIKE '%habit%';
DB가 반환하는 원본 (3행):
| title | isbn | author_name |
|---|---|---|
| 7 Habits of Highly Effective People | 978-1982137274 | Sean Covey |
| 7 Habits of Highly Effective People | 978-1982137274 | Stephen Covey |
| The Power of Habit | 978-0812981605 | Charles Duhigg |
우리가 원하는 최종 형태 (2개 객체):
[
{
isbn: "978-1982137274",
title: "7 Habits of Highly Effective People",
authorNames: ["Sean Covey", "Stephen Covey"],
},
{
isbn: "978-0812981605",
title: "The Power of Habit",
authorNames: ["Charles Duhigg"],
},
]
데이터 처리 파이프라인
flowchart TD
A["SQL 결과 (3행)"] --> B["_.groupBy(isbn)으로 그룹핑"]
B --> C["isbn 978-1982137274: 2행\nisbn 978-0812981605: 1행"]
C --> D["각 그룹에서 author_name 집계"]
D --> E["최종 결과 (2개 객체)\nauthorNames 필드에 배열로"]
구현 단계
Step 1: isbn으로 행 그룹핑
import _ from "lodash";
type DataRow = Record<string, unknown>;
const sqlRows: DataRow[] = [
{ title: "7 Habits of Highly Effective People", isbn: "978-1982137274", author_name: "Sean Covey" },
{ title: "7 Habits of Highly Effective People", isbn: "978-1982137274", author_name: "Stephen Covey" },
{ title: "The Power of Habit", isbn: "978-0812981605", author_name: "Charles Duhigg" },
];
const rowsByIsbn = _.groupBy(sqlRows, "isbn");
/*
{
"978-1982137274": [
{ title: "7 Habits...", isbn: "...", author_name: "Sean Covey" },
{ title: "7 Habits...", isbn: "...", author_name: "Stephen Covey" },
],
"978-0812981605": [
{ title: "The Power of Habit", isbn: "...", author_name: "Charles Duhigg" },
],
}
*/
Step 2: 특정 필드 집계 함수 aggregateField
function aggregateField(
rows: DataRow[],
fieldName: string, // 집계 대상 필드 (예: "author_name")
aggregateFieldName: string // 집계 결과를 담을 새 필드명 (예: "authorNames")
): DataRow {
const aggregatedValues = _.map(rows, fieldName); // 모든 행에서 해당 필드 값 추출
const firstRow = _.nth(rows, 0) as DataRow;
const withAggregated = _.set({ ...firstRow }, aggregateFieldName, aggregatedValues);
return _.omit(withAggregated, fieldName); // 원본 단일 필드 제거
}
Step 3: aggregateField 유닛 테스트
const rows7Habits: DataRow[] = [
{ author_name: "Sean Covey", isbn: "978-1982137274", title: "7 Habits of Highly Effective People" },
{ author_name: "Stephen Covey", isbn: "978-1982137274", title: "7 Habits of Highly Effective People" },
];
const expected = {
isbn: "978-1982137274",
title: "7 Habits of Highly Effective People",
authorNames: ["Sean Covey", "Stephen Covey"],
};
const result = aggregateField(rows7Habits, "author_name", "authorNames");
console.assert(_.isEqual(expected, result), "aggregateField 테스트 실패");
Step 4: 전체를 아우르는 aggregateFields 함수
function aggregateFields(
rows: DataRow[],
idFieldName: string, // 그룹핑 기준 필드 (예: "isbn")
fieldName: string, // 집계할 필드 (예: "author_name")
aggregateFieldName: string // 집계 결과를 담을 새 필드명 (예: "authorNames")
): DataRow[] {
const groupedRows = _.values(_.groupBy(rows, idFieldName));
return _.map(groupedRows, (group) =>
aggregateField(group, fieldName, aggregateFieldName)
);
}
최종 사용:
const result = aggregateFields(sqlRows, "isbn", "author_name", "authorNames");
/*
[
{ isbn: "978-1982137274", title: "7 Habits of Highly Effective People", authorNames: ["Sean Covey", "Stephen Covey"] },
{ isbn: "978-0812981605", title: "The Power of Habit", authorNames: ["Charles Duhigg"] },
]
*/
핵심 인사이트:
aggregateFields는 책-저자 관계뿐 아니라 주문-상품, 유저-태그 등 어떤 다대다 관계에도 재사용할 수 있다. 필드명이 문자열이기 때문에 가능한 것이다.
챕터 10 요약
DOP 데이터베이스 원칙 요약
- 데이터는 항상 데이터로 표현한다. DB에서 왔든 메모리에 있든 상관없이 제네릭 컬렉션을 사용한다
- 관계형 DB 결과는 리스트 of 맵으로 표현하며, 이는 NoSQL에도 동일하게 적용된다
- 제네릭 함수(
renameResultKeys,aggregateFields)는 특정 엔티티에 종속되지 않고 재사용 가능하다 - Late Binding: 데이터 타입을 직렬화 시점까지 미룸으로써 코드 유연성이 높아진다
- 필드명은 문자열이자 일급 시민(first-class citizen): 이것이 DOP에서 제네릭 데이터 조작의 핵심 원동력이다
- 복잡한 디자인 패턴이나 클래스 계층 없이 시스템 복잡도를 줄인다
이번 챕터에서 활용한 Lodash 함수
| 함수 | 설명 |
|---|---|
_.at(map, paths) |
맵에서 지정한 경로들의 값을 배열로 반환 |
_.omit(map, paths) |
지정한 키들을 제외한 새 맵을 반환 |
_.nth(arr, n) |
배열에서 n번째 요소를 반환 |
_.groupBy(coll, f) |
컬렉션을 f의 반환값 기준으로 그룹핑하여 맵으로 반환 |
_.values(map) |
맵의 값들을 배열로 반환 |
_.set(map, key, value) |
맵에 키-값을 설정하여 새 맵 반환 |
_.get(map, key) |
맵에서 키에 해당하는 값을 반환 |
_.reduce(coll, fn, init) |
컬렉션을 누산 함수로 하나의 값으로 줄임 |
11. 웹 서비스
핵심 인사이트: DOP(Data-Oriented Programming)의 원칙을 웹 서비스 구현에 적용하는 방법. 클라이언트 요청과 서버 응답을 불변 데이터 컬렉션(맵)으로 표현하고, 제네릭 데이터 조작 함수로 비즈니스 로직을 구현한다.
11.1 Another Feature Request
배경 설정
- 기존 DB 기반 도서 검색에서 Open Library Books API를 통한 풍부한 도서 정보 제공으로 요구사항 변경
- Open Library API의 특성: 책마다 응답 필드 수가 극단적으로 다름 (어떤 책은 수십 개, 어떤 책은 2~3개)
- 전통적 OOP 방식으로는 이렇게 희소하고(sparse) 예측 불가능한 데이터를 표현하기 매우 어려움
DOP의 해답
- 데이터를 데이터로 표현하면, 필드 수가 들쭉날쭉한 스파스 데이터는 아무런 문제가 되지 않음
- 맵(Map) 자료구조는 필드 존재 여부와 무관하게 동일하게 처리 가능
11.2 Building the Insides Like the Outsides
핵심 원칙
“We should build the insides of our systems like we build the outsides.” 시스템의 내부를 외부와 동일한 방식으로 구축해야 한다.
외부 통신 방식 분석
- 웹 브라우저 - 웹 서버 - 데이터베이스 간 통신은 언어에 독립적인 데이터 포맷(JSON) 으로 이루어짐
- 어떤 프로그래밍 언어를 사용하든 JSON 파서가 존재하고, 컴포넌트는 서로의 내부 구조를 알 필요 없음
전통적 OOP의 문제점
- 데이터를 클래스로 표현하면, 내부 컴포넌트들이 서로의 클래스 정의(internals)를 알아야 함
- 특정 클래스의 멤버에 접근하려면 반드시 해당 클래스 정의를 import해야 함
- 결과적으로 컴포넌트 간 강한 결합(tight coupling) 발생
DOP의 접근 방식
- 내부 컴포넌트들도 제네릭 데이터 컬렉션(불변 맵) 으로 통신
- 컴포넌트는 서로의 내부 구조를 알 필요 없이, 필드 이름만 알면 됨
- 결과: 느슨한 결합(loose coupling)
웹 서비스 데이터 흐름 5단계
flowchart LR
A[Client JSON Request] -->|1. JSON.parse| B[Data Map]
B -->|2. Business Logic| C[Data Map]
C -->|3. JSON.stringify| D[DB/API Request]
D -->|4. JSON.parse| E[Data Map]
E -->|5. Business Logic| F[Data Map]
F -->|6. JSON.stringify| G[Client JSON Response]
| 단계 | 설명 |
|---|---|
| 1 | 클라이언트 JSON 요청을 데이터(맵)로 파싱 |
| 2 | 비즈니스 로직에 따라 데이터 조작 |
| 3 | 데이터를 DB/외부 API 요청 JSON으로 직렬화 |
| 4 | JSON 응답을 다시 데이터(맵)로 파싱 |
| 5 | 비즈니스 로직에 따라 데이터 조작 |
| 6 | 데이터를 클라이언트 응답 JSON으로 직렬화 |
- 각 단계의 비즈니스 로직은 제네릭 데이터 조작 함수로 표현됨
- 이 구조는 외부 통신(JSON over the wire)과 구조적으로 동일
11.3 Representing a Client Request as a Map
클라이언트 요청 설계
- 클라이언트가 어떤 필드를 받을지 직접 결정할 수 있도록 단일 엔드포인트 설계
- 모바일/데스크톱, 기본/확장 화면 등 다양한 케이스를 하나의 엔드포인트로 처리 가능
요청 페이로드 예시:
{
"title": "habit",
"fields": ["title", "weight", "number_of_pages"]
}
JSON Schema로 요청 유효성 검증
const searchBooksRequestSchema = {
type: "object",
properties: {
title: { type: "string" },
fields: {
type: "array",
items: {
enum: [
"title",
"full_title",
"subtitle",
"publisher",
"publish_date",
"weight",
"physical_dimensions",
"number_of_pages",
"subjects",
"publishers",
"genre",
],
},
},
},
required: ["title", "fields"],
} as const;
fields배열의 각 항목을string이 아닌enum으로 정의하여, 허용된 필드 이름만 수신 가능하도록 제한- 유효성 검증 라이브러리로 AJV(Another JSON Validator) 사용
JSON 파싱
const jsonString = '{"title":"habit","fields":["title","weight","number_of_pages"]}';
const requestData = JSON.parse(jsonString); // 제네릭 맵으로 변환
- Express 같은 웹 프레임워크는 이 파싱을 자동으로 처리
- DOP 관점에서 파싱의 결과물은 항상 제네릭 맵
11.4 Representing a Server Response as a Map
Open Library API 응답 특성
GET https://openlibrary.org/isbn/{isbn}.json형태의 단순한 인터페이스- 응답 필드가 책마다 다름 (희소 데이터) - 전통 OOP에서는 처리하기 어렵지만 DOP에서는 자연스럽게 맵으로 처리
노출할 필드 목록
title,full_title,subtitle,publisher,publish_dateweight,physical_dimensions,number_of_pagessubjects,publishers,genre
Open Library 응답 JSON Schema
const basicBookInfoSchema = {
type: "object",
required: ["title"],
properties: {
title: { type: "string" },
publishers: { type: "array", items: { type: "string" } },
number_of_pages: { type: "integer" },
weight: { type: "string" },
physical_format: { type: "string" },
subjects: { type: "array", items: { type: "string" } },
isbn_13: { type: "array", items: { type: "string" } },
isbn_10: { type: "array", items: { type: "string" } },
publish_date: { type: "string" },
physical_dimensions: { type: "string" },
},
} as const;
// isbn_13 또는 isbn_10 중 하나는 반드시 존재해야 함 (스키마 조합)
const mandatoryIsbn13 = { type: "object", required: ["isbn_13"] } as const;
const mandatoryIsbn10 = { type: "object", required: ["isbn_10"] } as const;
const bookInfoSchema = {
allOf: [
basicBookInfoSchema,
{ anyOf: [mandatoryIsbn13, mandatoryIsbn10] },
],
} as const;
allOf+anyOf를 활용한 스키마 조합(schema composition) 으로 복잡한 유효성 규칙 표현isbn_13또는isbn_10중 하나가 반드시 있어야 한다는 조건을 선언적으로 표현
Open Library 데이터 소스 구현
import Ajv from "ajv";
import _ from "lodash";
const ajv = new Ajv({ allErrors: true });
class OpenLibraryDataSource {
static rawBookInfo(isbn: string): Record<string, unknown> {
const url = `https://openlibrary.org/isbn/${isbn}.json`;
const jsonString = fetchResponseBody(url); // 동기 I/O 가정 (실제는 async/await 필요)
return JSON.parse(jsonString);
}
static bookInfo(isbn: string, requestedFields: string[]): Record<string, unknown> {
const relevantFields = [
"title", "full_title", "subtitle", "publisher",
"publish_date", "weight", "physical_dimensions",
"genre", "subjects", "number_of_pages",
];
const rawInfo = OpenLibraryDataSource.rawBookInfo(isbn);
if (!ajv.validate(bookInfoSchema, rawInfo)) {
const errors = ajv.errorsText(ajv.errors);
throw new Error(`Internal error: Unexpected result from Open Books API: ${errors}`);
}
// 이중 필터링: 1) 서버에서 허용하는 필드, 2) 클라이언트가 요청한 필드
const relevantInfo = _.pick(_.pick(rawInfo, relevantFields), requestedFields);
return _.set(relevantInfo, "isbn", isbn); // isbn을 키로 추가 (나중에 조인에 사용)
}
}
- 이중
_.pick: 서버 측 허용 필드 필터 후, 클라이언트 요청 필드 필터 적용 isbn필드를 결과에 포함: 이후 두 데이터 소스를 조인할 때 키로 활용하기 위함
핵심 인사이트: 데이터를 맵으로 표현하기 때문에, 필드 수가 아무리 달라도 동일한
_.pick함수로 필드 필터링이 가능하다.
11.5 Passing Information Forward
OOP의 “열린 편지” 문제
핵심 인사이트: 허고는 약혼녀에게 꽃과 편지를 전달해달라고 친구 윌리에게 부탁했다. 윌리는 “임무를 충실히 수행하기 위해” 편지를 읽어야 했다고 말했다. 허고는 당연히 실망했다 - 배달부는 편지 내용을 알 필요가 없다.
- 전통 OOP = 윌리: 정보를 전달하기 위해 내부 구조(
DBBook,OpenLibraryBook,CombinedBook등의 클래스)를 알아야 함 - DOP = 허고가 기대한 배달부: 정보를 제네릭 데이터 구조로 그냥 전달(pass forward)
두 데이터 소스 결합 방법
| 방식 | 장점 | 단점 |
|---|---|---|
| 중첩(Nesting) | 충돌 처리 불필요 | 결과가 플랫하지 않음 |
| 병합(Merging) | 결과가 플랫함 | 동일 필드명 충돌 시 처리 필요 |
- 현재 시나리오(DB 정보 + Open Library 정보)에서는 공통 필드 없음 -> 병합 채택
_.merge로 두 맵 병합
const dataFromDb = {
available: true,
isbn: "978-1982137274",
};
const dataFromOpenLib = {
title: "7 Habits of Highly Effective People : Revised and Updated",
subtitle: "Powerful Lessons in Personal Change",
number_of_pages: 432,
full_title: "7 Habits of Highly Effective People : Revised and Updated Powerful Lessons in Personal Change",
publish_date: "2020",
publishers: ["Simon & Schuster, Incorporated"],
};
const combined = _.merge(dataFromDb, dataFromOpenLib);
// 결과: 두 맵의 모든 필드가 하나의 플랫한 맵으로 합쳐짐
응답 JSON Schema
const searchBooksResponseSchema = {
type: "object",
required: ["title", "isbn", "available"],
properties: {
title: { type: "string" },
available: { type: "boolean" },
publishers: { type: "array", items: { type: "string" } },
number_of_pages: { type: "integer" },
weight: { type: "string" },
physical_format: { type: "string" },
subjects: { type: "array", items: { type: "string" } },
isbn: { type: "string" },
publish_date: { type: "string" },
physical_dimensions: { type: "string" },
},
} as const;
11.6 Search Result Enrichment in Action
전체 데이터 흐름 (7단계)
flowchart TD
A[1. 클라이언트 요청 수신] --> B[2. 요청에서 query와 fields 추출]
B --> C[3. DB에서 title 매칭 도서 조회]
C --> D[4. 각 ISBN에 대해 Open Library에서 정보 가져오기]
D --> E[5. Open Library 응답에서 요청된 필드만 추출]
E --> F[6. DB 정보와 Open Library 정보를 조합]
F --> G[7. 클라이언트에 응답 전송]
DB 데이터 소스 구현
const dbSearchResultSchema = {
type: "array",
items: {
type: "object",
required: ["isbn", "available"],
properties: {
isbn: { type: "string" },
available: { type: "boolean" },
},
},
} as const;
class CatalogDB {
static matchingBooks(title: string): Array<{ isbn: string; available: boolean }> {
const matchingBooksQuery = `
SELECT isbn, available
FROM books
WHERE title LIKE '%$1%';
`;
const books = dbClient.query(catalogDB, matchingBooksQuery, [title]);
if (!ajv.validate(dbSearchResultSchema, books)) {
const errors = ajv.errorsText(ajv.errors);
throw new Error(`Internal error: Unexpected result from the database: ${errors}`);
}
return books as Array<{ isbn: string; available: boolean }>;
}
}
_.keyBy를 활용한 배열 -> 맵 변환
const books = [
{ title: "7 Habits of Highly Effective People", isbn: "978-1982137274", available: true },
{ title: "The Power of Habit", isbn: "978-0812981605", available: false },
];
_.keyBy(books, "isbn");
// 결과:
// {
// "978-1982137274": { title: "7 Habits...", isbn: "978-1982137274", available: true },
// "978-0812981605": { title: "The Power...", isbn: "978-0812981605", available: false },
// }
_.keyBy: 지정한 필드를 키로 사용하여 배열을 맵으로 변환_.groupBy와 유사하지만, 각 키에 단 하나의 원소만 있다고 가정
제네릭 배열 조인 함수
function joinArrays<A extends object, B extends object>(
a: A[],
b: B[],
keyA: keyof A,
keyB: keyof B
): Array<A & B> {
const mapA = _.keyBy(a, keyA as string);
const mapB = _.keyBy(b, keyB as string);
const mapsMerged = _.merge(mapA, mapB);
return _.values(mapsMerged) as Array<A & B>;
}
- 4개의 인자: 두 배열과 각각의 조인 키 필드명 (두 배열에서 키 필드명이 다를 수 있음)
- 내부 동작:
- 각 배열을
_.keyBy로 맵으로 변환 _.merge로 두 맵을 병합_.values로 다시 배열로 변환
- 각 배열을
조인 함수 단위 테스트
const dbBookInfos = [
{ isbn: "978-1982137274", title: "7 Habits of Highly Effective People", available: true },
{ isbn: "978-0812981605", title: "The Power of Habit", available: false },
];
const openLibBookInfos = [
{ isbn: "978-0812981605", title: "7 Habits...", subtitle: "Powerful Lessons in Personal Change", number_of_pages: 432 },
{ isbn: "978-1982137274", title: "The Power of Habit", subtitle: "Why We Do What We Do in Life and Business", subjects: ["Social aspects", "Habit", "Change (Psychology)"] },
];
const result = joinArrays(dbBookInfos, openLibBookInfos, "isbn", "isbn");
// 각 isbn을 기준으로 DB 정보와 Open Library 정보가 병합된 배열 반환
console.assert(_.isEqual(result, expectedJoinedArrays));
최종 통합 구현
class OpenLibraryDataSource {
static rawBookInfo(isbn: string): Record<string, unknown> {
const url = `https://openlibrary.org/isbn/${isbn}.json`;
const jsonString = fetchResponseBody(url);
return JSON.parse(jsonString);
}
static bookInfo(isbn: string, requestedFields: string[]): Record<string, unknown> {
const relevantFields = [
"title", "full_title", "subtitle", "publisher",
"publish_date", "weight", "physical_dimensions",
"genre", "subjects", "number_of_pages",
];
const rawInfo = OpenLibraryDataSource.rawBookInfo(isbn);
if (!ajv.validate(bookInfoSchema, rawInfo)) {
throw new Error(`Internal error: Unexpected result from Open Books API: ${ajv.errorsText(ajv.errors)}`);
}
const relevantInfo = _.pick(_.pick(rawInfo, relevantFields), requestedFields);
return _.set(relevantInfo, "isbn", isbn);
}
// 단일 isbn -> 단일 bookInfo의 배열 버전
// 실제 운영에서는 Promise.all로 병렬 처리 필요
static async multipleBookInfo(
isbns: string[],
fields: string[]
): Promise<Record<string, unknown>[]> {
return Promise.all(isbns.map((isbn) => OpenLibraryDataSource.bookInfo(isbn, fields)));
}
}
class Catalog {
static async enrichedSearchBooksByTitle(
request: Record<string, unknown>
): Promise<Record<string, unknown>[]> {
// 1. 요청 유효성 검증
if (!ajv.validate(searchBooksRequestSchema, request)) {
throw new Error(`Invalid request: ${ajv.errorsText(ajv.errors)}`);
}
const title = _.get(request, "title") as string;
const fields = _.get(request, "fields") as string[];
// 2. DB에서 매칭 도서 조회
const dbBookInfos = CatalogDB.matchingBooks(title);
// 3. Open Library에서 풍부한 도서 정보 조회
const isbns = _.map(dbBookInfos, "isbn") as string[];
const openLibBookInfos = await OpenLibraryDataSource.multipleBookInfo(isbns, fields);
// 4. isbn을 키로 두 데이터 소스 조인
const response = joinArrays(dbBookInfos, openLibBookInfos, "isbn", "isbn");
// 5. 응답 유효성 검증
if (!ajv.validate(searchBooksResponseSchema, response)) {
throw new Error(`Invalid response: ${ajv.errorsText(ajv.errors)}`);
}
return response;
}
}
// 웹 서비스 레이어 (JSON 직렬화/역직렬화 담당)
class Library {
static async searchBooksByTitle(payloadBody: string): Promise<string> {
const payloadData = JSON.parse(payloadBody);
const results = await Catalog.enrichedSearchBooksByTitle(payloadData);
return JSON.stringify(results);
}
}
핵심 인사이트: 클래스는 유사한 도메인 엔티티에 대해 동작하는 상태 없는(stateless) 함수들을 묶는 수단으로만 사용될 때 훨씬 단순해진다.
챕터 11 요약
챕터 핵심 원칙 정리
- 시스템의 내부를 외부처럼 구축하라: 외부 컴포넌트가 JSON으로 통신하듯, 내부 컴포넌트도 제네릭 데이터 컬렉션(맵)으로 통신
- 느슨한 결합(Loose Coupling): DOP에서 내부 컴포넌트는 서로의 내부 구조를 알 필요 없이 필드 이름만 알면 됨
- 비즈니스 로직 = 제네릭 데이터 조작 함수: 파싱, 필터링, 병합, 조인 등의 범용 함수로 비즈니스 로직 구현 가능
- 클래스의 역할 재정의: 상태를 캡슐화하는 OOP 클래스 대신, 동일 도메인의 stateless 함수를 묶는 네임스페이스로 활용
이 챕터에서 사용된 Lodash 함수
| 함수 | 설명 |
|---|---|
_.pick(obj, fields) |
객체에서 지정한 필드만 추출하여 새 객체 반환 |
_.get(obj, path) |
객체에서 경로로 값 안전하게 조회 |
_.set(obj, path, value) |
객체에 경로로 값 설정 (불변성 주의: 원본 변경) |
_.merge(obj1, obj2) |
두 객체의 필드를 하나의 객체로 병합 |
_.keyBy(arr, key) |
배열을 지정한 필드를 키로 하는 맵으로 변환 |
_.map(arr, fn) |
배열 각 원소에 함수 적용 |
_.values(obj) |
객체의 값들을 배열로 반환 |
_.isEqual(a, b) |
두 값의 깊은 동등성(deep equality) 비교 |
12. 고급 데이터 검증
DOP에서는 함수 인자와 반환값에 대한 데이터 스키마를 정의함으로써, 개발 단계에서 OOP의 타입 시스템에 준하는 명확성을 얻으면서도 런타임 조건 검증이라는 추가적인 이점을 누릴 수 있다.
배경: 왜 시스템 내부 데이터 검증이 필요한가
DOP에서는 모든 데이터가 제네릭 맵/배열 형태로 흐른다. 시스템 경계(HTTP 요청, DB 응답 등)에서의 검증은 이미 다뤘지만, 함수 내부에서는 두 가지 문제가 남는다.
- 함수 인자의 기대 형태(shape)를 파악하기 어렵다
- 잘못된 데이터를 넘겼을 때 의미 있는 에러 메시지가 없다
두 종류의 데이터 검증 비교
| 검증 종류 | 목적 | 실행 환경 |
|---|---|---|
| 경계(Boundary) 검증 | 유효하지 않은 데이터의 시스템 유입 방지 | 프로덕션 |
| 내부(Inside) 검증 | 개발 편의성 향상 | 개발(Dev) |
핵심 인사이트: 내부 데이터 검증은 프로덕션에서 비활성화해야 한다. 경계에서 이미 검증을 마쳤으므로 중복 검증은 성능 낭비다. Java의
assert와 동일한 개념이다.
12.1 함수 인자 검증 (Function Arguments Validation)
데이터 모델 다이어그램
classDiagram
class Catalog {
booksByIsbn: Record~string, Book~
authorsById: Record~string, Author~
}
class Book {
title: string
isbn: string
publicationYear: number
authorIds: string[]
bookItems: BookItem[]
}
class BookItem {
id: string
libId: string
purchaseDate: string
isLent: boolean
}
class Author {
id: string
name: string
bookIsbns: string[]
}
Catalog --> Book
Catalog --> Author
Book --> BookItem
JSON Schema 핵심 개념
이종 맵(Heterogeneous Map) vs 동종 맵(Homogeneous Map)
| 맵 종류 | 설명 | DOP 용도 | JSON Schema 표현 |
|---|---|---|---|
| 이종 맵 | 키 이름이 정해져 있고, 값의 형태가 각각 다름 | 레코드(Record) | properties 사용 |
| 동종 맵 | 키 이름이 불특정, 모든 값이 동일한 형태 | 인덱스(Index) | additionalProperties에 스키마 지정 |
// 동종 맵(인덱스)의 JSON Schema 표현 예시
const numberMapSchema = {
type: "object",
additionalProperties: { type: "number" }
// 키 이름은 무엇이든 허용하되, 값은 반드시 number
};
스키마 정의: 단계별 구성
1단계 - 원자 스키마부터 작성
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv();
addFormats(ajv);
// Author 스키마 (가장 단순한 것부터)
const authorSchema = {
type: "object",
required: ["id", "name", "bookIsbns"],
properties: {
id: { type: "string" },
name: { type: "string" },
bookIsbns: {
type: "array",
items: { type: "string" }
}
}
};
2단계 - 중첩 스키마는 변수로 분리
// BookItem 스키마를 별도 변수로 분리 -> 가독성 향상
const bookItemSchema = {
type: "object",
required: ["id", "libId", "purchaseDate", "isLent"],
properties: {
id: { type: "string" },
libId: { type: "string" },
purchaseDate: { type: "string" },
isLent: { type: "boolean" }
}
};
const bookSchema = {
type: "object",
required: ["title", "isbn", "authorIds", "bookItems"],
properties: {
title: { type: "string" },
publicationYear: { type: "integer" }, // optional 필드: required 미포함
isbn: { type: "string" },
authorIds: { type: "array", items: { type: "string" } },
bookItems: { type: "array", items: bookItemSchema } // 재사용
}
};
TIP: 복잡한 데이터 스키마를 정의할 때는 중첩 스키마를 변수로 분리하라. 가독성이 크게 향상된다.
3단계 - 인덱스(동종 맵) 스키마 조합
const catalogSchema = {
type: "object",
required: ["booksByIsbn", "authorsById"],
properties: {
booksByIsbn: {
type: "object",
additionalProperties: bookSchema // 값은 모두 bookSchema를 따름
},
authorsById: {
type: "object",
additionalProperties: authorSchema // 값은 모두 authorSchema를 따름
}
}
};
튜플(Tuple)로 함수 인자 스키마 통합
- 함수 인자 여러 개를 하나의 스키마로 표현하는 방법
- JSON Schema의
prefixItems를 활용하면 고정 크기 배열로 튜플 정의 가능
// searchBooksByTitle(catalogData, query) 인자 스키마
const searchBooksArgsSchema = {
type: "array",
prefixItems: [
catalogSchema, // 첫 번째 인자: catalogData
{ type: "string" } // 두 번째 인자: query
]
};
함수 인자 검증 코드 적용
declare function dev(): boolean; // 개발 환경 여부 판별
function searchBooksByTitle(catalogData: unknown, query: unknown): BookInfo[] {
if (dev()) {
const args = [catalogData, query];
if (!ajv.validate(searchBooksArgsSchema, args)) {
const errors = ajv.errorsText(ajv.errors);
throw new Error(`searchBooksByTitle called with invalid arguments: ${errors}`);
}
}
const allBooks = Object.values((catalogData as Catalog).booksByIsbn);
const matchingBooks = allBooks.filter((book) =>
book.title.includes(query as string)
);
return matchingBooks.map((book) => bookInfo(catalogData as Catalog, book));
}
TIP: 데이터 검증을 단위 테스트처럼 취급하라. 단위 테스트를 작성할 만한 함수에만 검증을 추가하면 충분하다.
12.2 반환값 검증 (Return Value Validation)
함수 인자뿐만 아니라 반환값도 스키마로 검증할 수 있다.
반환값 스키마 정의
const searchBooksResponseSchema = {
type: "array",
items: {
type: "object",
required: ["title", "isbn", "authorNames"],
properties: {
title: { type: "string" },
isbn: { type: "string" },
authorNames: {
type: "array",
items: { type: "string" }
}
}
}
};
입출력 검증이 모두 포함된 최종 구현
function searchBooksByTitle(catalogData: unknown, query: unknown): BookInfo[] {
// 인자 검증 (dev only)
if (dev()) {
if (!ajv.validate(searchBooksArgsSchema, [catalogData, query])) {
throw new Error(
`searchBooksByTitle called with invalid arguments: ${ajv.errorsText(ajv.errors)}`
);
}
}
const allBooks = Object.values((catalogData as Catalog).booksByIsbn);
const matchingBooks = allBooks.filter((book) =>
book.title.includes(query as string)
);
const bookInfos = matchingBooks.map((book) =>
bookInfo(catalogData as Catalog, book)
);
// 반환값 검증 (dev only)
if (dev()) {
if (!ajv.validate(searchBooksResponseSchema, bookInfos)) {
throw new Error(
`searchBooksByTitle returned an invalid value: ${ajv.errorsText(ajv.errors)}`
);
}
}
return bookInfos;
}
12.3 고급 데이터 검증 (Advanced Data Validation)
핵심 철학: 정적 타입을 넘어서
| 검증 방식 | 실행 시점 | 검증 가능한 조건 |
|---|---|---|
| OOP 정적 타입 | 컴파일 타임 | 타입 정보만 가능 |
| DOP JSON Schema | 런타임 | 타입 + 범위 + 패턴 + 포맷 등 모두 가능 |
런타임에는 실제 데이터가 존재하기 때문에, 단순 타입 이상의 조건을 검증할 수 있다.
숫자 범위 검증
// 출판 연도: 1900 이상, 2021 이하인 정수
const publicationYearSchema = {
type: "integer",
minimum: 1900,
maximum: 2021
};
정규식(Pattern) 검증
// UUID 패턴 검증
const uuidSchema = {
type: "string",
pattern:
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
};
// ISBN 패턴
const isbnSchema = {
type: "string",
pattern: "^[0-9-]{10,20}$"
};
// 도서관 ID 패턴
const libIdSchema = {
type: "string",
pattern: "^[a-z0-9-]{3,20}$"
};
// 저자 ID 패턴
const authorIdSchema = {
type: "string",
pattern: "[a-z-]{2,50}"
};
포맷(Format) 검증 - 사전 정의된 패턴
// 날짜 형식 검증 (YYYY-MM-DD)
const dateSchema = {
type: "string",
format: "date" // JSON Schema 내장 포맷 활용
};
참고: JSON Schema 명세상
format은 어노테이션 용도지만, AJV 등 실제 라이브러리들은 검증에도 활용한다.
정제된 카탈로그 스키마 (최종본)
const bookItemSchema = {
type: "object",
additionalProperties: {
id: uuidSchema,
libId: libIdSchema,
purchaseDate: { type: "string", format: "date" },
isLent: { type: "boolean" }
}
};
const bookSchema = {
type: "object",
required: ["title", "isbn", "authorIds", "bookItems"],
properties: {
title: { type: "string" },
publicationYear: publicationYearSchema,
isbn: isbnSchema,
publisher: { type: "string" },
authorIds: { type: "array", items: authorIdSchema },
bookItems: bookItemSchema
}
};
const authorSchema = {
type: "object",
required: ["id", "name", "bookIsbns"],
properties: {
id: { type: "string" },
name: { type: "string" },
bookIsbns: { items: isbnSchema }
}
};
12.4 데이터 모델 다이어그램 자동 생성
흐름
flowchart LR
A[JSON Schema] --> B[변환 도구\nMalli / JSON Schema Viewer]
B --> C[PlantUML 텍스트]
C --> D[데이터 모델 다이어그램 이미지]
활용 도구
| 도구 | 역할 |
|---|---|
| Malli | JSON Schema를 PlantUML 텍스트로 변환 |
| JSON Schema Viewer | JSON Schema를 시각적 다이어그램으로 렌더링 |
| PlantText | PlantUML 텍스트를 온라인에서 이미지로 렌더링 |
생성된 PlantUML 예시
@startuml
Entity1 *-- Entity2
Entity1 *-- Entity4
Entity2 *-- Entity3
class Entity1 {
+ booksByIsbn: {Entity2}
+ authorsById: {Entity4}
}
class Entity2 {
+ title: String
+ publicationYear: Number
+ isbn: String
+ authorIds: [String]
+ bookItems: [Entity3]
}
class Entity3 {
+ id: String
+ libId: String
+ purchaseDate: String
+ isLent: Boolean
}
class Entity4 {
+ id: String
+ name: String
+ bookIsbns: [String]
}
@enduml
주의사항
- JSON Schema에는 스키마에 이름을 붙이는 기능이 없어, 자동 생성 시
Entity1,Entity2등 임의 이름이 사용된다. - 숫자 범위, 정규식 등 타입을 초과하는 조건은 데이터 모델의 범위 밖이므로 다이어그램에 포함되지 않는다.
12.5 스키마 기반 단위 테스트 자동 생성
핵심 아이디어
스키마로 랜덤 데이터를 자동 생성하고, 함수에 주입한 뒤 반환값이 출력 스키마를 만족하는지 확인하면 단위 테스트가 완성된다.
flowchart TD
A[입력 스키마로\n랜덤 데이터 생성] --> B[함수 실행]
B --> C{출력이 출력\n스키마를 만족?}
C -->|Yes| D[테스트 통과]
C -->|No| E[테스트 실패]
사용 도구: JSON Schema Faker
import { JSONSchemaFaker } from "json-schema-faker";
// UUID 스키마로 랜덤 데이터 생성 예시
const randomUuid = JSONSchemaFaker.generate(uuidSchema);
// → "7aA8CdF3-14DF-9EF5-1A19-47dacdB16Fa9"
// 카탈로그 스키마로 랜덤 전체 카탈로그 생성
const randomCatalog = JSONSchemaFaker.generate(catalogSchema);
반환값 검증과의 연계
별도의 assertion 로직이 필요 없다. 함수 내부에 이미 반환값 스키마 검증 코드가 있으므로, 스키마 위반 시 자동으로 예외가 발생해 테스트가 실패한다.
완성된 스키마 기반 단위 테스트
import _ from "lodash";
function searchBooksTest(): boolean {
const catalogRandom = JSONSchemaFaker.generate(catalogSchema) as Catalog;
// 랜덤 쿼리는 매칭 결과가 없을 수 있으므로
// 실제 첫 번째 책 제목의 첫 글자를 쿼리로 사용
const firstBook = Object.values(catalogRandom.booksByIsbn)[0];
const query = firstBook.title.substring(0, 1);
try {
searchBooksByTitle(catalogRandom, query);
return true;
} catch (error) {
console.log(error); // 실패 원인 출력
return false;
}
}
searchBooksTest(); // true or false
테스트로 발견된 버그 사례
위 테스트 실행 시 다음 오류가 발생했다.
searchBooksByTitle returned a value that doesn't conform to schema:
data[0].authorNames[0] should be string,
data[0].authorNames[1] should be string
원인: 랜덤 생성된 카탈로그에서 authorIds가 참조하는 저자가 authorsById 인덱스에 존재하지 않아 undefined가 반환됨.
수정:
function authorNames(catalogData: Catalog, book: Book): string[] {
return book.authorIds.map((authorId) => {
return (
catalogData.authorsById[authorId]?.name ?? "Not available"
// ^^^^^^^^^^^^^^
// 저자가 없을 경우 기본값 반환 -> 스키마(string) 만족
);
});
}
핵심 인사이트: 스키마 기반 자동 생성 테스트는 개발자가 미처 생각하지 못한 엣지 케이스를 랜덤 데이터로 자동 탐색해 버그를 발견할 수 있다.
12.6 고급 JSON Schema 치트 시트
전체 문법 요약
const advancedCheatSheet = {
type: "array",
items: {
type: "object",
properties: {
// 기본 타입
myNumber: { type: "number" },
myString: { type: "string" },
myBool: { type: "boolean" },
myEnum: { enum: ["myVal", "yourVal"] },
// 고급: 숫자 범위
myAge: {
type: "integer",
minimum: 0,
maximum: 120
},
// 고급: 사전 정의 포맷
myBirthday: {
type: "string",
format: "date" // YYYY-MM-DD
},
// 고급: 정규식 패턴
myLetters: {
type: "string",
pattern: "[a-zA-Z]*"
},
// 고급: 동종 맵(인덱스)
myNumberMap: {
type: "object",
additionalProperties: { type: "number" }
},
// 고급: 튜플
myTuple: {
type: "array",
prefixItems: [
{ type: "string" },
{ type: "number" }
]
}
},
required: ["myNumber", "myString"],
additionalProperties: false // 명시되지 않은 필드 불허
}
};
유효 데이터 예시
[
{
"myNumber": 42,
"myString": "I-love-you",
"myEnum": "myVal",
"myBool": true,
"myTuple": ["Hello", 42]
},
{
"myNumber": 54,
"myString": "Happy",
"myAge": 42,
"myBirthday": "1978-11-23",
"myLetters": "Hello",
"myNumberMap": {
"banana": 23,
"apple": 34
}
}
]
챕터 12 요약
- 내부 데이터 검증은 개발 편의를 위한 것으로, 프로덕션에서는 반드시 비활성화한다.
- 함수 인자를 튜플 스키마로 묶어 하나의 JSON Schema로 표현한다.
- 이종 맵은 레코드 표현에, 동종 맵은 인덱스 표현에 사용하며 JSON Schema에서 다르게 정의된다.
- DOP의 데이터 검증은 런타임에 실행되므로 숫자 범위, 정규식, 포맷 등 정적 타입을 초과하는 조건을 표현할 수 있다.
- JSON Schema로 데이터 모델 다이어그램을 자동 생성할 수 있다 (Malli, JSON Schema Viewer).
- 스키마 기반 단위 테스트는 랜덤 데이터 자동 생성(JSON Schema Faker)과 반환값 검증 코드의 조합으로 완성되며, 개발자가 예상하지 못한 버그를 잡아낸다.
- 데이터 검증은 단위 테스트와 동일한 기준으로 적용한다. 모든 함수에 추가할 필요는 없다.
13. 멀티메서드로 구현하는 다형성
핵심 인사이트: OOP 없이도 다형성을 구현할 수 있다. 멀티메서드(multimethods)는 OOP 다형성보다 더 강력한 다형성을 제공한다.
13.1 The Essence of Polymorphism — 다형성의 본질
다형성이란?
- 다형성(Polymorphism): 그리스어 polús(많다) + morphe(형태) — 서로 다른 객체가 동일한 인터페이스를 각자의 방식으로 구현하는 능력
- OOP 관점: 인터페이스를 정의하고, 여러 클래스가 각기 다른 방식으로 구현
// OOP 방식 예시 (Java 스타일 → 개념 참고용)
interface IAnimal {
greet(): void;
}
class Dog implements IAnimal {
constructor(private name: string) {}
greet() { console.log(`Woof woof! My name is ${this.name}`); }
}
class Cat implements IAnimal {
constructor(private name: string) {}
greet() { console.log(`Meow! I am ${this.name}`); }
}
switch문과 OOP 다형성의 차이
- DOP에서는 동물을 맵(객체) 으로 표현하고,
switch문으로 분기할 수 있음 - 하지만
switch문 방식의 치명적 단점: 새로운 케이스를 추가하려면 기존 코드를 수정해야 함
type AnimalType = "dog" | "cat" | "cow";
interface Animal {
type: AnimalType;
name: string;
}
// switch 방식 — 확장성 없음
function greet(animal: Animal): void {
switch (animal.type) {
case "dog":
console.log(`Woof Woof! My name is: ${animal.name}`);
break;
case "cat":
console.log(`Meow! I am: ${animal.name}`);
break;
case "cow":
console.log(`Moo! Call me ${animal.name}`);
break;
}
}
핵심 인사이트
다형성의 핵심 가치는 확장성(extensibility)이다. OOP에서는 새 클래스만 추가하면 기존 코드 수정 없이 기능을 확장할 수 있다. DOP에서도 멀티메서드를 통해 동일한 확장성을 달성할 수 있다.
13.2 Multimethods with Single Dispatch — 단일 디스패치 멀티메서드
멀티메서드의 구조
멀티메서드는 두 파트로 구성된다:
| 구성요소 | 역할 |
|---|---|
| 디스패치 함수 (dispatch function) | 인터페이스 정의 + 인자 검증 + 디스패치 값 반환 |
| 메서드들 (methods) | 각 디스패치 값에 대한 구체적인 구현 |
멀티메서드 흐름도
flowchart TD
A[animal 인자] --> B[greetDispatch 함수]
B --> |"dog"| C[greetDog]
B --> |"cat"| D[greetCat]
B --> |"cow"| E[greetCow]
C --> F[Woof woof!]
D --> G[Meow!]
E --> H[Moo!]
핵심 인사이트: 멀티메서드의 인자는 디스패치 함수와 메서드 양쪽 모두에 전달된다.
TypeScript 구현 (arrows/multimethod 라이브러리 패턴)
import { multi, method } from "@arrows/multimethod";
interface Animal {
type: string;
name: string;
}
// 1. 디스패치 함수 — 시그니처 정의 + 인자 검증 + 디스패치 값 반환
function greetDispatch(animal: Animal): string {
// 런타임 검증 (개발 환경)
if (!animal?.type || !animal?.name) {
throw new Error(`greet called with invalid arguments`);
}
return animal.type; // 디스패치 값 반환
}
// 2. 멀티메서드 초기화
let greet = multi(greetDispatch);
// 3. 각 디스패치 값에 대한 메서드 구현 (별도 모듈에서 선언 가능)
function greetDog(animal: Animal): void {
console.log(`Woof woof! My name is ${animal.name}`);
}
greet = method("dog", greetDog)(greet);
function greetCat(animal: Animal): void {
console.log(`Meow! I am ${animal.name}`);
}
greet = method("cat", greetCat)(greet);
function greetCow(animal: Animal): void {
console.log(`Moo! Call me ${animal.name}`);
}
greet = method("cow", greetCow)(greet);
// 4. 호출 — 일반 함수처럼 사용
const myDog: Animal = { type: "dog", name: "Fido" };
const myCat: Animal = { type: "cat", name: "Milo" };
greet(myDog); // → "Woof woof! My name is Fido"
greet(myCat); // → "Meow! I am Milo"
기본 구현 (Default Implementation)
// 매핑된 메서드가 없을 때 실행되는 기본 구현
function greetDefault(animal: Animal): void {
console.log(`My name is ${animal.name}`);
}
greet = method(greetDefault)(greet);
const myHorse: Animal = { type: "horse", name: "Horace" };
greet(myHorse); // → "My name is Horace"
핵심 규칙 정리
- 디스패치 함수는 시그니처 정의, 인자 검증, 디스패치 값 반환 3가지를 담당
- 메서드 선언은 멀티메서드 초기화 코드와 분리된 모듈에 있어도 됨 → 이것이 확장성의 핵심
- 멀티메서드는 일반 함수처럼 호출한다
- 내부적으로는 디스패치 값을 키로, 메서드를 값으로 하는 해시맵으로 동작
13.3 Multimethods with Multiple Dispatch — 다중 디스패치 멀티메서드
개념
- 다중 디스패치: 디스패치 함수가 여러 인자의 타입을 조합한 값을 반환
- OOP에서는 불가능한 영역 — 멀티메서드만의 강점
예시 시나리오: 다국어 동물 인사
동물(dog/cat/cow) x 언어(en/fr) = 6가지 조합을 처리
flowchart TD
A["animal, language 인자"] --> B[greetLangDispatch]
B --> |"[dog, en]"| C[greetLangDogEn]
B --> |"[dog, fr]"| D[greetLangDogFr]
B --> |"[cat, en]"| E[greetLangCatEn]
B --> |"[cat, fr]"| F[greetLangCatFr]
B --> |"[cow, en]"| G[greetLangCowEn]
B --> |"[cow, fr]"| H[greetLangCowFr]
TypeScript 구현
interface Language {
type: "en" | "fr";
name: string;
}
// 디스패치 함수 — 두 인자의 타입을 배열로 반환
function greetLangDispatch(animal: Animal, language: Language): [string, string] {
if (!animal?.type || !language?.type) {
throw new Error("greetLang called with invalid arguments");
}
return [animal.type, language.type]; // 배열로 반환
}
let greetLang = multi(greetLangDispatch);
// 영어 메서드
function greetLangDogEn(animal: Animal, language: Language): void {
console.log(`Woof woof! My name is ${animal.name} and I speak ${language.name}`);
}
greetLang = method(["dog", "en"], greetLangDogEn)(greetLang);
// 프랑스어 메서드 (동물 의성어도 언어별로 다름!)
function greetLangDogFr(animal: Animal, language: Language): void {
console.log(`Ouaf Ouaf! Je m'appelle ${animal.name} et je parle ${language.name}`);
}
greetLang = method(["dog", "fr"], greetLangDogFr)(greetLang);
// ... 나머지 cat/cow 메서드 동일 패턴
const french: Language = { type: "fr", name: "Français" };
const english: Language = { type: "en", name: "English" };
greetLang(myDog, french); // → "Ouaf Ouaf! Je m'appelle Fido et je parle Français"
greetLang(myDog, english); // → "Woof woof! My name is Fido and I speak English"
언어별 동물 의성어 비교 (재미있는 사실)
| 동물 | 영어 | 프랑스어 |
|---|---|---|
| 개 | Woof Woof | Ouaf Ouaf |
| 고양이 | Meow | Miaou |
| 소 | Moo | Meuh |
핵심 규칙
다중 디스패치에서 배열 요소의 순서는 중요하지 않지만, 일관성이 있어야 한다. 디스패치 함수에서 반환하는 배열 순서와, 메서드 등록 시 사용하는 배열 순서가 반드시 일치해야 한다.
13.4 Multimethods with Dynamic Dispatch — 동적 디스패치 멀티메서드
개념
- 동적 디스패치: 디스패치 함수가 인자의 정적 타입이 아닌 런타임 값을 기반으로 디스패치 값을 반환
- 예: 문자열 길이, 숫자 범위, 불리언 조건 등
예시 시나리오: 이름 길이에 따른 인사
이름이 5글자 초과이면 이름을 말할 수 없는 동물 — 디스패치 값에 boolean을 포함
// 동적 디스패치 — 런타임 조건을 배열에 포함
function dysGreetDispatch(animal: Animal): [string, boolean] {
if (!animal?.type || !animal?.name) {
throw new Error("dysGreet called with invalid arguments");
}
const hasLongName = animal.name.length > 5; // 런타임 값 계산
return [animal.type, hasLongName];
}
let dysGreet = multi(dysGreetDispatch);
// 이름이 긴 경우 (5글자 초과)
function dysGreetDogLong(animal: Animal): void {
console.log(`Woof woof! My name is ${animal.name}`);
}
dysGreet = method(["dog", true], dysGreetDogLong)(dysGreet);
// 이름이 짧은 경우 (5글자 이하) — 이름 말 못 함
function dysGreetDogShort(animal: Animal): void {
console.log("Woof woof!");
}
dysGreet = method(["dog", false], dysGreetDogShort)(dysGreet);
// cat, cow도 동일 패턴...
const myDog: Animal = { type: "dog", name: "Fido" }; // 4글자 → false
const myCow: Animal = { type: "cow", name: "Clarabelle" }; // 9글자 → true
dysGreet(myDog); // → "Woof woof!"
dysGreet(myCow); // → "Moo! Call me Clarabelle"
세 가지 디스패치 방식 비교
| 방식 | 디스패치 기준 | 반환 타입 예시 |
|---|---|---|
| 단일 디스패치 | 단일 인자의 타입 필드 | "dog" |
| 다중 디스패치 | 여러 인자의 타입 조합 | ["dog", "en"] |
| 동적 디스패치 | 런타임 값 (계산된 조건) | ["dog", true] |
13.5 Integrating Multimethods in a Production System — 실무 적용
실제 요구사항
저자 이름을 HTML/Markdown 형식으로 표시하되, 저서 수에 따라 서식(bold/italic)이 달라짐
| 저서 수 | 기울임 | 굵게 |
|---|---|---|
| 10권 이하 | O | X |
| 11 ~ 50권 | X | O |
| 51권 이상 | O | O |
TypeScript 구현
interface Author {
name: string;
bookIsbns: string[];
}
type TextFormat = "markdown" | "html";
type ProlificityLevel = "low" | "medium" | "high";
// 헬퍼 함수 — 저자의 다작 수준 계산
function prolificityLevel(author: Author): ProlificityLevel {
const books = author.bookIsbns.length;
if (books <= 10) return "low";
if (books >= 51) return "high";
return "medium";
}
// 디스패치 함수 — 다작 수준 + 포맷 형식의 조합
function authorNameDispatch(
author: Author,
format: TextFormat
): [ProlificityLevel, TextFormat] {
if (!author?.name || !author?.bookIsbns) {
throw new Error("Author.myName called with invalid arguments");
}
return [prolificityLevel(author), format];
}
let authorMyName = multi(authorNameDispatch);
// HTML 메서드
function authorNameLowHtml(author: Author): string {
return `<i>${author.name}</i>`;
}
authorMyName = method(["low", "html"], authorNameLowHtml)(authorMyName);
function authorNameMediumHtml(author: Author): string {
return `<b>${author.name}</b>`;
}
authorMyName = method(["medium", "html"], authorNameMediumHtml)(authorMyName);
function authorNameHighHtml(author: Author): string {
return `<b><i>${author.name}</i></b>`;
}
authorMyName = method(["high", "html"], authorNameHighHtml)(authorMyName);
// Markdown 메서드
function authorNameLowMarkdown(author: Author): string {
return `*${author.name}*`;
}
authorMyName = method(["low", "markdown"], authorNameLowMarkdown)(authorMyName);
function authorNameMediumMarkdown(author: Author): string {
return `**${author.name}**`;
}
authorMyName = method(["medium", "markdown"], authorNameMediumMarkdown)(authorMyName);
function authorNameHighMarkdown(author: Author): string {
return `***${author.name}***`;
}
authorMyName = method(["high", "markdown"], authorNameHighMarkdown)(authorMyName);
// 테스트
const yehonathan: Author = {
name: "Yehonathan Sharvit",
bookIsbns: ["9781617298578"]
};
authorMyName(yehonathan, "html"); // → "<i>Yehonathan Sharvit</i>"
authorMyName(yehonathan, "markdown"); // → "*Yehonathan Sharvit*"
Markdown 서식 규칙 정리
| 서식 | HTML 태그 | Markdown 문법 |
|---|---|---|
| 기울임 | <i>텍스트</i> |
*텍스트* |
| 굵게 | <b>텍스트</b> |
**텍스트** |
| 굵게 + 기울임 | <b><i>텍스트</i></b> |
***텍스트*** |
언어별 멀티메서드 라이브러리 지원 현황
| 언어 | 라이브러리 | 제네릭 데이터 구조 지원 |
|---|---|---|
| JavaScript/TypeScript | @arrows/multimethod |
O |
| Python | multimethods |
O |
| Ruby | ruby-multimethods |
O |
| Java | Java Multimethod Framework | X |
| C# | 네이티브 (dynamic 키워드) |
X |
Java와 C#은 정적 타입에만 작동하며, 제네릭 데이터 구조(맵 등)에는 적용 불가.
챕터 13 요약
다형성
- 다형성의 핵심 가치는 확장성이다
- 멀티메서드는 데이터가 제네릭 맵으로 표현될 때도 다형성을 활용할 수 있게 한다
멀티메서드 구조
- 멀티메서드 = 디스패치 함수 + 여러 메서드
- 디스패치 함수의 3가지 책임: 시그니처 정의, 인자 검증, 디스패치 값 반환
- 멀티메서드 초기화와 메서드 구현은 분리 가능 → 확장성의 핵심 원천
- 멀티메서드는 일반 함수처럼 호출
- 대응하는 메서드가 없을 때를 위한 기본 구현 등록 가능
세 가지 디스패치 종류
- 단일 디스패치(Single dispatch): OOP 상속 모방, 단일 인자의
type필드 기반 - 다중 디스패치(Multiple dispatch): 여러 인자 타입 조합 기반 — OOP로는 불가
- 동적 디스패치(Dynamic dispatch): 런타임 인자 값 기반 (숫자, 불리언 등) — OOP로는 불가
주의사항
- 다중/동적 디스패치에서 배열 요소의 순서는 디스패치 함수와 메서드 등록 간에 일관성 유지 필수
- 인자는 디스패치 함수와 메서드 양쪽 모두에 전달됨
14. 고급 데이터 조작
“Whatever is well-conceived is clearly said”
비즈니스 로직이 복잡한 데이터 처리를 요구할 때, 언어 런타임이나 서드파티 라이브러리가 제공하는 범용 함수만으로는 부족할 수 있다. 이럴 때 직접 범용 데이터 조작 함수를 작성하고, 그 위에 비즈니스 로직을 구현하는 것이 핵심 전략이다.
이 챕터가 다루는 내용
- 중첩된 데이터 조작
- 비즈니스 로직을 명확하고 간결하게 작성하는 법
- 비즈니스 로직과 범용 데이터 조작의 분리
- 커스텀 데이터 조작 도구 구축
- 상황에 맞는 최적의 도구 선택
14.1 Updating a value in a map with eloquence
문제: 맵 내부의 값을 기반으로 갱신하기
배열 필드 내 중복 제거와 같이, 현재 값을 기반으로 새 값을 계산하여 맵을 갱신해야 하는 상황은 매우 흔하다.
나쁜 예 (tedious): _.get, _.uniq, _.set을 순차적으로 나열
import _ from "lodash";
function removeAuthorDuplicates(book: object): object {
const authors = _.get(book, "authors");
const uniqAuthors = _.uniq(authors);
return _.set(book, "authors", uniqAuthors);
}
- 코드의 의도가 잘 드러나지 않음
- 단계가 많아 읽기 불편함
해법: update 함수 설계
TIP: 커스텀 데이터 조작 함수의 시그니처를 찾는 가장 좋은 방법은, 그 함수를 사용하는 가장 편리한 방식을 먼저 상상하는 것이다. (Put the cart before the horse)
먼저 사용 방식을 작성하고, 그로부터 시그니처를 역으로 도출한다.
// update가 이미 구현되어 있다고 가정하고 먼저 사용 방식을 작성
function removeAuthorDuplicates(book: object): object {
return update(book, "authors", _.uniq);
}
훨씬 간결하고 의도가 명확하다.
update 함수 동작 정의 (영어로 먼저 기술)
update는map,path,fun을 받는다.path에 위치한 현재 값currentValue에 대해fun(currentValue)를 계산하고, 해당 경로의 값이 갱신된 새로운 맵을 반환한다.
flowchart LR
A["map\n{ position: manager, income: 100000 }"]
B["fun\nx => x * 2"]
C["path\nincome"]
D["result\n{ position: manager, income: 200000 }"]
A --> update
B --> update
C --> update
update --> D
TIP: 커스텀 데이터 조작 함수를 구현하기 전에, 그 함수가 무엇을 하는지 평이한 영어로 먼저 기술하라.
update 함수 구현
function update<T extends object>(
map: T,
path: string,
fun: (currentValue: unknown) => unknown
): T {
const currentValue = _.get(map, path);
const nextValue = fun(currentValue);
return _.set(_.cloneDeep(map), path, nextValue);
}
사용 예시
const m = {
position: "manager",
income: 100000,
};
const result = update(m, "income", (x) => (x as number) * 2);
// { position: "manager", income: 200000 }
핵심 인사이트
| 접근 방식 | 코드 | 가독성 |
|---|---|---|
| get + uniq + set | 3단계 명시 | 낮음 |
| update + uniq | 1단계 추상화 | 높음 |
update는 특정 비즈니스 로직(중복 제거)을 모르는 범용 함수여야 한다.- 실제 변환 로직(
_.uniq,x => x * 2등)은 인자로 주입한다.
14.2 Manipulating nested data
문제: 중첩 배열을 평탄화하여 가져오기
책 목록에서 모든 저자 ID를 추출하는 함수가 필요하다. 각 책은 authorIds 배열 필드를 갖는다.
문제가 있는 시도
function authorIdsInBooks(books: object[]): string[][] {
return _.map(books, "authorIds");
// 결과: [["sean-covey", "stephen-covey"], ["alan-moore", "dave-gibbons"]]
// 원하는 결과: ["sean-covey", "stephen-covey", "alan-moore", "dave-gibbons"]
}
_.map 만으로는 배열의 배열이 반환된다.
해법: map 후 flatten
function authorIdsInBooks(books: object[]): string[] {
return _.flatten(_.map(books, "authorIds"));
}
flatMap 유틸 함수 추출
map + flatten 조합은 매우 빈번한 패턴이다. 이름을 부여해 추상화한다.
function flatMap<T, R>(
coll: T[],
f: ((item: T) => R[]) | string
): R[] {
return _.flatten(_.map(coll, f as (item: T) => R[]));
}
// flatMap을 사용한 최종 구현
function authorIdsInBooks(books: object[]): string[] {
return flatMap(books, "authorIds");
}
코드 크기보다 중요한 것: 의도의 명확성
Theo가 Dave에게 질문했다: “Could you pass me that thing on your desk that’s used for writing?” 몇 초 후에야 Dave는 펜임을 알아챘다. “Why didn’t you simply ask for the pen?”
- 이름 없는 서술은 읽는 데 시간이 걸린다.
flatMap이라는 이름 하나가 코드 독자에게 즉각적인 의미를 전달한다.- 코드 크기가 작아서 좋은 것이 아니라, 명명(naming)이 의도를 명확히 전달하기 때문에 좋다.
14.3 Using the best tool for the job
문제: forEach로 대출 비율 계산
interface BookItem {
id: string;
libId: string;
isLent: boolean;
}
interface Book {
isbn: string;
title: string;
bookItems: BookItem[];
}
나쁜 예 (forEach 사용)
function lendingRatio(books: Book[]): number {
const bookItems = flatMap(books, "bookItems") as BookItem[];
let lent = 0;
let notLent = 0;
_.forEach(bookItems, (item) => {
if (_.get(item, "isLent")) {
lent = lent + 1;
} else {
notLent = notLent + 1;
}
});
return lent / (lent + notLent);
}
forEach가 나쁜 이유: 지나치게 범용적이다
Theo: “나는 어떤 forEach도 있는 코드를 좋아하지 않는다.” 유틸 함수를 만들 때는 범용성이 미덕이지만, 유틸 함수를 사용할 때는 문제를 해결하는 가장 구체적인 함수를 써야 한다.
마치 나사를 조일 때 스위스 아미 나이프 대신 드라이버를 써야 하는 것과 같다. 드라이버를 쓰면 보는 사람도 즉시 “나사를 조이는 중”임을 안다.
TIP: 문제를 해결하는 가장 덜 범용적인 유틸 함수를 골라라.
reduce로 개선
변수 2개(lent, notLent) 대신 맵({ lent: 0, notLent: 0 })으로 상태를 표현하고 _.reduce를 사용한다.
function lendingRatio(books: Book[]): number {
const bookItems = flatMap(books, "bookItems") as BookItem[];
const stats = _.reduce(
bookItems,
(res, item) => {
if (_.get(item, "isLent")) {
res.lent = res.lent + 1;
} else {
res.notLent = res.notLent + 1;
}
return res;
},
{ lent: 0, notLent: 0 }
);
return stats.lent / (stats.lent + stats.notLent);
}
reduce도 비즈니스 로직 코드 안에서는 숨겨야 한다
TIP: 비즈니스 로직 코드 안에서
_.reduce나 다른 저수준 데이터 조작 함수를 직접 사용하지 마라. 대신, 적절한 이름을 가진 유틸 함수 뒤에_.reduce를 숨겨라.
reduce 호출이 하는 일을 한 마디로 정의하면: 불리언 필드 값이 true/false인 횟수를 센다. 이를 countByBoolField로 추출한다.
countByBoolField 설계
먼저 사용 방식을 작성한다 (cart before horse)
function lendingRatio(books: Book[]): number {
const bookItems = flatMap(books, "bookItems") as BookItem[];
const stats = countByBoolField(bookItems, "isLent", "lent", "notLent");
return stats.lent / (stats.lent + stats.notLent);
}
단위 테스트 먼저 작성
const input = [
{ a: true },
{ a: false },
{ a: true },
{ a: true },
];
const expectedRes = { aTrue: 3, aFalse: 1 };
_.isEqual(countByBoolField(input, "a", "aTrue", "aFalse"), expectedRes);
// true
구현
function inc(n: number): number {
return n + 1;
}
function countByBoolField(
coll: object[],
field: string,
keyTrue: string,
keyFalse: string
): Record<string, number> {
return _.reduce(
coll,
(res, item) => {
if (_.get(item, field)) {
return update(res, keyTrue, inc as (v: unknown) => unknown);
}
return update(res, keyFalse, inc as (v: unknown) => unknown);
},
{ [keyTrue]: 0, [keyFalse]: 0 }
);
}
update+inc조합으로 불변성을 유지하면서 카운터를 증가시킨다.- 비즈니스 로직 코드(
lendingRatio)에서reduce가 완전히 사라진다.
14.4 Unwinding at ease
문제: 도서관별 도서 그룹핑
각 책(Book)은 여러 개의 bookItems를 가지고, 각 bookItem은 어느 도서관(libId)에 있는지를 나타낸다. 목표: libId를 키로 하여, 해당 도서관에 있는 (책 정보 + bookItem) 목록을 그룹핑한다.
입력 데이터 예시
const books: Book[] = [
{
isbn: "978-1779501127",
title: "Watchmen",
bookItems: [
{ id: "book-item-1", libId: "nyc-central-lib", isLent: true },
],
},
{
isbn: "978-1982137274",
title: "7 Habits of Highly Effective People",
bookItems: [
{ id: "book-item-123", libId: "hudson-park-lib", isLent: true },
{ id: "book-item-17", libId: "nyc-central-lib", isLent: false },
],
},
];
기대 결과
{
"hudson-park-lib": [
{
isbn: "978-1982137274",
title: "7 Habits of Highly Effective People",
bookItems: { id: "book-item-123", isLent: true, libId: "hudson-park-lib" },
},
],
"nyc-central-lib": [
{
isbn: "978-1779501127",
title: "Watchmen",
bookItems: { id: "book-item-1", isLent: true, libId: "nyc-central-lib" },
},
{
isbn: "978-1982137274",
title: "7 Habits of Highly Effective People",
bookItems: { id: "book-item-17", isLent: false, libId: "nyc-central-lib" },
},
],
}
어려운 이유
bookItem에는 isbn, title 정보가 없다. 그룹핑을 하려면 각 bookItem을 책 정보와 결합해야 한다. MongoDB의 $unwind 연산자가 이 역할을 한다.
unwind 함수 개념
flowchart LR
A["map\ncustomer-id: joe\nitems: [ phone, pencil ]"]
B["path\nitems"]
C["result 0\ncustomer-id: joe\nitems: phone"]
D["result 1\ncustomer-id: joe\nitems: pencil"]
A --> unwind
B --> unwind
unwind --> C
unwind --> D
- 배열 필드(
items)를 가진 맵 하나를 받아 - 배열의 각 원소마다 원래 맵의 나머지 필드는 유지한 채, 해당 필드를 원소 하나로 교체한 맵을 생성
- 결과: 원소 개수만큼의 맵 배열
TIP: 복잡한 함수를 구현하기 전에, 반드시 단위 테스트를 먼저 작성하라.
unwind 단위 테스트
const customer = {
"customer-id": "joe",
items: [
{ item: "phone", quantity: 1 },
{ item: "pencil", quantity: 10 },
],
};
const expectedRes = [
{ "customer-id": "joe", items: { item: "phone", quantity: 1 } },
{ "customer-id": "joe", items: { item: "pencil", quantity: 10 } },
];
_.isEqual(unwind(customer, "items"), expectedRes); // true
unwind 구현
function unwind<T extends object>(map: T, field: string): T[] {
const arr = _.get(map, field) as unknown[];
return _.map(arr, (elem) => _.set(_.cloneDeep(map), field, elem));
}
- 데이터가 불변이므로 각 원소마다
_.cloneDeep으로 맵을 복제한 뒤_.set으로 해당 필드만 교체한다. - 놀랍도록 단순한 구현이지만, 이 함수 위에 복잡한 비즈니스 로직을 깔끔하게 올릴 수 있다.
booksByRack 최종 구현
먼저 사용 방식을 작성한다 (cart before horse)
function booksByRack(books: Book[]): Record<string, object[]> {
const bookItems = flatMap(books, (book: Book) => unwind(book, "bookItems"));
return _.groupBy(bookItems, "bookItems.libId");
}
flatMap+unwind: 각 책을 bookItem 단위로 분해하면서 책 정보(isbn, title)를 보존_.groupBy:bookItems.libId기준으로 그룹핑- 복잡해 보이는 요구사항이 단 2줄로 구현된다.
- 복잡성은
unwind라는 범용 유틸 함수 안에 격리되어 있다.
챕터 14 요약
핵심 원칙 정리
| 원칙 | 설명 |
|---|---|
| 관심사 분리 | 비즈니스 로직 코드와 데이터 조작 구현을 명확히 분리한다 |
| Cart before horse | 함수를 구현하기 전에 사용 방식을 먼저 작성하여 시그니처를 도출한다 |
| 영어로 먼저 기술 | 구현 전에 함수의 동작을 평이한 언어로 정의한다 |
| 단위 테스트 먼저 | 복잡한 함수는 구현 전에 단위 테스트를 먼저 작성한다 |
| 최소 범용성 원칙 | 문제를 해결하는 가장 덜 범용적인 유틸 함수를 선택한다 |
| reduce 숨기기 | 비즈니스 로직 코드에 reduce를 직접 노출하지 말고 의미 있는 이름의 함수로 감싼다 |
커스텀 데이터 조작 함수 설계 4단계 프로세스
- 시그니처 발견: 구현 전에 사용 방식을 먼저 작성한다 (cart before horse)
- 단위 테스트 작성: 기대 입력과 출력을 명확히 정의한다
- 동작 기술: 함수가 하는 일을 평이한 언어로 정확히 서술한다
- 구현: 위 세 단계를 바탕으로 구현한다
이 챕터에서 직접 구현한 유틸 함수들
| 함수 | 역할 |
|---|---|
update(map, path, fun) |
맵의 특정 경로 값을 함수로 변환하여 새 맵 반환 |
flatMap(coll, f) |
map 후 flatten, 중첩 배열 평탄화 |
countByBoolField(coll, field, keyTrue, keyFalse) |
불리언 필드의 true/false 횟수를 세어 반환 |
unwind(map, field) |
배열 필드를 원소별로 분해하여 맵 배열로 변환 |
Lodash 함수 참고표
| 함수 | 설명 |
|---|---|
_.flatten(arr) |
배열을 1단계 깊이로 평탄화 |
_.uniq(arr) |
중복 없는 배열 반환 |
_.sum(arr) |
배열 내 값의 합 계산 |
_.every(coll, pred) |
모든 원소가 pred를 만족하는지 확인 |
_.forEach(coll, f) |
각 원소에 f를 실행 (반환값 없음) |
_.sortBy(coll, f) |
f 기준으로 오름차순 정렬된 배열 반환 |
15. 디버깅
핵심 인사이트: DOP에서는 버그가 발생한 시나리오의 컨텍스트를 캡처하고, 이를 REPL이나 유닛 테스트에서 재현(replay) 할 수 있다. 이것이 전통적인 디버거 방식과 DOP 디버깅의 근본적인 차이다.
15.1 Determinism in programming
핵심 개념: 결정론(Determinism)
- 결정론의 정의 (프로그래밍 맥락): 동일한 원인은 항상 동일한 결과를 낳는다
- 원인 = 함수 인자(arguments)
- 결과 = 반환값(return value)
- 사이드 이펙트(side effect)와 프로그램 상태(state)가 개입하면 결정론이 무너진다
DOP에서 결정론이 성립하는 이유
- DOP는 불변 데이터(immutable data)를 다루는 모듈과 상태를 다루는 모듈(
SystemState)을 명확히 분리한다 - 불변 데이터를 다루는 모듈 내에서는 함수 동작이 결정론적이다
- 같은 인자 → 항상 같은 반환값
TIP: 불변 데이터를 다루는 모듈에서 함수 동작은 결정론적이다. 동일한 인자는 항상 동일한 반환값으로 이어진다.
함수 런타임 컨텍스트 (Function Run-time Context)
- 정의: 함수가 호출될 때 인자에 전달된 값들의 집합
- 일반적으로는 함수 인자 + 프로그램 상태 모두 포함
- DOP에서는: 불변 데이터를 다루므로 함수 컨텍스트 = 함수 인자의 값들만으로 구성
TIP: DOP에서 함수 컨텍스트는 함수 인자의 값들로만 구성된다.
재현 가능성(Reproducibility)의 두 가지 조건
| 조건 | 설명 |
|---|---|
| 불변성(Immutability) | 데이터가 변경되지 않으므로, 동일한 인자로 함수를 다시 호출하면 동일하게 동작한다 |
| 직렬화 용이성(Ease of serialization) | 제네릭 자료구조(generic data structure)는 JSON으로 쉽게 직렬화/역직렬화할 수 있다 |
TIP: 재현 가능성의 두 가지 조건은 불변성과 직렬화 용이성이다.
REPL이란?
- Read Eval Print Loop의 약자
- 코드 조각을 입력받아 실행하고 결과를 출력하는 프로그래밍 환경
- 디버깅 중 “과학 실험실”처럼 활용: 운영 중인 프로세스와 완전히 분리된 환경에서 실험 가능
| 언어 | REPL |
|---|---|
| Node.js | Node CLI (node) |
| Browser JS | 브라우저 콘솔 |
| Java | JShell |
| Python | Python 인터프리터 |
15.2 Reproducibility with numbers and strings
컨텍스트 캡처(Context Capturing) 코드
- 정의: 함수 본문 시작 부분에 인자의 값을 출력하는 코드를 삽입하는 기법
- 디버거 없이도 함수가 어떤 인자로 호출되었는지 파악 가능
숫자(Number) 타입의 컨텍스트 캡처
function nthDigit(a: number, n: number): number {
// 컨텍스트 캡처 코드
console.log(a);
console.log(n);
return Math.floor(a / Math.pow(10, n - 1)) % 10;
}
- 숫자는
console.log로 출력해도 그대로 복사/붙여넣기 가능 → 재현 용이
문자열(String) 타입의 함정
// 잘못된 방법 - 따옴표와 이스케이프 문자가 사라진다
function hasWordStartingWith(sentence: string, prefix: string): boolean {
console.log(sentence); // 출력: I like the word "reproducibility"
console.log(prefix); // 출력: li
// ...
}
console.log로 문자열을 출력하면 따옴표와 이스케이프 문자가 제거된다- 이렇게 출력된 값을 복사하면 REPL에서 동일하게 재현할 수 없다
JSON 직렬화로 문제 해결
function hasWordStartingWith(sentence: string, prefix: string): boolean {
// JSON.stringify로 직렬화하면 따옴표와 이스케이프가 보존된다
console.log(JSON.stringify(sentence));
console.log(JSON.stringify(prefix));
const words = sentence.split(" ");
return words.find(word => word.startsWith(prefix)) !== undefined;
}
JSON.stringify를 사용하면 문자열이 정확하게 직렬화된다- 출력:
"I like the word \"reproducibility\""→ 그대로 복사하여 재현 가능 - JSON은 객체/배열만이 아니라 모든 기본 타입(숫자, 문자열, 불리언 등)도 유효한 JSON 데이터다
TIP: 제네릭 자료구조를 복사/붙여넣기하려면 직렬화와 역직렬화를 사용한다.
15.3 Reproducibility with any data
DOP에서 모든 데이터의 재현 가능성
- DOP의 본질: 데이터를 일급 시민(first-class citizen) 으로 취급
- 데이터가 제네릭 자료구조로 표현되므로, 숫자/문자열과 동일한 방식으로 중첩된 맵(nested map)도 재현 가능
중첩 데이터의 컨텍스트 캡처
interface Book {
isbn: string;
title: string;
authorIds: string[];
publicationYear?: number;
}
interface CatalogData {
booksByIsbn: Record<string, Book>;
authorsById: Record<string, { name: string; bookIsbns: string[] }>;
}
const Catalog = {
searchBooksByTitle(catalogData: CatalogData, query: string): BookInfo[] {
// 컨텍스트 캡처: 모든 인자를 JSON으로 직렬화하여 출력
console.log(JSON.stringify(catalogData));
console.log(JSON.stringify(query));
const allBooks = Object.values(catalogData.booksByIsbn);
const queryLowerCased = query.toLowerCase();
const matchingBooks = allBooks.filter(book =>
book.title.toLowerCase().startsWith(queryLowerCased)
);
return matchingBooks.map(book => Catalog.bookInfo(catalogData, book));
}
};
REPL에서 함수 재현하기
엔드포인트를 직접 트리거하지 않고, 콘솔 출력을 복사하여 REPL에서 재현:
// 1. 콘솔에서 복사한 catalogData를 그대로 붙여넣는다
const catalogData: CatalogData = {
"booksByIsbn": {
"978-1982137274": {
"isbn": "978-1982137274",
"title": "7 Habits of Highly Effective People",
"authorIds": ["sean-covey", "stephen-covey"]
},
"978-1779501127": {
"isbn": "978-1779501127",
"title": "Watchmen",
"publicationYear": 1987,
"authorIds": ["alan-moore", "dave-gibbons"]
}
},
"authorsById": { /* ... */ }
};
const query = "Watch";
// 2. 함수를 동일한 컨텍스트로 재현
Catalog.searchBooksByTitle(catalogData, query);
TIP: 재현 가능성은 시나리오를 깨끗한(pristine) 환경에서 재현할 수 있게 해준다.
핵심 워크플로우: 짧은 피드백 루프
flowchart LR
A[엔드포인트 트리거\n컨텍스트 캡처] --> B[REPL에 컨텍스트 붙여넣기]
B --> C[코드 수정]
C --> D{REPL에서\n재실행}
D -- 실패 --> C
D -- 성공 --> E[최종 엔드투엔드 테스트]
- 중요: 코드 개선 반복 과정에서는 엔드포인트를 직접 트리거하지 말 것
- REPL 환경이 훨씬 빠른 피드백을 제공한다
- 개선이 완료된 후 최종적으로 엔드투엔드 테스트를 진행한다
15.4 Unit tests
재현 가능성을 유닛 테스트에 활용하는 방법
- 전통적 문제: 유닛 테스트용 입력 데이터를 수동으로 구성하는 것은 번거롭다 (중첩된 필드 등)
- DOP 해결책: 실제 시스템을 원하는 조건으로 실행시킨 후, 함수 내에서 데이터를 캡처하여 유닛 테스트에 활용
방법 1: 인라인 데이터 (콘솔 → 클립보드 → 코드)
// 엔드포인트 실행 후 콘솔에서 복사한 데이터를 그대로 유닛 테스트에 붙여넣기
const catalogData: CatalogData = {
"booksByIsbn": { /* 복사된 데이터 */ },
"authorsById": { /* 복사된 데이터 */ }
};
const query = "Habit";
const result = Catalog.searchBooksByTitle(catalogData, query);
const expectedResult = [{
authorNames: ["Sean Covey", "Stephen Covey"],
isbn: "978-1982137274",
title: "7 Habits of Highly Effective People",
}];
console.assert(JSON.stringify(result) === JSON.stringify(expectedResult));
방법 2: 파일에 캡처 데이터 저장
파일 경로 생성
import { v4 as generateUUID } from 'uuid';
import * as fs from 'fs';
const capturedDataFolder = "test-data";
function dataFilePath(context: string): string {
const uuid = generateUUID();
return `${capturedDataFolder}/${context}-${uuid}.json`;
}
- 파일명 규칙:
{컨텍스트명}-{UUID}.json - UUID 사용 이유: 같은 함수에서 여러 번 캡처해도 파일이 덮어쓰여지지 않음
데이터 덤프 함수
function dumpData(data: unknown, context: string): void {
const path = dataFilePath(context);
// JSON.stringify 두 번째 인자: null (replacer 없음)
// 세 번째 인자: 들여쓰기 공백 수
const content = JSON.stringify(data, null, 2);
// 비동기로 작성 - 실제 작업을 방해하지 않기 위해
fs.writeFile(path, content, () => {
console.log(`Data for ${context} stored in: ${path}`);
});
}
- 비동기(async) 쓰기를 사용하는 이유: 실제 함수 실행을 느리게 만들지 않기 위해
- DOP에서 비동기 쓰기가 안전한 이유: 데이터가 불변(immutable)이므로, 파일에 쓰는 도중에 함수가 데이터를 변경할 수 없다
함수에 dumpData 적용
const Catalog = {
searchBooksByTitle(catalogData: CatalogData, query: string): BookInfo[] {
// 모든 인자를 배열로 묶어 한 파일에 저장
dumpData([catalogData, query], 'searchBooksByTitle');
const allBooks = Object.values(catalogData.booksByIsbn);
return allBooks
.filter(book => hasWordStartingWith(book.title, query))
.map(book => Catalog.bookInfo(catalogData, book));
}
};
데이터 읽기 함수
function readData<T>(path: string): T {
// 유닛 테스트에서는 동기(sync) 읽기 사용
// 이유: 데이터를 읽기 전에 테스트를 실행할 수 없기 때문
return JSON.parse(fs.readFileSync(path, 'utf-8')) as T;
}
파일 기반 유닛 테스트
const data = readData<[CatalogData, string]>(
"test-data/searchBooksByTitle-68e57c85-2213-471a-8442-c4516e83d786.json"
);
const [catalogData, query] = data;
const result = Catalog.searchBooksByTitle(catalogData, query);
const expectedResult = [/* ... */];
데이터 저장 방식 비교
| 방식 | 장점 | 단점 | 적합한 경우 |
|---|---|---|---|
| 인라인 데이터 | 데이터를 코드에서 바로 확인 가능 | 데이터가 많으면 코드가 지저분해짐 | 데이터가 단순할 때 |
| 파일 저장 | 코드 가독성 유지, 대용량 데이터 처리 용이 | 파일 경로 관리 필요 | 데이터가 클 때 (예: 카탈로그 전체) |
개선된 검색 구현 및 정규식 활용
function hasWordStartingWith(sentence: string, prefix: string): boolean {
const sentenceLowerCase = sentence.toLowerCase();
const prefixLowerCase = prefix.toLowerCase();
// \b: 단어 경계(word boundary) 메타문자
// RegExp 생성자에 전달 시 \\b로 이스케이프 필요
const prefixRegExp = new RegExp("\\b" + prefixLowerCase);
return sentenceLowerCase.match(prefixRegExp) !== null;
}
\b(단어 경계) 정규식으로 접두사 매칭을 구현\bHabit은 “7 Habits of Highly Effective People”에 매칭됨\babit은 매칭되지 않음
다중 케이스 유닛 테스트
// 매칭되어야 하는 쿼리들
const matchingQueries = ["Habit", "habit", "7 Habit", "habits of"];
const expectedResult = [{ title: "7 Habits of Highly Effective People", /* ... */ }];
const allMatch = matchingQueries.every(query => {
const result = Catalog.searchBooksByTitle(catalogData, query);
return JSON.stringify(result) === JSON.stringify(expectedResult);
});
// → true
// 매칭되지 않아야 하는 쿼리들
const nonMatchingQueries = ["abit", "bit", "7 abit", "habit of"];
const emptyExpected: BookInfo[] = [];
const noneMatch = nonMatchingQueries.every(query => {
const result = Catalog.searchBooksByTitle(catalogData, query);
return JSON.stringify(result) === JSON.stringify(emptyExpected);
});
// → true
15.5 Dealing with external data sources
외부 데이터 소스와 재현 가능성의 한계
- 문제: 함수가 데이터베이스나 외부 서비스에서 데이터를 가져오는 경우, 동일한 함수 컨텍스트라도 외부 데이터가 바뀌면 다른 결과가 나올 수 있다
- 외부 데이터 소스는 함수 컨텍스트의 숨겨진 변수처럼 작동한다
불변 데이터베이스(Immutable Database)
- 불변 데이터베이스의 동작 원리:
- 레코드를 업데이트하는 대신 새 버전(새 타임스탬프) 을 생성
- 쿼리는 데이터베이스 자체가 아닌 스냅샷(snapshot) 에 대해 실행됨
- 동일한 파라미터의 쿼리는 항상 동일한 결과를 반환 (스냅샷은 변하지 않으므로)
- 이런 방식의 DB를 기능적 데이터베이스(functional database) 또는 추가 전용 데이터베이스(append-only database) 라고도 한다
- 예시: Datomic (일부 디지털 뱅킹 서비스에서 사용)
실무에서의 현실적 접근
- 대부분의 일반 데이터베이스는 불변성을 보장하지 않는다
- 하지만 로컬 개발 환경에서 디버깅할 때는 실질적으로 데이터가 바뀌지 않는다
- 로컬 환경에서는 본인만 시스템과 상호작용하기 때문
- 따라서 실무에서는 DOP의 재현 가능성 접근 방식이 외부 데이터 소스가 있는 경우에도 충분히 유효하다
챕터 15 요약
핵심 원칙 정리
- 컨텍스트 캡처(Context Capturing): 함수가 호출된 컨텍스트를 캡처하고 REPL 또는 유닛 테스트에서 재현하는 기법
- DOP에서 함수 컨텍스트 = 데이터만으로 구성
- 컨텍스트 저장 위치: 클립보드, 콘솔, 파일
DOP 디버깅이 강력한 이유
flowchart TD
A["DOP 핵심 특성"] --> B["불변 데이터\n(Immutable Data)"]
A --> C["제네릭 자료구조\n(Generic Data Structure)"]
B --> D["함수 동작이 결정론적\n(Deterministic)"]
C --> E["쉬운 직렬화/역직렬화\n(Easy Serialization)"]
D --> F["재현 가능성\n(Reproducibility)"]
E --> F
F --> G["REPL에서 재현"]
F --> H["유닛 테스트에서 재현"]
G --> I["짧은 피드백 루프\n빠른 버그 수정"]
H --> I
재현 가능성의 두 조건 (재확인)
- 불변성(Immutability): 데이터가 변경되지 않으므로 동일한 인자로 함수를 호출하면 동일하게 동작함이 보장된다
- 직렬화 용이성(Ease of serialization): 제네릭 자료구조는 JSON으로 쉽게 직렬화/역직렬화할 수 있다
전통적 디버거 vs DOP 디버깅
| 항목 | 전통적 디버거 | DOP 디버깅 |
|---|---|---|
| 버그 재현 | 동일한 실행 조건 재현 어려움 | 컨텍스트 캡처로 정확히 재현 가능 |
| 피드백 속도 | 엔드포인트 재트리거 필요 | REPL에서 즉시 실험 가능 |
| 테스트 연계 | 별도 작업 필요 | 캡처된 컨텍스트를 유닛 테스트에 바로 활용 |
| 데이터 의존성 | 프로그램 상태 포함 | 함수 인자만으로 충분 (불변성 덕분) |
실용적 체크리스트
- 컨텍스트 캡처 시
JSON.stringify(data, null, 2)사용 (들여쓰기 포함) - 파일 저장 시 UUID로 파일명 충돌 방지
- 파일 쓰기는 비동기(
fs.writeFile), 파일 읽기는 동기(fs.readFileSync) - 코드 개선 반복 중에는 엔드포인트 재트리거 금지 → REPL로만 검증
- 최종 완료 후 반드시 엔드투엔드(end-to-end) 테스트 수행
부록 완전 정리 노트
부록 A. DOP의 4가지 핵심 원칙
DOP란 무엇인가
- Data-Oriented Programming(DOP) 은 정보가 중심이 되는 소프트웨어 시스템(프론트엔드, 백엔드, 웹서비스 등)의 설계와 구현을 단순화하기 위한 프로그래밍 패러다임
- 코드와 데이터를 결합하는 객체 중심 설계 대신, 데이터를 일급 시민(first-class citizen) 으로 취급
- DOP 원칙은 언어에 종속되지 않음 — OOP(Java, C#), FP(Clojure, Haskell), 혼합형(TypeScript, Python, Ruby) 모두에서 적용 또는 위반 가능
핵심 인사이트: DOP 시스템에서 코드는 데이터와 분리된다. 데이터는 불변(immutable)이며 별도의 스키마를 가지는 제네릭 자료구조로 표현된다.
4가지 원칙 개요
flowchart TD
DOP["Data-Oriented Programming"]
P1["원칙 1: 코드와 데이터 분리"]
P2["원칙 2: 제네릭 자료구조로 데이터 표현"]
P3["원칙 3: 데이터 불변성"]
P4["원칙 4: 스키마와 표현의 분리"]
DOP --> P1
DOP --> P2
DOP --> P3
DOP --> P4
원칙 1: 코드와 데이터를 분리하라 (Separate Code from Data)
참고: 코드는 함수 안에 위치하며, 그 함수의 동작은 함수 컨텍스트에 캡슐화된 데이터에 의존하지 않아야 한다.
위반 사례 — OOP 방식
class Author {
constructor(
private firstName: string,
private lastName: string,
private books: number
) {}
fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
isProlific(): boolean {
return this.books > 100;
}
}
const obj = new Author("Isaac", "Asimov", 500);
obj.fullName(); // "Isaac Asimov"
- 코드(
fullName,isProlific)가 데이터(firstName,lastName,books)와 결합 — 원칙 위반
위반 사례 — FP 방식 (클로저 남용)
function createAuthorObject(firstName: string, lastName: string, books: number) {
return {
fullName: () => `${firstName} ${lastName}`,
isProlific: () => books > 100,
};
}
- 데이터가 함수의 렉시컬 스코프에 숨겨짐 — 원칙 위반
준수 사례 — FP 방식 (권장)
function createAuthorData(firstName: string, lastName: string, books: number) {
return { firstName, lastName, books };
}
function fullName(data: { firstName: string; lastName: string }): string {
return `${data.firstName} ${data.lastName}`;
}
function isProlific(data: { books: number }): boolean {
return data.books > 100;
}
const authorData = createAuthorData("Isaac", "Asimov", 500);
fullName(authorData); // "Isaac Asimov"
준수 사례 — OOP 방식 (static 메서드 활용)
class AuthorData {
constructor(
public firstName: string,
public lastName: string,
public books: number
) {}
}
class NameCalculation {
static fullName(data: { firstName: string; lastName: string }): string {
return `${data.firstName} ${data.lastName}`;
}
}
class AuthorRating {
static isProlific(data: { books: number }): boolean {
return data.books > 100;
}
}
원칙 1의 이점
| 이점 | 설명 |
|---|---|
| 코드 재사용 | fullName은 Author뿐 아니라 firstName, lastName을 가진 어떤 객체에도 사용 가능 |
| 독립 테스트 | 객체 전체를 인스턴스화하지 않고 데이터만 만들어 함수 단독 테스트 가능 |
| 낮은 복잡도 | 코드 엔티티와 데이터 엔티티가 분리되어 각각을 독립적으로 이해 가능 |
// 독립 테스트 예시
const testData = { firstName: "Isaac", lastName: "Asimov" };
console.log(fullName(testData) === "Isaac Asimov"); // true
원칙 1의 비용
- 데이터 접근 제어 불가: 어떤 코드든 데이터에 접근 가능 (캡슐화 없음) → 원칙 3(불변성)으로 보완
- 패키징 없음: 어떤 함수가 어떤 데이터를 다루는지 발견하기 어려움
- 엔티티 수 증가: N개의 클래스가 코드/데이터로 분리되면 N~2N개의 엔티티 생성 → 원칙 2로 완화
원칙 2: 제네릭 자료구조로 데이터를 표현하라 (Represent Data with Generic Data Structures)
참고: 애플리케이션 데이터는 맵(map/dictionary) 과 배열(array/list) 같은 제네릭 자료구조로 표현한다.
위반 사례 — 특정 클래스로 데이터 표현
class AuthorData {
constructor(
public firstName: string,
public lastName: string,
public books: number
) {}
}
준수 사례 — 맵 리터럴로 데이터 표현
function createAuthorData(firstName: string, lastName: string, books: number) {
return { firstName, lastName, books };
}
const author = createAuthorData("Isaac", "Asimov", 500);
원칙 2의 이점
1. 제네릭 함수 활용 가능
import _ from "lodash";
const author = createAuthorData("Isaac", "Asimov", 500);
// JSON 직렬화 (언어 내장)
JSON.stringify(author);
// → '{"firstName":"Isaac","lastName":"Asimov","books":500}'
// 특정 필드만 추출 (Lodash)
const nameOnly = _.pick(author, ["firstName", "lastName"]);
JSON.stringify(nameOnly);
// → '{"firstName":"Isaac","lastName":"Asimov"}'
참고: “10개의 자료구조에 10개의 함수를 두는 것보다, 1개의 자료구조에 100개의 함수를 두는 것이 낫다.”
2. 유연한 데이터 모델
const author = createAuthorData("Isaac", "Asimov", 500);
// 클래스 재정의 없이 필드를 동적으로 추가
const authorWithFullName = { ...author, fullName: "Isaac Asimov" };
- 특히 웹앱/웹서비스처럼 데이터 형태가 동적으로 변하는 환경에서 유리
원칙 2의 비용
| 비용 | 설명 |
|---|---|
| 성능 저하 (미미) | 클래스 멤버 접근보다 맵 키 조회가 약간 느림 |
| 스키마 없음 | 데이터 형태가 코드에 명시되지 않음 → 원칙 4로 보완 |
| 런타임 오류 | 필드명 오타 시 컴파일 타임이 아닌 런타임에야 발견 |
| 타입 캐스팅 | 정적 타입 언어에서는 명시적 타입 변환 필요 |
// 런타임 오류 예시 — 필드명 오타
function fullName(data: { firstName: string; lastName: string }) {
return `${data.firstName} ${data.lastName}`;
}
// fistName (오타) → 런타임에서 undefined 반환
fullName({ fistName: "Isaac", lastName: "Asimov" } as any);
// → "undefined Asimov"
원칙 3: 데이터는 불변이다 (Data is Immutable)
참고: 데이터를 직접 변경하지 않는다. 변경이 필요할 때는 새로운 버전의 데이터를 생성한다.
불변성 위반 사례
const myData = { num: 42 };
const yourData = myData;
yourData.num = yourData.num + 1;
console.log(myData.num); // 43 — myData까지 변경됨!
불변성 준수 — 단순 복사 방식
function changeValue<T extends object>(obj: T, key: keyof T, value: T[keyof T]): T {
return { ...obj, [key]: value };
}
const myData = { num: 42 };
const yourData = changeValue(myData, "num", myData.num + 1);
console.log(myData.num); // 42 — 원본 보존
console.log(yourData.num); // 43
불변성 준수 — 전용 라이브러리 사용 (Immer 활용)
import { produce } from "immer";
const myData = { num: 42 };
const yourData = produce(myData, (draft) => {
draft.num = draft.num + 1;
});
console.log(myData.num); // 42
console.log(yourData.num); // 43
참고: 원서에서는
Immutable.js를 예로 들지만, TypeScript 생태계에서는Immer가 더 널리 쓰임. 두 라이브러리 모두 영속 자료구조(persistent data structures) 를 효율적으로 구현함.
원칙 3의 이점
1. 안심하고 데이터 전달 가능
- 함수에 데이터를 넘겨도 원본이 변경되지 않음이 보장됨
2. 예측 가능한 코드 동작
const myData = { num: 42 };
setTimeout((data) => {
// 불변 데이터라면 data.num은 항상 42
console.log(data.num);
}, 1000, myData);
myData.num = 0; // 가변 데이터라면 콜백 결과가 불확실
3. 빠른 동등성 비교 (Reference Equality)
- React 같은 UI 프레임워크에서 렌더링 최적화에 직접 활용
- 객체 주소가 동일 → 내용 변경 없음 → 재렌더링 불필요
- 전체 필드 순회 없이 참조 비교만으로 동등성 판단 가능
4. 동시성 안전 무료 제공
- 다중 스레드 환경에서 mutex 같은 동기화 장치 불필요
- 데이터가 절대 변하지 않으므로 race condition 원천 차단
원칙 3의 비용
- 성능 저하: 인플레이스 변경보다 새 버전 생성이 느리고 메모리 더 사용 (대부분 무시 가능한 수준)
- 라이브러리 의존: Clojure 같은 일부 언어와 달리, 대부분의 언어에서 영속 자료구조를 위한 서드파티 라이브러리 필요
원칙 4: 데이터 스키마와 데이터 표현을 분리하라 (Separate Data Schema from Data Representation)
참고: 데이터의 예상 형태(shape)는 데이터 자체와 별도로 스키마로 표현하며, 어떤 데이터에 스키마를 적용할지는 개발자가 자유롭게 결정한다.
스키마 정의 예시 — JSON Schema + Ajv (TypeScript)
import Ajv from "ajv";
const addAuthorRequestSchema = {
type: "object",
required: ["firstName", "lastName"],
properties: {
firstName: { type: "string" },
lastName: { type: "string" },
books: { type: "integer" }, // optional
},
};
const ajv = new Ajv({ allErrors: true });
// 유효한 데이터
const validData = { firstName: "Isaac", lastName: "Asimov", books: 500 };
ajv.validate(addAuthorRequestSchema, validData); // true
// 유효하지 않은 데이터
const invalidData = { firstName: "Isaac", lastNam: "Asimov", books: "five hundred" };
ajv.validate(addAuthorRequestSchema, invalidData); // false
console.log(ajv.errorsText());
// "data must have required property 'lastName', data/books must be integer"
원칙 4의 이점
1. 검증 여부를 자유롭게 선택 가능
- 빠른 프로토타이핑 단계에서 스키마 정의를 미룰 수 있음
- 분할 리팩토링(split phase refactoring) 시 내부 함수에는 스키마 생략 가능
2. 선택적(Optional) 필드 처리 자연스러움
// JSON Schema에서 required 배열에 없으면 자동으로 optional
const authorSchema = {
type: "object",
required: ["firstName", "lastName"], // books는 optional
properties: {
firstName: { type: "string" },
lastName: { type: "string" },
books: { type: "number" },
},
};
// books 없어도 유효
ajv.validate(authorSchema, { firstName: "Yehonathan", lastName: "Sharvit" }); // true
// books가 있는데 타입이 틀리면 무효
ajv.validate(authorSchema, { firstName: "Albert", lastName: "Einstein", books: "Five" }); // false
3. 고급 유효성 검사 조건 지원
const strictSchema = {
type: "object",
required: ["firstName", "lastName"],
properties: {
firstName: { type: "string", maxLength: 100 },
lastName: { type: "string", maxLength: 100 },
books: { type: "integer", minimum: 0, maximum: 10000 },
},
};
- 정규식 검증, 숫자 범위, 배수 조건 등 정적 타입 시스템보다 강력한 표현력
4. 데이터 모델 시각화 자동 생성
- JSON Schema Viewer, Malli 같은 도구로 JSON Schema → UML 다이어그램 자동 생성 가능
원칙 4의 비용
- 데이터-스키마 연결이 느슨: 데이터와 스키마의 연결이 명시적이지 않아 검증 누락 위험
- 런타임 검증: OOP의 컴파일 타임 검증과 달리 런타임에 검증이 수행됨 (단, 개발 환경에서만 검증을 활성화하여 프로덕션 성능 보호 가능)
원칙 요약 비교표
| 원칙 | 핵심 내용 | 주요 이점 | 주요 비용 |
|---|---|---|---|
| 원칙 1 | 코드와 데이터 분리 | 재사용성, 테스트 용이성, 낮은 복잡도 | 접근 제어 없음, 엔티티 수 증가 |
| 원칙 2 | 제네릭 자료구조 사용 | 제네릭 함수 활용, 유연한 데이터 모델 | 스키마 없음, 런타임 오류, 성능 미미 저하 |
| 원칙 3 | 데이터 불변성 | 예측 가능성, 빠른 비교, 동시성 안전 | 성능 저하, 라이브러리 필요 |
| 원칙 4 | 스키마-표현 분리 | 선택적 검증, optional 필드, 고급 조건 | 느슨한 연결, 런타임 검증 |
부록 B. 정적 타입 언어에서의 제네릭 데이터 접근
배경
- 제네릭 자료구조(맵 등)는 동적 타입 언어(TypeScript/JS, Python, Ruby)에서 자연스럽지만, 정적 타입 언어(Java, C#)에서는 다음 문제가 발생:
- 맵 필드 접근 시 타입 캐스팅 필요
- 필드명이 컴파일 타임에 검증되지 않음
- IDE 자동완성 등 편의 기능 미지원
이 부록의 예시는 원서 기준 Java이지만, 아래는 TypeScript 관점으로 재해석함.
접근 방식 1: Dynamic Getter (동적 게터)
type StringMap = Record<string, unknown>;
function get(map: StringMap, key: string): unknown {
return map[key];
}
// 중첩 맵 접근
function getByPath(map: StringMap, path: string[]): unknown {
let value: unknown = map;
for (const key of path) {
if (value == null || typeof value !== "object") return null;
value = (value as StringMap)[key];
}
return value;
}
const watchmen: StringMap = {
isbn: "978-1779501127",
title: "Watchmen",
publicationYear: 1987,
};
(get(watchmen, "title") as string).toUpperCase(); // "WATCHMEN"
- 장점: 완전한 제네릭 접근, 필드명을 동적으로 받을 수 있음
- 단점: 사용 측에서 타입 캐스팅 필요
접근 방식 2: Value Getter (값 게터)
function getAsString(map: StringMap, key: string): string {
return map[key] as string;
}
function getAsNumber(map: StringMap, key: string): number {
return map[key] as number;
}
function getAsStringByPath(map: StringMap, path: string[]): string {
return getByPath(map, path) as string;
}
// 타입 캐스팅 없이 사용
getAsString(watchmen, "title").toUpperCase(); // "WATCHMEN"
// 중첩 접근
const searchResults: StringMap = {
"978-1779501127": { isbn: "978-1779501127", title: "Watchmen", publicationYear: 1987 },
"978-1982137274": { isbn: "978-1982137274", title: "7 Habits of Highly Effective People", publicationYear: 2020 },
};
getAsStringByPath(searchResults, ["978-1779501127", "title"]).toUpperCase(); // "WATCHMEN"
- 장점: 사용 측 타입 캐스팅 불필요
- 단점: 타입별로 게터 함수를 각각 구현해야 함 (
getAsString,getAsNumber등)
접근 방식 3: Typed Getter (타입드 게터)
class Getter<T> {
constructor(private keyOrPath: string | string[]) {}
get(map: StringMap): T {
if (Array.isArray(this.keyOrPath)) {
return getByPath(map, this.keyOrPath) as T;
}
return map[this.keyOrPath] as T;
}
}
// 게터 인스턴스 생성
const TITLE = new Getter<string>("title");
const YEAR = new Getter<number>("publicationYear");
TITLE.get(watchmen).toUpperCase(); // "WATCHMEN"
YEAR.get(watchmen).toFixed(0); // "1987"
// 리스트 매핑
const books = [watchmen, searchResults["978-1982137274"] as StringMap];
books.map((b) => TITLE.get(b).toUpperCase());
// → ["WATCHMEN", "7 HABITS OF HIGHLY EFFECTIVE PEOPLE"]
// 중첩 게터
const NESTED_TITLE = new Getter<string>(["978-1779501127", "title"]);
NESTED_TITLE.get(searchResults).toUpperCase(); // "WATCHMEN"
- 장점: 타입 캐스팅 불필요, 타입별 구현 불필요, 사용 시점에 컴파일 타임 검증 및 자동완성 지원
- 단점: 게터 생성 시점에는 필드명이 문자열이라 컴파일 타임 검증 없음
접근 방식 4: Reflection을 통한 클래스 멤버 제네릭 접근
TypeScript에서는 Java의 Reflection과 달리,
keyof타입 연산자와 제네릭을 통해 유사한 효과를 낼 수 있음.
function getMember<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
interface BookData {
isbn: string;
title: string;
publicationYear: number;
}
const sevenHabits: BookData = {
isbn: "978-1982137274",
title: "7 Habits of Highly Effective People",
publicationYear: 2020,
};
getMember(sevenHabits, "title").toUpperCase(); // "7 HABITS OF HIGHLY EFFECTIVE PEOPLE"
// getMember(sevenHabits, "nonexistent"); // 컴파일 타임 에러!
- 장점: 완전한 컴파일 타임 검증, IDE 자동완성, 타입 캐스팅 불필요
- 단점: 데이터 수정(새 버전 생성)이 맵 방식보다 불편
네 가지 방식 비교 요약
| 방식 | 데이터 표현 | 이점 | 단점 |
|---|---|---|---|
| Dynamic Getter | 맵 | 완전한 제네릭 접근 | 타입 캐스팅 필요 |
| Value Getter | 맵 | 타입 캐스팅 불필요 | 타입별 구현 필요 |
| Typed Getter | 맵 | 사용 시 컴파일 검증 | 생성 시 검증 없음 |
Reflection / keyof |
클래스 | 완전한 컴파일 검증 | 데이터 수정 불편 |
부록 C. 프로그래밍 패러다임의 계보 속 DOP
DOP의 역사적 타임라인
timeline
title DOP의 역사적 계보
1958 : Lisp — John McCarthy
: 불변 리스트 처리 언어 발명
: 제네릭 자료구조 아이디어의 원형
1981 : Values and Objects — Bruce MacLennan
: 값(불변)과 객체(상태 있음)의 개념적 구분
: 값 중심 코드의 단순성 강조
2000 : Ideal Hash Trees — Phil Bagwell
: HAMT(Hash Array Mapped Trie) 발명
: 효율적인 영속 자료구조의 기반
2006 : Out of the Tar Pit — Moseley & Marks
: 복잡성 = 이해하기 어려운 것으로 정의
: 대부분의 복잡성은 우발적(accidental)이라 주장
2007 : Clojure — Rich Hickey
: "Just use maps!" 철학
: 불변 맵 + 제네릭 함수 언어 구현
2009 : 불변성의 대중화
: Clojure의 영속 자료구조가 타 언어로 이식
: Immutable.js(JS), Paguro(Java) 등 등장
각 원칙의 역사적 뿌리
원칙 1 (코드-데이터 분리)
- OOP vs FP의 전통적 논쟁의 핵심
- 현대에는 OOP 언어도 람다 지원, FP 언어도 상태 허용하며 경계가 흐려짐
- DOP는 두 패러다임 모두에서 코드-데이터 분리를 실천하는 방법 제시
원칙 2 (제네릭 자료구조)
- 1995년 JavaScript의 객체 리터럴이 해시맵을 쉽게 다루는 방식을 대중화
- JS 생태계의 확산(프론트엔드, 백엔드, 데스크탑)이 전체 개발 커뮤니티에 영향
원칙 3 (불변성)
- Joshua Bloch의 “Effective Java”에서 “불변성을 최소화하라” 를 Java 베스트 프랙티스로 언급
- Alan Kay(OOP 창시자 중 한 명): “프로그래머가 내부 상태를 건드리는 것은 원하지 않았다”
- 2007년 Clojure의 효율적 영속 자료구조 구현 이전까지는 프로덕션 규모 적용이 어려웠음
원칙 4 (스키마-표현 분리)
- 동적 타입 언어에 대한 “타입 안전성 없다”는 비판에 대한 응답
- JSON Schema의 등장으로 해시맵 데이터에도 강력한 런타임 검증 가능
- 정적 타입 검증보다 오히려 더 풍부한 검증 조건 표현 가능
DOP vs 유사 데이터 관련 패러다임 비교
| 패러다임 | 목적 | 데이터의 핵심 관심사 |
|---|---|---|
| Data-Oriented Design | 성능 향상 | 데이터 레이아웃 (캐시 효율) |
| Data-Driven Programming | 명확성 향상 | 데이터로 표현된 동작(DSL) |
| Data-Oriented Programming | 복잡도 감소 | 데이터 표현 방식 |
- Data-Oriented Design: 주로 게임 개발에서 CPU 캐시 효율 최적화 목적
- Data-Driven Programming: 선언적 프로그래밍의 일종, 동작을 데이터(DSL)로 기술
- DOP: 정보 시스템의 복잡도 감소가 목적, 데이터를 일급 시민으로 취급
언어별 영속 자료구조 라이브러리
| 언어 | 라이브러리 |
|---|---|
| Java | Paguro |
| C# | 언어 내장 지원 |
| JavaScript/TypeScript | Immutable.js, Immer |
| Python | Pyrsistent |
| Ruby | Hamster |
부록 D. Lodash 함수 레퍼런스
책 전반에서 DOP의 제네릭 함수 활용을 보여주기 위해 사용된 함수 목록. Lodash 외에도 동일한 접근법은 다른 라이브러리나 커스텀 코드로 구현 가능.
Lodash FP 설정
import fp from "lodash/fp";
const _ = fp.convert({
cap: false,
curry: false,
fixed: false,
immutable: true, // 원본 변경 없이 새 값 반환
rearg: false,
});
immutable: true설정으로 원본 데이터를 변경하지 않는 함수 사용- 원칙 3(불변성)을 Lodash 차원에서 보장
맵(Map)에서의 Lodash 함수
| 함수 | 설명 |
|---|---|
_.at(map, [paths]) |
경로 배열에 해당하는 값들의 배열 반환 |
_.get(map, path) |
특정 경로의 값 반환 |
_.has(map, path) |
특정 경로의 필드 존재 여부 확인 |
_.merge(mapA, mapB) |
두 맵을 재귀적으로 병합한 새 맵 반환 |
_.omit(map, [paths]) |
지정된 경로를 제외한 새 맵 반환 |
_.set(map, path, value) |
지정 경로에 값을 추가/변경한 새 맵 반환 |
_.values(map) |
맵의 모든 값을 배열로 반환 |
const author = { firstName: "Isaac", lastName: "Asimov", books: 500 };
_.get(author, "firstName"); // "Isaac"
_.has(author, "books"); // true
_.set(author, "books", 600); // { firstName: "Isaac", ..., books: 600 } (원본 불변)
_.omit(author, ["books"]); // { firstName: "Isaac", lastName: "Asimov" }
_.values(author); // ["Isaac", "Asimov", 500]
배열(Array)에서의 Lodash 함수
| 함수 | 설명 |
|---|---|
_.concat(arrA, arrB) |
두 배열을 이어붙인 새 배열 반환 |
_.flatten(arr) |
1단계 중첩 배열을 평탄화 |
_.intersection(arrA, arrB) |
두 배열의 교집합 (중복 제거) |
_.nth(arr, n) |
n번째 인덱스 요소 반환 |
_.sum(arr) |
배열 요소의 합 계산 |
_.union(arrA, arrB) |
두 배열의 합집합 (중복 제거) |
_.uniq(arr) |
중복 제거된 새 배열 반환 |
_.concat([1, 2], [3, 4]); // [1, 2, 3, 4]
_.flatten([[1, 2], [3, 4]]); // [1, 2, 3, 4]
_.intersection([1, 2, 3], [2, 3, 4]); // [2, 3]
_.union([1, 2], [2, 3]); // [1, 2, 3]
_.uniq([1, 1, 2, 3, 2]); // [1, 2, 3]
컬렉션(배열+맵) 공통 Lodash 함수
| 함수 | 설명 |
|---|---|
_.every(coll, pred) |
모든 요소가 조건을 만족하는지 확인 |
_.filter(coll, pred) |
조건을 만족하는 요소만 모아 배열로 반환 |
_.find(coll, pred) |
조건을 만족하는 첫 번째 요소 반환 |
_.forEach(coll, f) |
각 요소에 함수를 적용 (사이드 이펙트용) |
_.groupBy(coll, f) |
f의 반환값을 키로, 해당 요소 배열을 값으로 하는 맵 반환 |
_.isEmpty(coll) |
컬렉션이 비어있는지 확인 |
_.keyBy(coll, f) |
f의 반환값을 키로, 마지막 해당 요소를 값으로 하는 맵 반환 |
_.map(coll, f) |
각 요소에 f를 적용한 결과 배열 반환 |
_.reduce(coll, f, initVal) |
컬렉션을 단일 값으로 누적 계산 |
_.size(coll) |
컬렉션의 크기 반환 |
_.sortBy(coll, f) |
f 기준 오름차순 정렬 배열 반환 |
_.isEqual(collA, collB) |
두 컬렉션의 깊은 동등성 비교 |
_.isArray(coll) |
배열 여부 확인 |
_.isObject(coll) |
객체(컬렉션) 여부 확인 |
const authors = [
{ firstName: "Isaac", lastName: "Asimov", books: 500 },
{ firstName: "John", lastName: "Doe", books: 3 },
{ firstName: "Jane", lastName: "Austen", books: 6 },
];
// 다작 작가만 필터링
_.filter(authors, (a) => a.books > 100);
// → [{ firstName: "Isaac", ... }]
// 성(lastName)으로 그룹핑
_.groupBy(authors, (a) => a.lastName[0]);
// → { A: [Asimov, Austen], D: [Doe] }
// 책 수 합계
_.reduce(authors, (acc, a) => acc + a.books, 0);
// → 509
// 깊은 동등성 비교 — 불변 데이터 비교 유틸리티
_.isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }); // true
최종 정리: DOP 4원칙 한눈에 보기
flowchart LR
subgraph 데이터 측면
D1["제네릭 자료구조\n(맵, 배열)"]
D2["불변(Immutable)"]
D3["스키마 분리\n(JSON Schema 등)"]
end
subgraph 코드 측면
C1["순수 함수\n(데이터에 독립적)"]
end
C1 -->|원칙 1: 분리| D1
D1 -->|원칙 2: 표현| D2
D2 -->|원칙 3: 불변| D3
D3 -->|원칙 4: 스키마 분리| C1
| 원칙 | 한 줄 요약 | TypeScript 핵심 패턴 |
|---|---|---|
| 원칙 1 | 함수와 데이터를 분리 | 순수 함수 + 데이터 객체 분리 |
| 원칙 2 | 맵/배열로 데이터 표현 | 인터페이스/클래스 대신 Record, 객체 리터럴 |
| 원칙 3 | 데이터를 변경하지 말고 새로 생성 | Spread({...obj}), Immer produce |
| 원칙 4 | 스키마는 데이터 밖에 | Ajv + JSON Schema로 런타임 검증 |