멀티 PG 도입을 위한 준비

2025년 12월 04일

experience

# 멀티 PG# 모듈러 연산# 가중치 기반 라우팅

들어가며

이번 글은 회사에서 새로운 PG 도입을 준비하며 점진적 전환을 위한 멀티 PG 설계와 고민거리들을 정리한 글입니다.

도입 배경

새로운 PG 도입을 검토하게 된 배경은 크게 두 가지입니다.

  1. 수수료 절감: 현재 사용 중인 PG보다 수수료가 낮은 PG사가 있어, 트래픽 일부를 전환하면 비용을 줄일 수 있습니다.
  2. 장애 대비: 단일 PG에 의존하고 있어 해당 PG에 장애가 발생하면 전체 결제가 중단됩니다. 사전에 여러 PG를 연동해두면 장애 상황에서 빠르게 대응할 수 있습니다.

이번 글의 범위

멀티 PG 시스템을 완벽하게 구축하려면 장애 감지, 자동 전환, 모니터링 등 고려할 것이 많습니다. 하지만 이번 첫 마일스톤에서는 단순한 가중치 기반 분산에 집중했습니다.

  • 이번에 다루는 것: userId 기반 가중치 분산으로 트래픽을 여러 PG에 나누기
  • 이번에 다루지 않는 것: 장애 감지 및 자동 전환 (PG 장애 시 수동으로 가중치 조정)

향후 자동 전환을 구현한다면, Health Check API를 주기적으로 호출하거나 결제 실패율을 모니터링해서 임계치 초과 시 자동으로 해당 PG를 비활성화하는 방식을 고려하고 있습니다.

빌링 결제란?

PG사서비스사용자PG사서비스사용자1. 카드 등록 (최초 1회)2. 정기 결제 (매월)카드 등록 요청빌링키 발급 요청빌링키 반환등록 완료빌링키로 결제 요청결제 완료결제 알림

  • 빌링키: 카드 정보를 대체하는 토큰입니다. PG사별로 발급됩니다.
  • 핵심: 카드 등록 시점에 PG가 결정되면, 해당 사용자의 정기 결제는 계속 그 PG를 사용합니다.

AS-IS: 단일 PG의 문제점

기존에는 하나의 PG사만 사용하고 있었습니다.

사용자

서비스

단일 PG사

이 구조에서는 다음과 같은 문제가 있습니다.

1. 장애 시 전체 결제 불가

사용자

서비스

PG사 장애

단일 PG에 장애가 발생하면 신규 카드 등록이 전면 중단됩니다. 기존 사용자의 정기 결제도 해당 PG를 통해 이루어지므로, 장애가 길어지면 매출에 직접적인 영향을 미칩니다.

2. 비용 협상력 부재

  • 특정 PG사에 종속되어 있으면 수수료 협상에서 불리합니다.
  • PG사별로 수수료가 다른데, 더 저렴한 PG를 선택할 수 없습니다.

3. 신규 PG 도입 시 안정성 검증 어려움

새로운 PG를 도입할 때 전체 트래픽을 한 번에 전환하면 리스크가 큽니다. 일부 트래픽만 새 PG로 보내면서 안정성을 검증할 방법이 필요합니다.

TO-BE: 멀티 PG 라우팅

이 문제들을 해결하기 위해 가중치 기반 멀티 PG 라우팅을 설계했습니다.

50%

30%

20%

사용자

서비스

PG 라우터

토스

KCP

나이스페이

기대 효과

문제 해결 방안
장애 시 전체 결제 불가 장애 PG 비활성화 → 나머지 PG로 자동 분산
비용 협상력 부재 저렴한 PG 가중치 증가로 비용 절감
신규 PG 안정성 검증 어려움 가중치 10% → 30% → 50% 단계적 증가로 점진적 검증

핵심 아이디어

전체 흐름

카드 등록 요청

userId 추출

userId % 가중치합

구간 매핑으로 PG 선택

빌링키 발급 및 저장

가중치와 모듈러 연산의 결합

각 PG사에 가중치를 부여하고, userId % 가중치합의 결과를 구간에 매핑합니다.

userId % 10 결과

가중치

토스: 5

KCP: 3

나이스페이: 2

0~4

5~7

8~9

토스: 5, KCP: 3, 나이스페이: 2 (가중치 합: 10)

userId % 10 결과:
- 0~4 → 토스 (50%)
- 5~7 → KCP (30%)
- 8~9 → 나이스페이 (20%)

왜 userId를 사용하는가?

  • 결정론적: 같은 userId는 항상 같은 PG를 선택합니다. 카드 재등록 시에도 동일한 PG를 사용합니다.
  • 균등 분배: userId가 충분히 많으면 자연스럽게 가중치 비율대로 분배됩니다.
  • 디버깅 용이: 특정 사용자의 PG를 쉽게 추적할 수 있습니다.
  • 빌링키 호환: 사용자가 카드를 재등록해도 같은 PG이므로 기존 빌링키 관리가 단순해집니다.

구현

PG 설정

data class PgConfig(
    val name: String,
    val weight: Int,
    val enabled: Boolean = true
)

@ConfigurationProperties(prefix = "payment")
data class PgProperties(
    val pg: List<PgConfig>
)
# application.yml
payment:
  pg:
    - name: TOSS
      weight: 5
      enabled: true
    - name: KCP
      weight: 3
      enabled: true
    - name: NICEPAY
      weight: 2
      enabled: true

PG 선택 로직

@Component
class PgSelector(
    private val pgProperties: PgProperties
) {
    fun select(userId: Long): String {
        val enabledConfigs = pgProperties.pg.filter { it.enabled }

        if (enabledConfigs.isEmpty()) {
            throw IllegalStateException("활성화된 PG가 없습니다")
        }

        val totalWeight = enabledConfigs.sumOf { it.weight }
        val bucket = (userId % totalWeight).toInt()

        var accumulated = 0
        for (config in enabledConfigs) {
            accumulated += config.weight
            if (bucket < accumulated) {
                return config.name
            }
        }

        // fallback (이론상 도달하지 않음)
        return enabledConfigs.last().name
    }
}

장애 대응: 특정 PG 비활성화

@Service
class PgManagementService(
    private val pgProperties: PgProperties
) {
    fun disablePg(pgName: String) {
        pgProperties.pg
            .find { it.name == pgName }
            ?.let { it.copy(enabled = false) }
            ?: throw IllegalArgumentException("존재하지 않는 PG: $pgName")
    }
}

특정 PG에 장애가 발생하면 enabled = false로 설정합니다. 그러면 해당 PG의 가중치가 제외되고, 나머지 PG들이 기존 비율을 유지하며 트래픽을 나눠 갖습니다.

장애 발생

토스 장애

토스: ❌

KCP: 3 (60%)

나이스페이: 2 (40%)

정상 상태

토스: 5 (50%)

KCP: 3 (30%)

나이스페이: 2 (20%)

토스 장애 발생 시:
KCP: 3, 나이스페이: 2 (가중치 합: 5)

userId % 5 결과:
- 0~2 → KCP (60%)
- 3~4 → 나이스페이 (40%)

테스트

@Test
fun `가중치 비율대로 PG가 선택된다`() {
    val selector = PgSelector(
        PgProperties(
            pg = listOf(
                PgConfig("TOSS", 5),
                PgConfig("KCP", 3),
                PgConfig("NICEPAY", 2)
            )
        )
    )

    val results = (0L until 10000L)
        .map { selector.select(it) }
        .groupingBy { it }
        .eachCount()

    // 대략 50%, 30%, 20% 비율로 선택됨
    assertThat(results["TOSS"]).isCloseTo(5000, within(100))
    assertThat(results["KCP"]).isCloseTo(3000, within(100))
    assertThat(results["NICEPAY"]).isCloseTo(2000, within(100))
}

@Test
fun `같은 userId는 항상 같은 PG를 반환한다`() {
    val selector = PgSelector(/* ... */)

    val userId = 12345L
    val results = (1..100).map { selector.select(userId) }.toSet()

    assertThat(results).hasSize(1) // 항상 같은 PG
}

고려사항

빌링키와 PG의 관계

중요: 빌링키는 발급한 PG에서만 사용 가능합니다.

결제 시도

토스에서 발급

✅ 성공

❌ 실패

❌ 실패

빌링키 A

토스

KCP

나이스페이

따라서 정기 결제 시에는 PG 선택 로직을 타지 않습니다. 저장된 빌링키의 PG로 바로 결제합니다.

fun processSubscriptionPayment(userId: Long) {
    val billingKey = billingKeyRepository.findByUserId(userId)
        ?: throw IllegalStateException("등록된 결제 수단이 없습니다")

    // 빌링키에 저장된 PG로 결제 (선택 로직 X)
    val pg = billingKey.pgType
    pgClientFactory.getClient(pg).pay(billingKey.key, amount)
}

가중치 변경 시 주의점

가중치를 변경하면 신규 카드 등록 사용자의 PG 배분이 달라집니다.

0011223344556토스 (0~4) 토스 (0~5) KCP (5~7) KCP (6~7) 나이스페이 (8~9) 나이스페이 (8~9) 변경 전변경 후userId 6의 PG 변경 예시

변경 전: 토스(5), KCP(3), 나이스페이(2) → userId 6 → KCP
변경 후: 토스(6), KCP(2), 나이스페이(2) → userId 6 → 토스 (변경됨!)

빌링 시스템에서의 영향:

  • 이미 카드 등록한 사용자: 영향 없음 (기존 빌링키 사용)
  • 카드 재등록 시: 다른 PG로 빌링키가 발급될 수 있음 → 기존 빌링키 무효화 필요

Hash 사용 고려

userId가 순차적으로 증가하는 경우, 특정 시간대 가입자들이 모두 같은 PG로 몰릴 수 있습니다. 이를 방지하려면 hash를 사용할 수 있습니다.

fun select(userId: Long): String {
    val hash = userId.hashCode().absoluteValue
    val bucket = hash % totalWeight
    // ...
}

마치며

빌링 결제 기반 구독 시스템에서 멀티 PG를 도입할 때 핵심은 다음과 같습니다.

  1. 카드 등록 시점에 PG 결정 - userId 기반 모듈러 + 가중치 구간 매핑
  2. 정기 결제는 빌링키 기반 - PG 선택 로직을 타지 않음
  3. 장애 시 신규 등록만 우회 - 기존 사용자는 영향 없음

정기 결제

저장된 빌링키

해당 PG로 결제

카드 등록

PG 선택 로직

빌링키 발급

실제 운영에서는 PG별 결제 성공률 모니터링, 장애 감지 알림, 빌링키 마이그레이션 전략 등을 추가로 고려해야 합니다.

© 2025, 미나리와 함께 만들었음