개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › 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에 전송할 뿐 트랜잭션을 커밋하는 것은 아니다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. new Team("개발팀"): 팀 객체를 만듭니다. 아직 자바 메모리에만 있고, members 목록은 비어 있습니다.
  2. em.persist(team): 기본 키를 IDENTITY 전략으로 만들기 때문에 INSERT가 바로 나가고, DB가 정한 id 1을 받아 옵니다.
  3. new Member("김철수"): 회원 객체를 만듭니다. team 필드는 아직 null입니다.
  4. team.getMembers().add(member): 팀 객체의 members 목록에 회원이 들어갑니다. 그런데 이 목록에는 mappedBy = "team"이 붙어 있습니다. "외래 키는 Member의 team 필드가 관리하고, 나는 읽기만 한다"는 뜻이어서, JPA는 SQL을 만들 때 이 목록을 보지 않습니다.
  5. em.persist(member): INSERT가 바로 나갑니다. team_id 컬럼에는 회원 객체의 team 필드에서 읽은 값이 들어갑니다. team 필드가 null이므로 team_id도 null입니다. 팀의 목록에 회원을 넣은 것은 이 값에 아무 영향을 주지 못했습니다.
  6. em.find(Team.class, team.getId()): 1차 캐시에 id 1인 팀이 있으므로 DB에 가지 않고 그 객체를 돌려줍니다. found와 team은 같은 객체입니다.
  7. found.getMembers().size(): found는 처음에 만든 team 객체 그대로이므로, 자바 목록에 들어 있는 개수를 그대로 돌려줍니다. DB에 묻지 않습니다. 목록에 회원을 넣었으므로 1입니다. DB의 team_id는 null인데, 메모리에서는 팀에 회원이 있는 것처럼 보입니다.
  8. 커밋: 더 보낼 SQL이 없습니다. 팀의 목록에 넣은 회원은 끝내 DB에 반영되지 않았습니다. 예외도 나지 않습니다.

실행 결과

코드

@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 로그에서는 테이블 이름 뒤에 별칭이 붙고 값 자리에 물음표가 들어갑니다.

앞 실습: 값 타입과 임베디드 타입 · 다음 실습: 다대일, 일대다, 일대일, 다대다

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요