Skip to content
Kang Log
Go back

4. 상품 생성 이벤트를 받아 Order에 상품 스냅샷 저장

Updated:

4. 상품 생성 이벤트를 받아 Order에 상품 스냅샷 저장

재고 서비스에 이어 주문 서비스도 ProductCreated 이벤트를 수신하도록 연결했다. 이벤트에 담긴 상품 정보를 Order DB에 저장하고, 처음 등록한 경우에는 ProductSnapshotRegistered 이벤트를 Outbox에 함께 저장한다.

전체 처리 흐름

Product의 OutboxRelay
    ↓
Kafka: product.events
    ↓
Order의 ProductEventListener
    ↓
JSON → ProductCreated 객체 변환
    ↓
ProductSnapShotService.register()
    ├─ 기존 스냅샷 있음 → 종료
    └─ 기존 스냅샷 없음
         → 상품 스냅샷 저장
         → ProductSnapshotRegistered Outbox 저장
    ↓
DB 트랜잭션 커밋
    ↓
Order의 OutboxRelay
    ↓
Kafka: order.events
    ↓
Notification에서 등록 이벤트 확인

1. 주문 서비스에 상품 정보를 저장하는 이유

주문을 처리하려면 상품 ID뿐 아니라 이름과 가격도 필요하다. 이를 매번 Product API에서 조회하면 주문 처리가 상품 서비스의 응답 속도와 장애에 영향을 받는다.

그래서 상품 생성 이벤트에 포함된 정보를 주문 서비스 DB에 저장하도록 했다.

Product가 상품 정보를 이벤트로 전달
    ↓
Order가 자신의 DB에 저장
    ↓
이후 주문 처리에서 로컬 상품 정보 사용 가능

현재의 ProductSnapshot은 주문 서비스가 보관하는 상품 정보의 복사본이다. 개별 주문이 생성될 때 주문 항목에 확정해서 남기는 상품명·가격과는 구분한다.

이번 구현에서는 상품 생성만 다루므로, 이후 상품명이나 가격이 변경됐을 때 스냅샷을 갱신하는 기능은 포함하지 않았다.

2. 상품 스냅샷 Repository 작성

기존 ProductSnapshot 엔티티를 저장하고 상품 ID로 조회할 Repository를 추가했다.

public interface ProductSnapShotRepository
        extends JpaRepository<ProductSnapshot, Long> {

    Optional<ProductSnapshot> findByProductId(Long productId);
}

여기서 조회 기준은 스냅샷 자체의 ID가 아니라 Product에서 전달받은 상품 ID다.

값의미
snapshot.getId()Order DB의 스냅샷 행 ID
snapshot.getProductId()Product에서 생성된 상품 ID

상품별 스냅샷 존재 여부를 확인하기 위해 findByProductId()를 사용한다.

3. Order의 Kafka Listener 연결

Order에서도 product.events를 구독하지만 Consumer Group은 order-service로 지정했다.

@Slf4j
@Component
@RequiredArgsConstructor
public class ProductEventListener {

    private final JsonMapper jsonMapper;
    private final ProductSnapShotService productSnapShotService;

    @KafkaListener(
            topics = "product.events",
            groupId = "order-service"
    )
    public void onProductCreated(ConsumerRecord<String, String> record) {
        Message<ProductCreated> message =
                Message.from(record, ProductCreated.class, jsonMapper);

        ProductCreated event = message.payload();

        productSnapShotService.register(
                event.productId(),
                event.name(),
                BigDecimal.valueOf(event.price())
        );

        log.info(
                "상품 스냅샷 처리 완료: messageId={}, productId={}",
                message.messageId(),
                event.productId()
        );
    }
}

Stock에서 사용한 공통 Message.from()을 그대로 활용한다.

Kafka 메시지
    ↓
헤더에서 메시지 정보 읽기
본문 JSON을 ProductCreated로 변환
    ↓
상품 ID·이름·가격을 Service에 전달

Stock과 Order는 서로 다른 그룹이므로 같은 이벤트를 각각 받는다.

ProductCreated
    ├─ stock-service → 재고 생성
    └─ order-service → 상품 스냅샷 생성

한쪽이 메시지를 읽었다고 다른 쪽에서 읽지 못하게 되는 것은 아니다.

4. 스냅샷 등록 이벤트 정의

Order에 상품 정보가 저장됐다는 사실을 알리기 위해 이벤트를 추가했다.

public record ProductSnapshotRegistered(
        Long productId,
        String name,
        long price
) {
}

두 이벤트는 상품 정보가 같더라도 의미가 다르다.

이벤트의미
ProductCreatedProduct에 상품이 생성되었다
ProductSnapshotRegisteredOrder에 해당 상품의 스냅샷이 등록되었다

이벤트 이름으로 어느 서비스에서 어떤 작업이 완료됐는지 구분한다.

5. 스냅샷과 Outbox를 같은 트랜잭션으로 저장

@Service
@RequiredArgsConstructor
public class ProductSnapShotService {

    private final ProductSnapShotRepository repository;
    private final OutboxWriter outboxWriter;

    @Transactional
    public void register(
            Long productId,
            String name,
            BigDecimal price
    ) {
        Optional<ProductSnapshot> existingSnapshot =
                repository.findByProductId(productId);

        if (existingSnapshot.isPresent()) {
            return;
        }

        ProductSnapshot snapshot =
                ProductSnapshot.of(productId, name, price);

        snapshot = repository.save(snapshot);

        outboxWriter.write(
                "order.events",
                String.valueOf(snapshot.getProductId()),
                new ProductSnapshotRegistered(
                        snapshot.getProductId(),
                        snapshot.getName(),
                        snapshot.getPrice().longValueExact()
                )
        );
    }
}

처리 순서는 다음과 같다.

  1. 상품 ID로 기존 스냅샷을 조회한다.
  2. 이미 있으면 종료한다.
  3. 없으면 새 스냅샷을 저장한다.
  4. 등록 이벤트를 Outbox에 저장한다.
  5. 두 작업을 함께 커밋한다.
스냅샷 저장 + Outbox 저장
    ├─ 성공 → 함께 커밋
    └─ 실패 → 함께 롤백

Outbox 저장이 실패하면 스냅샷도 롤백되므로, 스냅샷만 저장되고 등록 이벤트가 빠지는 상황을 막는다.

6. 가격 타입 변환

이벤트의 가격은 long이고, 스냅샷 엔티티의 가격은 BigDecimal이다.

Listener에서 엔티티에 전달할 때 변환한다.

BigDecimal.valueOf(event.price())

다시 이벤트를 만들 때는 정수형으로 변환한다.

snapshot.getPrice().longValueExact()
ProductCreated의 long 가격
    ↓ BigDecimal.valueOf()
ProductSnapshot의 BigDecimal 가격
    ↓ longValueExact()
ProductSnapshotRegistered의 long 가격

현재는 원 단위 정수 가격을 다루므로 이벤트에 long을 사용한다. longValueExact()는 소수 부분이나 범위 초과를 조용히 잘라내지 않고 예외로 알린다.

7. 기존 스냅샷 유지와 void 반환

같은 상품 생성 이벤트가 다시 들어오면 기존 스냅샷을 그대로 유지한다.

if (existingSnapshot.isPresent()) {
    return;
}

기존 상품명·가격을 덮어쓰지 않고, 새로운 등록 이벤트도 만들지 않는다.

최초 수신 → 스냅샷 + 등록 이벤트 저장
재수신 → 기존 스냅샷 확인 → 종료

처음에는 Service가 스냅샷 객체를 반환하는 형태를 고려했지만, Listener에서 반환값을 사용할 일이 없었다. 따라서 반환 타입을 void로 정리했다.

Stock은 반환받은 재고로 Redis를 초기화해야 하지만, Order는 Service 호출 이후 같은 객체로 수행할 추가 작업이 없다는 차이가 있다.

8. Kafka와 Outbox 설정 연결

Order에도 문자열 Consumer 설정을 추가했다.

spring:
  kafka:
    consumer:
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      auto-offset-reset: earliest
      enable-auto-commit: false

또한 OrderApplication에서 공통 Outbox 설정을 가져오도록 연결했다.

@Import({
        GlobalExceptionHandler.class,
        OutboxConfig.class
})

이를 통해 Order에서도 OutboxWriter와 기존 Relay를 사용할 수 있다.

Order 서비스 → 자신의 DB에 Outbox 저장
    ↓
Order의 Relay → order.events 발행

9. 상품 생성의 전체 서비스 연동 확인

Order 연결 이후 상품 생성 요청 하나로 전체 흐름을 확인했다.

상품 생성
    ↓
ProductCreated
    ├─ Stock → DB·Redis 초기 재고 생성
    │          → StockInitialized
    └─ Order → 상품 스냅샷 생성
               → ProductSnapshotRegistered
    ↓
Notification에서 세 종류 이벤트 확인

실제 환경에서 확인한 결과는 다음과 같다.

대상확인 결과
Product DB상품명·가격 저장
Stock DB상품 ID에 대한 초기 재고 0
Redis초기 가용 재고 0
Order DB상품 ID·이름·가격 저장
각 서비스 Outbox발행 후 PUBLISHED
Notification세 종류 이벤트 수신

상품 생성 API가 다른 서비스의 처리를 기다리지는 않지만, 이후 이벤트가 전달되면서 각 서비스의 데이터가 생성되는 것을 확인했다.

검증한 내용

주문 모듈 테스트 13개가 통과했다. H2에서 Listener를 직접 호출하여 저장·롤백 흐름을 검증했고, 실제 Kafka 연동은 별도로 확인했다.

여기까지 상품 생성 API → 이벤트 발행 → 재고·주문 서비스 처리 → Notification 확인으로 이어지는 #16을 완료했다. 다음 단계에서는 #17의 재고 증가 API를 구현한다.


Share this post on:

Previous Post
5. 재고 증가 API와 Outbox 이벤트 저장
Next Post
3. 상품 생성 이벤트를 받아 Stock에 초기 재고 생성