JPA

연관관계 매핑 기초

No-ah98 2022. 3. 29. 02:49

연관관계가 필요한 이유

-객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다. - 조영호(객체지향의 사실과 오해)

 

예제 시나리오

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

 

객체를 테이블에 맞추어 모델링..(연관관계가 없는 객체)


MEMBER : TEAM -> N : 1  ( "하나의 팀에 여러명 회원이 소속될 수 있다" )


  • 연관 관계가 없는 객체, 참조 대신에 외래 키를 그대로 사용
/**
 * @Entity를 무조건 넣어야됨
 * -> 로딩할 때, JPA를 사용하는 애라고 인식하여 관리하기 시작
 */
@Entity @Getter @Setter
public class Member {
    /**
     * @Id -> PK
     */
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

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

}
@Entity
public class Team {
  @Id
  @GeneratedValue
  private Long id;
  private String name;
  ...
}

  • 연관 관계가 없는 객체, 외래 키 식별자를 직접 다룸
    • 팀을 저장하고 회원을 저장한다. 이때, 팀의 ID(기본키)를 member의 teamId에 set한다.
    •  member.setTeamId(team.getId());
public Class Main {
  public static void main(String[] args) {
  
    ...
      
    //팀 저장
    Team team = new Team();
    team.setName("TeamA");
    em.persist(team);	// 영속 상태가 됨. PK값이 세팅 된 상태.
    
    //회원 저장
    Member member = new Memeber();
    member.setName("memberA");
    member.setTeamId(team.getId());	// 외래 키 식별자를 직접 다룸
    em.persist(member);
  }
}
  • 식별자로 다시 조회, 객체지향적인 방법은 아니다.
    • 연관 관계가 없기 때문에 조회를 두 번 따로 해야된다.
    • 객체지향적인 방법은 아니다
//조회
Member findMember = em.find(Member.class, member.getId());
Long findTeamId = findMember.getTeamId();

//연관관계가 없음
Team findTeam = em.find(Team.class, findTeamId.getId());

즉, 데이터베이스 테이블 스타일에 맞추어 객체의 관계를 끼워 맞추는 방식을 사용하면

"객체를 데이터베이스 테이블에 맞추어 데이터 중심으로 모델링하면 협력 관계를 얻을 수 없다."

단방향 연관관계

  • 객체 지향 모델링(객체 연관관계 사용)


  • 객체 지향 모델링(객체의 참조와 테이블의 외래 키를 매핑)
    • ​주석과 같이 외래 키 대신에 Team 객체를 선언
    • JoinColumn으로 조인 컬럼명을 명시, default로도 가능하지만 되도록이면 선언하는게 좋다.
    • 연관 관계 @ManyToOne을 설정
      • Member 입장에서 Team과의 관계는 N : 1
    • 이제, team 필드가 DB에 TEAM_ID(FK)와 매핑이 됨.
    • 관계를 선언(@ManyToOne)하고 조인할 컬럼명을 매핑함
    • 이를, 연관관계 매핑(ORM)이라고 한다.
@Entity
public class Member {
  @Id
  @GeneratedValue
  private Long id;
  
  @Column(name = "USERNAME")
  private String name;
  
  private int age;
  
//  @Column(name = "TEAM_ID")
//  private Long teamId;
  
  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;
  ...
}

  • 객체의 참조와 테이블의 외래키를 매핑한 모델링(ORM 매핑)


  • 연관관계 저장하는 방법 및 참조로 연관관계 조회(객체 그래프 탐색)
    • member.setTeam(team)
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);

//회원 저장
Member member = new Memeber();
member.setName("member1");
member.setTeam(team);    //단방향 연관관계 설정, 참조 저장
em.persist(member);

Member findMember = em.find(Member.class,  member.getId());
  • 위와 같이 코드를 작성하면 findMember 변수에서 getTeam()을 호출해서 바로 팀 조회가 가능(연관관계의 장점)
  • 참고로, em.find 할 때 select 쿼리는 날아가지 않는다.
    • why? member는 현재 영속상태(persist)가 됐기 때문에 1차 캐시에서 바로 리턴
    •  만약 find 하기전에 flush, clear를 한다면 JPA가 조인해서 한방쿼리를 사용한다.
      • 하지만 처음부터 Join 하지 않고 member만 순수하게 가져오고 싶으면 패치 옵션(Lazy, Eager)을 사용하면 된다.(최적화)

  • 객체지향 모델링(연관관계 수정)
    • setTeam()으로 새로운 팀을 설정하면 Update 쿼리를 통해서 외래키(FK)가 들어간 Value가 바뀐다.
//새로운 팀B
Team teamB = new Team();
team.setName("TeamB");
em.persist(teamB);

//회원1에 새로운 팀B 설정
member.setTeam(teamB);

양방향 연관관계(mappedBy)와 연관관계의 주인

  • 양방형 매핑 
    • 상황) Team 객체에서도 특정 Team에 속한 member를 조회하고 싶음 
    • DB는 방향이 없기 때문에, 외래키 하나로 Join을 통해서 양방향 조회가 가능
    • 하지만, 객체 연관관계를 양방향으로 조회하기 위해서는 둘 다 셋팅(Team team, List members)을 해줘야됨
    • 위 두 가지가 객체와 테이블 간에 차이


  • 양방향 매핑(코드)
    • Member 엔티티는 단방향과 동일하다.

  • 양방향 매핑(코드)
    • Team 엔티티는 컬렉션 추가
      • Team의 입장에서 Member와의 고나계는 일대다(@OneToMany)
      • mappedBy를 통해 Member 객체의 team 변수와 매핑되어 있음을 의미

  •  양방향 매핑(반대 방향으로 객체 그래프 탐색)
//팀 조회
Team findTeam = em.find(Team.class, team.getId());

// 역방향으로 멤버들 조회
int memberSize = findTeam.getMembers().size();

  • 연관관계 주인과 mappedBy
    • mappedBy = JPA의 멘탈붕괴 난이도
    • mappedBy는 처음에는 이해하기 어려움 
    • 객체와 테이블간에 연관관계를 맺는 차이를 이해해야 한다.
  • 객체와 테이블이 관계를 맺는 차이 
    • 객체 연관관계 = 2개
      • 회원 -> 팀 연관관계 1개(단방향)
      • 팀 -> 회원 연관관계 1개(단방향)
      • 객체의 양방향 관계는 사실, 서로 다른 단방향이 2개이다.
      • 즉, 객체간에 양방향으로 참조하기 위해서는 단방향 연관관계를 2개 만들어야 된다.
    • 테이블 연관관계 = 1개 
      • 회원 <-> 팀의 연관관계 1개(양방향)
      • 테이블은 외래키 하나로 두 테이블을 Join하여 양방향으로 조회가 가능 
        • SELECT *
            FROM MEMBER M
            JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
           
          SELECT *
            FROM TEAM T
            JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID

  • 둘 중 하나로 외래 키를 관리해야 한다.
    • 객체에서는 멤버에 팀을 넣고, 팀에 멤버를 넣는다. 두군데 모두 넣어 준다.
    • 그러면 여기서 의문점이 생긴다. 둘 중에 뭘로 연관관계를 매핑해야 되나?
      • Member의 team값을 업데이트 할때 외래키(FK) 업데이트?
      • Team의 members를 업데이트 했을때 외래키(FK) 업데이트?
    • 하지만 DB 입장에서는 둘 중 어느 것을 선택해도 상관이 없다. FK값만 업데이트 되면 된다.
    • 단방향 연관관계 매핑에서는 참조와 외래키만 업데이트 하면 됐지만, 양방향 관계 매핑에서는 다르다.
    • 이게 가장 큰 패러다임의 문제이다.

❗️ )Team을 연관관계의 주인으로 할 시, Team에 있는 member 값을 바꾸면 Member 테이블에 UPDATE 쿼리가 발생한다.

-> 직관적이지 않고 햇갈리기 쉽다 그리고 성능 이슈에 대한 문제도 발생한다.


연관관계의 주인(Owner)

  • 위에서 언급한 패러다임의 문제 해결법
  • 양방향 매핑 규칙
    • 규칙의 두 관계중 하나를 연관관계의 주인으로 지정
    • 연관관계의 주인만이 외래 키를 관리(등록, 수정)
    • 주인이 아닌쪽은 읽기만 가능
    • 주인은 mappedBy 속성 사용 X, @JoinColumn
    • 주인이 아니면 mappedBy 속성으로 주인 지정
      • "읽기"(조회)만 가능 

  • 누구를 주인으로?
    • 외래 키가 있는 곳을 주인으로 정해라 ( JoinColumn이 있는 쪽 )
      • (1(mappedBy) : N(주인) )
    • 여기서는 Member.team이 연관관계의 주인
    • 멤버와 팀이 다대일 관계, DB에서 보면 다쪽이 FK를 가지고 있다. 다쪽이 주인이 된다.
    • 연관관계의 주인은 비즈니스 적으로 중요하지 않다. 단지 FK를 가진 쪽이 주인이 되면 된다.
    • 예를 들면, 자동차와 자동차 바퀴가 있는데 비즈니스적으로는 자동차가 중요하지만, 자동차와 바퀴는 일대다 관계다. 다인 바퀴가 연관관계의 주인이 된다.
    • 이렇게 설계 해야,
      • 뒤에서 나올 성능이슈도 없고,
      • 엔티티랑 테이블이 매핑이 되어있는 테이블에서 FK가 관리가 된다.
    •  모 민족에서 프로젝트 중에, 모두 단방향으로 설계하고 개발했다. 양방향으로 뚫어서 조회가 필요한 경우에 그 때 양방향 매핑 하면 된다. 자바 코드만 수정이 일어나기 때문에 DB에 영향을 주지 않는다.
      • 이미, 단방향 매핑만으로 ORM 매핑이 다 끝났다. 양방향은 단순히 조회를 편하게 하기 위해 부가 기능이 조금 더 들어가는 거라고 보면 된다. 어차피 조회할 수 있는 기능만 있다.
  • 양방향 매핑시 가장 많이 하는 실수(연관관계의 주인에 값을 입력하지 않음)
    • 연관관계의 주인(Member)에 값을 입력하지 않음
    • Team의 members는 현재 mappedBy로 매핑되어 있기 때문에 읽기만 가능  
    • 그래서 실제 DB에 값을 조회 해보면 TEAM_ID에 null 값이 들어감, 아무런 영향을 못미침
Team team = new Team();
team.setName("TeamA");
em.persist(team);

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

// 10번째 라인 작업을 누락하고
//member.setTeam(team);

//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member); // team의 members는 읽기만 가능 

tx.commit();

 

 


  • 양방향 매핑시 연관관계의 주인에 값을 입력해야 한다.
    • 순수한 객체 관계를 고려하면 항상 양쪽 다 값을 입력해야 한다.
    • JPA 입장에서만 보면, 연관관계의 주인인 멤버에다가 팀을 세팅해주면 끝난다. DB에 FK값 세팅 되고 문제가 없다.
    • 굳이 team의 members 컬렉션에 멤버를 새로 넣어주지 않아도, 지연 로딩을 통해서 해당 멤버를 조회해올 수 있기 때문에 (아래의 코드에서는)아무 문제가 없다.
    • 아래의 코드에서는 em.flush(), clear() 하는 순간에 DB에 FK 세팅 된다. 그래서 지연 로딩을 해도 FK로 조인해서 가져올 수 있다.
    • 하지만, em.flush(), clear()가 일어나지 않으면 DB에 쿼리가 안날라가고, FK도 없이 MEMBER가 1차 캐시에만 영속화 되어있는 상태이다. members 조회해봤자 size 0이다.
      • 즉, flush(), clear()를 명시해놨으면 상관없지만 그게 아니면, 아래 2가지 상황에서 문제가 발생한다.
        • em.find
        • Test Case 작성
    • 결론은 진짜 객체 지향적으로 고려하면 항상 양쪽다 값을 넣어 주는 것이 맞다.
      • 추가적으로 JPA 없이 순수 자바 Object로 테스트 케이스가 동작하게끔 테스트 코드를 짤때도 NPE 발생한다. 양쪽다 넣어주자.

 

 

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

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

// 연관관계의 주인에 값 설정
member.setTeam(team);

// 역방향 연관관계를 설정하지 않아도, 지연 로딩을 통해서 아래에서 Member에 접근할 수 있다.
//team.getMembers().add(member);

// 이 동작이 수행되지 않으면 FK가 설정되어 있지 않은 1차캐시에만 영속화 된 상태이다. 
// SELECT 쿼리로 조회해봤자 list 사이즈 0이다.
em.flush();
em.clear();

Team findTeam = em.find(Team.class, team.getId());
List<Member> findMembers = findTeam.getMembers();

for (Member m : findMembers) {
    // flush, clear가 일어난 후에는 팀의 Members에 넣어주지 않았지만, 
    // 조회를 할 수 있음. 이것이 지연로딩
    System.out.println(m.getUsername());
}

tx.commit();

 

 

 

  • 양방향 연관관계의 주의 - 실습
    • 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
      • 양방향 연관관계시 양쪽으로 값을 셋팅 해주자.
    • 양방향 매핑시에 무한 루프를 조심하자
      • 예) Lombok에서 제공하는 toString() 사용시 서로를 호출하기 때문에 무한루프 및 스택오버플로 발생
      • 예 ) JSON 생성 라이브러리
        • 컨트롤러에서는 Entity 반환 X, DTO로 반환하자
          • why?) 무한루프 발생, Entity 변경하는 순간 API 스펙 변경
    • 연관관계 작성 시, 휴먼에러가 발생(실수)할 가능성이 높다. 연관관계 편의 메소드를 생성하자
      • 편의 메소드는 실수를 줄이기 위해, 주인쪽에서 연관관계의 값을 설정할때, 역방향 값도 함께 설정해준다. 
class Member {
    ...
        
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

양방향 매핑 정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 완료
    • JPA 모델링 할 때, 단방향 매핑으로 설계를 끝내야 됨, 
    • 일대다 에서 다 쪽에 단뱡항 매핑으로 설계하면 이미 테이블 외래키(FK) 설정은 끝 
    • 양방향 매핑은 반대편에서 조회할 일이 있을때만 매핑 
  • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐
  • JPQL에서 역방향으로 탐색할 일이 많음
  • 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 됨(테이블에 영향을 주지 않음)
    • 단방향 매핑에서 양방향 매핑으로 넘거ㅏ면서 추가된 것은 Team 객체에 members 컬렉션이 들어간것 뿐이다.
  • 연관관계의 주인을 정하는 기준
    • 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안됨
      • 해야된다면 연관관계 편의 메소드를 사용
    • 연관관계의 주인은 외래 키의 위치를 기준으로 정해야함.

 

 

Reference 

자바 ORM 표준 JPA 프로그래밍 - 기본편