-
🏦 Spring Boot 대용량 트래픽 환경에서의 계좌 API 구현Server/Spring Boot 2026. 1. 5. 16:55728x90반응형
동시성 제어와 락(Lock) 전략을 활용한 안전한 금융 트랜잭션 처리
📌 목차
개요
금융 서비스에서 가장 중요한 것은 데이터 정합성입니다. 동시에 수천 명의 사용자가 같은 계좌에 접근하더라도 잔액이 절대 틀리면 안 됩니다.
문제 상황 예시
[시나리오] 잔액: 10,000원 Thread A: 5,000원 출금 시도 Thread B: 3,000원 출금 시도 (동시에) ❌ 잘못된 결과: 둘 다 10,000원에서 차감 → 잔액 5,000원 또는 7,000원 ✅ 올바른 결과: 순차 처리 → 잔액 2,000원이런 Race Condition을 방지하기 위해 Lock(락) 메커니즘을 사용합니다.
락(Lock)의 종류와 특징
1. 낙관적 락 (Optimistic Lock)
@Version private Long version;항목 설명 동작 방식 충돌이 거의 없다고 가정, 커밋 시점에 버전 체크 장점 DB 락을 걸지 않아 성능이 좋음 단점 충돌 시 재시도 로직 필요 적합한 상황 읽기가 많고 충돌이 적은 경우 2. 비관적 락 (Pessimistic Lock)
@Lock(LockModeType.PESSIMISTIC_WRITE)항목 설명 동작 방식 충돌이 발생할 것으로 가정, 조회 시점에 락 획득 장점 데이터 정합성 100% 보장 단점 동시성 처리량 감소, 데드락 가능성 적합한 상황 금융 거래처럼 정합성이 최우선인 경우 3. 분산 락 (Distributed Lock)
// Redis를 이용한 분산 락 Redisson → RLock항목 설명 동작 방식 Redis/Zookeeper 등 외부 시스템으로 락 관리 장점 다중 서버 환경에서도 동작 단점 외부 의존성, 구현 복잡도 증가 적합한 상황 MSA, 다중 인스턴스 환경 🎯 본 예제에서는 비관적 락을 메인으로 사용합니다.
프로젝트 구조
src/main/java/com/example/banking/ ├── controller/ │ └── AccountController.java ├── service/ │ └── AccountService.java ├── repository/ │ └── AccountRepository.java ├── entity/ │ └── Account.java ├── dto/ │ ├── AccountResponseDto.java │ ├── DepositRequestDto.java │ ├── WithdrawRequestDto.java │ └── TransferRequestDto.java └── exception/ ├── AccountNotFoundException.java ├── InsufficientBalanceException.java └── GlobalExceptionHandler.java
Entity 설계
Account.java
package com.example.banking.entity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @Table(name = "accounts") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Account { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false, length = 20) private String accountNumber; @Column(nullable = false, length = 50) private String ownerName; @Column(nullable = false, precision = 15, scale = 2) private BigDecimal balance; /** * 낙관적 락을 위한 버전 필드 * 업데이트 시마다 자동으로 증가 */ @Version private Long version; @Column(updatable = false) private LocalDateTime createdAt; private LocalDateTime updatedAt; @PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now(); } @PreUpdate protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } @Builder public Account(String accountNumber, String ownerName, BigDecimal balance) { this.accountNumber = accountNumber; this.ownerName = ownerName; this.balance = balance != null ? balance : BigDecimal.ZERO; } // ========== 비즈니스 메서드 (도메인 로직) ========== /** * 입금 처리 * @param amount 입금액 */ public void deposit(BigDecimal amount) { validatePositiveAmount(amount); this.balance = this.balance.add(amount); } /** * 출금 처리 * @param amount 출금액 */ public void withdraw(BigDecimal amount) { validatePositiveAmount(amount); validateSufficientBalance(amount); this.balance = this.balance.subtract(amount); } private void validatePositiveAmount(BigDecimal amount) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("금액은 0보다 커야 합니다."); } } private void validateSufficientBalance(BigDecimal amount) { if (this.balance.compareTo(amount) < 0) { throw new InsufficientBalanceException( String.format("잔액이 부족합니다. 현재 잔액: %s, 요청 금액: %s", this.balance, amount) ); } } }💡 포인트
BigDecimal사용: 금융에서double/float는 부동소수점 오차 발생 가능@Version: 낙관적 락 지원- 비즈니스 로직은 Entity 내부에서 처리 (도메인 주도 설계)
Repository 구현
AccountRepository.java
package com.example.banking.repository; import com.example.banking.entity.Account; import jakarta.persistence.LockModeType; import jakarta.persistence.QueryHint; 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 java.util.Optional; public interface AccountRepository extends JpaRepository<Account, Long> { /** * 일반 조회 (락 없음) - 단순 잔액 확인용 */ Optional<Account> findByAccountNumber(String accountNumber); /** * 비관적 쓰기 락 (PESSIMISTIC_WRITE) * - SELECT ... FOR UPDATE 쿼리 실행 * - 다른 트랜잭션에서 읽기/쓰기 모두 대기 * - 금융 거래에 적합 */ @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints({ @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000") }) @Query("SELECT a FROM Account a WHERE a.accountNumber = :accountNumber") Optional<Account> findByAccountNumberWithPessimisticLock( @Param("accountNumber") String accountNumber ); /** * 비관적 읽기 락 (PESSIMISTIC_READ) * - SELECT ... FOR SHARE 쿼리 실행 * - 다른 트랜잭션에서 읽기는 가능, 쓰기는 대기 * - 데이터 변경 없이 정확한 조회가 필요할 때 */ @Lock(LockModeType.PESSIMISTIC_READ) @QueryHints({ @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000") }) @Query("SELECT a FROM Account a WHERE a.accountNumber = :accountNumber") Optional<Account> findByAccountNumberWithPessimisticReadLock( @Param("accountNumber") String accountNumber ); /** * 낙관적 락 (OPTIMISTIC) * - @Version 필드를 이용한 버전 체크 * - 충돌 시 OptimisticLockException 발생 */ @Lock(LockModeType.OPTIMISTIC) @Query("SELECT a FROM Account a WHERE a.accountNumber = :accountNumber") Optional<Account> findByAccountNumberWithOptimisticLock( @Param("accountNumber") String accountNumber ); /** * ID 기반 비관적 락 조회 */ @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> findByIdWithPessimisticLock(@Param("id") Long id); }🔐 Lock 타입 비교
Lock Type SQL 읽기 쓰기 용도 PESSIMISTIC_READFOR SHARE ✅ ❌ 공유 락 PESSIMISTIC_WRITEFOR UPDATE ❌ ❌ 배타 락 OPTIMISTIC- ✅ ✅ 버전 체크 ⏱️ Lock Timeout
@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")- 3초 동안 락 획득 실패 시 예외 발생
- 무한 대기 방지, 데드락 예방
Service 구현
AccountService.java
package com.example.banking.service; import com.example.banking.dto.*; import com.example.banking.entity.Account; import com.example.banking.exception.AccountNotFoundException; import com.example.banking.repository.AccountRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @Service @RequiredArgsConstructor @Slf4j public class AccountService { private final AccountRepository accountRepository; /** * 잔액 조회 (락 없음) * - 단순 조회는 락이 필요 없음 * - 정확한 실시간 잔액이 필요하면 비관적 읽기 락 사용 */ @Transactional(readOnly = true) public AccountResponseDto getBalance(String accountNumber) { Account account = findAccountByNumber(accountNumber); return AccountResponseDto.from(account); } /** * 정확한 잔액 조회 (비관적 읽기 락) * - 조회 시점의 정확한 잔액 보장 */ @Transactional(readOnly = true) public AccountResponseDto getBalanceWithLock(String accountNumber) { Account account = accountRepository .findByAccountNumberWithPessimisticReadLock(accountNumber) .orElseThrow(() -> new AccountNotFoundException(accountNumber)); return AccountResponseDto.from(account); } /** * 입금 처리 (비관적 락) */ @Transactional public AccountResponseDto deposit(DepositRequestDto request) { log.info("입금 요청 - 계좌: {}, 금액: {}", request.getAccountNumber(), request.getAmount()); // 비관적 락으로 계좌 조회 (다른 트랜잭션 대기) Account account = accountRepository .findByAccountNumberWithPessimisticLock(request.getAccountNumber()) .orElseThrow(() -> new AccountNotFoundException(request.getAccountNumber())); // 입금 처리 account.deposit(request.getAmount()); log.info("입금 완료 - 계좌: {}, 변경 후 잔액: {}", request.getAccountNumber(), account.getBalance()); return AccountResponseDto.from(account); } /** * 출금 처리 (비관적 락) */ @Transactional public AccountResponseDto withdraw(WithdrawRequestDto request) { log.info("출금 요청 - 계좌: {}, 금액: {}", request.getAccountNumber(), request.getAmount()); // 비관적 락으로 계좌 조회 Account account = accountRepository .findByAccountNumberWithPessimisticLock(request.getAccountNumber()) .orElseThrow(() -> new AccountNotFoundException(request.getAccountNumber())); // 출금 처리 (잔액 부족 시 예외 발생) account.withdraw(request.getAmount()); log.info("출금 완료 - 계좌: {}, 변경 후 잔액: {}", request.getAccountNumber(), account.getBalance()); return AccountResponseDto.from(account); } /** * 계좌 이체 (비관적 락 + 데드락 방지) * * ⚠️ 데드락 방지 전략: * - 항상 계좌 ID가 작은 순서로 락을 획득 * - Thread A: 계좌1 → 계좌2 순서로 락 * - Thread B: 계좌1 → 계좌2 순서로 락 (동일) */ @Transactional(isolation = Isolation.READ_COMMITTED) public TransferResponseDto transfer(TransferRequestDto request) { log.info("이체 요청 - 출금계좌: {}, 입금계좌: {}, 금액: {}", request.getFromAccountNumber(), request.getToAccountNumber(), request.getAmount()); String fromAccountNumber = request.getFromAccountNumber(); String toAccountNumber = request.getToAccountNumber(); // 자기 자신에게 이체 불가 if (fromAccountNumber.equals(toAccountNumber)) { throw new IllegalArgumentException("자기 자신에게는 이체할 수 없습니다."); } // 데드락 방지: 계좌번호 기준 정렬하여 항상 같은 순서로 락 획득 Account firstLock, secondLock; if (fromAccountNumber.compareTo(toAccountNumber) < 0) { firstLock = getAccountWithLock(fromAccountNumber); secondLock = getAccountWithLock(toAccountNumber); } else { firstLock = getAccountWithLock(toAccountNumber); secondLock = getAccountWithLock(fromAccountNumber); } // 출금/입금 계좌 결정 Account fromAccount = fromAccountNumber.equals(firstLock.getAccountNumber()) ? firstLock : secondLock; Account toAccount = toAccountNumber.equals(firstLock.getAccountNumber()) ? firstLock : secondLock; // 이체 처리 fromAccount.withdraw(request.getAmount()); toAccount.deposit(request.getAmount()); log.info("이체 완료 - 출금계좌 잔액: {}, 입금계좌 잔액: {}", fromAccount.getBalance(), toAccount.getBalance()); return TransferResponseDto.builder() .fromAccountNumber(fromAccountNumber) .toAccountNumber(toAccountNumber) .amount(request.getAmount()) .fromAccountBalance(fromAccount.getBalance()) .toAccountBalance(toAccount.getBalance()) .message("이체가 완료되었습니다.") .build(); } /** * 낙관적 락을 사용한 입금 (재시도 포함) * - 충돌 시 자동 재시도 (최대 3회) */ @Retryable( retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2) ) @Transactional public AccountResponseDto depositWithOptimisticLock(DepositRequestDto request) { log.info("낙관적 락 입금 요청 - 계좌: {}", request.getAccountNumber()); Account account = accountRepository .findByAccountNumberWithOptimisticLock(request.getAccountNumber()) .orElseThrow(() -> new AccountNotFoundException(request.getAccountNumber())); account.deposit(request.getAmount()); // 커밋 시점에 버전 충돌 확인 // 충돌 시 ObjectOptimisticLockingFailureException 발생 → 재시도 return AccountResponseDto.from(account); } // ========== Private Helper Methods ========== private Account findAccountByNumber(String accountNumber) { return accountRepository.findByAccountNumber(accountNumber) .orElseThrow(() -> new AccountNotFoundException(accountNumber)); } private Account getAccountWithLock(String accountNumber) { return accountRepository .findByAccountNumberWithPessimisticLock(accountNumber) .orElseThrow(() -> new AccountNotFoundException(accountNumber)); } }🔑 핵심 포인트
1. 비관적 락 사용
Account account = accountRepository .findByAccountNumberWithPessimisticLock(accountNumber) .orElseThrow(...);- 조회 시점에 락 획득
- 트랜잭션 종료까지 다른 트랜잭션 대기
2. 데드락 방지
if (fromAccountNumber.compareTo(toAccountNumber) < 0) { firstLock = getAccountWithLock(fromAccountNumber); secondLock = getAccountWithLock(toAccountNumber); } else { // 반대 순서 }- 항상 동일한 순서로 락 획득
- 교착 상태 원천 차단
3. 낙관적 락 + 재시도
@Retryable( retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2) )- 충돌 시 자동 재시도
- 지수 백오프로 부하 분산
Controller 구현
AccountController.java
package com.example.banking.controller; import com.example.banking.dto.*; import com.example.banking.service.AccountService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/accounts") @RequiredArgsConstructor public class AccountController { private final AccountService accountService; /** * 잔액 조회 * GET /api/accounts/{accountNumber}/balance */ @GetMapping("/{accountNumber}/balance") public ResponseEntity<ApiResponse<AccountResponseDto>> getBalance( @PathVariable String accountNumber) { AccountResponseDto result = accountService.getBalance(accountNumber); return ResponseEntity.ok(ApiResponse.success(result, "잔액 조회 성공")); } /** * 정확한 잔액 조회 (락 사용) * GET /api/accounts/{accountNumber}/balance/accurate */ @GetMapping("/{accountNumber}/balance/accurate") public ResponseEntity<ApiResponse<AccountResponseDto>> getAccurateBalance( @PathVariable String accountNumber) { AccountResponseDto result = accountService.getBalanceWithLock(accountNumber); return ResponseEntity.ok(ApiResponse.success(result, "정확한 잔액 조회 성공")); } /** * 입금 * POST /api/accounts/deposit */ @PostMapping("/deposit") public ResponseEntity<ApiResponse<AccountResponseDto>> deposit( @Valid @RequestBody DepositRequestDto request) { AccountResponseDto result = accountService.deposit(request); return ResponseEntity.ok(ApiResponse.success(result, "입금 완료")); } /** * 출금 * POST /api/accounts/withdraw */ @PostMapping("/withdraw") public ResponseEntity<ApiResponse<AccountResponseDto>> withdraw( @Valid @RequestBody WithdrawRequestDto request) { AccountResponseDto result = accountService.withdraw(request); return ResponseEntity.ok(ApiResponse.success(result, "출금 완료")); } /** * 계좌 이체 * POST /api/accounts/transfer */ @PostMapping("/transfer") public ResponseEntity<ApiResponse<TransferResponseDto>> transfer( @Valid @RequestBody TransferRequestDto request) { TransferResponseDto result = accountService.transfer(request); return ResponseEntity.ok(ApiResponse.success(result, "이체 완료")); } /** * 낙관적 락을 사용한 입금 * POST /api/accounts/deposit/optimistic */ @PostMapping("/deposit/optimistic") public ResponseEntity<ApiResponse<AccountResponseDto>> depositOptimistic( @Valid @RequestBody DepositRequestDto request) { AccountResponseDto result = accountService.depositWithOptimisticLock(request); return ResponseEntity.ok(ApiResponse.success(result, "입금 완료 (낙관적 락)")); } }
DTO 클래스
ApiResponse.java (공통 응답)
package com.example.banking.dto; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; import lombok.Getter; import java.time.LocalDateTime; @Getter @Builder @JsonInclude(JsonInclude.Include.NON_NULL) public class ApiResponse<T> { private final boolean success; private final String message; private final T data; private final LocalDateTime timestamp; public static <T> ApiResponse<T> success(T data, String message) { return ApiResponse.<T>builder() .success(true) .message(message) .data(data) .timestamp(LocalDateTime.now()) .build(); } public static <T> ApiResponse<T> error(String message) { return ApiResponse.<T>builder() .success(false) .message(message) .timestamp(LocalDateTime.now()) .build(); } }AccountResponseDto.java
package com.example.banking.dto; import com.example.banking.entity.Account; import lombok.Builder; import lombok.Getter; import java.math.BigDecimal; @Getter @Builder public class AccountResponseDto { private String accountNumber; private String ownerName; private BigDecimal balance; public static AccountResponseDto from(Account account) { return AccountResponseDto.builder() .accountNumber(account.getAccountNumber()) .ownerName(account.getOwnerName()) .balance(account.getBalance()) .build(); } }DepositRequestDto.java
package com.example.banking.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import lombok.Getter; import lombok.NoArgsConstructor; import java.math.BigDecimal; @Getter @NoArgsConstructor public class DepositRequestDto { @NotBlank(message = "계좌번호는 필수입니다.") private String accountNumber; @NotNull(message = "입금액은 필수입니다.") @Positive(message = "입금액은 0보다 커야 합니다.") private BigDecimal amount; }WithdrawRequestDto.java
package com.example.banking.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import lombok.Getter; import lombok.NoArgsConstructor; import java.math.BigDecimal; @Getter @NoArgsConstructor public class WithdrawRequestDto { @NotBlank(message = "계좌번호는 필수입니다.") private String accountNumber; @NotNull(message = "출금액은 필수입니다.") @Positive(message = "출금액은 0보다 커야 합니다.") private BigDecimal amount; }TransferRequestDto.java
package com.example.banking.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import lombok.Getter; import lombok.NoArgsConstructor; import java.math.BigDecimal; @Getter @NoArgsConstructor public class TransferRequestDto { @NotBlank(message = "출금 계좌번호는 필수입니다.") private String fromAccountNumber; @NotBlank(message = "입금 계좌번호는 필수입니다.") private String toAccountNumber; @NotNull(message = "이체 금액은 필수입니다.") @Positive(message = "이체 금액은 0보다 커야 합니다.") private BigDecimal amount; }TransferResponseDto.java
package com.example.banking.dto; import lombok.Builder; import lombok.Getter; import java.math.BigDecimal; @Getter @Builder public class TransferResponseDto { private String fromAccountNumber; private String toAccountNumber; private BigDecimal amount; private BigDecimal fromAccountBalance; private BigDecimal toAccountBalance; private String message; }
예외 처리
AccountNotFoundException.java
package com.example.banking.exception; public class AccountNotFoundException extends RuntimeException { public AccountNotFoundException(String accountNumber) { super("계좌를 찾을 수 없습니다: " + accountNumber); } }InsufficientBalanceException.java
package com.example.banking.exception; public class InsufficientBalanceException extends RuntimeException { public InsufficientBalanceException(String message) { super(message); } }GlobalExceptionHandler.java
package com.example.banking.exception; import com.example.banking.dto.ApiResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import jakarta.persistence.LockTimeoutException; import jakarta.persistence.PessimisticLockException; @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @ExceptionHandler(AccountNotFoundException.class) public ResponseEntity<ApiResponse<Void>> handleAccountNotFound( AccountNotFoundException e) { log.warn("계좌 조회 실패: {}", e.getMessage()); return ResponseEntity .status(HttpStatus.NOT_FOUND) .body(ApiResponse.error(e.getMessage())); } @ExceptionHandler(InsufficientBalanceException.class) public ResponseEntity<ApiResponse<Void>> handleInsufficientBalance( InsufficientBalanceException e) { log.warn("잔액 부족: {}", e.getMessage()); return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(e.getMessage())); } @ExceptionHandler(ObjectOptimisticLockingFailureException.class) public ResponseEntity<ApiResponse<Void>> handleOptimisticLock( ObjectOptimisticLockingFailureException e) { log.warn("낙관적 락 충돌 발생: {}", e.getMessage()); return ResponseEntity .status(HttpStatus.CONFLICT) .body(ApiResponse.error( "다른 사용자가 동시에 처리 중입니다. 잠시 후 다시 시도해주세요." )); } @ExceptionHandler({PessimisticLockException.class, LockTimeoutException.class}) public ResponseEntity<ApiResponse<Void>> handleLockTimeout(Exception e) { log.error("락 타임아웃 발생: {}", e.getMessage()); return ResponseEntity .status(HttpStatus.SERVICE_UNAVAILABLE) .body(ApiResponse.error( "서버가 바쁩니다. 잠시 후 다시 시도해주세요." )); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ApiResponse<Void>> handleValidation( MethodArgumentNotValidException e) { String message = e.getBindingResult() .getFieldErrors() .stream() .findFirst() .map(error -> error.getDefaultMessage()) .orElse("잘못된 요청입니다."); return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(message)); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<ApiResponse<Void>> handleIllegalArgument( IllegalArgumentException e) { return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(e.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity<ApiResponse<Void>> handleGeneral(Exception e) { log.error("예상치 못한 오류 발생", e); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiResponse.error("서버 오류가 발생했습니다.")); } }
테스트 코드
동시성 테스트
package com.example.banking.service; import com.example.banking.dto.DepositRequestDto; import com.example.banking.dto.WithdrawRequestDto; import com.example.banking.entity.Account; import com.example.banking.repository.AccountRepository; import org.junit.jupiter.api.BeforeEach; 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 java.math.BigDecimal; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest class AccountServiceConcurrencyTest { @Autowired private AccountService accountService; @Autowired private AccountRepository accountRepository; private static final String TEST_ACCOUNT = "1234567890"; @BeforeEach void setUp() { accountRepository.deleteAll(); Account account = Account.builder() .accountNumber(TEST_ACCOUNT) .ownerName("테스트") .balance(new BigDecimal("100000")) .build(); accountRepository.save(account); } @Test @DisplayName("동시에 100개의 출금 요청이 와도 잔액이 정확해야 한다") void concurrentWithdrawTest() throws InterruptedException { // given int threadCount = 100; BigDecimal withdrawAmount = new BigDecimal("1000"); // 각 1,000원 출금 ExecutorService executor = Executors.newFixedThreadPool(32); CountDownLatch latch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); // when for (int i = 0; i < threadCount; i++) { executor.submit(() -> { try { WithdrawRequestDto request = new WithdrawRequestDto(); // setter 또는 생성자로 값 설정 accountService.withdraw(request); successCount.incrementAndGet(); } catch (Exception e) { failCount.incrementAndGet(); } finally { latch.countDown(); } }); } latch.await(); executor.shutdown(); // then Account account = accountRepository.findByAccountNumber(TEST_ACCOUNT) .orElseThrow(); // 초기 잔액 100,000 - (성공 횟수 × 1,000) = 최종 잔액 BigDecimal expectedBalance = new BigDecimal("100000") .subtract(withdrawAmount.multiply(new BigDecimal(successCount.get()))); assertThat(account.getBalance()).isEqualByComparingTo(expectedBalance); System.out.println("성공: " + successCount.get()); System.out.println("실패: " + failCount.get()); System.out.println("최종 잔액: " + account.getBalance()); } @Test @DisplayName("동시에 입금과 출금이 섞여도 잔액이 정확해야 한다") void concurrentDepositAndWithdrawTest() throws InterruptedException { // given int threadCount = 100; ExecutorService executor = Executors.newFixedThreadPool(32); CountDownLatch latch = new CountDownLatch(threadCount); // when for (int i = 0; i < threadCount; i++) { final int index = i; executor.submit(() -> { try { if (index % 2 == 0) { // 짝수: 1,000원 입금 DepositRequestDto deposit = new DepositRequestDto(); accountService.deposit(deposit); } else { // 홀수: 1,000원 출금 WithdrawRequestDto withdraw = new WithdrawRequestDto(); accountService.withdraw(withdraw); } } catch (Exception e) { // 잔액 부족 등 예외 무시 } finally { latch.countDown(); } }); } latch.await(); executor.shutdown(); // then Account account = accountRepository.findByAccountNumber(TEST_ACCOUNT) .orElseThrow(); // 입금 50회, 출금 50회 → 잔액 동일해야 함 (잔액 부족 없다면) System.out.println("최종 잔액: " + account.getBalance()); } }
정리
✅ 핵심 정리
항목 권장 방식 금융 거래 비관적 락 ( PESSIMISTIC_WRITE)조회가 많은 서비스 낙관적 락 + 재시도 다중 서버 환경 Redis 분산 락 데드락 방지 일관된 락 획득 순서 금액 계산 BigDecimal필수🔐 락 선택 가이드
충돌 빈도가 낮은가? ├── Yes → 낙관적 락 (성능 우선) └── No → 데이터 정합성이 중요한가? ├── Yes → 비관적 락 (금융) └── No → 낙관적 락 + 재시도📌 주의사항
- 트랜잭션 범위 최소화: 락을 오래 잡으면 성능 저하
- 타임아웃 설정 필수: 무한 대기 방지
- 데드락 로깅: 문제 발생 시 추적 가능하도록
- 테스트 필수: 동시성 테스트로 검증
의존성 (build.gradle)
dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework:spring-aspects' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' // 또는 H2 (테스트용) runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' }
💡 Tip: 실제 운영 환경에서는 분산 락(Redis)과 함께 사용하고, 모니터링 시스템으로 데드락 발생을 감지하세요!
728x90반응형'Server > Spring Boot' 카테고리의 다른 글
Spring WebClient와 버추얼 스레드, 어떻게 조합할까? (1) 2026.01.09 Spring Boot 3.x에서 @Async와 버추얼 스레드 조합하기 (0) 2026.01.09 Java 21 버추얼 스레드 원리와 Spring Boot 설정 방법 (0) 2026.01.05 Spring Boot에서 많이 사용되는 View 엔진 추천 및 장단점 (0) 2025.04.29 자주 사용되는 디자인 패턴에 대해 알아보자 (springboot) (3) 2025.04.28