JPA의 쿼리가 어떻게 날아가는지 여러 방면으로 공부하다 save() 동작에 대한 의문이 생겼다.
그래서 깊게 알아보려 한다.
( 전체 코드 👉🏻 https://github.com/HoyeongJeon/blog-code/tree/main/howsavework )
고민의 시작
public void testNoneAnnotation() {
System.out.println("// JPA만 사용하기(@Transactional 이 없는 경우)");
Member member = memberRepository.findById(1L).get();
member.setAge(35);
memberRepository.save(member);
System.out.println("result: " + member.getAge());
}
위 코드를 돌리면
이렇게 쿼리가 나간다.
처음 select 는 Member member = memberRepository.findById(1L).get(); 여기서 나갔다는 것은 너무 쉽게 알 수 있다.
그런데 의문은 왜 select , update가 나갔으며, @Transaction이 없는데 어떻게 나이가 바뀌었을까? (초기 나이는 30으로 설정했다.)
이에 대해 알기 위해선 save() 메서드를 볼 필요가 있다.
save가 어떻게 구현되어있는지 보자.
save()의 내부로직
save는 그 자체로 @Transactional 어노테이션이 걸려있다.
로직을 보면
@Transactional
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (this.entityInformation.isNew(entity)) { // isNew로 entity가 DB에 존재하는지 확인
this.entityManager.persist(entity); // id가 있다면(entity가 메모리 상에 존재한다면) 영속화
return entity; // entity를 return하면서 트랜잭션도 종료됨 -> flush(), commit()이 발생!
} else {
return this.entityManager.merge(entity); // id가 존재하지 않는다면 merge(), 여기서도 return이므로 트랜잭션 종료
}
}
좀 복잡해지지만 천천히 살펴보자.
this.entityInformation.isNew(entity)
isNew를 통해 로직이 분기된다.
isNew는 뭘까?
쭉쭉 isNew()를 파고들어가면 ...
파라미터로 들어온 엔티티의 id 여부로 판단한다!
그러므로 save(S entity)의 로직은 다음으로 추측할 수 있다.
1. save(S entity)의 파라미터로 들어온 엔티티가 id를 가지고 있다.
- isNew(T entity) == false
- save는 엔티티를 merge한다.
2. save(S entity)의 파라미터로 들어온 엔티티의 id가 null이다.
- isNew(T entity) == true
- save는 엔티티를 persist한다.
실제 그런지 확인해보자.
실습해보기
1. 엔티티 ID가 NULL인 경우
엔티티 ID가 null이 되려면 엔티티의 PK 생성을 DB에게 위임하면 된다.
entity
@Entity
@Table(name = "member")
public class NullIdMember {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(name = "name")
private String name;
@Column(name = "age")
private Integer age;
public NullIdMember(String name, Integer age) {
this.name = name;
this.age = age;
}
protected NullIdMember() {
}
// getter , setter ...
}
respository
public interface NullIdMemberRepository extends JpaRepository<NullIdMember, Long> {
}
테스트 코드
@Test
@DisplayName("엔티티 ID가 NULL인 경우")
public void 엔티티_ID가_NULL인_경우() {
// given
System.out.println("========== 엔티티 ID가 NULL인 경우 ==========");
NullIdMember member = new NullIdMember("Alice", 30);
// when & then
nullIdMemberRepository.save(member);
System.out.println("=========================================");
}
우리가 생각한 동작 흐름이 맞다면 다음처럼 될 것이다.
1. 메모리에 member 객체가 생성되고, 해당 객체는 ID가 없다. (정확히 말하면 생성 시점에 없다).
2. save() 시 isNew(member) == true가 되고, persist가 실행된다. (이때 id가 생성된다)
즉, id 생성 쿼리를 제외하면, insert 쿼리만 실행되어야 한다.
우리의 예상대로 insert 쿼리만 실행됐다. (persist가 실행됐다!)
여기서 주의해야 할 점은 save() 이전에도 객체는 메모리에 존재하고 있다는 점이다!
영속성 컨텍스트엔 없었지만, 메모리엔 계속 존재하고 있다.
save() 자체가 @Transactional 어노테이션을 달고 있으므로, save()가 실행되면 flush()가 되고 commit()이 되어 DB에 엔티티가 저장된다!
2. 엔티티 ID가 존재하는 경우
Entity
@Entity
@Table(name = "member")
public class Member {
@Id
private Long id;
@Column(name = "name")
private String name;
@Column(name = "age")
private Integer age;
public Member(Long id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}
protected Member() {
}
// getter , setter ...
}
respository
public interface MemberRepository extends JpaRepository<Member, Long> {
}
테스트 코드
@Test
@DisplayName("엔티티 ID가 존재하는 경우")
public void 엔티티_ID가_존재하는_경우() {
// given
System.out.println("========== 엔티티 ID가 존재하는 경우 ==========");
Member member = new Member(1L, "Alice", 30);
// when & then
memberRepository.save(member);
System.out.println("=========================================");
}
우리가 생각한 동작흐름이 맞다면 다음처럼 될 것이다.
1. 메모리에 member 객체가 생성되고, 해당 객체는 ID를 가지고 있다(1L).
2. save() 시 isNew(member) == false 이므로, merge 된다.
실제 그런지 확인하기 전에, merge()가 어떻게 동작하는지 알아보자.
merge()에 대해 알아보자!
merge()는 새로운 객체를 영속성 컨텍스트에 복사하거나 준영속 상태의 엔티티를 영속성 컨텍스트에 영속 상태로 복사하는 작업이다.
여기서 새로운 객체를 영속성 컨텍스트에 복사한다는게 무엇일까?
생성한 객체와 merge 한 객체가 다르단 뜻일까?
@DisplayName("merge는 객체를 복사한다")
@Transactional
public void merge는_객체를_복사한다() {
// given
System.out.println("========== merge ==========");
Member member = new Member(1L, "Alice", 30);
em.persist(member);
// when & then
Member detachedMember = em.find(Member.class, 1L);
em.detach(detachedMember);
Member merge = em.merge(detachedMember);
System.out.println("둘이 같을까? " + (detachedMember == merge));
System.out.println("==========================");
}
서로 다르다!
여기서 select는 왜 나갈까?
Member detachedMember = em.find(Member.class, 1L);
여기서 나갔다고 생각할 수 있지만, 이 부분은 아니다.
왜냐면
// given
System.out.println("========== merge ==========");
Member member = new Member(1L, "Alice", 30);
em.persist(member);
// when & then
Member detachedMember = em.find(Member.class, 1L);
persist 할 때 member가 영속화된다.
그 이후 find는 영속성 컨텍스트에 영속화된 member를 가져오는 것이기에 db에 쿼리를 날리지 않는다.
그렇다면
저 select는 merge()할 때 나간다는 것이다.
엔티티가 준영속 상태인 경우, merge()를 사용하면 JPA는 먼저 DB에 해당 엔티티를 조회한다.
조회한 엔티티가 영속성 컨텍스트에 없다면 1차 캐시에 업데이트하고, 이미 관리중인 엔티티가 있다면 해당 엔티티를 사용한다.
준영속 상태의 엔티티의 모든 필드 값을 관리중인 엔티티에 전부 복사한다. (실제 값 반영은 flush나 commit이 될 때 UPDATE 또는 INSERT 쿼리가 실행된다.(DB에 엔티티가 존재하면 UPDATE , DB에 엔티티가 없다면 INSERT))
이렇게 값 복사가 완료되면 이제 영속화된 상태의 엔티티가 반환된다.
(서로 다른 객체를 사용하는 이유를 생각해 봤다..
아무래도 트랜잭션 밖에서 값이 수정될 위험도 있고, 레이스 컨디션에 걸릴 수도 있다. 추가로 낙관적 락의 경우, DB 엔티티의 버전을 가져와 비교하는데, 복사하지 않고 객체만 사용하면 낙관적 락도 작동하지 않을 것이기에 복사를 하는 게 아닐까?)
이제 아래 코드의 흐름을 예상해보자.
@Test
@DisplayName("엔티티 ID가 존재하는 경우")
public void 엔티티_ID가_존재하는_경우() {
// given
System.out.println("========== 엔티티 ID가 존재하는 경우 ==========");
Member member = new Member(1L, "Alice", 30);
// when & then
memberRepository.save(member);
System.out.println("=========================================");
}
1. 메모리에 member 객체가 생성되고, 해당 객체는 ID를 가지고 있다(1L).
2. save() 시 isNew(member) == false 이므로, merge 된다.
3. DB에 select 쿼리를 보내고 엔티티를 1차캐시에 저장한다.
4 준영속 상태의 member 값을 전부 복사해 1차캐시에 업데이트한다.
5. insert가 나간다. (1번의 member 객체는 DB에서 가져온 값이 아니라, 우리가 생성한 값이니까!)
그러므로 우리가 볼 땐
select
insert
이렇게 쿼리가 나가야 한다.
우리가 예상한대로 쿼리가 나갔다!!
문제 코드의 쿼리 분석
public void testNoneAnnotation() {
System.out.println("// JPA만 사용하기(@Transactional 이 없는 경우)");
Member member = memberRepository.findById(1L).get();
member.setAge(35);
memberRepository.save(member);
System.out.println("result: " + member.getAge());
}
위 코드를 돌리면
이렇게 쿼리가 나갔다.
처음 select는
Member member = memberRepository.findById(1L).get();
여기서 나갔다.
그 후 select & update 는
memberRepository.save(member);
여기서 나갔다.
member의 id가 존재하기에 save는 merge()를 실행한다.
merge()의 작동원리에 따라 select , update가 나갔다.
(여기서 member는 DB에 존재하는 member니까 update!)
나이는 merge() 과정에서 준영속 상태의 변경 사항을 모두 복사해서 1차캐시에 저장하기에, 나이도 변경되었다!
정리
save는 다음과 같이 동작한다.
저장하려는 엔티티의 PK가 없다 -> persist()
저장하려는 엔티티의 PK가 있다 -> merge()
save는 내부에 @Transactional이 달려있기에 , 메서드에 @Transactional이 없어도 save()를 하면 엔티티가 DB에 저장된다.
(잘못된 부분이 있으면 알려주세요 수정하겠습니다!)