Spring data JPA

JPA에 대해서 많은 포스팅은 한듯하다. 하지만 계속 계속 봐야겠다. 할때마다 까먹는다.
오늘은 Spring data jpa를 포스팅해보자.

Spring data JPA

Spring data jpa는 스프링 프레임워크에서 JPA를 편리하게 사용할 수 있도로고 제공해주는 프로젝트이다. 이 프로젝트는 데이터 접근 계층을 개발할 때 지루하게 반복되는 CRUD 문제를 조금더 세련된 방법으로 해결한다.

public class AccountRepository {
  public void save(Account account){
   ...
  }
  public Member findOne(Long id){
  ...
  }
  //update
  //delete
  //findAll
}

public class ItemRepository {
  public void save(Item item){
   ...
  }
  public Member findOne(Long id){
  ...
  }
  //update
  //delete
  //findAll
}

우리는 대게 이런작업을 일삼았다. 여기서 보면 두개는 비슷한 일을 한다. save, findOne, update, delete .. 기타 등등 그래서 이것을 해결하고자 우리는 제네릭 DAO를 만들어서 사용했다. 하지만 이방법은 공통 기능을 구현한 부모 ㅋ르래스에 너무 종속되고 구현 클래스 상속이 가지는 단점에 노출된다.

Spring data jpa는 CRUD를 처리하기 위한 공통 인터페이스가 존재 한다. 개발할때 인터페이스만 작성하면 실행 시점에 Spring data jpa가 구현 객체를 동적으로 생성해서 주입해준다. 그래서 우리는 구현 클래스 없이 인터페이스만 작성해도 개발할 수 있다.

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

위 처럼 작성하면 간단한 CRUD가 완성 된다.
일반적인 CRUD 메서드는 JpaRepository 인터페이스가 공통으로 제공하므로 문제가 될 건 없다. 예를들어 findByusername(String username) 처럼 메서드의 이름을 분석해서 JPQL를 실행한다.

spring data jpa Query creation
위에 링크에 가면 자세히 알 수 있다.

Spring data jpa는 스프링 데이터 프로젝트의 하위 프로젝트 중 하나이다. JPA 말고도 여러가지 다양한 데이터 저장소를 제공해준다.

쿼리 메서드

쿼리 메서드 기능은 스프링 데이터 JPA가 제공하는 마법 같은 기능이다. 대표적으로 메서드 이름만으로 쿼리를 생성하는 기능이 있다.
이외에도 NamedQuery, @Query 어노테이션 등을 활용해 좀더 세부적으로 개발 할 수 있다.

위에서 메서드명으로 쿼리를 생성하는 것은 잠깐 봤으니 생략 하겠다. 위의 문서에 더 자세히 나와있으니 참고하길 바란다.

JPA NamedQuery

JPA Named 쿼리는 이름 그대로 쿼리에 이름을 부여해서 사용하는 방법이다. 한번 살펴보자.

@Entity
@NamedQuery(
  name = "Account.findByusername",
  query = "select a from Account a where a.name = :name"
)
public class Account {

  @Id
  @GeneratedValue
  @Column(name = "ACCOUNT_ID")
  private Long id;

  private String name;

  private String password;

  private String email;
  ...getter setter ... etc
}

@NamedQuery 어노테이션으로 Named 쿼리를 지정하였다.

@Repository("accountRepositoryImplCustom")
public class AccountRepositoryImplCustom {

  @PersistenceContext
  private EntityManager entityManager;

  public List<Account> findByname(String name){
    return entityManager.createNamedQuery("Account.findByusername", Account.class)
      .setParameter("name", name)
      .getResultList();
  }
}

그리고 createNamedQuery에 위에 해당하는 Name을 지정해주면 된다.
위는 스프링 데이터 JPA를 쓰지 않았을 경우이고 Spring data jpa를 쓰면 더 간단해진다.

public interface AccountRepository extends JpaRepository<Account, Long> {

  List<Account> findByusername(@Param("name") String name);
}

스프링 데이터 JPA는 선언한 도메인 클래스 +.(점) + 메서드 이름으로 Named쿼리를 찾는다. 만약 실행한 Named 쿼리가 없다면 메서드 이름으로 쿼리 생성 전략을 사용한다.

@Query

레파지토리 메서드에 직접 쿼리를 정의하려면 Spring에 있는 @Query 어노테이션을 사용하면 된다. 메서드에 정적 쿼리를 직접 작성하므로 이름 없는 Named 쿼리라 할 수 있다. 또한 JPA Named 쿼리처럼 애플리케이션 실행 시점에 문법 오류를 발견 할 수 있는 장점이 있다.

@Query("select a from Account a where a.name = ?1")
List<Account> findByusernames(String name);

위에는 JPQL을 사용했을 경우이다 기본으로 JPQL을 사용한다. 네이티브 쿼리를 사용하고 싶다면 아래와 같이 하면 된다.

@Query(value = "select * from Account where name = ?1", nativeQuery = true)
List<Account> findByusernamesQueryNative(String name);

책에는 네이티브 쿼리시 위치 기반 파라미터가 0부터 시작한다고 했지만 필자는 1부터 시작했다. 버전이 올라가면서 바뀐듯하다?

파라미터 바인딩

Spring data jpa는 위치기반 파라미터와 이름 기반 파라미터 바인딩을 모두 지원한다.

select a from Account a where a.name = ?1 //위치 기반
select a from Account a where a.name = :name //이름 기반

기본값은 위치 기반인데 파라미터 순서로 바인딩한다. 이름 기반 파라미터 바인딩을 사용하려면 spring에 있는 @Param 어노테이션을 사용하면 된다.

@Query(value = "select a from Account a where a.name = :name")
List<Account> findByusernamesNamedQueryNative(@Param("name") String name);

코드 가독성과 유지보수를 위해 이름 기반 파라미터 바인딩을 사용하자!

영속성 전이 CASCADE

저번에 잠깐 알아보긴 했는데 오늘 포스팅하는김에 한개 더 남기자

영속성 전이

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶다면 영속성 전이 기능을 사용하면 된다.
JPA의 CASCADE옵션으로 영속성 전이를 제공한다. 쉽게 말해서 영속성 전이를 사용하면 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장할 수 있다.

@Entity
@Data
public class Parent {

  @Id @GeneratedValue
  private Long id;

  @OneToMany(mappedBy = "parent")
  private List<Child> children = new ArrayList<>();
}

@Entity
@Data
public class Child {

  @Id @GeneratedValue
  private Long ig;

  @ManyToOne
  private Parent parent;
}

이런 엔티티가 있다고 가장하자. 1대N의 구조인 부모와 자식관계를 나타내고 있다.
만약 1명의 부모와 2명의 자식을 저장한다고 가정할때 아래 코드와 같이 작성할 것이다.

private static void saveNoCascade(EntityManager entityManager){
  Parent parent = new Parent();
  entityManager.persist(parent);

  Child child1 = new Child();
  child1.setParent(parent);
  entityManager.persist(child1);

  Child child2 = new Child();
  child2.setParent(parent);
  entityManager.persist(child2);
}

JPA에서 엔티티를 저장할 때 연관된 엔티티는 모두 영속 상태이어야 한다. 따라서 부모 엔티티를 영속 상태로 만들고 나머지 두개의 자식들도 영속 상태로 만들었다.

영속성 전이 (저장)

@Entity
@Data
public class Parent {

  @Id @GeneratedValue
  private Long id;

  @OneToMany(mappedBy = "parent" ,cascade = CascadeType.PERSIST)
  private List<Child> children = new ArrayList<>();
}

위와 같이 cascade = CascadeType.PERSIST 로 지정하면 부모를 영속화할 때 연관된 자식들도 함께 영속화 한다.

private static void saveWithCascade(EntityManager entityManager){
  Child child1 = new Child();
  Child child2 = new Child();

  Parent parent = new Parent();
  child1.setParent(parent);
  child2.setParent(parent);

  parent.getChildren().add(child1);
  parent.getChildren().add(child2);

  entityManager.persist(parent);
}

부모 엔티티만 영속화를 했지만 CascadeType.PERSIST 같은 속성으로 자식 엔티티까지 함께 영속화해서 저장한다.

영속성 전이 (삭제)

부모와 자식 엔티티를 모두 제거하려면 아래와 같이 각각 엔티티를 한개씩 삭제해야 한다.

private static void deleteNoCascade(EntityManager entityManager){
  Parent parent = entityManager.find(Parent.class, 1L);
  Child child1 = entityManager.find(Child.class, 2L);
  Child child2 = entityManager.find(Child.class, 3L);

  entityManager.remove(child1);
  entityManager.remove(child2);
  entityManager.remove(parent);
}

영속성 전이 CascadeType.REMOVE를 사용하면 부모 엔티티와 연관된 자식 엔티티도 함께 삭제 된다.

@OneToMany(mappedBy = "parent" ,cascade = CascadeType.REMOVE)
private List<Child> children = new ArrayList<>();
private static void deleteWithCascade(EntityManager entityManager){
  Parent parent = entityManager.find(Parent.class, 1L);
  entityManager.remove(parent);
}

코드를 실행하면 DELETE SQL을 3번 실행하고 부모는 물론 연관된 자식도 모두 삭제한다. 삭제 순서를 외래키를 고려해서 자식부터 삭제하고 부모를 삭제한다. 만약 CascadeType.REMOVE를 설정 하지 않고 하면 어떻게 될까?
부모 엔티티만 삭제 하려다 외래키 제약 조건으로 인해 예외가 발생한다.

java.lang.IllegalArgumentException: attempt to create delete event with null entity

이외에도 CascadeType 속성은 여러가지가 있다 CascadeType enum을 통해 확인

고아 객체

JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체 제거라 한다. 이 기능을 사용해서 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제 된다.

@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> children = new ArrayList<>();
private static void deleteOrphan(EntityManager entityManager){
  Parent parent = entityManager.find(Parent.class, 1L);
  parent.getChildren().remove(0);
}

이렇게 하면 컬렉션에서 첫번째 자식을 삭제한다고 되어있는데 나는 왜 안되지? orphanRemoval = true 을 주면 데이터베이스에서도 삭제가 된다고 하는데..
만약 모두 비우고 싶다면 clear() 메서드를 사용하면 된다. orphanRemoval 옵션은 @OneToOne 또는 @OneToMany에서만 사용할 수 있다.

영속성 전이 + 고아 객체

orphanRemoval = true 와 CascadeType.ALL을 동시에 사용하면 어떻게 될까? 일반적으로 엔티티는 entityManager.persist() 통해 영속화 되고 entityManager.remove() 를 통해 제거 된다. 이것은 엔티티 스스로 생명주기를 관리한다는 뜻이다. 그런데 두 옵션을 모두 활성화하면 부모 엔티티를 통해서 자식의 생명주기까지 관리할 수 있다.
자식을 저장하려면 부모에 등록만 하면 된다. (CASCADE)

Parent parent = entityManager.find(Parent.class, parentId);
parent.addChild(child1);

자식을 삭제하려면 부모에서 제거하면 된다.

Parent parent = entityManager.find(Parent.class, parentId);
parent.getChildren().remove(child1);

근데 왜 나는 orphanRemoval = true 이게 안먹힐까?

JPA 영속성

오늘 알아볼 것은 JPA의 기초인 영속성을 알아보자. 맨날 매핑, 조인등등 알아봤지만 JPA에서 가장 중요한 영속성을 포스팅은 안한듯하다.

영속성 컨텍스트

영속성 컨텍스트란 엔티티를 영구 저장하는 환경? 이라고 해석 할 수 있다. 엔티티 매니저로 엔티티를 저장하거나 조회하면 엔티티 매니저는 영속성 컨텍스트에 엔티티를 보관하고 관리한다.

entityManager.persist(member)

이 코드는 단순히 회원 엔티티를 저장하는데 정확히 이야기 하면 persist() 메서드는 엔티티 매니저를 사용해서 회원 엔티티를 영속성 컨텍스트에 저장한다.

엔티티의 생명주기

  • 비영속성(new/transient) : 영속성 컨텍스트와 전혀 관계가 없는 상태
  • 영속(managed) : 영속성 컨텍스트에 저장된 상태
  • 준영속(detached) : 영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제(removed) : 삭제된 상태

한개씩 살펴보자

비영속성

엔티티 객체를 생성하고 순수한 객체 상태이며 저장하지 않았다. 영속성 컨텍스트나 데이터베이스와는 전혀 관련이 없다. 이것을 비영속성 상태라 한다.

Member member = new Member();
member.setId("MemberId1");
member.setName("name1");

영속

엔티티 매니저를 통해서 엔티티를 영속성 컨텍스트에 저장했다. 영속성 컨텍스트가 관리하는 엔티티를 영속 상태라 한다. 이제 회원 엔티티는 비영속상태에서 영속상태가 되었다. 결국 영속 상태라는 것은 영속성 컨텍스트에 의해 관리된다는 뜻이다. 그리고 entityManager.find()나 JPQL을 사용해서 조회한 엔티티도 영속성 컨텍스트가 관리하는 영속상태이다.

entityManager.persist(member);

준영속

영속성 컨텍스트가 관리하던 영속 상태의 엔티티를 영속성 컨텍스트가 관리하지 않으면 준영속 상태가 된다. 특정 엔티티를 준영속 상태로 만들려면 entityManager.detach()를 호출하면 된다. entityManager.close()를 호출해서 영속성 컨텍스트를 닫거나 entityManager.clear()를 호출해서 영속성 컨텍스트를 초기화해도 영속성 컨텍스트가 관리하던 영속 상태의 엔티티는 준영속이 된다.

entityManager.detach(member);

삭제

엔티티를 영속성 컨텍스트와 데이터베이스에서 삭제한다.

entityManager.remove(member);

특징

  1. 영속성 컨텍스트는 엔티티를 식별자 값으로 구분한다. 따라서 영속 상태는 식별자 값이 반드시 있어야 한다. 없으면 예외가 발생한다.
  2. 영속성 컨텍스트에 엔티티를 저장하면 이 엔티티는 언제 저장이 될까? JPA는 보통 트랜잭션을 커밋하는 순간 영속성 컨텍스트에 새로 저장된 엔티티를 데이터베이스에 반영하는데 이를 플러시(flush)라 한다.
  3. 영속성 컨텍스트가 엔티티를 관리하면 다음과 같은 장점이 있다.
    • 1차 캐시
    • 동일성 보장
    • 트랜잭션을 지원하는 쓰기 지연
    • 변경 감지
    • 지연 로딩

플러시

플러시(flush())는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다. 플러시를 실행하면 다음과 같은 일이 일어난다.
1. 변경 감지가 동작해서 영속성 컨텍스트에 있는 모든 엔티티를 스냅샷과 비교해서 수정된 엔티티를 찾는다. 수정된 엔티티는 수정 쿼리를 만들어 쓰기 지연 SQL 저장소에 등록한다.
2. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송한다. (등록, 수정, 삭제 쿼리)

영속성 컨텍스트를 플러시하는 방법은 3가지가 있다.
1. 직접 호출 하는 방법인데 엔티티 매니저의 flush() 메서드를 직접 호출해서 영속성 컨텍스트를 강제로 플러시 하면 도니다. 테스트나 다른 프레임워크와 JPA를 함께 사용할 때를 제외하고 거의 사용하지 않는다.
2. 트랜잭션 커밋이 자동으로 플러시가 호출된다.
3. JPQL 쿼리 실행 할때에도 플러시가 자동으로 호출된다.

준영속

영속상태에서 준영속 상태 변화를 알아보자
위에서도 말했듯이 영속성 컨텍스트가 관리하는 영속 상태의 엔티티가 영속성 컨텍스트에서 분리된 것을 준영속 상태라 한다. 준영속 상태의 엔티티는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다.
영속 상태의 엔티티를 준영속 상태로 만드는 방법은 크게 3가지가 있다.
1. entityManager.detach(member) // 특정 엔티티만 준영속 상태로 전환한다.
2. entityManager.clear() // 영속성 컨텍스트를 완전히 초기화 한다.
3. entityManager.close() // 영속성 컨텍스트를 종료한다.

private static void detached(EntityManager entityManager) {
    Member member3 = new Member("Id3", "회원A", 40);
    entityManager.persist(member3);
    entityManager.detach(member3); // 준영속으로 전환
//    entityManager.clear();   //모든 엔티티를 준영속 전환
//    entityManager.close(); // 영속성 컨텍스트 종료
}

detach 메서드를 호출하는 순간 해당 엔티티를 관리하지 말라는 것이다. 이는 1차 캐시부터 쓰기 지연 SQL 저장소까지 해당 엔티티를 관리하기 위한 모든 정보가 사라진다. 실제 위의 코드도 데이터베이스에 저장되지 않는다.
detach 메서드는 엔티티 하나의 대상이지만 clear는 모든 엔티티를 초기화해서 준영속 상태로 만든다. 이때에도 마찬가지로 데이터베이스에 반영되지 않는다.
close 메서드도 clear랑 비슷하지만 약간 다른것 같다 close는 entityManager를 종료 시키기에 다시 entityManager를 만들어야 하는 듯하다.

준영속 상태의 특징

  1. 거의 비영속 상태에 가깝다.
  2. 비영속과는 달리 식별자 값을 가지고 있다.
  3. 지연 로딩을 할 수 없다.

병합

준영속 상태의 엔티티를 다시 영속 상태로 변경하려면 병합을 사용하면 된다. merge() 메서드는 준영속 상태의 엔티티를 받아서 새로운 영속상태의 엔티티를 반환한다.

Member member = createMember("id4", "회원B", 31);
//준영속일때 변경
member.setUsername("병합");
merge(member);
print();

private static Member createMember(String id, String name, Integer age) {
    EntityManager entityManager = entityManagerFactory.createEntityManager();
    EntityTransaction transaction = entityManager.getTransaction();

    Member member = new Member(id, name, age);
    try {
        transaction.begin(); // 트랜잭션 시작
        entityManager.persist(member);
        transaction.commit();
    } catch (Exception e) {
        transaction.rollback();
    } finally {
        entityManager.close();
    }
    return member;
}

private static void merge(Member member) {
    EntityManager entityManager = entityManagerFactory.createEntityManager();
    EntityTransaction transaction = entityManager.getTransaction();
    try {
        transaction.begin(); // 트랜잭션 시작
        Member mergeMember = entityManager.merge(member);
        System.out.println("entityManager.contains(mergeMember) : " +
                entityManager.contains(mergeMember)
        );
        System.out.println("entityManager.contains(member) : " +
                entityManager.contains(member));
        transaction.commit();
    } catch (Exception e) {
        transaction.rollback();
    } finally {
        entityManager.close();
    }
}

만약 위와 같은 코드가 있다면 준영속일때 변경을 했지만 merge를 해서 다시 영속 상태로 변경시켰다. 그럼 실제 데이터베이스에도 준영속 상태에서 변경된 값이 들어간다.

비영속 병합

병합은 비영속 엔티티도 영속 상태로 만들 수 있다.

Member member = new Member();
Member newMember = entityManager.merge(member);
tx.commit();

병합은 파라미터로 넘어온 엔티티의 식별자 값으로 영속성 컨텍스트를 조회하고 찾는 엔티티가 없으면 데이터베이스에서 조회한다. 만약 데이터베이스에서도 찾지 못하면 새로운 엔티티를 생성해서 병합한다. 병합은 준영속, 비영속을 신경쓰지 않는다. 식별자 값으로 엔티티를 조회할 수 있으면 불러서 병합하고 조회할 수 없으면 새로 생성해서 병합한다. 따라서 병합은 save or update 기능을 수행한다.

이것으로 JPA 영속성에 대해서 알아봤다.