반응형

https://github.com/Seungkyu-Han/micro_service_webflux

 

GitHub - Seungkyu-Han/micro_service_webflux: Webflux 환경에서 MSA의 Saga, Outbox, CQRS를 연습해보기 위한 리포지

Webflux 환경에서 MSA의 Saga, Outbox, CQRS를 연습해보기 위한 리포지토리입니다. - Seungkyu-Han/micro_service_webflux

github.com

 

기존의 Saga 패턴에서 더 강화된 내용이라고 생각하면 될 것이다.

 

Saga 패턴은 긴 트랜잭션을 짧은 트랜잭션으로 나누고 process와 rollback을 사용하여 하나씩 나아가는 구조였다.

 

여기서 이벤트를 전송하고, 변경된 내용을 데이터베이스에 저장하게 되는데 만약 이벤트를 전송하고 데이터베이스에서 에러가 발생해 이벤트만 전송되게 된다면 문제가 발생할 수 있다.

 

이런 문제를 해결하기 위해 사용하는 패턴이다.

방법은 먼저 데이터베이스에 변경사항들을 저장하고, 스케줄러를 사용해 한 번에 이벤트를 전송하는 것이다.

이 때 변경사항들은 기존의 데이터베이스가 아닌, 이벤트를 위한 별도의 저장공간을 만들게 된다.

이곳에 보낼 데이터를 미리 저장해두고 나중에 보내기 때문에 보낼 편지함(Outbox)패턴이라고 불리게 된다.

그리고 모든 사항이 완료된 Outbox 데이터들은 데이터베이스의 최적화를 위해 스케줄러를 사용해서 지속적으로 삭제해준다.

 

이러한 방법을 MSA에서 Outbox 패턴이라고 한다.

 

이렇게 별도의 데이터베이스에 저장해두고 한 번에 보내게되며, 실제 서비스에서는 1~2초의 간격으로 스케줄러를 실행한다고 한다.

 

우선 아래에는 직접 작성한 Outbox 패턴으로 설명해보겠다.

 

@Component
class PaymentOutboxScheduler(
    private val paymentOutboxHelper: PaymentOutboxHelper,
    private val paymentRequestMessagePublisher: PaymentRequestMessagePublisher
): OutboxScheduler {

    private val logger = LoggerFactory.getLogger(PaymentOutboxScheduler::class.java)

    @Transactional
    @Scheduled(fixedDelay = 10000, initialDelay = 10000)
    override fun processOutboxMessages() {
        logger.info("결제를 요청하는 스케줄러가 실행되었습니다.")
        paymentOutboxHelper.getPaymentOutboxMessageByOutboxStatusAndOrderStatus(
            OutboxStatus.STARTED,
            listOf(OrderStatus.PENDING, OrderStatus.CANCELLING)
        ).publishOn(Schedulers.boundedElastic()).map{
            paymentOutboxMessage: PaymentOutboxMessage ->
            if(paymentOutboxMessage.payload.orderStatus == OrderStatus.CANCELLING) {

                paymentOutboxMessage.payload.paymentOrderStatus = PaymentOrderStatus.CANCELLING
            }
            paymentRequestMessagePublisher.publish(
                paymentOutboxMessage = paymentOutboxMessage,
                callback = ::updateOutboxStatus
            ).subscribe()
        }.subscribe()
    }

    private fun updateOutboxStatus(paymentOutboxMessage: PaymentOutboxMessage, outboxStatus: OutboxStatus): Mono<Void> {
        paymentOutboxMessage.outboxStatus = outboxStatus
        return paymentOutboxHelper.save(paymentOutboxMessage).then()
    }
}

 

주문 서버에서 결제 서버로 결제를 요청하는 과정이다.

우선 주문이 발생하면 Outbox의 상태가 Start인 값들만 조회한다.

 

처음에 Outbox에 Start로 저장을 하기 때문에 한번도 전송된 적이 없는 데이터를 불러오는 것이다.

 

그렇게 조회된 모든 데이터를 모두 publisher로 전송을 하며, 전송을 하면 callback을 사용해 Outbox의 Status를 Complete로 변경해준다.

 

publisher의 내용이다.

override fun publish(
        paymentOutboxMessage: PaymentOutboxMessage,
        callback: (PaymentOutboxMessage, OutboxStatus) -> Mono<Void>
    ): Mono<Void> {
        return mono{

            val paymentEventPayload = paymentOutboxMessage.payload

            logger.info("{} 주문에 대한 이벤트 전송을 준비 중입니다.", paymentEventPayload.orderId.toString())

            val paymentRequestAvroModel = paymentEventPayloadToPaymentRequestAvroModel(paymentEventPayload)

            reactiveKafkaProducer.send(
                paymentRequestTopic,
                paymentEventPayload.orderId.toString(),
                paymentRequestAvroModel
            ).publishOn(Schedulers.boundedElastic()).map{
                callback(paymentOutboxMessage, OutboxStatus.COMPLETED).subscribe()
            }.doOnError{
                callback(paymentOutboxMessage, OutboxStatus.FAILED).subscribe()
            }.subscribe()

            logger.info("{}의 주문이 메시지 큐로 결제 요청을 위해 전송되었습니다", paymentEventPayload.orderId.toString())

        }.then()
    }

 

이렇게 해당 데이터를 model로 변환하여 kafka로 전송을 하고, 전송 상태의 여부에 따라 callback을 사용하여 outbox의 상태를 변환한다.

 

결제 서버의 내용은 작성하지 않겠지만, 결제 서버에서도 수신 받은 내용에 따라 내용을 처리하고 kafka로 전송할 데이터를 outbox에 저장해주면 된다.

 

이제 여기서 문제가 생기게 된다.

 

스케줄러를 사용하기 때문에 특정 시간에만 동기화가 이루어지게 되며, 해당 스케줄러가 동작하는 시간에만 CPU의 사용량이 늘어나게 된다.

 

이러한 방법을 해결하기 위해 마지막으로 CDC 패턴을 사용한다고 한다.

CDC와 관련된 내용은 CQRS 다음에 작성해보도록 하겠다.

반응형

프로젝트에서 DeepL API를 사용해 PDF를 번역하는 기능이 추가되었다.

https://developers.deepl.com/docs

 

Introduction | DeepL API Documentation

Learn more about the DeepL API's capabilities and common use cases.

developers.deepl.com

 

우선 DeepL의 공식문서이다.

처음에는 늘 그렇듯 curl을 사용해 파일을 보내고, 응답을 받을 것이라고 생각했지만 자바쪽으로 지원해주는 라이브러리가 있었다.

 

 

자바 라이브러리의 주소는 다음과 같다.

https://github.com/DeepLcom/deepl-java

 

GitHub - DeepLcom/deepl-java: Official Java library for the DeepL language translation API.

Official Java library for the DeepL language translation API. - DeepLcom/deepl-java

github.com

 

요즘에는 자료가 많이 없는 기능들을 사용하다보니, 이렇게 깃허브에 직접 찾아들어가 사용방법을 찾아보는 일이 많아졌다.

 

implementation("com.deepl.api:deepl-java:1.9.0")

 

현재 기준으로 가장 최신 버전인 해당 라이브러리를 추가해준다.

 

처음에는 해당 문서처럼 Translator를 생성하려고 했지만, 해당 기능은 Deprecated 되었다고 한다.

(그러면 문서 좀 수정해주지...)

 

지금은 DeepLClient 클래스를 생성하고, 해당 생성자에 DeepL의 API 키를 넘겨주면 된다.

 

DeepLClient(deepLKey)

 

해당 클래스에서도 translateDocument라는 함수를 사용할 것이다.

 

해당 함수를 오버로딩하고 있는 함수들은 다음과 같다.

    public DocumentStatus translateDocument(File inputFile, File outputFile, @Nullable String sourceLang, String targetLang, @Nullable DocumentTranslationOptions options) throws DocumentTranslationException, IOException {
        try {
            if (outputFile.exists()) {
                throw new IOException("File already exists at output path");
            } else {
                InputStream inputStream = new FileInputStream(inputFile);

                DocumentStatus var8;
                try {
                    OutputStream outputStream = new FileOutputStream(outputFile);

                    try {
                        var8 = this.translateDocument(inputStream, inputFile.getName(), outputStream, sourceLang, targetLang, options);
                    } catch (Throwable var12) {
                        try {
                            outputStream.close();
                        } catch (Throwable var11) {
                            var12.addSuppressed(var11);
                        }

                        throw var12;
                    }

                    outputStream.close();
                } catch (Throwable var13) {
                    try {
                        inputStream.close();
                    } catch (Throwable var10) {
                        var13.addSuppressed(var10);
                    }

                    throw var13;
                }

                inputStream.close();
                return var8;
            }
        } catch (Exception exception) {
            outputFile.delete();
            throw exception;
        }
    }

    public DocumentStatus translateDocument(File inputFile, File outputFile, @Nullable String sourceLang, String targetLang) throws DocumentTranslationException, IOException {
        return this.translateDocument((File)inputFile, (File)outputFile, (String)sourceLang, targetLang, (DocumentTranslationOptions)null);
    }

    public DocumentStatus translateDocument(InputStream inputStream, String fileName, OutputStream outputStream, @Nullable String sourceLang, String targetLang, @Nullable DocumentTranslationOptions options) throws DocumentTranslationException {
        DocumentHandle handle = null;

        try {
            handle = this.translateDocumentUpload(inputStream, fileName, sourceLang, targetLang, options);
            DocumentStatus status = this.translateDocumentWaitUntilDone(handle);
            this.translateDocumentDownload(handle, outputStream);
            return status;
        } catch (Exception exception) {
            throw new DocumentTranslationException("Error occurred during document translation: " + exception.getMessage(), exception, handle);
        }
    }

    public DocumentStatus translateDocument(InputStream inputFile, String fileName, OutputStream outputFile, @Nullable String sourceLang, String targetLang) throws DocumentTranslationException {
        return this.translateDocument(inputFile, fileName, outputFile, sourceLang, targetLang, (DocumentTranslationOptions)null);
    }

 

File을 넘기는 함수가 아닌 inpuStream을 넘기는 함수를 사용할 것이며, 사용 가능한 언어의 종류는 DeepL의 공식문서에 나와있다.

 

여기서 sourceLang은 Null이 가능하다.

현재 문서에 대한 정보를 주지 않아도, 번역을 해보니 sourceLang을 지정해 줄 때와 똑같은 결과가 나왔었다.

 

해당 함수를 사용하면 byteOutputStream이 나오게 된다.

해당 byteOutputStream을 byteArray로 바꾸어서 controller에서 응답해주면 된다.

@Service
class DeepLManager(
    @Value("\${deepL.key}")
    private val deepLKey: String
) {

    fun translateDocument(inputStream: InputStream, fileName: String, targetLang: LanguageEnum): ByteArray {
        ByteArrayOutputStream().use{
            byteArrayOutputStream ->
            DeepLClient(deepLKey).translateDocument(
                inputStream,
                fileName,
                byteArrayOutputStream,
                null,
                targetLang.targetLang,
            )
            return byteArrayOutputStream.toByteArray()
        }
    }

}

 

해당 서비스는 DeepL로 부터 번역된 ByteArray를 가져오는 서비스이고

그냥 응답받은 byteArrayOutputStream에서 toByteArray만 호출하면 ByteArray로 변환된다.

 

이렇게 응답받은 ByteArray을

return translateConnector.createTranslate(
            docsId, createTranslateReq
        ).map{
            ByteArrayOutputStream().use{

            }
            ResponseEntity.ok()
                .contentType(
                    MediaType.APPLICATION_OCTET_STREAM
                )
                .headers{
                    header ->
                    header.contentDisposition = ContentDisposition.builder("pdf")
                        .filename("${docsId}-${createTranslateReq.targetLang.name}.pdf")
                        .build()
                }
                .body(it)
        }

 

이런 식으로 controller에서 응답해주면 된다.

이렇게 하면 지정해준 파일의 이름으로 번역된 파일을 다운받을 수 있다.

 

+ 근데 이게 생각보다 돈이 많이 나오는 거 같다.

이렇게 만들고 몇번 요청을 한 후, PM한테 금액을 확인해달라고 부탁하니 벌써 5만원이 나왔다고 한다...

다들 조심해서 사용하는 게 좋을 것 같다.......

'틔움랩' 카테고리의 다른 글

MockBean deprecated와 대체  (0) 2025.01.28
Github action을 통한 Spring CI/CD 설정  (0) 2025.01.25
반응형

https://github.com/Seungkyu-Han/micro_service_webflux

 

GitHub - Seungkyu-Han/micro_service_webflux: Webflux 환경에서 MSA의 Saga, Outbox, CQRS를 연습해보기 위한 리포지

Webflux 환경에서 MSA의 Saga, Outbox, CQRS를 연습해보기 위한 리포지토리입니다. - Seungkyu-Han/micro_service_webflux

github.com

 

MSA에서 제일 중요한 부분 중 하나라고 생각한다.

현재 개발 중인 서버의 구조이다.

 

  1. 클라이언트가 주문을 하면, 주문 서버의 API를 사용해 주문을 하게 된다.
  2. 주문 서버만을 사용해 주문을 완료할 수 없으니, 우선 고객의 잔액을 확인하기 위해 payment 서버로 메시지를 보내게 된다.
  3. 결제 서버는 금액과 잔액을 확인한 후에, 결제가 가능하다면 금액을 뺀 잔액을 저장하고 주문 서버로 응답 메시지를 보내게 된다.
  4. 주문 서버는 결제가 가능하다면, 식당 서버로 메시지를 보내 해당 식당의 상태와 메뉴를 확인한다.
  5. 식당 서버는 해당 메시지를 확인 한 후 그에 맞는 메시지를 주문 서버에게 응답한다.
  6. 해당 메시지를 확인 한 후, 결과를 데이터베이스에 저장한다.

 

이런 과정을 통해 결제가 진행된다.

늘 그렇듯 이런 과정에 transaction 처리를 해야한다.

하지만 다른 서버간에 메시지를 보내는 과정에서 어떻게 transaction 처리를 할 수 있을까?

이럴 때 사용하는 것이 saga 패턴이다.

 

Saga 패턴마이크로서비스 아키텍처에서 분산 트랜잭션을 관리하는 방법 중 하나다.

각 서비스가 개별적으로 트랜잭션을 수행하고, 트랜잭션 간 일관성을 유지하기 위해 보상 작업(rollback)이나 이벤트 체인을 활용하는 방식을 말한다.

 

즉 해당 서버로부터 응답을 받고, 그 결과에 따라 작업을 process 할지 rollback 할지 결정하는 것이다.

 

우선 현재 서비스를 분석해보면, 다음과 같은 실패가 존재한다.

  1. 결제 과정에서 잔액이 부족해 결제를 실패하는 경우
  2. 결제는 성공했지만, 식당에서 문제가 있어 주문이 실패하는 경우

1번은 잔액을 보존하고, 주문 서버로 실패 이벤트만 전송하면 되지만 2번은 아니다.

2번은 식당 서버에서 실패 이벤트를 받으면, 결제 서버로도 실패 이벤트를 전송해 잔액을 복구해야 한다.

 

우선 서비스에 적용을 해보도록 하자.

Saga의 적용을 위해 아래와 같은 인터페이스를 생성한다.

interface SagaStep<T, SuccessEvent: DomainEvent<*>, FailEvent: DomainEvent<*>> {

    fun process(data: T): Mono<SuccessEvent>
    fun rollback(data: T): Mono<FailEvent>
}

여기서 DomainEvent는 common 모듈에서 각각의 이벤트를 위해 상속받아 구현한다.

 

결제와 관련된 Saga는 다음과 같이 구현한다.

@Component
class OrderPaymentSaga(
    private val orderDomainService: OrderDomainService,
    private val orderRepository: OrderRepository
): SagaStep<PaymentResponse, OrderPaidEvent, EmptyEvent> {

    private val logger = LoggerFactory.getLogger(OrderPaymentSaga::class.java)

    override fun process(data: PaymentResponse): Mono<OrderPaidEvent> {
        logger.info("주문 {}의 상태를 결제완료로 변경합니다", data.id)
        return orderRepository.findById(ObjectId(data.id))
            .flatMap{
                val orderPaidEvent = orderDomainService.payOrder(order = it)
                orderRepository.save(it)
                    .thenReturn(orderPaidEvent)
            }.doOnNext{
                logger.info("주문 {}의 상태가 결제완료로 변경되어 저장되었습니다.", it.order.orderId.id)
            }
    }

    override fun rollback(data: PaymentResponse): Mono<EmptyEvent> {
        logger.info("주문 {}의 상태를 취소로 변경합니다.", data.id)
        return orderRepository.findById(ObjectId(data.id))
            .flatMap {
                orderDomainService.cancelOrder(order = it)
                orderRepository.save(it)
                    .thenReturn(EmptyEvent())
            }.doOnNext{
                logger.info("주문 {}의 상태가 취소로 변경되어 저장되었습니다.", data.id)
            }
    }
}

 

주문이 실패한다면, 해당 주문만 실패로 데이터베이스에 저장해준다.

주문이 성공한다면, 해당 주문을 결제성공으로 데이터베이스에 저장하고 식당 서버로 승인 요청 이벤트를 전송한다.

 

승인과 관련된 Saga이다.

@Component
class RestaurantApprovalSaga(
    private val orderDomainService: OrderDomainService,
    private val orderRepository: OrderRepository,
    private val orderCancelledPaymentRequestMessagePublisher: OrderCancelledPaymentRequestMessagePublisher
): SagaStep<RestaurantApprovalResponse, EmptyEvent, OrderCancelledEvent> {

    private val logger = LoggerFactory.getLogger(RestaurantApprovalSaga::class.java)

    @Transactional
    override fun process(data: RestaurantApprovalResponse): Mono<EmptyEvent> {
        logger.info("{} 주문이 승인 완료되었습니다", data.id)

        return orderRepository.findById(ObjectId(data.id))
            .flatMap {
                orderDomainService.approveOrder(order = it)
                logger.info("바뀐거: $it")
                orderRepository.save(it)
                    .thenReturn(EmptyEvent())
            }.doOnNext{
                logger.info("{} 주문이 승인 완료되어 저장되었습니다", data.id)
            }
    }

    @Transactional
    override fun rollback(data: RestaurantApprovalResponse): Mono<OrderCancelledEvent> {
        logger.info("{} 주문이 미승인 되었습니다", data.id)
        return orderRepository.findById(ObjectId(data.id))
            .flatMap {
                val orderCancelledEvent = orderDomainService.cancelOrderPayment(it)
                orderRepository.save(it)
                    .thenReturn(orderCancelledEvent)
            }.doOnNext{
                logger.info("{} 주문이 취소 중 상태로 저장되었습니다", it.order.orderId.id.toString())
            }.doOnNext{
                orderCancelledPaymentRequestMessagePublisher.publish(it)
            }.doOnNext {
                logger.info("{} 주문의 결제 취소 이벤트를 전송했습니다.", it.order.orderId.id.toString())
            }
    }
}

 

주문이 승인되었다면, 해당 주문만 승인 완료로 데이터베이스에 저장해주면 된다. (이미 결제 서버에서 결제는 완료되었기 때문에)

주문이 실패했다면, 결제 서버의 잔액을 복구시켜야 하기 때문에 결제서버로 실패 이벤트를 전송해준다.

 

이런 식으로 메시지를 받을 때마다, process 할건지 rollback 할건지 결정해서 다음 프로세스를 진행하면 된다.

그리고 당연히 메시지를 받고 보내는 과정까지의 해당 서버는 transaction 하게 동작해야 한다.

 

enum class OrderStatus {
    PENDING, PAID, APPROVED, CANCELLING, CANCELLED
}

 

주문의 상태는 다음과 같다.

PENDING: 주문이 막 생성된 상태

PAID: 결제가 완료된 상태 (Order -> Payment -> Order로 성공 이벤트를 응답받음)

APPROVED: 주문이 승인된 상태 (Order -> Payment -> Order -> Restaurant -> Order로 성공 이벤트를 응답받음)

CANCELLING: 승인이 취소된 상태 (Order -> Payment -> Order -> Restaurant -> Order로 실패 이벤트를 응답받음)

CANCELLED: 모든 취소가 완료된 상태

 

해당 상태들을 데이터베이스에 저장해가며, 장기 transaction을 메시지를 응답받고 보내는 과정으로 분해하여 transaction을 적용하며 saga패턴을 적용하면 된다.

 

해당 서버 내에서 transaction을 적용하는 것은 어렵지 않겠지만, 이런 프로세스를 이해 할 수 있도록 saga 패턴을 적용할 서버를 제대로 분석하는 것이 필요하다고 생각한다.

'MSA' 카테고리의 다른 글

MSA에 CQRS 패턴 적용하기  (0) 2025.03.01
MSA에 Outbox 패턴 적용하기  (0) 2025.02.28
DDD에서 Hexagonal Architecture로 변경하기  (0) 2025.02.20
Spring + Kafka에서 avro 사용하기  (1) 2025.02.15
DDD의 핵심 요소  (0) 2025.02.14

+ Recent posts