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 Entity의 Id의 생성 전략이 없고
if Entity의 Id가 있으면
엔티티를 영속(persist)한다.
if Entity의 Id가 없으면
엔티티의 Id가 없으므로 예외가 발생한다.
if Entity의 Id 생성 전략이 있고
if Entity의 Id가 있으면
엔티티를 준영속(detach) 상태로 판단한다.
그리고 `PersistentObjectException` 예외를 던진다. <- 이 부분을 기억하자
if Entity의 Id가 없으면
엔티티를 비영속(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 JPA
의 save()
메소드는 다음과 같이 구현되어 있다.
@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
@GeneratedValue
가 있다면 다음과 같은 결과가 출력된다.
Member Count = 2
Member Id = 1, Name = member
Member Id = 2, Name = member
즉, @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);
}
주문을_저장한다
테스트는 정상적으로 수행된다.
주문을_조회한다
테스트에서 다음과 같은 문제가 발생한다.
PersistentObjectException
예외를 발생시키지 않고 정상적으로 INSERT 쿼리가 날아간다.따라서 다음과 같이 코드를 수정하면 된다.
@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 템플릿을 사용했으면 이 문제는 만날 일도 없었을텐데...