본문 바로가기
[ ORM ]/JPA

[ JPA ] 값 타입(Value Object)

by 환이s 2024. 3. 29.


Intro

 

JPA의 데이터 타입을 분류하면 엔티티 타입값 타입(Value Object)으로 구분할 수 있다.

 

엔티티 타입@Entity 애노테이션으로 정의하는 객체, @Entity를 붙여서 관리하던 클래스들이다.

이 타입들은 PK값으로 관리가 되기 때문에 데이터가 변해도 식별자로 지속적으로 추적이 가능하고 관리도 편리하다.

 

예를 들면

Member 테이블인 즉, 회원 엔티티가 있으면 회원의 주소 및 나이 값을 변경해도 식별자로 인식이 가능하다.

 

그에 반해 값 타입

자바 기본 타입과 래퍼 클래스, 문자열처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체다.

 

식별자가 없고 값만 있으므로 변경 시 추적이 불가하다.

 

만약 int 타입의 값이 10이 있다고 가정해 보면 10이라는 값을 100으로 변경하면 완전히 다른 값으로 대체된다.

 

그럼 지금부터 값 타입 자세하게 알아보자.


기본값 타입

 

기본 값 타입(Basic Value Type)

자바 기본 타입(int,double), 래퍼클래스(Integer, Long), 문자열(String) 같은 기본 값 타입들은

생명주기가 엔티티에 의존되어 있다.

 

간단한 예제 코드로 알아보자.

@Entity
public class Member {
    ...
    private String name; // 기본 값 타입
    
    private int age; // 기본 값 타입
    ...
}

 

위 코드의 name은 String, age 속성은 int가 값 타입이다.

name과 age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존한다.

 

값 타입의 속성은 식별자 값이 없으며, 공유를 막아야 한다.

만약 공유를 허용하면 회원의 이름을 변경할 때 다른 회원의 이름까지 변경될 위험이 존재하기 때문이다.

 

정리하자면

앞에서 언급했듯  값 타입의 속성은 소속된 엔티티에 의존하기 때문에

회원 엔티티 인스턴스를 제거하면 name, age 속성 값도 제거된다.


임베디드 타입(Embedded Type)

 

임베디드 타입(Embedded Type)은 주로 기본 값 타입을 모아서 만들기 대문에 복합 값 타입이라고도 한다.

 

즉, 임베디드 타입 역시 int, String과 같은 값 타입이며,

임베디드 타입의 값이 null이면 매핑한 컬럼 값 역시 모두 null이다.

 

복합 값타입으로 새로운 값 타입을 직접 정의할 수 있다.

 

예를 들면,

회원 엔티티에 다음과 같은 컬럼이 지정되어 있다고 가정해 보자.

 

 

회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가지고 있다.

 

실무에서 프로젝트를 진행한다고 가정했을 때 생각해 보면

처음부터 근무 시작일, 근무 종료일이나 상세 주소에 대해 정의하지는 않는다.

 

단순하게 근무 시작일은 근무 기간,

상세 주소는 집 주소라고 정의한다.

 

이러한 과정에서 임베디드 타입으로 만들어주고 사용할 수 있다.

 

임베디드 타입을 적용해서 회원 엔티티를 수정하면

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

처음에 생성된 startDate , endDate 속성은 Period 클래스에 추가해서 묶어주고

집 주소에 필요한 city, street, zipcode 속성은 Address 클래스에 묶어서 관리할 수 있다.

 

하지만 단순히 클래스로 묶었다고 해서 사용할 수 있는 건 아니다.

해당 임베디드 타입으로 생성한 클래스는 @Embeddable 애노테이션을 꼭 적용해줘야 한다.

@Embeddable
public class Address {

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

    public Address() { // 기본생성자 필수 
    }
}
@Embeddable
public class Period {

    private LocalDateTime startDate;
    private LocalDateTime endDate;

    public Period() { // 기본생성자 필수 
    }
}

 

@Embeddable 애노테이션은 값 타입을 정의하는 곳에 표시하는 애노테이션인데,

예제에서는 Address, Period 클래스가 해당이 된다.

 

추가로 임베디드 타입 클래스에는 기본 생성자는 필수이다.

 

@Embeddable 애노테이션은 값 타입을 정의한다고 했다.

그럼 정의한 값 타입을 사용하려면 사용하는 곳을 지정해줘야 한다.

 

사용하는 곳은 Member 엔티티 이므로, 값 타입을 사용하기 위한 설정을 해줘야 한다.

@Entity
public class Member {

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

    	@Column(name = "USERNAME")
    	private String username;
        
       
    	@Embedded // 값 타입 사용하기
    	private Period period;

    	
    	@Embedded // 값 타입 사용하기
    	private Address homeAddress;
        
 }

 

정의한 값 타입을 사용하려면 @Embedded 애노테이션을 적용시키면 된다.

 

임베디드 타입의 장점은 코드의 재사용을 높이고, 높은 응집도, 값 타입별 의미 있는 메서드를 만들 수 있다.

 

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

 

 

임베디드 타입은 어떠한 이슈를 만든다고 해도 엔티티의 값일 뿐이다.

 

위 사진을 확인해 보면 값 타입으로 생성된 Period, Address 클래스는 Member 엔티티에 의존하면서

별도로 테이블 생성할 필요 없이, 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 동일하다.

 

그렇기에 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능하다.


값 타입과 불변 객체

 

값 타입 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다.

 

따라서 최대한 단순하고 안전하게 다룰 수 있어야 한다.

우선 값 타입들은 위에서 계속 언급했듯 서로 공유를 하면 안 된다.

 

예시로 회원의 나이를 변경한다고 했을 때

이름도 변경되면 안 되는 것처럼 기본값 타입은 애초에 값을 복사하기 때문에 공유를 할 수 없고,

래퍼 클래스나 문자열(String)은 참조 값을 복사하기 대문에 공유가 가능하지만 수정이 불가능하기 때문에 괜찮다.

 

하지만 임베디드 타입은 직접 정의한 객체 타입이기 때문에

기본값 타입과는 다르게 공유가 가능하고 수정 또한 가능해서 유의해야 한다.

 

예를 들면

 

회원 1과 회원 2의 주소 값이 동일하게 NewCity로 요청이 들어왔다고 가정해 보자

Address address = new Address("city","street","zipcode");

Member member1 = new Member();
member1.setUsername("member1");
member1.setAddress(address);

Member member2 = new Member();
member2.setUsername("member2");
member2.setAddress(member1.getAddress());

 

위 코드처럼 작성해서 실행을 해버리면 member2에도 동일한 값이 들어가지만 부작용(side effect)이 발생한다.

 

여기서 회원 2의 주소를 변경하려고 주소를 수정하면

회원 1의 주소도 함께 변경된다.

 

그래서 값 타입의 실제 인스턴스인 값을 공유하는 방식보다는

값(인스턴스)을 복사해서 사용한다.

Address copyAddress = member1.getAddress();
member1.setAddress( new Address(
	copyAddress.getCity(), copyAddress.getStreet(), copyAddress.getZipCode() 
	))

 

그렇다면 지금까지 알아본 내용을 정리해 보면

객체 타입의 한계점을 느낄 것이다.

 

객체 타입은 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.

 

하지만 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다.

자바 기본 타입에 값을 대입하면 값을 복사한다.

 

객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다.

그렇기에 객체의 공유 참조는 피할 수 없다.

 

또한  항상 값을 복사해서 사용하는 것이 아닌, 다른 해결 방법이 있다.

또 다른 방법은 바로 불변객체로 만드는 것이다.

 

불변객체는 생성 시점 이후에 변경할 수 없는 객체이다.

Integer, String 등이 이에 속하고, 불변객체로 만드는 방법은 바로 setter를 정의하지 않거나 private로 정의하면 된다.

public String getCity() {
        return city;
    }

    private void setCity(String city) {
        this.city = city;
    }

    public String getStreet() {
        return street;
    }

    private void setStreet(String street) {
        this.street = street;
    }

    public String getZipcode() {
        return zipcode;
    }

    private void setZipcode(String zipcode) {
        this.zipcode = zipcode;
    }

 

만약 불변 객체의 값을 수정해야 할 경우에는 새로운 객체를 생성하여 필요한 부분만 수정해야 한다.

Address address = new Address("city", "street", "10000");

Member member1 = new Member();
member1.setName("member1");
member1.setHomeAddress(address);
em.persist(member1);

//code 추가
Address newAddress = new Address("NewCity", address.getStreet(), address.getZipcode());
member1.setHomeAddress(newAddress);

 

이와 같이 불변객체를 사용해서 제약을 걸어주면 부작용이라는 큰 재앙을 막을 수 있다.


값 타입의 비교

 

 

일반적으로 Java에서는 값을 비교할 때 비교 연산자(==)와 equals()를 사용한다.

 

값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다.

 

값 타입의 비교는 두 가지 방법이 있는데,

바로동일성(identity), 동등성(equivalence) 비교이다.

 

동일성 비교비교 연산자(==)를 사용하여 참조 값을 비교한다.

즉, 메모리 주소 값을 비교한다.

 

동등성 비교equals() 메서드를 사용하여 객체의 내용을 비교한다.

즉, 객체의 값(=value)을 비교한다.

 

위 그림 예제를 통해 알아보자.

public class JpaMain {

    public static void main(String[] args) {

        Integer a = new Integer(10);
        Integer b = new Integer(10);
        
        System.out.println("a == b = " + (a == b));
        System.out.println("a.equals(b) = " + a.equals(b));
}

 

예제를 토대로 실행해 보면 a == b의 결과는 false, a.equals(b)의 값은 true라는 결과가 나왔다.

 

그 이유는 객체 a와 b는 서로 다른 객체이므로,

비교 연산자(==)를 사용하여 비교해 보면 당연히 false가 나온다.

즉, 위에서 언급했듯 메모리 주소 값이 다르기 때문에 false 값이 출력된 것이다.

 

반대로 equals(b)는 

객체 a와 b가 서로 다른 객체이지만, 동일한 값을 가지고 있으므로 equeals() 연산의 결과는 true가 나온 것이다.

 

하지만 값 타입 객체는 다른 결과를 만든다.

 

public class JpaMain {

    public static void main(String[] args) {

        Address address1 = new Address("서울시", "종로구", "00000");
        Address address2 = new Address("서울시", "종로구", "00000");

        System.out.println("address1 == address2 ==> " + (address1 == address2));
        System.out.println("address1.equals(address2) ==> " + (address1.equals(address2)));    
    }
 }

 

위 코드를 실행해 보면 어떤 결과가 나올까?

 

우선 코드를 분석해 보면 

비교 연산자(==)의 경우, address1과 2는 서로 다른 객체로, 서로 다른 메모리 주소 값을 할당받는다.

즉, false를 반환할 것으로 예상된다.

 

equals()의 경우, address1과 2는 동일한 값을 가진다. 그렇다면 true를 반환할 것으로 예상될 것이다.

 

하지만 실제 결과로는 둘 다 false 값이 나온다.

 

그 이유는 객체의 타입에 따라 사용되는 equals() 메서드가 다르기 때문이다.

값 타입 클래스는 equals() 메서드를 재정의 해서 사용해야 한다.

 

 

equals() 메서드를 재정의 할 때에는 다음과 같은 규칙을 따르도록 하자.

  • IDE에서 지원하는 equals() 메서드 재정의 방식을 준수한다.
  • equals() 메서드 재정의 시 반드시 "Use getters during code generation" 옵션을 사용하자.
  • 이 옵션을 사용하는 이유는 필드에 직접 접근하는 것이 아닌 getter()를 사용하여 접근하기 때문이다.
  • Proxy객체는 필드의 직접 접근이 불가능하다. 반드시 getter()를 호출해야 접근이 가능하다.
    • 즉, Proxy 객체가 언제 사용될지 확실하지 않으므로 반드시 해당 옵션을 사용한다.

 

위처럼 재정의 하면 다음과 같은 equals() 메서드가 작성된다.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Address address = (Address) o;
    return Objects.equals(getCity(), address.getCity()) && 
            Objects.equals(getStreet(), address.getStreet()) && 
            Objects.equals(getZipcode(), address.getZipcode());
}

@Override
public int hashCode() {
    return Objects.hash(getCity(), getStreet(), getZipcode());
}

 

equals() 메서드를 재정의하면 자동으로 hashCode() 메서드를 함께 재정의해준다.

재정의 해서 다시 객체를 비교해 보면 equals() 비교 값이 true가 나오는 걸 확인할 수 있다.

 

이처럼 Java는 직접 작성한 클래스의 객체를 비교할 때 

equals() 메서드를 사용하는 경우, 반드시 재정의해서 사용한다.

 

결론으로는

임베디드 타입을 사용하여 객체를 생성하는 경우, 객체 비교를 위해서는 반드시 equals() 메서드를 재정의하자.


마치며

 

오늘은 값 타입에 대해 알아봤습니다.

값 타입을 공부하면서 다시 초임으로 돌아가 Java부터 공부할 수 있었습니다.

확실히 값 타입을 사용하면 Entity의 가독성을 높일 수 있을 거 같습니다.

 

그럼 다음 포스팅에서 뵙겠습니다.

위 포스팅 글은 김영한님의 자바 ORM 표준 JPA 프로그래밍-기본편을 참고했습니다.

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 | 김영한 - 인프런

김영한 | JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다., 실무에서도

www.inflearn.com

 

728x90