Spring Data JPA를 조금 더 편하게 써보자

1

Spring Data JPA를 사용하면 JPA를 더 쉽고 편리하게 사용할 수 있다.

Entity

@Entity
public class Item {
    @Id @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private String name;

    public Item(String name) {
        this.name = name;
    }
}

Repository

Spring Data JPA를 활용하면 반복으로 사용되는 기능들을 추상화해두었다.

public interface ItemRepository extends JpaRepository<Item, Long> {
}

선언하면 준비는 끝났다. 다들 아시겠지만 이렇게 선언만 해주어도 JPA 관련 기능들을 전부 제공받을 수 있다.

@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {
    <S extends T> S save(S entity);

    <S extends T> Iterable<S> saveAll(Iterable<S> entities);

    Optional<T> findById(ID id);

    boolean existsById(ID id);

    Iterable<T> findAll();

    Iterable<T> findAllById(Iterable<ID> ids);

    long count();

    void deleteById(ID id);

    void delete(T entity);

    void deleteAllById(Iterable<? extends ID> ids);

    void deleteAll(Iterable<? extends T> entities);

    void deleteAll();
}

기본적으로 findById 메서드의 반환은 Optional 이기에 null 에 대한 처리를 해주어야 한다.

Item item = itemRepository.findById(itemId).orElseThrow(() -> new IllegalStateException("Item not found"));

이런식으로 사용할 수 있겠다. CrudRepository의 기능 중 findById 메서드를 통해 엔티티를 조회할 수 있다.

JPA는 더티 체킹이라는 기술로 변경 사항을 감지해 업데이트 문을 날려주게 되는데,

public class ItemService {

	private final ItemRepository items;

	@Transactional
	public void modifyItemName(String name) {
    	Item item = itemRepository.findById(itemId).orElseThrow(() -> new IllegalStateException("Item not found"));
        item.modifyName(name)
    }
}

와 같이 조회를 통해 Persistence Context에 퍼올린 후 변경감지를 통해 엔티티의 값을 수정하는 코드들이 빈번하게 발생한다.

public class ItemService {

	private final ItemRepository items;

	@Transactional
	public void modifyItemName(Long id, String name) {
    	Item item = this.getByItemId(id);
        item.modifyName(name)
    }
    
    private Item getItemByIdOrElseThrow(Long itemId) {
		return itemRepository.findById(itemId).orElseThrow(() -> new IllegalStateException("Item not found"));
	}
    
    @Transactional(readOnly = true)
    public ItemResponse getItemById(Long item) {
    	Item item = this.getByItemId(id);
        return new ItemResponse(item.getId(), item.getName());
    }
}
private Item getItemById(Long itemId) {
	return itemRepository.findById(itemId).orElseThrow(() -> new IllegalStateException("Item not found"));
}

재사용성을 챙기기 위해 위와 같은 형태로(?) 풀어낼 수도 있겠지만,

당장 이 코드 뿐 아니라 값을 변경하거나 id로 조회하는 모든 단건 조회에서 사용될 것이 뻔한데, 공통적으로 사용할 수 있게 제공해주는 게 좋을 것 같았다.

그래서 조금 더 쉽게 쓰고 싶은 욕구가 차올랐다.

다시 코드로 돌아와,

.orElseThrow(() -> new IllegalStateException("Item not found"));

이 걸 붙여주기만 하면 될 것 같다.

어디다 붙여줄까 고민하다가 JpaRepository 쪽에 디폴트 메서드로 붙였다.

@NoRepositoryBean
public interface JpaRepositoryExtension<T, ID> extends JpaRepository<T, ID> {

    default T getByIdOrElseThrow(ID id) {
        return getByIdOrElseThrow(id, "Entity not found with id: " + id);    }

    default T getByIdOrElseThrow(ID id, String errorMessage) {
        return findById(id).orElseThrow(() -> new IllegalStateException(errorMessage));
    }
}

Repository

public interface ItemRepository extends JpaRepositoryExtension<Item, Long> {
}

로 변경하면 디폴트 메서드를 활용할 수 있다.

어떤 인터페이스를 확장할까 고민하던 찰 나, CrudRepository와 JpaRepository가 보였다.
CrudRepository 가 getByIdOrElseThrow 라는 시그니처를 갖는게 좋아보였지만
CrudRepository는 Spring Data 프로젝트의 고수준 인터페이스로써 우리 애플리케이션 코드에서 공통적으로 확장한 인터페이스를 상속해야하는데 제한이 있을 수 밖에 없다, 또한 getByIdOrElseThrow 뿐 아니라, 추가적으로 EntityManager의 기능들을 제공하기에 적합하다 생각했다.

public interface CustomJpaRepository<T, ID> extends JpaRepositoryExtension<T, ID>, CrudRepositoryExtension<T, ID> {}

이런식으로 만들 수도 있겠지만, 아래와 같이 간편하게 만들었다.

simple is best

@NoRepositoryBean
public interface JpaRepositoryExtension<T, ID> extends JpaRepository<T, ID> {

    void clear();

    void detach(T entity);

    boolean contains(T entity);
    
    default T getByIdOrElseThrow(ID id) {
        return getByIdOrElseThrow(id, "Entity not found with id: " + id);    }

    default T getByIdOrElseThrow(ID id, String errorMessage) {
        return findById(id).orElseThrow(() -> new IllegalStateException(errorMessage));
    }
}

확장한 인터페이스 구현해주기

다들 아시다 싶히 SimpleJpaRepository 가 concrete class

final 키워드가 붙어있지 않기에 상속이 가능하고 이를 확장하여 공통 기능들을 정의해주었다.

public class SimpleJpaRepositoryExtension<T, ID> extends SimpleJpaRepository<T, ID> implements ExtendedJpaRepository<T, ID> {
    private final EntityManager entityManager;

    public ExtendedSimpleJpaRepository(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
        super(entityInformation, entityManager);
        this.entityManager = entityManager;
    }

    public ExtendedSimpleJpaRepository(Class<T> domainClass, EntityManager entityManager) {
        super(domainClass, entityManager);
        this.entityManager = entityManager;
    }

    @Override
    public boolean contains(T entity) {
        return this.entityManager.contains(entity);
    }

    @Override
    public void clear() {
        this.entityManager.clear();
    }

    @Override
    public void detach(T entity) {
        this.entityManager.detach(entity);
    }
}

확장한 구현체 등록하기

spring boot의 강력한 autoconfigure 의 자동 구성 방식으로는 확장한 클래스를 모르기 때문에 그대로 사용할 수 없다는 제약이 있다.
아래와 같이 직접 지정해주어야 한다.
@EnableJpaRepositories(repositoryBaseClass = ExtendedSimpleJpaRepository.class)

JPA, Datasource 쪽에 구성을 설정하고 애플리케이션에서는 컴포넌트 스캔을 통한 구성을 유도하는 방식으로 사용 하고 있다.

@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(repositoryBaseClass = SimpleJpaRepositoryExtension.class)
// @EnableJpaAuditing 
// @EnableTransactionManagement
// @EntityScan 등등 Jpa와 관련된 설정을 이쪽에 몰아넣는다.
public class JpaConfigurer {
}

간단한 테스트 코드

@DataJpaTest
class JpaExtensionTest {
	@Autowired
    private ItemRepository items;
    
    @Test
    void t1() {
        // 비영속화 상태
        Item item = new Item("item");
        assertThat(items.contains(item)).isFalse();

        // 영속화 상태
        Item saved = items.save(item);

        assertThat(items.contains(item)).isTrue();

        items.clear();
        
        assertThat(itemJpaRepository.contains(item)).isFalse();
        Item existingItem = itemJpaRepository.getByIdOrElseThrow(item.getId());
        
        // 영속화 상태
        assertThat(itemJpaRepository.contains(existingItem)).isTrue();
    }
}

Persistence Context를 날리거나 하는 작업들을 따로 엔티티매니저를 주입받지 않고 내가 주입한 Repository를 통해 entity manager의 기능도 함께 사용할 수 있다.

0개의 댓글