[jpa] 연관관계 매핑 기초

태아불(엔티티)이 서로 연관관계를 가질 때 드러나는 JPA와 SQL 패러다임 차이가 있다.
바로 조인참조이다.

SQL은 외래키라는 것을 통해 테이블끼리 관계를 가지고, 조인이라는 것을 통해 두 테이블의 모든 데이터에 접근 가능하다.
SQL의 경우 외래키만 있으면 어느쪽에서든 조회가 가능하다. 기본적으로 양방향이다.

하지만 객체에서는 이런 행위가 불가능하다.
엔티티간의 관계는 참조를 통해 형성된다.
클래스의 필드로 다른 클래스를 가지고 있어야하며, 한쪽으로만 접근, 즉 단방향 탐색만 가능하다.

객체 연관관계 vs 테이블 연관관계

  1. 객체
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Setter
@Getter
class Mamber{
private String id;
private String username;

private Team team;
}

@Setter
@Getter
class Team{
private String id;
private String name;
}

public void save(){
// 연관관계 세팅
Team team = new Team("team1", "어벤져스");
Member member1 = new Member("member1", "멤버1");
Member member2 = new Member("member2", "멤버2");

member1.setTeam(team);
member2.setTeam(team);

// 연관관계 탐색
Team foundTeam = member1.getTeam();
assertThat(foundTeam.getName(), is("어벤져스"));
}

위처럼 참조를 통해 연관관계를 탐색하는 것을 객체 그래프 탐색 이라고 한다.

  1. 테이블
1
2
3
4
SELECT T.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID = T.ID
WHERE M.MEMBER_ID = 'member1'

위처럼 외래키를 통해 연관관계를 탐색하는 것을 조인 이라고 한다.

위와같은 패러다임을 풀기위해 나온것이 방향이라는 개념이고,
여기서 단방향, 양방향의 개념이 나온다.

단방향 연관관계

우리는 ORM을 사용중이다. 위의 객체연관 관계를 그대로 활용하되, JPA에게 알려주기만 하면 된다.

1
2
3
4
5
6
7
8
9
10
@Setter
@Entity
class Member{
private String id;
private String username;

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

단방향이므로 Team 쪽에 따로 해줄것은 없다.

  1. @ManyToOne
    N:1의 관계라는 것을 나타내주는 어노테이션이다.
    Teamp 하나에 Member 여러개가 소속될 수 있기 때문이다.
    사용할 수 있는 옵션은 아래와 같다.
속성 기능 기본값
optional FK nullable 한지의 여부이다 true
referencedColumnName 외래 키가 참조하는 대상 테이블의 컬럼명 참조하는 테이블의 기본키 컬럼명
fetch lazy로딩, eager 로딩을 설정할 수 있다. @ManyToOne = FetchType.EAGER, @OneToMany = FetchType.LAZY

optonal 속성에 따른 쿼리 방식
이 값이 true일 경우 JPA는 N쪽 테이블을 조회해온 후, fk 값에 따라 조회를 1쪽 테이블을 추가로 조회하거나(null일 경우 조회하지 않음) LEFT OUTER JOIN을 사용한다.
무작정 INNER JOIN을 하면 fk가 null일 경우 출력되지 않을 것이므로, 당연한 결과다.

반대로 false로 설정하면 바로 INNER JOIN으로 처리한다.
테이블 설계를 FK NOT NULL로 해도 이 속성값이 true일 경우 LEFT OUTER JOIN 등으로 처리하므로, FK NOT NULL일 경우에는 false로 주는 것이 좋다.

  1. @JoinColumn
    관계에 사용되는 외래키를 작성하는 부분이다.
    (이 외래키(TEAM_ID)에 해당하는 엔티티는 이것(Team)이다 라고 보면 편하다)
    결과적으로 객체를 RDB와 매핑할 것이기 때문에, 이렇게 하나라도 더 알려줘야 탐색의 시간을 줄일 수 있다.(리플렉션으로 객체의 모든 값을 탐색하며 관계를 알아내기에는 너무 낭비이기 떄문에)
    사용할 수 있는 옵션은 아래와 같다.
속성 기능 기본값
name 매핑할 외래키 이름 필드명 + _ + 참조하는 테이블의 기본 키 컬럼명
referencedColumnName 외래키가 참조하는 대상 테이블의 컬럼명 참조하는 테이블의 기본키 컬럼명
foreignKey(DDL) 외래키 제약조건 설정 가능

연관관계 사용

저장

1
2
3
4
5
6
7
8
9
10
11
12
13
public void save(){
Team team = new Team("team1", "어벤져스");
em.persist(team);

Member member1 = new Member("member1", "멤버1");
member1.setTeam(team);

Member member2 = new Member("member2", "멤버2");
member2.setTeam(team);

em.persist(member1);
em.persist(member2);
}

객체간에 관계를 맺고 persist를 땋! 때려주면

1
2
3
4
INSERT INTO TEAM VALUES("team1", "어벤져스");

INSERT INTO MEMBER VALUES("member1", "멤버1", "team1");
INSERT INTO MEMBER VALUES("member2", "멤버2", "team1");

처럼 team의 id값이 member의 외래키 값으로 세팅되어 저장된다.

엔티티 저장 시 연관된 모든 엔티티는 영속 상태여야 한다.
존재하는 엔티티라는 것이 보장되어야 하기 때문이다.

이러한 특징 때문에 비효율적이라고 생각할 수 있다.
외부에서 명확한 identity가 넘어왔음에도 불구하고, find로 조회해서 영속성 컨텍스트에 넣어줘야 하기 때문이다.
사실상 외부에서 명확한 identity가 넘어왔음에도 불구하고는 우리의 입장이지, framework는 그것을 모른다. 그러므로 고집을 부릴수는 없는 노릇…
조금 다른 방식으로 풀어볼 수는 있다(em.getReference)

조회

1
2
3
4
5
6
public void find(){
Member memver = em.find(Member.class, "member1");
Team team = member.getTeam();

assertThat(team.getName(), is("어벤져스");
}

객체 그래프 탐색으로 매우 간단하게 찾아갈 수 있다.
(또는 JPQL로도 조회 가능하다)

1
2
3
4
5
6
-- optional = false
SELECT M.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID = T.ID
WHERE
M.ID = "member1";

수정

1
2
3
4
5
6
public void update(){
Team team = em.find(Team.class, "team2");

Member givenMember = em.find(Member.class, "member1");
givenMember.setTeam(team);
}

변경감지가 동일하게 동작하여 update문이 발생하게 된다.

1
2
3
4
5
UPDATE MEMBER
SET
TEAM_ID = 'team2', ...
WHERE
ID = 'member1'

연관관계 제거

1
2
3
4
public void remove(){
Member givenMember = em.find(Member.class, "member1");
givenMember.setTeam(null);
}

위처럼 null로 세팅해 연관관계를 제거해줄 수도 있다.
fk인 team_id가 null로 세팅된다.

1
2
3
4
5
UPDATE MEMBER
SET
TEAM_ID = null, ...
WHERE
ID = 'member1'

삭제

1
2
3
4
5
6
public void save(){
member1.setTeam(null);
member2.setTeam(null);

em.remove(team);
}

연관관계를 제거해주지 않고 삭제할 경우 외래키 제약조건에 걸리므로, 관계 제거를 선행해줘야 한다.

양방향 연관관계

현재는 Member -> Team의 관계만 형성되어있는데(객체지향 관점에서)
Team -> Member의 관계까지 추가하면 양방향 연관관계가 성립된다.

Member -> Team이 N:1 관계였으므로, Team -> Member는 1:N의 관계를 가진다.

관계는 반대편 관계에 달려있다. 반대편이 1:N 관계일 경우 N:1, 1:1일 경우 1:1 관계를 가진다.
자바에서 1:N의 관계를 표현하려면 배열을 사용해야하는데, JPA에서는 여기서 Collection을 사용한다.(List, Set, Map 등)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Setter
@Entity
class Team{
private String id;
private String name;

@OneToMany(mappedBy = "team")
private List<Member> memberList;
}

public void find(){
Team givenTeam = em.find(Team.class, "team");

List<Member> givenMembers = givenTeam.getMembers();

// do something...
}

mappedBy 속성은 양방향 매핑일 떄 사용하는데 반대쪽 매핑의 필드 이름을 값으로 주면 된다.

연관관계의 주인

SQL의 경우 기본적으로 양방향 연관관계를 가지지만,

위에서도 언급했지만, SQL의 경우 외래키 하나로 양방향 연관관계가 형성된다

객체지향의 경우 양방향 연관관계라는 것이 애초에 없다.
단방향 연관관계 2개를 로직으로 잘 묶어서 양방향 연관관계처럼 보이게 하는 것 일 뿐이다.

근데 이렇게 양방향으로 연관관계를 형성해주면 결과적으로 연관관계를 컨트롤 해줄 수 있는 곳이 2군데가 생기게 된다.
하지만 SQL의 경우 언급했다시피, 연관관계를 컨트롤 하는 곳은 단 한군데(외래키)이다.
즉 이 객체들이 SQL로 매핑되려면, 누가 연관관계를 컨트롤하는지 알려줘야 하고, 컨트롤하는 주체를 연관관계의 주인 이라고 하는 것이다.

말이 거창하지만, 그냥 외래키 관리자를 말하는 것이다.
일반적인 상황에서는 그냥 테이블상에서 외래키를 갖고있는 엔티티가 연관관계의 주인이 된다.

연관관계의 주인은 양방향 연관관계를 가졌을떄만 지정해주면 된다.
단방향으로 지정했을 경우에는 ORM 입장에서 혼동스러울 부분이 없기 때문이다.

아까 위에서 양방향 연관관계를 맺으면서 mappedBy 속성을 사용했는데, 이 속성이 곧 연관관계의 주인을 알려주는 속성이다.
mappedBy 속성 지정에는 아래와 같은 룰이 존재하는데, 이를 보면 용도를 알 수 있다.

  • 주인은 mappedBy 속성을 사용하지 않는다
  • 주인이 아니면 mappedBy 속성을 사용해서 연관관계의 주인을 지정해야 한다.

결국 위에서도 mappedBy 속성을 사용함으로써 내가 연관관계의 주인이 아니라고 알려주는 것이다.

ORM 입장에서는 @OneToMay에서 참조하는 클래스를 탐색한 뒤, mappedBy에 명시된 필드를 찾아가 외래키 정보를 얻을 것이다(아마도)

1:N 관계에서 외래키를 관리하는 쪽은 N 쪽이기 때문이다.
(그래서 @ManyToOne에 mappedBy 속성이 없다)

ORM 입장에서도 이 개념이 중요하게 작용하는게,
연관관계의 주인만이 연관관계와 매핑되는 외래키를 관리(등록, 수정, 삭제)할 수 있고,
주인이 아닌 쪽은 읽기만 가능하게 된다.

Team의 @OneToMany에 있는 members의 원소들을 백날 더하고 빼고 해봤자 아무일도 일어나지 않는다(ㅋㅋ)

1
2
3
4
5
6
7
8
9
10
11
public void owner(){
Team team = em.find(Team.class, "team1");

Member member1 = em.find(Member.class, "member1");
Member member2 = em.find(Member.class, "member1");

team.getMembers().add(member1); // 무시됨
team.getMembers().add(member2); // 무시됨

member.setTeam(team); // 설정됨
}

연관관계의 주인만이 연관관계(외래키)를 컨트롤 할 수 있다는걸 명심하자.

연관관계 편의 메서드

사실상 위의 행위는 객체지향 관점에서 보면 좀 문제가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void save{
Team team = new Team("team1", "어벤져스");
em.persist(team);

Member member1 = new Member("member1", "멤버1");
Member member2 = new Member("member2", "멤버2");

member1.setTeam(team);
member2.setTeam(team);

em.persist(member1);
em.persist(member2);
}

public void find{
Team team = em.find(Team.class, "team1");

List<Member> members = team.getMembers();
assertThat(members.size(), is(2));
}

Team 내에 있는 List 에 아무도 값을 넣어준적이 없는데 find 메서드가 정상 동작한다.
이는 hibernate라는 애가 중간에 있기 때문인데, 객체지향을 중요시하는 ORM을 사용하면서 위처럼만 놔두게 되면 결국 RDB 와 다를게 없다고 생각된다.
(만약 ORM Framework가 중간에 없었다면 심각한 오류를 발생시켰을 것이다.)

그러므로 순수한 객체까지 고려하는, 연관관계 편의 메서드라는 것을 작성해줘야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Member{
public void setTeam(Team team){
this.team = team;
this.team.getMembers().add(this);
}
}

class Team{
public void addMembers(Member member){
this.members.add(member);
member.setTeam(this);
}
}

N 쪽에 set, 1 쪽에 add가 항상 묶여서 수행되도록 작성했다.

연관관계 편의 메서드 작성 시 주의사항

사실상 위의 연관관계 편의 메서드는 싱크를 완벽히 맞춰주는 메서드는 아니다.
위와 같은 편의메서드를 사용할 경우 아레와 같은 버그를 막을 수 없다.

1
2
3
4
member1.setTeam(team1);
member1.setTeam(team2); // team2로 변경

Member foundMember = team1.getMember(); // member1이 여전히 조회된다

별로 마주할 일 없는 시나리오라고 생각할 수 있으나, 그렇게 따지면 항상 hibernate를 거치면 되므로 연관관계 편의 메서드 자체도 필요가 없어지게 된다.
하지만… 위에서도 언급했지만 그건 정말 위험한 코드이다. 언제 어디서 예기치 못한 오류가 발생할지 모른다.

ORM으로 바라보기 보다 객체지향으로 먼저 바라봐야 한다고 (나는) 생각한다.(ㅋㅋ)
좀 더 완벽하게 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Member{
public void setTeam(Team team){
if(this.team != null){
this.team.getMembers().remove(this);
}

this.team = team;

if(team != null && !team.getMembers().contains(this)){
this.team.getMembers().add(this);
}
}
}

class Team{
public void addMember(Member member){
if(!this.members.contains(member)){
this.members.add(member);
}

member.setTeam(this);
}
}

무한루프에 빠질 수 있는 가성성과 위의 버그를 제거하였다.
양방향 연관관계를 형성할 때는 항상 위처럼 연관관계 편의 메서드를 만들어줘야 한다(필수!).