[JPA 기본] 값 타입 👨‍💻

홍정완·2022년 11월 11일
0

JPA

목록 보기
36/38
post-thumbnail

JPA 타입 분류


JPA의 데이터 타입을 가장 크게 분류하면 엔티티 타입값 타입으로 나눌 수 있다.


엔티티 타입

  • @Entity로 정의하는 객체
  • 식별자를 통해 지속해서 추적할 수 있다.
  • 식별자를 변경하지 않는 한, 내부 값을 변경해도 식별자를 통해 가져올 수 있다.

값 타입

  • int, Integer, String 등의 자바 기본 타입 또는 객체
  • 값 타입은 식별자가 없고 숫자나 문자로만 이루어져 있어 추적할 수 없다.
  • 숫자 100 -> 200으로 변경한다 가정하면, 완전히 다른 값으로 대체된 것이다.

비유하자면, 엔티티 타입살아있는 생물이고 값 타입단순한 수치 정보다.


  • 기본 값 타입

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

  • 컬렉션 값 타입


기본 값 타입은, 자바가 제공하는 기본 데이터 타입이다.
임베디드 타입은, JPA에서 사용자가 직접 정의한 값 타입이다.
컬렉션 값 타입은, 하나 이상의 값 타입을 저장할 때 사용한다.



기본 값 타입


기본형 타입 (Primitive type)

int a = 20;
int b = a;

b = 10

System.out.println("a :" + a); // a : 20
System.out.println("b :" + b); // b : 10

자바에서 int, double과 같은 Primitive type은 공유되지 않는다.
예를 들어 a=b와 같은 코드는 b를 직접적으로 사용하는 것이 아니라, b의 값을 복사a에 입력한다. 그렇기에 기존 값을 변경시키는 것이 아니라, 다른 값으로 대체되는 것이며 이로 인해 사이드 이펙트는 일어나지 않는다.



참조형 타입 (Reference type)

Integer a = new Integer(20);
Integer b = a;

Primitive 외에도 Integer와 같은 래퍼 클래스나 String과 같은 특수한 클래스도 있다.
이것들은 객체지만 자바 언어에서 기본 타입처럼 사용할 수 있게 지원하므로 기본값 타입으로 정의했다.
다만, 이들은 객체이기 때문에 서로 간에 공유는 가능하다.
그럼에도 우리는 이전에 Long과 같은 래퍼 클래스 타입을 id값으로 사용했다. 그 이유는 뭘까? 🤔


이는 불변 객체이기에 가능했던 것이다.
불변 객체란 자신이 소유한 값을 변경할 수 없는 값이다.
위 코드에서도 자신의 값을 변경할 수 있는 메서드(방법)이 없다는 것을 알 수 있다.
만약 다른 값으로 사용하고 싶다면?, 새로운 객체를 참조하는 방식으로 사용해야 한다.

즉, 그렇기에 이런 불변 객체들은 JPA를 사용함에 있어 아주 유용한 방식이 된다.
정리하자면, 불변 객체는 다른 참조 변수끼리 공유는 가능하지만, 변경은 불가능하다.



기본 값 타입 특징

  • 생명 주기를 엔티티에 의존하고 있다.
  • 값 타입은 공유하면 안 된다. (사이드 이펙트가 일어나지 않아야 한다.)
    • Primitive type, 공유가 아닌 복사로 이 같은 특징에 알맞다.
    • Reference type, 공유는 가능하지만 복사가 아니기에 사용 가능하다.



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


JPA에서는 새로운 값 타입을 직접 정의할 수 있으며 이를 임베디드 타입이라 부른다. 주로 기본 값 타입을 모아서 만들어진 복합 값 타입이라고도 한다. (기본 값이 아니면 사이드 이펙트가 발생하기 때문이다.)


기본값 타입

@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    // 근무 기간
    @Temporal(TemporalType.DATE) startDate;
    @Temporal(TemporalType.DATE) endDate;
    
    // 집 주소 표현
    private String city;
    private String street;
    private String zipcode;
    // ...
}

  • 회원 엔티티 : 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편 번호

하지만, 이는 단순히 필드를 풀어서 설명한 것일 뿐이다. 이것보다는 아래와 같이 설명하는 것이 더 좋을 것이다.


  • 회원 엔티티 : 이름, 근무 기간, 집 주소

회원이 상세한 데이터를 그대로 가지고 있는 것은 객체 지향적이지 않으며, 응집력만 떨어뜨린다. 대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해질 것이다.

즉, DB를 위해 필드를 풀어헤치지 말고, 이들을 하나의 클래스로 만들어서 객체 지향적이며 응집력이 높도록 관리 해보자는 뜻이다.



값 타입 전용 회원 엔티티

@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @Embedded Period workPeriod;    // 근무 기간 
    @Embedded Address homeAddress;  // 집 주소  
    // ...
}

Period

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

Address

@Embeddable
public class Address {
    
    @Column(name="city)
    private String city;
    private String street;
    private String zipcode;
    // ..
}

분리된 클래스들을 통해 엔티티(객체)가 보다 의미 있어지게 되었고 가독성재사용성은 물론, 각각의 클래스들은 응집력이 높아진 상태임을 확인할 수 있다.

임베디드 타입을 사용하려면 다음 2가지 어노테이션이 필요하다.

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

그리고 임베디드 타입 클래스는 기본 생성자가 필수다.
임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하므로
엔티티와 임베디드 타입의 관계를 UML로 표현하자면 컴포지션 관계가 된다.


임베디드 타입의 장점

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



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


임베디드 타입을 데이터베이스 테이블에 매핑하는 방법을 알아보자



임베디드 타입은 엔티티의 값일뿐이다. 따라서 값이 속한 엔티티의 테이블에 매핑한다.

사실, 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다. 즉, 임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하다. 잘 설계한 ORM 애플리케이션은 매핑 한 테이블의 수보다 클래스의 수가 더 많다.

ORM을 사용하지 않고 개발하면 테이블 컬럼과 객체 필드를 대부분 1:1로 매핑한다. 주소나 근무기간 같은 값 타입 클래스를 만들어서 더 객체지향적으로 개발하고 싶어도 SQL을 직접 다루면 테이블 하나에 클래스 하나를 매핑하는 것도 고단한 작업인데 테이블 하나에 여러 클래스를 매핑하는 것은 상상하기도 싫을 것이다.

이런 지루한 작업은 JPA에 맡기고 더 세밀한 객체지향 모델을 설계하는 데 집중하자



@AttribuiteOverride : 속성 재정의


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


Member

@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @Embadded Address homeAddress;
    @Embadded Address companyAddress;
    
}

위 코드를 보면 엔티티가 엔티티를 참조하는 형태를 아주 당연하게도 임베디드 타입으로 전환시킨 것을 알 수 있다. 하지만, 문제는 테이블에 매핑하는 컬럼명이 중복되는 것이다. (단일 타입 값이면, Column(name="이름")으로 바꿀 수 있지만 복합 타입이므로 안된다.)

이때는, @AttributeOverrides를 사용해서 매핑 정보를 재정의해야 한다.


@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @Embadded 
    @AttributeOverrides({
        @AttributeOverride(name = "city", column = @Column(name = "HOME_CITY")),
        @AttributeOverride(name = "street", column = @Column(name = "HOME_STREET")),
        @AttributeOverride(name = "zipcode", column = @Column(name = "HOME_ZIPCODE"))
    })
    Address homeAddress;
    
    @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"))
    })
    @Embadded Address companyAddress;    
}

@AttributeOverrides는 상위 클래스나 참조하는 엔티티의 @Column 설정을 변경할 수 있다. 이를 활용해 각각의 임베디드 타입의 @Column 정보를 변경할 수 있다.

단, @AttributeOverrides를 너무 많이 사용하면 엔티티 코드가 지저분해지므로 잘 사용하자 😉



임베디드 타입과 null

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

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

회원 테이블의 주소와 관련된 CITY, STREET, ZIPCODE 컬럼 값은 모두 null이 된다.



값 타입과 불변 객체


값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 한다.


값 타입 공유 참조

  • 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다.
member1.setHomeAddress(new Address("OldCity");
Address address = member1.getHomeAddress();

address.setCity("NewCity"); // 회원 1의 address 값을 공유해서 사용
member2.setHomeAddress(address);

위 코드를 보면 알 수 있듯이,
회원 2의 주소만 NewCity로 변경되길 기대했지만, 회원 1의 주소도 NewCity로 변경되어 버린다. 이는 서로 같은 인스턴스(객체)를 참조하고 있기 때문이며 이를 공유 참조라고 부른다.


이러한 공유 참조로 인해 발생하는 버그는 찾기 어렵다. 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 부작용(용어) 이 발생하기 때문이다.

그렇다면 이러한 부작용은 어떻게 해결해야 할까? 🤔 정답은, 값을 복사해서 사용하면 된다.



값 타입 복사

  • 값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하다. 대신에 값을 복사해서 사용해야 한다.
member1.setHomeAddress(new Address("OldCity");
Address address = member1.getHomeAddress();

Address newAddress = address.clone();   
    
newAddress.setCity("NewCity"); 
member2.setHomeAddress(newAddress);

새로운 Address 객체를 얻기 위해 clone()을 이용했다. 이로 인해 영속성 컨텍스트는 member2의 Address의 값만 변경한다. 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.


임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이라는 것이다.
객체를 대입할 때마다, 인스턴스를 복사해서 대입하면 공유 참조를 피할 수 있다.
문제는 복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다는 것이다.

객체의 공유 참조는 피할 수 없다.

따라서 근본적인 해결책이 필요한데 가장 단순한 방법은 객체의 값을 수정하지 못하게 막으면 된다.
예를 들어, Address 객체의 setCity()같은 수정자 메서드를 모두 제거하는 것이다.
이렇게 하면 공유 참조를 해도 값을 변경하지 못하므로 부작용의 발생을 막을 수 있다.



불변 객체

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

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

불변 객체를 구현하는 방법은 다양하게 있지만, 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않으면 된다.


@Embaddable
public class Address {

    private String city;
    
    protected Address;
    
    // 생성자로 초기 값을 설정한다.   
    public Address(String city) { this.city = city; }
    
    // 접근자(getter)는 노출한다.  
    public String getCity() {
        return city;
    }

    // 수정자(stter)는 만들지 않는다.   
}

불변 객체

Address address = member1.getHomeAddress();

Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);

만약 값을 수정해야 할 일이 있다면 위 코드와 같이 새로운 객체를 생성해서 사용해야 한다. 참고로 Integer, String과 같은 래퍼 클래스는 자바에서 제공하는 대표적인 불변 객체이다. 정리하자면, 불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다.



값 타입 비교


자바에서 제공하는 비교 연산은 2가지이다.

  • 동일성 비교 : 인스턴스가 소유한 값을 비교 ==
  • 동등성 비교 : 인스턴스를 특정 기준에 빗대어 비교 equals()

Primitive type은 메모리가 소유하고 있는 을 기준으로 비교를 한다. (동일성)

int a = 10;
int b = 10;

a == b

Reference type 또한 메모리가 소유하고 있는 을 기준으로 비교할 수는 있지만, 메모리가 소유하고 있는 값은 다른 메모리의 주소이다.

Address a = new Address("서울시", "종로구", "1번지");
Address b = new Address("서울시", "종로구", "1번지");

그렇기에 소유한 값을 비교하는 동일성 비교(==)는 사용하기 힘들고
대신, 동등성(equals())비교를 이용해서 진행해야 한다.

값 타입의 equals()를 정의(오버라이딩)할 때는 보통 모든 필드의 값을 비교하도록 구현한다.


자바에서 equals()를 재정의하면 hashCode()도 재정의하는 것이 안전한다.
그렇지 않으면 해시를 사용하는 컬렉션(HashSet, HashMap)이 정상 동작하지 않는다.
자바 IDE에는 대부분 equals, hashCode 메서드를 자동으로 생성해 주는 기능이 있다.



값 타입의 컬렉션


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


@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;
    
    @Embedded
    private Address homeAddress;
    
    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOODS", joinColumms = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FOOD_NAME")   
    private Set<String> favoriteFoods = new HashSet<String>();
    
    @ElementCollection
    @CollectionTable(name = "ADDRESS", joinColumms = @JoinColumn(name = "MEMBER_ID"))
    private List<Address> addressHistory = new ArrayList<Address>();
    // ...
}

@Embeddable  
public class Address {
    
    @Column
    private String city;
    private String street;
    private String zipCode;
    // ...
}

favortieFoods는 기본값 타입인 String컬렉션으로 갖는다. 이것을 DB 테이블로 매핑해야 하는데, RDB의 테이블은 컬럼안에 컬렉션을 포함할 수 없다. 따라서, 별도의 테이블을 추가하여 사용하는 방법으로 진행해야 한다.


@CollectionTable은 컬렉션을 위한 별도의 테이블을 생성하기 위한 어노테이션이다. name=""을 통해 테이블 이름을 지정하고, joinColumm=을 통해 조인할 컬럼을 지정한다. 여기서 조인할 컬럼은 한 개이므로 joinColumms = @JoinColumn(name = "MEMBER_ID")로만 지정한다.

addressHistory는 임베디드 타입인 Address를 컬렉션으로 가진다. 이것도 마찬가지로 별도의 테이블을 사용해야 한다. 그리고 테이블 매핑 정보는 @AttributeOverride를 사용해서 정의할 수 있다.


@Collectiontable를 생략하면 기본값을 사용해서 매핑한다.

  • 기본값 : {엔티 티이름}_{컬렉션 속성 이름}

예를 들어, Member 엔티티 addressHistoryMember_addressHistory 테이블과 매핑한다.



💡 값 타입 컬렉션의 제약사항


엔티티는 식별자가 있으므로, 엔티티의 값을 변경해도 식별자로 DB에 저장된 원본 데이터를 쉽게 찾아서 변경할 수 있다. 반면에 값 타입은 식별자라는 개념이 없고 단순한 값들의 모음이므로 값을 변경해버리면 DB에 저장된 원본 데이터를 찾기는 어렵다.

특정 엔티티 하나에 소속된 값 타입은 값이 변경되어도 자신이 소속된 엔티티를 DB에서 찾고 값을 변경하면 된다.


문제는 값 타입 컬렉션이다. 값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관된다.
따라서 여기에 보관된 값 타입의 값이 변경되면 DB에 있는 원본 데이터를 찾기 어려워지는 문제가 발생한다.

이런 문제로 인해, JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면, 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체이 있는 모든 값을 DB에 다시 저장한다.


DELETE FROM ADDRESS WHERE MEMBER_ID = 100;
INSERT INTO ADDRESS(MEMBER_ID, CITY, STREET, ZIPCODE) VALUES(100, ...)
INSERT INTO ADDRESS(MEMBER_ID, CITY, STREET, ZIPCODE) VALUES(100, ...)
...

예를 들어, 식별자가 100번인 회원이 관리하는 주솟값 타입 컬렉션을 변경하면 다음 SQL 같이 테이블에서 회원 100번과 관련된 모든 주소 데이터를 삭제하고 현재 값 타입 컬렉션에 있는 값을 다시 저장한다. 여기서는 현재 값 타입 컬렉션에 주소가 2건 있어서 2번 INSERT 되었다.


따라서, 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려하기도 한다.


추가로 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 이를 통해, DB 기본 키 조건으로 컬럼에 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없게 만든다.

지금까지 설명한 문제를 해결하려면 값 타입 컬렉션을 사용하는 대신에 새로운 엔티티를 만들어서 일대 다 관계로 설정하면 된다. 여기에 추가적으로 영속성 전이 + 고아 객체 제거 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.



Member

@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;
    
    @Embedded
    private Address homeAddress;
    
    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOODS", joinColumms = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FOOD_NAME")   
    private Set<String> favoriteFoods = new HashSet<String>();
    
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<Address> addressHistory = new ArrayList<Address>();
    // ...
}

AddressEntity

@Entity
public calss AddressEntity {

    @Id
    @GeneratedValue
    private Long id;
    
    @Embedded Address address;
}

값 타입 컬렉션을 변경했을 때, JPA 구현체들은 테이블의 기본 키를 식별해서 변경된 내용만 반영하려고 노력한다. 하지만 사용하는 컬렉션이나 여러 조건에 따라 기본 키를 식별할 수도 있고 식별하지 못할 수도 있다.
따라서 값 타입 컬렉션을 사용할 때는 모두 삭제하고 다시 저장하는 최악의 시나리오를 고려하면서 사용해야 한다. 값 타입 컬렉션의 최적화에 관한 내용은 각 구현체의 설명서를 참고하자.



✅ 정리


엔티티 타입의 특징

  • 식별자가 있다.
    • 엔티티 타입은 식별자가 있고 식별자로 구분할 수 있다.
  • 생명주기가 있다.
    • 생성하고, 영속화하고, 소멸하는 생명주기가 있다.
    • em.persist(entity)로 영속화한다.
    • em.remove(entity)로 제거한다.
  • 공유할 수 있다.
    • 참조 값을 공유할 수 있으며 이것을 공유 참조라 한다.
    • 예를 들어, 회원 엔티티가 있다면 다른 엔티티에서 얼마든지 회원 엔티티를 참조할 수 있다.

값 타입의 특징

  • 식별자가 없다.
  • 생명 주기를 엔티티에 의존한다.
    • 스스로 생명주기를 가지지 않고 엔티티에 의존한다.
    • 의존하는 엔티티를 제거하면 같이 제거된다.
  • 공유하지 않는 것이 안전하다.
    • 엔티티 타입과는 다르게 공유하지 않는 것이 안전하다.
    • 대신에 값을 복사해서 사용하자
    • 오직 하나의 주인만이 관리해야 한다.
    • 불변 객체로 만드는 것이 안전하다.



값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다. 특히 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안 된다. 식별자가 필요하고 지속적으로 값을 추적하고 구분하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.

profile
습관이 전부다.

0개의 댓글