DDD 도메인 서비스

도메인 서비스

한 애그리거트에 넣기 애매한 도메인 기능을 억지로 특정 애그리거트에 구현하면 안 된다.

  • 코드가 길어지고 외부에 대한 의존이 높아지게 되며 코드를 복잡하게 만들어 수정을 어렵게 만든다.
  • 도메인 개념이 명시적으로 드러나지 않게 된다.

이런 경우에 도메인 기능을 별도 서비스로 구현하자.

도메인 서비스 특징

  • 도메인 서비스는 도메인 영역에 위치한 도메인 로직을 표현할 때 사용한다.
  • 도메인 서비스는 애그리거트나 밸류와 비교할 때 상태 없이 로직만 구현한다는 점에서 다르다.
  • 도메인 서비스를 사용하는 주체는 애그리거트가 될 수도 있고 응용 서비스가 될 수도 있다.
  • 특정 기능이 응용 서비스인지 도메인 서비스인지 애매할 때는 해당 로직이 애그리거트의 상태를 변경하거나 상태 값을 계산한다면 도메인 로직으로 판단하면 된다.

외부 시스템 연동과 도메인 서비스

외부 시스템이나 타 도메인과의 연동 기능도 도메인 서비스가 될 수 있다. 예를 들어 HTTP API 호출을 통해서만 확인할 수 있는 정보가 있을 때, 특정 도메인 입장에서는 도메인 로직이 될 수 있다.
중요한 점은 시스템 연동 관점에서 인터페이스를 작성하는 것이 아니라 도메인 로직 관점에서 인터페이스를 작성해야 한다는 것이다.

public interface SurveyPermissionChecker {
    boolean hasUserCreationPermission(String userId);
}

public class CreateSurveyService {
    private SurveyPermissionChecker surveyPermissionChecker;
    
    public Long createSurvey(CreateSurveyRequest request) {
        validate(request);
        
        // 도메인 서비스를 이용해서 외부 시스템 연동을 표현
        if (!surveyPermissionChecker.hasUserCreationPermission(request.getUserId())) {
            throw new BadRequestException();
        }
    }
    
    ...
}

도메인 서비스 패키지 위치

도메인 서비스는 다른 도메인 구성요소와 동일한 패키지에 위치시킨다. 도메인 서비스 개수가 많거나 다른 구성요소(엔티티, 밸류)와 명시적으로 구분하고자 할 때는 domain.model, domain.service, domain.repository와 같이 하위 패키지를 구분하자.

도메인 서비스의 인터페이스와 클래스

  • 도메인 서비스의 로직이 고정되어 있지 않은 경우 도메인 서비스 자체를 인터페이스로 구현하고 이를 구현한 클래스를 둘 수도 있다. 특히 외부 시스템이나 별도 엔진을 이용해서 구현할 때 인터페이스와 클래스를 분리하게 된다.
  • 도메인 영역에는 도메인 서비스 인터페이스가 위치하고 실제 구현은 인프라스트럭처 영역에 위치시킬 수 있다. 이를 통해 도메인 영역이 특정 구현에 종속되는 것을 방지할 수 있고 테스트가 쉬워진다.

선점 잠금(Pessimistic Lock)

  • 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식. 스레드2는 스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹된다.
  • 선점 잠금은 보통 DBMS 가 제공하는 행단위 잠금을 사용해서 구현한다.
  • 대부분의 DMBS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 커넥션만 접근할 수 있는 잠금장치를 제공한다.

선점 잠금과 교착상태

  • 선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태가 발생하지 않도록 주의해야 한다.
  • 잠금을 구할 때 최대 대기 시간을 지정해야 한다. JPA의 힌트는 잠금을 구하는 대기 시간을 밀리초 단위로 지정한다. 지정한 시간 이내에 잠금을 구하지 못하면 예외를 발생시킨다.
  • DBMS에 따라 힌트가 적용되지 않을 수도 있기 때문에 관련 기능을 지원하는지 확인해야 한다.
public interface MemberRepository extends Repository<Member, MemberId> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
            @QueryHint(name = "javax.persistence.lock.timeout", value = "2000")
    })
    @Query("select m from Member m where m.id = :id")
    Optional<Member> findByIdForUpdate(@Param("id") Memberid memberId);
}

DBMS에 따라 교착상태에 빠진 커넥션을 처리하는 방식이 다르다. 쿼리별로 대기 시간을 지정할 수 있는 DBMS가 있고 커넥션 단위로만 대기 시간을 지정할 수 있는 DBMS도 있다. 따라서 선점 잠금을 사용하려면 사용하는 DBMS에 대해 JPA가 어떤 식으로 대기 시간을 처리하는지 확인해야 한다.

비선점 잠금

  • 선점 잠금으로 모든 트랜잭션 충돌 문제가 해결되는 것은 아니다.
  • 비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.
  • 비선점 잠금은 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가한다. 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정한다. 그리고 수정에 성공하면 버전 값을 1 증가시킨다.
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
    @EmbeddedId
    private OrderNo number;
    
    @Version
    private long version;
    
    ...
}

JPA는 엔티티가 변경되어 UPDATE 쿼리를 실행할 때 @Version 에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다.

public class StartShippingService {
    public void startShipping(StartShippingRequest request) {
        Order order = orderRepository.findById(new OrderNo(request.getOrderNumber()));
        if (!order.matchVersion(request.getVersion())) { // 응용 서비스에 전달된 요청 데이터의 버전이 일치하는 체크
            throw new VersionConflictException();
        }
        ...
    }
}

@Controller
public class OrderAdminController {
    private StartShippingService startShippingService;
    
    @PostMapping("/startShipping")
    public String startShipping(StartShippingRequest request) {
        try {
            startShippingService.startShipping(request);
            return "shippingStarted";
        } catch (OptimisticLockingFailureException | VersionConflictException e) {
            // 트랜잭션 충돌 후 처리
            ...
        }
    }
}

폼을 서버에 전송할 때 버전을 함께 전송하게 한다면 트랜잭션 충돌 문제를 해소할 수 있다.

강제 버전 증가

  • 애그리거트 루트 외에 다른 엔티티가 존재하는데 기능 실행 도중 루트가 아닌 다른 엔티티의 값만 변경됐을 때 JPA는 루트 엔티티의 버전 값을 증가시키지 않는다.
  • 구성요소 중 일부 값이 바뀌면 논리적으로 애그리거트는 바뀐 것이다. 따라서 애그리거트 내에 어떤 구성요소의 상태가 바뀌면 루트 애그리거트의 버전값이 증가해야 비선점 잠금이 올바르게 동작한다.
  • JPA는 위와 같은 문제를 처리할 수 있도록 엔티티를 조회할 때 강제로 버전 값을 증가시키는 LockModeType.OPTIMISTIC_FORCE_INCREMENT 잠금 모드를 지원한다. 엔티티 상태 변경유무와 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다.

오프라인 선점 잠금

  • 단일 트랜잭션에서 동시 변경을 막는 선점 잠금 방식과 달리 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다.
  • 첫 번째 트랜잭션을 시작할 때 오프라인 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다. 잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.
  • 오프라인 선점 방식은 잠금 유효 시간을 가져야 한다. 유효 시간이 지나면 자동으로 잠금을 해제해서 다른 사용자가 잠금을 일정 시간 후 다시 구할 수 있도록 해야 한다. 이 경우 사용자가 수정 폼에서 오랜 시간동안 수정을 하고 있다면 수정 폼에 1분 단위로 Ajax 호출을 해서 잠금 유효 시간을 1분씩 증가시켜서 유효시간을 증가시키는 방법을 사용할 수도 있다.
public interface LockManager {
    // 잠금을 시도
    LockId tryLock(String type, String id) throws LockException;
    
    // 잠금 선점 확인
    void checkLock(LockId lockId) throws LockException;
    
    // 잠금 해제
    void releaseLock(LockId lockId) throws LockException;
    
    // 잠금 유효시간 증가
    void extendLockExpiration(LockId lockId, long inc) throws LockException;
}
// LockManager 구현에 필요한 테이블
create table locks {
    `type` varchar(255),
    id varchar(255),
    lockid varchar(255),
    expiration_time datetime,
    primary key (`type`, id)
} character set utf8;

create unique index locks_idx ON locks (lockid);
Reference
  • 도메인 주도 개발 시작하기, 최범균 지음

Categories:

Updated:

Comments