연관관계 매핑 기초

hyyyynjn·2021년 8월 31일
1
post-thumbnail

목표

  • 객체테이블 연관관계의 차이를 이해
  • 객체의 참조테이블의 외래 키를 매핑
  • 용어 이해
    • 방향(Direction) : 단방향, 양방향
    • 다중성(Multiplicity) : 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M) 이해
    • 연관관계의 주인(Owner) : 객체 양방향 연관관계는 관리 주인이 필요 (제일 어렵)

✅ 연관관계가 필요한 이유

📢예제 시나리오

  • 회원과 팀이 있다.
  • 회원은 하나의 팀에만 소속될 수 있다.
  • 회원과 팀은 다대일 관계다

📌객체를 테이블에 맞추어 모델링

객체를 테이블에 맞추어 모델링하면 연관관계가 없는 객체가 된다.

  • Member 테이블의 외래키 TEAM_ID 를 그대로 모델링
  • 📢참조 대신에 외래 키를 그대로 사용

TEAM객체를 참조하는게 아니라 그냥 외래키 값 TEAM_ID을 그대로 사용한다.

  • 📢외래 키 식별자를 직접 다룸

member.setTeamId(team.getId()); : 왜래키 식별자를 직접 다룬다.

  • 📢식별자로 다시 조회

연관관계가 없기 때문에 식별자로 다시 조회해야한다. 👉 객체지향적인 방법이 아니다

📌객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다

  • 테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾는다.
  • 객체는 참조를 사용해서 연관된 객체를 찾는다.
  • 테이블과 객체 사이에는 이런 큰 간격이 있다.

✅ 단방향 연관관계

  • 📢객체 지향 모델링

TEAM_ID가 아닌 TEAM 이라는 참조값을 사용한다. (객체 연관관계를 사용)

📌단방향 매핑

  • 📢객체의 참조와 테이블의 외래 키를 매핑
@Entity
public class Member {

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

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

//    @Column(name = "TEAM_ID")
//    private Long teamId;

    @ManyToOne // member : team = n : 1 (다대일 관계)
    @JoinColumn(name = "TEAM_ID") // 객체 연관관계 team 참조와 테이블 연관관계 TEAM_ID 외래키와 매핑
    private Team team;}
  • @ManyToOne : member : team = n : 1 (다대일 관계)
  • @JoinColumn(name = "TEAM_ID") : 객체 연관관계 team 참조와 테이블 연관관계 TEAM_ID 외래키와 매핑
    👉 ORM 매핑이 완성됨

  • 📢연관관계 저장

JPA가 team에서 pk값을 추출하여 member의 fk로 설정한다.

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            //팀 저장
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            //회원 저장
            Member member = new Member();
            member.setName("member1");
            member.setTeam(team);
            em.persist(member);

            // findMember를 영속성 컨택스트의 값이 아닌 DB의 값으로 채우기 위해서
            // flush로 DB에 쿼리를 날리고
            // clear로 영속성 컨택스트를 깨끗이 초기화한다
            System.out.println("em.flush()");
            em.flush();
            em.clear();

            //조회
            System.out.println("참조를 사용해서 연관관계 조회");
            Member findMember = em.find(Member.class, member.getId());

            //참조를 사용해서 연관관계 조회
            Team findTeam = findMember.getTeam();
            System.out.println("findTeam = " + findTeam.getName());

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

        emf.close();
    }
}

member.setTeam(team);

  • memberteam 참조를 저장하면 JPA는 데이터베이스의 MEMBER 테이블의 fk값으로 TEAM 테이블의 pk을 설정한다.

  • em.persist(member); : member를 영속성 컨택스트의 1차 캐시에 저장
  • em.find(Member.class, member.getId()); : 영속성 컨택스트의 1차 캐시에서 member 객체를 가져온다
    • 1차 캐시가 아닌 데이터베이스에서 가져오고 싶다면 em.flush(); em.clear();를 해준다.
    • 로그를 보면 member를 조회할 때 member, team을 한번에 조인해서 가져온다는 것을 알 수 있다.
  • findMember.getTeam(); : 참조로 연관관계를 조회할 수 있다 👉 객체 그래프 탐색을 활용할 수 있다.
  • 📢연관관계 수정

연관관계를 수정할 수 있다.


✅양방향 연관관계와 연관관계의 주인 (중요)

  • 테이블 연관관계
    • 단방향 테이블 연관관계양방향 테이블 연관관계의 차이가 없다.
    • 그냥 두 테이블에서 pk, fk를 활용하여 조인하면 된다.
      👉 테이블은 외래키 하나만으로 양방향 연관관계가 이뤄진다.
  • 객체 연관관계
    • 단방향 객체 연관관계
      • Member에서는 Team team으로 team 참조가 가능하다.
      • Team에서 Member로 참조할 수 있는 길이 없다.
    • 양방향 객체 연관관계
      • Team의 List members를 통해 Member로 참조한다.

📌양방향 매핑

  • 📢Member 엔티티는 단방향과 동일하다
@Entity
public class Member {

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

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

    @ManyToOne // member : team = n : 1 (다대일 관계)
    @JoinColumn(name = "TEAM_ID") // 객체 연관관계 team 참조와 테이블 연관관계 TEAM_ID 외래키와 매핑
    private Team team;}
  • 📢Team 엔티티는 컬렉션 추가한다
@Entity
public class Team {

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

    private String name;

    @OneToMany(mappedBy = "team")
    // member : team = n : 1 이므로 OneToMany
    // (mappedBy = "team") : Member 클래스의 team이라는 변수와 매핑
    private List<Member> members = new ArrayList<>();

}

Team은 List<Member> 컬랙션을 추가해주어 양방향 매핑을 한다.
👉 이제 Team에서 Member로 참조할 수 있다.

  • @OneToMany(mappedBy = "team")
    • member : team = n : 1 이므로 @OneToMany
    • Member 클래스의 team이라는 변수와 매핑하기 위해 mappedBy = "team"
  • 📢반대 방향으로 객체 그래프 탐색
Member findMember = em.find(Member.class, member.getId());
List<Member> members = findMember.getTeam().getMembers();

//역방향 조회
for (Member m : members) {
    System.out.println("m.getUserName = " + m.getUserName());
}

Team findTeam = em.find(Team.class, team.getId());
//역방향 조회
int memberSize = findTeam.getMembers().size(); 
System.out.println("memberSize = " + memberSize);

📌연관관계의 주인과 mappedBy

객체와 테이블간에 연관관계를 맺는 차이를 이해해야 한다.

  • 📢객체와 테이블이 관계를 맺는 차이점

  • 객체 연관관계 = 2개
    • 회원 -> 팀 연관관계 1개(단방향 Team team)
    • 팀 -> 회원 연관관계 1개(단방향 List<Member> members)
      👉 단방향 연관관계가 사실상 2개 있다 (억지로 양방향이라고 일컬을 뿐)
  • 테이블 연관관계 = 1개
    • 회원 <-> 팀의 연관관계 1개(양방향 TEAM_ID (pk), TEAM_ID (fk))
  • 📢객체의 양방향 관계
  • 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단뱡향 관계 2개다.
  • 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.
  • 📢테이블의 양방향 연관관계
  • 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다.
  • MEMBER.TEAM_ID 외래 키 하나로 양방향 연관관계 가진다. (양쪽으로 조인할 수 있다.)
  • 📢둘 중 하나로 외래 키를 관리해야 한다

멤버를 바꾸거나 새로운 팀에 들어가려 할 때, MEMBER 테이블의 TEAM_ID fk는 언제 업데이트 해야할까❓

  • Member 객체의 Team team을 변경했을 때?
  • Team 객체의 List members를 변경했을 때?

👉 Member 또는 Team 객체 중 하나(연관관계 주인)만으로 외래키 TEAM_ID fk를 관리해야한다.

  • 📢연관관계의 주인(Owner)

양방향 매핑 규칙

  • 객체의 두 관계중 하나를 연관관계의 주인으로 지정한다.
    • Member 속 Team team 또는 Team 속 List members 중 하나를 지정
  • 연관관계의 주인만이 외래 키를 관리(등록, 수정) 할 수 있다.
  • 주인이 아닌쪽은 읽기만 가능하다 (엄청 중요)
  • 주인은 mappedBy 속성 사용하면 안된다.
  • 주인이 아니면 mappedBy 속성으로 주인 지정한다.
  • 📢그럼 누구를 연관관계 주인으로 설정해야할까❓

외래 키가 있는 있는 곳을 주인으로 정해라 (갓영한님의 추천)

  • 여기서는 Member 속 Team team이 연관관계의 주인 (진짜 매핑 : 외래키를 등록, 수정할 수 있다)
  • Team 속 List members는 연관관계의 하인 (가짜 매핑 : 읽기만 가능하다)

왜 왜래키가 있는 쪽을 주인으로 설정할까 ❓

  • 만약 왜래키로 참조받는 쪽을 주인으로 설정한다면,
    • Team 객체의 List members 를 update했는데 MEMBER 테이블로 쿼리가 날아간다면 👉 벌써부터 헷갈리기 시작
  • 데이터베이스 입장에서 외래키가 있는 테이블이 N, 아닌 테이블이 1이다
    • 이말은 즉, 데이터베이스 N 테이블에 매핑된 객체(Member)가 객체 연관관계에서 주인이 되어야 한다.
      • Member 객체 : N 👉 @ManyToOne
      • Team 객체 : 1 👉 @OneToMany

📌양방향 매핑시 가장 많이 하는 실수

public class JpaMain {
    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("TeamA");
            // team의 List members에 member를 집어넣음
            // INSERT 쿼리는 나간다. 
            team.getMembers().add(member);
            em.persist(team);

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

        emf.close();
    }
}

team.getMembers().add(member);

  • MEMBER 테이블의 TEAM_ID fk는 null로 채워진다.
    • 연관관계의 주인은 Member의 Team team인데, Team의 List members로 add했기 때문이다.

위 코드를 고쳐보자

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {

            //팀 저장
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);
            
            //회원 저장
            Member member = new Member();
            member.setUserName("member1");
            // 연관관계의 주인인 member로 team을 세팅한다.
            member.setTeam(team);
            em.persist(member);

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

member.setTeam(team); 이게 핵심이다

  • 📢순수한 객체관계를 고려한다면 항상 양쪽에 값을 입력해야 한다

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {

            //팀 저장
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            //회원 저장
            Member member = new Member();
            member.setUserName("member1");
            // 연관관계의 주인인 member로 team을 세팅한다.
            member.setTeam(team);
            em.persist(member);

            System.out.println("em.flush, em.clear");
            em.flush();
            em.clear();

            System.out.println("em.find(Team.class, team.getId());");
            Team findTeam = em.find(Team.class, team.getId());
            System.out.println("findTeam.getMembers();");
            List<Member> members = findTeam.getMembers();

            for (Member m : members) {
                System.out.println("m.getUserName() = " + m.getUserName());
            }

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

        emf.close();
    }
}

em.find(Team.class, team.getId()); 시점에 select 쿼리가 나가고
findTeam.getMembers(); 시점에서도 select 쿼리가 나간다

  • JPA에서 List members 를 사용하는 시점에 select 쿼리를 날린다.
    👉 team.getMembers().add(member); 를 안해줘도 List members에 값이 세팅된다.
    👏그런데, team.getMembers().add(member);를 안하면 뭔가 객체지향스럽지 않고 문제점이 있다.

📌양방향 연관관계 주의

  • 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
    • member.setTeam(team);
    • team.getMembers().add(member);
  • 연관관계 편의 메소드를 생성하자
  • 양방향 매핑시에 무한 루프를 조심하자
    • 예: toString(), lombok, JSON 생성 라이브러리
  • ✍순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {

            //팀 저장
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            //회원 저장
            Member member = new Member();
            member.setUserName("member1");
            // 연관관계의 주인인 member로 team을 세팅한다.
            member.setTeam(team);
            em.persist(member);

//            team.getMembers().add(member);
//            System.out.println("em.flush, em.clear");
//            em.flush();
//            em.clear();

            System.out.println("em.find(Team.class, team.getId());");
            Team findTeam = em.find(Team.class, team.getId());
            System.out.println("findTeam.getMembers();");
            List<Member> members = findTeam.getMembers();

            System.out.println("========");
            for (Member m : members) {
                System.out.println("m.getUserName() = " + m.getUserName());
            }
            System.out.println("========");

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

        emf.close();
    }
}


em.flush(); em.clear(); 하지 않으면 문제가 생긴다.

Member와 Team의 데이터가 영속성 컨택스트의 1차 캐시에만 저장된 상태이다.

  • Team findTeam = em.find(Team.class, team.getId()); 은 1차 캐시에 있는 데이터를 가져온다.
    • 데이터베이스가 아닌 메모리에있는 데이터만으로 조회한다. (select 쿼리가 나가지 않는다)

team.getMembers().add(member);를 해주면

select 쿼리는 나가지 않지만 값을 조회할 수 있다.

  • 테스트 케이스 작성할 경우에도 member.setTeam(team);, team.getMembers().add(member); 처럼 양쪽에 값을 세팅해줘야 한다.
  • ✍연관관계 편의 메소드를 생성하자
public void setTeam(Team team) {
    this.team = team; // member에 team을 세팅
    team.getMembers().add(this); // team에 this(member)를 세팅
}

Member 클래스의 setTeam 메소드 안에 team.getMembers().add(this);를 넣어주자

  • ✍양방향 매핑시 무한루프를 조심하자

toString(), lombok, JSON 생성 라이브러리에서 문제가 발생한다.

  • JSON 생성 라이브러리의 경우 스프링의 Controller에서 Response를 보낼 때,
    엔티티 클래스를 사용하면 연관관계에 있는 클래스를 무한 참조하기 때문이다. (그래서 엔티티를 Dto로 변환하여 사용)
@Entity
public class Member {

...
    @ManyToOne // member : team = n : 1 (다대일 관계)
    @JoinColumn(name = "TEAM_ID") // 객체 연관관계 team 참조와 테이블 연관관계 TEAM_ID 외래키와 매핑
    private Team team;
...
    @Override
    public String toString() {
        return "Member{" +
                "id=" + id +
                ", userName='" + userName + '\'' +
                ", team=" + team +
                '}';
    }
}

@Entity
public class Team {

...
    @OneToMany(mappedBy = "team")
    // member : team = n : 1 이므로 OneToMany
    // (mappedBy = "team") : Member 클래스의 team이라는 변수와 매핑
    private List<Member> members = new ArrayList<>();
...
    @Override
    public String toString() {
        return "Team{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", members=" + members +
                '}';
    }
}

이제 메인함수에서 System.out.println("findTeam="+findTeam); 를 실행하면

TeamtoString()members를 호출하면
👉 MembertoSring()team을 호출하면서 무한루프에 빠져 StackOverflowError 발생


📌양방향 매핑 정리

  • 👏JPA에서의 설계에서 단방향 매핑만으로도 이미 연관관계 매핑은 완료된다
    • 처음 설계할 경우, 단방향 매핑으로만 한다.
    • 반대쪽으로 조회가 필요할 경우에만 양방향을 추가한다.
  • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐이다
  • JPQL에서 역방향으로 탐색할 일이 많다
  • 👏단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 된다 (테이블에 영향을 주지 않음)
  • 연관관계의 주인을 정할 때에는
    • 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안된다
    • 👏연관관계의 주인은 외래 키의 위치를 기준으로 정해야한다

즉, N:1관계에서 N에 해당하는 객체에 연관관계를 단방향으로 모두 설정한 뒤에
개발하면서 필요할 때 마다(반대 방향으로의 조회) 양방향 매핑을 추가해주면 된다.

0개의 댓글