📌 사전 용어 익히기
이체 : 한 계좌에서 다른 계좌로 자금을 이동시키는 금융거래
당행 : 현재 거래하고 있는 은행
당행 이체 : 같은 은행 내의 계좌 간 자금 이동
📌 당행 이체 기능 흐름 생각해보기
당행 이체는 어떤 흐름을 갖고 기능이 동작할지 생각해보자
A계좌, B계좌가 존재하는 상황을 가정하고 생각해보자
상황 : A계좌에서 B계좌로 10,000원을 이체
🔨 Flow
(1) A계좌에서 보낸돈 10,000원을 출금 (계좌 잔액 - 10,000원)
(2) A계좌 출금 내역 기록
(3) B계좌에 A계좌에서 보낸돈 10,000원을 입금 (계좌 잔액 + 10,000원)
(4) B계좌 입금 내역 기록
2개의 큰 흐름이 존재한다고 생각한다.
(1), (2)번은 반드시 하나의 작업 단위로 이루어져야 한다.
Q. 왜 하나의 작업 단위로 이루어져야 할까?
하나의 작업단위로 이루어지지 않으면, 로직을 실행중에 오류가 발생하면 다음과 같은 상황이 발생한다.
가정 상황 : A계좌에서 출금을 하는 로직은 정상적으로 실행이 되고, B계좌에 입금되는 로직은 오류가 발생한 상황을 가정
(1) A계좌에서 출금하는 로직이 정상실행 되므로, A계좌에서 10,000이 출금됨
(2) A계좌에서 10,000원이 출금된 내역을 기록함
(3) B계좌에서 입금되는 로직에 오류가 발생하므로, 오류로 인해 입금이 되지 않음
A계좌에서 10,000원이 출금됬음에도 B계좌에 10,000원이 입금되지 않는 상황이 발생한다.
돈이 공중분해가 되는 상황이 발생한다.
Q. 하나의 작업 단위로 이루어지면 어떻게 되는데?
A계좌에서 출금하는 로직과 B계좌의 입금하는 로직이 하나의 작업(transaction)으로 묶여 있는 경우, 두 로직중 한 로직에 에러가 발생할 경우 작업을 취소하고, 작업 단위를 시작하기 전 상태로 원상복구(Rollback) 시킨다.
🔨 Flow
(1) 트랜잭션 시작
(2) A계좌 출금
(3) B계좌 입금
✅ Commit : 두 작업을 모두 성공한 경우 트랜잭션을 커밋하여 변경사항 저장
❌ Rollback : 두 작업중 하나의 작업이라도 실패한 경우 트랜잭션을 롤백하여 변경사항을 취소
@Entity 설계
- 고유 id
- 출금 계좌
- 입금 계좌
- 이체되는 금액
- 출금 후 계좌의 잔액
- 입금 후 계좌의 잔액
Q. 왜 출금 후 계좌의 잔액과 입금 후 계좌의 잔액을 사용할까?
출금 후 계좌의 잔액과 입금 후 계좌의 잔액을 기록하게 되면, 데이터 일관성을 쉽게 확인할 수 있다고 판단했다.
* 데이터 일관성: 트랜잭션이 실행을 성공적으로 완료하면 언제나 일관성 있는 데이터베이스 상태로 유지하는 것을 의미한다.
* 일관성: 하나의 방법이나 태도로써 처음부터 끝까지 한결같은 성질을 뜻한다.
TransferHistory.java
package com.example.payment.transfer.entity;
import com.example.payment.global.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.math.BigDecimal;
@Entity
public class TransferHistory extends BaseEntity {
@Id @GeneratedValue
private Long id;
@Column(length = 10)
private String withdrawalAccountNumber;
@Column(length = 10)
private String depositAccountNumber;
private BigDecimal transferAmount;
private BigDecimal amountAfterWithdrawal;
private BigDecimal amountAfterDeposit;
}
@Service 구현
TransferService.java
package com.example.payment.transfer;
import com.example.payment.account.AccountRepository;
import com.example.payment.account.entity.Account;
import com.example.payment.member.exception.NotMatchPasswordException;
import com.example.payment.transfer.dto.reqeust.TransferRequest;
import com.example.payment.transfer.exception.NotEnoughWithdrawalMoney;
import com.example.payment.transferHistory.TransferHistoryRepository;
import com.example.payment.transferHistory.entity.TransferHistory;
import java.math.BigDecimal;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class TransferService {
private final TransferHistoryRepository transferHistoryRepository;
private final AccountRepository accountRepository;
@Transactional
public void transfer(final TransferRequest request) {
final String withdrawalAccountNumber = request.withdrawalAccountNumber();
final String depositAccountNumber = request.depositAccountNumber();
final BigDecimal transferAmount = request.transferAmount();
// 1. 계좌의 존재 여부를 확인한다.
Account withdrawalAccount = accountRepository.getByAccountNumber(withdrawalAccountNumber);
Account depositAccount = accountRepository.getByAccountNumber(depositAccountNumber);
// 2. 비밀번호 일치여부를 확인한다.
if(!checkAccountPassword(request.accountPassword(), withdrawalAccount.getPassword())){
throw new NotMatchPasswordException();
}
// 3. 출금 게좌에 출금 액수만큼 빼준다.
if(!checkWithdrawalMoney(withdrawalAccount, transferAmount)) {
throw new NotEnoughWithdrawalMoney();
}
final BigDecimal amountAfterWithdrawal = withdrawalAccount.getBalance().subtract(transferAmount);
withdrawalAccount.updateBalance(amountAfterWithdrawal);
// 4. 입금 계좌에 입금 액수만큼 더해준다.
final BigDecimal amountAfterDeposit = depositAccount.getBalance().add(transferAmount);
depositAccount.updateBalance(amountAfterDeposit);
// 5. 이체 내역을 기록한다.
TransferHistory transferHistory = TransferHistory.builder()
.amountAfterWithdrawal(amountAfterWithdrawal)
.amountAfterDeposit(amountAfterDeposit)
.transferAmount(transferAmount)
.depositAccountNumber(depositAccountNumber)
.withdrawalAccountNumber(withdrawalAccountNumber)
.build();
transferHistoryRepository.save(transferHistory);
}
/**
* a.compareTo(b) : a가 b보다 큰 경우 1을 return 한다.
*/
private boolean checkWithdrawalMoney(final Account withdrawlAccount, final BigDecimal transferAmount) {
// a.compareTo(b) : a가 b보다 큰 경 1을 return
return withdrawlAccount.getBalance().compareTo(transferAmount) == 1;
}
private boolean checkAccountPassword(final String requestPassword, final String accountPassword) {
return requestPassword.equals(accountPassword);
}
}
이체를 할때 확인해주어야 할 항목들이 존재한다.
(1) 계좌가 존재하는 계좌인지
(2) 계좌 비밀번호가 일치하는지
(3) 계좌에 이체할 금액 이상의 돈이 존재하는지
@Test 구현
ControllerTest
MockMvc를 사용해서 ControllerTest를 구현했다.
ControllerTest 코드는 크게 성공하는 경우와 실패하는 경우를 나눠서 테스트 코드를 작성했다.
성공하는 경우와 실패하는 경우의 기준은 Request 작성할 때, Request에 필요한 값들이 잘 주어졌는지 아닌지에 대한 부분이다.
TransferRequest.java
package com.example.payment.transfer.dto.reqeust;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import org.hibernate.validator.constraints.Length;
public record TransferRequest(
@NotBlank(message = "출금 계좌 번호는 필수로 입력해야 합니다.") @Length(min = 10, max = 10)
String withdrawalAccountNumber,
@NotBlank(message = "입금 계좌 번호는 필수로 입력해야 합니다.") @Length(min = 10, max = 10)
String depositAccountNumber,
@NotNull(message = "이체할 금액은 필수로 입력해야 합니다.") @DecimalMin("1")
BigDecimal transferAmount,
@NotNull(message = "계좌의 비밀번호는 필수로 입력해야 합니다.")
String accountPassword
) {
}
Q. @NotBlank와 @NotNull은 왜 사용했는가?
@NotBlank와 @NotNull 어노테이션을 사용해서, 필요한 값이 꼭 들어오도록 구현해주었다.
Controller에서 @Valid 어노테이션을 사용함으로써, 적절하지 않은 값이 들어오지 않을 경우 Exception을 터트려 준다.
터진 Exception은 구현해준 @ControllerAdvice에서 ExceptionHandler로 예외를 잡아 적절한 Response를 응답해준다.
@NotBlank란?
null과 적어도 공백이 아닌 문자가 하나 존재해야 한다.
@NotNull이란?
null이 되면 안된다.
TransferControllerTest
package com.example.payment.transfer;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.example.payment.global.error.ControllerAdvice;
import com.example.payment.transfer.dto.reqeust.TransferRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@WebMvcTest(controllers = TransferController.class)
public class TransferControllerTest {
@MockBean
TransferService transferService;
@MockBean
TransferController transferController;
@Autowired
ObjectMapper objectMapper;
MockMvc mockMvc;
@BeforeEach
void init() {
mockMvc = MockMvcBuilders.standaloneSetup(transferController)
.setControllerAdvice(new ControllerAdvice())
.alwaysDo(print())
.build();
}
@Test
@DisplayName("계좌 이체에 성공한다.")
void 계좌_이체에_성공한다() throws Exception{
//given
final TransferRequest request = new TransferRequest("0123456789", "1234567890", BigDecimal.valueOf(10000),"1234");
//then
mockMvc.perform(post("/api/transfer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
.andExpect(status().isOk());
}
@ParameterizedTest
@MethodSource("provideInvalidTransferRequests")
@DisplayName("필수 필드를 입력하지 않아서 계좌 이체를 실패한다.")
void 필수_필드를_입력하지_않아서_회원탈퇴를_실패한다(TransferRequest request) throws Exception{
//then
mockMvc.perform(post("/api/transfer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
.andExpect(status().isBadRequest());
}
private static Stream<Arguments> provideInvalidTransferRequests() {
return Stream.of(
Arguments.of(new TransferRequest(null, "1234567890", BigDecimal.valueOf(10000),"1234")),
Arguments.of(new TransferRequest("1234567890", null, BigDecimal.valueOf(10000),"1234")),
Arguments.of(new TransferRequest("0123456789", "1234567890", null,"1234")),
Arguments.of(new TransferRequest(null, "1234567890", BigDecimal.valueOf(10000),null)),
Arguments.of(new TransferRequest(null, "1234567890", BigDecimal.valueOf(0),"1234"))
);
}
}
😵 Refactoring
ControllerTest에서 실패하는 경우에 대해서 코드를 짤때, 실패하는 TransferRequest 클래스를 일일히 하나씩 생성해 주었고, 각 TransferRequest마다 @Test 코드를 생성해 주었다. TransferRequest클래스를 생성할때마다 중복되는 코드를 작성하게 되는 문제점이 존재했다.
한번에 Request를 주입해주는 방법은 없을까? 라는 생각을 하게되었고, JUnit에 @ParameterizedTest라는 어노테이션을 알게 되었다.
private static Stream<Arguments> provideInvalidTransferRequests() {
return Stream.of(
Arguments.of(new TransferRequest(null, "1234567890", BigDecimal.valueOf(10000),"1234")),
Arguments.of(new TransferRequest("1234567890", null, BigDecimal.valueOf(10000),"1234")),
Arguments.of(new TransferRequest("0123456789", "1234567890", null,"1234")),
Arguments.of(new TransferRequest(null, "1234567890", BigDecimal.valueOf(10000),null)),
Arguments.of(new TransferRequest(null, "1234567890", BigDecimal.valueOf(0),"1234"))
);
}
일반적으로 문자열을 사용해서 @ParameterizedTest 어노테이션에 문자열 형식으로 직접 작성하지만, TransferRequest에 입력해주어야 할 값들이 많아서, 코드가 지저분해진다고 판단했다. @ParameterizedTest 어노테이션에 지저분하게 코드를 작성하는 것보단 따로 @MethodSource 어노테이션을 사용해서 TransferRequest를 제공 받는 방식으로 구현했다.
@MethodSource
"@ParameterizedTest 어노테이션이 명시되어 있는 메서드에 Stream<Arguments>를 제공해준다."
RepositoryTest
계좌 이체시 계좌 이체 내역을 기록해줄때, 기록이 잘 되는지 확인해주는 테스트 코드를 작성했다.
TransferHistoryRepositoryTest
package com.example.payment.transferHistory;
import com.example.payment.transferHistory.entity.TransferHistory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.math.BigDecimal;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
public class TransferHistoryRepositoryTest {
@Autowired
TransferHistoryRepository transferHistoryRepository;
@Test
@DisplayName("계좌 이체 내역을 저장합니다.")
void 계좌_이체_내역을_저장합니다() throws Exception{
//given
TransferHistory transferHistory = TransferHistory.builder()
.withdrawalAccountNumber("0123456789")
.depositAccountNumber("1234567890")
.transferAmount(BigDecimal.valueOf(10000))
.amountAfterWithdrawal(BigDecimal.valueOf(0))
.amountAfterDeposit(BigDecimal.valueOf(20000))
.build();
//when
TransferHistory savedTransferHistory = transferHistoryRepository.save(transferHistory);
//then
SoftAssertions.assertSoftly(softAssertions -> {
softAssertions.assertThat(savedTransferHistory.getWithdrawalAccountNumber()).isEqualTo(transferHistory.getWithdrawalAccountNumber());
softAssertions.assertThat(savedTransferHistory.getDepositAccountNumber()).isEqualTo(transferHistory.getDepositAccountNumber());
softAssertions.assertThat(savedTransferHistory.getTransferAmount()).isEqualTo(transferHistory.getTransferAmount());
softAssertions.assertThat(savedTransferHistory.getAmountAfterDeposit()).isEqualTo(transferHistory.getAmountAfterDeposit());
softAssertions.assertThat(savedTransferHistory.getAmountAfterWithdrawal()).isEqualTo(transferHistory.getAmountAfterWithdrawal());
});
}
}
Q. @DataJpaTest는 왜 사용하는가?
불필요한 full auto-configuration 사용을 막고, JPA 테스트와 관련된 configuration만 사용하기 위해서
그리고 기본적으로 Test코드에 @Transactional 어노테이션을 달아주지 않아도 @DataJpaTest는 각 테스트를 Transactional하게 해주고, 각 테스트가 끝날때, rollback해준다.
Q. @Test코드에서 왜 @Transactional을 사용하는가?
(1) 각 테스트 케이스가 끝날 때마다 Rollback을 해주기 때문에, 다음 테스트에 영향을 주지 않기 위해서 사용한다.
(2) 또한 여러 테스트가 동시에 실행이 될때, 일관성 있는 DB 상태를 만들기 위해서 사용한다.
결론 : 테스트를 Rollback시킴으로 인해, 각 테스트를 독립적으로 실행할 수 있게 된다.
Q. SoftAssertions는 왜 사용하는가?
각 Assertions들이 서로 독립적으로 영향을 받지 않아, 이전 Assertions가 오류가 발생해도 다음 Assertions도 테스트 결과를 받아볼 수 있다. 한번에 Assertions 테스트에 대한 피드백을 받을 수 있다.
ServiceTest
package com.example.payment.account;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.example.payment.account.dto.AccountDto;
import com.example.payment.account.dto.request.AccountCreateRequest;
import com.example.payment.account.entity.Account;
import com.example.payment.member.MemberRepository;
import com.example.payment.member.entity.Member;
import com.example.payment.member.exception.NotExistMemberException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import org.assertj.core.api.Assertions;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
public class AccountServiceTest {
@Mock
MemberRepository memberRepository;
@Mock
AccountRepository accountRepository;
@InjectMocks
AccountService accountService;
@Test
@DisplayName("계좌를 등록한다.")
void 계좌를_등록한다() throws Exception{
//given
final Member member = Member.builder()
.email("abc@abc.com")
.password("abc123")
.nickName("abc")
.build();
final Account account = Account
.builder()
.accountNumber("1234567891")
.balance(BigDecimal.ZERO)
.member(member)
.password("1234")
.build();
final AccountCreateRequest request = new AccountCreateRequest("abc@abc.com", "abc123", "1234");
//when
when(memberRepository.getByEmail(member.getEmail())).thenReturn(member);
when(accountRepository.existsByAccountNumber(anyString())).thenReturn(false);
when(accountRepository.save(any(Account.class))).thenReturn(account);
//then
final AccountDto accountDto = accountService.createAccount(request);
SoftAssertions.assertSoftly(softAssertions -> {
softAssertions.assertThat(accountDto.accountNumber()).isEqualTo(account.getAccountNumber());
softAssertions.assertThat(accountDto.balance()).isEqualTo(account.getBalance());
});
}
@Test
@DisplayName("존재하지 않는 계정으로 계좌를 생성시 계좌 등록을 실패한다.")
void 존재하지_않는_계정으로_계좌를_생성시에_계좌_등록을_실패한다() throws Exception{
//given
final Member member = Member.builder()
.email("abc@abc.com")
.password("abc123")
.nickName("abc")
.build();
final AccountCreateRequest request = new AccountCreateRequest("abc@abc.com", "abc123", "1234");
//when
when(memberRepository.getByEmail(member.getEmail())).thenThrow(new NotExistMemberException());
//then
Assertions.assertThatThrownBy(() ->accountService.createAccount(request))
.isInstanceOf(NotExistMemberException.class);
}
@Test
@DisplayName("계좌 생성시, 이미 계좌가 존재하면, 랜덤한 계좌 번호를 다시 생성한다.")
public void 계좌_생성시_이미_계좌가_존재하면_랜덤한_계좌_번호를_다시_생성한다() {
//given
final Member member = Member.builder()
.email("abc@abc.com")
.password("abc123")
.nickName("abc")
.build();
final Account account = Account
.builder()
.accountNumber("1234567891")
.balance(BigDecimal.ZERO)
.member(member)
.password("1234")
.build();
final AccountCreateRequest request = new AccountCreateRequest("abc@abc.com", "abc123", "1234");
//when
when(memberRepository.getByEmail(member.getEmail())).thenReturn(member);
when(accountRepository.existsByAccountNumber(anyString())).thenReturn(true).thenReturn(false); // 처음엔 true, 마지막엔 false
when(accountRepository.save(any(Account.class))).thenReturn(account);
accountService.createAccount(request);
// then
verify(accountRepository, times(2)).existsByAccountNumber(anyString());
}
@Test
@DisplayName("계좌번호로 계좌의 잔액을 가져온다.")
void 계좌의_잔액을_가져온다() throws Exception{
//given
final Member member = Member.builder()
.email("abc@abc.com")
.password("abc123")
.nickName("abc")
.build();
final Account account = Account
.builder()
.accountNumber("1234567891")
.balance(BigDecimal.ZERO)
.member(member)
.password("1234")
.build();
final String accountNumber = "01234567891";
//when
when(accountRepository.getByAccountNumber(accountNumber)).thenReturn(account);
//then
final AccountDto accountDto = accountService.getBalance(accountNumber);
SoftAssertions.assertSoftly(softAssertions -> {
softAssertions.assertThat(accountDto.accountNumber()).isEqualTo(account.getAccountNumber());
softAssertions.assertThat(accountDto.balance()).isEqualTo(account.getBalance());
});
}
@Test
@DisplayName("이메일로 계좌의 정보를 가져온다.")
void 이메일로_계좌의_정보를_가져온다() throws Exception{
//given
final Account account1 = Account.builder().accountNumber("123456781").password("1234").balance(BigDecimal.ZERO).build();
final Account account2 = Account.builder().accountNumber("123456781").password("1234").balance(BigDecimal.ZERO).build();
final Account account3 = Account.builder().accountNumber("123456781").password("1234").balance(BigDecimal.ZERO).build();
final List<Account> accounts = new ArrayList<>();
accounts.add(account1);
accounts.add(account2);
accounts.add(account3);
final Member member = Member.builder()
.email("abc@abc.com")
.accounts(accounts)
.password("abc123")
.nickName("abc")
.build();
//when
when(memberRepository.getByEmail(anyString())).thenReturn(member);
//then
List<AccountDto> resultAccounts = accountService.getAccounts(member.getEmail());
Assertions.assertThat(resultAccounts.size()).isEqualTo(3);
System.out.println(resultAccounts.get(0));
SoftAssertions.assertSoftly(softAssertions -> {
softAssertions.assertThat(resultAccounts.get(0).accountNumber()).isEqualTo("123456781");
softAssertions.assertThat(resultAccounts.get(0).balance()).isEqualTo(BigDecimal.ZERO);
softAssertions.assertThat(resultAccounts.get(1).accountNumber()).isEqualTo("123456781");
softAssertions.assertThat(resultAccounts.get(1).balance()).isEqualTo(BigDecimal.ZERO);
softAssertions.assertThat(resultAccounts.get(2).accountNumber()).isEqualTo("123456781");
softAssertions.assertThat(resultAccounts.get(2).balance()).isEqualTo(BigDecimal.ZERO);
});
}
}