JPA (9) JPA의 값 타입

김영한님의 자바 ORM 표준 JPA 프로그래밍 강의 정리

JPA의 데이터 타입

  • Entity type
    • @Entity로 정의하는 객체
    • 데이터가 변해도 식별자로 지속해서 추정 가능
  • Value type
    • int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
    • 식별자없이 값만 있으므로 변경시 추적 불가

기본 값 타입

  • 자바 기본 타입(int, double)
  • wrapper 클래스(Integer, Long)
  • String
  • 생명 주기를 엔티티에 의존
    • 회원 엔티티를 삭제하면 이름, 나이 필드 등도 함께 삭제됨
  • 값 타입은 공유하면 안됨
    • 회원 이름을 변경할 때 다른 회원의 이름도 함께 변경하면 안됨
자바의 기본 타입

int, double 같은 기본 타입(primitive type)은 값이 공유되지 않으며 항상 값을 복사한다.

Integer 같은 wrapper 클래스나 String 같은 클래스는 공유 가능한 객체이지만 값을 변경할 수 없다.

임베디드 타입

  • 복합 값 타입
  • 새로운 값 타입을 직접 정의 가능
  • 주로 기본 값 타입을 모아서 만들게 됨
  • int, String 같은 값 타입
  • @Embeddable: 값 타입을 정의하는 곳에 사용
  • @Embedded: 값 타입을 사용하는 곳에 사용
  • 기본 생성자 필수
  • 장점
    • 재사용
    • 높은 응집도
    • 해당 값 타입만 사용하는 의미있는 메소드 생성 가능
    • 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티의 생명주기에 의존
  • 테이블 매핑
    • 임베디드 타입은 엔티티의 값
    • 매핑 테이블은 동일
    • 객체와 테이블을 세밀하게 매핑할 수 있음
    • 잘 설계한 ORM 애플리케이션은 테이블 수보다 클래스의 수가 더 많음
  • @AttributeOverrides
    • 한 엔티티에서 같은 값 타입을 두 번 이상 사용하면 컬럼 명이 중복됨
    • @AttributeOverrides 어노테이션을 이용해 재정의
  • 임베티드 타입의 값이 null이면 매핑한 컬럼 값 모두 null

멤버 엔티티에서 startDate, endDate로 매핑되어 있던 컬럼을 Period라는 임베디드 타입으로 넣고 city, street, zipcode는 Address라는 임베디드 타입으로 넣어주었다. 집 주소와 회사 주소 컬럼이 있을 때 Address가 중복되는데 이 때, @AttributeOvverrides 어노테이션을 사용해 임베디드 타입의 속성을 재정의 할 수 있다.

@Entity
public class Member {
	@Id
	@GeneratedValue	
	private Long id;
	private String name;

	@Embedded
	private Period period;

	@Embedded
	private Address address;

	@Embedded
	@AttributeOverrides({
		@AttributeOverride(name = "city", column = @Column(name = "work_city")),
		@AttributeOverride(name = "street", column = @Column(name = "work_street")),
		@AttributeOverride(name = "zipcode", column = @Column(name = "work_zipcode"))
	})
	private Address workAddress;
}

@Embeddable
public class Period {
	private LocalDateTime startDate;
	private LocalDateTime endDate;

	public Period() {}
}

@Embeddable
public class Address {
	private String city;
	private String street;
	private String zipcode;

	public Address() {}
}

값 타입과 불변 객체

  • 값 타입은 단순하고 안전하게 다룰 수 있어야 함
  • 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 부작용 발생할 수 있음
  • 값 타입의 실제 인스턴스 값을 공유하는 것보다 인스턴스를 복사해서 사용
  • 객체 타입의 한계
    • 항상 값을 복사해서 사용하면 공유 참조로 인한 부작용 발생 가능
    • 임베디드 타입처럼 직접 정의한 값 타입은 객체 타입
    • 객체 타입은 참조 값을 직접 대입하는 것을 막을 수 없어 객체의 공유 참조를 피할 수 없음
  • 불변 객체
    • 객체 타입을 수정할 수 없게 만들어 부작용 차단
    • 값 타입을 불변 객체로 설계해서 값을 변경할 수 없도록 함
    • 생성자로만 값을 설정하고 setter 메서드를 만들지 않음

Address라는 임베디드 타입이 있을 때 같은 주소를 가진 멤버를 추가한다고 하면 각각 setAddress에 주소 인스턴스를 넣어주면 된다. 하지만 member1에서 (3)에서처럼 주소값을 변경하게 되면 member2에서도 같은 주소를 참조하게 돼서 UPDATE 쿼리가 두 번 나가게 된다. 이런 문제를 막기 위해서는 (1)처럼 새로운 주소 인스턴스를 만들고 (2)에서 새로 만든 주소 인스턴스를 넣어주어야 한다. 또는 Address 객체에서 setter 메서드를 private으로 하거나 만들지 않아서 값을 변경할 수 없도록 막아야 한다.

Address address = new Address("seoul", "seocho", "123");

Member member1 = new Member();
member1.setAddress(address);
em.persist(member1);

Address address2 = new Address(address.getCity(), address.getStreet(), address.getZipcode()); // (1)

Member member2 = new Member();
member2.setAddress(address);	// (2)
em.persist(member2);

member1.getAddress().setZipcode("456");		// (3)

값 타입 컬렉션

  • 값 타입을 한 개 이상 저장할 때 사용
  • @ElementCollection, @CollectionTable 어노테이션 사용
  • DB에서는 컬렉션을 같은 테이블에 저장할 수 없음
  • 조회할 때 지연 로딩이 기본값
  • 컬렉션을 저장하기 위한 별도의 테이블 필요
  • 값 타입 컬렉션도 엔티티의 라이프 사이클에 의존
    • 영속성 전이(cascade)와 고아 객체 제거 기능을 가진다고 볼 수 있음
  • 제약 사항
    • 엔티티와 다르게 식별자 개념이 없어 변경하면 추적이 어려움
    • 값 타입 컬렉션에 변경 사항이 생기면 주인 엔티티와 관련된 데이터를 모두 제거한 후 현재 값을 다시 저장
    • 값 타입 컬렉션은 매핑하는 테이블서 모든 컬럼을 묶어서 기본키를 구성해야 함
  • 값 타입 컬렉션 대안
    • 일대다 관계
    • 영속성 전이와 고아 객체 제거 기능 사용
    • 값 타입 콜렉션은 정말 단순할 때만 사용하는 것이 좋음

멤버 엔티티가 favoriteFood와 addressHistory라는 값 타입 컬렉션을 가진다고 할 때 아래 코드처럼 @ElementCollection과 @CollectionTable 어노테이션으로 설정할 수 있다.

@Entity
public class Member {

	@Id
	@GeneratedValue
	private Long id;

	@ElementCollection
	@CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
	private Set<String> favoriteFood = new HashSet<>();

	@ElementCollection
	@CollectionTable(name = "ADDRESS_HISTORY", joinColumns = @JoinColumn(name = "MEMBER_ID"))
	private List<Address> addressHistory = new ArrayList<>();
}

값 타입 콜렉션에 데이터를 추가할 때는 각 값 타입에 맞게 추가해준 후에 주인 엔티티를 저장하면 주인 엔티티의 테이블과 값 타입 콜렉션의 테이블에 모두 INSERT 쿼리가 나간다. 각 타입 콜렉션의 생명 주기가 주인 엔티티에 의존적인 것을 확인할 수 있다.

Member member = new Member();

member.getFavoriteFood().add("Pasta");
member.getFavoriteFood().add("Pizza");
member.getFavoriteFood().add("Sushi");

member.getAddress().add(new Address("old1", "street", "123"));
member.getAddress().add(new Address("old2", "street", "123"));

em.persist(member);

값 타입 콜렉션도 @ElementCollection에서 fetch 전략 기본값이 지연 로딩이다. (1)에서는 멤버 테이블에서만 SELECT 쿼리가 나가고 (2)에서 favorite_food 테이블에 SELECT 쿼리가 나가게 된다.

Member findMember = em.find(Member.class, member.getId());	// (1)
findMember.getFavoriteFood().forEach(System.out::println);	// (2)

값 타입 콜렉션을 수정할 때는 값 타입에 따라 쿼리가 달라질 수 있다. (1)의 favorite_food 같은 경우는 String 값을 삭제하게 추가한다. 여기서는 JPA 쿼리가 delete from favorite_food where member_id = ? and favoriteFood = ? 이렇게 나오면서 삭제하려는 값만 삭제할 수 있다. Address 클래스에 equals 메서드와 hashCode 메서드가 재정의 되어 있을 때 (2)에서는 city가 old1인 주소를 삭제할 수 있다. 하지만 JPA에서는 식별할 수가 없기 때문에 delete from address where member_id=? 쿼리가 나가면서 멤버 엔티티와 관련된 데이터를 모두 지운 후에 추가로 INSERT 쿼리가 나가게 된다.

findMember.getFavoriteFood().remove("Pasta");	// (1)
findMember.getFavoriteFood().add("Burger");

findMember.getAddress().remove(new Address("old1", "street", "123"));	// (2)
findMember.getAddress().add(new Address("new", "street", "123"));

이런 상황을 방지하려면 값 타입 컬렉션을 엔티티로 만들고 그 엔티티에서 값 타입을 사용하는 것이 좋다. 그리고 cascade와 orphanRemoval 설정을 추가해주면 기존의 값 타입 콜렉션이 주인 엔티티의 생명 주기에 의존하던 것처럼 동작한다.

@Entity
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<>();
}