4 분 소요

1편에서는 테스트용 Repository를 만들어서 직접 사용했었다!
그런데 Spring Data JPA를 사용중이라면 아마 Repository interface에 JpaRepository를 상속해서 쓸 확률이 높다.

interface BeverageRepository : JpaRepository<Beverage, Long> {
}

// Querydsl을 사용중이라면
interface BeverageRepository : JpaRepository<Beverage, Long>, BeverageRepositoryCustom {
}


문제 상황

오버라이딩할 함수가 많다

이 경우에는 BeverageRepository를 구현하는 테스트용 구체 클래스를 작성할 때 JpaRepository에서 제공하는 모든 함수를
오버라이딩해줘야 하는 번거로움이 있다. 그치만 운영 환경에서 사용하는 함수만 구현해주면 되기 때문에 큰 문제는 아닐 수 있다.

중복 로직이 많아진다

그치만 도메인이 Beverage 뿐만 아니라 여러가지가 있다고 해보자.
아무리 Spring Data JPA에서 제공하는 함수를 적게 쓰더라도, save(), findById() 등은 쓸 것이다. 그러면 이 함수들은 모든 테스트용 Repository 클래스에서 같은 로직이지만 똑같이 구현해줘야 한다. 심지어는 ID, List 등의 필드도 그렇다!

class TestBeverageRepository : BeverageRepository {

    // 필드 중복
    private val autoGeneratedId = AtomicLong(0)
    private val beverages = ArrayList<Beverage>()
    
    // 저장 함수 중복
    override fun save(beverage: Beverage): Beverage {
        return Beverage(
            id = autoGeneratedId.incrementAndGet(),
            name = beverage.name,
        ).also { beverages.add(it) }
    }
}

class TestBrandRepository : BrandRepository {

    // 필드 중복
    private val autoGeneratedId = AtomicLong(0)
    private val brands = ArrayList<Brand>()
    
    // 저장 함수 중복
    override fun save(brand: Brand): Brand {
        return Brand(
            id = autoGeneratedId.incrementAndGet(),
            name = brand.name,
        ).also { brands.add(it) }
    }
}


개선안 : JpaRepository 흉내내기

JpaRepository는 자주 사용되는 로직들을 가져다 바로 쓸 수 있도록 잘 구현이 돼있다. 테스트 환경에서도 사용할 수 있다면 좋겠지만, entityManager를 참조하고 있어서 지금처럼 DB에 의존하지 않으려는 상황에서는 적용하기 어렵다.

그러면 DB를 사용하지는 않지만 JpaRepository에서 제공하는 기능들은 사용할 수 있게끔 테스트 전용으로 구현하면 되지 않을까?

관계도

구현할 추상 클래스를 TestJpaRepository라 하면, 관계도는 아래처럼 된다.
Querydsl을 사용한다고 했을 때, BeverageRepositoryCustom은 Querydsl을 위해 만들어진 interface고
그걸 구현한 클래스를 BeverageRepositoryImpl라 하자.

?

  • TestJpaRepository는 JpaRepository를 구현

이때 테스트에서 주입할 TestBeverageRepository는 TestJpaRepositoryBeverageRepository를 구현한다.
(BeverageRepository가 JpaRepository를 이미 상속하고 있는데 문제없냐고 할 수 있지만, 테스트할 땐 문제없었다)

?

그러면 BeverageService 테스트 클래스에서 BeverageRepository 구현체로 TestBeverageRepository를 사용하면 된다😁

TestJpaRepository 구현

TestJpaRepository를 구현할 땐 2가지를 고려했다.

  1. 위 로직처럼 DB 역할을 해줄 ID, List 필드는 그대로 가져간다.
  2. 여러 타입을 받을 수 있도록 제네릭으로 구현한다. (엔티티, ID 타입)

T, ID는 JpaRepository처럼 엔티티와 ID를 제네릭으로 받는다.
(단, 엔티티 타입은 null이면 안되므로 where에 T : Any로 조건을 걸었다.)

idName을 입력받아 필드로 관리하는데, 엔티티 id 필드 이름은 다양할 수 있으므로 입력받도록 했다.
좀 더 아래에서 id를 추출하는 getId()나 id 값을를 업데이트하는 updateId()에서 리플렉션할 때 사용된다.

그리고 위에 TestBeverageRepository 코드에 있던 필드들을 여기에서 index, entityList로 정의한다.
이렇게 되면 TestJpaRepository를 상속하는 것만으로 이런 필드들을 굳이 반복해서 정의하지 않아도 된다.

abstract class TestJpaRepository<T, ID>(
    private val idName: String,
) : JpaRepository<T, ID> where T : Any {

    private val index = AtomicLong(0L) // 위에서 autoGeneratedId와 같은 역할
    private val indexSet = mutableSetOf<ID>()
    protected val entityList = mutableListOf<T>() // 위에서 beverages와 같은 역할
    ...
}
  • indexSet : 성능 향상을 위해 사용 (optional)
  • 전체 코드는 여기에서 확인할 수 있다!

이걸 상속받은 클래스를 정의할 때는 아래처럼 사용하면 된다.

// Beverage의 ID 필드 이름이 `id`라 가정
class TestBeverageRepository : TestJpaRepository<Beverage, Long>("id")

// 만약 Querydsl을 사용해 BeverageRepositoryCustom interface가 있다면 아래처럼 쓰면 됨
class TestBeverageRepository : TestJpaRepository<Beverage, Long>("id"), BeverageRepositoryCustom


함수는 JpaRepository에서 제공하는 save(), findById(), existsById() 등을 구현한다.
entityList에 저장하거나 조회하는 등의 로직만 넣어주면 되서 어렵지 않다.

override fun <S : T> save(entity: S): S {
    Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL)
    upsert(entity) // 아래에서 설명
    return entity
}

override fun findById(id: ID): Optional<T> {
    Assert.notNull(id, ID_MUST_NOT_BE_NULL)
    return Optional.ofNullable(entityList.find { it.getId<T, ID>() == id })
}

override fun existsById(id: ID): Boolean {
    Assert.notNull(id, ID_MUST_NOT_BE_NULL)
    return entityList.any { it.getId<T, ID>() == id }
}
  • Assert.notNull() : 제네릭으로 들어오는 파라미터가 null인지 검증
  • id로 비교가 필요한 경우 entityList의 요소인 entity의 id 값은 getId<T, ID>()를 사용
    • entity 타입이 제네릭(S)으로 들어왔기 때문에 내무 필드인 id를 직접 참조할 수 없으므로 리플렉션 활용
  • flush() 같은 함수는 여기서 필요없으므로 body를 비워놓음

save() 안에 upsert()는 update or insert를 하는 함수다.
isNew()로 검증해 Long, Int, String이 기본값이면 update, 그렇지 않으면 insert를 수행한다.
update하는 경우 기존 entity가 entityList에서 삭제돼야 하므로 지우고 새로 만든 entity를 삽입한다.

private fun <S : T> upsert(entity: S) {
    val requestId = entity.getId<T, ID>()
    if (isNew(requestId)) {
        updateId(entity)
    } else {
        entityList.removeIf { it.getId<T, ID>() == requestId }
    }
    entityList.add(entity)
}

private fun isNew(id: Any?): Boolean {
    if (id == null) return true
    return when (id) {
        is Long -> id == 0L
        is Int -> id == 0
        is String -> id.isEmpty()
        else -> throw IllegalArgumentException("Unsupported id type: ${id::class.simpleName}")
    }
}

private fun <S : T> updateId(entity: S) {
    entity::class.java.getDeclaredField(idName).apply {
        isAccessible = true

        generateId(type).let { newIndex ->
            set(entity, newIndex)
            if (indexSet.contains(newIndex)) {
                entityList.removeIf { it.getId<T, ID>() == newIndex }
            } else {
                indexSet.add(newIndex)
            }
        }
    }
}
  • updateId() : entity의 id를 직접 참조할 수 없기 때문에 리플렉션으로 값을 조작해 새 id 값으로 초기화함
  • generateId() : AtomicLong.incrementAndGet()이나 UUID.randomUUID()로 새 id 값 생성

getId()는 T 타입인 entity의 id 값을 리플렉션으로 얻어내는 함수다.
테스트 함수에서 사용할 수도 있으므로 접근 제한자는 protected로 설정했다.

protected fun <T : Any, ID> T.getId(): ID? {
    return this::class.memberProperties
        .firstOrNull { it.name == idName }?.getter?.call(this) as? ID
}
  • this::class : T(this)의 KClass
  • T의 모든 필드를 돌면서 위에서 입력한 idName과 같은 필드를 찾아 값을 반환

JpaRepository를 사용하지 않아도 쓸 수 있다

TestJpaRepository는 JpaRepository를 상속받아 구현한 추상 클래스다.
그래서 코드상으로는 JpaRepository에 의존하는 클래스라고 볼 수 있다.

그치만 JpaRepository를 사용하지 않는 코드라고 해도, save(), findById(), findAll()의 역할은 하면서
이름만 다른 함수들이 있을텐데 함수명만 일치시켜준다면 얼마든지 활용할 수 있다.
JpaRepository를 사용하지 않는 환경에서도 문제없이 도입할 수 있을 것이다!

문제점 : join이 필요한 상황

TestRepository별로 DB를 하나씩 갖고 있는 개념이기 때문에 join 쿼리가 있는 TestRepository인 경우 난감해진다.
Beverage 엔티티가 Food, Place라는 엔티티와 연관관계를 맺고있다고 해보자.
근데 TestBeverageRepository에 있는 DB는 Beverage를 위한 DB이기 때문에 Food, Place를 처리할 수 없다.
그래서 TestFoodRepository, TestPlaceRepository를 필드로 가져야 한다.

class TestBeverageRepository : JpaRepository<Beverage, Long> {
    private val foodRepository: TestFoodRepository<Food, Long>()
    private val placeRepository: TestPlaceRepository<Place, Long>()
    ...
}

그치만 이 경우 Repository 간에 같은 엔티티를 관리하는 케이스에서 정합성이 깨지는 문제가 발생한다.
단위 테스트로 개발하는 경우가 많을거라 개인적으로 해당되는 케이스는 많지 않을거라 생각된다🤔

자세한 문제 상황 및 개선 방법은 3편에서 설명하겠다.

References

카테고리:

업데이트:

댓글남기기