BackEnd/JPA

[JPA] 연관관계 매핑 기초

샤아이인 2022. 4. 3.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

1. 단방향 연관관계

우리는 객체와 테이블 연관관계의 차이를 이해해야 합니다.

객체지향의 페러다임과 RDB의 페러다임 간의 간극에서 오는 차이가 있으며 이를 인지하고 공부해야 합니다.

 

1. 연관관계의 필요성

객체지향 설계의 목표는 자율적인 객체들의 협력 공통체를 만드는 것이다. (조영호)

 

2. 객체를 테이블에 맞춰 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다.

 

● 객체를 테이블에 맞춰 모델링 하기 (연관관계가 없는 객체)

우선 모델링할 다이어 그램을 살펴보자.

출처 - 인프런 김영한 JPA

MEMBER 테이블에서 TEAM의 PK값을 FK로 갖고있다.

코드로 보면 다음과 같다.

/* 회원(Member) 엔티티*/
@Entity
public class Member {
    
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
    
    @Column(name = "TEAM_ID")
    private Long teamId;
}

/* 팀(Team) 엔티티 */
@Entity
public class Team{
    @Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;
}
 

위 다이어 그램에서 봤던 그대로 테이블에 맞춰서 Entity를 모델링 한 코드이다.

 

이제 코드를 실행해보면 다음과 같은 DDL을 확인할수 있다.

TEAM_ID가 TABLE에 그대로 생성되는것을 확인할수 있었다.

지금은 참조가 아닌, 외래키(FK)를 사용해서 그대로 가져오고 있는 문제가 있다.

 

예를 들어 Member를 만들고 Team을 저장할때는 team의 Id값을 가져와서 회원정보에 등록해주어야 한다.

다음 코드를 살펴보자. Team의 식별자를 직접 가져와서 등록하는 코드이다.

transaction.begin();
try {
    //팀 저장
    Team team = new Team();
    team.setName("TeamA");
    entityManager.persist(team);
    
    //회원 저장
    Member member = new Member();
    member.setName("member1");
    member.setTeamId(team.getId()); // Id로 직접 등록하기
    entityManager.persist(member);

    transaction.commit();
}catch (Exception e){
    transaction.rollback();
}finally {
    entityManager.close();
}
 

위 코드에서

member.setTeamId(team.getId());
 

이부분이 객체지향 스럽지 못한 부분이다.

member.setTeam(team);
 

위 코드처럼 setTeam()으로 team을 등록해야 객체지향 코드라고 할수 있을것이다.

하지만 지금 우리의 코드는 외래 키(FK) 식별자를 직접 다루고 있다.

 

이를 실행해보면 다음과 같다.

(참고: 같은 시퀀스 객체를 사용했기 때문에 먼저 만들어진 TEAM_ID 가 1번이고, 다음에 만들어진 MEMBER_ID가 2번이다.)

MEMBER 테이블의 TEAM_ID 컬럼에 TEAM 번호가 잘 기입되어 있다.

 

JOIN하면 다음과 같은 결과도 얻을수가 있다.

 

● 만약 team을 조회하고 싶다면 어떻게 해야할까?

이또한 문제가 생긴다.

Member findMember = entityManager.find(Member.class, member.getId());
Long findTeamId = findMember.getTeamId(); // Team id를 얻어온 후
Team findTeam = entityManager.find(Team.class, findTeamId); // Id로 조회
 

매번 member를 우선 조회한 뒤 외래키를 뽑아 그것으로 팀의 정보를 조회해야한는 불상사사 발생했다.

이는 연관관계가 없기때문이다.

 

객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다!

 

테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾는다. 하지만 객체는 참조를 사용해서 연관된 객체를 찾는다.

테이블과 객체 사이에는 이런 큰 간격이 있다

 

3. 단방향 연관관계

출처 - 인프런 김영한 JPA

Member가 이전과 달리 Team의 Id 값이 아니라, Team 의 참조값을 가져온다.

// @Column(name = "TEAM_ID")
// private Long teamId;

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

코드는 위와같이 변경된다.

Member : Team = N : 1 이기 때문에 @ManyToOne 을 적용한다.

또한 Member 객체의 Team 레퍼런스와 Member 테이블의 FK와 mapping을 해야한다.

따라서 @JoinColumn(name = "TEAM_ID") 또한 추가해주어야 한다.

(ps @JoinColumn은 외래키 매핑시에 사용된다. default : [필드명]_[참조하는 테이블의 기본키 컬럼명])

 

따라서 다음처럼 다이어그램이 형성된다.

출처 - 인프런 김영한 JPA
//팀 저장
Team team = new Team();
team.setName("TeamA");
entityManager.persist(team);

//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team);
entityManager.persist(member);

// 회원조회
Member findMember = entityManager.find(Member.class, member.getId());

// 팀 조회
Team findTeam = findMember.getTeam();
System.out.println("findTeam = " + findTeam.getName());

transaction.commit();
 

바로 member에서 member.getTeam()으로 Team을 조회할수 있게 되었다.

team 정보를 출력한후, commit 되는 시점에 INSERT 쿼리가 전송되었다.

 

만약 DB에서 가져오는 쿼리까지 다 보고싶다면, 중간에 flush를 통해 DB에 먼저 보낸후 찾아와야 한다. 다음처럼 말이다.

//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team);
entityManager.persist(member);

// Flush 먼저하기
entityManager.flush();
entityManager.clear();

// 회원조회
Member findMember = entityManager.find(Member.class, member.getId());

// 팀 조회
Team findTeam = findMember.getTeam();
System.out.println("findTeam = " + findTeam.getName());
 

위 코드를 실행하면 우선 INSERT 문이 2번 나간 후에

SELECT 문으로 결과를 DB에서 가져온다.

 

2. 양방향 연관관계와 연관관계의 주인1 - 기본

이번 시간에는 양방향 연관관계에 대하여 알아보자.

출처 - 인프런 김영한 JPA

기존의 단방향 연관관계 에서는 Member의 getTeam()을 통해 Team 에 접근할수 있었지만, Team을 통해서 Member에 접근할수는 없었다.

 

하지만 생각해보면 DB 에서는 FK를 갖고 양쪽 모두에서 서로에게 접근이 가능하다.

이를 해결하는 양방향 연관관계 에서는 Team 쪽에도 Member 의 List인 members라는 참조값을 갖고있어야 한다.

@Entity
public class Team{
    ...
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    ...
}
 

이를 통해 Team에서 members 를 출력해보는 예시는 다음과 같다.

// 회원조회
Member findMember = entityManager.find(Member.class, member.getId());

// 맴버 조회
List<Member> members = findMember.getTeam().getMembers();
for (Member m : members) {
    System.out.println("member = " + m.getName());
}
 

다음과 같이 DB에서 SELECT 하여 members를 찾아오는것을 확인할수 있다.

양방향으로 탐색이 가능해졌다.

 

● 연관관계의 주인과 mappedBy

mappedBy는 연관관계의 개념에 대해 이해를 어렵게 만드는 주범이다.

객체와 테이블간 연관관계를 맺는 차이를 이해해야 한다.

 

● 객체와 테이블이 관계를 맺는 차이란?

- 객체에서의 양방향 연관관계는 사실 2개의 단방향 연관관계 이다.

회원 => 팀 (단방향 1)

팀 => 회원 (단방향 2)

 

- 테이블 연관관계 = 1개

회원 <=> 팀 (양방향)

 

● 중요한 차이점!

객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개 이다.

객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.

A → B (a.getB())
B → A (b.getA())
 

테이블에서 양방향으로 참조할경우 외래 키(FK) 하나로 연관관계가 형성된다. (양쪽으로 JOIN 가능)

SELECT * 
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID

SELECT * 
FROM TEAM T
JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID
 

● 둘중 하나가 외래키(FK)를 관리해야 한다.

출처 - 인프런 김영한 JPA

위 그래프 에는 참조값이 2개(team, members)가 있다.

그럼 MEMBER Table의 FK는 member 객체의 team값을 업데이트 했을때 수정이 되어야할까?

아니면 team의 members를 업데이트 했을때 수정이 되야할까?

 

딜레마가 온다... 둘중에 뭘 봐야할까?

DB Table 입장에서는 TEAM_ID 라는 FK값만 업데이트 되면 된다. 하지만 객체는 그럴수가 없다.

 

=> 둘중 하나를 주인(Owner)을 정해야 한다.

 

● 연관관계의 주인(Owner)

- 양방향 매핑 규칙

객체의 두 관계중 하나를 연관관계의 주인으로 지정해야 한다.

연관관계의 주인만이 외래 키를 관리(등록, 수정)하며, 주인이 아닌쪽은 읽기만 가능하다.

 

주인은 mappedBy 속성을 사용하지 않는다. 주인이 아니면 mappedBy속성으로 주인을 지정한다.

 

● 누구를 주인으로?

=> 외래 키(FK)가 있는 곳을 주인으로 정하자

출처 - 인프런 김영한 JPA

위 그레프 에서는 Member.team이 연관관계의 주인이다.

 

한가지 의문이 든다?

Team에서 외래키를 관리하는 것은 불가능할까?

=> 가능은 하다.

 

하지만 Team에서 members를 수정하면 Team이 아닌 Member에 업데이트 쿼리가 날라가기 때문에 이를 읽는 개발자 입장에서 햇갈리기 쉽다.

 

자신은 Team의 members를 수정했는데, Member 테이블에 update 쿼리가 날라가면 이상하지 않은가?

 

결론! 외래 키(FK)가 있는곳을 주인으로 결정하자!

 

3. 양방향 연관관계와 연관관계의 주인 2 - 주의점

● 양방향 매핑시 가장 많이 하는 실수

=> 연관관계 주인에 값을 입력하지 않는 경우

// Member 생성
Member member = new Member();
member.setName("mamber1");
entityManager.persist(member);

// Team 생성
Team team = new Team();
team.setName("teamA");
team.getMembers().add(member); // 역방향(주인이 아닌 방향)만 연관관계 설정

entityManager.persist(team);

// 1차 캐시와 DB 동기화
entityManager.flush();
entityManager.clear();

transaction.commit();
 

실행시 쿼리는 다음과 같다.

Insert 쿼리가 2번 나가는것을 확인할수 있다. 각각 Member 와 Team에 대한 Insert 이다.

이제 DB에 가서 확인해 보자.

MEMBER의 TEAM_ID 값이 null인것을 확인할수 있다.

 

왜 이런 현상이 발생한것 일까? 어디서 문제일까?

=> 양방향 연관관계에서 주인이 아닌쪽은 읽기만 가능하다. 쓰기를 해도 쿼리에 변경이 없다.

따라서 연관관계의 주인에 값을 추가해 주어야 DB에도 반영이 된다.

 

코드를 다음과 같이 연관관계의 주인에서 값을 변경하도록 해야한다.

Team team = new Team();
team.setName("teamA");
entityManager.persist(team);

Member member = new Member();
member.setName("mamber1");
member.setTeam(team); // 연관관계의 주인쪽에서 값 업데이트
entityManager.persist(member);

// team.getMembers().add(member); 반대 방향의 추가

entityManager.flush(); // 1차 캐시 DB와 동기화
entityManager.clear();

System.out.println("====================");
Team findTeam = entityManager.find(Team.class, team.getId());
System.out.println("====================");
List<Member> members = findTeam.getMembers();
for (Member m : members) {
    System.out.println("m = " + m.getName());
}
System.out.println("====================");

transaction.commit();
 

첫 select는 team을 DB에서 찾아오는 쿼리이고, 두번째 는 team으로 부터 members를 얻어오는 쿼리이다.

member를 사용하는 시점에 쿼리를 날려주었다.

 

사실 양방향 연관관계의 경우 주인쪽과, 주인이 아닌쪽 양방향 모두에서 등록해주는것이 객체지향적이다.

team.getMembers().add(member);
 

이전 코드에서는 team에 member를 등록하는 위 코드는 생략되어 있지만, 사실 적어주는것이 더 좋다.

 

만약 이를 생략하게 되면 2가지 문제가 발생할수가 있다.

1) em.flush(), em.clear()를 생략한 경우의 코드

Team team = new Team();
team.setName("teamA");
entityManager.persist(team);

Member member = new Member();
member.setName("mamber1");
member.setTeam(team); // 연관관계의 주인쪽에서 값 업데이트
entityManager.persist(member);

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

System.out.println("====================");
for (Member m : members) {
    System.out.println("m = " + m.getName());
}
System.out.println("====================");

transaction.commit();
 

위 코드는 이전 코드에서 flush(), clear()를 생략한 코드이다. 즉, DB와 1차 캐시의 동기화 과정이 빠져있다.

 

이 경우 다음 코드에서 찾아오게 되는 findTeam은 1차 캐시에서 찾아오게 된다.

Team findTeam = entityManager.find(Team.class, team.getId());
 

Team의 경우 영속화가 되기 직전까지 name말고는 업데이트 된 정보가 없었다.

따라서 당연히 1차 캐시에 저장되있는 team또한 name에 대한 정보만 갖고있는 것 이다.

 

이렇게 1차 캐시에서 찾아온 findTeam에 아무리 getMembers()를 호출해 봤자. 해당하는 값이 없기때문에 찾아올수가 없다.

List<Member> members = findTeam.getMembers();
 

결과는 다음과 같이 나온다.

member에 대한 정보가 출력되지 않는것을 확인할수가 있다.

 

따라서 양쪽 모두 값을 업데이트 해주는것이 맞다!

팀에도 값을 업데이트 하고, 멤버에도 값을 업데이트 하는것 이다.

 

2) 테스트 케이스 작성의 경우

테스트 케이스는 JPA없이도 작동하도록 만들어 지는데 이때도 양방향으로 등록해 두지 않으면 한쪽에서는 조회가 되는데, 다른 쪽에서는 조회가 되지 않는 문제가 생긴다.

 

순수한 객체 관계를 고려하면 양쪽 다 값을 입력해야 한다.

 

● TIP : 연관관계 편의 메서드 만들기

양방향 모두를 직접 등록해주는 방식은 생각보다 귀찮다.

이를 해결하기 위해 한번의 등록으로 양쪽 모두에 등록되도록 메서드를 만들어주면 된다.

class Member {
    // 생략...
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }

}
 

- 이제 Team을 업데이트 하는 시점에서 해당 team에 Member(this)도 같이 추가가된다.

- 연관관계 편의 메서드같은 메서드는 관례적으로 쓰이는 Getter, Setter가아닌 사용자 정의 메서드명(임의)으로 정의해주는게 좋다.

 

또한 이런 편의 메서드는 Member쪽에서 만들어도 되고, Team쪽에서 만들어도 된다.

둘다 가능한 방식이다.

 

하지만 위 편의메서드 또한 한가지 버그가 있다.

예를 들어 다음과 같은 코드를 수행한다고 해보자.

member1.setTeam(teamA);
member1.setTeam(teamB);
Member findMember = teamA.getMember();

위 코드같은 경우 member1의 팀을 teamA 에서 B로 변경할 때 teamA -> member1 에 대한 관계가 제거되지 않는다.

따라서 다음과 같은 상태가 되버린다.

따라서 기존 팀이 있으면 기존 팀과 회원의 연관관계를 삭제하는 코드를 추가해야 한다.

class Member {
    // 생략...
    public void changeTeam(Team team) {
        if(this.team != null) {
            this.team.getMembers().remove(this);
        }
       
        this.team = team;
        team.getMembers().add(this);
    }

}

 

 

● 양방향 매핑시 무한 루프를 조심하자

예를 들어 toString(), lombok, JSON 생성 라이브러리를 사용할때 순환 참조되는 문제가 발생할수도 있다.

/* 회원(Member) 엔티티*/
@Entity
public class Member {
		...
    @Override
    public String toString() {
        return "Member{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", team=" + team +
                '}';
    }
		...
}

/* 팀(Team) 엔티티 */
@Entity
public class Team{
    ...
    @Override
    public String toString() {
        return "Team{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", members=" + members +
                '}';
    }
		...
}
 

Member 객체를 출력한다고 해보자.

이는 member.toString()이 호출되게 된다. => toString() 내부의 team을 호출 => team.toString() 호출 => toString() 내부의 members 호출 => members들을 하나씩 호출 => 각각 member.toString()이 호출

 

위와 같이 순환으로 버퍼 오버플로우가 발생하면서 종료된다.

 

Lombok도 위과 같은 toString()을 다 만들기 때문에 같은 오류가 발생한다.

=> Lombok에서는 toString() 사용하지 말기

 

JSON 생성 라이브러리도, 컨트롤러에서 Entity를 직접 반환할때 JSON으로 변환될때 순환적으로 무한루프에 빠지며 오류가 발생한다.

=> 컨트롤러에서는 Entity자체를 직접 반환하지 말것!, DTO 같은거 사용하기.

 

● 정리

단방향 매핑만으로도 이미 연관관계 매핑은 완료

- 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐

- JPQL에서 역방향으로 탐색할 일이 많음

- 단방향 매핑을 잘 해두고, 양방향을 필요할때 추가해도 된다.

 

우선 단방향 매핑으로 설계를 끝내려 노력하자.

우리의 예시에서는 Team에서는 members 라는 collection과 mappedBy를 처음부터 지정할 필요가 없다는 의미이다.

단방향으로 사용하다, 필요한 시점에 collection과 mappedBy를 추가해주면 된다.


실습과정에서 다음과 같은 Table을 만들때 한가지 영한님의 설명이 잘 이해가지 않는 부분이 있었다.

위 Table을 바탕으로 Entity를 만들때 Member 엔티티를 보면 Order에 대한 참조를 가지고 있다.

Member 에 orders 라는 필드가 있는 것 이다. 이는 잘못된 설계라 하셨다.

 

기본적으로는 단방향 매핑으로 끝내는 것이 가장 좋기 때문에, 위와 같은 경우에는 단방향 매핑으로 Order 엔티티 에서만 Member에 대한 참조를 가지고 있는것이 바람직 하다고 하셨다. (Member : Order = 1 : N)

 

여기서 든 의문이, 그럼 " '내 주문내역' 같은 경우는 Member를 통해서 Order를 보는게 아닌가?" 이였다.

어떻게 이를 해결하는 것 인가?

 

영한님은 다음과 같이 답변해 주셨다.

주문내역을 조회하는 쿼리(JPQL)를 떠올려보시면 됩니다.

select o
from Order o
where o.member.id = :memberId

주문내역은 쿼리 자체가 order만 있어도 충분합니다^^
우리가 설계를 할 때 회원이 주문을 하니까 회원에 뭔가 넣어야지라고 생각할 수 있는데, 사실 이렇게 설계하면, 회원이 모든 것을 다 참조해야 합니다. 오히려 주문, 배송 등등 객체 각각이 살아있어서 필요한 곳을 참조하는 식으로 설계하는게 더 좋은 설계 방법입니다^^
감사합니다.

'BackEnd > JPA' 카테고리의 다른 글

[JPA] 프록시와 연관관계 관리  (0) 2022.04.06
[JPA] 고급 매핑  (0) 2022.04.05
[JPA] 다양한 연관관계 매핑  (0) 2022.04.04
[JPA] 엔티티 매핑  (0) 2022.04.02
[JPA] 영속성 관리 - 내부 동작 방식  (0) 2022.04.02

댓글