Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ 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);
}
}
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,64 @@
package com.donggi.sendzy.remittance.application;

import com.donggi.sendzy.account.application.AccountLockingService;
import com.donggi.sendzy.account.domain.AccountService;
import com.donggi.sendzy.remittance.domain.RemittanceRequest;
import com.donggi.sendzy.remittance.domain.RemittanceRequestStatus;
import com.donggi.sendzy.remittance.domain.RemittanceStatusHistory;
import com.donggi.sendzy.remittance.domain.service.RemittanceRequestService;
import com.donggi.sendzy.remittance.domain.service.RemittanceStatusHistoryService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class RemittanceExpirationService {

private final RemittanceRequestService remittanceRequestService;
private final RemittanceStatusHistoryService remittanceStatusHistoryService;
private final AccountLockingService accountLockingService;
private final AccountService accountService;

/**
* 송금 요청 만료 처리 (REQUIRES_NEW 트랜잭션)
* <p>
* 송금 요청 수락/거절 흐름 중 만료 상태로 전환해야 하는 경우,
* 예외 발생 여부와 관계없이 만료 처리와 히스토리 기록이 DB에 반영되어야 하므로
* 별도의 트랜잭션으로 분리해 처리합니다.
* <p>
* - 기존 트랜잭션이 롤백되더라도, 해당 메서드는 독립적으로 커밋됩니다.
* - Propagation.REQUIRES_NEW 설정을 통해 트랜잭션을 분리합니다.
*
* @param remittanceRequest 만료 처리할 송금 요청
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void expireRequest(final RemittanceRequest remittanceRequest) {
// 송금 요청 상태 변경 → EXPIRED
remittanceRequestService.expire(remittanceRequest);

// 송금자 계좌 롤백 처리
rollbackHoldAmount(remittanceRequest.getSenderId(), remittanceRequest.getAmount());

// 히스토리 저장
recordStatusHistory(remittanceRequest);
}

private void recordStatusHistory(final RemittanceRequest request) {
remittanceStatusHistoryService.recordStatusHistory(
new RemittanceStatusHistory(
request.getId(),
request.getSenderId(),
request.getReceiverId(),
request.getAmount(),
RemittanceRequestStatus.EXPIRED
)
);
}

private void rollbackHoldAmount(final long senderId, final long amount) {
final var senderAccount = accountLockingService.getByMemberIdForUpdate(senderId);
accountService.cancelWithdraw(senderAccount, amount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.donggi.sendzy.remittance.domain.service.RemittanceHistoryService;
import com.donggi.sendzy.remittance.domain.service.RemittanceRequestService;
import com.donggi.sendzy.remittance.domain.service.RemittanceStatusHistoryService;
import com.donggi.sendzy.remittance.infrastructure.expiration.ExpirationQueueManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -22,11 +23,12 @@
@RequiredArgsConstructor
public class RemittanceRequestApplicationService {

private final AccountService accountService;
private final AccountLockingService accountLockingService;
private final RemittanceHistoryService remittanceHistoryService;
private final RemittanceRequestService remittanceRequestService;
private final RemittanceHistoryService remittanceHistoryService;
private final RemittanceStatusHistoryService remittanceStatusHistoryService;
private final ExpirationQueueManager expirationQueueManager;
private final AccountService accountService;
private final AccountLockingService accountLockingService;
private final MemberService memberService;

/**
Expand Down Expand Up @@ -80,18 +82,13 @@ private void recordRemittanceStatusHistory(final Member sender, final Member rec
));
}

private Long recordRemittanceRequest(final Member sender, final Member receiver, final Long amount) {
return remittanceRequestService.recordRequestAndGetId(
new RemittanceRequest(
sender.getId(),
receiver.getId(),
RemittanceRequestStatus.PENDING,
amount
)
);
private long recordRemittanceRequest(final Member sender, final Member receiver, final Long amount) {
final var request = new RemittanceRequest(sender.getId(), receiver.getId(), RemittanceRequestStatus.PENDING, amount);
expirationQueueManager.register(request);
return remittanceRequestService.recordRequestAndGetId(request);
}

private Long recordRemittanceHistory(final Member sender, final Account senderAccount, final Long amount) {
private long recordRemittanceHistory(final Member sender, final Account senderAccount, final Long amount) {
return remittanceHistoryService.recordHistoryAndGetId(
new RemittanceHistory(
null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,23 @@
import com.donggi.sendzy.remittance.domain.RemittanceStatusHistory;
import com.donggi.sendzy.remittance.domain.service.RemittanceRequestService;
import com.donggi.sendzy.remittance.domain.service.RemittanceStatusHistoryService;
import com.donggi.sendzy.remittance.exception.ExpiredRemittanceRequestException;
import com.donggi.sendzy.remittance.exception.InvalidRemittanceRequestStatusException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@RequiredArgsConstructor
@Service
public class RemittanceRequestProcessor {

private final RemittanceRequestService remittanceRequestService;
private final AccountLockingService accountLockingService;
private final RemittanceStatusHistoryService remittanceStatusHistoryService;
private final RemittanceExpirationService remittanceExpirationService;
private final AccountLockingService accountLockingService;
private final AccountService accountService;

@Transactional
Expand All @@ -41,7 +45,7 @@ public void handleAcceptance(final long requestId, final long receiverId) {
remittanceRequestService.accept(remittanceRequest);

// 상태 변경 히스토리 저장
recordStatus(remittanceRequest, RemittanceRequestStatus.ACCEPTED);
recordStatusHistory(remittanceRequest, RemittanceRequestStatus.ACCEPTED);
}

@Transactional
Expand All @@ -53,28 +57,46 @@ public void handleRejection(final long requestId, final long receiverId) {
validateReceiverAuthorityAndStatus(remittanceRequest, receiverId);

// 송금자 계좌 롤백 처리
final var senderAccount = accountLockingService.getByMemberIdForUpdate(remittanceRequest.getSenderId());
senderAccount.cancelWithdraw(remittanceRequest.getAmount());
accountService.update(senderAccount);
rollbackHoldAmount(remittanceRequest.getSenderId(), remittanceRequest.getAmount());

// 송금 요청 상태 변경 → REJECTED
remittanceRequestService.reject(remittanceRequest);

// 상태 변경 히스토리 저장
recordStatus(remittanceRequest, RemittanceRequestStatus.REJECTED);
recordStatusHistory(remittanceRequest, RemittanceRequestStatus.REJECTED);
}

private void rollbackHoldAmount(final long senderId, final long amount) {
final var senderAccount = accountLockingService.getByMemberIdForUpdate(senderId);
accountService.cancelWithdraw(senderAccount, amount);
}

private void validateReceiverAuthorityAndStatus(final RemittanceRequest remittanceRequest, final long receiverId) {
validateReceiverAuthority(remittanceRequest, receiverId);
validateStatus(remittanceRequest);
checkExpiration(remittanceRequest);
}

private void validateReceiverAuthority(final RemittanceRequest remittanceRequest, final long receiverId) {
if (!remittanceRequest.getReceiverId().equals(receiverId)) {
throw new AccessDeniedException("해당 송금 요청의 수신자만 처리할 수 있습니다.");
}
}

private void validateStatus(final RemittanceRequest remittanceRequest) {
if (!remittanceRequest.isPending()) {
throw new InvalidRemittanceRequestStatusException(remittanceRequest.getStatus());
}
}

private void recordStatus(RemittanceRequest request, RemittanceRequestStatus status) {
private void checkExpiration(final RemittanceRequest remittanceRequest) {
if (remittanceRequest.isExpired(LocalDateTime.now())) {
remittanceExpirationService.expireRequest(remittanceRequest);
throw new ExpiredRemittanceRequestException();
}
}

private void recordStatusHistory(final RemittanceRequest request, final RemittanceRequestStatus status) {
remittanceStatusHistoryService.recordStatusHistory(
new RemittanceStatusHistory(
request.getId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class RemittanceRequest {

private static final long EXPIRATION_DAYS = 3;

private Long id;
private Long senderId;
private Long receiverId;
private RemittanceRequestStatus status;
private Long amount;
private LocalDateTime createdAt;
private transient LocalDateTime expiredAt;

public RemittanceRequest(
final Long senderId,
Expand All @@ -34,15 +37,48 @@ public RemittanceRequest(
this.createdAt = LocalDateTime.now();
}

/**
* 테스트용 생성자입니다.
* 접근제어자를 package-private 으로 설정하여 외부에서 직접 생성할 수 없도록 합니다.
*/
RemittanceRequest(
final Long senderId,
final Long receiverId,
final RemittanceRequestStatus status,
final Long amount,
final LocalDateTime createdAt
) {
this.senderId = senderId;
this.receiverId = receiverId;
this.status = status;
this.amount = amount;
this.createdAt = createdAt;
}

public boolean isPending() {
return this.status == RemittanceRequestStatus.PENDING;
}

public boolean isExpired(final LocalDateTime now) {
return createdAt.plusDays(EXPIRATION_DAYS).isBefore(now);
}

public void accept() {
status = this.status.accept();
}

public void reject() {
status = this.status.reject();
}

public void expire() {
status = this.status.expire();
}

public LocalDateTime getExpiredAt() {
if (expiredAt == null) {
expiredAt = createdAt.plusDays(EXPIRATION_DAYS);
}
return expiredAt;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

expire 메서드가 호출될 때 expiredAt 또한 업데이트 해준다면 데이터가 불완전한 시점을 줄일 수 있을 것으로 보입니다.

getExpiredAt은 getter이지만 실제로는 내부 expiredAt 의 상태를 변경시킬 수 있으므로 메서드 네이밍으로 인한 오해를 불러일으킬 수 있습니다. 메서드 이름을 변경하거나, 내부 로직의 변경이 필요한데요. 메서드 내용을 보니 expire메서드가 호출될 때 expiredAt을 같이 업데이트해준다면 순수한 getter로서 동작할 수 있겠네요.

또한 expiredAt을 계산할 때 createdAt.plusDays(EXPIRATION_DAYS); 방식으로 계산하는건 꽤나 위험한 방식입니다. 서비스의 만료시간 정책이 중간에 변경된다면 사용자가 인지하는 만료일과 서비스가 실제로 만료처리하는 일자가 달라질 수 있는데요, 우선순위는 항상 사용자가 송금요청시 확인한 만료일이여야 합니다. 만료일시는 송금요청하는 시간에 확정됩니다.

만료 시점은 생성일로부터 일정 시간이 지날 때 만료라고 명시되어있으므로 getExpiredAt을 호출할 때 계산하는게 아니라 송금요청 데이터 생성 시 부터 TTL성격의 expiredAt 데이터를 함께 생성해주는 것이 더 적절하지 않을까요?

Copy link
Collaborator Author

@donggi-lee-bit donggi-lee-bit Apr 9, 2025

Choose a reason for hiding this comment

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

만료 시각을 단순히 만료 여부를 판단하기 위한 보조 도구 정도로만 인식하면서, 생성 시점에 저장해야 한다는 관점을 놓쳤던 것 같습니다. 😂

말씀하신 것처럼 getter 내에서 특정 필드 값을 변경하는 방식은 해당 메서드를 사용하는 쪽에서 의도와는 다른 동작이 있어 혼란이 있어 보입니다.

또한 만료 정책은 변경될 수 있고, 사용자는 송금 요청 시점에 인지한 정책에 따라 만료 상태가 갱신되어야 하므로, getter나 별도의 로직이 아닌 송금 요청 생성 시점의 만료 정책을 적용해 만료 시각을 확정시키는 방법이 더 적절한 설계라고 생각합니다.

해당 내용 반영하여 수정하겠습니다!

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public RemittanceRequestStatus accept() {
public RemittanceRequestStatus reject() {
return REJECTED;
}

@Override
public RemittanceRequestStatus expire() {
return EXPIRED;
}
},
ACCEPTED,
REJECTED,
Expand All @@ -25,4 +30,8 @@ public RemittanceRequestStatus accept() {
public RemittanceRequestStatus reject() {
throw new UnsupportedOperationException("현재 송금 상태에서는 거절할 수 없습니다: " + this);
}

public RemittanceRequestStatus expire() {
throw new UnsupportedOperationException("현재 송금 상태에서는 만료할 수 없습니다: " + this);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.donggi.sendzy.remittance.domain.RemittanceRequest;

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

public interface RemittanceRequestRepository {
Expand Down Expand Up @@ -32,4 +33,10 @@ public interface RemittanceRequestRepository {
* @param remittanceRequest 업데이트할 송금 요청 정보
*/
void update(final RemittanceRequest remittanceRequest);

/**
* PENDING 상태의 요청 목록을 조회합니다.
* @return 조회된 송금 요청 목록
*/
List<RemittanceRequest> findPendingRequests();
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ public void reject(final RemittanceRequest remittanceRequest) {
remittanceRequestRepository.update(remittanceRequest);
}

@Transactional
public void expire(final RemittanceRequest remittanceRequest) {
remittanceRequest.expire();
remittanceRequestRepository.update(remittanceRequest);
}

@Transactional(readOnly = true)
public RemittanceRequest getById(final long requestId) {
return remittanceRequestRepository.findById(requestId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.donggi.sendzy.remittance.exception;

public class ExpiredRemittanceRequestException extends RuntimeException {

public ExpiredRemittanceRequestException() {
super("송금 요청이 만료되었습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.donggi.sendzy.remittance.domain.repository.TestRemittanceRequestRepository;
import org.apache.ibatis.annotations.Mapper;

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

@Mapper
Expand All @@ -17,4 +18,6 @@ public interface RemittanceRequestMapper extends RemittanceRequestRepository, Te
Optional<RemittanceRequest> findByIdForUpdate(final long requestId);

void deleteAll();

List<RemittanceRequest> findPendingRequests();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.donggi.sendzy.remittance.infrastructure.expiration;

public class ExpirationQueueInterruptedException extends RuntimeException {

public ExpirationQueueInterruptedException() {
super("만료 큐에서 요청을 가져오는 도중 인터럽트되었습니다.");
}
}
Loading
Loading