개요
- 목적
- 앞서 충전 기능을 구현하면서 자연스럽게 출금 기능과 장부(내부 정산용)의 필요성이 느꼈다.
2025.11.29 - [Spring] - [Spring] 충전 기능 구현 with. TossPayments - 또한, 서비스 관리자는 내부 지갑의 돈의 흐름을 기록하고 이를 통해 검산이 가능해야 한다고 생각했다.
모든 충전/출금 내역을 기록해 두고,
" 총 충전액 = (모든) 유저 잔액 + 출금 완료 잔액 + 출금 대기 잔액"
이 성립해야 손실 없이 정상적으로 동작하다고 볼 수 있기 때문이다. - 따라서 모든 충전/출금 에 대한 기록을 남기고 그 기록을 기반으로 서비스 정합성 체크를 할 수 있는 장부를 만드는 것이 이번 구현의 목적이다.
- 앞서 충전 기능을 구현하면서 자연스럽게 출금 기능과 장부(내부 정산용)의 필요성이 느꼈다.
- 설계 고민
- 출금 수단 선택
- 충전은 토스페이먼츠를 API 를 사용했지만, 출금까지 PG로 처리하려면 대부분 사업자 등록, 실제 사용자 계좌, 정산용 출금 계좌 등록 등 실제 계좌들이 필요하다.
- 학습 및 프로젝트 단계에서는 실제 계좌를 사용하기엔 부담이 크고, 사업자 등록 번호도 없기 때문에
"사용자 출금 요청 → 관리자 실제 계좌 수동 이체" 구조로 설계했다. - 따라서 시스템은 출금 요청/승인/거절 관리 및 장부 처리에 집중하였다.
- 장부 방식 : 이중부기(double) vs 이벤트 단일 처리
- 장부 설계를 할 때 두 가지 접근을 고민하였다.
- 1) 이중부기 : 모든 거래를 차변/대변 두 군데에 기록 후 회사 전체 자산/부채 완벽하게 추적가능
단, 테이블 구조 및 코드 등 무거워지며 학습의 의도와 다르게 흘러갈 수 있다고 판단 - 2) 이벤트 단일 엔트리 방식 : 충전/출금/거절 등 각 이벤트를 한 줄씩 기록하는 방식
"총 충전액 = 유저 잔액+출금 완료 금액+출금 대기 잔액" 저럼 검산식으로 정합성 체크
따라서 이중부기에 비해 가볍고 간단한 검산식으로 충분하다고 판단하여 해당 방법으로 진행했다.
- 1) 이중부기 : 모든 거래를 차변/대변 두 군데에 기록 후 회사 전체 자산/부채 완벽하게 추적가능
- 장부 설계를 할 때 두 가지 접근을 고민하였다.
- 출금 수단 선택
- 설계 방법
- wallet (사용자 잔고), withdraw_request(출금 요청), wallet_ledger(입/출금 기록) 으로 도메인 모델을 정의했다.
- 출금 흐름
- 출금 요청 → (관리자) 출금 승인 or 거절
- 출금 요청
- 지갑 잔액에서 미리 요청한 만큼 차감
- withdraw_request (PENDING) 상태 등록
- wallet_leager 기록 저장
- 출금 승인 / 거절
- 승인 : withdraw_request (COMPLETED) 상태 변경
- 거절 : withdraw_request (REJECTED) 상태 변경 → withdraw_request(amount) 금액 유저 지갑에 추가
- 장부 흐름
- 총 충전액 : wallet_ledger (TOPUP_CARD, TOPUP,ACCOUNT 상태의 ) 금액 총 합
- 검증 합 : wallet(모든 유저 잔액) + withdraw_request(출금 금액) + withdraw_request(출금 대기 금액)
- 따라서 총 충전액 = 검증 합 이 맞아야 장부에 이상이 없음
구현

- Client
- 출금 요청
- Controller
- Session 확인
- WithdrawRequestCreateDto ( 금액, 은행코드, 은행계좌, 이름 ) 매핑
- withdrawService.createWithdrawRequest( sessionUser.id(), dto) 호출
- Service
- 출금 금액 확인
- 유저 조회
- 잔액 조회
- 잔액 검증 (현재 잔액 - 출금잔액 > 0) 및 잔액 차감
- 장부 기록 (wallet_ledger) 저장
- 출금 요청 (walletdraw_request) 생성 및 저장

- admim
- 출금 승인
- 출금 거절
- 장부 조회
- Controller
- 출금 승인
- withdrawAdminService.approve(sessionUser.id()) 호출
- 출금 거절
- withdrawAdminService.reject(sessionUser.id()) 호출
- 장부 조회
- withdrawAdminService.getLedgerSummary(sessionUser.id()) 호출
- 출금 승인
- Service
- getAdminOrThrow
- "admin".equals (sessionUserId.getRole()) 로 관리자 권한 확인
- 출금 승인
- 관리자 권한 체크 getAdminOrThrow() 호출
- 출금 요청 조회
- 상태 조회 (PENDING)
- 상태 변경 (COMPLETED)
- 시간 저장 (LocalDateTime.now)
- 승인 admin ID 저장
- 출금 거절
- 관리자 권한 체크 getAdminOrThrow() 호출
- 출금 요청 조회
- 상태 조회 (PENDING)
- 출금 요청한 유저 지갑 조회
- 지갑 금액 + 거절한 금액 더해주기
- 장부 기록 (유저, 거절, 금액, 수수료, 거절 후 금액, 사유)
- 상태 변경 (REJECTED)
- 장부 조회
- 관리자 권한 체크 getAdminOrThrow() 호출
- 충전 타입 (카드, 계좌) 기준으로 총 충전액 및 건수 조회
- 출금 완료 및 건수 조회
- 출금 대기 및 건수 조회
- 유저 잔액 총 합 조회
- 충전액 - ( 출금 완료 + 출금 대기 + 유저 잔액 ) 검산식 기준으로 diff (정합성 체크) 계산
- DTO (충전 금액, 충전 개수, 출금 완료, 완료 개수 .... 등 ) 반환
- getAdminOrThrow
트러블슈팅
- 상황 1. 기존 유저에 role 필드 추가 후 세션/OAuth2 연동 문제
- 초기 DB설계에는 role 컬럼이 없었다.
- 이후 권한 관리 ( "admin", "user" ) 를 위해 user 테이블과 User엔티티에 role 필드를 추가했다.
- 프론트엔드에서 /api/me 결과로 관리자의 여부를 판단하고 관리자 버튼 노출, 관리자 페이지 접근등을 처리하려면 세션에도 role 함께 들어 있어야 된다고 판단했다.
- 문제 분석
- OAuth2User oAuth2User = super.loadUser(userRequest);
Map<String, Object> attributes = oAuth2User.getAttributes();
여기서 바로 attributes.put("role",user.getRole()); 를 통해 추가하고 싶었다. - 하지만 문제는
- getAttributes()가 반환하는 Map은 불변(immutable)인 경우가 많고 해당 상태에서 put을 호출 하면
→ java.lang.UnsupportedOperationException 발생 가능성이 높았다. - 예외가 나지 않더라고 최종 principal은 return new DefaultOAuth2user(...)에서 내가 어떤 Map으로
넘기느냐로 결정 되기 때문에 getAttributes()를 건드리는 것만으로는 보장이 되지 않는다.
- getAttributes()가 반환하는 Map은 불변(immutable)인 경우가 많고 해당 상태에서 put을 호출 하면
- 즉, 원본 Map에 직접 put() 하는 건 구조상 그리고 안전성 면에서도 좋은 선택이 아니라는 것을 알았다.
- OAuth2User oAuth2User = super.loadUser(userRequest);
- 해결 방법
- 새로운 Map으로 복사 후 role 추가
- Map<String, Object> attributes = oAuth2User.getAttributes();
Map<String, Object> newAttrs = new HashMap<>(attributes); 처럼 복사본 생성
그후 newAttrs.put("role",user.getRole()); 를 통해 Role을 넣어 주었다.
따라서 원본인 attributes는 건드리지않고 새로운 newAttrs 로 필요한 "role"를 넣어 주었다.
- Map<String, Object> attributes = oAuth2User.getAttributes();
- 새로 만든 newAttrs 로 최종 principal 을 생성했다.
return new DefaultOAuthUser( newAttrs, ....) 처럼 스프링 시큐리티에 올라가는 최종 principal의 "role" 를 추가할 수 있었다.
- 새로운 Map으로 복사 후 role 추가
- 결과
- 기존 DB에는 role이 없었지만, DB 마이그레이션 + 엔티티 기본값 (@PrePersist)으로 role 의 기본값을 "user" 로 채워 넣었다.
- OAuth2 로그인 과정에서 Service에서 새로운 Map을 만들어 role을 포함시킨 뒤 principal을 생성함으로 role 정보도 함께 저장 가능하게 되었다.
- 이제 principal에서 필요한 정보들만 꺼내어 Session에 올려둠으로써 다른 Service에서도 사용가능하게끔 구현했다.
- 상황 2. 출금 , 잔액 동시성 문제를 막기 위한 비관적 락(pessimistic lock) 설계
- 서비스에서 하나의 지갑에 대해
1) 사용자의 출금 요청
2) 관리자의 출금 승인/거절
3) 충전 처리
들이 동시에 들어올 수 있는 구조였다. 즉, 같은 유저의 지갑 잔액을 여러 트랜잭션이 동시에 수정할 가능성이 존재했다.
- 서비스에서 하나의 지갑에 대해
- 문제 분석
- 초기에는 단순하게 findById() 로 지갑을 조회 한 후
Wallet wallet = walletRepository.findById(user)
wallet.setBalance(wallet.getBalance() - amount) 처럼 사용 하려고 했다.
하지만 이 방법은 다음과 같은 동시성 문제를 일으킬 수 있다.
1) 두 요청이 동시에 같은 시점의 잔액 (ex: 10,000원)을 읽는다.
2) 각각 10,000 - 7,000 = 3,000원으로 계산 후 저장한다.
3) 최종 잔액은 3,000원이 되지만, 실제로는 14,000원이 빠져나간 셈이라 데이터가 꼬인다. - 따라서 한 트랜잭션이 잔액을 변경하는 동안 다른 트랜잭션도 같은 잔액 기준으로 계산하는 상황을 막지 못한다는 점이 문제였다.
돈이 실제로 걸려 있는 서비스에서는 이런 동시성 버그가 있다면 치명적이라고 판단했다.
- 초기에는 단순하게 findById() 로 지갑을 조회 한 후
- 해결 방법
- 비관적 락(pessimistic lock) 기반 조회 메서드 도입
기존 findById() 대신 DB레벨에서는 락을 거는 전용 메서드를 설계했다.
- 비관적 락(pessimistic lock) 기반 조회 메서드 도입
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select w from Wallet w where w.user.user_id = :userId")
Optional<Wallet> findByUserIdForUpdate(@Param("userId") Long userId);
- 해당하는 로우에 비관적 락을 걸어 하나의 트랜잭션이 처리되는 동안 다른 트랜잭션이 같은 출금 요청/지갑을 수정하지 못하게 했다.
또한 출금 요청 생성, 출금 거절 시 잔액 복구 등 돈이 실제로 변경되는 해당 구간을 모두 @Transactional 로 묶었다. - 따라서 조회 → 검증 → 잔액 변경 → 장부 기록이 하나의 작업 단위로 처리 되도록 구현했다.
이렇게 동시에 처리가 들어오더라도 동시성 버그가 발생하는 가능성을 차단할 수 있었다.
결과




'Spring' 카테고리의 다른 글
| [Spring] CaffeineCache TTL 설정 오류로 인한 NPE 발생 사례 (0) | 2026.04.29 |
|---|---|
| [Spring] 예외 처리 종류 & 상태 코드 (0) | 2025.12.28 |
| [Spring] 충전 기능 구현 with. TossPayments (0) | 2025.11.29 |
| [Spring] 웹소켓(STOMP) 을 이용한 채팅 구현 (0) | 2025.09.04 |
| [Spring] 카카오 로그인 API 연결 해보기 (0) | 2025.04.12 |