항해의 1주 차는 TDD에 대한 과제였다.
- 포인트 사용/충전/조회
- 특정 사용자의 포인트 내역 조회
일주일 동안 무엇을 고민했는지 정리하는 시간을 가지려 한다.
1. 요구 사항 분석
막상 테스트를 작성하려 하니 처음에는 기본 기능에 대한 테스트 말고는 작성하질 못했다.
내가 뭘 테스트하고 구현해야하는지. 요구사항에 대해 깊게 분석하지 않았던 게 문제였다.
Q. 구현하고자 하는 것이 무엇인가?
- 회원의 포인트 사용, 충전 기능 및 포인트 내역 조회
Q. 무엇을 고려해야 할까?
- 특정 유저의 작업(사용/충전)은 순차적으로 실행되어야 하지만
각 유저에 대한 작업은 동시에 실행될 수 있다.
- 잔고가 부족한 경우, 포인트 사용에 실패해야 한다.
- 충전은 최소 1,000원 이상부터 시작해야 한다.
- 포인트 사용/충전 요청에 입력된 포인트값이 0 이하인 경우 실패해야 한다.
- 입력된 회원 아이디가 0 이하인 경우 실패해야 한다.
Q. 어떻게 구현할 것인가?
- red - green - refactoring
- red : 비즈니스 로직 구현 없이 테스트 케이스를 토대로, 적혀있는 글을 코드로 표현한다.
- green : 하드코딩을 통해 테스트를 성공시킨다.
- refactoring : 성공한 테스트를 토대로 비즈니스 로직을 구현한다.
Test Case
- 포인트 충전
- 하나의 트랜잭션 안에서 포인트 충전과 해당 충전에 대한 포인트 내역이 저장된다.
- 입력된 포인트 값이 1000 미만인 경우, 포인트 충전에 실패한다.
- 포인트 사용
- 보유 포인트가 무조건 있어야 포인트 사용을 할 수 있다.
- 보유한 포인트보다 입력한 포인트가 더 큰 경우, 포인트 사용에 실패한다.
대부분 추상적이고, 테스트 케이스도 부족하다.
과제를 제출하고 피드백에서 무엇을 테스트 하고 싶은 건지, 레드 > 그린 > 리펙토링이 잘 수행된 것 같은지 고민해 보라는 내용이 있었다.
내가 뭘 개발해야하는지 모르고 코드를 작성하면 읽는 사람한테 그대로 보이나 보다.
추상화된 요구사항을 구체화하는 연습이 필요하다.
2. 동시성 제어
과제에서는 동시성 허용과 동시성 제어. 두 가지 모두 구현되어야 했다.
- 여러 사용자의 요청에 대한 작업은 동시에 실행되어야 한다.
- A의 포인트 충전 / B의 포인트 조회 / C의 포인트 사용 에 대한 작업은 동시에 실행되어야 한다.
- 동일한 사용자의 요청에 대한 작업은 순차적으로 실행되어야 한다.
- A 의 포인트 충전, 사용 요청에는 순차적으로 작업이 실행되어야 한다.
1. synchronized
가장 먼저 생각난 동시성 제어 방식이다.
동일한 사용자 요청에 대한 작업은 순차적으로 실행될 수 있지만, 여러 사용자의 요청에도 작업이 순차적으로 실행된다.
자바의 모든 객체는 모니터 락을 가지고 있는데, synchronized 키워드를 통해 암묵적으로 사용된다.
특정 객체의 모니터 락을 획득한 스레드만이 해당 객체의 synchronized 메서드나 블록을 실행할 수 있다. 때문에 다른 스레드는 모니터 락이 해제될 때까지 대기해야 하는 상황이 발생한다.
2. ReentrantLock
synchronized 가 ‘암묵적 락’이라면 ReentrantLock은 ‘명시적 락’이다.
lock(), unlock()을 호출해야만 락을 사용할 수 있어, 락의 범위를 좁히는 등. 더 세밀하게 락을 제어할 수 있다. 하지만 락 메서드를 명시적으로 호출해야 하기 때문에 휴먼에러가 발생할 수 있다.
3. ConcurrentHashMap + ReentrantLock(fairness) - 선택
과제에서는 동시성을 허용하면서 제어해야 한다.
ConcurrentHashMap으로 동시성을 허용하고, ReentrantLock으로 동시성을 제어하는 방법을 선택했다.
private final ConcurrentHashMap<Long, Lock> locks = new ConcurrentHashMap<>();
private Lock getLock(long userId) {
return locks.computeIfAbsent(userId, id -> new ReentrantLock(true));
}
ConcurrentHashMap 은 내부적으로 버킷 단위로 락을 관리하여 다른 키에 대한 동시 접근은 허용하지만, 값 자체의 상태나 연산은 별도의 동기화가 필요하다.
ReentrantLock을 사용하면, 특정 키에 대한 연산을 원자적으로 실행할 수 있다. 하지만 작업의 실행 순서는 보장하지 않는데, 공정 모드(fairness)로 설정하면 해결할 수 있다.
공정 모드는 락 내부에서 큐를 사용하여 대기 중인 스레드의 순서를 보장하는데, 락이 해제되면 대기 큐의 가장 앞에 있는 스레드가 우선적으로 락을 획득한다.
userId 별로 독립적인 락을 사용할 수 있게 되어 동일한 회원의 요청은 순차적으로, 다른 회원의 요청은 병렬로 실행될 수 있다.
3. 동적 값에 대한 테스트
회원 포인트 정보가 저장된 테이블과 회원의 포인트 사용/충전 내역이 저장된 테이블.
둘 다 Map 방식으로 구현된 인메모리 DB이다.
코드를 살펴봤을 때 insert 메서드가 조금 달랐는데, UserPoint의 경우 메서드 내부에서 시간을 정하고 PointHistory는 외부에서 시간값을 전달받도록 되어있다.
public UserPoint insertOrUpdate(long id, long amount) {
UserPoint userPoint = new UserPoint(id, amount, System.currentTimeMillis());
table.put(id, userPoint);
return userPoint;
}
public PointHistory insert(long userId, long amount, TransactionType type, long updateMillis) {
PointHistory pointHistory = new PointHistory(cursor++, userId, amount, type, updateMillis);
table.add(pointHistory);
return pointHistory;
}
PointHistory 는 시간 값을 왜 외부에서 받게 하는 걸까?
포인트 충전 시, 해당 건에 대한 포인트 내역이 저장되고, 포인트가 업데이트된다.
// Service
public UserPoint chargePoint(long id, long amount) {
UserPoint userPoint = userPointTable.selectById(id);
pointHistoryTable.insert(id, amount, TransactionType.CHARGE, System.currentTimeMillis());
return userPointTable.insertOrUpdate(id, userPoint.addPoint(amount));
}
// Test
@Test
void chargePoint_shouldChargedPoint() {
// given
long userId = 1L;
long originalPoint = 1000L;
long chargePoint = 1500L;
long currentTime = System.currentTimeMillis();
when(userPointTable.selectById(userId))
.thenReturn(new UserPoint(userId, originalPoint, currentTime));
when(userPointTable.insertOrUpdate(userId, originalPoint + usePoint))
.thenReturn(new UserPoint(userId, originalPoint + chargePoint, currentTime));
// when
UserPoint chargedUserPoint = pointService.chargePoint(userId, usePoint);
//then
assertThat(chargedUserPoint)
.extracting("id", "point", "updateMillis")
.containsExactly(userId, originalPoint + chargePoint, currentTime);
verify(pointHistoryTable, times(1))
.insert(eq(userId), eq(chargePoint), eq(TransactionType.CHARGE), eq(currentTime));
}
Argument(s) are different! Wanted:
pointHistoryTable.insert(
1L,
1500L,
CHARGE,
1734841530191L
);
-> at io.hhplus.tdd.database.PointHistoryTable.insert(PointHistoryTable.java:21)
Actual invocations have different arguments at position [3]:
pointHistoryTable.insert(
1L,
1500L,
CHARGE,
1734841530230L
);
-> at io.hhplus.tdd.point.PointService.chargePoint(PointService.java:51)
포인트 충전 테스트를 실행했을 때의 결과다.
마지막 verify에서 검증하고자 했던 시간은 테스트에서 미리 지정한 currentTime 값인데
실제 비즈니스 로직에서는 메서드가 호출되는 시점에 시간 값이 결정되다 보니 시간값이 달라 테스트에 실패했다.
테스트만 실패할 뿐, 실제 API에서는 정상 동작한다.
그리고 verify에서 eq(currentTime)을 anyLong()으로 변경하면 테스트는 통과한다.
시스템에서 알아서 시간값을 정해준다는 것을 알고 있고, 신뢰하고 있기 때문에 상관없을 거라 생각되기도 하지만
결국 내가 제어할 수 없는 값이 된다.
외부에서 전달하는 값이고 특정 validate가 존재하지 않다면, 제어해서 검증된 값을 넘겨줘야 하는 게 맞다고 생각한다.
ArgumentCaptor
메서드 호출에 사용되는 인자에 대해서 검증하고 싶을 때, ArgumentCaptor를 사용할 수 있다.
ArgumentCaptor는 실제로 메서드가 호출되었을 때 그 메서드의 인수를 캡처한다.
확인을 위해 pointHistory.insert()가 실행될 때 전달되는 시간값과 ArgumentCaptor를 사용해서 캡처한 인수가 같은지 콘솔에 출력하도록 했다.
// Service
long time = System.currentTimeMillis();
System.out.println(time);
pointHistoryTable.insert(id, amount, TransactionType.CHARGE, time);
// Test
ArgumentCaptor<Long> timeCaptor = ArgumentCaptor.forClass(Long.class);
verify(pointHistoryTable, times(1))
.insert(eq(userId), eq(chargePoint), eq(TransactionType.CHARGE), timeCaptor.capture());
System.out.println(timeCaptor.getValue());
1주 차 간단 KPT 회고
Keep
- 사용하고자 하는 기술에 대해 깊게 고민
- 구현하면서 고민한 내용을 매일같이 기록
Problem
- 요구사항을 제대로 분석하지 않고 코드 작성
Try
- 추상화된 요구 사항을 구체화하는 것에 집중
구현하고자 하는 것 그리고 기술에 대해 깊게 고민하는 시간이 참 고통스러웠다.
꼬리에 꼬리를 무는 식으로, 연관된 기술들이 정말 많았다. 무엇보다 깊어질수록 본질을 잊어버리는 것이 가장 문제였다.. 가장 고통스러운 시간이었던 만큼, 공부한 내용 그리고 고민했던 부분을 잊고 싶지 않아 내용이 정리가 안되더라도 무조건 기록했던 것 같다.
그리고 과제 이해도가 정말 부족했다 생각한다.
요구사항에 대해서 제대로 분석하지 않았고, 동시성 분석 보고서도 단순 개념 나열에 그쳤다.
2주 차는 코드 작성보다 분석, 설계 시간을 더 많이 가져보면 어떨까.