Object-Relational Behavioral Patterns
구조적 매핑(테이블↔클래스, 컬럼↔필드)보다 더 어려운 것이 행동 문제다. 객체를 읽고 수정하는 과정에서 발생하는 동시성, 중복, 성능 문제를 다루는 세 가지 핵심 패턴.
Unit of Work
비즈니스 트랜잭션 동안 DB에 영향을 줄 수 있는 모든 것을 추적하고, 완료 시 변경사항을 일괄 반영한다.
왜 필요한가
객체를 수정할 때마다 바로 DB에 쓰면 세 가지 문제가 생긴다:
- 작은 DB 호출이 너무 많아진다 — 성능이 떨어진다.
- 전체 상호작용 동안 트랜잭션을 열어둬야 한다 — 여러 요청에 걸친 작업에서는 비현실적.
- 뭘 변경했는지 추적하기 어렵다 — 새로 생성한 객체, 수정한 객체, 삭제한 객체를 구분해야 한다.
동작 방식
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이 복잡해지면 이 삼총사 없이는 불가능하다.