항해 플러스 6기 8주차를 회고하며 - 인덱스는 진짜 유명한 쿼리 튜닝임 & 사가 패턴 사가세요

1. 문제

이번 8주차의 과제는 다음과 같다.

* 시나리오에서 수행하는 조회 쿼리를 수집하고 인덱스를 적용하여 성능을 개선할 쿼리가 있다면 적용하여 성능 비교 분석하기
* 시나리오에서의 트랜잭션 범위를 분석해보고, 서비스가 확장된다고 가정할 때 현재 구조의 한계와 해결 방안을 분석해보기
* 부가 기능을 현재 비즈니스 로직에 영향을 주지 않게 구현하기

이번 과제는 쿼리 성능 개선의 핵심, 쿼리 튜닝의 꽃 인덱스를 심층적으로 탐구해보고 사용하고, 서비스의 MSA 환경으로의 확장을 고민해보며 현재 상황에서 확장했을 때 발생할 문제와 해결 방안을 고민해볼 수 있는 주제이다.

사실 인덱스의 경우에는 조회 성능을 향상시키기 위해 추가하는, 하지만 CUD가 자주 일어나는 경우에는 오히려 성능이 감소한다는 정도로 어느 정도는 알고 있던 개념이었다. 실제로 실무에서도 조회가 많이 발생하는 컬럼에 적용하는 등 이미 사용하기도 하는 기능이었어서 무난할 것으로 자만했다.

그렇다. 자만… 어쩌면 그동안 인덱스에 대해 잘 알고 있다고 오해하고 있었다고 생각한다. 이번 과제를 하면서 멘토링 시간에 코치님이 PK와 인덱스의 차이에 대해 지나가는 투로 언급하셨는데, 곰곰히 생각해보니 그 차이를 모르고 있었다. 그래서 이 부분도 알아볼 겸 인덱스에 대한 전반적인 개념들을 보고서에 정리해보자 해서 자세히 알아보았다. 그러면서 처음으로 알게 된 인덱스의 종류, 구조, 동작 원리 등 내가 자세히 모르고 있던 지식들을 마주했고 스스로 이렇게 생각하게 됐다.
‘어우. 나 인덱스 잘 모르고 있었네.’

또 다른 주제는 트랜잭션 범위와 MSA 환경 서비스 확장에 관한 내용이었다. MSA는 요즘 대부분의 회사들이 채택하고 운영하고 있는 시스템 아키텍처이다. 많은 회사에서 채택한 만큼 제대로 공부하고 싶은 마음도 있고, 이러한 아키텍처적인 부분을 배우고 경험해보고 싶어서 항해 플러스를 신청한 이유도 있어서 좀 더 자세히 관련 내용을 찾아보며 공부하고자 했다.

2. 시도 & 해결

먼저 인덱스에 대한 개념 정리를 했다. 앞서 언급했던 것처럼 나는 내가 생각한 것 만큼 인덱스에 대해 잘 알지 못했기 때문에 이번 기회에 핵심 개념은 제대로 정리하고 넘어가고자 했다.

인덱스란?

인덱스에 대해 공부하고 정리해 본 내용은 아래 인덱스 톺아보기 게시글에서 확인할 수 있다.

인덱스 톺아보기

그 다음으로 현재 시나리오에서 발생하는 조회 쿼리를 수집해보고, 어떤 테이블의 컬럼에 인덱스를 적용하는 게 적절할 지 분석했다. 그렇게 선정하게 된 조회 쿼리 및 테이블의 컬럼은 다음과 같다.

선정한 조회 쿼리
1. 사용자 ID로 잔액 조회하는 컬럼
2. 상품 ID로 상품 세부 정보 조회하는 컬럼
3. 사용자 ID로 장바구니 조회하는 컬럼

선정한 테이블 컬럼 및 선정 이유
1. 잔액 (Balance) 테이블의 userId 컬럼
    * 잔액 정보와 사용자 ID (userId) 는 1:1 관계로, userId 컬럼은 카디널리티가 높은 컬럼이다.
    * 잔액 테이블은 사용자가 회원가입할 때만 삽입되는 테이블이기 때문에 삽입이 적다.
    * 사용자 ID (userId) 는 수정이 불가능한 값으로 수정이 일어나지 않는 컬럼이다.
2. 상품 세부 정보 (ProductDetail) 테이블의 productId 컬럼
    * 상품 세부 정보와 상품 ID (productId) 는 1:1 관계로, productId 컬럼은 카디널리티가 높은 컬럼이다.
    * 상품 정보는 상품을 등록할 때만 삽입이 일어나므로 삽입이 적은 테이블이다.
    * 상품 ID (productId) 는 수정이 불가능한 값으로 수정이 일어나지 않는 컬럼이다.
3. 장바구니 (Cart) 테이블의 userId 컬럼
    * 사용자 ID (userId) 는 수정이 불가능한 값으로 수정이 일어나지 않는 컬럼이다.

인덱스 적용에 대한 더 자세한 보고서는 링크를 남겨두겠다. (쿼리 성능 개선 및 인덱스 적용 보고서)

그리고 다른 주제인 트랜잭션 범위 분석 및 서비스 확장 대응에 대한 보고서 작성을 진행했다. 진행 내용은 우선 현재 시나리오의 트랜잭션 범위를 분석하는 것이었다. 그 중에서는 E 커머스 시나리오의 핵심 로직인 주문결제 기능에 대해 분석했다.

시나리오 트랜잭션 범위 분석

주문 트랜잭션

transaction_range_order

주문 기능에서는 이전 6주차인 동시성 제어 과제에서 상품 재고 차감 기능을 이벤트로 분리했다. 즉 주문 기능 중 주문 정보 저장과 장바구니 상품 삭제 기능이 하나의 트랜잭션 범위이며 해당 트랜잭션이 커밋되면 상품 재고 차감 이벤트가 발생, 상품 재고 차감은 별도의 트랜잭션을 가지게 되는 구조이다.

결제 트랜잭션

transaction_range_payment

결제 기능에서는 주문 정보 조회부터 결제 및 주문 상태 업데이트부터 모든 동작을 하나의 트랜잭션 범위에서 처리하고 있다. 이는 기능 내 하나의 동작에서 실패가 발생하면 모든 동작을 롤백 처리하기 위해 설정한 의도이기도 했다.

다음으로 MSA 환경으로 확장할 때 한계와 해결 방안이다. 한계는 사실 명확했다. 현재 구조는 단일 트랜잭션이라 서비스가 분리될 때 분산된 트랜잭션에 대해 일관성을 유지하기 어려운 점이다. 아래 그림은 주문 로직의 시퀀스 다이어그램이다.

monolothic-sequence

만약 현재 시나리오 서비스를 MSA로 확장한다고 하면, 서비스마다 별도로 DB가 구성될 것이다. 즉 서비스 별로 트랜잭션을 가지게 되기에 트랜잭션을 단순하게 유지하기 어려워진다. 아래 그림은 MSA 환경으로 확장할 때의 시퀀스 다이어그램이다.

microservice-sequence

이러한 분산 트랜잭션 문제를 해결하기 위한 해결 방안으로 2PC 방법과 사가 패턴 (Saga Pattern) 이 있다.

2PC (Two-Phase Commit)

2PC 방안은 Prepare 단계와 Commit 단계로 구성하는 분산 트랜잭션 구현 방법이다.

  • Prepare 단계
    • 관련된 모든 서비스는 Commit 준비하고, Transaction Coodinator에게 트랜잭션을 시작할 준비가 되었음, 즉 Commit할 준비가 되었음을 알린다.
  • Commit 단계
    • Prepare 단계에서 트랜잭션을 시작할 준비가 되었다면, Coodinator는 Commit을 요청한다. 만약 서비스 하나라도 실패가 발생한다면, Coodinator는 관련된 모든 서비스에 해당 트랜잭션을 롤백하도록 요청한다.

2pc-sequence

2PC 방안을 적용한 예시는 다음과 같다.

  1. 사용자가 주문 요청을 보내면 Transaction Coodinator가 트랜잭션을 시작한다.
  2. Coodinator가 Order Microservice에게 주문 정보 저장에 대한 Prepare 요청을 보낸다.
  3. Coodinator가 Product Microservice에게 재고 차감에 대한 Prepare 요청을 보낸다.
  4. Coodinator가 두 서비스에 대해 트랜잭션 처리 준비가 완료되었음을 확인하면 Commit 요청을 보낸다.

2PC 방안은 트랜잭션의 원자성을 보장하는 방식으로, 모든 서비스는 성공하거나 실패한다.

2PC 방안은 분산 트랜잭션 처리를 위한 전통적인 방법이지만, Transaction Coodinator에 의존하여 모든 서비스에 대해 준비 상태를 확인하고, 상태 변경하는 방법이라 성능 측면에서 효율적인 방법은 아니다. 또한 NoSQL 등 일부 구현에도 지원하지 않아서 제약이 있는 방안이다.

사가 패턴 (Saga Pattern)

사가 패턴은 각 서비스의 로컬 트랜잭션을 순차적으로 처리하고, 각 로컬 트랜잭션은 DB를 업데이트한 후 다음 로컬 트랜잭션을 트리거하는 메시지를 게시한다. 트랜잭션의 결과에 따라 롤백이 필요한 경우 보상 트랜잭션을 진행한다.

보상 트랜잭션은 서비스에서 트랜잭션 처리에 실패할 경우 그 서비스의 앞선 다른 서비스의 트랜잭션 처리를 되돌리는 트랜잭션이다.

사가 패턴은 크게 2가지로 구분할 수 있다.

코레오그래피 기반 사가 (Choreography-based Saga)

각 로컬 트랜잭션이 다른 서비스의 로컬 트랜잭션을 이벤트 트리거하는 방식이다. 중앙 집중된 지점 없이 이벤트를 교환하며, 모든 서비스가 메시지 브로커를 통해 이벤트를 발행/소비한다.

다음은 현재 시나리오의 주문 기능을 코레오그래피 기반의 사가 패턴을 구현한 예시이다.

choreography-based-saga

  1. 주문 요청하면 Order 서비스는 주문 저장 트랜잭션을 처리하고 결과를 Product 서비스에게 이벤트로 전달한다.
  2. 트랜잭션 성공, 실패 응답이 큐 (channel)에 들어간다.
  3. Product 서비스의 이벤트 핸들러가 발생한 주문 요청에 대해 재고 차감 시도한다.
  4. 재고 차감 시도한 후 결과에 대해 이벤트를 발생시킨다.
  5. Order 서비스는 결과에 따라 실패한 경우에는 보상 트랜잭션, 문제 없는 경우에는 다음 프로세스를 진행한다.

코레오그래피 기반 사가 패턴의 장점은 추가 서비스 구현이 필요하지 않아 서비스가 많지 않은 간단한 플로우에 적합하다.

단점은 어떤 서비스가 어떤 이벤트를 수신 대기하는지 추적이 어렵다는 점과, 새로운 서비스 추가가 필요한 경우 워크플로우가 복잡해질 수 있다는 점이 있다. 그리고 각 서비스 간 이벤트를 전달해주는 구조 상 순환 종속성 문제가 발생할 수 있다.

오케스트레이션 기반 사가 (Orchestration-based Saga)

분산 트랜잭션을 책임지는 별도의 중계자가 중앙 집중식 컨트롤러 역할을 하고 각 서비스에 실행할 트랜잭션을 알려주는 방식이다. 중계자는 사가 인스턴스를 발급하여 요청 실행, 각 서비스 상태 확인, 실패에 대한 복구를 처리한다.

다음은 현재 시나리오의 주문 기능을 오케스트레이션 기반의 사가 패턴을 구현한 예시이다.

orchestration-saga

  1. 주문 요청하면 Order 서비스에서 요청을 수신하고, 중계자가 주문 로직인 주문 저장 동작을 수행한다.
  2. 재고 차감 명령 이벤트를 발생시킨다.
  3. Product 서비스가 재고 차감을 시도한다.
  4. 재고 차감을 시도한 결과를 이벤트로 응답한다.
  5. 주문 로직 트랜잭션이 끝나면 중계자를 종료하여 전체 트랜잭션 처리를 종료한다.

오케스트레이션 기반 사가 패턴의 장점은 시간이 지나면서 새로운 서비스가 추가되는 복잡한 워크플로우에 적합하다. 또한 중계자는 일방적으로 서비스에 의존하기 때문에 순환 종속성 문제가 발생되지 않는다.

단점은 중계자가 전체 워크플로우를 관리하기 때문에 이 부분이 실패 지점이 될 수 있다.

오케스트레이션 기반 사가 패턴은 대표적으로 Axon 프레임워크를 통해 구현할 수 있다.

사가 패턴은 트랜잭션의 격리성 (Isolation)을 보장하지 않는다는 특징이 있다. 따라서 이러한 문제점을 보완하기 위한 설계가 필요하다.

마지막으로 해야할 과제는 기존 비즈니스 로직에 영향을 미치지 않게 부가 기능을 구현하는 과제가 남았다. 여기서 구현할 부가 기능은 결제 내역을 외부 데이터 플랫폼에 전송하는 기능으로 선택했다. 해당 부가 기능은 외부 시스템과 통신을 하는 기능이라 비즈니스 로직과 큰 연관이 없는 동작이라는 것이 특징이다. 그렇기 때문에 해당 동작이 실패했다고 해서 비즈니스 로직까지 실패하는 것을 방지하기 위한 과제이다.

결제 내역을 외부 데이터 플랫폼에 전송하는 기능 자체는 이전 4주차 과제에서 진행한 경험이 있다. 그 당시에도 결제 로직에 영향을 주지 않게 하기 위해 Spring Event를 활용하여 비동기 이벤트로 처리하긴 했다. 하지만 이번 과제에서 이 부분을 더 확장시키고 싶어서 Kafka를 활용해보기로 했다.

먼저 트랜잭셔널 이벤트로 결제 트랜잭션이 커밋 완료된 후 외부 데이터 플랫폼 전송 메시지를 Producing 하도록 이벤트 리스너를 구현했다.

@Component
class DataPlatformEventListener(private val messageProducer: MessageProducer) {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun listen(event: DataPlatformEvent) {
        // 외부 데이터 플랫폼에 전송 요청하는 이벤트 발행
        messageProducer.sendPaymentDataPlatformMessage(event.paymentResultInfo)
    }
}

Kafka의 Producer와 Consumer는 다음과 같이 구성되어 있다.

@Component
class KafkaProducer(
    private val kafkaTemplate: KafkaTemplate<String, Any>
) : MessageProducer {
    override fun sendPaymentDataPlatformMessage(message: PaymentDataMessage) {
        kafkaTemplate.send(DATA_PLATFORM_TOPIC, message.paymentId.toString(), message)
    }
}
@Component
class KafkaListener(
    private val producerService: ProducerService
) {
    @KafkaListener(groupId = "\${spring.kafka.consumer.group-id}", topics = [DATA_PLATFORM_TOPIC])
    fun listenPaymentDataPlatformEvent(@Payload message: PaymentDataMessage) {
        val dataPlatform = ExternalDataPlatform()
        dataPlatform.sendPaymentData(message.orderId, message.currentBalance, message.paymentDate)
    }
}

실제로 외부 서버가 존재하지 않기 때문에 ExternalDataPlatform 클래스의 sendPaymentData() 메서드는 외부 데이터로 통신하는 메서드라고 가정한다. 해당 메서드는 다음과 같이 실패했을 경우 메인 비즈니스 로직에 영향이 없는지 확인하기 위해 예외를 의도적으로 발생시켜주고 있다.

class ExternalDataPlatform {
    fun sendPaymentData(paymentResultInfo: PaymentResultInfo) {
        throw ExternalRequestException("Error:: 외부 데이터 플랫폼에 전송 중 예외 발생")
    }
}

3. 알게된 것

먼저 인덱스에 대해 잘 알고 있다고 믿었던 자만을 깨뜨릴 수 있었다. 그리고 알게 되었다. 인덱스는 그렇게 만만한 녀석이 아니었다는 걸…

실무에서 이미 적용되어 있는 것을 따라서 적용시켜 본 경험으로 알고 있다고 착각했던 지난 날의 나에게 “아니! 넌 아무것도 몰라!”라고 외칠 수 있게 되었다.

다음으로 분산 트랜잭션 환경과 그토록 용어로만 많이 들어본 사가 패턴에 대해 자세히 알아볼 수 있었다.

이번 주차 멘토링 시간 때 재밌던 일화가 있는데, 코치님께 스스로 분산 트랜잭션을 해결하기 위한 방안을 고안해서 설명드렸는데 “맞아요. 그게 바로 사가 패턴입니다!” 라는 답변을 들었다. 놀랍게도 스스로 사가 패턴을 고안해버린 것이다💡❗ 이렇게 쉽게(?) 고안해낼 수 있을 정도로 사가 패턴은 알고 보면 단순하다고 할 수 있었다. (어쩌면 사가 패턴이 별거 없고 인덱스가 생각보다 엄청난 녀석일지도…)


이번 과제도 통과해서 새로운 뱃지, 레드 뱃지를 얻을 수 있었다! 벌써 항해 플러스의 과제가 80% 진행했다는 것이 믿기지가 않는다…

솔직히 항해 플러스를 하면서 퇴근 후 과제를 하는 것이 쉬운 일은 아니었다. 당연히 힘든 것은 사실이다. 하지만 성장이라는 같은 목표를 가지고 함께 소통하는 팀원들 및 항해 플러스 동기들, 아낌없이 코칭해주시는 코치님들이 좋아서일까? 아니면 과제를 더 완벽하게 할 수 있었다는 아쉬움 때문일까? 항해 과정이 끝나가는 것이 시원섭섭했다.🥲

‘만남은 쉽고 이별은 어려워’라는 노래가 있듯, 항해의 시작은 나름 간단하게 시작했지만, 끝이 다가오는 지금 왜인지 모를 아쉬움이 느껴진다. (가을 타는건가?🍃🍁)


🤩 다음 수료생 추천 할인 혜택!

혹시라도 항해 플러스에 합류하고 싶은데 비싼 수강료 때문에 망설여진다면…? 🤔

수료생 추천 할인 혜택으로 20만 원을 할인받으실 수 있다는 사실! 💡

결제페이지 → 할인 코드 → 수료생 할인 코드에 tJQjYK 입력하면 추가로 20만 원을 할인받는 혜택 꼭 챙겨가시길 바란다🚀🚀🌟


#추천인: tJQjYK #항해플러스 #항해99