들어가며
이번 글은 회사에서 새로운 PG 도입을 준비하며 점진적 전환을 위한 멀티 PG 설계와 고민거리들을 정리한 글입니다.
도입 배경
새로운 PG 도입을 검토하게 된 배경은 크게 두 가지입니다.
- 수수료 절감: 현재 사용 중인 PG보다 수수료가 낮은 PG사가 있어, 트래픽 일부를 전환하면 비용을 줄일 수 있습니다.
- 장애 대비: 단일 PG에 의존하고 있어 해당 PG에 장애가 발생하면 전체 결제가 중단됩니다. 사전에 여러 PG를 연동해두면 장애 상황에서 빠르게 대응할 수 있습니다.
이번 글의 범위
멀티 PG 시스템을 완벽하게 구축하려면 장애 감지, 자동 전환, 모니터링 등 고려할 것이 많습니다. 하지만 이번 첫 마일스톤에서는 단순한 가중치 기반 분산에 집중했습니다.
- 이번에 다루는 것: userId 기반 가중치 분산으로 트래픽을 여러 PG에 나누기
- 이번에 다루지 않는 것: 장애 감지 및 자동 전환 (PG 장애 시 수동으로 가중치 조정)
향후 자동 전환을 구현한다면, Health Check API를 주기적으로 호출하거나 결제 실패율을 모니터링해서 임계치 초과 시 자동으로 해당 PG를 비활성화하는 방식을 고려하고 있습니다.
빌링 결제란?
- 빌링키: 카드 정보를 대체하는 토큰입니다. PG사별로 발급됩니다.
- 핵심: 카드 등록 시점에 PG가 결정되면, 해당 사용자의 정기 결제는 계속 그 PG를 사용합니다.
AS-IS: 단일 PG의 문제점
기존에는 하나의 PG사만 사용하고 있었습니다.
이 구조에서는 다음과 같은 문제가 있습니다.
1. 장애 시 전체 결제 불가
단일 PG에 장애가 발생하면 신규 카드 등록이 전면 중단됩니다. 기존 사용자의 정기 결제도 해당 PG를 통해 이루어지므로, 장애가 길어지면 매출에 직접적인 영향을 미칩니다.
2. 비용 협상력 부재
- 특정 PG사에 종속되어 있으면 수수료 협상에서 불리합니다.
- PG사별로 수수료가 다른데, 더 저렴한 PG를 선택할 수 없습니다.
3. 신규 PG 도입 시 안정성 검증 어려움
새로운 PG를 도입할 때 전체 트래픽을 한 번에 전환하면 리스크가 큽니다. 일부 트래픽만 새 PG로 보내면서 안정성을 검증할 방법이 필요합니다.
TO-BE: 멀티 PG 라우팅
이 문제들을 해결하기 위해 가중치 기반 멀티 PG 라우팅을 설계했습니다.
기대 효과
| 문제 | 해결 방안 |
|---|---|
| 장애 시 전체 결제 불가 | 장애 PG 비활성화 → 나머지 PG로 자동 분산 |
| 비용 협상력 부재 | 저렴한 PG 가중치 증가로 비용 절감 |
| 신규 PG 안정성 검증 어려움 | 가중치 10% → 30% → 50% 단계적 증가로 점진적 검증 |
핵심 아이디어
전체 흐름
가중치와 모듈러 연산의 결합
각 PG사에 가중치를 부여하고, userId % 가중치합의 결과를 구간에 매핑합니다.
토스: 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: truePG 선택 로직
@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, 나이스페이: 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에서만 사용 가능합니다.
따라서 정기 결제 시에는 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 배분이 달라집니다.
변경 전: 토스(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를 도입할 때 핵심은 다음과 같습니다.
- 카드 등록 시점에 PG 결정 - userId 기반 모듈러 + 가중치 구간 매핑
- 정기 결제는 빌링키 기반 - PG 선택 로직을 타지 않음
- 장애 시 신규 등록만 우회 - 기존 사용자는 영향 없음
실제 운영에서는 PG별 결제 성공률 모니터링, 장애 감지 알림, 빌링키 마이그레이션 전략 등을 추가로 고려해야 합니다.