Technology/Others

선배 Proxy 고려해서 equals 오버라이드 해주세요, 탕탕 후루후루~

zin_ 2024. 7. 26. 17:18

CI에서만 test가 통과하지 않는 이상한 현상이 생겼다.

해당 test code 파일만 돌렸을 땐, 우연히 A와 B의 id 값이 동일해서 통과했던 것이다.

그러니까 A와 B를 equals 비교했을 때 통과하면 안 됐던 것이다.

 

 

How?

 

 

JPA Entity를 사용하면서 Id와 같은 공통 코드들을 상위로 끌어올리기 위해 BaseEntity를 다음과 같이 만들어뒀다.

 

@MappedSuperclass
abstract class BaseEntity(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
) : Serializable {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is BaseEntity) return false

        if (id != other.id) return false

        return true
    }

    override fun hashCode(): Int {
        return id?.hashCode() ?: 0
    }
}

 

침착맨처럼 침착하게 살펴보면 type 비교를 하지 않는다는 걸 알 수 있다.

처음엔 단순하게 "type 비교 추가하면 되지!" 하고 아래처럼 추가했다.

 

@MappedSuperclass
abstract class BaseEntity(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
) : Serializable {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is BaseEntity) return false
        
        if (other::class != this::class) return false

        if (id != other.id) return false

        return true
    }

    override fun hashCode(): Int = id?.hashCode() ?: 0
}

 

other::class != this::class 이 친구로 비교하면 뭐가 문제일까?

우리가 JPA를 사용한다는 점을 고려해 침착하게 생각해보면, Proxy를 떠올릴 수 있다.

 


 

Proxy란, Lazy Loading에 의해서 DB에서 아직 정보를 읽어오지 않은 가짜 Entity Object이다.

그럼 이 친구는 어떻게 만들어지나?

 

'가짜' Entity Object라는 표현처럼 짝퉁인건데, Entity Object와 똑같이 생겼다는 것이다.

그래서 해당 Entity Object를 상속받아서 짝퉁을 제작한다.

 

 

So what?

 

Class 간에 타입 비교를 할 때, 우리는 의도치 않게 가짜 Entity Object인 Proxy Class와 실제 Entity Object의 Class를 비교하고 있을 수 있다. 이렇게 되면 당연히 KREAM에서 검수에 불합격한다.

 

JPA 구현체로 Hibernate를 쓰니까 HibernateProxy인지 판단하는 로직을 추가해야한다.

 

@MappedSuperclass
abstract class BaseEntity(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
) : Serializable {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true

        if (other !is BaseEntity) return false

        if (getClass(this) != getClass(other)) return false

        if (id != other.id) return false

        return true
    }

    override fun hashCode(): Int = id?.hashCode() ?: 0

    private fun getClass(entity: BaseEntity): Class<*> {
        return when (entity) {
            is HibernateProxy -> entity.hibernateLazyInitializer.persistentClass::class
            else -> entity::class
        }
    }
}

 

하지만 우리는 Hibernate만을 사용한다고 자신할 수 있을까?

더 나은 범용적인 비교 방법은 없을까?

 

물론 Hibernate 없이 JPA를 쓰는건 상상도 해보지 못했다.

하지만 우리는 더 나은 개발을 하기 위해 JPA의 특정 구현 기술에 종속되는 코드를 작성하지 않도록 해보자.

 

KClass에는 isSubClassOf()라는 메소드가 있다.

 

@MappedSuperclass
abstract class BaseEntity(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
) : Serializable {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true

        if (other !is BaseEntity) return false

        if (!this::class.isSubclassOf(other::class) || !other::class.isSubclassOf(this::class)) return false

        return id == other.id
    }

    override fun hashCode(): Int = id?.hashCode() ?: 0
}

 

 

위 코드는 두 개의 Entity를 비교할 때, 둘 중 어느 것이든 Proxy가 포함되어 있어도 다음과 같이 비교해준다.

 

fun KClass<*>.isSubclassOf(base: KClass<*>): Boolean =
    this == base ||
            DFS.ifAny(listOf(this), KClass<*>::superclasses) { it == base }

 

 

Tidy First?

 

저 조건문이 나의 의도를 잘 전달할 수 있도록 compareClassesIncludeProxy()라는 메소드로 추출해주었다.

 

@MappedSuperclass
abstract class BaseEntity(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
) : Serializable {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true

        if (other !is BaseEntity) return false

        if (!compareClassesIncludeProxy(other)) return false

        return id == other.id
    }

    private fun compareClassesIncludeProxy(other: Any) =
        this::class.isSubclassOf(other::class) || other::class.isSubclassOf(this::class)

    override fun hashCode(): Int = id?.hashCode() ?: 0
}

 

비교하는 두 개의 Entity Class가 모두 방금 생성된 id가 null인 경우도 꼼꼼하게 검수해주자.

 

@MappedSuperclass
abstract class BaseEntity(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
) : Serializable {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true

        if (other !is BaseEntity) return false

        if (!compareClassesIncludeProxy(other)) return false

        if (id == null || other.id == null) return false

        return id == other.id
    }

    private fun compareClassesIncludeProxy(other: Any) =
        this::class.isSubclassOf(other::class) || other::class.isSubclassOf(this::class)

    override fun hashCode(): Int = id?.hashCode() ?: 0
}

 

 


 

요즘 부쩍 "경우의 수"를 고려하는 것을 연습하고 싶어졌다.

현실에서 나와 내 주변 상황을 고려하듯, 코드에서도 내가 짜려는 코드와 주변에 구현되어 있는 코드와 기술들의 상황을 고려하는 것이다.

연습하면 위와 같은 더 나은 개발을 더 자주 할 수 있지 않을까?

반응형