1. DB 테이블 중심으로 Entity 클래스를 설계했을 때의 문제점


다음과 같은 관계를 가지는 테이블을 가지고 설명하겠습니다.

[그림 1] N:1 관계를 가지는 테이블

DB 테이블에 맞춰 Member Entity 클래스를 설계하면 다음과 같이 될 것입니다.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long memberId;
    
    private String username;
    
    private Long teamId;
}

보시면 테이블의 컬럼에 맞춰서 Entity 클래스의 필드를 정의했습니다.
이러면 멤버와 멤버가 속한 팀을 조회할 때 멤버를 우선 조회한 후, 가져온 teamId 값으로 다시 Team 테이블을 조회해야 합니다.

Member findMember = entityManager.find(Member.class, 1L); // id가 1인 데이터가 있다고 가정
Long teamId = findMember.getTeamId();
Team findTeam = entityManager.find(Team.class, teamId);

그런데, 멤버와 팀의 관계를 객체 중심으로 생각해보면 아래와 같이 Member 클래스 내에 Team 타입의 필드를 두는게 맞습니다.

(DB 테이블은 외래 키로 조회하지만, 객체는 연관관계, 의존관계를 생각해보시면 참조를 이용해 값을 가져옵니다.)

@Entity
public class Member {
    ... 생략
    
    private Team team;
}

2. 단방향 연관관계


연관관계는 연관관계인데 단방향은 무엇을 말하는 걸까요?
위 DB 테이블을 보시면 Member 테이블에서 외래 키를 가지고 Team 테이블을 조회할 수 있고, 반대로 Team 테이블에서도 teamId를 가지고 Member 테이블을 조회할 수 있습니다. 즉, DB 테이블은 서로가 서로를 조회할 수 있는 양방향 관계라고 할 수 있습니다.

이제 객체 관점에서 생각해보겠습니다.
객체 관점에서 생각해보면 DB 테이블처럼 무조건 양방향일 필요는 없습니다. Member 클래스와 Team 클래스에 필드로 서로에 대한 참조를 넣어주면 양방향이 되는 것이고, 한 쪽에만 넣어주면 단방향이 되는 것입니다.

 

위 테이블처럼 N:1 관계에서 단방향을 설정하는 방법은 다음과 같습니다.

@Entity
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long memberId;
    
    private String username;
    
    @ManyToOne
    @JoinColumn(name = "teamId")
    private Team team;
}

Member와 Team은 N:1 관계로 Member는 N(여러개)에 속합니다. N -> 1로 가는 방향이기 때문에 @ManyToOne 이 됩니다.
다음으로 조인할 컬럼명(외래 키명)을 명시해줍니다. (N:1 관계에서 외래 키는 N쪽에 둡니다.)

// DB에서 JOIN문을 통해 한번에 가져옵니다.
Member findMember = entityManager.find(Member.class, 1L); // 키값이 1인 데이터가 있다고 가정
Team findTeam = findMember.getTeam();

위와 같이 .find() 로 Member를 가져오면 다음과 같이 LEFT OUTER JOIN 을 통해 Team 객체를 세팅합니다.

[그림 2] 연관관계에서 .find 시 실행되는 SQL

3. 양방향 연관관계


이제 Team에서도 Member를 조회하고 싶다고 해보겠습니다.
우선, Member 클래스는 위와 같고, Team 클래스는 다음과 같이 @OneToMany 애너테이션을 추가합니다.

@Entity
public class Team {
    ... 생략
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>(); // NPE 방지를 위해 바로 할당합니다.(관례)
}

@OneToMany 는 Team과 Member가 1:N 관계라서 그런건줄 알겠는데, 괄호 안에 mappedBy 속성은 무엇일까요?
이는 Team과 Member 테이블을 연결시키는 외래 키 컬럼과 매핑된 Member 객체의 필드명을 가리킵니다.

양방향 연관관계에선 주인(Owner)라는 개념이 있는데요, 아래 코드를 살펴보겠습니다.

[그림 3] Member, Team 모두 자신을 기준으로 잡고 값을 추가할 수 있다.

좌측은 Team 객체를 중심으로 Member를 추가하고, 우측은 Member 객체를 중심으로 Team을 설정합니다.
객체 기준으로 생각해보면 둘 다 말이됩니다. 그럼 DB 기준으로 한 번 생각해보겠습니다. 그림의 우측을 보시면, Member 테이블에 외래 키가 있기 때문에 딱 깔끔하게 떨어지는 반면, 좌측의 그림을 보시면 Team 테이블에는 외래 키가 없기 때문에 자신을 세팅하면서 타 테이블의 값을 수정해주는 형태를 가집니다.

 

좌측의 그림을 다시 한번 살펴보겠습니다.
Team 객체를 생성하고, 자신을 Team으로 가지는 Member 객체를 추가했습니다. 이게 SQL로 나갈려면 Team 테이블에 INSERT 쿼리가 나가고, Member 테이블에 UPDATE 쿼리가 나가야합니다. Team Entity 클래스는 분명 Team 테이블만을 가리키는데, Member 테이블의 값도 수정해버립니다. 🤔
이렇게 하나의 테이블을 가리키는 Entity 클래스에서 타 테이블의 값도 같이 수정해주는 모양이 부자연스럽고 오류가 날 여지가 많기 때문에 JPA에서는 이를 막아놨습니다.

 

즉, 연관관계의 주인(외래 키)가 없는 Team Entity 객체에서는 Member 테이블의 값을 수정하지 못 합니다.

 

아래 그림은 양방향 관계에서 JPA가 값을 담는 타이밍이 흥미로워 추가했습니다.
.find() 로 Member 객체를 가져오면 .getTeam().getMembers() 로 객체 그래프 탐색이 굉장히 유연하게 가능해졌습니다.
이때 Team 테이블과 연관된 테이블이 Member뿐만 아니라 여러 개라면 어떻게 될까요?
.find(Member.class) 로 멤버만 가져올 뿐인데 Team과 연관된 모든 객체를 SELECT해서 가져오기는 부담스럽습니다.
그래서 아래 그림을 보시면 Team 객체 안의 객체 필드는 사용하는 시점에 SELECT로 값을 채웁니다.

[그림 4] 값을 채우는 타이밍

양방향 연관관계일때 값 추가

위에서 살펴봤듯이, 연관관계 주인(외래 키 부분)이 있는 곳에서 수정이 가능하다고 했습니다.
그럼 아래 그림은 어떤 결과가 나올까요?

[그림 5] 연관관계의 주인이 있는 쪽에서 값을 추가하기

안타깝게도 저 경우 '유재석' 이란 데이터가 출력되지 못 합니다. (JPA가 저렇게 추가되는 데이터 하나하나를 다 확인 할 수는 없나봅니다..)
그래서 추가를 할 경우 Member와 Team 양쪽 모두에 값을 추가해줘야합니다.