ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 🏦 Spring Boot 대용량 트래픽 환경에서의 계좌 API 구현
    Server/Spring Boot 2026. 1. 5. 16:55
    728x90
    반응형

    동시성 제어와 락(Lock) 전략을 활용한 안전한 금융 트랜잭션 처리

    📌 목차

    1. 개요
    2. 락(Lock)의 종류와 특징
    3. 프로젝트 구조
    4. Entity 설계
    5. Repository 구현
    6. Service 구현
    7. Controller 구현
    8. DTO 클래스
    9. 예외 처리
    10. 테스트 코드
    11. 정리

    개요

    금융 서비스에서 가장 중요한 것은 데이터 정합성입니다. 동시에 수천 명의 사용자가 같은 계좌에 접근하더라도 잔액이 절대 틀리면 안 됩니다.

    문제 상황 예시

    [시나리오] 잔액: 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_READ FOR SHARE 공유 락
    PESSIMISTIC_WRITE FOR 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 → 낙관적 락 + 재시도

    📌 주의사항

    1. 트랜잭션 범위 최소화: 락을 오래 잡으면 성능 저하
    2. 타임아웃 설정 필수: 무한 대기 방지
    3. 데드락 로깅: 문제 발생 시 추적 가능하도록
    4. 테스트 필수: 동시성 테스트로 검증

    의존성 (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
    반응형

    댓글

Designed by Tistory.