[김영한 - JPA 기본] #9 값 타입

JPA의 데이터 타입 분류


  • 엔티티타입

    • @Entity로 정의하는 객체
    • 식별자(Id)가 있기 때문에 데이터가 변해도 추적 가능
      • 예) 회원 entitiy의 키나 나이가 변해도 id로 식별 가능
  • 타입

    • int, Integer, String,와 같은 자바 기본 타입이나 객체
    • id가 별도로 존재하지 않고 값만 있으므로 변경시 추적 불가
      • 예) int a = 100 에서 200으로 변경한다는 건 완전 다른 값으로 변경
    • 엔티티의 생명주기에 의존한다

값 타입 분류


  • 기본값 타입

    • 자바 기본 타입 (int, double)
    • 래퍼 클래스 (Integer, Long)
    • String
  • 임베디드 타입 (embedded type, 복합 값 타입)

    • JPA에서 별도로 정의해야 사용 가능
  • 컬렉션 값 타입 (collection value type)

    • JPA에서 별도로 정의해야 사용 가능

기본 값 타입


  • 엔티티에 의존하는 생명 주기

    • 예) 회원 entitiy를 삭제하면 이름, 나이 필드도 함꼐 삭제
  • 값 타입은 공유하면 안된다

    • 예) 회원 이름 변경시 다른 회원 이름도 같이 변경되면 안된다
  • 당연한 얘기지만 기본타입은 항상 값을 복사한다
    그러나 래퍼, String 클래스는 공유가능(= 연산 가능, 레퍼런스 타입) 변경(set)이 불가하다

이러한 이유 덕에 우린 기본 값 타입을 안전하게 사용이 가능했던 것이다 !

임베디드 타입 Embedded Type


  • JPA의 기능으로 새로운 값을 정의할 수 있다

  • 주로 기본 값 타입을 모아서 만들어서 복합 값 타입 이라고도 한다

  • 하지만 결국 int, String 같은 값 타입이다 ^^ ㅎ

임베디드 타입: 내 코드 (눈갱 주의!!)


실행부

LocalDateTime startDateTime = LocalDateTime.of(2020, 4, 1, 9, 0); // 내 입사일
LocalDateTime endDateTime = LocalDateTime.of(2022, 2, 7, 18, 0); // 내 퇴사일 by 우테코 입교

Member memberA = Member
  .builder()
  .name("user A")
  .period(new Period(startDateTime, endDateTime))
  .build();

em.persist(memberA);

boolean isWorkNow = memberA.getPeriod().isWork();

out.println("isWorkNow = " + isWorkNow);

isWorkNow 는 근속여부인데 true로 현재 근속중인 것으로 나온다 !

@Entity
public class Member extends BaseEntity {

  ...

	@Embedded
	private Period period;

	@Embedded
	@AttributeOverrides({
		@AttributeOverride(
			name = "city",
			column = @Column(name = "HOME_CITY")
		),
		@AttributeOverride(name = "street",
			column = @Column(name = "HOME_STREET")
		),
		@AttributeOverride(name = "zipcode",
			column = @Column(name = "HOME_ZIPCODE")
		)
	})
	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;
@Embeddable
public class Address {

    private String city;
    private String street;
    private String zipcode;

}

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Period {

	@Column(name = "WORK_START_DATE")
	private LocalDateTime startDate;

	@Column(name = "WORK_END_DATE")
	private LocalDateTime endDate;

	public Period(LocalDateTime startDate, LocalDateTime endDate) {
		this.startDate = startDate;
		this.endDate = endDate;
	}

	public boolean isWork() {
		LocalDateTime now = LocalDateTime.now();
		return (startDate.equals(now) || now.isAfter(startDate)) && (endDate.equals(now) || now.isBefore(endDate));
	}
}

임베디드 타입의 장점


  • 재사용성이 좋으며 높은 응집도를 가진다

  • Periord.isWork() 처럼 해당 타입만 사용하는 의미 있는 메서드를 사용할 수 있다

    • 예) 현재 이 사람이 근속중인가?
    • SRP 원칙을 지킬 수 있다
  • 이 역시 값 타입이므로 생명주기는 현재 타입을 소유한 엔티티에 의존한다 !

임베디드 타입과 테이블 매핑


  • 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능
  • 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다 !

임베디드 타입과 연관관계

  • value 타입안에 value타입, Entity 를 넣을 수 있다고 한다
    • 실제로 해보았을때는 둘다 잘 되지 않았다 …
      아직 쓰일 일도 없구 공감이 안되어서 굳이 해보지는 않았다 ㅎㅎ..

임베디드 타입: @AttributeOverride: 속성 재정의

  • 한 인티티에서 같은 값 타입 사용시 컬럼명 중복

    • 예) 집 주소, 회사 주소
  • @AttributeOverrides, @AttributeOverride 사용 가능

임베디드 타입과 null

  • 임베디드 타입 값이 null일 경우 매핑한 컬럼 값은 모두 null 이다

값 타입과 불변 객체


값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다.

문제의 코드


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

Member memberA = Member
  .builder()
  .name("user A")
  .homeAddress(address)
  .build();

Member memberB = Member
  .builder()
  .name("user B")
  .homeAddress(address)
  .build();


memberA.getHomeAddress().setCity("new city"); // 문제 발생 !


em.persist(memberA);
em.persist(memberB);

위와 같은 상황에서 Address 객체라는 참조타입을 공유하므로 두 회원의 정보가 동시에 바뀌어버리는 문제가 발생한다 !

값 타입 공유 참조


  • 임베디드 값 타입과 같은 값을 여러 엔티티에서 공유하는 것은 위험하다
    • 참조형이기 때문에 수정이 일어났을 시 여러 엔티티에서 동시 변경
  • 그떄그때 마다 복사해서 사용하자 ! (아래 코드 참고)
  • 임베디드 타입과 같은 값 타입은 참조형이며 = 등의 연산을 피해갈 수 없다
  • 운영에서 버그 찾기 정말 어렵다고 한다

값을 공유참조로부터 안전하게 변경하기 by 깊은 복사


Address newCity = new Address("new city", address.getStreet(), address.getZipcode());
memberA.setHomeAddress(newCity);

위와 같이 값을 복사해서 사용하면 된다 !

불변 객체 (Immutable Object)


불변이라는 작은 제약을 걸어 큰 재앙을 막을 수 있다

생성 시점 이후 절대 값을 변경할 수 없는 객체

  • 진정한 의미의 객체

  • 생성자로만 값을 할당하고 수정자(Setter)를 만들지 않는다

  • 예) Integer, String은 자바가 제공하는 대표적인 불변 객체이다

값 타입의 비교


우선 코드로 먼저 보도록 하자 !

Address address1 = new Address("city A", "street", "zipcode");
Address address2 = new Address("city A", "street", "zipcode");

System.out.println("(address1 == address2) = " + (address1 == address2));
System.out.println("address1.equals(address2) = " + address1.equals(address2));

address1 과 address2 가 같은지를 비교하는 코드이다

아마 자바를 열심히 공부를 한 사람들이라면 여기서 어떤 내용을 다룰 것인지 모두 느낌을 잡았을 것이다

결론적으로 euqlas와 hashCode를 오버라이드하면 된다

hash code도 오버라이딩해야 HashMap등의 사용하는 API들이 효율적으로 동작 가능하다

  • 성(identity) 비교: 인스턴스의 참조 값을 비교, == 사용
  • 성(equivalence) 비교: 인스턴스의 값을 비교, equals() 사용
    • 레퍼런스 타입인 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야 함
    • equals() 메소드를 적절하게 재정의해야 한다 (주로 모든 필드 사용)

값 타입 컬렉션


  • 말 그대로 컬렉션 ! 값 타입을 하나 이상 저장할 때 사용
  • @ElementCollection, @CollectionTable 사용
  • DB는 컬렉션이라는 값을 테이블에 저장할 수 없다 (예를들어 엑셀 한칸에 list형 자료를 넣을 수 없음)
  • 컬렉션을 저장하기 위한 별도의 테이블 필요
  • id등의 고유 식별자를 도입하는 순간 개념적으로 값 타입이 아닌 엔티티가 된다
    • 때문에 값타입은 id가 존재하지 않는다
    • 값을 변경하면 추적이 어렵다
  • (그외) jpa 에서 플러시할 때 순서의 보장은 해주지 않는다 !

예제 코드

@Entity
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

	@Id
	@GeneratedValue
	private Long id;


	@ElementCollection(fetch = FetchType.EAGER)
	@CollectionTable(
		name = "FAVORITE_FOOD" ,
		joinColumns = @JoinColumn(name = "MEMBER_ID")
	)
	@Column(name = "FOOD_NAME")
	private Set<String> favoriteFoods = new HashSet<>();


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

	...
}
Set<String> favoriteFoods = new HashSet<>();
favoriteFoods.add("간장 치킨");
favoriteFoods.add("불닭 볶음면");
favoriteFoods.add("돈까스");

List<Address> addressHistory = new ArrayList<>();
addressHistory.add(new Address("old1 city", "street", "zipcode"));
addressHistory.add(new Address("old2 city", "street", "zipcode"));


저장

Member memberA = Member
	.builder()
	.name("user A")
	.favoriteFoods(favoriteFoods)
	.addressHistory(addressHistory)
	.homeAddress(new Address("homne city", "home street", "zipcode"))
	.build();

em.persist(memberA);

조회

out.println("#1 memberA.getId() = " + memberA.getId());

수정 : 잘못된 예

findMember.getHomeAddress().setCity("updated city");

값 타입은 이기 떄문에 이렇게 하지 말것 !!!
통쨰로 갈아 끼워줘야 한다

수정 : 올바른 예

Address homeAddress = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("updated city", homeAddress.getStreet(), homeAddress.getZipcode()));

값 컬렉션은 지우고 add를 한다 !

findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

주의 : 리스트는 delete & insert 할 떄
주인 엔티티와 연관된 모든 데이터를 삭제하고 변경할 데이터를 포함하여 모두 저장

findMember.getAddressHistory().remove(new Address("old1 city", "street", "zipcode"));
findMember.getAddressHistory().add(new Address("new1 city", "street", "zipcode"));

위 사항을 해결하려면 @OrderColumn 어노테이션을 추가해 주어야 한다 이럴 경우 위치에 해당하는 컬럼값이 생기는 데

이걸 기준으로 식별하여 값을 수정처러한다

수정된 코드 (List 값 컬렉션)

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

그러나 위 옵션을 위험하다.. 원하는데로 동작하지 않을 떄도 있고..

0, 1, 2, 3 값등으로 셋팅되는데 그중 2가 없으면 null 이 들어오는 등

그러나.. 이렇게 번잡하게 쓸바엔 리스트 값타입을 엔티티로 승격 시키는 것이 낫다 !!

승격시킨 Address Entity 코드

@Entity
@Table(name = "ADDRESS")
@Getter @Setter
public class AddressEntity {

	@Id @GeneratedValue
	private Long id;

	private Address address; // 이곳에서 한번 감싼다 !

	public AddressEntity() {
	}

	public AddressEntity(Address address) {
		this.address = address;
	}

	public AddressEntity(String city, String street, String zipcode) {
		this.address = new Address(city, street, zipcode);
	}
}
@Entity
class Member {
	...

	@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
	@JoinColumn(name = "MEMBER_ID")
	private List<AddressEntity> addressHistory = new ArrayList<>();
}
List<AddressEntity> addressHistory = new ArrayList<>();
addressHistory.add(new AddressEntity("old1 city", "street", "zipcode"));
addressHistory.add(new AddressEntity("old2 city", "street", "zipcode"));

		Member memberA = Member
			.builder()
			...
			.addressHistory(addressHistory)
			.build();

그럼 값 타입 컬렉션은 언제 쓰는거야?

예를 들어 좋아하는 메뉴를 멀티 셀렉트 박스 (치킨, 피자) 로 구성된 항목 체크할 때 등 단순한거 할 때 사용한다

반대로 주소 이력같은 경우는 엔티티이다, (값을 변경하지 않더라도 !) 그 히스토리 테이블 대상으로 조회할 수도 있기 떄문

그외


equalshash code를 구현할 때 가급적이면 getter를 이용하는 게 좋다

image

image

그래야 프록시로 만들어졌을 때 진짜 객체에 접근할 수 있다 (영한님 말씀)

image

그리고 상태와 행위를 한 곳에서 관리할 수 있다 .. ㅎㅎ




© 2020.12. by 따라쟁이

Powered by philz