diff --git a/build.gradle b/build.gradle index b8ffdd6..c095ddf 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.security:spring-security-crypto' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.micrometer:micrometer-tracing-bridge-brave' implementation 'io.micrometer:micrometer-registry-prometheus' implementation 'io.zipkin.reporter2:zipkin-reporter-brave' diff --git a/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceProcessorService.java b/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceProcessorService.java index c71072f..892a2d9 100644 --- a/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceProcessorService.java +++ b/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceProcessorService.java @@ -25,6 +25,7 @@ import org.creditto.core_banking.global.common.CurrencyCode; import org.creditto.core_banking.global.response.error.ErrorBaseCode; import org.creditto.core_banking.global.response.exception.CustomBaseException; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,6 +51,7 @@ public class RemittanceProcessorService { private final ExchangeService exchangeService; private final TransactionService transactionService; private final RemittanceFeeService remittanceFeeService; + private final RedisTemplate redisTemplate; /** * 전달된 Command를 기반으로 해외송금의 모든 단계를 실행합니다. @@ -135,6 +137,15 @@ public OverseasRemittanceResponseDto execute(final ExecuteRemittanceCommand comm accountRepository.save(account); + // 정기 송금일 경우 + if (command.regRemId() != null) { + String regularRemittanceHistoryKey = "regularRemittanceHistory::" + command.regRemId(); + redisTemplate.delete(regularRemittanceHistoryKey); + } else { // 일회성 송금일 경우 + String oneTimeRemittanceHistoryKey = "oneTimeRemittanceHistories::" + userId; + redisTemplate.delete(oneTimeRemittanceHistoryKey); + } + return OverseasRemittanceResponseDto.from(overseasRemittance); } diff --git a/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceQueryService.java b/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceQueryService.java index 828065a..786626e 100644 --- a/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceQueryService.java +++ b/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceQueryService.java @@ -5,6 +5,7 @@ import org.creditto.core_banking.domain.overseasremittance.dto.OverseasRemittanceResponseDto; import org.creditto.core_banking.domain.overseasremittance.repository.OverseasRemittanceRepository; import org.creditto.core_banking.global.response.exception.CustomBaseException; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +21,7 @@ public class RemittanceQueryService { private final OverseasRemittanceRepository remittanceRepository; + private final RedisTemplate redisTemplate; /** * 특정 고객의 모든 해외송금 내역을 조회합니다. @@ -28,10 +30,24 @@ public class RemittanceQueryService { * @return 고객의 송금 내역 DTO 리스트 */ public List getRemittanceList(Long userId) { - return remittanceRepository.findByUserIdWithDetails(userId) + String key = "oneTimeRemittanceHistories::" + userId; + + // redis에서 캐시 확인 + List cachedList = (List) redisTemplate.opsForValue().get(key); + if (cachedList != null) { + return cachedList; + } + + // 캐시 없으면 DB 조회 + List dbList = remittanceRepository.findByUserIdWithDetails(userId) .stream() .map(OverseasRemittanceResponseDto::from) .toList(); + + // 결과를 redis에 저장 + redisTemplate.opsForValue().set(key, dbList); + + return dbList; } /** diff --git a/src/main/java/org/creditto/core_banking/domain/regularremittance/dto/RemittanceHistoryDto.java b/src/main/java/org/creditto/core_banking/domain/regularremittance/dto/RemittanceHistoryDto.java index e5f8e74..a3afcfa 100644 --- a/src/main/java/org/creditto/core_banking/domain/regularremittance/dto/RemittanceHistoryDto.java +++ b/src/main/java/org/creditto/core_banking/domain/regularremittance/dto/RemittanceHistoryDto.java @@ -1,18 +1,19 @@ package org.creditto.core_banking.domain.regularremittance.dto; +import lombok.AccessLevel; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import java.math.BigDecimal; import java.time.LocalDate; @Getter -@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor public class RemittanceHistoryDto { - Long remittanceId; - BigDecimal sendAmount; - BigDecimal exchangeRate; - LocalDate createdDate; + private Long remittanceId; + private BigDecimal sendAmount; + private BigDecimal exchangeRate; + private LocalDate createdDate; } diff --git a/src/main/java/org/creditto/core_banking/domain/regularremittance/service/RegularRemittanceService.java b/src/main/java/org/creditto/core_banking/domain/regularremittance/service/RegularRemittanceService.java index 903698c..cbe094d 100644 --- a/src/main/java/org/creditto/core_banking/domain/regularremittance/service/RegularRemittanceService.java +++ b/src/main/java/org/creditto/core_banking/domain/regularremittance/service/RegularRemittanceService.java @@ -1,6 +1,11 @@ package org.creditto.core_banking.domain.regularremittance.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.creditto.core_banking.domain.account.entity.Account; import org.creditto.core_banking.domain.account.repository.AccountRepository; import org.creditto.core_banking.domain.overseasremittance.entity.OverseasRemittance; @@ -15,6 +20,9 @@ import org.creditto.core_banking.domain.regularremittance.repository.RegularRemittanceRepository; import org.creditto.core_banking.global.response.error.ErrorBaseCode; import org.creditto.core_banking.global.response.exception.CustomBaseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,15 +30,20 @@ import java.util.List; import java.util.Objects; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class RegularRemittanceService { + private static final Logger log = LoggerFactory.getLogger(RegularRemittanceService.class); private final RegularRemittanceRepository regularRemittanceRepository; private final OverseasRemittanceRepository overseasRemittanceRepository; private final AccountRepository accountRepository; private final RecipientFactory recipientFactory; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + /** * 특정 사용자의 모든 정기송금 설정 내역을 조회합니다. @@ -97,12 +110,27 @@ public RemittanceDetailDto getScheduledRemittanceDetail(Long userId, Long regRem * @return 해당 정기송금 설정에 대한 모든 송금 기록 목록 ({@link RemittanceHistoryDto}) */ public List getRegularRemittanceHistoryByRegRemId(Long userId, Long regRemId) { + String key = "regularRemittanceHistory::" + regRemId; + + try { + Object cachedObject = redisTemplate.opsForValue().get(key); + if (cachedObject != null) { + if (cachedObject instanceof String) { + return objectMapper.readValue((String) cachedObject, new TypeReference>() {}); + } + return (List) cachedObject; + } + } catch (Exception e) { + log.error("Redis cache deserialization error", e); + } + + // 캐시 없으면 DB 조회 RegularRemittance regularRemittance = regularRemittanceRepository.findById(regRemId) .orElseThrow(() -> new CustomBaseException(ErrorBaseCode.NOT_FOUND_REGULAR_REMITTANCE)); verifyUserOwnership(regularRemittance.getAccount().getUserId(), userId); - return overseasRemittanceRepository.findByRecur_RegRemIdOrderByCreatedAtDesc(regRemId).stream() + List dbList = overseasRemittanceRepository.findByRecur_RegRemIdOrderByCreatedAtDesc(regRemId).stream() .map(overseas -> new RemittanceHistoryDto( overseas.getRemittanceId(), overseas.getSendAmount(), @@ -110,6 +138,16 @@ public List getRegularRemittanceHistoryByRegRemId(Long use overseas.getCreatedAt().toLocalDate() )) .toList(); + + try { + // 결과를 JSON 문자열로 변환하여 Redis에 저장 + String jsonString = objectMapper.writeValueAsString(dbList); + redisTemplate.opsForValue().set(key, jsonString); + } catch (JsonProcessingException e) { + log.error("Redis cache serialization error", e); + } + + return dbList; } /** @@ -220,10 +258,12 @@ public void updateScheduledRemittance(Long regRemId, Long userId, RegularRemitta } else if (remittance instanceof WeeklyRegularRemittance weekly) { weekly.updateSchedule(dto.getScheduledDay()); } + // 정기송금 "설정"이 변경되었으므로, 관련 "내역" 캐시도 삭제 + redisTemplate.delete("regularRemittanceHistory::" + regRemId); } /** - * 기존 정기 해외송금 설정을 삭제합니다. + * 기존 정기 해외송금 설정을 삭제합니다。 * * @param regRemId 삭제할 정기송금의 ID * @param userId 사용자 ID @@ -235,6 +275,8 @@ public void deleteScheduledRemittance(Long regRemId, Long userId) { verifyUserOwnership(remittance.getAccount().getUserId(), userId); regularRemittanceRepository.delete(remittance); + // 정기송금 "설정"이 삭제되었으므로, 관련 "내역" 캐시도 삭제 + redisTemplate.delete("regularRemittanceHistory::" + regRemId); } private void verifyUserOwnership(Long ownerId, Long requesterId) { @@ -273,4 +315,4 @@ private boolean hasDuplicateRemittance(Account account, Recipient recipient, Reg return false; } -} +} \ No newline at end of file diff --git a/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java b/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java new file mode 100644 index 0000000..69a4d7f --- /dev/null +++ b/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java @@ -0,0 +1,37 @@ +package org.creditto.core_banking.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // LocalDate, LocalDateTime 직렬화를 위한 ObjectMapper 설정 + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + // JavaTimeModule이 등록된 ObjectMapper를 사용하는 Serializer 생성 + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + // key:value + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(serializer); + + // hash key:value + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(serializer); + + return template; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1cd215b..1bfe977 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,6 +6,11 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: localhost + port: 6379 + timeout: 2000 scheduler: remittance: diff --git a/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java b/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java new file mode 100644 index 0000000..3f1e3fb --- /dev/null +++ b/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java @@ -0,0 +1,24 @@ +package org.creditto.core_banking.domain.redis; + +import org.junit.jupiter.api.Disabled; +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.RedisTemplate; + +@Disabled +@SpringBootTest +public class RedisTest { + + @Autowired + private RedisTemplate redisTemplate; + + @Test + void testRedisConnection() { + String key = "test-key"; + String expectedValue = "redis"; + redisTemplate.opsForValue().set(key, expectedValue); + Object value = redisTemplate.opsForValue().get(key); + System.out.println("Redis Value: " + value); + } +} diff --git a/src/test/java/org/creditto/core_banking/domain/regularremittance/service/RegularRemittanceServiceTest.java b/src/test/java/org/creditto/core_banking/domain/regularremittance/service/RegularRemittanceServiceTest.java index 807ce16..d82ca16 100644 --- a/src/test/java/org/creditto/core_banking/domain/regularremittance/service/RegularRemittanceServiceTest.java +++ b/src/test/java/org/creditto/core_banking/domain/regularremittance/service/RegularRemittanceServiceTest.java @@ -1,5 +1,8 @@ package org.creditto.core_banking.domain.regularremittance.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.creditto.core_banking.domain.account.entity.Account; import org.creditto.core_banking.domain.account.entity.AccountState; import org.creditto.core_banking.domain.account.entity.AccountType; @@ -26,6 +29,7 @@ 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.RedisTemplate; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @@ -58,6 +62,11 @@ class RegularRemittanceServiceTest { @Autowired private FeeRecordRepository feeRecordRepository; + @Autowired + private RedisTemplate redisTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private Long testUserId = 3L; private Long otherUserId = 4L; @@ -125,7 +134,7 @@ void getRegularRemittanceHistoryByRegRemId_Success() { void getRegularRemittanceHistoryByRegRemId_Forbidden() { assertThrows(CustomBaseException.class, () -> regularRemittanceService.getRegularRemittanceHistoryByRegRemId(otherUserId, testMonthlyRemittance.getRegRemId())); } - + @Test @Transactional @DisplayName("정기송금 상세 조회 (월간) - 성공") @@ -168,7 +177,7 @@ void getScheduledRemittanceDetail_Weekly_Success() { void getScheduledRemittanceDetail_Forbidden() { // when & then CustomBaseException exception = assertThrows(CustomBaseException.class, - () -> regularRemittanceService.getScheduledRemittanceDetail(otherUserId, testMonthlyRemittance.getRegRemId())); + () -> regularRemittanceService.getScheduledRemittanceDetail(otherUserId, testMonthlyRemittance.getRegRemId())); assertThat(exception.getErrorCode()).isEqualTo(ErrorBaseCode.FORBIDDEN); } @@ -178,7 +187,7 @@ void getScheduledRemittanceDetail_Forbidden() { void getScheduledRemittanceDetail_NotFound() { // when & then CustomBaseException exception = assertThrows(CustomBaseException.class, - () -> regularRemittanceService.getScheduledRemittanceDetail(testUserId, 999L)); + () -> regularRemittanceService.getScheduledRemittanceDetail(testUserId, 999L)); assertThat(exception.getErrorCode()).isEqualTo(ErrorBaseCode.NOT_FOUND_REGULAR_REMITTANCE); } @@ -200,6 +209,51 @@ void getRegularRemittanceDetail_Forbidden() { assertThrows(CustomBaseException.class, () -> regularRemittanceService.getRemittanceHistoryDetail(otherUserId, testOverseasRemittance.getRemittanceId(), testMonthlyRemittance.getRegRemId())); } + @Test + @Transactional + @DisplayName("정기송금 내역 조회 - Cache Miss") + void getRegularRemittanceHistoryByRegRemId_CacheMiss() throws JsonProcessingException { + // given + Long regRemId = testMonthlyRemittance.getRegRemId(); + String key = "regularRemittanceHistory::" + regRemId; + + // when + List result = regularRemittanceService.getRegularRemittanceHistoryByRegRemId(testUserId, regRemId); + + // then + // 1. 반환된 결과가 DB의 실제 데이터와 일치하는지 검증 + assertThat(result).hasSize(1); + assertThat(result.get(0).getRemittanceId()).isEqualTo(testOverseasRemittance.getRemittanceId()); + + // 2. Redis에 값이 JSON 문자열 형태로 저장되었는지 검증 + String cachedJson = (String) redisTemplate.opsForValue().get(key); + assertThat(cachedJson).isNotNull(); + assertThat(objectMapper.writeValueAsString(result)).isEqualTo(cachedJson); + } + + @Test + @DisplayName("정기송금 내역 조회 - Cache Hit") + void getRegularRemittanceHistoryByRegRemId_CacheHit() throws JsonProcessingException { + // given + // DB에 없는 ID로 캐시 데이터를 준비 + Long nonExistentRegRemId = 999L; + String key = "regularRemittanceHistory::" + nonExistentRegRemId; + + // 미리 Redis에 식별 가능한 가짜 데이터를 JSON 문자열로 저장 + List fakeCachedList = List.of(new RemittanceHistoryDto(999L, BigDecimal.TEN, BigDecimal.ONE, LocalDate.now())); + String fakeJson = objectMapper.writeValueAsString(fakeCachedList); + redisTemplate.opsForValue().set(key, fakeJson); + + // when + List result = regularRemittanceService.getRegularRemittanceHistoryByRegRemId(testUserId, nonExistentRegRemId); + + // then + // 1. 반환된 결과가 캐시된 데이터와 동일한지 검증 + assertThat(result).hasSize(1); + assertThat(result.get(0).getRemittanceId()).isEqualTo(999L); + assertThat(result.get(0).getSendAmount()).isEqualByComparingTo(BigDecimal.TEN); + } + @Test @Transactional @DisplayName("월간 정기송금 신규 등록")