SQL mapper & ORM/JPA

5. 연관관계 매핑 기초

wooweee 2023. 10. 4. 07:03
728x90
  • 핵심
    1. 방향 : 단방향, 양방향
    2. 다중성 : 다대일, 일대다, 일대일, 다대다
    3. 연관관계의 주인 : 객체 양방향 연관관계는 관리 주인이 필요

 

1. 연관관계가 필요한 이유

  • 시나리오
    • 회원과 팀
    • 회원은 하나의 팀에만 소속
    • 회원 : 팀 = n : 1

 

1.1. 연관관계 없는 경우

  • 모델링

객체에도 team 참조가 아닌 team_id가 들어간다.

 

  • Member 엔티티
package hellojpa.chap5;

import javax.persistence.*;

@Entity
@SequenceGenerator(
        name = "MEMBER_SEQ_GENERATOR",
        sequenceName = "MEMBER_SEQ",
        // initialValue = 1, allocationSize = 1) // 1부터 시작하고 seq가 1씩 증가
        initialValue = 1, allocationSize = 50) // 1부터 시작하고 seq가 50씩 증가 <최적화> - db와 네트워크 소통을 최소화 하기 위함
public class MemberChap5 {
    @Id
    @GeneratedValue //Auto
    @Column(name = "MEMBER_ID")
    private Long id;
    @Column(name = "USERNAME")
    private String username;

    @Column(name = "TEAM_ID")
    private Long teamId; // 참조가 아닌 외래키 값을 그대로 들고 옴
}

 

 

  • Team 엔티티
package hellojpa.chap5;

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

@Entity
public class Team {
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    private String name;

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

 

  • 실행 코드
package hellojpa.chap5;

public class JpaChap5 {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager(); // database의 connection 하나 받았다고 생각하셈
        EntityTransaction tx = em.getTransaction(); // jpa는 tx가 필수
        tx.begin(); // tx 시작

        try {
            /* 연관관계가 없어서 객체스럽지 않은 저장 */
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            MemberChap5 member = new MemberChap5();
            member.setUsername("member1");
            member.setTeamId(team.getId()); // 객체스럽지 않다.
            em.persist(member);

            /* 연관관계가 없어서 객체스럽지 않은 조회 */
            MemberChap5 findMember = em.find(MemberChap5.class, member.getId());
            Long teamId = findMember.getTeamId();

            Team findTeamId = em.find(Team.class, teamId);
            // findTeamId를 가지고 필요한 값을 찾아온다.

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

 

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

  • 차이점
    • 테이블 : FKjoin을 사용해서 연관 테이블을 찾는다.
    • 객체 : 참조를 사용해서 연관 객체를 찾는다.

 

 

1.1. 연관관계 있는 경우

  • 단방향 연관관계
  • 양방향 연관관계
  • 연관관계 주인

 

 

2. 단방향 연관관계

2.1. 객체 지향 모델링

  • Member 엔티티
    • n : 1 의 테이블 모델링인 경우 n에 무조건 fk 키가 걸린다.
    • Member가 n이므로 연관관계를 건다.
    • @ManyToOne : db 관점으로 직독직해하면된다. many가 member이고 one이 team이다.
    • @JoinColumn(name = "TEAM_ID") : fk column명을 작성하면된다. 보통 team의 Id로 설정된 column명을 넣는다. 얘도 그냥 @Column 과 동일
package hellojpa.chap5;

import javax.persistence.*;

@Entity
@SequenceGenerator(
        name = "MEMBER_SEQ_GENERATOR",
        sequenceName = "MEMBER_SEQ",
        // initialValue = 1, allocationSize = 1) // 1부터 시작하고 seq가 1씩 증가
        initialValue = 1, allocationSize = 50) // 1부터 시작하고 seq가 50씩 증가 <최적화> - db와 네트워크 소통을 최소화 하기 위함
public class MemberChap5 {
    @Id
    @GeneratedValue //Auto
    @Column(name = "MEMBER_ID")
    private Long id;
    @Column(name = "USERNAME")
    private String username;

    /*
    // 참조가 아닌 외래키 값을 그대로 들고 옴
    @Column(name = "TEAM_ID")
    private Long teamId;
    */

    /* @ManyToOne
    * db 관점으로 누가 1이고 누가 n인지 중요하다.
    * member : n  |  team : 1
    * member 입장에선 many 이고 Team 입장에선 one이다.
    * */
    @ManyToOne(fetch = FetchType.LAZY) // EAGER가 default, LAZY는 select가 분리되서 나감
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

 

  • 매핑된 모델링

 

  • 실행 코드
package hellojpa.chap5;

public class JpaChap5 {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager(); // database의 connection 하나 받았다고 생각하셈
        EntityTransaction tx = em.getTransaction(); // jpa는 tx가 필수
        tx.begin(); // tx 시작

        try {
            /* 연관관계가 없어서 객체스럽지 않은 저장 */
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            MemberChap5 member = new MemberChap5();
            member.setUsername("member1");
            // member.setTeamId(team.getId()); // 객체스럽지 않다.
            member.setTeam(team); // 객체스럽다.
            em.persist(member);

            /* 연관관계가 없어서 객체스럽지 않은 조회 */
            MemberChap5 findMember = em.find(MemberChap5.class, member.getId());
            // Long teamId = findMember.getTeamId();
            // Team findTeamId = em.find(Team.class, teamId);
            Team findTeam = findMember.getTeam();

            // findTeamId를 가지고 필요한 값을 찾아온다.

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

 

 

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

  • 아주 중요
  • 결론 : 단방향으로 최대한 끝내고 필요시 양방향을 추가하는 형태로, 최대한 양방향을 피해야한다. - 주의해야하는 사항이 너무 많기 때문
  • 하지만 일대다 관계를 할 빠에는 양방향관계를 해라

 

3.1. 양방향 매핑

  • table의 fk와 객체의 참조의 차이점에 의해 생긴 것
  • 객체는 Table과 달리 Team 엔티티에도 member 객체를 넣어야지 table처럼 양방향 조회가 가능해진다.
  • 단방향 모델링과 양방향 모델링에서 테이블의 모델링은 변한 부분이 없는데 이는 Table은 Fk를 가지고 JOIN만 하면 Team이든 Member 든 조회가 가능하기 때문
  • 그래서 Table을 양방향이라고 하지만, 실상 table의 연관관계에는 방향이란 개념자체가 없다.

 

  • Team 엔티티
    • @OneToMany : team이 1 이기 때문
    • mappedBy ="team" : 현재 객체에서 2개가 연관관계를 가지는 컬럼이 존재하게 될 때 db가 뭐를 fk로 잡아야할지 모르게 된다. 여기서 fk로 잡히는 column을 연관관계의 주인이라하고 주인이 아닌 쪽은 mappedBy로 연관관계의 주인인 필드명을 mapped 한다는 것을 표시해야한다. 또한 연관관계의 주인이 아니기 때문에 read only이다. 
package hellojpa.chap5;

import hellojpa.Member;

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;

    /* 양방향 매핑
    * table은 join과 fk 가지고 양방향이 가능하지만 객체참조는 그것이 불가능.
    * 그래서 양방향 매핑이 필요
    * @OneToMany : 일대다
    * mappedBy = "team" : 일대다 매핑에서 뭐량 연결되는지 알려주는 것
    *                   : team은 Member 엔티티 내부 team이란 필드와 연결됨을 의미
    * mappedBy는 연관관계의 주인이 아니기 때문에 읽기만 가능하고 등록과 수정은 불가
    * */
    @OneToMany(mappedBy = "team")
    private List<MemberChap5> members = new ArrayList<>(); // npe 뜨지 않게 하는 관례. 읽기만 가능

}

 

 

3.2. 객체와 테이블간에 연관관계를 맺는 차이

  • 연관관계 주인과 mappedBy를 이해하고 사용하기 위해서 객체와 테이블간에 연관관계를 맺는 차이를 이해해야 한다.

  • 객체 연관관계 = 2개
    • 회원 -> 팀 : 연관관계 1개 (단방향)
    • 팀 -> 회원 : 연관관계 1개 (단방향)
    • 참고. 사실상 단방향 2개가 있는 것이지 양방향이란 것은 없다.

  • 테이블 연관관계 = 1개
    • 회원 <- fk -> 팀의 연관관계 2개 (양방향)

 

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

 

  • 테이블인 연관관계 1개, 양방향 객체는 연관관계가 2개 -> 테이블에 들어갈 객체 단일 연관관계 2개 중 1개만 골라야한다.

 

3.3. 연관관계 주인 

  • 2개(객체 연관관계)와 1개(table 연관관계)의 딜레마 해결

 

  • 규칙
    1. 객체의 2 관계 중 1개를 연관관계의 주인으로 지정
      - 주인을 db의 fk로 넣을 것  객체에는 team이란 참조가, table에는 team의 pk 가 fk로 저장 (jpa가 해줌)
    2. 연관관계의 주인만이 외래키를 관리 (등록, 수정, 읽기)
    3. 주인 아닌 쪽은 읽기만 가능
    4. 주인은 mappedBy 속성 사용 안함
    5. 주인이 아니면 mappedBy 속성으로 주인인 필드명을 작성

 

  • 누구를 주인으로?
    • 외래 키가 있는 곳을 주인으로 정해라 == db에서 외래키가 있는 곳은 N인 table에만 존재 == @ManyToOne이 붙는 엔티티에 주인이 존재
      1. 안헷갈림
      2. 성능 이슈 존재
    • 참고로 @ManyToOne의 속성으로 mappedBy가 존재하지도 않음 

 

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

3.4.1. 연관관계의 주인에 값을 입력하지 않음

  • memberTable에 TEAM_ID가 null로 나타남
/* 연관관계 주인이 아닌 필드를 이용한 문제 코드
MemberChap5 memberChap5 = new MemberChap5();
memberChap5.setUsername("member1");
em.persist(memberChap5);

Team team = new Team();
team.setName("TeamA");
team.getMembers().add(memberChap5); // 읽기 전용이기 때문에 사용하지 않는다.
em.persist(team);

tx.commit();

 

  • 주인에 값을 입력해야 TEAM_ID가 정확히 나타난다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);

MemberChap5 memberChap5 = new MemberChap5();
memberChap5.setUsername("member1");
memberChap5.setTeam(team); // 주인 쪽에 값을 넣음

em.persist(memberChap5);

 

  • 하지만 team.getMembers().add(); 또한 코드상 들어가야한다. - 1번 2번 이유 존재
    • 객체의 입장에서 보면 team.getMembers().add(member)하면 객체에 값이 들어가게 되고 1차 캐시에는 저장된 값이 올라가게 된다. 하지만 db상에는 해당 필드에 맞는 컬럼이 없기 때문에 값이 저장이 되지 않으므로 위의 코드처럼 자주 하는 실수라고 한다.
    • 반면 member.setTeam(team)을 하게 되면 1차 캐시에도 오르고 db에도 해당 필드에 맞는 컬럼이 존재하기 때문에 db에도 값이 잘 저장이 되지만 객체 입장에서는 members의 list에는 아무 값이 없다. 때문에 1차캐시는 존재하고 list는 비어잇는 상태에서는 아래의 코드에서 find를 할경우 해당 list 값을 받아 올 수 가 없다.

    • 따라서 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하고 이를 편의 메서드를 통해서 단위적으로 관리를 하는 것을 권장한다.
/* readOnly라서 실제로 create에 영향을 못주지만 넣어야하는 이유
* 1. List<memberChap5> 의 arrayList에 값을 넣어준다.
*    수동으로 flush(), clear() 하면 db에서 select 후 members를 list에 담고 그걸 출력해주지만
*    없으면 1차 캐시에서 값을 조회하는데 이 때는 list에 아무 값이 없기 때문에 team.getMembers().add()가 없으면 아무것도 출력이 안된다.
* 2. test 케이스에는 단위테스트에서 java만 사용할텐데 이 때, 조회가 안되기 때문
*
* 아래 1,2,3 번을 돌아가면서 돌리면 확인 가능
* 참고로 4. 은 MemberChap5, Team 엔티티에 편의메서드를 만들어서 코드를 생략 - 편의 메서드는 상황에 맞춰 필요한거 1개만 만들어서 쓸 것. 둘다 쓰면 안됨
* */

1. 출력 안됨
// team.getMembers().add(memberChap5); // 엔티티에 편의메서드가 만들어지지 않은 상태여야함
// em.flush();
// em.clear();


2. 출력 됨
// team.getMembers().add(memberChap5); // 엔티티에 편의메서드가 만들어지지 않은 상태여야함
em.flush();
em.clear();


3. 출력 됨
team.getMembers().add(memberChap5); // 엔티티에 편의메서드가 만들어지지 않은 상태여야함
// em.flush();
// em.clear();

 

  • 양방향에 코드 members가 어떻게 자동으로 들어가는지
  • jpa가 getMembers()할 때 막 select해서 가지고온 값을 arrayList에 넣는다.
    • 해당 코드 윗부분은 flush(), clear()가 수행되고 team.getMembers().add(member) 가 없는 경우
    • 1차 캐시가 없어서 직접 db에 접근해서 members에 관련된 값을 select 해온 후, members에 담고 출력함
    • 하지만 실제 로직에서 flush(), clear()는 사용 안하니깐 주의 할 것

 

 

3.4.2. 편의 메서드

  • 결론적으로 member.setTEAM(team); 와 team.getMembers().add(member); 두개가 항상 들어가야한다.
  • 매번 2개의 메서드를 작성하기가 번거럽기 때문에 엔티티 내부에 편의 메서드를 작성한다.
  • 주의할 점은 아무 엔티티에다가 편의메서드를 작성해도 되지만 꼭 1개만 작성해서 하나로만 사용 할 것 주의

 

  • member 편의 메서드
// 편의 메서드
public void changeTeam(Team team) { // setter는 관례적이름이라 logic이 들어갈 경우 이름을 변경 - 김영한님 방식
    this.team = team;
    team.getMembers().add(this);
}

 

  • team 편의 메서드
public void addMember(MemberChap5 memberChap5){
    memberChap5.setTeam(this);
    members.add(memberChap5);
}

 

  • 사용 실행 파일
Team team = new Team();
team.setName("TeamA");
em.persist(team);

MemberChap5 memberChap5 = new MemberChap5();
memberChap5.setUsername("member1");

/* 편의 메서드는 꼭 2개중 1개만 만들고 1개만 사용할 것 */
memberChap5.changeTeam(team); // MemberChap5 편의 메서드

em.persist(memberChap5);

team.addMember(memberChap5);// Team 편의 메서드

 

 

 

3.5. 양방향 연관관계 주의 결론

  • 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
  • 연관관계 편의 메서드를 생성하자 - 1가지만 사용할 것
  • 양방향 매핑시 무한 루프 조심 - 편의 메서드 2개 사용할 때 무한 루프 발생, toString(), lombok, JSON 생성 라이브러리
    • JSON 라이브러리 같은 경우는 @Controller에 엔티티를 절대 반환하지 않으면 98%는 문제를 일으키지 않는다. 
      1. 무한루프 방지
      2. 엔티티가 변경되는 순간 api 스팩이 바뀐다.
    • 엔티티를 반환하지 말고 DTO로 변환해서 반환해라

 

3.6. 양방향 매핑 정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 완료
    - 단방향으로 설계를 최대한 끝내라. 개발을 하면서 필요할 때 양방향을 추가해라 (테이블에 영향을 주지 않기 때문에 상관 없다.)

  • 양방향 매핑은 반대 방향으로 조회 기능이 추가된 것뿐 - 기능에 비해 고려할게 많아짐

  • JPQL에서 역방향으로 탐색할 일이 많기 때문에 사용은 해야함
  • 연관관계의 주인은 외래 키의 위치를 기준으로 정해야 한다.