[Spring] H2, Mockito 안쓰고 테스트 하기 (2) - Spring Data JPA
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는 TestJpaRepository
와 BeverageRepository
를 구현한다.
(BeverageRepository가 JpaRepository를 이미 상속하고 있는데 문제없냐고 할 수 있지만, 테스트할 땐 문제없었다)
그러면 BeverageService 테스트 클래스에서 BeverageRepository 구현체로 TestBeverageRepository를 사용하면 된다😁
TestJpaRepository 구현
TestJpaRepository를 구현할 땐 2가지를 고려했다.
- 위 로직처럼 DB 역할을 해줄 ID, List 필드는 그대로 가져간다.
- 여러 타입을 받을 수 있도록 제네릭으로 구현한다. (엔티티, 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를 직접 참조할 수 없으므로 리플렉션 활용
- entity 타입이 제네릭(
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편에서 설명하겠다.
댓글남기기