Spring

[Spring JPA] 연관관계 매핑 (4)

아윤_ 2024. 5. 16. 21:23

 

연관관계가 필요한 이유

 

예제 시나리오를 보며 설명을 진행한다.

 

예제 시나리오

  • 회원과 팀이 있다.
  • 회원은 하나의 팀에만 소속될 수 있다.
  • 회원과 팀은 다대일 관계다.

 

객체를 테이블에 맞추어 모델링

 

예제 시나리오에 따라 객체를 테이블에 맞추어 모델링해보면 다음과 같이 나타낼 수 있다.

 

 

다음과 같은 모델링 결과를 통해 알 수 있는 점은 객체 간에는 연관관계가 없지만, 테이블 간에는 연관관계가 존재한다는 점이다.

 

테이블을 자세히 살펴보면, 멤버와 팀이 다대일 관계를 가지고 있고, 이 중, '다'의 관계인 MemberForeign Key를 가지고 있는 것을 알 수 있다.  

 

참조 대신에 외래키를 그대로 사용

 

현재 객체를 테이블에 맞추어 모델링을 진행했으므로 참조 대신에 외래키를 그대로 사용하여 연관관계 매핑을 진행한 결과는 다음과 같다.

 

Member 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private Long teamId;

}

 

Team

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

 

외래키 식별자를 직접 사용

 

연관관계가 있는 회원과 팀을 저장하게 될 경우 회원을 저장할 때 외래키 식별자인 teamId를 직접 사용하는 것을 알 수 있다.  

// 회원 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);

// 팀 저장
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);

 

 

식별자로 다시 조회

 

저장된 member와 team을 식별자로 다시 조회해보면, team을 조회할 때 member의 teamId를 사용해서 team을 조회하는 것을 알 수 있다. 하지만, 이는 객체 지향적인 방법이 아니다.

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

// 연관관계가 없음
Long teamId = findMember.getTeamId();
Team findTeam = em.find(Team.class, teamId);

 

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

 

방금까지의 예제를 통해 다음과 같은 사실을 알 수 있다.

  • 테이블외래 키로 조인을 해서 연관된 테이블을 찾는다.
  • 객체참조를 사용해서 연관된 객체를 찾는다.
  • 테이블과 객체 사이에는 이러한 큰 간격이 있다.

 

 

단방향 연관관계

 

이번에는 객체 중심으로 연관관계 매핑을 진행해보도록 하자.

 

객체 지향 모델링 - 객체 연관관계 사용

 

객체 연관관계를 사용하여 객체 지향 모델링을 진행한 결과는 다음과 같다.

 

 

테이블 중심 모델링과 달리, Member에서 team의 id가 아닌 team의 참조값을 그대로 가져온 것을 볼 수 있다.

 

객체의 참조와 테이블의 외래키를 매핑

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    // private Long teamId;
    @ManyToOne
    @JoinColum(name="team_id")
    private Team team;
}

 

객체의 참조와 테이블의 외래키를 매핑할 경우, 이 둘의 관계가 어떤 관계인지 JPA에게 알려주어야 한다.

예제에서는 회원과 팀의 관계가 N:1 관계이므로 회원의 입장에서는 다대일 관계이다. 따라서 다대일 관계라는 의미의 @ManyToOne 어노테이션을 붙어주어야 한다. 또한, 멤버 테이블에서 팀의 id를 참조하기 위한 매핑이 필요하므로 @JoinColumn 어노테이션을 붙여주었다.

 

ORM 매핑

 

이를 ORM 매핑으로 나타내면 다음과 같다. 

 

 

연관관계 저장

 

객체의 참조를 사용하여 연관관계를 매핑하면, 다음과 같이 객체지향적으로 연관관계를 저장할 수 있다.

// 팀 저장
Team team = new Team();
team.setName("teamA");
em.persist(team);

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

 

참조로 연관관계 조회 - 객체 그래프 탐색

 

저장된 멤버와 팀을 조회하는 코드도 다음과 같이 수정된다.

Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();

참조로 연관관계를 조회할 경우, 다음처럼 객체 그래프 탐색이 가능해진다. 핵심은 JPA에서 객체의 참조와 DB의 외래키를 이렇게 매핑할 수 있다는 것이다.

 

연관관계 수정

 

// 새로운 팀 B
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);

// 회원 1에 새로운 팀 B 설정
member.setTeam(teamB);

 

회원 1의 팀을 teamB로 바꾸고 싶을 경우 다음과 같이 수정이 가능하다.

 

 

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

 

이 부분은 매우 중요한 부분이니 꼭 알고 넘어가자.

 

양방향 매핑

 

다음과 같이 양쪽으로 참조해서 갈 수 있는 관계를 양방향 연관관계라고 한다.

 

 

테이블 연관관계의 경우 단방향 연관관계와 차이가 없다. 왜냐하면 member 테이블에서 team_id를 조인하여 사용하면 되기 때문이다. 즉, 테이블의 연관관계는 외래키 하나로 양방향이 다 있다.

 

문제는 객체이다. 이전의 단방향에서 team 객체는 member로 갈 수 없었다. 따라서 양방향 영관관계를 위해서는 team에 members 리스트를 넣어주어야 한다.

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    @ManyToOne
    @JoinColum(name="team_id")
    private Team team;
}

 

Member 엔티티는 단방향과 코드가 동일하다.

 

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

 

양방향 연관관계를 위해 @OneToMany 어노테이션을 지정하였으며, 멤버 객체에 있는 team 변수와 매핑하겠다는 의미로  mappedBy를 사용해야 한다.

 

new ArrayList<>();로 바로 초기화를 해주는 것이 관례이다.

 

반대 방향으로 객체 그래프 탐색

 

Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size();  //역방향 조회

 

기존에는 Member -> Team으로만 객체 그래프 탐색이 가능했지만, 양방향 연관관계 매핑을 통해 Team -> Member로 탐색이 가능한 것을 확인할 수 있다.

 

연관관계의 주인과 mappedBy

 

mappedBy는 JPA에서 매우 어려운 난이도를 갖고 있기 때문에 처음에는 이해하기 어렵다. mappecBy를 이해하기 위해서는 객체와 테이블 간에 연관관계를 맺는 차이를 이해해야 한다.

 

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

  • 객체 연관관계 = 2개
    • 회원 -> 팀 연관관계 1개 (단방향)
    • 팀 -> 회원 연관관계 1개 (단방향)
  • 테이블 연관관계 = 1개
    • 회원 <-> 팀의 연관관계 1개 (양방향)

 

객체의 양방향 관계

  • 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개이다.
  • 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.
  • A -> B (a.getB())
  • B -> A (b.getA())

 

테이블의 양방향 연관관계

  • 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다.
  • MEMBER.TEAM_ID 외래 키 하나로 양방향 연관관계를 가진다. (양쪽으로 조인할 수 있다)
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

 

둘 중 하나로 외래 키를 관리해야 한다.

 

여기서 딜레마가 발생한다. 둘 중 하나로 외래 키를 관리해야 하는데, 무엇을 선택해야 할까?

 

 

연관관계의 주인(Owner)

 

연관관계의 주인을 정하기 위해 양방향 매핑 시에는 다음과 같은 규칙이 있다.

  • 객체의 두 관계 중 하나를 연관관계의 주인으로 지정
  • 연관관계의 주인만이 외래키를 관리(등록, 수정)
  • 주인이 아닌 쪽은 읽기만 가능 (매우 중요)
  • 주인은 mappedBy 속성 사용 불가
  • 주인이 아니면 mappedBy 속성으로 주인 지정

 

누구를 주인으로 할까?

  • 외래 키가 있는 곳을 연관관계의 주인으로 정해야 한다.
  • 즉, Member.team이 연관관계의 주인이 된다.

 

 

만약 외래 키가 있는 곳이 아닌 Team의 members를 연관관계의 주인으로 정하면 members의 값을 바꿨을 경우 멤버 업데이트 쿼리가 날아가게 되는 문제가 발생한다. (헷갈리고, 성능 이슈도 발생한다)

 

따라서 '다(N)' 쪽이 연관관계의 주인이 돼야 한다.

 

 

양방향 매핑 시 주의점

 

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

 

양방향 매핑 시 가장 많이 하는 실수를 보며 주의점에 대해 알아보자.

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

Member member = new Member();
member.setName("member1");

//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers.add(member);
em.persist(member);

 

양방향 매핑 시 가장 많이 하는 실수는 다음처럼 역방향에만 연관관계를 설정하고, 연관관계의 주인에는 값을 입력하지 않는 경우이다. 

 

해당 코드를 실행하여 실제 멤버 테이블을 살펴보면

 

 

다음과 같이 team_id가 null인 것을 확인할 수 있다. 그 이유는 연관관계의 주인이 Member의 team이기 때문이다. 따라서 다음과 같이 코드를 변경해야 한다.

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

Member member = new Member();
member.setName("member1");
member.setTeam(team);

em.persist(member);

 

하지만 이 또한 순수한 객체 관계를 고려하면 좋은 코드는 아니다. 따라서 순수한 객체 관계를 고려하면 항상 다 양쪽 값을 입력해야 한다.

 

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

Member member = new Member();
member.setTeam(team);

team.getMembers().add(member);
//연관관계의 주인에 값 설정
member.setTeam(team);

em.persist(member);

 

결과를 확인해보면 team_id가 null이 아닌 것을 확인할 수 있다.

 

 

양방향 매핑 시 주의사항

  • 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자.
  • 연관관계 편의 메소드를 생성하자
    • member.setTeam() 시에 그 내부에 team.getMembers().add(this)와 같은 식으로 메소드를 만든다.
    • 둘 중에 하나만 만들자
  • 양방향 매핑 시에 무한루프를 조심하자
    • 예: toString(), lombok, JSON 생성 라이브러리 → lombok에서 toString 만드는 건 만들지 말자, JSON 생성 라이브러리 사용 시 컨트롤러에서 엔티티를 절대 반환하면 안 된다.(DTO로 변환해서 반환한다)

 

양방향 정리

  • 단방향 매핑만으로도 미리 연관관계 매핑은 완료 (기본적으로 단방향 매핑으로 다 끝낸다고 생각해야 한다! 객체 입장에서 양방향 매핑은 좋지 않다)
  • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐이다
  • JPQL에서 역방향으로 탐색할 일이 많다
  • 단방향 매핑을 잘 하고, 양방향은 필요할 때 추가해도 된다 (테이블에 영향을 주지 않는다)