이번에는 JPA 연관 관계 기초에 대해 알아 볼 것이다.
시작하기 전 개념 알기*
회원 ->팀 처럼, 둘 중 한 쪽만 참조하는 것을 단방향 관계라고 하며, 양쪽 모두 서로 참조하는 것을 양방향 관계라고 함
다대일(N:1), 일대다(1:N), 일대일(1:1) 관계가 있는데, 여러 회원은 한 팀에 속할 수 있으니 다대일 관계임.
객체와 테이블 연관관계 차이
객체 관계 매핑 : 단반향 연관관계
연관 관계 사용
객체 관계 매핑 : 양방향 연관관계
연관관계의 주인
회원과 팀이 있을 떄 연관관계를 객체와 테이블이 각각 어떻게 맺는지 비교해보자.
▸ 객체
회원 객체는 필드 (Member.team)을 통해 팀 객체와 연관 관계를 맺는다.
여기서 회원 객체와 팀 객체는 단방향 관계이다. 팀에서 회원을 알 수 없다.
▸ 테이블
TEAM_ID 외래키를 통해 회원과 팀 객체가 연관관계를 맺는다.
회원과 팀 테이블은 양방향 관계이다. TEAM_ID 외래키 하나로 JOIN을 하면 MEMBER JOIN TEAM , TEAM JOIN MEMBER이 모두 가능하다.
▸ 차이
위를 보면 참조를 통한 연관관계는 언제나 단방향임을 알 수 있다.
팀 객체에도 회원 필드를 추가해서 양방향 관계처럼 만들 수 있지만, 이는 양방향 관계가 아니라 서로 다른 단뱡향 관계 2개이다.
하지만 외래키를 통해 연관관계를 맺는 테이블은 항상 양방향 관계이다.
객체와 테이블을 통한 연관관계를 알아보았으니 이제 JPA를 통해 매핑을 시작 해 보겠다.
▸ 회원 엔티티
@Entity
public class Member{
@Id
@Column (name = "MEMBER_ID")
private String id;
private String userName;
//연관관계 매핑
@ManyToOne
@JoinColumn (name="TEAM_ID")
private Team team;
//연관관계 설정
public void setTeam(Team team){
this.team = team;
}
}
▸ 팀 엔티티
@Entity
public class Team{
@Id
@Column (name = "TEAM_ID")
private String id;
private String name;
}
테이블은 외래키로 연관관계를 맺고, 객체는 참조를 통해 맺는다.
그래서 Member.team (참조되는 객체)와 TEAM_ID (외래키) 를 매핑하는 것이 JPA 연관관계 매핑이다.
연관관계 매핑을 위한 어노테이션은 다음과 같다.
@ManyToOne : 이름 그대로 다대일 관계라는 매핑 정보를 나타낸다. 연관관계 매핑시 이러한 다중성을 나타내는 어노테이션은 필수이다.
참고로 optinal 속성이 있는데, 기본값은 true이며 false로 설정하면 연관된 엔티티가 항상 있어야 한다.
@JoinColumn (name="TEAM_ID") : 외래 키를 매핑할 때 사용한다. name 속성에는 매핑할 외래 키 이름을 지정한다. 생략도 가능하다.
@JoinColumn을 생략하면 어떤 전략을 사용할까?
-> 필드명 + _ + 참조하는 테이블의 컬럼명 과 같은 기본 전략을 사용한다
(ex: 위의 회원 클래스에서 생략하면, team(=필드명) + __ (언더바) + TEAM_ID (=참조하는 테이블의 컬럼명) = team_TEAM_ID 외래 키를 사용하게 됨
열심히 한 매핑 작업이 끝났으니 본격적으로 CRUD를 해 보겠다.
▸ 저장
public void teamSave(){
//팀1 저장
Team team1 = new Team("team1", "팀1");
em.persist(team1);
//회원1 저장
Member member1 = new Member("member1" ,"회원1");
member1.setTeam(team1); //연관관계 설정 member1 -> team1
//회원2 저장
Member member2 = new Member("member2", "회원2");
member2.setTeam(team1); //연관관계 설정 member2 -> team1
}
주의!! JPA 에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 함
이렇게 간단히 회원 엔티티가 팀 엔티티를 참조하고 저장한 것으로 끝이 났다.
굳이 회원을 등록할 때 외래키를 함께 입력하는 쿼리를 날리지 않아도 되는 이유는, 앞선 연관관계 설정으로 JPA가 알아서 회원이 참조한 팀의 식별자 (=@Id) 를 외래 키로 사용해서 적절한 등록 쿼리를 생성 해 주기 때문이다.
▸ 조회
조회하는 방법엔 크게 객체 그래프 탐색 ( = 객체 연관 관계를 사용한 조회), 객체 지향 쿼리 (=JPQL)의 2가지 방법이 있다.
객체 그래프 탐색
member.getTeam();
앞선 매핑으로 간단히 참조를 통해 조회할 수 있다.
객체지향 쿼리
JPQL도 조인을 지원하며, 이를 통해 팀1에 소속된 모든 회원을 조회해보자.
String jpql = "select m from Member m join m.team t where t.name =:teamName";
List<Member> resultList = entityManager.createQuery
(jpql, Member.class).setParameter("teamName","팀1")
.getResultList();
여기서 :은 파라미터 바인딩을 받는 문법이다.
완성된 쿼리문을 보면 SQL에 보다 더 객체를 대상으로 하고, 간결한 것을 알 수 있다.
▸ 수정
회원이 팀 탈퇴하고 딴 데 가고 싶다고 한다. 수정 해 주자.
//새로운 팀2
Team team2 = new Team("team2", "팀2");
em.persist(team2);
//회원1에 새로운 팀2 설정
Member member = entityManager.find(Member.class , "member1");
member.setTeam(team2);
참조하는 대상만 변경해주면 알아서 수정이 된다.
어떻게 이게 가능한지 이해가 잘 되지 않는다면, 이전 장으로 돌아가서 변경 감지 부분을 읽고 오면 도움이 될 것 같다.
▸ 연관관계 제거
회원이 그냥 아무데도 안 간다고 한다. 없애주자.
Member member1 = entityManager.find(Member.class, "member1");
member1.setTeam(null); //연관관계 제거
이렇게 되면 member table 에서 TEAM_ID의 값이 null로 업데이트 되게 된다.
▸ 연관된 엔티티 삭제
연관된 엔티티를 삭제하려면 기존에 있던 연관관계를 먼저 제거하고 삭제해야 한다.
그렇지 않으면 외래 키 제약조건으로 인해 데이터베이스에서 오류가 발생하는데,
첫 토이 프로젝트에서 이러한 상황을 한 번쯤은 보았을 것이다.
member1.setTeam(null); 이렇게 연관관계를 제거해주고
entityManager.remove(team); 이렇게 엔티티를 삭제해주자.
앞서 이야기 했듯이 참조를 통한 관계는 양방향 연관관계가 없고, 단반향 연관관계 2개가 있을 뿐이다.
이번에는 단반향 연관관계 2개를 잘 만들어 참조를 통해 마치 양방향처럼 쓸 수 있게 해 보자.
여기서 팀은 여러 회원을 가질 수 있다. 팀에서 회원은 일대다 관계이다.
그러므로 팀에서 참조를 통해 회원을 알아내고자 할 때, 참조 변수를 List 처럼 배열 형식으로 만들어야 한다.
양방향 연관관계 매핑
▸ 회원
@Entity
public class Member{
@Id
@Column (name = "MEMBER_ID")
private String id;
private String userName;
//연관관계 매핑
@ManyToOne
@JoinColumn (name="TEAM_ID")
private Team team;
//연관관계 설정
public void setTeam(Team team){
this.team = team;
}
}
보다시피 변한 거 없다. 이미 회원에서 팀으로의 단반향 관계는 이전에서 설정을 마쳤기 때문이다.
▸ 팀
@Entity
public class Team{
@Id
@Column (name = "TEAM_ID")
private String id;
private String name;
@OneToMany (mappedBy = "team")
private List<Member> members = new ArrayList<Member>();
}
여기는 좀 변했다.
일대다 관계이니 @OneToMany 매핑 정보가 추가되고, mappedBy가 추가되었다. mappedBy는 양방향 매핑일 때 사용하는 속성인데, 반대쪽 매핑 필드의 이름을 값으로 주면 된다. 아래에서 이야기 할 연관관계 주인에서 조금 더 자세히 설명하겠다.
mappedBy 이 친구는 대체 왜 필요할까?
앞서 이야기 했듯, 참조를 통한 양방향 관계는 그저 잘 만든 단방향 관계 2개일 뿐이다.
외래키는 1개인데 관계는 2개라면 이걸 어떻게 해결해야 할까?
@JoinColumn을 통해 외래키 컬럼을 짝지어 줬는데 그럼 반대편은 어떻게 하지? MEMBER_ID라는 외래키를 또 만들어버릴까?
이러한 문제들을 해결하기 위해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래키를 관리하도록 했는데 이것을 연관관계의 주인이라고 한다.
양방향 연관관계를 매핑할 땐 반드시 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다. 연관관계의 주인만이 DB 연관관계와 매핑되고 외래 키를 관리 (등록, 수정, 삭제) 할 수 있다. 반면에 주인이 아닌 쪽은 읽기만 할 수 있다.
어떤 연관관계를 주인으로 정할지는 mappedBy 속성을 사용하면 된다.
▸ 연관관계 주인 정하기
주인은 어떻게 정해야 할까?
테이블에서 외래키를 가지고 있는 테이블을 주인으로 정하면 된다.
이렇게 정하면, 주인은 자기 테이블에 있는 외래키를 관리하면 되는데, 만약 주인을 반대로 설정했다면 물리적으로 전혀 다른 테이블의 외래 키를 관리해야 한다. 그러면 안 된다.
다시 말하지만, 외래키를 가지고 있냐 없냐가 기준이지 비즈니스적 중요도는 아니다.
예를 들어 자동차랑 바퀴가 있을 때 , 왜인지 자동차를 주인 시켜줘야 할 것 같지만 테이블 관계를 떠올리면 바퀴 테이블 안에 외래키가 있다. 그러므로 바퀴를 주인 시켜줘야 한다.
일대다 관계에서는 항상 다 쪽이 외래키를 가진다.
따라서 @ManyToOne에는 mappedBy 속성이 없다.
테이블의 관계를 살펴보면 항상 왜 다 쪽이 주인이 되는지 알 수 있을 것이다.
▸ 양방향 연관관계의 주의점
연관관계의 주인 만이 외래키를 관리(등록, 수정, 삭제) 할 수 있다.
그런데 이를 간과하고, 양방향 연관관계에서 흔히 하는 실수는 주인에는 값을 입력하지 않고 주인이 아닌 곳에만 값을 입력하는 것이다.
Member member1 = new Member("member1", "회원1");
entityManager.persist(member1);
Team team1 = new Team("team1", "팀1");
team1.getMembers().add(member1); //주인이 아닌 곳에 연관관계 설정
entityManager.persist(team1);
언뜻 보면 제대로 된 코드같지만 DB를 살펴보면 여전히 회원1은 어느 팀에도 소속되지 않는 상태이다. 그러니, 반드시 주인 (여기서는 member1.setTeam(team1);) 에 값을 넣어줘야 한다.
▸ 순수한 객체까지 고려한 양방향 연관관계
JPA 관점에서만 보면 , 주인에만 값을 저장하기만 하면 된다.
그런데 객체 지향 관점에서 보면 과연 이렇게 해도 괜찮은걸까?
조금만 생각해 보면, JPA를 사용하지 않고 엔티티에 대한 테스트 코드를 작성할 때와 같은 순수한 객체 상태일 때 문제가 발생 될 것임을 알 수 있다.
그래서 이번에는 순수한 객체까지 고려한 양방향 연관관계 설정을 해 볼 것이다.
주인이 되는 회원 엔티티에서, 팀과의 연관 관계를 설정해주는 setTeam method를 조금 변형하면 연관관계 편의 메소드를 만들 수 있다.
public class Member {
private Team team;
public void setTeam (Team team){
if( this.team != null ){
this.team.getMembers().remove(this);
//member에 team 값을 넣기 전에,
//현재 만약 team 값이 있으면 그 team이 참조하는 회원 리스트에 회원을 빼줌
this.team = team;
team.getMembers().add(this); // team에도 member의 값을 넣어줌
}
}
코드를 조금 더 설명하자면,
회원에 팀 값을 대입하기 전에 if 문을 통해 해당 회원의 팀 필드가 null 값인지 확인해야 하고, null이 아니면 현재 참조하고 있는 팀의 멤버 리스트에서 회원을 제거해 주어야 한다. 그렇게 하지 않으면 memberA에 team1을 넣은 후 team2로 바꿔 넣어줄 때 memberA의 team 필드는 team2가 될 것이지만, team1의 회원 리스트에는 여전히 memberA가 존재할 것이기 때문이다.
이렇게 순수한 객체까지 고려한 양방향 관계설정을 해 보았다.
단반향에 비해 양방향 매핑은 복잡하니, 주의하며 로직을 견고하게 작성해야 할 것이다.
다음에는 다양한 연관관계 매핑을 알아보도록 하겠다.
팁!
양방향 매핑 시 무한 루프에 빠지지 않도록 조심해야 한다.
Member.toString()에서 getTeam()을 호출하고, Team.toString()에서 getMember()을 호출하면 무한 루프에 빠질 수 있다.
참조: 자바 ORM 표준 JPA 프로그래밍 - 김영한