Spring Data JPA에서 제공하는 JpaRepository.save(T); [T는 Entity]의 내부 동작 방식에 대해서 정리하려고 합니다.
JPA에서의 save 메서드 구현체 코드는 다음과 같습니다.
// SimpleJpaRepository.java [JPA 구현체]
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null.");
if (entityInformation.isNew(entity)) { // 1
em.persist(entity); // 2
return entity; // 3
} else { // 4
return em.merge(entity); // 5
}
}
JPA 내부에서 1번의 isNew 메서드를 통해 매개변수로 들어온 entity가 새로운 것인지, 이미 존재하는 entity인지 확인을 합니다.
- 레퍼런스 타입[String, Long etc..] 일 경우 null인지 확인합니다.
- 기본 타입(Primitive Type)[int, long, char etc..] 일 경우 값이 0인지 확인합니다.
따라서 @Id 생성 방식이 @GeneratedValue 일 경우에는 2번의 em.persist를 하기 전까지 해당 멤버 변수가 null 혹은 0이기 때문에 새로운 entity임을 인지하고 if문을 타서 2, 3번을 순차적으로 수행합니다. 3번의 return entity;와 같이 save를 호출한 곳에서는 entity를 사용할 수 있으며, return을 통해 넘어간 entity에는 원래는 null 이었을 @Id의 멤버 필드가 JPA의 @GeneratedValue 전략에 맞춰 값이 채워져 있는 것을 확인하실 수 있을겁니다.
그러나 만약 @Id가 @GeneratedValue가 아닌 사용자가 직접 입력을 받는 변수일 경우는 어떻게 될까요?
아래와 같은 Member 객체가 있다고 가정해보겠습니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@Column
private String email;
private String name;
private Member(String email, String name) {
this.email = email;
this.name = name;
}
public static Member createMember(String email, String name) {
return new Member(email, name);
}
}
위 Member 객체처럼 회원가입 시 사용자가 입력한 이메일을 PK로 설정했을 경우에 [JPA 구현체 내부]에서 1번 isNew(entity) 메서드가 String email 변수를 확인하고, null이 아닌 값이 채워져 있기 때문에 if문이 아닌 else 구문으로 이동하게 됩니다. 그리하여 5번의 em.merge(entity)를 수행하게 되는데, 여기서 실제로 회원가입을 통해 입력한 이메일이 실제로 존재하는지 확인하기 위한 SELECT 쿼리 한 번을 보내게 됩니다.
SELECT 쿼리를 한 번 날림으로써 실제로 해당 이메일이 DB에 존재하는지, 존재하지 않는지 확인합니다.
- DB에 있을 경우? entity에 입력된 정보로 merge가 발생합니다.
- DB에 없을 경우? 새로운 entity이므로 INSERT 쿼리가 발생합니다.
[Member 테스트 코드]
[실제 쿼리 로그]
이처럼 순수하게 save를 원하는 상황에서 SELECT라는 불필요한 쿼리가 날아가게 되므로 잘못 설계한 도메인이라 할 수 있습니다. 이와 같은 방법을 쉽게 해결하려면 entity 자체의 @Id 값을 @GeneratedValue로 설정하면 되겠지만, 부득이하게 사용자의 입력에 의존하는 @Id를 설계해야 할 경우도 있습니다. 이 경우에는 다음과 같이 해결할 수 있습니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity implements Persistable<String> {
@Id
@Column
private String email;
private String name;
private Member(String email, String name) {
this.email = email;
this.name = name;
}
public static Member createMember(String email, String name) {
return new Member(email, name);
}
@Override
public String getId() {
return email;
}
@Override
public boolean isNew() {
return getCreatedDate() == null; // 객체가 만들어진 시간[BaseEntity에 존재함]
}
}
위 코드에는 JPA Auditing[BaseEntity]이 적용되어있습니다. 해당 내용은 따로 다루겠습니다.
눈여겨봐야 할 부분은 바로 implements Persistable <String>이란 코드가 추가된 것입니다. Persistable <Id의 Type> 인터페이스를 상속하게 되면, JPA 구현체 내부에서 작동했던 isNew()를 개발자가 오버라이딩하여 비교 대상을 직접 지정할 수 있습니다. getId() 메서드는 @Id의 멤버 변수를 return 하도록 구현합니다. isNew() 메서드는 객체가 만들어진 시간의 null 여부에 따라 새로운 entity인지 아닌지를 구분하라고 지정해줍니다.
Member 테스트 코드를 수정하지 않고 똑같이 실행했을 경우 다음과 같은 결과를 확인할 수 있습니다.
이처럼 Auditing과 Persistable<>을 구현하게 되면 불필요한 SELECT 쿼리를 날리지 않고 INSERT 쿼리 한 번만 날리는 것을 확인할 수 있습니다.
'Spring > Spring Data JPA' 카테고리의 다른 글
Spring Data JPA - 상속(Inheritance) + 다대다(N:M) 관계 (0) | 2022.01.09 |
---|---|
Spring Data JPA - Self-Reference (0) | 2021.12.20 |
Spring Data JPA - N + 1 문제 ② 해결 방법 (0) | 2021.12.02 |
Spring Data JPA - N + 1 문제 ① (0) | 2021.11.29 |
Spring Data JPA - Auditing (0) | 2021.11.28 |