ID가 있는 엔티티를 save() 할때 발생하는 SELECT 쿼리 없애기
INSERT 하려면 SELECT 2번 나감
최근에 인덱스를 한번 정리하고 싶어서, 서버의 API로 요청이 들어올 때 발생하는 모든 쿼리를 리스트업 했었다.
그리고 다음은 새로운 유저가 OAuth를 통해 로그인 할 때 발생하는 쿼리들이다.
Member를 SELECT
하는 쿼리가 두번 발생한 이후에 INSERT
쿼리가 발생한다.
우리 서비스는 로컬 회원가입이 없다.
구글과 카카오를 통한 소셜 로그인만 가능한데,
사용자가 로그인 버튼을 누르고 사용자가 선택한 provider
로 부터 사용자의 정보를 받는다.
위 정보를 통해 우리 서비스에 등록된 사용자라면 로그인을,
등록되지 않은 사용자라면 등록 후 로그인을 진행한다.
@Transactional
public TokenResponse createAccessToken(final String code, final OAuthProvider provider) {
final MemberInfo memberInfo = provider.getUserInfo(code);
final Member member = memberRepository.findById(memberInfo.openId())
.orElseGet(() -> memberRepository.save(memberInfo.toMember()));
return new TokenResponse(jwtTokenProvider.createAccessFrom(member.getId()));
}
우리 서버에 등록된 사용자인지 확인하기 위해 findBy()
를 한번 호출한다.
그렇기 때문에 첫번째 SELECT
는 이해가 되는데 두번째 SELECT
는 왜 발생하는 걸까.
원인
repository의 save method를 뜯어보자.CrudRepository
의 구현체인 SimpleJpaRepository
의 save()
method를 확인해봤다.
@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);
}
}
인자로 들어온 entityInformation
의 isNew()
의 인자로 다시 entity를 넘겨서 결과(boolean)에 따라,persist()
또는 merge
를 호출한다.
merge의 동작 순서
- 1차 캐시에 해당 식별자가 있는지 확인하고 없으면 database 를 조회한다.(
SELECT
)- database 에 값이 있으면 해당 값을 받아오고,
입력받은 test 객체의 값을 복사해서 database 에서 가져온 객체에 copy 를 한다.- 그런 뒤 db에 저장.(
INSERT
)
아무래도 INSERT
직전의 SELECT
두번은 persist가 아닌 merge가 호출 되기 때문인듯하다.
JPA가 save
하려는 entity가 새로운 entity인지 아닌지 판단하는 기준이 무엇일까?(isNew()
)
디버깅을 찍고 하나씩 내려가봤다.
AbstractEntityInformation
라는 추상 클래스에 정의된 isNew()
메서드를 타는 것을 확인했다.
대충 entity
의 id
가 Primitieve Type
이 아닌 경우에는 id
가 null
인 경우 새로운 entity라 판단을 하고,id
가 Primitive Type
인 경우에는 Number
의 인스턴스가 아닌경우에는 예외가 터진다.
그리고 Number
의 인스턴스인 경우는 long
type으로 변환한 뒤, 그 값이 0L인 경우에만 새로운 entity라고 판단한다.
위에서 말했든 우리 서비스는 로컬 회원 가입이 없다.
그리고 member enttiy
의 경우 pk
의 값을 auto_increament
로 하는 것은 보안상 좋지 않다고 한다.
그렇기 때문에 우리는 member entity
의 pk
값을 각각의 provider
에서 받아온 OIDC
를 그대로 이용하고 있다.
@Entity
public class Member {
@Id
private String id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String image;
}
그렇기 때문에, 새로운 회원일지라도 id
값을 갖고 있고,
그렇기 때문에 항상 merge가 호출되어 불필요한 select문이 발생하는 것이다.
해결책
아까 위에서 본 AbstractEntityInformation
의 두개의 구현체와 하나의 확장 추상클래스가 있더라.
그중에 JpaMetamodelEntityInformation
의 super
를 타고 Abstract~
에 있는 isNew()
가 실행되었다.
그리고 남은 하나의 구현체는 JpaPersistableEntityInformation
이다.
이 구현체의 isNew()
method를 확인해보면, 단순하게 entity의 isNew() method를 호출한다.
그리고 마지막으로 하나 남은 JpaEntityInformationSupport
를 확인 해보면,
domainClass(entity.class)가 Persistable
의 구현체이면 PersistableEntityInformation
이 반환되고,
아니라면, 현재 사용되고 있는 JpaMetamodelEntityInformation
이 반환된다.
그리고 이 정적메서드(getEnitityInformation
)는SimpleJpaRepository
의 생성자에서 사용된다.
그렇다면 우리가 사용하고 있는 Member Entity
클래스가 Persistable
의 구현체가 된다면,SimpleJpaRepository
필드의 EntityInformation
에 PersistableEntityInformation이
할당 될거고,save
가 호출 될때, entity
의 isNew
메서드가 호출되며, 그 결과값이 true
를 반환한다면,SELECT
없이 바로 INSERT
쿼리가 발생할 것으로 예상된다.
적용
이제 적용을 해보자.
Member Entity
에 이게 새로운 Entity
인지 기존에 있던 건지 구분을 할 컬럼이 하나 필요하다.boolean
으로 하나 박을 수도 있긴한데,
너무 의미 없는 컬럼이 될것 같아서 CreatedAt
을 사용하기로 했다.
기존에 다른 entity에서 사용하던 Base Entity
가 있다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(nullable = false)
protected LocalDateTime createdAt;
public LocalDateTime getCreatedAt() {
return createdAt;
}
}
Member
에 얘랑 Persistable
두개를 심어주자.
@Entity
public class Member extends BaseEntity implements Persistable<String> {
// ...
@Override
public boolean isNew() {
return Objects.isNull(createdAt);
}
@Override
public String getId() {
return id;
}
}
그리고 나서 다시 test를 돌려보면
한번의 SELECT
이후 바로 INSERT
가 발생하는 것을 확인할 수 있다.