JPA - 값 타입(2)

DevSeoRex·2022년 12월 4일
0
post-thumbnail

@AttributeOverride : 속성 재정의

  • 임베디드 타입에 정의된 매핑정보를 재정의할때 엔티티에 @AttributeOverride를 사용한다.

// 매핑 정보 재정의 예제 - @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_STREEET")),
    @AttributeOverride(name = "zipcode", column = @Column
    (name = "COMPANY_ZIPCODE"))
    })
    Address companyAddress;
}
  • @AttributeOverride를 사용하면 애너테이션을 많이 사용하게 되어, 코드가 지저분해진다.
  • 한 엔티티에 같은 임베디드 타입을 중복해서 사용할 일은 많지 않다.

💡 @AttributeOverrides는 엔티티에 설정해야 한다. 임베디드 타입이 임베디드 타입을      가지고 있어도 엔티티에 꼭 설정해야 한다.

임베디드 타입과 null

  • 임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null이 된다.
member.setAddress(null);		// null 입력
em.persist(member);

임베디드 타입인 Address의 값을 null로 셋팅하면 회원 테이블의 주소와 관련된 모든 컬럼 값이 null이 된다.

값 타입과 불변 객체

값 타입 공유 참조

  • 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다.
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

address.setCity("NewCity");		// 회원1의 address 값을 공유해서 사용
member2.setHomeAddress(address);

위의 코드를 작성하면서 당연히 회원2의 도시만 NewCity로 변경될거라 생각하지만, 이 경우 회원1의 주소도 같이 NewCity로 변경되게 된다.
회원1과 회원2가 같은 address 인스턴스를 참조하기 때문에 영속성 컨텍스트가 회원1, 회원2 둘 다 city 속성이 변경 된 것으로 판단해서 UPDATE SQL을 실행하기 때문이다.

💡 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용(Side-Effect)라고 한다.

  • 위와 같은 부작용을 방지하려면 값을 복사해서 사용하면 된다.

값 타입 복사

member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

// 회원1의 address 값을 복사해서 새로운 newAddress 값을 생성
Address newAddress = address.clone();

newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);

위 코드를 실행하면 회원2의 주소만 NewCity로 변경된다.

  • 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.

직접 정의한 값 타입 vs 자바의 기본 타입(primitive type)

  • 자바의 기본 타입에 값을 대입하면 값을 복사해서 전달한다.
int a = 10;
int b = a;			// 기본 타입은 항상 값을 복사한다.
b = 4;

// result 
a = 10, b = 4

a의 값을 b에 넣고, b의 값을 4로 변경해도 a는 그대로 10이라는 원래 값을 유지한다.

  • a와 b는 완전히 독립된 값을 가지며, 부작용도 없다.

자바는 객체에 값을 대입하면 항상 참조 값을 전달한다.

Address a = new Address("Old");
Address b = a;		// 객체 타입은 항상 참조 값을 전달한다.
b.setCity("New");

Address b = a에서 a가 참조하는 인스턴스의 참조 값을 b에 넘겨주게 된다.
그렇게 되면 a와 b는 같은 인스턴스를 공유 참조하게 되는 것이다. b.setCity("New")의 의도는 b.city 값만
변경하는 것이겠지만 의도와 다르게 a.city의 값도 변경되게 된다.

물론 객체를 대입할 때마다 인스턴스를 복사해서 대입하면 공유 참조를 피할 수 있지만,
복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다는 것이다.

💡 객체의 공유 참조는 피할 수 없으므로, 근본적인 해결책은 setter, 즉 수정자 메서드를 모두 제거하면 된다.
     이렇게 하면 공유 참조를 해도 값을 변경하지 못하므로 부작용을 막을 수 있다.

불변 객체(immutable Object)

  • 객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다.
  • 값 타입은 될 수 있으면 불변 객체로 설계 해야한다.
  • 한 번 만들면 절대 변경할 수 없는 객체를 불변 객체라고 한다.
  • 불변 객체의 값은 조회할 수 있지만 수정할 수 없다.
  • 불변 객체라 할지라도 참조 값 공유를 피할 수 없지만, 인스턴스의 값을 수정할 수 없으므로 부작용이 없다.

불변 객체를 구현하는 방법

  • 불변 객체를 구현하는 방법은 여러가지가 있지만 가장 간단한 방법은 생성자로만 값을 설정하게 하는 것이다.
// 불변 객체 구현 예제

@Embaddable
public class Address {
	
    private String city;
    
    protected Address()	{ }	// JPA에서는 기본 생성자가 반드시 필요하다.
    
    // 생성자로 초기 값 설정
    public Address(String city) { this.city = city }
    
    //  접근자(Getter)는 노출한다.
    public String getCity() {
    	return city;
    }
    
    // 수정자(Setter)는 만들지 않는다.
}

// 불변 객체 사용 예제

Address address = member1.getHomeAddress();
// 회원1의 주소값을 조회해서 새로운 주소값을 생성
Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);

값 타입의 비교

자바가 제공하는 객체 비교는 2가지가 있다.

  • 동일성(Identity) 비교 : 인스턴스의 참조 값을 비교, ==을 사용한다.
  • 동등성(Equivalence) 비교 : 인스턴스의 참조 값을 비교, equals( )를 사용한다.
int a = 10;
int b = 10;

Address a = new Address("서울시", "종로구", "1번지");
Address b = new Address("서울시", "종로구", "1번지");
  • Address 값 타입을 a == b로 동일성 비교하면 둘은 서로 다른 인스턴스이므로 false가 반환된다.
  • 값 타입은 비록 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다.
  • 값 타입을 비교하려면 동등성 비교를 해야하는데, equals( ) 메서드를 재정의 해야한다.

💡 equals( )를 재정의할때 hashCode( )도 같이 재정의하는 것이 안전하다.
     그렇지 않으면 해시를 사용하는 컬렉션(HashSet, HashMap)이 정상 동작하지 않기 때문이다.

값 타입 컬렉션

  • 값 타입을 하나 이상 저장하려면 컬렉션에 보관할 수 있다.
  • 값 타입 컬렉션을 사용하려면 @ElementCollection & @CollectionTable 애너테이션을 사용하면 된다.
// 값 타입 컬렉션 예제

@Entity
public class Member {
	
    @Id @GeneratedValue
    private Long id;
    
    @Embedded
    private Address homeAddress;
    
    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOODS",
    	joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FOOD_NAME")
    private Set<String> favoriteFoods = 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;
}
  • 관계형 데이터베이스의 테이블은 컬럼안에 컬렉션을 포함할 수 없다.
  • 별도의 테이블을 추가해서 @CollectionTable를 사용해서 추가한 테이블을 매핑해야 한다.
  • 값으로 사용되는 컬럼이 하나일 경우 @Column을 사용해서 컬럼명을 지정할 수 있다.

💡 @CollectionTable를 생략하면 기본값을 사용해서 매핑한다.
     기본값 : {엔티티이름}_{컬렉션 속성이름}

값 타입 컬렉션 사용

// 값 타입 컬렉션 사용예제
Member member = new Member();

// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));

// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");

// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
member.getAddressHistory().add(new Address("서울", "강북", "000-000"));

em.persist(member);
  • 위의 코드를 보면 member 엔티티만 영속화 했다. JPA는 member 엔티티의 값 타입도 함께 저장한다.
  • 실제 데이터베이스에 실행되는 INSERT SQL은 아래와 같다.
    • member : INSERT SQL 1번
    • member.homeAddress : 임베디드 값 타입이므로 회원 테이블을 저장하는 SQL에 포함
    • member.favoriteFoods : INSERT SQL 3번
    • member.addressHistory : INSERT SQL 2번

💡 값 타입 컬렉션은 영속성 전이 + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.

값 타입 컬렉션의 수정

// 값 타입 컬렉션의 수정 예제

Member member = em.find(Member.class, 1L);

// 1. 임베디드 값 타입 수정
member.setHomeAddress(new Address("새로운도시", "신도시", "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 한다.
  • 기본값 타입 컬렉션 수정 : 탕수육을 치킨으로 변경하려면 탕수육을 제거하고 치킨을 추가해야 한다. 자바의 String타입은 수정할 수 없다.
  • 임베디드 값 타입 컬렉션 수정 : 값 타입은 불변해야 하므로, 컬렉션에서 기존 주소를 삭제하고 새로운 주소를 등록했다.

값 타입 컬렉션의 제약사항

  • 값 타입은 식별자라는 개념이 없고 단순한 값들의 모음이므로 값을 변경해버리면 저장된 원본 데이터를 찾기 어렵다.
  • 값 타입 컬렉션에 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다.
  • JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면 값 타입 컬렉션이 매핑된 테이블에 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다.

실무에서의 값 타입 컬렉션 사용

  • 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 일대다 관계를 고려해야 한다.
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다.
  • 컬럼에 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없다는 제약도 있다.
  • 값 타입 컬렉션을 사용하는 대신에 새로운 엔티티를 만들어서 영속성 전이 + 고아 객체 제거 기능을 적용하면 값 타입 컬렉션처럼 사용이 가능하다.
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = 
		new ArrayList<AddressEntity>();

위와 같이 코드를 작성하면 값 타입 컬렉션처럼 일대다 관계의 엔티티를 사용할 수 있다.

💡 값 타입 컬렉션을 사용할 때는 모두 삭제하고 다시 저장하는 최악의 시나리오를 고려하면서 사용해야 한다.

출처 : 자바 ORM JPA 프로그래밍(에이콘, 김영한 저)

0개의 댓글