본문 바로가기

3.구현/Java or Kotlin

[kotlin] JPA 14: Spring Boot에서 프록시와 예외처리

들어가기

알아두면 도움이되는 프록시, 그리고 예외에 대해서 살펴보자.

작성자: ospace114@empal.com, http://ospace.tistory.com/

프록시

JPA에서는 프록시는 엔티티를 로딩하지 않고 이를 대신하는 객체로 사용된다. 그리고 실제 사용 시점에 엔티티를 로딩하는 역할을 한다. 이는 지연 로딩을 위한 기능으로 성능 최적화 용도로 사용된다. 프록시 구현 방식은 엔티티 클래스를 상속해서 프록시 클래스를 구현한다. 이때 사용하는 프록시 클래스는 단순 객체이면 org.hibernate.proxy.HibernateProxy을 사용하고 리스트 객체이면 org.hibernate.collection.spi.PersistentBag을 사용한다. 이런 프록시를 통해 원본 엔티티의 지연 로딩 같은 기능을 구현하고 있다.

@Entity
open class Post(
    @ManyToOne(fetch=FetchType.LAZY)
    private owner: Member?,
    //...
) {
//...
}

주의할 부분은 Member 엔티티 정의할때에도 open 형태로 정의해야 한다. 런타임에서 상속에 의해서 재정의되기 때문에 open이 없으면 상속되지않아 호출시 에러가 발생한다. 위의 owner 값을 추출해서 클래스 이름을 출력하면 com.tistory.ospace.member.entity.Member$HibernateProxy$iHWpLdeB 처럼 표시된다. 클래스 이름은 “$” 구분자로 세가지 요소가 구성된다. 앞에 원본 엔티티인 Member가 오고, 다음은 프록시인 HibernateProxy가 오며, iHWpLdeB은 유니크 식별자로 사용된다. 이런 형태로 기존 Member 엔티티를 상속한 프록시 객체를 하이버네이트에서 자동 생성하고 관리한다.

Lazy로 설정된 속성을 획득하면 프록시 객체를 얻을 수도 있지만 엔티티 매니저의 getReference()와 엔티티 ID로 조회해서 지연 로딩에 의한 프록시 객체를 얻을 수 있다.

    override fun getMemberById(id: Long): Member? {
        return em.getReference(Member::class.java, id)
    }

프록시 객체로 인해 데이터베이스 접근을 지연시켜 필요할 때 데이터베이스 조회를 수행하여 데이터베이스 접근을 줄임으로 성능 최적화를 가능하게 한다.

이런 프록시에 대해서 두가지 관점에서 살펴보려고 한다. 하나는 엔티티 비교이고 다른 하나는 JSON 변환이다.

엔티티 비교

엔티티의 비교는 일반 객체 비교와 비슷하다. 차이점이라고 하면 데이터베이스 유무에 있다. 비교에 있어서 동일성과 동등성이 있다. 동일성이라는 것은 같은 객체를 의미하고 동등성은 객체는 다르지만 같은 값을 가진다. 동일성 측면에서는 별다른 이슈가 없기에 동등성을 살펴보자.

동등성에서 있어서 객체 관점과 데이터베이스 관점으로 나눌 수 있다. 객체의 동등성은 단순하다 속성 값 들이 모두 같은지를 비교하면 된다. 데이터베이스는 레코드 관점에서 식별자 값이 같다면 나머지는 확인할 필요 없이 같은 데이터라고 판단할 수 있어서 비교가 더 단순한다. 그러나 이를 엔티티 관점에서 보면 데이터베이스와 객체가 고려되어야 하기 때문에 메모리 상에서 값이 변경된 상태, 데이터베이스 동기 상태, 버전 관리 등에 의해서 상태 변경, 다중 데이터베이스 등을 고려될 수 있다.

가장 간단하게 엔티티 비교는 equals()을 사용한 방법이다. 다음과 같이 Member의 equals()를 구현할 수 있다.

open class Member(
  @Id
  private var id: Long? = null,
  private var name: String? = null,
) {
  //..
  override fun equals(other: Any?): Boolean {
    if (null == other) return false
    if (this === other) return true    // 동일성
    if (this::class != other::class) return false
    other as Member
    return when { // 동등성
        name != other.name -> false
        // ...
        else -> true
    }
  }
}

other가 null인지 확인하고 “this === other”로 동일성을 체크한 후에 같은 클래스 타입 확인을 위해 “this::class != other::class”로 확인하고 있다. 여기서 한가지 고려할 부분은 Member의 프록시 클래스는 Member$HibernateProxy$xxxx로 생성되고 클래스 타입 확인해서 에러가 발생한다. 이를 해결하는 방법은 다음과 같다.

override fun equals(other: Any?): Boolean {
  if (null == other) return false
  if (this === other) return true    // 동일성

  return if (other is Member) when { // 동등성
      name != other.name -> false
      // ...
      else -> true
  } else false
}

“other is Member”로 Member을 상속한 객체인 프록시 객체에서도 확인할 수 있다. 물론 범용성은 떨어지지만 프록시에 대해서 문제 없이 동작한다.

추가로 고려한 부분으로 엔티티가 상속 관계에 있을 경우이다. 아래와 같이 슈퍼타입으로 Member가 있고 서브타입으로 FreeMember가 있다.

Fig 14-1. 상속관계와 프록시

FreeMember 엔티티 객체가 영속 컨텍스트에 있는 상태에서 해당 엔티티를 프록시 객체로하고 Member 타입으로 조회할 경우를 보자. 그러면 조회된 객체는 Member&HibernateProxy$xxxx 타입이 된다. 내부적으로 FreeMember 엔티티이지만 Member 프록시 객체이기에 FreeMember 객체와 비교하면 실패한다. 만약 강제로 다운캐스팅에 예외가 발생할 수 있다.

이에 대한 해결 방법은 단순하다.

  • 조회할 대상에 대해 슈퍼타입이 아닌 정확한 엔티티 타입으로 조회
  • 프록시 객체가 생성되지 않도록 조회(LAZY 미사용, 직접 조회, 프록시 제거)
  • 슈퍼타입에 값 비교할 속성에 접근할 수 있는 인터페이스 제공

가장 간단한 해결법은 프록시 객체를 생성하지 않게 한다. 즉, 직접 조회하면 된다. 그리고 LAZY을 사용하지 않거나 사용한다면 엔티티그래프로 전략을 변경할 수도 있다. 아니면 프록시가 생겼다면 프록시에 있는 원본 엔티티 객체를 추출할 수도 있다.

@Suppress("UNCHECKED_CAST")
fun <T> unProxy(entity: Any?): T {
    var ret = entity
    if (null != ret && ret is HibernateProxy) {
        ret = ret.hibernateLazyInitializer?.implementation
    }
    return ret as T
}

unProxy()로 프록시 객체에 있는 원본 엔티티를 추출해서 리턴한다. 다른 방법으로는 조회용 메소드를 인터페이스에 추가해서 액세스할 수 있게 한다.

@Inheritance
open abstract class Member {
  fun getPoints(): Int
  //...
}

이를 상속해서 구현하면 된다.

@Entity
open class FreeMember : Member {
  override fun getPoints(): Int {
    //...
  }
}

슈퍼타입으로 조회한다고 해도 인터페이스를 통해서 접근할 수 있고 동등성 확인도 문제는 없다. 그러나 엔티티 비교 관점에서 보면 모든 값을 비교해야하기 때문에 상속해서 확장할때에 추가되는 값에 대해서 접근할 수 있는 인터페이스를 모두 추가해야한다는 문제점이 있다.

엔티티 직렬화 이슈

Spring Data에서는 엔티티가 JSON으로 변환할 경우에 보통 Serializer에 의해 jackson으로 변환작업을 진행한다. 한가지 주의 사항은 Serializer에 의해서 변환하는 과정에서 해당 엔티티 Proxy에서 Serializer가 없어서 에러가 발생한다. 발생하는 예외는 HttpMessageConversionException 로서 “No serializer found for class ~” 메시지가 발생한다. 마지막 메시지지인 “Hobby$HibernateProxy$GI0UNBDz["hibernateLazyInitializer"])”에서 중요한 에러원인을 확인할 수 있다. 이를 해석하면 HibernateProxy 객체에서 “hibernateLazyInitializer” 속성을 접근하려고 했지만 빈 객체라서 에러가 발생했다. 이 에러는 다음과 같이 간단하게 해결할 수 있다.

spring.jackson.serialization.fail-on-empty-beans=false

물론 런타임에 매퍼객체 옵션에 해당 경우 FAIL_ON_EMPTY_BEANS을 설정할 수 도 있다.

val mapper = ObjectMapper()
mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, true)

이 경우 해당 매퍼객체를 사용해서 직접 직렬화해야한다. 앞의 방법들은 빈 객체를 직렬화할 경우 실패해도 예외를 발생하지 않도록 한다. 그러면 “hibernateLazyInitializer”가 “{}”으로 변환된다. 이렇게 "hibernateLazyInitializer”가 결과에 표시되도 문제가 없다면 상관없지만 허용하지 않을 경우도 있다. 이를 해결하기 위해 앞의 설정 없이 추가적인 작업이 필요하다. 즉, 프록시 객체가 아닌 일반 엔티티 객체로 변경하면 된다.

val hobby = member.getHobby()
if (hobby is HibernateProxy) {
    member.setHobby(hobby.hibernateLazyInitializer?.implementation as Hobby)
}

위의 코드는 HibernateProxy 인터페이스를 통해 원본객체를 획득하여 다시 member 객체로 설정하고 있다. 앞에 unProxy()을 사용하면 좀더 간단하게 처리할 수 있다.

member.setHobby(unProxy(member.getHobby())

엔티티를 바로 JSON으로 변환하는 형태는 그다지 바람직하지는 않다. 물론 아주 간단한 경우에 제한적으로 사용할 수 있겠지만, 가급적 DTO 같은 객체로 변경해서 JSON으로 변환하는게 좋다.

예외처리

JPA에서 표준 예외는 크게 2 종류로 구분할 수 있다. 트랜잭션에 있어서 롤백은 다시 처리하기 전의 원래 상태로 돌아가는 과정이다. 이는 처리하는 중간에 더이상 처리할 수 없어서 데이터 무결성을 위해 실행되는 과정이다. 예외에서 어떤 경우에 롤백해야하는지 할필요가 없는지 판단은 중요하다. JPA에서도 예외를 하나는 트랜잭션 롤백하는 예외와 롤백하지 않는 예외로 구분한다. 단어 의미그대로 전자는 롤백하는 경우에 해당하는 예외이고, 후자는 롤백과는 무관한 예외이다.

JPA 예외는 javax.persistence 패키지에 포함되어 있다. JPA의 모든 예외는 PersistenceException의 서브클래스이며 RuntimeException에 해당한다. 모든 JPA 예외는 Spring Data 예외로 변환된다. PersistenceException는 Spring Data에서는 JpaSystemException으로 변환된다.

트랜잭션 롤백하는 예외

트랜잭션 롤백 예외는 롤백하기 위한 예외로서 심각한 경우이다. 그렇기 때문에 중간에 예외 처리하고 난후에 예외를 던지지 않는다면 롤백되지 않을 수 있고 이로 인해 데이터 무결성이 깨질수 있다. 그렇기에 가급적이면 중간에서 예외 처리하면 안 되고, 만약에 한다면 반드시 예외를 던져야 한다.

JPA 예외 Spring Data 변환 예외 설명
EntityExistsException DataIntegrityViolationException 동일한 엔티티가 존재할 경우
EntityNotFoundException JpaObjectRetrievalFailureException 엔티티가 존재하지 않은 경우
OptimisticLockException JpaOptimisticLockingFailureException 난관적 락 충돌시
PerssimisticLockException PessimisticLockingFailureException 비관적 락 충돌시
RollbackException TransactionSystemException 커밋 실패시
TransactionRequiredException InvalidDataAccessApiUsageException 트랜잭션 없이 엔티티 변경시

트랜잭션 롤백하지 않는 예외

반대로 트랜잭션 롤백하지 않는 예외는 예외처리할 수 있는 예외이다. 예외처리하고 이후 작업을 계속진행할 수 있다.

예외 Spring Data 변환 예외 설명
NoResultException EmptyResultDataAccessException singleResult에서 결과가 없을 경우
NonUniqueResultException IncorrectResultSizeDataAccessException singleResult에서 결과가 두개 이상인 경우
LockTimeoutException CannotAcquiredLockException 비관적 락 대기시간 초과시
QueryTimeoutException QueryTimeoutException 쿼리 실행 대기시간 초과시

주의 사항

예외로 인해 롤백으로 발생한 경우에 있어서 주의사항이다.

  • 롤백은 데이터베이스만 적용되며, 엔티티는 그대로 유지된다.
  • 영속 컨텍스트가 트랜잭션 범위 안이라면 문제 없지만 그렇지 않으면 초기화해야 한다.
  • 스프링에서는 AOP 범위에서 트랜잭션과 영속 컨텍스트가 같이 관리되므로 문제가 없다.

마무리

간단하게 프록시와 예외에 대해서 살펴보았다. 프록시는 인지하지 못하지만 내부에서 빈번하게 사용된다. 알아두고 있어야 이와 관련된 에러가 발생할때 원인 파악하기 쉬워진다. 그리고 예외는 에러가 발생할 때 반드시 확인하고 처리해야할 부분이므로 무엇이 있는지, 또한 예외처리할 범위를 아는게 중요하다. 엄청중요하지 않지만 신경써야할 부분을 간단하게 정리해보았다.

부족한 글이지만 여러분에게 도움이 되었으면 하네요. 모두 즐거운 코딩생활되세요. ospace.

참고

[1] 김영한, 자바 ORM 표준 JPA 프로그래밍, 에이콘, 2015

[2] Hibernate ORM User Guide, https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html

[3] Suraj Mishra, Scroll API in Spring Data JPA, https://www.baeldung.com/spring-data-jpa-scroll-api, 2023.09.05

반응형