본문 바로가기
서버/JPA

[JPA] 단방향, 양방향 연관관계 매핑과 주의점

by 베어 그릴스 2022. 10. 31.
320x100

*본 게시글은 김영한님의 자바 ORM JPA 표준 책을 보고 이해한 내용을 바탕으로 정리한 글입니다.

 

엔티티들은 대부분 다른 엔티티와 연관관계를 가지고 있다.

sql문을 사용해서 테이블에 접근할 때는 외래 키를 사용하여 join을 하기 때문에 어떤 방향으로든 테이블을 탐색할 수 있다.

 

그러나 객체는 참조를 사용해서 관계를 맺는다.

 

즉, 서로 setter 혹은 생성자를 통해 서로의 참조를 넘겨주어야 한다.

 

ORM에서 가장 중요하고 또 어려운 객체의 연관관계 매핑에 대하여 알아보자.

(해당 포스팅에선 우선 이해를 위해 다대일 관계를 기반으로 설명하겠습니다.)

 

단반향 연관관계

JPA에서 연관관계를 매핑할 때는 따로 Id를 사용하지 않는다.

 

Id를 사용하려면 아래와 같이 외래 키 식별자를 직접 다루어야 하고 이는 객체지향적인 방법이 아니다. (객체가 아닌 외래 키를 다룸)

 

member.setTeamId(team.getId());

 

순수한 객체 연관관계를 보자.

public class Member{

    private Long id;

    private Team team;
    
    public void setTeam(){
    
    }
}

public class Team{
}

 

이렇게 하면 우리는 meber1.getTeam()을 통해 팀을 조회할 수 있고 즉, 객체의 참조를 사용해서 연관관계를 탐색할 수 있고,  이를 객체 그래프 탐색이라 한다.

지금은 방향이 meber에서 team으로만 가능하므로 단방향 연관관계가 형성되어 있다.

 

조인을 하면 알고 있겠지만 member의 team, member에 속한 Team도 알 수 있고, 이를 양방향 연관관계라고 한다.

 

각설하고 JPA를 사용해서 데이터베이스에 저장되어있는 Member(N)와 Team(1)을 매핑해보자.

public class Member{
	@Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

	@ManyToOne
    @JoinColumn(name="TEAM_ID")
   	private Team team;
    
    public void setTeam(){
    }
}

public class Team{
	@Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
}
  • @ManyToOne : 다대일 관계라는 매핑 정보. 연관관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션을 필수로 사용해야 한다.
  • @JoinColumn(name="~") : 조인 컬럼은 외래 키를 매핑할 때 사용한다. 외래 키로 연관관계를 맺으므로 이 값을 지정하면 된다. 생략 가능하나 default로 설정된 "필드명_참조하는 테이블의 컬럼명"으로 외래 키를 찾는다.

*주의 : 이를 활용해 엔티티를 저장할 때 member.setTeam(team)을 하기 위해서는 반드시 team이 영속 상태여야 한다. 또한, 기존에 존재하던 Team을 삭제하기 위해선 기존에 있던 연관관계를 제거해야 한다. 즉, 위 예시로는 em.remove(team)을 하기 전에 member.setTeam(null)을 해주어야만 한다.

 

 

*참고: 보통 다대일 관계라면 다쪽에 FK를 둔다. ( 일 쪽에 FK가 있다고 생각해보자. 일 쪽은 많은 FK를 가지고 있어야 하고, 데이터의 중복이 생기게 될 것이다. )

 

 

양방향 연관관계

반대로 팀에서 멤버를 조회하는 것은 어떻게 할 수 있을까?

 

멤버와 팀은 다대일 관계였던 반면 팀에서 회원은 일대다 관계이다.

일대다 관계는 여러 건과 연관관계를 맺을 수 있으므로 컬렉션(List)을 사용해야 한다.

 

즉, 매핑시켜보면 다음과 같다.

public class Member{
	@Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

	@ManyToOne
    @JoinColumn(name="TEAM_ID")
   	private Team team;
    
    public void setTeam(){
    }
}

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

이제 team.getMembers()를 통해 팀과 연관된 모든 멤버를 가져올 수 있다.

 

연관관계의 주인 (mapped by)

테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다.

엔티티를 단방향으로 매핑하면 참조를 하나만 사용하므로 이 참조로 외래 키를 관리하면 된다.

그러나 엔티티를 양방향으로 매핑하면 팀에서도 멤버를 참조하여 객체의 연관관계를 관리하는 포인트가 2곳이 된다.

 

엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나이기 때문에 둘 사이에 차이가 발생한다.

즉, 둘 중 하나의 엔티티만 외래 키를 관리해야 하고 이를 연관관계의 주인이라 한다.

 

이렇게 설정된 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있다.

주인이 아닌 반대편은 read만 가능하고 외래 키를 변경할 수 없다.

 

연관관계의 주인은 mappedBy 속성에 의해 정해진다.

  • 주인은 mappedBy 속성을 사용하지 않는다.
  • 주인이 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.

mappedBy 속성은 양방향 매핑일 때 사용되고, 반대쪽 매핑의 필드 이름을 값으로 주면 된다.

즉, mappedBy를 통해 연관관계의 주인이 아님을 설정하면 된다.

 

외래 키를 관리할 연관관계의 주인을 그렇다면 member로 설정해야 할까 team으로 설정해야 할까?

위에서 언급했지만 보통 외래 키는 다 쪽에 있다.

 

만약 다 쪽을 연관관계의 주인으로 설정한다면 자기 테이블에 있는 외래 키를 관리하면 된다.

하지만 일 쪽을 연관관계의 주인으로 설정하면 전혀 다른 테이블에 있는 외래 키를 관리해야 한다.

 

데이터 베이스 테이블의 관계에서는 항상 다 쪽이 외래 키를 가진다. 즉, 다 쪽인 @ManyToOne은 항상 연관관계의 주인이므로 mappedBy 속성을 가지고 있지 않다.

 

 

양방향 연관관계의 저장

양방향 연관관계에서 연관관계의 주인은 항상 다 쪽에 있고, 연관관계의 주인이 아닌 쪽은 read만 가능하다고 하였다.

CRUD 할 때도 마찬가지다.

 

저장할 때 Team의 members에는 따로 멤버를 add하지 않아도 연관관계의 주인인 멤버에만 setTeam을 해주면 데이터베이스에 제대로 저장된다.

 

그러나 반대로 team.members.add(member) 후에 실제 멤버에는 setTeam()을 하지 않으면 연관관계의 주인이 아닌 team에서만 연관관계를 명시했으므로 데이터베이스에 들어간 member에는 FK가 null로 설정되어있다.

 

그러나, 객체를 고려한다면 양쪽 다 관계를 맺어주는 것이 좋다. 데이터베이스에 들어가기 전에 실질적으로 team.members에는 member가 들어가 있지 않기 때문에 DB는 고려가 되어있지만 객체는 고려가 되어있지 않다.

즉, member.setTeam(team)을 하고, 바로 team.members.size()를 조회하면 당연하지만 0일 것이다. 데이터베이스에 들어가기 전까지는 아직 두 객체의 양방향 연관관계가 연결되어있지 않기 때문이다.

 

즉, 객체까지 고려한다면

member.setTeam(team)//연관관계의 주인

team.members.add(member)//저장 시 사용되지 않는다. 메모리 상에서만 사용된다.

둘 다를 해주는 것이 좋다.

 

연관관계 편의 메소드

이렇게 메소드를 두 개 작성하는 것은 각각 호출하다 보면 하나를 빼먹을 수도 있다. 즉, 두 코드를 하나인 것처럼 사용하는 것이 안전하다.

Member 클래스의 setTeam() 메소드를 수정해서 코드를 리팩터링 해보자.

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

이렇게 리팩터링 하면 두줄의 코드를 한 줄 만에 간편하게 바꿀 수 있다.

 

그러나 다음 상황을 생각해보자.

member1.setTeam(teamA);
member1.setTeam(teamB);
Member findMember = teamA.getMember();

멤버의 team을 바꾸었지만 teamA에서는 당연히 member1이 아직 존재할 것이다.

 

즉, teamA에 존재하는 멤버를 삭제하기 위해 다음과 같이 setTeam의 코드를 바꾸어야 한다.

public void setTeam(Team team){

	if(this.team != null){
    	this.team.getMembers().remove(this);
    }
	this.team = team;
    team.getMembers().add(this);
}

 

*주의 : 양방향 매칭 시에는 무한 루프에 빠지지 않게 조심하여야한다. 예를 들어, toString()으로 member에서 getTeam()을 호출하고, Team.toString()에서 getMember를 호출하면 무한루프에 빠질 수 있다. Lombok 메소드 이용 시 자주 일어난다.

정리

  • 단방향 매핑만으로 테이블과 객체의 연관관계 매핑은 완료된다.
  • 단방향을 양방향으로 만들어 반대방향으로 객체 그래프 탐색 기능이 추가된다.
  • 양방향 연관관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야 한다.
  • 연관관계의 주인만이 외래 키를 등록, 수정, 삭제할 수 있다.

 

728x90