13 min read

<JPA> 연관관계 매핑 기초

지금부터 JPA 문법의 꽃이라고 볼 수 있는 연관관계 매핑에 대해 알아보겠습니다.본 포스팅은 단방향 및 양방향 관계에 대한 포스팅이 이루어질 예정입니다.우선 객체와 테이블을 유기적으로 연결해주는 문법이 ORM이며,자바로 이를 구현한것 JPA입니다.그래서 객체의 경우 서로 다른 객체에 접근할때 참조를,테이블은 서로 다른 테이블에 접근할때 join이라는 문법을 사용하신다는것을 이해하고 포스팅을 읽어주시면 됩니다.

DB설계 상황

  • Entity : Member,Team
  • 연관관계 : Member와 Team은 다대일인 상황입니다.

단방향 연관관계

  • 우선 RDB에서 일대다 관계에 대해 먼저 이해해야합니다.
  • 쉽게 생각하면,多에 해당하는 엔티티가 一에 해당하는 엔티티에 대한 식별자를 가져야하기 때문에 FK를 가진다고 이해하시면 됩니다.즉,여기서는 Member라는 엔티티가 Team의 PK값을 FK로 가지게 됩니다.
  • 아래 코드는 단순히 Member가 FK값을 실제 필드값(teamId)으로 가지게 설계했습니다.
@Setter
@Getter
@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private Long teamId;
}
@Setter
@Getter
@Entity
public class Team {
    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String teamName;
}
  • 이 상황에서 아래 코드를 실행시켜 디비에 쿼리를 날려봅시다.
			Team team = new Team();
            team.setTeamName("Pizza");
            em.persist(team);

            Member member = new Member();
            member.setName("brido");
            member.setTeamId(team.getId());
            em.persist(member);

            tx.commit();
  • 아래와 같이 수작업을 통해 넣은 FK가 잘 들어간것을 볼 수 있습니다.
  • 여기서 em.find를 통해 member 인스턴스의 Team 객체를 어떻게 얻어올 수 있을까요?em.find(Team.class,member.getId()); 명령어를 사용해서 디비로부터 데이터를 꺼내올 수 있습니다.
  • 어느정도 객체지향에 대한 이해를 하고 계신분들이라면 위의 과정은 전혀 객체지향스럽지 않음을 알 수 있을것입니다.예를 들어 설명하면 Member를 통해 팀의 이름을 찾고 싶을 경우 굉장히 번거로운 절차를 거쳐야 하기 때문입니다.(em.find를 통해 멤버 객체를 찾고 한번 더 레퍼런스 조회 진행)
  • 그래서 아래와 같이 Member 엔티티의 필드값을 객체지향스럽게 바꾸는 문법을 사용합니다.team의 PK(teamId)를 Member 객체의 필드값으로 받지 않고 Team 객체 자체를 필드값으로 받으면 됩니다.
  • 하지만 그냥 Team 객체를 선언만 하면 아래아 같이 Enity attribute is not marked with association annotation이라는 에러가 발생합니다.
  • 여기서 바로 연관관계 매핑이 필요합니다.
@Setter
@Getter
@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private Team team;
}
  • 단방향 연관관계 매핑을 진행한 코드로 바꿔보겠습니다.
//...
public class Member {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

	@ManyToOne
	@JoinColumn(name = "team_id")
    private Team team;
}
  • @ManyToOne 어노테이션은 해당 어노테이션을 사용하는 엔티티 기준으로 RDB관계를 적용한것이라고 보면됩니다.즉,여기서는 Member가 多이기 때문에 Many로 시작하고 Team의 경우 Member 기준 一이기 때문에 many to one이라고 이해하시면 될것 같습니다.
  • @JoinColumn은 SQL의 Join 문법을 사용하기 위해 FK(식별자)가 필요한데 이를 알려주는 어노테이션이라고 보면 됩니다.
  • 그러면 위와 같이 엔티티를 설정하고 em.persist를 진행해봅시다.
			Team team = new Team();
            team.setTeamName("Pizza");
            em.persist(team);

            Member member = new Member();
            member.setName("brido");
            member.setTeam(team);
            em.persist(member);

            tx.commit();
  • 실행시킨 결과는 아래와 같이 team_id를 FK로 삼아 테이블이 구성됨을 볼 수 있습니다.
  • 지금까지 단방향 연관관계에 대해서 설명드렸습니다.그러면 여기서 단방향 연관관계만 맺을 수 있는 연관관계는 무엇일까요?우선 위의 예처럼 @ManyToOne은 당연히 가능합니다.그 반대인 @OneToMany는 가능할까요?
  • 이에 대한 해답을 아래의 양방향 연관관계를 배우면서 유추해봅시다.

양방향 연관관계와 연관관계의 주인

  • 우선 테이블간의 연관관계부터 이해하면 양방향 연관관계를 이해하기 쉽습니다.
  • 테이블의 경우 처음부터 PK를 FK로 넣어주기만 하면 두개의 테이블간의 서로서로 join문법을 통해 데이터에 접근할수 있습니다.즉 애초에 방향성이라는 것이 없다고 봐도 됩니다.
  • 하지만 객체의 경우 이전까지의 예제처럼 Member에다가 Team 엔티티만 넣어두면 Member에서의 Team으로의 접근은 가능하지만 Team에서 Member로의 접근은 불가능합니다.
  • 이러한 점을 개선시키고자 나온것이 양방향 연관관계라는 것 입니다.단방향이던 관계를 양방향으로 확장시켜보겠습니다.Member엔티티는 변경할 곳이 없습니다.
public class Team {
    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String teamName;

    @OneToMany(mappedBy = "team")
    List<Member> members = new ArrayList<>();
}
  • 우선 Team이라는 객체가 Member 엔티티들을 리스트로 받아서 접근하려 합니다.
  • 여기서 @OneToMany의 경우 어노테이션의 기준이 되는 Team 엔티티가 一이고 이와 연관관계를 맺는 Member가 多이기 때문에 One to Many가 적용됩니다.
  • 이제 내부의 mappedBy = “team”에 대해 설명해드리겠습니다.
  • 설명에 앞서 아래의 RDB 다이어그램을 봅시다.보시면 Team의 PK인 TEAM_ID가 Member 테이블의 FK로 넘어가면서 테이블간의 join 연산에 활용됩니다.그러면 Member에게 Team에 대한 식별자는 FK인 TEAM_ID가 됩니다.
  • 이러한 관계를 JPA도 동일하게 가져왔다고 생각해봅시다.즉,Team에서 Member로의 접근을 위해 사용하는 FK처럼 mappedBy를 통해 Member 클래스내에 존재하는 Team 인스턴스인 team에 접근하는것입니다.
  • 이는 또한 두 엔티티간의 변동사항은 모두 식별자를 기준으로 일어난다는것을 의미합니다.예를 들어 Team의 멤버가 변경되는 경우,Team 클래스의 리스트에 있는 멤버를 변경해야할까요?Member 클래스의 team 인스턴스를 변경해야 할까요?
  • 전자를 진행할게 될 경우 Team 객체를 update하는 쿼리를 날리려는 의도임에도 실제 SQL은 Member에 대한 update 쿼리가 발생할 것입니다.하지만 후자의 경우 RDB와 일치하게 Member 테이블에 upate 쿼리가 발생할 것입니다.
  • 결론적으로 방금 설명드린 RDB와의 쿼리가 발생하는 테이블의 일치성 및 RDB의 FK(식별자)를 도입하기 위해 Member객체의 team 인스턴스로 mappedBy해주어 양방향 연관관계를 완성시킵니다.
  • 또한 위와같이 mappedBy의 대상이 되는 인스턴스가 있을텐데 이를 연관관계의 주인이라고 이해하시면 됩니다.위의 예시에서는 Member.team이 식별자(FK)이자 객체의 변경지점이기 때문에 연관관계의 주인입니다.연관관계의 주인라는 단어를 곱씹으며 의미를 생각하면 이해가 가실것입니다.
  • 그러면 단방향 연관관계의 마지막절에서 했던 질문에 대한 답을 해드리겠습니다.xToMany의 연관관계의 경우 연관관계의 주인이 필수적으로 존재해야하기 때문에 단방향 연관관계가 성립할 수는 있습니다.하지만 mappedBy옵션을 매길 객체가 없기에 @JoinColum을 통해 식별자를 넣어줘야합니다.이로인해 쿼리가 mappedBy를 사용할때보다 많이 발생하게 됩니다.결론적으로 사용을 할 수는 있지만 안하는게 좋습니다.그와 반대로 xToOne의 연관관계들은 단방향으로 엔티티간의 연관관계를 맺을 수 있습니다.(ManyToOne 또는 OneToOne)
  • 해당 개념을 처음 접하시는 분들은 당연히 아리송하실텐데,사실 필자의 경우에도 처음 JPA로 어플리케이션을 만들때 위와같은 개념을 완벽히 정립하지 않은채로 시작했습니다.따라하면서 만들다보면 RDB와 동일한 노선을 가기위해 JPA 문법이 구성됨을 알 수 있을 것입니다.

양방향 연관관계 주의점 1

  • 지금부터 연관관계의 주인이 아닌 엔티티를 변경할 경우,생기는 오류를 알아봅시다.
			Member member = new Member();
            member.setName("brido");
            em.persist(member);

            Team team = new Team();
            team.setTeamName("Pizza");
            team.getMembers().add(member);            				em.persist(team);

            tx.commit();
  • team.getMembers().add(member);명령어는 연관관계의 주인이 아닌곳에 엔티티의 값을 변경시키고 있습니다.신기하게도 위의 코드를 실행시키면 insert문은 두 개 발생되며 정상적으로 동작하는 것처럼 보입니다.
  • 하지만 실제 디비를 까보면 아래와 같이 멤버 엔티티의 FK값인 TEAM_ID가 설정되어있지 않음을 볼 수 있습니다.
  • 그러면 아래와 같이 연관관계의 주인인 엔티티를 변경시켜주면 정상적으로 디비에 반영이 됩니다.
            
			Team team = new Team();
            team.setTeamName("Pizza");       						em.persist(team);			

			Member member = new Member();
			member.setName("brido");
			member.setTeam(team);
            em.persist(member);

            tx.commit();

양방향 연관관계 주의점 2(연관 관계 편의 메서드)

이번에는 조금 어려울 수도 있는 내용을 살펴보겠습니다.과연 아래의 코드에서 findMembers의 Member들을 정상적으로 디비에서 꺼내올 수 있을까요?

		Team team = new Team();
        team.setTeamName("Pizza");
        em.persist(team);

        Member member = new Member();
        member.setName("brido");
        member.setTeam(team);
        em.persist(member);
            
        em.flush();
        em.clear();

        Team findTeam = em.find(Team.class,team.getId());
        List<Member> findMembers = findTeam.getMembers();

        System.out.println("====================");
        for (Member findMember : findMembers) {
            System.out.println("findMember.getName() = " + findMember.getName());
        }
       	System.out.println("findMembers.size() = " + findMembers.size());
        System.out.println("====================");

        tx.commit();
  • 아래의 결과를 보면 너무 잘 들고오는것을 확인할수 있습니다.
  • 그러면 이번에는 em.flush()와 em.clear()를 주석처리한 후 코드를 실행시켜볼까요? 아래와 같이 findMembers를 우리가 의도한 대로 끌고오지 못하는것을 확인할수 있습니다.
  • 우선 이러한 차이점이 발생하는 원인에 대해 먼저 설명드리겠습니다.다들 아시다시피 em.flush()와 em.clear()를 하게 되면 1차 캐시에 존재하는 모든 엔티티들은 지워줍니다.그래서 첫번째 코드에서 em.find를 통해 얻은 Team 객체는 디비로부터 변경사항이 반영된 객체를 가져온것입니다.
  • 반면에 flush와 clear를 주석처리하고 나서 em.find를 하게되면 1차 캐시에 저장되어있는 Team 객체를 그대로 가져와 버립니다.즉,변경사항이 반영되지 않는 객체를 들고와버려서 위와 같은 결과가 발생하는 것입니다.
  • 그러면 위와 같은 상황을 어떻게 해결할 수 있을까요? 생각보다 간단합니다.객체와 테이블간의 간극을 메꿔주면 됩니다.현재 코드에서는 member 인스턴스의 setTeam()을 통해 member -> team으로의 객체간의 관계를 설정해줍니다.반대로 team -> member로의 객체간의 관계를 설정해주면 해결됩니다. team.getMembers().add(member); 코드를 em.find()전에 넣어볼까요? 그러면 당연하게도 findMembers의 크기가 1이 나오고 정상적으로 작동함을 볼수 있습니다.
  • 결론적으로 연관관계의 주인 -> 반대편으로의 객체 설정뿐만 아니라 반대편 -> 주인으로의 객체 설정을 해주면 해결됩니다.
  • 현재는 두번의 과정을 거쳐 객체 설정을 해주고 있는데 이를 조금 더 편하게 사용하기 위한 목적의 메서드가 연관관계 편의 메서드입니다.
  • 아래의 코드의 changeTeam 메서드입니다.
//...
public class Member {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

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

    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}
  • 실제 코드를 호출하는 부분에서는 member.changeTeam(team);처럼 간단하게 한번의 메서드 호출로 객체간의 설정을 완료해줍니다.
  • 참고로 연관관계편의메서드의 경우 네이밍을 setEntity보다는 change와 같은 단어를 사용해 setter와는 구별되는 중요한 메서드임을 나타내는 것이 좋습니다.

Ref : 김영한 - JPA 기본편