이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › JPA › 연관관계
다대일, 일대다, 일대일, 다대다
관계의 종류에 따라 테이블과 외래 키는 어떻게 달라지나
엔티티 사이의 관계에는 다대일, 일대다, 일대일, 다대다가 있습니다. 어느 것을 고르느냐에 따라 만들어지는 테이블의 수, 외래 키가 놓이는 테이블, 저장할 때 나가는 SQL이 달라집니다.
실행으로 대조 · Hibernate 7.4.5와 H2에서 열네 가지 조합을 실행해 테이블, 외래 키와 유니크 제약, 나간 SQL, 남은 값을 대조
이 실습에 나오는 개념
- 다대일과 일대다 연관관계
- 회원 여러 명이 팀 하나에 속하는 관계를 회원 쪽에서 보면 다대일(@ManyToOne), 팀 쪽에서 보면 일대다(@OneToMany)다. 테이블에서는 어느 쪽에서 보든 외래 키가 "다" 쪽 테이블(회원)에 놓인다. 다대일은 외래 키를 가진 엔티티가 그 값을 직접 관리하므로 INSERT 한 번으로 저장된다. 일대다 단방향은 반대편 엔티티의 목록이 외래 키를 관리해서, 행을 먼저 넣은 뒤 외래 키를 채우는 UPDATE가 더 나간다.
- 일대일 연관관계
- 한 엔티티가 다른 엔티티 하나와만 이어지는 관계이며 @OneToOne으로 매핑한다. 외래 키는 두 테이블 가운데 어느 쪽에 두어도 되고, 외래 키를 둔 테이블의 엔티티가 연관관계의 주인이 된다. 외래 키 컬럼에는 유니크 제약이 함께 걸려, 같은 상대를 두 행이 가리킬 수 없게 된다. 다대일과 테이블 모양은 같고 이 유니크 제약이 있다는 점만 다르다.
- 다대다와 연결 테이블
- 회원이 상품 여러 개를 고르고 한 상품을 여러 회원이 고를 수 있는 것처럼, 양쪽 모두 상대가 여러 개인 관계가 다대다다. 테이블 두 개의 외래 키만으로는 표현할 수 없어서, 양쪽의 기본 키를 외래 키로 가진 연결 테이블을 가운데에 둔다. @ManyToMany를 쓰면 JPA가 이 연결 테이블을 자동으로 만들고 관리한다. 자동으로 만든 연결 테이블에는 외래 키 두 개만 들어가므로, 수량이나 날짜 같은 값이 더 필요하면 연결 테이블을 엔티티로 만들어 일대다와 다대일로 풀어 쓴다.
- 연관관계의 주인과 mappedBy
- 양방향 연관관계에서 외래 키를 실제로 관리하는 쪽을 연관관계의 주인이라고 한다. JPA는 주인 쪽 참조에 들어 있는 값만 보고 외래 키를 INSERT하거나 UPDATE한다. 주인이 아닌 쪽에는 mappedBy에 주인 쪽 필드 이름을 적어 읽기만 한다고 표시하며, 여기에 값을 넣어도 DB에는 반영되지 않는다. 외래 키가 있는 테이블에 대응하는 엔티티, 곧 다대일에서 "다" 쪽이 주인이다(JPA 명세는 양방향 일대다·다대일 연관관계에서 "다" 쪽이 주인이어야 한다고 정해 두었고, 그래서 @ManyToOne에는 mappedBy가 없다). 양방향인데 mappedBy를 빠뜨리면 두 참조가 서로 다른 연관관계로 취급되어 뜻하지 않은 연결 테이블이 생긴다.
- 기본 키, 유니크 키, 외래 키
- 기본 키(PK)는 테이블의 각 행을 유일하게 식별하는 컬럼으로 값이 중복되거나 비어 있을 수 없고 테이블에 하나만 둔다. 유니크 키는 값의 중복을 막는 제약으로 여러 개 둘 수 있다. 외래 키(FK)는 다른 테이블의 기본 키(또는 유니크 키)를 참조하는 컬럼으로, 참조 대상이 실제로 존재하도록 보장한다.
이렇게 바꿔 보세요
- 처음 설정으로 — 일대다 단방향. INSERT가 끝난 뒤에 외래 키를 채우는 UPDATE가 회원 수만큼 더 나갑니다.
- 다대일 — 테이블은 같은데 INSERT만으로 끝납니다.
- 일대다에서 @JoinColumn을 빼면 — 외래 키 대신 연결 테이블이 생깁니다.
- 다대다 — 가운데에 연결 테이블 member_product가 자동으로 생깁니다.
- 일대일: 외래 키를 회원 테이블에 — member.locker_id에 외래 키와 유니크 제약이 생깁니다.
- 일대일: 외래 키를 사물함 테이블에 — 외래 키가 locker.member_id로 옮겨 갑니다.
- 일대다에서 회원이 3명이면 — UPDATE도 3번으로 늘어납니다.
처음 설정으로 실행하면
- em.persist(team): 팀을 저장합니다. 기본 키가 IDENTITY 전략이어서 INSERT가 바로 나가고 id 1을 받아 옵니다.
- 1번째 반복, em.persist(member): Member 클래스에는 팀을 가리키는 필드가 없습니다. 그래서 INSERT 문에 team_id가 들어가지 않고, 이 행의 team_id는 null로 남습니다.
- 1번째 반복, team.getMembers().add(member): 팀의 목록에 회원을 넣습니다. 외래 키 team_id는 member 테이블에 있지만, 그것을 관리하는 것은 Team의 이 목록입니다. 지금은 메모리의 목록만 바뀌었습니다.
- 2번째 반복, em.persist(member): Member 클래스에는 팀을 가리키는 필드가 없습니다. 그래서 INSERT 문에 team_id가 들어가지 않고, 이 행의 team_id는 null로 남습니다.
- 2번째 반복, team.getMembers().add(member): 팀의 목록에 회원을 넣습니다. 외래 키 team_id는 member 테이블에 있지만, 그것을 관리하는 것은 Team의 이 목록입니다. 지금은 메모리의 목록만 바뀌었습니다.
- 트랜잭션 커밋: 커밋 직전에 flush가 일어납니다. JPA가 팀의 members 목록을 살펴보니 회원이 2명 들어 있습니다. INSERT 때 넣지 못한 team_id를 이제 채워야 합니다.
- 회원1의 외래 키를 채우는 UPDATE가 나갑니다. team_id가 null에서 1로 바뀝니다. 다대일이었다면 나가지 않았을 SQL입니다.
- 회원2의 외래 키를 채우는 UPDATE가 나갑니다. team_id가 null에서 1로 바뀝니다. 다대일이었다면 나가지 않았을 SQL입니다.
실행 결과
- 일대다 단방향에서도 외래 키는 member 테이블에 있습니다. 테이블에서 외래 키는 언제나 "다" 쪽에 놓이기 때문입니다. 그런데 그것을 관리하는 것은 반대편 엔티티인 Team의 목록입니다. Member를 INSERT할 때는 팀을 알 수 없어서, 나중에 UPDATE로 외래 키를 채웁니다. 회원 2명에 UPDATE가 2번 더 나갔습니다.
- 테이블 모양은 다대일과 똑같은데 SQL만 더 나갑니다. 그래서 일대다 단방향보다 다대일을 먼저 쓰고, 팀에서 회원 목록이 필요하면 mappedBy를 붙인 양방향으로 만드는 방법을 많이 씁니다.
코드
@Entity
class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String name;
@OneToMany // 팀 하나에 회원 여러 명
@JoinColumn(name = "team_id") // 외래 키는 상대 테이블(member)에 있다
List<Member> members = new ArrayList<>();
}
@Entity
class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String name;
} // 팀을 가리키는 필드가 없다
// 생성자와 getter, setter는 줄였습니다
// 트랜잭션 안에서
Team team = new Team("개발팀");
em.persist(team);
for (int i = 1; i <= 2; i++) {
Member member = new Member("회원" + i);
em.persist(member);
team.getMembers().add(member);
}
// 트랜잭션 커밋
이 모형의 전제
엔티티마다 필드는 id와 name만 두었고, 기본 키는 IDENTITY 전략이어서 persist를 부를 때 INSERT가 바로 나갑니다. 일대다와 다대다는 한쪽에서만 상대를 가리키는 단방향으로 만들었습니다. 열네 가지 조합(다대일, 일대다, @JoinColumn 없는 일대다, 다대다는 상대의 수 1·2·3개씩, 일대일은 외래 키 위치 두 가지) 모두를 Hibernate 7.4.5와 H2 데이터베이스에서 같은 구조의 코드로 실행해, 만들어진 테이블과 컬럼, 외래 키 제약이 걸린 컬럼과 유니크 제약, 나간 SQL의 종류와 테이블별 횟수, 커밋 전까지 나간 SQL, 외래 키 컬럼에 남은 값이 화면과 일치하는 것을 확인했습니다. 일대다 단방향의 INSERT 문에 team_id가 들어 있지 않다는 것도 실제 SQL에서 확인했습니다. 연결 테이블과 그 컬럼의 이름(team_member, members_id, member_product, products_id)은 Hibernate 7.4.5가 기본 규칙으로 지은 것이고 @JoinTable로 바꿀 수 있습니다. 화면의 SQL 문장은 읽기 쉽게 줄여 쓴 것이어서 실제 문장과 글자가 같지는 않습니다. 테이블 안의 컬럼 순서도 실제와 다를 수 있습니다.
앞 실습: 연관관계의 주인 · 다음 실습: 상속 관계 매핑