Skip to content
Merged
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ 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 'org.redisson:redisson-spring-boot-starter:3.45.1'
implementation 'io.micrometer:micrometer-tracing-bridge-brave'
implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication
@ConfigurationPropertiesScan
public class CoreBankingApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package org.creditto.core_banking.domain.account.repository;

import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.creditto.core_banking.domain.account.dto.AccountSummaryRes;
import org.creditto.core_banking.domain.account.entity.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {

Expand All @@ -27,4 +30,9 @@ public interface AccountRepository extends JpaRepository<Account, Long> {
"COUNT(ac), COALESCE(SUM(ac.balance), 0)) " +
"FROM Account ac WHERE ac.userId = :userId")
AccountSummaryRes findAccountSummaryByUserId(@Param("userId") Long userId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("SELECT a FROM Account a WHERE a.id = :id")
Optional<Account> findByIdForUpdate(@Param("id") Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.creditto.core_banking.domain.account.service;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "core.account-lock")
public class AccountLockProperties {

private final long waitMillis;
private final long leaseMillis;
private final String accountLockPrefix;

public AccountLockProperties(long waitMillis, long leaseMillis, String accountLockPrefix) {
this.waitMillis = waitMillis;
this.leaseMillis = leaseMillis;
this.accountLockPrefix = accountLockPrefix;
}

public long getWaitMillis() {
return waitMillis;
}

public long getLeaseMillis() {
return leaseMillis;
}

public String getAccountLockPrefix() {
return accountLockPrefix;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package org.creditto.core_banking.domain.account.service;

import java.util.concurrent.TimeUnit;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.creditto.core_banking.global.response.error.ErrorBaseCode;
import org.creditto.core_banking.global.response.exception.CustomBaseException;
import org.redisson.RedissonShutdownException;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class AccountLockService {

private final RedissonClient redissonClient;
private final AccountLockProperties accountLockProperties;

public <T> T executeWithLock(Long accountId, LockCallback<T> callback) {
RLock lock = redissonClient.getLock(accountLockProperties.getAccountLockPrefix() + accountId);
boolean redisLockAcquired = false;
boolean redisAvailable = true;

try {
redisLockAcquired = lock.tryLock(
accountLockProperties.getWaitMillis(),
accountLockProperties.getLeaseMillis(),
TimeUnit.MILLISECONDS
);
} catch (RedissonShutdownException redisException) {
redisAvailable = false;
log.warn("Redis lock 불가, fallback 전략을 사용합니다. accountId={}, reason={}", accountId, redisException.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CustomBaseException(ErrorBaseCode.ACCOUNT_LOCK_INTERRUPTED);
}

try {
if (!redisAvailable) {
// Redis 사용 불가 & DB 분산락만 적용
return callback.invokeFallback();
}

if (!redisLockAcquired) {
// Lock 획득 실패
throw new CustomBaseException(ErrorBaseCode.ACCOUNT_LOCK_TIMEOUT);
}

return callback.invoke();

} catch (InterruptedException e) {
// Interrupt 관련 에러
Thread.currentThread().interrupt();
throw new CustomBaseException(ErrorBaseCode.ACCOUNT_LOCK_INTERRUPTED);
} finally {
if (redisLockAcquired && lock.isHeldByCurrentThread()) {
try {
lock.unlock();
} catch (RuntimeException unlockException) {
log.warn("Redis lock 해제 실패. accountId={}, reason={}", accountId, unlockException.getMessage());
}
}
}
}

public void executeWithLock(Long accountId, Runnable runnable) {
executeWithLock(accountId, new LockCallback<Void>() {
@Override
public Void invoke() {
runnable.run();
return null;
}

@Override
public Void invokeFallback() {
runnable.run();
return null;
}
});
}

public interface LockCallback<T> {
T invoke() throws InterruptedException;

default T invokeFallback() throws InterruptedException {
return invoke();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class AccountService {
private final TransactionStrategyFactory strategyFactory;
private final PasswordValidator passwordValidator;
private final PasswordEncoder passwordEncoder;
private final AccountLockService accountLockService;


/**
Expand Down Expand Up @@ -82,15 +83,16 @@ public void verifyPassword(Long accountId, String rawPassword) {
*/
@Transactional
public void processTransaction(Long accountId, BigDecimal amount, TxnType txnType, Long typeId) {
// 팩토리에서 거래 유행에 맞는 전략 호출
// 전략 조회
TransactionStrategy strategy = strategyFactory.getStrategy(txnType);

// 계좌 정보 조회
Account account = accountRepository.findById(accountId)
.orElseThrow(() -> new CustomBaseException(ErrorBaseCode.NOT_FOUND_ACCOUNT));

// 거래 타입 실행
strategy.execute(account, amount, typeId);
// 분산 락 적용
accountLockService.executeWithLock(accountId, () -> {
// 비관적 락 적용
Account account = accountRepository.findByIdForUpdate(accountId)
.orElseThrow(() -> new CustomBaseException(ErrorBaseCode.NOT_FOUND_ACCOUNT));
strategy.execute(account, amount, typeId);
});
}

public AccountRes getAccountById(Long id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import lombok.RequiredArgsConstructor;
import org.creditto.core_banking.domain.account.entity.Account;
import org.creditto.core_banking.domain.account.repository.AccountRepository;
import org.creditto.core_banking.domain.account.service.AccountLockService;
import org.creditto.core_banking.domain.exchange.dto.ExchangeReq;
import org.creditto.core_banking.domain.exchange.dto.ExchangeRes;
import org.creditto.core_banking.domain.exchange.entity.Exchange;
Expand Down Expand Up @@ -50,6 +51,7 @@ public class RemittanceProcessorService {
private final ExchangeService exchangeService;
private final TransactionService transactionService;
private final RemittanceFeeService remittanceFeeService;
private final AccountLockService accountLockService;

/**
* 전달된 Command를 기반으로 해외송금의 모든 단계를 실행합니다.
Expand All @@ -67,7 +69,6 @@ public class RemittanceProcessorService {
@Transactional
Copy link
Contributor

Choose a reason for hiding this comment

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

high

현재 execute 메서드에 @Transactional이 적용되어 있어 메서드 시작 시점에 트랜잭션이 시작됩니다. 하지만 이 트랜잭션 내에서 exchangeService.exchange()(88라인)를 통해 외부 네트워크 호출이 발생하고 있습니다.

네트워크 호출과 같이 오래 걸릴 수 있는 작업을 트랜잭션 내에서 수행하는 것은 다음과 같은 이유로 안티패턴으로 간주됩니다.

  1. 메서드가 실행되는 동안 DB 커넥션 풀의 커넥션을 계속 점유하여, 높은 부하 상황에서 커넥션 풀 고갈을 유발할 수 있습니다.
  2. 트랜잭션이 잠금을 사용하는 경우, 잠금 유지 시간이 길어져 경합이 증가하고 데드락 발생 가능성이 높아집니다.

트랜잭션의 범위를 최소화하도록 리팩토링하는 것을 권장합니다. 트랜잭션은 잔액 확인, 계좌 업데이트, 송금 및 거래 내역 저장 등 원자적으로 실행되어야 하는 최종 작업만 감싸야 합니다.

아래와 같은 리팩토링을 제안합니다.

  1. execute 메서드에서 @Transactional 애노테이션을 제거합니다.
  2. processRemittanceTransaction과 같은 새로운 private 또는 protected 메서드를 만들고 @Transactional을 적용합니다.
  3. 원자성이 필요한 로직을 이 새로운 메서드로 옮깁니다.
  4. execute 메서드에서 외부 호출이 완료된 후 이 새로운 트랜잭션 메서드를 호출합니다.
// @Transactional 제거
public OverseasRemittanceResponseDto execute(final ExecuteRemittanceCommand command) {
    // ... (엔티티 조회, 환전, 수수료 계산 등)
    // 이 부분은 트랜잭션 없이 실행

    // ...

    // 원자성이 필요한 로직은 별도의 트랜잭션 메서드로 분리하여 호출
    return processRemittanceTransaction(command, totalDeduction, recipient, regularRemittance, savedExchange, feeRecord, actualSendAmount);
}

@Transactional
protected OverseasRemittanceResponseDto processRemittanceTransaction(...) {
    return accountLockService.executeWithLock(command.accountId(), () -> {
        // 분산 락 및 비관적 락 내부 로직
        // (잔액 확인, 출금, 송금 및 거래 내역 저장)
        // ...
    });
}

이렇게 변경하면 데이터베이스 리소스를 더 빨리 해제하여 애플리케이션의 성능과 안정성을 향상시킬 수 있습니다.

public OverseasRemittanceResponseDto execute(final ExecuteRemittanceCommand command) {

// 관련 엔티티 조회
Account account = accountRepository.findById(command.accountId())
.orElseThrow(() -> new CustomBaseException(NOT_FOUND_ACCOUNT));

Expand Down Expand Up @@ -98,44 +99,44 @@ public OverseasRemittanceResponseDto execute(final ExecuteRemittanceCommand comm
// 총 차감될 금액 계산 (실제 보낼 금액 + 총 수수료)
BigDecimal totalDeduction = actualSendAmount.add(totalFee);

// 잔액 확인
if (!account.checkSufficientBalance(totalDeduction)) {
// 실패 트랜잭션 기록
transactionService.saveTransaction(account, actualSendAmount, TxnType.WITHDRAWAL, null, TxnResult.FAILURE);
throw new CustomBaseException(ErrorBaseCode.INSUFFICIENT_FUNDS);
}

// 3. DTO에 담겨올 ID로 Exchange 엔티티 다시 조회
Long exchangeId = exchangeRes.exchangeId();

Exchange savedExchange = exchangeRepository.findById(exchangeId)
.orElseThrow(() -> new CustomBaseException(NOT_FOUND_EXCHANGE_RECORD));

// 5. 송금 이력 생성
OverseasRemittance overseasRemittance = OverseasRemittance.of(
recipient,
account,
regularRemittance,
savedExchange,
feeRecord,
actualSendAmount,
command
);
remittanceRepository.save(overseasRemittance);

// 수수료 차감 및 거래 내역 생성
if (totalFee.compareTo(BigDecimal.ZERO) > 0) {
account.withdraw(totalFee);
transactionService.saveTransaction(account, totalFee, TxnType.FEE, overseasRemittance.getRemittanceId(), TxnResult.SUCCESS);
}

// 송금액 차감 및 거래 내역 생성
account.withdraw(actualSendAmount);
transactionService.saveTransaction(account, actualSendAmount, TxnType.WITHDRAWAL, overseasRemittance.getRemittanceId(), TxnResult.SUCCESS);

accountRepository.save(account);

return OverseasRemittanceResponseDto.from(overseasRemittance);
Long exchangeId = exchangeRes.exchangeId();

Exchange savedExchange = exchangeRepository.findById(exchangeId)
.orElseThrow(() -> new CustomBaseException(NOT_FOUND_EXCHANGE_RECORD));

return accountLockService.executeWithLock(command.accountId(), () -> {
Account lockedAccount = accountRepository.findByIdForUpdate(command.accountId())
.orElseThrow(() -> new CustomBaseException(NOT_FOUND_ACCOUNT));

if (!lockedAccount.checkSufficientBalance(totalDeduction)) {
transactionService.saveTransaction(lockedAccount, actualSendAmount, TxnType.WITHDRAWAL, null, TxnResult.FAILURE);
throw new CustomBaseException(ErrorBaseCode.INSUFFICIENT_FUNDS);
}

OverseasRemittance overseasRemittance = OverseasRemittance.of(
recipient,
lockedAccount,
regularRemittance,
savedExchange,
feeRecord,
actualSendAmount,
command
);
remittanceRepository.save(overseasRemittance);

if (totalFee.compareTo(BigDecimal.ZERO) > 0) {
lockedAccount.withdraw(totalFee);
transactionService.saveTransaction(lockedAccount, totalFee, TxnType.FEE, overseasRemittance.getRemittanceId(), TxnResult.SUCCESS);
}

lockedAccount.withdraw(actualSendAmount);
transactionService.saveTransaction(lockedAccount, actualSendAmount, TxnType.WITHDRAWAL, overseasRemittance.getRemittanceId(), TxnResult.SUCCESS);

accountRepository.save(lockedAccount);

return OverseasRemittanceResponseDto.from(overseasRemittance);
});
}

private ExchangeRes exchange(Long userId, ExecuteRemittanceCommand command) {
Expand All @@ -152,4 +153,4 @@ private FeeRecord calculateFee(ExchangeRes exchangeRes, CurrencyCode currencyCod
);
return remittanceFeeService.calculateAndSaveFee(feeReq);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.creditto.core_banking.global.config;

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<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);

// key:value
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());

// hash key:value
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer((new GenericJackson2JsonRedisSerializer()));

return template;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ public enum ErrorBaseCode implements ErrorCode {
CONFLICT(HttpStatus.CONFLICT, 409, "이미 존재하는 리소스입니다."),
DB_CONFLICT(HttpStatus.CONFLICT, 409, "DB 관련 충돌 문제입니다."),
DUPLICATE_REMITTANCE(HttpStatus.CONFLICT, 40911, "동일한 내용의 자동이체가 이미 등록되어 있습니다."),
ACCOUNT_LOCK_TIMEOUT(HttpStatus.LOCKED, 42301, "계좌 처리 대기 시간이 초과되었습니다."),
ACCOUNT_LOCK_INTERRUPTED(HttpStatus.LOCKED, 42302, "계좌 잠금 처리 중 오류가 발생했습니다."),

/**
* 500 INTERNAL SERVER ERROR - 서버 오류
Expand Down
11 changes: 11 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ spring:
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
timeout: 2000

scheduler:
remittance:
Expand Down Expand Up @@ -34,3 +39,9 @@ server:
logging:
level:
org.hibernate.sql: DEBUG

core:
account-lock:
account-lock-prefix: ${ACCOUNT_LOCK_PREFIX}
wait-millis: ${ACCOUNT_LOCK_WAIT_MILLIS}
lease-millis: ${ACCOUNT_LOCK_LEASE_MILLIS}
Loading