멀티 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사서비스사용자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 결과가중치토스: 5KCP: 3나이스페이: 20~45~78~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 배분이 달라집니다.

012345678910토스 (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별 결제 성공률 모니터링, 장애 감지 알림, 빌링키 마이그레이션 전략 등을 추가로 고려해야 합니다.

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