값 타입(기본 값 타입, 임베디드 타입, 컬렉션 타입)

땡글이·2023년 3월 7일
0

JPA

목록 보기
2/9

기본 값 타입

기본 값 타입은 말 그대로, 기본으로 제공되는 값 타입을 의미한다. 예시로는 다음과 같은 타입들이 있다.

  • 자바기본타입 (int, double, ...)
  • 래퍼 클래스 (Integer, Long, ...)
  • String

기본 값 타입의 특징으로는 생명 주기를 엔티티에 의존한다는 것이다. 예를 들면, 회원 엔티티를 삭제하면 이름, 나이와 같은 필드들도 같이 삭제가 된다. 또한 값 타입이 변경될 시에는 공유되지 않아야 한다라는 특징도 가진다. 즉, A회원의 이름을 변경했을 때 B회원의 이름은 바뀌지 않아야 한다는 것이다.

참고로, 자바 기본 타입은 주소를 복사하는 것이 아니라 항상 값을 복사를 한다. 하지만 래퍼 클래스(Integer, Long ..) 은 공유가 가능하지만, 변경이 불가능하다. 새로운 인스턴스를 만들어야 한다.

임베디드 타입

이 부분이 조금 중요하다. 물론 성능의 개선을 이뤄내는 것은 아니지만, 가독성이나 타입의 재사용에 많은 도움이 될 수 있다.

임베디드 타입은 개발자가 제공되는 값 타입과는 다른 새로운 값 타입을 만드는 것이다. JPA에서는 이 타입을 임베디드 타입이라고 한다. 주로 기본값 타입을 모아서 만들어서 복합 값 타입이라고도 한다.

예를 들면, 회원 엔티티에서 주소 정보를 가진다고 가정해보자. 주소에는 우편번호, 상세주소와 같이 여러 값들이 필요하다. 하지만, "주소"를 표현하기 위해 여러 타입들이 정의되는 것이 코드로 유지보수할 때에는 불편할 수 있기에 임베디드 타입으로 새롭게 정의해서 개발자들이 유지보수하기 쉽게, 개발하기 편하게 나온 타입이다.

임베디드 타입 사용 방법

우선 임베디드 타입을 사용할 때는 기본 생성자가 필수이다.

왜 기본생성자가 필수일까?
기본 생성자가 없으면 JPA가 프록시 객체를 만들 수 없어 문제가 발생할 수 있고, 또한 JPA는 리플렉션을 사용하여 객체를 생성하기 때문에 기본 생성자가 필요하다.

값 타입을 정의하는 클래스에서는 @Embeddable 어노테이션을 붙여줘야 하고, 값 타입을 사용하는 필드에서는 @Embedded 어노테이션을 붙여줘야 한다. 아래 예시를 보면, 바로 이해가 될 것이다.

@Embeddable
public class Address {

    private String city;
    private String street;
    private String zipcode;

    public Address() {
    }

    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}
@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    private String username;


    // 주소 Address
    @Embedded
    private Address homeAddress;
}

위처럼 설계하면, 실제 데이터베이스에서 Member 테이블에는 id, username, city, street, zipcode 필드를 가지게 된다.

즉, 엔티티 내에 직접 필드를 정의하는 것과 결과적으로는 차이가 없지만, 임베디드 타입을 사용함으로써 가독성이 좋아지고, 재사용성이 좋아진다는 것을 확인할 수 있다.

@AttributeOverride : 속성 재정의

임베디드 타입에 정의한 매핑정보를 재정의하려면, 엔티티에 @AttributeOverride 를 사용하면 된다. 예를 들어 위의 회원에게 주소가 하나 더 필요하다면 어떻게 해야할까?

@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    private String username;


    // 주소 Address
    @Embedded
    private Address homeAddress;
    
    @Embedded
    private Address companyAddress;
}

바로 위 예제처럼 작성하게되면, 필드 이름이 중복된다. (city, street, zipcode 필드가 중복됨)

이럴 때 @AttributeOverride 어노테이션을 이용해서 아래처럼 해결하면 된다.

@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    private String username;


    @Embedded
    private 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"))
    })
    private Address companyAddress;
}

이렇게 되면 실제로 DB에서 Member 테이블에는 city, street, zipcode, COMPANY_CITY, COMPANY_STREET, COMPANY_ZIPCODE 필드가 저장이 됨으로써 필드 중복을 피할 수 있다.

임베디드 타입이 null 이면, 임베디드 타입에 포함되는 컬럼들(city, street, zipcode)은 모두 null이 된다.

임베디드 타입의 장점

임베디드 타입의 장점을 정리하면 다음과 같다.

  • 재사용성
  • 높은 응집도
  • Period.isWork() 처럼 해당 값 타입만 사용하는 의미 있는 메서드를 만들 수 있다.
  • 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티에 생명주기를 의존한다.

앞에서도 얘기했지만 DB에서는 임베디드 타입을 쓰는 것과 안쓰는것에 차이가 없다.

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

객체 타입과 불변객체

임베디드 타입과 같이 객체 타입은 불변 객체로 사용해야 한다. 불변객체는 생성 시점 이후 값(필드)을 절대 변경할 수 없는 객체를 의미한다.

왜 불변객체로 만들어야할까? 예제를 봐보자.

// 잘못된 코드 (불변객체 X)
Address address = new Address("city", "street", "1000");

Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(address);
em.persist(member2);

member2.getHomeAddress().setCity("newCity");

tx.commit();

목적은 member2 객체의 city 필드만 변경시키고 싶었지만, 결과적으로는 member1의 city 필드도 변경되었다. update 쿼리도 2번 나가는 것을 확인할 수 있다.

이유는 임베디드 타입이 객체타입이라서 Address 인스턴스 하나에 Member 인스턴스 2개가 공유 참조를 하고 있었던 것이다.

이 문제를 해결하려면, Address 객체를 불변 객체로 만들어야 한다.

// Address.java
// Setter 삭제
@Embeddable
@Access(AccessType.FIELD)
public class Address {

    private String city;
    private String street;
    private String zipcode;

    public Address() {
    }

    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    public String getZipcode() {
        return zipcode;
    }
    
    @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.getCity())
                && Objects.equals(street, address.getStreet())
                && Objects.equals(zipcode, address.getZipcode());
    }

    @Override
    public int hashCode() {
        return Objects.hash(getCity(), getStreet(), getZipcode());
    }
}

@Access(AccessType.FIELD) 를 넣은 이유

JPA는 필드 접근, 프로퍼티 접근 2가지 방법을 이용해 엔티티의 데이터에 접근한다. 하지만, @Id 어노테이션이 붙은 필드가 없는 클래스는 데이터에 접근할 때 프로퍼티로 접근해야할지, 필드로 접근해야할지 명확히 판단되지 않아서 인텔리제이에서 setter를 지우게 되면, getter에 에러 메시지(For property-based access both setter and getter should be present) 가 띄게 된다.
그렇기에 @Access 어노테이션을 활용해 필드 접근을 하도록 구현해준다. 참고로, @Id 어노테이션이 있는 클래스에서는 모든 데이터에 접근할 때 필드 접근을 하도록 자동으로 세팅된다.

// 올바른 코드 (불변객체O)
Address address = new Address("city", "street", "1000");

Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(address);
em.persist(member2);

// member2.getHomeAddress().setCity("newCity");
member2.setHomeAddress(new Address("newCity", address.getStreet(), address.getZipcode()));

tx.commit();

Address 를 불변 객체로 만듦으로써 부작용(side-effect) 를 막을 수 있게 되었다. setter를 삭제해도 되고, setter를 private으로 만들어도 된다.

값 타입 컬렉션


DB에서는 컬렉션을 저장할 수가 없다. 따라서 컬렉션을 저장하기 위한 별도의 테이블이 필요하다. 위와 그림처럼, Member 클래스에서 값 타입을 컬렉션으로 가지게 되면, DB에서는 별도의 테이블을 만들어 사용한다.

값 타입 컬렉션 사용방법

@ElementCollection 어노테이션과 @CollectionTable 어노테이션을 활용해서 만들면 된다. @ElementCollection 은 해당 타입이 컬렉션 타입이라는 것을 명시하는 것이고, @CollectionTable 은 DB에서 만들 새로운 테이블에 대한 내용을 정의하는 것이다.

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

값 타입 컬렉션 저장 예제

@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    private String username;

    @Embedded
    private Address homeAddress;

    @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<>();
}
Address address = new Address("homeCity", "street", "10000");

Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);

member1.getFavoriteFoods().add("치킨");
member1.getFavoriteFoods().add("족발");
member1.getFavoriteFoods().add("피자");

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

em.persist(member1);

em.flush();
em.clear();


tx.commit();
// 실행결과
Hibernate: 
    /* insert org.example.domain.Member
        */ insert 
        into
            Member
            (city, street, zipcode, username, endDate, startDate, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row org.example.domain.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row org.example.domain.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row org.example.domain.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row org.example.domain.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row org.example.domain.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)

값 타입 컬렉션 조회 예제

값 타입 컬렉션들은 모두 지연 로딩 방식으로 조회한다.

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

Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);

member1.getFavoriteFoods().add("치킨");
member1.getFavoriteFoods().add("족발");
member1.getFavoriteFoods().add("피자");

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

em.persist(member1);

em.flush();
em.clear();

System.out.println("============ START ============");
Member findMember = em.find(Member.class, member1.getId());


tx.commit();
...
============ START ============
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_6_0_,
        member0_.city as city2_6_0_,
        member0_.street as street3_6_0_,
        member0_.zipcode as zipcode4_6_0_,
        member0_.username as username5_6_0_,
        member0_.endDate as enddate6_6_0_,
        member0_.startDate as startdat7_6_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?

지연 로딩으로 조회되므로, AddressHistory 컬렉션이나 FavoriteFoods 컬렉션은 조회되지 않는 것을 확인할 수 있다.

...

System.out.println("============ START ============");
Member findMember = em.find(Member.class, member1.getId());

List<Address> addressHistory = findMember.getAddressHistory();
for (Address address1 : addressHistory) {
    System.out.println("address = " + address1.getCity());
}

Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String favoriteFood : favoriteFoods) {
    System.out.println("favoriteFood = " + favoriteFood);
}
tx.commit();
============ START ============
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_6_0_,
        member0_.city as city2_6_0_,
        member0_.street as street3_6_0_,
        member0_.zipcode as zipcode4_6_0_,
        member0_.username as username5_6_0_,
        member0_.endDate as enddate6_6_0_,
        member0_.startDate as startdat7_6_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?
Hibernate: 
    select
        addresshis0_.MEMBER_ID as member_i1_0_0_,
        addresshis0_.city as city2_0_0_,
        addresshis0_.street as street3_0_0_,
        addresshis0_.zipcode as zipcode4_0_0_ 
    from
        ADDRESS addresshis0_ 
    where
        addresshis0_.MEMBER_ID=?
address = old1
address = old2
Hibernate: 
    select
        favoritefo0_.MEMBER_ID as member_i1_4_0_,
        favoritefo0_.FOOD_NAME as food_nam2_4_0_ 
    from
        FAVORITE_FOOD favoritefo0_ 
    where
        favoritefo0_.MEMBER_ID=?
favoriteFood = 족발
favoriteFood = 치킨
favoriteFood = 피자

값 타입 컬렉션 수정 예제

값 타입 컬렉션에서 삭제하면, delete 쿼리가 나가고, 새로운 것을 추가하면 insert 쿼리가 나간다. 마치 영속성 전이 + 고아 객체 제거 기능처럼!

System.out.println("============ START ============");
Member findMember = em.find(Member.class, member1.getId());

// 치킨 -> 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

// old1 -> new1
// equals() 메서드 이용해서 객체 찾아서 지운다.
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("new1", "street", "10000"));

tx.commit();
============ START ============
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_6_0_,
        member0_.city as city2_6_0_,
        member0_.street as street3_6_0_,
        member0_.zipcode as zipcode4_6_0_,
        member0_.username as username5_6_0_,
        member0_.endDate as enddate6_6_0_,
        member0_.startDate as startdat7_6_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?
Hibernate: 
    select
        favoritefo0_.MEMBER_ID as member_i1_4_0_,
        favoritefo0_.FOOD_NAME as food_nam2_4_0_ 
    from
        FAVORITE_FOOD favoritefo0_ 
    where
        favoritefo0_.MEMBER_ID=?
Hibernate: 
    select
        addresshis0_.MEMBER_ID as member_i1_0_0_,
        addresshis0_.city as city2_0_0_,
        addresshis0_.street as street3_0_0_,
        addresshis0_.zipcode as zipcode4_0_0_ 
    from
        ADDRESS addresshis0_ 
    where
        addresshis0_.MEMBER_ID=?
Hibernate: 
    /* delete collection org.example.domain.Member.addressHistory */ delete 
        from
            ADDRESS 
        where
            MEMBER_ID=?
Hibernate: 
    /* insert collection
        row org.example.domain.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row org.example.domain.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* delete collection row org.example.domain.Member.favoriteFoods */ delete 
        from
            FAVORITE_FOOD 
        where
            MEMBER_ID=? 
            and FOOD_NAME=?
Hibernate: 
    /* insert collection
        row org.example.domain.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)

조금 생각해봐야할 부분은, Address 타입 컬렉션(AddressHistory)에서는 하나를 지우고 하나를 저장하는 쿼리가 아니라, 관련 memberId 를 가지는 모든 컬럼을 삭제하고 삭제되지 않은 기존 컬럼과 새롭게 저장된 컬럼을 다시 저장하는 insert 쿼리를 날리게 된다.
그러므로, Address 타입 컬렉션(AddressHistory) 관련 쿼리는 delete 쿼리 1번(memberId 기준으로 삭제), insert 쿼리 2번('new1', 'old2')가 발생한 것이다.

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

결론적으로, 이건 쓰면 안된다! 개발자가 예상하지 못한 성능 저하를 야기할 수 있다.

그럼 어떻게? 일대다 관계를 고려해서 새로운 테이블을 관리하도록 하자.

  • 새로 만든 테이블에서 영속성 전이(Cascade) + 고아 객체 제거 를 사용해서 값 타입 컬렉션처럼 사용한다. (ex - AddressEntity)
@Entity
public class AddressEntity {
    @Id @GeneratedValue
    private Long id;

    private Address address;
}
@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    private String username;

    @Embedded
    private Address homeAddress;

    @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<>();

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<AddressEntity> addressHistory = new ArrayList<>();
}

엔티티 타입과 값 타입 비교

엔티티 타입 정리

  • 식별자 O
  • 생명주기 관리
  • 공유 가능
    • 참조 값을 공유할 수 있다. 이것을 공유 참조라고 한다.
    • 예를 들어, 회원 엔티티가 있다면 다른 엔티티에서 얼마든지 회원 엔티티를 참조할 수 있다.

값 타입 정리

  • 식별자 X
  • 생명주기를 엔티티에 의존
  • 공유하지 않는 것이 안전(복사해서 사용)
  • 불변 객체로 만들어야 한다.

값 타입은 정말 간단한 값들을 컬렉션으로 저장할 때만 사용하도록 한다. 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다.

식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 엔티티 타입으로 만들어야 한다.

Reference

자바 ORM 표준 JPA 프로그래밍
https://1-7171771.tistory.com/123
https://ttl-blog.tistory.com/120#%ED%--%--%EB%--%-C%--%EC%A-%--%EA%B-%BC%--%EB%B-%A-%EC%-B%-D%EA%B-%BC%--%ED%--%--%EB%A-%-C%ED%-D%BC%ED%-B%B-%--%EC%A-%--%EA%B-%BC%--%EB%B-%A-%EC%-B%-D%--%ED%--%A-%EA%BB%--%--%EC%--%AC%EC%-A%A-%ED%--%--%EA%B-%B-

profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

0개의 댓글