값 타입

김민우·2022년 9월 15일
0

JPA

목록 보기
8/10

JPA는 데이터 타입을 크게 두 가지로 구분한다.

  • 엔티티 타입
    - @Entity로 정의하는 객체
    • 데이터가 변해도 식별자로 지속해서 추적 가능
    • 예)회원 엔티티의 키나 나이값을 변경해도 식별자(PK)로 인식 가능
  • 값 타입
    - int, Integer, String 처럼 단순히 값을 사용하는 자바 기본 타입이나 객체
    • 식별자가 없고 값만 있으므로 변경시 추적이 불가능
    • 생명 주기를 엔티티에 의존한다.
    • 예) 숫자 100을 200으로 변경하면 완전히 다른 값으로 대체

참고
자바의 기본 타입은 절대 공유되지 않는다.

값 타입은 크게 3가지로 할 수 있다.

  • 기본값 타입
    • 자바 기본 타입(int , double )
    • 래퍼 클래스( Integer , Long )
    • String
  • 임베디드 타입(embedded type, 복합 값 타입)
  • 컬렉션 값 타입(collection value type)

하나씩 살펴보자.


기본 값 타입

기본 값 타입에 대한 예시를 잠깐 살펴보자.

int a = 10;
int b = a;

a = 20;

System.out.println("a = " + a);
System.out.println("b = " + b);
결과 
a = 20
b = 10

값이 공유되지 않는다는 것을 알 수 있다.
그러나, IntegerString 은 공유가 가능한 객체이지만 변경하면 안된다.

기본값 타입이 엔티티에 필드로 사용되는 경우를 생각해보자. 엔티티가 삭제되면 필드 또한 삭제될 것이다. (회원을 삭제하면 이름, 나이 필드도 함께 삭제)
이러한 성질 때문에 기본 값 타입은 생명주기를 엔티티에 의존한다고 한다.

엔티티에 묶여 사용되므로 값 타입은 절대로 공유하면 안된다. 회원 이름을 변경하는데 다른 회원의 이름도 변경된다고 생각해보자. 끔찍할 것이다.

이 내용이 간단한건 사실이다. 그러나 임베디드 타입을 이해하기 위해서 더 나아가 JPA에 대해 자세히 공부하기 위해선 이를 확실히 알고 넘어가야한다.


임베디드 타입

우편 번호나 (x, y) 좌표같은 특정한 값을 저장하기 위한 별도의 클래스 정도로 생각하면 된다. 물론 이는 절대로 엔티티가 아닌 int , String 과 같은 값 타입이므로 추적도 안되고 한 번 변경하면 끝이다. 주로 기본 값 타입을 모아서 만들어서 복합 값 타입이라고도 한다.

이렇게 보면 잘 이해가 안갈 수 있으나 예제를 보면 바로 느낌이 올 것이다. 다음 예제를 보자.

예제

  • 회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다

이것들은 일일히 하나의 기본 값 타입으로 필드를 정의하기엔 공통적인 속성이 눈에 보인다. (근무 시작일와 근무 종료일 주소도시와 주소 번지, 주소 우편번호)
이렇게 공통적인 속성들은 각각을 묶어 별도의 클래스로 정의하면 편하지 않을까? 라는 생각이 든다.

회원 엔티티는 이름, 근무 기간, 집 주소를 가진다와 같이 추상화해서 말한다.
공통적인 속성들을 하나로 묶어서 별도의 객체로 정의를 했더니 매우 간단해졌다.
이것이 바로 임베디드 타입이다. (물론 여기서 정의한 workPeriod, homeAddress 객체들은 절대로 엔티티가 아니다.)

임베디드 타입을 사용하면 다음과 같이 연관관계를 맺을 수 있다.

사용법

어노테이션만 추가하면 된다.

  • @Embeddable : 값 타입을 정의하는 곳에 표시
  • @Embedded : 값 타입을 사용하는 곳에 표시
    • 이 어노테이션은 생략이 가능하나, 가능하면 @Embeddable과 같이 명시해주자. (개발은 혼자하는게 아니니깐.)

이렇게 어노테이션을 추가하면 기본 생성자는 필수이다.

장점

위의 예시를 보면 알 수 있듯이 코드가 좀 더 효율적으로 바뀌었다. 공통적인 속성을 갖는 엔티티들을 굳이 복잡하게 구현할 필요가 없으니 말이다. 덕분에 용어의 공통화, 코드의 공통화 그리고 객체와 테이블을 세밀하게 매핑하는 것이 가능해져 응집도가 높아진다.

임베디드 타입을 사용하면 공통적인 속성인 필드에 대해 의미있는 메소드나 제약 조건(길이 수 제한 등)를 적용할 수 있다. 정말 값 타입을 의미있게 사용할 수 있도록 해준다. 덕분에 기존 방법에 비해 좀 더 객체지향적으로 코드를 짤 수 있다.

계속 강조하지만 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존하여 재사용에 용이하다.

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

테이블 매핑

연관 관계

임베디드 타입을 사용하든 안하든 매핑하는 테이블은 똑같다. 다만 매핑 방식이 다르다.
테이블은 DB를 관리에 중점을 두고 설계가 되는 반면 객체는 데이터 뿐만 아니라 메소드 같은 부가적인 기능, 행위들을 들고 있다.

임베디드 타입을 통해 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능해진다. 설계적으로 봤을 때 필드가 너무 많으면 혼란이 생긴다. (개발은 혼자하는게 아니니깐)

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

참고
임베디드 타입 클래스 필드에 @Column 사용 가능하다.
즉, 필드로 엔티티가 들어올 수 있다.

참고
엔티티에서 동일한 임베디드 타입 필드가 2개 이상 있으면 기본적으로 예외가 발생한다. 이 때는, @AttributeOverride 을 사용 하자.

참고
만약, 매핑을 할 때 임베디드 타입 값이 null 이면 매핑한 컬럼 값들은 모두 null이 된다.

참고
equals, hashCode 를 재정의할 때 Getter를 사용하지 않고 필드 값 그대로 적용을 해버리면 프록시 객체에 접근을 해버려서 오류가 생길 수 있다. Getter를 호출해야 프록시 객체여도 equals, hashCode를 적용할 수 있다.


값 타입과 불변 객체

값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다 룰 수 있어야 한다. 쉬운 거 같은데 깊이가 있다. 잘 알아두면 개발하는데 도움이 많이 된다.

개발할 때 크게 신경쓰지 않는 부분이 있다. (값을 복사, 변경할 때) 문제는 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유할 수 있는데, 이는 위험하고 부작용을 야기한다.

다음과 같이 회원1, 2가 동일한 주소(임베디드 타입)을 참조한다고 생각해보자. 회원1이 값을 변경하면 회원2에 저장된 값 또한 바뀔 것이다.

이런 버그는 진짜 잡기 힘들다. 이런 상황을 의도할 수도 있지만 그럴려면 임베디드 타입이 아닌 엔티티로 만들어야 한다. 어떻게 해결할 수 있을까?

항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용 을 피할 수 있다. 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다. 누군가가 이를 어기고 참조 값을 직접 대입을 했으면 컴파일 단계에서 이를 막을 방법이 없다. 즉, 객체의 공유 참조는 피할 수 없다.

왜 이러한 문제가 발생하는 생각해보자. 정말 단순하게 값을 수정함으로써 발생하는 것이다. 이러한 부작용을 피하기 위해선 객체 수정 불가능 하도록 즉, 불변 객체로 생성하면 된다. 불변 객체를 만드는 방법 중 대표적으로 생성자로만 값을 세팅하고 수정자(Setter)를 만들지 않는 방법이 있다.

불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다.

불변 객체

생성 시점 이후 절대 값을 변경할 수 없는 객체를 뜻한다. 가장 대표적이면서 쉬운 방법은 생성자로만 값을 생성하고 수정자를 만들지 않는 것이다. 우리가 자주 사용하는 Integer, String 은 자바가 제공하는 대표적인 불변 객체이다. 값을 수정할 수 없다는 제약 조건이 있지만 수정을 열어둠으로써 발생하는 큰 부작용(Side Effect)를 원천에 차단할 수 있다는 강력한 장점이 있다.

만약 불변 객체인데 값을 바꾸기 위해선 어떻게 해야할까? 당연히 수정이 불가능하므로 새로운 객체를 생성한 후 대입해야 한다.

항상 값타입은 불변으로 만들자.


값 타입의 비교

값을 비교하는 방법 2가지를 알아보자.

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

값 타입을 비교할 때는 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다.
따라서, 반드시 동등성 비교를 사용해야 한다.

값 타입의 equals() 메소드를 적절하게 재정의(주로 모든 필드를 재정의)해서 사용하자.
객체의 equals의 디폴트는 각 객체들의 동일성 비교이다. 따라서, 모든 필드에 대해 동등성 비교로 재정의 해야 한다.

참고
프록시나 다형성으로 복잡하게 짜여진 코드라면 재정의된 equals 메소드를 getter로 고쳐야할 수도 있다.

근데 실무에서는 equals()를 사용할 일이 생각보다 많지 않다...


값 타입 컬렉션

단어 그대로 값 타입을 컬렉션에 저장하여 사용할 때 사용한다.

컬렉션을 DB에 저장해야 하는 상황을 생각해보자. RDB는 기본적으로 테이블 안에 컬렉션을 담을 수 있는 구조가 없다. (요즘은 JSON 방식을 지원하긴 하지만 기본적으로는 안된다.)

업로드중..

즉, 컬렉션을 DB에 보관하기 위해선 별도의 테이블로 뽑아야 관리가 된다.
이 때, 테이블에 별도의 식별자( id ) 같은 것을 PK로 사용한다면 이는 값 타입이 아닌 엔티티가 되버린다. 값 타입은 테이블에 값들만 저장되며 이들을 묶어서 PK로 구성하면 된다.

값 타입 컬렉션을 생성하면 별도의 테이블이 생성되어 값들이 저장되고 연관관계가 필요하므로 FK가 추가된다.

다음과 같이 @ElementCollection, @CollectionTable 사용하여 값 타입 컬렉션을 생성할 수 있다.

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

저장

@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD", joinColumns =
        @JoinColumn(name = "MEMBER_ID")
    )
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
  • @CollectionTablejoinColumns 속성을 통해 PK를 지정해준다.
  • 예외적으로 @Column 속성을 통해 컬렉션 원소들의 column명을 별도로 지정할 수 있다.

컬렉션들은 일대다의 개념이므로 DB안에 컬렉션을 한테이블에 저장하는 방법이 없다. 그래서, 꼼수같이 일대다로 풀어서 별도의 테이블을 만들어야 한다.
즉, 컬렉션을 저장할 별도의 테이블이 필요하다.

값타입 컬렉션을 별도로 persist를 하지 않아도 엔티티를 persist를 하면 자동으로 저장이 된다. 이 둘의 라이프 사이클이 같이 돌아간다.
이유를 생각해보면 별거 없다. 값 타입 컬렉션도 이름 그대로 값 타입이므로 별도의 라이프 사이클이 없다. 라이프 사이클이 엔티티에 의존한다.

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

조회

try { 
	....
	em.flush();
	em.clear();

	System.out.println("=========START=========");
	Member findMember = em.find(Member.class, member.getId());
	...
  • em.flush(), em.clear() 를 통해 완전히 어플리케이션을 처음 실행한 상태와 같아진다.
  • Member 엔티티 select SQL에 값 타입 컬렉션이 없다.
    • 값 타입 컬렉션이 지연 로딩 됬음을 알 수 있다.

값 타입 컬렉션 객체를 엔티티를 통해 갖고 와서 사용해보자. 그제서야 쿼리가 나간다.

List<Address> addressHistory = findMember.getAddressHistory();

for (Address address : addressHistory) {
	System.out.println("address = " + address.getCity());
}

Set<String> favoriteFoods = findMember.getFavoriteFoods();

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

쿼리가 나가는 것을 보면 값 타입 컬렉션들을 모두 지연 로딩임을 알 수 있다.

수정

findMember.getAddressHistory().setCity("newCity");  // (x)
// 사이드 이펙트 발생. 값 타입은 불변이여야 한다.

Address address = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", address.getCity(),
					address.getZipcode())); // (o) 
// 별도의 객체를 생성해야 한다.

값 타입은 불변이므로 별도의 객체를 생성하여 수정을 해야 한다.

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

// 주소 수정
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));

이를 실행 해보면 값 타입 컬렉션의 원소 하나만 삭제/추가 해도 쿼리가 나가는 것을 확인할 수 있다.
근데 수정 과정이 삭제, 삽입을 사용하여 수정을 하는게 매우 비효율적이다. 어쩔 수 없다. 직접 이렇게 객체를 찾아서 삭제해야한다.

참고
remove 메소드는 기본적으로 equals() 메소드를 사용하여 조회하므로 equals() 메소드가 엉뚱하게 재정의 되있다면 치명적인 오류가 발생할 수 있으니 각별히 주의하자.

이렇게 수정을 하면 우리가 아는 컬렉션 처럼 테이블도 일부 수정이 될까? 위 코드의 쿼리를 보자.

Hibernate: 
    /* delete collection hellojpa.Member.addressHistory */ delete 
        from
            ADDRESS 
        where
            MEMBER_ID=?
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)

DB 입장에서 값 타입 컬렉션 원소들을 동적으로 관리(중간에 값이 수정/삭제 되는 것을 실시간 확인)하기 힘들다. @OrderColumn 같은 어노테이션을 사용하여 해결은 가능하나 이 방법은 매우 위험하다. (중간값을 바꾸면 인덱스가 null 이 된다거나...)

이는 완전히 다른 방법으로 해결해야 한다.

값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. (null 입력, 중복 저장 모두 X)

대안

위에서 살펴본 단점들을 어떻게 극복할 수 있을까? 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려하는게 낫다.

AddressEntity

@Entity
@Table(name = "ADDRESS")
public class AddressEntity {

    @Id
    @GeneratedValue
    private Long id;

    private Address address;

    public AddressEntity() {

    }

    public AddressEntity(String old1, String street, String s) {
        this.address = new Address(old1, street, s);
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }
}

Member

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

일대다 관계를 위한 엔티티를

  1. 일대다 단방향으로 매핑
  2. 영속성 전이(Cascade) + 고아 객체 제거를 사용

하여 해서 값 타입 컬렉션 처럼 사용하자. 이렇게 할 시 장점이 많아진다.

  • 값 타입으로 사용하는 것 보다 활용할 수 있는게 많아진다.
  • 실무에서 쿼리 최적화에도 유리하다.
  • 엔티티이므로 값을 자유롭게 가져와서 수정이 가능해진다.
  • 이를 엔티티 승격이라 한다. (값 타입 -> 엔티티 이므로)

실무에서 이 방법을 매우 많이 사용한다. 꼭 알아두자!

그러면 값 타입 컬렉션은 언제 사용할까? 정말 단순한 경우에 사용한다. (체크 버튼 옵션이 2개 중복 선택 가능의 경우, (x, y) 좌표 등..)
이렇게 추적할 필요도 없고 값이 변경되어도 큰 영향이 없는 경우에 값 타입 컬렉션을 사용한다.

엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다. 식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.

0개의 댓글