개발/Database

[JPA Lazy Evaluation] LazyInitializationException: could not initialize proxy – no Session

BEBONG 2018. 11. 6. 23:11

Basic Information

LazyInitializationException: could not initialize proxy - no Session

JPA 관련해서 작업을 하다보면 위와 같은 에러메시지를 만나곤 합니다. 무슨 의미일까? 


JPA Session

여기서 Session 은 JPA 영속성 컨텍스트가 유지되는 하나의 세션을 의미합니다. 

즉, 트랜잭션 시작 ~ 트랜잭션 끝 까지를 의미합니다. 


언제 이런 예외가 발생할까?? 

회원가입을 예로들면, 회원가입을 구현하기 위해 1. 중복체크 2. 데이터 저장 순서로 보통 구현합니다. 

아래 예에서는 중복체크를 위해 getOne을 이용해서 Lazy 엔티티를 가져왔습니다. 

(getOne에 대해서는 아래 부분에서 설명합니다.)

그리고 엔티티에 필요한 작업을 하고 저장할때 이전에 가져온 Lazy 엔티티를 사용합니다. 

하지만 @Transaction이 나뉘어져 있는 것에 주목합니다. 

다른 세션에서 Lazy 엔티티를 전달받아 사용할 경우 이런 예외가 발생할 수 있습니다. 


[Wrong code]

  1. A {
  2. @Transaction
  3. isDuplicate() { userEntity = getOne(id) ... }
  4. ...
  5. @Transaction
  6. save(userEntity);
  7. }

이럴 경우 아래와 같이 중복체크, 데이터 저장을 하나의 세션으로 묶어줘야 합니다.

[Right code]
  1. @Transaction
  2. B() {
  3. userEntity = isDuplicate()
  4. save(userEntity)
  5. }

isDuplicate에서는 중복제크를 위해 getOne을 이용해서 데이터베이스에서 이미 값이 있는지 확인했습니다. 

JPA에는 getOne, findOne 두가지 방법으로 데이터를 읽어올 수 있습니다. 

getOne은 Lazy Evaluation으로 동작합니다. 즉, getOne은 껍데기인 프록시의 레퍼런스를 리턴합니다. 

그리고 리턴된 객체를 save()에서 사용하는데, 다른 @Transaction 으로 묶여있기 때문에, save() 에서는 해당 레퍼런스를 알지 못합니다. 

그래서 proxy를 initialize (초기화) 할 수 없습니다. 해당 프록시를 초기화하기 위한 세션이 없기 때문입니다. 

그래서 LazyInitializationException: could not initialize proxy - no Session 와 같은 에러가 발생합니다.




JPA getOne, findOne 차이

JPA는 @Trasnaction로 묶인 곳 안(영속성 컨텍스트)에서 Entity를 관리합니다.  

@Transaction은 스프링에서 트랜잭션을 관리하는 방법 중 하나입니다. 

스프링에서 @Transaction은 프록시를 이용해서 동작합니다. 프록시가 Target Object를 감싸서 트랜잭션 부가기능을 제공합니다.

@Transaction은 프록시를 이용한다는 점에 주목합니다. 때문에 외부에서 접근해야 프록시가 적용됩니다. 프록시이기 때문입니다. 

때문에 @Transaction이 적용된 자기 자신(this)의 다른 메소드를 그냥 호출하면 프록시를 타지않기 때문에 유효하지 않습니다. 

다른 추가적인 방법이 필요합니다.


JPA는 매번 데이터베이스에 접근하는 비효율적인 계산을 방지하기 위해 영속성 컨텍스트에 엔티티를 관리합니다. 

엔티티를 관리할때 Lazy Evaluation 개념이 나옵니다. 

어떤 엔티티의 모든 필드가 필요한 것이 아닐 경우가 있습니다. 

A가 B 엔티티를 필드로 가지고있는데, B 엔티티의 모든 필드가 당장 필요한것이 아니라, A엔티티를 반환하기 위해 참조만 필요할 경우 JPA는 Lazy Evaluation을 적용합니다.

즉, B엔티티를 데이터베이스에서 접근해서 모든 필드를 채워오는 것이 아니라, 껍데기 뿐인 프록시 객체를 생성한 뒤 참조만 A엔티티에 넘겨줍니다. 그리고 나중에 B엔티티를 사용할 필요가 있을 때만 데이터베이스에서 접근해서 껍데기 뿐이였던 프록시 객체를 채워넣습니다. 

물론 영속성 컨텍스트에 보관되어있는 객체들에 다시 접근할 경우, 데이터베이스에 다시 접근하는 것이아니라 컨텍스트에 있는 객체를 재사용합니다. 

다시말하자면, Lazy Evaluation을 적용하면 프록시를 이용하며 껍데기인 참조만 리턴됩니다. 


이것이 findOne과 getOne의 차이입니다. findOne은 데이터를 바로 가져오고, getOne은 Lazy Evaluation이 적용된 프록시를 리턴합니다. 

주석을 보면 findOne의 경우 "Retrieves an entity” 이고, getOne은 "Returns a reference to the entity”로 레퍼런스만 가져옵니다. 


[findOne 주석]
/**
* Retrieves an entity by its id.
*
* @param id must not be {@literal null}.
* @return the entity with the given id or {@literal null} if none found
* @throws IllegalArgumentException if {@code id} is {@literal null}
*/
T findOne(ID id);

[getOne 주석]
/**
* Returns a reference to the entity with the given identifier.
*
* @param id must not be {@literal null}.
* @return a reference to the entity with the given identifier.
* @see EntityManager#getReference(Class, Object)
*/
T getOne(ID id);





With Experience
실제로 경험한 것을 정리합니다. 

Background

하고자 하는것은 간단합니다.  아래와 같이 ExampleEntity 가 ExampleAttachmentEntity 와 1:N 관계를 맺고있습니다. 

DTO는 ExampleEntity는 ExampleDto DTO를 사용하고, ExampleAttachmentEntity는 ExampleAttachmentDto가 사용됩니다. 

ExampleEntity 리스트를 가져와서 ExampleDto 리스트로 변환한 뒤 캐쉬에 저장합니다. 

이후에 같은 요청들은 캐쉬를 통해 가져다 사용하도록 합니다. 코드는 다음과같습니다. 

ExampleEntity에서는 List로 exampleAttachments을 가지고 있는 것에 주목합니다. 



[Entities & Dtos]
  1. @Entity(name = “ExampleEntity")
  2. @Table(name = “example")
  3. public class ExampleEntity {
  4. @Id
  5. @GeneratedValue(strategy = GenerationType.AUTO)
  6. @Column(name = "id")
  7. private Long id;
  8. @JsonIgnore
  9. @Where(clause = "deleted='N'")
  10. @OneToMany(mappedBy = “example", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
  11. private List exampleAttachments;
  12. }
  1. @Entity(name = “ExampleAttachmentEntity")
  2. @Table(name = “example_attachment")
  3. public class ExampleAttachmentEntity {
  4. @Id
  5. @GeneratedValue(strategy = GenerationType.AUTO)
  6. @Column(name = "id")
  7. private Long id;
  8. }
  1. public class ExampleDto {
  2. private Long id;
  3. ...
  4. private List exampleAttachments;
  5. }
  1. public class ExampleAttachmentDto{
  2. private Long id;
  3. ...
  4. }

그리고 getExamples 메소드에서는 find메소드를 이용해서 database에 접근하여 ExampleEntity 리스트를 가져옵니다.

그리고 가져온 exampleEntity 리스트를 convert 메소드를 이용해서 ExampleDto 리스트로 변환합니다. 

그리고 메소드에는 캐쉬를 적용하여 DB접근을 최소화합니다. 

코드는 다음과같습니다. 


[캐쉬가 적용된 getExamples]
  1. @ReadThroughSingleCache(namespace = “example_cache_v1", expiration = 60)
  2. public List getExamples(@ParameterValueKeyProvider(order = 0) Condition condition) {
  3. // get exampleEntities from database
  4. Page entities = find(condition);
  5. return convert(entities.getContent());
  6. }

getExamples 에서는 convert 메소드를 이용해서 ExampleEntity리스트를 ExampleDto 리스트로 변환합니다. 

변환할때 필드가 많아서 ModelMapper를 이용합니다. 

이때 ExampleEntity에 멤버변수로 있는 List<ExampleAttachmentEntity>또한 ModelMapper가 복사합니다. 

이때 문제가 발생합니다. 


[문제의 코드 - convert]
  1. private ModelMapper modelMapper = new ModelMapper();
  2. private List convert(List exampleEntities) {
  3. List exampleDtos = exampleEntities.stream().map(entity-> {
  4. return modelMapper.map(entity, ExampleDto.class);
  5. }).collect(Collectors.toList());
  6. return exampleDtos;
  7. }

ERROR

발생한 에러는 다음과 같습니다. 전체에러 중에 중요한 부분은 다음과 같습니다. 문제가 무엇일까요?

failed to lazily initialize a collection, could not initialize proxy - no Session


[전체 에러로그]
  1. message
  2. ...
  3. WARN 2018-10-10 18:33:43 [http-nio-10001-exec-16] com.google.code.ssm.aop.CacheAdvice.warn:51 - Caching on method execution(ExampleFindService.getExamples(..)) and key [example_cache_v1:N2018-10-10A00110721] aborted due to an error. com.google.code.ssm.providers.CacheException: java.lang.RuntimeException: Exception waiting for value at com.google.code.ssm.providers.spymemcached.MemcacheClientWrapper.get(MemcacheClientWrapper.java:165) ~[spymemcached-provider-3.6.0.jar:na] at com.esotericsoftware.kryo.serializers.CollectionSerializer.read(CollectionSerializer.java:134) ~[kryo-shaded-3.0.3.jar:na] at at com.google.code.ssm.providers.spymemcached.TranscoderAdapter.decode(TranscoderAdapter.java:47) ~[spymemcached-provider-3.6.0.jar:na] at net.spy.memcached.transcoders.TranscodeService$1.call(TranscodeService.java:63) ~[spymemcached-2.11.7.jar:2.11.7] at java.util.concurrent.FutureTask.run(FutureTask.java:266) ~[na:1.8.0_152] at net.spy.memcached.transcoders.TranscodeService$Task.run(TranscodeService.java:110) ~[spymemcached-2.11.7.jar:2.11.7] at net.spy.memcached.transcoders.TranscodeService$Task.get(TranscodeService.java:96) ~[spymemcached-2.11.7.jar:2.11.7] ... 116 common frames omitted Caused by: org.hibernate.LazyInitializationException: failed to lazily initialize a collection, could not initialize proxy - no Session at org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:569) ~[hibernate-core-4.2.20.Final.jar:4.2.20.Final] at org.hibernate.collection.internal.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:188) ~[hibernate-core-4.2.20.Final.jar:4.2.20.Final] at org.hibernate.collection.internal.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:548) ~[hibernate-core-4.2.20.Final.jar:4.2.20.Final] at org.hibernate.collection.internal.AbstractPersistentCollection.write(AbstractPersistentCollection.java:373) ~[hibernate-core-4.2.20.Final.jar:4.2.20.Final] at org.hibernate.collection.internal.PersistentBag.add(PersistentBag.java:291) ~[hibernate-core-4.2.20.Final.jar:4.2.20.Final] at com.esotericsoftware.kryo.serializers.CollectionSerializer.read(CollectionSerializer.java:134) ~[kryo-shaded-3.0.3.jar:na] at com.esotericsoftware.kryo.serializers.CollectionSerializer.read(CollectionSerializer.java:40) ~[kryo-shaded-3.0.3.jar:na] at com.esotericsoftware.kryo.Kryo.readObject(Kryo.java:708) ~[kryo-shaded-3.0.3.jar:na] at com.esotericsoftware.kryo.serializers.ObjectField.read(ObjectField.java:125) ~[kryo-shaded-3.0.3.jar:na] ... 130 common frames omitted




원인: 영속성 컨텍스트를 벗어난 JPA 프록시 객체
ModelMapper가 List<ExampleAttachmentEntity>를 List<ExampleAttachmentDto>로 매핑할때 얕은 복사가 일어났습니다. 
따라서 JPA 하이버네이트 프록시 객체의 레퍼런스가 저장되있습니다. 
따라서 캐쉬에는 프록시 객체의 레퍼런스가 저장되었고, 캐쉬에 저장된 JPA 프록시 객체를 이용해서 실제값을 가져오려고 하나 이미 Transaction 을 벗어난 상태입니다. 따라서 JPA의 영속성 컨텍스트 안에 있지 않기 때문에 값을 가져올 수 없습니다. 
따라서 Lazy Evaluation이 불가능합니다. 


해결
아래와 같이 Deep Copy를 사용합니다.
Deepcopy 방법은 여러가지가 있을 수 있습니다.  
모델매퍼 설정을 통해서 할 수도 있고 아래처럼 새로운 converter를 만들어서 할 수도 있습니다. 
Convert 메소드를 deepcopy 메소드로 고쳐줍니다. 

  1. private List convert(List before) {
  2. List notices = before.stream().map(entity-> {
  3. return ExampleConverter.deepcopy(notice);
  4. }).collect(Collectors.toList());
  5. return notices;
  6. }
  1. public class ExampleConverter {
  2. public static ExampleDto deepcopy(ExampleEntity exampleEntity) {
  3. return ExampleDto.builder()
  4. .id(exampleEntity.getId())
  5. ...
  6. .exampleAttachments(ExampleAttachmentConverter.deepcopy(exampleEntity.getExampleAttachments()))
  7. .build();
  8. }
  9. }
  1. public class ExampleAttachmentConverter {
  2. public static List deepcopy(List exampleAttachments) {
  3. if (exampleAttachments == null) {
  4. return Lists.newArrayList();
  5. }
  6. List exampleAttachmentDtos = Lists.newArrayList();
  7. for (ExampleAttachmentEntity exampleAttachmentEntity : exampleAttachments) {
  8. exampleAttachmentDtos.add(
  9. ExampleAttachmentDto.builder()
  10. .id(exampleAttachmentEntity.getId())
  11. ...
  12. .build()
  13. );
  14. }
  15. return siteNoticeAttachmentDtos;
  16. }
  17. }

 

회고
JPA에서 Lazy Evaluation은 값은 지금당장 필요없고, 레퍼런스만 리턴해도 될 때 사용합니다. 
Lazy Evaluation의 결과 나중에 값을 가져올 수 있는 프록시 객체가 리턴됩니다. 
프록시 객체는 영속성 컨텍스트 안에서만 동작합니다. 
때문에 캐시에 만약 프록시 객체가 저장되었고, 나중에 값을 가져오려고 하면 아래와 같이 에러가 발생합니다. 
"LazyInitializationException: could not initialize proxy - no Session"
에러는 JPA 세션이 없어서 프록시 객체가 유효하지 않다고 합니다.
JPA는 ORM(Object Relation Mapping)으로 SQL과 객체지향의 차이를 줄여줍니다. 따라서 개발자는 편하게 개발할 수 있습니다. 
하지만 적어도 JPA가 어떤원리로 ORM을 제공해주는지 알아야 제대로 사용할 수 있을것입니다. 

... Q. 의문
맨위의 ExampleEntity를 보면 List<ExampleAttachmentEntity>를 FetchType.EAGER 로 설정했습니다. 그럼에도 Lazy 로 동작하는 이유는?