[자바 ORM 표준 JPA 프로그래밍] 8주차 스터디

박서영·6일 전

09장. 값 타입

JPA의 데이터 타입

  • 엔티티 타입:
    • @Entity로 정의하는 객체
    • 식별자를 통해 지속해서 추적 가능
  • 값 타입:
    • int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입 또는 객체
    • 식별자가 없고 문자/숫자같은 속성만 있어 추적이 불가능함.

값 타입의 종류

기본값 타입: 자바 기본 타입(int, double), 래퍼 클래스(Integer), String
임베디드 타입: 복합 값 타입
컬렉션 값 타입

9.1 기본값 타입

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

값 타입

  • Member의 String, int가 값 타입에 해당
  • Member 엔티티는 id라는 식별자 값을 가지고, 생명주기 역시 있지만, 값 타입인 name, age속성은 식별자값도 없고 생명주기 역시 회원 엔티티에 의존하게 된다.
  • 회원 엔티티 인스턴스를 제거하게되면 name, age 역시 사라지게 된다.
  • 값 타입은 공유해서는 안된다.
    예) 다른 회원 엔티티의 이름을 변경하는데 나의 이름까지 변경됨.

9.2 임베디드 타입(복합 타입)

임베디드 타입: 새로운 값 타입을 정의해서 사용하는 것을 말함.

@Entity
public class Member {
	@Id @GeneratedValue
    private Long id;
    
    private String name;
    
    @Temporal(TemporalType.DATE) java.util.Date startDate;
    @Temporal(TemporalType.DATE) java.util.Date endDate;
    
    private String city;
    private String street;
    private String zipcode;
}

위의 회원 엔티티는 단순히 정보를 풀어둔 것에 불과한다. 회원이 상세한 데이터를 그대로 가지고 있는 상태는 객체지향적이지 않아 응집력을 떨어뜨린다.

해결: 근무기간, 주소 타입 같은 타입을 가지도록 임베디드 타입을 사용한다.

@Entity
public class Member {
	
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    
    @Embedded Period workPeriod;
    @Embedded Address homeAddress;
}

//기간 임베디드 타입
@Embeddable
public class Period {
	
    @Temporal (TemporalType.DATE) java.util.startDate;
    @Temporal (TemporalType.DATE) java.util.Date endDate;
    
    public boolean isWork (Date dat) {}
}

//주소 임베디드 타입
@Embeddable
public class Address {
	@Column (name="city") //매핑할 컬럼 정의 가능
    private String city;
    
    private String city;
    private String zipcode;
}
  • startDateendDate를 합해서 Period(기간) 클래스를 만듦
  • city, street, zipcode를 합해 Address(주소) 클래스를 만듦

새로 정의한 값 타입들을 재사용을 할 수 있고 응집도 역시 높음.
해당 값 타입만 사용하는 메소드 역시 만들 수 있음.

임베디드 타입을 사용하기 위한 어노테이션

  • @Embeddable: 값 타입을 정의하는 곳에 표시
  • @Embedded: 값 타입을 사용하는 곳에 표시

또한 임베디드 타입은 기본 생성자가 필수적이다. 임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하기 때문에 UML로 엔티티-임베디드 타입의 관계를 표현하면 컴포지션 관계가 됨.

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

임베디드 타입 = 엔티티의 값. 따라서 값이 속한 엔티티의 테이블에 매핑.
임베디드 타입은 객체와 테이블을 세밀하게 매핑하는 것이 가능하다. 잘 설계한 ORM 애플리케이션의 경우는 매핑한 테이블의 수보다 클래스의 수가 더 많다.

ORM을 사용하지 않고 개발하게되면 테이블 컬럼과 객체 필드를 대부분 1:1로 매핑하게 된다. 근무기간이나 주소같은 값 타입 클래스를 만들어 개발하기에는 SQL을 직접 다룰 때 테이블 하나에 여러 클래스를 매핑하는 등 복잡한 과정이 발생하기 때문이다. 다만, ORM을 사용해 이런 반복적인 작업을 JPA에 위임하고 객체지향 모델 설계에 집중할 수 있다.

(2) 임베디드 타입과 연관관계

임베디드 타입의 경우 값 타입을 포함하거나 엔티티를 참조할 수 있다.

@Entity
public class Member {
	@Embedded Address address;
    @Embedded PhoneNumber phoneNumber;
}

@Embeddable
public class Address{
    String street;
    String city;
    String state;
    @Embedded Zipcode zipcode;
}

@Embeddable
public class Zipcode {
	String zip;
    String plusFour;
}

@Embeddable
public class PhoneNumber {
	String areaCode;
    String localNumber;
    @ManyToOne
    PhoneServiceProvider provider;
}

@Entity
public class PhoneServiceProvider {
	@Id String name;
}

위를 보면 값 타입에 해당하는 Address가 값 타입인 Zipcode를 포함하고 있고, 값 타입 PhoneNumber가 엔티티 타입인 PhoneServiceProvider를 참조한다.

(3) @AttributeOverride: 속성 재정의

임베디드 타입에 정의한 매핑정보를 재정의하기 위해서는 엔티티에 @AttributeOverride를 사용하면 된다.

예) 회원에게 주소가 하나 더 필요할 때

@Entity
public class Member {
	@Id @GeneratedValue
    privaet Long id;
    
    private String name;
    
    @Embedded Address homeAddress;
    @Embedded Address companyAddress;
}

위의 코드에서 주소를 추가하는 것은 쉽지만 문제는 **테이블에 매핑하는 컬럼명이 중복**된다. 이때는 `@AttributeOverrides`를 사용해 매핑정보를 재정의해야한다.

```java
@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_STREET")),
        @AttributeOverride(name="zipcode", column=@Column(nmae="COMPANY_ZIPCODE")
    })
    Address companyAddress;
}

@AttributeOverride를 사용했을 때, 어노테이션을 너무 많이 사용하여 엔티티 코드가 지저분해진다는 점이 있다. 하지만 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일은 많지 않다.

(4) 임베디드 타입과 null

임베디드 타입이 null일 경우에는 매핑한 컬럼 값이 모두 null이 된다.

member.setAddress(null);
em.persist(member);

9.3장 값 타입과 불변 객체

값 타입은 복잡한 객체를 단순화하기 위해 만든 개념.

(1) 값 타입 공유 참조

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하게되면 위험하다.

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

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

문제점: 회원2에 새로운 주소를 할당할 때 회원1의 주소를 그대로 참조하여 사용. -> 회원2의 주소만 NewCity로 바뀌는 것이 아니라 회원1의 주소 역시 NewCity로 변경되어버림.

이유: 회원1과 회원2가 같은 인스턴스를 참조하기 때문에 영속성 컨텍스트는 회원1과 회원2 둘 모두 city 속성이 변경되었다고 판단하여 각각을 UPDATE SQL을 실행한다. 이런 공유 참조로 인한 버그는 찾아내기가 어렵다.

부작용: 뭔가 수정했는데 전혀 예상하지 못한 곳에서 문제가 발생하는 것. 방지를 위해서는 값을 복사해 사용하면 된다.

(2) 값 타입 복사

값 타입의 실제 인스턴스 값을 공유하는 것은 위험하기에 값을 복사해서 사용해야한다.

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

Address newAddress = address.clone();

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

회원2에 새로운 주소를 할당하기 위해 clone() 메소드를 만들었는데, 이 메소드는 스스로를 복사해 반환하도록 구현되어있다. 즉, 회원1의 주소 인스턴스를 복사하여 사용한다.

위의 코드는 의도대로 회원2의 주소만 NewCity로 변경하게된다. 또한 영속성 컨텍스트 역시 회원2의 주소만 변경된 것으로 판단하여 회원2에 대해서만 UPDATE SQL을 실행하게된다.

이렇게 값을 항상 복사해서 사용하게되면 공유 참조로 인한 부작용을 피할 수 있다.

[문제1]

하지만, 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이라는 점이다.

자바의 기본 타입은 항상 값을 복사해서 전달하지만, 객체 타입의 경우에는 항상 참조값을 전달한다. 즉, 두 객체가 같은 인스턴스를 공유 참조하는 일이 발생한다.

[해결1]

객체를 대입할 때마다 인스턴스를 복사해 대입하면 공유 참조를 피할 수 있다.


[문제2]

복사하지 않고 원본의 참조값을 직접 넘기는 것을 막을 수 있는 방법이 없다. 자바에서는 대입하려는 것이 값 타입인지의 여부를 신경 쓰지 않고, 자바 기본 타입인 경우 값을 복사하고 객체일 경우 참조를 넘긴다.

[해결2]

즉, 객체의 공유 참조는 피할 수 없다. 따라서 해결책이 필요한데 가장 단순한 방법은 객체의 값을 수정하지 못하게 막으면 된다.

예를 들면 Address 객체의 setCity() 같은 수정자 메소드를 모두 제거하는 것이다. 이렇게 되면 공유 참조를 해도 값을 변경하지 못하기에 부작용의 발생을 막을 수 있다.

(3) 불변 객체

객체를 불변하게 만들면 값을 수정할 수 없기에 부작용을 원천 차단할 수 있다. 따라서 값 타입은 될 수 있으면 불변 객체로 설계해야한다.

불변 객체: 한 번 만들면 절대 변경할 수 없는 객체로 조회는 가능하지만 수정은 불가능.

하지만 불변 객체 역시 객체이기에 인스턴스의 참조 값 공유를 피할 수는 없음. 다만, 참조값을 공유하더라도 인스턴스의 값을 수정할 수 없기에 부작용은 발생하지 않음.

구현방법: 생성자로만 값을 설정하고 수정자를 만들지 않는다.

예) Address

@Embeddable
public class Address {
	private String city;
    
    protected Address () {this.city = city}
    
    //접근자(Getter)는 노출.
    public String getCity() {return city;}
}

불변 객체의 사용

Address address = member1.getHomeAddress();
Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);

위의 Address는 불변객체로 값을 수정할 수 없기에 공유해도 부작용이 발생하지 않는다. 만약 값을 수정해야한다면, 새로운 객체를 만들어 사용해야한다.

+) Integer, String은 자바가 제공하는 대표적인 불변 객체.


9.4 값 타입의 비교

int a = 10;
int b = 10;

Address a = new Address("서울시", "종로구", "1번지");
Address b = new Address("서울시", "종로구", "1번지");
  • int a의 숫자 10과 int b의 숫자 10은 같다고 표현
  • Address a와 Address b는 같다고 표현

자바가 제공하는 객체 비교

  • 동일성 비교: 인스턴스의 참조값을 비교하며 ==를 사용.
  • 동등성 비교: 인스턴스의 값을 비교, equals() 사용.

위의 코드에서 Address 값 타입을 a==b로 동일성 비교를 하는 경우 둘은 서로 다른 인스턴스 이기에 결과가 거짓이된다.

다만, 값 타입의 경우는 인스턴스가 달라도 그 안의 값이 같으면 같은 것으로 봐야한다. 그렇기에 값 타입 비교를 위해서는 동등성 비교를 해야한다. 이를 위해서는 Address의 equals() 메소드를 재정의해야한다.

값 타입의 equals() 메소드를 재정의할 때는 보통 모든 필드의 값을 비교하도록 구현한다.


9.5 값 타입 컬렉션

값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @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>();
}

값 타입 컬렉션을 사용하는 favoriteFoods, addressHistory@ElementCollection을 지정하였다.

favoriteFoods의 경우 기본값 타입인 String 컬렉션을 가진다. 이를 데이터베이스 테이블로 매핑해야하는데 관계형 데이터베이스의 테이블은 컬럼 안에 컬렉션을 포함할 수는 없다.

따라서 별도의 테이블을 추가하고 @CollectionTable를 사용해 추가한 테이블을 매핑해야한다. 또한 만약 favoriteFoods처럼 값으로 사용되는 컬럼이 하나일 때는 @Column을 사용해 컬럼명을 지정할 수 있다.

addressHistory는 임베디드 타입인 Address를 컬렉션으로 가진다. 이것 역시 마찬가지로 별도의 테이블을 사용해야한다. 테이블의 매핑정보는 @AtrributeOverride를 사용해서 재정의할 수 있음.

(1) 값 타입 컬렉션 사용

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번

따라서 em.persist() 한 번으로 총 6번의 INSERT SQL을 실행하게 된다.

값 타입 컬렉션 역시 조회 시 fetch 전략을 선택할 수 있는데, 기본은 LAZY이다.

예) 지연로딩으로 모두 설정했다고 가정하고 아래킝 코드를 실행

Member member = em.find(Member.class, 1L);
Address homeAddress = member.getHomeAddress();
Set<String> favoriteFoods = member.getFavoriteFoods(); //LAZY

for (String favoriteFood : favoriteFoods) {
	System.out.println("favFood: "+favoriteFood);
}

List<Address> addressHistory = member.getAddressHistory(); //LAZY

addressHistory.get(0);
  • member: 회원을 조회한다. 이때 임베디드 값인 homeAddress도 함께 조회한다. SELECT SQL을 한 번 호출.
  • member.homeAddress: 앞에서 회원 조회 시 같이 조회해둔다.
  • member.favoriteFoods: LAZY로 설정하여 실제 컬렉션 사용 시 SELECT SQL을 1번 호출.
  • member.addressHistory: LAZY로 설정하여 실제 컬렉션 사용 시 SELECT SQL을 1번 호출.

예) 값 타입 컬렉션의 수정

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

member.setHomeAddress(new Address("새로운도시", "신도시1", "123456");

Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("탕수육");
favoriteFoods.add("치킨");

List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존 주소", "123-123-");
addressHistory.add(new Address("새로운 도시", "새로운 주소", "123-456");
  • 임베디드 값 타입 수정: homeAddress 임베디드 값 타입은 MEMBER 테이블과 매핑하였으므로 MEMBER 테이블만 UPDATE하게된다. 사실 Member 엔티티를 수정하는 것과 같음.
  • 기본값 타입 컬렉션 수정: 탕수육을 치킨으로 수정하기 위해서는 탕수육을 제거하고 치킨을 추가해야한다. 자바의 String 타입은 수정이 불가하다.
  • 임베디드 값 타입 컬렉션 수정: 값 타입은 불변해야하기에 기존 주소를 삭제하고 새로운 주소를 등록하는 방식을 취한다. 값 타입은 equals()hashcode를 반드시 구현해야한다.

(2) 값 타입 컬렉션의 제약사항

엔티티는 식별자가 있어 값을 변경하여도 식별자로 데이터베이스에 저장된 원본 데이터를 쉽게 찾아 변경할 수 있지만, 값 타입은 식별자 개념이 없고 단순한 값들의 모음이기에 값을 변경해버리면 데이터베이스에 저장된 원본 데이터를 찾기 어렵다.

특정 엔티티 하나에 소속된 값 타입은 값이 변경되어도 자신이 소속된 엔티티를 데이터베이스에서 찾고 값을 변경하면된다. 다만, 값 컬렉션은 보관된 값 타입들을 별도의 테이블에 보관하기에 여기에 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다.

따라서 JPA 구현체들을 값 타입 컬렉션에 변경 사항이 발생하면 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다.

따라서 실무에서 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신 일대다 관계를 고려해야함.

+) 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야한다. 따라서 데이터베이스 기본 키 제약 조건으로 인해 컬럼으로 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약도 존재한다.

위의 문제를 해결하기 위해서는 값 타입 컬렉션을 만들기 위해서는 새로운 엔티티를 만들어 일대다 관계로 설정하면 된다. 여기에 추가로 영속성 전이 + 고아 객체 제거 기능을 적용하여 값 타입 컬렉션처럼 사용할 수 있다.

@Entity
public class AddressEntity {
	@Id
   	@GeneratedValue
    private Long id;
    
    @Embedded Address address;
}

설정은 아래와 같이 하면 된다.

@OneToMany (cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<AddressEntity>();
profile
이불 밖은 위험해.

0개의 댓글