research

Mapping to Relational Databases

Mapping to Relational Databases

DCD까지 만들었는데 "이걸 DB에 어떻게 저장하지?"라는 질문이 남았다면, 이 챕터가 그 답이다. 데이터 소스 계층의 아키텍처 패턴, 행동 패턴, 구조적 매핑 패턴을 전체적으로 조감한다.

Architectural Patterns: 네 가지 선택지

도메인 로직이 DB와 대화하는 방식에 대한 아키텍처 결정. 이 선택은 번복하기 어렵다.

1. Table Data Gateway

테이블당 하나의 Gateway 객체. 모든 SQL(select, insert, update, delete)을 캡슐화한다. 메서드 호출 결과로 Record Set을 반환한다. Table Module과 궁합이 좋다. 저장 프로시저를 감싸는 용도로도 적합하다.

2. Row Data Gateway

쿼리 결과의 각 행마다 하나의 객체를 만든다. 객체 지향적으로 데이터에 접근할 수 있지만, 도메인 로직은 포함하지 않는다(순수 데이터 접근만).

3. Active Record

Row Data Gateway에 도메인 로직을 추가한 것. 객체가 자기 데이터의 CRUD와 비즈니스 로직을 모두 담당한다. Domain Model이 DB 구조와 거의 1:1로 매칭될 때 적합하다. Rails가 대표적 구현.

4. Data Mapper

도메인 객체와 DB 사이에 독립적인 매핑 계층을 둔다. 도메인 객체는 DB의 존재를 전혀 모르고, DB 스키마도 도메인을 모른다. 가장 복잡하지만 두 계층이 독립적으로 진화할 수 있다.

Fowler의 경험칙: Domain Model + Gateway는 피하라. 단순하면 Active Record, 복잡하면 Data Mapper. 중간은 없다. 그리고 Data Mapper를 직접 구현하려 하지 말고 O/R 매핑 도구를 사라. 매핑 코드 작성이 전체 개발 노력의 약 1/3을 차지한다는 일화가 있을 정도다.

Behavioral Problem: 세 가지 필수 패턴

DB와 객체를 매핑할 때 구조보다 더 어려운 것이 행동 문제다.

Unit of Work

비즈니스 트랜잭션 동안 변경된 모든 객체를 추적하고, commit 시 한꺼번에 DB에 반영한다. 개발자가 일일이 save()를 호출하는 대신, Unit of Work가 "뭘 새로 만들었고, 뭘 수정했고, 뭘 삭제했는지" 알아서 관리한다.

Identity Map

동일한 DB 행을 두 번 읽었을 때 두 개의 객체가 생기는 것을 방지한다. 데이터를 읽을 때마다 Identity Map을 먼저 확인하고, 이미 로드되어 있으면 기존 참조를 반환한다. 캐시 역할도 하지만, 본질적 목적은 정체성 유지다.

Lazy Load

Order를 로드할 때 연결된 Customer, LineItem을 전부 따라가다 보면 거대한 객체 그래프 전체가 메모리로 올라온다. Lazy Load는 실제로 접근할 때까지 로드를 지연시키는 기법이다. 참조를 따라가는 순간에야 비로소 DB 쿼리가 발생한다.

Structural Mapping: 객체와 관계의 불일치

객체와 관계형 DB의 근본적 차이점들:

참조 방식: 객체는 메모리 레퍼런스로 연결하고, 관계형은 외래 키로 연결한다. → identity-field|Identity Field로 각 객체에 관계형 ID를 부여하고, foreign-key-mapping|Foreign Key Mapping으로 참조를 변환한다.

컬렉션: 객체는 한 필드에 여러 값(리스트)을 가질 수 있지만, 관계형은 정규화상 단일 값만 허용한다. → 방향이 뒤집힌다. 객체에서 Order→LineItem[]이면, 테이블에서는 LineItem에 Order FK가 있다.

다대다 관계: 객체에서는 양쪽 모두 컬렉션을 가지면 되지만, 관계형에서는 불가능하다. → association-table-mapping|Association Table Mapping으로 별도의 연결 테이블을 만든다.

상속: 관계형 DB에는 상속이 없다. 세 가지 전략이 있다:

  • Single Table Inheritance: 계층 전체를 하나의 테이블에. 단순하지만 NULL 컬럼이 많다.
  • Class Table Inheritance: 클래스당 하나의 테이블. 정규화되지만 JOIN이 필요하다.
  • Concrete Table Inheritance: 리프 클래스마다 테이블. JOIN 없지만 공통 필드가 중복된다.

실용 팁

  • 여러 행을 한 번에 읽어라. 50개 객체가 필요하면 50번 쿼리하지 말고, 200개를 가져와서 메모리에서 필터링하는 게 낫다.
  • JOIN은 3~4개까지. 그 이상은 성능이 급격히 떨어진다. 캐시된 뷰로 보완할 수 있다.
  • 일반론을 믿지 말고 프로파일링하라. DB 시스템과 앱 서버의 캐싱은 예측 불가능하다.