
Member와 Team은 N:1 관계이다. 한 팀의 여러 멤버가 존재할 경우를 DB 테이블화 한다면 두 개의 테이블로 외래키를 Member쪽에 두어 TEAM_ID 컬럼을 두고 이를 foreign_key로 team의 id와 연결할 수 있다.
데이터베이스의 기본개념을 잠깐 설명하자면, 외래키를 N쪽에 할당하는 이유는 첫번째로는 중복을 피하기 위함에 있다.

위 테이블은 Marco의 신상내용과 그의 주문 데이터를 나타낸 것이다. 위의 표를 보니 Marco는 4번의 주문을 한 것으로 보인다. 위 주문 테이블에는 4개의 행이 쌓였는데 중복이 보인다. 마르코 신상정보를 마르코의 주문마다 넣어주고있는 것이다. 이를 외래키를 통해 테이블을 두 개로 분리하면 효율적으로 테이블을 구축할 수 있다.


이런식으로 중복을 피할 수 있다. 이때 외래키가 어디에 들어가야하는지 바로 보이게 된다. 이렇게 1:N관계에서 외래키는 당연히 "더 구체적인" N에 놓여야한다는 것이다.
테이블에서 외래키로 하여금 서로의 테이블을 연결하는 것은 매우 익숙한 상황이다. 하지만 객체지향의 관점에서는 조금 어색한 부분이 존재한다.
Member는 필드로 teamId를 가지는데 이것을 가지고 team을 조회해야하므로
em.find(Team.class, member1.getTeamId()
위와 같이 find()로 하여금 Team 객체를 가져와야 한다. 만약 JPA가 아닌 보통의 컬렉션이라고 한다면 우리는 애초에 Member객체를 다음과 같이 설계할 것이다.
public class Member {
private Long id;
private String username;
public Team team;
}
-> 활용 : member.getTeam().getName()
이렇게 Team의 필드로 연결하지않고 곧바로 객체를 연결하여 참조형을 활용하여 더 편리하게 다룰 수 있게 된다.
바로 이 지점에서 객체와 테이블간의 개념적 괴리가 발생한다. 객체를 테이블에 맞추어 설계했더니 객체를 객체처럼 사용할 수 없었다. 더 정확하게 설명하자면 자바의 객체의 연관관계 개념을 가장 합리적으로 사용할 수 없게 된다.
테이블은 외래 키 조인을 사용해서 연관된 테이블을 찾는 것이고 객체는 참조를 사용해서 연관된 객체를 찾는데 객체가 자신의 특징을 버리고 테이블 특징에 이를 맞춘 것이다.
테이블 설계시 외래키 설정으로 하여금 N쪽에 1에 대한 id를 두어 연결을 만들어낸다. 이렇게 외래키로 연결된 두 테이블은 양방향적으로 JOIN이 가능해진다.
반면 객체의 경우를 생각해보자.
public class Member {
private Long id;
private String username;
public Team team;
}
public class Team {
private Long id;
private String name;
}
Member에 Team객체를 필드로 두었을 때 Member를 통해 Team에 접근하는 것은 문제가 없어보이지만 Team에서 자신을 참조한 Member를 찾을 방법이 없다.
그럼에도 불구하고 일단 이 단방향 연결을 엔티티의 필드에 애노테이션을 적용하여 통해 완성해보자.
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@Column(name = "username")
private String username;
@ManyToOne
@JoinColumn(name = "team_id")
public Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
Member가 N이고 Team이 1이므로 @ManyToOne을 선언해줄 수 있다. 그래서 어떤 키와 연결하여 외래키를 설정할 것인지 @JoinColumn으로 설정한다. 이때 실제 테이블에 생성되는 외래키 컬럼또한 @JoinColumn의 name속성으로 지정한 해당 상대 연관 컬럼명과 동일하게 유지된다.
이렇게 ddl.auto를 통해 테이블을 생성쿼리를 관찰하면 외래키 관계가 잘 생성된 것을 볼 수 있다.
DB의 테이블은 이렇게 한쪽에서만 외래키를 걸어도 두 테이블이 모두 연결되어 서로를 Join할 수 있다.
JPA의 중요한 목적중 하나는 결국 DB를 이용하지만 엔티티를 자바의 객체지향적 특성을 잘 살릴 수 있도록 객체처럼 사용하는 것에 목적이 있다.
이 목적을 달성하기 위해, DB의 컨셉에 객체지향을 빼앗기지 않기 위해 엔티티들의 연관관계에 있어 양방향 연관관계를 걸어주어야 한다.
위의 Team 객체에서 Member를 조회할 수 있도록 다음과 같이 양방향 연관관계를 걸어준다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
N(멤버)를 객체에 들여오기 위해 리스트를 사용하고 ArrayList()로 즉각 초기화해준다.
Team(1) : Member(N)이기 때문에 @OneToMany 애노테이션을 걸어준다.
이때 가장 헷갈리고 어려운 개념이 mappedBy이다.
이 개념이 필요한 이유는 테이블에는 없는 개념이 양방향 연결이기 때문이다. 다시 말하지만 테이블은 외래키 연결 한번으로 두 테이블을 개념적 양뱡향 연결이 완료된다.
하지만 객체는 그 구현이 불가능하기에 단방향 연결 두개로 하여금 양방향 연결을 구현한다. 이때 문제는 양쪽에 서로가 존재하다보니 연결을 위한 셋팅에 있어서 혹은 연결을 변화할 때 양쪽에서 이 행위들이 모두 일어나면 의도된 목적들이 꼬일 수 있다는 것이다.
이때 mappedBy로 하여금 그것을 규칙화 할 수 있다.
mappedBy를 통해 team으로부터 매핑되었다.라는 것을 알려주어야 한다는 것이다. 즉 Team 객체에 존재하는 Member를 담은 이 리스트는 순전히 readOnly의 특성을 가지게 된다.
즉 mappedBy가 선언된 쪽의 반대쪽 엔티티가 연관관계의 주인이 되는 것이다. 연관관계의 주인에 해당되는 엔티티에서 해당 연결을 좌지우지할 수 있고 반대쪽은 오로지 연관관계가 형성된 다른 엔티티에 대해서readOnly상태가 되는 것이다.
연관관계의 주인은 항상 외래키가 존재하는 곳에 두도록 하자.
"자세한 것에 우선권"이 있는 자바의 문법적 특징과 비슷하다. 또한 인터페이스-구현체 관계를 생각해보아도 그렇다. 구체적인 쪽에서 관계를 걸어주는 것은 인터페이스-구현체에서도 관찰되는 현상이다. 확장성, 유지보수성을 생각하면 이러한 규칙을 시스템화하는 것은 옳다.
1:N 관계에서 더 구체적인 것은 당연히 N이다. 그렇기에 외래키도 N에 존재한다.
규칙을 자세하게 풀면 다음과 같다.
1:N 에서 N에 외래키가 존재하고 외래키쪽에 연관관계의 주인을 형성해야하므로 반대쪽인 1쪽에 mappedBy를 설정해야 한다.
team의 List<Members>는 readOnly라고 했지만 실제로는 team의 리스트에도 Member를 담아두는 것이 좋다.
그 이유는 1차 캐시에서도 일관성을 지킬 수 있기 때문이다.
Member과 Team을 생성해서 Member에 Team을 올려 persist() 호출하는 코드를 작성해보자.
Team team = new Team();
team.setName("TEAM A");
em.persist(team);
Member member = new Member();
member.setTeam(team);
member.setUsername("haneul");
em.persist(member);
이렇게 코드를 짰을 경우 트랜잭션 내에서 flush()이전까진 member와 team 객체는 영속성 컨텍스트에 존재한다.
member에는 setTeam()에 의해 team 객체가 잘 포함된 듯 보이나, team의 List<Member> 필드에는 member가 들어와있지 않을 것이다.
1차 캐시에 존재할 때도 team객체에 member가 들어있을 수 있게 setter대신 다음 처럼 코드를 직접짜줄 수 있다. 이것을 연관관계 편의 메서드라고 한다.(공식적 용어는 아님)
Team team = new Team();
team.setName("TEAM A");
em.persist(team);
Member member = new Member();
member.changeTeam(team);
member.setUsername("didie");
em.persist(member);
// Member 클래스에 작성
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
일반 setter라면 this.team=team이 끝이지만 team.getMembers().add(this);을 추가하여 양쪽 엔티티에 모두 서로의 정보가 담기도록 setter를 쓰지 않고 별도의 메서드를 만들어 활용해줄 수 있다.
자바 입장에서는 양방향 연관관계가 필요하지만 사실 DB입장에서는 단방향 연관관계, 즉 외래키(@JoinColumn)만 잘 설정해주면 된다.
그러므로, 처음 테이블-엔티티를 설계할 때는 단방향으로 설계를 해주고 초기 아키텍처가 모두 구성된 이후에 필요한 경우에 맞추어 양방향 연관관계를 설정해서 활용한다. 이때 필요한 경우라는 것은 외래키가 아닌 엔티티로부터 Join과 같은 로직이 필요할 경우가 그에 해당한다.
양방향 연관관계를 넣는 것이 DB 테이블 구조에 변화를 주지 않기 때문에 이러한 방식이 가장 최적화된 방식이라고 볼 수 있다.