본문 바로가기

TDD(3) - 완성

@정소민fan2025. 10. 22. 19:23

일단은 내 감각대로 TDD 과제를 완성해서 제출했다.
특히 헷갈리는 부분은 동시성 테스트 부분이었는데, 코드를 작성하면서도 이게 정말로 진정한 동시성이라고 할수 있나? 하는 부분이 있었다.
여러 테스트를 작성했지만, 반복적인 내용이 대부분이고 난이도가 어렵진 않으니까 동시성을 테스트한 코드와 그 테스트를 위한 코드만 조금 보자.
 

@Test
fun 포인트가_정상적으로_증가() {
    val increasePoint = 1000L
    val mockUserPoint = makeMockUserPoint(0)
    val expectPoint = increasePoint + mockUserPoint.point
    val userPoint = makeMockUserPoint(increasePoint)
    given(userPointTable.selectById(mockUserId)).willReturn(mockUserPoint)
    given(userPointTable.insertOrUpdate(mockUserId, expectPoint)).willReturn(userPoint)

    val resultUserPoint : UserPoint = pointService.increasePoint(mockUserId, increasePoint, time)

    assertThat(mockUserId).isEqualTo(resultUserPoint.id)
    assertThat(resultUserPoint.point).isEqualTo(expectPoint)
    then(userPointTable).should(times(1)).insertOrUpdate(mockUserId,expectPoint)
    then(userPointTable).should(times(1)).selectById(mockUserId)
    then(pointHistoryFactory).should(times(1)).chargePoint(mockUserId, increasePoint, time)
    /** 의문점 : then에서 어떤 시간에 저장되었는지 계속 넘겨줘야 하는데, System.CurrentTimeMillis가 일치하지 않아서 service까지 같은 타임을
        공유하도록 인자로 넘겨줘야함... 서비스까지 인자를 넘겨주고 싶지는 않은데 방법이 없을까
     */
}

 
리포지토리가 되는 UserPointTable의 로직부터 내가 제대로 이해하지 못했던 것 같다.
UserPointTable의 insertOrUpdate는 값을 HashMap에 그대로 덮어씌우는 역할이거나, 추가하는 것 뿐이지, 더해주거나 하는 연산은 따로 없던 것이다.
pointHistoryTable은 뭐지? 다음을 참고하자

더보기

pointHisotryTable은 포인트 내역을 저장해두는 테이블이다.

코드는 다음과 같이 생겼다.

@Component
class PointHistoryTable {
    private val table = mutableListOf<PointHistory>()
    private var cursor: Long = 1L

    fun insert(
        id: Long,
        amount: Long,
        transactionType: TransactionType,
        updateMillis: Long,
    ): PointHistory {
        Thread.sleep(Math.random().toLong() * 300L)
        val history = PointHistory(
            id = cursor++,
            userId = id,
            amount = amount,
            type = transactionType,
            timeMillis = updateMillis,
        )
        table.add(history)
        return history
    }

    fun selectAllByUserId(userId: Long): List<PointHistory> {
        return table.filter { it.userId == userId }
    }
}
fun insertOrUpdate(id: Long, amount: Long): UserPoint {
    Thread.sleep(Math.random().toLong() * 300L)
    val userPoint = UserPoint(id = id, point = amount, updateMillis = System.currentTimeMillis())
    table[id] = userPoint
    return userPoint
}

 
근데 테스트가 저번에는 성공했는데? 사실 값을 덮어씌우는 로직이기 때문에 expectPoint를 검사하면 제대로 나왔던 것이다.
로직을 이제 제대로 알았으니, selectById로 포인트가 얼마나 있는지 미리 확인하고, 그 포인트에 더한 값을 insert해서 테스트하는 것이 좋겠다.
그러면 given으로 selectById에도 기대값을 주고, 1번 실행될 것이라 then으로 확인하자.
 

/**
 * 포인트가 감소하는 기본적인 단위 테스트
 * 이 테스트가 직관적인가? 잘 모르겠음
 */
@Test
fun 포인트가_정상적으로_감소() {
    val decreasePoint = 1000L
    val mockUserPoint = makeMockUserPoint(0)
    val expectPoint = mockUserPoint.point - decreasePoint
    val willReturningUserPoint = makeMockUserPoint(-decreasePoint)
    given(userPointTable.selectById(mockUserId)).willReturn(mockUserPoint)
    given(userPointTable.insertOrUpdate(mockUserId, expectPoint)).willReturn(willReturningUserPoint)

    val resultUserPoint : UserPoint = pointService.decreasePoint(mockUserId, decreasePoint, time)

    assertThat(mockUserId).isEqualTo(resultUserPoint.id)
    assertThat(resultUserPoint.point).isEqualTo(expectPoint)
    then(userPointTable).should(times(1)).insertOrUpdate(mockUserId,expectPoint)
    then(userPointTable).should(times(1)).selectById(mockUserId)
    then(pointHistoryFactory).should(times(1)).usePoint(mockUserId, decreasePoint, time)
}

/**
 * 커스텀한 예외로 만들어서 테스트할수도 있으나, 과제 요구사항에 없으니까 패스
 */
@Test
fun 포인트가_가진것보다_더_감소할때_예외발생() {
    val decreasePoint = 2000L
    val mockUserPoint = makeMockUserPoint(0)
    val expectPoint = mockUserPoint.point - decreasePoint

    given(userPointTable.selectById(mockUserId)).willReturn(mockUserPoint)

    assertThrows<Exception> { pointService.decreasePoint(mockUserId, decreasePoint, time) }

    then(userPointTable).should(times(0)).insertOrUpdate(mockUserId,expectPoint)
    then(userPointTable).should(times(1)).selectById(mockUserId)
    then(pointHistoryFactory).should(times(0)).chargePoint(mockUserId, decreasePoint, time)
}

그리고 포인트 감소로직. 첫번째의 정상적으로 감소하는 테스트는 증가랑 비슷하니까 크게 신경쓸것은 없다.
이제 포인트가 가진것보다 더 감소할 때 예외가 발생하는지를 테스트하자.
assertThrows<Exception>으로 특정 로직을 실행했을 때 예외가 발생하는지를 테스트하도록 했다.
 
그리고 대망의 동시성 테스트
일단 동시성 테스트를 위해 통합 테스트 환경부터 만들어두어야 했다.

@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) // 테스트가 하나 끝날때마다 모든 빈을 새로 만듬 -> 상태 공유되지 않음
class PointIntegrationTest {

통합 테스트니까 @SpringBootTest 를 달아주자
UserPointTable과 포인트 내역을 저장하는 PointHisotryTable은 내부적으로 Map을 사용하기 때문에 각 테스트를 수행할 때마다BeforeEach로 기존 내역들을 모두 삭제하는 작업이 필요하다. 그 대신

@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)

이 어노테이션을 추가하여 매 테스트마다 빈을 새로 만들도록 했다. 이유가 뭐냐면 저 테이블 클래스는 수정하면 안된다는 제약조건이 걸려있어서 내가 clear 메소드를 따로 추가해둘수가 없었기 때문이다.

@Autowired
private lateinit var pointService: PointService

@Autowired
private lateinit var pointHistoryFactory: PointHistoryFactory

@Autowired
private lateinit var pointHistoryTable: PointHistoryTable

@Autowired
private lateinit var userPointTable: UserPointTable

그리고 통합 테스트에 사용될 빈들을 Autowired로 주입해주자
왜 lateinit을 달아주느냐? 코틀린은 변수가 선언될 때 항상 초기화되어야한다는 규칙이 있는데, 지금처럼 나중에 DI로 주입받는다면 lateinit을 달아 나중에 초기화된다는 것을 명시해야한다.
 
그럼 이제 동시성을 테스트해보자. 우선 동시에 여러 스레드가 한 유저의 포인트를 충전하는 상황을 가정해보자. 포인트가 얼마나 있는지 확인하고, 그 값에 증가값을 더해서 덮어씌우는 형식이기 때문에 동시에 여러 스레드가 포인트를 확인한다면, 서로의 값이 덮어씌워지는 불상사가 생길 수 있다. 코루틴을 사용해서 동시성 테스트를 작성해보자.

@Test
fun `100개의 요청이 동시에 한 유저의 포인트를 10씩 증가시킨다`() {
    val threadCount = 100
    val chargeAmount = 10L
    val expectedPoint = (threadCount * chargeAmount)

    // 내부의 모든 코루틴이 끝날 때까지 현재 스레드를 차단(대기)
    runBlocking {
        // 100개의 코루틴 작업을 동시에 실행
        val jobs = List(threadCount) {
            launch(Dispatchers.IO) {
                pointService.increasePoint(userA_ID, chargeAmount, time)
            }
        }

        // 리스트의 모든 작업(jobs)이 완료될 때까지 대기
        jobs.joinAll()
    }

    val finalUserPoint = userPointTable.selectById(userA_ID)

    assertThat(finalUserPoint.point).isEqualTo(expectedPoint)
}

이렇게 작성하고 테스트를 돌려보면? 당연히 실패한다. 그러면 이제 테스트를 통과하도록 리팩토링해보자

fun increasePoint(userId: Long, increasePoint: Long, time: Long): UserPoint {
    val curUserPoint = userPointTable.selectById(userId)
    val updatePoint = curUserPoint.point + increasePoint

    pointHistoryFactory.chargePoint(userId, increasePoint, time)

    return userPointTable.insertOrUpdate(userId, updatePoint)
}

기존에는 이렇게 코드가 작성되어있다. 동시성을 방어할수있는 어떠한 코드도 작성되어있지 않다. 하다못해 트랜잭션이라도 있으면 모르겠는데, 그것도 없다. 나는 이를 thread-safe한 ConcurrentHashMap과 ReetrantReadWriteLock을 사용하여 해결했다.

private val userLocks = ConcurrentHashMap<Long, ReentrantReadWriteLock>()

fun increasePoint(userId: Long, increasePoint: Long, time: Long): UserPoint {

    val lock = userLocks.computeIfAbsent(userId) { ReentrantReadWriteLock() }
    val writeLock = lock.writeLock()

    writeLock.lock()
    try {
        val curUserPoint = userPointTable.selectById(userId)
        val updatePoint = curUserPoint.point + increasePoint

        pointHistoryFactory.chargePoint(userId, increasePoint, time)

        return userPointTable.insertOrUpdate(userId, updatePoint)
    } finally {
        writeLock.unlock()
    }
}

그리고 테스트를 다시 돌려보면??

ㅇㅋ 통과
그러면 포인트 충전 중에 조회를 시도할 경우에는 어떻게 할까? 충전 이전에 값을 보여주어야 할까? 충전 이후의 값을 보여주어야 할까? 내 생각엔 충전이 완료될 때까지 조회를 미루고, 충전이 완료되면 조회되도록 하는게 좋을 것 같다.
그러면 이제 다음과 같이 테스트 코드를 작성할 수 있겠다.

@Test
fun `포인트 충전 중에 조회를 시도하면, 충전이 완료된 후의 포인트를 조회한다`() {
    val chargePoint = 1000L

    for (i in 1..100){

        userPointTable.insertOrUpdate(userA_ID, 0) // 0으로 초기화해두기

        runBlocking {
            val write = async(Dispatchers.IO) {
                pointService.increasePoint(userA_ID, chargePoint, time)
            }
            val read = async(Dispatchers.IO) {
                pointService.getUserPoint(userA_ID)
            }
            val writeResult = write.await()
            val readResult = read.await()

            assertThat(writeResult.point)
                .withFailMessage("Write 실패! (Run #${i})")
                .isEqualTo(chargePoint)
            assertThat(readResult.point)
                .withFailMessage("Race Condition! (Run #${i}) - 읽은 값: ${readResult.point}")
                .isEqualTo(chargePoint)
        }

    }

}

100번 정도 동시성을 테스트해보면서 테스트하기로 했다. 당연히 처음엔 실패한다. increase가 write lock을 가지고 있다 해도, read는 막히지 않기 때문이다. 이를 방지하기 위해 위에서 ReetrantReadWriteLock을 사용한 것이다.
그러면 Read Lock을 사용하도록 getUserPoint를 수정해보자.

fun getUserPoint(userId: Long): UserPoint {
    val lock = userLocks.computeIfAbsent(userId) { ReentrantReadWriteLock() }
    val readRock = lock.readLock()

    readRock.lock()
    try {
        return userPointTable.selectById(userId)
    } finally {
        readRock.unlock()
    }
}

자 이제 녹색 체크를 기대하며 두근두근한 마음으로 테스트를 돌렸는데...

실패했다!! 뭐지?
이것때문에 살짝 헤멨었는데, 생각해보면 완전한 동시란 있을수가 없다. 따라서 read이든 write이든 먼저 수행되는 쪽이 있을테고, 만약 write보다 read가 먼저 시작되면 0을 읽어오는 것이 당연한 것이었다.
내가 원하는 테스트는 충전 도중 조회가 수행되는 테스트였다. 따라서 테스트 코드의 수정이 필요했다.

@Test
fun `포인트 충전 중에 조회를 시도하면, 충전이 완료된 후의 포인트를 조회한다`() {
    val chargePoint = 1000L

    for (i in 1..100){

        userPointTable.insertOrUpdate(userA_ID, 0) // 0으로 초기화해두기

        runBlocking {

            val writeSwitch = CompletableDeferred<Unit>()

            val write = async(Dispatchers.IO) {
                writeSwitch.complete(Unit)
                pointService.increasePoint(userA_ID, chargePoint, time)
            }

            val read = async(Dispatchers.IO) {
                writeSwitch.await()
                delay(10) //항상 쓰기가 먼저 시작되도록
                pointService.getUserPoint(userA_ID)
            }

            val writeResult = write.await()
            val readResult = read.await()

            assertThat(writeResult.point)
                .withFailMessage("Write 실패! (Run #${i})")
                .isEqualTo(chargePoint)
            assertThat(readResult.point)
                .withFailMessage("Race Condition! (Run #${i}) - 읽은 값: ${readResult.point}")
                .isEqualTo(chargePoint)
        }

    }

}

CompletableDeferred를 사용해 이 인스턴스에 complete로 완료되었다는 신호를 주지 않으면, await로 기다리도록 할 수 있다.
설명이 좀 난해한데, 위 코드는 increasePoint를 getUserPoint가 기다리는 형식이다. 그런데 delay(10)을 왜 또 추가했냐면, 이렇게까지 해야 complete로 스위치가 켜지자마자, await로 기다리던 getUserPoint가 increasePoint보다도 먼저 실행될 수 있기 때문이다.
 
이렇게 하고 테스트를 돌려보면?

이렇게 잘 통과한다 !!!
의문점은 이게 정말 동시성 테스트인가? 하는 것이다 이제와서 보니까 그냥 delay만 추가해도 될 것 같기도하고...
 
이렇게 TDD를 공부해보았는데, 아직은 모호한 점이 많다.
뭔가...뭔가뭔가다

정소민fan
@정소민fan :: 코딩은 관성이야

코딩은 관성적으로 해야합니다 즐거운 코딩 되세요

목차