여러분이 열심히 짠 테스트는 안녕하십니까?

DevSeoRex·2025년 1월 18일
2
post-thumbnail

안녕하세요.
오늘은 테스트 코드 작성 방법에 대해 고민한 내용을 나눠보고자 합니다.
사내에서 수많은 테스트 코드를 작성하고 관리하는 많은 개발자 분들이 계실텐데요. 이렇게 한 번 질문해보고 싶습니다. "여러분이 열심히 짠 테스트는 안녕하십니까?"

사내에서 리팩토링을 수행하면서 비어있는 테스트 코드 폴더를 채워나가며 꽤나 많은 테스트를 단기간에 작성해오면서 생긴 문제들과 앞으로 테스트를 작성할 때 어떻게 작성해야 할지 결정한 나름의 기준에 대해서 생각을 나누려고 이 글을 작성하게 되었습니다.

🛎 이 글은 개인적인 생각이 많이 내포되어 있으므로, 기존 여러 이론부합하지 않는 주장이 있을 수도 있습니다.

🙂 테스트 코드 이대로 괜찮은가?

사내에서 테스트 코드를 작성할때 어떻게 작성하고 계신가요?
저는 아래와 같은 기준을 잡고 테스트 코드를 작성하고 있고, 그 기준에 따라 많은 테스트를 작성했습니다.

  • Repository Test는 Mocking 하지 않고 Inmemory-DB를 활용해 실제 쿼리를 실행한다.
  • Service Test는 Repository에 대해서 Mocking 하고 Business Layer에서 나오는 예외를 검증한다.
  • Controller Test는 요청에 대한 검증과 올바른 응답이 나오는지 여부를 검증하는 것에 검증한다.

대부분의 프로젝트에서는 Database에 접근해서 쿼리를 하는 Persistence-Layer가 포함되어 있습니다.
Persistence-LayerDatabase에 직접 접근해서 쿼리를 해보고 정상적으로 동작하는지 검증하는 것이 중요하다는 판단이 들어서 예외적으로 Repository Test 만큼은 직접 동작시켜 봐야 한다는 생각을 했습니다.

다만 Test Code를 작성함에 있어서 제 1원칙으로 삼는 것은 어떤 환경에서 몇 번을 수행시켜도 늘 같은 결과를 보여줘야 한다는 것입니다.

운영환경이나 개발환경에서 DB의 일시적인 장애라던지 DB가 꺼져있거나 네트워크 문제가 있는 등의 문제가 생김으로 인해서 Test실패하는 것까지도 이 원칙에 부합하지 않는다는 생각을 하게 되었습니다.

Business-Layer부터 Mocking으로 쌓아올려서 Repository 부터 검증을 해왔기 때문에 나머지 부분은 Mocking 해도 된다고 생각을 했습니다.

이 원칙을 잘 지켜서 테스트 코드를 작성해서 문제가 안생겼다면 이런 글을 작성하지 않았겠지만 이번에 제가 테스트 코드가 제대로 작성되지 않아서 생긴 문제와 이 문제에 대해서 앞으로 어떻게 방식개선할건지 나눠보고자 합니다.

😮 Test Code 어떻게 작성할 것인가?

사내에서 한 마이크로 서비스에 대해서 리팩토링을 수행하고 테스트 코드를 작성하고 있었습니다.
그 와중에 어떤 Service Class에서 Repository를 단순히 호출하는 일만 하는 함수만 5개 정도 존재하는 클래스였습니다.

이런 함수에 대해서 테스트 코드작성할 것인가? 이것에 대해서 고민하게 되었습니다.

@Service
@Transactional(readOnly = true)
class LoadBoardService(
	private val boardRepository: BoardRepository,
) : LoadBoardUseCase {

	override fun loadBoardById(id: Long) = boardRepository.findById(id)
}

위와 같은 함수가 있는 클래스에서 테스트 코드를 과연 작성해야 할까? 라는 의문이 들었어요.
그렇다면 이 함수를 테스트 하려면 어떻게 테스트를 작성해야 할까요?

internal LoadBoardServiceTest : BusinessLayerTestSupport() {
	private val boardRepository = mockk<BoardRepository>()
    private val loadBoardService = LoadBoardService(boardRepository)
    
    this.Given("게시글 하나를 조회하려는 상황에서") {
    	val boardReponse = fixture<BoardResponse>()
        every { boardRepository.findById(any<Long>()) } returns boardResponse
        
        When("조회를 시도하면") {
        	val actualResult = loadBoardService.loadBoardById(1L)
            
        	Then("정상적으로 조회되어야 한다.") {
            	actualResult shouldBe boardResponse
                verify(exactly=1) { boardRepository.findById(any<Long>()) }
            }
        }
    }
}

생각보다 많은 양의 코드를 작성하지는 않지만 이것 또한 비용이라는 생각이 들었습니다.
여기서 생각해봐야 할 부분은 테스트를 위한 테스트를 작성하고 있지는 않은가? 라는 부분입니다.

테스트 코드는 장기적으로 코드안정성을 높여주고 그로 인해 많은 버그를 예방할 수 있으면 CI/CD 환경에서 지속적으로 검증되고 통합되게 됩니다.

하지만 테스트 코드 역시 지속적으로 관리해야 하는 부채에 해당되며 테스트 코드 또한 품질이 저하될경우 리팩토링의 대상이 되어야 합니다. 이는 지속적으로 비용증가시키는 행위인 것입니다.

지금껏 테스트 코드를 작성해오면서 이 서비스 클래스도 언젠가는 더 많은 책임을 지게 될 것이고 "미리 테스트를 작성해두어야 해!" 라는 생각으로 부채를 늘려가고 있었던 것입니다.

테스트 코드를 작성하는 것이 반드시 비용감소시키는 행동이 될 수 없습니다.
테스트 코드를 관리하는 것은 분명한 비용이 있으며 그 비용이 전체적인 프로덕션 안정화에 쓰던 기존 비용이 저렴할때 테스트 코드와 TDD의 의미를 갖출 수 있는 것입니다.

🥹 Business-Layer Test 이제는 Classist로 해야 하는가?

사내에서 대부분의 프로젝트에서는 JPA를 사용하고 있습니다.
Business-Layer에서 특정 Repository 들을 호출하고 그로 인해 값이 변경되어야 하는 로직이 있다고 했을때 Mocking을 사용해서 테스트를 하면 문제가 왜 생기는지 보겠습니다.


interface BoardRepository : JpaRepository<Board, Long> {
	@Modifying(flushAutomatically = true, clearAutomatically = true)
    @Query("some modifying query~")
	fun modifyBoardStatus(boardId: Long, status: Status)
}

@Service
@Transactional
class ModifyBoardService(
	private val boardRepository: BoardRepository,
) : ModifyBoardUseCase {

	override fun modifyBoard(modifyBoardServiceRequest: ModifyBoardServiceRequest) {
    	// Board 조회
        val board = boardRepository.findById(modifyBoardServiceRequest.boardId)
        
        // Board 상태 변경 쿼리 실행
        boardRepository.modifyBoardStatus(modifyBoardServiceRequest.boardId, Status.Common)
        
        // 내부 상태 변경 -> Dirty Checking으로 쿼리가 나갈 것을 기대
        board.setBoardName("새로운 게시글")
    }
}

이런 서비스 클래스Mocking해서 테스트 하면 어떤 문제가 생길까요?
문제를 단 한번에 캐치하셨다면 최고의 JPA Master 이십니다! 저는 이 문제를 찾는데 꽤나 많은 시간을 보냈습니다.

여기서 문제는 Repository의 변경 쿼리가 나가는 함수에 있습니다.

interface BoardRepository : JpaRepository<Board, Long> {
	@Modifying(flushAutomatically = true, clearAutomatically = true)
    @Query("some modifying query~")
	fun modifyBoardStatus(boardId: Long, status: Status)
}

이 코드에서 영속성 컨텍스트를 비우는 옵션을 true로 켜두었기 때문에 문제가 생깁니다.
Board를 최초 조회했을때 이 엔티티는 영속성 컨텍스트에서 관리하게 되고 영속성 컨텍스트에서 관리하는 엔티티만 내부 상태를 변경했을때 트랜잭션 종료시에 스냅샷과 비교하여 변경 쿼리가 나가게 됩니다.

우리는 이걸 변경 감지 즉, Dirty-Checking 이라고 합니다.

그렇다면 이 서비스 클래스의 함수는 어떤 오류를 가지고 있을까요?

    	// Board 조회
        val board = boardRepository.findById(modifyBoardServiceRequest.boardId)
        
        // Board 상태 변경 쿼리 실행
        boardRepository.modifyBoardStatus(modifyBoardServiceRequest.boardId, Status.Common)
        
        // 내부 상태 변경 -> Dirty Checking으로 쿼리가 나갈 것을 기대
        board.setBoardName("새로운 게시글")

코드를 살펴보면 아래와 같은 순서로 동작합니다.

  1. Board 엔티티를 하나 조회한다
  2. Board 상태를 변경하는 쿼리가 실행되는 함수를 호출한다.
  3. Board 엔티티의 이름을 변경한다 -> Dirty-Checking 동작을 기대한다.

여기서 문제는 Dirty-Checking이 예상한대로 동작하지 않는 것이 가장 큰 문제였습니다.
2번에서 BoardRepository 함수를 호출할때 영속성 컨텍스트를 비웠기 때문에 Board 엔티티는
영속성 컨텍스트에서 관리되지 않으므로 변경 감지가 동작하지 않아서 Mocking한 테스트 코드로는 이 상황을 인지할 수 없었습니다.

그래서 이 글의 결론으로 저만의 테스트 코드 작성 원칙을 정했습니다.

🤩 결론 (다음으로...)

저는 이번 이슈를 해결하며 이번 일을 계기로 아래와 같은 테스트 코드 작성 방향을 정했습니다.

  • 모든 클래스의 함수에 대해서 테스트를 작성하지 않는다!
    • 테스트 코드도 관리 비용이 들기 때문에 단순 호출이나 복잡한 비즈니스가 있지 않으면 테스트를 작성하지 않는다.
  • Business-Layer 테스트는 Classsist Style로 작성한다!
    • JPA Dirty Checking과 같이 런타임에만 동작을 확인할 수 있는 경우에는 실제 구현체를 동작시켜봐야 하기 때문에 Mocking을 하지 않기로 했습니다.
  • 외부 서비스 호출이나 HttpClient 요청 등에 대해서만 Mocking을 한다.
    • 외부 서비스 장애나 Http 요청 실패 등으로 테스트가 실패하면 안되며, 특정 상황에서 어떤 응답이 올것이라는 기본 기대값을 가지고 테스트를 하는 것이 맞다고 판단이 들었습니다.

테스트 코드를 작성하면서, 테스트 코드는 비용이고 추후에 기술 부채가 될 여지가 다분하다는 것을 인지하고 현재 시스템을 안정화 시키고 리팩토링 내성이 강한 애플리케이션을 만들기 위한 초석으로 테스트 코드를 보수적으로 작성해 나가는 것이 중요하다는 것을 알게 된 사건이였습니다.

저는 이번 일로 데이터가 잘못 들어가고 있다는 것을 알게되어 데이터 보정 스크립트를 작성하고 약 200만건 이상의 데이터를 수정하게 되었습니다.

값비싼 수업료를 냈지만, 그래도 시행착오를 겪으며 성장하는 제 모습을 기대하며 이 글을 마칩니다.
오늘도 제 글을 읽어주셔서 감사합니다!

🙇‍♂️

1개의 댓글

comment-user-thumbnail
5일 전

테스트에 대한 가치관과 재미있는 일화네요! 글 감사합니다!

답글 달기