Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
382ad0a
feat: 송금 요청 만료 기능
donggi-lee-bit Apr 6, 2025
25d0a89
refactor: 송금 요청 검증 메서드 분리
donggi-lee-bit Apr 7, 2025
6e33f1b
refactor: 출금 대기 금액 취소 로직을 도메인 서비스로 캡슐화
donggi-lee-bit Apr 7, 2025
f2aa57f
feat: 송금 요청 만료 시 송금자의 출금 대기 금액 롤백 처리
donggi-lee-bit Apr 7, 2025
e4336a4
feat: 송금 요청 만료 시간을 계산하여 관리하는 필드 추가
donggi-lee-bit Apr 8, 2025
fd80157
refactor: 송금 요청 만료 처리 메서드 내에서 송금자의 송금 대기 금액 롤백 처리
donggi-lee-bit Apr 8, 2025
a059aa9
feat: 송금 요청 만료 갱신을 위한 ExpirationQueueManager 추가
donggi-lee-bit Apr 8, 2025
28c7245
feat: 송금 요청 생성 시 만료 갱신을 위해 송금 요청을 큐에 등록
donggi-lee-bit Apr 8, 2025
ed56581
feat: 만료된 송금 요청을 큐에서 가져와서 만료 갱신
donggi-lee-bit Apr 8, 2025
3f8a236
feat: 서비스 재시작 시 아직 처리되지 않은 요청들을 조회하여 만료 갱신 큐에 등록
donggi-lee-bit Apr 8, 2025
f78de68
refactor: 만료 시각을 송금 요청이 생성되는 시점에 확정되도록 생성 방식 변경
donggi-lee-bit Apr 10, 2025
abd9645
remove: DelayQueue 기반 만료 처리 로직 제거
donggi-lee-bit Apr 11, 2025
06ae7fc
feat: 잔액 조회 시 만료된 요청이 존재할 경우 해당 요청을 만료 처리
donggi-lee-bit Apr 24, 2025
4458744
feat: 비동기 처리용 스레드풀 추가
donggi-lee-bit Apr 24, 2025
fd51ec6
feat: 기본 스레드풀 정의
donggi-lee-bit Apr 24, 2025
cc3843c
feat: 만료된 요청 조회 기능
donggi-lee-bit Apr 24, 2025
5542170
feat: 송금 요청 만료 상태로 일괄 갱신
donggi-lee-bit Apr 24, 2025
93da03b
feat: 송금 요청 상태 변경 이력 일괄 저장
donggi-lee-bit Apr 24, 2025
8a378d1
feat: 네임드락 실행 템플릿
donggi-lee-bit May 6, 2025
31a3dba
feat: 네임드락 정책을 관리하는 enum 클래스
donggi-lee-bit May 6, 2025
391c921
feat: 송금 요청 만료 등으로 인한 계좌의 송금 대기 금액 롤백
donggi-lee-bit May 6, 2025
1aa9a1d
test: Account 도메인의 롤백 검증 로직 추가에 따른 테스트 환경 보완
donggi-lee-bit May 6, 2025
06a7a1b
feat: 송금 요청 만료 일괄 처리 기능 추가
donggi-lee-bit May 6, 2025
144cbff
refactor: 만료 처리 로직 개선
donggi-lee-bit May 6, 2025
5353361
feat: 송금 요청 만료 처리를 위한 스케줄러 로직 추가
donggi-lee-bit May 6, 2025
1dc05c6
test: 만료 요청 배치 갱신 로직 테스트 추가
donggi-lee-bit May 6, 2025
85929c4
refactor: 상태별 정적 팩토리 메서드 도입하여 RemittanceStatusHistory 생성 로직 개선
donggi-lee-bit May 6, 2025
7629744
test: TestContainer로 MySQL DB를 실행시켜 네임드락 경합 테스트 추가
donggi-lee-bit May 6, 2025
ca3e030
test: SpringBoot 실행이 필요한 테스트에 커스텀 어노테이션 IntegrationTest 을 사용하도록 변경
donggi-lee-bit May 7, 2025
dc319c9
test: 네임드락 경합 테스트에서 SpyBean 없이 동작하도록 테스트 구조 변경
donggi-lee-bit May 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ dependencies {

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.2'
testImplementation 'org.testcontainers:mysql'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'io.rest-assured:rest-assured'
testImplementation 'io.rest-assured:json-path'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testRuntimeOnly 'com.h2database:h2'
}

jib {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.donggi.sendzy.account.application;

import com.donggi.sendzy.account.domain.AccountService;
import com.donggi.sendzy.account.dto.AccountBalanceResponse;
import com.donggi.sendzy.remittance.application.RemittanceExpirationService;
import com.donggi.sendzy.remittance.domain.RemittanceRequest;
import com.donggi.sendzy.remittance.domain.repository.RemittanceRequestRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class AccountBalanceQueryService {

private final AccountService accountService;
private final RemittanceRequestRepository remittanceRequestRepository;
private final RemittanceExpirationService remittanceExpirationService;

@Transactional(readOnly = true)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 메소드에서는 만료된 요청을 갱신하는 DML작업이 포함되어있으므로 readOnly옵션을 적용하시면 예외가 발생할 수 있습니다~

public AccountBalanceResponse getBalanceWithRequestExpiredCheck(final long memberId) {
final var account = accountService.getByMemberId(memberId);
final var now = LocalDateTime.now();
final List<RemittanceRequest> pendingRequests = remittanceRequestRepository.findPendingRequestsBySenderId(memberId);

final Map<Boolean, List<RemittanceRequest>> requestsByExpiration = pendingRequests.stream()
.collect(Collectors.partitioningBy(r -> r.isExpired(now)));
final var expiredRequests = requestsByExpiration.get(true);
final var activeRequests = requestsByExpiration.get(false);

// 만료된 요청 갱신
expiredRequests.forEach(remittanceExpirationService::expireRequest);

return AccountBalanceResponse.from(account, activeRequests);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.donggi.sendzy.account.controller;

import com.donggi.sendzy.account.domain.AccountService;
import com.donggi.sendzy.account.application.AccountBalanceQueryService;
import com.donggi.sendzy.account.dto.AccountBalanceResponse;
import com.donggi.sendzy.common.security.CustomUserDetails;
import lombok.RequiredArgsConstructor;
Expand All @@ -14,11 +14,10 @@
@RestController
public class AccountRestController {

private final AccountService accountService;
private final AccountBalanceQueryService accountBalanceQueryService;

@GetMapping("/balance")
public AccountBalanceResponse getBalance(@AuthenticationPrincipal CustomUserDetails userDetails) {
final var balance = accountService.getByMemberId(userDetails.getMemberId()).getBalance();
return new AccountBalanceResponse(balance);
public AccountBalanceResponse getBalance(@AuthenticationPrincipal final CustomUserDetails userDetails) {
return accountBalanceQueryService.getBalanceWithRequestExpiredCheck(userDetails.getMemberId());
}
}
10 changes: 10 additions & 0 deletions src/main/java/com/donggi/sendzy/account/domain/Account.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.donggi.sendzy.account.domain;

import com.donggi.sendzy.account.exception.InsufficientPendingAmountException;
import com.donggi.sendzy.account.exception.InvalidRollbackAmountException;
import com.donggi.sendzy.account.exception.InvalidWithdrawalException;
import com.donggi.sendzy.common.utils.Validator;
import lombok.AccessLevel;
Expand Down Expand Up @@ -33,6 +35,14 @@ public void commitWithdraw(final long amount) {
}

public void cancelWithdraw(final long amount) {
if (amount <= 0) {
throw new InvalidRollbackAmountException(amount);
}

if (pendingAmount < amount) {
throw new InsufficientPendingAmountException(pendingAmount, amount);
}

this.pendingAmount -= amount;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.donggi.sendzy.account.domain;

import java.util.List;
import java.util.Optional;
import java.util.Set;

public interface AccountRepository {

Expand Down Expand Up @@ -30,4 +32,19 @@ public interface AccountRepository {
* @param account 업데이트할 계좌
*/
void update(final Account account);

/**
* 계좌 목록을 일괄 업데이트합니다.
*
* @param accounts 업데이트할 계좌 목록
*/
void bulkUpdate(final List<Account> accounts);

/**
* 회원 ID 목록에 해당하는 계좌 목록을 조회합니다.
*
* @param senderIds 회원 ID 목록
* @return 조회된 계좌 목록
*/
List<Account> findAllByMemberIdIn(final Set<Long> senderIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class AccountService {
Expand Down Expand Up @@ -38,7 +44,40 @@ public void transfer(final Account sender, final Account receiver, final long am
}

@Transactional
public void update(final Account account) {
public void cancelWithdraw(final Account account, final long amount) {
account.cancelWithdraw(amount);
accountRepository.update(account);
}

@Transactional
public void rollbackHoldAmounts(final List<RollbackTarget> rollbackTargets) {
// 롤백 대상 송금자 ID에 해당하는 계좌 정보를 조회하고 ID 기준으로 매핑
final Map<Long, Account> accounts = loadAccounts(rollbackTargets);

// 각 롤백 대상에 대해 계좌의 출금 대기 금액을 롤백 처리
applyRollbacks(accounts, rollbackTargets);

// 롤백 처리된 계좌들을 일괄 업데이트하여 DB에 반영
accountRepository.bulkUpdate(accounts.values().stream().toList());
}

private Map<Long, Account> loadAccounts(final List<RollbackTarget> targets) {
final Set<Long> senderIds = targets.stream()
.map(RollbackTarget::senderId)
.collect(Collectors.toSet());

final List<Account> accountList = accountRepository.findAllByMemberIdIn(senderIds);
return accountList.stream()
.collect(Collectors.toMap(Account::getMemberId, Function.identity()));
}

private void applyRollbacks(final Map<Long, Account> accountMap, final List<RollbackTarget> targets) {
for (RollbackTarget target : targets) {
final Account account = accountMap.get(target.senderId());
if (account == null) {
throw new AccountNotFoundException(target.senderId());
}
account.cancelWithdraw(target.amount());
}
}
}
10 changes: 10 additions & 0 deletions src/main/java/com/donggi/sendzy/account/domain/RollbackTarget.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.donggi.sendzy.account.domain;

/**
* 송금 요청 만료 등으로 인해 계좌의 송금 대기 금액을 롤백할 때 사용되는 도메인 객체
*
* @param senderId 송금자 계좌 ID
* @param amount 롤백할 송금 대기 금액
*/
public record RollbackTarget(long senderId, long amount) {
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,36 @@
package com.donggi.sendzy.account.dto;

public record AccountBalanceResponse(long balance) {
import com.donggi.sendzy.account.domain.Account;
import com.donggi.sendzy.remittance.domain.RemittanceRequest;

import java.util.List;

public record AccountBalanceResponse(
long balance,
long holdAmount,
long availableAmount,
List<HoldDetail> holdDetails
) {
/**
* 잔액 조회 응답 객체
* @param account 계좌 정보
* @param activeRequests 진행중인 송금 요청 리스트
* @return AccountBalanceResponse
*/
public static AccountBalanceResponse from(final Account account, final List<RemittanceRequest> activeRequests) {
final var holdAmount = activeRequests.stream()
.mapToLong(RemittanceRequest::getAmount)
.sum();

final var availableAmount = account.getBalance() - holdAmount;

final List<HoldDetail> holdDetails = activeRequests.stream()
.map(request -> new HoldDetail(request.getReceiverId(), request.getAmount()))
.toList();

return new AccountBalanceResponse(account.getBalance(), holdAmount, availableAmount, holdDetails);
}

private record HoldDetail(long receiverId, long amount) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.donggi.sendzy.account.exception;

public class InsufficientPendingAmountException extends RuntimeException {
public InsufficientPendingAmountException(final long pendingAmount, final long rollbackAmount) {
super("롤백할 수 있는 대기 금액이 부족합니다. 현재 대기 금액: " +
pendingAmount + ", 롤백 시도 금액: " + rollbackAmount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.donggi.sendzy.account.exception;

public class InvalidRollbackAmountException extends RuntimeException {
public InvalidRollbackAmountException(final long amount) {
super("롤백 금액은 0보다 커야 합니다. 입력된 금액: " + amount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import com.donggi.sendzy.account.domain.TestAccountRepository;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;
import java.util.Optional;
import java.util.Set;

@Mapper
public interface AccountMapper extends AccountRepository, TestAccountRepository {
Expand All @@ -18,4 +20,6 @@ public interface AccountMapper extends AccountRepository, TestAccountRepository
Optional<Account> findByMemberId(final long memberId);

Optional<Account> findByMemberIdForUpdate(final long memberId);

List<Account> findAllByMemberIdIn(final Set<Long> senderIds);
}
58 changes: 58 additions & 0 deletions src/main/java/com/donggi/sendzy/common/async/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.donggi.sendzy.common.async;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.Arrays;
import java.util.concurrent.Executor;

@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);

@Bean("remittanceExpireExecutor")
public ThreadPoolTaskExecutor remittanceExpireExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();

taskExecutor.setCorePoolSize(4); // 항상 살아있을 스레드
taskExecutor.setMaxPoolSize(16); // 최대 확장
taskExecutor.setQueueCapacity(5_000); // 대기 큐

taskExecutor.setThreadNamePrefix("expire-");
taskExecutor.setAwaitTerminationSeconds(30);
taskExecutor.setWaitForTasksToCompleteOnShutdown(true);

taskExecutor.initialize();
return taskExecutor;
}

@Bean("defaultTaskExecutor")
public ThreadPoolTaskExecutor defaultTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(8);
taskExecutor.setMaxPoolSize(32);
taskExecutor.setQueueCapacity(10_000);
taskExecutor.setThreadNamePrefix("async-");
taskExecutor.initialize();
return taskExecutor;
}

@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("[async] {}({}) 실패", method.getName(), Arrays.toString(params), ex);
}

@Override
public Executor getAsyncExecutor() {
return defaultTaskExecutor();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.donggi.sendzy.account.exception.InvalidWithdrawalException;
import com.donggi.sendzy.member.exception.EmailDuplicatedException;
import com.donggi.sendzy.member.exception.InvalidPasswordException;
import com.donggi.sendzy.remittance.exception.ExpiredRemittanceRequestException;
import com.donggi.sendzy.remittance.exception.InvalidRemittanceRequestStatusException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
Expand Down Expand Up @@ -93,4 +94,12 @@ public ProblemDetail handleBadRequestException(final BadRequestException e) {
public ProblemDetail handleInvalidRemittanceRequestStatusException(final InvalidRemittanceRequestStatusException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}

/**
* 송금 요청이 만료된 경우
*/
@ExceptionHandler(ExpiredRemittanceRequestException.class)
public ProblemDetail handleExpiredRemittanceRequestException(final ExpiredRemittanceRequestException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.donggi.sendzy.common.lock;

import com.donggi.sendzy.common.exception.BusinessException;

public class NamedLockAcquisitionException extends RuntimeException {

public NamedLockAcquisitionException(final String lockName) {
super("Lock 획득 실패: " + lockName);
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/donggi/sendzy/common/lock/NamedLockMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.donggi.sendzy.common.lock;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface NamedLockMapper {

int getLock(@Param("lockName") final String lockName, @Param("timeout") final int timeout);

int releaseLock(@Param("lockName") final String lockName);
}
23 changes: 23 additions & 0 deletions src/main/java/com/donggi/sendzy/common/lock/NamedLockPolicy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.donggi.sendzy.common.lock;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

/**
* 네임드 락의 식별자(key)와 타임아웃 설정을 정의합니다.
*
* - 락 이름은 "도메인:의도:세부내용" 형식으로 구성하여 명확한 의미 전달과 충돌 방지를 유도합니다.
* - 타임아웃 값은 락 점유 대기 시간(seconds)으로, 상황에 따라 설정할 수 있습니다.
*
* 공통 락 정의를 enum으로 관리함으로써, 일관성 있는 락 네이밍과 정책 적용을 지원합니다.
*/
@Getter
@RequiredArgsConstructor
public enum NamedLockPolicy {

REMITTANCE_EXPIRE_BATCH("remittance:expiration:batch", 0),
;

private final String key;
private final int timeout;
}
Loading
Loading