백엔드/SPRING

[JPA 프로그래밍] 8. 값 타입

-minari- 2025. 2. 7. 21:15

JPA의 데이터 타입 분류


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

 

엔티티 타입

  • 엔티티 타입은 @Entity로 정의하는 객체 
  • 데이터가 변해도 식별자로 지속해서 추적 가능
  • ex) 회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식 가능

 

값 타입

  • int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
  • 식별자가 없고 값만 있으므로 변경시 추적 불가
  • ex) 숫자 100을 200으로 변경하면 완전히 다른 값으로 대체

 

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

 

기본값


회원 엔티티에서 name과 age가 기본값 타입이다. 회원 엔티티는 id라는 식별자 값도 가지고 생명주기도 있지만, 값 타입인 name, age 속성은 식별자 값도 없고 생명 주기도 회원 엔티티에 의존한다. 따라서 회원 엔티티를 삭제하면 이름, 나이 필드도 함께 사라진다. 그리고 값 타입은 공유하면 안 된다.

@Entity
public class Member{
	
    @Id @GeneratedValud
    private Long id;
    
    private String name;
    private int age;
    
}

 

[참고]
int, double 같은 기본 타입은 절대 공유하면 안 된다. 기본 타입은 항상 값을 복사한다. Integer 같은 래퍼 클래스나 String 같은 특수한 클래스는 공유 가능한 객체이지만 변경 불가하다.

 

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


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

 

예시

회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다. 야기서 공통되는 근무와 주소를 묶어서 임베디드 타입으로 만들 수 있다.

 

근무(시작일, 종료일)과 주소(도시, 번지, 우편번호)를 묶어서 workPeriod와 homeAddress 임베디드 타입으로 나타냈다.

 

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

  • @Embeddable : 값 타입을 정의하는 곳에 표시
  • @Embedded : 값 타입을 사용하는 곳에 표시
  • 기본 생성자 필수이다.
@Entity
public class Member {

    @Embedded
    private Period workPeriod;
    @Embedded
    private Address homeAddress;
    
}

@Embeddable
public class Period {
    
    private LocalDateTime startDate;
    private LocalDateTime endDate;

}

@Embeddable
public class Address {

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

 

  • 임베디드 타입은 엔티티의 값일 뿐이다
  • 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다
  • 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하다
  • 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다

 

임베디드 타입의 장점

  • 재사용
  • 높은 응집도
  • 해당 값만 사용하는 의미 있는 메소드를 만들 수 있다
  • 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명 주기를 의존한다.

 

임베디드 타입과 연관관계

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

 

@AttributeOverride : 속성 재정의

임베디드 타입에 정의한 매핑 정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 된다.

@Entity
public class Member{
	
   ...
   
   @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "city", column = @Column("HOME_CITY")),
        @AttributeOverride(name = "street", column = @Column("HOME_STREET")),
        @AttributeOverride(name = "zipcode", column = @Column("HOME_ZIPCODE"))})
    private Address homeAddress;
    
}

 

임베디드 타입과 null

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

 

 

값 타입과 불변 객체


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

 

값 타입 공유 참조

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다.

 

회원 2에 새로운 주소를 할당하려고 회원1의 주소를 그대로 참조해서 사용했다. 이 코드를 실행하면, 회원2의 주소만 변경되길 기대했지만 회원1의 주소도 "NewCity"로 변경되어 버린다. 값 타입의 실제 인스턴스인 값을 공유하는 것은 이러한 부작용을 가져올 수 있기 때문에 위험하다.

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

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

 

이러한 부작용을 막으려면 값(인스턴스)를 복사해서 사용하면 된다.

 

회원 2에 새로운 주소를 할당하기 위해 clone() 메소드를 만들었는데, 이 메소드는 자신을 복사해서 반환하도록 구현했다. 이 코드를 실행하면 의도한 대로 회원2의 주소만 "NewCity"로 변경된다. 

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

Address newAddress = address.clone();

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

 

한계

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

 

이를 해결할 수 있는 가장 단순한 방법은 객체의 값을 수정하지 못하게 막으면 된다

 

불변 객체

 값 타입은 부작용 걱정 없이 사용할 수 있어야 한다. 부작용이 일어나면 값 타입이라 할 수 없다. 객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다. 따라서 값 타입은 될 수 있으면 불변 객체로 설계해야 한다. 생성자로만 값을 설정하고 수정자를 만들지 않으면 된다. 

 

만약, 값을 수정하고 싶다면 새로운 인스턴스를 만들어 설정하면 된다.

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

Member m1 = new Member();
m1.setUsername("m1");
m1.setHomeAddress(address);
em.persist(m1);

Address newAddress = new Address("newCity", address.getStreet(), address.getZipcode());

m1.setHomeAddress(newAddress);

 

값 타입의 비교


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

Address 값 타입을 a == b로 동일성 비교를 하면 둘은 다른 인스턴스이므로 거짓이 나온다. 이것은 우리가 원하는 값이 아니다. 값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다. 따라서 값 타입을 비교할 때는  a.equals(b)를 사용해서 동등성 비교를 해야 한다. 물론 Address의 equals 메소드를 적절하게 재정의 해야 한다.

 

값 타입 컬렉션


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

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

@Entity
public class Member {

    ...
    
    @Embedded
    private Address homeAddress;
    
    @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<>();
    
    ... 
    
}

 

 

값 타입 컬렉션의 제약 사항

값 타입은 엔티티와 다르게 식별자 개념이 없다. 그렇기 때문에 값을 변경해버리면 추적이 어렵다. 

값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다. 예를 들어 식별자가 100번인 회원이 관리하는 주소 값 타입 컬렉션을 변경하면 테이블에서 회원 100번과 관련된 모든 주소 테이블을 삭제하고 현재 값 타입 컬렉션에 있는 값을 다시 저장한다.

 

따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다. 일대다 관계를 위한 엔티티를 만들고 여기서 값 타입을 사용하면 된다. 여기에다 영속성 전이와 고아 객체 제거 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.

 

정리

엔티티 타입의 특징

  • 식별자가 있다.
    - 엔티티 타입은 식별자가 있고 식별자로 구분할 수 있다
  • 생명 주기가 있다
    - 생성하고, 영속화하고, 소멸하는 생명 주기가 있다
    - em.persist()로 영속화한다
    - em.remove()로 제거한다
  • 공유할 수 있다
    - 참조 값을 공유할 수 있다.
    - 예를 들어 회원 엔티티가 있다면 다른 엔티티에서 얼마든지 회원 엔티티를 참조 할 수 있다

 

값 타입의 특징

  • 식별자가 없다
  • 생명 주기를 엔티티에 의존한다
    - 스스로 생명주기를 가지지 않고 엔티티에 의존한다. 의존하는 엔티티를 제거하면 같이 제거된다
  • 공유하지 않는 것이 안전하다
    - 엔티티 타입과는 다르게 공유하지 않는 것이 안전하다. 대신에 값을 복사해서 사용해야 한다
    - 오직 하나의 주인만이 관리해야 한다
  • 불변 객체로 만드는 것이 안전하다