JPA 값 타입과 임베디드 타입

임준영·2021년 4월 20일
0

JPA

목록 보기
4/12
post-thumbnail

값 타입

JPA는 데이터 타입을 가장 크게 분류하면 엔티티 타입과 값(value) 타입으로 나눌 수 있습니다. 엔터티 타입은 @Entity로 정의하는 객체이고, 값 타입은 int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말합니다. 엔티티 타입은 식별자를 통해서 지속해서 추적할 순 있지만, 값 타입은 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없습니다.

엔티티는 키나 나이와 같은 값을 변경해도 식별자만 유지되면 같은 회원으로 인식할 수 있습니다. 하지만 숫자 값 100을 200으로 변경하면 완전히 다른 값으로 대체 됩니다.
비유하자면 엔티티 타입은 살아있는 생물이고 값 타입은 단순한 수치 정보입니다.

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

기본값 타입은 String, int처럼 자바가 제공하는 기본 데이터 타입이고 임베디드 타입은 JPA에서 사용자가 직접 정의한 값 타입입니다. 마지막으로 컬렉션 값 타입은 하나 이상의 값 타입을 저장 할 때 사용합니다.

가장 단순한 기본값 타입은 아래 코드와 같습니다

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    private int age;

}

위의 코드를 보면 값 타입인 name, age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존합니다. 따라서 회원 엔티티의 인스턴스를 제거하면 name, age 값도 제거 됩니다.

자바에서 int, double 같은 기본 타입은 절대 공유되지 않습니다. call by value입니다.

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

새로운 값 타입을 직접 정의해서 사용할 수 있는데 이것을 임베디드 타입이라고 합니다. 중요한 것은 직접 정의한 임베디드 타입도 int, String 처럼 값 타입이라는 것입니다.

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    // 근무 기간
    @Temporal(TemporalType.DATE) 
    private Date startDate;
    
    @Temporal(TemporalType.DATE) 
    private Date endDate;
    
    // 집 주소 표현
    private String city;
    private String street;
    private String zipcode;
}
  • 회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편 번호를 가집니다.

이런설명은 단순히 정보를 풀어둔 것 뿐이니다. 그리고 근무 시작일과 우편번호는 서로 아무 관련이 없습니다. 이것보단 아래처럼 명확하게 설명하는 것이 좋습니다

  • 회원 엔티티는 이름, 근무 기간, 집 주소를 가집니다.

회원이 상세한 데이터를 그대로 가지고 있는 것은 객체 지향적이지 않으며 응직력만 떨어뜨립니다. 대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해 질 것입니다. [근무기간, 집 주소] 를 가지도록 임베디드 타입을 사용해 보겠습니다.

Member.java

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @Embedded  
    private Period workPeriod; // 근무 기간
    
    @Embedded 
    private Address homeAddress; // 집 주소
}

Period.java

public class Period {
    
    // 근무 기간
    @Temporal(TemporalType.DATE) 
    private Date startDate;
    
    @Temporal(TemporalType.DATE) 
    private Date endDate;

    public boolean isWork(Date date){
        //.. 값 타입을 위한 메소드를 정의 할 수 있습니다.
    }
}

Address.java

public class Address{
    
    // 집 주소 표현
    private String city;
    private String street;
    private String zipcode;
    // ...
}

임베디드 타입 사용 후 엔티티 연관관계

임베디트 타입을 사용하니 엔티티가 더욱 의미 있고 응집력 있게 변한 것을 알 수 있습니다.

  • startDate, endDate를 합해서 Period 클래스를 만들었습니다.
  • city, street, zipcode를 합해서 Address 클래스를 만들었습니다.

새로 정의한 값 타입들은 재사용할 수 있고 응집도도 아주 높습니다. 또한 Period.isWork() 매소드 처럼 해당 값 타입만 사용하는 의미 있는 메소드도 만들 수 있습니다. 임베디드 타입을 사용하려면 다음 2가지 어노테이션이 필요합니다.

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

위의 2가지 어노테이션 중 하나는 생략해도 됩니다.

그리고 임베디드 타입은 기본 생성자가 필수입니다.
하이버네이트는 임베디드 타입을 컴포넌트(component)라고 합니다.

임베디드 타입은 엔티티의 값일 뿐입니다. 따라서 값이 속한 엔티티의 테이블에 매핑합니다. 임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능합니다. 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많습니다.

1. 임베디드 타입과 연관관계

임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있습니다. 엔티티는 공유될 수 있으므로 참조한다고 표현하고, 값 타입은 특정 주인에 소속되고 논리적인 개념상 공유되지 않으므로 포함된다고 표현합니다.

public class Member{

    @Embedded 
    private Address address; // 임베디드 타입 포함
    
    @Embedded 
    private PhoneNumber phoneNumber; // 임베디드 타입 포함
}

@Embeddable
public class Address{
    private String street;
    private String city;
    private String state;

    @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;
}

코드를 보면 값 타입인 Address가 값 타입인 Zipcode를 포함하고, 값 타입인 PhoneNumber가 엔티티 타입인 PhoneServiceProvider를 참조하고 있습니다.

2. @AttributeOverride 속성 재정의

임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 됩니다. 예를 들어 회원에 주소가 하나 더 필요하면 아래 코드와 같이 @AttributeOverrides를 사용해서 매핑정보를 재정의해야 합니다.

@Entity
public class Member{

    @Id @GeneratedValue
    private Long id;
    private String name;

    @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;
}

@AttributeOverrides는 엔티티에 설정해야 합니다. 임베디드 타입이 임베디드 타입을 가지고 있어도 엔티티에 설정해야 합니다.

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

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

회원 테이블의 주소와 관련된 city, street, zipcode 컬럼 값은 모두 null이 됩니다.

3. 값 타입과 불변 객체

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

값 타입 공유 참조

임베디드 타입 값 타입을 여러 엔티티에서 공유하면 위험합니다. 공유하려면 어떤 문제가 발생하는지 알아봐야 합니다.

위의 그림을 코드로 나타내면 다음과 같습니다.

member1.setAddress(new Address("OldCity"));
Address address = member1.getAddress();

address.setCity("NewCity");
member2.setHomeAddress(address);

회원2에 새로운 주소를 할당하려고 회원1의 주소를 그대로 참조해서 사용했습니다. 실제로 회원2만 NewCity로 변경되길 기대했지만 회원1의 주소도 NewCity로 변경되어 버립니다. 회원1과 회원2 둘 다 같은 address 인스턴스를 참조하기 때문입니다. 영속성 컨텍스트는 회원1과 회원2 둘 다 city 속성이 변경된 것으로 판단해서 회원1, 회원2 각각 UPDATE SQL을 실행한다.

이러한 공유 참조로 발생하는 버그는 정말 찾아내기 어렵습니다. 이렇듯 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용이라 합니다. 이런 부작용을 막으려면 값을 복사해서 사용하면 됩니다.

값 타입의 복사

값 타입의 실제 인스턴스인 값을 공유하는 것은 위험합니다. 대신에 값(인스턴스)를 복사해서 사용해야 합니다.

member1.setAddress(new Address("OldCity"));
Address address = member1.getAddress();

// 회원1의 address 값을 복사해서 새로운 newAddress 값을 생성
Address newAddress = address.clone();

newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);

회원2에 새로운 주소를 할당하기 위해 clone() 메소드를 만들었는데, 이 메소드는 자신을 복사해서 반환하도록 구현했습니다. 이 코드를 실행하면 의도한 대로 회원2 주소만 NewCity로 변경됩니다. 그리고 영속성 컨텍스트는 회원2의 주소만 변경된 것으로 판단해서 회원2에 대해서만 UPDATE SQL을 실행합니다.

이처럼 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있습니다.
문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본타입이 아니라 객체 타입이라는 것입니다.
자바는 기본 타입에 값을 대입하면 값을 복사해서 전달하고, 객체에 값을 대입하면 항상 참조 값을 전달합니다.

Address b=a 에서 a가 참조하는 인스턴스의 참조 값을 b에 넘겨줍니다. 따라서 a와 b는 같은 인스턴스를 공유 참조합니다. 마지막 줄의 b.setCity("New")의 의도는 b.city 값만 변경하려 했지만 공유 참조로 인해 부작용이 발생해서 a.city 값도 변경됩니다.

물론 객체는 대입할 때마다 인스턴스를 복사해서 대입하면 공유 참조를 피할 수 있습니다. 문제는 복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다는 것입니다. 자바는 대입하려는 것이 값 타입인지 아닌지는 신경쓰지 않습니다. 단지 자바 기본 타입이면 값을 복사해서 넘기고, 객체면 참조를 넘길 뿐입니다.

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

4. 불변 객체(immutable)

한번 만들면 절대 변경할 수 없는 객체를 불변(immutable) 객체라고 합니다. 불변 객체의 값은 조회할 수 있지만 수정할 수는 없습니다. 불변 객체도 결국은 객체입니다. 따라서 인스턴스의 공유 참조를 피할 수 없습니다. 하지만 참조 값을 공유해도 인스턴스의 값을 수정할 수 없으므로 부작용이 발생하지 않습니다.

불변 객체를 구현하는 다양한 방법이 있지만 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않으면 됩니다. Address를 불변 객체로 만들면 아래 코드와 같습니다.

@Embeddable
public class Address {

    private String city;
    
    // JPA에서 기본 생성자는 필수입니다.
    protected Address() {} 
    
    public Address(String city){
        this.city = city;
    }
    
    public String getCity(){
        return city;
    }

    // 수정자(setter)는 만들지 않습니다.
}

불변 객체 사용

Address address = member1.getHomeAddress();
//회원1의 주소값을 조회해서 새로운 주소값을 생성
Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);

Address는 이제 불변 객체입니다. 값을 수정할 수 없으므로 공유해도 부작용이 발생하지 않습니다. 만약 값을 수정해야 한다면 새로운 객체를 생성해서 사용해야 합니다. 참고로 Integer, String은 자바가 제공하는 대표적인 불변 객체 입니다.

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

참조 문헌: ORM 표준 JPA 프로그래밍

0개의 댓글