이 글은 안정적인 서비스를 위해 결제 상태 서버에 캐시를 적용한 과정을 다룹니다.
다음과 같이 서버의 사용량을 가정하였습니다.
| 평균 TPS | 10,000 |
| 최대 TPS | 20,000 |
결제 시스템에서는 하루에도 수백 번의 결제 상태 조회가 발생하고 있으며, 다양한 기능과 웹훅 알림들이 빠르게 확장되고 있어요. 이렇게 많은 조회를 기반으로 결제 서비스가 성장하면서, 사용자와 내부 시각화 대시보드, 웹훅 리스너까지 포함해 TPS가 평균 1만, 최대 2만까지 늘어났습니다.
Database: 더 이상 버틸 수 없다!
결제 상태 서비스는 사용자의 결제 건별 상태(PENDING, COMPLETED, FAILED)를 기록하고 조회할 수 있는 서비스입니다. 결제 상태 변경은 결제 승인 또는 실패 시점에 한 번 발생하지만, UI나 후속 처리 서비스에서 결제 상태를 매번 확인하기 때문에 조회량이 폭증합니다.
어느 날, 신규 기능 배포 이후 TPS가 급증했고, DB 조회량이 급격히 늘어나 DB 부하가 심각해졌습니다. 이 트래픽이 유지될 경우 다른 서비스에도 문제가 생길 수 있어 급히 롤백하며 DB 부하를 줄일 방안을 고민했고, 그 결과 흔히 알고 있는 ‘캐시’를 적용하기로 했지만, 막상 시도해 보니 생각보다 간단치 않았어요.
캐시 적용은 보다 신중하게
캐시를 적용한다고 마스터 DB에만 의존하지 않고 Replication 구조로 부하를 분산할 수 있지 않냐고 생각할 수도 있습니다. 하지만 결제 상태는 금융 트랜잭션의 핵심 데이터로, 상태가 DB에 커밋된 순간 바로 다음 요청에서 정확히 반영되어야 합니다. 이를 Strong Consistency라고 부르며, 만약 잘못된 상태가 전달되면 결제 중복 처리나 환불 누락 같은 심각한 금융 사고가 발생할 수 있어요. Replication DB 구조에서는 복제 지연(Replication Delay)이 발생할 수 있어, 신뢰성을 위해 바로 읽기 대신 Redis Look-aside 캐시를 활용하기로 결정했습니다.
복제 지연(Replication Delay)?
주 DB(Primary)와 보조 DB(Replica) 간의 데이터 동기화가 지연되는 현상
이러한 방식으로 캐시를 적용했어요!
결제 상태 서버는 대부분 조회성 트래픽이므로, 상태 정보가 자주 변경되지 않고 대부분이 Cache Hit를 하기 때문에 Look-aside 전략을 사용했습니다. Look-aside 전략은 캐시에 먼저 조회하고, 데이터가 없으면 DB에서 조회한 뒤 캐시에 저장하고 응답하는 방식이에요. 만약 상태 변경 빈도가 높다면 다른 캐시 정책을 고려해야 합니다.
@Component
class PaymentStatusEntityListener {
private val eventPublisher: ApplicationEventPublisher by lazy {
SpringContext.context
}
@PostUpdate
@PostRemove
@PostPersist
fun postProcess(status: PaymentStatus) {
eventPublisher.publishEvent(
StrongConsistencyCacheEvictEvent.of(
paymentStatus = status,
)
)
}
}
@Component
class StrongConsistencyCacheEvictListener(
private val strongConsistencyCacheService: StrongConsistencyCacheService,
) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun listen(event: StrongConsistencyCacheEvictEvent) {
strongConsistencyCacheService.evict(
// ... key: payment_${event.paymentStatus.id}
)
}
}
캐시 만료 처리는 Spring의 @EntityListener와 @TransactionalEventListener를 사용해, DB 커밋 이후에 캐시가 만료되도록 했습니다. 커밋 전 만료 처리 시, 아래와 같은 과거 버전이 다시 캐시에 적재되는 문제가 발생할 수 있기 때문이에요.
[과거 버전이 다시 적재되는 케이스]
A Thread: 결제 상태 변경(PENDING→COMPLETED)하여 Version이 1에서 2로 올라감
A Thread: Version 1 캐시 Evict 처리됨 (아직 Commit 전)
B Thread: Cache Miss → DB에서 Version 1 조회 후 Cache에 적재
A Thread: Version 2 Commit
C Thread: Cache Hit → 잘못된 Version 1 응답
하지만 @TransactionalEventListener가 AFTER_COMMIT으로 설정되어 있어 캐시 Evict가 실패해도 트랜잭션이 롤백되지 않기 때문에, 과거 캐시가 지속 응답될 수 있습니다.
@Service
@CircuitBreaker(name = ResilienceConfiguration.REDIS_CIRCUIT_BREAKER)
class StrongConsistencyCacheService {
// 캐시 get, evict 메서드 구현
}
@Bean
fun redisCircuitBreaker(registry: CircuitBreakerRegistry): CircuitBreaker {
val circuitBreaker = registry.circuitBreaker(
REDIS_CIRCUIT_BREAKER,
circuitBreakerConfig()
)
circuitBreaker.eventPublisher.onError { _: CircuitBreakerOnErrorEvent ->
circuitBreaker.transitionToForcedOpenState()
}
return circuitBreaker
}
fun <T> getOrElse(
key: String,
orElseFunc: () -> T?,
): T? {
val value: T? = try {
strongConsistencyCacheService.get(key = key)
} catch (ex: Exception) {
if (redisCircuitBreaker.state.isOpened()) {
return orElseFunc()
} else {
throw ex
}
}
return value
}
이 문제를 해결하기 위해 Circuit Breaker를 활용했습니다. 캐시 Evict 실패 시 Circuit을 강제 Open 상태로 전환해 자동 복구되지 않도록 하고, 캐시 GET 시 Circuit이 Open이면 모든 트래픽을 바로 DB로 조회해 잘못된 캐시 응답을 방지합니다. 캐시 장애가 발생하면 DB 부하가 증가하지만, 잘못된 결제 상태 응답으로 인한 사고보다 낫다고 판단했어요.
그렇게 열심히 준비했지만… 현실은?
Strong Consistency를 제대로 지키는지 확인하기 위해, 캐시 조회 대신 항상 DB 응답을 비교 검사했습니다. 테스트를 통과할 줄 알았지만, 아래와 같은 0.003초 격차 문제를 발견했어요.
[문제 과정]
A Thread - 결제 상태 변경(PENDING→COMPLETED)
A Thread - PaymentStatus 값 변경, ApplicationEvent 발행
A Thread - DB Commit
B Thread - Kafka Event Consume → 캐시에서 **이전 Version 조회**
A Thread - AFTER_COMMIT 설정으로 이제 캐시 Evict
Kafka 이벤트 소비가 캐시 Evict 이전에 발생해 잘못된 상태가 조회된 거죠. 이 문제는 Kafka 이벤트 발행 시점을 캐시 Evict 이후로 옮기면 해결됩니다.
@Component
class StrongConsistencyCacheEvictListener(
private val strongConsistencyCacheService: StrongConsistencyCacheService,
) {
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun listen(event: StrongConsistencyCacheEvictEvent) {
strongConsistencyCacheService.evict(...)
}
}
fun doAfterCommit(order: Int = Ordered.LOWEST_PRECEDENCE, action: () -> Unit) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() {
action()
}
override fun getOrder() = order
}
)
} else {
action()
}
}
// Kafka 이벤트 발행 부분
doAfterCommit {
kafkaTemplate.send(topic = paymentStatusChangeTopic,
key = status.id.toString(),
value = status)
}
이제 캐시 Evict → Kafka 발행 순으로 보장되어, 아래와 같이 동작합니다.
[문제 해결 후 과정]
A Thread - 상태 변경
A Thread - DB Commit
A Thread - AFTER_COMMIT 캐시 Evict
A Thread - Kafka 이벤트 발행
B Thread - Kafka 이벤트 Consume → 변경된 Version 조회
여전히 캐시 Evict 전 다른 요청이 들어올 가능성이 아주 낮지만, 정책으로 보강했습니다.
정책 레벨 안전장치
결제 상태 변경 API가 완료된 순간, 바로 다음 요청에는 DB에 저장된 최신 상태가 응답되어야 한다.
API 처리 완료 전은 Evict·이벤트 처리가 끝나지 않은 상태이므로, 해당 정책을 SLA로 명문화해 프런트 및 운영 팀 모두 명확히 인지하도록 했습니다.
캐시를 적용하면서 느낀 점
- Execution Over Perfection: 완벽해지려 하기보다, 빠르게 적용하고 모니터링하며 이슈를 해결해나가자.
- 코드 vs 정책: 프로그래머는 모든 것을 코드로만 해결하려 하지 말고, 더 간단한 정책·절차로도 충분히 방어할 수 있다.
지금은 DB 부하 없이 최대 2만 TPS를 안정적으로 버티고 있습니다. 추후 불필요한 조회를 줄이기 위해 Passport 유사 개념인 PaymentPassport 도입을 고민 중입니다.
'개발' 카테고리의 다른 글
| 내 컴퓨터를 서버로 사용해보자 (0) | 2024.08.29 |
|---|---|
| 오늘도 QA가 안된다고 말했다 (0) | 2024.08.24 |
| 채팅 시스템에 사용할 메시지 브로커 선택하기 (0) | 2024.06.05 |
| 당신은 정말 객체를 지향하고 있나요 (0) | 2024.05.25 |