연관관계 매핑시 고려사항 세가지가 있다.
- 다중성
- 단방향, 양방향
- 연관관계 주인
하나씩 차례로 알아보자.
1. 다중성
JPA에서 제공하는 어노테이션은 전부 다 DB와 매핑하기 위해 제공한다.
- 다대일(@ManyToOne)
- 일대다(@OneToMany)
- 일대일(@OneToOne)
- 다대다(@ManyToMany)
2. 단방향 양방향
테이블 외래키 하나로 양쪽 조인 가능하다. 그래서 사실 방향이라는 개념이 없다.
하지만 객체는 참조용 필드가 있는 쪽으로만 참조 가능하다. 즉, 한쪽만 참조하면 단방향, 양쪽이 서로 참조하면 양방향이다.
3. 연관관계의 주인
테이블은 외래키 하나로 두 테이블이 연관관계를 맺는다. 하지만 객체 양방향 관계는 참조가 두곳 다 일어난다. 그래서 두 객체중 테이블의 외래키를 관리할 곳을 지정해줘야 한다.
즉, 외래 키를 관리하는 쪽이 연관관계의 주인이 된다. 더 나아가 연관관계의 반대편(주인의 반대편)은 외래 키에 영향을 주지 않고 단순 조회만 하도록 한다.
4. 다대일 (N:1)
RDBMS에서는 '다' 쪽에 항상 외래키가 존재한다. 객체에서는 외래키 있는 곳에 연관된 참조를 넣어 매핑을 걸면 된다.
예제 코드
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
다대일 양방향은 반대쪽 (Team)에 연관관계를 추가해주면 된다. 참조를 추가해도 테이블에 영향을 끼치진 않는다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
5. 일대다 (1: N)
여기서는 '1'이 연관관계의 주인이 된다. 이 모델은 권장하진 않지만 표준 스펙에서는 지원한다.
Member 객체는 Team 객체를 알고 싶지 않지만 Team 객체는 Member을 알고싶어하는 설계를 했다고 가정하자.
Team의 members(List)를 변경했을 때 Member 테이블을 변경시켜줘야 한다. 이런게 일대다 단방향 관계이다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
@OneToMany
@JoinColumn(name = "TEAM_ID")
private List<Member> members = new ArrayList<>();
}
Team 클래스를 변경하고 테스트 클래스를 작성해보자.
@Test
void test_1(){
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member1 = new Member();
member1.setUsername("member1");
member1.setAge(20);
em.persist(member1);
Team team1 = new Team();
team1.setName("teamA");
team1.getMembers().add(member1);
em.persist(team1);
tx.commit();
}
실행결과 Member와 Team은 Insert를 하고 Member의 TEAM_ID를 UPDATE하는 쿼리도 같이 한번 더 실행됐다.
Hibernate:
/* insert for
jpa.practice.v0.Member */insert
into
member (age, username, id)
values
(?, ?, ?)
Hibernate:
/* insert for
jpa.practice.v0.Team */insert
into
team (name, id)
values
(?, ?)
Hibernate:
update
member
set
team_id=?
where
id=?
문제는 테이블 일대다 관계는 항상 '다' 쪽에 외래키가 존재한다. 객체와 테이블의 차이 때문에 반대편 테이블의 외래 키를 관리하는 특이한 구조이다.
@JoinColumn을 꼭 사용해야 한다. 안그러면 중간 조인 테이블이 생긴다.
그리고 연관관계 관리를 위해 추가로 UPDATE SQL이 실행된다.
6. 일대일 (1:1)
일대일 관계는 그 반대도 일대일이다. 주 테이블이나 대상 테이블 중에 외래 키 선택가능하다.
사용법은 간단한다. @OneToOne 어노테이션과 @JoinColumn을 사용하면 된다.
@Entity
public class Locker {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "LOCKER_ID")
private Long id;
private String name;
}
// Member.java
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
}
위에와 똑같이 다대일 양방향 매핑처럼 외래 키가 있는 곳이 연관관계의 주인이다. 반대편도 역시 mappedBy을 적용하면 된다.
주의사항이 있다. 바로 대상 테이블(locker)에서 외래키 단방향은 지원하지 않는다.
여기서 주 테이블 / 대상 테이블이 잘 이해가 안갈 수 있다. FK가 있는 테이블을 주 테이블이라 한다.
쉽게 이해하자면 비즈니스 관점에서 실제 개발을 해보아야 주 테이블이 정해진다. 보통 게시판, 첨부파일이라고 하면 게시판이 주 테이블이 될 가능성이 높을 것이다.
@OneToOne의 대상테이블은 왜 지연로딩이 불가능할까?
다시 돌아와서 대상 테이블에 외래 키의 제일 큰 단점은 지연로딩을 설정해도 항상 즉시 로딩된다는 점이다.
여기서 또 의문이 들 수도 있다. 왜 대상 테이블 @OneToMany와 같은 건 지연로딩이 되는데 왜 @OneToOne은 안될까?
Member, Locker 엔티티가 존재한다고 가정해보자. 외래키는 Locker에 있고, Locker가 연관관계의 주인이다.
만약 Locker 의 결과가 없다면 어떻게 노출이 될까? 바로 결과는 null이 되어야 한다.
그런데 만약 Member -> Locker가 지연로딩 관계라면 Locker는 프록시 객체가 되어야 한다. 이런 상황에서 Meber.Locker를 호출한다면 우리가 기대하는 것은 null이 되어야 하지만 프록시 객체를 가지게 된다.
결국 프록시를 미리 넣게 되면 null값을 넣을 수 없는 문제가 발생한다.
그런데 지금처럼 @OneToOne을 넣게 된다면 Member 를 조회할때 Locker의 데이터가 있는지 없는지 판단이 불가능하기 때문에 강제로 즉시로딩을 해서 데이터가 있으면 해당 데이터를 넣고 없으면 null을 입력하게 된다.
그러면 @OneToMany(컬렉션)의 경우에는 어떻게 될까?
컬렉션은 재미있게도 null일 필요가 없다. 컬렉션 자체가 데이터가 없는 Empty를 표현할 수 있기 때문이다. 따라서 컬렉션은 항상 지연로딩으로 동작할 수 있다.
이제 코드를 통해 알아보자.
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
// @OneToOne(fetch = FetchType.LAZY)
// @JoinColumn(name = "LOCKER_ID")
@OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
private Locker locker; //Member 테이블을 대상 테이블로 변경 (FK 존재 X)
}
// Locker.java
@Entity
@Data
public class Locker {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "LOCKER_ID")
private Long id;
private String name;
@OneToOne
@JoinColumn(name = "MEMBER_ID")
private Member member; // Locker 테이블을 주 테이블로 변경 (FK 존재 O)
}
테스트 클래스를 작성해보자.
@Test
@Transactional
@Rollback(false)
void test_1(){
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member1 = new Member();
member1.setUsername("member1");
member1.setAge(20);
em.persist(member1);
Locker locker = new Locker();
locker.setName("locker1");
locker.setMember(member1);
em.persist(locker);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member1.getId());
System.out.println("findMember.getLocker() = " + findMember.getLocker().getName());
tx.commit()
// 이하 생략...
}
Member 클래스에서 볼 수 있듯이 Locker를 조회할 때 FetchType.LAZY으로 지연로딩으로 설정했다.
하지만 실행결과는
Hibernate:
select
l1_0.locker_id,
m1_0.id,
m1_0.age,
m1_0.username,
l1_0.name
from
locker l1_0
left join
member m1_0
on m1_0.id=l1_0.member_id
where
l1_0.member_id=?
findMember.getLocker() = locker1
즉시로딩으로 실행하는 것을 볼 수 있다.
7. 다대다(N:M)
관계형 데이터베이스는 정규화된 테이블 두개로 다대다 관계를 표현할 수 없다. 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야 한다.
하지만 객체는 컬렉션을 사용해서 객체 두개로 다대다 관계가 가능하다.
@ManyToMany 사용하고 @JoinTable로 연결 테이블 지정해서 사용한다. 물론 단방향 양방향 둘다 가능하다.
그러나 편리해 보이지만 실무에서 사용하면 안된다고 한다. 왜냐하면 연결 테이블이 단순히 연결만 하고 끝나지 않는다. 필드가 두 객체의 ID 뿐만 아니라 다른 필드도 필요할 수도 있다라는 말이다.
다대다 매핑을 쓰지 않으려면 연결 테이블을 엔티티로 승격하면 된다.
'JPA' 카테고리의 다른 글
[JPA] JPA의 타입 (임베디드 타입, 값 타입, 컬렉션 타입)이란? (1) | 2024.12.20 |
---|---|
[JPA] 프록시와 지연로딩, 즉시로딩 (0) | 2024.11.26 |
[JPA] 엔티티 매핑 (0) | 2024.11.25 |
[JPA] 영속성 전이 CASCADE, OrphanRemoval = true 이해 및 차이 (0) | 2024.11.19 |
[JPA] JPA와 영속성 컨텍스트란? (1) | 2024.11.18 |