자바 ORM 표준 JPA 프로그래밍 섹션9. 값 타입
JPA의 데이터 타입 분류
엔티티 타입
@Entity로 정의하는 객체로 데이터가 변해도 식별자로 지속해서 추적이 가능하다. 예를 들어 회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식이 가능한 경우이다. 추가로 공유가 가능하며 생명 주기 관리가 가능하다.
값 타입
int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체이다. 식별자가 없고 값만 있기 때문에 변경 시 추적이 불가능하다. 예를 들면 숫자 100을 200으로 변경하면 완전히 다른 값으로 대체되는 경우이다. 추가로 생명 주기가 엔티티에 의존되며 공유하지 않는 것이 안전하며 불가피할 경우 복사해서 사용할 수 있다. 이런 특성 때문에 불변 객체로 만드는 것이 안전하다.
분류 체계는 아래와 같다.
- 기본값 타입
- 자바 기본 타입 (int, double)
- 래퍼 클래스 (Integer, Long)
- String
- 임베디드 타입 (embedded type, 복합값 타입) : JPA에서 정의해서 써야 하는 타입
- 컬렉션 값 타입 (collection value type) : Java Collection에 기본값 타입이나 임베디드 타입을 넣을 수 있는 타입
기본값 타입
예시로는 String name, int age가 있으며 생명주기를 엔티티에 의존한다. 예를 들면 회원을 삭제하면 이름, 나이 필드도 함께 삭제되는 경우를 통해 확인할 수 있다. 값 타입은 공유하면 안되며 예를 들면 회원 이름 변경 시 다른 회원의 이름도 함께 변경되는 안된다.
int, double 같은 자바의 기본 타입(primitive type)은 절대 공유되면 안된다. 아래에서도 볼 수 있듯이 a랑 b는 공유가 되고 있지 않기 때문에 b에 저장한 10 이라는 값이 a의 값이 바뀐다고 해서 b도 바뀌지 않는다.
public class ValueMain {
public static void main(String[] args) {
int a = 10;
int b = a;
a = 20;
System.out.println("a="+a); //20
System.out.println("b="+b); //10
}
}
기본 타입은 항상 값을 복사한다. 하지만 Integer 같은 래퍼 클래스나 String 값은 특수한 클래스는 공유가 가능한 객체이다. 하지만 변경은 불가능하다.
아래와 같이 참조값이 넘어가므로 같은 값을 공유할 수 있다.
Integer c = new Integer(10);
Integer d = c; //참조값이 넘어간다.
System.out.println("c="+c); //10
System.out.println("d="+d); //10
만약 아래와 같이 c의 값을 지정할 수 있다면 아래처럼 다시 값 변경이 가능하겠지만.. setValue 자체가 불가능하므로 변경은 불가능하다. 이를 통해 side effect를 방지할 수 있다.
Integer c = new Integer(10);
Integer d = c; //참조값이 넘어간다.
c.setValue(20);
System.out.println("c="+c); //20
System.out.println("d="+d); //20
임베디드 타입 (복합값 타입)
JPA의 임베디드 타입은 새로운 값 타입을 직접 정의할 수 있는 타입이다. 주로 기본 값 타입을 모아 만들기 때문에 복합 값 타입이라고도 부른다. 임베디드 타입은 엔티티가 아니라 int, String과 같은 값 타입이기 때문에 추적이 불가능하다.
예를 들면 회원 엔티티에 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다고 해보자. 이때 근무 시작일과 근무 종료일이 유사하고 주소 도시와 주소 번지, 주소 우편번호가 서로 유사하다. 따라서 회원 엔티티가 이름, 근무 기간, 집 주소만 가지도록 추상화 하여 설명할 수 있다. 이처럼 아래와 같이 묶어낼 수 있는 것이 임베디드 타입이다.
아래와 같이 Period와 Address 타입을 새로 클래스처럼 만들어 낼 수 있다.
임베디드 타입과 테이블 매핑
위의 예시를 바탕으로 테이블과 엔티티를 설계하면 아래와 같다. 아래에서 확인할 수 있듯이 DB에서는 테이블이 동일하다.
즉 임베디드 타입은 엔티티의 값일 뿐이고 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다. 또한 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능하다. 잘 설계한 ORM 어플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.
임베디드 타입 사용법
@Embeddable 은 값 타입을 정의하는 곳에 표시한다. @Embedded 는 값 타입을 사용하는 곳에 표시한다. 이때 기본 생성자는 필수이다. 이때 임베디드 타입의 값(Address)이 null이면 매핑한 컬럼 값(city,street,zipcode)이 모두 null이다.
@Entity
public class Member extends BaseEntity{
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String name;
@Embedded
private Period workPeriod;
@Embedded
private Address address;
}
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
public Address() {
}
...
}
@Embeddable
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
public Period() {
}
...
}
Member member = new Member();
member.setName("영서");
member.setAddress(new Address("city","street","zipcode"));
member.setWorkPeriod(new Period());
em.persist(member);
tx.commit();
임베디드 타입의 장점
재사용이 가능하고 높은 응집도를 나타낸다. Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메소드를 만들어 객체지향적인 설계를 할 수 있다. 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티에 생명 주기를 의존한다.
@AttributeOverride 속성 재정의
아래와 같이 한 엔티티에서 같은 값 타입을 사용할 때 사용하는 것이 @AttributeOverride 이며 이 어노테이션이 없으면 컬럼명이 중복되어 에러가 발생한다. @AttributeOverrides나 @AttributeOverride를 사용하여 컬럼명 속성을 재정의해야 한다.
@Entity
public class Member extends BaseEntity{
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String name;
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name="city",
column=@Column("WORK_CITY")),
@AttributeOverride(name="street",
column=@Column("WORK_STREET")),
@AttributeOverride(name="zipcode",
column=@Column("WORK_ZIPCODE"))})
private Address workAddress;
...
}
값 타입과 불변 객체
값 타입의 공유 참조
임베디드 타입 같은 값 타입은 여러 엔티티에서 공유가 가능한데 그렇게 되면 side effect가 발생할 수 있어 위험하다.
아래와 같이 city의 값을 NewCity로 바꾸면 회원1과 회원2의 모든 city 값이 NewCity로 바뀐다.
따라서 이렇게 공유하고 싶다면 값 타입이 아닌 엔티티를 사용해야 한다.
값 타입의 복사
값 타입의 실제 인스턴스인 값을 공유하는 것은 위헙하다. 따라서 대신 값(인스턴스)를 복사하여 사용해야 한다.
Address address = new Address("city","street","1000");
Member member = new Member();
member.setName("member1");
member.setHomeAddress(address);
em.persist(member);
Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode();
Member member2 = new Member();
member2.setName("member2");
member2.setHomeAddress(copyAddress);
member.getHomeAddress().setCity("newCity");
tx.commit();
이렇게 하면 마지막에 city 값을 바꾸어도 memeber2의 city 값은 바뀌지 않는다.
객체 타입의 한계
항상 값을 복사하여 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다. 이때 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이라는 것이다. 자바 기본 타입에는 값을 대입하면 값을 복사하지만 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없어 객체의 공유 참조는 피할 수 없다.
//기본 타입(primitive type)
int a = 10;
int b = a;//기본 타입은 값을 복사
b = 4; //a 값 유지
//객체 타입
Address a = new Address(“Old”);
Address b = a; //객체 타입은 참조를 전달
b. setCity(“New”) //a와 b 모두 city 값이 New가 됨
불변 객체
불변 객체는 객체 타입을 수정할 수 없게 만들어 부작용을 원천 차단하는 객체이다. 따라서 값 타입은 불변 객체(immutable object)로 설계해야 한다. 불변 객체란 생성 시점 이후 절대 값을 변경할 수 없는 객체이다. 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 된다. 참고로 Integer과 String은 자바가 제공하는 대표적인 불변 객체이다.
값 타입의 비교
값 타입을 비교할 때에는 동일성 비교와 동등성 비교를 구분하여야 한다.
동일성(identity) 비교란 인스턴스의 참조 값을 비교하는 것이며 == 비교를 사용한다. 동등성(equivalence) 비교란 인스턴스의 값을 비교하는 것이며 equals() 비교를 사용한다. 값 타입은 동등성 비교를 해야 한다. 이때 값 타입의 equals() 메소드를 적절하게 재정의 해야 하는데 주로 모든 필드를 재정의 해야 한다. 예시는 아래와 같다.
@Embeddable
public class Address {
...
@Override
public boolean equals(Object o){
if (this==o) return true;
if (o==null || getClass() != o.getClass()) return false;
Address address = (Address) o;
return Objects.equals(city, address.city) &&
Objects.equals(street, address.street) &&
Objects.equals(zipcode, address.zipcode);
}
@Override
public int hashCode(){
return Objects.hash(city, street, zipcode);
}
}
Address address1 = new Address("city","street","10000");
Address address2 = new Address("city", "street", "1000");
System.out.println("address1==address2"+(address1==address2)); //fasle
System.out.println("address1 equals address2:"+(address1.equals(address2))); //true
값 타입 컬렉션
값 타입 컬렉션이란 값 타입을 컬렉션에 넣어 사용하는 것을 의미한다. 아래와 같이 String과 Address의 Set, List를 저장하고 싶을 때 관계형 DB는 내부적으로 컬렉션을 다룰 수 있는 구조가 없다. 따라서 별도의 테이블을 일대다로 생성해야 한다. 별도의 테이블은 모든 컬럼을 다 묶어 하나의 PK를 만들어 낸다. 값 타입 컬렉션은 주로 추적하거나 값이 변경될 일이 없는 단순한 경우에 사용한다.
값 타입 컬렉션은 값 타입을 하나 이상 저장할 때 사용하며 @ElementCollection, @CollectionTable을 사용한다. DB는 컬렉션을 같은 테이블에 저장할 수 없기 때문에 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
값 타입 컬렉션은 아래와 같이 설정할 수 있다.
@Entity
public class Member extends BaseEntity{
...
@ElementCollection
@CollectionTable(name="FAVORITE_FOOD",
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<>();
...
}
값 타입 컬렉션의 사용
값 타입 컬렉션 저장은 아래와 같이 사용할 수 있다.
Member member = new Member();
member.setName("m");
member.setHomeAddress(new Address("city1","street","1234"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");
member.getAddressHistory().add(new Address("old1","street","1234"));
member.getAddressHistory().add(new Address("old2","street","1234"));
여기서 확인할 수 있는 점은 값 타입을 컬렉션은 따로 persist 하지 않아도 라이프 스타일이 Member와 같이 돌아간다는 것이다.
값 타입 컬렉션 조회는 아래와 같이 할 수 있다. 이를 통해 값 타입 컬렉션도 지연 로딩 전략을 사용함을 알 수 있다.
Member findMember = em.find(Member.class, member.getId());
List<Address> addressHistory = findMember.getAddressHistory();
값 타입 컬렉션 수정은 아래와 같이 할 수 있다. Setter로 바꿀 수 없고 추적도 안되기 때문에 새로운 Address 인스턴스를 갈아 끼워야 한다.
findMember.getHomeAddress().setCity("newCity"); //불가능
Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipcode()));
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
따라서 종합적으로 값 타입 컬렉션은 영속성 전이(Cascade)와 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
값 타입 컬렉션의 제약사항
값 타입은 엔티티와 다르게 식별자 개념이 없어 값을 변경하면 추적이 어렵다. 값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장해야 한다. 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어 기본키(PK)를 구성해야 한다. 대신 이때 null 입력과 중복 저장이 불가능하다.
값 타입 컬렉션의 대안
실무에서는 상황에 따라 값 타입 컬렉션 대신 일대다 관계를 고려한다. 일대다 관계를 위한 별도의 엔티티를 만들고 여기에서 값 타입을 사용한다. 이때 영속성 전이(Cascade)와 고아 객체 제거 기능을 사용하여 값 타입 컬렉션처럼 사용할 수 있다. 이를 값 타입을 엔티티로 승격한다고 표현한다.
@Entity
@Table(name="ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
}
@Entity
public class Member{
...
@OneToMany(cascade=CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name="MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
...
}
즉 값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다. 엔티티와 값 타입을 혼동하여 엔티티를 값 타입으로 만들어선 안되며 식별자가 필요하고 지속해서 값을 추적하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.
'Spring > SpringBoot&JPA' 카테고리의 다른 글
[JPA] 객체지향 쿼리 언어 (0) | 2024.04.03 |
---|---|
[JPA] 프록시, 즉시/지연 로딩, 영속성 전이(CASCADE), 고아 객체 (0) | 2024.04.01 |
[JPA] 상속관계 매핑 (0) | 2024.03.29 |
[JPA] 다양한 연관관계 (다대일, 일대다, 일대일, 다대다) (0) | 2024.03.29 |
[JPA] 연관관계 매핑 (단방향, 양방향, 연관관계의 주인) (0) | 2024.03.26 |