항해 1주차에는 TDD 방법론 사용하여 포인트 REST API를 개발하는게 발제였다.
개발하는 과정과 문제에 봉착했을때 어떻게 해결했는지에 대해 정리해보려고 한다.
개발 진행 과정
1. 테스트가 실패하도록 작성
2. 테스트를 성공시키기 위한 최소한의 코드 작성
3. 실제 코드 리팩토링
요구 사항
- PATCH /point/{id}/charge : 포인트를 충전한다.
- PATCH /point/{id}/use : 포인트를 사용한다.
- GET /point/{id} : 포인트를 조회한다.
- GET /point/{id}/histories : 포인트 내역을 조회한다.
- 잔고가 부족할 경우, 포인트 사용은 실패하여야 합니다.
- 동시에 여러 건의 포인트 충전, 이용 요청이 들어올 경우 순차적으로 처리되어야 합니다.
위와 같이 요청 사항이 주어졌을때 아래 순서에 따라 개발을 진행해 나아갔다.
개발 진행 과정
1. 요구사항을 분석
- 요청하는 값에 대한 validation 체크한다.
- 충전 / 사용 시 정책에 어긋날 시 예외 처리한다.
- 충전 정책 : 포인트 충전을 요청한 금액과 기존 잔고의 합이 최대 잔고보다 크면 포인트 충전 실패한다.
- 사용 정책 : 포인트 사용을 요청한 금액이 기존 잔고보다 크면 포인트 사용 실패한다.
2. 포인트 서비스 단위테스트 작성(with TDD)
-Mockito를 사용하여 Mock객체를 생성하고, 테스트 대상 코드를 검증한다.
- Mockito 동작 순서
1. Mock 객체 생성 : 의존성을 대체하기 위한 가짜 객체를 생성한다. ( @Mock 어노테이션 사용)
2. Stubbing(동작 설정) : Mock 객체가 호출되었을 때 특정한 동작을 수행하도록 Stubbing 설정한다.
- when(...).thenReturn(...) : 특정 메서드 호출 시 반환값 설정한다.
3. 테스트 대상 코드 실행 : Mock 객체를 포함한 실제 메서드 실행한다.
4. 검증 : Mock 객체의 메서드가 예상대로 호출되었는지 확인한다.
예시 ) 포인트 조회 테스트
@ExtendWith(MockitoExtension.class)
public class PointServiceTest {
@Mock
private UserPointTable userPointTable;
@InjectMocks
private PointService pointService;
@Mock
private UserPoint mockUserPoint;
@BeforeEach
void init(){
// 포인트 조회 예상값 세팅
mockUserPoint = new UserPoint(1L, 700L, System.currentTimeMillis());
}
@Test
void getUserPointTest(){
//given
long userId = 1L;
when(userPointTable.selectById(userId)).thenReturn(mockUserPoint);
//when
UserPoint userPoint = pointService.getUserPoint(userId);
//then
assertEquals(mockUserPoint, userPoint);
verify(userPointTable, times(1)).selectById(userId);
}
}
테스트 코드를 살펴보면
- given : given에 userId값을 1L로 선언하고, when()을 통해 Stubbing(동작 정의)을 한다.
- when : Mock 객체를 포함하고 있는(의존성 주입) pointService 객체의 .getUserPoint() 메서드를 호출하여 userPoint를 반환한다. (결과값)
- then : 예상값과 결과값이 일치하는지 체크하고, userPointTable(Mock 객체)의 .selectById() 메서드가 1회 호출되었는지 확인한다.
3. 서비스 로직 구현
테스트 코드만 짠 상태라면 테스트에 실패할 것이다.
왜냐하면 아직 pointService의 .getUserPoint() 메서드를 정의하지 않았기 때문이다.
그러면 테스트가 성공할 수 있게 pointService의 .getUserPoint() 메서드 로직을 작성한다.
4. 코드 리팩토링
테스트가 성공할 수 있게 최소한의 비즈니스 로직을 작성했다면 코드를 구체적으로 리팩토링 해나아간다.
5. 포인트 validation 단위 테스트 작성(with TDD)
사용자 id와 포인트 금액에 대한 값 검증이 필요하다.
- 널이면 이거나, 0이거나, 음수인 경우에 IllegalArgumentException 예외가 발생한다.
개발하면서 부딪힌 문제
1. validation 체크를 어떤 방식으로 할까?
사전 스터디에서 validation 체크를 했을 때는 @Valid를 사용하여 컨트롤러에서 입력값을 검증하고
DTO 필드에 유효성 검증 어노테이션을 추가하여(ex. @NotNull, @NotEmpty @Size 등등...) validation 체크를 하였다.
그러나 이번 발제에선 원시 타입을 사용하여 필드에 대한 null 체크가 불가했기 때문에 다른 방법을 모색하였다.
그중에 validation 체크를 컴포넌트로 따로 분리하여 체크하도록 하고, Service에서 로직 수행전에 요청 받은 값에 대한 validation 체크를 해주었다.
보안할 점 : 현재는 Service 계층에서 validation 처리를 하고 있으나, Controller 계층에서 Service 메서드 호출 전에 검증해주는 방향으로 가야한다.
2. 포인트 충전 / 저장 후에 포인트 내역에 기록을 저장할 때 단위테스트 할 때 발생한 문제
PointService의 patchPointCharge(), patchPointUse() 메서드에서 충전 / 사용 후에 포인트 내역을 저장하는데
이때, pointHistoryTable.insert() ← 변경시간에 대한 인자 값으로 직접 System.currentTimeMillis()를 전달해주었다.
단위 테스트 코드에서도 기대값에 System.currentTimeMillis()로 세팅하니 기대값과 반환값의 변경시간이 달라서 테스트 오류가 발생했다.
해결 방식 : Contoller에서 PointService의 충전, 사용 메서드를 호출할 때 매개변수로 userId와 amount 값만 전달하였는데
fixedTime 변수에 고정으로 시간을 세팅하여 fixedTime도 같이 전달해주었다.
그리고 테스트 코드에서도 fixedTime을 고정된 시간으로 선언하고 기대값 세팅할 때 fixedTime로 전달해주었다.
Contoller
@PatchMapping("{id}/charge")
public UserPoint charge(@PathVariable long id,@RequestBody long amount){
long fixTime = 1700000000000L;
UserPoint userPoint = pointService.patchPointCharge(id, amount, fixTime);
return userPoint;
}
Service
public UserPoint patchPointCharge(long userId, long addAmount,long fixTime){
// validation 체크 ...
// 포인트 충전 ...
pointHistoryTable.insert(userId, addAmount, TransactionType.CHARGE, fixTime);
// 충전한 포인트 반환 ...
}
ServiceUnitTest
@Test
@DisplayName("포인트 충전 테스트")
void patchPointChargeTest(){
// given() ...
// 포인트 히스토리에 충전 내역 저장된 기대값
PointHistory mockPointHistory =
new PointHistory(1L, userId, addAmount, TransactionType.CHARGE, fixedTime);
// Mock 객체 Stubbing ...
// when() ... (테스트할 실제 코드 호출)
// then() ... (검증)
}
3. Validation, Service 단위 테스트를 분리
현재 Validation을 컴포넌트로 분리하고, Service에서 의존성 주입받아서 사용하고 있다.
처음에는 Service 단위 테스트에 Validation 컴포넌트 객체를 Mocking 해서 Stubbing 하려고 했는데
그렇게 되면 Service 단위 테스트 코드가 길어질 것 같아서 Validation 테스트 코드는 따로 분리해서 단위 테스트 해주었다.
회고
이번 주차의 주 목표는 TDD 개발 방법론을 기반으로 테스트 코드 작성에 익숙해지는 것이었다.
아직 테스트 코드에 익숙하지 않아서 validation 체크만 넣어도 테스트 코드가 길어지고, 검증을 너무 세세하게 한 것 같았다.
코치님께서도
- given/when 이 많을 수록 테스트 코드를 작성하는데 수월했나?
- 리듬감있게 빨강 > 그린 > 리펙토링을 잘 수행하신 것 같나?
- 무엇을 테스트하고 있는지 잘 고민해보셨으면 좋겠다.
- 테스트 코드가 구현을 테스트함으로써, prod 코드가 조금만 변경되도 모두 깨질 것이다.
테스트 코드에 대해 위와 같이 코멘트 해주셨는데 앞으로 테스트 코드를 짤 때
어떻게 하면 효율적으로 테스트 코드를 짜고, TDD에 집중할 수 있을까?를 고민하면서 테스트를 진행해야 할 것 같다.
'항해 플러스 백엔드 > TDD & 클린 아키텍처' 카테고리의 다른 글
[항해] 2주차, 동시성 제어하기 (with DB 레벨) (0) | 2025.01.05 |
---|---|
[항해] 2주차, 클린 아키텍처 (2) | 2025.01.05 |
[항해] 1주차, 동시성 제어하기(순서 보장 포함) (2) | 2024.12.22 |