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

[JPA] 양방향 연관관계와 연관관계의 주인

by hk27 2022. 2. 4.
두 엔티티가 양방향 연관관계를 가져도, 테이블은 변하지 않습니다.

 

안녕하세요.

JPA의 양방향 연관관계에와 연관관계의 주인 대해서 알아보겠습니다.

 

지난 게시글에서 단방향 연관관계를 매핑하고 CRUD를 수행하는 방법을 알아보았습니다.

이어지는 내용이니 관심있는 분들은 아래 게시글을 참고해주세요.

[JPA] 단방향 연관관계

 

양방향 연관관계

두 객체가 서로를 참조할 때, 양방향 연관관계라고 합니다. 

두 객체가 서로의 정보를 찾는 코드가 많을 때, 양방향 연관관계를 갖게 설계하면 코드를 간결하게 사용할 수 있습니다. 

아래 사진은 멤버 객체도 팀 객체를 참조하고, 팀 객체도 멤버 객체를 참조하는 경우입니다. 

 

 

테이블은 외래 키 하나로 양방향으로 조회합니다. 객체가 단방향 연관관계를 가질 때도, 테이블은 양방향 연관관계였습니다. 따라서 객체가 양방향 연관관계를 갖는다고 해서, DB에 추가할 내용은 전혀 없습니다.

 

양방향 연관관계 매핑

엔티티가 양방향 연관 관계를 가질 때, 어떻게 매핑하는지 알아봅시다.

 

회원 엔티티

@Getter @Setter
@Entity
public class Member {
    @Id
    @Column(name="MEMBER_ID")
    private String id;
    private String username;

    @ManyToOne
    @JoinColumn(name="TEAM_ID")
    private Team team;

    public Member() {}

    public Member(String id, String username) {
        this.id = id;
        this.username = username;
    }
}

회원 엔티티는 단방향일 때와 동일합니다.

 

팀 엔티티

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

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    public Team() {}

    public Team(String id, String name) {
        this.id = id;
        this.name = name;
    }
}

9~10번째 줄의 코드가 추가되었습니다. 

팀 입장에서는 여러 명의 회원이 연관될 수 있으므로 팀과 회원은 일대다 관계입니다. 따라서 팀은 멤버 리스트를 갖습니다. 

@OneToMany는 일대다 관계임을 나타내기 위해서 사용합니다. 

mappedBy 속성은 양방향 매핑일 때 사용하며, 반대쪽 엔티티의 필드 이름을 값으로 주면 됩니다.

Member.team 객체와 매핑되므로 team을 값으로 주었습니다. 

 

이제 팀 객체에서도 멤버 리스트를 조회할 수 있습니다.

public void findMembers(){
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1);
    em.persist(member1);

    Member member2 = new Member("member2", "회원2");
    member2.setTeam(team1);
    em.persist(member2);

    em.flush();
    em.clear();

    Team team = em.find(Team.class, "team1");
    List<Member> members = team.getMembers();
    for (Member member : members) {
        System.out.println("member.getUsername() = " + member.getUsername());
        // member.getUsername() = 회원1
        // member.getUsername() = 회원2
    }
}

 

연관관계 주인: 외래 키를 관리하는 참조

@OneToMany(mappedBy = "team")에서, mappedBy는 무슨 역할을 할까요?

질문에 답하기 위해서는 연관관계의 주인이라는 개념을 먼저 알아야 합니다.

연관 관계 주인은 JPA 계의 포인터 어려운 개념이므로, 하나씩 천천히 알아보겠습니다. 

 

먼저 객체와 테이블이 연관관계를 맺는 차이를 이해해야 합니다.

위의 사진을 보면, 객체도 테이블도 양방향으로 연관 관계를 맺는 것 같지만 사실은 그렇지 않습니다.

객체에는 양방향 연관관계라는 것이 없습니다. 

객체는 기본적으로 단방향 연관관계이고, 양방향 연관관계는 단방향 연관관계 2개를 설명하기 위해 사용하는 표현입니다.

즉 멤버 -> 팀, 팀 -> 멤버라는 단방향 연관관계가 2개인 것입니다.

그러나 테이블은 PK값만으로 양방향 연관관계를 갖고, 회원 <-> 팀은 양방향 연관관계 1개를 갖습니다. 

 

그렇다면 어려움이 있습니다.

멤버 객체의 team필드와, 팀 객체의 members 필드 중 무엇을 참고해서 FK 값을 변경해야 할까요?

두 객체의 정보가 맞지 않으면 DB 데이터에 오류가 생길 수 있습니다.

따라서 양방향 매핑을 할 때는, 객체의 두 관계 중 하나를 연관관계의 주인으로 지정해서

연관관계의 주인만 외래 키를 관리하고(등록, 수정) 주인이 아닌 쪽은 읽기만 지원합니다.

주인에는 mappedBy 속성을 사용하지 않고, 주인이 아직 쪽에만 mappedBy 속성을 사용합니다. 

 

그렇다면 멤버와 팀 중 무엇을 주인으로 정해야 할까요?

규칙은 외래 키가 있는 곳을 주인으로 정하는 것입니다. 보통 외래 키를 갖는 @ManyToOne이 주인이 됩니다. 

외래 키가 있는 곳의 객체에서 값이 변경될 때 DB에 반영합니다.

만약 외래 키가 아닌 곳을 주인으로 정하면, 주인의 필드에 값이 바뀔 때 외래 키가 있는 다른 테이블의 값을 변경해야 하는데 이것은 어색합니다.

외래 키가 있는 곳을 주인으로 정해서, 주인의 필드에 값이 바뀌면 주인과 연결된 테이블의 외래 키 값을 변경합니다.

예를 들어서 멤버와 팀의 관계에서는, 외래 키를 갖는 멤버가 연관관계의 주인입니다. 

 

 

연관관계의 주인이 아니면 읽기만 가능하기 때문에, 팀의 members에 값을 넣어도 DB에 변경 사항이 반영되지 않습니다.

팀에 연결된 멤버 정보를 바꾸고 싶으면, 연관관계의 주인인 멤버의 team 필드가 변경되어야 DB에 update 쿼리가 전송됩니다. 

 

 

양방향 연관관계의 주의점 

연관관계의 주인에 값을 입력하지 않으면 데이터가 반영되지 않습니다.

이 실수는 양방향 매핑 시 가장 많이 하는 실수입니다.

코드를 함께 봅시다.

연관관계의 주인이 아닌 Team의 members에만 값을 저장했습니다. 

public void findMembersError(){
    Member member1 = new Member("member1", "회원1");
    em.persist(member1);

    Member member2 = new Member("member2", "회원2");
    em.persist(member2);

    Team team1 = new Team("team1", "팀1");
    team1.getMembers().add(member1);
    team1.getMembers().add(member2);
    System.out.println("team1 members size = "+team1.getMembers().size()); // team1 members size = 2
    em.persist(team1);

    em.flush();
    em.clear();

    Team findTeam = em.find(Team.class, "team1");
    System.out.println("findTeam members size = "+findTeam.getMembers().size()); // findTeam members size = 0
}

 

객체를 양방향 연관관계로 매핑했으니 팀의 members 값만 지정되어도 멤버의 team 값이 반영되어야 할 것 같지만, 그렇지 않습니다.

팀의 members는 연관관계의 주인이 아니기 때문에 외래 키의 값을 변경할 수 없고, 원하는 대로 동작하지 않습니다. 

 

양방향 매핑 시에는 연관 관계의 주인에 값을 입력해야 합니다. 

연관 관계의 주인에만 값을 저장해도, 양방향으로 매핑됩니다.

아래 코드를 봅시다.

public void findMembers(){
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1);
    em.persist(member1);

    Member member2 = new Member("member2", "회원2");
    member2.setTeam(team1);
    em.persist(member2);

    System.out.println("team1 members size = "+team1.getMembers().size()); // team1 members size = 0

    em.flush();
    em.clear();

    Team findTeam = em.find(Team.class, "team1");
    System.out.println("findTeam members size = "+findTeam.getMembers().size()); // findTeam members size = 2
    System.out.println("team1 members size = "+team1.getMembers().size()); // team1 members size = 0
}

Member에만 team을 저장하고 Team에는 members를 저장하지 않았지만 em.find로 팀을 찾으면 멤버 2명이 조회됩니다. 

JPA가 mappedBy를 보고 멤버에 select문을 날려 team_id 값으로 멤버 엔티티를 찾아 객체를 만듭니다.

DB의 멤버 테이블에 TEAM_ID도 잘 저장됩니다. 

 

 

연관관계의 주인에만 값을 저장한 위의 코드도 잘 동작하지만, 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하는 것이 좋습니다. 위의 코드에서 team1 객체의 멤버 수는 2인데, DB에서 찾아온 findTeam 객체의 멤버 수는 0입니다. 

이런 경우 JPA를 사용하지 않는 순수한 객체 상태에서 심각한 문제가 발생할 수 있기 때문에, 양방향인 경우 연관 관계를 저장할 때 양쪽 객체 모두에 값을 저장하는 것이 바람직합니다.

private static void 양방향(){

    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1); // 연관관계의 주인
    team1.getMembers().add(member1); // 연관관계의 주인이 아님. 저장 시 사용되지 않음
    em.persist(member1);

    Member member2 = new Member("member2", "회원2");
    member2.setTeam(team1); // 연관관계의 주인
    team1.getMembers().add(member2); // 연관관계의 주인이 아님. 저장 시 사용되지 않음
    em.persist(member2);

    System.out.println("team1 members size = "+team1.getMembers().size()); // team1 members size = 2

    em.flush();
    em.clear();

    Team findTeam = em.find(Team.class, "team1");
    System.out.println("findTeam members size = "+findTeam.getMembers().size()); // findTeam members size = 2
    System.out.println("team1 members size = "+team1.getMembers().size()); // team1 members size = 2
}

 

연관관계 편의 메소드

따라서 양방향 연관관계에는 양쪽 객체에 값을 입력하기 위해서 두 메소드를 함께 이용하는 경우가 많습니다. 

member1.setTeam(team1); // 연관관계의 주인
team1.getMembers().add(member1); // 연관관계의 주인이 아님. 저장 시 사용되지 않음

문제는 실수로 둘 중 하나만 호출하면 양방향이 깨질 수 있습니다.

따라서 두 코드를 하나인 것처럼 사용하는 것이 안전합니다.

 

Member 클래스에 메소드를 추가해봅시다. 

@Entity
public class Member {
    public void changeTeam(Team team){
        if(this.team != null){
            this.team.getMembers().remove(this);
        }
        this.team = team;
        team.getMembers().add(this);
    }
}

 

기존에 연결된 팀이 있었다면 연관관계를 삭제하고, Member의 team에도, Team의 members에도 값을 반영합니다.

이렇게 양방향 연관관계를 한번에 저장하는 메소드를 연관관계 편의 메소드라고 합니다.

양방향 연관관계를 사용할 때는, 꼭 연관관계 편의 메소드를 사용합시다. 

 

무한 루프

양방향 매핑 시에는 무한 루프를 조심해야 합니다.

toString()이나 JSON 생성 라이브러리를 사용할 때 주의해야 합니다.

예를 들어서 멤버와 팀 클래스에 모두 ToString 어노테이션을 붙여봅시다.

@Entity
@ToString
public class Member {}

@Entity
@ToString
public class Team {}

System.out.println(member1);

 

멤버를 출력하면 무슨 일이 일어날까요? 

멤버를 출력하기 위해서 팀 필드를 출력하려고 하면, 팀 객체를 출력해야 합니다.

그러나 팀 객체에도 toString이 있으면 팀 객체의 멤버 필드를 출력해야 하고, 무한 루프가 발생해 스택오버플로우 에러가 납니다. 

따라서 양방향 매핑 시에는 toString과 JSON 생성 라이브러리를 사용하면 안 됩니다.

 

 

양방향 매핑 정리

단방향 매핑만으로도 이미 연관관계 매핑은 완료됩니다. 

양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐, 테이블을 바꾸지 않습니다. 

처음 설계는 단방향 매핑으로 하고, 개발 시 양방향을 고려합니다.

JPQL 쿼리를 작성할 때 역방향으로 탐색할 일이 많거나, 반대 방향 조회가 정말 필요할 때만 양방향 매핑을 추가합시다.

 

 

인프런  '자바 ORM 표준 JPA 프로그래밍 - 기본편' 강의를 듣고 공부하며 정리한 자료입니다. 

잘못된 부분은 피드백 주시면 감사하겠습니다. 

글 읽어주셔서 감사합니다. 

 

참고 자료

자바 ORM 표준 JPA 프로그래밍 - 기본편 섹션 5. 연관관계 매핑 기초, https://www.inflearn.com/course/ORM-JPA-Basic

김영한, 자바 ORM 표준 JPA 프로그래밍, 에이콘출판(2015), pp.178-195.

댓글