값 타입

윤용운·2022년 5월 16일
1

JPA_스터디

목록 보기
9/9
post-thumbnail

9장. 값 타입

JPA에서는 크게 엔티티 타입값 타입으로 나눌 수 있다. 엔티티 타입@Entity로 정의하는 객체이며, 식별자를 통해 지속해서 추적할 수 있다. 값 타입은 int, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말하며, 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없다.

값 타입은 3가지로 나눌 수 있다.

  • 기본값 타입
    • 자바 기본 타입
    • 래퍼 클래스
    • String
  • 임베디드 타입(복합 값 타입)
  • 컬렉션 값 타입

기본값 타입

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

	private String name;	// 기본값 타입
	private int age;		// 기본값 타입
}

위 코드의 String, int가 값 타입이다.

  • Member 엔티티는 id라는 식별자 값도 가지고 있고, 생명주기도 존재한다.
  • 값 타입인 name, age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존하므로, 회원 엔티티 제거시 같이 제거된다.
  • 값 타입은 공유되면 안된다.

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

새로운 값 타입을 직접 정의해서 사용할 수 있다.

@Entity
public class Member {
	...
    
    @Embedded
    Period workPeriod;
    @Embedded
    Address homeAddress;
}

@Embeddalbe
public class Period {
	@Temporal(TemporalType.DATE)
    Date startDate;
	@Temporal(TemporalType.DATE)
    Date endDate;
    ...
    public boolean isWork(Date date) {
    	// .. 값 타입을 위한 메소드를 정의할 수 있다
    }
}

@Embeddalbe
public class Address {
	@Column(name="city")	// 매핑할 컬럼 정의 가능
	private String city;
    private String street;
    private String zipcode;
    ...
}


새로 정의한 값 타입들은 재사용할수 있고, 응집도도 아주 높다. Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메소드도 만들 수 있다.

임베디드 타입 사용 시, 2가지 어노테이션이 필요하다.

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

또한, 임베디드 타입은 기본 생성자가 필수다. 그리고, 임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하므로 엔티티와 임베디드 타입의 관계를 컴포지션(composition) 관계가 된다.

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

임베디드 타입은 엔티티의 값일 뿐이므로, 값이 속한 엔티티의 테이블에 매핑한다. 따라서, 사용하기 전후에 매핑하는 테이블은 같다.

임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게 매핑할수 있다. ORM을 사용하지 않으면 테이블 컬럼과 객체 필드를 대부분 1:1로 매핑하는데, 이런 지루한 작업은 JPA에 맞기고 좀 더 세밀한 객체지향 모델을 설계할 수 있다.

잘 설계한 ORM 어플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다고 한다.

임베디드 타입과 연관관계

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

  • 값 타입인 Address가 값 타입인 Zipcode를 가질 수 있다
  • 값 타입인 PhoneNumber가 엔티티 타입인 PhoneServiceProvider를 참조할 수 있다

@AttributeOverride : 속성 재정의

@AttributeOverride를 통해 임베디드 타입에 정의한 매핑정보를 재정의 할 수 있다.

@Entity
public class Member {
	@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(name = "COMPANY_ZIPCODE)),
    })
    Address companyAddress;
}

다음과 같이 정의하면, 테이블에는 아래와 같이 정의된다.

CREATE TABLE MEMBER (
	COMPANY_CITY varchar(255),
	COMPANY_STREET varchar(255),
	COMPANY_ZIPCODE varchar(255),
	city varchar(255),
	street varchar(255),
	zipcode varchar(255)
    ...
)

@AttributeOVerride를 사용하면 어노테이션이 너무 많아져서 엔티티 코드가 지저분해진다. 다행히도, 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일은 많지 않다.

임베디드 타입과 null

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

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

회원 테이블을 확인해 보면, CITY, STREET, ZIPCODE 컬럼 값은 모두 null이 된다.

값 타입과 불변 객체

값 타입 공유 참조

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

값 타입을 여러 엔티티에서 공유하게 되면, 회원 2의 엔티티를 변경하게 되면 회원 1의 엔티티도 같이 변경되게 된다. 이렇게 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용이라 하고, 이런 부작용을 막으려면 값을 복사해서 사용하면 된다.

값 타입 복사


회원 1의 주소 인스턴스를 복사해서 회원 2에 넘겨주게 되면 회원 2의 주소가 변경되면 회원 2에 대해서만 UPDATE SQL을 실행하게 된다.

자바는 기본 타입에 값을 대입하면 값을 복사해서 전달한다. 하지만 객체 타입은 값을 대입하면 항상 참조값을 전달하기 때문에, 인스턴스를 복사해서 대입하는 방법 말고는 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다. 따라서, 객체 값을 수정하지 못하게 막게 되면 공유 참조를 해도 값을 변경하지 못하므로 부작용의 발생을 막을 수 있다.

불변 객체

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

불변 객체란 한번 만들면 절대 변경할 수 없는 객체를 말한다. 값을 조회는 할 수 있지만, 수정은 할 수 없으며, 이로 인해 참조 값을 공유해도 인스턴스의 값을 수정할 수 없으므로 부작용이 발생하지 않는다.

@Embeddable
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();
Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);

값을 수정해도 부작용이 발생하지 않게 되고, 값을 수정해야 하면 위처럼 새로운 객체를 생성해서 사용해야 한다. 불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다

값 타입의 비교

자바가 제공하는 객체 비교는 2가지가 있는데, 다음과 같다.

  • 동일성(identity) 비교 : 인스턴스의 참조 값을 비교. == 사용
  • 동등성(equivalence) 비교 : 인스턴스의 값을 비교. equals() 사용
    값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은것으로 봐야한다. ==을 사용하게 되면 서로 다른 인스턴스이므로 결과는 거짓이다. 따라서 equals() 메소드를 재정의하고, equals()로 동등성 비교를 해야 한다.

값 타입 컬렉션

값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable 어노테이션을 사용하면 된다.

@Entity
public class Member {
    ...
    @ElementCollection
    @CollectionTable(
        name = "FAVORITE_FOOD",
        joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FOOD_NAME") // 컬럼명 지정 (예외)
    private Set<String> favoriteFoods = new HashSet<>();

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

위의 코드를 보면 Member 엔티티에서 값 타입 컬렉션을 사용하는 favoriteFoods, addressHistory@ElementCollection을 지정하였다.

  • favoriteFoods
    favoriteFoods는 String을 컬렉션으로 가진다. 관계형 데이터베이스의 테이블은 컬럼 안에 컬렉션을 보관할 수 없으므로, 위의 그림처럼 별도의 테이블(FAVORITE_FOOD)을 생성한 후 @CollectionTable을 사용해 추가한 테이블을 매핑해야 한다. 또한 favoriteFoods처럼 값으로 사용되는 컬럼이 하나면 @Column을 통해 컬럼명을 지정할 수 있다.

  • addressHistory
    addressHistory는 임베디드 타입인 Address를 컬렉션으로 가지고, 이또한 별토의 테이블을 사용해야 한다. 테이블 매핑정보는 @AttributeOverride를 사용하여 재정의할 수 있다.

FAVORITE_FOOD, ADDRESS 테이블을 보면 모든 속성을 PK로 둠을 알 수 있는데, 이는 하나러 식별자로 두는 개념을 도입하면 엔티티가 될 수도 있고, 이에 따라 테이블에 값들만 저장하고 이들을 묶어서 PK로 사용한다고 한다.

@CollecionTable을 생략하면 기본값(엔티티이름_컬렉션속성이름)을 사용하여 매핑한다

값 타입 컬렉션 사용

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 엔티티의 값 타입도 함께 저장한다. 위 코드에서는 총 6번(member, member.favoriteFoods * 3, member.addressHistory * 2) INSERT SQL문이 생성됨을 알 수 있다.

INSERT INTO MEMBER(ID, CITY, STREET, ZIPCODE) VALUES (1, '통영', '몽돌해수욕장', '660-123')
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "짬뽕")
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "짜장")
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "탕수육")
INSERT INTO ADDRESS(MEMBER_ID, CITY, STREET, ZIPCODE) VALUES (1, '서울', '강남', '123-123')
INSERT INTO ADDRESS(MEMBER_ID, CITY, STREET, ZIPCODE) VALUES (1, '서울', '강북', '000-000')

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

또한, 값 타입 컬렉션도 조회할 떄 페치 전략을 선택할 수 있고, LAZY가 기본이다.

수정 시에는 값 타입은 불변해야 하므로, 기존 값은 삭제해주고 새로운 객체, 혹은 값을 추가해야 한다.

값 타입 컬렉션의 제약 사항

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

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

이로 인해 JPA에서는 값 타입 컬렉션에 변경이 발생하면 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션에 있는 모든 값을 DB에 다시 저장하게 된다. 이는 매우 위험하므로, 실무에서는 값 타입 컬렉션 대신 1대다 관계 + 영속성 전이 + 고아 객체 제거 기능을 사용하는 것을 추천한다고 한다.

Reference

  • 자바 ORM 표준 JPA 프로그래밍 (김영한)

0개의 댓글