[JPA] 값 타입

olsohee·2023년 7월 18일
0

JPA

목록 보기
8/21
post-custom-banner

1. JPA의 데이터 타입

JPA의 데이터 타입을 크게 분류하면 엔티티 타입과 값 타입으로 나뉠 수 있다.
엔티티 타입은 @Entity로 정의하는 객체이고, 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말한다.
엔티티 타입은 식별자를 통해 지속적으로 그 값들을 추적할 수 있지만, 값 타입은 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없다. 그리고 값 타입은 기본값 타입, 임베디드 타입, 컬렉션 값 타입으로 나뉜다.

  • 엔티티 타입

    • @Entity로 정의하는 객체

    • 데이터가 변해도 식별자를 통해 지속적으로 추적 가능

  • 값 타입

    • int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체

    • 식별자 없고 값만 있으므로 변경시 추적 불가능

    • 생명주기를 엔티티 의존

      • 기본값 타입: 자바 기본 타입(int), 래퍼 클래스(Integer), String처럼 자바가 제공하는 기본 데이터 타입

      • 임베디드 타입: JPA에서 사용자가 직접 정의한 값 타입

      • 컬렉션 값 타입: 하나 이상의 값 타입을 저장할 때 사용


2. 기본값 타입

@Entity
public class Member {

	@Id @GeneratedValue
    private Long id;
    
    private String name;
    private int age;
}
  • 위 코드에서 String, int가 기본값 타입이다.

  • Member 엔티티는 id라는 식별자 값을 가지고 생명주기도 있지만, 값 타입인 name, age는 식별자 값도 없고 생명주기도 Member 엔티티에 의존한다. 따라서 Member 엔티티를 제거하면 name, age 값도 제거된다.

  • 값 타입은 공유하면 안된다. 예를 들어, 다른 회원 엔티티의 이름을 변경한다고 해서 나의 이름까지 변경되면 안된다.

  • int, double 같은 자바의 기본 타입은 절대 공유되지 않고 항상 값이 복사된다. 따라서 값 타입으로 사용하기에 안전하다. 그러나 Integer, String 같은 클래스는 공유가 가능하고 참조 값이 전달된다. 따라서 여러 엔티티 간에 공유되지 않도록 주의해야 한다.


3. 임베디드 타입

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private int age;
    
    // 근무 기간
    @Temporal(TemporalType.DATE)
    private Date startDate;
    
    @Temporal(TemporalType.DATE)
    private Date endDate;

    // 집 주소
    private String city;
    private String street;
    private String zipcode;
}
  • 위 코드에서 '회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다.'

  • 그러나 위와 같은 설명보다 '회원 엔티티는 이름, 근무 기간, 집 주소를 가진다'고 설명하는 것이 더 명확하다.

  • 즉 회원이 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력이 떨어진다. 대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해질 것이다. 따라서 다음과 같이 임베디드 타입을 사용하면 된다.

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private int age;

    // 근무 기간
    @Embedded
    private Period workPeriod;

    // 집 주소
    @Embedded
    private Address homeAddress;
}
@Embeddable
public class Period {

    @Temporal(TemporalType.DATE)
    private Date startDate;

    @Temporal(TemporalType.DATE)
    private Date endDate;
}
@Embeddable
public class Address {

    private String city;
    private String street;
    private String zipcode;
    
    public boolean isWork(Date date) {
    	// 값 타입을 위한 메소드 정의 가능
    }
}
  • startDate, endDate를 합해서 Period 클래스를 만들고, city, street, zipcode를 합해서 Address 클래스를 만들었다.

  • 임베디드 타입을 사용하려면 다음과 같이 어노테이션을 붙이면 된다.

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

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

  • 기본 생성자가 필수이다. 이때 기본 생성자는 public 또는 protected로 설정해야 하는데 protected로 설정하는 것이 더 안전하다. JPA가 이런 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플랙션 같은 기술을 사용할 수 있도록 지원해야 하기 때문이다.

  • 임베디드 타입을 사용함으로써 새로 정의한 값 타입들을 재사용할 수 있으며, 응집도도 높다.

  • Period.isWork()처럼 해당 값 타입에서만 사용하는 의미 있는 메소드도 만들 수 있다.

  • 임베디드 타입도 값 타입이다. 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티에 생명주기를 의존한다.

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

  • 임베디드 타입은 엔티티의 값일 뿐이다. 임베디드 타입의 데이터베이스 테이블 매핑은 값이 속한 엔티티의 테이블에 매핑된다. 즉 위 예제의 경우 회원 테이블은 id, age, name, startDate, endDate, city, street, zipcode 칼럼을 가진다. 즉 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블의 형태는 같다.

  • 임베디드 타입 덕분에 객체와 테이블을 세밀하게 매핑하는 것이 가능하다. 따라서 잘 설계한 ORM 애플리케이션은 임베디드 타입을 적절하게 활용하여 매핑한 테이블 수보다 클래스 수가 더 많다.

임베디드 타입과 연관관계

@Entity
public class Member {

    @Embedded
    Address address;

    @Embedded
    PhoneNumber phoneNumber;
}
@Embeddable
public class Address {

    private String city;
    private String street;
    
    @Embedded
    Zipcode zipcode; // 임베디드 타입 포함
}
@Embeddable
public class Zipcode {

    private String zip;
    private String plusFour;
}
@Embeddable
public class PhoneNumber {

    private String areaCode;
    private String localNumber;
    
    @ManyToOne
    PhoneServiceProvider provider; // 엔티티 참조
}
@Entity
public class PhoneServiceProvider {

    @Id
    private String name;
    ...
}
  • 임베디드 타입은 임베디드 타입을 포함할 수 있으며, 엔티티도 포함할 수 있다.

@AttributeOverride: 속성 재정의

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    
    // 집 주소
    @Embedded
    Address homeAddress;
    
    // 회사 주소
    @Embedded
    Address companyAddress;
}
  • 만약 회원에게 주소가 하나 더 필요하면 어떻게 해야 할까? 위와 같이 매핑하면 회원 테이블에 매핑하는 칼럼명이 중복된다.

  • 따라서 이런 경우 다음과 같이 @AttributeOverride를 사용해서 매핑정보를 재정의하면 된다.

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    
    // 집 주소
    @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;
}

임베디드 타입과 null

  • 임베디드 타입이 null이면 매핑한 칼럼 값도 모두 null이 된다. 예를 들어 회원 엔티티의 임베디드 타입인 address가 null이면 city, street, zipcode 칼럼 값은 모두 null이 된다.

4. 값 타입과 불변 객체

member1.setAddress(new Address("oldCity"));

Address address = member1.getAddress();
member2.setAddress(address);

// 회원2 주소 변경 -> 회원1 주소까지 변경
member2.getAddress().setCity("newCity");
  • 회원2에 새로운 주소를 할당하려고 회원1의 주소를 그대로 참조해서 사용했다. 그 결과 회원2의 주소만 "newCity"로 변경되는 것이 아니라, 회원1의 주소도 "nerwCity"로 변경된다. 회원1의 address와 회원2의 address가 같은 인스턴스를 참조하기 때문이다.

  • 이렇게 공유 참조로 인해 발생하는 버그는 찾아내기 어렵다. 이렇게 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용(side effect)이라 한다.

  • 이런 부작용을 막으려면 값을 복사해서 사용하면 된다.

member1.setAddress(new Address("oldCity"));

// 회원1의 주소 값을 복사해서 새로운 인스턴스 생성
Address address1 = member1.getAddress();
Address address2 = new Address(address1.getCity(), address1.getStreet(), address1.getZipcode());

member2.setAddress(address2);

// 회원2 주소 변경 -> 부작용 발생X
member2.getAddress().setCity("newCity");
  • 이제 회원1의 address와 회원2의 address는 같은 인스턴스를 참조하지 않는다.

  • 이처럼 항상 값을 복사해서 새로운 인스턴스를 생성하면 공유 참조로 발생하는 부작용을 피할 수 있다.

  • 그러나 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다. 자바의 기본 타입에 값을 대입하면 값이 복사되어 안전하다. 그러나 객체 타입은 참조 값이 대입되어 공유 참조를 피할 수 없다.

  • 따라서 공유 참조로 인한 부작용을 막기 위해 값 타입을 불변 객체로 만들어야 한다. 객체를 불변하게 만들면 값을 수정할 수 없기 때문이다. 결론적으로 값 타입은 될 수 있으면 불변 객체로 설계해야 한다.

  • 불변 객체를 구현하는 방법 중 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않는 것이다. 만약 값을 수정해야 하면 setter를 사용하지 않고, 아예 새로운 인스턴스를 생성하고 갈아끼워야 한다.

// 불변객체로 설계하면 setter 사용 불가
member.getAddress().setCity("newCity"); 

// 새로운 객체를 생성해 다시 값을 대입
Address address = member.getAddress();
member.setAddress(new Address("newCity", address.getStreet(), address.getZipcode());

5. 값 타입의 비교

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

    • 동일성 비교: 인스턴스 참조 값을 비교, == 사용

    • 동등성 비교: 인스턴스 값을 비교, equals() 사용

Address a = new Address("서울시", "종로구", "1번지");
Address a = new Address("서울시", "종로구", "1번지");
  • 위 코드에서 a == b로 동일성 비교를 하면 서로 다른 인스턴스이므로 결과는 거짓이다.

  • 그러나 우리가 기대하는 결과가 아니다. 값 타입은 서로 다른 인스턴스여도 그 안에 값이 같으면 같은 것으로 봐야 한다.

  • 따라서 값 타입을 비교할 때는 equals() 메소드를 사용해서 동등성 비교를 해야하고, 이때 Address의 equals() 메소드를 반드시 재정의해야 한다.


6. 값 타입 컬렉션

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

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    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<>();

    @ElementCollection
    @CollectionTable(name = "address",
            joinColumns = @JoinColumn(name = "member_id"))
    private List<Address> addressHistory = new ArrayList<>();
}
  • 데이터베이스의 테이블은 컬럼 안에 컬렉션을 포함할 수 없다. 따라서 별도의 테이블을 만들고 일대다 관계가 된다.

  • 값 타입 컬렉션을 사용하기 위해서는 @ElementCollection 어노테이션을 사용한다.

  • 그리고 컬렉션이 저장될 별도의 테이블을 매핑하기 위해 @CollectionTable 어노테이션을 사용하고 @JoinColumn을 통해 외래키를 매핑한다.

  • favoriteFoods처럼 값으로 사용하는 컬럼이 하나이면 @Column을 사용하여 컬럼명을 지정할 수 있다.

  • 값 타입 컬렉션을 매핑하는 테이블은 모든 칼럼을 묶어서 기본 키를 구성한다. 따라서 데이터베이스의 기본 키 제약 조건으로 인해 컬럼에 null을 입력할 수 없고, 같은 값을 저장할 수 없다는 한계가 있다.

값 타입 컬렉션 사용: 조회

Member member = new Member();

// 임베디드 값 타입
member.setHomeAddress(new Address("city", "street", "10000"));

// 기본값 타입 컬렉션
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("고기");

// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("oldCity1", "oldStreet1", "12010"));
member.getAddressHistory().add(new Address("oldCity2", "oldStreet2", "12121"));

em.persist(member);
  • 위 예제에서 em.persist(member)를 통해 데이터베이스에 실행되는 insert sql은 다음과 같다.

    • member: insert sql 1번

    • member.homeAddress: 컬렉션이 아닌 임베디드 값 타입이므로 회원 테이블에 member를 저장하는 sql에 포함된다.

    • member.favoriteFood: insert sql 3번

    • member.addressHistory: insert sql 2번

  • 따라서 em.persist(member) 한 번 호출로 영속성 컨텍스트를 플러시할 때 총 6번의 insert sql이 실행된다.

  • 이때 Member 테이블 뿐만 아니라 favorite_foods 테이블과 address 테이블에도 sql이 실행된다. 즉 값 타입 컬렉션은 그 생명주기를 엔티티가 관리한다. 즉 값 타입 컬렉션은 영속성 전이와 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.

Member findMember = em.find(Member.class, 1L);
  • 값 타입 컬렉션도 조회할 때 패치 전략을 사용할 수 있는데 기본이 LAZY이다.

  • 따라서 위 예제 코드에서 회원을 조회할 때 select sql은 member 테이블에만 실행되고, favorite_foods 테이블과 address 테이블에는 회원의 favoriteFoodaddressHistory가 사용될 때 sql이 실행된다.

값 타입 컬렉션 사용: 수정

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

// 임베디드 값 타입 수정
member.setHomeAddress(new Address("newCity", "newStreet", "newZipcode"));

// 기본값 타입 컬렉션 수정
member.getFavoriteFoods().remove("치킨"); // 기존 값 제거
member.getFavoriteFoods().add("탕수육"); // 새로운 값 추가

// 임베디드 값 타입 컬렉션 수정
member.getAddressHistory().remove(new Address("oldCity1", "oldStreet1", "12010")); // 기존 값 제거
member.getAddressHistory().add(new Address("oldCity3", "oldStreet3", "12111")); // 새로운 값 추가
  • 임베디드 값 타입 수정: 임베디드 값 타입을 수정할 때는 setter를 사용하지 않고, 아예 새로운 인스턴스를 생성하고 갈아끼워야 한다. 그리고 homeAddress 임베디드 값 타입은 member 테이블과 매핑했으므로 member 테이블만 update sql이 실행된다.

  • 기본값 타입 컬렉션 수정: 기존 값을 remove()로 제거하고 새로운 값을 추가한다.

  • 임베디드 값 타입 컬렉션 수정: 기존 값을 remove()로 제거하고 새로운 값을 추가한다. 이때 기존 값을 제거하기 위해 값 타입의 인스턴스 참조 값 비교가 아닌 값 비교로 equals()를 반드시 구현해야 한다.

값 타입 컬렉션의 제약사항

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

  • 따라서 이런 문제로 JPA 구현체들은 값 타입 컬렉션에 변경사항이 있을시, 값 타입 컬렉션이 매핑된 테이블에서 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션 객체에 있는 모든 값들을 데이터베이스에 다시 저장한다.

  • 예를 들어 식별자가 100번인 회원이 관리하는 주소 값 타입 컬렉션을 변경하면, 값 타입 컬렉션이 매핑된 테이블에서 member_id가 100인 모든 칼럼을 삭제하고, 회원의 값 타입 컬렉션 객체에 있는 모든 값들을 다시 저장한다.

// 관련된(member_id가 100인) 모든 칼럼 delete
delete from address where member_id=100;
 
// member의 값 타입 컬렉션 객체의 값들 insert 
insert into address (member_id, city, street, zipcode) values (100, "city1", ...);
insert into address (member_id, city, street, zipcode) values (100, "city2", ...);
  • 따라서 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에, 새로운 엔티티를 만들고 일대다 관계로 설정하는 것이 좋다. 여기에 추가로 영속성 전이(cascade) + 고아 객체 제거(orphan remove)를 적용하면 값 타입 컬렉션처럼 사용할 수 있다.
@Entity
public class AddressEntity {
 
	@Id @GeneratedValue
    private Long id;
    
    @Embedded
    private Address address;
}
@Entity
public class Member {
 
 	...
    
    // 일대다 단방향 매핑
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "memder_id")
	private List<AddressEntity> addressHistory = new ArrayList<>(); 
}

7. 정리

엔티티 타입과 값 타입을 정리하면 다음과 같다.

엔티티 타입의 특징

  • 식별자가 있고, 식별자로 구분할 수 있다.

  • 생명주기가 있다.

  • 참조 값을 공유할 수 있다. 이를 공유 참조라 한다. 예를 들어 회원 엔티티가 있으면 다른 엔티티에서 회원 엔티티를 참조할 수 있다.

값 타입의 특징

  • 식별자가 없다.

  • 생명주기를 엔티티에 의존한다. 따라서 의존하는 엔티티가 제거되면 값 타입도 같이 제거된다.

  • 공유하지 않는 것이 안전하다. 엔티티 타입과 달리 값 타입은 식별자가 없기 때문에 변경시 추적이 힘들다. 따라서 공유하지 않고 값을 복사해서 사용해야 한다.

  • 오직 하나의 주인 엔티티만이 관리해야 한다.

  • 불변 객체로 만드는 것이 안전하다.

값 타입은 정말 값 타입이라 판단될 때 사용해야 한다. 만약 식별자가 필요하고 지속해서 값을 추적하고 구분하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.

profile
공부한 것들을 기록합니다.
post-custom-banner

2개의 댓글

comment-user-thumbnail
2023년 7월 18일

글 잘 봤습니다, 감사합니다.

답글 달기
comment-user-thumbnail
2023년 7월 18일

많은 도움이 되었습니다, 감사합니다.

답글 달기