본 프로젝트는 카카오페이 지연이체 서비스 구조를 단순화하여, Java 멀티스레드 환경에서 발생할 수 있는 동기화 문제와 그에 대한 해결 전략을 구현한 프로젝트입니다.
🔗 참고 링크: KakaoPay Tech Blog: 지연이체 서비스 개발기: 은행 점검 시간 끝나면 송금해 드릴게요! (feat. 발표 후기)
Main (시나리오 생성)
↓
TransferCreator (거래 생성 + Repository 저장)
↓
TransferRepository (DB 역할)
↓
TransferScheduler (주기적 실행 대상 스캔, 상태 변경(DELAYED -> PREPARING) 후 Queue 등록)
↓
TransferQueue (BlockingQueue)
↓
TransferConsumer (멀티 스레드 실행, 상태 변경(PREPARING -> DONE))
↓
UserLockManager (userId 단위 락 획득/해제)
본 프로젝트는 Producer–Consumer 패턴을 기반으로 구성되었다.
-
Scheduler Thread 1개
- 일정 주기(200ms)로 실행 가능한 이체(
지금시간 >= bankOpenAt)를 조회 - 상태 변경 후 Queue에 등록
- 일정 주기(200ms)로 실행 가능한 이체(
-
Consumer Thread 2개
- Queue에서 작업을 가져와 실행
- 동시에 여러 이체를 처리
- 동일 userId는 Lock으로 동시 실행 방지
| 스레드 | 역할 |
|---|---|
| Scheduler | 실행 대상 탐색 및 Queue 등록 |
| Consumer-1 | Queue에서 작업 실행 |
| Consumer-2 | Queue에서 작업 실행 |
- 유저 A: 3건
- 유저 B: 1건
- 유저 C: 1건
- 은행 점검 종료: 2초 후
은행 점검 종료 시간(bankOpenAt)은 요청 생성 시점 + 2초로 설정된다.
- 사용자들이 지연 이체 요청을 등록한다.
- 모든 요청은 DELAYED 상태로 TransferRepository에 저장된다.
- 스케줄러는 0.2초마다 반복 실행되며,
now >= bankOpenAt인 이체를 찾는다. - 실행 가능한 이체를 찾으면, 해당 이체의 상태를 DELAYED -> PREPARING으로 바꾼 뒤 Queue에 넣는다.
- Consumer 2개가 동시에 실행을 시작한다.
- 동일 userId의 이체는 Lock을 통해 동시에 실행되지 않도록 제어한다.
- 실행이 완료되면 상태를
DONE으로 변경하고 Lock을 해제한다.
DELAYED
↓ (스케줄러가 실행 대상으로 선택)
PREPARING
↓ (컨슈머 실행 완료)
DONE
| 상태 | 의미 |
|---|---|
| DELAYED | 은행 점검이 끝나기를 기다리는 상태 |
| PREPARING | 실행 대기열에 등록된 상태 |
| DONE | 이체 완료 상태 |
Consumer는 여러 스레드로 실행되며 동시에 Queue에서 이체 요청을 가져온다.
이때 Queue가 thread-safe하지 않은 자료구조(예: ArrayList, LinkedList)였다면, 다음과 같은 문제가 발생할 수 있다.
Consumer-1: userB의 거래 조회 -> status == PREPARING 확인
Consumer-2: userB의 거래 조회 -> status == PREPARING 확인
두 스레드가 동시에 같은 거래를 확인하고 실행하면 같은 이체가 두 번 실행될 수 있다. (중복 송금 위험)
이 시스템은 Consumer를 2개 이상 실행하여 Queue에 들어온 이체 요청을 병렬 처리한다.
이때 Queue에는 서로 다른 유저 요청뿐 아니라, 같은 유저(userId)의 요청이 여러 개 들어올 수 있다.
userA: 3건
userB: 1건
userC: 1건
userA의 거래가 다음과 같이 3건 있다고 가정하자.
'가', '나', '다' (모두 PREPARING)
이 경우 다음과 같은 상황이 발생할 수 있다.
- Consumer-1이 userA의 '가' 이체를 처리 중
- Consumer-2가 동시에 userA의 '나' 이체를 꺼내 처리 시작
즉, 같은 유저의 이체가 동시에 처리될 수 있다.
예를 들어 userA 잔액이 1000원이고 700원 출금 이체 2건이 동시에 실행되면, 두 Consumer가 모두 잔액 1000원을 기준으로 검증을 통과할 수 있다.
이 경우 잔액이 음수가 되거나, 업데이트가 덮어써져 이체 1건이 유실되는 등 데이터 정합성이 깨질 수 있다.
LinkedBlockingQueuetake()는 하나의 Consumer만 가져갈 수 있음- 하나의 Queue 원소는 하나의 스레드만 처리
lockManager.tryLock(userId);- userId를 기준으로 Lock을 획득
- 동일 userId의 이체는 동시에 실행되지 않도록 제어
ReentrantLock기반- 한 유저의 이체는 순차적으로 처리
| 제어 단계 | 목적 |
|---|---|
| BlockingQueue | 작업 분배 보호 |
| ReentrantLock | 동일 사용자 동시 실행 방지 |
- Java 17
-
GitHub Releases에서
delayed-transfer-service.jar다운로드 -
실행
java -jar delayed-transfer-service.jar또는 .jar 없이 IDE에서 Main 클래스 실행
| 실제 카카오페이 | 본 프로젝트 |
|---|---|
| Kafka | BlockingQueue |
| Consumer 3대 | Consumer 2대 |
| User Lock | ReentrantLock |
| DB | ConcurrentHashMap |
| 스케줄러 | Thread 기반 반복 실행 |
박주호 |
이유림 |
이채은 |
하은영 |
feat/[기능명]
예시:
feat/user-lock
feat/delayed-transfer
새로운 기능 추가
ex) feat: 지연 이체 스케줄러 구현
코드 리팩토링
ex) refactor: Transfer 상태 변경 로직 개선
버그 수정
ex) bug: interrupt 처리 누락으로 인한 스레드 종료 문제 수정
문서 수정
ex) docs: README 업데이트
테스트 코드 추가/수정
ex) test: UserLockManager 동시성 테스트 추가
빌드 설정 변경
ex) build: jar 실행 설정 추가
CI 설정 변경
ex) ci: GitHub Actions 설정 추가
기타 변경
ex) chore: 불필요한 로그 제거
코드 스타일 변경
ex) style: 코드 포맷팅 적용