diff --git a/.env b/.env index d75760c..0ed59a2 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ MYSQL_DATABASE=commerce_dev_db MYSQL_ROOT_PASSWORD=je1234 -MYSQL_PORT=3308 \ No newline at end of file +MYSQL_PORT=3308 +REDIS_PORT=6379 +REDIS_PASSWORD=je1234 \ No newline at end of file diff --git a/buildSrc/src/main/groovy/myproject-convention.gradle b/buildSrc/src/main/groovy/myproject-convention.gradle index 942c1ab..f24ce61 100644 --- a/buildSrc/src/main/groovy/myproject-convention.gradle +++ b/buildSrc/src/main/groovy/myproject-convention.gradle @@ -22,6 +22,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly("com.mysql:mysql-connector-j") compileOnly 'org.projectlombok:lombok' diff --git a/docker-compose.yml b/docker-compose.yml index 103cf10..653c16b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,16 @@ services: volumes: - db-data:/var/lib/mysql + redis: + image: redis:7.2-alpine + container_name: commerce-redis + ports: + - "${REDIS_PORT}:6379" + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis-data:/data + # 데이터 영속성을 위함 volumes: db-data: + redis-data: 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 new file mode 100644 index 0000000..16650b9 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/out/PgStrategy.java @@ -0,0 +1,16 @@ +package com.commerce.platform.core.application.out; + +import com.commerce.platform.core.domain.enums.PgProvider; + +public abstract class PgStrategy { + + /** + * pg사별 요청에 따라 [Card | Easy | Phone]PayService 구현체 실행한다. + * @param request todo dto + * @return todo 결재응답dto + */ + public abstract String process(String request); + + public abstract PgProvider getPgProvider(); + +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/enums/PayMethod.java b/platform/src/main/java/com/commerce/platform/core/domain/enums/PayMethod.java index 52d00b9..c6b9f29 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/enums/PayMethod.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/enums/PayMethod.java @@ -7,6 +7,7 @@ @AllArgsConstructor public enum PayMethod { CARD("카드결제"), + EASY_PAY("간편결제"), PHONE("휴대폰결제"); private final String value; 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 5410d31..9adffd5 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 @@ -3,16 +3,58 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + @Getter @AllArgsConstructor public enum PgProvider { - // todo 여기서 pg사별 지원하는 결제유형을 관리해야되는지 - // todo 카드-수기, 인증, sms 위한 값을 만들지 이 부분은 생략할지 CardAuthType - TOSS("토스"), - OLIVE_NETWORKS("올리브네트웍스"), - NHN("NHN"), - NICE_PAYMENTS("나이스페이먼츠"); - - private final String value; + 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) + ), + + DANAL( + Set.of(PayMethod.PHONE), + Set.of(PayProvider.LG, PayProvider.KT, PayProvider.SKT) + + ), + PAYLETTER( + Set.of(PayMethod.PHONE), + Set.of(PayProvider.LG, PayProvider.KT) + ) + ; + + 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)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("미지원 PG사")); + } } 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 new file mode 100644 index 0000000..19841b2 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/service/PaymentPgRouter.java @@ -0,0 +1,60 @@ +package com.commerce.platform.core.domain.service; + +import com.commerce.platform.core.application.out.PgStrategy; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * PG 라우팅 서비스 + * 결제방식 + 카드사/통신사 => PG + * return 서버 정상 + 수수료 가장 저렴한 PG + */ +@Slf4j +@Service +public class PaymentPgRouter { + + private final Map pgStrategies; + private final PgCacheService pgCacheService; + + public PaymentPgRouter(List list, PgCacheService pgCacheService) { + this.pgStrategies = list.stream() + .collect(Collectors.toMap(PgStrategy::getPgProvider, pg -> pg)); + this.pgCacheService = pgCacheService; + } + + /** + * 결제유형+카드사에 따라 PG 선택 + * Redis에서 캐싱 + */ + public PgStrategy routePg(PayMethod payMethod, PayProvider payProvider) { + + List supportedPgs = PgProvider.getByPayMethod(payMethod, payProvider); + + PgProvider selectedPg = pgCacheService.getBestPg(payMethod, payProvider, supportedPgs); + + if (selectedPg == null) { + throw new IllegalStateException("현재 사용 가능한 PG사가 없습니다"); + } + + return pgStrategies.get(selectedPg); + } + + /** + * PG Provider => Strategy 조회 + */ + public PgStrategy getPgStrategyByProvider(PgProvider pgProvider) { + PgStrategy strategy = pgStrategies.get(pgProvider); + if (strategy == null) { + throw new IllegalArgumentException("존재하지 않는 PG: " + pgProvider); + } + return strategy; + } +} 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 new file mode 100644 index 0000000..2ca4f98 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java @@ -0,0 +1,139 @@ +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는 제외 + */ +@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 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() + ); + } + + return configs.stream() + .sorted(Comparator.comparing(PgFeeInfo::getFeeRate)) + .map(pgFeeInfo -> pgFeeInfo.getPgProvider().name()) + .collect(Collectors.toSet()); + } + + /** + * PG 헬스 체크 + */ + public boolean isHealthy(PgProvider pgProvider) { + String healthKey = HEALTH_KEY_PREFIX + pgProvider.name(); + return redisTemplate.opsForValue().get(healthKey) == null; + } + + /** + * PG 장애 + * TTL: 30m + */ + 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/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfo.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfo.java new file mode 100644 index 0000000..af88443 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfo.java @@ -0,0 +1,71 @@ +package com.commerce.platform.infrastructure.persistence; + +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 jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * PG사별 결제방식 + 카드사/통신사 조합의 수수료율 저장 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "pg_fee_info") +public class PgFeeInfo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PgProvider pgProvider; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PayMethod payMethod; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PayProvider payProvider; + + @Column(nullable = false, precision = 4, scale = 2) + private BigDecimal feeRate; + + @Column(nullable = false) + private boolean isActive; + + @Column(nullable = false, updatable = false) + private LocalDate frDt; + + @Column(nullable = false, updatable = false) + private LocalDate toDt; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public PgFeeInfo(PgProvider pgProvider, PayMethod payMethod, + PayProvider payProvider, BigDecimal feeRate, + LocalDate frDt, LocalDate toDt) { + this.pgProvider = pgProvider; + this.payMethod = payMethod; + this.payProvider = payProvider; + this.feeRate = feeRate; + this.isActive = true; + this.frDt = frDt; + this.toDt = toDt; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfoRepository.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfoRepository.java new file mode 100644 index 0000000..c3df13d --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfoRepository.java @@ -0,0 +1,37 @@ +package com.commerce.platform.infrastructure.persistence; + +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PayProvider; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface PgFeeInfoRepository extends JpaRepository { + + /** + * 결제방식 + 카드사/통신사로 활성화된 PG 수수료 조회 + * 수수료 asc + */ + @Query(""" + SELECT p FROM PgFeeInfo p + WHERE p.payMethod = :payMethod + AND p.payProvider = :payProvider + AND p.isActive = true + AND NOW() between p.frDt AND p.toDt + ORDER BY p.feeRate ASC + """) + List findByPayMethodAndPayProvider( + @Param("payMethod") PayMethod payMethod, + @Param("payProvider") PayProvider payProvider + ); + + @Query(""" + SELECT p FROM PgFeeInfo p + WHERE p.isActive = true + AND NOW() between p.frDt AND p.toDt + ORDER BY p.feeRate ASC + """) + List findAllActiveAndValid(); +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/DanalStrategy.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/DanalStrategy.java new file mode 100644 index 0000000..bc2bddd --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/DanalStrategy.java @@ -0,0 +1,20 @@ +package com.commerce.platform.infrastructure.pg; + +import com.commerce.platform.core.application.out.PgStrategy; +import com.commerce.platform.core.domain.enums.PgProvider; +import org.springframework.stereotype.Component; + +@Component +public class DanalStrategy extends PgStrategy { + + @Override + public String process(String request) { + return ""; + } + + @Override + public PgProvider getPgProvider() { + return PgProvider.DANAL; + } + +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/NHNStrategy.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/NHNStrategy.java new file mode 100644 index 0000000..d26e1a1 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/NHNStrategy.java @@ -0,0 +1,18 @@ +package com.commerce.platform.infrastructure.pg; + +import com.commerce.platform.core.application.out.PgStrategy; +import com.commerce.platform.core.domain.enums.PgProvider; +import org.springframework.stereotype.Component; + +@Component +public class NHNStrategy extends PgStrategy { + + @Override + public String process(String request) { + return ""; + } + + public PgProvider getPgProvider() { + return PgProvider.NHN; + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/TossStrategy.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/TossStrategy.java new file mode 100644 index 0000000..0955da0 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/TossStrategy.java @@ -0,0 +1,19 @@ +package com.commerce.platform.infrastructure.pg; + +import com.commerce.platform.core.application.out.PgStrategy; +import com.commerce.platform.core.domain.enums.PgProvider; +import org.springframework.stereotype.Component; + +@Component +public class TossStrategy extends PgStrategy { + + @Override + public String process(String request) { + return ""; + } + + @Override + public PgProvider getPgProvider() { + return PgProvider.TOSS; + } +} diff --git a/platform/src/main/resources/application.yml b/platform/src/main/resources/application.yml index ed766c6..4821882 100644 --- a/platform/src/main/resources/application.yml +++ b/platform/src/main/resources/application.yml @@ -16,6 +16,11 @@ spring: format_sql: true dialect: org.hibernate.dialect.MySQLDialect + data: + redis: + host: localhost + port: 6379 + docker: compose: enabled: true 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 new file mode 100644 index 0000000..9eb5ed6 --- /dev/null +++ b/platform/src/test/java/com/commerce/platform/core/domain/service/PaymentPgRouterTest.java @@ -0,0 +1,55 @@ +package com.commerce.platform.core.domain.service; + +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.pg.NHNStrategy; +import com.commerce.platform.infrastructure.pg.TossStrategy; +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 static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class PaymentPgRouterTest { + @Autowired + private PaymentPgRouter paymentPgRouter; + + @Autowired + private PgCacheService pgCacheService; + + @Autowired + private StringRedisTemplate redisTemplate; + + + @DisplayName("결제유형에 따른 라우팅") + @Test + void routePg() { + assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.KB)) + .as("KB 카드 결제는 TOSS 만 존재").isInstanceOf(TossStrategy.class); + + assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.SAMSUNG)) + .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); + + assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.SAMSUNG)) + .as("SAMSUNG 카드 결제 : NHN 장애로 TOSS!").isInstanceOf(TossStrategy.class); + + // nhn 장애 원복 + redisTemplate.delete("pg:health:NHN"); + } +} \ No newline at end of file diff --git a/platform/src/test/resources/application.yml b/platform/src/test/resources/application.yml new file mode 100644 index 0000000..b60eec6 --- /dev/null +++ b/platform/src/test/resources/application.yml @@ -0,0 +1,33 @@ +spring: + application: + name: platform + + datasource: + url: jdbc:mysql://localhost:3308/commerce_dev_db?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: je1234 + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 10 + connection-timeout: 3000 + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + data: + redis: + host: 127.0.0.1 + port: 6379 + password: je1234 + + docker: + compose: + enabled: false + +aes256: + key: 61qDonoZcEtIEvUZVPkIKIYovHH82rXtK7T1g/rcc1k= \ No newline at end of file