[JPA] 값 타입

imcool2551·2022년 4월 8일
1

JPA

목록 보기
9/12
post-thumbnail

본 글은 인프런 김영한님의 JPA 로드맵을 기반으로 정리했습니다.

1. 값 타입


JPA에서 데이터의 타입은 두 가지로 나뉜다.

  • 엔티티 타입

    • @Entity로 정의한 객체

    • 식별자로 추적 가능

  • 값 타입

    • int, Integer, String 처럼 값을 표현하는 기본 타입이나 객체

    • 식별자가 없기 때문에 변경시 추적 불가

두 타입 모두 시리즈의 이전 글들에서 많이 살펴봤다. 예를 들어 Member는 엔티티 타입이고 Member 엔티티의 String name 과 같은 필드가 값 타입이다.

지금까지는 String이나 Integer와 같은 기복 값 타입만 살펴봤지만 JPA를 사용하면 더 다양한 종류의 값 타입을 사용할 수 있다. 크게 3가지로 분류한다.

  • 기본값 타입: int, Integer, String 처럼 단순히 값 하나로 표현한다.

  • 임베디드 타입: 복합 값 타입으로써, 클래스로 표현한다.

  • 값 타입 컬렉션: 기본값 타입이나 임베디드 타입을 컬렉션으로 표현한다.

이번 글에서 값 타입을 하나씩 살펴볼 것이다. 또, 값 타입 사용시 주의점들을 알아본다.

2. 기본 값 타입


기본 값 타입은 다음과 같은 타입들을 말한다.

  • int age 와 같은 자바의 기본 타입(primitive type)

  • Integer와 같은 래퍼 타입

  • String name 과 같은 문자열 타입

기본 값 타입은 생명주기를 엔티티에 의존한다. 예를 들어 Member 엔티티를 삭제하면 name과 age 역시 삭제된다.

기본 값 타입은 불변이거나 불변이 아닐 경우 절대 공유하면 안 된다. 다행인 것은 기본 값 타입 사용시 자바가 언어 차원에서 공유하면 안 되는 것이나 불변을 보장해준다는 것이다.

자바의 int, double과 같은 기본 타입(primitive type)은 항상 값 복사를 통해 사용하기 때문에 공유할 수 없고, Integer와 같은 래퍼 객체나 String 객체는 공유 가능하지만 불변이기 때문에 안심하고 사용할 수 있다.

만약 int가 공유 가능했더라면 한 회원의 나이를 수정하면 다른 회원의 나이도 덩달아 수정되어버리는 대참사가 일어날 수 있다.

3. 임베디드 타입


임베디드 타입은 한글로 복합 값 타입이라 한다. 복합 값 타입이라고 부르는 이유는 여러 타입을 모아서 만들기 때문이다. 임베디드 타입은 다른 임베디드 타입을 포함할 수 있지만 주로 int, String과 같은 기본 값 타입들을 모아서 만든다.

예제를 통해 살펴보자. 회원 엔티티는 이름(name), 근무 시작일(startDate), 근무 종료일(endDate), 주소 도시(city), 주소 번지(street), 주소 우편번호(zipcode)를 가진다.

회원 엔티티의 필드 중 근무 시작일과 근무 종료일은 매우 밀접한 관계를 가진다. 또한 주소 도시, 주소 번지, 주소 우편번호도 매우 밀접한 관계를 가진다. 밀접환 관계를 가진 필드들을 하나의 타입으로 묶어내면 회원 엔티티를 더 명확하고 간결하게 표현할 수 있다. 나아가 묶어낸 타입에 메서드를 추가하여 클래스의 응집성을 높일 수도 있을 것이다.

관련 있는 필드들을 임베디드 타입으로 묶어냄으로써 회원 엔티티를 이름, 근무 날짜 정보, 주소 정보로 간추려서 표현할 수 있게 되었다.

코드를 통해 살펴보자.

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;
}

임베디드 타입의 필드에는 @Embedded를 붙인다. 임베디드 타입의 클래스에 @Embeddable을 붙이면 @Embedded 애노테이션은 생략해도 되지만 명시적으로 붙여주는 것이 가독성이 더 좋다.

이제 임베디드 타입인 Period와 Address를 만들어보자.

@Embeddable
@Getter @Setter
public class Period {

    private LocalDateTime startDate;
    private LocalDateTime endDate;
}
@Embeddable
@Getter @Setter
public class Address {

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

    protected Address() {
    }

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

클래스에 @Embeddable만 붙이면 매우 쉽게 임베디드 타입을 만들 수 있다. 회원 엔티티의 표현이 단순해진 것에 더하여서 필요에 따라 임베디드 타입에 의미 있는 메서드를 추가해서 클래스의 응집성을 높일 수 있다.

임베디드 타입은 기본 값 타입과 마찬가지로 모든 생명주기를 값 타입을 소유한 엔티티에 의존한다. 회원을 삭제하면 나이, 이름이 삭제되는 것처럼 근무 날짜 정보와 주소 정보도 함께 삭제된다.

다음 그림처럼 임베디드 타입을 만들어도 매핑되는 테이블은 변화가 없다.

임베디드 타입은 클래스이긴 하지만 엔티티가 아니다. 단순히 엔티티의 값일 뿐이다. 그래서 임베디드 타입이 null이면 임베디드 타입의 모든 필드에 매핑된 컬럼도 null이 된다.

임베디드 타입을 잘 사용하면 테이블의 변화없이 엔티티의 응집성을 높일 수 있으니 적극적으로 사용하도록 하자. 잘 설계한 ORM 애플리케이션은 테이블의 수보다 클래스의 수가 많다.

임베디드 타입은 또 다른 임베디드 타입이나 다른 엔티티를 포함할 수도 있다. 기본 값 타입과 연관된 엔티티를 임베디드 타입으로 잘 묶어내면 클래스의 표현이 한층 더 명확해지고 각 클래스의 응집성 또한 높일 수 있을 것이다.

한 엔티티가 같은 임베디드 타입을 두 개 이상 사용할 필요가 있을 수 있다. 기본적으로 임베디드 타입은 필드의 이름이 컬럼명으로 그대로 매핑되기 때문에 이 경우 컬럼의 이름을 재정의해야한다. 이 때 @AttributeOverrides, @AttributeOverride 애노테이션을 사용한다.

코드로 살펴보자.

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name="city",
                    column=@Column(name="WORK_CITY")),
            @AttributeOverride(name="street",
                    column=@Column(name="WORK_STREET")),
            @AttributeOverride(name="zipcode",
                    column=@Column(name="WORK_ZIPCODE"))
    })
    private Address workAddress;
}

집 주소 정보와 근무지 주소 정보를 모두 표현하기 위해 Address 임베디드 타입을 두 번 사용했기 때문에 둘 중 하나의 테이블 컬럼명을 재정의 해줘야 올바로 매핑할 수 있다. 위의 경우 근무지 주소 정보를 표현하는 컬럼을 재정의했다.

4. 값 타입 사용 시 주의 사항


컬렉션 값 타입을 다루기 이전에 아주 중요한 주의사항을 몇 가지 짚고 넘어가자.

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

단순하고 안전하게 다루기 위한 구체적인 방침이 있다.

4.1 값 타입을 공유하지 말라

자바의 기본 타입은 항상 값 복사가 일어나고 래퍼 클래스와 문자열 클래스는 불변이기 때문에 언어 차원에서 안전하게 다룰 수 있다.

그러나 사용자가 직접 만든 임베디드 클래스의 경우 얘기가 다르다. 임베디드 타입을 여러 엔티티에서 공유하고 불변이 아니라면 부작용(side effect)이 발생할 수 있다.

회원들이 주소 타입을 공유해서 사용한다. 이 때 한 회원이 주소 타입의 값을 변경하면 다른 회원의 주소도 변경되어버리는 대참사가 일어난다. 이를 막기 위해 임베디드 타입은 공유하는 대신 복사해서 사용해야한다.

객체를 복사해서 사용하면 공유로 인한 부작용(side effect)을 피할 수 있다. 객체를 복사하는 수 많은 방법이 있지만 단순하게 다음처럼 할 수 있다.

Address address = new Address("서울", "xx구 xx동 xx번지", "01234");
Address anotherAddress = new Address(address.getCity(), address.getStreet(), address.getCity());

그러나 이는 근본적인 해결책이 되지 못한다. 임베디드 타입은 참조 타입이기 때문에 int같은 기본 타입처럼 값 복사가 일어나지 않는다. 즉, 참조 값을 직접 대입하는 것을 막을 방법이 없다. 마치 다음과 같이 말이다.

Address address = new Address("서울", "xx구 xx동 xx번지", "01234");
Address anotherAddress = address;

객체를 복사해서 사용해야 한다는 것을 알고 주의를 기울여서 개발해도 위와 같은 코드가 불리는 것을 막을 방법이 없다. 즉, 컴파일 타임에 객체의 공유 참조를 알 방법이 없다.

4.2 값 타입을 불변으로 만들라

자바 기본을 다시 살펴보자.

int a = 10;
int b = a;
b = 4;

기본 타입의 경우 값 복사가 일어나기 때문에 b를 4로 변경해도 a는 그대로 10이다.

Address a = new Address("서울", "xx구 xx동 xx번지", "01234");
Address b = a;
b.setCity("경기")

그러나 객체의 경오 참조 값을 복사하기 때문에 a, b는 참조 변수는 객체를 바라보고 있다. 그렇기 때문에 객체의 값을 수정해버리면 의도하지 않았던 부작용이 발생할 수 있다.

이를 막기 위해 임베디드 타입 객체를 불변으로 만들어서 부작용을 원천 차단해야한다. 불변이란 생성 시점 이후 절대 값을 변경할 수 없는 객체다. Integer와 같은 래퍼 객체나 String이 대표적인 불변 객체다.

@Embeddable
@Getter
public class Address {

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

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

생성자로 값을 설정한 뒤 그 이후엔 값을 수정할 수 없도록 세터를 제거하면 된다.

참고로, 객체는 완전히 불변으로 만들거나 그럴수 없는 경우 최대한 불변에 가깝게 만들수록 다루기 쉬워지는 경향이 있다. 이 시리즈의 글들에서 편의를 위해 엔티티에 세터를 달아서 사용하고 있지만 이 또한 객체를 불변에서 멀어지게 만드는 나쁜 습관이다. 객체는 가능한 한 불변으로 만들자. 불변이라는 제약으로 부작용이라는 큰 재앙을 막을 수 있다.

4.3 값 타입의 동등성 비교

값 타입은 이름 그대로 값을 표현하는 타입이다. 그렇기 때문에 표현하는 값이 같다면 같다고 볼 수 있다. 여기서 또 다시 자바의 기본 타입과 참조 타입의 차이를 언급할 수 밖에 없다.

int a = 10;
int b = 10;
a == b // true
Address a = new Address("서울", "xx구", "01234");
Address b = new Address("서울", "xx구", "01234");
a == b // false

기본 타입의 경우 == 비교를 통해 값이 같은지 비교할 수 있다. 그러나, IntegerString 혹은 Address 와 같은 사용자가 정의한 타입은 참조 타입이기 때문에 == 비교를 통해 값이 같은지 비교할 수 없다. 참조 타입에서 == 비교는 값 비교가 아닌 참조 주소의 비교가 일어난다. 즉, 두 객체가 정말로 같은 인스턴스를 참조하고 있는지 비교하기 때문에 값의 동등성 비교에 적절하지 않다.

임베디드 타입은 값의 동등성을 비교할 수 있도록 equals()를 재정의 해줘야한다. hashCode() 또한 재정의 해줘야 HashSet처럼 해시값을 사용하는 컬렉션도 오류 없이 사용할 수 있다. 주로 모든 필드가 같은지 비교하도록 재정의해주면 된다.

@Embeddable
@Getter
@EqualsAndHashCode
public class Address {

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

    protected Address() {
    }

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

롬복의 애노테이션 @EqualsAndHashCode은 두 메서드를 모두 재정의해준다. 롬복을 사용하지 않더라도 보통 IDE에서 두 메서드를 재정의 해주는 편의 기능을 제공하니 적극 사용하자. 직접 재정의 하기엔 상당히 까다로워서 오류가 생기기 쉽다.

5. 값 타입 컬렉션


값 타입 마지막 종류인 값 타입 컬렉션을 살펴보자. 결론부터 말하자면 값 타입 컬렉션은 기본 값 타입과 임베디드 타입과 달리 자주 사용되지 않는다. 왜 그런지 살펴보자.

위와 같이 기본 값 타입이나 임베디드 타입을 컬렉션으로 가지는 것이 값 타입 컬렉션이다.
Member 엔티티와 값 타입 컬렉션은 다음처럼 테이블에 매핑된다.

전통적인 관계형 데이터베이스는 컬럼에 원자값만 들어갈 수 있기 때문에 컬렉션을 통째로 넣을 수 없다. 컬렉션을 담을 수 있는 새로운 테이블을 생성해야한다.

새로운 테이블의 PK는 기존 테이블(Member)를 참조하는 FK와 값을 표현하는 모든 필드의 조합이 된다. 기본 값 타입이라면 FK와 기본 값이 함께 PK가 된다. 임베디드 타입이라면 FK와 임베디드 타입의 모든 필드의 조합이 PK가 된다.

코드로 살펴보자.

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

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

@ElementCollection 애노테이션을 통해 필드가 값 타입 컬렉션임을 명시한다. @CollectionTable 애노테이션을 통해 새로 생길 테이블의 이름과 기존 테이블의 FK가 들어갈 컬럼의 이름 등 테이블의 정보를 명시한다.

값 타입 컬렉션은 새로운 테이블로 매핑되지만 엔티티가 아니다. 기존 엔티티의 값을 표현하는 필드일 뿐이다. 기본 값 타입과 임베디드 타입처럼 기존 엔티티에 종속되는건 마찬가지다. 때문에 영속성 전이(Cascade) + 고아 객체 제거(orphanRemoval) 기능을 필수로 가진다. 참고로 값 타입 컬렉션은 지연 로딩 전략을 사용한다.

값 타입 컬렉션은 아래와 같은 제약사항 때문에 자주 사용되지 않는다.

  • 엔티티와 다르게 식별자 개념이 없다. 외래키와 값 타입의 값들이 모여서 PK를 구성한다.

  • 식별자 개념이 없기 때문에 값을 변경했을 때 추적이 어렵다.

  • 값 타입 컬렉션을 변경하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 현재 값을 모두 다시 저장한다. 바로 위에서 말했듯이 변경 추적이 어렵기 때문에 합리적인 동작이지만 이는 성능에 좋지 않다.

  • 값 타입의 값들은 PK를 구성하기 때문에 null이 될 수 없고 중복 저장할 수도 없다. (기본키 제약조건은 UNIQUE + NOT NULL)

값 타입 컬렉션을 사용하기 전에 일대다 관계를 고려하자. 일대다 관계를 위한 새로운 엔티티를 만들고 새로운 엔티티에서 값 타입을 사용하는 것이다. 그리고 영속성 전이(Cascade) + 고아 객체 제거(orphanRemoval) 기능을 사용하면 엔티티를 마치 값 타입 컬렉션 처럼 사용할 수 있다.

코드로 살펴보자.

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @OneToMany(cascade = CascadeType.ALL,  orphanRemoval = true)
    @JoinColumn(name = "PLAYER_ID")
    private List<AddressEntity> addressHistory = new ArrayList<>();
}
@Entity
@Table(name = "ADDRESS")
@Getter @Setter
public class AddressEntity {

    @Id @GeneratedValue
    private Long id;

    private Address address;

    public AddressEntity() {
    }

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

Member는 값 타입을 컬렉션을 직접 가지는 대신 새로운 엔티티 AddressEntity와 일대다 관계를 맺는다. 그리고 영속성 전이 + 고아 객체 기능을 사용해서 엔티티를 마치 값 타입 컬렉션처럼 사용한다.

새로운 엔티티는 단순히 값 타입을 감싸는 엔티티다. 중요한 차이는 엔티티는 식별자를 가질 수 있다는 것이다.

6. 정리


엔티티 타입과 값 타입을 비교하면서 마치겠다.

  • 엔티티 타입

    • 식별자를 가진다.

    • 생명 주기를 스스로 관리한다.

    • 여러 엔티티에 공유될 수 있다.

  • 값 타입

    • 식별자가 없다.

    • 생명 주기를 주인 엔티티에게 의존한다.

    • 공유하지 않고 복사하는 것이 안전하다.

    • 불변 객체로 만드는 것이 안전하다.

엔티티 타입과 값 타입의 가장 중요한 차이는 식별자다. 지속해서 값을 추적, 변경하려면 식별자가 필요하기 때문에 엔티티로 만들어야 한다. 값 타입은 정말 값 타입일 때만 사용하자. 값 타입의 좋은 후보는 주소 정보, 좌표 정보와 같이 값을 표현하고 잘 변하지 않는 것들이다. 값 타입을 만들기로 했다면 공유하지 말고, 불변으로 만들고, 동등성 비교를 위해 필요한 메서드들을 재정의해주자.

profile
아임쿨

0개의 댓글