콘텐츠로 이동

ShoppingMall 따라 읽기

가이드 홈 | 이전: DeliveryDispatch 따라 읽기 | 다음: GameQuest 따라 읽기

이 장을 읽고 나면

ShoppingMall 샘플을 편집기에 열고, HTTP로 접수된 주문이 owner Spot에서 재고 예약 → 결제 승인 → 확정으로 진행되고 실패하면 보상되기까지 코드를 따라갈 수 있다. 이 장의 코드는 언어별 예제 저장소의 ShoppingMall 샘플에서 가져온다.

샘플 고르기가 이 샘플이 무엇을 보여 주는지 소개했다. 이 장은 그 소개 다음에 읽는 자리다 — 역할과 코드 위치, 주요 시나리오의 메시지 흐름, 각 흐름에 등장하는 framework 기능과 그것을 설명하는 장을 소스가 놓인 순서대로 따라간다. 이 장은 ShoppingMall 샘플의 역할과 코드 위치, 주요 메시지 흐름, 실행 검증을 소스 순서대로 설명한다. 요구사항, 메시지 계약과 검증 기준은 ShoppingMall 시나리오에서 참고한다.

1. 이 샘플이 보여 주는 것

주문 하나는 OrderId를 id로 하는 Instance Spot이 소유한다. API는 HTTP를 종단하고 주문 상태를 직접 바꾸지 않으며, 같은 id로 요청을 보내기만 한다. Spot 안의 코드는 event stream을 다시 읽어 현재 단계를 알아내고 다음 단계 하나를 실행한 뒤 event를 기록한다 — 조율 상태, 스케줄러, outbox 같은 별도 계층이 없다.

↗ 크게 보기

event sourcing은 framework 기능이 아니라 이 샘플이 Spot 위에 둔 application 설계다. framework가 맡는 것은 OrderId로 현재 owner를 찾는 routing, 첫 요청에서의 생성, 그리고 한 주문의 handler를 한 번에 하나씩 실행하는 것이다.

2. 역할과 코드 위치

역할 process 수 소유하는 것 코드 위치
CommerceApi 2 HTTP 입력 검증, idempotency mapping, workflow 요청, read model 조회 Server/CommerceApi
OrderWorkflow 2 order workflow Instance Spot, replay·다음 단계·event 기록, 외부 module adapter Server/OrderWorkflow
Client 1 주문 시작, polling, 중복·실패·재개·rebuild assertion Client

상태 전이, event 생성과 보상 규칙은 Server/OrderWorkflow/Domain에 있고 framework type을 참조하지 않는다. replay, fold, 다음 단계 판정과 expected version 기록은 Server/OrderWorkflow/Application이 맡는다. 메시지는 shared의 JSON 계약이 정한다.

3. 서버 구성

OrderWorkflow는 mesh의 object server로 order workflow Instance Spot factory를 등록한다. factory는 relocation 때 상태를 옮기지 않고 target에서 다시 만드는 정책을 고른다 — 상태는 event stream에서 복원하면 되기 때문이다.

Server/OrderWorkflow/src/main/java/systems/zlink/samples/shoppingmall/server/orderworkflow/Program.java

ZLinkMeshNodeBuilder node = options.addRouteMesh(SampleNames.OrderSpotDiscovery);
node.listen(workflow.spotRouterEndpoint())
        .setRoutingId(RoutingId.from(workflow.instanceName()));
node.objects()
        .server()
        .addInstanceSpotFactory(
                SampleNames.OrderWorkflowSpotType,
                OrderWorkflowSpot.class,
                factory -> factory.recreateOnRelocation());

CommerceApi는 같은 mesh의 object client다. 두 API와 두 Workflow가 있지만 어느 쪽도 상대의 endpoint를 알지 않는다.

Server/CommerceApi/src/main/java/systems/zlink/samples/shoppingmall/server/commerceapi/Program.java

options.addRouteMesh(SampleNames.OrderSpotDiscovery)
        .setRoutingId(RoutingId.from(api.instanceName()))
        .listen()
        .objects()
        .client();

Instance Spot의 생성 정책은 활성화와 수명이, relocation 때 다시 만드는 정책은 Relocation이 다룬다.

4. 주문 시작 — 첫 요청이 owner를 만든다

client가 HTTP로 주문을 시작하면 API는 idempotency key를 확인한다. 이미 시작된 주문이면 read model을 돌려주고, 아니면 mapping을 예약한 뒤 workflow에 Start를 요청한다.

↗ 크게 보기

idempotency key가 같은 시작 요청을 같은 주문으로 연결하므로 중복 요청이 새 workflow를 만들지 않는다.

Server/CommerceApi/src/main/java/systems/zlink/samples/shoppingmall/server/commerceapi/CommerceApiService.java

validate(request);
Messages.CartSeed cart = store.getCart(request.cartId());
store.validateShippingAddress(request.shippingAddressId());
store.getPaymentMethod(request.paymentMethodId());
OrderDomain.IdempotencyMapping mapping =
        store.reserveIdempotency(request.idempotencyKey(), topology.api().instanceName());
store.saveOrderPaymentMethod(mapping.orderId(), request.paymentMethodId());
Messages.OrderState existing = store.findProjection(mapping.orderId());
if (existing != null) {
    return CompletableFuture.completedFuture(
            new Messages.StartOrderRes(mapping.orderId(), existing.status()));
}

Messages.StartOrderWorkflowReq workflowRequest =
        workflowRequest(request, mapping.orderId(), cart);
return workflowRequest(mapping.orderId(), workflowRequest)
        .timeout(SampleTimings.WorkflowTimeout)
        .submit(Messages.StartOrderWorkflowRes.class)
        .thenApply(
                started -> {
                    store.markIdempotencyStarted(request.idempotencyKey());
                    return new Messages.StartOrderRes(
                            mapping.orderId(), started.state().status());
                })
        .exceptionallyCompose(
                failure -> {
                    Throwable cause =
                            failure instanceof CompletionException
                                            && failure.getCause() != null
                                    ? failure.getCause()
                                    : failure;
                    if (cause instanceof ZLinkFrameworkException framework
                            && framework.kind() == ZLinkFrameworkErrorKind.REJECTED) {
                        OrderDomain.IdempotencyMapping stored =
                                store.findIdempotency(request.idempotencyKey());
                        Messages.OrderState state =
                                stored == null
                                        ? null
                                        : store.findProjection(stored.orderId());
                        if (state != null) {
                            return CompletableFuture.completedFuture(
                                    new Messages.StartOrderRes(
                                            stored.orderId(), state.status()));
                        }
                    }
                    return CompletableFuture.failedFuture(cause);
                });

workflow로 가는 요청은 모두 같은 형태다 — OrderId를 Spot id로, Instance Spot type과 mesh를 함께 적는다. 그 id의 Spot이 없으면 첫 요청이 eligible node 중 한 곳에 만들고, 있으면 현재 owner가 받는다.

Server/CommerceApi/src/main/java/systems/zlink/samples/shoppingmall/server/commerceapi/CommerceApiService.java

private ZLinkSpotRequestCall workflowRequest(String orderId, Object request) {
    return routes.requestToSpot(orderId, request)
            .instanceSpot(SampleNames.OrderWorkflowSpotType)
            .inMesh(SampleNames.OrderSpotDiscovery);
}

Spot 쪽에서는 request handler가 요청을 Spot의 start 메서드로 넘긴다.

Server/OrderWorkflow/src/main/java/systems/zlink/samples/shoppingmall/server/orderworkflow/spots/handlers/StartOrderWorkflowSpotHandler.java

public final class StartOrderWorkflowSpotHandler
        implements ZLinkSpotRequestHandler<
                OrderWorkflowSpot, Messages.StartOrderWorkflowReq, Messages.StartOrderWorkflowRes> {
    private final OrderWorkflowService workflow;

    public StartOrderWorkflowSpotHandler(OrderWorkflowService workflow) {
        this.workflow = workflow;
    }

    @Override
    public CompletionStage<Messages.StartOrderWorkflowRes> handle(
            OrderWorkflowSpot spot, Messages.StartOrderWorkflowReq request) {
        System.out.println(
                "shoppingmall-order started order="
                        + request.orderId()
                        + " spot="
                        + spot.context().spotId());
        return workflow.startInSpot(spot, request)
                .thenCompose(
                        state ->
                                spot.closeIfTerminal(state)
                                        .thenApply(
                                                ignored ->
                                                        new Messages.StartOrderWorkflowRes(state)));
    }
}

start는 event stream에 시작 event를 기록하고 Created 상태를 응답한다. 예약·승인·확정은 응답 뒤에 이어진다.

Server/OrderWorkflow/src/main/java/systems/zlink/samples/shoppingmall/server/orderworkflow/spots/handlers/StartOrderWorkflowSpotHandler.java

@Override
public CompletionStage<Messages.StartOrderWorkflowRes> handle(
        OrderWorkflowSpot spot, Messages.StartOrderWorkflowReq request) {
    System.out.println(
            "shoppingmall-order started order="
                    + request.orderId()
                    + " spot="
                    + spot.context().spotId());
    return workflow.startInSpot(spot, request)
            .thenCompose(
                    state ->
                            spot.closeIfTerminal(state)
                                    .thenApply(
                                            ignored ->
                                                    new Messages.StartOrderWorkflowRes(state)));
}

Instance Spot 호출은 Spot이, 같은 id로 동시에 온 요청이 하나의 생성을 기다리는 규칙은 활성화와 수명이 다룬다.

5. 단계 진행 — replay, 다음 단계, 기록

HTTP 응답은 Created에서 끝나고 나머지 단계는 background continuation이 진행한다. client는 polling으로 Confirmed 또는 Failed를 확인한다.

Server/OrderWorkflow/src/main/java/systems/zlink/samples/shoppingmall/server/orderworkflow/OrderWorkflowService.java

Messages.OrderState state = store.findProjection(request.orderId());
if (!spot.isTerminal(state)) {
    spot.context()
            .outbound()
            .sendToSpot(
                    spot.context().spotId(),
                    new Messages.RunOrderWorkflowMsg(request.orderId()))
            .submit();
}
return CompletableFuture.completedFuture(state);

continuation의 한 회전은 event stream을 읽어 aggregate를 복원하는 데서 시작한다. 진행 지점을 따로 저장하지 않는다 — 마지막 event가 곧 현재 단계다.

Server/OrderWorkflow/src/main/java/systems/zlink/samples/shoppingmall/server/orderworkflow/OrderWorkflowService.java

Messages.OrderState state = saveProjection(orderId);
if (Messages.OrderStatuses.InventoryReserved.equals(state.status())) {
    System.out.println(
            "shoppingmall-order replayed order="
                    + orderId
                    + " generation="
                    + spot.context().objectGeneration());
}

현재 단계에 따라 다음 단계 하나를 실행한다. 재고 예약이 거절되면 결제를 호출하지 않고, 결제가 거절되면 이미 기록된 예약을 해제한 뒤 실패로 끝낸다.

↗ 크게 보기

실패한 단계는 다음 단계를 시작하지 않고 이미 기록한 상태에 맞는 보상만 실행한다.

Server/OrderWorkflow/src/main/java/systems/zlink/samples/shoppingmall/server/orderworkflow/OrderWorkflowService.java

if (Messages.OrderStatuses.Created.equals(state.status())) {
    state = reserveInventory(orderId, state);
}
if (Messages.OrderStatuses.InventoryReserved.equals(state.status())) {
    state = authorizePayment(orderId, state);
}
if (Messages.OrderStatuses.PaymentAuthorized.equals(state.status())) {
    state = confirm(orderId);
}
return state;

event는 expected version으로 기록한다. 이전 owner가 아직 남아 같은 stream에 기록하려 하면 version이 어긋나 실패하므로, 두 owner가 같은 단계를 두 번 실행하지 않는다. 기록 뒤에는 read model을 갱신한다.

Server/OrderWorkflow/src/main/java/systems/zlink/samples/shoppingmall/server/orderworkflow/OrderWorkflowService.java

private void append(String orderId, long expectedVersion, List<OrderDomain.OrderEvent> events) {
    store.appendEvents(orderId, expectedVersion, events);
    saveProjection(orderId);
}

이 모든 단계는 같은 Spot의 turn 안에서 실행되므로 한 주문에 경쟁하는 writer가 없다. 직렬 실행의 범위는 실행 모델이 다룬다.

6. 중복, 재개와 projection 재생성

같은 key로 두 번 시작하면 mapping 예약에서 먼저 성공한 요청의 OrderId를 양쪽이 사용하고, stream에 이미 있는 command는 다시 기록되지 않는다. 중단된 주문은 재개 명령으로 같은 continuation을 다시 돌린다 — replay가 마지막 단계를 알려 주므로 완료한 단계는 건너뛴다.

↗ 크게 보기

조회는 read model만 읽고 주문을 진행시키지 않는다.

Server/CommerceApi/src/main/java/systems/zlink/samples/shoppingmall/server/commerceapi/CommerceApiService.java

public CompletionStage<Messages.GetOrderStateRes> getOrder(String orderId) {
    return CompletableFuture.completedFuture(
            new Messages.GetOrderStateRes(store.findProjection(orderId)));
}

read model이 지워지거나 어긋나면 event stream만으로 다시 만든다.

Server/Shared/src/main/java/systems/zlink/samples/shoppingmall/server/shared/store/RedisCommerceStore.java

public Messages.OrderState rebuildProjection(String orderId) {
    Messages.OrderState rebuilt = OrderProjection.fold(readEvents(orderId));
    if (rebuilt == null) {
        throw new IllegalStateException("Order '" + orderId + "' does not exist.");
    }
    saveProjection(rebuilt);
    return rebuilt;
}

7. 종료와 lifecycle

주문이 Confirmed나 Failed에 도달하면 Spot은 context close로 종료를 요청할 수 있다. Context close는 종료 결과를 반환하며, 종료 절차의 결과가 확정되면 완료된다. 닫힌 뒤 같은 OrderId로 온 요청은 새 generation의 Spot을 만들고, 그 Spot은 stream을 replay해 terminal 상태를 그대로 돌려준다.

↗ 크게 보기

terminal 상태의 Spot을 닫아도 같은 OrderId의 다음 요청은 event stream을 replay해 현재 상태를 복원한다.

// 이 구현의 order workflow Spot은 terminal에서 Close하지 않는다.

Ready owner의 process가 사라지면 진행 중인 요청은 Unavailable로 끝나고, framework는 다른 node에 같은 주문을 자동으로 만들지 않는다. 계획된 relocation은 별개의 절차이며, 그 뒤의 재개도 위와 같은 replay로 이어진다. Close와 generation은 활성화와 수명이, 계획된 이동은 Relocation이 다룬다.

8. 실행과 검증

runner 하나가 Redis 컨테이너, 서버 process와 client 시나리오를 함께 띄운다.

framework/languages/java/samples/java/ShoppingMall/run_sample.sh

client는 정상·실패 결과, 중복 시작, 재개와 projection 재생성을 assertion으로 확인한다. 확인 항목과 로그 문자열은 ShoppingMall 시나리오가 정한다.

9. 관련 문서