ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 도메인 주도 설계의 적용-4.ORM과 투명한 영속성 2부
    [옛날 글들] 도메인 주도 설계 2024. 4. 24. 14:48
    728x90

    이전글 : 도메인 주도 설계의 적용-4.ORM과 투명한 영속성 1부

     

    이 글은 제가 2008년 6월부터 10월까지 5개월간 마이크로소프트웨어에 연재했던 "도메인 주도 설계의 적용"이라는 원고의 원글입니다.  잡지에 맞추어 편집을 하는 과정에서 지면 상의 제약으로 인해 수정되거나 삭제된 부분이 있어 제 블로그에 원글을 올립니다. 도메인 주도 설계(Domain-Driven Design)에 관심 있는 분들에게 도움이 되었으면 합니다. 

    엔티티의 생명 주기

    엔터프라이즈 어플리케이션을 구성하는 도메인 객체의 생명 주기를 바라보는 시각은 크게 두 가지로 나눌 수 있다.

     

    첫 번째는 도메인 객체의 생명주기를 구현 기술에 종속적인 시각으로 바라보는 것이다. 예를 들어 퍼시스턴스 메커니즘으로 JDBC를 직접 사용하는 트랜잭션 스크립트(TRANSACTION SCRIPT) 패턴 기반의 어플리케이션을 생각해 보자. 이 경우 도메인 로직은 트랜잭션 스크립트 내에 절차적 방식으로 구현되고 도메인 개념들은 getter/setter만을 가지는 빈약한 도메인 모델(Anemic Domain Model)로 구성된다. 빈약한 도메인 모델은 도메인 개념을 반영한 클래스명과 속성명을 가지지만 단순히 레이어 간의 데이터 전달을 위해서만 사용되는 가짜 객체를 의미한다. 빈약한 도메인 모델을 적용한 도메인 객체는 상태는 가지지만 행위는 포함하지 않는다. 이 경우 정보를 데이터베이스에 저장하기 위해 속성이 설정된 도메인 객체를 생성하고 JDBC API를 사용하여 데이터베이스에 정보를 저장한 후 도메인 객체를 가비지 컬렉터에 넘겨 소멸시킨다. 만약 데이터베이스에 저장된 데이터가 필요한 경우 JDBC API를 사용하여 쿼리를 실행하고 새로운 도메인 객체를 생성한 후 반환된 결과 셋을 도메인 객체로 변환시킨다. 사용이 종료된 도메인 객체는 가비지 컬렉터에 넘겨져 소멸된다.

     

    이 시각은 관계형 데이터베이스와 객제 지향이라는 구현 기술 자체에 지나치게 종속되어 있다. 도메인 객체들의 생명주기는 데이터베이스와의 상호작용 관점에서 파악된다. 도메인 개념의 유일성이나 추적성은 구현 기술에 파묻혀 그 의미를 상실하고 도메인 객체는 단지 데이터베이스 테이블의 구조를 반영한 메모리 저장소로 파악된다. 이 시각을 가진 대부분의 사람들은 도메인 객체의 생명주기를 데이터베이스와의 상호작용을 중심으로 파악하는 경향이 강하다.

     

    두 번째 시각은 도메인 객체의 생명주기를 엔티티 관점에서 바라보는 것이다. 고객이 신규 가입한 경우 시스템에는 새로운 고객 정보를 가지는 새로운 엔티티가 생성된다. 고객 엔티티는 잠시 메모리 객체의 형태로 시스템 내에 존재한다. 비즈니스 로직을 처리한 후에 고객 엔티티는 형태를 바꾸어 데이터베이스 레코드의 형태로 존재한다. 또 다른 비즈니스 로직이 고객 정보를 필요로할 경우 고객 엔티티는 다시 메모리 객체로 형태를 바꾸어 해당 비즈니스 로직을 처리한다. 최종적으로 고객이 탈퇴를 하는 경우 해당 고객 엔티티 정보가 소멸된다. 즉, 고객이 탈퇴를 한 이후로는 해당 고객의 정보가 데이터베이스의 형태로든 메모리 객체의 형태로든 시스템 내에 존재하지 않게 된다.

     

    이 시각에서 도메인 객체는 단순히 데이터베이스와의 상호작용을 위해 필요한 데이터 저장소가 아니다. 도메인 객체는 엔티티의 한 형태일 뿐이다. 도메인 객체가 가비지 컬렉터에 의해 소멸되는 것은 도메인 개념의 소멸이 아니라 형태의 변경에 따른 불필요한 메모리의 해제일 뿐이다. 도메인 개념 자체는 사라지지 않는다. 이 시각은 도메인 분석 시에 발견된 연속성과 추적성을 그대로 구현 레벨에 반영하기 위한 발상의 전환을 가져온다.

     

    도메인을 시스템 개발의 주도적 위치로 격상시키기 위해서는 도메인 객체를 엔티티 개념에서 바라볼 필요가 있다. 그리고 우리의 분석/설계 도구에는 이미 이를 가능하게 해주는 유용한 도구가 존재하고 있다. 리포지토리가 바로 그것이다.

     

    고객이 신규 주문을 입력한 경우 OrderRepository는 새로운 Order 엔티티를 내부적으로 등록하고 관리한다. 저장된 Order가 필요한 경우 클라이언트는 OrderRepository에게 저장된 Order 엔티티를 찾아 달라고 요청하고 OrderRepository는 관리하고 있던 Order 엔티티를 찾아 반환한다. 고객이 마음을 바꿔 주문을 취소한 경우 등록된 Order 엔티티가 더 이상 필요하지 않으므로 OrderRepository에게 해당 Order 엔티티를 제거하라고 요청한다. OrderRepository는 해당 Order 엔티티를 제거한다.

     

    이 시나리오에서 Order 엔티티는 새로운 주문 정보가 추가되는 시점에 단 한번 생성된다. 그 이후로는 동일한 Order 엔티티가 동일한 주문 정보를 표현한다. 그리고 최종적으로 해당 주문 정보가 도메인에서 사라지는 시점에 Order 엔티티도 소멸된다. 따라서 Order 엔티티의 생명주기는 실제 도메인 내에서 일어나는 주문의 생명주기와 일치한다. 이 관점에서는 Order 엔티티의 생명주기 중간에 새로운 객체가 생성된다고 생각하지 않는다. 단지 리포지토리가 이미 등록되어 있던 Order 엔티티를 반환할 뿐이다.

     

    리포지토리엔티티의 생성과 소멸 시점 사이를 책임지는 생명주기 관리 객체이다. 우리는 리포지토리를 통해 동일한 엔티티를 얻을 수 있다. 즉, 엔티티의 유일성을 보장할 수 있다. 시스템의 모든 부분이 리포지토리를 통해 엔티티에 접근하기 때문에 변경된 엔티티 정보는 시스템 전체에 전파된다. 리포지토리는 도메인 객체들이 메모리 내에서 관리된다는 신기루를 만들어 냄으로써 도메인 모델 전체의 복잡성을 낮춘다. 리포지토리가 실제로 엔티티를 메모리에서 관리하는지 데이터베이스에서 관리하는지 또는 XML 파일 내에 관리하는 지는 도메인 레이어와는 상관이 없다. 리포지토리는 내부 구현을 캡슐화하면서 도메인 레이어에 객체 지향적인 컬렉션 관리 인터페이스를 제공한다.

     

    지금까지 개발해 온 주문 시스템을 떠올려 보라. OrderRepository는 메모리 내에 컬렉션을 관리하는 Registrar를 사용해서 Order 엔티티를 관리한다. 외부의 도메인 객체는 OrderRepository가 이전에 등록한 동일한 Order 엔티티를 메모리에 계속 저장하고 있다고 생각한다. 따라서 도메인 객체는 OrderRepository에게 해당 Order 엔티티를 찾아 달라고 요구하고 반환된 Order 엔티티는 이전에 등록된 바로 그 Order 엔티티라고 생각한다.

     

    이제 OrderRepository가 메모리가 아닌 데이터베이스에 Order 엔티티를 저장하도록 내부 구현을 변경하는 경우를 생각해 보자. 다행히도 이전에 리팩토링을 통해 인터페이스와 구현 클래스를 분리하고 의존성 주입을 제공하는 Spring 프레임워크를 도입한 덕에 OrderRepository의 내부 구현이 변경되었더라도 도메인 레이어에는 영향을 미치지 않게 되었다. OrderRepository는 이제 내부적으로 Registrar 대신 데이터베이스에 접근하기 위한 다른 인프라 스트럭처를 사용하는 클래스를 의존 주입하도록 수정되겠지만 이 사실은 인터페이스에 의해 외부로 새나가지 않는다. 따라서 도메인 객체들은 OrderRepository가 여전히 메모리 컬렉션 내에 동일한 Order 엔티티를 관리하고 있다고 가정할 수 있다. OrderRepository가 내부적으로 저장 메커니즘을 데이터베이스에서 XML 파일로 변경한다고 해도 메모리 컬렉션 관점을 수정할 필요는 없다. 이것이 인터페이스를 통한 캡슐화의 힘이며 도메인 모델에 리포지토리를 도입한 결과로 얻게 되는 추상화의 장점이다.

     

    그러나 OrderRepository가 데이터베이스를 사용할 경우 한 가지 문제가 발생한다. 지금까지 참조 객체의 동일성을 테스트하기 위해 사용하는 “==” 연산자가 메모리 주소를 비교한다는 것이다. 리포지토리가 내부적으로 저장소로써 데이터베이스를 사용한다면 엔티티를 룩업할 때마다 새로운 객체가 생성되어 반환될 것이다. 결국 이들은 동일한 엔티티지만 동일한 메모리 주소를 가지지 않기 때문에 식별자를 비교하는 “==” 테스트가 실패하게 될 것이다. 결국 “==” 테스트의 실패로 인해 지금까지 유지해왔던 도메인 객체의 추적성이 한 순간에 붕괴되는 결과를 낳고 만다. 따라서 우리는 객체 지향 시스템이 제공하는 참조 객체의 식별자를 그대로 사용할 수 없다. 다양한 형태로 옷을 갈아 입는 엔티티의 특성을 고려하는 순간 새로운 식별자의 개념이 필요해 진다.

    엔티티의 식별자(Identity)

    엔티티는 추적해야할 도메인 개념이 시간과 장소에 따라 다양한 형태를 지닐 수 있다는 개념을 도메인 모델에 도입한다. 따라서 변화되는 엔티티의 모든 형태가 공유할 수 있는 일반적인 식별자의 개념을 필요로 한다. 참조 객체의 경우처럼 식별자를 단순히 객체 생성 시에 할당되는 메모리 주소로만 한정해서 생각한다면 데이터베이스 레코드의 형태를 띈 도메인 개념을 엔티티로 간주할 수는 없을 것이다. 따라서 생명주기에 걸쳐 구현 기술과 무관하게 각 엔티티의 유일성을 보장할 수 있는 속성을 식별자로 삼아야 한다.

     

    그렇다면 엔티티의 식별자로 적당한 속성의 특징은 무엇일까? 무엇이 구현 기술과 무관하게 엔티티의 유일성을 보장할 수 있을까? 이에 대한 해답을 데이터베이스 테이블의 후보 키(candidate key) 식별 방법으로부터 얻을 수 있다. 일반적으로 테이블의 후보 키는 다음과 같은 특징을 가진다.

     

    ■ 후보 키의 모든 컬럼 값은 NULL이 아니어야 한다.

    ■ 각 로우는 유일한 값을 가진다.

    ■ 값이 결코 변경되어서는 안된다.

     

    데이터베이스 모델링에서는 이런 특성을 가진 후보 키중 가장 적절한 것을 주 키(primary key)로 삼는다. 나머지 후보 키들은 테이블의 유일 키(unique key)로 선택된다.

     

    동일한 특성이 엔티티의 식별자에도 적용될 수 있을까? 그렇다고 생각한다. 데이터베이스 테이블에 저장된 레코드는 엔티티의 특별한 형태라고 볼 수 있다. 엔티티가 여러 형태를 거쳐 소멸될 때까지 유일성과 추적성을 보장하는 방법은 영속성 메커니즘에 동일하게 적용될 수 있는 식별자를 사용하는 것이다.

     

    이런 관점에서 데이터베이스의 모델링 과정을 엔티티 중심의 프로세스로 설명할 수 있다. 시스템에서 지속적으로 관리해야 할 엔티티를 식별하고, 해당 엔티티의 유일성과 추적성을 보장하는 속성들을 찾아 엔티티의 후보 식별자로 선택한다. 후보 식별자 중 해당 엔티티에 가장 적절한 후보 식별자를 선택하여 엔티티의 최종 식별자로 삼는다. 이 기반 모델로부터 데이터베이스의 논리/물리 모델을 도출하고 주 키와 유일 키를 식별한다. 이것을 엔티티라는 개념을 중심으로 데이터 모델링의 3단계인 개념 모델링, 논리 모델링, 물리 모델링을 확장한 것으로 보아도 무방하다.

     

    기반 모델을 정제하여 도메인 모델을 발전시켜 가면서 선택된 식별자를 엔티티의 속성으로 모델링한다. 엔티티의 식별자는 테이블의 주 키와 동일할 것이다. 바꾸어 말하면 엔티티는 테이블의 주 키를 속성으로 가질 것이다. 만약 엔티티의 생명주기 중 XML 형태로 표현될 필요가 있다면 엔티티의 식별자가 XML의 엘리먼트로 표현될 것이다.

     

    식별자에 대한 이런 관점은 모델 주도 설계(MODEL-DRIVEN DESIGN) 개념을 기반으로 한다. 즉, 통일된 하나의 기반 모델을 사용하여 소프트웨어 개발을 주도하는 것이다. 이를 통해 소프트웨어 개발 과정을 분리된 개별 활동이 아닌 하나의 큰 흐름으로 볼 수 있도록 하는 정신적 틀을 제공한다.

     

    이것이 너무 이론적이라고 생각하는가? 그렇다면 실용적인 관점에서 식별자를 바라보도록 하자. 거의 모든 엔터프라이즈 어플리케이션은 영속성 메커니즘으로 관계형 데이터베이스를 사용한다. 따라서 엔티티의 관점에서 영속성과 추적성, 그리고 유일성을 보장받을 수 있는 가장 좋은 방법은 데이터베이스 테이블의 주 키를 식별자로 사용하는 것이다. 메모리 내에 생성된 엔티티가 데이터베이스의 한 로우로 맵핑될 수 없다면 데이터베이스를 영속성 저장소로 사용하는 시스템에서 엔티티를 추적할 수 없을 것이다. 따라서 엔티티는 객체와 데이터베이스 레코드와의 추적성을 보장하기 위해 주 키를 속성으로 포함해야 하며 데이터베이스와 맵핑하기 위해  이 속성을 사용해야 한다. 이처럼 도메인 모델 작성 시 데이터베이스의 주키를 엔티티의 식별자로 포함시키는 것을 식별자 필드(IDENTITY FIELD) 패턴이라고 한다.

     

    이제 우리는 도메인 객체를 식별하기 위해 사용되는 3가지 식별자의 개념을 모두 살펴 보았다. 정리해 보자.

     

    ■ 두 오브젝트가 동일한 메모리 주소를 공유한다면 이들은 동일하다. 이것은 “==” 연산자를 사용하여 확인할 수 있다. 이것을 객체 식별자(object identity)라고 한다. 우리는 참조 객체를 살펴 보면서 객체 식별자의 개념을 살펴 보았다.

    ■ 두 오브젝트가 동일한 값을 가진다면 이들은 동등하다. 이것은 equals(Object o) 메소드를 사용하여 확인할 수 있다. 이것을 동등성(equality)이라고 한다. 우리는 값 객체를 살펴 보면서 등등성의 개념을 살펴 보았다.

    ■ 두 오브젝트가 데이터베이스의 동일한 로우에 저장되어 있을 경우, 즉 동일한 테이블과 동일한 주 키를 공유한다면 오브젝트는 동일하다. 이것을 데이터베이스 식별자(database identity)라고 한다.

     

    데이터베이스 식별자를 엔티티의 식별자로 사용하기 위해서는 객체의 동일성을 확인하기 위해 식별자 필드를 비교해야 한다. 따라서 단순히 참조 객체처럼 “==”를 사용히여 동일성을 비교할 수 없으며 값 객체의 경우처럼 equals()를 오버라이딩해야 한다. 차이점이라면 값 객체가 속성으로 가지는 모든 값들을 비교하는 반면, 엔티티는 데이터베이스 키를 비교한다는 점이다. 단 데이터베이스 테이블에서 대리 키(surrogate key)를 주 키로 사용하는 경우에는 대리 키 대신 자연 키(natural key)를 비교해야한다. 대리 키를 비교할 경우 영속성 객체가 비영속 상태(transient state)나 준영속 상태(detached state)일 때 해쉬 기반 처리가 오작동할 우려가 있기 때문이다. 이처럼 테이블의 주 키로 대리키가 사용될 경우 동등성 비교를 위해 자연 키를 사용하는 것을 “비즈니스 키 동등성(business key equality)”이라고 한다. 자세한 내용은 Gavin King의 저서 “Java Persistence with Hibernate” Chapter 9을 참조하라. equals()를 오버라이딩할 때마다 hashCode() 역시 오버라이딩해야함을 잊지 말자. 두 오브젝트가 동등하다면, 동일한 해쉬 코드를 가져야 한다.

     

    엔티티의 생명주기와 식별자의 개념을 살펴 보았으니 이제 리포지토리를 구현하기 위해 필요한 ORM 기술에 관해 살펴보도록 하자. ORM의 상세한 내용은 이 아티클의 범위를 벗어나므로 여기에서는 주문 시스템을 구현하기 위해 필요한 몇 가지 특징만을 살펴 보기로 한다.

     

    다음글 : 도메인 주도 설계의 적용-4.ORM과 투명한 영속성 3부

    728x90
Designed by Tistory.