Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public void doApproval(PayOrderCommand payOrdercommand) {
orderEntity.validForPay();

// pg사 라우팅
PgStrategy pgStrategy = pgRouter.routPg(payOrdercommand.getPayMethod());
PgStrategy pgStrategy = pgRouter.routePg(payOrdercommand.getPayMethod(), payOrdercommand.getPayProvider());

// 결재 entity 생성
payOrdercommand.setApprovedAmount(orderEntity.getResultAmt());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.commerce.platform.core.application.out;

import com.commerce.platform.core.application.in.dto.PayCancelCommand;
import com.commerce.platform.core.application.in.dto.PayOrderCommand;
import com.commerce.platform.core.application.out.dto.PgPayResponse;
import com.commerce.platform.core.domain.enums.PgProvider;

public abstract class PgStrategy {
Expand All @@ -13,4 +16,13 @@ public abstract class PgStrategy {

public abstract PgProvider getPgProvider();

// todo tmp
public PgPayResponse processApproval(PayOrderCommand payOrdercommand) {
return null;
}

// todo tmp
public PgPayResponse processCancel(PayCancelCommand cancelCommand) {
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,18 @@
import lombok.Getter;

import java.util.Arrays;
import java.util.List;
import java.util.Set;

@Getter
@AllArgsConstructor
public enum PgProvider {
TOSS(
Set.of(PayMethod.CARD, PayMethod.EASY_PAY),
Set.of(PayProvider.SHIN_HAN, PayProvider.KB, PayProvider.NH,
PayProvider.HYUNDAI, PayProvider.SAMSUNG, PayProvider.BC)
),
NHN(
Set.of(PayMethod.CARD, PayMethod.EASY_PAY),
Set.of(PayProvider.NH, PayProvider.HYUNDAI,
PayProvider.SAMSUNG, PayProvider.BC)
),
NICE_PAYMENTS(
Set.of(PayMethod.CARD, PayMethod.EASY_PAY),
Set.of(PayProvider.HANA, PayProvider.LOTTE,
PayProvider.SAMSUNG, PayProvider.BC)
),
TOSS,
NHN,
NICE_PAYMENTS,

DANAL(
Set.of(PayMethod.PHONE),
Set.of(PayProvider.LG, PayProvider.KT, PayProvider.SKT)

),
PAYLETTER(
Set.of(PayMethod.PHONE),
Set.of(PayProvider.LG, PayProvider.KT)
)
DANAL,
PAYLETTER
;

private final Set<PayMethod> payMethods;
private final Set<PayProvider> payProviders;

public static List<PgProvider> getByPayMethod(PayMethod payMethod, PayProvider payProvider) {
List<PgProvider> pgProviders = Arrays.stream(PgProvider.values())
.filter(pg -> pg.getPayMethods().contains(payMethod))
.filter(pg -> pg.getPayProviders().contains(payProvider))
.toList();

if(pgProviders.isEmpty()) throw new IllegalArgumentException("지원 PG사 없음");
return pgProviders;
}

public static PgProvider getByPgName(String pgName) {
return Arrays.stream(PgProvider.values())
.filter(pg -> pg.name().equalsIgnoreCase(pgName))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
import com.commerce.platform.core.domain.enums.PayProvider;
import com.commerce.platform.core.domain.enums.PgProvider;
import com.commerce.platform.infrastructure.adaptor.PgCacheService;
import com.commerce.platform.infrastructure.persistence.PgFeeInfo;
import com.commerce.platform.infrastructure.persistence.PgFeeInfoRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.stream.Collectors;

/**
Expand All @@ -23,22 +29,40 @@ public class PaymentPgRouter {

private final Map<PgProvider, PgStrategy> pgStrategies;
private final PgCacheService pgCacheService;
private final PgFeeInfoRepository feeInfoRepository;
//결제방식 + 카드사/통신사 별 수수료기준 정렬됨
private Map<PayMethod, Map<PayProvider, TreeSet<PgFeeInfo>>> pgFeeCache = null;

public PaymentPgRouter(List<PgStrategy> list, PgCacheService pgCacheService) {
public PaymentPgRouter(List<PgStrategy> list, PgCacheService pgCacheService, PgFeeInfoRepository feeInfoRepository) {
this.pgStrategies = list.stream()
.collect(Collectors.toMap(PgStrategy::getPgProvider, pg -> pg));
this.pgCacheService = pgCacheService;
this.feeInfoRepository = feeInfoRepository;
}

@EventListener(ApplicationStartedEvent.class)
public void initPgCache() {
pgFeeCache = feeInfoRepository.findAllActiveAndValid()
.stream()
.collect(Collectors.groupingBy(PgFeeInfo::getPayMethod,
Collectors.groupingBy(PgFeeInfo::getPayProvider,
Collectors.toCollection(() ->
new TreeSet<>(Comparator.comparing(PgFeeInfo::getFeeRate))
)
)));
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@f-lab-lyan 🙂

PostConstruct 는 싱글톤 생성 락에서 실행되어, 외부bean작업 포함되는게 좋지 않다고해서
ApplicationStartedEvent 시에 초기화하는 방향으로 수정해봤습니다.


/**
* 결제유형+카드사에 따라 PG 선택
* Redis에서 캐싱
* 결제유형 + 카드사 => 유효 PG 추출
* redis 캐싱된 health check
*/
public PgStrategy routePg(PayMethod payMethod, PayProvider payProvider) {

List<PgProvider> supportedPgs = PgProvider.getByPayMethod(payMethod, payProvider);

PgProvider selectedPg = pgCacheService.getBestPg(payMethod, payProvider, supportedPgs);
PgProvider selectedPg = pgFeeCache.get(payMethod).get(payProvider)
.stream()
.filter(pgFeeInfo -> pgCacheService.isHealthy(pgFeeInfo.getPgProvider()))
.toList()
.getFirst()
.getPgProvider();

if (selectedPg == null) {
throw new IllegalStateException("현재 사용 가능한 PG사가 없습니다");
Expand All @@ -48,7 +72,7 @@ public PgStrategy routePg(PayMethod payMethod, PayProvider payProvider) {
}

/**
* PG Provider => Strategy 조회
* PG Provider => Strategy bean 추출
*/
public PgStrategy getPgStrategyByProvider(PgProvider pgProvider) {
PgStrategy strategy = pgStrategies.get(pgProvider);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,118 +1,25 @@
package com.commerce.platform.infrastructure.adaptor;

import com.commerce.platform.core.domain.enums.PayMethod;
import com.commerce.platform.core.domain.enums.PayProvider;
import com.commerce.platform.core.domain.enums.PgProvider;
import com.commerce.platform.infrastructure.persistence.PgFeeInfo;
import com.commerce.platform.infrastructure.persistence.PgFeeInfoRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
* PG 라우팅을 위한 Redis 캐시 서비스
* 수수료 낮은 순으로 정렬된 PG 목록 관리
* 장애 PG는 제외
* 장애 PG 캐싱
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PgCacheService {

private final StringRedisTemplate redisTemplate;
private final PgFeeInfoRepository feeInfoRepository;

private static final String ROUTE_KEY_PREFIX = "pg:route:";
private static final String HEALTH_KEY_PREFIX = "pg:health:";

/**
* ZSet에 캐싱 확인 및 캐싱
* key= pg:route:CARD:SHIN_HAN
* score: 수수료율
*/
@PostConstruct
public void initPgCache() {
Set<String> keys = redisTemplate.keys(ROUTE_KEY_PREFIX + "*");

if(!keys.isEmpty()) return;

// 전체 캐싱
feeInfoRepository.findAllActiveAndValid()
.forEach(feeInfo -> {
String key = buildRouteKey(feeInfo.getPayMethod(), feeInfo.getPayProvider());

redisTemplate.opsForZSet().add(
key,
feeInfo.getPgProvider().name(),
feeInfo.getFeeRate().doubleValue());
});
}

public PgProvider getBestPg(PayMethod payMethod, PayProvider payProvider, List<PgProvider> supportedPgs) {
// redis 조회
Set<String> pgProviders = getAvailablePgsFromCache(payMethod, payProvider);

// miss
if (pgProviders == null || pgProviders.isEmpty()) {
pgProviders = refreshCache(payMethod, payProvider);
}

// 장애 PG 제외 첫번째 선택
PgProvider bestPg = null;
for (String pgName : pgProviders) {
bestPg = PgProvider.getByPgName(pgName);
if (supportedPgs.contains(bestPg) && isHealthy(bestPg)) {
return bestPg;
}
}

return null;
}

/**
* ZSet 수수료 asc
*/
private Set<String> getAvailablePgsFromCache(PayMethod payMethod, PayProvider payProvider) {
String key = buildRouteKey(payMethod, payProvider);
return redisTemplate.opsForZSet().range(key, 0, -1);
}

/**
* DB에서 수수료 조회 및 Redis 캐싱
* ZSet score: 수수료율
*/
public Set<String> refreshCache(PayMethod payMethod, PayProvider payProvider) {
String key = buildRouteKey(payMethod, payProvider);
// DB 조회: 수수료 낮은 순
List<PgFeeInfo> configs = feeInfoRepository
.findByPayMethodAndPayProvider(payMethod, payProvider);

// todo 별도 스레드로 하는것이 좋을지
// 기존 캐시 삭제
redisTemplate.delete(key);

for (PgFeeInfo config : configs) {
redisTemplate.opsForZSet().add(
key,
config.getPgProvider().name(),
config.getFeeRate().doubleValue()
);
}
private static final String HEALTH_KEY_PREFIX = "pg:health:";

return configs.stream()
.sorted(Comparator.comparing(PgFeeInfo::getFeeRate))
.map(pgFeeInfo -> pgFeeInfo.getPgProvider().name())
.collect(Collectors.toSet());
}

/**
* PG 헬스 체크
*/
Expand All @@ -129,11 +36,4 @@ public void markPgAsUnhealthy(PgProvider pgProvider) {
String healthKey = HEALTH_KEY_PREFIX + pgProvider.name();
redisTemplate.opsForValue().set(healthKey, "ERROR", 30, TimeUnit.MINUTES);
}

/**
* Redis Key 생성: pg:route:CARD:SHIN_HAN
*/
private String buildRouteKey(PayMethod payMethod, PayProvider payProvider) {
return ROUTE_KEY_PREFIX + payMethod.name() + ":" + payProvider.name();
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@f-lab-lyan 🙂
redis 캐싱정보에서 수수료는 제외했습니다.
pg health 는 분산환경에서 실시간으로 모두 반영해야되는 정보라서 redis 에 유지했습니다!

Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
* paymentUseCase, 결제관련 repository 통합테스트
*/
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({
PaymentUseCaseImpl.class,
Expand Down Expand Up @@ -304,15 +307,15 @@ void doPartCancel_failed() {
private void mockPgStrategy() {
PgStrategy mockPgStrategy = mock(PgStrategy.class);

when(mockPaymentPgRouter.routePg(any(PayMethod.class), any(PayProvider.class)))
.thenReturn(mockPgStrategy);

when(mockPaymentPgRouter.getPgStrategyByProvider(any()))
.thenReturn(mockPgStrategy);

when(mockPgStrategy.processCancel(any()))
.thenReturn(success_pgResponse);

when(mockPaymentPgRouter.routPg(PayMethod.CARD))
.thenReturn(mockPgStrategy);

when(mockPgStrategy.getPgProvider())
.thenReturn(PgProvider.TOSS);

Expand Down
Loading
Loading