티스토리 뷰
금융 서비스의 핵심은 데이터 무결성이라고 생각합니다. 단순히 잔액을 옮기는 기능을 넘어, 수천 건의 요청이 동시에 발생해도 데이터가 꼬이지 않고 네트워크 오류가 있어도 중복 거래가 발생하지 않도록 안정적인 송금 시스템을 설계·구현하며 겪은 과정을 기록합니다.
본 글에서 ‘송금’은 타행 이체가 아닌 동일 시스템 내 계좌 간 이체를 의미합니다.
1. 프로젝트 목표 및 핵심 설계
프로젝트는 확장 가능하고 신뢰할 수 있는 송금 시스템을 구축을 목표로 하며 아래 4가지 원칙을 준수했습니다.
- 데이터 정합성: 모든 금융 거래의 원자성 보장
- 동시성 제어: 동일 계좌에 대한 경합 상황 해결
- 멱등성 보장: 동일 요청 재시도 시 중복 처리 방지
- 성능 최적화: 대량 거래 발생 시에도 빠른 한도 조회 및 잔액 갱신
2. DB 모델링
| 테이블 | 설명 | 비고 |
| Account | 계좌 테이블 | 현재 잔액과 계좌 상태를 관리하며, 삭제 시 Soft Delete 방식을 사용합니다. |
| DailyLimitUsage | 일일 한도 테이블 | 일자별 누적 합계 테이블을 별도로 관리합니다. |
| LimitSetting | 한도 설정 테이블 | 계좌별 한도 설정 정보를 저장하는 테이블입니다. |
| Transaction | 거래 내역 테이블 | 모든 거래의 최종 내역을 저장합니다. |
2.1 DailyLimitUsage 설계 의도
매번 Transaction 테이블을 합산(SUM)하여 한도 체크하는 방식은 데이터가 늘어날수록 성능이 저하됩니다. 이를 방지하고자 일자별 누적 합계 테이블을 별도로 관리하여 조회 성능을 최적화했습니다.
3. 트러블 슈팅
3.1 동시성 제어와 데드락 방지
금융 데이터는 정합성이 최우선입니다. 충돌 발생 시 롤백과 재시도가 잦은 낙관적 락 대신, 데이터 수정 시점에 즉시 락을 거는 비관적 락을 선택했습니다.
하지만 이체는 두 계좌의 락을 동시에 획득해야 하므로, A → B, B → A 송금이 동시에 발생할 경우 서로의 락 해제를 기다리는 데드락에 빠질 수 있습니다. 해결 방법으로 계좌 ID를 기준으로 정렬된 순서에 따라 락을 획득하도록 설계했습니다.
// 코드 핵심 로직 예시
Long firstId = Math.min(fromAccountId, toAccountId);
Long secondId = Math.max(fromAccountId, toAccountId);
// 낮은 ID부터 순차적으로 락 획득 (순환 대기 방지)
accountRepository.findLockedByAccountId(firstId);
accountRepository.findLockedByAccountId(secondId);
3.2 갱신 유실 방지와 식별자 설계
비관적 락을 적용했음에도 불구하고, 객체 조회 순서에 따라 데이터 최신성이 보장되지 않아 잔액이 유실되는 문제를 경험했습니다. 이는 설계 당시 영속성 컨텍스트의 동작 방식을 간과하여 발생한 실수였습니다.
락을 획득하기 전 계좌 엔티티를 먼저 조회하여 메모리에 올리는 것이 문제였습니다. 이후 findLockedByAccountId로 비관적 락을 시도하더라도, JPA는 이미 영속성 컨텍스트에 존재하는 과거 상태의 엔티티를 그대로 반환합니다. 결과적으로 락은 걸었지만 데이터는 락을 걸기 전의 과거 스냅샷을 사용하게 되며, 다른 트랜잭션의 변경 사항을 덮어쓰는 갱신 유실이 발생했습니다.
문제를 해결하기 위해 락을 걸기 전에는 계좌의 ID만 최소한으로 조회하고, 실제 비관적 락을 획득하는 시점에 최신 엔티티를 새롭게 로드하도록 로직을 개선했습니다. 락 획득 시점과 엔티티 로드 시점을 일치시켜 항상 최신 데이터를 바탕으로 잔액 계산이 이루어지게 설계했습니다.
또한 API 파라미터 설계 시 시스템 내부 식별자인 AccountId 대신 AccountNo를 사용한 것 역시 의도적인 선택이었습니다. 이는 보안상 내부 PK 구조를 외부에 노출하지 않기 위함이었습니다. 내부 로직에서는 계좌번호를 통해 ID를 먼저 식별한 후, 해당 ID를 기반으로 데이터에 접근함으로써 시스템 내부 구조를 은닉하고 식별자 노출로 인한 보안 리스크를 최소화했습니다.
// 개선된 로직: ID 식별 후, 락 획득 시점에 최신 엔티티 로드
Long fromAccountId = accountRepository.findIdByAccountNo(fromAccountNo);
Long toAccountId = accountRepository.findIdByAccountNo(toAccountNo);
// PK 기반 락 획득 시점에 최신 상태의 엔티티를 영속성 컨텍스트에 로드
Account firstLockedAccount = accountRepository.findLockedByAccountId(firstId);
Account secondLockedAccount = accountRepository.findLockedByAccountId(secondId);
3.3 멱등성 이중 방어
네트워크 지연으로 인한 중복 요청은 금전적 사고로 이어집니다. 이를 막기 위해 이중 방어막을 구축했습니다.
| 구분 | 설명 |
| Application Level(Redis) | SETNX를 사용하여 동일한 transaction_request_id로 들어오는 요청은 1초간 락킹하여 1차 차단합니다. |
| Database Level | 유니크 제약 조건을 통해 동일 요청이 DB에 중복 저장되는 것을 막습니다. |
멱등성은 중복 요청을 막는 것을 넘어 여러 번 요청해도 일관된 결과를 받는 것까지 포함해야 한다고 판단했습니다. 락 획득 실패 시 에러를 내지 않고 DB를 조회해 이미 완료된 요청이면 기존 결과를 반환하고 진행 중이면 '처리 중' 응답을 내려주었습니다. 프론트엔드는 이를 기반으로 로딩 UI를 노출하거나 폴링을 수행하여, 중복 클릭 시에도 최종적으로 동일한 성공 화면을 보게 설계했습니다.
4. 기술적 트레이드오프
4.1 Redis vs MySQL Native
멱등성을 보장하기 위한 설계 과정에서 Redis 분산 락과 MySQL의 INSERT IGNORE 방식 사이에서 고민이 있었습니다.
MySQL Native 방식을 활용하면 별도의 Redis 인프라 구축 없이도 충분히 멱등성을 보장할 수 있다고 생각했습니다. 사전 조회 과정 없이 바로 삽입을 시도해 네트워크 왕복 횟수를 줄일 수 있고, 중복 시에도 예외 대신 단순히 0을 반환받아 처리하는 방식이 매우 효율적인 전략이라고 판단했기 때문입니다. 추가 인프라 운영 부담 없이 DB가 제공하는 기능만으로 문제를 해결할 수 있다는 점도 큰 장점이었습니다.
하지만 고민 끝에 인프라 복잡도가 높아지더라도 Redis를 활용한 이중 방어 전략을 선택했습니다. 그 이유는 핵심 자원인 DB의 부하를 낮추는 것이 더 중요하다고 보았기 때문입니다. 송금 서비스처럼 트래픽이 집중되는 환경에서 DB 커넥션과 락은 매우 한정적인 자원입니다. 중복 요청이 왔을 때, DB 트랜잭션을 시작하기 전 단계인 Redis에서 요청을 선제적으로 차단함으로써 원천 DB를 보호하고 싶었습니다.
결국 시스템의 복잡도는 다소 높아지더라도, DB의 안정성을 최우선으로 고려하여 부하를 분산시키는 구조를 갖추는 것이 금융 시스템에 더 적합한 선택이라고 판단하여 Redis 기반의 설계를 최종적으로 채택하게 되었습니다.
4.2 다중 락 (계좌 락 + 일일 한도 락)
계좌 락에 이어, 일일 한도(AccountDailyLimitUsage)를 조회·생성(없으면 생성)하는 과정에서도 비관적 락을 적용할지 여부는 이번 설계에서 가장 큰 고민 중 하나였습니다. 이체 로직은 이미 두 계좌에 대해 락을 획득한 상태에서 진행되기 때문에, 여기에 한도 테이블까지 추가로 락을 잡으면 DB 커넥션 점유 시간과 락 대기 시간이 늘어나 전체 처리량이 떨어질 수 있습니다.
성능만 고려한다면 한도 조회를 락 없이 수행하는 편이 유리합니다. 하지만 금융 서비스에서 ‘일일 한도’는 보안 및 규제와 직결된 핵심 비즈니스 규칙이며, 한 번이라도 초과되면 심각한 사고로 이어질 수 있습니다. 락을 포기하면 짧은 순간에 동시 요청들이 한도 체크를 동시에 통과하는 레이스 컨디션이 발생할 수 있고, 그 결과 설정된 한도를 초과한 이체가 실행될 위험이 있습니다.
결국 저는 성능 비용을 감수하더라도 한도 조회 단계에서도 비관적 락을 적용하기로 결정했습니다. 처리 속도보다 “한도는 반드시 지켜져야 한다”는 원칙을 우선했고, 락을 통해 한도 누적과 이체 실행이 항상 원자적으로 일어나도록 보장했습니다.
private DailyLimitUsage getOrCreateUsage(Long accountId, LocalDate today) {
return dailyLimitUsageRepository.findLockedByAccountIdAndLimitDate(accountId, today)
.orElseGet(() -> dailyLimitUsageRepository.save(dailyLimitUsage.init(accountId, today)));
}
5. 검증 테스트
5.1 멱등성 테스트
- 동시에 중복 출금 요청이 와도, 실제 출금은 단 한 번만 반영되어 잔액과 거래 내역이 중복 생성되지 않음을 검증한다.
동시에 출금 요청 10건을 발생해 계좌 잔액이 100,000원인 계좌에 5,000원을 출금한다.
@Test
@DisplayName("트랜잭션요청아이디가 같으면 한번만 출금한다.")
void idempotent_test() throws Exception {
int requestCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(requestCount);
CountDownLatch readyLatch = new CountDownLatch(requestCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(requestCount);
AtomicInteger success = new AtomicInteger();
AtomicInteger fail = new AtomicInteger();
String transactionRequestId = "c1e12a85-46c8-4bp4-cc9b-2482b9f214et";
for (int i = 0; i < requestCount; i++) {
executorService.submit(() -> {
try {
readyLatch.countDown();
startLatch.await();
withdrawUseCase.execute(accountNo, withdrawAmount, transactionRequestId);
success.incrementAndGet();
} catch (Exception e) {
fail.incrementAndGet();
} finally {
endLatch.countDown();
}
});
}
readyLatch.await();
startLatch.countDown();
endLatch.await();
executorService.shutdown();
Account result = accountRepository.findById(accountId).orElseThrow();
Long historyCount = accountTransactionRepository.countByAccountId(accountId);
Transaction transaction = accountTransactionRepository.findByAccountIdAndTransactionRequestId(accountId, sameTransactionId).orElseThrow();
assertThat(result.getBalance()).isEqualTo(balance - withdrawAmount);
assertThat(fail.get()).isEqualTo(0);
assertThat(historyCount).isEqualTo(1);
assertThat(accountTransactionRepository.findByAccountIdAndTransactionRequestId(accountId, transactionRequestId)).isPresent();
}

5.2. 동시성 테스트
- 다수의 송금 요청이 동시에 발생해도 잔액 정합성이 깨지지 않고, 모든 요청이 정확히 한 번씩 반영되는지를 검증한다.
동시에 이체 요청 100건을 발생해 잔액이 각각 1,000,000원인 계좌 A에서 계좌 B로 10,000원을 송금한다.
모든 요청이 성공했을 때 계좌 A의 잔액은 0원, 계좌 B의 잔액은 2,000,000원이 되어야 한다.
@Test
@DisplayName("A에서 B로 100명이 동시에 1만원씩 송금하면 A는 0원, B는 200만원이 된다.")
void transfer_test() throws Exception {
int threadCount = 100;
long transferAmount = 10_000L;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
transferUseCase.execute(accountNoA, accountNoB, transferAmount, UUID.randomUUID().toString());
successCount.incrementAndGet();
} catch (Exception e) {
System.err.println("Transfer failed: " + e.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
Account finalA = accountRepository.findById(accountIdA).orElseThrow();
Account finalB = accountRepository.findById(accountIdB).orElseThrow();
assertThat(successCount.get()).isEqualTo(threadCount);
assertThat(finalA.getBalance()).isEqualTo(0L);
assertThat(finalB.getBalance()).isEqualTo(2_000_000L);
// 트랜잭션 기록은 출금/입금 쌍으로 총 200건이어야 함
assertThat(transactionRepository.count()).isEqualTo(threadCount * 2);
}

5.3 데드락 테스트
- A→B, B→A 교차 송금 시나리오를 통해 데드락 방지 로직을 검증한다.
계좌 A, B (잔액 1,000,000원)으로 A → B로 10,000원 송금 (50회), B → A로 10,000원 송금 (50회) 한다.
최종 잔액은 각각 1,000,000원(보낸 돈 50만, 받은 돈 50만)으로 유지되어야 한다.
@Test
@DisplayName("A→B와 B→A 송금이 동시에 100건 발생해도 데드락 없이 정합성이 유지된다.")
void deadlock_prevention_test() throws Exception {
// given
int threadCount = 100; // 총 100번의 송금 (A->B 50번, B->A 50번)
long transferAmount = 10_000L;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// when
for (int i = 0; i < threadCount; i++) {
boolean isAtoB = (i % 2 == 0); // 절반은 A->B, 절반은 B->A
String fromNo = isAtoB ? accountNoA : accountNoB;
String toNo = isAtoB ? accountNoB : accountNoA;
executorService.submit(() -> {
try {
// 각 요청마다 고유한 requestId(UUID) 생성
transferUseCase.execute(fromNo, toNo, transferAmount, UUID.randomUUID().toString());
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
System.err.println("Transfer failed: " + e.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
// then
Account finalA = accountRepository.findById(accountIdA).orElseThrow();
Account finalB = accountRepository.findById(accountIdB).orElseThrow();
// 1. 모든 요청이 성공했는지 확인 (데드락 발생 시 일부 타임아웃/실패 발생)
assertThat(successCount.get()).isEqualTo(threadCount);
assertThat(failCount.get()).isEqualTo(0);
// 2. 최종 잔액 검증
// A: 100만 - (50번 * 1만) + (50번 * 1만) = 100만
// B: 100만 - (50번 * 1만) + (50번 * 1만) = 100만
assertThat(finalA.getBalance()).isEqualTo(1_000_000L);
assertThat(finalB.getBalance()).isEqualTo(1_000_000L);
// 트랜잭션 기록은 출금/입금 각 100건이어야 함
assertThat(transactionRepository.countByAccountId(accountIdA)).isEqualTo(threadCount);
assertThat(transactionRepository.countByAccountId(accountIdB)).isEqualTo(threadCount);
}

아래의 Math.min, max 정렬 로직을 주석 처리한 뒤 테스트를 수행했을 때, 다음과 같은 에러 로그를 목격할 수 있었습니다.


6. 마치며
이번 송금 시스템을 구현하며 가장 크게 느낀 점은 기술은 단순한 도구일 뿐이고, 비즈니스의 성격과 우선순위에 따라 최선의 선택은 달라진다는 것입니다. 금융 서비스에서는 기능을 빠르게 완성하는 것보다 데이터 무결성을 끝까지 지켜내는 설계가 더 중요하다는 것을 다시 한 번 확인했습니다.
동시성 제어를 위해 비관적 락을 선택하고, 이체 시에는 락 순서를 고정해 데드락을 방지했으며, 영속성 컨텍스트로 인해 발생할 수 있는 갱신 유실 문제까지 직접 겪으며 락 획득 시점과 엔티티 로드 시점을 일치시키는 설계의 중요성을 체득했습니다. 또한 멱등성은 중복을 막는 것을 넘어, 여러 번 요청하더라도 항상 동일한 결과로 수렴하도록 만드는 UX까지 포함되어야 한다고 판단했고, 이를 위해 Redis 선차단과 DB 유니크 제약을 결합한 이중 방어 구조를 적용했습니다.
이 과정을 통해 설계 관점이 한층 더 성장한 기분이며, 앞으로는 Outbox 패턴, 타임아웃/백오프 정책, 장애·동시성 등 다양한 시나리오 테스트까지 확장해 보면서 운영 관점에서도 더 신뢰할 수 있는 구조로 발전시켜보려고 합니다.
'Backend' 카테고리의 다른 글
| 왜 변경이 잦은 컬럼에는 인덱스가 독이 될 수 있을까 (0) | 2026.01.29 |
|---|---|
| 비관적 락은 대기지옥, 낙관적 락은 재시도지옥이었다 (1) | 2026.01.09 |
| 1000만 건 테이블 13초를 0.2초로 줄인 페이징 성능 최적화 (0) | 2025.11.13 |
| AWS NAT 인스턴스와 NAT 게이트웨이 어떤 걸 써야 할까? (0) | 2025.11.05 |
| 아직도 DB에 JWT 저장하세요? (0) | 2025.10.29 |
- Total
- Today
- Yesterday
- Lower
- index
- find
- for
- combinations
- Python
- Method
- Lambda
- Built-in Functions
- bool
- isdigit
- counter
- If
- zip
- operators
- isalpha
- permutations
- Upper
- function
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |