이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › JPA › 연관관계
연관관계의 주인
팀의 목록에 회원을 넣었는데 왜 DB의 외래 키는 비어 있나
팀과 회원이 서로를 가리키면 자바 객체에는 참조가 두 개(회원의 team, 팀의 members) 생깁니다. 하지만 DB에서 둘을 잇는 것은 member 테이블의 외래 키 team_id 하나뿐입니다. JPA는 두 참조 가운데 한쪽만 보고 외래 키를 씁니다. 그쪽을 연관관계의 주인이라고 합니다.
실행으로 대조 · Hibernate 7.4.5와 H2에서 열두 가지 조합을 실행해 테이블, 나간 SQL, 외래 키 값, 목록의 크기를 대조
이 실습에 나오는 개념
- 연관관계의 주인과 mappedBy
- 양방향 연관관계에서 외래 키를 실제로 관리하는 쪽을 연관관계의 주인이라고 한다. JPA는 주인 쪽 참조에 들어 있는 값만 보고 외래 키를 INSERT하거나 UPDATE한다. 주인이 아닌 쪽에는 mappedBy에 주인 쪽 필드 이름을 적어 읽기만 한다고 표시하며, 여기에 값을 넣어도 DB에는 반영되지 않는다. 외래 키가 있는 테이블에 대응하는 엔티티, 곧 다대일에서 "다" 쪽이 주인이다(JPA 명세는 양방향 일대다·다대일 연관관계에서 "다" 쪽이 주인이어야 한다고 정해 두었고, 그래서 @ManyToOne에는 mappedBy가 없다). 양방향인데 mappedBy를 빠뜨리면 두 참조가 서로 다른 연관관계로 취급되어 뜻하지 않은 연결 테이블이 생긴다.
- 단방향과 양방향 연관관계
- 엔티티가 다른 엔티티를 참조하는 방향을 말한다. 회원만 팀을 필드로 가지면 단방향이고, 팀도 회원 목록을 필드로 가져 서로를 가리키면 양방향이다. 테이블은 외래 키 하나로 양쪽에서 조인할 수 있어서 방향이 따로 없지만, 객체는 참조를 가진 쪽에서만 상대를 찾아갈 수 있다. 그래서 객체의 양방향은 사실 서로 다른 단방향 참조 두 개이고, 둘 가운데 어느 쪽이 외래 키를 관리할지 정해 주어야 한다.
- 기본 키, 유니크 키, 외래 키
- 기본 키(PK)는 테이블의 각 행을 유일하게 식별하는 컬럼으로 값이 중복되거나 비어 있을 수 없고 테이블에 하나만 둔다. 유니크 키는 값의 중복을 막는 제약으로 여러 개 둘 수 있다. 외래 키(FK)는 다른 테이블의 기본 키(또는 유니크 키)를 참조하는 컬럼으로, 참조 대상이 실제로 존재하도록 보장한다.
- 1차 캐시
- 영속성 컨텍스트 안에 엔티티를 식별자 기준으로 보관하는 저장소다. 같은 트랜잭션 안에서 같은 식별자로 다시 조회하면 DB에 쿼리를 보내지 않고 보관된 엔티티를 돌려준다. 그 결과 같은 트랜잭션 안에서 같은 식별자의 엔티티는 항상 같은 객체다. 기본적으로 트랜잭션이 끝나면 사라지므로(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다) 애플리케이션 전체의 캐시와는 다르다.
- 쓰기 지연과 flush
- 쓰기 지연은 INSERT, UPDATE, DELETE 문을 바로 DB에 보내지 않고 모아 두었다가 한꺼번에 보내는 것이다. 단, 기본 키를 IDENTITY 전략(MySQL의 AUTO_INCREMENT 등)으로 생성하는 엔티티는 식별자를 얻기 위해 저장을 요청한 시점에 INSERT가 바로 실행된다. flush는 모아 둔 변경 내용을 DB에 보내는 동작으로, 트랜잭션 커밋 직전이나 JPQL 쿼리 실행 직전에 자동으로 일어난다. flush는 SQL을 DB에 전송할 뿐 트랜잭션을 커밋하는 것은 아니다.
이렇게 바꿔 보세요
- 처음 설정으로 — 팀의 목록에만 회원을 넣습니다. 예외는 없는데 DB의 team_id는 null입니다.
- 회원 쪽에만 넣으면 — team_id는 저장되지만, 같은 트랜잭션에서 읽은 팀의 목록은 비어 있습니다.
- 양쪽에 다 넣으면 — DB의 외래 키와 메모리의 목록이 모두 맞습니다.
- 팀 쪽에만 넣고 DB에서 다시 읽으면 — 메모리에서는 1명이던 목록이 DB에서 다시 읽으면 0명입니다.
- 회원 쪽에만 넣고 DB에서 다시 읽으면 — 외래 키가 저장돼 있으므로 다시 읽은 목록은 1명입니다.
- mappedBy를 빠뜨리면 — 연결 테이블 team_member가 따로 생기고 INSERT가 한 번 더 나갑니다.
처음 설정으로 실행하면
- new Team("개발팀"): 팀 객체를 만듭니다. 아직 자바 메모리에만 있고, members 목록은 비어 있습니다.
- em.persist(team): 기본 키를 IDENTITY 전략으로 만들기 때문에 INSERT가 바로 나가고, DB가 정한 id 1을 받아 옵니다.
- new Member("김철수"): 회원 객체를 만듭니다. team 필드는 아직 null입니다.
- team.getMembers().add(member): 팀 객체의 members 목록에 회원이 들어갑니다. 그런데 이 목록에는 mappedBy = "team"이 붙어 있습니다. "외래 키는 Member의 team 필드가 관리하고, 나는 읽기만 한다"는 뜻이어서, JPA는 SQL을 만들 때 이 목록을 보지 않습니다.
- em.persist(member): INSERT가 바로 나갑니다. team_id 컬럼에는 회원 객체의 team 필드에서 읽은 값이 들어갑니다. team 필드가 null이므로 team_id도 null입니다. 팀의 목록에 회원을 넣은 것은 이 값에 아무 영향을 주지 못했습니다.
- em.find(Team.class, team.getId()): 1차 캐시에 id 1인 팀이 있으므로 DB에 가지 않고 그 객체를 돌려줍니다. found와 team은 같은 객체입니다.
- found.getMembers().size(): found는 처음에 만든 team 객체 그대로이므로, 자바 목록에 들어 있는 개수를 그대로 돌려줍니다. DB에 묻지 않습니다. 목록에 회원을 넣었으므로 1입니다. DB의 team_id는 null인데, 메모리에서는 팀에 회원이 있는 것처럼 보입니다.
- 커밋: 더 보낼 SQL이 없습니다. 팀의 목록에 넣은 회원은 끝내 DB에 반영되지 않았습니다. 예외도 나지 않습니다.
실행 결과
- 주인이 아닌 쪽(mappedBy가 붙은 Team.members)에만 넣으면 외래 키가 저장되지 않습니다. 예외가 나지 않아서 저장된 줄 알기 쉽습니다. 같은 트랜잭션 안에서는 메모리의 목록에 회원이 보이기 때문에 더 알아차리기 어렵습니다.
코드
@Entity
class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String name;
@OneToMany(mappedBy = "team") // 주인이 아니다. 읽기만 한다
List<Member> members = new ArrayList<>();
}
@Entity
class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id") // 주인이다. 외래 키 team_id를 관리한다
Team team;
}
// 생성자와 getter, setter는 줄였습니다
// 트랜잭션 안에서
Team team = new Team("개발팀");
em.persist(team);
Member member = new Member("김철수");
team.getMembers().add(member); // 주인이 아닌 쪽에 넣는다
em.persist(member);
Team found = em.find(Team.class, team.getId());
int size = found.getMembers().size();
// 트랜잭션 커밋
이 모형의 전제
팀 하나와 회원 하나를 새로 저장하는 경우만 다룹니다. 이미 저장된 회원의 팀을 바꾸는 경우(UPDATE가 나가는 경우)는 다루지 않았습니다. 기본 키는 IDENTITY 전략이어서 persist를 부를 때 INSERT가 바로 나갑니다. 열두 가지 조합 모두를 Hibernate 7.4.5와 H2 데이터베이스에서 같은 구조의 코드로 실행해, 만들어진 테이블과 컬럼, 나간 SQL의 종류와 테이블별 횟수, 그 SQL이 persist 때 나갔는지 커밋 직전에 나갔는지, DB의 member.team_id 값, 연결 테이블의 행 수, team.getMembers().size()가 화면과 일치하는 것을 확인했습니다. 연결 테이블과 그 컬럼의 이름(team_member, team_id, members_id)은 Hibernate 7.4.5가 기본 규칙으로 지은 것이고 @JoinTable로 바꿀 수 있습니다. 화면의 SQL 문장은 읽기 쉽게 줄여 쓴 것이어서 실제 문장과 글자가 같지는 않습니다. SQL 로그에서는 테이블 이름 뒤에 별칭이 붙고 값 자리에 물음표가 들어갑니다.
앞 실습: 값 타입과 임베디드 타입 · 다음 실습: 다대일, 일대다, 일대일, 다대다