사건의 발단

어느 날, 잘 되던 API 에서 이런 오류가 나옵니다.

Estimate 라는 엔티티를 들고 있는 Tender 라는 데이터를 저장하려는데 아래 오류 발생. (estimate 도 tender 를 참조하고 있는 상황)

Call to TraversableResolver.isReachable() threw an exception

로그를 까보다보니

Caused by: org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.healing.어쩌구.. 등이 나옵니다. 왠지 hibernate proxy object 와 연관이 있어보입니다.

javax.validation.ValidationException: HV000041: Call to TraversableResolver.isReachable() threw an exception.
	at org.hibernate.validator.internal.engine.ValidatorImpl.isReachable(ValidatorImpl.java:1621)
	at org.hibernate.validator.internal.engine.ValidatorImpl.isValidationRequired(ValidatorImpl.java:1597)
	at org.hibernate.validator.internal.engine.ValidatorImpl.validateMetaConstraint(ValidatorImpl.java:609)
...중략
	at org.hibernate.internal.SessionImpl.save(SessionImpl.java:707)
	at org.hibernate.internal.SessionImpl.save(SessionImpl.java:702)
	at com.healing.beauty.dao.BaseDao.saveNew(BaseDao.java:114)
	at com.healing.beauty.service.EstimateService.add(EstimateService.java:90)
...중략
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
	at java.lang.Thread.run(Thread.java:745)
Caused by: org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.healing.beauty.domain.Tender.estimates, could not initialize proxy - no Session
	at org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:576)
	at org.hibernate.collection.internal.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:215)
	at org.hibernate.collection.internal.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:555)
	at org.hibernate.collection.internal.AbstractPersistentCollection.read(AbstractPersistentCollection.java:143)
	at org.hibernate.collection.internal.PersistentSet.hashCode(PersistentSet.java:447)
	at com.healing.beauty.domain.**Tender**.hashCode(Tender.java:28)
	at com.healing.beauty.domain.**Estimate**.hashCode(Estimate.java:16)
	at org.hibernate.validator.internal.engine.resolver.CachingTraversableResolverForSingleValidation$TraversableHolder.buildHashCode(CachingTraversableResolverForSingleValidation.java:143)
	at org.hibernate.validator.internal.engine.resolver.CachingTraversableResolverForSingleValidation$TraversableHolder.<init>(CachingTraversableResolverForSingleValidation.java:104)
	at org.hibernate.validator.internal.engine.resolver.CachingTraversableResolverForSingleValidation$TraversableHolder.<init>(CachingTraversableResolverForSingleValidation.java:86)
	at org.hibernate.validator.internal.engine.resolver.CachingTraversableResolverForSingleValidation.isReachable(CachingTraversableResolverForSingleValidation.java:31)
	at org.hibernate.validator.internal.engine.**Va*lidatorImpl.isReachable***(ValidatorImpl.java:1612)
	... 103 common frames omitted

아래 메시지까지 보니 hibernate 에서 엔티티를 저장하는 과정에서 validate 관련 프로세스가 있는데 이때 entity 의 hashCode 가 쓰이는듯하고 hashCode() 에서 Tender 와 Estimate (이제는 안쓰이는 강남언니 도메인들) 가 쓰이는데 현재 2개의 엔티티는 서로를 참조하고 있다보니 오류가 나오는것으로 보입니다.
그래도 기존까지 잘 쓰였었는데 ( @JsonIgnore@JackBackReference 등을 이용해서 내려줄때 순환참조 되지 않게 막을 수 있다) 왜 이런 이슈가 나올까 했더니..
새로 추가한 Lombok이 문제였습니다.

원인파악

Lombok으로 바꾸면서 hashCode(), toString() 자동 생성됨

롬복에서 @Data 어노테이션을 쓰면 자동으로 toString()equals() hashCode() 등의 메소드를 생성해서 존재하는 모든 프로퍼티들을 기반으로 메소드를 작성합니다.

@Data 어노테이션 상세하게 알기

Lombok 자동생성 toString method는 해당 클래스의 모든 필드와 값을 String 값으로 표현해주게 됩니다. 이 때, 이 toString 같은 녀석은 @JsonIgnore 계열의 어노테이션과 상관없이 수행되는 메소드( serialization 과 무관)이기 때문에 Proxy object를 그대로 스티링화의 대상으로 인식해서 수행합니다. 그러니 **initialize 안된 Proxy object**를 쓰려다가 오류가 나고 있었습니다. (물론 이 말은 어딘가 로깅등에서 toString 을 쓰고 있었던것으로 보인다)

해결방법

  1. ToString 과 Equals 등을 꼭 사용하고 싶다면 → 개별 exclude

    • 제외할 필드 (프록시로 다른 엔티티와 연결된) 를 @ToString 을 따로 명시적으로 써서 exclude 시킵니다. 꼭 해당 필드도 투스트링이나 해시코드등에 사용해야 한다면 해당 메소드를 override

      @Entity
      @Data
      @EqualsAndHashCode(exclude="foo")
      @ToString(exclude="foo")
      public class Bar {
      	@ManyToOne
      	private Foo foo;
      }
      
  2. 게터세터만 생성되면 오케이 →@Getter, @Setter 만 사용

    • toString 등 을 자동시전 해버리는 @Data 는 조금 위험할 수 있으니, @Getter, @Setter 어노테이션만을 선택적으로 사용합니다.

    @Data → All together now: A shortcut for @ToString, @EqualsAndHashCode, @Getter on all fields, @Setter on all non-final fields, and @RequiredArgsConstructor

    @Entity
    @Getter
    @Setter
    public class Bar {
    	@ManyToOne
    	private Foo foo;
    }
    

결론

hibernate 로 만들어진 entity 는 DB 와 커넥션이 이어져 있는 Persistent 상태로 존재하게 되는데, 이 때 이미 데이터를 다 가져와서 매핑되어 제대로 만들어진 객체가 있는 반면 lazy 로 연결된 entity들은 proxy 형태로 남아있다가 initialize 되어야만 사용이 가능합다. 그런데 만약 initializing 이 안된 상태에서 transaction 이 종료돼면 해당 데이터는 그냥 프록시의 껍데기만 남게 되고 이 과정에서 다양한 문제들이 발생할 수 있게 됩니다.
껍데기채로 리스폰스에 담겨서 client 가 이해하지 못하는 밸류가 전달되거나, 위에 처럼 껍데기만 있는데 사용하려다가 lazy 오류가 난다거나... hibernate 버전이 올라가면서 이와 같은 오류를 핸들링하거나 방지하는 것들이 생기는데, 예를 들면 아래처럼 init 안된 object 를 serialize 할때는 자동으로 null 로 바꿔서 오류가 안나게 해주는 모듈도 존재합니다. 해당 모듈을 아래처럼 ****추가해주어서 jackson converter 로 통신하는 것을 도와줄 수 있습니다

public class HibernateAwareObjectMapper extends ObjectMapper {

	private static final long serialVersionUID = 2293078232819971861L;

	// objectMapper를 hibernate4module 사용
	public HibernateAwareObjectMapper() {
		**Hibernate4Module** hm = new Hibernate4Module();
		// 추가적으로 @transient 필드를 serialize 하지 않는 설정을 방지
		hm.disable(Feature.USE_TRANSIENT_ANNOTATION);
		**registerModule**(hm);
	}
}
<mvc:annotation-driven>
		<mvc:message-converters>
			<bean
				class="org.springframework.http.converter.json.**MappingJackson2HttpMessageConverter**">
				<property name="objectMapper">
					<bean primary="true" class="com.healing.beauty.common.**HibernateAwareObjectMapper**" />
				</property>
			</bean>
		</mvc:message-converters>
	</mvc:annotation-driven>

이런것과 관련된 고생을 너무 많이 해서 여러 포스팅을 적어두었는데 추후에 정리해봐야겠습니다.

역시 조심해서 잘 쓰시기를.. 늘 느끼는것이지만 매우 편리하지만 꽤 위험한 ORM 의 세계

참고

Does Lombok @Data override the existing toString and hashCode methods?

Brown
CTO
강남언니팀을 더 멋지게, 더 즐겁게 일할 수 있도록 만드는 일을 하고 있습니다. 마치 매일 새로운 회사를 다니는듯 하게 만드는 것이 꿈입니다.