스터디/JPA 스터디

[JPA] 값 타입

제이온 (Jayon) 2022. 1. 1.

jpa-study에서 스터디를 진행하고 있습니다.

기본 값 타입

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private int age;
}    

 

위의 Member에서 String, int가 기본 값 타입이다. 해당 값 타입은 회원 엔티티에 의존하며, 공유될 수 없다. 예를 들어 타인의 이름이 바뀐다고 해서, 내 이름까지 바뀌면 안 된다.

 

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

새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA에서는 이것을 임베디드 타입이라고 한다.

 

@Entity
public class Member {

    @Id
    @GeneratedValue
    priate Long id;

    private String name;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

}

@Embeddable
public class Period {

    @Temporal(TemporalType.DATE)
    private Date startDate;

    @Temporal(TemporalType.DATE)
    private Date endDate;
}

@Embeddable
public class Address {

    private String city;

    private String street;

    private String zipcode;

}

 

위와 같이 커스텀 객체를 만들고, 위에 @Embeddable 어노테이션을 붙이면 임베디드 타입을 만들 수 있다. 그리고 Member 엔티티에서는 해당 임베디드 타입을 사용하기 위해 필드 이름 위에 @Embedded 어노테이션을 붙이면 된다.

 

Untitled

 

참고로 위 코드에서는 생략하였지만, 임베디드 타입은 기본 생성자가 반드시 있어야 한다. 또한, 임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명 주기에 의존하므로 엔티티와 임베디드 타입의 관계를 UML로 표현하면 컴포지션 관계가 된다.

 

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

Untitled

 

임베디드 타입은 엔티티의 값일 뿐이다. 따라서 임베디드 타입을 사용할 때와, startDate 또는 endDate 같은 기본 타입을 사용할 때의 회원 테이블 결과는 동일하다. 즉 임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하며, 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.

객체지향적인 개발을 위해 임베디드 타입을 적극적으로 고려해 보자.

 

임베디드 타입과 연관 관계

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

 

Untitled

 

Member는 Address와 PhoneNumber를 포함하고 있고, Address는 Zipcode를 포함하고 있으며 PhoneNumber는 PhoneNumber 엔티티를 참조한다.

 

@Entity
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
    private 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;

}

 

위 그림과 코드 상에서 PhoneNumber 엔티티 명칭이 다르긴 하지만, 어쨌든 큰 맥락은 동일하다. 임베디드 타입은 값 타입을 포함할 수 있고, 엔티티를 공유할 수 있다는 점을 명심하자.

 

@AttributeOverride: 속성 재정의

임베디드 타입에 정의한 매핑 정보를 재정의해야 할 수 있다. 가령, 회원에게 주소가 2개 필요하다면 어떨까?

 

@Entity
public class Member {

    @Id
    @GeneratedValue
    priate Long id;

    private String name;

    @Embedded
    private Address homeAddress;

    @Embedded
    private Address companyAddress;

}

@Embeddable
public class Address {

    private String city;

    private String street;

    private String zipcode;

}

 

위와 같이 단순히 두 개의 임베디드 타입을 명칭을 다르게 쓰면 가능해 보인다. 하지만, 테이블에 매핑하는 컬럼명이 중복된다. homeAddress와 companyAddress 모두 city, street, zipcode 컬럼을 가지고 있기 때문이다. 따라서 @AttributeOverrides를 사용해서 매핑 정보를 재정의해야 한다.

 

@Entity
public class Member {

    @Id
    @GeneratedValue
    priate 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;

}

@Embeddable
public class Address {

    private String city;

    private String street;

    private String zipcode;

}

 

다만, 위 어노테이션을 사용하면 엔티티 코드가 지저분해질 수 있으니, 적게 사용하게끔 설계하는 것이 좋다.

 

임베디드 타입과 null

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

 

값 타입과 불변 객체

값 타입 공유 참조

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다. 공유하였을 때 문제를 살펴 보자.

 

Untitled

 

위와 같이 회원 2명이 같은 주소 타입을 공유하고 있을 수 있다. 이 상태에서 회원 2가 주소를 NewCity로 변경하면 어떻게 될까?

 

member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

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

 

우리는 회원 1은 OldCity, 회원 2는 NewCity로 설정되길 원할 것이다. 하지만, 두 엔티티가 같은 Address 객체를 사용하고 있으므로 회원 2가 City의 상태를 바꾸는 순간 회원 1 객체의 City도 바뀌게 된다. 이렇게 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용이라고 한다.

 

값 타입 복사

값 타입의 실제 인스턴스인 값을 공유하지 말고 값을 깊은 복사해서 사용해야 한다.

 

Untitled

 

각 회원 엔티티가 서로 다른 주소 객체를 쓰면 된다.

 

member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

// 회원 1의 Address 값을 깊은 복사
Address newAddress = address.clone(); // (o)

// 아래와 같이 얕은 복사를 하면 주소를 공유하게 됨.
Address newAddress = address; // (x)

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

 

자바는 대입하려는 것이 값 타입이면 값을 복사해서 넘기고, 객체면 참조를 넘기는 것에 유의하자.

 

불변 객체

그러나 개발자가 객체의 필드를 하나 하나 확인해 가면서 상태가 변할 수 있는 임베디드 타입을 고려한 뒤, clone() 메소드를 계속 호출하여 매번 객체를 깊은 복사하는 것은 바람직하지 않다. 따라서 부작용을 원천 봉쇄하도록 객체를 불변 객체로 생성해야 한다.

한 번 만들면 절대 변경할 수 없는 객체를 불변 객체라 하고, 불변 객체의 값은 조회만 가능하다. 따라서 참조 값을 공유하더라도 인스턴스의 값 수정이 불가능하므로 부작용이 발생하지 않는다.

불변 객체에 관한 자세한 설명은 이곳을 참조하고, 가장 간단하게 불변 객체를 만드려면 setter를 없애고 getter만 노출하면 된다.

 

@Embeddable
public class Address {

    private String city;

    protected Address() {} // JPA에서 기본 생성자는 필수다.

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

    // getter
}

 

위와 같이 Address 타입을 불변 객체로 만들자.

 

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

 

Address는 값을 수정할 수 없으므로 공유해도 부작용이 발생하지 않고, Member 엔티티의 값을 수정해야하면 위와 같이 새 객체를 할당해서 사용해야 한다.

 

값 타입 비교

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

 

값 타입 컬렉션

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

 

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address homeAddress;

    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOODS",
        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<>();

}

@Embeddable
public class Address {

    private String city;

    private String street;

    private String zipcode;

}

 

favoriteFoods는 기본 값 타입인 String을 컬렉션으로 가진다. 이것을 데이터베이스 테이블로 매핑해야 하는데, 관계형 데이터베이스의 테이블은 컬럼 안에 컬렉션을 포함할 수 없다. 따라서 아래처럼 별도의 테이블을 추가해야 한다. addressHistory도 마찬가지로 별도의 테이블이 필요하다.

 

 

만약 favoriteFoods 같이 사용되는 컬럼이 하나라면 @Column을 통해 컬럼명을 지정해 줄 수 있고, addressHistory 같이 사용되는 컬럼이 여러 개라면 @AttributeOverride를 사용하여 컬럼명을 지정해 줄 수 있다.

 

값 타입 컬렉션의 제약 사항

값 타입은 식별자라는 개념이 없고, 단순한 값들의 모음이므로 값을 변경해 버리면 데이터베이스에 저장된 원본 데이터를 찾기 어렵다. 특정 엔티티 하나에 소속된 값 타입은 값이 변경되어도 자신이 소속된 엔티티를 데이터베이스에 찾고 값을 변경하면 된다.

하지만 값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관되고, 해당 테이블에는 식별자가 없으므로 데이터베이스에 있는 원본 데이터를 찾기 힘들다. 이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면, 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스 다시 저장한다.

따라서 값 타입 컬렉션 보다는 일대다 관계를 고려하는 것이 좋다.

 

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address homeAddress;

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

}

@Entity
public class AddressEntity {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address address;

}

 

연관 관계의 주인이 N 쪽이 아니어서 UPDATE 쿼리가 2번 날아가지만, 값 타입 컬렉션보다는 훨씬 성능이 좋다. 그리고 웬만한 애플리케이션에서 UPDATE 쿼리 2번 정도 매번 날아가는 것은 크게 문제가 되지 않는다.

 

출처

김영한 - 자바 ORM 표준 JPA 프로그래밍

댓글

추천 글