[JPA] 비영속 Entity에 대한 실수 (비영속 vs 준영속)

비영속? 준영속?

Entity의 상태에는 4가지가 존재한다

  • 비영속 = 영속성 컨텍스트와 전혀 관련이 없는 상태
  • 영속 = 영속성 컨텍스트가 관리하고 있는 상태
  • 준영속 = 영속성 컨텍스트의 관리를 더이상 받지 않는 상태
  • 삭제 = 아예 삭제가 된 상태

 

여기서 준영속 상태영속성 컨텍스트의 관리를 더이상 받지 않는 상태라고 설명하였다

그런데 이를 다른 측면에서 바라보면 다음과 같이 설명할 수 있다

영속성 컨텍스트의 관리를 받기 위해서는 식별자 @Id가 반드시 필요하다
따라서 영속성 컨텍스트의 관리를 더이상 받지 않는다는 것은 다르게 말하면 식별자는 존재하는 상태라고 볼 수 있다

 

이 시점에 다음 엔티티를 한번 살펴보자

@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "member")
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private int age;
}

PK 생성 전략은 DB에 전적으로 맡기는 IDENTITY 전략으로 설정하였다

  • DB Vendor는 MySQL을 사용하였고 그에 따라서 AUTO_INCREMENT로 PK가 관리된다
  • 그리고 Entity에 대한 setter가 열려있다는 사실도 기억해두자

 

비영속 엔티티에 대한 영속화 (persist)

@DataJpaTest
class MemberTest {
    @PersistenceContext
    private EntityManager em;

    @Test
    @DisplayName("비영속 엔티티에 대해서 정상적으로 영속화(persist)")
    void test1() {
        Member member = new Member();
        member.setName("서지원");
        member.setAge(22);
        em.persist(member);
    }
}

비영속 상태의 Member Entityem.persist를 통해서 영속화하는 코드이다

  • 정상적으로 영속화된다
  • PK 전략이 IDENTITY이므로 영속성 컨텍스트에서 관리되기 위해서 즉시 Insert Query가 나갔다

 

비영속 엔티티에 대한 실수 (With setter)

@Test
@DisplayName("비영속 엔티티에 대한 개발자의 실수 (Detached Entity Passed to persist)")
void test2() {
    Member member = new Member();
    member.setId(1L);
    member.setName("서지원");
    member.setAge(22);

    assertThatThrownBy(() -> em.persist(member))
            .isInstanceOf(PersistenceException.class);
}

위의 테스트 코드를 보면 test1과는 다르게 Member Entity에 명시적으로 식별자를 부여해주고 있다

테스트가 성공했다는 의미는 em.persist(member) 실행 도중에 PersistenceException이 발생했다는 의미이다

 

이제 PersistenceException이 발생한 흐름을 디버깅을 통해 거슬러 올라가보자

 

 

DefaultPersistEventListener

detached entity passed to persist ~~라는 예외 코드가 어디서부터 올라왔는지 확인을 해보니 DefaultPersistEventListener로부터 올라온 에러임을 확인하였다

 

표시된 부분을 보면 뭔가 이상한 느낌이 드는게 정상이다

분명히 위에서 Member Entity는 비영속 상태임을 확인했고 setId를 통해서 직접적으로 식별자를 명시해주었다
그런데 현재 Member Entity의 entityStateDETACHED(=준영속)로 도출이 된 것이다
  • 실제로 entityState가 DETACHED로 나오는 과정을 디버깅해서 알아보자

 

1. em.persist(member)

  • em.persist로 영속화 하는 과정을 디버깅을 통해서 살펴보자

 

2. DefaultPersistEventListener - onPersist

invoke 및 여러 프로세스는 건너뛰고 핵심 부분인 DefaultPersistEventListener의 onPersist로 들어왔다

 

Member Entity에 대한 EntityEntry 정보를 얻기 위해 해당 코드로 step into를 해보자

 

3. EntityEntryContext - "ManagedEntity"

여기서 일단 중요한 정보가 도출되었다

managedEntity == null → Member Entity는 관리되는 엔티티가 아니다 (비영속 or 준영속)
  • 사실 테스트 코드만 봐도 Member가 영속성 컨텍스트에 의해 관리되지 않는다는 사실은 알고 있다
  • Why? persist 이전에 영속화하는 어떠한 로직도 없기 때문

계속해서 흐름을 거슬러 올라가보자

 

4. DefaultPersistEventListener - "EntityState"

이제 드디어 Member Entity의 EntityState를 확인하는 부분에 진입하였다

 

5. EntityState(Enum) - getEntityState

이전에 managedEntity == null를 확인했었다

그 결과를 토대로 EntityState에서는 managedEntity == null -> 관리되지 않는 엔티티라고 결과를 내린 것이다

  • 관리되지 않는 엔티티 : 비영속 or 준영속

 

이제 남은 경우의 수는 2가지이다

  • TRANSIENT = 비영속
  • DETACHED = 준영속

어떤 상태가 선택될지는 ForeignKeys.isTransient로 판별이 되기 때문에 해당 메소드로 step into를 해보자

 

6. ForeignKeys - isTransient

메소드명만 봐도 알 수 있듯이 해당 Entity가 비영속 상태인지 판별하는 메소드임을 추측할 수 있다

결론에 다다르게 할 persister.isTransient(entity, session)에 step into를 해보자

 

7. AbstractEntityPersister -> isTransient

  • 우리가 테스트 코드에서 setId(1L)식별자 ID를 강제적으로 설정해주었다
  • 그에 따라서 현재 id: 1라는 결과가 도출된 것이다
id == null이면 isTransient의 반환 값은 true이기 때문에 해당 Entity는 TRANSIENT = 비영속라고 판단할 수 있다
하지만 id != null이므로 현재 위의 코드상에서는 true로 return된다는 보장은 없다

 

그 아래 코드도 한번 살펴보자

결정적인 단서가 도출되었다

  • result는 해당 Context에서 Entity가 id를 가지고 있냐 가지고 있지 않냐를 판단하는 boolean variable이다
  • 따라서 result가 false로 도출됨에 따라 Member Entity는 식별자 ID(1)를 보유하고 있고 그에 따라서 최종적인 isTransient의 반환 값은 false 

 

8. 결론

결론적으로 Member Entity의 EntityStateDETACHED로 결정됨에 따라 PersistentObjectException 예외가 터지게 되는 것이다

 

물론 Member Entity는 영속화된 적이 단 한번도 없었기 때문에 비영속이라고 생각할 수 있다

그러나 식별자를 부여하는 순간 영속성 컨텍스트는 식별자가 있고 현재 관리중인 엔티티가 아니니까 해당 엔티티는 당연히 준영속 엔티티구나라고 인식하는 것이다

>> 의도치 않게 준영속 엔티티로 엔티티의 상태를 전환시킨 것이 문제이다

따라서 식별자를 보유한 비영속 엔티티에 대해서 em.persist로 영속화를 시도하니까 영속성 컨텍스트는 이를 받아들일 수 없었던 것이다

 

 

해결 방안

1. persist 대신 merge

영속성 컨텍스트가 현재 Member Entity를 준영속으로 인식하고 있다
그에 따라서 우리도 영속성 컨텍스트가 인식하는 상태에 맞추어서 준영속 엔티티를 영속 상태로 만드는 merge를 활용해야 한다

@Test
@DisplayName("비영속 엔티티(식별자 보유)에 대해서 merge를 통해서 영속 상태로 전환시키자")
void test3() {
    Member member = new Member();
    member.setId(1L);
    member.setName("서지원");
    member.setAge(22);
    em.merge(member);
}

merge를 통해서 준영속 → 영속 상태 전환이 정상적으로 이루어진것을 확인할 수 있다

 

여기서 select query가 왜 발생했는지 의문을 가지는 사람이 있을 것이다
그 이유는 다음과 같다

1. 기본적으로 EntityManager의 merge는 인자로 넘긴 Entity의 식별자를 통해서 먼저 영속성 컨텍스트를 탐색한다
→ 더 정확히 말하자면 인자로 넘긴 Entity의 식별자 값으로 관리되는 엔티티가 있는지 영속성 컨텍스트를 탐색하는 것이다

2. 만약 영속성 컨텍스트에 없다면 해당 식별자를 통해서 DB를 조회한다
  • 바로 이 부분에서 Member Entity의 식별자를 통해서 DB를 조회하게 되고 그에 따라서 select query가 발생하는 것이다

 

그러나 이 해결방안은 절대로 올바른 해결방안이라고 할 수 없다

왜냐하면 애초에 비영속 상태의 Member Entity에 대한 영속화가 우리의 주요한 목적이였다
하지만 개발자의 실수 (setter 열기 + 식별자 강제 주입)을 통해서 의도치 않게 준영속 상태가 되었기 때문에 지금 문제가 발생한 것이다

 

2. setter 지양

사실 가장 간단한 방법은 Entity에 대한 setter를 없애버리는 것이다

  • 문장 그대로 setter를 안쓰면 해결?이 아니라 "외부에서 Entity의 ID를 건드릴 수 없도록 설정한다"라고 받아들이면 된다
  • Reflection API를 통한 비정상적인 설정 케이스는 간주하지 않고 설명
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private int age;

    private Member(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public static Member createMember(String name, int age) {
        return new Member(name, age);
    }
}
  • 필자는 @Setter는 무조건 지양하고 위와 같이 정적 팩토리 메소드를 통해서 엔티티 객체를 생성한다
  • Setter는 단순히 Entity뿐만 아니라 모든 Layer에서 지양한다
물론 정적 팩토리 메소드가 아닌 빌더 패턴을 통해서 객체를 생성하는 방법 또한 존재한다
둘 중에서 뭘 선택할지는 개인의 스타일에 따라 선택하면 된다

 

@Test
@DisplayName("정적 팩토리 메소드를 통해서 Member 객체 반환받기")
void test() {
    em.persist(Member.createMember("서지원", 22));
}

 

3. Spring Data JPA 활용?

이 방법은 직접적인 해결방안?이라고 설명할 수는 없다

  • Data JPA 내부 처리 메커니즘으로 인한 간접적인 해결이므로

 

Spring Data JPA → SimpleJpaRepository의 save 내부 로직을 확인해보자

SimpleJpaRepository' save의 내부 로직에서는 Entity의 isNew를 판단해서 persist or merge를 진행하고 있다