[JPA 기본편] 8. 값 타입

HJ·2024년 2월 24일

JPA 기본편

목록 보기
8/10
post-thumbnail

김영한 님의 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의를 보고 작성한 내용입니다.


1. JPA 의 데이터 타입 분류

[ 엔티티 타입 ]

@Entity 로 정의하는 객체이며, 데이터가 변해도 식별자로 지속해서 추척이 가능합니다. 예를 들어, 회원 엔티티의 이름을 변경해도 식별자로 인식이 가능합니다.

[ 값 타입 ]

int, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 의미합니다. 식별자가 없고 값만 있기 때문에 변경했을 때 추척이 불가능합니다.

값 타입은 또 다시 기본값 타입, 임베디드 타입, 컬렉션 값 타입으로 분류할 수 있으며, 모든 값 타입은 값 타입을 소유한 엔티티의 생명주기에 의존합니다.

[ 기본값 타입 ]

String 이나 자바 기본 타입인 int 나 double, 래퍼 클래스인 Integer 나 Long 등이 기본값 타입에 해당합니다.

기본값 타입은 생명주기를 엔티티에 의존합니다. 예를 들어 회원을 삭제하면 내부의 이름과 나이 필드도 함께 삭제됩니다.

또 값타입은 공유를 하면 안되는데 회원 이름을 변경한다고 해서 다른 회원의 이름이 변경되면 안됩니다.

int, double 과 같은 기본 타입은 항상 값을 복사하기 때문에 a = b 이후에 a 를 변경해도 b 는 변경되지 않습니다.

Integer 나 String 과 같은 클래스는 레퍼런스를 사용하기 때문에 공유는 가능하지만 변경은 불가능합니다.




2. 임베디드 타입

새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA 에서는 이를 임베디드 타입이라고 합니다. 임베디드 타입은 int, String 과 같은 엔티티 내부의 값 타입입니다.

위의 그림은 값 타입을 사용하기 전과 후를 나타낸 그림입니다. startData, endDate 를 Period 라는 임베디드 타입을 만들고, 이를 Member 내부에서 사용하는 방식입니다.

JPA 에서 값 타입을 정의하는 곳에 표시하는 @Embeddable 과 값 타입을 사용하는 곳에 표시하는 @Embedded 를 사용하는데 임베디드 타입 내부에 기본 생성자는 필수입니다.

임베디드 타입은 재사용이 가능하고 응집도가 높아 Period.isWork() 처럼 해당 값 타입만 사용하는 의미있는 메서드를 만들 수 있습니다.

임베디드 타입을 사용할 때 매핑만 해주면 임베디드 타입을 사용하지 않을 때와 테이블의 구조는 동일합니다.


[ @AttributeOverrides ]

한 엔티티에서 같은 값 타입을 사용하면 컬럼명이 중복되는데 @AttributeOverrids 와 @AttributeOverride 를 사용해서 컬러명 속성을 재정의 할 수 있습니다.

public class Member {
    @Embedded
    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 {
    ...
    @Embedded
    private Zipcode zipcode;    // 임베디드 타입 포함
}

@Embeddable
public class Zipcode {
    private String area;
    ...
}
// ------------------------------------------
@Embeddable
public class PhoneNumber {
    ...
    @ManyToOne
    private PhoneEntity phoneEntity;    // 엔티티 참조
}

@Entity
public class PhoneEntity {
    @Id @GeneratedValue
    private Long id;
    ...
}

[ null ]

임베디드 타입에 null 을 넣게되면 임베디드 타입에 해당하는 모든 컬럼들은 null 로 저장되게 됩니다.

public class Member {
    ...
    @Embedded
    private Address homeAddress = null;
}




3. 값 타입과 불변 객체

3-1. 값 타입

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 side effect 가 발생할 수 있어 위험합니다.

[ 값 타입 공유 ]

Address address = new Address("city", "street", "10000");

Member member1 = new Member();
member1.setName("member1");
member1.setAddress(address);
em.persist(member1);

Member member2 = new Member();
member2.setName("member2");
member2.setAddress(address);
em.persist(member2);

member1.getAddress().setCity("newCity");

member1 과 member2 가 같은 address 를 공유하는 상황에서 member1 을 통해 address 를 변경하면 insert 이후에 member1 과 memebr2 모두에게 update 쿼리가 날라가게 됩니다.
그래서 결국 member1 의 주소를 변경했지만 member2 의 주소도 newCity 로 변경됩니다.


[ 값 타입 복사 ]

Address address = new Address("city", "street", "10000");

Member member1 = new Member();
member1.setName("member1");
member1.setAddress(address);
em.persist(member1);

Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());

Member member2 = new Member();
member2.setName("member2");
member2.setAddress(copyAddress);
em.persist(member2);

member1.getAddress().setCity("newCity");

값 타입 자체를 공유하는 것이 아닌 값을 복사해서 위처럼 사용하는 것이 올바른 방법입니다.


3-2. 불변객체

자바 기본 타입은 값을 대입하면 값을 복사합니다. 하지만 임베디드 타입처럼 직접 정의한 값 타입은 자바 기본 타입이 아닌 객체 타입이고, 객체 타입은 참조 값을 복사해서 넣게 됩니다.

객체 타입을 수정할 수 없게 만들면 함께 수정되는 부작용을 원천 차단시킬 수 있습니다. 그래서 값 타입은 불변 객체로 설계해야 합니다.

불변 객체란 생성 시점 이후 절대 값을 변경할 수 없는 객체입니다. 생성자로만 값을 생성하고 setter 를 만들지 않으면 됩니다. ( Integer, String 이 대표적인 불변객체입니다. )
만약 값을 수정하고 싶다면 new 키워드를 통해 새로운 객체를 만들어 사용해야 합니다.


3-3. 값 타입 비교

예를 들어, int 형 a 와 b 가 동일한 값을 가졌다면 == 비교를 했을 때 동일하다고 판단됩니다. 하지만 Address 의 경우 동일한 값을 가져도 == 비교를 했을 때 false 가 나오게 됩니다.

== 비교는 인스턴스의 참조 값을 비교하는 동일성 비교이기 때문에 인스턴스의 값을 비교하는 equals() 를 이용한 동등성 비교를 해야합니다.

즉, 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야하며, equals() 는 기본이 == 비교이기 때문에 값 타입의 equals() 메소드를 모든 필드를 사용하도록 재정의하는 것이 필요합니다.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Address address = (Address) o;
    return Objects.equals(city, address.city) 
            && Objects.equals(street, address.street) 
            && Objects.equals(zipcode, address.zipcode);
}

@Override
public int hashCode() {
    return Objects.hash(city, street, zipcode);
}

equals() 와 hashCode() 눈 기본적으로 생성해주는 것을 사용하는 것이 좋습니다.




4. 값 타입 컬렉션

4-1. 개념

값 타입 컬렉션이란 값 타입을 컬렉션에 담아서 사용하는 것을 말하는데 값 타입을 하나 이상 저장할 때 사용하며, @ElementCollection, @CollectionTable 을 사용합니다.

값 타입이 하나만 있을 때는 엔티티의 필드로 넣으면 구현할 수 있었는데, 컬렉션을 사용하면 일대다 개념이기 때문에 RDB 에서 테이블 안에 컬렉션을 담을 수 없습니다.

그래서 값 타입 컬렉션에 대해서 별도의 테이블을 사용해야 합니다. 값 타입 테이블에 id 같은 개념을 넣어서 PK 로 쓰면 엔티티가 되어 버리기 때문에 값들을 묶어서 PK 로 사용해야 합니다.


public class Member {
    ...
    @Embedded
    private Address homeAddress;

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

@CollectionTable 은 생성될 테이블명을 지정합니다. 또 외래키를 지정할 수 있는데 위에서는 MEMBER_ID 를 외래키로 지정하였습니다.


4-2. 사용 예시

[ 저장 ]

Member member = new Member();
member.setName("member1");
member.setHomeAddress(new Address("city", "street", "10000"));

member.getAddressHistory().add(new Address("old1", "street", "10000"));
member.getAddressHistory().add(new Address("old2", "street", "10000"));

em.persist(member);

4-1 에서 지정한 것처럼 ADDRESS 라는 이름으로 테이블이 하나 생성되었고, 값 타입 컬렉션에 담긴 정보들은 ADDRESS 테이블에 저장된 것을 확인할 수 있습니다.

또 member 만 persist() 했는데 값 타입 컬렉션은 자동으로 저장되었습니다. 왜냐하면 값 타입 컬렉션도 스스로 라이프 사이클을 가지지 않기 때문에 라이프 사이클이 member 에 소속되어 다른 테이블임에도 함께 저장된 것입니다.


[ 조회 ]

Member findMember = em.find(Member.class, member.getId());
System.out.println("----------------------------findMember 반환");
for (Address ad : findMember.getAddressHistory()) {
    System.out.println("history = " + ad.getCity() + ", " 
                                    + ad.getStreet() + ", " 
                                    + ad.getZipcode());
}
select
    m1_0.MEMBER_ID,
    m1_0.city,
    m1_0.street,
    m1_0.zipcode,
    m1_0.name 
from
    Member m1_0 
where
    m1_0.MEMBER_ID=?
----------------------------findMember 반환
select
    ah1_0.MEMBER_ID,
    ah1_0.city,
    ah1_0.street,
    ah1_0.zipcode 
from
    ADDRESS ah1_0 
where
    ah1_0.MEMBER_ID=?

member 에 소속된 homeAddress 에 해당하는 값 타입은 함께 조회되었지만, 값 타입 컬렉션은 조회되지 않았습니다. 이 말은 값 타입 컬렉션은 지연로딩이라는 뜻입니다.
그래서 findMember 이후에 값을 사용할 때 쿼리가 나가는 것을 확인할 수 있습니다.


[ 수정 ]

Member findMember = em.find(Member.class, member.getId());
List<Address> addressHistory = findMember.getAddressHistory();
addressHistory.remove(new Address("old1", "street", "10000"));
addressHistory.add(new Address("newCity1", "street", "10000"));

remove() 를 통해 제거할 때 equals() 를 사용하기 때문에 이전에 했던 것처럼 equals() 와 hashCode() 를 제대로 구현해야 합니다.

또 값 타입은 불변 객체여야 하기 때문에 수정할 때 set 을 사용할 수 없으므로 새로운 값 타입 객체를 생성해서 사용합니다.

컬렉션의 값만 변경해도 JPA 가 DB 에 쿼리를 날려줍니다. 그래서 값 타입 컬렉션은 영속성 전이와 고아 객체 제거 기능을 가진다고 볼 수 있습니다.


4-3. 값 타입 컬렉션 제약 사항

값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장합니다.

Hibernate: 
    delete from
        ADDRESS 
    where
        MEMBER_ID=?
Hibernate: 
    insert into
        ADDRESS (MEMBER_ID, city, street, zipcode) 
    values (?, ?, ?, ?)
Hibernate: 
    insert into
        ADDRESS (MEMBER_ID, city, street, zipcode) 
    values (?, ?, ?, ?)

위의 수정 코드의 SQL 로그인데 delete 를 보면 해당 member 에 해당하는 모든 값을 지우는 것을 확인할 수 있고, 하나의 address 만 추가했는데 두 개의 insert 문이 실행되었습니다.

기존 데이터 2개 중 하나만 지웠기 때문에 하나가 남아있게 되고, 신규 insert 와 함께 기존 insert 1번이 실행된 것입니다.


저장할 때도 주의해야 할 점이 있는데 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 합니다. null 도 안되고, 중복 저장도 안됩니다.


@Entity
public class AddressEntity {
    @Id @GeneratedValue
    private Long id;
    private Address address;
}
// -----------------------------
@Entity
public class Member {
    ...
    // 일대다 단방향 매핑
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<AddressEntity> addressHistory = new ArrayList<>();
}

그래서 실무에서는 상황에 따라 값 타입 컬렉션 대신 일대다 관계를 고려해서 엔티티를 만들고, 여기서 값 타입을 사용하는 것이 좋습니다. 영속성 전이와 고아 객체 제거 기능을 사용해서 값 타입 컬렉션처럼 사용하면 됩니다.

0개의 댓글