본문 바로가기
Back-end/java spring

[JPA] 값 타입

by kkkdh 2023. 2. 18.
728x90

기본값 타입

JPA의 기본적인 데이터 타입 분류는 크게 두가지이다. (최상위 분류)

  • 엔티티 타입
    • @Entity 어노테이션으로 정의하는 객체
    • 데이터가 변해도 식별자를 이용해서 지속적으로 추적 가능
    • 예) 회원 엔티티의 키나 이름 값을 변경해도 식별자로 인식 가능
  • 값 타입
    • int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 의미
    • 식별자 없고 값만 있어 변경시 추적이 불가능하다.
    • 예) 숫자 100 → 200 변경하면, 완전히 다른 값으로 대체된 것

 

값 타입 분류

  • 기본값 타입
    • 자바 기본 타입 (int, double ...)
    • Wrapper class (Integer, Double ...)
    • String
  • 임베디드 타입 (embedded type, 복합 값 타입)
  • 컬렉션 값 타입 (collection value type)

 

다시 기본 값 타입이란

앞서 설명한 바와 같이 엔티티 객체에 속하는 자바 기본 타입이나 객체 형태의 필드라고 생각하면 된다.

 

  • 예) String name, int age 같은 필드들
  • 생명 주기엔티티에 의존한다.
    • 예) 회원 엔티티 삭제하면, 이름과 나이 필드도 같이 삭제
  • 값 타입은 절대로 공유하면 X
    • 예) 회원 이름 변경시 다른 회원 객체의 이름이 같이 변경되면 안된다.
참고: 자바의 기본 타입은 절대 공유되지 않는다.
  • int, double 같은 기본 타입(primitive type)은 절대 공유 X
  • 기본 타입은 항상 값을 복사한다.
  • Integer 같은 wrapper 클래스나 String 같은 특수 클래스는 공유 가능한 객체(래퍼런스를 긁어간다)이지만, 값을 변경할 수 없다. (변경 자체를 불가능하게 만들어 side effect 막는다)

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

굳이 우리말로 풀어 설명하면, 내장 타입이라고도 할 수 있는 임베디드 타입은 

  • 새로운 값 타입을 직접 정의할 수 있다.
  • JPA는 임베디드 타입(embedded type)이라고 부른다.
  • 간단히 설명하면, 여러 개의 기본값을 모아 만든 복합 값 타입이라고 한다.
  • int, String과 같은 값 타입이다.

 

임베디드 타입은 다음과 같이 묶어서 저장할 수 있을 것 같은 기본 값 타입들을 실제로 묶어 관리하기 위해 사용할 수 있다.

{시작일, 종료일}, {주소 도시, 주소 번지, 주소 우편번호} (출처: Java 표준 ORM JPA 프로그래밍 - 기본편 강의)

위와 같이 근무 시작일, 종료일 정보와 주소 관련 값 타입들은 묶어서 관리하면 좋을 것 같고, 실제로 도메인을 설명할 때에도 편리하게 설명하기 위해 다음과 같이 추상화를 거쳐서 표현한다.

(출처: Java 표준 ORM JPA 프로그래밍 - 기본편 강의)

일반적으로 위 그림과 같이 "회원 엔티티는 이름, 주소, 근무 기간 정보를 갖는다."라고 추상화를 거쳐서 설명하는 것이 편리하다.

이렇게 Period, Address를 따로 만들어서 복합 값 타입으로 관리하는 것을 의미 (출처: Java 표준 ORM JPA 프로그래밍 - 기본편 강의)

쉽게 얘기해서 클래스를 따로 만들어서 기본 값 타입을 묶어 관리하는 것이다.

 

임베디드 타입 사용시 장점

  • 재사용성 증가!
  • 높은 응집도
  • Period.isWork() 처럼 해당 값 타입만 사용하는 의미있는 메서드 설계가 가능해진다. (근무 시간 값 타입만 활용한 메서드)
  • 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명 주기를 의존한다.

 

그렇다면 어떻게 임베디드 타입으로 만들 수 있을까?

  • @Embeddable: 값 타입을 정의하는 곳에 표시
  • @Embedded: 값 타입을 사용하는 곳에 표시
  • 기본 생성자는 필수로 있어야
  • 둘 중 하나만 있어도 되지만, 둘을 같이 사용하도록 하자.

임베디드 타입을 사용하지 않는 경우

우선 임베디드 타입을 사용하지 않는 기존 방식은 위와 같이 주소와 근무 시간 정보를 각각의 필드로 풀어서 위와 같이 코드를 작성해야할 것이다.

 

위의 코드를 다음과 같이 변경할 수 있다.

위와 같이 임베디드 타입으로 사용될 클래스를 분리해서 정의한 후 Embeddable 어노테이션을 명시했다.

실행했을때 결과를 보면, 테이블 입장에서는 임베디드 타입을 사용한다고 해서 칼럼이 변경된다거나 하지는 않음을 확인할 수 있다.

테이블 create query가 날라가는 모습

이전과 다르게 다음과 같이 약간의 객체 지향적인 데이터 추가도 가능하다. (뿐만 아니라 특정 데이터 타입만을 이용한 메서드 설계등 조금 더 유연한 설계가 가능해진다 + 재사용성도 증가하겠지?)

임베디드 타입에 해당하는 객체를 활용해서 데이터를 추가하는 방식
DB에는 이렇게 데이터가 추가된다.

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

  • 임베디드 타입은 그저 엔티티의 값일 뿐
  • 앞서 정리한 바와 같이 임베디드 타입을 활용해도 테이블의 형태는 변하지 않는다.
  • 객체와 테이블을 아주 세밀하게 (find-grained) 매핑하는 것이 가능
  • 잘 설계한 ORM 애플리케이션은 매핑한 테이블 수보다 클래스 수가 더 많다고..
  • 임베디드 타입 값이 null이면, 그 안에 매핑한 컬럼은 모두 값이 null이다.

 

임베디드 타입안에 임베디드 타입 필드가 포함될 수 있으며, 엔티티 또한 포함될 수 있다!

 

한 엔티티에서 같은 값 타입을 사용하면??

  • 컬럼명이 중복된다.
  • 이런 경우 @AttributeOverrides, @AttributeOverride를 사용해 컬럼명 속성을 재정의 할 수 있다.

값 타입과 불변 객체

임베디드 타입 같은 값 타입을 사용하는 경우, 여러 엔티티에서 임베디드 값 타입을 공유하는 상황에서 문제가 발생한다.

회원 1에 대해서 수정하면, 회원 2의 정보까지 변경된다.. (출처: Java 표준 ORM JPA 프로그래밍 - 기본편 강의)

이런 side effect로 인한 버그는 찾기가 정말 어렵다고 한다. 

 

  • 값 타입의 실제 인스턴스를 공유하는 것은 따라서 위험한 행위이다.
  • 참조를 갖다 쓰는 것이 아니라 복사한 후에 사용해야 한다. (깊은 복사를 해야될 것 같다)

이렇게 복사해서 사용해야 된다. (출처: Java 표준 ORM JPA 프로그래밍 - 기본편 강의)

 

  • 임베디드 타입같이 직접 정의해서 사용하는 값 타입은 자바 기본 타입이 아닌, 객체 타입
  • 따라서 자바 기본 타입과 달리 참조 값을 직접 대입하는 것을 막을 수 없다.
    • 기본 타입은 값을 대입하면 복사하는 방식으로 동작
  • 객체의 공유 참조는 피할 수 없다.

복사해서 사용하면 해결할 수 있지만, 협업 도중에 누군가 복사하는 것을 잊고 사용한다면..?

 

기본 타입

int a = 10;
int b = a; //기본 타입 사용시 값을 복사
b = 4; // a != b

객체 타입

Address A = new Address("old");
Address B = A; //객체 타입은 참조를 전달
b.setCity("new"); // A.getCity() == B.getCity()

 

불변 객체

  • 이러한 상황을 해결하기 위한 것이 불변 객체이다. 
  • 객체 타입을 수정할 수 없게 만들어버리면, side effect 원천 차단!!
  • 불변 객체는 생성 시점 이후에는 절대 값을 변경할 수 없는 객체를 의미한다.
  • 어떻게?
    • 생성자로만 값을 설정하고, setter를 만들지 않으면 된다! (임베디드 객체를 만들때 이렇게 VO로 만들면 될 것 같다)
    • 찾아보니 VO(Value Object)가 이 조건과 같이 생성자로만 필드의 값을 초기화하고, setter를 별도로 만들지 않는 객체를 의미한다고 한다. 따라서 임베디드 값 타입을 VO 역할에 맞게 구현하면 좋을 것 같다는 생각을 했다.
  • 참고: Integer, String은 자바가 제공하는 대표적인 불변 객체이다!! (Wrapper class나 String class를 필드 선언에 사용해도 괜찮은 이유)

결론

불변이라는 작은 제약으로 큰 재앙을 막자. 모든 값 타입들을 불변으로 만들자..

 


값 타입의 비교 (동등성 비교)

값 타입은 인스턴스가 다르더라도 그 안의 값이 같으면 같은 것으로 취급해야 한다! (완전 VO의 개념과 같은 것 같다.)

 

예를 들어 다음과 같은 경우 두 임베디드 값 타입이 서로 같다고 나와야 한다.

Address addr1 = new Address("city", "street", "1000");
Address addr2 = new Address("city", "street", "1000");

addr1 == addr2 -> true가 되어야

하지만, 실제로 코드를 실행하면, false 결과가 나올 것이다. (참조 값을 비교할 태니깐)

 

이러한 비교를 동일성 비교라고 하는데, 우리는 동등성 비교를 해야한다!

  • 동일성(identity) 비교: 인스턴스의 참조 값을 비교, == 사용
  • 동등성(equality) 비교: 인스턴스의 값을 비교, equals() method 사용
  • 따라서 값 타입은 a.equals(b)를 사용해 동등성 비교를 해줘야 한다.
  • 그렇기에 우리는 값 타입의 equals method를 적절하게 overriding 해야함 (주로 모든 필드를 사용한다고 함)

동등성 비교를 위한 equals method overriding

위와 같은식으로 equals method를 overriding 해서 동등성 비교를 하도록 세팅해줘야 한다.


값 타입 컬렉션

값 타입을 컬렉션에 담아서 쓰는 것을 의미한다. (일대다 관계에서 매핑을 위한 entity 리스트 사용과는 또 다른 값 타입을 컬렉션에 담아서 사용하는 형태를 뜻한다고 한다.)

 

하지만, DBMS들은 기본적으로 컬럼의 값으로 자료구조 형태의 데이터를 넣을 수 없다. (최근에 json 타입을 일부 지원한다 하더라도 배열 같은 형태의 데이터 저장은 불가)

 

따라서 별도의 테이블을 이용해 자료를 저장해야 한다.

 

값 타입 컬렉션은

  • 한 개 이상의 값 타입을 저장하고 싶을 때 사용한다.
  • @ElementCollection, @CollectionTable 어노테이션을 사용한다.
  • 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없기 때문에, 별도의 테이블을 만들어 값 타입 컬렉션을 저장해야 한다.

 

다음과 같은 코드를 통해 Member entity에 값 타입 컬렉션을 적용해봤다.

두 개의 새로운 값 타입 컬렉션 필드

두가지의 값 타입 컬렉션 필드를 선언해 봤는데, 하나는 지난번에 만든 Address 임베디드 값 타입을 Set 자료구조로 표한한 필드이고, 나머지 하나는 favoriteFoods라는 문자열 리스트에 대한 필드이다.

 

두 개의 값 타입 컬렉션을 위와 같이 @ElementCollection, @CollectionTable annotation을 이용해 새로운 테이블을 통해 필드 값을 관리할 수 있도록 지정했다.

 

@ElementCollection 어노테이션은 해당 필드가 값 타입 컬렉션임을 명시하고, @CollectionTable 어노테이션은 값 타입 컬렉션이 매핑될 테이블에 대한 정보를 지정하기 위해 사용한다. (특히 JoinColumn 어노테이션을 활용해 각 테이블에서 사용할 FK를 지정했다.)

세개의 테이블은 위와 같은 create sql문을 통해 생성된다. Member table에 생성된 city, street, zipcode 필드는 지난번에 Address라는 임베디드 값 타입을 이용한 칼럼 매핑 과정으로 생성된 필드들임에 주의하자.

 

값 타입 컬렉션으로 하여금 생성되는 테이블은 다음과 같은 특징을 갖는다.

  • 원래 속한 테이블의 PK를 FK로 갖는다.
  • 테이블의 모든 칼럼을 합쳐 PK로 사용한다.
  • 따라서 별도의 식별자를 생성해 사용하지 않는다.

 

값 타입 컬렉션을 사용해보자.

앞서 설계한 entity 객체를 이용해 값 타입 컬렉션에 데이터를 추가하는 예제를 다음과 같이 작성했다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();

tx.begin();

try{
    Member member = new Member();
    member.setName("kang");
    member.setHomeAddress(new Address("city1","street", "zipcod1"));

    member.getFavoriteFoods().add("치킨");
    member.getFavoriteFoods().add("피자");
    member.getFavoriteFoods().add("삼겹살");

    member.getAddressHistory().add(
            new Address("old city1","street", "zipcod1"));
    member.getAddressHistory().add(
            new Address("old city2","street", "zipcod1"));

    em.persist(member);

    tx.commit();
}

간단하게 컬렉션 필드를 이용한 데이터 추가 예시이다.

결과는 위와 같이 테이블에 작성된다.

기대했던 대로 테이블로 만들어진 값 타입 컬렉션에 데이터가 잘 추가된다. (member_id 칼럼에 매핑도 잘 되었다)

 

값 타입 컬렉션은 Member entity를 영속화하면, 라이프사이클이 같이 돌아서 같이 저장되는 흥미로운 결과를 확인할 수 있었다. (필드(기본 값 타입)랑 똑같은 취급을 하기 때문에 라이프사이클이 엔티티에 의존한다고 볼 수 있었다)

 

참고: 값 타입 컬렉션은 영속성 전이(cascade) + 고아 객체 제거 기능을 필수로 가진다고 생각하면 된다!

 

이번에는 조회를 해보자.

Member findMember = em.find(Member.clss, member.getId());

코드는 대략 위와 같이 그냥 조회하는 단순한 코드를 사용했다.

조회 결과 신기하게도, 임베디드 값 타입은 한 번에 조회해오는 반면, 테이블로 매핑된 값 타입 컬렉션은 지연로딩 하는 방식을 취한다는 것을 확인할 수 있었다.

 

이렇게 코드를 자서 지연 로딩을 확인해보자.

빨간 부분에서 값에 접근할때, 실제 테이블의 데이터를 조회한다.
조회 쿼리는 각각 이렇게 나간다.

마지막으로 수정을 해보자.

수정을 위한 코드는 이렇게

  • 자바 컬렉션에서 remove할때, 기본적으로 동일성이 아닌 동등성 비교를 한다고 함
  • 따라서 같은 값의 새로운 객체를 생성해서 기존의 객체를 컬렉션에서 제거하는 방식을 따랐다.
  • 새로운 객체를 컬렉션에 추가할때도 마찬가지로 새로운 객체를 생성해서 넣어주는 방식

이렇게 추가를 했더니, 쿼리가 신기하게도

뭐가 아주 많이 날라간다

실무에서 이런 경우가 발생하면, 굉장히 머리가 아프다고 한다..

 

이렇게 쿼리가 날라가는 이유는 기본적으로 값 타입 컬렉션에서 기존 값을 수정할때, 해당 member_id에 맞는 모든 데이터를 삭제한 뒤에 다시 추가하는 방식을 취하기 때문이라고 한다.

 

따라서 원하는 결과대로 DB에 데이터가 반영은 되었으나, 이를 위해서 모든 데이터가 내려갔다가 다시 올라가는 현상이 벌어졌다.

 

정리

  • 값 타입은 엔티티와 다르게 식별자 개념이 없음
  • 따라서 값을 변경하는 경우 추적이 어렵다
  • 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장하는 방식
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 칼럼을 묶어 기본키를 구성해야 한다. → not nullable, 중복 X

 

값 타입 컬렉션의 대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신 일대다 관계를 고려한다고 함
  • 일대다 관계를 위한 엔티티를 만들어 여기서 값 타입을 사용
  • 영속성 전이(cascade) + 고아 객체 제거를 사용해 값 타입 컬렉션처럼 사용이 가능
  • ex) Address를 감싼 AddressEntity를 만들어 사용

값 타입을 엔티티로 "승급"해서 사용 (김영한 님이 실무에서 많이 사용하는 방법이라고 한다)

일대다 매핑을 해준다.

위와 같이 AddressEntity라는 클래스를 새로 만들어 값 타입 컬렉션을 엔티티로 승급시켰다.

AddressEntity 클래스 설계

이렇게 일대다 관계로 바꿔서 값 타입 컬렉션을 변환해주고, 영속성 전이 + 고아 객체 제거를 활용해서 더 효율적으로 관리할 수 있다고 한다.

Address 테이블에 ID가 추가되어 엔티티로 승급했음을 확인할 수 있다.

 

정리

  • 엔티티 타입의 특징?
    • 식별자가 있다.
    • 생명 주기를 관리
    • 공유
  • 값 타입의 특징
    • 식별자가 없다.
    • 생명 주기를 엔티티에 의존
    • 공유하지 않는 것이 안전하다 (복사해서 사용해야만, 불변 객체로)
    • 불변 객체로 만들어 사용하는 것 안전

값 타입은 정말 값 타입이라 판단될 때에만 사용하자!

식별자가 필요하고, 지속해서 값을 추적하고 변경해야 할 때에는 엔티티로 사용하고, 그렇지 않은 경우에만 값 타입으로 만들어 사용하는 것이 좋다!

728x90

'Back-end > java spring' 카테고리의 다른 글

[JPA] JPQL 2 - 중급 문법 정리  (0) 2023.02.24
[JPA] JPQL 1 - 기본 문법 정리  (0) 2023.02.22
[JPA] 프록시  (0) 2023.02.15
[JPA] 고급 매핑  (0) 2023.02.06
[JPA] 다양한 연관관계 매핑  (0) 2023.02.01

댓글