JPA의 데이터 타입을 크게 분류하면 엔티티 타입과 값 타입으로 나뉠 수 있다.
엔티티 타입은 @Entity
로 정의하는 객체이고, 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말한다.
엔티티 타입은 식별자를 통해 지속적으로 그 값들을 추적할 수 있지만, 값 타입은 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없다. 그리고 값 타입은 기본값 타입, 임베디드 타입, 컬렉션 값 타입으로 나뉜다.
엔티티 타입
@Entity
로 정의하는 객체
데이터가 변해도 식별자를 통해 지속적으로 추적 가능
값 타입
int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
식별자 없고 값만 있으므로 변경시 추적 불가능
생명주기를 엔티티 의존
기본값 타입: 자바 기본 타입(int), 래퍼 클래스(Integer), String처럼 자바가 제공하는 기본 데이터 타입
임베디드 타입: JPA에서 사용자가 직접 정의한 값 타입
컬렉션 값 타입: 하나 이상의 값 타입을 저장할 때 사용
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
}
위 코드에서 String, int가 기본값 타입이다.
Member 엔티티는 id
라는 식별자 값을 가지고 생명주기도 있지만, 값 타입인 name
, age
는 식별자 값도 없고 생명주기도 Member 엔티티에 의존한다. 따라서 Member 엔티티를 제거하면 name
, age
값도 제거된다.
값 타입은 공유하면 안된다. 예를 들어, 다른 회원 엔티티의 이름을 변경한다고 해서 나의 이름까지 변경되면 안된다.
int, double 같은 자바의 기본 타입은 절대 공유되지 않고 항상 값이 복사된다. 따라서 값 타입으로 사용하기에 안전하다. 그러나 Integer, String 같은 클래스는 공유가 가능하고 참조 값이 전달된다. 따라서 여러 엔티티 간에 공유되지 않도록 주의해야 한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
// 근무 기간
@Temporal(TemporalType.DATE)
private Date startDate;
@Temporal(TemporalType.DATE)
private Date endDate;
// 집 주소
private String city;
private String street;
private String zipcode;
}
위 코드에서 '회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다.'
그러나 위와 같은 설명보다 '회원 엔티티는 이름, 근무 기간, 집 주소를 가진다'고 설명하는 것이 더 명확하다.
즉 회원이 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력이 떨어진다. 대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해질 것이다. 따라서 다음과 같이 임베디드 타입을 사용하면 된다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
// 근무 기간
@Embedded
private Period workPeriod;
// 집 주소
@Embedded
private Address homeAddress;
}
@Embeddable
public class Period {
@Temporal(TemporalType.DATE)
private Date startDate;
@Temporal(TemporalType.DATE)
private Date endDate;
}
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
public boolean isWork(Date date) {
// 값 타입을 위한 메소드 정의 가능
}
}
startDate, endDate를 합해서 Period 클래스를 만들고, city, street, zipcode를 합해서 Address 클래스를 만들었다.
임베디드 타입을 사용하려면 다음과 같이 어노테이션을 붙이면 된다.
@Embeddable
: 값 타입을 정의하는 곳에 표시
@Embedded
: 값 타입을 사용하는 곳에 표시
기본 생성자가 필수이다. 이때 기본 생성자는 public 또는 protected로 설정해야 하는데 protected로 설정하는 것이 더 안전하다. JPA가 이런 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플랙션 같은 기술을 사용할 수 있도록 지원해야 하기 때문이다.
임베디드 타입을 사용함으로써 새로 정의한 값 타입들을 재사용할 수 있으며, 응집도도 높다.
Period.isWork()
처럼 해당 값 타입에서만 사용하는 의미 있는 메소드도 만들 수 있다.
임베디드 타입도 값 타입이다. 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티에 생명주기를 의존한다.
임베디드 타입은 엔티티의 값일 뿐이다. 임베디드 타입의 데이터베이스 테이블 매핑은 값이 속한 엔티티의 테이블에 매핑된다. 즉 위 예제의 경우 회원 테이블은 id
, age
, name
, startDate
, endDate
, city
, street
, zipcode
칼럼을 가진다. 즉 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블의 형태는 같다.
임베디드 타입 덕분에 객체와 테이블을 세밀하게 매핑하는 것이 가능하다. 따라서 잘 설계한 ORM 애플리케이션은 임베디드 타입을 적절하게 활용하여 매핑한 테이블 수보다 클래스 수가 더 많다.
@Entity
public class Member {
@Embedded
Address address;
@Embedded
PhoneNumber phoneNumber;
}
@Embeddable
public class Address {
private String city;
private String street;
@Embedded
Zipcode zipcode; // 임베디드 타입 포함
}
@Embeddable
public class Zipcode {
private String zip;
private String plusFour;
}
@Embeddable
public class PhoneNumber {
private String areaCode;
private String localNumber;
@ManyToOne
PhoneServiceProvider provider; // 엔티티 참조
}
@Entity
public class PhoneServiceProvider {
@Id
private String name;
...
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
// 집 주소
@Embedded
Address homeAddress;
// 회사 주소
@Embedded
Address companyAddress;
}
만약 회원에게 주소가 하나 더 필요하면 어떻게 해야 할까? 위와 같이 매핑하면 회원 테이블에 매핑하는 칼럼명이 중복된다.
따라서 이런 경우 다음과 같이 @AttributeOverride
를 사용해서 매핑정보를 재정의하면 된다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
// 집 주소
@Embedded
Address homeAddress;
// 회사 주소
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column=@Column(name = "company_city")),
@AttributeOverride(name = "street", column=@Column(name = "company_street")),
@AttributeOverride(name = "zipcode", column=@Column(name = "company_zipcode"))})
Address companyAddress;
}
address
가 null이면 city
, street
, zipcode
칼럼 값은 모두 null이 된다. member1.setAddress(new Address("oldCity"));
Address address = member1.getAddress();
member2.setAddress(address);
// 회원2 주소 변경 -> 회원1 주소까지 변경
member2.getAddress().setCity("newCity");
회원2에 새로운 주소를 할당하려고 회원1의 주소를 그대로 참조해서 사용했다. 그 결과 회원2의 주소만 "newCity"로 변경되는 것이 아니라, 회원1의 주소도 "nerwCity"로 변경된다. 회원1의 address와 회원2의 address가 같은 인스턴스를 참조하기 때문이다.
이렇게 공유 참조로 인해 발생하는 버그는 찾아내기 어렵다. 이렇게 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용(side effect)이라 한다.
이런 부작용을 막으려면 값을 복사해서 사용하면 된다.
member1.setAddress(new Address("oldCity"));
// 회원1의 주소 값을 복사해서 새로운 인스턴스 생성
Address address1 = member1.getAddress();
Address address2 = new Address(address1.getCity(), address1.getStreet(), address1.getZipcode());
member2.setAddress(address2);
// 회원2 주소 변경 -> 부작용 발생X
member2.getAddress().setCity("newCity");
이제 회원1의 address와 회원2의 address는 같은 인스턴스를 참조하지 않는다.
이처럼 항상 값을 복사해서 새로운 인스턴스를 생성하면 공유 참조로 발생하는 부작용을 피할 수 있다.
그러나 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다. 자바의 기본 타입에 값을 대입하면 값이 복사되어 안전하다. 그러나 객체 타입은 참조 값이 대입되어 공유 참조를 피할 수 없다.
따라서 공유 참조로 인한 부작용을 막기 위해 값 타입을 불변 객체로 만들어야 한다. 객체를 불변하게 만들면 값을 수정할 수 없기 때문이다. 결론적으로 값 타입은 될 수 있으면 불변 객체로 설계해야 한다.
불변 객체를 구현하는 방법 중 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않는 것이다. 만약 값을 수정해야 하면 setter를 사용하지 않고, 아예 새로운 인스턴스를 생성하고 갈아끼워야 한다.
// 불변객체로 설계하면 setter 사용 불가
member.getAddress().setCity("newCity");
// 새로운 객체를 생성해 다시 값을 대입
Address address = member.getAddress();
member.setAddress(new Address("newCity", address.getStreet(), address.getZipcode());
자바가 제공하는 객체 비교는 2가지이다.
동일성 비교: 인스턴스 참조 값을 비교, ==
사용
동등성 비교: 인스턴스 값을 비교, equals()
사용
Address a = new Address("서울시", "종로구", "1번지");
Address a = new Address("서울시", "종로구", "1번지");
위 코드에서 a == b
로 동일성 비교를 하면 서로 다른 인스턴스이므로 결과는 거짓이다.
그러나 우리가 기대하는 결과가 아니다. 값 타입은 서로 다른 인스턴스여도 그 안에 값이 같으면 같은 것으로 봐야 한다.
따라서 값 타입을 비교할 때는 equals()
메소드를 사용해서 동등성 비교를 해야하고, 이때 Address의 equals()
메소드를 반드시 재정의해야 한다.
값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection
, @CollectionTable
어노테이션을 사용하여 값 타입 컬렉션을 사용하면 된다.
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@Embedded
private Address homeAddress;
@ElementCollection
@CollectionTable(name = "favorite_foods",
joinColumns = @JoinColumn(name = "member_id"))
@Column(name = "food_name")
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
@CollectionTable(name = "address",
joinColumns = @JoinColumn(name = "member_id"))
private List<Address> addressHistory = new ArrayList<>();
}
데이터베이스의 테이블은 컬럼 안에 컬렉션을 포함할 수 없다. 따라서 별도의 테이블을 만들고 일대다 관계가 된다.
값 타입 컬렉션을 사용하기 위해서는 @ElementCollection
어노테이션을 사용한다.
그리고 컬렉션이 저장될 별도의 테이블을 매핑하기 위해 @CollectionTable
어노테이션을 사용하고 @JoinColumn
을 통해 외래키를 매핑한다.
favoriteFoods
처럼 값으로 사용하는 컬럼이 하나이면 @Column
을 사용하여 컬럼명을 지정할 수 있다.
값 타입 컬렉션을 매핑하는 테이블은 모든 칼럼을 묶어서 기본 키를 구성한다. 따라서 데이터베이스의 기본 키 제약 조건으로 인해 컬럼에 null을 입력할 수 없고, 같은 값을 저장할 수 없다는 한계가 있다.
Member member = new Member();
// 임베디드 값 타입
member.setHomeAddress(new Address("city", "street", "10000"));
// 기본값 타입 컬렉션
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("고기");
// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("oldCity1", "oldStreet1", "12010"));
member.getAddressHistory().add(new Address("oldCity2", "oldStreet2", "12121"));
em.persist(member);
위 예제에서 em.persist(member)
를 통해 데이터베이스에 실행되는 insert sql은 다음과 같다.
member
: insert sql 1번
member.homeAddress
: 컬렉션이 아닌 임베디드 값 타입이므로 회원 테이블에 member
를 저장하는 sql에 포함된다.
member.favoriteFood
: insert sql 3번
member.addressHistory
: insert sql 2번
따라서 em.persist(member)
한 번 호출로 영속성 컨텍스트를 플러시할 때 총 6번의 insert sql이 실행된다.
이때 Member
테이블 뿐만 아니라 favorite_foods
테이블과 address
테이블에도 sql이 실행된다. 즉 값 타입 컬렉션은 그 생명주기를 엔티티가 관리한다. 즉 값 타입 컬렉션은 영속성 전이와 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
Member findMember = em.find(Member.class, 1L);
값 타입 컬렉션도 조회할 때 패치 전략을 사용할 수 있는데 기본이 LAZY이다.
따라서 위 예제 코드에서 회원을 조회할 때 select sql은 member
테이블에만 실행되고, favorite_foods
테이블과 address
테이블에는 회원의 favoriteFood
와 addressHistory
가 사용될 때 sql이 실행된다.
Member member = em.find(Member.class, 1L);
// 임베디드 값 타입 수정
member.setHomeAddress(new Address("newCity", "newStreet", "newZipcode"));
// 기본값 타입 컬렉션 수정
member.getFavoriteFoods().remove("치킨"); // 기존 값 제거
member.getFavoriteFoods().add("탕수육"); // 새로운 값 추가
// 임베디드 값 타입 컬렉션 수정
member.getAddressHistory().remove(new Address("oldCity1", "oldStreet1", "12010")); // 기존 값 제거
member.getAddressHistory().add(new Address("oldCity3", "oldStreet3", "12111")); // 새로운 값 추가
임베디드 값 타입 수정: 임베디드 값 타입을 수정할 때는 setter를 사용하지 않고, 아예 새로운 인스턴스를 생성하고 갈아끼워야 한다. 그리고 homeAddress
임베디드 값 타입은 member
테이블과 매핑했으므로 member
테이블만 update sql이 실행된다.
기본값 타입 컬렉션 수정: 기존 값을 remove()
로 제거하고 새로운 값을 추가한다.
임베디드 값 타입 컬렉션 수정: 기존 값을 remove()
로 제거하고 새로운 값을 추가한다. 이때 기존 값을 제거하기 위해 값 타입의 인스턴스 참조 값 비교가 아닌 값 비교로 equals()
를 반드시 구현해야 한다.
특정 엔티티에 소속된 값 타입은 값이 변경되어도 자신이 소속된 엔티티를 데이터베이스에서 찾고 값을 변경하면 된다. 그러나 값 타입 컬렉션의 경우, 값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관된다. 따라서 이 값들이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다는 문제가 있다.
따라서 이런 문제로 JPA 구현체들은 값 타입 컬렉션에 변경사항이 있을시, 값 타입 컬렉션이 매핑된 테이블에서 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션 객체에 있는 모든 값들을 데이터베이스에 다시 저장한다.
예를 들어 식별자가 100번인 회원이 관리하는 주소 값 타입 컬렉션을 변경하면, 값 타입 컬렉션이 매핑된 테이블에서 member_id
가 100인 모든 칼럼을 삭제하고, 회원의 값 타입 컬렉션 객체에 있는 모든 값들을 다시 저장한다.
// 관련된(member_id가 100인) 모든 칼럼 delete
delete from address where member_id=100;
// member의 값 타입 컬렉션 객체의 값들 insert
insert into address (member_id, city, street, zipcode) values (100, "city1", ...);
insert into address (member_id, city, street, zipcode) values (100, "city2", ...);
@Entity
public class AddressEntity {
@Id @GeneratedValue
private Long id;
@Embedded
private Address address;
}
@Entity
public class Member {
...
// 일대다 단방향 매핑
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "memder_id")
private List<AddressEntity> addressHistory = new ArrayList<>();
}
엔티티 타입과 값 타입을 정리하면 다음과 같다.
식별자가 있고, 식별자로 구분할 수 있다.
생명주기가 있다.
참조 값을 공유할 수 있다. 이를 공유 참조라 한다. 예를 들어 회원 엔티티가 있으면 다른 엔티티에서 회원 엔티티를 참조할 수 있다.
식별자가 없다.
생명주기를 엔티티에 의존한다. 따라서 의존하는 엔티티가 제거되면 값 타입도 같이 제거된다.
공유하지 않는 것이 안전하다. 엔티티 타입과 달리 값 타입은 식별자가 없기 때문에 변경시 추적이 힘들다. 따라서 공유하지 않고 값을 복사해서 사용해야 한다.
오직 하나의 주인 엔티티만이 관리해야 한다.
불변 객체로 만드는 것이 안전하다.
값 타입은 정말 값 타입이라 판단될 때 사용해야 한다. 만약 식별자가 필요하고 지속해서 값을 추적하고 구분하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.
글 잘 봤습니다, 감사합니다.