research

Object-Relational Behavioral Patterns

Object-Relational Behavioral Patterns

구조적 매핑(테이블↔클래스, 컬럼↔필드)보다 더 어려운 것이 행동 문제다. 객체를 읽고 수정하는 과정에서 발생하는 동시성, 중복, 성능 문제를 다루는 세 가지 핵심 패턴.

Unit of Work

비즈니스 트랜잭션 동안 DB에 영향을 줄 수 있는 모든 것을 추적하고, 완료 시 변경사항을 일괄 반영한다.

왜 필요한가

객체를 수정할 때마다 바로 DB에 쓰면 세 가지 문제가 생긴다:

  1. 작은 DB 호출이 너무 많아진다 — 성능이 떨어진다.
  2. 전체 상호작용 동안 트랜잭션을 열어둬야 한다 — 여러 요청에 걸친 작업에서는 비현실적.
  3. 뭘 변경했는지 추적하기 어렵다 — 새로 생성한 객체, 수정한 객체, 삭제한 객체를 구분해야 한다.

동작 방식

Unit of Work는 세 개의 리스트를 관리한다:

  • new: 새로 생성된 객체
  • dirty: 수정된 객체
  • removed: 삭제된 객체

commit()이 호출되면, Unit of Work가 트랜잭션을 열고 → 동시성 체크를 수행하고 → 변경사항을 올바른 순서로 DB에 반영한다. 개발자는 명시적 save()를 호출할 필요가 없다.

등록 방식 세 가지

Caller Registration: 개발자가 직접 unitOfWork.registerDirty(obj) 호출. 유연하지만 까먹기 쉽다.

Object Registration: 객체의 setter가 자동으로 등록. setName() 내부에서 unitOfWork.registerDirty(this). 개발자 부담이 줄지만 도메인 객체에 등록 코드가 침투한다.

Unit of Work Controller: Unit of Work가 모든 DB 읽기를 관장. 읽을 때 원본 복사본을 저장해두고, commit 시 비교해서 변경된 필드만 업데이트. TOPLink(현 EclipseLink)가 이 방식을 사용한다.

부가 기능

  • 업데이트 순서 관리: 외래 키 참조 무결성 때문에 테이블 쓰기 순서가 중요할 수 있다. Unit of Work가 이를 자동화한다.
  • 데드락 최소화: 모든 트랜잭션이 같은 테이블 순서로 쓰면 데드락 위험이 줄어든다.
  • 배치 업데이트: 여러 SQL을 하나의 원격 호출로 묶어 보낸다.

Identity Map

한 비즈니스 트랜잭션 내에서 각 DB 행에 대해 객체가 하나만 존재하도록 보장한다.

왜 필요한가

같은 고객을 두 번 로드하면 두 개의 객체가 생긴다. 하나를 수정하면 다른 하나에는 반영이 안 된다. 둘 다 저장하면? 두 번째 저장이 첫 번째를 덮어쓴다. 혼란의 시작이다.

동작 방식

find(id):
  1. Identity Map에서 id로 검색
  2. 있으면 → 기존 객체의 참조를 반환
  3. 없으면 → DB에서 로드 → Identity Map에 등록 → 반환

본질적 목적은 정체성 유지이지 캐싱이 아니다. 캐시로서의 효과도 있지만, Identity Map의 존재 이유는 "같은 행 = 같은 객체"를 보장하는 것이다.

구현 선택

  • 테이블당 하나 vs 세션 전체에 하나: 보통은 테이블(클래스)당 하나의 Map.
  • 명시적 vs 일반적: Key가 ID이고 Value가 도메인 객체인 단순한 Map.
  • Unit of Work와의 통합: Identity Map은 Unit of Work 안에 넣는 것이 자연스럽다.

Lazy Load

필요한 데이터를 모두 담고 있지는 않지만, 필요할 때 가져오는 방법을 알고 있는 객체.

왜 필요한가

Order를 로드하면 Customer도 로드해야 한다. Customer를 로드하면 Address도, 이전 Orders도... 하나의 객체를 로드하다가 거대한 객체 그래프 전체가 메모리로 올라올 수 있다.

네 가지 구현 방식

Lazy Initialization: getter에서 null이면 그때 로드.

getCustomer() {
    if (customer == null)
        customer = Customer.find(customerId);
    return customer;
}

Virtual Proxy: 실제 객체처럼 행동하는 프록시. 접근 시 실제 객체를 로드.

Value Holder: 값을 감싸는 범용 Lazy Load 컨테이너.

Ghost: 불완전하게 로드된 실제 객체. ID만 가지고 있다가, 다른 필드에 접근하면 나머지를 로드.

주의사항

Lazy Load를 과하게 쓰면 잔잔한 DB 호출이 폭증한다(N+1 문제). 한 번의 JOIN으로 가져올 수 있는 데이터를 100번의 Lazy Load로 가져오게 될 수 있다. Eager Loading과 Lazy Loading의 균형을 잡아야 하며, 이는 클라이언트가 데이터를 어떻게 사용하는지를 이해해야만 결정할 수 있다.

세 패턴의 협력

이 세 패턴은 Data Mapper와 함께 쓸 때 가장 빛을 발한다. Active Record에서는 Unit of Work 없이도 어느 정도 관리 가능하지만, Domain Model이 복잡해지면 이 삼총사 없이는 불가능하다.