스프링 데이터 JPA를 사용하면, JpaRepository 인터페이스를 상속받아서 새로운 인터페이스를 만들어 쿼리 메서드를 상황에 맞춰 만드는 것만으로도 기능이 잘 동작한다.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
}
위의 코드는 JpaRepository 인터페이스를 상속받아 만든 리포지토리를 MemberRepository이다. 물론 잘 동작하는 리포지토리이다. 근데 @Repository 어노테이션을 붙여서 스프링 빈으로 등록해주지도 않았고, 구현체에 대해선 일절 건드리지 않았다.
어떻게 잘 동작할 수 있을까? 이유는 스프링 데이터 JPA가 사용자가 JpaRepository 인터페이스를 상속받는 리포지토리 인터페이스를 만나면, 이를 구현한 클래스를 동적으로 생성하고 생성한 클래스를 빈으로 등록하여 의존성 주입을 해주기 때문 이다.

직접 출력해보자.
@Autowired MemberRepository memberRepository;
@Test
public void test() {
System.out.println(memberRepository.getClass());
}
class com.sun.proxy.$Proxy138
실제로 프록시 객체가 주입된 것을 확인할 수 있다. 이는 SimpleJpaRepository 클래스를 기반으로 생성된 프록시 객체이다.
결론은, 스프링 데이터 JPA가 JpaRepository 인터페이스를 상속받는 리포지토리 인터페이스를 만나면 SimpleJpaRepository 클래스를 기반으로 동적으로 프록시를 생성해서 잘 동작할 수 있었던 것이다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private final EntityManager em;
@Override
public List<T> findAll() {
return getQuery(null, Sort.unsorted()).getResultList();
}
@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);
}
}
...
}
SimpleJpaRepository 클래스의 내부를 살펴보면, 이는 결국 EntityManager를 기반으로 작성되어 있다. 즉, 내부적으로는 순수 JPA로 구현되어 있고 개발자들은 내부적인 것들을 몰라도 사용할 수 있게끔 추상화가 되어 있는 것이다.
entityInformation.isNew(entity) 가 true라면, em.persist(entity)를 수행하지만, 아니라면 em.merge(entity) 를 수행한다. entityInformation.isNew(entity)가 의미하는 바는 데이터베이스에 없는 새로운 엔티티인 것을 의미한다.
em.persist() 는 영속성 컨텍스트의 쓰기 지연 SQL 저장소에 INSERT SQL문을 저장하고, 스냅샷을 저장해둔다.
하지만, em.merge()는 DB에 있는 데이터를 가져와서, save()한 엔티티로 교체해버린다. 즉, select SQL문을 실행해서 em.persist()에 비해 굉장히 비효율적으로 동작하게 된다.
em.persist() 호출em.merge() 호출첫번째 예제>
@Entity
@Getter
public class Item {
@Id @GeneratedValue
private Long id;
}
@Test
public void save() {
Item item = new Item();
itemRepository.save(item);
}
디버깅해보면 위의 상황에서는 em.persist()로 동작한다.
두번째 예제>
@Entity
@Getter
public class Item {
@Id
private String id;
public Item(String id) {
this.id = id;
}
}
@Test
public void save() {
Item item = new Item("A");
itemRepository.save(item);
}
직접 디버깅해보면 위의 상황에서는 em.merge()로 동작한다. 왜? null이 아니기 때문이다. 데이터베이스에 해당 PK가 없음에도 말이다.
아까 말했듯, em.merge() 로 동작하게 되면 select SQL문이 실행되어 em.persist()에 비해 성능상 비효율적이다.
정리하자면, JPA 식별자 생성 전략이 첫번째 예제 처럼@GeneratedValue를 쓰면 save() 호출 시점에 식별자가 없으므로, 새로운 엔티티로 인식해서 정상 동작한다.
그런데 JPA 식별자 생성 전략이 @Id 만 사용해서 직접 할당 방식이라면, 이미 식별자 값이 있는 상태로 save()를 호출한다.
그렇게 되면 em.merge()가 호출된다. merge()는 우선 DB를 조회해서 값을 확인하고, 값이 없으면 새로운 엔티티로 인지하므로 persist()에 비해 성능 상 비효율적이다.
따라서, 식별자를 직접 할당하는 방식은 Persistable 인터페이스를 구현해서 새로운 엔티티 확인 여부를 직접 구현하는게 효과적이다. 참고로 등록시간을 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 편리하게 확인할 수 있다. 아래의 예제를 보자.
@Entity
@Getter
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {
@Id
private String id;
@CreatedDate
private LocalDateTime createdDate;
public Item(String id) {
this.id = id;
}
@Override
public boolean isNew() {
return createdDate == null;
}
}
위처럼 createdDate 필드의 null 여부로 새로운 엔티티인지 확인하는 로직을 오버라이딩해주면 persist() 함수에서도 의도치 않은 성능상 저하를 막을 수 있다.
@CreatedDate어노테이션으로 생성되는 생성날짜는em.save()메서드 이후에 호출되기에 이렇게 새로운 엔티티인지를 체크할 수 있다.