Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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 @@ -6,20 +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 bumblebee.xchangepass.global.exception.CommonException;
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.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

Expand All @@ -32,6 +40,7 @@
import java.util.concurrent.Executor;

@Service
@RequiredArgsConstructor
Copy link
Collaborator

Choose a reason for hiding this comment

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

RequiredArgsConstructor 사용햇는데 생성자 주입 받고있는데 수정부탁드립니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

고쳤습니다.

public class ExchangeService {

@Value("${api.key}")
Expand All @@ -40,27 +49,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<String, Object> 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 = "https://v6.exchangerate-api.com/v6/" + authkey + "/latest/" + baseCurrency;
Copy link
Collaborator

Choose a reason for hiding this comment

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

global/common 안에 있는 constants 파일에 상수를 보관해서 관리하는게 좋아보입니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

수정했습니다.

try {
return restTemplate.getForObject(API_URL, ExchangeRateResponse.class);
} catch (HttpClientErrorException e) {
Expand Down Expand Up @@ -98,37 +113,88 @@ public CompletableFuture<Void> 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<CompletableFuture<Void>> futures = new ArrayList<>();

Set<String> rateKeys = redisTemplate.keys("rate::" + baseCurrency + "::*");
if (rateKeys != null && !rateKeys.isEmpty()) {
redisTemplate.delete(rateKeys);
for (String targetCurrency : Country.create()) {
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
cache.evict("rate::" + baseCurrency + "::" + targetCurrency);
}, executor);

futures.add(future);
}

// 모든 작업 완료까지 대기
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<ExchangeRate> exchangeRates = exchangeRepository.findByBaseCurrency(baseCurrency);
if (!exchangeRates.isEmpty()) {
Map<String, Double> conversionRates = exchangeRates.get(0).getExchangeRates();
return ExchangeRateResponse.builder()
.baseCurrency(baseCurrency)
.conversionRates(conversionRates)
.build();
return toResponse(baseCurrency, exchangeRates);
}

boolean lockAcquired = lockManager.tryAcquireLock();

if (lockAcquired) {
try {
exchangeRates = exchangeRepository.findByBaseCurrency(baseCurrency);
if (!exchangeRates.isEmpty()) {
return toResponse(baseCurrency, exchangeRates);
}

fetchAndSaveAllExchangeRates().join();

exchangeRates = exchangeRepository.findByBaseCurrency(baseCurrency);
if (!exchangeRates.isEmpty()) {
return toResponse(baseCurrency, exchangeRates);
Copy link
Collaborator

Choose a reason for hiding this comment

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

똑같은 코드가 두번 반복됩니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

추후 계속 반복될경우 코드를 빼는 방향으로 가겠습니다.
두번정도는 충분히 괜찮다고 생각합니다.

}

} 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<ExchangeRate> exchangeRates) {
Map<String, Double> conversionRates = exchangeRates.get(0).getExchangeRates();
return ExchangeRateResponse.builder()
.baseCurrency(baseCurrency)
.conversionRates(conversionRates)
.build();
}

public void saveRatesToTempDB(String baseCurrency, ExchangeRateResponse response) {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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;

@Component
@RequiredArgsConstructor
public class ExchangeRateLockManager {

@PersistenceContext
private final EntityManager entityManager;

private static final long LOCK_KEY = 987654321L;
Copy link
Collaborator

Choose a reason for hiding this comment

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

이것도 상수 관리 필드에 옮겨주시면 좋겠습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

수정했습니다.


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();
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -164,37 +160,68 @@ public void Test8() throws InterruptedException {
}

@Test
@DisplayName("최적 스레드 수 계산 테스트 - 외부 API 호출 기준")
void 스레드수계산테스트() {
@DisplayName("스레드별 로드 측정 및 최적 스레드 수 계산")
void 스레드별로드측정테스트() throws Exception {
int cores = Runtime.getRuntime().availableProcessors();
List<String> 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<String, Double> rates = new HashMap<>();
rates.put("KRW", 1300.00);
Map<String, List<Long>> threadDurations = new ConcurrentHashMap<>();
List<CompletableFuture<Void>> 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<Void> 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<String, List<Long>> entry : threadDurations.entrySet()) {
String thread = entry.getKey();
List<Long> 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("비동기 업데이트시 기존 데이터를 가져오므로 사용자 블로킹 발생 안함")
Expand Down Expand Up @@ -232,6 +259,7 @@ void testFetchExchangeRatesWhileUpdating() throws ExecutionException, Interrupte
assertThat(updatedResponse.conversionRates().get("KRW")).isEqualTo(1472.8846);

}

@Test
@DisplayName("동기식 업데이트 할시 기존 데이터 못가져오는 경우")
void testFetchExchangeRatesWhileUpdating2() throws ExecutionException, InterruptedException {
Expand Down Expand Up @@ -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);


}
}
Loading