DDD - 트랜잭션 스크립트 패턴, 액티브 레코드 패턴


모든 비즈니스 하위 도메인은 전략적 중요성과 복잡도가 다르다.

이 포스트에서는 여기서는 비즈니스 로직 코드를 모델링하고 구현하는 다양한 방법 중 비교적 간단한 비즈니스 로직을 다루는 2개의 패턴인 트랜잭션 스크립트와 액티브 레코드에 대해 알아본다.


목차


1. 트랜잭션 스크립트 패턴

트랜잭션 스크립트 패턴은 프로시저를 기반으로 시스템의 비즈니스 로직을 구성하며, 각 프로시저는 퍼블릭 인터페이스를 통해 사용자가 실행하는 작업을 구현한다.


1.1. 구현

각 프로시저는 절차지향(순차적인 처리) 스크립트로 구현한다.

이 프로시저가 구현해야 하는 유일한 요구사항은 트랜잭션 동작이다.
각 작업은 성공하거나 실패할 수 있지만, 유효하지 않은 상태를 만들면 안된다.
즉, 트랜잭션 동작이 반영되어야 한다.


1.2. 트랜잭션 스크립트 구현의 실패 사례

트랜잭션 스크립트 패턴은 이후에 보게 될 고급 비즈니스 로직 구현 패턴의 기반이 되므로 잘 알아두어야 한다.

이제 트랜잭션 스크립트를 잘못 구현하여 발생하는 데이터 손상의 사례를 알아보자.


1.2.1. 트랜잭션 동작 구현 실패

트랜잭션 동작 구현 실패의 간단한 사례는 전체를 아우르는 트랜잭션 없이 여러 업데이트를 하는 경우이다.

예를 들어 User 테이블의 레코드를 업데이트하고, VisitsLog 테이블의 레코드를 삽입하는 로직이 있을 때 B 테이블의 레코드를 삽입하기 전에 문제가 발생하면 시스템이 일관되지 않은 상태가 된다.
(A 테이블은 업데이트 되었지만, B 테이블은 해당 기록이 없는 상태)

이 경우는 두 데이터의 변경을 모두 포함하는 트랜잭션을 만들어서 해결할 수 있다.

try {
  _db.StartTransaction();    // 트랜잭션 시작
  
  _db.Execute( 
    "UPDATE User SET latest_visit = @p1 WHERE user_id=@p2", visitedOn, userId
  );
  _db.Execute(
    "INSERT INTO VisitsLog(user_id, visit_date) VALUES (@p1, @p2)", userId, visitedOn
  );
  
  _db.Commit();  // 트랜잭션 커밋
} catch (Exception e) {
    _db.Rollback();  // 트랜잭션 롤백
  throw e;
}

1.2.2. 분산 트랜잭션

분산 트랜잭션에서 통합할 수 없는 여러 개의 저장 장치로 작업하는 경우는 상황이 좀 복잡해진다.

최근 분산 시스템에서는 DB 의 데이터를 변경한 후 다음 메시지 버스에 메시지를 발행하여 다른 컴포넌트에 변경 사항을 알리는 것이 일반적이다.

_db.Execute(
  "UPDATE User SET latest_visit = @p1 WHERE user_id=@p2", visitedOn, userId
);

_messageBus.Publish("VISIT TOPIC", new { UserId=userId, VisitDate=visitedOn});

위 코드에서 A 테이블 업데이트 이후 메시지 버스가 발행되기 전에 발생한 모든 오류는 시스템의 상태를 손상시킨다.
(A 테이블은 업데이트 되었지만, 다른 컴포넌트는 메시지 버스에 메시지를 발행이 되지 않아 알림을 받지 못한 상태)

여러 저장 장치에 걸쳐있는 분산 트랜잭션은 복잡하고 확장하기 어려우며 오류가 발생하기 쉬우므로 일반적으로 피하는 방식이다.

CQRS(Command-Query Responsibility Segregation) 아키텍처 패턴을 사용하여 여러 장치를 다루는 방법은 추후 다룰 예정입니다. (p. 68)

CQRS 에 대한 추가 설명은 DDD - CQRS 를 참고하세요.

다른 DB 에 변경사항을 커밋한 후 안정적인 메시지 발행을 가능하게 하는 아웃박스 패턴에 대해서는 추후 다룰 예정입니다. (p. 68)

아웃박스 패턴에 대한 추가 설명은 2.3.2.1. 아웃박스 패턴(outbox pattern) 을 참고하세요.


1.2.3. 암시적 분산 트랜잭션

아래 코드를 보자.

public void execute() {
    _db.Execute(
            "UPDATE User SET visits = visits + 1" +
                    "WHERE user_id = @p1", userId
    );
}

위 메서드를 호출하면 해당 카운터의 값이 1씩 증가한다.
메서드가 수행하는 모든 작업은 하나의 DB 에 있는 하나의 테이블에 있는 값을 업데이트하는 것이다.
하지만 이것은 여전히 잠재적으로 일관성없는 상태로 이어질 수 있는 분산 트랜잭션이다.

execute() 메서드는 void 타입이므로 데이터를 반환하지는 않지만 작업의 성공/실패 여부는 호출자에게 전달하며, 실패한 경우 호출자는 예외를 전달받는다.

하지만 만약 메서드는 성공했지만 호출자에게 결과를 전달하지 못하면 어떻게 될까?

  • execute() 가 REST 서비스의 일부이고, 네트워크 중단이 발생한 경우

이 경우 사용자는 실패를 가정하고, execute() 메서드를 다시 호출하게 되며 카운터값은 또 증가하게 된다.

이런 문제를 해결하는 방법은 2가지 정도가 있다.

멱등적으로 구현하기 위해 사용자에게 카운터 값을 전달하도록 요청할 수 있다.
카운터 값을 제공하기 위해 호출자는 먼저 현재 값을 읽고 로컬에서 증가시킨 뒤 그 값을 매개변수로 제공해야 한다.

public void execute() {
    _db.Execute(
            "UPDATE User SET visits = @p1" +
                    "WHERE user_id = @p2", visits, userId
    );
}

위와 같이 구현할 경우 작업을 여러 번 실행하더라도 최종 결과는 변경되지 않는다.

낙관적 동시성 제어를 사용하기 위해 해당 메서드를 호출하기 전에 카운터의 현재 값을 읽어서 그대로 매개변수로 제공한다.
execute() 에서는 호출자가 처음 읽은 값과 동일한 경우에만 카운터 값을 업데이트한다.

public void execute() {
    _db.Execute(
            "UPDATE User SET visits = visits + 1" +
                    "WHERE user_id = @p1 AND visits = @p2", userId, visits
    );
}

위와 같이 구현할 경우 작업을 여러 번 실행하더라도 WHERE … visits=@p2 의 조건이 충족되지 않으므로 데이터가 변경되지 않는다.


1.3. 트랜잭션 스크립트 패턴을 사용하는 경우

트랜잭션 스크립트 패턴은 비즈니스 로직이 단순한 절차적 작업처럼 매우 간단한 도메인에 효과적이다.
예) ETL(Extract Transform Load, 추출-변환-적재) 작업

트랜잭션 스크립트 패턴의 경우 비즈니스 로직이 단순한 지원 하위 도메인에 적합하며, 일반 하위 도메인과 외부 시스템을 연동하기 위한 어댑터로 사용하거나 충돌 방지 계층의 일부로 사용할 수도 있다.

충돌 방지 계층의 일부로 사용하는 것에 대한 자세한 내용은 추후 다룰 예정입니다. (p. 71)

트랜잭션 스크립트 패턴의 주요 장점은 단순함이다.
최소한의 추상화로 런타임 성능을 최적화하고, 비즈니스 로직을 이해하기 위한 시간을 최소화한다.

비즈니스 로직이 복잡할수록 트랜잭션 간에 미즈니스 로직이 중복되기 쉽고 결과적으로 중복된 코드가 동기화되지 않을 때 일관성 없는 동작이 발생하므로 핵심 하위 도메인에는 트랜잭션 스크립트 패턴을 사용하면 안된다.
핵심 하위 도메인의 비즈니스 로직이 복잡한 경우 트랜잭션 스크립트 패턴이 대처할 수 없다는 문제점이 발생할 수 있다.

이런 단순함 때문에 트랜잭션 스크립트 패턴은 때로 안티 패턴으로 취급되기도 한다.


2. 액티브 레코드

트랜잭션 스크립트 패턴과 마찬가지로 액티브 레코드 패턴도 비즈니스 로직이 단순한 경우 사용하지만, 액티브 레코드 패턴은 좀 더 복잡한 자료 구조에서도 비즈니스 로직을 구현할 수 있다.

예를 들어 일대다 혹은 다대다 관계가 있는 복잡한 데이터 모델이 있다.
간단한 트랜잭션 스크립트 패턴을 통해 이런 자료 구조를 조작하면 중복 코드가 많이 생성되고, 메모리 표현 방식으로 매핑하면 데이터가 사방에 중복되어 나타날 것이다.


2.1. 구현

액티브 레코드 패턴은 액티브 레코드라고 하는 전용 객체를 사용하여 복잡한 자료 구조를 표현한다.

자료 구조 외에도 이 전용 객체는 레코드 생성, 읽기, 업데이트, 삭제를 위한 CRUD 작업도 구현하기 때문에 액티브 레코드 객체는 ORM 과도 관련이 있다.
즉, 액티브 레코드는 데이터 접근 로직을 구현한다.

트랜잭션 스크립트 패턴과 마찬가지고 액티브 레코드 패턴은 트랜잭션 스크립트로 시스템의 비즈니스 로직을 만드는데 차이점은 액티브 레코드의 경우 DB 에 직접 접근하는 대신 트랜잭션 스크립트가 액티브 레코드 객체를 조작한다는 것이다.

try {
  _db.StartTransaction();    // 트랜잭션 시작
  
  // 액티브 레코드 객체 조작
  var user = new User();
  user.Name = userDetails.name;
  user.Email = userDetails.email;
  user.save();
  
  _db.Commit();  // 트랜잭션 커밋
} catch (Exception e) {
        _db.Rollback();  // 트랜잭션 롤백
  throw e;
}

액티브 레코드 패턴의 목적은 메모리 상의 객체를 DB 스키마에 매핑하는 복잡성을 숨기는 것이다.
영속성을 담당하는 것 외에 액티브 레코드 객체에는 비즈니스 로직이 포함될 수 있다.
예) 필드에 할당된 새 값의 유효성 검사

즉, 액티브 레코드 객체의 고유한 기능은 자료 구조와 비즈니스 로직의 분리이다.


2.2. 액티브 레코드 패턴을 사용하는 경우

액티브 레코드는 본질적으로 DB 에 대한 접근을 최적화하는 트랜잭션 스크립트이므로 액티브 레코드 패턴은 사용자 입력의 유효성을 검사하는 CRUD 와 같은 비교적 간단한 비즈니스 로직만 지원할 수 있다.

따라서 트랜잭션 스크립트 패턴과 마찬가지로 액티브 레코드 패턴도 지원 하위 도메인, 일반 하위 도메인과 외부 솔루션의 연동, 모델 변환 작업 등에 적합하다.
두 패턴의 차이점은 액티브 레코드의 경우 복잡한 자료 구조를 DB 스키마에 매핑하는 복잡성을 해소한다는 것이다.

액티브 레코드 패턴은 빈약한 도메인 모델 안티패턴(anemic domain model anti-pattern) 이라고도 한다.
하지만 비즈니스 로직이 단순할 때 액티브 레코드를 사용하는 데는 아무런 문제가 없다.


3. 실용적인 접근 방식

비즈니스 데이터가 중요하고 코드의 무결성도 중요하지만, 실용적인 접근 방식이 더 바람직한 경우도 있다.
예) 대규모로 데이터를 다루는 시스템에서는 데이터의 일관성 보장이 덜 엄격할 수 있음
100만 개의 데이터 중 하나의 레코드 상태를 손실시키는 것이 실제로 미즈니스에 쇼스토퍼가 되는지?

쇼스토퍼(show-stopper)

쇼를 중단시킬 만큼 치명적인 해프닝이라는 의미

항상 그렇듯 보편적인 법칙은 없다.
그것은 작업 중인 비즈니스 도메인에 달려있다.


정리하며..

  • 트랜잭션 스크립트 패턴
    • 시스템 작업을 간단하고 쉬운 절차지향 스크립트로 구성함
    • ETL 처럼 단순한 비즈니스 로직을 가진 지원 하위 도메인에 적합함
  • 액티브 레코드 패턴
    • 비즈니스 로직이 단순하지만 복잡한 자료 구조에서 작동하는 경우 해당 자료 구조를 액티브 레코드로 구현
    • 액티브 레코드 객체는 간단한 CRUD 데이터 접근 방법을 제공하는 자료 구조임

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 블라드 코노노프 저자의 도메인 주도 설계 첫걸음을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10