Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2f7ba51
feat: 송금 도메인 모델
donggi-lee-bit Mar 20, 2025
8646f67
feat: Data access 를 위한 Repository 클래스 뼈대
donggi-lee-bit Mar 20, 2025
cc0a7dd
feat: 계좌 도메인 모델에 대기 금액 필드 추가
donggi-lee-bit Mar 20, 2025
ee58548
feat: Remittance 도메인 생성 기능
donggi-lee-bit Mar 20, 2025
6c9284d
feat: 송금 기능 개발
donggi-lee-bit Mar 20, 2025
fb32e3e
test: Account 클래스 출금 기능 테스트
donggi-lee-bit Mar 23, 2025
b63ede1
refactor: 도메인 객체 생성 후 ID 반환 로직 변경
donggi-lee-bit Mar 24, 2025
235f43d
test: 송금 기능 테스트 코드 작성과 누락된 비즈니스 로직 추가
donggi-lee-bit Mar 24, 2025
f7327ed
refactor: 회원 조회 테스트 시 객체 간 동등 비교를 통해 검증하도록 변경
donggi-lee-bit Mar 24, 2025
043d5ee
feat: 송금 요청 API
donggi-lee-bit Mar 24, 2025
24b9b55
refactor: 회원 등록 시 반환되는 값 대신 저장된 회원을 직접 호출하여 ID를 사용하도록 변경
donggi-lee-bit Mar 24, 2025
f85a008
chore: origin 환경에서 테스트가 실패하는 문제, 원인 파악을 위해 주석 처리
donggi-lee-bit Mar 24, 2025
86efbeb
chore: origin 환경에서 테스트가 실패하는 문제, 원인 파악을 위해 객체 간 비교가 아닌 ID 비교
donggi-lee-bit Mar 24, 2025
4d8c037
feat: 송금 요청 시 발생하는 예외 핸들링
donggi-lee-bit Mar 25, 2025
dd3f346
chore: 사용하지 않는 EqualsAndHashCode 어노테이션 제거
donggi-lee-bit Mar 25, 2025
0e1c691
refactor: Account 에서 출금 검증 로직을 메서드로 추출해 의미를 명확하게 표현
donggi-lee-bit Mar 27, 2025
1786e55
refactor: 데이터가 조회되지 않을 경우 예외를 던지는 조회 방식의 의도를 드러내도록 네이밍 변경
donggi-lee-bit Mar 27, 2025
43d7744
refactor: RemittacneHistory 조회 결과를 List 타입으로 변경하여 복수 건 조회가 가능하도록 수정
donggi-lee-bit Mar 27, 2025
51c9278
refactor: 송금 요청 처리 후 관련 정보 확인 방식 변경
donggi-lee-bit Mar 27, 2025
f9159ee
remove: 사용되지 않는 findBySenderId 메서드 삭제
donggi-lee-bit Mar 27, 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
33 changes: 33 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,6 @@
package com.donggi.sendzy.account.domain;

import com.donggi.sendzy.account.exception.InvalidWithdrawalException;
import com.donggi.sendzy.common.utils.Validator;
import lombok.AccessLevel;
import lombok.Getter;
Expand All @@ -12,11 +13,26 @@ public class Account {
private Long id;
private Long memberId;
private Long balance;
private Long pendingAmount;

public Account(final Long memberId) {
validate(memberId);
this.memberId = memberId;
this.balance = 0L;
this.pendingAmount = 0L;
}

public void withdraw(final Long amount) {
validateWithdraw(amount);
this.pendingAmount += amount;
}

public void deposit(final Long amount) {
final var fieldName = "amount";
Validator.notNull(amount, fieldName);
Validator.notNegative(amount, fieldName);

this.balance += amount;
}

private void validate(final Long memberId) {
Expand All @@ -27,4 +43,21 @@ private void validateMemberId(final Long memberId) {
final var fieldName = "memberId";
Validator.notNull(memberId, fieldName);
}

private void validateWithdraw(final Long amount) {
final var fieldName = "amount";
Validator.notNull(amount, fieldName);

if (amount <= 0) {
throw new InvalidWithdrawalException(amount);
}

if (this.balance < amount) {
throw new InvalidWithdrawalException();
}

if (this.balance < this.pendingAmount + amount) {
throw new InvalidWithdrawalException();
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

amount <= 0 / this.balance < amount / this.balance < this.pendingAmount + amount은 어떤 조건을 검증할것인지에 대한 명시성이 부족합니다. 단순히 어떤 값이 어떤 형태여야한다는 조건이 아닌, validation이라는 비즈니스 검증 로직이 들어갔으므로 어떤 형태의 검증 요구사항을 수행하는 것인지 명확하게 표현해야 검증에 대한 코드를 읽기 수월해집니다.

예를 들어, 이런식으로 표현할 수 있습니다.

if (!isAmountPositive()) {
    throw new InvalidWithdrawalException(amount);
}

if (!isEnsureBalanceAvailable()) {
    throw new InvalidWithdrawalException();
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

출금 검증 로직을 메서드로 추출해서 의미를 명확하게 표현하도록 변경했습니다!

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,33 @@ public interface AccountRepository {
* @param account 생성할 계좌
* @return 생성된 계좌의 ID
*/
Long create(Account account);
Long create(final Account account);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

👍


/**
* 회원 ID로 계좌를 조회합니다.
* @param memberId 회원 ID
* @return 조회된 계좌
*/
Optional<Account> findByMemberId(Long memberId);
Optional<Account> findByMemberId(final Long memberId);

/**
* 회원 ID로 계좌를 조회하고, 조회된 계좌에 배타적 잠금(Exclusive Lock)을 겁니다.
* @param memberId 회원 ID
* @return 잠금이 설정된 계좌(Optional)
*/
Optional<Account> findByIdForUpdate(final Long memberId);

/**
* 계좌의 대기 중인 금액을 업데이트합니다.
* @param id 계좌 ID
* @param pendingAmount 대기 중인 금액
*/
void updatePendingAmount(final Long id, final Long pendingAmount);

/**
* 계좌의 잔액을 업데이트합니다.
* @param id 계좌 ID
* @param balance 잔액
*/
void updateBalance(final Long id, final Long balance);
}
22 changes: 20 additions & 2 deletions src/main/java/com/donggi/sendzy/account/domain/AccountService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.donggi.sendzy.account.domain;

import com.donggi.sendzy.member.exception.MemberNotFoundException;
import com.donggi.sendzy.account.exception.AccountNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -14,6 +14,24 @@ public class AccountService {
@Transactional(readOnly = true)
public Account getByMemberId(final Long memberId) {
return accountRepository.findByMemberId(memberId)
.orElseThrow(() -> new MemberNotFoundException("회원을 찾을 수 없습니다. :" + memberId));
.orElseThrow(() -> new AccountNotFoundException(memberId));
}

@Transactional
public void withdraw(final Account account, final Long amount) {
account.withdraw(amount);
accountRepository.updatePendingAmount(account.getId(), account.getPendingAmount());
}

@Transactional
public void deposit(final Account account, final Long amount) {
account.deposit(amount);
accountRepository.updateBalance(account.getId(), account.getBalance());
}

@Transactional(readOnly = true)
public Account findByIdForUpdate(final Long memberId) {
return accountRepository.findByIdForUpdate(memberId)
.orElseThrow(() -> new AccountNotFoundException(memberId));
Comment on lines +32 to +35
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

반드시 대상을 조회해야 하는 경우에는 get~~
대상이 있는지 없는지 모르지만 찾아보는 경우(null 허용)에는 findBy~~로 컨벤션을 통일해주면 이 메서드의 의도를 명확하게 드러낼 수 있습니다.
메서드 명칭은 find이나, 실제로는 데이터가 없을 경우 예외를 발생시키고 있으니 메서드 이름에서 의도를 명확하게 드러낼 수 있는 방법을 고민해보시는건 어떨까요?

또한, 불필요하게 메서드 파라미터로 Wrapper Type을 받고 있습니다.
null이 허용되지 않는 파라미터라면 Wrapper Type의 사용을 지양해주세요~

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

데이터가 조회되지 않을 경우 예외를 던지는 현재 흐름에서는 말씀 주신 것처럼 get~~ 형태로 네이밍하는 것이 메서드의 의도를 더 명확하게 드러내는 방향이라고 생각합니다. 사용하는 입장에서도 해당 리소스가 반드시 존재해야 하는 상황이라 get이 더 어울리는 표현인 것 같습니다.

평소에 관성적으로 findBy~~를 사용하는 경우가 있는 것 같은데, 앞으로 이런 맥락에 따라 네이밍을 좀 더 신중하게 고려해보겠습니다!

그리고 현재 메서드에서 파라미터에 Wrapper 타입을 사용할 필요는 없는데, 이런 부분도 수정하고 이후에는 기본형을 우선적으로 고려하는 습관을 들이도록 하겠습니다!

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.donggi.sendzy.account.exception;

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

public class AccountNotFoundException extends NotFoundException {
public AccountNotFoundException(final Long memberId) {
super("계좌를 찾을 수 없습니다. :" + memberId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.donggi.sendzy.account.exception;

public class InvalidWithdrawalException extends RuntimeException {
public InvalidWithdrawalException() {
super("잔액이 부족합니다.");
}

public InvalidWithdrawalException(final Long amount) {
super("송금액은 0원 이상이어야 합니다. 사용자 요청 금액: " + amount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
import com.donggi.sendzy.account.domain.AccountRepository;
import com.donggi.sendzy.account.domain.TestAccountRepository;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.Optional;

@Mapper
public interface AccountMapper extends AccountRepository, TestAccountRepository {
Long create(Account account);
Long create(final Account account);

Optional<Account> findByMemberId(Long memberId);
Optional<Account> findByMemberId(final Long memberId);

void deleteAll();

void updatePendingAmount(@Param("id") final Long id, @Param("pendingAmount") final Long pendingAmount);

void updateBalance(@Param("id") final Long id, @Param("balance") final Long balance);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.donggi.sendzy.common.exception;

public class BadRequestException extends RuntimeException {
public BadRequestException(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.donggi.sendzy.common.exception;

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.member.exception.MemberNotFoundException;
Expand Down Expand Up @@ -46,10 +47,10 @@ public ProblemDetail handleInvalidPasswordException(InvalidPasswordException e)
}

/**
* 클라이언트가 요청한 회원이 존재하지 않는 경우
* 클라이언트가 요청한 데이터가 존재하지 않는 경우
*/
@ExceptionHandler(MemberNotFoundException.class)
public ProblemDetail handleMemberNotFoundException(MemberNotFoundException e) {
@ExceptionHandler(NotFoundException.class)
public ProblemDetail handleMemberNotFoundException(NotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}

Expand All @@ -68,4 +69,20 @@ public ProblemDetail handleAuthenticationException(AuthenticationException e) {
public ProblemDetail handleAccessDeniedException(AccessDeniedException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, e.getMessage());
}

/**
* 클라이언트가 출금 요청을 잘못한 경우
*/
@ExceptionHandler(InvalidWithdrawalException.class)
public ProblemDetail handleInvalidWithdrawalException(final InvalidWithdrawalException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
}

/**
* 클라이언트 요청이 잘못된 경우
*/
@ExceptionHandler(BadRequestException.class)
public ProblemDetail handleBadRequestException(final BadRequestException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.donggi.sendzy.common.exception;

public class NotFoundException extends RuntimeException {
public NotFoundException(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,28 @@ public interface MemberRepository {
/**
* 회원을 저장합니다.
* @param member 저장할 회원
* @return 저장된 회원의 ID
* @return 저장된 행의 수
*/
Long create(Member member);
Long create(final Member member);

/**
* 이메일로 회원이 존재하는지 확인합니다.
* @param email 이메일
* @return 회원이 존재하면 true, 존재하지 않으면 false
*/
boolean existsByEmail(String email);
boolean existsByEmail(final String email);

/**
* 이메일로 회원을 조회합니다.
* @param email 이메일
* @return 조회된 회원
*/
Optional<Member> findByEmail(String email);
Optional<Member> findByEmail(final String email);

/**
* ID로 회원을 조회합니다.
* @param id 조회할 회원의 ID
* @return 조회된 회원
*/
Optional<Member> findById(final Long id);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.donggi.sendzy.member.domain;

import com.donggi.sendzy.member.exception.MemberNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -14,7 +15,8 @@ public class MemberService {

@Transactional
public Long registerMemberAndGetId(final Member member) {
return memberRepository.create(member);
memberRepository.create(member);
return member.getId();
}

@Transactional(readOnly = true)
Expand All @@ -26,4 +28,10 @@ public boolean existsByEmail(final String email) {
public Optional<Member> findByEmail(final String email) {
return memberRepository.findByEmail(email);
}

@Transactional(readOnly = true)
public Member findById(final Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(memberId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

public class MemberNotFoundException extends BusinessException {

public MemberNotFoundException(String message) {
super(message);
public MemberNotFoundException(final Long memberId) {
super("존재하지 않는 회원입니다. memberId: " + memberId);
}
}
Loading
Loading