본문 바로가기
JPA/JPA

[JPA] 값 타입

by yoon_seon 2023. 4. 22.

🎈 JPA의 데이터 타입 분류

JPA의 데이터 타입 분류는 크게 엔티티 타입과 값 타입 두가지이다.

 

엔티티 타입

  • @Entity로 정의하는 객체로 타입들을 PK로 관리하기 때문에 데이터가 변해도 식별자로 지속해서 추적이 가능하다.
    ex. 회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식 가능

값 타입

  • int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
  • 식별자가 없고 값만 있으므로 변경 시 추적 불가
    ex. 숫자 100을 200으로 변경하면 완전히 다른 값으로 대체

 

🎈 기본값 타입

  • 자바 원시 타입(int, double)
  • 래퍼 클래스(Integer, Long)
  • String

 

기본값 타입의 특징은 생명주기들이 엔티티의 의존하는 것이다. 예를 들면 회원을 삭제하면 이름, 나이 필드도 함께 삭제된다.

값 타입이 중요하게 다루는 점은 공유되어 다른 값이 변경되면 안 된다라는 점이다. 회원 1의 이름을 변경하였다고 회원 2의 이름이 변경되면 안 되기 때문이다.

참고로 자바의 원시 타입은 절대 공유되지 않는다. 자바 원시 타입들은 항상 값을 복사만 할 뿐 참조값이 사용하지 않기 때문에 사이드 임팩트가 전혀 없다. 래퍼 클래스나 String 같은 특수한 클래스는 참조값으로 공유는 가능한 객체이지만 값 변경 시 다른 값이 변경되지는 않는다.

그렇기 때문에 지금 가지 기본값 타입을 사용했을 때 안전하게 사이드임팩트 없이 개발할 수 있었던 것이다.

 

🎈 임베디드 타입

JPA는 새로운 값 타입을 직접 정의한 것을 임베디드 타입이라고 한다. 주로 기본 값 타입을 모아서 만들기 때문에 복합 값 타입이라고도 불리며 임베디드 타입도 역시 int, String과 같은 '값 타입'이다.

 

임베디드타입을 이해하기 위해 간단한 예시를 보자.

 

회원 엔티티는 이름, 근무시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다.

그림과 같이 이름, 근무기간, 주소 정도로 묶은 것이 임베디드 타입이다.

@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() {} // 기본 생성자 필수
}

@Entity
@Getter @Setter
public class Member {
	// ...
    
	@Embedded
	private Period workPeriod;
	@Embedded
	private Address homeAddress;
}
  • @Embeddable : 값 타입을 정의하는 곳에 표시 (Period, Member)
  • @Embedded : 값 타입을 사용하는 곳에 표시 (Member)
  • 임베디드 타입의 기본 생성자 필수이다.

이런 임베디드 타입의 장점은 재사용성, 높은 응집도, Period.isWork()처럼 임베디드 타입에 사용할 특정 메서드를 따로 관리할 수 있다는 점이다. 

임베디드 타입은 엔티티의 값일 뿐이기 때문에 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 변하는 것이 없다.

임베디드 타입을 사용함으로써 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능하다.

참고로 임베디드 타입의 값이 null이면 매핑한 컬럼 값은 모두 null이 된다.

 

@AttributeOverride : 속성 재정의

한 엔티티에서 같은 값 타입을 사용하거나 컬럼명이 중복될 때 사용한다.

@Entity
@Getter @Setter
public class Member {
	//...
	@Embedded
	private Address homeAddress;
	@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;

 

🎈값 타입과 불변 객체

값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 단순하고 안전하게 다룰 수 있어야 한다.

기본 값 타입은 공유가 불가능 하지만 임베디드 타입은 직접 정의한 타입이기 때문에 공유가 가능하다. 같은 값 타입을 여러 엔티티에서 공유할 경우 다른 엔티티도 수정하는 부작용이 발생한다.

회원이 1과 회원 2가 같은 임베디드 타입을 공유하고 있다고 가정해 보자.

Address address = new Address("city", "street", "zipcode");

Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member1");
member2.setHomeAddress(address);
em.persist(member2);

// member1의 주소를 변경했지만 member2도 변경됨
member1.getHomeAddress().setCity("newCity");

이렇게 되면 member1의 주소 도시를 변경할 목적으로 member1의 주소를 수정했지만 같은 값 타입을 공유하는 member2의 주소도 변경된다. 그래서 값을 복사해서 사용해야 한다.

Address address = new Address("city", "street", "zipcode");

MemberR member1 = new MemberR();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);

// 값을 복사해서 사용해야한다.
Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());

MemberR member2 = new MemberR();
member2.setUsername("member1");
member2.setHomeAddress(copyAddress); // 복사한 값을 사용
em.persist(member2);

하지만 개발자가 실수로 값을 복사하지 않고 기존 임베디드 타입 인스턴스를 사용했다면 공유 참조를  막을 수 있는 방법이 전혀 없다.

그래서 객체 타입을 수정할 수 없게 불변 객체로 만들어 부작용을 원천 차단해야 한다.

ex. 생성자로만 변경, setter 미설정

 

그러면 member2가 아니라 member1에서도 수정할 수가 없게 된다...

Address address = new Address("city", "street", "zipcode");

MemberR member1 = new MemberR();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);

// member1.getHomeAddress().setCity("newCity"); 불변객체이므로 수정 불가능

Address newAddress = new Address("newCity", address.getStreet(), address.getZipcode());
member1.setHomeAddress(newAddress); // 통으로 갈아 넣어야한다...

member1을 수정하려면 Address 객체를 새로 생성한 다음 member1에 객체를 다시 넣어야 한다.

번거롭긴 하지만 부작용이 이라는 큰 재앙을 막을 수 있다.

값 타입은 무조건 불변으로 만들자

 

🎈 값 타입의 비교

값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다.

1. 동일성비교 (==비교)

인스턴스의 참조값을 비교한다.  자바 원시타입 해당

 

2. 동등성 비교(equals 비교)

자바 참조타입 해당

@Embeddable
@Getter
public class Address {
	// ...

	@Override
	public int hashCode() {
		return Objects.hash(city, street, zipcode);
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Address other = (Address) obj;
		return Objects.equals(city, other.city) &&
				Objects.equals(street, other.street) &&
				Objects.equals(zipcode, other.zipcode);
	}
}
Address address1 = new Address("city", "street", "zipcode");
Address address2 = new Address("city", "street", "zipcode");

System.out.println(address1.equals(address2)); //true

임베디드 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야하기 때문에 임베디드 타입에 equals를 오버라이드해서 사용하자.

 

🎈 컬렉션 값 타입

임베디드 타입이나 기본 값 타입을 컬렉션에 넣어 사용하는 것으로 값 타입을 하나 이상 저장할 때 사용한다.

데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다. 컬렉션을 저장하기 위해서는 별도의 테이블이 필요하다.

 

하나의 회원은 여러음식을 선호할 수 있고 여러 주소를 가질 수 있다고 하면 다음과 같이 만들어 줄 수 있다.

@Entity
@Getter @Setter
public class MemberR {
	// ...
    
    @ElementCollection
	@CollectionTable(name="FAVORITY_FOOD", joinColumns = @JoinColumn(name="MEMBER_ID"))
	@Column(name = "FOOD_NAME")
	private Set<String> favorityFoods = new HashSet<>();
	
	@ElementCollection
	@CollectionTable(name="ADDRESS", joinColumns = @JoinColumn(name="MEMBER_ID"))
	private List<Address> addressHistory = new ArrayList<>();
}
  • @ElementCollection : 값 타임 컬렉션을 매핑할 때 사용한다. (기본값이 지연 로딩이다.)
  • @CollectionTable : 값 타입 컬렉션을 매핑할 테이블에 대한 정보를 지정한다.
    joinColumns : 외래키로 사용할 컬럼 지정

joinColumns에는 FK로 쓸 값을 넣어주고 식별자는 모든 컬럼의 PK의 조합이 된다. 컬렉션을 위한 별도의 테이블이 만들어진다.

지연 로딩을 사용해서 Member를 가져올 때 FAVORITE FOOD는 프록시 객체로 들어오고 실제로 쓰일 때 쿼리가 날아간다. 영속성 전이(Cascade)와 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.

 

값 타입 컬렉션의 제약사항

값 타입은 엔티티와 다르게 식별자 개념이 없기 떄문에 값을 변경하면 추적이 어렵다.

값 타입 컬렉션이 자료형 Set일 경우 Key로 해당 인덱스를 데이터를 찾을 수 있기 때문에 delete SQL과 insert SQL이 전송되지만 List같은 자료형은 Key로 데이터에 접근하지 않기 때문에 수정할 데이터를 변경할 방법이 없다. 그래서 값 타입 컬렉션에 변경 사항이 생기면 주인엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 다시 저장한다. 

또, 값 타입이 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야하기 때문에 null 입력이 안되고 중복 저장도 안된다.

 

결론은 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려하는게 낫다. 일대다 관계를 위한 엔티티를 만들고 여기에서 값 타입을 사용해서 영속성 전이(Cascade) + 고아 객체 제거로 값 타입 컬렉션처럼 사용할 수 있다.

 

정리

엔티티타입의 특징

  • 식별자가 있다.
  • 생명주기가 관리된다
  • 공유할 수 있다

값 타입의 특징

  • 식별자가 없다.
  • 생명 주기를 엔티티에 의존한다.
  • 공유하지 않는 것이 안전하다. 공유해야한다면 복사해서 사용한다.
  • 불변 객체로 만드는 것이 안전하다.

 

값 타입은 정말 값 타입이라 판단될 때만 사용해야한다.(단순 삭제해도 문제가 없을 때)

엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다.

식별자가 필요하고, 지속적으로 값을 추적해야하며 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.

 

 

 


해당 글은 인프런의 [자바 ORM 표준 JPA 프로그래밍 - 기본편] 강의를 정리한 내용입니다.

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런 | 강의

JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다., - 강의 소개 | 인프런

www.inflearn.com

댓글