diff --git a/platform/src/main/java/com/commerce/platform/PlatformApplication.java b/platform/src/main/java/com/commerce/platform/PlatformApplication.java index e9617fb..eabe938 100644 --- a/platform/src/main/java/com/commerce/platform/PlatformApplication.java +++ b/platform/src/main/java/com/commerce/platform/PlatformApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class PlatformApplication { diff --git a/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCaseImpl.java b/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCaseImpl.java index 8d43271..fac2c46 100644 --- a/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCaseImpl.java +++ b/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCaseImpl.java @@ -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()); diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/PgStrategy.java b/platform/src/main/java/com/commerce/platform/core/application/out/PgStrategy.java index 16650b9..b3b293b 100644 --- a/platform/src/main/java/com/commerce/platform/core/application/out/PgStrategy.java +++ b/platform/src/main/java/com/commerce/platform/core/application/out/PgStrategy.java @@ -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 { @@ -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; + } } diff --git a/platform/src/main/java/com/commerce/platform/core/domain/enums/PgProvider.java b/platform/src/main/java/com/commerce/platform/core/domain/enums/PgProvider.java index 9adffd5..10d8998 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/enums/PgProvider.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/enums/PgProvider.java @@ -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 payMethods; - private final Set payProviders; - - public static List getByPayMethod(PayMethod payMethod, PayProvider payProvider) { - List 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)) diff --git a/platform/src/main/java/com/commerce/platform/core/domain/service/PaymentPgRouter.java b/platform/src/main/java/com/commerce/platform/core/domain/service/PaymentPgRouter.java index 19841b2..f73e87a 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/service/PaymentPgRouter.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/service/PaymentPgRouter.java @@ -5,11 +5,18 @@ 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.scheduling.annotation.Scheduled; 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; /** @@ -23,22 +30,33 @@ public class PaymentPgRouter { private final Map pgStrategies; private final PgCacheService pgCacheService; + private final PgFeeInfoRepository feeInfoRepository; + //결제방식 + 카드사/통신사 별 수수료기준 정렬됨 + private Map>> pgFeeCache = null; - public PaymentPgRouter(List list, PgCacheService pgCacheService) { + public PaymentPgRouter(List 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() { + setPgFeeCache(); } /** - * 결제유형+카드사에 따라 PG 선택 - * Redis에서 캐싱 + * 결제유형 + 카드사 => 유효 PG 추출 + * redis 캐싱된 health check */ public PgStrategy routePg(PayMethod payMethod, PayProvider payProvider) { - - List 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사가 없습니다"); @@ -48,7 +66,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); @@ -57,4 +75,20 @@ public PgStrategy getPgStrategyByProvider(PgProvider pgProvider) { } return strategy; } + + @Scheduled(cron = "0 * * * * *") + private void refreshPgCache() { + setPgFeeCache(); + } + + private void setPgFeeCache() { + pgFeeCache = feeInfoRepository.findAllActiveAndValid() + .stream() + .collect(Collectors.groupingBy(PgFeeInfo::getPayMethod, + Collectors.groupingBy(PgFeeInfo::getPayProvider, + Collectors.toCollection(() -> + new TreeSet<>(Comparator.comparing(PgFeeInfo::getFeeRate)) + ) + ))); + } } diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java index 2ca4f98..56437f4 100644 --- a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java +++ b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java @@ -1,26 +1,15 @@ 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 @@ -28,91 +17,9 @@ 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 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 supportedPgs) { - // redis 조회 - Set 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 getAvailablePgsFromCache(PayMethod payMethod, PayProvider payProvider) { - String key = buildRouteKey(payMethod, payProvider); - return redisTemplate.opsForZSet().range(key, 0, -1); - } - - /** - * DB에서 수수료 조회 및 Redis 캐싱 - * ZSet score: 수수료율 - */ - public Set refreshCache(PayMethod payMethod, PayProvider payProvider) { - String key = buildRouteKey(payMethod, payProvider); - // DB 조회: 수수료 낮은 순 - List 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 헬스 체크 */ @@ -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(); - } } diff --git a/platform/src/test/java/com/commerce/platform/core/application/in/PaymentUseCaseImplTest.java b/platform/src/test/java/com/commerce/platform/core/application/in/PaymentUseCaseImplTest.java index 80c7ae8..39f6398 100644 --- a/platform/src/test/java/com/commerce/platform/core/application/in/PaymentUseCaseImplTest.java +++ b/platform/src/test/java/com/commerce/platform/core/application/in/PaymentUseCaseImplTest.java @@ -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, @@ -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); diff --git a/platform/src/test/java/com/commerce/platform/core/domain/service/PaymentPgRouterTest.java b/platform/src/test/java/com/commerce/platform/core/domain/service/PaymentPgRouterTest.java index 9eb5ed6..acedd33 100644 --- a/platform/src/test/java/com/commerce/platform/core/domain/service/PaymentPgRouterTest.java +++ b/platform/src/test/java/com/commerce/platform/core/domain/service/PaymentPgRouterTest.java @@ -1,55 +1,132 @@ package com.commerce.platform.core.domain.service; +import com.commerce.platform.PlatformApplication; 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.adaptor.PgCacheService; +import com.commerce.platform.infrastructure.persistence.PgFeeInfo; +import com.commerce.platform.infrastructure.persistence.PgFeeInfoRepository; import com.commerce.platform.infrastructure.pg.NHNStrategy; import com.commerce.platform.infrastructure.pg.TossStrategy; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.redis.core.StringRedisTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -@SpringBootTest +/** + * PaymentPgRouter 단위테스트 + */ +@ExtendWith(MockitoExtension.class) class PaymentPgRouterTest { - @Autowired private PaymentPgRouter paymentPgRouter; - @Autowired + @Mock private PgCacheService pgCacheService; - @Autowired - private StringRedisTemplate redisTemplate; + @Mock + private PgFeeInfoRepository feeInfoRepository; + + @BeforeEach + void init_paymentPgRouter() { + // mock pgStrategy + TossStrategy tossMock = mock(TossStrategy.class); + NHNStrategy nhnMock = mock(NHNStrategy.class); + + when(tossMock.getPgProvider()).thenReturn(PgProvider.TOSS); + when(nhnMock.getPgProvider()).thenReturn(PgProvider.NHN); + + // PaymentPgRouter 생성 + paymentPgRouter = new PaymentPgRouter( + List.of(tossMock, nhnMock), + pgCacheService, + feeInfoRepository + ); + + // mock 수수료 + PgFeeInfo tossFee_kb = new PgFeeInfo( + PgProvider.TOSS, PayMethod.CARD, PayProvider.KB, + BigDecimal.valueOf(2.5), + LocalDate.now(), LocalDate.now().plusMonths(1) + ); + + PgFeeInfo tossFee_samsung = new PgFeeInfo( + PgProvider.TOSS, PayMethod.CARD, PayProvider.SAMSUNG, + BigDecimal.valueOf(2.6), + LocalDate.now(), LocalDate.now().plusMonths(1) + ); + PgFeeInfo nhnFee_samsung = new PgFeeInfo( + PgProvider.NHN, PayMethod.CARD, PayProvider.SAMSUNG, + BigDecimal.valueOf(2.3), + LocalDate.now(), LocalDate.now().plusMonths(1) + ); + + when(feeInfoRepository.findAllActiveAndValid()) + .thenReturn(List.of(tossFee_kb, tossFee_samsung, nhnFee_samsung)); + + // 캐시 초기화 @EventListener(ApplicationReadyEvent.class) + paymentPgRouter.initPgCache(); + } @DisplayName("결제유형에 따른 라우팅") @Test void routePg() { + // TOSS, NHN 모두 정상 + when(pgCacheService.isHealthy(PgProvider.TOSS)) + .thenReturn(true); + when(pgCacheService.isHealthy(PgProvider.NHN)) + .thenReturn(true); + assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.KB)) - .as("KB 카드 결제는 TOSS 만 존재").isInstanceOf(TossStrategy.class); + .as("KB 카드 결제는 TOSS만 존재") + .isInstanceOf(TossStrategy.class); assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.SAMSUNG)) - .as("SAMSUNG 카드 결제는 NHN 우선").isInstanceOf(NHNStrategy.class); - + .as("SAMSUNG 카드 결제는 NHN이 수수료 낮아서 우선") + .isInstanceOf(NHNStrategy.class); } @DisplayName("1위 장애시 2위 반환") @Test void routePg_health() { - // nhn 장애 - pgCacheService.markPgAsUnhealthy(PgProvider.NHN); - - assertThat(pgCacheService.isHealthy(PgProvider.NHN)) - .isEqualTo(false); + // NHN 장애, TOSS 정상 + when(pgCacheService.isHealthy(PgProvider.NHN)).thenReturn(false); + when(pgCacheService.isHealthy(PgProvider.TOSS)).thenReturn(true); assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.SAMSUNG)) - .as("SAMSUNG 카드 결제 : NHN 장애로 TOSS!").isInstanceOf(TossStrategy.class); + .as("SAMSUNG 카드 결제: NHN 장애로 TOSS로 폴백") + .isInstanceOf(TossStrategy.class); + } + + @DisplayName("ApplicationStartedEvent 동작 검증") + @Test + void initPgCache() { + SpringApplication application = new SpringApplication(PlatformApplication.class); + + application.addListeners( + (ApplicationListener) event -> { + PaymentPgRouter targetBean = (PaymentPgRouter) event.getApplicationContext() + .getBean("paymentPgRouter"); + +// assertThat(targetBean.pgFeeCache).isNotEmpty(); + } + ); + + application.run(); - // nhn 장애 원복 - redisTemplate.delete("pg:health:NHN"); } } \ No newline at end of file