
JPA의 데이터 타입은 크게 엔티티 타입과 값 타입으로 나눌 수 있다.
엔티티 타입은 식별자를 통해 지속해서 추적할 수 있지만, 값 타입은 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없다.
Member 엔티티는 id 라는 식별자 값도 가지고 생명주기도 있지만 값 타입인 name, age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존한다. 그래서 회원 엔티티 인스턴스를 제거하면 name, age 값도 제거된다. 그리고 값 타입은 공유하면 안 된다.
새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA에서는 이것을 임베디드 타입emvedded type이라고 한다. 중요한 것은 직접 정의한 임베디드 타입도 int, String 처럼 값 타입이라는 것이다.
회원이 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력만 떨어뜨린다. 대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해질 것이다. [근무기간], [집 주소]를 가지도록 임베디드 타입을 사용하자.
@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;
//..
}

새로 정의한 값 타입들은 재사용할 수 있고 응집도도 아주 높다. 또한 Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메서드도 만들 수 있다.
임베디드 타입을 사용하려면 두 가지 어노테이션을 사용해야 하며 둘 중에 하나는 생략해도 된다.
임베디드 타입은 기본 생성자가 필수다.
또한 임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하므로 엔티티와 임베디드 타입의 관계를 UML로 표현하면 컴포지션(composition) 관계가 된다.
임베디드 타입은 엔티티의 값일 뿐이다. 따라서 값이 속한 엔티티의 테이블에 매핑한다. 예제에서 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다. 임베디드 타입 덕분에 객체와 테이블을 세밀하게 매핑하는 것이 가능하다. 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더많다.
임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다.
임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 된다. 예를 들어 회원에게 주소를 하나 더 추가해보자.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded Address homeAddress; // 집 주소
@Embedded Address companyAddress; // 회사 주소
//
}
이처럼 주소에 집 주소와 회사 주소를 추가하였다. 이런 경우 테이블에 매핑하는 컬럼명이 중복되어 문제가 발생한다.
@AttributeOverride를 사용하여 매핑정보를 재정의해야 한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@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; // 회사 주소
//
}
이렇게 변경하면 생성된 테이블에서 재정의한대로 변경된 것을 확인할 수 있다.
CREATE TABLE MEMBEER (
COMPANY_CITY varchar(255),
COMPANY_STREET varchar(255),
COMPANY_ZIPCODE varchar(255),
city varchar(255),
street varchar(255),
zipcode varchar(255),
...
임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null이 된다. 예를 들어 Address를 null로 설정하면, city, street, zipcode가 모두 null로 설정된다.
임베디드 타입은 여러 엔티티에서 공유하면 위험하다.
member1.setHomeAddress(new Address("OldCity"));
Address address = member.getHomeAddress();
address.setCity("NewCity"); //회원1의 address 값을 공유해서 사용
member2.setHomeAddress(address);
회원2에 새로운 주소를 할당하려고 회원1의 address를 그대로 참조해서 사용했다. 회원2의 주소만 "NewCity"로 바뀔것 같지만 회원1의 주소도 "NewCity"로 변경되어 버린다. 회원1과 회원2가 같은 address 인스턴스를 참조했기 때문이다. 영속성 컨텍스트는 회원1과 회원2의 city속성이 변경된 것으로 판단되어 각각 UPDATE SQL을 실행한다.
위에서 본 문제를 해결하기 위해서 값(인스턴스)을 복사해서 사용해야 한다. 예를 들어, clone() 를 만들어 자신을 복사해서 반환하고 이를 이용해서 새로운 Address를 만드는 것이다.
문제는 객체 타입이다. 객체에 값을 대입하면 항상 참조 값을 전달한다.
Address a = new Address("Old");
Address b = a; //객체 타입은 항상 참조 값을 전달
b.setCity("New");
Address b=a에서 a가 참조하는 인스턴스의 참조 값을 b에 넘겨주어 같은 인스턴스를 공유 참조한다.
물론 앞서 말한 것처럼, 각체를 대입할 때마다 인스턴스를 복사해서 대입하면 공유 참조를 피할 수 있다.
그러나 문제는 복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다는 것이다.
객체의 공유 참조는 피할 수 없다. 따라서 근복적인 해결책이 필요한데 가장 단순한 방법은 객체의 값을 수저하지 못하게 막으면 된다. 예를 들어 Address 객체의 setCity() 같은 수정자 메서드를 모두 제거하는 것이다. 이렇게 하면 공유 참조를 해도 값을 변경하지 못하므로 부작용의 발생을 막을 수 있다.
값 타입은 부작용 없이 사용해야 한다. 그러기 위해서 객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다.
한 번 만들면 절대 변경할 수 없는 객체를 불변 객체라고 한다. 불변 객체의 값은 조회할 수 있지만 수정할 수 없다.
불변 객체를 구현하는 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않는 것이다. Integer, String은 자바가 제공하는 대표적인 불변 객체이다.
값 타입은 비록 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다. 따라서 값 타입을 비교할 때는 동등성비교를 해야한다. 물론 값 타입의 equals() 메서드를 재정의해야 한다. 재정의할 때 보통 모든 필드의 값을 비교하도록 구현한다. 자바에서 equals()를 재정의하면 hashCode()도 재정의해야지 해시를 사용하는 컬렉션(HashSet, HashMap) 사용시 안전하다.
값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable 을 사용하면 된다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded
Address homeAddress; // 집 주소
@ElementCollection
@CollectionTable(name = "FAVORITE_FOODS",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> faovriteFoods = new HashSet<String>();
@ElementCollection
@CollectionTable(name = "ADDRESS",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<Address>();
//
}
@Embeddable
public class Address {
@Column
private String city;
private String street;
private String zipcode;
//
}

favoriteFoods는 기본값 타입인 String을 컬렉션으로 가진다. 이것을 데이터베이스 테이블로 매핑해야 하는데
관계형 데이터베이스의 테이블은 컬럼안에 컬렉션을 포함할 수 없다. 따라서 별도의 테이블을 추가하고 @ColumnTablee을 사용해서 추가한 테이블을 매핑해야 한다. 그리고 favoriteFoods처럼 값으로 사용되는 컬럼이 하나면 @Column을 사용해서 컬럼명을 지정할 수 있다.
addressHistory는 임베디드 타입인 Address를 컬렉션으로 가진다. 이것도 마찬가지로 별도의 테이블을 사용해야 한다. 그리고 테이블 매핑정보는 @AttributeOverride를 사용해서 재정의할 수 있다.
값 타입 컬렉션은 영속성 전이(Cascade) + 고아객체 제거(ORPHAN REMOVE) 기능을 필수로 가진다고 볼 수 있다.
값 타입 컬렉션도 조회할 때 fetch 전략을 선택할 수 있는데 LAZY가 기본이다.
Member member = em.find(Member.class, 1L);
//1. 임베디드 값 타입 수정
member.setHomeAddress(new Address("새로운도시", "신도시1", "123456"));
//2. 기본값 타입 컬렉션 수정
Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("탕수육");
favoriteFoods.add("치킨");
//3. 임베디드 값 타입 컬렉션 수정
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존주소", "123-123"));
addressHistory.add(new Address("신도시", "신주소", "123-456"));
임베디드 값 타입 수정
homeAddress 임베디드 값 타입은 MEMBER 테이블과 매핑했으므로 MEMBER 테이블만 UPDATE한다. Member엔티티 수정하는 것과 같다.
기본값 타입 컬렉션 수정
탕수육을 치킨으로 변경하려면 탕수육을 제거하고 치킨을 추가해야 한다. 자바의 String 타입은 수정할 수 없다.
임베디드 값 타입 컬렉션 수정
값 타입은 불변해야 한다. 따라서 컬렉션에서 기본 주소를 삭제하고 새로운 주소를 등록했다. 참고로 값 타입은 equals, hashcode를 꼭 구현해야 한다.
값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관된다. 따라서 여기에 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다는 문제가 있다. 이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면, 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다.
따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다. 추가로 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 따라서 데이터베이스 기본 키제약 조건으로 인해 컬럼에 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약도 있다.
지금까지 설명한 문제를 해결하려면 값 타입 컬렉션을 사용하는 대신에 새로운 엔티티를 만들어서 일대다 관계로 설정하면 된다. 여기에 추가로 영속성 전이(Cascade) + 고아객체 제거(ORPHAN REMOVE) 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.
@Entity
public class AddressEntity {
@Id @GeneratedValue
private Long id;
@Embedded Address address;
//
}
// 설정 코드
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory =
new ArrayList<AddressEntity>();
컴포지션(Composition) 관계는 객체지향 설계에서 두 클래스 간의 강한 의존 관계를 나타내는 관계 유형입니다. UML(Unified Modeling Language) 다이어그램에서 사용되는 이 용어는 "전체와 부분" 간의 관계를 모델링합니다.
전체와 부분의 강한 결합:
엔티티와 값 타입의 관계에서 엔티티가 삭제되면 값 타입도 함께 소멸합니다.독립적인 존재 불가:
UML 표기:
┌─────────┐ ┌──────────┐
│ 집 │────◆────│ 방 │
└─────────┘ └──────────┘"집"이 삭제되면 "방"도 함께 삭제됩니다.| 특징 | 컴포지션 | 어그리게이션 |
|---|---|---|
| 생명주기 의존성 | 전체 삭제 → 부분도 삭제 | 전체 삭제 → 부분은 독립적으로 유지 |
| 예 | 사람과 심장 | 자동차와 타이어 |
| UML 표기 | 채워진 다이아몬드 | 비어 있는 다이아몬드 |
JPA에서 값 타입(예: @Embeddable)은 엔티티에 포함되어 컴포지션 관계를 형성합니다.
값 타입의 생명주기: 값 타입은 엔티티와 동일한 생명주기를 가집니다.
임베디드 타입 예제:
@Embeddable
public class Period {
private LocalDate startDate;
private LocalDate endDate;
public boolean isWork() {
return LocalDate.now().isAfter(startDate) && LocalDate.now().isBefore(endDate);
}
}
@Entity
public class Employee {
@Id
private Long id;
@Embedded
private Period workPeriod;
}
위 코드에서 Employee와 Period는 컴포지션 관계입니다. Employee가 삭제되면 Period도 함께 삭제됩니다.
컴포지션은 객체지향 설계에서 객체의 강한 결합 관계를 나타내며, 데이터의 생명주기를 관리하고 데이터 무결성을 유지하는 데 유용합니다. JPA에서는 이를 통해 엔티티와 값 타입의 관계를 자연스럽게 표현할 수 있습니다.
값 타입을 비교할 때, 값이 같은지 여부에 따라 비교하는 이유는 불변 객체(immutable object)와 관련이 있습니다. 불변 객체는 그 상태가 생성된 이후 변경되지 않는 객체로, 주로 값 타입을 불변 객체로 설계합니다.
값 타입은 주로 불변 객체로 설계됩니다. 불변 객체의 특성상 한 번 생성되면 객체의 내부 상태를 변경할 수 없기 때문에, 값 타입의 비교 시 객체의 상태(state)를 기반으로 동등성을 판단합니다. 값 타입의 내부 속성 값이 같으면 두 객체는 사실상 동일한 의미를 가진 객체로 간주되며, 이렇게 동일한 값을 가진 객체들이 "같다"고 평가됩니다.
JPA에서는 값 타입이 동등성 비교를 할 때 객체의 식별자가 아니라 값을 비교합니다. 값 타입의 경우, 두 객체가 같은 값을 가지면 같은 객체로 취급되며, 이는 불변 객체의 특성상 자연스러운 설계입니다. 예를 들어, Period라는 값 타입이 startDate와 endDate를 가지고 있다면, 두 Period 객체가 동일한 startDate와 endDate를 가졌다면 그들은 동일한 값 타입으로 간주됩니다.
JPA에서는 값 타입을 객체로 취급하되, 비교할 때는 객체의 참조가 아니라 그 값을 비교합니다. 예를 들어, @Embeddable로 정의된 클래스에서 equals()와 hashCode() 메서드를 오버라이드하여 객체의 값 비교를 수행할 수 있습니다. 이렇게 하면, 객체의 인스턴스가 다르더라도 그 값이 같으면 동일한 객체로 간주합니다.
불변 객체의 특성은 여러 상황에서 유용합니다. 예를 들어, 데이터베이스에서 객체를 비교할 때, 값이 동일한 두 객체를 동일한 객체로 취급함으로써 효율적이고 일관성 있는 데이터를 관리할 수 있습니다. 이 과정에서 객체의 인스턴스가 다르더라도 값만 같다면 동일한 값 타입으로 취급되기 때문에, 애플리케이션 로직에서 객체의 비교가 더 단순해집니다.
따라서 값 타입은 불변 객체의 특성을 가지며, 그 값이 같으면 동일한 객체로 취급되는 이유는 값 타입이 "상태를 비교"하는 방식으로 설계되어 있기 때문입니다. 이 방식은 데이터의 일관성을 유지하고 비교를 더 효율적으로 만들어 줍니다.
참조 : [자바 ORM 표준 JPA 프로그래밍]