Post

MSA와 Pagination

MSA 환경에서 Pagination 구현

MSA, 그리고 “통합 페이지네이션” 문제

MSA(Microservice Architecture)로 시스템을 잘 분리하고 나면 각 서비스는 자신의 책임(데이터 소유권)에만 집중할 수 있어 확장성과 유지보수성이 극대화됩니다. 하지만 프론트엔드 입장에서는 종종 이 분리된 데이터들을 하나의 뷰에서 통합하여 보여줘야 하는 요구사항이 발생합니다.

제가 마주했던 요구사항은 “하나의 페이지에서 A 서비스의 ‘글’, B 서비스의 ‘글’, C 서비스의 ‘글’을 생성일자(createdAt) 순으로 섞어서 페이지네이션(Pagination) 처리”하는 것이었습니다. 각 글들은 각 도메인에 맞게 디테일이 다른 게시글이라고 생각하면 됩니다.

여기서 핵심 요구사항은 createdAt이라는 공통 기준으로 정렬하여 단일 페이지네이션을 제공하는 것입니다.

각기 다른 DB를 가진 서비스들의 데이터를 어떻게 효율적으로 조합하고, 정렬하고, 페이징할 수 있는지에 대해 정리해보았습니다.

문제 정의: 왜 단순한 API 호출로는 해결할 수 없는가?

처음에 생각했던 시나리오와 해당 시나리오의 문제점을 보면서 어떤 문제가 있는지 도출합니다.

시나리오 1: 게이트웨이에서의 인메모리(In-Memory) 집계

가장 쉽게 떠올린 방법은 다 조회하고, 정렬해서 내려주자 입니다.

  1. API 게이트웨이(혹은 BFF)가 A, B 서비스의 findAll() API를 각각 호출합니다.
  2. 모든 데이터를 메모리에 올린 후, createdAt 기준으로 정렬(Sorting)합니다.
  3. 정렬된 리스트에서 (page - 1) * size 만큼 오프셋(Offset)을 적용하여 원하는 페이지의 데이터를 반환합니다.

문제점: 데이터가 수백만 건이라면 API 게이트웨이는 100% OOM(Out of Memory)으로 중단됩니다. MSA의 이점을 완전히 무시하는 최악의 안티패턴입니다.

시나리오 2: 각 서비스에 나눠서 요청 후 집계

시나리오 1에서 데이터를 다 가져왔던 것이 문제니까, 한페이지에 10개씩 보여줘야한다면, 각 서비스에게 적당히 나눠서, (예: 5, 5) 합쳐서 정렬해서 내려주자입니다.

  1. 클라이언트가 1페이지 (size=10)를 요청합니다.
  2. 게이트웨이는 A, B 서비스에 각각 “1페이지 5개, 1페이지 5개”를 요청합니다.
  3. 총 10개의 데이터를 받아 다시 정렬한 후 반환합니다.

문제점: 이 방식은 데이터의 정합성이 완전히 깨집니다. 전체 데이터셋의 11~20번째 항목은 A 서비스에만 몰려있을 수도 있습니다. 각 서비스의 2페이지 데이터를 합친다고 해서 전체의 2페이지가 되는 것은 아닙니다.

시나리오 3: 각 서비스에 동일한 페이지 요청 후 집계

시나리오 1에서 데이터를 다 가져왔던 것이 문제니까, 각 서비스에게 n(10개)개씩만 요청해서 거기서 정렬해서 내려주자 입니다.

  1. 클라이언트가 2페이지 (size=10)를 요청합니다.
  2. 게이트웨이는 A, B 서비스에 각각 “1페이지, 10개”를 요청합니다.
  3. 총 30개의 데이터를 받아 다시 정렬한 후, 상위 10개를 반환합니다.

문제점: 이 방식 또한 데이터의 정합성이 완전히 깨집니다. 전체 데이터셋의 11~20번째 항목은 A 서비스에만 몰려있을 수도 있습니다. 각 서비스의 2페이지 데이터를 합친다고 해서 전체의 2페이지가 되는 것은 아닙니다.

해결전략 1: 요구사항 변경하기 (View에서 탭으로 구분)

현재 고민하고 있는 부분을 탭으로 도메인별로 화면을 그려줄 수 있습니다. 가장 손쉽게 각 모듈별로 pagination을 하는 방법입니다.

그러나, 이는 하나의 리스트로 보여줘야하는 요구사항을 만족할 수 없습니다.

해결 전략 2: API Gateway/BFF에서의 실시간 집계 (Scatter-Gather)

“Scatter-Gather” 패턴이라고도 부르는 패턴을 적용해봅니다. 시나리오2를 조금 더 보완한 모델입니다. 각 마이크로 서비스에 “적당히 여유분의 데이터까지 받아서 정렬한다.” 입니다.

  • 동작 방식 (Cursor 기반 가정)
    1. 클라이언트가 “1페이지 10개”를 요청합니다.
    2. 게이트웨이는 A, B 서비스에 각각 “20개”씩 요청합니다. (모듈 갯수 * 10(페이지당 아이템 갯수)개)를 요청합니다.
    3. 총 40개의 데이터를 받아 메모리에서 정렬합니다.
    4. 상위 10개만 클라이언트에 반환합니다.
    5. 다음 페이지 요청 시, 반환했던 10번째 아이템의 createdAt (혹은 ID)을 커서(Cursor)로 사용하여, “이 시간 이후 10개”를 A, B에 각각 요청합니다.
  • 한계점
    • 성능 저하: 항상 요청 size * N (서비스 개수) 만큼의 데이터를 네트워크를 통해 전송받고, 게이트웨이에서 정렬해야 합니다. 서비스가 10개라면 10배의 오버헤드가 발생합니다.
    • 깊은 페이지네이션(Deep Pagination) 문제: 오프셋 기반이라면 뒤 페이지로 갈수록 모든 서비스에서 page * size 만큼의 데이터를 가져와야 하므로 사실상 불가능합니다.
    • 복잡성: 커서 기반 로직, 특정 서비스에 데이터가 쏠렸을 때의 처리 등 게이트웨이의 코드가 매우 복잡해집니다.

해결 전략 3: CQRS와 검색 엔진을 활용한 비동기식 “읽기 모델” 구축

이 문제의 근본 원인은 “읽기(Read)”와 “쓰기(Write)”를 동일한 데이터 소스에서 처리하려 하기 때문입니다. 특히 통합 조회라는 “읽기” 요구사항은 개별 서비스라는 “쓰기” 모델과 맞지 않습니다.

여기서 CQRS (Command Query Responsibility Segregation) 패턴을 도입해봅니다.

  1. 쓰기(Command): 데이터 생성/수정/삭제는 기존처럼 서비스 A, B, C가 알아서 처리합니다. (각자의 DB에 저장)
  2. 읽기(Query): “통합 페이지네이션”과 같은 복잡한 읽기 요구사항을 처리하기 위한 별도의 읽기 전용 저장소를 구축합니다.
  3. A, B, C에서 데이터 변경이 발생하면 이벤트(Event)를 발행하여, 이 “읽기 전용 저장소”를 비동기적으로 업데이트합니다.

이 “읽기 전용 저장소”로 Elasticsearch와 같은 검색 엔진을 사용할 수 있습니다. Elasticsearch는 대용량 데이터의 복잡한 정렬 및 페이지네이션처리가 쉽고 빠릅니다.

내가 선택한 해결 전략(Phase 1) : Scatter-Gather

저는 MSA에서 pagination을 해결하기 위해 “Scatter-Gather”을 선택하였습니다. 이 방식은 명확한 문제점이 존재합니다.

  • 성능 문제 (잘못된 페이징): 이 방식은 사실상 페이지 정합성이 맞지 않습니다. 전체의 2페이지(11~20번째 글)가 모두 A 서비스에 있을 수 있는데, 이 방식으로는 B, C 서비스의 2페이지 데이터를 불필요하게 가져오게 됩니다. (앞서 ‘문제 정의’에서 다룬 시나리오 3의 문제입니다.)

  • 네트워크 오버헤드: 항상 N(서비스 개수) * K(페이지 크기) 만큼의 데이터를 네트워크를 통해 전송받아야 합니다. 서비스가 10개라면 10배의 낭비가 발생합니다.

  • 게이트웨이 부하: 모든 정렬 로직이 게이트웨이 메모리에서 발생하므로, 게이트웨이가 무거워지고 장애 지점(SPOF)이 될 가능성이 있습니다.

  • 깊은 페이지네이션(Deep Pagination) 불가: 100페이지를 요청하면, 게이트웨이는 A, B, C에서 각각 100페이지(약 1000개)씩, 총 3000개를 가져와 정렬해야 합니다. 이는 현실적으로 불가능합니다.

그러나 이런 문제점에도 이 방식을 선택한 이유는 다음과 같습니다.

  • 압도적으로 빠른 구현 속도 (Time-to-Market): Kafka, Elasticsearch 같은 새로운 인프라 구축 없이, 게이트웨이의 로직 수정만으로 가장 빠르게 기능을 출시할 수 있었습니다.

  • 초기 트래픽 감당 가능: 서비스 초기 단계이며, 통합 피드의 트래픽이 많지 않고 사용자들이 ‘깊은 페이지네이션’(예: 10페이지 이상)을 거의 사용하지 않을 것이라는 데이터 예측이 있었습니다.

  • 인프라 복잡도: 새로운 기술 스택(ES, Kafka)을 도입하고 운영하는 것은 학습 곡선과 인적 자원이 필요한 일이므로, 이는 “필요한 시점”에 도입하기로 했습니다.

  • “완벽하진 않지만, 현재의 비즈니스 요구를 가장 빨리 만족시키는” 현실적인 방법이었습니다.

향후 계획(Phase 2)CQRS + Elasticsearch 도입하기

현재 빠르게 어느정도 수준의 요구사항을 만족하는 모델의 한계를 인지하고 있기에, 향후 적용할 아키텍처는 Event-Driven 방식을 기반의 CQRS(명령 조회 책임 분리) 패턴을 도입하여, 통합 조회를 위한 별도의 “읽기 전용 저장소”를 구축하는 것입니다.

보다 쉽게 적용하기 위해 Spring event@TransactionalEventListener를 활용하여 미래의 Kafka 도입 준비를 미리 할 예정입니다.

Step 1: 각 마이크로서비스의 도메인 이벤트 발행 (feat. Spring)

아키텍처 및 동작 방식은 다음과 같습니다.

  1. 쓰기(Write): 기존과 동일합니다. A, B 서비스는 각자의 DB에 데이터를 저장합니다.
  2. 이벤트 발행: 데이터 저장 트랜잭션이 성공하면, 각 서비스는 Kafka로 “게시글 생성/수정/삭제” 이벤트를 발행합니다.
  3. 데이터 수집(Aggregation): 별도의 컨슈머 서비스(Aggregator)가 이 이벤트들을 구독하여, “통합 게시글” 스키마로 변환합니다.
  4. 읽기 모델 저장: 변환된 데이터를 Elasticsearch의 단일 인덱스(integrated-posts-index)에 색인합니다.
  5. 읽기(Read): 통합 피드 API(/integration/posts)는 더 이상 A, B, C를 호출하지 않습니다. 오직 Elasticsearch에만 빠르고 효율적인 페이징 쿼리를 전송합니다.

서비스 A, B 에서 게시글이 생성/수정될 때, 트랜잭션이 커밋된 후 이벤트를 발행합니다.

(예: 서비스 A의 PostService.java)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Spring Framework의 @TransactionalEventListener 활용
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePostCreatedEvent(PostCreatedEvent event) {
    // Kafka로 발행할 DTO (공통 필드 포함)
    IntegrationEventDto dto = new IntegrationEventDto(
        event.getId(),
        "NOTICE", // A 서비스의 타입
        event.getTitle(),
        event.getCreatedAt()
    );
    // 현재는 내부 로직 처리, Phase 2에서는 Kafka 발행
    // kafkaProducer.send("integration-topic", dto);
    internalEventHandler.handle(dto);
}

Step 2: Kafka를 이용한 이벤트 브로커

발행된 이벤트들은 중앙의 Kafka 같은 메시지 브로커의 특정 토픽(integration-topic 등)으로 전송됩니다. Kafka는 A, B, C 서비스와 읽기 모델을 구축할 컨슈머 서비스 간의 디커플링(Decoupling)을 보장합니다.

Step 3: “통합 뷰” 전용 컨슈머 서비스 (Aggregator)

별도의 Spring Boot 애플리케이션(이하 “Aggregator”)을 만듭니다. 이 서비스는 오직 하나의 책임만 가집니다.

  1. integration-topic을 구독(Subscribe)합니다.
  2. A, B, C 서비스로부터 들어오는 이벤트를 수신합니다.
  3. 이 이벤트 데이터를 Elasticsearch가 이해할 수 있는 “공통 스키마” 문서로 변환합니다.

Step 4: Elasticsearch에 데이터 적재 (Materialized View)

Aggregator는 변환된 데이터를 Elasticsearch에 색인(Indexing)합니다. 이것이 바로 우리의 “읽기 전용 통합 뷰 (Materialized View)”입니다.

(예: integration-index의 스키마)

1
2
3
4
5
6
7
8
9
10
11
{
  "mappings": {
    "properties": {
      "originId": { "type": "keyword" }, // 원본 서비스의 ID (A-1, B-5 등)
      "originType": { "type": "keyword" }, // "POST", "PRODUCT", "EVENT"
      "title": { "type": "text" },
      "createdAt": { "type": "date" }, // 통합 정렬의 기준
      // ... 기타 공통 필드 (작성자, 썸네일 URL 등)
    }
  }
}

Step 5: 통합 API 엔드포인트 제공

이제 “통합 페이지네이션” API를 제공할 차례입니다. 이 API는 더 이상 A, B, C를 호출하지 않습니다.

  1. BFF 또는 API 게이트웨이에 GET /integration/feed?page=1&size=20 엔드포인트를 만듭니다.
  2. 이 API는 내부적으로 Elasticsearch에 직접 쿼리합니다.
  3. 쿼리: “integration-index”에서 createdAt을 기준으로 내림차순 정렬하고, pagesize에 맞게 페이징하여 데이터를 가져옵니다.

CQRS, Elasticsearch로 구현시 고려사항

데이터 일관성: 최종적 일관성(Eventual Consistency)의 수용

가장 큰 트레이드오프입니다. 사용자가 서비스 A에 글을 작성한 직후, 통합 피드에 즉시 나타나지 않을 수 있습니다. (Kafka -> Aggregator -> ES 색인까지 수백 ms ~ 수 초의 지연 발생 가능)

이는 비즈니스 요구사항과의 협의가 필요합니다. 대부분의 “통합 피드”는 실시간 일관성보다는 최종적 일관성으로도 충분한 경우가 많습니다.

데이터 스키마: 공통 정렬/필터 필드 표준화

Elasticsearch에 저장할 “공통 스키마”를 잘 정의해야 합니다. 정렬 기준이 되는 createdAt, 필터링 기준이 될 authorId 등은 모든 서비스 이벤트에 공통적으로 포함되도록 표준화해야 합니다.

초기 데이터 적재(Initial Data Load) 처리

Kafka 이벤트는 신규/변경 건만 처리합니다. 기존 DB에 있던 수백만 건의 데이터를 ES로 옮기기 위한 별도의 배치(Batch) 작업(Job)이 필요합니다.

Spring Batch 등을 이용해 각 서비스의 DB를 읽어 ES로 직접 Bulk Insert하여 초기 적재본을 구성할 예정입니다.

Kafka 이벤트 처리 중 실패(장애)가 발생하면 데이터 정합성 보장

컨슈머(Aggregator)의 재시도(Retry) 로직과 데드 레터 큐(DLQ)를 활용합니다. ES 색인 실패 시, 몇 차례 재시도 후 실패한 메시지는 DLQ 토픽으로 보내고 알림을 발생시켜 수동으로 원인을 파악하고 재처리할 수 있도록 구성할 예정입니다.

Elasticsearch 인덱스 스키마가 변경될 때(예: 새 필드 추가)는 대응

ES의 Reindex API와 Alias(별칭) 기능을 활용한 Blue/Green 배포 전략을 사용할 예정입니다.

새 스키마로 integration-index-v2를 생성하고, 기존 데이터를 Reindex API로 마이그레이션한 후, API가 바라보는 Alias를 v1에서 v2로 원자적으로 교체하여 다운타임 없이 스키마를 변경할 수 있습니다.

결론: 현실적인 트레이드오프와 진화하는 아키텍처

MSA 환경에서 통합 페이지네이션은 간단해 보이지만 복잡한 트레이드오프를 요구하는 문제입니다.

저는 ‘완벽한’ 아키텍처를 처음부터 구축하기보다, 비즈니스 속도를 우선하여 Phase 1 (Scatter-Gather)을 채택했습니다. 이는 ‘빠른 출시’라는 명확한 이점을 주었지만, ‘페이지 정합성’과 ‘성능’이라는 기술적 부채를 안고 가는 결정이었습니다.

중요한 것은 이 기술적 부채를 인지하고, 서비스의 성장에 맞춰 Phase 2 (CQRS + Elasticsearch)로 진화할 로드맵을 설계했다는 점입니다.

Phase 2가 도입되면, ‘읽기’와 ‘쓰기’가 분리되어 다음과 같은 이점을 얻을 것으로 기대합니다.

  1. 성능: 사용자는 서비스 개수와 무관하게 매우 빠른 “통합 피드” 응답을 받습니다.

  2. 결합도(Coupling) 감소: 읽기 API는 A, B, C 서비스의 장애 상태와 완전히 분리됩니다. A 서비스가 죽어도 (이미 색인된) 통합 피드는 정상적으로 작동합니다.

  3. 확장성: 추후 D, E 서비스의 데이터도 통합 피드에 넣고 싶다면, Aggregator 서비스가 D, E의 이벤트도 구독하도록 수정하고 ES 스키마에 originType만 추가하면 됩니다. 기존 API는 코드를 한 줄도 수정할 필요가 없습니다.

복잡한 “읽기” 요구사항은 “쓰기” 모델과 분리하여 전용 “읽기 모델”을 구축하는 것이 MSA를 성공적으로 운영하는 핵심 전략 중 하나임을 다시 한번 깨닫게 되었습니다.

This post is licensed under CC BY 4.0 by the author.