코드 그라데이션

양방향 연관관계와 연관관계의 주인 본문

Spring/JPA 공부

양방향 연관관계와 연관관계의 주인

완벽한 장면 2023. 8. 19. 18:10

객체와 테이블의 패러다임의 차이를 잘 이해해야 한다.

 

양방향 매핑

 

• 테이블은 단방향일 때와 동일하다(변화 x)

왜???

테이블은 그냥 TEAM_ID(FK)로 join만 하면 됨

=> 테이블의 연관관계는 FK 하나로 양방향이 다 있는 것.

     (FK만 집어넣으면 다 알 수가 있으므로, 더 정확하게 말하면 방향이랄 게 없다.)

 

• 문제는 객체다.

Member가 Team을 가졌으므로 member -> team은 가능했으나, team -> member는 방법이 없었다.

그래서 Team에다가 List : member를 넣어줘야 양쪽으로 이동이 가능하다.

 

즉, 객체는양쪽에 다 세팅이 필요했는데, 

테이블은 외래키 하나만 넣어주면 양쪽 다 볼 수(이동) 있다는 차이가 있다.

 

 

양방향 매핑 (2) Member 엔티티는 단방향과 동일

Member

 

양방향 매핑 (3) Team 엔티티는 컬렉션 추가

Team

 

양방향 매핑 (4) 반대 방향으로 객체 그래프 탐색

 

전체 코드

JPAMain

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.setTeamId(team.getId()); // member.setTeam(); 이 아니다.
      member.setTeam(team);
      // 이러면 Jpa가 팀에서 알아서 pk값을 꺼내서 Insert할 때 FK 값으로 사용한다.

      em.persist(member);

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

      Member findMember = em.find(Member.class, member.getId());
      List<Member> members = findMember.getTeam().getMembers();

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

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

 

 

Member

 @Id
  @GeneratedValue(strategy = GenerationType.AUTO)
  @Column(name = "MEMBER_ID")
  private Long id;

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

  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;
  //Member 입장에서는 여러 멤버가 하나의 팀에 소속될 수 있으므로.
  // 그리고 객체에 있는 Team team;레퍼런스랑. Member 테이블의 TEAM_ID(FK)랑 매핑을 해야.


  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;
  }
}

 

Team

@Entity
public class Team {

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

  private String name;

// 추가
  @OneToMany(mappedBy = "team")
  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;
  }
}

 

 

조회가 모두 가능해진다.

 


연관관계의 주인과 mappedBy

 

 

객체와 테이블이 관계를 맺는 차이 (출발점)

 

그림으로 비교

 

 

객체의 양방향 관계

• 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단뱡향 관계 2개다.
• 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.

예시

 

테이블의 양방향 관계

 

 

 

 

외래키 업데이트에 대한 고민의 결과물

=> 둘 중 하나로 외래 키를 관리해야 한다.

 

 

연관관계의 주인

- 양방향 매핑에서 등장한 개념

 

 

누구를 주인으로?

외래 키가 있는 곳을 주인으로 정해라

  • 이렇게 하면 왠만한 고민은 다 해결. DB 입장에서는 외래키 있는 곳이 N // 없는 곳이 무조건 1
  • 즉, DB의 N쪽이 무조건 연관관계의 주인이 된다.(이 설계가 깔끔함.

• 여기서는 Member.team이 연관관계의 주인

  • 그러니까 여기서는 Member에 있는 Team team이 주인이 될 지, Team에 있는 List members가 주인이 될 지를 정해야 한다는 말이었음.


 

양방향 매핑 시 가장 많이 하는 실수 (연관관계 주인에 값을 입력하지 않음)

이걸 수정해서

양방향 매핑시 연관관계의 주인에 값을 입력해야 한다.
(순수한 객체 관계를 고려하면 항상 양쪽 다 값을 입력해야 한다.)

 

양방향 연관관계 주의 - 실습

• 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
• 연관관계 편의 메소드를 생성하자
• 양방향 매핑 시에 무한 루프를 조심하자
   예: toString(), lombok, JSON 생성 라이브러리

 

실습

JpaMain

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.getMembers().add(member); // 여기
      em.persist(team);

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

이렇게 놓고 실행해보면

14:18:20.668 [main] DEBUG org.hibernate.SQL - 
    /* insert inflearn.exjpa.jpaExample.Member
        */ insert 
        into
            Member
            (TEAM_ID, USERNAME, MEMBER_ID) 
        values
            (?, ?, ?)
Hibernate: 
    /* insert inflearn.exjpa.jpaExample.Member
        */ insert 
        into
            Member
            (TEAM_ID, USERNAME, MEMBER_ID) 
        values
            (?, ?, ?)
14:18:20.671 [main] DEBUG org.hibernate.SQL - 
    /* insert inflearn.exjpa.jpaExample.Team
        */ insert 
        into
            Team
            (name, TEAM_ID) 
        values
            (?, ?)
Hibernate: 
    /* insert inflearn.exjpa.jpaExample.Team
        */ insert 
        into
            Team
            (name, TEAM_ID) 
        values
            (?, ?)

INSERT 쿼리는 분명 두 번이 나왔다.

그런데 콘솔에 확인해보면

 

TEAM_ID 가 null 값이 조회가 되고 있다.

왜일까?

연관관계 주인은 Member에 있는 team 이다.

Team에 있는 members는 JPA에서 update 할 때나 insert 할 때는 아예 쳐다보질 않는다.

그래서 team에 값을 세팅해줘야 한다는 이야기.

-> 연관관계의 주인에만 값을 넣어보고, 연관관계 주인이 아닌 곳에는값을 넣지 않는다는 뜻임.

순서도 값을 넣기 위해 살짝 바꿨음

 

JPAMain

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.setTeam(team);
      em.persist(member);

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

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

 

실행 결과

14:54:57.734 [main] DEBUG org.hibernate.SQL - 
    call next value for hibernate_sequence
Hibernate: 
    call next value for hibernate_sequence
14:54:57.737 [main] DEBUG org.hibernate.id.enhanced.SequenceStructure - Sequence value obtained: 1
14:54:57.738 [main] DEBUG org.hibernate.resource.jdbc.internal.ResourceRegistryStandardImpl - HHH000387: ResultSet's statement was not registered
14:54:57.739 [main] DEBUG org.hibernate.event.internal.AbstractSaveEventListener - Generated identifier: 1, using strategy: org.hibernate.id.enhanced.SequenceStyleGenerator
14:54:57.752 [main] DEBUG org.hibernate.SQL - 
    call next value for hibernate_sequence
Hibernate: 
    call next value for hibernate_sequence
14:54:57.753 [main] DEBUG org.hibernate.id.enhanced.SequenceStructure - Sequence value obtained: 2
14:54:57.753 [main] DEBUG org.hibernate.resource.jdbc.internal.ResourceRegistryStandardImpl - HHH000387: ResultSet's statement was not registered
14:54:57.753 [main] DEBUG org.hibernate.event.internal.AbstractSaveEventListener - Generated identifier: 2, using strategy: org.hibernate.id.enhanced.SequenceStyleGenerator
14:54:57.754 [main] DEBUG org.hibernate.event.internal.AbstractFlushingEventListener - Processing flush-time cascades
14:54:57.754 [main] DEBUG org.hibernate.event.internal.AbstractFlushingEventListener - Dirty checking collections
14:54:57.757 [main] DEBUG org.hibernate.engine.internal.Collections - Collection found: [inflearn.exjpa.jpaExample.Team.members#1], was: [<unreferenced>] (initialized)
14:54:57.761 [main] DEBUG org.hibernate.event.internal.AbstractFlushingEventListener - Flushed: 2 insertions, 0 updates, 0 deletions to 2 objects
14:54:57.761 [main] DEBUG org.hibernate.event.internal.AbstractFlushingEventListener - Flushed: 1 (re)creations, 0 updates, 0 removals to 1 collections
14:54:57.762 [main] DEBUG org.hibernate.internal.util.EntityPrinter - Listing entities:
14:54:57.762 [main] DEBUG org.hibernate.internal.util.EntityPrinter - inflearn.exjpa.jpaExample.Team{members=[], name=TeamA, id=1}
14:54:57.762 [main] DEBUG org.hibernate.internal.util.EntityPrinter - inflearn.exjpa.jpaExample.Member{id=2, team=inflearn.exjpa.jpaExample.Team#1, username=member1}
14:54:57.766 [main] DEBUG org.hibernate.SQL - 
    /* insert inflearn.exjpa.jpaExample.Team
        */ insert 
        into
            Team
            (name, TEAM_ID) 
        values
            (?, ?)
Hibernate: 
    /* insert inflearn.exjpa.jpaExample.Team
        */ insert 
        into
            Team
            (name, TEAM_ID) 
        values
            (?, ?)
14:54:57.769 [main] DEBUG org.hibernate.SQL - 
    /* insert inflearn.exjpa.jpaExample.Member
        */ insert 
        into
            Member
            (TEAM_ID, USERNAME, MEMBER_ID) 
        values
            (?, ?, ?)
Hibernate: 
    /* insert inflearn.exjpa.jpaExample.Member
        */ insert 
        into
            Member
            (TEAM_ID, USERNAME, MEMBER_ID) 
        values
            (?, ?, ?)

 

콘솔 확인하면

잘 들어가 있음을 확인 가능

 


그런데 양쪽에 값을 세팅해주는 게 더 안전하다.

 

일단

JPAMain

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.setTeam(team);
      em.persist(member);

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

      // 이걸 추가하면
      Team findTeam = em.find(Team.class, team.getId());
      List<Member> members = findTeam.getMembers();
      for (Member m : members) {
        System.out.println("m = " + m.getUsername());
      }
      // 이렇게 해도(현재 리스트에 값을 세팅한 게 없어도) 값이 출력이 된다.
      // jpa에서 select 쿼리를멤버를 조회하면서 한번 더 보냄.

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

      // 이렇게 해도(현재 리스트에 값을 세팅한 게 없어도) 값이 출력이 된다.
      // jpa에서 select 쿼리를멤버를 조회하면서 한번 더 보냄.

이거에 대해 확인하면

 

Team findTeam = em.find(Team.class, team.getId());

이 라인에 대한 쿼리는

Hibernate: 
    select
        team0_.TEAM_ID as team_id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?

이게 나가고

 

List<Member> members = findTeam.getMembers(); 

여기서의 쿼리도 한 번 나감.

Hibernate: 
    select
        members0_.TEAM_ID as team_id3_0_0_,
        members0_.MEMBER_ID as member_i1_0_0_,
        members0_.MEMBER_ID as member_i1_0_1_,
        members0_.TEAM_ID as team_id3_0_1_,
        members0_.USERNAME as username2_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?

 

 

JPA는

members의 데이터를 긁어와서 실제 사용하는 시점에 쿼리 한 번 날린다.

쿼리 나갈 때

members0_.TEAM_ID=? 

FK로 이미 다 연결이 되어 있기 때문에

이렇게 FK에 있던 값으로 세팅을 해서 자기와 연관된 값을 가지고 온다.

그래서 team.getMembers().add(member); 하지 않아도.JPA를 통해 값을 출력을 해도 값이 출력된다.

 

그런데  team.getMembers().add(member);을 안 해주면 좀 이상한 느낌.

뭔가 객체지향스럽지가 않다.

 

두 군데서 문제가 된다.

만약에

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.setTeam(team);
      em.persist(member);

//      team.getMembers().add(member);

// 요기
//      em.flush();
//      em.clear();

      // 더 안전하게, 양쪽에 다 값을 세팅
      Team findTeam = em.find(Team.class, team.getId()); // 1차 캐시
      List<Member> members = findTeam.getMembers();
      for (Member m : members) {
        System.out.println("m = " + m.getUsername());
      }
      // 이렇게 해도(현재 리스트에 값을 세팅한 게 없어도) 값이 출력이 된다.
      // jpa에서 select 쿼리를멤버를 조회하면서 한번 더 보냄.

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

 

이 부분을 주석처리 해버린다면

1차캐시에서 값을 가져오는것이기 때문에 

DB에서 select 쿼리는 나가지 않는다.

 

team이 그냥 그대로 컬렉션에 저장되거나 하지 않고 1차 캐시에 올라가 있는 상태이고,

그 상태 그대로를 가져오므로 역시 아무것도 없는 상태다.

순수한 객체상태로 볼 수 있음

 

그래서 이걸 객체중심적으로 생각해봐도, 양쪽에 다 세팅을 해주는 게 맞다.

 

저장을 할 때

    try {

      // 순서도 조금 바꿔서
      Team team = new Team();
      team.setName("TeamA");
      em.persist(team);

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

//      team.getMembers().add(member); 이거를 가져다가

후략

헷갈릴 수 있으니 Member에서 setTeam을 할 때, 거기다가 

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

이렇게 넣어주는 게  더 깔끔하게 실수 방지를 할 것이다.

 

이렇게 하면 연관관계 편의 메서드, 즉, 원자적으로 메서드를 사용할 수 있게 됨.

* 영한킴의 tip.

연관관계 편의 메서드나 JPA의 상태를 변경할 때는 메서드에 set 대신에 다른 걸 쓴다.

 

그래서

// set 대신에 새롭게
  public void changeTeam(Team team) {
  this.team = team;
  team.getMembers().add(this);
}

 

    try {

      // 순서도 조금 바꿔서
      Team team = new Team();
      team.setName("TeamA");
      em.persist(team);

      Member member = new Member();
      member.setUsername("member1");
      member.changeTeam(team); // 이렇게 변모
      em.persist(member);

//      team.getMembers().add(member); 이거를 가져다가

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

      // 더 안전하게, 양쪽에 다 값을 세팅
      Team findTeam = em.find(Team.class, team.getId());
      List<Member> members = findTeam.getMembers();
      for (Member m : members) {
        System.out.println("m = " + m.getUsername());
      }

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

아니면 team을 기준으로 member을 넣어도 된다.

 

changeTeam() 도 되지만

team.addMember() 이렇게 넣고,

Team에 메서드를

public void addMember(Member member) {
	member.setTeam(this);
	members.add(this);
}

// 여기서 members인 이유는 위에 <List>로 넣어놨으니까.

StackOverFlow 나오거나 장애 생기거나 한다.

 

컨트롤러에서 엔티티를 절대 반환하지 마라.

- 무한루프가 생기거나, 엔티티를 변경하는 순간 API의 스펙이 바뀌어버린다.

 


양방향 매핑 정리

• 단방향 매핑만으로도 이미 연관관계 매핑은 완료
• 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐
• JPQL에서 역방향으로 탐색할 일이 많음
• 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 됨 (테이블에 영향을 주지 않음)

 

연관관계의 주인을 정하는 기준

• 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안 됨
• 연관관계의 주인은 외래 키의 위치를 기준으로 정해야 함

 

728x90
Comments