JPA 학습하기 (2편)

2023. 11. 20. 20:55BACKEND

728x90

영속성 전이(CASCADE)와 고아 객체

영속성 전이는 부모객체가 영속성에 추가될때 자식객체도 자동으로 영속성에 추가되게끔 하는 것이다. 프록시나 즉시로딩,지연로딩과는 전혀 관계가 없는 별개의 주제인데 오해하는 경우가 많다.

중요한 코드만 작성하고 그외 필드나 getter,setter같은 코드는 생략하였다.

@Entity
public class Parent{
    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "parent")
    private List<Child> childList = new Array<>();

    public void addChild(Child child){ // 편의 메서드
        childList.add(child);
        child.setParent(this);
    }
}

부모 엔티티가 List로 childList를 가지고 있다.

@Entity
public class Child{
    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;

    public void addChild
}

자식이 Owner이고 자식도 부모객체를 가지고 있다.

Child child1 = new Child();
Child child2 = new Child();

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

em.persist(parent);
em.persist(child1);
em.persist(child2);

원래는 이런식으로 persist가 3번 호출되어야 할것이다. 부모와 자식을 다 생성해주어야 하니까 그런데 영속성전이를 사용해 부모엔티티가 persist되면 자식도 persist가 되게 할 수 있다는 것이다.

@Entity
public class Parent{
    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL) // 영속성전이 추가
    private List<Child> childList = new Array<>();

    public void addChild(Child child){ // 편의 메서드
        childList.add(child);
        child.setParent(this);
    }
}

cascade = CascadeType.ALL를 연관관계를 적는 어노테이션에 cascade를 추가했다. 이렇게 변경하면 em.persist(parent);로 부모를 추가하면 자식도 자동으로 추가된다.

CASCADE의 종류

  • ALL : 모두 적용
  • PERSIST : 영속
  • REMOVE : 삭제
  • MERGE : 병합
  • REFRESH : REFRESH
  • DETACH : DETACH

실제로는 all, persist 정보만 사용한다. 영속성 전이는 연관관계를 매핑하는것과는 아무 관련이 없다 엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공하는 것이 전부다. 하나의 부모에서 자식을 관리하는 경우에 유용하다. 게시판 같은 경우가 유용하다.

고아객체

부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 것이다.

@Entity
public class Parent{
   @Id
   @GeneratedValue
   private Long id;

   @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true) // 고아객체를 삭제하는 속성 추가
   private List<Child> childList = new Array<>();

   public void addChild(Child child){ // 편의 메서드
       childList.add(child);
       child.setParent(this);
   }
}

orphanRemoval = true를 추가하면 부모엔티티의 컬렉션에 삭제되면 DB의 값도 삭제되게 된다.

Parent findParent = em.find(Parent.class, parent.getId());
findParent.getChildList().remove(0); // orphanRemoval 동작

부모 엔티티에서 자식컬렉션중 0번째를 삭제하면 컬렉션에서만 지워지는게 아니라 실제 delete 쿼리가 날라가면서 DB에서 삭제되게 된다.

참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 판단하고 삭제하는 기능이다. 영속성 전이와 고아객체를 관리하는 옵션을 모두 사용하게되면 결과적으로 도메인 주도 설계의 Aggregate Root 개념을 구현할 때 유용하다 즉, 부모엔티티를 가지고 자식엔티티의 생명주기를 관리하는것이 가능해지는 것이다.

값 타입

JPA의 데이터 타입 분류

  • 엔티티 타입
    • @Entity로 정의하는 객체
    • 데이터가 변해도 식별자로 지속해서 추적 가능하다.
    • 예) 회원 엔티티의 키, 나이 값을 변경해도 식별자로 인식이 가능하다.
  • 값 타입
    • int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
    • 식별자가 없고 값만 있으므로 변경시 추적이 불가능하다.
    • 예) 숫자 100을 200으로 변경하면 완전히 다른 값으로 대체 되버린다.

JPA에는 최상위에 엔티티 타입과 값 타입이 있다. 그리고 값타입 안에는 기본값 타입, 임베디드 타입, 컬렉션 값 타입으로 나뉘어진다.

임베디드 타입(복합값 타입)

  • 새로운 값 타입을 직접 정의할 수 있음
  • JPA는 임베디드 타입(embedded type) 이라 부른다.
  • 주로 기본 값 타입을 모아서 만들어서 복합 값 타입이라고도 부름
  • int, String과 같은 값 타입이다. 엔티티 타입이 아니라서 변경하면 그냥 끝이다.

예를들어 member객체에 집주소, 우편주소, 번지수, 시작일, 종료일 같은 내용이 담겨있다고 할때 [집주소,우편주소,번지수] 를 homeAddress 라는 하나의 객체로 묶고 [시작일,종료일]을 workPeriod라는 객체로 묶어준다. 그럼 좀더 의미적으로 객체지향적인 관리가 이루어지게 될것이다. 그리고 따로 빼준 객체에 @Embeddable을 넣어주고 함축어되어 있는 객체 필드에 @Embedded 어노테이션을 붙여주면 된다.

Member 객체

@Entity
public class Member{
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")

    @Column(name = "USERNAME")
    private String username;

    //기간 Period
    @Enbedded
    private Period workPeriod;

    //주소
    @Enbedded
    private Address homeAddress;
}

Period 객체

@Embeddable
public class Period{
    private LocalDateTime startDate;
    private LocalDateTime endDate;
}

Address 객체

@Embeddable
public class Address{
    private String city;
    private String street;
    private String zipcode;
}

위 내용처럼 엔티티의 내용중 응집력을 가져야할 내용들을 따로 빼내서 객체로 만들어주고 따로 빼낸 객체에 @Embeddable 빼놓은 객체를 가져다쓸 엔티티에 @Enbedded 어노테이션을 붙여주면 된다.

값 타입과 불변 객체

값 타입은 복잡한 객체 세상을 조금이라도 단순화 하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 한다.

Address address = new Address(city, street, 1000);

Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(address);
em.persist(member);

Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(address);
em.persist(member2);

member.getHomeAddress().setCity("newCity"); // 첫번째 멤버만 바꾸려 했는데 참조관계로 모두 바뀜

위 코드처럼 멤버1과 멤버2를 만든다. 그런데 맨밑에 첫번째 멤버의 homeAddress의 city를 변경하려고할때 의도치 않게 모두 변경되는 문제가 발생한다. 이런식의 사이드 이펙트에서 발생하는 버그현상은 잡아내기 굉장히 어렵다. member를 만드는행위는 계층에서 이루어지고 비즈니스 로직도중에 member의 값을 바꾸는 행위를 했는데 의도치않게 다른 모든 멤버의 값이 변경되는 오류가 발생할 수 있다.

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다.

Address address = new Address(city, street, 1000);

Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(address); // 여기에선 그냥 address를 삽입
em.persist(member);

Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());

Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(copyAddress); // 복사한 copyAddress를 삽입
em.persist(member2);

이런 식으로 address를 그냥 넣는게 아니라 복사를 해서 값을 사용해야 한다.

객체 타입의 한계 및 불변객체

  • 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다. (하지만 누군가 실수로 복사를 안하고 그냥 값을 넣는다면 막을 방법이 없다.)
  • 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다. (원시데이터가 아닌 객체는 = 으로 대입하면 무조건 참조관계가 되는데 이걸 == 비교하면 true가 나올 수가 없다.)
  • 결과적으로 객체는 = 으로 넣으면 다 대입이 가능해서 공유참조를 피할 수 없다. (기본타입을 primitive type 이라고 부름, 자바스크립트의 원시 데이터)
  • 애초에 불변객체, 즉 변경이 불가능한 객체로 만들어버린다. 생성 시점 이후에 값을 변경하는게 불가능한 객체로 만들어버려야 한다.
  • 애초에 값을 생성자로만 삽입하고 setter를 만들지 않으면 된다.
  • Integer, String은 자바가 제공하는 대표적인 불변 객체 이다.

값 타입의 비교

값타입의 비교에서는 인스턴스가 달라도 그안에 값이 같으면 같은 것으로 봐야한다.

int a = 10;
int b = 10;

System.out.println("boolean = " + a==b); // true

기본값은 비교하면 트루가 나오지만

Address a = new Address("서울");
Address b = new Address("서울");

System.out.println("boolean = " + a==b); // false

주소같은 경우에는 값이 같지만 객체비교는 인스턴스가 달라서 참조값이 다르기 때문에 false로 나온다.

이런 부분때문에 동일성 비교와 동등선 비교를 구분해야 한다.

  • 동일성 (identity )비교 : 인스턴스의 참조 값을 비교, == 사용
  • 동등성 (equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용
  • 값 타입은 a.equanls(b)를 사용해 동등성을 비교해야 한다.
  • 값 타입의 equals() 메소드를 적절하게 재정의 해야한다. (주로 모든 필드 사용)

equals 메소드가 기본비교가 == 비교이므로 @Override를 해서 사용해야한다. 오버라이드할때 IDE에서 기본으로 만들어주는 equals를 사용하면 된다. euqlas를 오버라이드로 구현할 때는 hashCode 메서드로 만들어 주어야한다. 그래야 hash를 사용하는 hashMap이나 자바 컬렉션에서 효율적으로 사용할 수 있다.

값 타입 컬렉션

값 타입을 List, Set, Map 과 같이 컬렉션으로 사용하는 것을 값 타입 컬렉션이라고 한다.

@Entity
public class Mameber{
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")

    @Column(name = "USERNAME")

    @Embedded
    private Address homeAddress;

    @EmementCollection // 값타입 컬렉션인 경우 적어야 하는 어노테이션
    @CollectionTable(name = "FAVORITE_FOODS", joinColumns = @JoinColumn(name = "MEMBER_ID")) // FAVORITE_FOODS라는 테이블과 매핑해서 컬렉션으로 저장한다, join이 필요한경우 @JoinColumn 사용
    private Set<String> favoriteFoods = new HashSet<>();

    @EmementCollection
    @CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
    private List<Address> addressHistory = new ArrayList<>();
}

위와 같이 값타입 컬렉션을 사용하면 된다. @Embedded의 경우 필드를 연관된 것 끼리 묶어주기 위해 사용했다. @EmementCollection과 @CollectionTable 같은 경우는 컬렉션으로 들어가는 값을 별도의 DB 테이블로 만들어 관리하는 것이고 그렇게 분리된 테이블의 값을 값 타입 컬렉션으로 매핑하는 것이다.

  • 값 타입을 하나 이상 저장할 때 사용한다.
  • @ElementCollection과 @CollectionTable를 사용해 설정하면 돤다.
  • 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다. (엔티티의 일반 필드와 컬렉션 값 타입을 같이 담을 수 없다는 뜻이다, 별도의 테입르이 필요하므로 join 키가 필요하게 된다.)
  • 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
Member member = new Member();
member.setUsername("member1"); // 일반 필드 값 채우기
member.setHomeAddress(new Address("homeCity, "street, "1000")); // Embedded 필드 값 채우기, 참조를 막기위해 new로 생성

member.getFavoriteFoods().add("치킨"); // get으로 가져와 컬렉션 다루듯이 add로 삽입한다. 
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");

member.getAddressHistory().add(new Address("old1", "street", "500")) // 참조관계를 막기위해 new로 생성해서 넣었다.
member.getAddressHistory().add(new Address("old2", "street", "500"))

em.persist(member);

em.persist(member);를 할때 addressHistory와 favoriteFoods는 컬렉션 값 타입 이여서 별도의 DB 테이블로 관리한다고 했다. 그런데 member만 persist()했을 뿐인데 값이 자동으로 FAVORITE_FOODS와 ADDRESS테이블 에도 저장이 되게 된다. 라이프사이클이 하나의 엔티티로 관리가 된다.

엔티티 안에 들어있는 값 타입 컬렉션은 모두 지연로딩(LAZY)으로 동작한다. 실제 값을 조회하거나 변경하거나 사용할 때 쿼리를 통해 값을 가지고 온다.

값 타입 컬렉션의 값 수정

// homeCity 였던 값을 newCity로 바꾸고 싶은 상황

Member findMember = em.find(Member.class, member.getId()) // 위에 생성된 객체가 있다고 치고
findMember.getHomeAddress().setCity("newCity") // set으로 변경 시도

이렇게 하면 변경되지 않을까 생각할 수 있으나 이렇게 set으로 바꾸면 안된다, 값 타입은 immutable 해야되기 때문이다.

Member findMember = em.find(Member.class, member.getId())
Address a = findMember.getHomeAddress(); // city만 바꾸고 나머지는 원래 기존값을 쓰고싶어서
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipcode())) // 새 인스턴스로 갈아끼움

setCity가 아닌 setHomeAddress를 통해 어드레스 객체를 새로 넣어주어야 한다, Address 객체의 인스턴스를 아예 새로 갈아끼워야 한다.

// 치킨을 한식으로 바꾸고 싶은 상황
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

set의 경우 아예 값을 지우고 넣어주어야 한다, 업데이트가 불가능한 상황이기도 하지만 업데이트를 하면 안되고 아예 값을 지우고 다시 넣어주어야 한다.

그런데 문제는 값 타입 컬렉션은 변경사항이 발생하면 주인 엔티티와 관련된 모든 데이터를 삭제하고 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.

결과적으로 값 타입 컬렉션은 쓰면안된다, 극복할 수는 있으나 너무 복잡해진다 이렇게 어플리케이션이 복잡해지는 상황을 피하기 위해서라도 다른방향으로 해결책을 풀어나가야 한다.

값 타입 컬렉션 대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 만들길 고려한다. (값 타입 컬렉션으로 만들어서 @ElementCollection과 @CollectionTable 어노테이션으로 만들고 join을 하는등의 노력대신 그냥 Entity 클래스로 만들라는 뜻이다.)
  • 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용 (1:N 관계 + 단방향으로 만들어서 관리)
  • 영속성 전이 + 고아 객체 제거를 사용해서 값 타입 컬렉션 처럼 사용

객체지향 쿼리 언어

실무에서는 여러가지 복잡한 쿼리들이 필요하게되고 JPA에서는 다양한 쿼리 방법을 지원한다.

  • JPQL
  • JPA Criteria
  • QueryDSL
  • 네이티브 SQL
  • JDBC API 직접사용, MyBatis, SpringJdbcTemplate 사용

JPQL 이란?

  • JPA를 사용하면 엔티티 객체를 중심으로 개발하게 된다.
  • 문제는 검색 쿼리, 예) member중 나이가 18세 이상인 모든 회원을 가져오고 싶은경우
  • 검색을 할 때도 테이블이 아닌 엔티티 객체를 대상으로 검색을 실행해야 한다.
  • 모든 DB데이터를 객체로 변환해서 검색하는 것은 불가능
  • 애플리케이션이 필요한 데이터만 DB에서 불러오려면 결국 검색 조건이 포함된 SQL이 필요

이러한 문제를 해결하기위해

  • JPA는 SQL을 추상화한 JPQL이라는 객체 지향 쿼리 언어를 제공한다.
  • SQL과 문법 유사, SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN 등등 ANSI표준 SQL에서 지원하는것은 모두 지원한다.
  • JPQL은 엔티티 객체를 대상으로 쿼리를 한다.
  • SQL은 데이터베이스를 대상으로 쿼리를 한다.

결국 JPQL을 SQL로 번역해서 실행하게 되는 것이다.

List<Member> result = em.createQuery(
    "select m from Member m where m.username like '%kim%'",
    member.class
).getResultList();

이런 식으로 쿼리를 짤수있는데 위 코드에서의 Member는 테이블 member가 아니라 엔티티객체 member를 의미하게 된다. 그런데 이런 JPQL은 사실상 스트링, 문자열로 쿼리를 만드는 것이기 때문에 동적쿼리를 만드는게 어려움이있다. (문자와 문자를 합치기 때문에 노가다가 있다.) 동적 쿼리를 해결하기 위한 방안으로 JPA Criteria 혹은 QueryDSL이 있다.

JPA표준으로써 JPA Criteria가 있긴 하지만, 복잡한 쿼리에경우 너무 복잡하고 운영하기 어려움이 많아 실무에서 잘 안쓰인다고 한다.

QueryDSL

  • 문자가 아닌 자바코드로 JPQL을 작성할 수 있다.
  • JPQL 빌더 역할을 한다.
  • 컴파일 시점에 문법 오류를 찾을 수 있다.
  • 동적쿼리 작성이 편리하다.
  • 단순하고 쉽다.
  • 실무에서 사용하길 권장한다.

JPQL 공부하기

JPQL 문법

  • 엔티티와 속성은 대소문자를 구분한다. (Member, age) 엔티티는 앞글자가 대문자여야 한다.
  • JPQL 키워드는 대소문자를 구분하지 않는다. (SELECT, FROM, where)
  • 엔티티의 이름을 사용한다. 테이블의 이름이 아니다. (@Entity의 name값을 의미한다. 기본값은 table이름과 엔티티 객체이름이 동일함)
  • 별칭을 사용할땐 별칭을 적어주어야 하고 as는 생략할 수 있다. (어찌보면 당연한 얘기)

집합과 정렬

select
    count(m), // 회원수
    sum(m.age), // 나이 합
    avg(m.age), // 평균 나이
    max(m.age), // 최대 나이
    min(m.age), // 최소 나이
from Member m

기본적으로 ANSI표준 SQL에서 지원하는것들을 다 그대로 사용할 수 있다.

TypeQuery, Query

TypeQuery는 반환타입이 명확할 때 사용한다. Query는 타입 반환이 명확하지 않을 때 사용한다.

// 반환되는 타입이 Member라는게 명확한 상황
TypedQuery<Member> query1 = em.createQuery("select m from Member m", Member.class);

// 반환되는 타입이 username은 스트링이고 age는 인트인 상황
Query query = em.createQuery("select m.username, m.age from Member m")

반환되는 결과물이 컬렉션이라면 getResultList()를 사용하면되고 컬렉션이 아니라면 getSingleResult를 사용하면 된다. 다만, getResultList()는 만약 DB에서 조회한 결과물이 없으면 빈 컬렉션이 반환되기 때문에 크게 문제가없는데 getSingleResult를 사용해서 하나의 결과물만 반환해야하는데 결과가 없으면 javax.persistence.NoResultException예외 처리에 걸린다. 결과가 2개이상이면 javax.persistence.NonUniqueResultException예외가 발생한다. 딱 하나의 결과물만 반환하지 않으면 예외처리가 실행되는 것이다.

값이 없다고 해서 예외처리가 되는것때문에 try, catch를 해야하는부분 때문에 논란이 있다고 한다. 그래서 Spring Data JPA를 사용하면 거기에선 아예 try,catch 처리가 되어있다고 한다. 표준스펙에서는 1개가 아닌 상황에서는 예외처리가 되고 Spring Data JPA에서는 자동으로 null을 반환하게 처리되어있다.

파라미터 넘겨주기

Member result = em.createQuery("select m from Member m where m.username = :username", Member.class)
    .setParameter("username", "member1") // :username 이라고 파라미터로 받기로한 값을 set으로 설정해주었다.
    .getSingleResult();

System.out.println("singleResult = " + singleResult);

:username 으로 파라미터를 표기하고 setParameter를 통해 값을 전달한다. 위치기반의 파라미터도 지원한다. 마치 Mybaties에서 param1, param2으로 사용하듯이 지원을 하긴하는데 비추한다. 파라미터가 추가되면 순서가 다 밀리니까

프로젝션

프로젝션이란 select절에서 조회할 대상을 지정하는 것을 의미한다.

  • 프로젝션 대상 : 엔티티, 임베디드 타입, 스칼라 타입(숫자, 문자등 기본 데이터 타입)
  • SELECT m FROM Member m -> 엔티티 프로젝션
  • SELECT m.team FROM Member m -> 엔티티 프로젝션 (멤버와 관련된 팀객체가 엔티티)
  • SELECT m.address FROM Member m -> 임베디드 타입 프로젝션 (이전 코드에서 Address라는 임베디드 값 타입을 만들고 @Embedded로 값을 넣었었으니까 임베디드 타입 프로젝션이 된다.)
  • SELECT m.username, m.age FROM Member m -> 스칼라 타입 프로젝션
  • DISTINCT로 중복 제거

페이징 API

JPA에서의 페이징을 위한 API는 매우 쉽고 간결하며 아트의 경지다.

  • JPA는 페이징을 다음 두 API로 추상화
  • setFirstResult(int startPosition) 조회를 시작할 위치, 0부터 시작한다.
  • setMaxResults(int maxResult) 조회할 데이터 수

이렇게 2가지 API로 페이징을 추상화 하였다.

List<Member> result = em.createQuery("select m from Member m order by m.age desc", Member.class)
    .setFirstResult(0)
    .setMaxResults(10)
    .getResultList();

System.out.println("result.size = " + result.size());
for(Member member1 : result){
    System.out.println("member1 = " + member1);
}

위 코드처럼 어디서를 기준으로 몇개의 값을 가져올 것인지만 지정해주면 된다. 심플하고 명확하다. 인간의 머리로 추상적인 작업만 채워주면 실질적인 쿼리의 구현체는 라이브러리가 알아서 해주는 것이다.

조인(JOIN)

  • inner조인 : SELECT m FROM Member m JOIN m.team t
  • outer조인 : SELECT m FROM Member m LEFT JOIN m.team t
  • 세타 조인 : SELECT count(m) from Member m, Team t where m.username = t.name

세타조인의 경우 연관관계가 없는 것을 그냥 join 하게 된다.

조인 - ON 절

  • ON절을 활용한 조인 (JPA 2.1부터 지원한다, 과거버전이므로 대부분 JPA 2.1 이상이다.)
    • 조인 대상은 on으로 필터링할 수 있다.
    • 연관관계가 없는 엔티티를 외부조인 할 수 있다. (하이버네이트 5.1 부터, 마찬가지로 대부분 5.1 이상)

예)

JPQL:
SELECT m, t FROM Member m LEFT JOIN m.team t on t.name = 'A' // 팀이름이 a인 경우에서만 멤버와 팀을 join한다.

SQL:
SELECT m.*, t.* FROM Member m LEFT JOIN Team t on m.TEAM_ID=t.id and t.name='A' 

위 처럼 on을 통해 join할 대상을 필터링 하고나서 join을 할 수 있다.

서브쿼리

JPA에서 일반 SQL 쿼리처럼 서브쿼리를 작성할 수 있다.

// 나이가 평균보다 많은 회원
select m from Member m where m.age > (select avg(m2.age) from Member m2)

// 한 건이라도 주문한 고객
select m from Member m where (select count(o) from Order o where m = o.member) > 0

서브쿼리 지원함수

  • [NOT] EXISTS (subquery) : 서브쿼리에 결과가 존재하면 참
    • {ALL | ANY | SOME} (subquery)
    • ALL 모두 만족하면 참
    • ANY, SOME : 같은 의미, 조건을 하나라도 만족하면 참
  • [NOT] IN (subquery) : 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참

JPA 서브쿼리의 한계

  • JPA는 WHERE, HAVING 절에서만 서브 쿼리를 사용할 수 있다.
  • 하이버네이트를 사용하면 SELECT절에서 서브쿼리를 사용하는게 가능하게 된다. 예) select (select avg(m1.age) from Member m1) as avgAge from Member m join Team t on m.username = t.name
  • FROM 절에서 서브쿼리는 현재 JPQL에서 사용할 수 없다.
    • 이런경우 join을 통해 대부분 해결이 가능하지만, 불가능한 경우도 있다.
    • from절에 서브쿼리가 정말 필요한 경우 네이티브 SQL로 작성하거나, 쿼리를 분해해서 2번 날리거나, 그냥 쿼리를 가져와서 애플리케이션에서 조립하는 형태로 사용하는 방법이 있겠다. (join을 통해 해결하는것이 더 좋다.)

조건식 - CASE 식

기본 CASE 식

select
    case when m.age <=10 then '학생요금'
         when m.age >=60 then '경로요금'
         else '일반요금'
    end
from Member m

단순 CASE 식

select
    case t.name
        when '팀A' then '인센티브110%'
        when '팀B' then '인센티브120%'
        else '인센티브105%'
    end
from Team t

COALESCE : 하나씩 조회해서 null이 아니면 반환 NULLIF : 두 값이 같으면 null 반환, 다르면 첫번째 값 반환

JPQL의 기본 함수

JPQL에서는 DB와 상관없이 제공해주는 표준 함수들이 있다. 예를들어 문자를 더하는 concat함수, 문자를 자르는 substring함수, 공백을 제거하는 trim함수 등이 있다. 표준으로 제공하는 기본함수 이외에 필요한 부분은 사용자 정의 함수를 통해 사용할 수 있다.

  • CONCAT : 문자를 더한다. select concat('a','b') from Member m
  • SUBSTRING : 특정 위치부터 갯수만큼 문자를 자른다 select concat(m.username, 1, 3) from Member m
  • TRIMG : 문자의 공백 제거 select trim(m.username) from Member m
  • LOWER, UPPER : 소문자, 대문자로 변경 select LOWER(m.username) from Member m
  • LENGTH : 문자의 길이 select LENGTH(m.username) from Member m
  • LOCATE : 두번째 인자를 상대로 첫번째 인자의 문자를 검사해 index를 반환한다. select locate('de', 'abcdefg') from Member m, 4를 반환한다 (문자가 아닌 숫자로 반환한다.)
  • ABS, SQRT, MOD : 수학 관련 펑션이다, SQL과 동일
  • SIZE : 컬렉션의 크기를 돌려준다. select size(t.members) From Team t
  • INDEX : 값 타입에 @OrderColumn 어노테이션을 주고 값의 자릿값을 구할때 사용한다고 하는데 별로 추천하지 않는다.

경로 표현식

  • .(점)을 찍어 객체 그래프를 탐색하는것이 경로 표현식인데 경로표현식에 3가지 경우가 있다.
select m.username -> 상태 필드
    from Member m
        join m.team t -> 단일 값 연관 필드
        join m.orders o -> 컬렉션 값 연관 필드
where t.name = '팀A'

위 예시처럼 상태필드, 단일 값 연관 필드, 컬렉션 값 연관 필드 총 3가지로 구분된다.

3가지 경우를 구분해야 하는 이유가, 내부가 동작하는 방식이 다르고 결과가 달라진다.

  • 상태필드(state field) : 단순히 값을 저장하기 위한 필드다. m.username
  • 연관 필드(association field) : 연관관계를 위한 필드
    • 단일 값 연관 필드: @ManyToOne, @OneToOne, 대상이 엔티티 (m.team)
    • 컬렉션 값 연관 필드: @OneToMany, @ManyToMany, 대상이 컬렉션 (m.orders)

경로 표현식 특징

  • 상태필드: 경로 탐색의 끝, 더이상 탐색할 부분이 없다.
  • 단일 값 연관 경로: 묵시적 내부 조인(inner join)발생, 탐색 O
  • 컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 탐색 X
    • FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색이 가능하다.

위 내용과 같은 특징이 있는데 결론은 묵시적 join은 사용하면 안된다. 어플리케이션이 커지고 쿼리가 수백개씩 발생하는데 묵시적 join이 발생하면 나중에 큰 문제를 겪게되고, 묵시적 조인은 조인이 발생하는 상황을 한눈에 파악하기 어렵다는 것 자체가 문제고 SQL 성능 튜닝에도 큰 영향을 주므로 쓰지않아야 한다. 항상 묵시적 join이 아닌 명시적 join을 사용해 값을 가져와 탐색을 해야한다.

fetch join

페치조인은 실무에서 매우 중요하다.

  • SQL 조인의 종유가 아니다 X
  • JPQL에서 성능 최적화를 위해 제공하는 기능
  • 연관된 엔티티나 컬렉션을 SQL 한번에 함께 조회하는 기능
  • join fetch 명령어 사용
  • 페치 조인 ::= [LEFT [OUTER] | INNER] JOIN FETCH 조인경로

엔티티 페치 조인

  • 회원을 조회하면서 연관된 팀도 함께 조회(SQL 한번에)
  • SQL을 보면 회원 뿐만 아니라 팀(T.*)도 함께 SELECT
  • JPQL 에서 select m from Member m join fetch m.team을 실행하면
  • SQL에서 실제로 날라가는 쿼리는 SELECT M.*, T.* FROM MEMBER M INNER JOIN TEAM T ON M.TEAM_ID=T.ID 으로 변환된다.

만약 리스트를 뿌려야하는 상황이여서 반복문을 돌리며 멤버의 이름과 멤버가 속한 팀의이름을 가져와 뿌리는 상황이라면 프록시로 team정보를 가지고 있다가 team.getName()을 할 때 프록시가 실제 값에 접근하게 된다. 그럼 지연로딩으로 인해(모든 엔티티를 LAZY로 설정해야함) 반복문에서 getName을 할 때 DB에 쿼리를 날려서 영속성 컨텍스트에 값을 올려서 결과를 반환하게 된다. 즉 쿼리가 여러방이 나가게 된다는 것이다 만약 회원이 100명인데 회원마다 속한 team이 전부 다르면 쿼리가 100번이상 발생한다는 의미이다.

1 : N 상황일 때 페치조인

1:N 인 상황에서 페치조인을 하면 데이터가 뻥튀기 된다. join을 하면 팀A에 몇명의 회원이 있는지 알 수 없고 속해있는 회원수 만큼 컬럼이 늘어나기 때문이다. 그럼 반복문을 돌릴 때 중복된 결과가 나오게되는데 이걸 제거하기위해 DISTINCT를 사용하게 된다.

fetch join과 DISTINCT

  • SQL의 DISTINCT는 중복된 결과를 제거하는 명령
  • JPQL의 DISTINCT 역할은 SQL처럼 하나의 DISTINCT로 처리가 불가능하기 떄문에 2가지 기능이 제공된다.
    • SQL에 DISTINCT를 추가
    • 애플리케이션에서 엔티티 중복 제거

SQL에 DISTINCT를 추가해서 쿼리가 실행될 때 중복을 제거한 이후 엔티티가 중복되는 경우가 있는지를 확인해 엔티티의 중복도 제거해주는 것이다.

select distinct t From Team t join fetch t.members를 사용해 DB에서의 중복과 식별자가 같은 엔티티 중복을 제거를 한다.

fetch join 의 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다. (하이버네이트는 가능 하지만 가급적 사용하지 말 것) *둘 이상의 컬렉션은 페치 조인 할 수 없다.
  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다. (setFirstResult, setMaxResults)
    • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징이 가능
    • 하이버네이트는 경고 로그를 남기고 메모리에서 페이징 (매우 위험)

fetch join을 할 때 페이징API를 쓸 수 없는것을 해결하기위한 글로벌 옵션으로 persistence.xml에(JPA 설정 파일)에 옵션을 넣는부분에

<property name="hibernate.default_batch_fetch_size" value="100"/>

default_batch_fetch_size옵션의 value는 1,000 이하로 설정하는 것이 좋다. JPA에서의 성능문제는 대부분 N+1 문제, 즉 쿼리가 예상했던것 이상으로 발생하는 문제인 경우가 많고 해결방법으로 fetch join을 이용해 해결이 가능하다.

fetch join - 정리

  • 모든 것을 페치 조인으로 해결할 수 는 없다.
  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적 이다.
  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치조인 보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적 이다.

JPQL 엔티티 직접사용

  • JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키 값을 사용
  • JPQL에서 엔티티를 직접사용하는 경우
    • select count(m.id) from Member m // 엔티티의 아이디를 사용
    • select count(m) from Member m // 엔티티를 직접 사용
  • 위에 두가지 select문이 SQL로 변형될 때 select count(m.id) as cnt from Member m 이렇게 실행된다.

jpql에서 엔티티아이디를 사용하거나 엔티티를 count로 찍어도 둘다 같은 쿼리가 실행되어 count(m.id)로 실행되게 된다. 즉, 엔티티를 파라미터로 전달할 수 있고 식별자(id)를 전달할 수도 있다. 하지만 실행되는 쿼리는 똑같이 식별자로 쿼리를 실행시킨다는 것이다.

Named 쿼리 - 어노테이션

@NamedQuery 어노테이션을 사용해 쿼리를 함수로 빼놓는것 처럼 정해놓고 name값으로 불러올 수 있다.

@Entity
@NamedQuery(
    name = "Member.findByUsername",
    query = "select m from Member m where m.username = :username")
public class Member {
    ...
}

List<Member> resultList =
    em.createNamedQuery("Member.findByUsername", Member.class)
        .setParameter("username", "회원1")
        .getResultList();

위와 같이 entity를 지정할 때 @NamedQuery어노테이션으로 네임드쿼리를 정의하고 name값으로 이름을 정해준다. 나중에 쿼리를 사용할 때 name값을 이용해 호출하면 재사용이 가능해진다.

  • 미리 정의해서 이름을 부여해두고 사용하는 JPQL
  • 동적 쿼리는 안되고 정적쿼리만 가능해진다.
  • 어노테이션, XML에 정의하는것이 가능해진다.
  • 애플리케이션 로딩 시점에 초기화 후 재사용한다 (이부분이 큰 장점, 정적쿼리니까 변하지 않을 것이고 어플리케이션 로딩시점에 하이버네이트나 JPA같은 것들이 네임드쿼리를 파싱해서 캐싱하게 된다.)
  • 애플리케이션 로딩 시점에 쿼리를 검증한다. (이부분 역시 큰 장점이다. 애플리케이션을 실행할 때 미리 에러를 체크해줄 수 있다. 문법오류 등등 파악가능)

컴파일을 할 때 에러가 나는것이 가장 좋고, 어플리케이션이 실행되는 런타임 시점에 오류가 나는것도 그나마 낫지만 사용자가 버튼을 누를 때 에러가 발생하는 에러가 가장 최악의 에러이다. 실무에서는 spring data JPA를 사용하게될텐데 그떈 Repository 인터페이스에 @Query어노테이션으로 적어주면 알아서 네임드쿼리로 작성해준다. 엔티티 안에 네임드쿼리로 적어주게되면 엔티티가 너무 지저분해지는데 이런부분을 해소해준다.

벌크연산

pk를 찍어서 업데이트를 하는 상황을 제외한 모든 update, delete 문이라고 생각하면 된다. 즉 글로벌하게 적용되어야 하는 update, delete를 실행해주는게 벌크연산이라고 생각하면 된다.

예를들어 재고가 10개 미만인 모든 상품가격을 10% 상승시키려면, 너무많은 SQL실행이 이루어져야 한다.

  1. 재고가 10개 미만인 상품을 리스트로 조회해야되고
  2. 상품 엔티티의 가격을 10% 증가시키고
  3. 트랜잭션 커밋 시점에 변경감지가 동작한다.

변경한 데이터가 100건이면 100번의 update SQL이 실행되어야되고 100만건이면 100만번 실행되어야 한다. 이런걸 해결하기 위한게 벌크연산이다.

em.createQuery("update Member m set m.age = 20")
    .excuteUpdate(); // integer를 반환하는데 영향을 받은 row의 숫자를 의미한다.

위 처럼 모든 멤버나이를 20살로 변경하는 예제이다. 하이버네이트를 사용하면 insert를 벌크연산 하는것도 가능하다.

벌크 연산 주의

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스를 직접 쿼리한다.
    • 벌크연산을 먼저 실행하고
    • 벌크 연산 수행 후 영속성 컨텍스트를 초기화 한다.
    • 플러시는 자동으로 벌크연산 이전에 실행해준다.

즉, 영속성 컨텍스트를 건드릴 만한 행위를 하기전에 벌크연산을 먼저 하거나, 벌크연산을 수행한 이후 DB의 내용과 영속성 컨텍스트의 내용이 다른상황을 막기위해 영속성컨텍스트를 초기화해주면 된다.( em.clear() )

변경 감지와 병합 (merge)

JPA를 사용할 때 변경 감지와 머지의 차이를 이하하는게 매우 중요하다

public class ItemUpdate{

    @Autowired
    EntityManager em;

    public void updateTest(){
        Book book = em.find(Book.class, 1L);

        book.setName("example"); // 변경감지 == dirty checking 
    }

}

위 코드를 보면 em.find로 엔티티의 값을 찾으면 영속성컨텍스트에 값이 캐시가 되어서 값이 변경되면 알아서 커밋할때(플러시 될 때) 변경감지(더티체킹)를 통해 값이 변경된다, 내가 원하는 값으로 업데이트가 된다 그런데 준영속 상태 즉, 더이상 영속성 컨텍스트가 관리해주지 않는 상태이다.

준영속성 상태가 되기위해서는 em.detach() em.clear() 혹은 em.close()와 같은 메서드를 통해 영속성 컨텍스트를 비우는 경우도 해당되지만 DB에 저장되어 있는 값을 호출해와서 객체에 담는 행위도 준영속 상태로 볼 수 있는 것이다.

book 아이템을 수정하는 컨트롤러 코드

@PostMapping("/items/{itemId}/edit")
public String updateItem(@PathVariable String itemId, @ModelAttribute("form") BookForm form){
    Book book = new Book();
    book.setId(form.getId());
    book.setName(form.getName());
    book.setPrice(form.getPrice());
    book.setStockQuantity(form.getStockQuantity());
    book.setAuthor(form.getAuthor());
    book.setIsbn(form.getIsbn());

    itemService.saveItem(book);
    return "redirect:/items";
}

위 상태가 준영속 상태에 해당되는 상황이다. Book book = new Book(); 으로 새로운 객체를 만들고 book.setId()를 통해 새로만든 객체긴 하지만 식별이 가능한 id를 할당해 주었다. 새로 만든 객체긴 하지만 식별이 가능하므로 준영속 상태가 되는것이다. 마치 em.persist()로 값을 저장한뒤 영속성컨텍스트가 비워진 것처럼 말이다.

이렇게 준영속 상태인 경우에는 영속성 컨텍스트가 관리를 안해주니까 단순히 set을 통해 값을 바꿔도 변경이 감지가 안된다 그래서 변경하기위해 2가지 방법을 사용할 수 있다.

  1. 변경 감지 기능을 사용
    • DB에서 id를 가지고 find()로 실제 엔티티를 조회해서 변경하고싶은 내용을 변경한다(변경하고 싶은값은 파라미터로 받음) @Transactional 어노테이션 안에서 set을 통해 값을 변경하면 알아서 커밋되는 시점에 영속성 컨텍스트가 값의 변경을 추적해 업데이트 시켜주게된다.
  2. 병합(merge) 사용
    • 병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능이다.
    • 머지를 사용하면 id로 엔티티 객체를 찾아 전달받은 값의 변경사항을 알아서 갈아끼워 주는 것인데 직접 변경 감지로 set을 통해 값을 바꾸는 것과는 동작에 차이가 있다.
    • 변경감지와 병합이 매우 비슷해 보이는데 실무에서 주의해야 하는 부분은 모든값을 다 바꾼다는점, 병합시 값이 없으면 null로 업데이트할 위험이 있다는 점이다. (병합은 모든 필드를 교체한다.) 실무에서는 이것이 서비스 장애를 만들 가능성이 있다.

실무에서는 변경감지 기능을 통해 수정을 하는것이 더 나은 방법이고, 머지는 별로 사용되지 않는다. 실무에서는 항상 요구사항과 기획이 변경될 가능성, 기타 등등의 이유로 훨씬 복잡하다 머지를 아예 사용한다고 변경감지로 업데이트 되어야하는 값만 정확하게 변경시켜주는것이 더 좋은 선택이다.

  • 가장 좋은 해결 방법
    • 병합을 사용하지 말고 변경감지를 이용한다
    • 컨트롤러에서 어설프게 엔티티를 생성하지 말자
    • 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 전달하자 (파라미터 혹은 너무 파라미터가 많으면 Dto를 별도로 생성하자)
    • 트랜잭션이 있는 서비스 계층에서 영속상태의 엔티티를 조회하고(id를 통해find()로 DB에서 찾아옴) 엔티티의 데이터를 직접 변경해주자
    • 트랜잭션 커밋 시점에서 알아서 변경감지(더티체킹)를 통해 값을 플러시 해주며 변경된다.

'BACKEND' 카테고리의 다른 글

Node.js 로그인 구현하기 (passport)  (2) 2023.11.25
Mybatis 간단 사용법 익히기  (1) 2023.11.21
JPA 학습하기 (1편)  (3) 2023.11.19
multer - 파일 업로드 관리 (Node.js)  (1) 2023.11.18
MySQL 기초  (0) 2023.11.17