양방향 연관관계 사용 시 주의점

땡글이·2023년 3월 24일
0

JPA

목록 보기
8/9

JPA를 사용하다보면, 단방향 연관관계와 양방향 연관관계를 사용한다. 나 또한 그렇게 많이 사용했었다. 하지만, 양방향 연관관계를 사용할 때는 정말 조심해야 한다! 조심해야되는 부분들에 대해 정리해보고자 한다.

양방향 연관관계란?

우선 연관관계의 개념에 대해 알아보자. 연관관계는 객체의 참조와 테이블의 외래 키가 매핑돼서 맺어진다. 연관관계의 종류로는 다음과 같다.

  • 단방향 연관관계
  • 양방향 연관관계

데이터베이스에서는 외래 키(FK)를 이용해서 양방향으로 연관관계를 가질 수 있다. 아래의 두 가지 SQL문이 데이터베이스의 테이블은 외래 키(FK) 하나로도 양방향으로 동작할 수 있다는 예시이다.

  • select * from Member m join Team t on m.team_id = t.team_id
  • select * from Team t join Member m on m.team_id = t.team_id

하지만 객체는 그렇지 않다! 단방향 참조만이 가능하다. 그래서, 객체에서도 단방향 연관관계 2개(회원 -> 팀, 팀 -> 회원)를 만들어 양방향 연관관계 를 구현하는 것이다.

양방향 연관관계의 규칙

다음으로 양방향 연관관계의 규칙들을 살펴보자.

  • 연관관계를 가지는 객체 중 하나를 연관관계의 주인으로 지정
  • 외래 키가 있는 쪽을 연관관계 주인으로 정함
  • 주인이 아닌 쪽은 읽기만 가능
    • Cascade, orphanRemoval 와 같은 영속성 전이, 고아 객체의 개념도 포함해서 말하면, 쓰기, 삭제 작업도 가능해짐
  • 주인이 아닌 쪽은 mappedBy 속성으로 연관관계의 주인을 지정

양방향 연관관계에서 주인이 아닌 엔티티(Team)에서 List<Member> 변수에 어떤 행동을 취해도 변화가 일어나지 않는다!

다만, 규칙에서도 말했듯, 영속성 전이(Cascade)나 고아객체제거(orphanRemoval) 옵션의 값도 같이 고려되면 List<Member> 변수에 어떤 행동을 취함으로써 생성, 삭제 등이 가능해진다.

양방향 연관관계 사용 시, 주의점

@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

...

@Entity
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}
Team team = new Team("teamA");
em.persist(team);

Member member = new Member("member1");

// 역방향만 연관관계 설정
team.getMembers().add(member);

em.persist(member);

이렇게 되면, 어떻게 될까?? 당연히 member 테이블의 team에 대한 외래 키(FK)는 null이 된다. 왜? 연관관계의 주인(Member)에서는 어떠한 작업도 해주지 않았기에 연관관계 설정이 된지 전혀 모르는 것이다.

기억하자! 양방향 연관관계여도 연관관계의 주인에서 항상 연관관계를 설정해줘야 한다는 것을 잊지말자! 양방향 연관관계는 객체 그래프 탐색을 편리하게 하기 위한 방법 중 하나이다.

양방향 연관관계의 허점

양방향 연관관계는 복잡한 어플리케이션에서 사용하다가 괜히 유지보수에 애를 먹게 될 수 있다. 하나의 예시를 봐보자.

@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;


    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;
        this.team = team;

//        if (team != null)
//            changeTeam(team);
    }
    
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}
    @Test
    public void testEntity() {
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);

        Member member3 = new Member("member3", 30, teamB);
        Member member4 = new Member("member4", 40, teamB);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);

        // 초기화
        em.flush();
        em.clear();

        List<Team> teams = em.createQuery("select t from Team t", Team.class)
                .getResultList();

        for (Team team : teams) {
            System.out.println("team = " + team.getName());
            for (Member member : team.getMembers()) {
                System.out.println(" > member = " + member);
            }
        }

    }
[출력문]
team = teamA
 > member = Member(id=3, username=member1, age=10)
 > member = Member(id=4, username=member2, age=20)
team = teamB
 > member = Member(id=5, username=member3, age=30)
 > member = Member(id=6, username=member4, age=40)

잘 동작한다. 문제가 없다.

해당 로직에서 N+1문제가 발생하지만, N + 1문제에 대해선 여기서 언급하지 않고 넘어간다. N+1문제를 해결하기 위한 방법에 대해 알고싶다면 해당 포스팅을 참고해주시기 바랍니다 :)

이제 아래의 예제를 살펴보자.

    @Test
    public void testEntity() {
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);

        Member member3 = new Member("member3", 30, teamB);
        Member member4 = new Member("member4", 40, teamB);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);

        // 초기화
//        em.flush();
//        em.clear();

        List<Team> teams = em.createQuery("select t from Team t", Team.class)
                .getResultList();

        for (Team team : teams) {
            System.out.println("team = " + team.getName());
            for (Member member : team.getMembers()) {
                System.out.println(" > member = " + member);
            }
        }

    }
[출력문]
team = teamA
team = teamB

엥? Team 엔티티의 List<Member> 변수에 연관관계가 있는 Member 엔티티가 존재하지 않는다. 분명 Member 엔티티에서 연관관계를 설정(this.team=team)해줬고, 데이터베이스에도 연관관계가 잘 설정되어있고 Member 엔티티를 영속화시켜줬는데도 Team 엔티티에서 역으로 객체 그래프 탐색이 되지 않는다...

사실 잘 생각해보면 당연하다. 이게 데이터베이스와 객체가 맺는 연관관계의 차이점으로 만들어지는 허점이다. 위의 코드는 Member엔티티에서 연관관계를 설정해줬지만 Team 엔티티에서 List<Member> 변수에는 해당 엔티티 정보를 추가해주지 않았다. 그래서 영속성 컨텍스트에서는 Team 엔티티에서 역으로 객체 그래프를 탐색할 수 없었던 것이다.

정리

즉 정리해보자면, 만약 엔티티를 영속화시키고 영속성 컨텍스트를 비웠다면 Team엔티티에 대한 정보가 없으니 데이터베이스로부터 데이터를 가져오고, List<Member>변수에 접근해서 Member의 필드에 접근한다면 지연로딩으로 Team과 연관관계를 가지는 Member 엔티티를 조회할 수 있다.
하지만, 엔티티를 영속화시키고 영속성 컨텍스트를 비우지 않았다면, Team엔티티에 대한 정보가 영속성 컨텍스트에 계속 남아있으므로 영속성 컨텍스트 안의 Team 객체에 접근해서 List<Member> 변수로 연관관계를 가지는 Member객체들을 탐색한다.

그렇기에, 양방향 연관관계를 설정하고 개발할 때에는 이런 문제가 생기지 않도록 연관관계 양쪽에서 관리를 철저히 해주어야 한다. 처음부터 Member 엔티티에서 Team과의 연관관계를 설정할 때, 아래와 같이 구현했다면 이런 문제점은 없앨 수 있다.

@Entity
public class Member {

	...	
        
    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;

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

다들 양방향 연관관계를 사용하실 때 주의해서 사용하세요~

profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

0개의 댓글