diff --git a/src/main/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeService.java b/src/main/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeService.java index 2457c173..af0f1521 100644 --- a/src/main/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeService.java +++ b/src/main/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeService.java @@ -6,31 +6,28 @@ import bumblebee.xchangepass.domain.exchangeRate.repository.ExchangeRateTempRepository; import bumblebee.xchangepass.domain.exchangeRate.repository.ExchangeRepository; import bumblebee.xchangepass.domain.exchangeRate.util.Country; +import bumblebee.xchangepass.domain.exchangeRate.util.ExchangeRateLockManager; import bumblebee.xchangepass.global.error.ErrorCode; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.Cacheable; import org.springframework.context.ApplicationContext; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.scheduling.annotation.Async; +import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; import java.math.BigDecimal; import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import static bumblebee.xchangepass.global.common.Constants.url; + @Service public class ExchangeService { @@ -40,27 +37,33 @@ public class ExchangeService { private final ExchangeRepository exchangeRepository; private final ExchangeRateTransactionService exchangeTransactionService; private final ApplicationContext applicationContext; - private final RestTemplate restTemplate = new RestTemplate(); - private final RedisTemplate redisTemplate = new RedisTemplate<>(); + private final RestTemplate restTemplate; private final Executor executor; private final ExchangeRateTempRepository exchangeRateTempRepository; - private final String url = "https://v6.exchangerate-api.com/v6/" + authkey + "/latest/"; + private final ExchangeRateLockManager lockManager; + private final CacheManager cacheManager; + @Autowired public ExchangeService(@Qualifier("asyncExecutor") Executor executor, ExchangeRepository exchangeRepository, ExchangeRateTempRepository exchangeRateTempRepository, ExchangeRateTransactionService exchangeTransactionService, - ApplicationContext applicationContext) { + ApplicationContext applicationContext, + ExchangeRateLockManager lockManager, + CacheManager cacheManager, + RestTemplate restTemplate) { this.exchangeRepository = exchangeRepository; this.exchangeRateTempRepository = exchangeRateTempRepository; this.exchangeTransactionService = exchangeTransactionService; this.applicationContext = applicationContext; this.executor = executor; - + this.restTemplate = restTemplate; + this.lockManager = lockManager; + this.cacheManager = cacheManager; } public ExchangeRateResponse fetchExchangeRates(String baseCurrency) { - String API_URL = url + baseCurrency; + String API_URL = url + authkey + "/latest/" + baseCurrency; try { return restTemplate.getForObject(API_URL, ExchangeRateResponse.class); } catch (HttpClientErrorException e) { @@ -98,37 +101,83 @@ public CompletableFuture fetchAndSaveAllExchangeRates() { public void fetchAndSaveExchangeRate(String baseCurrency) { ExchangeRateResponse response = fetchExchangeRates(baseCurrency); saveRatesToTempDB(baseCurrency, response); - - ExchangeService self = applicationContext.getBean(ExchangeService.class); - self.evictExchangeRateCache(baseCurrency); + evictExchangeRateCache(baseCurrency); } + public void evictExchangeRateCache(String baseCurrency) { - redisTemplate.delete("all::" + baseCurrency); + try { + Cache cache = cacheManager.getCache("exchangeRates"); + if (cache != null) { + cache.evict("all::" + baseCurrency); + List currencies = List.of("USD","KRW"); + List> futures = new ArrayList<>(); + + for (String targetCurrency : currencies) { + CompletableFuture future = CompletableFuture.runAsync(() -> { + cache.evict("rate::" + baseCurrency + "::" + targetCurrency); + }, executor); + + futures.add(future); + } - Set rateKeys = redisTemplate.keys("rate::" + baseCurrency + "::*"); - if (rateKeys != null && !rateKeys.isEmpty()) { - redisTemplate.delete(rateKeys); + // 모든 작업 완료까지 대기 + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } + } catch (RedisConnectionFailureException e) { + throw ErrorCode.REDIS_EVICT_ERROR.commonException(); + } catch (Exception e) { + throw ErrorCode.CACHE_EVICT_ERROR.commonException(); } } - @Transactional + @Cacheable(value = "exchangeRates", key = "'all::' + #baseCurrency", sync = true) public ExchangeRateResponse getExchangeRateAll(String baseCurrency) { - List exchangeRates = exchangeRepository.findByBaseCurrency(baseCurrency); if (!exchangeRates.isEmpty()) { - Map conversionRates = exchangeRates.get(0).getExchangeRates(); - return ExchangeRateResponse.builder() - .baseCurrency(baseCurrency) - .conversionRates(conversionRates) - .build(); + return toResponse(baseCurrency, exchangeRates); + } + + boolean lockAcquired = lockManager.tryAcquireLock(); + + if (lockAcquired) { + try { + fetchAndSaveAllExchangeRates().join(); + + exchangeRates = exchangeRepository.findByBaseCurrency(baseCurrency); + if (!exchangeRates.isEmpty()) { + return toResponse(baseCurrency, exchangeRates); + } + + } finally { + lockManager.releaseLock(); + } } else { - fetchAndSaveAllExchangeRates(); + int retry = 50; + for (int i = 0; i < retry; i++) { + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } - return fetchExchangeRates(baseCurrency); + exchangeRates = exchangeRepository.findByBaseCurrency(baseCurrency); + if (!exchangeRates.isEmpty()) { + return toResponse(baseCurrency, exchangeRates); + } + } } + throw ErrorCode.EXCHANGE_RATE_NOT_FOUND.commonException(); } + private ExchangeRateResponse toResponse(String baseCurrency, List exchangeRates) { + Map conversionRates = exchangeRates.get(0).getExchangeRates(); + return ExchangeRateResponse.builder() + .baseCurrency(baseCurrency) + .conversionRates(conversionRates) + .build(); + } public void saveRatesToTempDB(String baseCurrency, ExchangeRateResponse response) { try { diff --git a/src/main/java/bumblebee/xchangepass/domain/exchangeRate/util/ExchangeRateLockManager.java b/src/main/java/bumblebee/xchangepass/domain/exchangeRate/util/ExchangeRateLockManager.java new file mode 100644 index 00000000..eb5084fe --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/exchangeRate/util/ExchangeRateLockManager.java @@ -0,0 +1,38 @@ +package bumblebee.xchangepass.domain.exchangeRate.util; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import lombok.RequiredArgsConstructor; +import org.hibernate.Session; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; + +import static bumblebee.xchangepass.global.common.Constants.LOCK_KEY; + +@Component +@RequiredArgsConstructor +public class ExchangeRateLockManager { + + @PersistenceContext + private final EntityManager entityManager; + + + + public boolean tryAcquireLock() { + Query query = entityManager.createNativeQuery("SELECT pg_try_advisory_lock(:lockKey)"); + query.setParameter("lockKey", LOCK_KEY); + Boolean result = (Boolean) query.getSingleResult(); + return result != null && result; + } + + public void releaseLock() { + entityManager.unwrap(Session.class).doWork(connection -> { + try (PreparedStatement stmt = connection.prepareStatement("SELECT pg_advisory_unlock(?)")) { + stmt.setLong(1, LOCK_KEY); + stmt.executeQuery(); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionService.java b/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionService.java index b65226d3..e7d3027f 100644 --- a/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionService.java +++ b/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionService.java @@ -110,14 +110,13 @@ public ExchangeResponseDTO executeTransaction(Long transactionId, Long userId) { if (fromBalance.getBalance().compareTo(amount) < 0) { WalletInOutRequest chargeRequest = WalletInOutRequest.builder() -// .userId(userId) .fromCurrency(Currency.getInstance(fromCurrency)) .toCurrency(Currency.getInstance(fromCurrency)) .amount(amount) .chargeDatetime(LocalDateTime.now()) .build(); -// walletService.charge(chargeRequest); + walletService.charge(userId, chargeRequest); } WalletBalance toBalance = getOrCreateBalance(wallet, toCurrency); diff --git a/src/main/java/bumblebee/xchangepass/global/common/Constants.java b/src/main/java/bumblebee/xchangepass/global/common/Constants.java index 14b98feb..fc4a4e46 100644 --- a/src/main/java/bumblebee/xchangepass/global/common/Constants.java +++ b/src/main/java/bumblebee/xchangepass/global/common/Constants.java @@ -21,4 +21,10 @@ public class Constants { // Token 유효 기간 public static final Long REFRESH_TOKEN_TTL = 24 * 60 * 60L; // 24시간 (초 단위) public static final Long JWT_TOKEN_VALID = 1000 * 60 * 30L; // jwt AccessToken 만료 시간 1시간 + + // 외부 API 호출 + public static final String url = "https://v6.exchangerate-api.com/v6/"; + + // postgreSQL LOCK_KEY 상수 + public static final long LOCK_KEY = 987654321L; } \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/global/config/RestTemplateConfig.java b/src/main/java/bumblebee/xchangepass/global/config/RestTemplateConfig.java new file mode 100644 index 00000000..a602dfed --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/global/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package bumblebee.xchangepass.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java b/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java index a87c9da7..62a6be69 100644 --- a/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java +++ b/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java @@ -78,7 +78,9 @@ public enum ErrorCode { /*기타*/ ENTITY_FIELD_ACCESS_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "G001", "엔티티 필드 접근 오류"), - REDIS_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "Redis 연결 실패"); + REDIS_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "Redis 연결 실패"), + CACHE_EVICT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "R002", "캐시 삭제 중 오류 발생"), + REDIS_EVICT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "R003", "Redis 캐시 삭제 중 오류 발생"); @Schema(description = "에러 코드", example = "U003") diff --git a/src/test/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeServiceTest.java index b784368c..fec1a0aa 100644 --- a/src/test/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeServiceTest.java @@ -5,6 +5,7 @@ import bumblebee.xchangepass.domain.exchangeRate.entity.ExchangeRateTemp; import bumblebee.xchangepass.domain.exchangeRate.repository.ExchangeRateTempRepository; import bumblebee.xchangepass.domain.exchangeRate.repository.ExchangeRepository; +import bumblebee.xchangepass.domain.exchangeRate.util.Country; import bumblebee.xchangepass.global.error.ErrorCode; import com.sun.management.OperatingSystemMXBean; import org.awaitility.Awaitility; @@ -20,13 +21,8 @@ import org.springframework.transaction.annotation.Transactional; import java.lang.management.ManagementFactory; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; +import java.util.*; +import java.util.concurrent.*; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.awaitility.Awaitility.await; @@ -164,37 +160,68 @@ public void Test8() throws InterruptedException { } @Test - @DisplayName("최적 스레드 수 계산 테스트 - 외부 API 호출 기준") - void 스레드수계산테스트() { + @DisplayName("스레드별 로드 측정 및 최적 스레드 수 계산") + void 스레드별로드측정테스트() throws Exception { int cores = Runtime.getRuntime().availableProcessors(); + List currencies = Country.create(); // 총 163개국 - // 1. 응답 시간 측정 (전체 fetch → 외부 API 포함) - long responseStart = System.currentTimeMillis(); - service.fetchAndSaveExchangeRate("USD"); // 단일 외부 API 호출 - long responseEnd = System.currentTimeMillis(); - long responseTime = responseEnd - responseStart; + ExecutorService executor = Executors.newFixedThreadPool(6); // 초기값 (변경 가능) - // 2. CPU 처리 시간 측정 (외부 API 제외한 내부 처리) - Map rates = new HashMap<>(); - rates.put("KRW", 1300.00); + Map> threadDurations = new ConcurrentHashMap<>(); + List> futures = new ArrayList<>(); - ExchangeRate dummyRate = ExchangeRate.builder() - .baseCurrency("USD") - .rate(rates) - .build(); - long cpuStart = System.nanoTime(); - exchangeRepository.save(dummyRate); // 저장 로직만 측정 - long cpuEnd = System.nanoTime(); - long cpuTime = (cpuEnd - cpuStart) / 1_000_000; // ms 단위 + long totalStart = System.currentTimeMillis(); + + for (String baseCurrency : currencies) { + CompletableFuture future = CompletableFuture.runAsync(() -> { + long start = System.nanoTime(); + try { + service.fetchAndSaveExchangeRate(baseCurrency); + } catch (Exception e) { + System.err.println("[" + baseCurrency + "] 에러: " + e.getMessage()); + } + long end = System.nanoTime(); + long duration = (end - start) / 1_000_000; // ms + + String threadName = Thread.currentThread().getName(); + threadDurations.computeIfAbsent(threadName, k -> new ArrayList<>()).add(duration); + + }, executor); + futures.add(future); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + long totalEnd = System.currentTimeMillis(); - // 3. 최적 스레드 수 계산 - int optimalThreads = (int)((double) responseTime / cpuTime * cores); + // 결과 분석 + System.out.println("===== 스레드별 처리 시간 분석 ====="); + long totalCount = 0; + long totalTimeSum = 0; - // 4. 출력 + for (Map.Entry> entry : threadDurations.entrySet()) { + String thread = entry.getKey(); + List durations = entry.getValue(); + long sum = durations.stream().mapToLong(Long::longValue).sum(); + long avg = sum / durations.size(); + + totalTimeSum += sum; + totalCount += durations.size(); + + System.out.printf("%s - 처리 수: %d개, 평균 처리 시간: %dms%n", thread, durations.size(), avg); + } + + double avgPerTaskTime = (double) totalTimeSum / totalCount; + long totalElapsed = totalEnd - totalStart; + int estimatedOptimalThreads = (int) (totalElapsed / avgPerTaskTime); + + System.out.println("\n===== 전체 요약 ====="); + System.out.println("총 처리 시간: " + totalElapsed + "ms"); + System.out.println("총 작업 수: " + totalCount + "개"); + System.out.printf("작업당 평균 처리 시간: %.2fms%n", avgPerTaskTime); System.out.println("CPU 코어 수: " + cores); - System.out.println("단일 외부 API 응답 시간: " + responseTime + "ms"); - System.out.println("내부 처리(CPU) 시간: " + cpuTime + "ms"); - System.out.println("계산된 최적 스레드 수: " + optimalThreads + "개"); + System.out.println("추정 최적 스레드 수: " + estimatedOptimalThreads + "개"); + + executor.shutdown(); } @Test @DisplayName("비동기 업데이트시 기존 데이터를 가져오므로 사용자 블로킹 발생 안함") @@ -232,6 +259,7 @@ void testFetchExchangeRatesWhileUpdating() throws ExecutionException, Interrupte assertThat(updatedResponse.conversionRates().get("KRW")).isEqualTo(1472.8846); } + @Test @DisplayName("동기식 업데이트 할시 기존 데이터 못가져오는 경우") void testFetchExchangeRatesWhileUpdating2() throws ExecutionException, InterruptedException { @@ -262,7 +290,8 @@ void testFetchExchangeRatesWhileUpdating2() throws ExecutionException, Interrupt ExchangeRateResponse initialResponse = service.getExchangeRateAll("USD"); assertThat(initialResponse).isNotNull(); - assertThat(initialResponse.conversionRates().get("KRW")).isEqualTo(1472.8846); + assertThat(initialResponse.conversionRates().get("KRW")).isEqualTo(1455.6702); + } } \ No newline at end of file diff --git a/src/test/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeServiceUnitTest.java b/src/test/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeServiceUnitTest.java new file mode 100644 index 00000000..b2cd1c86 --- /dev/null +++ b/src/test/java/bumblebee/xchangepass/domain/exchangeRate/service/ExchangeServiceUnitTest.java @@ -0,0 +1,138 @@ +package bumblebee.xchangepass.domain.exchangeRate.service; + +import bumblebee.xchangepass.domain.exchangeRate.dto.response.ExchangeRateResponse; +import bumblebee.xchangepass.domain.exchangeRate.entity.ExchangeRate; +import bumblebee.xchangepass.domain.exchangeRate.repository.ExchangeRateTempRepository; +import bumblebee.xchangepass.domain.exchangeRate.repository.ExchangeRepository; +import bumblebee.xchangepass.domain.exchangeRate.util.ExchangeRateLockManager; +import bumblebee.xchangepass.global.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ExchangeServiceUnitTest { + + @InjectMocks + private ExchangeService exchangeService; + + @Mock + private ExchangeRepository exchangeRepository; + + @Mock + private ExchangeRateTransactionService exchangeTransactionService; + + @Mock + private ExchangeRateTempRepository exchangeRateTempRepository; + + @Mock + private ApplicationContext applicationContext; + + @Mock + private ExchangeRateLockManager lockManager; + + @Mock + private CacheManager cacheManager; + + @Mock + private RestTemplate restTemplate; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + exchangeService = new ExchangeService( + Executors.newSingleThreadExecutor(), + exchangeRepository, + exchangeRateTempRepository, + exchangeTransactionService, + applicationContext, + lockManager, + cacheManager, + restTemplate + ); + } + + @Test + @DisplayName("기존 환율이 존재하면 정상 응답을 반환한다") + void test1() { + // given + String baseCurrency = "USD"; + String targetCurrency = "KRW"; + Map mockRates = Map.of(targetCurrency, 1350.0); + + ExchangeRate mockRate = ExchangeRate.builder() + .baseCurrency(baseCurrency) + .rate(mockRates) + .build(); + + when(exchangeRepository.findByBaseCurrencyAndKey(baseCurrency, targetCurrency)) + .thenReturn(List.of(mockRate)); + + // when + ExchangeRateResponse result = exchangeService.getExchangeRateForCountry(baseCurrency, targetCurrency); + + // then + assertNotNull(result); + assertEquals(baseCurrency, result.baseCurrency()); + assertEquals(1350.0, result.conversionRates().get(targetCurrency)); + } + + @Test + @DisplayName("환율 정보가 없으면 예외를 던진다") + void test2() { + // given + String baseCurrency = "USD"; + String targetCurrency = "ABC"; + + // when + when(exchangeRepository.findByBaseCurrencyAndKey(baseCurrency, targetCurrency)) + .thenReturn(List.of()); + + // then + assertThrows( + ErrorCode.EXCHANGE_RATE_FOR_COUNTRY.commonException().getClass(), + () -> exchangeService.getExchangeRateForCountry(baseCurrency, targetCurrency) + ); + } + + @Test + @DisplayName("fetchExchangeRates는 RestTemplate을 통해 환율 정보를 정상적으로 반환한다") + void test3() { + // given + String baseCurrency = "USD"; + String apiKey = "dummy-key"; + ReflectionTestUtils.setField(exchangeService, "authkey", apiKey); + + RestTemplate restTemplateMock = mock(RestTemplate.class); + ExchangeRateResponse dummyResponse = new ExchangeRateResponse(baseCurrency, Map.of("KRW", 1350.0)); + + when(restTemplateMock.getForObject(anyString(), eq(ExchangeRateResponse.class))) + .thenReturn(dummyResponse); + + ReflectionTestUtils.setField(exchangeService, "restTemplate", restTemplateMock); + + // when + ExchangeRateResponse result = exchangeService.fetchExchangeRates(baseCurrency); + + // then + assertNotNull(result); + assertEquals("USD", result.baseCurrency()); + assertEquals(1350.0, result.conversionRates().get("KRW")); + } + + +}