안녕하세요. 강남언니 광고 스쿼드에서 광고 시스템을 만들고 있는 백엔드 개발자 Joon입니다. 저는 강남언니 유저들에게 더 많은 가치를 지속적으로 전달할 수 있는 구조, 즉 지속 성장이 가능한 구조에 관심을 가지고 있습니다.

제가 백엔드 개발자가 된 이후로 가장 많이 들어 온 키워드 중 하나는 도메인 주도 설계(Domain-Driven Design, 이하 DDD)입니다. DDD는 현실 비즈니스 도메인을 잘 반영한 소프트웨어를 만들기 위한 여러 전략, 전술적인 도구의 모음으로, 많은 회사에서 소프트웨어 제품을 만드는 데 DDD에 소개된 도구들을 사용하고 있습니다. 강남언니에서는 최근 ‘성과형 광고 시스템’을 만들어 런칭했는데, 복잡한 성과형 광고 비즈니스 도메인의 문제를 소프트웨어로 잘 풀기 위해 DDD의 여러 전략, 전술 패턴을 사용했습니다.

성과형 광고 시스템에서는 실시간으로 많은 노출, 클릭과 그에 따른 과금이 발생하기 때문에 동시성 상황 관리에 특히 신경을 써야 했습니다. 그러다 보니 데이터베이스 트랜잭션, 낙관적 동시성 처리를 위한 버전 정보와 같은 영속성 지식을 도메인 모델에 노출하고 싶은 욕구가 발생했습니다. 여러 고민 끝에, 강남언니에서는 IoC(Inversion of Control)를 이용해 도메인 모델을 오염시키지 않고 동시성 문제를 방지할 수 있었습니다. 이 글에서는 강남언니가 성과형 광고 시스템 개발을 시작한 때로 거슬러 올라가, 간소화한 성과형 광고 시스템 일부를 만들어 나가면서 접한 문제 상황을 소개하려고 합니다. DDD 전략, 전술 도구들을 사용하면서 같은 고민을 해 보신 분들께 참고가 되기를 바랍니다.

성과형 광고

강남언니는 병원이 등록한 의료 광고를 앱에 노출하고 있습니다. 병원들은 본인의 의료 광고를 눈에 더 잘 띄는 위치에 노출하기 위해, 추가 비용을 내고 ‘부가 광고’를 신청할 수 있습니다. 강남언니는 지금까지, 병원에게 정해진 금액을 받고 일정 기간 동안 광고 노출을 보장하는 ‘보장형 부가 광고 시스템(이하 보장형 광고 시스템)’을 운영해 왔습니다. 보장형 광고 체계에서 병원은 광고를 한 번 신청하면 일정 기간 동안 안정적으로 광고를 노출할 수 있습니다. 또한 광고 신청 시점에 광고비가 정해져 있어, 광고 집행에 드는 비용을 확실하게 통제할 수 있는 장점이 있습니다. 하지만 강남언니에 입점한 병원 수가 늘어나면서 광고를 신청하고자 하는 병원 수도 늘어나, 선착순 광고 신청에 실패하는 병원들이 늘어나기 시작했습니다. 또한 어떤 병원은 충분히 지불 가능한 광고 비용이 다른 병원에게는 부담스러운 경우도 있습니다.

보장형 광고 시스템의 문제를 보완하고자, 강남언니에서는 ‘성과형 부가 광고(이하 성과형 광고)’라는 광고 시스템을 새로 출시했습니다. 성과형 광고는 보장형 광고와 달리, 노출, 클릭 등의 성과가 발생할 때마다 병원에게 광고비를 받는 시스템입니다. 성과형 광고 체계에서 병원은 입찰을 통해 원하는 비용으로 광고를 집행할 수 있습니다. 강남언니는 병원에게 광고가 항상 노출된다고 보장하지 않기 때문에, 여러 병원의 광고 신청을 제한 없이 받을 수 있습니다. 또한 각 유저 특성에 더 알맞는 광고를 보여줄 수 있습니다.

캠페인

성과형 광고 시스템을 설계하기 위해 광고 도메인 전문가와 대화하면서, 성과형 광고에는 ‘캠페인’이라는 개념이 있다는 것을 알게 되었습니다. 캠페인은 성과형 광고의 최상위 개념으로, 병원 관계자는 캠페인에 이름을 부여하고, 캠페인을 통해 예산을 관리합니다. 또한, 캠페인을 켜고 끄면서 광고의 노출 여부를 제어합니다. 예산 초과 여부, 광고할 상품의 상태 유효 여부 등에 따라 캠페인의 광고 노출 가능 여부는 달라지는데, 이 정보 역시 캠페인에 담깁니다. 즉, 광고 노출이 가능한 상태이면서, 병원 관계자가 캠페인을 켜 둔 경우에만 유저에게 광고를 노출할 수 있습니다.

public final class Campaign {
    private final UUID id; // 캠페인 식별자
    private final long hospitalId; // 병원 식별자
    private String name; // 병원 관계자가 부여한 캠페인 이름
    private Budget budget; // 예산
    private Status status; // 병원 관계자가 제어하는 캠페인 상태
    private boolean isAdExposureAvailable; // 광고 노출 가능 여부

    public enum Status {
        SWITCHED_ON,
        SWITCHED_OFF,
        DELETED,
    }
    ...
}

애그리거트 패턴(Aggregate Pattern)을 이용해 캠페인을 만듭니다. 캠페인은 풍부한 도메인 모델(Rich Domain Model, Anemic Domain Model과 대치)로, 캠페인의 상태를 변경하는 명령은 캠페인 애그리거트 내에 캡슐화되고, 캠페인 애그리거트 스스로 본인의 무결성을 책임지게 됩니다. 캠페인 식별자, 병원 식별자와 같이 변하지 않아야 하는 값은 불변 필드로, 변할 수 있는 값은 가변 필드로 구성합니다. 캠페인 하위에는 ‘광고 그룹’, ‘타겟팅’, ‘광고’와 같은 개념이 종속되는데, 이들은 캠페인과는 다른 생명 주기를 가져 별개의 애그리거트로 구성했습니다.

캠페인 켜기 기능

병원 관계자가 어드민을 통해 캠페인을 켜고 끌 수 있도록 하려고 합니다. 캠페인을 켜는 명령을 SwitchOnCampaign이라고 이름 지었습니다.

public final class SwitchOnCampaign {
    private final UUID id;

    public SwitchOnCampaign(UUID id) {
        this.id = id;
    }
}

SwitchOnCampaign 명령을 실행하는 명령 실행기를 작성합니다. 명령 실행기는 애그리거트가 수행할 수 없는 비즈니스 논리를 수행하는 일종의 도메인 서비스(Domain Service)로, 도메인 모델의 일부입니다.

예를 들어, ‘한 병원에서 캠페인을 최대 5개까지만 등록할 수 있다’는 비즈니스 정책이 있다고 하겠습니다. 캠페인 등록 명령이 발생했을 때 이 정책이 지켜지는지는 캠페인이 스스로 검사할 수 없기 때문에 캠페인 등록 명령 실행기(도메인 서비스)가 수행하게 됩니다.

Service, 혹은 Data, Info와 같은 접미어는 객체의 역할을 다소 모호하게 만들 수 있기 때문에 SwitchOnCampaignCommandExecutor라고 이름 짓겠습니다. 도메인 모델을 데이터베이스와 같은 영속 장치 관련 컨텍스트와 분리하기 위해 리파지토리 패턴(Repository Pattern)을 사용합니다.

public final class SwitchOnCampaignCommandExecutor {
    
    private final ICampaignRepository repository;

    public SwitchOnCampaignCommandExecutor(ICampaignRepository repository) {
        this.repository = repository;
    }

    public void execute(SwitchOnCampaign command) {
        Campaign campaign = repository.find(command.getId())
            .orElseThrow(() -> new AggregateNotFoundException());
        campaign.switchOn(command);
        repository.update(campaign);
    }
}

명령 실행기는 리파지토리로부터 캠페인을 읽어와, 캠페인에게 명령을 전달합니다. 이후 변경된 캠페인을 리파지토리로 저장합니다.


public final class Campaign {

    private final UUID id;
    private final long hospitalId;
    private Status status;
    ...
    private final List<ICampaignEvent<? extends ICampaignEventPayload>> events = new ArrayList<>();

    public void switchOn(SwitchOnCampaign command) {
        if (this.status.equals(Status.DELETED)) {
            throw new InvariantViolationException();
        }
        if (!this.status.equals(Status.SWITCHED_ON)) {
            this.status = Status.SWITCHED_ON;
            this.events.add(new CampaignSwitchedOn(...))
        }
    }
}

캠페인은 SwitchOnCampaign 명령을 받아 처리합니다. 명령이 유효한지를 검사하고, 캠페인의 상태가 SWITCHED_ON 상태가 되는 경우 CampaignSwitchedOn이라는 도메인 이벤트를 생성해, 자체 Collection에 저장합니다. 이 도메인 이벤트는 이후 외부 메시지 브로커로 전달되는데, 리파지토리가 그 준비 과정을 책임집니다. 이벤트 발행 과정에 대해서는 이후 다른 글에서 자세하게 다루겠습니다.

캠페인 예산 소진에 따른 광고 노출 여부 제어

강남언니 유저들은 화면에 노출된 광고를 클릭할 수 있습니다. 노출, 클릭과 같은 성과가 발생하면, 병원은 그에 따른 광고비를 강남언니에 지불합니다. 만약 캠페인의 예산이 소진되면, 캠페인의 광고 노출이 불가능하도록 해야 합니다. ‘캠페인의 예산 소진됨’ 이벤트를 BudgetExhausted로 이름 지었습니다.

public final class BudgetExhausted implements ICampaignEvent<BudgetExhaustedEventPayload>{
    private final UUID id;
    ...
}

도메인 이벤트가 외부 메시지 브로커로 전달되면, 메시지 브로커를 폴링하는 응용 프로그램 계층 내 메시지 리스너에 의해 시스템에 도착하게 됩니다. 리스너는 메시지를 도메인 이벤트로 정제해, 도메인 이벤트 처리기로 전달합니다. 이 도메인 이벤트 처리기 중 하나인 예산 소진됨 이벤트 처리기를 생성합니다. 이름은 BudgetExhaustedEventHandler로, 앞서 살펴 본 명령 실행기와 동일하게 영속 장치의 특성과는 무관하게 설계된 리파지토리 인터페이스에 의존하게 합니다.

public final class BudgetExhaustedEventHandler {

    private final ICampaignRepository repository;

    public BudgetExhaustedEventHandler(ICampaignRepository repository) {
        this.repository = repository;
    }

    public void handle(BudgetExhausted event) {
        Campaign campaign = repository.find(event.getId())
            .orElseThrow(() -> new AggregateNotFoundException());
        campaign.handle(event);
        repository.update(campaign);
    }
}

이벤트 처리기도 명령 실행기와 비슷하게 동작합니다. 리파지토리로부터 캠페인을 읽어와 상태를 변경한 다음 다시 저장합니다.


public final class Campaign {

    private final UUID id;
    private final long hospitalId;
    ...
    private boolean isAdExposureAvailable;
    private final List<ICampaignEvent<? extends ICampaignEventPayload>> events = new ArrayList<>();
    ...

    public void handle(BudgetExhausted event) {
        if (this.isAdExposureAvailable) {
            this.isAdExposureAvailable = false;
            this.events.add(new AdExposurePaused(...));
        }
    }
}

캠페인은 예산 소진됨 이벤트를 수신하면, 광고 노출이 불가능하다는 것을 기록합니다. 만약 이전에는 광고 노출이 가능했다면, 광고 노출 중단됨(AdExposurePaused) 이벤트를 생성해 자체 Collection에 저장합니다.

리파지토리 구현

지금까지는 등장한 리파지토리는 모두 인터페이스로, 데이터베이스 특성과는 무관하게 도메인 언어로만 작성된 도메인 모델이었습니다. 데이터베이스 특성을 고려한 구현들은 모두 리파지토리 구현체 내부에 숨겨져 있습니다.

public interface ICampaignRepository {

    Optional<Campaign> find(UUID id);

    void update(Campaign campaign);

    ...
}

이제는 리파지토리 구현체를 확인해 보겠습니다. 캠페인을 저장하는 데이터베이스는 관계형 데이터베이스인 Amazon Aurora MySQL로, 리파지토리 구현체는 JpaRepository를 이용해 작성했습니다.

public class CampaignRepository implements ICampaignRepository {

    private final CampaignJpaRepository jpaRepository;
    private final EventStore eventStore;

    @Override
    public Optional<Campaign> find(UUID id) {
        return jpaRepository.findById(id).map(CampaignDataModel::toEntity());
    }

    @Transactional
    @Override
    public void update(Campaign campaign) {
        CampaignDataModel dataModel = jpaRepository.findById(campaign.getId())
            .orElseThrow(() -> new AggregateNotFoundException());
        dataModel.update(campaign);
        eventStore.store(campaign.getEvents());
    }

    ...
}

update 메서드는 캠페인을 수정하고, 캠페인 인스턴스가 가지고 있는 도메인 이벤트들을 데이터베이스에 저장합니다. 이 두 가지 동작은 모두 성공하거나, 모두 실패해야 합니다. 원자성을 확보하기 위해 update 메서드는 트랜잭션 내에서 수행되도록 합니다.


리파지토리 구현체는 캠페인 애그리거트를 그대로 사용하지 않고, 데이터 모델을 따로 정의해 사용합니다. 데이터베이스로부터 읽어 온 데이터 모델 인스턴스는 캠페인 도메인 모델 인스턴스로 변환해 반환하며, Argument로 받은 캠페인 도메인 모델을 데이터베이스에 저장할 때에도 데이터 모델로 변환합니다.

...
@Entity
@Table(name = "campaigns")
public class CampaignDataModel {

    @Id
    @Column(columnDefinition = "BINARY(16)")
    private UUID id;

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long sequence;

    private long hospitalId;

    @Enumerated(value = EnumType.STRING)
    private Status status;

    ...

}

데이터 모델은 테이블 이름, 컬럼 정보 등, JPA가 데이터베이스 Row와 Java 클래스 인스턴스를 매핑하는 데 필요한 여러 가지 설정을 가지고 있습니다. 테이블 이름, 컬럼 정보는 사용하는 데이터베이스 특성에 강하게 결합된 정보입니다. 만약 성과형 광고를 운영하면서 생기는 새로운 요구 사항들을 만족하기 위해 데이터베이스 종류를 변경하게 되면, 데이터 모델은 새로 구현되어야 할 가능성이 높습니다.

더 나아가 데이터베이스 동작 최적화를 위해, 도메인 모델에는 없으나 데이터 모델에만 있는 정보도 있을 수 있습니다. 캠페인은 UUID 타입의 식별자를 가지고 있습니다. 하지만 MySQL 엔진에서 UUID 값을 담는 컬럼을 Primary Key로 지정하는 경우, 데이터 Insert 성능에 악영향이 있습니다. 그래서 UUID 타입의 식별자를 담는 컬럼은 Unique Key로 지정하고, sequence라는 숫자형 컬럼을 추가해 Primary Key로 사용하게 되었습니다. 역시 사용하는 데이터베이스 종류가 바뀌면 sequence 컬럼은 필요가 없어질 수도 있습니다.

도메인 모델은 비즈니스 도메인의 지식을 추상화한 결과물로, 도메인 지식은 일반적으로 데이터베이스와 무관합니다. ‘성과형 광고’라는 비즈니스 도메인에서 중요한 것은 ‘캠페인을 저장한다’는 지식이지, ‘캠페인을 MySQL 데이터베이스에 저장한다’ 혹은 ‘캠페인을 MongoDB 4.4에 저장한다’가 아닙니다. 사용하는 데이터베이스 종류나 버전이 변경되더라도 ‘캠페인을 저장한다’는 도메인 모델에는 영향이 없도록 하기 위해, 별개의 모델(데이터 모델)을 활용합니다.

동시성 문제 시나리오

이제 앞서 살펴 본 두 가지의 캠페인 변경 프로세스를 자세히 들여다 보려고 합니다.

병원 관계자는 어드민을 통해 캠페인을 켤 수 있습니다. 캠페인을 켜는 명령이 들어오면 명령 실행기는 캠페인을 읽어와 상태를 변경한 다음 다시 저장합니다.

public final class SwitchOnCampaignCommandExecutor {

    private final ICampaignRepository repository;

    public SwitchOnCampaignCommandExecutor(ICampaignRepository repository) {
        this.repository = repository;
    }

    public void execute(SwitchOnCampaign command) {
        Campaign campaign = repository.find(command.getId())
            .orElseThrow(() -> new AggregateNotFoundException());
        campaign.switchOn(command);
        repository.update(campaign);
    }
}

강남언니 유저들은 화면에 노출된 광고를 클릭할 수 있습니다. 노출, 클릭과 같은 성과가 발생하면, 병원은 그에 따른 광고비를 강남언니에 지불합니다. 만약 병원이 설정한 예산 이상의 광고비가 발생하면 예산 소진됨 이벤트가 발생하고, 예산 소진됨 이벤트를 처리하는 이벤트 처리기는 광고 노출을 중지하게 됩니다.

public final class BudgetExhaustedEventHandler {

    private final ICampaignRepository repository;

    public BudgetExhaustedEventHandler(ICampaignRepository repository) {
        this.repository = repository;
    }

    public void handle(BudgetExhausted event) {
        Campaign campaign = repository.find(event.getId())
            .orElseThrow(() -> new AggregateNotFoundException());
        campaign.handle(event);
        repository.update(campaign);
    }
}

Happy Path

한 프로세스 동작이 종료된 이후에 다른 프로세스가 동작하는 경우, 동시성 문제는 발생하지 않습니다.

예를 들어, SwitchOnCampaignCommandExecutor의 동작이 모두 끝난 다음 BudgetExhaustedEventHandler 동작이 시작하는 시나리오에서는 문제가 발생하지 않습니다. SwitchOnCampaignCommandExecutor는 캠페인의 statusSWITCHED_ON으로 변경해 저장합니다. BudgetExhaustedEventHandler가 캠페인을 읽어왔을 때 캠페인의 statusSWITCHED_ON이며, 이벤트 처리기는 isAdExposureAvailablefalse로 변경해 저장합니다.

Unhappy Path(Write Skew)

문제는 두 캠페인 변경 프로세스가 동시에 동작할 때 발생합니다. 병원 관계자가 캠페인을 켜 SwitchOnCampaignCommandExecutor가 동작하는 동시에, 다른 쓰레드에서는 캠페인의 예산이 모두 소진되어 BudgetExhaustedEventHandler가 동작하는 시나리오를 예로 들어 보겠습니다.

SwitchOnCampaignCommandExecutor가 캠페인의 statusSWITCHED_ON으로 변경해 저장하기 전에 BudgetExhaustedEventHandler가 캠페인을 조회합니다. SwitchOnCampaignCommandExecutor가 데이터베이스에 캠페인을 저장하지만, 이는 곧이어 BudgetExhaustedEventHandler에 의해 덮어쓰입니다. 이런 문제가 발생하는 이유는 BudgetExhaustedEventHandler가 데이터베이스로부터 데이터를 읽어 온 이후 데이터베이스에 데이터를 쓰는 시점에 데이터의 상태가 달라졌기 때문입니다. 만약 예산 소진됨 이벤트 처리 결과가 덮어쓰인다면, 병원이 설정한 예산을 초과해 광고가 집행되는 등 심각한 문제가 발생할 수도 있습니다.

동시성 문제 해결 시나리오

비관적 잠금(Pessimistic Lock)

이제 앞서 발견한 동시성 문제 발생 시나리오에 대비하려고 합니다. 처음으로 고려해 본 것은 비관적 잠금(Pessimistic Lock)입니다. 비관적 잠금은 충돌 문제가 발생할 것이라고 가정하고, 다른 주체가 같은 데이터를 변경하지 못하게(혹은 읽지도 못하게) 방지하는 방법입니다. 아래와 같이 캠페인 조회 메서드에 배타적 잠금을 걸어, 한 쓰레드가 먼저 캠페인을 조회하면 다른 쓰레드는 캠페인을 조회하지 못하고 대기하도록 할 수 있습니다. 비관적 잠금은 데이터베이스에서 제공하는 기능을 사용하는 것으로, SELECT … FOR UPDATE;와 같은 쿼리가 생성됩니다.

public interface CampaignJpaRepository extends JpaRepository<CampaignDataModel, UUID> {

    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    Optional<CampaignDataModel> findById(UUID id);

}
public class CampaignRepository implements ICampaignRepository {

    private final CampaignJpaRepository jpaRepository;
    private final EventStore eventStore;

    @Override
    public Optional<Campaign> find(UUID id) {
        return jpaRepository.findById(id).map(CampaignDataModel::toEntity());
    }

    @Transactional
    @Override
    public void update(Campaign campaign) {
        CampaignDataModel dataModel = jpaRepository.findById(campaign.getId())
            .orElseThrow(() -> new AggregateNotFoundException());
        dataModel.update(campaign);
        eventStore.store(campaign.getEvents());
    }
}

비관적 잠금은 트랜잭션 내에서만 유효합니다. Lock을 획득한 쓰레드가 트랜잭션을 commit하면, Lock이 release되어 다른 쓰레드가 Lock을 획득할 수 있게 됩니다. 따라서 원자성이 확보되어야 하는 코드 모음은 하나의 트랜잭션 내에서 실행되어야 합니다.


앞서 동시성 문제 시나리오에서 확인한 것에 따르면, 원자성은 명령 실행기와 이벤트 처리기 단위로 확보되어야 합니다. 따라서 명령 실행기와 이벤트 처리기 각각은 트랜잭션 내에서 동작해야 합니다.

public final class SwitchOnCampaignCommandExecutor {

    private final ICampaignRepository repository;

    public SwitchOnCampaignCommandExecutor(ICampaignRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void execute(SwitchOnCampaign command) {
        Campaign campaign = repository.find(command.getId())
            .orElseThrow(() -> new AggregateNotFoundException());
        campaign.switchOn(command);
        repository.save(campaign);
    }
}
public final class BudgetExhaustedEventHandler {

    private final ICampaignRepository repository;

    public BudgetExhaustedEventHandler(ICampaignRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void handle(BudgetExhausted event) {
        Campaign campaign = repository.find(event.getId())
            .orElseThrow(() -> new AggregateNotFoundException());
        campaign.handle(event);
        repository.update(campaign);
    }
}

저는 이 방법을 선택하지 않기로 했습니다. 첫 번째 이유는 트랜잭션이 도메인 지식이 아니라고 판단했기 때문입니다. 데이터베이스 종류나 데이터베이스를 사용하는 방법에 따라 트랜잭션 적용 여부는 달라집니다. 예를 들어, 캠페인을 저장하는 데이터베이스를 MongoDB로 변경한다면 트랜잭션을 사용할 수 없게 될 수도 있습니다.

‘성과형 광고’라는 비즈니스 도메인에서 중요한 것은 ‘캠페인을 저장한다’는 지식이지, ‘캠페인을 MySQL 데이터베이스에 저장한다’ 혹은 ‘캠페인을 MongoDB 4.4에 저장한다’가 아닙니다.

성과형 광고 비즈니스 도메인에서 중요한 것은 ‘캠페인을 저장한다’는 지식이지, ‘캠페인을 데이터베이스 트랜잭션 내에서 저장한다’가 아님을 상기하였습니다.


두 번째 이유는, 명령 실행기와 이벤트 처리기에 트랜잭션을 적용하려면 도메인 모델에 Spring 의존성이 필요하기 때문입니다. 성과형 광고 시스템은 아래 그림과 같이 여러 개의 Java 모듈로 구성했고, 그 중 domain-model 모듈에는 애그리거트, 명령 실행기, 이벤트 처리기와 같은 도메인 모델이 배치되었습니다. 다른 모듈들과는 달리 domain-model 모듈은 Spring과 같은 프레임워크에 의존하지 않게 하고자 했습니다. 하지만 @Transactional 어노테이션 또는 PlatformTransactionManager 인터페이스는 Spring이 제공하고 있어, 명령 실행기와 이벤트 처리기에 트랜잭션을 적용하기 위해서는 domain-model 모듈에 Spring 의존성이 추가되어야 합니다.

Spring이 지원하는 기능들 중에는 도메인 모델에 사용해도 문제 없는 것들도 많지만, 그렇지 않은 것도 있습니다. 예를 들어, Spring StringUtils 클래스는 문자열과 관련된 유용한 메서드들을 제공합니다. ‘병원 관계자가 입력한 캠페인 이름 앞 뒤의 공백을 제거해 저장한다’는 정책이 있다면, 이 비즈니스 정책을 만족하기 위해 StringUtils.trimWhitespace 메서드를 활용할 수 있습니다. Spring의 기능을 사용했지만, 도메인 지식이 아닌 것이 도메인 모델에 침투하지는 않습니다.

반면, 데이터 페이지네이션을 위해 사용하는 Spring의 Pageable 인터페이스는 어떨까요? 클라이언트, 도메인 모델, 리파지토리 구현체 모두 Pageable 인터페이스를 이용하던 중, 만약 데이터베이스를 MongoDB로 변경하게 되면서 더 이상 Pageable 인터페이스를 이용할 수 없게 되면 어떻게 해야 할까요? 클라이언트 코드, 도메인 모델, 리파지토리 구현체 모두를 수정하는 것이 제일 깔끔하지만, 동작하고 있는 소프트웨어에 이런 큰 변경을 만들기는 어렵습니다. 코드 어딘가에 Spring Pageable과 MongoDB 용도의 페이지네이션 클래스를 변환해 주는 코드를 만들거나, 기존 API를 그대로 버려둔 채 새로운 버전의 API를 만드는 일이 가장 쉬울 것입니다. 이 글을 읽으시는 많은 분들이 이런 이유로 만들어진 레거시 코드를 많이 보셨을 거라 생각합니다.

이 시나리오가 너무 비관적인 것일 수도 있습니다. 당연히 도메인 모델이 Spring에 절대 의존하지 말아야 한다는 것은 아닙니다. 비즈니스 상황에 따라, 드는 비용에 따라, 도메인 모델에 Spring 의존성을 추가하고 명령 실행기와 이벤트 처리기에 트랜잭션을 적용할 수도 있겠습니다. 하지만 우리 비즈니스 요구 사항은 꽤 자주 변하고, 그에 따른 소프트웨어 변경도 잦을 거라고 예상했습니다. 저는 데이터베이스 관련 변경이 필요할 때에는 도메인 모델에 영향 없이 data-access 모듈만 변경하고 싶습니다. 비즈니스 변화에 잘 대응할 수 있도록 설계 확장성을 확보하는 데에는, domain-model 모듈이 아예 Spring에 의존하지 않아 Spring이 제공하는 기능을 사용할 수 없도록 강제하는 것이 좋겠다고 판단했습니다.

낙관적 잠금(Optimistic Lock)

비관적 잠금에 이어, 낙관적 잠금을 적용하는 것을 고려해 보겠습니다. 낙관적 잠금은 대부분의 경우 충돌이 발생하지 않을 것이라 가정해 여러 주체가 같은 데이터를 읽고 변경할 수 있도록 하고, 충돌이 발생하면 충돌한 변경 중 하나만 반영되도록 하는 방법입니다. 낙관적 잠금에 사용할 기준 값을 두고, 읽은 후 쓸 때까지 기준 값이 바뀌지 않은 경우 변경이 반영되는 방식입니다. 낙관점 잠금을 이용할 때에는 원자성이 확보되어야 하는 코드 모음에 트랜잭션이 적용되지 않아도 됩니다.

예를 들어, version이라는 프로퍼티을 낙관적 잠금에 이용한다고 하겠습니다. 데이터베이스 테이블에 version 컬럼을 생성하고, 어플리케이션도 그에 대응하는 version 프로퍼티를 생성해 이용합니다.

  1. 쓰레드 1이 캠페인을 DB로부터 읽었을 때, version 값은 n입니다.
  2. 쓰레드 2가 캠페인을 DB로부터 읽었을 때, version 값은 n입니다.
  3. 쓰레드 1이 캠페인을 수정(UPDATE campaigns SET … , version = version + 1 WHERE id = {id} AND version = n)합니다. 수정에 성공하면서 version 값은 n+1이 됩니다.
  4. 쓰레드 2가 캠페인을 수정(UPDATE campaigns SET … , version = version + 1 WHERE id = {id} AND version = n)합니다. version 값이 일치하지 않아, 수정에 실패합니다.

아래와 같이 Spring JPA에서 제공하는 @Version 어노테이션을 이용해 낙관적 잠금을 사용할 수 있습니다. 위 시나리오의 4번과 같이 다른 주체가 이미 데이터를 수정해 버전 값이 달라진 경우, Spring JPA는 예외를 발생시킵니다. 이 예외를 이용해 재시도, 에러 반환 등의 처리를 할 수 있습니다.

...
@Entity
@Table(name = "campaigns")
public class CampaignDataModel {

    @Id
    @Column(columnDefinition = "BINARY(16)")
    private UUID id;

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long sequence;

    private long hospitalId;

    @Enumerated(value = EnumType.STRING)
    private Status status;

    @Version
    private Long version;

    ...

}

명령 실행기와 이벤트 처리기는 캠페인을 읽어왔을 때 기존 버전 값을 알고 있어야 합니다. 앞서 살펴 본 것처럼 명령 실행기와 이벤트 처리기 단위에서 원자성이 확보되어야 하기 때문입니다.

public final class SwitchOnCampaignCommandExecutor {

    private final ICampaignRepository repository;

    public SwitchOnCampaignCommandExecutor(ICampaignRepository repository) {
        this.repository = repository;
    }

    public void execute(SwitchOnCampaign command) {
        Campaign campaign = repository.find(command.getId())
            .orElseThrow(() -> new AggregateNotFoundException()); // version 값을 받아 와야 합니다.
        campaign.switchOn(command);
        repository.update(campaign); // 읽어 온 version 값을 repository로 넘겨야 합니다.
    }
}
public final class BudgetExhaustedEventHandler {

    private final ICampaignRepository repository;

    public BudgetExhaustedEventHandler(ICampaignRepository repository) {
        this.repository = repository;
    }

    public void handle(BudgetExhausted event) {
        Campaign campaign = repository.find(event.getId())
            .orElseThrow(() -> new AggregateNotFoundException()); // version 값을 받아 와야 합니다.
        campaign.handle(event);
        repository.update(campaign); // 읽어 온 version 값을 repository로 넘겨야 합니다.
    }
}

명령 실행기와 이벤트 처리기가 버전 정보를 알아야 하고, 이를 위해서는 캠페인 애그리거트에 버전 정보를 추가해 주어야 합니다. 리파지토리 구현체는 데이터베이스에서 읽어온 데이터 모델을 캠페인 애그리거트로 변환하는데, 이 때 캠페인 애그리거트에 버전 정보도 입력해 주어야 합니다.

public final class Campaign {

    private final UUID id;
    private final long hospitalId;
    private Status status;
    ...
    private long version;

    public void handle(BudgetExhausted event) {
        if (this.isAdExposureAvailable) {
            this.isAdExposureAvailable = false;
            this.events.add(new AdExposurePaused(...));
        }
    }
}
...
@Entity
@Table(name = "campaigns")
public class CampaignDataModel {

    @Id
    @Column(columnDefinition = "BINARY(16)")
    private UUID id;

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long sequence;

    private long hospitalId;

    @Enumerated(value = EnumType.STRING)
    private Status status;

    @Version
    private Long version;

    ...

    public static CampaignDataModel create(Campaign entity) {
        return new CamapaignDataModel(
            entity.getId();
            ...,
            entity.getVersion();
        );
    }

    public Campaign toEntity() {
        return new Campaign(
            id,
            hospitalId,
            ...,
            version
        );
    }
}

낙관적 잠금을 사용하려니, 비관적 잠금을 사용할 때와 비슷한 문제가 생깁니다. 트랜잭션과 마찬가지로 버전 정보는 도메인 지식이 아닙니다. ‘읽어 왔을 때의 버전이 유지되고 있을 때만 데이터를 업데이트한다’는 것은 영속성 지식이지, 도메인 지식이 아닙니다. 트랜잭션, Spring 의존성을 도메인 모델에 침투시키는 것보다는 꺼림칙함이 덜하지만, 저는 이 방법도 선택하지 않기로 했습니다. 버전 정보와 같은 영속성 지식은 데이터 모델, 리파지토리 구현체 등 data-access 모듈에만 숨기고 싶습니다.

If sophisticated domain experts don’t understand the model, there is something wrong with the model.

만약 수준 높은 도메인 전문가가 모델을 이해하지 못한다면, 모델에는 무언가 잘못된 것이 있는 것이다.

- Eric Evans

광고 도메인 전문가가 인식하는 캠페인에는 버전과 같은 정보는 없을 것입니다.

차라리..?

도메인 모델에 영속성 지식을 노출하지 않기 위해 고민하다보니, 차라리 리파지토리 메서드를 잘게 쪼개는 것이 나을까 하는 생각이 들기도 합니다.

public interface ICampaignRepository {
    ...
    void edit(EditCampaign command);
    void switchOn(SwitchOnCampaign command);
    void switchOff(SwitchOffCampaign command);
    void handle(BudgetExhausted event);
    ...
}
public class CampaignRepository implements ICampaignRepository {

    private final CampaignJpaRepository jpaRepository;
    private final EventStore eventStore;

    ...

    @Transactional
    @Override
    public void switchOn(SwitchOnCampaign command) {
        validate(command);
        Campaign campaign = jpaRepository.find(command.getId())
            .orElseThrow(() -> new AggregateNotFoundException());
        campaign.switchOn(command);
        repository.save(campaign);
    }

    ...
}

명령 실행기, 이벤트 처리기가 수행하던 논리들이 리파지토리 구현체에 작성됩니다. 리파지토리 구현체는 domain-model 모듈이 아닌 data-access 모듈에 있어, 리파지토리 구현체는 비관적 잠금과 낙관적 잠금 모두 사용할 수 있습니다. 하지만 저는 이 방법도 사용하지 않기로 했습니다. 도메인 모델에 영속성 지식이 드러나는 문제는 없지만, 반대로 도메인 모델에 있어야 할 중요한 비즈니스 논리들이 도메인 모델 밖으로 확산되기 때문입니다. 코드의 재사용성 또한 저하됩니다.

해결 방법

앞서 생각해 본 방법들을 이용하면 도메인 모델이 영속성 지식을 다루게 되거나, 도메인 모델에 있어야 할 중요한 비즈니스 논리들이 흩어지게 됩니다. 결국 명령 실행기, 이벤트 처리기가 애그리거트를 읽어와 수정하고, 저장하는 방법으로는 도메인 모델을 망치지 않으면서 동시성 문제를 해결할 수 없습니다. 그래서, ‘애그리거트를 읽어와 수정하고 저장하는’ 문제의 코드 흐름 제어권 배치를 바꾸기로 했습니다.

public interface ICampaignRepository {
    ...
    void update(UUID id, Consumer<Campaign> modifier);

}

리파지토리는 캠페인을 입력받는 대신, 수정할 캠페인의 식별자와, 캠페인을 수정하는 함수를 입력받도록 합니다.


명령 실행기와 이벤트 처리기는 이제 캠페인을 직접 읽어와 수정하지 않습니다. 리파지토리에게 수정해야 할 캠페인의 식별자와, 캠페인을 읽으면 적용할 함수를 전달합니다.

public final class SwitchOnCampaignCommandExecutor {

    private final ICampaignRepository repository;

    public SwitchOnCampaignCommandExecutor(ICampaignRepository repository) {
        this.repository = repository;
    }

    public void execute(SwitchOnCampaign command) {
        repository.update(command.getId(), campaign -> campaign.switchOn(command));
    }
}
public final class BudgetExhaustedEventHandler {

    private final ICampaignRepository repository;

    public BudgetExhaustedEventHandler(ICampaignRepository repository) {
        this.repository = repository;
    }

    public void handle(BudgetExhausted event) {
        repository.update(event.getId(), campaign -> campaign.handle(event);
    }
}

리파지토리는 입력받은 식별자와 캠페인 수정 함수를 이용해, 캠페인을 읽고 수정한 다음 저장합니다.

public class CampaignRepository implements ICampaignRepository {

    private final CampaignJpaRepository jpaRepository;
    private final EventStore eventStore;

    ...

    @Transactional
    @Override
    public void update(UUID id, Consumer<Campaign> modifier) {
        CampaignDataModel dataModel = jpaRepository.findById(id)
            .orElseThrow(AggregateNotFoundException::new);
        Campaign campaign = dataModel.toEntity();
        modifier.accept(campaign);
        dataModel.update(campaign);
        eventStore.store(campaign.getEvents());
    }
}

이제 데이터를 읽어오고, 수정하고, 저장하는 일련의 과정 모두를 리파지토리가 수행합니다. 리파지토리 메서드에 적용된 트랜잭션 내에서 비관적 잠금을 이용할 수도 있고, 데이터 모델에만 버전 정보를 추가해 낙관적 잠금을 이용할 수도 있습니다. 어느 방법을 이용하든 도메인 모델은 영속성 지식으로 오염되지 않고, 비즈니스 논리가 도메인 모델을 벗어나 리파지토리 구현체로 확산되지 않습니다.

IoC(Inversion of Control)

이런 관점의 변경을 IoC(Inversion of Control)라고 부르기도 합니다. 원래 리파지토리를 통해 읽어온 캠페인의 상태를 변경하는 것은 명령 실행기와 이벤트 처리기의 역할입니다. 제어권 배치가 변경된 후에는 리파지토리가 캠페인 상태 변경을 제어하게 되었습니다.


이 글에서 우리가 작성해 온 코드는 사실 처음부터 IoC를 이용하고 있었습니다. 이벤트 처리기를 예로 들어 보겠습니다. 이벤트 처리기 입장에서, 이벤트 처리기가 동작하기 위해 필요한 의존성을 스스로 생성하는 흐름을 가장 쉽게 떠올릴 수 있습니다.

public final class BudgetExhaustedEventHandler {
	
    private final ICampaignRepository repository;

    public BudgetExhaustedEventHandler() {
        this.repository = new CampaignRepository(...);
    }

    ...
}

하지만 우리는 의존성을 모듈별로 잘 나누어 관리하기 위해서, 또는 Spring이 Proxy를 이용해 트랜잭션을 지원하기 때문에, 이하 여러 가지 이유로 이벤트 처리기에 의존성을 주입해 주었습니다. 이 역시 제어의 역전입니다.

public final class BudgetExhaustedEventHandler {

    private final ICampaignRepository repository;

    public BudgetExhaustedEventHandler(ICampaignRepository repository) {
        this.repository = repository;
    }

    ...
}
@Configuration
public class AdManagerServiceConfiguration {

    @Bean
    public ICampaignRepository campaignRepository(
        CampaignJpaRepository jpaRepository,
        JpaEventStore jpaEventStore
    ) {
        return new CampaignRepository(
            jpaRepository,
            new CampaignEventStore(jpaEventStore)
        );
    }

    @Bean
    public BudgetExhaustedEventHandler(ICampaignRepository repository) {
        return new BudgetExhaustedEventHandler(repository);
    }
}

마치며

지금까지 DDD의 여러 도구를 사용해 만든 강남언니 성과형 광고 시스템을 예시로, 어떻게 영속성 지식과 도메인 지식을 잘 분리하면서 동시성 문제를 방지할 수 있는지를 소개했습니다. 여러 DDD 자료에 도메인 모델은 Spring과 같은 프레임워크나 데이터베이스 등의 인프라스트럭처에 의존하지 않아야 한다고 소개되어 있지만, 저는 트랜잭션, 버전 등의 지식을 도메인 모델에 노출하고 싶은 욕구가 드는 상황이 많았습니다. 저와 같은 고민을 해 보신 분들께 도움이 되면 좋겠습니다. 긴 글 읽어 주셔서 감사합니다.

Joon
Server Developer
강남언니에서 백엔드 개발을 하고 있습니다. 유저들에게 더 많은 가치를 지속적으로 전달할 수 있는 좋은 설계, 좋은 구조에 관심을 가지고 있습니다.