안녕하세요. 오늘은 JPA 데이터 타입에 대하 알아보는 시간을 갖으려 합니다.
김영한님의 자바 ORM 표준 JPA 프로그래밍을 읽고 정리했습니다.
JPA의 데이터 타입을 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있다. 엔티티 타입은 @Entity로 정의하는 객체고, 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말한다.
엔티티 타입은 식별자를 통해 지속해서 추적할 수 있지만, 값 타입은 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없다.
기본값 타입
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
}
Member에서 String, int가 값 타입이다. Member 엔티티는 id라는 식별자 값도 있고 생명 주기도 있지만 값 타입인 name, age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존한다.
따라서 회원 엔티티 인스턴스를 제거하면 name, age 값도 제거된다. 그리고 값 타입은 공유하면 안된다. 예를 들어 회원 엔티티의 이름을 변경한다고 해서 내 이름이 변경되면 안된다.
임베디드 타입 (복합 값 타입)
새로운 값 타입을 정의해서 사용할 수 있는데 JPA에서는 이를 임베디드 타입이라고 한다.
중요한 것은 직접 정의한 임베디드 타입도 int, String처럼 값 타입이다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Temporal(Temporal.DATE) java.util.Date startDate;
@Temporal(Temporal.DATE) java.util.Date endDate;
private String city;
private String street;
private String zipcode;
}
위의 코드는 평범한 회원 엔티티이다.
회원 엔티티는 이름, 근무기간, 집 주소를 갖고 있다.
회원이 상세한 데이터를 그대로 갖고 있는 것은 객체지향적이지 않고 응집력을 떨어뜨린다. 대신에 근무 기간, 주소 같은 타입이 있으면 코드가 명확해질 것이다.
[근무기간, 집 주소]를 가지도록 임베디드 타입을 사용해보자.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded Period workPeriod; // 근무기간
@Embedded Address homeAddress; // 집 주소
}
@Embeddable
public class Period {
@Temporal(TemporalType.DATE) java.util.Date startDate;
@Temporal(TemporalType.DATE) java.util.Date endDate;
public boolean isWork(Date date) {
// 값 타입을 위한 메서드를 정의
}
}
@Embeddable
public class Address {
@Column(name = "city")
private String city;
private String street;
private String zipcode;
}

startDate, endDate를 가지고 Period 클래스를 만들었다.
city, street, zipcode를 가지고 Address 클래스를 만들었다.
새로 정의한 값 타입들은 재사용이 가능하고 응집도가 높다. 또한, Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메서드를 만들 수 있다.
임베디드 타입을 사용하려면 두 가지 어노테이션을 사용해야 한다.
@Embedded : 값 타입 사용하는 곳에 표시
@Embeddable : 값 타입을 정의하는 곳에 표시
그리고 임베디드 타입은 기본 생성자가 필수다. 임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하므로 엔티티와 임베디드 타입의 관계를 UML로 표시하면 컴포지션 관계가 된다.
컴포지션은 다른객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 메서드를 호출하는 기법이다.

임베디드 타입과 테이블 매핑
임베디드 타입은 테이블 매핑을 어떻게 하는지 알아보자.
임베디드 타입은 엔티티의 값일 뿐이다. 따라서 값이 속한 엔티티의 테이블에 매핑한다. 위에 코드에서 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 동일하다.
임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하다. 잘 설계한 ORM 어플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.
임베디드 타입과 연관관계
임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다.
@Entity
public class Member {
@Embedded Address address;
@Embedded PhoneNumber phoneNumber;
}
@Embeddable
public class Address {
String street;
String city;
String state;
@Embedded Zipcode zipcode;
}
@Embeddable
public class Zipcode {
String zip;
String plusFour;
}
@Embeddable
public class PhoneNumber {
String stateCode;
String localNumber;
@ManyToOne
PhoneServiceProvider provider;
}
@Entity
public class PhoneServiceProvider {
@Id String name;
}
위의 코드를 보면 값 타입인 Address가 값 타입인 Zipcode를 포함하고, 값 타입인 PhoneNumber가 엔티티 타입인 PhoneServiceProvider를 참조한다.
@AttributeOverride : 속성 재정의
임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 된다.
예를 들어 회원에게 주소가 하나 더 필요하다면 어떻게 해야 할까?
@ENtity
public class Member {
@Embedded Address homeAddress;
@Embedded Address companyAddress;
}
멤버 엔티티에 회사 주소를 추가한 것을 볼 수 있다.
문제는 테이블에 매핑하는 컬럼명이 중복되게 된다. 이떄는 @AttributeOverrides를 사용해서 매핑정보를 재정의해야 한다.
@Entity
public class Member {
@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;
}
@AttributeOverrides를 사용하면 어노테이션을 많이 사용해서 엔티티 코드가 더러워진다. 그러나 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일이 많지 않다.
임베디드 타입과 null
임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null인다.
member.setAddress(null);
em.persist(member);
// 주소와 관련된 city, street, zipcode 컬럼이 모두 null
값 타입과 불변 객체
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다뤄야한다.
값 타입 공유 참조
임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다. 공유하면 어떤 문제가 생기는 지 알아보자.
member1.setHomeAddress("OldCity");
Address address = member1.getHomeAddress();
address.setCity("NewCity");
member2.setHomeAddress(address);
회원2에 새로운 주소를 할당하려고 하면 회원1의 주소를 그대로 참조해서 사용했다. 이는 회원2의 주소만 NewCity로 변경되기를 기대했지만 회원1의 주소 또한 변경된다. 회원1과 회원2가 같은 address 인스턴스를 참조하기 때문이다.
이러한 공유 참조로 발생한 버그는 찾기 어렵다. 이렇듯 뭔가를 수정했는데 예상치 못한 곳에서 문제가 발생하는 것을 부작용이라 한다.
이런 부작용을 막으려면 값을 복사해서 사용하면 된다.
값 타입 복사
값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하다. 대신 값을 복사해서 사용해야한다.
member1.setHomeAddress("OldCity");
Address address = member1.getHomeAddress();
Address newAddress = address.clone();
newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);
회원1의 주소 인스턴스를 복사해서 사용한다. 이 코드를 실행하면 의도한 대로 회원2의 주소만 NewCity로 변경한다. 그리고 영속성 컨텍스트는 회원2의 주소만 변경된 것으로 보고 회원2의 update sql을 실행한다.
이처럼 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다. 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이라는 것이다.
자바는 객체에 값을 대입하면 항상 참조 값을 전달한다.
Address b = a에서 a가 참조하는 인스턴스의 참조 값을 b에 넘겨준다. 따라서 공유 참조로 인해 부작용이 발생하여 a.city의 값도 변경된다.
Address a = new Address("Old");
Address b = a;
b.setCity("New");
물론 객체를 대입할 때마다 인스턴스를 복사해서 대입하면 공유 참조를 피할 수 있다. 문제는 복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다는 것이다.
불변 객체
값 타입은 부작용 걱정 없이 사용할 수 있어야 한다. 부작용이 일어나면 값 타입이라고 할 수 없다. 객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다. 따라서 값 타입은 될 수 있으면 불변 객체로 설계되어야 한다.
불변 객체는 한 번 만들면 절대 변경할 수 없는 객체이다.
불변 객체의 값은 조회할 수 있지만 수정할 수 없다.
불변 객체를 구현하는 다양한 방법이 있지만 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않으면 된다.
@Entity
public class Address {
pricate String city;
protected Address();
public Address(String city) {
this.city = city;
}
public String getCity() {
return city;
}
// setter는 만들지 않는다.
}
Address address = member1.getHomeAddress();
Address newAddress = new Address(address.getCity());
member.2setHomeAddress(newAddress);
Address는 이제 불변 객체다. 값을 수정할 수 없으므로 공유해도 부작용이 발생하지 않는다. 만약 값을 수정해야한다면 새로운 객체를 생성해서 사용해야 한다.
참고로 Integer, String은 자바가 제공하는 대표적인 불변 객체다.
값 타입의 비교
동일성 비교 : 인스턴스의 참조 값을 비교, == 사용
동등성 비교 : 인스턴스의 값을 비교, equals() 사용
Address 값 타입을 a == b로 동일성 비교를 하면 둘은 서로 다른 인스턴스이므로 결과는 거짓이다.
그러나 값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다. 따라서 값 타입을 비교할 때는 a.equals(b)를 사용해서 동등성 비교를 해야 한다.
물론 Address의 equals() 메서드를 재정의해야 한다.
값 타입 컬렉션
값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable 어노테이션을 사용하면 된다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Embedded
private Address address;
@ElementCollection
@CollectionTable(name = "FAVORITE_FOODS",
joinColumns = @JoinCOlumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<String>();
@ElementCollection
@CollectionTable(name = "ADDRESS",
joinColumns = @JoinCOlumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
}
@Embeddable
public class Address {
@Column
private String city;
private String street;
private STring zipcode;
}
멤버 엔티티를 보면 값 타입 컬렉션을 사용하는 favoriteFoods, addressHistory에 @ElementCollection을 지정했다.
favoriteFoods는 기본 값 타입인 String을 컬렉션으로 가진다. 이것을 데이터베이스 테이블로 매핑해야하는데 관계형 데이터베이스의 테이블은 컬럼 안에 컬렉션을 포함할 수 없다. 따라서 별도의 테이블을 추가하고 @CollectionTable을 사용해서 추가한 테이블을 매핑해야 한다.
그리고 favoriteFoods처럼 값으로 사용되는 컬럼이 하나면 @Column을 사용해서 컬럼명을 지정할 수 있다.
addressHistory는 임베디드 타입인 Addrss를 컬렉션으로 가진다. 이것도 마찬가지로 별도의 테이블을 사용해야 한다. 그리고 테이블 매핑 정보는 @AttributeOverride를 사용해서 재정의할 수 있다.
값 타입 컬렉션을 어떻게 사용하는지 알아보자.
Member member = new Member();
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");
member.getAddressHistory().add(new Address("서울" ,"강남", "123-123"));
member.getAddressHistory().add(new Address("서울" ,"강북", "000-000"));
em.persist(member);
등록하는 코드를 보면 마지막에 member 엔티티만 영속화했다. jpa는 이 때 member 엔티티의 값 타입도 함께 저장한다.
memeber : insert query 1번
member.homeAddress : 임베디드 값 타입이므로 회원테이블에 저장하는 sql에 포함
member.favoriteFoods : insert query 3번
member.addressHistory : insert query 2번
총 6번의 insert SQL을 실행한다.
값 타입 컬렉션도 조회할 때 페치 전략을 선택할 수 있는데 LAZY가 기본이다.
값 타입 컬렉션의 제약사항
특정 엔티티 하나에 소속된 값 타입은 값이 변경되어도 자신이 소속된 엔티티를 데이터베이스에서 찾고 값을 변경하면 된다. 문제는 값 타입 컬렉션이다. 값 타엡 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관된다. 따라서 여기에 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다는 문제가 있다.
이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면 값 타입 컬렉션에 매핑된 테이블에 연관된 모든 데이터를 삭제하고 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다.
예를 들어 식별자가 100번인 회원이 관리하는 주소 값 타입 컬렉션을 변경하면 테이블에서 회원 100번과 관련된 모든 주소 데이터를 삭제하고 현재 값 타입 컬렉션에 있는 값을 다시 저장한다.
따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.
'🍀spring > 스프링 jpa' 카테고리의 다른 글
[Spring JPA] 프록시와 연관관계 관리 (0) | 2024.03.03 |
---|---|
[Spring JPA] 고급 매핑 (상속, 복합 키 매핑) (0) | 2024.02.29 |
[Spring JPA] 다양한 연관관계 매핑 (1) | 2024.02.29 |
[Spring JPA] 연관관계 매핑 기초 (1) | 2024.02.28 |
[Spring JPA] 엔티티 매핑 (1) | 2024.02.26 |