JPA

[JPA] 연관관계 매핑 기초

행복하개! 2020. 10. 12. 01:00

 

 

 

일단 JPA에서 연관관계 매핑은 정말 중요하다. 어찌보면 객체지향과 RDB의 메커니즘 차이를 좁혀주는데 중요한 이론이기도 하다.

 

Member와 Team이라는 테이블이 있다고 가정해보자. 먼저 Member는 하나의 팀만 가질 수 있고, Team은 여러명의 멤버를 가질 수 있다. Team에는 Member의 PK가 외래키로 존재한다.

 

먼저 RDB의 경우에는 FK(외래키)만 존재해도 어느방향에서도 접근하고 조회할 수 있다. 즉 Team에는 member의 PK가 외래키로 존재하므로 이 외래키를 가지고 두 테이블을 Team에서 조인하든 member에서 조인하던 조인이라는 SQL 쿼리를 만들어서 조회할 수 있다.

 

하지만 객체의 경우에는 그런 SQL문이 없다. 이걸 억지로 쥐어짜서 가능하게 한다면 member id를 가지고 id를 가져와서 team에 그 id가 존재하는지 보고 거기서 member를 빼오고..

Member findMember = em.find(Member.class, member.getId());
Long findTeamId = findMember.getTeamId();
Team findTeam = em.find(Team.class, findTeamId);
// 너무 객체지향스럽지 않다.

이렇게 구현이야 할 수 있지만 이건 전혀 객체지향스럽지 않다. 객체를 테이블에 맞춰서 데이터 중심으로 모델링하게 되면 전혀 협력의 관계가 이루어지지 않는다.

 

여기서 객체와 테이블의 연관관계의 차이가 뚜렷하게 발생하는 것이다. 이 차이를 이해하고 접근해야 한다.

 

먼저 단방향 매핑을 살펴보자

정말 객체지향스럽게 만든다면 엔티티에 FK로 id가 아닌 참조가 되어야 한다. 

class Member

/* id를 넣는 경우.. */
@Column(name = "TEAM_ID")
private Long teamId; // 객체지향적인 방식이 아니게 된다.

/* 참조를 활용하는 방식 */
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;

두 테이블에서 FK가 있는 곳은 한 곳이다. 그리고 그 곳은 多쪽인 경우가 많다. 이게 무슨이야기냐 하면 Team에는 여러명의 Member가 존재할 수 있기 때문에 Member가 多라고 보면 된다. 즉, Member객체가 多부분이다.

 

여기서 새로운 어노테이션이 보인다. @ManyToOne은 多에서 1로 매핑 될때 사용하는 어노테이션이다. Member가 多 부분이기 때문에 @ManyToOne 어노테이션을 활용해서 연결해야한다. 여기서 @JoinColumn(anem = "TEAM_ID")는 Team 객체의 FK값을 의미한다고 보면 된다. 

 

이렇게 되면 단방향으로 매핑이 완료된다.

 

그럼 앞으로 사용할 때는

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

// 조회
Member findMember = em.find(Member.class, member.getId());
//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();

// 새로운 팀B
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);

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

이렇게 참조 저장을 하고 get으로 불러오면 끝.

 

 

 

근데 이제 가장 곰곰히 생각해야될 문제는 양방향 문제이다. 앞서 말했지만 데이터베이스는 FK만 두 테이블중 어디에 있어도 어느 테이블에서든 조인을 통해 두 테이블 모두 조회가 가능하다. 근데 객체는 그렇지 않다. 물론 여기서 해결 방법은 당연히 있다. 코드를 보자.

Member

@Entity
public class Member {
   @Id @GeneratedValue
   private Long id;
   
   @Column(name = "USERNAME")
   private String name;
   private int age;
   
   @ManyToOne
   @JoinColumn(name = "TEAM_ID")
   private Team team;
   … 
}

Team

@Entity
public class Team {
   @Id @GeneratedValue
   private Long id;
   private String name;
   
   @OneToMany(mappedBy = "team")
   List<Member> members = new ArrayList<Member>();
   …
}

 

Member에서는 앞서 단방향 매핑을 끝낸 상태이고 Team에서는 @OneToMany를 이용한다. 여기서 mappedBy는 Member에 존재하는 team을 거울로 삼겠다라는 뜻이 된다. 또한 Member가 여러개이므로 Collection List를 이용해서 불러온다.

 

근데.. 계속말하지만 데이터베이스는 FK하나만 있으면 된다. 하지만 객체는 아니다. 위의 코드를 보면 사실상 단방향 두개를 가지고 양방향을 표현한 것이다. 즉.. 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 된다는 의미와 같다. 근데 비슷한 매커니즘으로 통용을 하려고 보면 결국 한놈에는 "진짜"가 존재해야한다. 여기서 진짜는 연관관계의 주인이라고 말한다.

 

여기서 이제 룰이 하나 만들어진다. 양방향 매핑의 규칙은 두 객체 중 하나를 연관관계의 주인으로 삼아야한다는 것이고, 연관관계의 주인만 외래키를 관리할 수 있으며 주인이 아닌쪽은 Read만 가능하게 된다. 주인은 mappedBy 속성을 사용하지 않고 주인이 아닌 가짜 매핑의 경우에만 mappedBy를 사용한다.

 

여기서 중요한 점은 외래키가 있는 곳은 주인으로 정해야된다는 점이다. 여러 이유가 존재하지만 외래키가 있는 곳이 아닌 반대편 테이블에 주인을 걸게 되면 Team에서 업데이트를 했더니 Member 테이블의 쿼리가 날라갈 수도 있다. 이자체가 헷갈리게 되고 나중에 성능 이슈도 있게 된다. 외래키가 있는 곳을 연관관계 매핑의 주인으로 설정하자.

 

여기서 많이 할 수 있는 실수는 mappedBy로 되어있는 가짜 매핑에 update를 하는 경우이다.

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

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

//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);

이 경우에는 아무일도 일어나지 않게 된다. 앞서 말했지만 read only이기 때문에 변화가 일어나지 않는다. 양방향 매핑시에는 무조건 주인에 값을 입력해야한다.

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

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

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

em.persist(member);

 

 

 

그리고 추가적으로 고려해야하는 상황들이 있다.

 

먼저 순수 객체 상태를 고려해서 항상 양쪽에 값을 성정해야한다. 순수 자바 상태에서 영속성 컨텍스트에 persist() 한다고 DB에 접근해서 가져오는 것이 아닌 1차캐시에 머물러 있기 때문에 members도 다시 접근하고 하겠지만 순수하게 1차 캐시에 들어가 있는 경우에는 값을 조회하지 못할 수도 있다. 이를 방지하기 위해서 두쪽에 모두 값을 셋팅해줘야한다. 이는 순수 자바 상태로 셋팅을 하는 것이다.

 

근데 두쪽에서 모두 setTeam() getMembers().add(member); .. 이렇게 셋팅하기에는 사람이라 귀찮고 까먹을 수 있다. 이를 위해서 연관관계 편의 메소드를 생성해서 Member에서든 Team에서는 한번에 값을 셋팅할 수 있게 changeBlaBla() 메서드를 만들어서 관리하면 편하다.

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

이런 연관관계 편의 메서드는 정해진 규칙은 없다. Member에서 할 수도 있고 Team에서 할 수도 있다. 다만 상황에 맞게 잘 사용하면 된다.

 

또한 toString()과 lombok JSON 생성 라이브러리 사용시에 무한루프를 조심해야한다. 만약에 Member에서 toString을 사용하면 안에 Team이 존재하기 때문에 이 Team의 toString()이 호출 된다. 그러면 또 Member의 toString()이 또 발동되듯 무한 루프가 발생하여 StackOverFlow가 발생할 수 있다. 추가적으로 Controller에서는 절대 Entity를 DTO식으로 사용하면 안된다. API 스펙이 바뀌어 버릴 수 있기 때문에 가급적이면 DTO 클래스를 만들어서 사용해야한다.