JPA에서 정적 필드 Test Fixture 사용 시 외래키 제약 이슈

Glen·2023년 7월 12일
0

TroubleShooting

목록 보기
2/6

서론

JPA를 사용하고, 다음과 같은 엔티티가 정의되어 있다.

@Entity
@Table(name = "orders")
class Order {
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
      
    @ManyToOne(fetch = FetchType.LAZY)  
    private Member orderer;
    
    ...
}

@Entity
class Member {
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
      
    private String name;
    
    ...
}

그리고 주문(Order)을 저장하고 조회하는 테스트를 작성했다.

public static Member MEMBER = new Member("member");

@Autowired  
MemberRepository memberRepository;  
  
@Autowired  
OrderRepository orderRepository;  
  
@Test  
void 주문을_저장한다() {  
    // given  
    memberRepository.save(MEMBER);  
    Order order = new Order(MEMBER);  
  
    // when  
    Order saveOrder = orderRepository.save(order);  
  
    // then  
    assertThat(saveOrder.getId()).isNotNull();  
}  
  
@Test  
void 주문을_조회한다() {  
    // given  
    memberRepository.save(MEMBER);  
    Order actual = new Order(MEMBER);  
    orderRepository.save(actual);  
  
    // when  
    Order expect = orderRepository.findById(actual.getId()).get();  
  
    // then  
    assertThat(expect).usingRecursiveComparison()  
        .isEqualTo(actual);  
}

그리고 테스트를 실행하니, 다음과 같은 예외가 발생하며 테스트 중 하나가 실패했다.

Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referential integrity constraint violation: "FKN7UTYV8A953CNEX3O1DHH9GG5: PUBLIC.ORDERS FOREIGN KEY(ORDERER_ID) REFERENCES PUBLIC.MEMBER(ID) (CAST(1 AS BIGINT))";
SQL statement: insert into orders (id, orderer_id) values (default, ?) [23506-214]

하지만 테스트를 각각 독립적으로 하나씩 실행시키면 해당 예외가 발생하지 않고 테스트가 통과했다.

대체 왜 이런 일이 발생한 걸까?

본론

JPA를 사용하고, 객체(엔티티)를 영속한다는 것은 영속성 컨텍스트에 엔티티가 저장되어 관리되는 것을 뜻한다.

JPA는 엔티티를 영속할 때 다음과 같은 작업을 수행한다.

if EntityId의 생성 전략이 없고
    if EntityId가 있으면
        엔티티를 영속(persist)한다.
    if EntityId가 없으면
        엔티티의 Id가 없으므로 예외가 발생한다.
        
if EntityId 생성 전략이 있고
    if EntityId가 있으면
        엔티티를 준영속(detach) 상태로 판단한다.
        그리고 `PersistentObjectException` 예외를 던진다. <- 이 부분을 기억하자
    if EntityId가 없으면
        엔티티를 비영속(transient) 상태로 판단한다.
        엔티티를 DB에 저장하고, 영속한다.
        엔티티의 Id 필드에 값을 설정한다.

그리고 객체에 연관 관계가 설정된 객체가 있을 때 해당 객체의 id 값으로 DB에 저장한다.

Team team = new Team(1L);
Member member = new Member(1L, team);

em.persist(team); // Team 엔티티 영속
em.persist(member); // Member 엔티티 영속 

em.flush();
// Team INSERT
// insert into team (id) values (1L)

// Member INSERT
// insert into team (id, team_id) values (1L, 1L)

이때, 연관된 엔티티가 비영속 상태(transient)이면 TransientPropertyValueException가 발생한다.

Team team = new Team(1L);
Member member = new Member(1L, team);

em.persist(member); // Member 엔티티 영속, Team 엔티티는 비영속 상태

em.flush();
// TransientPropertyValueException 발생!

추가로 Id 생성 전략이 없고, 식별자가 null이 아니면서, 영속성 컨텍스트에서 관리되지 않는 엔티티는 JPA는 해당 엔티티가 비영속 상태인지, 준영속 상태인지 판단을 해야 하므로 DB를 조회하여 값을 가져온다.

따라서 다음과 같은 조회 쿼리가 발생한다.

select
    null,
    t1_0.name 
from
    team t1_0 
where
    t1_0.id=?

만약 조회한 값이 없으면 비영속 상태라 판단하고 TransientPropertyValueException 예외를 발생시킨다.

Id 생성 전략이 있고, 식별자가 null이 아니면서 영속성 컨텍스트에서 관리되지 않는 엔티티는 DB 조회 없이 준영속(detach) 상태로 판단한다.

뜬금없이 JPA의 기초적인 부분에 대해 알아봤다.

하지만 이 기초적인 부분이 발생하는 문제와 연관이 있다.

테스트에서 문제가 발생하는 부분은 다음과 같다.

@Test  
void 주문을_조회한다() {  
    // given  
    memberRepository.save(MEMBER);  
    Order actual = new Order(MEMBER);  
    orderRepository.save(actual); // 여기서 예외 발생
  
    // when  
    Order expect = orderRepository.findById(actual.getId()).get();  
  
    // then  
    assertThat(expect).usingRecursiveComparison()  
        .isEqualTo(actual);  
}

여기서 Order를 저장할 때, 다음과 같은 예외가 발생한다.

Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referential integrity constraint violation: "FKN7UTYV8A953CNEX3O1DHH9GG5: PUBLIC.ORDERS FOREIGN KEY(ORDERER_ID) REFERENCES PUBLIC.MEMBER(ID) (CAST(1 AS BIGINT))";
SQL statement:insert into orders (id, orderer_id) values (default, ?) [23506-214]

해당 예외는 무결성 제약 조건 위반으로 인해 발생한다.

생성된 SQL문을 분석하면 orderer_id를 저장할 때 발생한다.

즉, Order를 저장할 때 연관된 Member가 비영속 상태로 판단하지 않고 DB에 저장된 준영속 상태라고 판단했기 때문이다.

따라서 TransientPropertyValueException 예외가 발생하지 않고, DB에서 예외가 발생한다.

해당 문제의 원인은 정적 필드인 Test Fixture를 사용했기 때문이다.

첫 번째 테스트에서는 정상적으로 MEMBER가 영속된다.

이때, MEMBER의 id가 null이므로, JPA는 비영속 상태로 판단하고 DB에 저장한 뒤 id를 세팅하여 영속 상태로 만든다.

그 뒤 첫 번째 테스트가 끝나고 영속성 컨텍스트에 있는 엔티티가 모두 지워진다. (em.clear() 호출)

하지만 MEMBER는 정적 필드이므로 메모리에서 초기화되지 않는다.

그리고 영속 상태인 MEMBER는 준영속 상태가 된다. (id가 null이 아니므로)

하지만 데이터베이스에는 MEMBER가 없다. (테스트에서 트랜잭션이 끝나고 rollback 되므로)

그리고 두 번째 테스트가 실행될 때 문제가 발생한다.

연관 관계가 설정된 엔티티를 영속할 때, 연관된 엔티티가 비영속 상태이면 예외가 발생한다.

ORDER와 연관된 MEMBER는 id가 null이 아니므로 준영속 상태이다.

따라서 JPA는 정상적인 흐름이라 판단하고 TransientPropertyValueException 예외를 던지지 않고, DB에 INSERT 쿼리를 날린다.

바로 여기서 무결성 제약 조건 예외가 발생하게 된다.

하지만 이상한 점이 있다.

코드의 흐름은 다음과 같다.

@Test  
void 주문을_조회한다() {  
    // given  
    memberRepository.save(MEMBER); // MEMBER는 준영속인데??
    Order actual = new Order(MEMBER);  
    orderRepository.save(actual);
  
    // when  
    Order expect = orderRepository.findById(actual.getId()).get();  
  
    // then  
    assertThat(expect).usingRecursiveComparison()  
        .isEqualTo(actual);  
}

MEMBER는 준영속 상태이다.

그리고 준영속인 MEMBER를 다시 영속하면 PersistentObjectException 예외가 발생해야 한다.

따라서 memberRepository.save() 메서드에서 예외가 발생해야 하는데, 엉뚱한 orderRepository.save() 메서드에서 예외가 발생했다.

이것은 Repository의 동작 방식과 관련이 있다.

memberRepository.save() 메소드는 직접 구현한 것이 아닌 Spring Data JPA가 제공하는 메소드이다.

Spring Data JPAsave() 메소드는 다음과 같이 구현되어 있다.

@Transactional  
@Override  
public <S extends T> S save(S entity) {  
  
   Assert.notNull(entity, "Entity must not be null");  
  
   if (entityInformation.isNew(entity)) {  
      em.persist(entity);  
      return entity;  
   } else {  
      return em.merge(entity);  
   }  
}

엔티티가 isNew 이면, persist를 호출하고, isNew가 아니면 merge를 호출한다.

isNew는 Persistable 인터페이스를 구현하여 재정의할 수 있다.
기본적으로 id가 null이면 isNew가 true이다.

따라서 PersistentObjectException 예외가 발생하지 않은 것이다.

merge는 persist와 다르게 비영속 상태의 엔티티를 영속 상태로 만드는 것이 아닌, 준영속 상태의 엔티티를 영속 상태로 만들 때 사용해야 한다.

이때, 엔티티가 DB에 저장되어 있지 않다면 DB에 저장하고, 저장된 엔티티를 영속성 컨텍스트에서 관리한다.

여기서 중요한 점은 merge는 식별자의 @GeneratedValue 어노테이션 유무에 따라 동작이 달라진다.

Member member1 = new Member(1000L,"member");
em.merge(member1);
em.flush();
em.clear();

Member member2 = new Member(member1.getId(), "member");
em.merge(member2);
em.flush();
em.clear();

List<Member> members = em.createQuery("SELECT m from Member m", Member.class).getResultList();  
System.out.println("Member Count = " + members.size());  
for (Member member : members) {  
    System.out.println("Member Id = " + member.getId() + ", Name = " + member.getName());  
}

@GeneratedValue가 없다면 다음과 같은 결과가 출력된다.

Member Count = 1
Member Id = 1000, Name = member
  1. id가 1000인 Member를 조회한다.
  2. DB에 해당 Member를 찾을 수 없다.
  3. id가 1000인 Member를 저장한다.
  4. id가 1000인 Member를 조회한다.
  5. 조회가 성공했으므로 저장하지 않는다.

@GeneratedValue가 있다면 다음과 같은 결과가 출력된다.

Member Count = 2
Member Id = 1, Name = member
Member Id = 2, Name = member
  1. id가 1000인 Member를 조회한다.
  2. DB에 해당 Member를 찾을 수 없다.
  3. id가 1000인 Member를 저장하는게 아닌, 생성 전략에 따른 id를 가진 Member를 저장한다. (id = 1)
  4. id가 1000인 Member를 조회한다.
  5. DB에 해당 Member를 찾을 수 없다.
  6. id가 1000인 Member를 저장하는게 아닌, 생성 전략에 따른 id를 가진 Member를 저장한다. (id = 2)

즉, @GeneratedValue 어노테이션이 있고, merge를 호출했을 때 DB에 해당 엔티티가 없다면, 생성 전략에 따른 엔티티를 생성한다.

추가로 merge의 파라미터로 넘어간 엔티티는 영속성 컨텍스트에서 관리되지 않는다.
반환된 엔티티가 영속성 컨텍스트로 관리된다.

결론

위의 문제점들이 복합적으로 작용하여 테스트가 실패한 것이다.

@Test  
void 주문을_저장한다() {  
    // given  
    memberRepository.save(MEMBER);  
    Order order = new Order(MEMBER);  
  
    // when  
    Order saveOrder = orderRepository.save(order);  
  
    // then  
    assertThat(saveOrder.getId()).isNotNull();  
}  
  
@Test  
void 주문을_조회한다() {  
    // given  
    Member save = memberRepository.save(MEMBER); 
    Order actual = new Order(MEMBER);
    orderRepository.save(actual);
  
    // when  
    Order expect = orderRepository.findById(actual.getId()).get();  
  
    // then  
    assertThat(expect).usingRecursiveComparison()  
        .isEqualTo(actual);  
}

주문을_저장한다 테스트는 정상적으로 수행된다.

주문을_조회한다 테스트에서 다음과 같은 문제가 발생한다.

  1. MEMBER는 영속성 컨텍스트에서 관리되지 않고, 준영속 상태이다. 이때 id는 1 이다.
  2. DB에는 아무 값이 없다.
  3. MEMBER의 id가 있으므로, merge가 호출되며, id가 1인 MEMBER를 DB에 저장하길 기대하지만 실제로 id가 2인 MEMBER가 저장된다. (id가 1인 MEMBER가 DB에 없으므로)
  4. order에는 id가 1인 MEMBER가 연관 관계가 설정된다.
  5. order는 id가 없으므로, persist가 호출된다.
  6. 연관 관계인 MEMBER는 준영속 상태이므로 PersistentObjectException 예외를 발생시키지 않고 정상적으로 INSERT 쿼리가 날아간다.
  7. 여기서 바로 무결성 제약 조건 위반 예외가 발생하게 된다.

따라서 다음과 같이 코드를 수정하면 된다.

@Test  
void 주문을_저장한다() {  
    // given  
    Member saveMember = memberRepository.save(MEMBER);  
    Order order = new Order(saveMember);  
  
    // when  
    Order saveOrder = orderRepository.save(order);  
  
    // then  
    assertThat(saveOrder.getId()).isNotNull();  
}  
  
@Test  
void 주문을_조회한다() {  
    // given  
    Member saveMember = memberRepository.save(MEMBER);  
    Order actual = new Order(saveMember);  
  
    orderRepository.save(actual);  
  
    // when  
    Order expect = orderRepository.findById(actual.getId()).get();  
  
    // then  
    assertThat(expect).usingRecursiveComparison()  
        .isEqualTo(actual);  
}

만약 @GeneratedValue 어노테이션이 없다면 4번에서 id가 1인 MEMBER가 DB에 저장될 것이므로, 문제가 발생하지 않는다.

하지만 @GeneratedValue 어노테이션을 설정하지 않는 경우가 드물기도 하고, Id 값이 고정된 정적 필드로 된 Test Fixture 보다, 메소드로 만들어 새로운 인스턴스를 반환하게 하는 것이 바람직할 것 같다.

public static Member MEMBER(Long id) {  
    return new Member(id, "member");  
}

단순히 정적 필드 Test Fixture를 사용해서 예외가 발생한 것이 아닌, JPA가 구현하는 여러 기능과 Spring Data JPA가 제공하는 기능들로 인해 쉽게 발견할 수 없는 예외가 발생했다.

해결하느라 덕분에 JPA의 동작을 좀 더 이해할 수 있었다.


JDBC 템플릿을 사용했으면 이 문제는 만날 일도 없었을텐데...

profile
꾸준히 성장하고 싶은 사람

0개의 댓글