JPA를 사용해 엔티티 객체와 DB 테이블을 매핑하기 위해서는 연관관계 매핑이 필수적입니다. 특히 객체의 참조와 테이블의 외래 키를 매핑하는 부분은 굉장히 중요합니다. 객체는 참조를 통해 연관관계를 형성하고 테이블은 외래 키를 통해 연관관계를 형성하는 차이점을 이해하고 이 둘을 매핑해 봅시다. 객체의 참조와 테이블의 외래 키를 매핑할 때는 방향, 다중성, 그리고 연관관계의 주인이 중요한 세 가지 지점입니다.
만약 아래 그림처럼 객체를 설계할 때 참조 대신 외래 키를 그대로 사용해서 테이블에 맞추어 모델링하면 어떨까요?
테이블은 기본 키와 외래 키를 가지고 연관관계를 형성하는 것이 일반적입니다. 하지만 객체에서도 참조가 아닌 키 값을 가지고 연관관계를 형성하게 되면 객체 지향적이지 않을뿐더러 Member 객체의 Team 정보를 조회할 때 바로 객체를 반환받지 못하고 Member가 가진 teamId로 다시 Team을 조회해야 합니다.
다시 말해 객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없습니다.
테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾고, 객체를 참조를 사용해서 연관된 객체를 찾습니다. 이런 큰 간격을 이해하고 객체와 테이블의 연관관계 매핑을 시작해야 합니다.
단방향 연관관계
이번에는 객체를 키 값이 아닌 참조로 연관관계를 구현해 봅시다. 객체를 키 값이 아닌 참조로 연관관계를 구현했기 때문에 객체의 참조와 테이블의 외래 키를 매핑해야 합니다. 아래 그림으로 살펴봅시다.
Member 엔티티의 team 참조를 추가해 Member 엔티티와 Team 엔티티를 단방향으로 연결했습니다. JPA에게도 Member와 Team의 관계를 알려야 합니다. Member 입장에서 Team은 다대일이기 때문에 @ManyToOne 애노테이션과 함께 Member의 team 필드를 MEMBER 테이블의 TEAM_ID를 @JoinColumn(name = "TEAM_ID") 애노테이션으로 매핑하게 됩니다. 아래 코드를 살펴봅시다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
private int age;
// @Column(name = "TEAM_ID")
// private Long teamId;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team); //단방향 연관관계 설정, 참조 저장
em.persist(member)
//조회
Member findMember = em.find(Member.class, member.getId());
//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();
// 새로운 팀B
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);
// 회원1에 새로운 팀B 설정
member.setTeam(teamB)
Member와 Team 엔티티처럼 다대일 단방향 연관관계에서는 항상 다쪽이 연관관계의 주인이 됩니다. 다대일 관계일 때 테이블의 다쪽에 외래 키가 있기 때문입니다.
Member 엔티티에서는 Team 객체를 참조하고 있어서 수정 또는 조회가 가능하지만, Team 엔티티에서는 Member를 참조하지 않는 단방향 관계로 Team 엔티티가 Member 엔티티를 알 수 없습니다.
하지만 개발을 하다 보면 Team 엔티티에서도 자신을 참조하는 Member 엔티티들을 조회하고 싶을 때가 있습니다. 이때 다대일 단방향 연관관계를 다대일 양방향 연관관계로 수정하면 됩니다. 테이블 입장에서는 객체를 단방향으로 설계하던 양방향으로 설계하던 변경 사항이 없습니다. 양방향 연관관계는 단방향 설계에서 Team 엔티티에 Member 엔티티의 참조를 추가하면 그만입니다.
따라서 JPA 모델링할 때는 단방향 매핑으로 설계를 끝내고 이후에 요구사항에 따라 양방향 매핑으로 수정하는 방식이 이상적입니다.
양방향 연관관계와 연관관계의 주인
위에서는 Member와 Team 엔티티를 단방향으로 설계했다면 이번에는 둘을 양방향으로 설계해 봅시다. 양방향 연관관계에서 연관관계의 주인을 정하는 일은 굉장히 중요합니다. 연관관계의 주인 쪽에서만 데이터를 수정할 수 있고, 그 반대인 연관관계 거울 쪽에서는 조회만 가능합니다. Member와 Team의 경우 다대일 연관관계이기 때문에 DB 테이블의 외래 키가 Member 테이블에 위치하게 되고, 외래 키가 있는 Member 엔티티가 연관관계의 주인이 됩니다.
위의 그림과 같이 객체를 양방향 연관관계로 설계해도 테이블 구조는 단방향일 때와 차이가 없습니다. 테이블 입장에서는 객체가 양방향이던 단방향이던 Member에서 Team을 조회하면 MEMBER의 TEAM_ID와 TEAM의 TEAM_ID를 조인하면 되고, TEAM 입장에서는 MEMBER를 알고 싶다면 TEAM의 PK와 MEMBER의 FK를 조인하면 됩니다.
즉, 테이블의 연관관계는 객체와 달리 외래 키(FK) 하나로 양방향 관계가 있는 것입니다. FK를 두 테이블 중 한쪽에만 넣어도 서로를 조회할 수 있기 때문에 테이블의 경우 사실상 방향 개념이 없다고 보는 게 맞습니다. 하지만 객체는 테이블과 달리 클래스에 필드가 있어야 조회가 가능합니다.
Member 엔티티가 연관관계의 주인이 되고 Team 엔티티가 연관관계의 거울이 되도록 구현한 코드를 살펴보겠습니다. 연관관계의 주인은 @JoinColumn 애노테이션을 사용하고, 연관관계 거울은 mappedBy 속성을 통해 지정합니다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
private int age;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
…
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<Member>();
…
}
Team 엔티티의 @OneToMany(mappedBy = "team") 애노테이션을 사용하여 Team의 members 필드가 Member 엔티티의 team 필드의 연관관계 거울임을 나타냅니다. 연관관계 거울인 Team 엔티티의 members 필드는 DB의 Member 엔티티들을 조회할 수 있지만, members를 수정한다고 해서 DB에 수정사항이 반영되지 않습니다. DB의 데이터를 수정할 수 있는 필드는 Member 엔티티의 team 필드로 MEMBER 테이블의 외래 키 TEAM_ID와 매핑되어 있기 때문에 가능합니다.
객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 두 개를 합친 것입니다. 따라서 객체를 양방향으로 참조하기 위해서는 Member와 Team의 예시처럼 단방향 연관관계 두 개를 만들어야 합니다. 이때 서로를 필드로 가진 두 객체 중 한 객체가 테이블의 외래 키를 관리해야 합니다. 테이블의 외래 키를 관리하는 엔티티가 연관관계의 주인이고 해당 엔티티만이 테이블의 데이터를 수정할 수 있게 됩니다.
양방향 연관관계의 둘 중 하나로 외래 키를 관리
양방향 연관관계에서 두 엔티티 중 하나는 외래 키를 관리해야 합니다. DB 입장에서는 누구에 의해서든 MEMBER 테이블의 TEAM_ID(FK)만 매핑되면 됩니다. 따라서 우리는 두 엔티티 중 외래 키를 관리할 엔티티를 지정해야 합니다. 외래 키를 관리하는 엔티티가 연관관계의 주인이 됩니다.
연관관계의 주인
연관관계의 주인만이 외래 키를 관리(등록, 수정)할 수 있고 주인이 아닌 쪽은 읽기만 가능합니다. 연관관계의 주인은 외래 키를 가진 테이블의 엔티티를 연관관계의 주인으로 정해야 합니다. 따라서 DB 입장에서 다대일의 경우 외래 키를 항상 다쪽에 두게 되고, 연관관계의 주인은 항상 다쪽 엔티티가 됩니다.
비즈니스적으로 중요하다고 생각되는 엔티티를 연관관계의 주인으로 두면 안됩니다. 비즈니스적인 중요성과 연관관계의 주인은 별개의 이야기입니다.
Member와 Team의 관계에서 Member가 다쪽이기 때문에 연관관계의 주인이 되었습니다. 따라서 연관관계의 주인인 Member의 team을 수정해야 DB에 적용되지, mappedBy 속성으로 연관관계 거울인 Team 엔티티의 members의 수정사항은 DB에 반영되지 않습니다.
물론 Team 엔티티의 members를 연관관계의 주인으로 설정할 수 있습니다. 하지만 Team이 연관관계 주인이 될 경우 Team의 members를 수정하면 TEAM 테이블이 아니라 MEMBER 테이블로 업데이트 쿼리가 나가야 합니다. 또한 Team에서 INSERT 쿼리가 나가면 Member에서 UPDATE 쿼리도 나가야 하는 문제가 생깁니다. 따라서 연관관계의 주인은 외래 키를 가진 테이블의 엔티티로 지정해야 합니다.
양방향 연관관계에서 아래 코드와 같이 연관관계 거울 쪽 데이터를 수정하고 데이터 변경을 기대하는 실수가 많으니 주의하도록 합시다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);
연관관계 거울의 데이터를 수정하고, 연관관계 주인 쪽 데이터를 수정하지 않았기 때문에 DB에서 유지하는 TEAM_ID는 null이 됩니다.
또한 양방향 매핑 시에 toString(), lombok, JSON 생성 라이브러리의 무한 루프를 조심해야 합니다.
연관관계 편의 메소드
양방향 매핑 시 연관관계의 주인만 업데이트해도 DB 입장에서 전혀 문제가 없습니다. (사실 문제가 생길 수 있습니다. 아래에서 살펴봅시다.) 연관관계 거울 쪽에서도 DB의 데이터를 조회하여 데이터를 동기화할 수 있습니다.
하지만 순수한 객체 관계를 고려한다면 항상 양쪽 모두 값을 업데이트해야 합니다. 이때 연관관계 편의 메소드를 생성하여 연관관계 주인에 변경 사항이 발생했을 때 연관관계 거울 엔티티에도 변경 사항을 적용합시다. 연관관계 편의 메소드는 아래 코드와 같습니다.
@Entity
public class Member {
// ... 생략
// 연관관계 편의 메소드
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
}
연관관계 편의 메소드를 사용하면 양방향 관계에서 한쪽만 업데이트하고 나머지 한쪽을 업데이트하지 않는 문제를 해결할 수 있습니다. 연관관계 편의 메소드는 두 엔티티 중에 어디에 두어도 상관없습니다. 하지만 둘 중에 한 곳에서만 구현하도록 합시다.
그렇다면 연관관계 편의 메소드를 사용하지 않고 연관관계의 주인 쪽만 업데이트할 경우 발생할 문제는 어떤 게 있을지 살펴봅시다.
먼저 JPA에 의존하지 않는 테스트 케이스를 작성할 때 문제가 됩니다. JPA에 의존적이지 않는 테스트를 작성하기 위해서는 양방향 관계에서 두 엔티티를 모두 업데이트해야 합니다.
두 번째로 같은 트랜잭션 안에서 주인 쪽 엔티티를 수정하고 영속성 컨텍스트가 플러시 되기 전에 거울 쪽 엔티티에서 조회를 하면 주인 쪽 엔티티의 수정사항이 반영되지 않은 상태로 조회됩니다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setUsername("member1");
member.setTeam(team);
em.persist(member);
// team.getMembers().add(member);
// em.flush();
// em.clear();
Team findTeam = em.find(Team.class, team.getId()); // 1차 캐시
List<Member> members = findTeam.getMembers(); // members에 아무 값이 없습니다.
위 코드처럼 영속성 컨텍스트가 플러시 되기 전에는 1차 캐시에서 거울 쪽 엔티티를 반환하기 때문에 주인 쪽 엔티티의 수정사항이 반영되지 않습니다. 단지 영속성 컨텍스트의 1차 캐시에 순수한 Team 객체가 들어간 상태입니다. 주인 쪽 엔티티의 수정사항이 DB에 들어간 이후에 거울 쪽 엔티티에서 조회를 해야 업데이트된 내용이 조회되는 문제가 발생합니다. 따라서 이런 케이스 때문에 양쪽의 엔티티에 값을 세팅하는 것이 맞습니다.
정리
연관관계의 주인은 외래 키의 위치를 기준으로 정해야 합니다. 또한 테이블 구조에 따라 단방향 관계로 엔티티를 먼저 설계하고 개발을 하며 역순으로 조회하는 기능이 필요할 경우 단방향 관계를 양방향으로 수정합시다. 엔티티를 단방향에서 양방향 관계로 수정했다고 해서 테이블 구조에 영향을 미치지 않습니다. 객체 입장에서는 복잡한 양방향보다 단방향이 좋습니다. 따라서 가능하다면 단방향 관계로 설계합시다.
'Spring > JPA' 카테고리의 다른 글
상속관계 매핑 (2) | 2024.10.17 |
---|---|
일대일 연관관계 매핑 (1) | 2024.10.17 |
기본키 매핑 (0) | 2024.10.17 |
영속성 컨텍스트 플러시 (1) | 2024.10.16 |
영속성 컨텍스트 (2) | 2024.10.16 |