이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › JPA › 엔티티 매핑
값 타입과 임베디드 타입
주소를 묶은 클래스는 테이블에 어떻게 저장되고, 왜 불변으로 만드나
도시와 거리처럼 늘 함께 다니는 값은 Address 같은 클래스로 묶어 두면 다루기 쉽습니다. 이런 클래스는 id가 없고 들어 있는 값이 전부여서 값 타입이라고 부릅니다. 값 타입은 엔티티와 달리 혼자서는 저장되지 않고, 그것을 가진 엔티티에 딸려서 저장됩니다. 값 하나는 엔티티의 테이블에 컬럼으로 들어가고, 여러 개를 담은 컬렉션은 그 엔티티의 id를 외래 키로 가진 별도 테이블에 들어갑니다. 여기에서 생기는 세 가지 장면을 봅니다. 테이블이 어떤 모양이 되는지, 같은 값 객체를 두 엔티티가 함께 쓰면 무슨 일이 생기는지, 값 타입을 여러 개 담은 컬렉션을 고치면 어떤 SQL이 나가는지입니다.
실행으로 대조 · Hibernate 7.4.5와 H2에서 쉰여섯 가지 조합을 실행해 테이블, 나간 SQL, 남은 값, 시작할 때의 예외를 대조
이 실습에 나오는 개념
- 값 타입
- 식별자(id) 없이, 들어 있는 값만으로 의미가 정해지는 타입이다. 숫자나 문자열 같은 기본 값, 주소처럼 여러 값을 묶은 임베디드 타입, 그것들을 여러 개 담은 값 타입 컬렉션이 여기에 든다. 엔티티는 id로 구분되어 값이 바뀌어도 같은 것으로 추적되지만, 값 타입은 그것을 가진 엔티티에 딸려 저장되고 엔티티가 지워지면 함께 사라진다. 여러 엔티티가 같은 값 타입 객체를 함께 가리키면 한쪽에서 고친 값이 다른 쪽에도 보이므로, 값 타입은 불변 객체로 만드는 것이 원칙이다.
- 임베디드 타입(@Embeddable)
- 도시와 거리처럼 함께 쓰이는 값을 클래스 하나로 묶어 엔티티의 필드로 쓰는 값 타입이다. 클래스에 @Embeddable을, 그것을 쓰는 필드에 @Embedded를 붙인다. 따로 테이블이 만들어지지 않고, 안에 든 필드가 엔티티 테이블의 컬럼으로 펼쳐져 저장된다. 그래서 테이블 모양과 SQL은 필드를 그대로 둔 경우와 같고, 자바 코드에서만 관련된 값과 메서드가 한곳에 모인다. 한 엔티티에서 같은 임베디드 타입을 두 번 쓰면 컬럼 이름이 겹치므로 @AttributeOverride로 이름을 바꿔 준다.
- 값 타입 컬렉션(@ElementCollection)
- 값 타입을 여러 개 담은 List나 Set을 엔티티의 필드로 두는 것이다. 한 행의 컬럼에 값을 여러 개 넣을 수는 없으므로, 엔티티의 id를 외래 키로 가진 별도의 테이블에 값마다 한 행씩 저장된다. 값 타입에는 id가 없어서 DB의 어느 행이 바뀐 것인지 집어내기 어렵다. 그래서 컬렉션이 바뀌면 그 엔티티의 행을 모두 지우고 다시 넣는 SQL이 나갈 수 있다. 값이 몇 개 안 되고 잘 바뀌지 않을 때 쓰고, 그렇지 않으면 엔티티로 만들어 일대다 관계로 푼다.
- 불변 객체
- 생성된 뒤에는 상태가 바뀌지 않는 객체다. 값이 바뀌지 않으므로 여러 스레드가 동시에 사용해도 안전하고, 다른 곳에 넘겨도 변경될 걱정이 없으며, 캐싱하거나 해시 컬렉션의 키로 쓰기에 적합하다. 필드를 private final로 선언하고 상태를 바꾸는 메서드를 두지 않으며, 클래스를 final로 해 하위 클래스가 동작을 바꾸지 못하게 한다. 필드가 가변 객체(List, Date 등)를 가리키면 생성자와 getter에서 복사본을 주고받아(방어적 복사) 밖에서 내부를 바꾸지 못하게 한다.
- 변경 감지(Dirty Checking)
- 영속성 컨텍스트가 관리하는 엔티티의 값이 바뀌면, 플러시할 때(보통 커밋 직전) JPA가 처음 읽었던 상태와 비교해 값이 달라진 엔티티에 대한 UPDATE 문을 자동으로 실행하는 기능이다. Hibernate는 기본적으로 바뀐 컬럼만이 아니라 전체 컬럼을 UPDATE 문에 넣으며, 바뀐 컬럼만 넣으려면 @DynamicUpdate를 쓴다. 따라서 조회한 엔티티의 값을 바꾸기만 하면 별도의 저장 메서드를 호출하지 않아도 DB에 반영된다.
이렇게 바꿔 보세요
- 처음 설정으로 — 회원1의 도시만 고쳤는데 회원2의 행까지 UPDATE됩니다.
- 복사해서 넣으면 — 객체가 따로이므로 회원1의 행만 UPDATE됩니다.
- 값 타입을 불변으로 만들면 — setter가 없어서 함께 쓰는 객체를 고칠 방법이 없습니다.
- 임베디드 타입의 테이블 — Address 테이블은 생기지 않고 member 테이블의 컬럼이 됩니다.
- 같은 값 타입을 두 번 쓰면 — 컬럼 이름이 겹쳐서 시작할 때 예외가 납니다.
- 값 타입 컬렉션에서 하나만 바꾸면 — 그 회원의 주소를 전부 지우고 다시 넣습니다.
- Set이고 컬럼이 모두 NOT NULL이면 — 바뀐 행 하나만 지우고 넣습니다.
처음 설정으로 실행하면
- new Address("서울", "강남대로"): 주소 값 객체를 하나 만듭니다. 변수 addr이 이 객체를 가리킵니다.
- new Member("회원1", addr): 회원1의 address 필드가 addr과 같은 객체를 가리킵니다. 객체가 복사된 것이 아니라, 같은 객체를 가리키는 참조가 하나 더 생긴 것입니다.
- new Member("회원2", addr): 회원2의 address 필드도 같은 객체를 가리킵니다. 이제 주소 객체 하나를 두 회원이 함께 쓰고 있습니다.
- em.persist(member1): INSERT가 나가 회원1의 행이 저장됩니다. 주소의 값은 member 테이블의 city, street 컬럼에 들어갑니다.
- em.persist(member2): INSERT가 나가 회원2의 행이 저장됩니다. 테이블에서는 두 행이 각자 city와 street 값을 따로 가지고 있습니다. 객체를 함께 쓴다는 사실은 테이블에 남지 않습니다.
- member1.getAddress().setCity("부산"): 회원1의 주소만 바꾸려는 코드입니다. 하지만 고친 것은 두 회원이 함께 쓰는 그 객체입니다. member2.getAddress().getCity()도 이제 "부산"입니다.
- 트랜잭션 커밋, 변경 감지: 커밋 직전에 JPA가 관리 중인 엔티티의 현재 값을 처음 저장한 값과 비교합니다. 회원1의 city가 서울에서 부산으로 달라졌으므로 회원1의 행에 UPDATE가 나갑니다.
- 회원2도 비교합니다: 회원2의 address가 가리키는 객체도 같은 객체이므로 city가 부산으로 달라져 있습니다. 회원2의 행에도 UPDATE가 나갑니다. 고치려던 것은 회원1뿐인데 회원2의 주소까지 바뀌었습니다.
실행 결과
- 값 타입 객체 하나를 두 엔티티가 함께 가리키면, 한쪽에서 고친 값이 다른 쪽에도 그대로 보입니다. JPA는 두 엔티티가 모두 달라졌다고 보고 UPDATE를 2번 보냅니다. 컴파일 오류도 예외도 없어서 찾기 어려운 버그가 됩니다.
- 자바에서는 객체를 변수에 넣거나 넘길 때 객체가 복사되지 않고 참조만 복사됩니다. 그래서 "함께 쓰지 않도록 조심한다"는 것만으로는 막기 어렵습니다.
코드
@Embeddable
class Address {
String city;
String street;
void setCity(String city) { this.city = city; } // 값을 고칠 수 있다
}
@Entity
class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String name;
@Embedded
Address address;
}
// 생성자와 getter는 줄였습니다
// 트랜잭션 안에서
Address addr = new Address("서울", "강남대로");
Member member1 = new Member("회원1", addr);
Member member2 = new Member("회원2", addr);
em.persist(member1);
em.persist(member2);
member1.getAddress().setCity("부산");
// 트랜잭션 커밋
이 모형의 전제
쉰여섯 가지 조합(테이블 모양 다섯 가지, 함께 쓰기 세 가지, 값 타입 컬렉션은 종류 네 가지 × 하는 일 세 가지 × 주소 2·3·4·5개) 모두를 Hibernate 7.4.5와 H2 데이터베이스에서 같은 구조의 코드로 실행해, 만들어진 테이블과 컬럼, 나간 SQL의 종류와 테이블별 횟수, 커밋 전까지 나간 SQL, DELETE가 그 회원의 행 전부를 지우는 문장인지, DB에 남은 값, 다시 읽은 address가 null인지, 시작할 때의 예외가 화면과 일치하는 것을 확인했습니다. 값 타입 컬렉션의 결과는 Hibernate 7.4.5의 동작이며 버전에 따라 다를 수 있습니다. 특히 Set에서 컬럼이 null을 허용하면 List처럼 전부 지우고 다시 넣는다는 점은 실행해서 확인한 것입니다. @OrderColumn에서 어느 번호의 행을 지우고 고치는지는 실제 SQL 문장의 조건(member_id와 idx)과 실행 뒤에 남은 행으로 확인했습니다. 기본 키는 IDENTITY 전략이어서 persist를 부를 때 INSERT가 바로 나갑니다. 함께 쓰기 장면은 두 회원을 새로 저장하는 한 트랜잭션 안의 일입니다. DB에서 읽어 온 엔티티들은 각자 새로 만들어진 값 객체를 가지므로, 코드에서 같은 객체를 직접 넣어 줄 때만 이 문제가 생깁니다. 화면의 SQL 문장은 읽기 쉽게 줄여 쓴 것이어서 실제 문장과 글자가 같지는 않습니다. 테이블 안의 컬럼 순서도 실제와 다를 수 있습니다.
앞 실습: 엔티티 매핑과 기본 키 · 다음 실습: 연관관계의 주인