728x90
반응형
SMALL
반응형
SMALL

2024.11.01 업데이트


페치 조인은 JPQL에서 엄청 엄청 중요한 내용 중 하나라는 생각이 든다. 왜 그러냐면 이 페치 조인이 연관된 엔티티를 SQL 한 번에 다 가져올 수 있는 방법인데 이게 왜 중요하냐? 이전에 N+1 문제에 대해 얘기하면서 이 N+1 문제는 즉시로딩뿐 아니라 지연로딩에서도 발생한다고 했다. 왜냐? 팀과 멤버를 생각해보면, 멤버를 전체 다 가져왔을 때 팀을 지연로딩으로 설정해 놓으면 최초 멤버를 가져올때는 팀을 쿼리해서 가져오지 않는다. 지연로딩이니까. 근데 이렇게 가져온 결과로 멤버에 접근해서 getTeam().getName() 이런 메서드를 호출하는 순간 팀을 가져오기 위한 프록시 초기화를 실행하고 이때 쿼리가 나간다. 즉, 지연로딩도 이렇게 N+1 문제가 발생한다는 이야기다. 그 문제를 해결할 수 있는 방법이 최초 한번의 SQL 쿼리로 멤버를 가져올 때 팀도 한번에 다 조인해서 가져오는 페치 조인을 사용하는 것이다.

 

사용법도 너무 간단하다. 아래처럼 JOIN FETCH로 조인할 연관 엔티티만 호출하면 된다.

SELECT m FROM Member m JOIN FETCH m.team

JPQL은 다음과 같은 SQL로 변환되어 나간다.

SELECT m.*, t.* FROM Member m INNER JOIN TEAM t ON m.team_id = t.id

m.*, t.*은 멤버와 팀의 모든 필드를 축약해서 작성한 것 뿐이고 그냥 내부 조인이 발생한다. 내부 조인이 발생하면 멤버와 팀의 데이터를 다 가져오기 때문에 한 번에 모든 데이터를 가져올 수 있는데 이게 사실 즉시 로딩이다. 그러나 즉시 로딩은 사용하면 안된다고 했었는데 즉시 로딩은 내가 멤버를 조회할 때 팀을 같이 조회할 필요가 없음에도 조인으로 가져오기 때문이었다. 그래서 지연 로딩이 기본으로 적용되어야 한다고 했는데 지연 로딩일 때 멤버를 조회하면 팀을 같이 조회해야 하는 경우 이 한 번의 쿼리로 모든 걸 다 해결할 수 있게 하는 것.

 

 

실제 사용 코드를 예시로 보자.

FETCH JOIN을 사용하지 않았을 때

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            for (int i = 0; i < 3; i++) {
                Team team = new Team();
                team.setName("Team" + i);
                entityManager.persist(team);

                Member member = new Member();
                member.setName("member" + i);
                member.setTeam(team);

                entityManager.persist(member);
            }

            entityManager.flush();
            entityManager.clear();

            List<Member> findMembers = entityManager
                    .createQuery("SELECT m FROM Member m", Member.class)
                    .getResultList();

            for (Member findMember : findMembers) {
                System.out.println("findMember.getName() = " + findMember.getName());
                System.out.println("findMember.getTeam().getName() = " + findMember.getTeam().getName());
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 멤버와 그 멤버가 속할 팀을 루프안에서 만든다. 3명의 멤버가 각각 다른 팀에 속해있다. 멤버를 영속 컨텍스트에 영속시킨 후 루프가 끝난 후 영속 컨텍스트를 플러시 한다. 그 후에 모든 멤버를 가져와 각 멤버의 정보를 출력한다. 
  • 이때 발생되는 결과 SQL쿼리를 확인해 보자.
Hibernate: 
    /* SELECT
        m 
    FROM
        Member m */ select
            member0_.id as id1_0_,
            member0_.name as name2_0_,
            member0_.TEAM_ID as team_id3_0_ 
        from
            Member member0_
findMember.getName() = member0
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
findMember.getTeam().getName() = Team0
findMember.getName() = member1
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
findMember.getTeam().getName() = Team1
findMember.getName() = member2
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
findMember.getTeam().getName() = Team2

나가는 SQL 쿼리에 집중해 보자. 최초에 멤버를 전부 조회하는 쿼리가 한 번 나간다. 합리적이다. 그런데 멤버의 팀을 찍으려고 할 때마다 팀을 조회하는 쿼리가 매번 나간다. 왜냐하면 멤버를 가져왔을 때 멤버가 가지고 있는 팀은 엔티티가 아닌 프록시이기 때문에 실제 사용하는 시점에 프록시에 엔티티 값을 집어넣어 줘야 하므로. 보면 팀 3개를 출력하기 위해 3번의 SQL문이 나간 게 보이는가? 이게 N+1 문제다. 지연로딩이든 즉시로딩이든 어떻게 사용하느냐에 따라 N+1 문제는 발생할 수 있게 된다.

 

그럼 지연로딩이었던 것을 즉시로딩으로 바꿀까? 절대 안 된다. 이 경우는 멤버를 조회할 때 팀도 조회하는 비즈니스 로직이기 때문에 이렇게 팀을 모두 조회하지만 그렇지 않은 로직을 타는 경우에는 불 필요한 조인이 발생하면 안된다. 이걸 해결해 주는 것이 페치조인이다.

 

FETCH JOIN을 사용할 때

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            for (int i = 0; i < 3; i++) {
                Team team = new Team();
                team.setName("Team" + i);
                entityManager.persist(team);

                Member member = new Member();
                member.setName("member" + i);
                member.setTeam(team);

                entityManager.persist(member);
            }

            entityManager.flush();
            entityManager.clear();

            List<Member> findMembers = entityManager
                    .createQuery("SELECT m FROM Member m JOIN FETCH m.team", Member.class)
                    .getResultList();

            for (Member findMember : findMembers) {
                System.out.println("findMember.getName() = " + findMember.getName());
                System.out.println("findMember.getTeam().getName() = " + findMember.getTeam().getName());
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 코드에서 변경된 부분은 딱 하나. 쿼리문에 JOIN FETCH가 추가된 것뿐이다. 그러나 결과는 엄청난 차이가 있다. 결과문을 보자.
Hibernate: 
    /* SELECT
        m 
    FROM
        Member m 
    JOIN
        FETCH m.team t */ select
            member0_.id as id1_0_0_,
            team1_.id as id1_3_1_,
            member0_.name as name2_0_0_,
            member0_.TEAM_ID as team_id3_0_0_,
            team1_.name as name2_3_1_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id
findMember.getName() = member0
findMember.getTeam().getName() = Team0
findMember.getName() = member1
findMember.getTeam().getName() = Team1
findMember.getName() = member2
findMember.getTeam().getName() = Team2
  • SQL문 딱 하나에 모든 데이터가 들어오고 한 번에 모두 조회가 가능해졌다. 위에서 고작 멤버 3명을 조회하기 위해 쿼리 4번이 나간 반면, 지금은 단 한 번의 쿼리로 모든 걸 만족시켰다. 이게 페치 조인이다. 
  • 엔티티 타입을 페치 조인해봤으니 컬렉션 값 타입도 페치 조인해보자. 살짝 다르고 알아야 할 부분이 있다. 

 

컬렉션 페치 조인 (일대다 관계, 컬렉션 페치 조인)

당연히 ManyToOne에서 페치 조인(멤버에서 팀을 가져올 때)이 가능하면 OneToMany에서도 페치 조인(팀에서 멤버들을 가져올 때)이 가능할건데 이 일대다에서 조인을 했을 때 조심할 부분이 있다. 우선 페치 조인으로 데이터를 가져와보자. 이건 페치 조인을 떠나서 그냥 일대다 입장에서 조인을 할 때 항상 조심할 부분이다.

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            Team team = new Team();
            team.setName("TEAM1");
            entityManager.persist(team);

            Team team2 = new Team();
            team2.setName("TEAM2");
            entityManager.persist(team2);

            Member member1 = new Member();
            member1.setName("MEMBER1");
            member1.setTeam(team);

            Member member2 = new Member();
            member2.setName("MEMBER2");
            member2.setTeam(team);

            Member member3 = new Member();
            member3.setName("MEMBER3");
            member3.setTeam(team2);

            entityManager.persist(member1);
            entityManager.persist(member2);
            entityManager.persist(member3);

            entityManager.flush();
            entityManager.clear();

            List<Team> findTeams = entityManager
                    .createQuery("SELECT t FROM Team t JOIN FETCH t.members", Team.class)
                    .getResultList();

            for (Team findTeam : findTeams) {
                System.out.println(findTeam.getName() + "|members.size(): " + findTeam.getMembers().size());
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • TEAM1, TEAM2 두 개의 팀이 있고, MEMBER1, MEMBER2, MEMBER3 세 명의 멤버가 있다.
  • TEAM1에는 MEMBER1, MEMBER2가 포함되어 있다.
  • TEAM2에는 MEMBER3이 포함되어 있다. 
  • 이 상태에서 팀을 뽑을건데 팀에 속한 Members를 페치 조인으로 가져와보자.
Hibernate: 
    /* SELECT
        t 
    FROM
        Team t 
    JOIN
        FETCH t.members */ select
            team0_.id as id1_3_0_,
            members1_.id as id1_0_1_,
            team0_.name as name2_3_0_,
            members1_.name as name2_0_1_,
            members1_.TEAM_ID as team_id3_0_1_,
            members1_.TEAM_ID as team_id3_0_0__,
            members1_.id as id1_0_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
TEAM1|members.size(): 2
TEAM1|members.size(): 2
TEAM2|members.size(): 1
  • 역시, 단 한번의 쿼리로 모든걸 해결했지만 이상한 점이 있다. TEAM1 데이터가 두번 찍힌다는 점이다. 즉, 가져온 findTeams3개의 레코드가 있다는 의미다. 왜 그럴까? 일대다 입장에서의 조인이기 때문이다.

아래 그림을 보면 이해가 된다.

팀을 기준으로 멤버와 조인하게 되면 팀A에 소속된 멤버가 두명이니까 두개의 레코드를 가진다. 이렇기에 팀A가 위처럼 두번 출력되는 것이다. 이렇게 일대다 조인은 데이터가 불어나게 되는데 이를 해결하기 위해서는 JPA에서는 해줄 수 있는게 없다. JPA입장에서는 이 데이터가 어떻게 만들어질지 전혀 알 지 못하는 상태이기 때문에.

 

그래서 DB로부터 조인한 쿼리로 받은 데이터는 이렇게 두 개가 들어오고 JPA는 그것을 객체로 표현해주기 때문에 다음과 같은 그림이 된다.

그래서 우선은 받은 객체가 두개니까 메모리에 주소값이 두개가 할당된다. 그러나, 그 두개의 객체는 같은 객체이므로 바라보는 주소는 같다. 영속 컨텍스트에는 같은 객체라면 하나만을 사용하면 되므로 영속 컨텍스트에는 하나의 객체만 있지만 조회한 findTeams 컬렉션에는 두개가 담겨있는 것. 그럼 중복을 제거하고 싶을 땐 어떻게 할까? DISTINCT를 사용하면 된다.

 

허나, SQLDISTINCT는 위 문제를 해결해주지 않는다. SQL에서 DISTINCT는 중복된 결과(완전히 똑같은)를 제거하는 명령이기 때문에 두 레코드를 중복으로 바라보지 않는다. 중복이 아니잖아. 레코드 기준으로 회원아이디도 회원이름도 다르니까.

 

그러나, JPQLDISTINCT는 2가지의 기능을 제공해준다.

  • SQLDISTINCT를 추가해주는 작업
  • 애플리케이션에 올라온 객체에서 엔티티 중복(주소가 같은 Team 객체)을 제거해준다. 이 엔티티 중복을 제거해줄 때 위 상황 같은 중복이 제거가 된다.

 

근데 그럼 중복 제거가 되면 문제가 없나요? SQL에서 DISTINCT는 완전히 똑같아야 제거를 해주는데 완전히 똑같지는 않잖아요?

▶ 맞다. 그런데 우리는 지금 객체지향 세계에 있다. 우리는 하나의 Team에 속한 모든 Member들을 가져오는 리스트를 Team 객체가 가지고 있기 때문에 어차피 그 안에 회원 2명이 담겨 오기 때문에 팀에 대한 중복 제거가 되도 아무런 문제가 없다.

 

그래서 결론은 DISTINCT 예약어를 추가해보자. 

List<Team> teams = em.createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class)
                    .getResultList();

실행 결과

Hibernate: 
    /* SELECT
        DISTINCT t 
    FROM
        Team t 
    JOIN
        FETCH t.members */ select
            distinct team0_.id as id1_3_0_,
            members1_.id as id1_0_1_,
            team0_.name as name2_3_0_,
            members1_.name as name2_0_1_,
            members1_.TEAM_ID as team_id3_0_1_,
            members1_.TEAM_ID as team_id3_0_0__,
            members1_.id as id1_0_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
TEAM1|members.size(): 2
TEAM2|members.size(): 1

 

 

일반 조인과 페치 조인의 차이점

일반 조인과 페치 조인은 어떤점이 다를까? 일반 조인은 조인을 하는건 맞다. 근데 그 조인 대상의 데이터를 메모리에 즉각적으로 퍼올리지는 않는다. 코드로 보는게 제일 빠르니 코드로 봐보자.

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            Team team = new Team();
            team.setName("TEAM1");
            entityManager.persist(team);

            Team team2 = new Team();
            team2.setName("TEAM2");
            entityManager.persist(team2);

            Member member1 = new Member();
            member1.setName("MEMBER1");
            member1.setTeam(team);

            Member member2 = new Member();
            member2.setName("MEMBER2");
            member2.setTeam(team);

            Member member3 = new Member();
            member3.setName("MEMBER3");
            member3.setTeam(team2);

            entityManager.persist(member1);
            entityManager.persist(member2);
            entityManager.persist(member3);

            entityManager.flush();
            entityManager.clear();

            List<Team> findTeams = entityManager
                    .createQuery("SELECT DISTINCT t FROM Team t JOIN t.members", Team.class)
                    .getResultList();

            for (Team findTeam : findTeams) {
                System.out.println(findTeam.getName() + "|members.size(): " + findTeam.getMembers().size());
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

 

  • 딱 아래 코드 부분에 집중해보자. 페치 조인이 아닌 일반 조인이다.
List<Team> findTeams = entityManager
                    .createQuery("SELECT DISTINCT t FROM Team t JOIN t.members", Team.class)
                    .getResultList();

 

실행 결과

Hibernate: 
    /* SELECT
        DISTINCT t 
    FROM
        Team t 
    JOIN
        t.members */ select
            distinct team0_.id as id1_3_,
            team0_.name as name2_3_ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
Hibernate: 
    select
        members0_.TEAM_ID as team_id3_0_0_,
        members0_.id as id1_0_0_,
        members0_.id as id1_0_1_,
        members0_.name as name2_0_1_,
        members0_.TEAM_ID as team_id3_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
TEAM1|members.size(): 2
Hibernate: 
    select
        members0_.TEAM_ID as team_id3_0_0_,
        members0_.id as id1_0_0_,
        members0_.id as id1_0_1_,
        members0_.name as name2_0_1_,
        members0_.TEAM_ID as team_id3_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
TEAM2|members.size(): 1
  • 최초에 SQL 쿼리를 보면, 멤버를 조인을 하는건 맞다. 그러나, SELECT절엔 팀 관련 데이터밖에 없다. 그렇기 때문에 내가 가져온 팀들에서 멤버를 조회하려고 할 때 다시 멤버관련 SQL문이 나가게 된다.
  • 즉, 일반 조인은 일반 조인 과정에서 관련 엔티티 데이터를 퍼올리지 않는다. 프록시와 비슷한 상태인거지. 그래서 결국 멤버까지 가져오려면 다시 쿼리를 날리게 된다. 이런 차이가 있다.

 

페치 조인 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다.(하이버네이트는 가능하나 가급적 사용하지 않아야 한다). 
List<Team> findTeams = entityManager
                    .createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members m", Team.class)
                    .getResultList();

저 코드에서 보면 JOIN FETCH 이후에 `t.members m` 이렇게 `m`으로 별칭을 주고 있는데 이런식으로 사용할 수 없다는 얘기다. 물론 하이버네이트는 가능하게 해준다. 그러나, 진짜로 페치 조인을 몇단계로 가져가야 할 경우 아닌 이상 절대 쓰지 않기로 하자. 그 외에 별칭을 가져가야 할 경우가 있다고 생각이 들면 그땐 다른 쿼리로 해결할 수 있는 경우가 무조건 있다고 생각해라.

 

  • 둘 이상의 컬렉션은 페치 조인 할 수 없다. 

예를 들어, 팀이 '멤버'라는 일대다 관계를 가지고 있고, 또 다른 뭐 '업무분야'라는 일대다 관계를 가지고 있을 때 이 두개를 같이 페치 조인하면 안된다. 그 이유는 위에서 봤지만 일대다만 해도 데이터가 부풀려 나오는데 이 경우는 일대다대다이다. 더 심한 데이터 부풀림 현상이 일어날 가능성이 농후하고 데이터 정합성에 문제가 발생할 수 있기 때문에 안된다.

 

  • 컬렉션을 페치 조인(일대다 조인)하면 페이징 API(setFirstResult(), setMaxResults())를 사용할 수 없다.

일대다 페치 조인일 때 데이터 부풀림 현상이 일어나는것을 확인했다. 그런데 이것을 페이징한다? 데이터 정합성에 문제가 생길 가능성이 농후하다. 그냥 하면 안된다. 근데 문제는 하이버네이트는 경고 로그를 남기고 메모리에 데이터를 올린 후 그 메모리의 데이터로 페이징을 결국은 해준다는 것이다. 절대 절대 사용하면 안된다. 그냥 일대다 페치 조인을 할거면 다대일로 페이징을 하면 된다. 아래는 경고 로그를 남기고 페이징을 하는 예시이다.

List<Team> findTeams = entityManager
                    .createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class)
                    .setFirstResult(0)
                    .setMaxResults(1)
                    .getResultList();

실행 결과

WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

 

전 죽어도 일대다로 페이징 하고 싶은데요?

▶ 그러면 방법이 있다. 가장 좋은 방법은 다대일로 페이징을 하면 되지만, 정말 때려죽어도 일대다로 페이징을 하고 싶다면, 다음과 같이 해보자.

List<Team> findTeams = entityManager
                    .createQuery("SELECT t FROM Team t", Team.class)
                    .setFirstResult(0)
                    .setMaxResults(2)
                    .getResultList();

for (Team findTeam : findTeams) {
    System.out.println(findTeam.getName() + "|members.size(): " + findTeam.getMembers().size());

    for (Member member : findTeam.getMembers()) {
        System.out.println("member.getName() = " + member.getName());
    }
}
  • 우선은 페치 조인을 하지 말고 팀을 가져오는데 여기서 페이징을 원하는 만큼 한다. (위 코드에서는 0부터 2까지)
  • 그 가져온 팀에서 멤버들을 가지고 와서 뿌려주면 된다. (위 코드에서 findTeam.getMembers()로 루프를 돌리는 부분)
  • 그런데, 이렇게 되면 팀에 있는 멤버를 가져올때마다 지연로딩에 대한 초기화가 일어나기 때문에 쿼리가 계속 나간다 즉, N+1 문제가 발생한다. 이때 BatchSize를 설정해서 한번에 여러 멤버를 가져올 수가 있다! 아래 글로벌 설정 코드를 보자.

persistence.xml

...
<property name="hibernate.default_batch_fetch_size" value="100"/>
...
  • 이렇게 batch_fetch_size를 100 정도로 설정을 하면, 팀에 있는 멤버들을 가져올 때 최대 100개까지 한번에 조회를 한다.

실행 결과

Hibernate: 
    /* SELECT
        t 
    FROM
        Team t */ select
            team0_.id as id1_3_,
            team0_.name as name2_3_ 
        from
            Team team0_ limit ?
Hibernate: 
    /* load one-to-many cwchoiit.jpql.Team.members */ select
        members0_.TEAM_ID as team_id3_0_1_,
        members0_.id as id1_0_1_,
        members0_.id as id1_0_0_,
        members0_.name as name2_0_0_,
        members0_.TEAM_ID as team_id3_0_0_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID in (
            ?, ?
        )
TEAM1|members.size(): 2
member.getName() = MEMBER1
member.getName() = MEMBER2
TEAM2|members.size(): 1
member.getName() = MEMBER3

위 쿼리를 보면 지연로딩으로 인한 프록시 초기화를 하기 위해 두번째 쿼리를 날리는데 그때 WHERE절을 잘보면 IN을 사용해서  TEAM_ID가 그 중 하나에 속했다면 멤버들을 다 가져오는 쿼리를 날리고 있다. 지금은 팀이 전체 2개밖에 없으니까 딱 IN 안에 2개만 들어갔지만, 만약 200개 300개가 넘으면 내가 설정한 BatchSize인 100개를 IN에 넣어 한번에 좀 여러개의 멤버를 가져올 수 있다. 이렇게 최적화를 하면서 일대다 페이징을 처리할 수도 있다. 

 

페치조인 한계 마무리

  • 모든 것을 페치 조인으로 해결할 수는 없다. (그러나, 거의 95%는 페치 조인으로 다 해결이 된다고 본다)
  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. 그러니까 멤버와 팀이 있으면 각각이 가진 필드들이나 참조를 그대로 반환하고 사용하는 경우를 말한다. 그렇지 않은 경우가 아래 경우다.
  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면 페치 조인보다는 일반 조인을 사용해서 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적이다.

 

다형성 쿼리

그렇게 중요한 부분은 아닌데 알고 가면 좋으니까 추가적으로 작성한다. 이전 포스팅에서 상속 관계 매핑에서 배운 부모-자식 관계에 대한 엔티티가 있을 때에 대한 이야기이다.

 

이렇게 생겨먹은 엔티티 구조가 있을 때, 조회 대상을 특정 자식으로 한정하는 쿼리에 대해 알아보자.

예를 들어, "Item 중에 Book, Movie를 조회해라" 라는 질의가 있으면 이렇게 하면 된다.

// JPQL
SELECT i 
FROM Item i
WHER TYPE(i) IN (Book, Movie)

// SQL
SELECT i
FROM Item i
WHERE i.DTYPE IN ('Book', 'Movie')

 

 

또는 캐스팅과 같은 방법도 있다. TREAT라는 키워드인데, 예를 들어 부모인 Item과 자식 Book이 있을 때, 이런식으로 작성할 수 있다.

// JPQL
SELECT i 
FROM Item i
WHERE TREAT(i as Book).author = 'kim'

// SQL
SELECT i.*
FROM Item i
WHERE i.DTYPE = 'Book' AND i.author = 'kim'

 

 

엔티티 직접 사용

무슨 말이냐면, 바로 JPQL을 보자.

엔티티 직접 사용 - 기본키 값

// JPQL
SELECT COUNT(m.id) FROM Member m // 엔티티의 아이디를 사용
SELECT COUNT(m) FROM Member m // 엔티티를 직접 사용
  • 이런 식으로 COUNT를 사용할 때 엔티티의 기본키를 사용하는게 아니라 엔티티 자체를 가지고 사용하는 걸 말하는데 이러면 어떻게 될까? 

 

▶ SQL로 번역될 때 해당 엔티티의 기본키 값을 사용한다.

// 번역된 SQL
SELECT COUNT(m.id) as cnt FROM Member m

 

 

엔티티 직접 사용 - 파라미터 값

위와 같은 경우 말고, 파라미터로 전달할 때도 엔티티를 넘길 수 있는데 이것 역시 식별자로 변환된다.

엔티티를 파라미터로 전달

String jpql = "select m from Member m where m = :member";
List result = em.createQuery(jpql).setParameter("member", member).getResultList();

 

식별자를 전달

String jpql = "select m from Member m where m.id = :memberId";
List result = em.createQuery(jpql).setParameter("memberId", memberId).getResultList();

 

실행된 SQL (둘 다 마찬가지로)

SELECT m.*
FROM Member m
WHERE m.id=?

 

 

엔티티 직접 사용 - 외래키 값

이번엔 외래키 자리에 넣을 값도 엔티티 자체를 넘길수도 있는데 이것 역시 마찬가지로 식별자로 변환된다.

 

외래키 자리에 엔티티를 직접 전달

String jpql = "select m from Member m where m.team = :team";
List result = em.createQuery(jpql).setParameter("team", team).getResultList();

 

외래키 자리에 식별자를 전달

String jpql = "select m from Member m where m.team.id = :teamId";
List result = em.createQuery(jpql).setParameter("teamId", teamId).getResultList();

 

실행된   (둘 다 마찬가지로)

SELECT m.*
FROM Member m
WHERE m.team_id=?

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 18. 벌크 연산  (0) 2023.10.30
[JPA] Part 17. Named Query  (2) 2023.10.30
[JPA] Part 15. JPQL  (0) 2023.10.28
[JPA] Part 14. 값 타입 컬렉션  (0) 2023.10.25
[JPA] Part 13. 임베디드 값 타입  (0) 2023.10.23
728x90
반응형
SMALL
반응형
SMALL

2024.10.31 업데이트


JPQL(Java Persistence Query Language) 데이터베이스의 SQL과 유사하나 객체지향 쿼리 언어이며 그렇기에 테이블을 대상으로 쿼리하는 게 아니라 엔티티 객체를 대상으로 쿼리한다는 차이점이 있다. JPQLSQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다. 이 말은 데이터베이스마다 다른 방언(MySQL은 LIMIT, Oracle은 ROWNUM 같은)에 상관없이 동작한다는 의미이다.

 

그러나 결국, DB는 SQL만을 받기 때문에 JPQL은 결국에는 SQL로 변환된다. 

JPQL을 잘 이해하고 사용할 줄 알아야 기본적인 쿼리문을 사용하는데 문제가 없고 복잡한 쿼리를 처리해주는 QueryDsl도 편하게 사용할 수 있다. 그래서 JPQL을 잘 사용할 줄 알아야 한다.

 

JPA의 다양한 쿼리 방법

JPQL을 공부하기 앞서, JPA는 다양한 쿼리 방법을 지원한다.

  • JPQL
  • JPA Criteria
  • QueryDsl
  • native SQL
  • JDBC API 직접 사용

지금까지는 이런식으로 간단하게 작성했다. 

AddressEntity ae = entityManager.find(AddressEntity.class, 1L);

근데, 개발을 하다보면 이런 간단한 쿼리도 있지만 복잡한 쿼리도 필요할 때가 있기 마련이다. 예를 들어, "유저의 이름이 중간에 'hello'가 들어가는 유저들" 이런 쿼리를 원한다면? 사실 이것도 복잡한 쿼리도 아니다. 근데 여기서 말하고자 하는건 이렇게 조건이 들어가는 경우가 비일비재 하다는 것이다. 

 

그러면 이제, 다음과 같이 쿼리를 작성할 수 있다.

이게 바로 JPQL이다. 그리고 이게 지금 IDE가 잘 도와주고 있기 때문에 이렇게 보이는거지 사실 저건 다 문자열이다. 그래서 IDE의 도움을 받지 못하는 경우에는 문자열에 잘못된 부분이 있어도 컴파일러는 체크할 수 없다.

 

그리고 이 문자열로 쿼리를 만들어 낼 때 가장 까다로운 것은 동적 쿼리를 만들기가 정말 어렵다는 것이다. 그래서 JPA Criteria라는게 있는데 결론부터 말하면 이건 사용하지 마라. 근데 일단 스펙에 있기 때문에 뭔지는 알아야 하니까 말하자면 아래와 같이 사용하면 된다.

 

JPA Criteria 사용 예시

// Criteria 사용 준비
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(Member.class);

//루트 클래스 (조회를 시작할 클래스)
Root<Member> m = query.from(Member.class);

// 쿼리 생성
CriteriaQuery<Member> cq = query.select(m).where(cb.equal(m.get("username"), "kim"));
List<Member> resultList = entityManager.createQuery(cq).getResultList();
  • 문자가 아닌 자바 코드로 JPQL을 작성할 수 있다. 
  • JPQL의 빌더 역할을 하는게 JPA Criteria이다.
  • JPA에서 공식적으로 지원하는 기능이다. 
  • 그러나, 너무 복잡하고 실용성이 없다. 실용성이 없다는 말은 저 코드를 보고 이게 어떤 쿼리인지 인지하기가 너무 어렵다. 지금이야 아주 간단한 쿼리니까 읽다보면 이해할 수 있겠지만, 조금만 복잡해져도 알아보기가 너무 어렵다.

그래서 결론을 말하면 그냥 딱 이렇게 생각하면 된다. 

JPQL + QueryDsl 두 개를 같이 사용하면 거의 95% 경우의 쿼리를 다 수행할 수 있다. 만약, 정말 정말 복잡한 쿼리가 있어서 저 둘로 해결하지 못한다면, 그럴때 nativeQuery를 사용하면 된다. 실제로 SQL을 작성해서 그 SQL을 수행할 수 있게 해주는 녀석이다. 아래가 그 예시 코드이다. 

String sql = "SELECT ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = 'kim'";
List<Member> result = entityManager.createNativeQuery(sql, Member.class).getResultList();

 

JPQL 문법

JPQL 문법이라고 SQL과 다를 것 없다. 다만 지켜야 할 규칙이 있다.

select m from Member as m where m.username = "member"
  • 엔티티와 속성은 대소문자 구분을 한다 (Member, username)
  • JPQL 키워드는 대소문자 구분을 하지 않는다 (SELECT, FROM, where)
  • 엔티티 이름을 사용한다 (테이블 이름이 아니다)
  • 별칭은 필수(m), as는 생략 가능

 

TypedQueryQuery 타입이 있는데 TypedQuery는 반환되는 데이터의 타입이 명확할 때를 말하고 Query는 명확하지 않을 때이다.

크게 중요한 내용은 아닌데 알아둬야 하니까.

TypedQuery<Member> members = em.createQuery("select m from Member m", Member.class);

이렇게 createQuery()에 두번째 인자로 타입을 지정할 수 있는데 아무 타입이나 지정할 수 있는건 아니고 거의 대부분은 엔티티를 지정한다. 이렇게 타입을 명확히 명시를 한 상태에서의 반환타입이 TypedQuery가 된다.

 

그러나 아래와 같이 idLong, usernameString인 데이터를 받아올 때는 타입을 명시할 수 없으므로 반환되는 데이터 타입이 Query가 된다. 이런 차이가 있다.

Query members = em.createQuery("select m.id, m.username from Member m");

 

 

그러나, 저런식으로 사용하지 않고 체인으로 사용하는게 일반적이고 그 예시는 다음과 같다.

List<Member> members = em.createQuery("select m from Member m", Member.class)
                                    .getResultList();
Member singleMember = em.createQuery("select m from Member m where m.id=1", Member.class)
                                    .getSingleResult();

이렇게 한번에 getResultList() 또는 getSingleResult()로 데이터를 받아온다. 메소드 명만 봐도 알겠지만 getResultList()는 복수의 데이터를 가져오는 방식이고 getSingleResult()는 단일값을 가져오는 방식이다.

 

그러나 주의할 점이 있다.

getResultList()는 데이터가 없는 경우 빈 리스트를 반환하기 때문에 큰 문제가 되지 않는다. 그러나 getSingleResult()는 무조건 딱 더도 덜도 말고 딱! 하나만 있어야 한다. 만약, getSingleResult()를 사용하는데, 데이터가 없는 경우 NoResultException이 발생하고 데이터가 둘 이상인 경우 NonUniqueResultException이 발생한다. 이것을 주의해야한다. (Spring Data JPA는 결과가 없으면 이 에러를 처리해서 null로 반환해주긴 함)

 

파라미터 바인딩 - 이름 기준, 위치 기준

파라미터를 던져줄 상황이 굉장히 많이 발생할텐데 사용방법은 또 간단하다.

이름 기준

List<Member> members = em
                    .createQuery("select m from Member m where m.username = :username", Member.class)
                    .setParameter("username", "Member1")
                    .getResultList();

username이라는 파라미터를 던져줄 때 setParameter()를 호출한다. 그리고 받는 쪽은 where m.username = :username 여기가 파라미터를 받는 부분이다.

 

위치 기준

위치기준은 사용하지 말자. 그러나 뭐가 위치 기준인지는 알아야 하니 일단 예시는 다음과 같다.

List<Member> members = em
                    .createQuery("select m from Member m where m.username=?1", Member.class)
                    .setParameter("1", "Member1")
                    .getResultList();

"?1" 이 부분이 파라미터를 받는 부분, 주는 부분은 setParameter("1", "value") 이 부분이다. 보면 알겠지만 코드 길어지거나 안보다가 보면 "1"이런 값들이 가시성이 확 떨어진다. 또한 중간에 추가하게 되면 순서가 달라지기 때문에 다 밀리게 되고 아주 불편하다.

 

 

프로젝션

프로젝션이란, SELECT절에 조회할 대상을 지정하는 것을 말한다.

 

프로젝션 대상

  • 엔티티
  • 임베디드 타입
  • 스칼라 타입 (숫자, 문자 등 기본 데이터 타입)

엔티티 프로젝션

// 엔티티 프로젝션
SELECT m FROM Member m
  • 멤버 전체를 가져오는 쿼리이므로 엔티티 프로젝션을 말한다.
  • 그리고 이렇게 엔티티 프로젝션을 통해 엔티티를 가져오면, 엔티티 매니저에 의해서 영속성 컨텍스트가 관리하는 대상이 된다. 즉, 1차 캐시에도 들어가고 변경감지도 수행된다는 의미다.
// 엔티티 프로젝션
SELECT m.team FROM Member m
  • 멤버의 팀을 가져오는 쿼리로 팀이 엔티티이기 때문에 엔티티 프로젝션이라 한다.
  • 근데 이 코드를 수행하면 어떤 쿼리가 나갈까? 멤버가 속한 팀을 가져오고 있는데 그럼? 맞다. 조인을 하게 된다.
List<Team> result = entityManager
                    .createQuery("SELECT m.team FROM Member m", Team.class)
                    .getResultList();

실행된 쿼리

Hibernate: 
    /* SELECT
        m.team 
    FROM
        Member m */ select
            team1_.id as id1_3_,
            team1_.name as name2_3_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id

이렇듯, 조인을 한다. 물론 당연한 소리다. 당연히 조인을 해야지. 근데 JPQL만 보면 조인을 한다고 명시적으로 적어주지 않았기 때문에 JPQL과 JPA에 대해 잘 이해하고 있는 사람이야 알겠지만 그렇지 않은 사람은 모를수도 있다. 즉, 명시적으로 JPQL을 작성해 주는게 훨씬 더 좋은 방향이라고 본다. 아래처럼 말이다.

List<Team> result = entityManager
                    .createQuery("SELECT t FROM Member m JOIN m.team t", Team.class)
                    .getResultList();

 

임베디드 타입 프로젝션

// 임베디드 타입 프로젝션
SELECT m.address FROM Member m
  • 멤버의 값 타입으로 임베디드한 Address를 가져오는 쿼리이므로 임베디드 타입 프로젝션이라 한다.

스칼라 타입 프로젝션

// 스칼라 타입 프로젝션
SELECT m.username, m.age FROM Member m
  • 멤버가 가지고 있는 문자, 숫자 등 기본 데이터 타입을 가져오므로 스칼라 타입 프로젝션이라 한다.
// DISTINCT로 중복을 제거
SELECT DISTINCT m.username, m.age FROM Member m
  • 참고로 중복제거도 역시 가능하다. 그래서 풀 코드로 작성하면 이렇게 쓰면 된다.
Query members = em.createQuery("select distinct m.id, m.username from Member m");
근데 여기서 한가지 의문이 발생한다. "이 경우에는 타입을 지정못하는데 가져올 땐 어떻게 가져와야 하지?"

 

방법은 여러가지가 있다.

첫번째는, 위에서 작성한 코드처럼 Query 타입으로 가져오는 방법이다.

Query query = em.createQuery("select m.username, m.id From Member m");
List<Object[]> result = query.getResultList();

Object[] data = result.get(0);
System.out.println("username: " + data[0]);
System.out.println("id: " + data[1]);

//Output:
username: Member1
id: 1
  • 모든 데이터 타입의 상위 타입은 Object 이므로, Object[]로 받는다. []로 받는 이유는 m.username, m.id[]에 0번, 1번으로 들어가기 때문이다. 그래서 0과 1을 찍어보면 각각 usernameid를 출력한다.
  • 그러나 좀 불편한 부분이 있어보인다. 그래서 좀 더 좋은 방법은 DTO 객체로 받아오는 것이다.

두번째는, DTO 객체로 받아오는 방법이다.

SelectMemberDTO

package org.example.entity.jpql;

public class SelectMemberDTO {
    
    private Long id;
    private String username;

    public SelectMemberDTO(Long id, String username) {
        this.id = id;
        this.username = username;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}
  • 이렇게 받아줄 DTO 클래스를 만들고 이 객체로 데이터를 받아오는 방법이다. 아래 코드를 보자.
List<SelectMemberDTO> result = em
            .createQuery(
                    "select new org.example.entity.jpql.SelectMemberDTO(m.id, m.username) From Member m",
                    SelectMemberDTO.class)
            .getResultList();

    for (SelectMemberDTO selectMemberDTO : result) {
        System.out.println("selectMemberDTO id= " + selectMemberDTO.getId());
        System.out.println("selectMemberDTO username= " + selectMemberDTO.getUsername());
    }
  • 보면 new org.example.entity.jpql.SelectMemberDTO(m.id, m.username) 이라고 작성되어 있다. 여기가 문자열이기 때문에 패키지명까지 쭉 작성해줘야하는 번거로움이 있긴하지만 위에 Object로 받는 방법보다는 훨씬 가시적이다.
  • 그리고 이후에 QueryDsl을 배우고 사용하면 저렇게 패키지도 쭉 안 적어도 된다. 이후에 같이 배워보자!

 

페이징

JPA에서 페이징하는 방법이 너무 간단하게 잘 되어있다. setFirstResult(), setMaxResults()를 사용하면 끝이다.

List<Member> resultList = em
                .createQuery("select m from Member m order by m.id desc", Member.class)
                .setFirstResult(0)
                .setMaxResults(10)
                .getResultList();
  • setFirstResult(0), setMaxResults(10)을 주면 0부터 10개까지를 가져온다. 이게 끝이다. 결과는 다음과 같다.
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_0_,
        member0_.TEAM_ID as team_id3_0_,
        member0_.username as username2_0_ 
    from
        Member member0_ 
    order by
        member0_.MEMBER_ID desc limit ?
member = Member99
member = Member98
member = Member97
member = Member96
member = Member95
member = Member94
member = Member93
member = Member92
member = Member91
member = Member90

 

조인

조인의 경우 크게 세 가지가 있다.

  • 내부 조인: [INNER] JOIN
  • 외부 조인: LEFT [OUTER] JOIN
  • 세타 조인

 

내부 조인 ([INNER] JOIN)

내부조인은 아래 JPQL을 보면 멤버를 기준(`FROM Member m`)으로 조인하고 있는데 이때, 팀의 값이 있는 데이터들만 뽑아낸다. 그러니까 팀의 값이 없는 멤버 레코드는 아예 결과로 나오지가 않는게 내부 조인이다.

SELECT m FROM Member m [INNER] JOIN m.team t

 

외부 조인 (LEFT [OUTER] JOIN)

외부조인은 아래 JPQL을 보면 멤버를 기준(`FROM Member m`)으로 조인하고 있는데 이때, 팀의 값이 없어도 결과로 레코드가 나온다. 대신 팀의 값이 null로 표현된다. 참고로, LEFT [OUTER] JOIN이다. OUTER는 생략가능하고 보통은 LEFT JOIN이라고 많이 말한다. OUTER JOIN 이런건 없다!

SELECT m FROM Member m LEFT [OUTER] JOIN m.team t

 

세타 조인

아무런 연관성이 없는 데이터들도 뽑아낸다. 보면 `m.team t` 가 아니라, `FROM Member m, Team t`다. 즉, 어떠한 연관도 없는 두 엔티티끼리도 조인이 가능한데 이것을 세타 조인이라고 한다.

SELECT count(m) FROM Member m, Team t WHERE m.username = t.name

 

 

그래서 조인을 사용하는 코드를 한번 작성해보면 다음과 같다.

// 내부 조인
String query = "select m from Member m join m.team t where t.name =:teamName";
List<Member> resultList = em
        .createQuery(query, Member.class)
        .setParameter("teamName", "Team1")
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();
        
// 외부 조인 
String query = "select m from Member m left join m.team t";
List<Member> resultList = em
        .createQuery(query, Member.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();
        
        
// 세타 조인
String query = "SELECT m FROM Member m, Team t WHERE m.username = t.name";
List<Member> resultList = em
        .createQuery(query, Member.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

 

조인 대상 필터링 - ON

조인 하기 전 필터링을 해서 데이터 양을 좀 줄일 수 있고 JPQL도 역시 유사한 키워드로 지원한다.

List<Member> result = entityManager
                    .createQuery("SELECT m FROM Member m JOIN m.team t ON t.name = :teamName", Member.class)
                    .setParameter("teamName", "teamA")
                    .getResultList();
  • 저렇게 ON절을 사용해서 팀의 이름을 특정하여 필터링을 할 수가 있다.
  • 실제 나가는 쿼리는 어떻게 나갈까? 바로 아래와 같이 나간다.
SELECT m.*, t.* 
FROM Member m
INNER JOIN Team t ON m.TEAM_ID = t.id AND (t.name = 'teamA')

 

 

연관 관계가 없는 엔티티의 외부 조인도 필터링을 걸 수 있다.

List<Member> result = entityManager
                    .createQuery("SELECT m FROM Member m JOIN Team t ON t.name = m.name", Member.class)
                    .getResultList();
  • 보면 m.team을 사용한게 아니라 JOIN Team t 이렇게 작성했다. 이렇게 연관관계가 없는 상태에서도 조인을 하는게 가능하고 이걸 세타 조인이라고 하며, ON절을 사용해서 필터링할 수 있다.
  • 실제 나가는 쿼리는 어떻게 나갈까? 바로 아래와 같이 나간다.
SELECT m.*, t.* 
FROM Member m
INNER JOIN Team t ON m.name = t.name

 

서브 쿼리

SQL문에서 사용하던 서브 쿼리랑 같은 것을 말하고 JPQL도 역시 이를 지원한다. 

SELECT m FROM Member m WHERE m.age > (SELECT avg(m2.age) FROM Member m2)

이렇게 서브 쿼리를 사용할 때 위에서처럼 mm2로 서브 쿼리랑 본 쿼리의 멤버를 분리해야 일반적으로 SQL 성능이 좋다. 

 

 

서브 쿼리 지원 함수로 다음과 같은 것들이 있다.

  • [NOT] EXISTS (subquery): 서브 쿼리에 결과가 존재하면 참
# 팀A 소속인 회원
SELECT m FROM Member m WHERE EXISTS (SELECT t FROM m.team t WHERE t.name = '팀A')

 

  • {ALL | ANY | SOME} (subquery)
    • ALL: 모두 만족하면 참
    • ANY, SOME: 같은 의미, 조건을 하나라도 만족하면 참
# 전체 상품 각각의 재고보다 주문량이 많은 주문들
SELECT o FROM Order o WHERE o.orderAmount > ALL (SELECT p.stockAmount from Product p)
# 어떤 팀이든 팀에 소속된 회원
SELECT m FROM Member m WHERE m.team = ANY (SELECT t FROM Team t)
  • [NOT] IN (subquery): 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참
# 어떤 팀이든 팀에 속한 멤버들
SELECT m FROM Member m WHERE m.team IN (SELECT t FROM Team t)

JPA 서브 쿼리 한계

  • JPA는 WHERE, HAVING 절에서만 서브 쿼리 사용 가능
  • SELECT절도 가능 (하이버네이트에서 지원)
  • FROM 절의 서브 쿼리는 현재 JPQL에서 불가능 (조인으로 풀 수 있으면 풀어서 해결)
FROM절의 서브 쿼리도 이제 가능하다! Hibernate 6.1.0.Final 버전부터 가능하다! (아래 참조링크 확인)
 

Hot features of Hibernate ORM 6.1 - In Relation To

Hibernate ORM version 6.1.0.Final was just announced a few days ago, but the announcement didn’t go into a too much detail. Read on if you want to know more about some of the hot new features this shiny new release comes with.

in.relation.to

 

 

JPQL 타입 표현

  • 문자  'HELLO', 'She"s'
  • 숫자  10L(Long), 10D(Double), 10F(Float)
  • Boolean → TRUE(true), FALSE(false)
String query = "SELECT m.username, 'HELLO', TRUE, 10L FROM Member m";

List<Object[]> resultList = em
        .createQuery(query)
        .setFirstResult(0)
        .setMaxResults(1)
        .getResultList();

for (Object[] objects : resultList) {
    System.out.println("objects = " + objects[0]);
    System.out.println("objects = " + objects[1]);
    System.out.println("objects = " + objects[2]);
    System.out.println("objects = " + objects[3]);
}

//Output:
objects = Member0
objects = HELLO
objects = true
objects = 10

 

  • ENUM jpa.MemberType.ADMIN (패키지명을 다 포함)
String query = "SELECT m.username, 'HELLO', TRUE, 10L " +
                    "FROM Member m " +
                    "WHERE m.memberType = org.example.entity.jpql.MemberType.ADMIN";

근데 보통은 파라미터를 바인딩해서 사용할테니까 저렇게까지 불편할 정도는 아니다. 파라미터 바인딩을 하면 다음과 같이 조금 더 편리해지고 보기도 좋아진다.

String query = "SELECT m.username, 'HELLO', TRUE, 10L, m.memberType " +
                    "FROM Member m " +
                    "WHERE m.memberType = :memberType";

List<Object[]> resultList = em
        .createQuery(query)
        .setParameter("memberType", MemberType.ADMIN)
        .setFirstResult(0)
        .setMaxResults(2)
        .getResultList();

 

 

  • 엔티티 타입  TYPE(m) = Member (상속 관계에서 사용)

이렇게 ITEM, BOOK이 상속관계일 때 ITEM으로부터 가져온 레코드 중 DTYPEBook인 녀석들만 가져오는 쿼리도 할 수 있다는 것을 보여준다.

String query = "SELECT i " +
                    "FROM Item i " +
                    "WHERE TYPE(i) = Book";

List<Item> resultList = em
        .createQuery(query, Item.class);
        .getResultList();

 

  • JPQL 기타 → EXISTS, IN, AND, OR, NOT, =, >, >=, <, <=, BETWEEN, LIKE, IS NULL 모두 다 가능하다.
String query = "SELECT m " +
                    "FROM Member m " +
                    "WHERE m.age BETWEEN 0 AND 10";

List<Member> resultList = em
        .createQuery(query, Member.class);
        .getResultList();

 

조건식

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

이렇게 생긴 문장 많이 봤을거다. SQL하면서. JPQL에서도 동일하게 지원해준다.

 

String query = "SELECT " +
                    "       case when m.age <= 10 then '학생요금'" +
                    "       when m.age >= 60 then '경로요금'" +
                    "       else '일반요금'" +
                    "       end " +
                    "FROM Member m";
            
List<String> resultList = em.createQuery(query, String.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

for (String s : resultList) {
    System.out.println("s = " + s);
}

COALESCE, NULLIF

COALESCE는 조회 결과가 NULL이 아니면 반환하는 조건식이다. 

SELECT COALESCE(m.username, '이름 없는 회원') FROM Member m

그러니까 찾은 유저의 m.usernameNULL인 경우 '이름 없는 회원'으로 대체한다는 의미이다.

 

NULLIF는 두 값이 같으면 NULL반환, 다르면 첫번째 값 반환

SELECT NULLIF(m.username, '관리자') FROM Member m

찾은 유저 각각의 유저네임이 관리자인 유저는 NULL로 유저네임을 반환하고 그게 아닌경우, 유저네임 그대로를 반환한다는 의미이다. 

 

 

JPQL 함수 

JPQL이 제공하는 표준 함수

JPQL이 표준으로 제공하는 함수들 (데이터베이스에 종속적이지 않아서 어떤 데이터베이스이든 상관없이 사용 가능하다)

  • CONCAT
  • SUBSTRING
  • TRIM
  • LOWER, UPPER
  • LENGTH
  • LOCATE
  • ABS, SQRT, MOD
  • SIZE, INDEX(JPA 용도)
// CONCAT
String query = "SELECT CONCAT('A', 'B') FROM Member m";

// SUBSTRING
String query = "SELECT SUBSTRING(m.username, 2, 3) FROM Member m";

....

//SIZE (컬렉션의 크기를 돌려줌)
String query = "SELECT SIZE(t.members) FROM Team t";
  • 기본으로 제공하는 함수가 있는 반면 DB에 종속적인 방언적 함수가 각 DB마다 또 있을 수 있는데 그런 함수가 JPQL이 지원을 즉각적으로 안하는 경우 사용자가 직접 함수를 등록해서 사용할 수 있다.

 

사용자 정의 함수

사용자 정의 함수란, JPQL이 공식적으로는 지원하지 않지만 해당 데이터베이스에서 지원하는 함수를 사용하고 싶을 때 그 함수를 등록해서 사용하는 방법을 말한다. 예를 들어, H2 데이터베이스는 `group_concat`이라는 함수가 있다. 이 함수는 조회한 레코드들로 사용자로부터 받은 특정 컬럼을 한 줄로 쭉 이어 붙이는 함수인데 이건 H2 데이터베이스에서 지원하는 함수다. 그리고 내가 만약 이 함수를 사용하고 싶다면?

 

아래와 같이 Dialect를 커스텀할 클래스하나를 만들고 H2 데이터베이스의 H2Dialect를 상속받는다. 그리고 내가 원하는 함수를 생성자 안에서 등록한다. 

package cwchoiit.jpql;

import org.hibernate.dialect.H2Dialect;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StandardBasicTypes;

public class MyH2Dialect extends H2Dialect {

    public MyH2Dialect() {
        registerFunction("group_concat", new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
    }
}

 

그리고 JPA 설정 파일에서 Dialect를 이 파일로 변경만 해주면 끝. 아래는 XML로 설정했기 때문에 XML 파일 형식인거고 요즘은 거의 YAML, properties 파일에서 설정할 것이다.

...
<property name="hibernate.dialect" value="cwchoiit.jpql.MyH2Dialect"/>
...

 

위와 같이 설정을 한 다음 사용할 수 있다. 사용하는 코드를 아래에서 확인해보자.

Main

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            Member member = new Member();
            member.setName("member");
            entityManager.persist(member);

            Member member2 = new Member();
            member2.setName("member2");
            entityManager.persist(member2);

            entityManager.flush();
            entityManager.clear();

            List<String> resultList = entityManager
                    .createQuery("SELECT FUNCTION('group_concat', m.name) FROM Member m", String.class)
                    .getResultList();

            for (String s : resultList) {
                System.out.println("s = " + s);
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

실행 결과

Hibernate: 
    /* SELECT
        FUNCTION('group_concat',
        m.name) 
    FROM
        Member m */ select
            group_concat(member0_.name) as col_0_0_ 
        from
            Member member0_
s = member,member2

 

 

경로 표현식

경로 표현식은 크게 세가지로 분류할 수 있다.

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

 

  • 상태 필드: 경로 탐색의 끝, 이후 탐색 불가능
// m.username이 상태 필드 경로 표현식
select m.username from Member m;

 

  • 단일 값 연관 경로: 묵시적 내부 조인 발생, 이후 탐색 가능 
// m.team이 단일 값 연관 경로 표현식
select m.team from Member m;
  • 컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 이후 탐색 불가능
// t.members가 컬렉션 값 연관 경로 표현식
select t.members from Team t;

 

여기서 솔직히 어려운 내용은 하나도 없다. 객체로 생각해보면 당연히 상태 필드는 그 녀석이 더 담고 있는 데이터가 없기 때문에 이후 탐색이 불가능한거고 특정 엔티티가 타입인 경우 그 녀석이 가지고 있는 다른 데이터가 있기 때문에 그 이후에 탐색이 가능한것이다. 컬렉션은 컬렉션의 하나하나의 데이터를 뽑아오는 게 아니라 컬렉션 자체를 반환하기 때문에 그 이후에 탐색이 불가능한 것이다.

 

이게 중요해서가 아니라 묵시적 내부 조인이 발생한다는 것이 중요하다.

// m.team이 단일 값 연관 경로 표현식
select m.team from Member m;

위 코드에서 멤버의 팀을 가져오기 위해서는 DB입장에서는 조인을 할 수 밖에 없다. 그래서 내부조인이 일어난다. 하지만 우리가 직접 명시하지 않았기 때문에 묵시적으로 조인이 발생한다. 코드 수행 결과를 한번 보면 다음과 같다.

String query = "SELECT m.team FROM Member m";

List<Team> resultList = em.createQuery(query, Team.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

for (Team team1 : resultList) {
    System.out.println("team1 = " + team1);
}

실행 결과

select
        team1_.TEAM_ID as team_id1_1_,
        team1_.name as name2_1_ 
    from
        Member member0_ 
    inner join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID limit ?
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e

보다시피 내부 조인이 일어난다. 당연하다. 근데 작성한 코드에서는 명시적으로 조인을 사용하지 않았기에 조인을 사용할 것이라고 예측하기는 쉽지는 않다. JPA를 잘 이해하고 잘 하는 사람이야 바로 보고 알수도 있겠다만. 

 

또 컬렉션에서도 경로 탐색이 더이상 불가능한데 다음 코드를 보자.

// t.members가 컬렉션 값 연관 경로 표현식
select t.members from Team t;

이렇게 생긴 쿼리를 실제로 수행하면 당연히 묵시적 내부 조인이 발생한다. 팀에 해당하는 멤버를 가져와야 하기 때문에.

그리고 여기서는 더 이상 탐색이 불가하다. 근데 탐색하고 싶을 수 있다. 멤버 하나하나의 이름이나 뭐 다른 데이터를 알고싶을 수 있잖아.

그럴 때는 FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색이 가능하다.

String query = "SELECT m.username FROM Team t join t.members m";

이렇게 컬렉션은 위 방법으로 이후 탐색을 진행할 수 있다. 그러나, 가장 중요한 내용은 다음과 같다.

묵시적 조인이 발생하는 경우를 모두 차단해라.

 

묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵고 조인은 SQL 튜닝에 중요 포인트다. 그렇기 때문에 묵시적 조인은 가급적(아예) 사용하지 말자. 

 

728x90
반응형
LIST
728x90
반응형
SMALL

2024.10.31 업데이트


참고로, 이 포스팅에서 가장 중요한 부분은 거의 마지막에 나온다!

 

 

 

엔티티에 컬렉션으로 관리하는 데이터는 흔히 있을 수 있는 일이지만 DB는 기본적으로는 컬렉션 데이터를 지원하지 않는다. 물론 요즘은 여러 방법으로 컬렉션을 테이블에서 관리할 수 있지만(JSON으로 데이터를 저장한다든지 등) 그러나 정석적인 방법은 컬렉션 데이터를 테이블로 만들어 참조로 관리하는 이다. 

 

반응형
SMALL

아래 그림을 보자. 

멤버라는 엔티티가 관리하는 데이터 favoriteFoodsaddressHistory는 컬렉션 값 타입이다. 이런 엔티티를 테이블화 하기 위해서는 각 컬렉션 값 타입을 테이블로 분리해서 1:N 관계로 만드는 것이다. 이게 정석적인 방법이다. 

 

위 그림을 보면 ADDRESSFAVORITE_FOOD도 모든 필드가 하나의 PK인데 이 이유는 컬렉션 '값 타입'이기 때문이다. 값 타입은 하나의 레코드 자체가 고유값이 되어야 하는 것이다. 만약, 여기서 어떤 구분할 수 있는 PK가 따로 있으면 그건 값 타입이 아니라 엔티티라고 불려야한다. 구현하는 방법도 간단하다. 코드를 보자.

 

Address

package cwchoiit.embedded;

import javax.persistence.Embeddable;
import java.util.Objects;

@Embeddable
public class Address {
    private final String city;
    private final String street;
    private final String zipCode;

    public Address() {
        this.city = null;
        this.street = null;
        this.zipCode = null;
    }

    public Address(String city, String street, String zipCode) {
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    public String getZipCode() {
        return zipCode;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Address address = (Address) o;
        return Objects.equals(city, address.city) && Objects.equals(street, address.street) && Objects.equals(zipCode, address.zipCode);
    }

    @Override
    public int hashCode() {
        return Objects.hash(city, street, zipCode);
    }
}

 

EmbedMember

package cwchoiit.embedded;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name="city", column=@Column(name = "OFFICE_CITY")),
            @AttributeOverride(name="street", column=@Column(name = "OFFICE_STREET")),
            @AttributeOverride(name="zipCode", column=@Column(name = "OFFICE_ZIPCODE"))
    })
    private Address officeAddress;

    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FAV_FOOD")
    private Set<String> favoriteFoods = new HashSet<>();

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

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Period getWorkPeriod() {
        return workPeriod;
    }

    public void setWorkPeriod(Period workPeriod) {
        this.workPeriod = workPeriod;
    }

    public Address getHomeAddress() {
        return homeAddress;
    }

    public void setHomeAddress(Address homeAddress) {
        this.homeAddress = homeAddress;
    }

    public Address getOfficeAddress() {
        return officeAddress;
    }

    public void setOfficeAddress(Address officeAddress) {
        this.officeAddress = officeAddress;
    }

    public Set<String> getFavoriteFoods() {
        return favoriteFoods;
    }

    public void setFavoriteFoods(Set<String> favoriteFoods) {
        this.favoriteFoods = favoriteFoods;
    }

    public List<Address> getAddressHistory() {
        return addressHistory;
    }

    public void setAddressHistory(List<Address> addressHistory) {
        this.addressHistory = addressHistory;
    }
}
  • 여기서 주의깊게 볼 부분은 바로 이 부분이다.
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FAV_FOOD")
private Set<String> favoriteFoods = new HashSet<>();

@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
  • 멤버 엔티티가 관리하는 컬렉션 두 개가 있다. favoriteFoods, addressHistory.
  • 이런 컬렉션 형태의 필드를 테이블화하기 위해 두 가지의 어노테이션이 필요하다. @ElementCollection, @CollectionTable. @CollectionTable 어노테이션에 name property는 테이블명을 의미한다. joinColumns는 이 테이블이 어떤 테이블과 조인될지를 선정한다. 즉, 외래키를 받는 부분이고 멤버에 속한 favoriteFoods이고 addressHistory니까 MEMBER_ID라는 외래키를 적용한다. 뭐 M_ID, MID라고 해도 상관은 없다.
  • favoriteFoods같은 경우엔 컬렉션에 들어있는 값이 String으로 된 문자열 단일 값이기 때문에 컬럼명을 지정해주기 위해 @Column 어노테이션도 사용했다. Address{city, street, zipCode} 같은 경우는 세 개의 필드가 한 객체로 만들어져 있으니 이런게 불가능하다. 사용하는게 필수는 아닌데 이런 경우에 이렇게 컬럼명도 지정할 수 있단 사실! 이렇게 두 개의 어노테이션만 있으면 자동으로 컬렉션 값 타입은 테이블로써 만들어지게 된다.

 

실행해보자.

Main

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
            member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));

            entityManager.persist(member);

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 코드를 보면 멤버 객체를 하나 생성하고 멤버 객체의 favoriteFoodsaddressHistory를 가져와 추가했다. 저 두 개의 엔티티는 멤버라는 부모 엔티티에 의해 생명주기가 관리된다. 왜냐? 값 타입이기 때문이다. 즉, 멤버 객체가 생성되고 소멸되는 주기가 곧 저 두 엔티티의 생명주기이고 멤버에 의해 관리 되기 때문에 영속 컨텍스트에는 멤버만을 추가해도 알아서 새로이 추가된 favoriteFoodsaddressHistoryINSERT문으로 나간다.

결과를 보자.

Hibernate: 
    insert 
    into
        Member
        (city, street, zipcode, name, MEMBER_ID) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        ADDRESS_HISTORY
        (MEMBER_ID, city, street, zipcode) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        FAVORITE_FOOD
        (MEMBER_ID, FOOD_NAME) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        FAVORITE_FOOD
        (MEMBER_ID, FOOD_NAME) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        FAVORITE_FOOD
        (MEMBER_ID, FOOD_NAME) 
    values
        (?, ?)

  • Member, AddressHistory, FavoriteFood 모두 INSERT문이 실행됐음을 확인할 수 있다. 컬렉션 타입은 이렇게 테이블화해서 관계 매핑으로 다루어야 한다.

그리고 이렇게 잘 들어갔다면, 한번 멤버를 조회해 보자. 어떻게 나올까?

값 타입 컬렉션 조회하기

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
            member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));

            entityManager.persist(member);

            entityManager.flush();
            entityManager.clear();

            System.out.println("================================================================");
            EmbedMember findMember = entityManager.find(EmbedMember.class, member.getId());

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

실행 결과

  • 실행 결과를 보면, 멤버 정보만 가져왔지 favoriteFoods, addressHistory 정보는 가져오지 않았다. 즉, 지연 로딩이라는 얘기다. 이렇게 값 타입 컬렉션은 기본이 지연로딩이다. 아래 사진을 보면 알 수 있다.

 

그래서, 꺼내와서 메서드를 호출해야 실제로 쿼리가 나가서 값을 가져온다.

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
            member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));

            entityManager.persist(member);

            entityManager.flush();
            entityManager.clear();

            System.out.println("================================================================");
            EmbedMember findMember = entityManager.find(EmbedMember.class, member.getId());

            List<Address> addressHistory = findMember.getAddressHistory();
            for (Address address : addressHistory) {
                System.out.println("address = " + address.getCity());
            }

            Set<String> favoriteFoods = findMember.getFavoriteFoods();
            for (String favoriteFood : favoriteFoods) {
                System.out.println("favoriteFood = " + favoriteFood);
            }
            
            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

실행 결과

 

 

근데 만약 데이터를 수정해야할 때는 어떻게 동작할까? 아래 수정 코드를 보자.

값 타입 컬렉션 수정하기

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
            member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));

            entityManager.persist(member);

            entityManager.flush();
            entityManager.clear();

            System.out.println("================================================================");
            EmbedMember findMember = entityManager.find(EmbedMember.class, member.getId());

            List<Address> addressHistory = findMember.getAddressHistory();
            for (Address address : addressHistory) {
                System.out.println("address = " + address.getCity());
            }

            Set<String> favoriteFoods = findMember.getFavoriteFoods();
            for (String favoriteFood : favoriteFoods) {
                System.out.println("favoriteFood = " + favoriteFood);
            }

            // 컬렉션 값 타입의 내용을 수정하는 방법
            favoriteFoods.remove("치킨");
            favoriteFoods.add("소고기");

            // 컬렉션 값 타입의 내용을 수정하는 방법 (remove 하는건 똑같다, 근데 여기는 레퍼런스를 받기 때문에 반드시 equals 를 구현해 놓은 상태여야 한다)
            addressHistory.remove(new Address("city1", "street1", "zipCode1"));
            addressHistory.add(new Address("city3", "street3", "zipCode3"));

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 새 멤버를 만들 때 addressHistory, favoriteFoods를 추가한 후 멤버만을 persist()해도 flush()를 호출하면 다음과 같이 데이터베이스에 Member, addressHistory, favoriteFoods가 모두 추가된다. 그 이유는 값 타입은 어떤것이든 엔티티의 생명주기에 종속적이기 때문이다. 
  • 이 말은, 멤버의 favoriteFoods를 가져와 요소를 날리면? favoriteFoods의 해당 레코드는 삭제가 된다. 반대로 멤버의 favoriteFoods를 가져와 요소를 추가하면? favoriteFoods에 추가한 요소가 레코드로 저장된다. 그리고 favoriteFoods는 컬렉션에 들어가는 타입이 String이라서 remove() 호출 시에 동등 비교를 위해 따로 해줄 일이 없다. 기본으로 equals()가 잘 구현되어 있기 때문이다.
  • 그러나, addressHistory 같은 경우, 컬렉션에 들어가는 타입이 Address라는 우리가 직접 만든 객체이므로, 리스트에서 이 요소를 제거하려면 equals()가 반드시 구현되어 있어야 한다. 그래야 remove() 호출 시 내부에서 equals()를 호출해서 동일한 요소를 찾아내 지울 수 있기 때문이다.
  • 그래서 위에 Address 코드를 보면, equals() hashCode()를 구현한 모습을 볼 수 있다.

 

그럼 실행해보자. 어떤 쿼리가 나갈까? 

  • 아래 파란 박스는 favoriteFoods 관련 쿼리이고, 위에 빨간 박스는 addressHistory 관련 쿼리이다.
  • favoriteFoods는 보다시피, 원하는 값을 하나 삭제하고 원하는 값을 하나 추가했다. 아주 마음에 든다.
  • 그러나 문제는 addressHistory이다. 얘를 보면 지금, 세개의 쿼리가 나갔는데 처음 쿼리가 전체를 삭제하는 쿼리다! 그리고 원래 있던 레코드를 하나 추가하고, 새로 추가하려고 했던 레코드를 추가한 것이다. 
  • 전체를 삭제해버린 후 기존에 남은 하나와 새로 추가한 하나를 나란히 INSERT한다. 이것이 값 타입 컬렉션을 테이블로 관리할 때 가장 큰 문제가 된다. 값 타입 컬렉션 테이블은 식별자가 따로 없기 때문에 JPA가 삭제할 때 어떤 데이터를 가져와 삭제해야 하는지 알지 못한다. 그렇기 때문에 전체를 삭제한 후 종속된 엔티티가 가지고 있는 모든 데이터를 다시 하나씩 추가한다. 
  • 근데 Set은 하나만 딱 잘 골라서 삭제하고 원하는거 추가하는데 List는 왜 안돼요? → SetList의 차이 그대로이기 때문이다. Set은? 중복값이란게 존재할 수가 없는 자료구조이다. 그렇기 때문에 내가 원하는 것을 삭제한다고 하면 JPA도 그 값을 그대로 찾아서 삭제하면 된다. List는? 중복값이란게 존재할 수 있는 자료구조다. 그 말은 내가 삭제하고자 하는 데이터가 한개뿐이 아니라 두개 세개가 있을 수도 있는데 이걸 JPA는 알지 못하기 때문에 다 날리고 새로 넣을 것들을 다시 넣게 되는 것이다.

🟠 결론은, 이 값 타입 컬렉션을 쓰면 안된다. 🟠

 

값 타입 컬렉션을 엔티티로 변경하라

값 타입 컬렉션의 가장 큰 문제는 식별자가 없기에 수정 쿼리를 수행할 때 JPA가 찾아내지 못한다는 것이다. 그리고 반대로 말하면 식별자가 있는 테이블은 엔티티라고 표현해야 한다. 그럼 위 코드의 문제점을 어떻게 수정하면 될까?

 

AddressEntity라는 엔티티를 만들고 기존의 값 타입 컬렉션을 엔티티로 승격시켜 식별자를 가지게 하는것이다.

 

AddressEntity

package cwchoiit.embedded;

import javax.persistence.*;

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

    private Address address;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private EmbedMember member;

    public AddressEntity() {
    }

    public AddressEntity(Address address) {
        this.address = address;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }

    public EmbedMember getMember() {
        return member;
    }

    public void setMember(EmbedMember member) {
        this.member = member;
    }
}

 

 

package cwchoiit.embedded;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name="city", column=@Column(name = "OFFICE_CITY")),
            @AttributeOverride(name="street", column=@Column(name = "OFFICE_STREET")),
            @AttributeOverride(name="zipCode", column=@Column(name = "OFFICE_ZIPCODE"))
    })
    private Address officeAddress;

    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FAV_FOOD")
    private Set<String> favoriteFoods = new HashSet<>();

    @OneToMany(mappedBy = "member")
    private List<AddressEntity> addressHistory = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Period getWorkPeriod() {
        return workPeriod;
    }

    public void setWorkPeriod(Period workPeriod) {
        this.workPeriod = workPeriod;
    }

    public Address getHomeAddress() {
        return homeAddress;
    }

    public void setHomeAddress(Address homeAddress) {
        this.homeAddress = homeAddress;
    }

    public Address getOfficeAddress() {
        return officeAddress;
    }

    public void setOfficeAddress(Address officeAddress) {
        this.officeAddress = officeAddress;
    }

    public Set<String> getFavoriteFoods() {
        return favoriteFoods;
    }

    public void setFavoriteFoods(Set<String> favoriteFoods) {
        this.favoriteFoods = favoriteFoods;
    }

    public List<AddressEntity> getAddressHistory() {
        return addressHistory;
    }

    public void setAddressHistory(List<AddressEntity> addressHistory) {
        this.addressHistory = addressHistory;
    }
}
  • 여기서는 다대일 양방향으로 매핑했다. 여기서 누군가는 "그냥 Member가 외래키 관리(일대다 방식)하고 CRUD 편리하게 리스트로 하면 안되나요? 일대다로 하면 안되나요?" 그렇게 해도 된다! 이 경우에는 AddressEntity가 멤버에 종속된 느낌이 강하게 들기 때문에 그렇게 만들어도 될 것 같다.
  • 그러나, 그렇게 만들면 이제 알아두어야 할 점은 결국 데이터베이스 테이블 관점에서는 Member에 외래키가 있는게 아니고 AddressEntity에 외래키가 있다는 점은 인지해야 하고, 그렇기 때문에, MemberAddressHistory를 가져와서 값을 추가하고 빼고 해도 나가는 쿼리는 AddressEntity의 업데이트 쿼리라는 것을 인지하고 있으면 된다.

 

이제 실행해 보자. 실행 코드는 다음과 같다.

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.AddressEntity;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            AddressEntity addressEntity = new AddressEntity();
            addressEntity.setAddress(new Address("city1", "street1", "zipCode1"));
            addressEntity.setMember(member);

            AddressEntity addressEntity2 = new AddressEntity();
            addressEntity2.setAddress(new Address("city2", "street2", "zipCode2"));
            addressEntity2.setMember(member);

            entityManager.persist(addressEntity);
            entityManager.persist(addressEntity2);
            entityManager.persist(member);

            entityManager.flush();
            entityManager.clear();

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

            // 값 타입 컬렉션을 엔티티로 승격시키고, 수정하는 방법
            AddressEntity ae = entityManager.find(AddressEntity.class, 1L);
            ae.setAddress(new Address("newCity", "newStreet", "newZipCode"));

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

 

실행 결과

  • 더 이상 전체 Address 정보를 삭제하지 않고, 삭제하고 추가하고자 하는 하나의 AddressEntity 레코드만을 업데이트하는 모습을 확인할 수 있다. 

 

결론 

값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다. 즉, 식별자가 필요하지 않고 엔티티에 종속된 생명 주기를 가져야 하는 경우에만 사용해야 하고 식별자가 필요하고 본인만의 생명 주기가 필요한 경우 엔티티로 승격시키자! 

 

그리고 사실 실무에서는 값 타입이 그렇게 많이 사용되지도 않는다. Position{x, y} 이렇게 x좌표, y좌표를 한번에 묶어서 가지는 값같은 정말 값 타입이면 되는 그런 경우 말고는 많이 사용되지 않는다.

728x90
반응형
LIST
728x90
반응형
SMALL

2024.10.30 업데이트


임베디드 값 타입은 꽤나 사용성을 높여준다. 임베디드 값 타입은 무엇이냐면 특정 엔티티에 필요한 필드를 클래스타입으로 받는 경우이다.

아래 예를 보자.

좌측 테이블은 기본타입으로만 설정된 테이블이다. 물론 이게 잘못된 건 아니다. 근데 기본 타입이 아니고 클래스로 설계된 타입을 사용할 때 얻는 이점이 매우 많기 때문에 임베디드 값 타입을 사용하는 것을 고려해볼 수 있다. 그래서 위와 같이 만들면 아래 사진 처럼 된다. 

객체 입장에서는, Period, Address 라는 클래스가 Member 엔티티에 들어있게 되지만, DB에서는 이전과 다를 것 없이 그냥 각 필드들이 컬럼으로 만들어진다. 그럼 어차피 DB 테이블에선 이전과 다를 것 없이 동일하게 필드로 표시되는데  임베디드 값 타입을 사용시 어떤 이점이 있을까?

  • 재사용성
  • 높은 응집도
  • 응집도가 높다는 것은, 의미 있는 메서드를 만들어 사용할 수 있고 그에 따라 객체 지향형 설계가 가능해짐 예를 들어, Period.isWork()와 같은 메서드를 만들어서 해당 객체에서만 사용되는 메서드를 Period 클래스에서 구현 가능
  • 임베디드 값 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존함

 

그럼 임베디드 값 타입을 사용하는 방법을 알아보자. 우선 임베디드 값 타입을 사용하는 방법은 다음과 같다.

  • @Embeddable → 값 타입을 정의하는 곳에 표시
  • @Embedded → 값 타입을 사용하는 곳에 표시
  • 기본 생성자 필수 (값 타입을 정의하는 클래스에서)
반응형
SMALL

 

EmbedMember

package cwchoiit.embedded;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import java.time.LocalDateTime;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    private LocalDateTime startDate;
    private LocalDateTime endDate;

    private String city;
    private String street;
    private String zipCode;
}
  • 이렇게 있는 엔티티에서 하고 싶은 건 (startDate, endDate) 이 두 필드를 하나의 클래스로 관리하고, (city, street, zipCode) 이 세 필드를 하나의 클래스로 관리하고 싶은 것이다.

 

우선, 그래서 Period 클래스를 하나 만들어보자.

Period

package cwchoiit.embedded;

import javax.persistence.Embeddable;
import java.time.LocalDateTime;

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

    public Period() {
        this.startDate = LocalDateTime.now();
        this.endDate = LocalDateTime.now();
    }

    public Period(LocalDateTime startDate, LocalDateTime endDate) {
        this.startDate = startDate;
        this.endDate = endDate;
    }

    public boolean isWork() {
        return !endDate.isAfter(LocalDateTime.now());
    }

    public LocalDateTime getStartDate() {
        return startDate;
    }

    public LocalDateTime getEndDate() {
        return endDate;
    }
}

 

Address

package cwchoiit.embedded;

import javax.persistence.Embeddable;

@Embeddable
public class Address {
    private final String city;
    private final String street;
    private final String zipCode;

    public Address() {
        this.city = null;
        this.street = null;
        this.zipCode = null;
    }

    public Address(String city, String street, String zipCode) {
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    public String getZipCode() {
        return zipCode;
    }
}
  • 이렇게 두 개의 클래스가 있고 이 두개의 클래스에 어노테이션으로 @Embeddable을 사용해준다. 그니까 직관적으로 이 클래스는 다른 클래스에 임베디드가 될 수 있다는 의미다.
  • 그리고 이렇게 따로 클래스로 뽑아버리니 좋은점은 각 필드간 응집도가 높아져서 이 클래스에서 유용하게 사용할 수 있는 메서드를 만들수도 있고 만든 메서드는 재활용 할수도 있어진다는 점이다. (예: Period.isWork())
  • 그리고 위에서 말한 기본 생성자 필수라는 게 이 @Embeddable 애노테이션이 붙은 클래스들에게 적용되는 말이다.
  • 그리고! 가장 중요한 것은, 이 두 임베디드 값 타입은 모두 불변 객체라는 것이다. 왜 불변객체로 만드는 걸까? 이후에 설명하겠다.

 

EmbedMember

package cwchoiit.embedded;

import javax.persistence.Embedded;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Period getWorkPeriod() {
        return workPeriod;
    }

    public void setWorkPeriod(Period workPeriod) {
        this.workPeriod = workPeriod;
    }

    public Address getHomeAddress() {
        return homeAddress;
    }

    public void setHomeAddress(Address homeAddress) {
        this.homeAddress = homeAddress;
    }
}
  • 위처럼 Period, Address 클래스를 타입으로 설정한 필드에 어노테이션으로 @Embedded를 붙여준다. 즉 이것도 마찬가지로 이 필드는 임베디드 되었다는 의미다.
  • 이렇게 멤버 클래스를 만들면 DB에 생성되는 테이블은 임베디드 값 타입을 사용하나 사용하지 않나 완전히 똑같다. 즉, 테이블에는 그냥 아래 모습 그대로 들어간다.

그럼 왜 사용할까? 다시 말하지만 객체 지향적으로 코드를 작성하기에 훨씬 유리하기 때문이다. 사용성도 높아지고. 각 클래스가 가지고 있는 메서드를 사용하는 이점이 생각보다 엄청 크다.

 

 

그래서 이렇게 임베디드 값 타입을 사용한 엔티티를 사용하는 방법은 뭐 별거 없다.

Main

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;
import cwchoiit.embedded.Period;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.time.LocalDateTime;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            EmbedMember embedMember = new EmbedMember();
            embedMember.setUsername("embed");
            embedMember.setHomeAddress(new Address("city", "street", "zipCode"));
            embedMember.setWorkPeriod(new Period(LocalDateTime.now(), LocalDateTime.now()));

            entityManager.persist(embedMember);

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 이전과 동일하게 사용하면 된다.

 

 

 

임베디드 값 타입과 연관관계

 

임베디드 값 타입으로 만든 클래스는 내부에 임베디드 값 타입으로 만든 클래스도 당연히 가질 수 있다. 위 그림에서 AddressZipcode를 가지고 있는 모습을 볼 수 있다. 근데, 재밌는게, 임베디드 값 타입으로 만든 클래스가 엔티티도 가질 수 있다. 위 그림에서 PhoneNumberPhoneEntity를 가지고 있음을 볼 수 있다.

 

그러니까 아래 코드처럼 말이다.

package cwchoiit.embedded;

import cwchoiit.fetch.Team;

import javax.persistence.Embeddable;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import java.time.LocalDateTime;

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    ...
    
}
  • Period라는 임베디드 값 타입 클래스는 아래와 같이 Team 이라는 엔티티도 가질 수 있다. (물론, 그렇게 자주 사용되는 방식은 아니다)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;

 

 

@AttributeOverrides @AttributeOverride

또 한가지는 같은 임베디드 값 타입의 클래스를 여러개 사용하고 싶을수가 있다. 예를 들면 아래 코드를 보자.

EmbedMember

package cwchoiit.embedded;

import javax.persistence.*;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

    @Embedded
    private Address officeAddress;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Period getWorkPeriod() {
        return workPeriod;
    }

    public void setWorkPeriod(Period workPeriod) {
        this.workPeriod = workPeriod;
    }

    public Address getHomeAddress() {
        return homeAddress;
    }

    public void setHomeAddress(Address homeAddress) {
        this.homeAddress = homeAddress;
    }

    public Address getOfficeAddress() {
        return officeAddress;
    }

    public void setOfficeAddress(Address officeAddress) {
        this.officeAddress = officeAddress;
    }
}
  • 이 코드에서 이 부분을 자세히 보자.
@Embedded
private Address homeAddress;

@Embedded
private Address officeAddress;
  • 충분히 의미 있을 만한 필드들이다. 그런데 둘 다 같은 임베디드 값 타입이다. 이럴때 이렇게만 두고 실행하면 에러가 난다. 그래서 아래처럼 바꿔줘야 한다.
@Embedded
private Address homeAddress;

@Embedded
@AttributeOverrides({
        @AttributeOverride(name="city", column=@Column(name = "OFFICE_CITY")),
        @AttributeOverride(name="street", column=@Column(name = "OFFICE_STREET")),
        @AttributeOverride(name="zipCode", column=@Column(name = "OFFICE_ZIPCODE"))
})
private Address officeAddress;
  • @AttributeOverrides@AttributeOverride를 사용해서, 이 애노테이션이 달린 임베디드 값 타입의 클래스의 어떤 필드를 어떤 컬럼명으로 매핑할건지를 지정해줘야 동일한 컬럼명을 사용하지 않게 되어 에러가 발생하지 않는다. 

 

 

임베디드 값 타입과 같은 값 타입은 왜 불변객체여야 할까?

굉장히 중요한 부분이다. 만약, 예를 들어 회원1회원2 모두 같은 Address 참조값을 가지고 있다고 가정해보자. 개발자가 그렇게 만든것이다. 최초의 의도는 두 회원이 같은 주소를 가지기 때문에 Address 객체를 하나 만들어서 두 회원에 모두 세팅한 것이다. 

 

여기까지는 그럴수 있다. 그리고 그걸 막을 방법도 없다. 근데 만약, 두 회원 중 하나를 선택해서 주소를 변경해 버리면 어떻게 될까? 다음과 같은 일이 일어난다. 

 

 

나의 의도는 회원1의 주소만 바꾸려는 의도였는데 회원2의 주소까지 변경되어 버린다. 이거는 막을 방법도 없고 이런 버그는 거의 잡는게 불가능하다. 코드로 이 내용을 구현해보자. 

 

우선 불변객체가 아니어야 하므로 Address를 불변객체에서 그렇지 않도록 변경해보자.

 

Address - 불변객체가 아님

package cwchoiit.embedded;

import javax.persistence.Embeddable;

@Embeddable
public class Address {
    private String city;
    private String street;
    private String zipCode;

    public Address() {
    }

    public Address(String city, String street, String zipCode) {
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getStreet() {
        return street;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public String getZipCode() {
        return zipCode;
    }

    public void setZipCode(String zipCode) {
        this.zipCode = zipCode;
    }
}

 

Main

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            Address address = new Address("city", "street", "zipCode");

            EmbedMember embedMember = new EmbedMember();
            embedMember.setUsername("embed");
            embedMember.setHomeAddress(address);

            EmbedMember embedMember2 = new EmbedMember();
            embedMember2.setUsername("embed2");
            embedMember2.setHomeAddress(address);

            entityManager.persist(embedMember);
            entityManager.persist(embedMember2);

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 지금 코드를 보면, 하나의 Address 객체를 만들고 두개의 EmbedMemberHomeAddress에 적용시켰다. 이러면 두 회원은 같은 값 타입을 참조하고 있게 된다.
  • 그리고, 이렇게 코드를 작성하면 컴파일러 단계에서 이를 방지할 수 있는 방법이 있나? 없다. 자바는 그냥 HomeAddress를 세팅하기 위한 세터에 적절한 타입의 Address를 받았다고 생각하기 때문에 이 시점에 컴파일러는 아무런 문제를 인지하지 못한다. 이게 핵심이다. 그리고 실제로 지금까지는 아무런 문제도 없다.

실행 결과

 

두 회원이 모두 같은 주소를 가지고 있다. 여기까지는 아무런 문제가 없다. 그런데 내가 여기서 둘 중 하나의 주소를 변경하는 시점부터 문제가 발생한다. 저 코드에서 딱 한줄을 추가해보자.

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            Address address = new Address("city", "street", "zipCode");

            EmbedMember embedMember = new EmbedMember();
            embedMember.setUsername("embed");
            embedMember.setHomeAddress(address);

            EmbedMember embedMember2 = new EmbedMember();
            embedMember2.setUsername("embed2");
            embedMember2.setHomeAddress(address);

            entityManager.persist(embedMember);
            entityManager.persist(embedMember2);
            
            embedMember.getHomeAddress().setCity("newCity");

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 바로 이 부분이다.
embedMember.getHomeAddress().setCity("newCity");
  • 난 딱 하나의 회원의 주소를 변경했을 뿐인데, 실행을 한다면 이런 결과를 얻게 된다.

  • 보면 알겠지만 업데이트 쿼리가 두번 나간다. 그리고 실제 결과도 이렇다. 같이 변경됐다.

 

 

이런 부작용이 발생할 수가 있다. 그러니까, 애시당초에 부작용을 발생시키지 않으려면 값의 변경을 할 수 없게 막아야 한다. 어떻게? 불변객체로 만들어서! 그래서 불변객체로 만들어야 한다는 것이다. 그래서 이렇게 강제하는 것이다. "한번 세팅된 값을 바꾸고 싶어? 그러면 새로 객체 만들어!" 

 

그래서 불변 객체로 만든 Address는 이렇게 만들 수 있다. 

package cwchoiit.embedded;

import javax.persistence.Embeddable;

@Embeddable
public class Address {
    private final String city;
    private final String street;
    private final String zipCode;

    public Address() {
        this.city = null;
        this.street = null;
        this.zipCode = null;
    }

    public Address(String city, String street, String zipCode) {
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    public String getZipCode() {
        return zipCode;
    }
}
  • 아까 위에서 본 그대로이다. 세터를 제거하고 필드를 전부 `final`로 변경했다. 그리고 오로지 생성자로만 값을 세팅할 수 있게 했다. 
  • 여기서 좀 더 친절한 코드가 되고 싶으면 새로운 불변객체를 만들어내는 with(...), from(...) 등의 메서드를 만들어서 변경하고자 하는 값을 받고 그 값을 세팅한 새로운 객체를 뱉어내는 메서드들도 만들 수 있겠다.
728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 15. JPQL  (0) 2023.10.28
[JPA] Part 14. 값 타입 컬렉션  (0) 2023.10.25
[JPA] Part 12. CASCADE, 고아 객체  (2) 2023.10.23
[JPA] Part 11. 지연로딩과 즉시로딩  (2) 2023.10.23
[JPA] Part 10. 프록시  (0) 2023.10.22
728x90
반응형
SMALL
반응형
SMALL

2024.10.30 업데이트


 

CASCADE는 영속성 전이를 어떤식으로 동작시킬 거냐를 지정하는 옵션이다.

일단 가장 먼저 알고 넘어갈 건 이 CASCADE는 연관관계나 지연로딩 즉시로딩과 전혀 아무런 상관이 없다.

 

그저 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티를 어떻게 할건지를 지정한다. 예를 들면 부모를 저장할 때 부모에 연관된 자식도 같이 영속하는 경우를 말한다.

 

말로는 잘 이해가 안되고 코드로 보면 바로 이해가 된다. 

 

Parent

package org.example.entity.cascade;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "parent")
    private List<Child> childList = new ArrayList<>();
    
    public void addChild(Child child) {
        this.getChildList().add(child);
        child.setParent(this);
    }
}

 

Child

package org.example.entity.cascade;

import javax.persistence.*;

@Entity
public class Child {

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

    private String name;

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

이렇게 두 엔티티가 있다고 가정했을 때, Parent가 2개의 Child를 가지는 코드를 작성한다고 해보자.

 

package org.example;

import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Parent parent = new Parent();
            parent.setName("parent");

            Child child = new Child();
            child.setName("childA");

            Child childB = new Child();
            childB.setName("childB");
            
            parent.addChild(child);
            parent.addChild(childB);
            
            em.persist(parent);
            em.persist(child);
            em.persist(childB);
            
            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 그럼 이렇게 코드가 작성될거다. 근데 사실 이게 좀 귀찮은 작업이다. parentchild, childB가 속해지는데 parent, child, childB 모두 영속시키는 코드를 작성해줘야 하니까. child, childB가 어차피 parent에 속한다면 그냥 parent 하나를 영속시켜서 한번에 모두 다 영속시키면 더 편리하지 않을까? 그게 가능하다. 다음 코드를 보자.
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
private List<Child> childList = new ArrayList<>();

Parent가 가지고 있는 childList 필드에 @OneToMany로 연관관계를 걸어놓은 상태에서 CascadeType.ALL 속성을 CASCADE에 부여하면 @OneToMany 애노테이션이 달린 필드의 타입인 Child들도 전이를 시키겠다는 것이다. 그러니까 CASCADE는 딱 이렇게 생각하면 좋다. cascade 옵션을 설정한 필드 타입은 (여기서는 List<Child>이니까 Child를 말함) 이 Parentpersist()로 영속시키거나 remove()로 삭제할 때 이 Parent가 가지고 있는 Child들도 다 영속되거나 다 삭제된다고 생각하면 좋다.

 

이렇게 변경하고 실행 코드에서 child들을 persist() 메소드로 호출하는 라인을 지워보자.

package org.example;

import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Parent parent = new Parent();
            parent.setName("parent");

            Child child = new Child();
            child.setName("childA");

            Child childB = new Child();
            childB.setName("childB");
            
            parent.addChild(child);
            parent.addChild(childB);
            
            em.persist(parent);
            //em.persist(child);
            //em.persist(childB);
            
            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 실행했을 때 INSERT문으로 parent, child, childB가 모두 들어간다. CASCADE는 이게 끝이다. 연관관계나 프록시와는 아무런 상관이 없다. 

 

CASCADE 종류

CASCADE의 종류는 다음과 같다. 보통은 ALL, PERSIST, REMOVE 정도가 주로 사용된다. 부모를 영속시킬 때 자식들도 한번에 다 영속시키거나, 부모를 삭제하면 그 하위 자식들도 다 같이 삭제되는 그런 경우가 거의 일반적이니까.

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

CASCADE는 자주 사용되는 편리한 옵션이지만, 주의할 사항이 있다. 위 예시처럼 Parent - Child 딱 하나로만 이루어진 연관 엔티티가 있는 경우는 CASCADE를 적용해도 무방하다. 근데 만약, ParentChild를 가지고 있고, 다른 어떤 엔티티(예를 들면.. 유치원 선생님 엔티티?)도 Child를 가지고 있다면 CASCADE 옵션을 사용하면 안된다. 위험하다. 예를 들어 A, B 엔티티가 있고 C라는 엔티티가 있을 때, A와 B 모두 C에 대한 부모가 되는 경우에 A에 CASCADE 옵션으로 REMOVE 또는 ALL을 적용해버리면 A를 삭제하면 C도 삭제가 될 것이다. 근데 B는 이를 모르고 있는 상태가 된다. 영문도 모른채 본인의 자식 엔티티 객체가 사라져버린다. 이런 이유때문에 조심해야한다. 다음 코드가 그 예시다.

 

Parent

package org.example.entity.cascade;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Parent {

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

    private String name;

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

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Child> getChildList() {
        return childList;
    }

    public void setChildList(List<Child> childList) {
        this.childList = childList;
    }

    public void addChild(Child child) {
        this.getChildList().add(child);
        child.setParent(this);
    }
}

 

ParentTwo

package org.example.entity.cascade;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class ParentTwo {

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

    private String name;

    @OneToMany(mappedBy = "parentTwo", cascade = CascadeType.ALL)
    private List<Child> childList = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Child> getChildList() {
        return childList;
    }

    public void setChildList(List<Child> childList) {
        this.childList = childList;
    }

    public void addChild(Child child) {
        this.childList.add(child);
        child.setParentTwo(this);
    }
}

 

Child

package org.example.entity.cascade;

import javax.persistence.*;

@Entity
public class Child {

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

    private String name;

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

    @ManyToOne
    @JoinColumn(name = "PARENTTWO_ID")
    private ParentTwo parentTwo;

    public ParentTwo getParentTwo() {
        return parentTwo;
    }

    public void setParentTwo(ParentTwo parentTwo) {
        this.parentTwo = parentTwo;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Parent getParent() {
        return parent;
    }

    public void setParent(Parent parent) {
        this.parent = parent;
    }
}
  • 주의 깊게 볼 내용은 Parent, ParentTwo 클래스 모두 Child와 관계를 가지고 있고, ParentParentTwo 둘 모두 CASCADE옵션을 ALL로 지정했다는 것이다. 여기서 만약, Parent 또는 ParentTwo 둘 모두에게 연결된 Child가 있는 Parent 또는 ParentTwo 레코드가 있고 그 레코드를 삭제하면 어떻게 될까? Child도 삭제가 된다. 왜냐하면 CASCADE옵션을 ALL로 주었기 때문에 PERSIST, REMOVE, DETACH등 모든 옵션이 적용된다. 그럼 다른 부모 입장에서 갑자기 자식이 사라진 셈이다. 망한거다.
package org.example;

import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.cascade.ParentTwo;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Parent parent = new Parent();
            parent.setName("parent");

            ParentTwo parentTwo = new ParentTwo();
            parentTwo.setName("parentTwo");

            Child child = new Child();
            child.setName("childA");

            parent.addChild(child);
            parentTwo.addChild(child);

            em.persist(parent);
            em.persist(parentTwo);

            em.flush();
            em.clear();

            ParentTwo parentTwo1 = em.find(ParentTwo.class, parentTwo.getId());
            em.remove(parentTwo1);

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 주의 깊게 볼 부분은 flush가 된 후 다시 찾아낸 ParentTwo 객체를 지우는 이 부분이다.
ParentTwo parentTwo1 = em.find(ParentTwo.class, parentTwo.getId());
em.remove(parentTwo1);

 

이러면 Child가 날아가버린다. 확인해보자.

Hibernate: 
    delete 
    from
        Child 
    where
        CHILD_ID=?
Hibernate: 
    delete 
    from
        ParentTwo 
    where
        PARENTTWO_ID=?

이러한 상황을 조심해야 하기 때문에 딱 관계가 Parent - Child처럼 다른 참조 객체가 없는 상황에서는 상관이 없지만 그게 아닌 경우 CASCADE 옵션은 사용을 하면 안된다.

 

 

고아 객체 (orphan)

고아 객체라는 건 부모 - 자식 관계를 가지고 있는 엔티티 구조에서 부모 엔티티와 연결이 끊어진 자식 객체를 말한다. 

그리고 JPA에서는 이 고아 객체를 제거해버리는 옵션이 주어지는데 이 옵션이 orphanRemoval 옵션이다.

 

바로 확인해보자.

 

Parent

package org.example.entity.cascade;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Parent {

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

    private String name;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Child> childList = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Child> getChildList() {
        return childList;
    }

    public void setChildList(List<Child> childList) {
        this.childList = childList;
    }

    public void addChild(Child child) {
        this.getChildList().add(child);
        child.setParent(this);
    }
}

 

이 부모 클래스에서 볼 부분은 다음 코드다.

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> childList = new ArrayList<>();

보면 orphanRemoval = true 라는 옵션을 주었다. 이 옵션을 주면 이 부모와 연결이 끊어진 자식 객체는 삭제가 된다.

 

Main

package org.example;

import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.cascade.ParentTwo;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Parent parent = new Parent();
            parent.setName("parent");

            Child child = new Child();
            child.setName("child");

            parent.addChild(child);

            em.persist(parent);
            em.flush();
            em.clear();

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

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 위 코드에서 자식을 생성하고 부모에 연결한 후 부모를 영속시킨 후 플러시를 실행했다. 여기까지 진행되면 Parent, Child 둘 모두 DB에 추가된다. 그리고 나서 이 코드를 보자.
Parent findParent = em.find(Parent.class, parent.getId());
findParent.getChildList().remove(0);
  • 엔티티 매니저로 부모 객체를 찾아서 부모 객체가 가지고 있는 자식을 remove() 했다. 그럼, 0번에 해당하는 Child 객체는 이제 더이상 부모인 Parent 객체의 리스트에서 관리되지 않는다. (여기서 혼동이 많은데, 지금 remove()를 호출한다고 한들, 당연히 DB에는 아무런 영향이 없다! 즉, 0ChildPARENT_ID 외래키가 없어지는게 아니다! 그저 객체 관점에서 0Child가 더 이상 부모 참조가 없어진 것 뿐이다. 그러나! `orphanRemoval = true` 활성화를 하면 객체가 고아가 되면 DB에도 영향을 준다는 것이다. DB에서도 삭제가 되니 말이다.) 
Hibernate: 
    insert 
    into
        Parent
        (name, PARENT_ID) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        Child
        (name, PARENT_ID, PARENTTWO_ID, CHILD_ID) 
    values
        (?, ?, ?, ?)
Hibernate: 
    select
        parent0_.PARENT_ID as parent_i1_2_0_,
        parent0_.name as name2_2_0_ 
    from
        Parent parent0_ 
    where
        parent0_.PARENT_ID=?
Hibernate: 
    select
        childlist0_.PARENT_ID as parent_i3_0_0_,
        childlist0_.CHILD_ID as child_id1_0_0_,
        childlist0_.CHILD_ID as child_id1_0_1_,
        childlist0_.name as name2_0_1_,
        childlist0_.PARENT_ID as parent_i3_0_1_,
        childlist0_.PARENTTWO_ID as parenttw4_0_1_,
        parenttwo1_.PARENTTWO_ID as parenttw1_3_2_,
        parenttwo1_.name as name2_3_2_ 
    from
        Child childlist0_ 
    left outer join
        ParentTwo parenttwo1_ 
            on childlist0_.PARENTTWO_ID=parenttwo1_.PARENTTWO_ID 
    where
        childlist0_.PARENT_ID=?
Hibernate: 
    delete 
    from
        Child 
    where
        CHILD_ID=?

위에서부터 차례로 부모와 자식이 INSERT된 후 부모를 조회하는데 부모를 조회한 후 자식을 호출하는 과정에서 자식을 찾아내는 조인쿼리가 실행된다. (왜냐? OneToMany는 기본이 지연로딩(LAZY)이고 아무것도 설정하지 않았기 때문에 초기화하는 시점에 자식을 찾아온다) 그리고 나서 실행된 DELETE문이 보인다. 이게 고아 객체를 제거하는 쿼리다.

 

그리고 이 경우에도 주의할 점은! CASCADE와 마찬가지 이유로, 반드시 참조하는 곳이 하나일 때 사용해야 한다! 즉, 특정 엔티티가 개인 소유하는 경우에만 사용해야 한다. (예: 게시글 - 첨부파일 관계) 

  

참고로, 개념적으로 부모를 제거하면 자식은 고아가 된다. 따라서 `orphanRemoval = true` 활성화하면, 부모를 제거할 때 자식도 함께 제거된다. 이것은 CASCADEREMOVE처럼 동작한다.

 

영속성 전이 + 고아 객체

만약, CascadeType.ALL + orphanRemoval = true 이 두가지 옵션을 모두 설정을 하면, 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있게 된다. 영속성 전이를 통해 영속을 시키든, 삭제를 시키든 그 하위 엔티티도 동일하게 동작할 것이고, 부모에서 자식을 떼버리면 고아 객체가 된 객체는 DB에서 삭제되기 때문에 완전히 부모를 통해 자식의 생명 주기를 관리할 수 있게 된다.

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

2024.10.30 업데이트


 

지연로딩과 즉시로딩에 대해 공부한 내용을 적고자 한다. 내가 JPA를 처음 이론적인 공부를 하지 않고 그냥 무작정 사용했을 때 이런 내용이 있는지도 사실 모르고 데이터를 받아올 때 무수히 많은 SQL문을 남발하곤 했는데, 그 남발하게된 SQL문의 원인 중 하나가 여기에 있다. 

 

우선 지연로딩즉시로딩은 JPA가 데이터를 데이터베이스로부터 조회할 때 조회할 레코드에 참조 객체(테이블 관점에서는 외래키)가 있는 경우 해당 데이터까지 한꺼번에 다 가져올지 말지를 정하는 기준을 말한다. 

 

다음 상황을 가정해보자.

팀 엔티티와 멤버 엔티티가 있고 팀과 멤버는 일대다 관계이다. 이 때 멤버를 조회할 때 팀도 한번에 조회해야 할까?

코드로 이를 직접 비교해보자.

 

지연로딩 (Lazy Fetch)

Member

package org.example.entity.fetch;

import org.example.entity.Team;

import javax.persistence.*;

@Entity
public class Member {

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

    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Team getTeam() {
        return team;
    }

    public void setTeam(Team team) {
        this.team = team;
    }
}
  • 멤버는 팀을 참조하고, LAZY(지연 로딩)를 Fetch 전략으로 가지고 있다.

Team

package org.example.entity;

import org.example.entity.fetch.Member;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@SequenceGenerator(
        name = "TEAM_SEQ_GENERATOR",
        sequenceName = "TEAM_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Member> getMembers() {
        return members;
    }

    public void setMembers(List<Member> members) {
        this.members = members;
    }
}
  • 팀은 별다른 내용은 없고 멤버와 양방향으로 참조한다. 이런 두 엔티티가 있을 때 멤버를 조회해보자. 

Main

package org.example;

import org.example.entity.Team;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            Member member = new Member();
            member.setTeam(team);
            member.setUsername("memberA");
            em.persist(member);

            em.flush();
            em.clear();

            Member findMember = em.find(Member.class, member.getId());
            System.out.println("findMember: " + findMember.getUsername());
            System.out.println("findMember Team Class: " + findMember.getTeam().getClass());

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 지연 로딩으로 설정하면 참조 객체에 대해 바로 엔티티로 찾아오는게 아니라 전 시간에 공부했던 프록시로 가져온다. 즉, 위 코드로 실행하면 멤버의 팀은 프록시로 가져와야 한다. 실행 결과는 다음과 같다.

찾은 멤버의 팀은 현재 Proxy로 가져온 상태임을 확인할 수 있다. 이게 지연로딩이고 실제 SQL문을 살펴봐도 팀과 조인한 SQL문이 실행되지 않았다. 이렇게 되면 만약 멤버를 조회했을 때 팀을 알 필요가 전혀 없는 상태라면 이런 SELECT문이 훨씬 이점을 가져갈 것이다.

 

 

이번에는 즉시로딩으로 실행해보자. 참고로 @ManyToOne, @OneToOne은 기본이 즉시로딩(EAGER)이다.

즉시로딩 (Eager Fetch)

위에서 작성한 코드에서 수정할 내용은 LAZYEAGER로 변경만 하면 된다.

package org.example.entity.fetch;

import org.example.entity.Team;

import javax.persistence.*;

@Entity
public class Member {

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

    private String username;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Team getTeam() {
        return team;
    }

    public void setTeam(Team team) {
        this.team = team;
    }
}

EAGER로 변경 후 실행해보자. SQL문을 확인해보면 다음과 같이 출력된다.

멤버를 조회할 때 바로 조인을 해서 TEAM 정보도 가져오는 SELECT문을 확인할 수 있다. 그리고 가져온 TEAM의 클래스를 찍어보면 이번엔 Proxy가 아니라 실제 엔티티를 가져왔다. 이게 즉시로딩이다.

 

 

결론

결론을 내리자면 즉시로딩은 가급적 사용하지 않기로 하자. 아무리 멤버를 조회할 때 팀도 바로 가져오는 상황이 있다고 한들 지연로딩으로 가져와서 팀 프록시를 초기화하는 방향으로 사용하는 걸 생각하자.

 

왜 그러냐면 다음과 같은 이유들이 있다. 

  • 즉시 로딩의 문제1 - 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.

이건 위에서 봤듯 분명 아래 코드밖에 사용하지 않았다. 

Member findMember = em.find(Member.class, member.getId());

물론, 여기서는 이 즉시로딩을 적용했고 이 코드를 사용하면 팀도 같이 가져온다는 걸 인지하고 있고 코드양이 적기 때문에 예상할 수 있는 SQL문이지만, 코드가 길어지고 메서드가 많아지면 많아질수록 실행되는 SQL문에서 왜 팀을 조인하는지 알아보기 힘들어질 가능성이 너무 크다. 거기다가, 지금이야 Team 하나만 즉시로딩으로 설정했으니 망정이지, 즉시로딩이 만약 3개, 4개 넘어가면 조인도 그만큼 많이 하게 된다.

 

  • 즉시 로딩의 문제2 - 즉시 로딩은 JPQL 사용을 할 때 N+1 문제가 생긴다.

이게 진짜 큰 문제인데 JPQL은 종종 사용된다. 복잡한 쿼리를 작성할 일이 많기 때문에 그래서 예시를 위해 다음 JPQL 코드를 살펴보자.

package org.example;

import org.example.entity.Team;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            Member member = new Member();
            member.setTeam(team);
            member.setUsername("memberA");
            em.persist(member);

            em.flush();
            em.clear();

            List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

위 코드부분에서 여기 부분이 JPQL을 사용하는 부분이다.

List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();

 

이 코드를 수행해보면 다음과 같은 SQL문이 출력된다.

Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_0_,
        member0_.TEAM_ID as team_id3_0_,
        member0_.username as username2_0_ 
    from
        Member member0_
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?

SELECT를 두번한다. 왜 두번하냐 ? 우선 위에 작성한 JPQL은 멤버를 조회한다. 멤버를 조회했을 때 멤버가 1개면 1개의 레코드, 2개면 2개의 레코드가 출력되는데 이 때 즉시로딩은 멤버가 가지고 있는 팀도 같이 가져와야하기 때문에 각 멤버별 팀까지 가져와야 하는 SELECT문이 나간다. 그럼 이게 멤버가 10명이 있을때, 각 멤버가 서로 다 다른 팀에 속한 경우, 팀을 가져오는 SELECT문 10번이 같이 나간다는 소리다. (물론, 멤버 10명이 다 같은 팀에 속해 있다면, 당연히 팀을 가져오는 쿼리는 한번만 나간다. 두번째부터는 이미 가져온 팀이니까)

 

그래서 N+1은 멤버를 조회하는 쿼리 1개 + 멤버가 가지고 있는 팀을 조회하는 쿼리 N개가 나간다. 그럼 최악의 경우, 멤버가 10명이고 각 멤버가 속한 팀이 서로 다 다르다면 한번에 10 + 1 쿼리가 나가게된다.

 

심지어! 이건 멤버가 팀만 가지고 있는 경우를 말한거지 만약 다른 참조객체가 2개, 3개, 여러개가 있으면 20 + 1, 30 + 1, N + 1이 된다는 소리다. 끔찍한 경우다. 그래서 즉시로딩은 사용하면 안된다.

 

그럼 지연로딩으로 설정만 하면 이런 N + 1 문제가 아예 없어지나요!?

→ 안타깝게도 아니다. 그러나, 최소한 지연로딩으로 설정하면 손 쓸 수도 없게 문제를 바라만봐야 하는 상황은 막을 수 있다.

 

왜 근데 아닐까? 지연로딩으로 설정하고 아래와 같이 팀 2개, 유저 2개를 만들어서 DB에 넣고 영속성 컨텍스트를 깔끔하게 정리한 후 다시 조회해보자.

package cwchoiit;

import cwchoiit.fetch.Team;
import cwchoiit.fetch.Users;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            Team team = new Team();
            team.setName("TEAM1");

            Team team2 = new Team();
            team2.setName("TEAM2");

            Users users = new Users();
            users.setUsername("choi");
            users.setTeam(team);

            Users users2 = new Users();
            users2.setUsername("kim");
            users2.setTeam(team2);

            entityManager.persist(team);
            entityManager.persist(team2);

            entityManager.persist(users);
            entityManager.persist(users2);

            entityManager.flush();
            entityManager.clear();


            List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

 

 

그리고 아래 코드처럼 JPQL로 모든 유저들을 가져오는 쿼리를 날리고 그 결과를 보자.

List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();

 

실행 결과

Hibernate: 
    /* SELECT
        u 
    FROM
        Users u */ select
            users0_.USER_ID as user_id1_12_,
            users0_.TEAM_ID as team_id3_12_,
            users0_.username as username2_12_ 
        from
            Users users0_
            
...

보다시피, 당연히 지연로딩이기 때문에 팀에 대한 정보는 아직 조회를 하지 않는다. 그러나, 만약 이 리스트를 순회해서 팀을 가져온다면?

그땐, 당연히 N + 1 문제가 결국엔 나타나게 된다. 

...

List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();

for (Users findUser : findUsers) {
    System.out.println(findUser.getTeam().getName());
}

...

 

실행 결과

...

Hibernate: 
    /* SELECT
        u 
    FROM
        Users u */ select
            users0_.USER_ID as user_id1_12_,
            users0_.TEAM_ID as team_id3_12_,
            users0_.username as username2_12_ 
        from
            Users users0_
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_11_0_,
        team0_.name as name2_11_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
TEAM1
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_11_0_,
        team0_.name as name2_11_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
TEAM2

...

실행 결과를 보면 역시 마찬가지로 각 멤버가 속한 팀에 대한 쿼리를 하나씩 날리고 있다. 즉, 지연로딩으로 해도 N + 1 문제는 여전히 발생할 수 있다는 소리다. 그러나 차이점은 명확하다. 즉시로딩은 JPQL 실행 시, 개발자가 손 쓸 수도 없게 바로 N + 1 문제가 발생하지만, 지연로딩은 JPQL 실행 시 바로 N + 1 문제는 발생하지 않는다. 그래서 만약, 순회를 하더라도 팀을 조회하는 코드가 없다면 N + 1 문제는 지연로딩만으로도 해결할 수 있다. (물론, 반쪽짜리 해결이고 사실 해결한 것이라 볼 수 없지만.)

 

그럼 이 N + 1 문제를 완전히 해결하는 방법은 뭔데요?

→ 이후에 배우겠지만 "지연 로딩 + 페치 조인"이다.

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 13. 임베디드 값 타입  (0) 2023.10.23
[JPA] Part 12. CASCADE, 고아 객체  (2) 2023.10.23
[JPA] Part 10. 프록시  (0) 2023.10.22
[JPA] Part 9. @MappedSuperclass  (0) 2023.10.22
[JPA] Part 8. 상속관계 매핑  (2) 2023.10.22
728x90
반응형
SMALL
반응형
SMALL

2024.10.30 업데이트


 

JPA에서 중요한 개념 중 하나인 프록시라는 게 있다. 중요한 개념이라기보단 중요하게 사용되는 지연로딩과 즉시로딩을 사용하려면 반드시 깊은 이해가 필요하다고 개인적으로 생각한다. 왜 그러냐면 회사에서 프로젝트를 진행 중에 이해도가 깊지 않은 상태에서 지연로딩을 마구잡이로 썼다가 프록시 초기화 오류를 진짜 무진장 만났던 기억이 아직도 생생하다. 

 

 

우선 서사는 이렇다.

멤버라는 테이블과 팀이라는 테이블이 있을 때를 가정해보자. 멤버는 팀에 속하고 아래 그림과 같다.

이런 구조를 가진 두 테이블이 있을 때 멤버를 조회한다고 가정해보자. 특정 멤버를 조회할 때 팀도 같이 조회해야 할까?

예를 들어 이런 메서드가 있다고 생각해보자.

private static void printMember(EntityManager em) {
    Member member = em.find(Member.class, 1L);
    System.out.println("member = " + member.getName());
}
    
private static void printMemberAndTeam(EntityManager em) {
    Member member = em.find(Member.class, 1L);
    System.out.println("member name = " + member.getName());
    System.out.println("member's team = " + member.getTeam());
}
  • printMemberAndTeam(EntityManager em)은 멤버와 팀을 모두 출력하는 메서드이다.
  • printMember(EntityManager em)은 멤버만 출력하는 메서드이다.
  • printMemberAndTeam()을 호출한다면, 멤버를 조회할 때 팀까지 같이 조회가 되면 훨씬 효율적이겠지만, printMember()를 호출한다면, 멤버를 조회할 때 팀까지 조회되는게 비효율적일 것이다. (팀을 가져오기 위해 JOIN을 할테니)

이런 상황을 해결하기 위해 '프록시'가 있다.

 

Proxy

우선 프록시를 깊이 있게 알기 전에 엔티티 매니저는 두 가지 핵심 메서드가 있다. (핵심 메서드라고 말은 했지만, 사실 순수 JPA를 사용할 일은 거의 없고 Spring Boot + Spring Data JPA를 같이 사용하는 게 거의 99%이고, 이 경우에는 entityManager.find(), entityManager.getReference() 같은 메서드를 사용하지 않아서 이 메서드를 막 외우고 그럴 필요는 없다. 다만 이런것으로부터 프록시와 초기화에 대해 이해하는게 정말 중요하기 때문에 이 내용을 알아야 한다.)

  • em.find()
  • em.getReference()

우선 find() 메서드는 이미 여러번 사용했기 때문에 잘 알고 있다. 영속성 컨텍스트 또는 데이터베이스로부터 특정 객체(레코드)를 반환하는 메서드이다. 그럼 getReference()는 무엇일까? 데이터베이스로 조회를 지연시키는 가짜 엔티티(프록시) 객체를 반환한다. 

 

이해하기 위해 코드를 실행해보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

            em.flush();
            em.clear();

            Member findMember = em.find(Member.class, member.getId());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

실행 결과

Hibernate: 
    insert 
    into
        Member
        (createdAt, description, email, lastModifiedAt, name, roleType, TEAM_ID, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
  • 실제 데이터베이스로부터 멤버를 조회하기 위한 SELECT문이 수행된다. 근데 getReference()를 사용해보면 어떻게 될까?

 

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

            em.flush();
            em.clear();

            Member findMember = em.getReference(Member.class, member.getId());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

실행 결과

Oct 22, 2023 7:06:06 PM org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProviderImpl$PoolState stop
INFO: HHH10001008: Cleaning up connection pool [jdbc:h2:tcp://localhost/~/h2/test]

Process finished with exit code 0
  • SELECT문이 출력되지 않는다. 즉 데이터베이스에 직접 조회를 하지 않았단 소리다. 이게 프록시다. 우선 객체를 찾아내는 조회 과정을 미루는것이다. 그럼 언제 실제로 조회할까? 실제로 사용할 때! 조회를 그때가서야 한다. 그리고 이 조회를 하는 순간을 "프록시를 초기화 한다" 라고 말한다.

  • 이렇게 getReference()를 호출하면, Proxy 객체를 반환하는데 이 상태에서는 DB로부터 조회하는 단계는 없다.

 

그럼, 이 프록시 객체를 초기화하는 순간을 보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

            em.flush();
            em.clear();

            Member findMember = em.getReference(Member.class, member.getId());
            System.out.println("No SELECT QUERY Until this line");

            // Execute SELECT Query
            System.out.println("findMember = " + findMember.getName());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 프록시 객체가 가지고 있는 메서드를 호출할 때 비로소 프록시는 실제 데이터를 DB로부터 조회해온다. 실행 결과는 다음과 같다. "No SELECT QUERY Until this line"이 출력된 후 SELECT문이 실행됨을 알 수 있다. 
No SELECT QUERY Until this line
Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
findMember = member1

 

그래서 이 과정을 그림으로 보면 다음과 같다.

  • 프록시 객체는 실제 대상 객체인 target을 가지고 있다. 이 target은 초기화되기 전까지는 값이 없는 상태다.
  • ClientgetName()과 같은 객체를 통해 가져와야 하는 값을 호출하는 무언가를 호출했을때 프록시는 초기화 요청을 진행하낟.
  • 그럼 DB로부터 실제 데이터를 조회한다.
  • 조회한 데이터에 대한 엔티티를 만들고 그 엔티티를 프록시 객체가 가지고 있는 target에 집어넣는다.
  • target이 가진 getName()을 호출하고 그 값을 Client에게 반환한다.

 

 

그래서 프록시는 이러한 특징을 가지고 있다.

  • 실제 클래스를 상속 받아서 만들어진다.
  • 실제 클래스와 겉 모양이 같다.
  • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용할 수 있다.
  • 프록시 객체는 실제 객체의 참조(Target)를 보관
  • 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출
  • 프록시 객체는 처음 사용할 때 한 번만 초기화
  • 프록시 객체를 초기화할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능하다.
  • 프록시 객체는 원본 엔티티를 상속받기 때문에 타입 체크 시 주의 요망 (`==`를 사용하면 안되고, `instance of`를 사용해야 한다)이게 무슨 말이냐면, 아래 코드를 보자.
Member member = new Member();
member.setName("member1");
em.persist(member);

Member member2 = new Member();
member2.setName("member2");
em.persist(member2);

em.flush();
em.clear();

Member m1 = em.find(Member.class, member.getId());
Member m2 = em.getReference(Member.class, member2.getId());

System.out.println("m1 = " + m1.getClass()); //m1 = class org.example.entity.Member
System.out.println("m2 = " + m2.getClass()); //m2 = class org.example.entity.Member$HibernateProxy$H8YFX6fR 

System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass())); //false
  • m1find() 메서드를, m2getReference() 메서드를 사용했다. 그러면 가져오는 클래스는 MemberMemberProxy가 된다. 이 때 이 두 객체 모두 타입을 Member로 받아왔지만 (프록시가 원본 엔티티를 상속받기 때문에 프록시 객체여도 Member 타입으로 받아올 수 있음) 두 객체의 클래스를 비교하면 false를 리턴한다는 뜻이다. 
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환이라고 되어 있으나, 좀 더 정확히는 같은 트랜잭션 내에서 JPA는 무결성과 일관성을 유지해주는 속성 때문에 getReference()가 아니라 find() 메서드를 사용하더라도 이미 영속성 컨텍스트에 프록시가 있으면 find() 메서드로 호출해도 프록시로 반환한다. 반대로 이미 영속성 컨텍스트에 찾은 동일한 멤버 객체가 실제로 있으면 getReference()를 호출해도 실제 엔티티를 반환한다는 의미다. 아래 코드로 예시를 살펴보자.
Member member = new Member();
member.setName("member1");
em.persist(member);

em.flush();
em.clear();

Member m1 = em.find(Member.class, member.getId());
Member m2 = em.getReference(Member.class, member.getId());

System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());

System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass()));
  • 위 코드를 보면, 영속성 컨텍스트를 깔끔히 정리한 후에 find() 메서드를 먼저 호출하고 getReference() 메서드를 호출한다. 이 때 find()메서드를 통해 실제 엔티티를 가져와 영속성 컨텍스트에 넣었기 때문에 getReference()를 호출해서 가져와도 실제 엔티티를 가져온다. 그 결과는 다음과 같다.
Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
m1 = class org.example.entity.Member
m2 = class org.example.entity.Member
m1 == m2: true

반대도 마찬가지다. 만약 아래와 같은 코드가 있을 때를 보자.

Member member = new Member();
member.setName("member1");
em.persist(member);

em.flush();
em.clear();

Member m2 = em.getReference(Member.class, member.getId());
Member m1 = em.find(Member.class, member.getId());

System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());

System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass()));
  • 이번엔 getReference()를 먼저 호출해서 멤버를 조회하면 이 때 m2는 프록시로 가져온다. 그럼 m1은 어떻게 가져올까? 그렇다. 프록시로 가져온다. 왜냐? JPA는 같은 트랜잭션 내 동일한 레코드에 대해서 동일성(`==`) 보장이라는 매커니즘이 있기 때문이다.

이 코드의 결과는 다음과 같다.

Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
m1 = class org.example.entity.Member$HibernateProxy$cFfoNpx3
m2 = class org.example.entity.Member$HibernateProxy$cFfoNpx3
m1 == m2: true
  • find() 메서드로 인해 SELECT문으로 실제 데이터베이스로부터 조회해도 가져오는 값은 Proxy이다. 근데 이제 복잡하게 생각말고, 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 프록시가 아닌 엔티티를 가져온다. 라고 한 줄로 요약하는 것이다.

 

또, 아주 중요한 내용이 하나 있다. 바로 다음 한 줄이다.

영속성 컨텍스트의 도움을 받을 수 없는 준영속(Detach)상태 일 때, 프록시를 초기화하면 에러 발생

이게 가장 중요한 부분이다. 준영속 상태가 되는 상황은 크게 세가지 정도가 있다.

  • 엔티티 매니저의 detach(해당 객체) 
  • 엔티티 매니저의 clear()
  • 엔티티 매니저의 close()

어떤 경우가 됐든 준영속 상태가 되었다면 프록시를 초기화할 때 LazyInitializationException이 발생한다. 영속성 컨텍스트의 도움을 받을 수 없다라는 말은 영속성 컨텍스트는 결국 하나의 엔티티 매니저당 하나의 영속성 컨텍스트를 가진다. 그럼 엔티티 매니저가 끝나면 영속성 컨텍스트도 끝나는 것이다. 이게 이제 스프링 부트를 같이 사용하면 @Transactional 이라는 애노테이션을 사용해서 간편하게 엔티티 매니저를 만들고 영속성 컨텍스트를 만들어 주는데, 이 @Transactional이 적용된 메서드가 끝난 상태에서 초기화를 시도하면 영속성 컨텍스트는 없기 때문에 동일하게 LazyInitializationException 에러가 발생한다.

 

 

코드로 보면 좀 더 이해가 빠르게 되더라. 코드를 보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

            em.flush();
            em.clear();

            Member m2 = em.getReference(Member.class, member.getId());

            em.detach(m2); //준영속 상태

            System.out.println("m2 Name: " + m2.getName());

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 위 코드처럼 프록시를 가져온 후 가져온 detach() 하고나서, 프록시를 초기화하려고 시도하면 다음과 같은 에러를 마주할 수 있다. 
org.hibernate.LazyInitializationException: could not initialize proxy [org.example.entity.Member#1] - no Session
	at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:176)
	at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:322)
	at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:45)
	at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:95)
	at org.example.entity.Member$HibernateProxy$AFKAFeFy.getName(Unknown Source)
	at org.example.Main.main(Main.java:37)

이 준영속 상태는 다시 말해 영속성 컨텍스트의 도움을 못받는 경우를 말하고, 영속성 컨텍스트는 하나의 트랜잭션이 곧 하나의 엔티티 매니저와 1:1로 매핑된다고 아주아주 이전에 말한 것 처럼 엔티티 매니저가 닫히거나(하나의 트랜잭션이 끝남), 비워지거나 등 어떤 식으로든 영속성 컨텍스트의 도움을 받지 못하는 경우 위 에러를 마주한다. 이런 상황을 이해해야 지연 로딩과 즉시 로딩에 대해 깊은 이해와 사용성을 높일 수 있다. 나도 직접 이런 경우를 실제 회사 프로젝트에서 많이 많이 겪다보니까 정말 힘들었는데.. 알고 나니 어려운 게 하나도 아니더라. 

 

 

프록시 유틸 메소드

프록시 관련해서 쓸만한 메서드들을 소개하자면, 프록시가 초기화 된 상태인지 아닌지 확인 가능한 isLoaded()가 있다.

emf.getPersistenceUnitUtil().isLoaded(Object entity);
// emf.getPersistenceUnitUtil()은 PersistenceUnitUtil를 반환

 

또는 강제 초기화를 할 수 있는 initialize() 메서드도 있다.

Hibernate.initialize(Object entity);
  • 근데 JPA 표준에서는 강제 초기화가 없기 때문에 그냥 해당 프록시가 상속 받는 클래스가 가진 메서드가 실제로 호출되는 순간이 곧 초기화 되는 순간이다. (예시: member.getName() 호출)
728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

2024.10.30 업데이트


 

이건 Part 8에서 알아본 상속 관계 매핑이 아니다! (느낌은 비슷하다, 왜냐하면 클래스에서는 extends로 사용되기 때문에 헷갈리긴 하지만 상속관계 매핑은 @Inheritance 애노테이션을 사용할때가 상속 관계 매핑이다.) 어떤 거냐면 테이블들이 공통으로 사용하는 필드들을 편하게 가져다 쓰기 위해 한 클래스에서 그 필드들을 선언하고 필요한 엔티티가 가져다가 사용하는 것을 말한다. 

 

자주 사용되며 아주 대표적인 예시가 createdDate, createdBy, lastModifiedBy, lastModifiedDate같은 필드를 다룰 때이다. 바로 코드를 보자. Part 8에서 사용했던 Item, Movie, Album, Book 테이블을 활용해서 적용해보자. 

 

BaseEntity

package org.example.entity.inheritance;

import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass
public abstract class BaseEntity {
    private String createdBy;
    private LocalDateTime createdDate;
    private String lastModifiedBy;
    private LocalDateTime lastModifiedDate;

    public String getCreatedBy() {
        return createdBy;
    }

    public void setCreatedBy(String createdBy) {
        this.createdBy = createdBy;
    }

    public LocalDateTime getCreatedDate() {
        return createdDate;
    }

    public void setCreatedDate(LocalDateTime createdDate) {
        this.createdDate = createdDate;
    }

    public String getLastModifiedBy() {
        return lastModifiedBy;
    }

    public void setLastModifiedBy(String lastModifiedBy) {
        this.lastModifiedBy = lastModifiedBy;
    }

    public LocalDateTime getLastModifiedDate() {
        return lastModifiedDate;
    }

    public void setLastModifiedDate(LocalDateTime lastModifiedDate) {
        this.lastModifiedDate = lastModifiedDate;
    }
}
  • BaseEntity라는 추상클래스를 만들고 공통적으로 사용될 데이터를 모두 작성한 후 이 클래스에 @MappedSuperclass 어노테이션을 추가해준다. 그럼 JPA는 아 이 클래스가 테이블로 만들어지는 클래스가 아니고 메타데이타성 클래스구나를 인지한다.
  • 직접 객체를 만들어 사용할 일도 없고, 엔티티도 아니기 때문에 추상 클래스로 만들기를 권장한다.

 

Item

package org.example.entity.inheritance;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public abstract class Item extends BaseEntity {

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

    private String name;
    private int price;

    public Item() {}

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}
  • 이제 Album, Book, Movie가 상속받는 Item 클래스는 BaseEntity를 부모로 선언한다. 그렇게 되면 나머지 Album, Book, Movie도 모두 가져다가 사용할 수 있게 된다.

 

Main

package org.example;

import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        
        tx.begin();
        try {
            Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
            movie.setCreatedBy("반지의제왕 감독");
            em.persist(movie);

            em.flush();
            em.clear();

            Item item = em.find(Item.class, movie.getId());
            
            System.out.println("Movie item = " + item.getName());
            System.out.println("Movie item createdBy = " + item.getCreatedBy());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 위 코드처럼 Movie 객체 하나를 추가해서 createdBy 값을 추가하고 DB에 저장한 뒤 DB로부터 데이터를 받아와보자.

실행 결과

Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    insert 
    into
        Item
        (createdBy, createdDate, lastModifiedBy, lastModifiedDate, name, price, DTYPE, ITEM_ID) 
    values
        (?, ?, ?, ?, ?, ?, 'Movie', ?)
Hibernate: 
    insert 
    into
        Movie
        (actor, director, ITEM_ID) 
    values
        (?, ?, ?)
Hibernate: 
    select
        item0_.ITEM_ID as item_id2_3_0_,
        item0_.createdBy as createdb3_3_0_,
        item0_.createdDate as createdd4_3_0_,
        item0_.lastModifiedBy as lastmodi5_3_0_,
        item0_.lastModifiedDate as lastmodi6_3_0_,
        item0_.name as name7_3_0_,
        item0_.price as price8_3_0_,
        item0_1_.author as author1_1_0_,
        item0_1_.isbn as isbn2_1_0_,
        item0_2_.artist as artist1_0_0_,
        item0_3_.actor as actor1_6_0_,
        item0_3_.director as director2_6_0_,
        item0_.DTYPE as dtype1_3_0_ 
    from
        Item item0_ 
    left outer join
        Book item0_1_ 
            on item0_.ITEM_ID=item0_1_.ITEM_ID 
    left outer join
        Album item0_2_ 
            on item0_.ITEM_ID=item0_2_.ITEM_ID 
    left outer join
        Movie item0_3_ 
            on item0_.ITEM_ID=item0_3_.ITEM_ID 
    where
        item0_.ITEM_ID=?
Movie item = 반지의 제왕
Movie item createdBy = 반지의제왕 감독

 

참고로, 이후에 Spring Data JPA를 같이 사용할땐 이렇게 직접 값을 넣어주지 않아도 알아서 넣어주는 방법이 있다. 지금은 순수 JPA만 사용중이니까 이렇게 직접 createdBy 값을 넣어줬지만.

 

실전 예제

그럼 이제, 상속관계 매핑도 배웠고 @MappedSuperclass도 배웠으니 이걸 사용해서 테이블을 엔티티로 매핑해보는 예제를 다뤄보자.

 

도메인 모델

 

상세

 

이렇게 되어 있을 때, 테이블을 엔티티로 설계해보자.

 

BaseEntity

package cwchoiit.relationship;

import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass
public abstract class BaseEntity {

    private LocalDateTime createdDate;
    private LocalDateTime lastModifiedDate;

    private String createdBy;
    private String lastModifiedBy;
}

 

Item

package cwchoiit.relationship;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public abstract class Item extends BaseEntity {

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

    private String name;
    private Integer price;
    private Integer stockQuantity;
}
  • 상속관계 매핑 전략을 JOINED로 설정했다. SINGLE_TABLE로 해도 무방하다.

Album

package cwchoiit.relationship;

import javax.persistence.Entity;

@Entity
public class Album extends Item {

    private String artist;
    private String etc;
}

 

Book

package cwchoiit.relationship;

import javax.persistence.Entity;

@Entity
public class Book extends Item {

    private String author;
    private String isbn;
}

 

Movie

package cwchoiit.relationship;

import javax.persistence.Entity;

@Entity
public class Movie extends Item {

    private String director;
    private String actor;
}

 

Member

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Member extends BaseEntity {

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

    private String name;
    private String city;
    private String street;
    private String zipCode;

    @OneToMany(mappedBy = "member")
    private List<Orders> orders = new ArrayList<>();
}

 

Orders

package cwchoiit.relationship;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class Orders extends BaseEntity {

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

    private LocalDateTime orderDate;
    private Status status;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @OneToOne
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery;
}

 

Delivery

package cwchoiit.relationship;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Delivery extends BaseEntity {

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

    private String city;
    private String street;
    private String zipCode;
    private Status status;
}

 

OrderItem

package cwchoiit.relationship;

import javax.persistence.*;

@Entity
public class OrderItem extends BaseEntity {

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

    private Integer orderPrice;
    private Integer count;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    @ManyToOne
    @JoinColumn(name = "ORDER_ID")
    private Orders orders;
}

 

Category

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Category extends BaseEntity {

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

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Category parent;

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

 

CategoryItem

package cwchoiit.relationship;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class CategoryItem extends BaseEntity {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "CATEGORY_ID")
    private Category category;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    private LocalDateTime created;
}

 

이렇게 구성하면 된다!

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

2024.10.30 업데이트


 

이제 상속관계를 매핑하는 법도 알아보자. 왜냐하면 객체는 상속을 받을 수 있으니까. 

우선은 관계형 데이터베이스는 상속 관계가 없다. 대신 슈퍼타입 서브타입 관계라는 모델링 기법이 있고 그 기법이 객체 상속과 유사하다.

그래서, 상속관계 매핑을 객체의 상속 구조와 DB의 슈퍼타입 서브타입 관계를 매핑해 보는 것이 주제다.

위 그림에서 ITEM이라는 테이블이 있고 그 테이블이 가지는 컬럼을 모두 동일하게 가지는 3개의 테이블 Album, Movie, Book이 있다고 가정해 보자. 이 경우 데이터베이스에서는 3가지 전략으로 테이블을 구성할 수 있다.

  • 조인 전략
  • 단일 테이블 전략
  • 구현 클래스마다 각 테이블 전략

 

 

조인 전략

조인 전략은 상위 테이블인 ITEM의 기본키를 각 테이블이 기본키이자 외래키로 가지는 방법이다. 다음 그림이 이를 설명한다.

ITEM 테이블에서 공통으로 사용되는 NAME, PRICE를 가지고 있으며 각 테이블마다 개인적으로 필요한 데이터는 각 테이블이 관리하는 방법이다. 그리고 이 때, ITEM 테이블에서는 어떤 테이블과 연결된 데이터인지를 구분 짓기 위해 DTYPE이라는 필드가 추가된다. 데이터를 가장 정교화된 방식으로 모델링을 깔끔하게 한 모델이다.

 

객체로 이를 구현해보면 다음과 같다.

 

Item

package org.example.entity.inheritance;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public abstract class Item {

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

    private String name;
    private int price;

    public Item() {}

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

가장 최상위인 아이템 클래스는 살펴볼 점이 두 가지가 있다. 

  • 첫 번째, @Inheritance는 이 클래스를 상속받을 클래스들과 어떤식으로 테이블이 구성될지를 알려주는 어노테이션이다. 이 전략의 기본값은 SINGLE_TABLE인데 이는 위에서 말한 단일 테이블 전략을 말한다. 그래서 JOINED로 변경했다.
  • 두 번째, @DiscriminatorColumn은 위에서 표기한 DTYPE을 칼럼으로 추가한다는 어노테이션이다. 기본 칼럼명은 DTYPE이다.

 

참고로, 현재 시점(2024.11.01)에서는 JOIN 전략일 때, @DiscriminatiorColumn 애노테이션을 사용하지 않아도 된다. 아니 오히려 사용하면 WARNING 로깅이 찍힌다. 예전 방식이라고. 사실 JOIN 전략일땐 이 DTYPE이 필요가 없다. 어차피 조인해서 값을 가져올 수 있기 때문에. 그런데, SINGLE_TABLE에서는 반드시 필요하다. 당연한게 모든 필드를 하나의 테이블에 넣어서 관리하면 각 레코드가 어떤 데이터인지 DTYPE 말고는 구분할 방법이 없기에.

 

 

Album

package org.example.entity.inheritance;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("Album")
public class Album extends Item {

    private String artist;

    public Album(String name, int price, String artist) {
        super(name, price);
        this.artist = artist;
    }

    public Album() {
        super();
    }
}
  • Item 클래스를 상속받을 Album이다. 이 클래스는 @ID가 필요없다. 왜냐하면 ItemIdPK이자 FK로 설정할 것.
  • @DiscriminatorValue("Album")ItemDTYPE값으로 들어갈 Value를 지정하는 것. 기본값은 엔티티명이 된다. 즉, 위 예시에서 굳이 값을 입력할 필요는 없었지만 저렇게 변경할 수 있다는 것을 기억하기 위해 "Album"을 입력했다.

 

Book

package org.example.entity.inheritance;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("Book")
public class Book extends Item {
    private String author;
    private String isbn;

    public Book() {
        super();
    }

    public Book(String name, int price, String author, String isbn) {
        super(name, price);
        this.author = author;
        this.isbn = isbn;
    }
}

 

 

Movie

package org.example.entity.inheritance;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("Movie")
public class Movie extends Item {
    private String director;
    private String actor;

    public Movie() {
        super();
    }

    public Movie(String name, int price, String director, String actor) {
        super(name, price);
        this.director = director;
        this.actor = actor;
    }
}
  • BookMovieAlbum과 맥락이 동일하다. 각 테이블에서 필요한 필드값만 다를뿐이다.

 

Main

package org.example;

import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();
        try {
            Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
            em.persist(movie);

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

 

실행 결과

 

JOINED 전략에서 조회

Movie 데이터를 DB로부터 가져올 때 JPA는 당연하게도 조인을 사용한다. 이 방식 자체가 조인 전략이니까. 

package org.example;

import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();
        try {
            Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
            em.persist(movie);

            em.flush();
            em.clear();

            Movie findMovie = em.find(Movie.class, movie.getId());
            System.out.println("findMovie = " + findMovie.getDirector());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

 

실행 로그 및 SQL문:

Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    insert 
    into
        Item
        (name, price, DTYPE, ITEM_ID) 
    values
        (?, ?, 'Movie', ?)
Hibernate: 
    insert 
    into
        Movie
        (actor, director, ITEM_ID) 
    values
        (?, ?, ?)
Hibernate: 
    select
        movie0_.ITEM_ID as item_id2_3_0_,
        movie0_1_.name as name3_3_0_,
        movie0_1_.price as price4_3_0_,
        movie0_.actor as actor1_6_0_,
        movie0_.director as director2_6_0_ 
    from
        Movie movie0_ 
    inner join
        Item movie0_1_ 
            on movie0_.ITEM_ID=movie0_1_.ITEM_ID 
    where
        movie0_.ITEM_ID=?
findMovie = 감독

보면 알겠지만, INSERT문이 두 번 들어가고 조회할 땐 조인을 해서 가져오기 때문에 아무래도 단일 테이블 전략에 비해 성능이 저하될 수 있다. 근데 사실 뭐 INSERT문 두번 한다고 해서 성능에 크게 영향을 주거나 그러지는 않는다.

 

 

단일 테이블 전략

단일 테이블 전략은 하나의 테이블에서 모든 데이터를 다 집어넣으면 된다. 다른 게 없다. 성능적인 이점을 가져갈 순 있다. 조인도 필요 없고 INSERT문도 한 번만 하면 되니까. 그 대신 NULL값이 지저분할 순 있겠지.

 

우선 그림으로 살펴보면 다음과 같다.

정말 한 테이블에 다 넣은 것. 얘는 조인 테이블과 달리 반드시 DTYPE 칼럼이 들어가야 한다. 조인 테이블은 DTYPE 컬럼이 없어도 가능하지만 이 테이블은 그렇게 되면 안 된다. 그래서, JPA가 @DiscriminatorColumn 어노테이션을 붙이지 않더라도 알아서 SINGLE_TABLE인 경우에 DTYPE을 추가해 준다. 반드시 필요한 데이터니까.

 

이를 코드상으로 구현해 보면 딱 하나만 변경해 주면 된다.

Item

package org.example.entity.inheritance;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // 이 부분
@DiscriminatorColumn
public abstract class Item {

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

    private String name;
    private int price;

    public Item() {}

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}
  • @Inheritance의 전략을 SINGLE_TABLE로 변경하면 된다.

실행 결과

Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    insert 
    into
        Item
        (name, price, actor, director, DTYPE, ITEM_ID) 
    values
        (?, ?, ?, ?, 'Movie', ?)
Hibernate: 
    select
        movie0_.ITEM_ID as item_id2_1_0_,
        movie0_.name as name3_1_0_,
        movie0_.price as price4_1_0_,
        movie0_.actor as actor8_1_0_,
        movie0_.director as director9_1_0_ 
    from
        Item movie0_ 
    where
        movie0_.ITEM_ID=? 
        and movie0_.DTYPE='Movie'
findMovie = 감독

INSERT문 한 번에 조회 시에도 조인이 필요 없어진다.

 

 

구현 클래스마다 테이블 전략

아이템 테이블을 아예 없애고, 아이템 테이블이 가지고 있는 필드들을 각 테이블 (Movie, Book, Album)이 모두 가지는 경우를 말한다.

그림으로 보면 다음과 같다.

각 테이블이 모두 NAME, PRICE라는 칼럼을 동일하게 가지고 있고, PKITEM_ID로 가지고 있는 그림이다.

이 경우도 마찬가지로 딱 한 부분만 변경하면 된다.

Item

package org.example.entity.inheritance;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item {

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

    private String name;
    private int price;

    public Item() {}

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}
  • @Inheritance 어노테이션의 전략을 TABLE_PER_CLASS로 전략을 변경하고 실행해 보자. ITEM 테이블은 만들어지지 않는다. 

실행 결과

Hibernate: 
    
    create table Album (
       ITEM_ID bigint not null,
        name varchar(255),
        price integer not null,
        artist varchar(255),
        primary key (ITEM_ID)
    )
Hibernate: 
    
    create table Book (
       ITEM_ID bigint not null,
        name varchar(255),
        price integer not null,
        author varchar(255),
        isbn varchar(255),
        primary key (ITEM_ID)
    )
Hibernate: 
    
    create table Movie (
       ITEM_ID bigint not null,
        name varchar(255),
        price integer not null,
        actor varchar(255),
        director varchar(255),
        primary key (ITEM_ID)
    )
Hibernate: 
    insert 
    into
        Movie
        (name, price, actor, director, ITEM_ID) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    select
        movie0_.ITEM_ID as item_id1_3_0_,
        movie0_.name as name2_3_0_,
        movie0_.price as price3_3_0_,
        movie0_.actor as actor1_6_0_,
        movie0_.director as director2_6_0_ 
    from
        Movie movie0_ 
    where
        movie0_.ITEM_ID=?
findMovie = 감독

근데, 결론은 이 구현 클래스마다 테이블 전략은 사용하지 말자. 왜냐하면 비효율적이다. 우선 같은 칼럼이 다 테이블마다 들어가는 것도 그렇지만 가장 큰 문제는 ITEM으로 조회했을 때 일어나는 현상 때문이다.

 

다음 코드를 보자. Item이 부모인데 당연히 Item으로 조회가 가능할 거고, 그때 일어나는 현상을 Hibernate가 찍어주는 로그로 확인해 보자.

package org.example;

import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();
        try {
            Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
            em.persist(movie);

            em.flush();
            em.clear();

            Item item = em.find(Item.class, movie.getId());
            System.out.println("Movie item = " + item.getName());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

 

실행 결과

Hibernate: 
    insert 
    into
        Movie
        (name, price, actor, director, ITEM_ID) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    select
        item0_.ITEM_ID as item_id1_3_0_,
        item0_.name as name2_3_0_,
        item0_.price as price3_3_0_,
        item0_.author as author1_1_0_,
        item0_.isbn as isbn2_1_0_,
        item0_.artist as artist1_0_0_,
        item0_.actor as actor1_6_0_,
        item0_.director as director2_6_0_,
        item0_.clazz_ as clazz_0_ 
    from
        ( select
            ITEM_ID,
            name,
            price,
            author,
            isbn,
            null as artist,
            null as actor,
            null as director,
            1 as clazz_ 
        from
            Book 
        union
        all select
            ITEM_ID,
            name,
            price,
            null as author,
            null as isbn,
            artist,
            null as actor,
            null as director,
            2 as clazz_ 
        from
            Album 
        union
        all select
            ITEM_ID,
            name,
            price,
            null as author,
            null as isbn,
            null as artist,
            actor,
            director,
            3 as clazz_ 
        from
            Movie 
    ) item0_ 
where
    item0_.ITEM_ID=?
Movie item = 반지의 제왕

조회 시 Album, Book, Movie 이 세 가지를 다 UNION으로 묶어서 조회하는 끔찍한 일이 일어난다. 그러니까 결론은 쓰지 말자. 

 

마무리

그럼 조인 전략단일 테이블 전략 둘 중 사용하면 되는데, 각 장단점이 있다. 그때그때 필요한 더 적합한 방식을 사용하면 되는데 우선 조인 전략이 정규화된, 정석적인 방법이란 걸 알아 두자. 그러니까 기본은 조인 전략을 사용한다는 것을 전제하에 두고 시작하면 된다.

 

조인 전략

장점

  • 테이블 정규화
  • 정석적인 방식
  • 객체와 상호보완적
  • 무결성 보존의 장점 (단일 테이블과 비교해서 NULL값이 들어가지 않는다)

단점

  • 조회 시 조인을 사용하니까 단일 테이블 전략에 비해 성능 저하 가능성
  • 생성 시 INSERT문이 두 번 사용되니까 단일 테이블 전략에 비해 성능 저하 가능성

 

 

단일 테이블 전략

장점

  • 간단함
  • 조회 및 생성 시 조인을 사용하지 않고 INSERT문이 한 번으로 끝난다.

단점

  • 불필요한 칼럼들에 대한 NULL값이 생긴다. (Movie 관련 데이터를 추가하면 Album, Book 관련 데이터는 모두 NULL)
728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

2024.10.29 업데이트


 

이 전 포스팅까지는 단방향, 양방향에 대해서 알아보았는데 결론은 테이블 관점은 방향이란 게 없고 자식 테이블에 외래키가 존재하며 그 외래키를 통해 부모 테이블과 조인하여 원하는 데이터를 얻어내거나 반대로 부모 테이블은 본인의 PK를 이용해서 자식 테이블의 외래키와 조인하여 원하는 데이터를 얻을 수 있고 객체 관점은 단방향 또는 양방향 연관관계라는 게 존재하며 양방향 연관관계는 사실 단방향 두 개를 의미하며 설계할 때 양방향이 단 하나도 없어도 애플리케이션에 전혀 문제가 없다는 것. 단방향으로 설계를 끝내고 필요하면 양방향 연관관계를 추가할 것이 결론이었다.

 

이제는 연관관계를 매핑할 때 ManyToOne, OneToMany, OneToOne, ManyToMany 관계에 대해서 알아보려고 한다. 

여기서, ManyToOne(다대일), OneToMany(일대다), OneToOne(일대일), ManyToMany(다대다)는, 앞부분에서 외래키를 관리하는것으로 생각하면 된다. 그래서 다대일이면 '다'쪽에 외래키가 있고, 일대다면 '일'쪽에 외래키가 있게 객체에서 설계한다는 단어이다. DB 설계와는 완전히 상관없는 얘기고 자바와 JPA, 즉, 객체 관점에서 외래키 관리를 앞쪽에서 한다고 생각하면 된다.

 

⭐️ ManyToOne

ManyToOne은 다대일로 다음과 같은 관계를 말한다.

유저 테이블과 게시물 테이블 두 개가 있을 때, 하나의 유저는 여러 개의 포스팅을 할 수 있다. 그럼 포스트 입장에서는 다대일이 되는 것이다. 그리고 외래키를 관리하는 쪽은 포스트 쪽이 된다. 이 ⭐️테이블의 설계를 그대로 객체 관점에서도 적용할 수 있는 게 다대일이다.⭐️

 

다음 코드를 보자. 멤버와 주문의 관계는 다대일 연관관계로 적용할 수 있다. 이때 자식 테이블은 주문이 될 것이고 주문 테이블에서 부모 테이블의 기본키를 외래키로 참조한다. 그 설계를 그대로 객체에 적용한다. 

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;
    private String city;
    private String street;
    private String zipcode;

}
@Entity
public class Order {
    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

}

주문 테이블에 멤버 타입의 멤버를 참조한다. 이 @JoinColumn 어노테이션은 멤버와 조인할 때, 어떤것을 가지고 조인할 생각이냐?란 질문에 대한 답변이라고 보면 된다. 당연히 멤버의 ID로 하면 된다. 그리고 하나 더 @ManyToOne 어노테이션이 들어있다. 즉, 주문 입장에서는 다대일 관계가 된다. 그리고 위와 같은 설계를 '다대일 단방향 연관관계'라고 한다. 다대일이니까 '다'쪽에 외래키를 가지고 있고, 한쪽에서만 참조하고 있는 형태.

 

다대일 양방향 연관관계를 만드려면 ?! 다음과 같은 코드로 만들면 된다. 

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

    private String name;
    private String city;
    private String street;
    private String zipcode;

}
  • 참고로, 꽤 중요한 내용인데, 컬렉션은 위 코드(private List<Order> orders = new ArrayList<>();)처럼 필드에서 초기화 하는게 가장 좋다. 우선 여러 가지 이유가 있다.
    • null Safety
    • 하이버네이트가 엔티티를 영속화할 때, 컬렉션을 한번 자기들이 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경을 해버린다. 그래서 컬렉션 내부의 값이 변경이 됐는지 아닌지 이런 체크도 자기들 소스에서 유용하게 처리하고 어떤 정상적인 흐름을 수행할 수 있다. 그런데 만약, 영속시킨 이후에 내가 갑자기 setOrders() 같은 메서드를 호출해서 컬렉션을 초기화하거나 값을 바꾼다면? 그럼 하이버네이트가 컬렉션에 대해 지원하는 기능을 사용 못하게 된다. 이게 진짜 문제다. 
  • 그래서, 결론적으로 필드레벨에서 컬렉션을 초기화하는게 가장 깔끔하고 안전하다.
@Entity
public class Order {
    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

}

복습하자면, 다대일 양방향 연관관계는 없어도 그만인데 조회의 편리함을 위해서 작성해도 괜찮다고 했다. 테이블 관점에서는 외래키는 주문 테이블만 가지고 있다. 양방향은 연관관계의 주인을 설정해야 하고 주인은 테이블 관점에서 외래키를 가지고 있는 쪽(자식테이블, 위 예제에서는 주문 테이블)이 연관관계의 주인이 되면 된다. 주인이 아닌 쪽은 mappedBy 옵션으로 "나는 연관관계의 주인이 아니고, 주인 객체(Order)의 필드 중 `member` 라는 필드와 연관된 필드입니다." 라고 알려주면 된다.

 

 

결론을 미리 여기서 말하면, 다대일 단방향 또는 다대일 양방향으로 설계하는 것이 가장 좋고 일대다, 다대다는 그냥 사용을 하면 안 된다고 생각해라.

 

 

OneToMany

일대다는 테이블 관점에서는 존재할 수 없는 연관관계다. 일대다이므로, '일' 쪽에 외래키를 가지고 있는, 즉, 부모 테이블이 외래키를 가지고 있는 경우인데 테이블 관점에서는 존재할 수 없다. 그렇기 때문에 일대다 연관관계는 사용하지 않는 것이 좋다는 결론을 바로 위에 작성했다. 

 

우선 그림을 살펴보면 다음과 같다.

객체 관점에서는 이런 경우가 흔하다고 볼 수 있다. 팀에서만 멤버에 관심이 있고 멤버 입장에서는 팀에 관심이 없을 수 있다. 이게 객체 관점에서는 전혀 문제 될 게 없는데 테이블 관점에서는 있을 수 없다. 1:N 관계에서 언제나 N 쪽에 외래키가 포함되기 때문이다. 아래 사진이 테이블 관점에서의 ERD이다.

 

그래서 객체 관점에서는 가능한 부분이기 때문에 다음과 같이 작성할 수 있다.

package org.example.entity.mapping;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Team {

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

    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}
  • @JoinColumn 부터 애매해진다. 외래키를 가지고 있는데 조인하는 필드도 본인의 ID라니! 그렇지만, 이렇게 해야 테이블 관점에서는 동작하기 때문에 어쩔 수 없다. 
package org.example.entity.mapping;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Member {

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

    private String username;
}

이렇게 작성하면 실제로 동작은 하는데 굉장히 부자연스럽다. 왜냐하면 테이블 관점에서는 팀에서 외래키가 존재하려야 할 수 없는데 지금 객체 관점에서는 존재하게 돼버리니 말이다. 각설하고 위처럼 일대다 단방향으로 설계를 했고 실제로 코드를 돌려보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;
import org.example.entity.mapping.Member;
import org.example.entity.mapping.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setUsername("member1");
            em.persist(member);

            Team team = new Team();
            team.setName("team1");
            team.getMembers().add(member);
            
            em.persist(team);

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

이렇게 코드를 작성하고 돌려보면 문제 없이 테이블이 만들어지고 데이터가 들어갈 거다. 문제없이 잘 들어가는데 다음 SQL문을 보자.

이상하다. 왜 이상하냐면 멤버와 팀을 새로 만들고 persist() 호출을 했으니 INSERT문이 실행되는 건 맞는데 그다음이다. 난 분명 다음 코드처럼 팀의 멤버를 가져와 멤버를 추가했는데 SQL문에서 멤버의 UPDATE 쿼리가 실행된다. 

team.getMembers().add(member);

왜냐하면 테이블 관점에서는 절대 외래키를 부모가 관리하지 않기 때문에 결국 멤버를 추가해서 팀과 결합시켰으면 멤버가 속한 팀 FK를 업데이트해줘야 하는 것이다. 이게 이제 문제가 발생할 여지가 크다. 어떤 문제냐면 코드를 보기에는 팀을 가져와 작업을 했는데 쿼리문에서 멤버가 업데이트되니까 이해하기가 힘든 것이다. (설령 JPA를 아주 잘 다루더라도 헷갈릴 소지가 있다 코드가 커지고 시간이 지나면 지날수록)

 

그러니까 결론은 그냥 다대일 단방향, 양방향을 사용하자... 

일대다 양방향도 있는데 그냥 넘어가겠다.. 그걸 알 필요가 없다. 다시 한 번 말하지만 다대일 단방향, 양방향을 사용하자.

 

OneToOne

이 일대일 관계는 주 테이블이나 대상 테이블 중에 외래키를 선택해서 넣을 수 있는 관계이다. 여기서 주 테이블, 대상 테이블은 약속된 단어가 아니라 자주 사용되는 테이블을 주 테이블이라고 칭하고 그보다 덜 사용되는 테이블을 대상 테이블이라고 말한 것이다. 

다만, 외래키에 데이터베이스 유니크 제약조건을 추가해야 한다. (꼭 추가해야 하는 건 아니지만 추가를 하는 게 관리하기 훨씬 유리하다)

 

일대일은 어떤쪽으로 외래키를 넣어도 상관없다. 위 ERD처럼 MemberLocker의 일대일 관계에서 멤버에서 외래키를 관리하게 설정하면 된다. 객체 관계에서도 마찬가지로 다음 그림처럼 만들면 된다.

 

나는 주 테이블을 Member 테이블로 선정하고 (주 테이블은 더 자주 사용되는 테이블이라고 생각하면 된다) 대상 테이블을 Locker 테이블이라고 했을 때 주 테이블에 외래키를 가지는 방식으로 설정해 일대일 단방향 관계를 객체로 구성하면 다음과 같다.

package org.example.entity.mapping;


import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Locker {
    
    @Id @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
}
package org.example.entity.mapping;

import javax.persistence.*;

@Entity
public class Member {

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

    private String username;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}

 

일대일 양방향 관계는 다음처럼 현시점은 연관관계의 주인(외래키를 가지고 있는 쪽)이 Member다. 그러니까 Locker에 다음과 같이 코드를 추가한다.

package org.example.entity.mapping;


import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Locker {
    
    @Id @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    
    @OneToOne(mappedBy = "locker")
    private Member member
}

이러면 일대일 양방향 관계는 끝이 난다. 당연히 양방향이니까 연관관계의 주인을 선택해야 하고 주인이 아닌 Locker 쪽은 mappedBy로 알려주어야 한다.

 

 

이제 단방향으로 외래키가 대상 테이블에 존재하는 경우를 살펴보면 다음 그림처럼 생겼다.

이것 또한 가능한데, 한 가지 주의할 점은! 만약 객체 관점에서 주 테이블에 외래키를 가지고 있는데 테이블 관점에서 대상 테이블에 외래키가 존재하는 경우는 JPA는 지원하지 않는다. 즉, 객체 관점에서 Member 객체가 Locker를 참조하고 있는데 테이블 관점에서는 LockerMEMBER_ID를 가지고 있는 경우를 말한다. 이런 경우는 JPA에서 지원하지 않으니까 조심해야 한다.

 

그래서, 혹시라도 테이블 관점에서 대상 테이블에 외래키가 존재할 때, 객체 관점에서 주 객체(테이블)에 대상 객체(테이블)를 참조하고 싶으면 무조건 일대일 양방향으로 설계해야 한다. 이 방법뿐이 없다. 

 

양방향은 위 주 테이블이 Member일 때 했던 것처럼 반대로 해주면 된다.

 

일대일 단방향, 양방향은 그냥 주 테이블 또는 대상 테이블 둘 중 하나에 외래키가 들어가면 끝나는 건데 여기서 좀 짚고 넘어갈 부분이 있다. 

 

⭐️ 일대일 양방향 연관관계에서 외래키가 대상 테이블 일 때는 지연 로딩처리가 불가능하다.

 

이를 이해하기 위해 주 테이블에 외래키가 있을 때를 살펴보자.   

주 테이블에 외래키를 보관하는 설계를 했을 때(양방향, 단방향 상관없음) 멤버를 조회하면 LOCKER값은 NULL 또는 특정 값이 존재할 거다. 멤버 테이블에 LOKCER_ID가 있고 멤버가 LOCKER가 할당된 상태라면 NULL값이 아니겠고 할당되지 않은 상태라면 NULL일 테니까. 그럼 할당된 상태일 때 지연 로딩이면 그대로 Lazy Fetch를 이행하면 되고 NULL이면 값이 없으니까 그냥 NULL을 반환하면 된다. 

 

근데 대상 테이블에 외래키가 있는 일대일 양방향 연관관계는 그게 불가능하다. 무슨 소리냐면 만약 아래 그림처럼 대상 테이블에 외래키가 있을 때를 살펴보자.

 

만약 위처럼 대상 테이블에 외래키가 있을 때 멤버를 조회했다고 가정해 보자. 멤버만 조회했을 때 바로 LOCKER와 연결된 상태인지 알 수 있을까? 없다. 멤버 테이블을 봐라. 멤버 테이블엔 LOCKER 관련 외래키가 없다. 그래서 JPA는 결국 LOCKER 테이블을 조회해서 현재 조회한 멤버의 PK와 동일한 멤버 ID가 있는지 확인을 해야 한다. 즉, 어차피 LOCKER를 조회하기 때문에 지연 로딩을 해봤자 아무런 이점을 가질 수 없단 소리다. 그래서 지연 로딩이 의미가 없고 지연 로딩으로 적용을 해도 바로 데이터를 받아온다. 

 

그래서 만약 일대일 관계를 사용하는 경우엔 주 테이블에 외래키를 보관하는 게 데이터베이스 관점 말고 개발자 관점에서 좀 더 유리할 수 있다. 근데 이건 객체 관점이고, 데이터베이스 관점에서는 조금 다르다. 예를 들어, 멤버 테이블에 외래키를 가지도록 테이블 관점에서 설계를 최초에는 했는데, 이후에 갑자기 일대일이 아니라 "멤버가 여러 라커를 가질 수 있다"라는 비즈니스 규칙이 변경되면, 그땐 데이터베이스의 테이블을 수정해야 하는데 그게 만만치가 않기 때문에 (왜냐하면 테이블 관점에서는 1:N에서 N쪽에 외래키가 반드시 있게 되는데 최초 설계는 멤버에 외래키가 있는 상태이니까) DBA는 이렇게 멤버에 외래키가 있는게 싫을 수도 있다. 그래서 이건 서로간의 충분한 협의가 필요하다. 근데 그냥 멤버에 외래키를 제안하고 DBA가 싫다고 하면 그냥 싸우지말고 말 들어주자. 객체입장에서 일대일 양방향으로 만들면 되는데 데 싸우는 시간이 아깝다.

 

ManyToMany

이건 쓰면 안 된다. 그냥 이게 결론이다. 근데 왜 쓰면 안 되는지를 알아보자. 

우선 데이터베이스에서 다대다 관계는 없다. 즉, 다대다 관계를 두 개의 테이블이 하나의 새로운 테이블(그 두 테이블을 연결해 주는)과 1:N 관계로 만들어야 한다. 아래 그림처럼 말이다.

근데 이게 왜 안되냐면, JPA에서 중간 테이블에 필요한 데이터가 들어가질 않는다. 예를 들어 위 상황에서 주문 수량이나 주문 일자 같은 데이터가 필요할 수 있는 가능성이 농후한데 그 데이터를 연결 테이블이 가질 수 없다. 아무것도 넣지 못한다. 각 테이블의 외래키 말고는.

 

그래서 다대다를 해결하는 방법은 연결 테이블을 새로운 엔티티로 승격시켜 사용하는 것. 다음 그림처럼 말이다.

그래서 위처럼 연결 테이블(MemberProduct)을 엔티티(ORDER)로 승격시켜 다대다를 일대다, 다대일로 풀어라.

그리고, 데이터베이스에서는 이렇게 풀 때, 중간 테이블의 PK를 양 옆 두 테이블의 FK를 하나의 PK로 만드는 경우가 종종 있는데 그러지 말고 그냥 이 테이블만의 고유 PK(ORDER_ID)를 만드는게 운영면에서 훨씬 유리하다. 

 

 

그럼 예시를 들어보자. 자주 사용되는 Follower, Following 같은 경우는 어떻게 해야 할까? 느낌은 ManyToMany인데 그렇게 사용하지 않기로 했다. 이 역시 마찬가지로 엔티티 하나를 승격시켜서 사용하면 된다. 다만, 여기서는 OneToMany, ManyToOne으로 풀 게 아니다. 왜냐하면 테이블이 두 개가 아니라 같은 멤버 테이블 하나일 테니까. 그래서 OneToMany, OneToMany로 풀면 된다.

 

이런 형태로 만들 수 있겠다. 코드를 작성해 보자.

package org.example.entity.mapping;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Member {

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

    private String username;

    @OneToMany(mappedBy = "from")
    private List<Followers> following = new ArrayList<>();

    @OneToMany(mappedBy = "to")
    private List<Followers> followers = new ArrayList<>();
}
package org.example.entity.mapping;

import javax.persistence.*;

@Entity
public class Followers {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "FOLLOW_ID")
    private Member from;

    @ManyToOne
    @JoinColumn(name = "FOLLOWER_ID")
    private Member to;
}

 

⭐️팔로워라는 다대다 테이블을 엔티티로 승격시킨 후, from, to 유저를 @ManyToOne으로 선언한다. 이제 이 외래키를 관리하는 쪽이 연관관계의 주인이 된다. 그럼 반대쪽 멤버 테이블에서는 @OneToManyfollowing(팔로우 하는 사람), followers(나를 팔로우하는 사람) 읽기 전용(mappedBy)으로 선언하면 된다. mappedBy는 반드시 이 애노테이션이 달린 필드로 선언된 엔티티의 실제 필드 이름을 가져야 하고, @JoinColumnname은 의미있는 이름을 직접 만들어주어도 괜찮다(FK를 다른 이름으로 만들어도 상관없으니까).⭐️

 

 

실전 예제

그러면, 지금까지 배운 모든 내용을 토대로 한번 엔티티 설계를 해보자. ERD는 다음과 같다.

 

논리적 설계는 이렇게 생겼다.

 

엔티티의 상세 내용은 다음과 같다.

 

이 모양을 토대로, 엔티티를 JPA를 활용해서 만들어보자. 참고로, 저기서 다대다도 있다. 다대다는 당연히 사용하면 안된다! 그런데, JPA 스펙에서 지원은 하는 내용이니까 이번 실전 예제에서만 한번만 다뤄보기로 하자.

 

Member

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Member {

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

    private String name;
    private String city;
    private String street;
    private String zipCode;
    
    @OneToMany(mappedBy = "member")
    private List<Orders> orders = new ArrayList<>();
}

 

Orders

package cwchoiit.relationship;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class Orders {

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

    private LocalDateTime orderDate;
    private Status status;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @OneToOne
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery;
}

 

Delivery

package cwchoiit.relationship;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Delivery {

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

    private String city;
    private String street;
    private String zipCode;
    private Status status;
}

 

OrderItem

package cwchoiit.relationship;

import javax.persistence.*;

@Entity
public class OrderItem {

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

    private Integer orderPrice;
    private Integer count;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    @ManyToOne
    @JoinColumn(name = "ORDER_ID")
    private Orders orders;
}

 

Item

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Item {

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

    private String name;
    private Integer price;
    private Integer stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();
}

 

Category

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Category {

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

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();

    @ManyToMany
    @JoinTable(name = "CATEGORY_ITEM",
            joinColumns = @JoinColumn(name = "CATEGORY_ID"), inverseJoinColumns = @JoinColumn(name = "ITEM_ID"))
    private List<Item> items = new ArrayList<>();
}

 

 

이렇게 만들면 된다. 재밌는 부분은 Category의 경우, 카테고리는 상위 카테고리 라는게 존재하는게 일반적이다. 그래서 parentchild가 모두 자기 자신의 타입으로 되어 있고, 각자 카테고리가 부모와 자식 카테고리를 가질테니 parent, child가 있다. 

 

그리고, @ManyToMany는 사용하면 안된다. 그러나 한번만 해보기위해서 이렇게 사용했다. 다대다는 @JoinTable 애노테이션을 사용해서 중간 테이블이 하나 생성된다고 했다. 그리고 그 테이블은 다른 필드 하나 없이 양 옆 테이블의 PK만을 가질 수 있다. 그래서 joinColumns, inverseJoinColumns의 값으로 CategoryItem 두 PK를 선언한다.

 

 

이렇게 해보면 되는데 역시 다대다는 사용하면 안된다고 했으니 이를 중간 테이블을 엔티티로 승격시켜서 다대일, 일대다로 풀어보자.

 

Category

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Category {

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

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Category parent;

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

 

CategoryItem

package cwchoiit.relationship;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class CategoryItem {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "CATEGORY_ID")
    private Category category;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    private LocalDateTime created;
}

 

Item

package cwchoiit.relationship;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Item {

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

    private String name;
    private Integer price;
    private Integer stockQuantity;
}

 

이렇듯, ItemCategory 사이에 있는 테이블을 엔티티로 승격시킨 CategoryItem이 존재하게 되고, 이 엔티티가 ItemCategory와 다대일, 일대다 관계를 가지도록 변경하면 된다. 원한다면 양방향으로 만들어도 상관없다. 

728x90
반응형
LIST

+ Recent posts