Back-End/JPA

9. 값 타입 (1)

wisdom11 2022. 6. 9. 12:05

 

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

엔티티 타입은 @Entity로 정의하는 객체이고, 값 타입은 int, Integer, String 처럼 단순한 값으로 사용하는 자바 기본 타입이나 객체를 말한다. 값 타입은 기본값 타입(basic value type), 임베디드 타입(embedded type), 컬렉션 값 타입(collection value type) 3가지로 나눌 수 있다.

값 타입은 식별자가 없고 추적할 수 없는 정보라고 볼 수 있다. 지금부터 값 타입에 대하여 알아보자.

 

1. 기본값 타입 (Basic Value Type)

예제 코드)

@Entity
public class Member {
	
    @Id @GeneratedValue
    private Long id;
    
    private String name; // 값 타입
    private int age;	 // 값 타입
    ...
}

위의 예제의 Member에서 String, int 가 값 타입이다.

name과 age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존한다.
즉 회원 엔티티 인스턴스를 제거하면 name, age 값도 제거된다.

그리고 값 타입은 공유하면 안 된다. 

 

2. 임베디드 타입 (복합 값 타입, Embedded Type)

JPA에서는 새로운 값 타입을 직접 정의하는 것을 임베디드 타입이라고 한다.
직접 정의한 임베디드 타입도 결국 int, String 처럼 값 타입이라는 점을 기억하자.

예제 코드)

@Entity
public class Member {
	
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    
    @Embedded Period workPeriod;
    @Embedded Address homeAddress;
    ...
}

@Embeddable
public class Period {
	
    @Temporal(TemporalType.DATE) java.util.Date startDate;
    @Temporal(TemporalType.DATE) java.util.Date endDate;
    ...
    
    public boolean isWork(Date date) {
    	...
    }
}

@Embeddable
public class Address {
	
    @Column(name="city") // 매핑할 컬럼 정의 가능
    private String city;
    private Stirng street;
    private String zipcode;
    ...
}

위와 같이 값 타입을 정의하면 Member 엔티티를 의미있고 응집력 있게 표현할 수 있다.

새로 정의한 값 타입들은 재사용할 수 있고, 응집도도 아주 높다. 또한 Period.isWork() 처럼 해당 값 타입만 사용하는 의미있는 메소드도 만들 수 있다.

임베디드 타입을 사용하기 위해 필요한 어노테이션 2가지는 다음과 같다. (둘 중 하나는 생략해도 된다.)

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

 

임베디드 타입은 값 타입이므로 엔티티의 생명주기에 의존한다. 그래서 엔티티와 임베디드 타입의 관계는 컴포지션(Composition) 관계이다.

연관관계

임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다. 예제 코드를 참고하자.

예제 코드)

@Entity
public class Member {
    @Embedded Address address;		// 임베디드 타입 포함
    @Embedded PhoneNumber phoneNumber;	// 임베디드 타입 포함
    ...
}

@Embeddable
public class Address {
    String city;
    Stirng street;
    String state;
    @Embedded Zipcode zipcode;	// 임베디드 타입 포함
}

@Embeddable
public class Zipcode {
    String zip;
    Stirng plusFour;
}


@Embeddable
public class PhoneNumber {
    String areaCode;
    String localNumber;
    @ManyToOne PhoneServiceProvider provider; // 엔티티 참조
    ...
}

@Entity
public class PhoneServiceProvider {
    @Id String name;
    ...
}

 

@AttributeOverride: 속성 재정의

@AttributeOverride 어노테이션을 사용하면 임베디드 타입에 정의한 매핑정보를 재정의할 수 있다.

예제 코드)

@Entity
public class Member {
	
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @Embedded 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"))
    })
    Address companyAddress;
}

한 엔티티에 같은 임베디드 타입을 2개 이상 사용할 경우, 컬럼명이 중복되는 문제가 있다.

그럴 때 예제처럼 매핑 정보를 재정의하여, 컬럼명을 변경할 수 있다.

예제 코드를 통해 다음과 같이 테이블이 생성된다.

CREATE TABLE MEMBER (
    COMPANY_CITY varchar(255),
    COMPANY_STREET varchar(255),
    COMPANY_ZIPCODE varchar(255),
    city varchar(255),
    street varchar(255),
    zipcode varchar(255),
    ...
)

 

null

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

예를 들어, member.setAddress(null)을 한 경우 address와 매핑한 컬럼인 city, street, zipcode 의 값이 모두 null이 된다.

 

3. 값 타입과 불변 객체

값 타입은 여러 엔티티에서 공유하면 위험하다. 값 타입을 공유하면 어떤 문제가 발생할까?

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

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

위와 같은 상황에서, member1의 address는 "OldCity", member2의 address는 "NewCity"가 되길 기대했지만, 실제로는 member1, member2 의 주소 모두 "NewCity"가 되어버린다.

이는 member1과 member2가 같은 address 인스턴스를 참조하기 때문이다. 영속성 컨텍스트는 member1과 member2 둘 다 city 속성이 변경된 것으로 판단하여 각각 UPDATE SQL을 실행할 것이다.

이렇듯 무언가를 수정했는데 전혀 예상지 못한 곳에서 문제가 발생하는 것을 부작용(Side Effect)라고 한다.

이러한 부작용을 막으려면 값을 복사해서 사용해야 한다.
다음 코드처럼 Address 객체 자신을 복사해서 반환하는 clone() 메소드를 구현하여 부작용을 피할 수 있다.

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

// member1의 address 값 복사해서 사용
Address newAddress = address.clone();

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

하지만 여기서 문제는, 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다는 것이다. 

이 문제의 해결책 중 가장 간단한 방법은 객체의 값을 수정하지 못하게 막는 것이다. Address 객체의 setCity()와 같은 수정자 메소드를 모두 제거하면, 공유 참조를 해도 값을 변경하지 못하므로 부작용을 막을 수 있다.

 

불변 객체 (Immutable Object)

한 번 만들면 절대 변경할 수 없는 객체를 불변 객체라고 한다. 불변 객체의 값은 조회할 수 있지만 수정할 수는 없다.

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

불변 객체를 구현하는 방법 중 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않는 것이다.

예제 코드)

@Embeddable
public class Address {
	
    private String city;
    protected Address() {} // jpa에서 기본 생성자는 필수다.
    public Address(String city) {this.city = city} // 초기값 설정
    public String getCity() {return city;}
    
    // 수정자(Setter)는 만들지 않는다.
}

 

4. 값 타입의 비교

값 타입을 비교할 때는 서로 다른 인스턴스더라도 (참조 값이 다르더라도) 그 안에 있는 값이 같으면 같은 것으로 봐야 한다.

따라서 값 타입을 비교할 때는 동일성 비교(==)가 아니라 동등성 비교(equals)를 해야 한다.

  • 동일성(Identity) 비교: 인스턴스의 참조 값을 비교한다. == 를 사용한다.
  • 동등성(Equivalence) 비교: 인스턴스의 값을 비교한다. equals() 메소드를 사용한다.

물론 Address의 equals() 메소드를 재정의해서 사용해야 한다. 이때 hashCode()도 함께 재정의해야 해시를 사용하는 컬렉션이 정상 동작한다.

 

728x90