응애개발자
article thumbnail
Published 2023. 7. 16. 01:11
값 타입 Spring/JPA
728x90

JPA 데이터 타입은 크게 2종류이다.(엔티티타입, 값타입)

 

엔티티 타입(식별자를 통해 지속해서 추적 가능)

  • @Entitiy로 정의하는 객체

값 타입(식별자가 없고 숫나나 문자같은 속성만 있으므로 추적 불가능)

  • 기본값 타입
    - 자바 기본 타입(int, double)
    - 래퍼 클래스(Integer)
    - String
  • 임베디드 타입(복합 값 타입)
  • 컬렉션 값 타입

비유를 하자면 엔티티 타입은 생물이고, 값 타입은 단순히 수치 정보다.

 

1. 기본값 타입

기본값 타입

위에서 String, int가 값 타입이다. id라는 식별자 값도 가지고 생명주기도 있지만 name, age속성은 식별자 값도 생명주기도 회원 엔티티에 의존한다.따라서 회원 엔티티 인스턴스를 제거하면 name, age값도 제거된다.

 

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

기본 회원 엔티티

위 코드는 단순히 정보를 풀어둔 것 뿐이다. 그리고 근무 시작일과 우편번호는 아무 관련이 없다. 이것보단 다음 코드들 처럼 명확하게 나누어서 분류하는 것이 응집력도 높이고 결합력도 낮추는 방법이다.

위 코드를 근무기간, 집 주소를 가지도록 임베디드 타입을 적용해보자.

값 타입 적용 회원 엔티티
기간 임베디드 타입
주소 임베디드 타입
회원 - 컴포지션 관계 UML

위처럼 더욱 의미 있고 응집력 있게 변한 것을 알 수 있다.

새로 정의한 값 타입들은 재사용할 수 있고 응집도도 아주 높다. 

 

임베디드 타입을 사용하려면 2가지 어노테이션이 필요하다

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

그리고 임베디드 타입은 기본 생성자가 필수다. 엔티티와 임베디드 타입의 관계를 UML로 표현하면 컴포지션관계가 된다. 

 

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

회원 - 테이블 매핑

잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다. 지루한 반복 작업으S JPA에 맡기고 더 세밀한 객체지향 모델을 설계하는데 집중해야 한다.

 

임베디드 타입과 연관관계

임베디드 타입과 연관관계
임베디드 타입과 연관관계

@AttributeOverride : 속성 재정의

같은 임베디드 타입을 가지고 있는 회원

위처럼 사용했을때 문제는 테이블에 매핑하는 컬럼명이 중복된다.

임베디드 타입 재정의
생성된 테이블

하지만 위처럼 사용하면 어노테이션을 너무 많이 사용해서 엔티티 코드가 지저분해진다. 다행히도 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일은 많지 않다.

 

임베디드 타입과 null

임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null이다. 위처럼 사용한다면 회원 테이블의 주소와 관련된 CITY, STREET, ZIPCODE 컬럼 값은 모두 null이 된다.

 

3. 값 타입과 불변 객체

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

 

값 타입 공유 참조

값 타입 공유 참조

위 상황을 코드로 나타내면

이렇게 나타난다. 위 코드를 실행하면 회원2 랑 회원 1 두 주소 모두 NewCity로 변경된다. 그 이유는 회원1과 회원 2 모두 같은 address인스턴스를 참조하기 때문이다. 영속성 컨텍스트는 회원1,2 둘 다 city 속성이 변경된 것으로 판단해서 회원 1, 2 각각 UPDATE SQL을 실행한다. 이렇듯 예상치 못한 곳에서 문제가 발생하는 것을 부작용(side effect) 이라 한다.

 

값 타입 복사

값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하므로 값을 복사해서 사용해야 한다.

값 타입 복사
위를 코드로 구현

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

자바는 기본 타입에 값을 대입하면 값을 복사해서 전달한다.

최종 결과는 a = 10, b = 4이다. 

문제는 Address 같은 객체 타입이다.

Address b = a 에서 참조하는 인스턴스의 참조 값을 b에 넘겨준다. 따라서 a와 b는 같은 인스턴스를 공유 참조한다. 그러므로 위의 결과는 a = New , b = New가 된다.

물론 객체를 대입할 때마다 인스턴스를 복사해서 대입하면 공유 참조를 피할 수 있다. 문제는 복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다는 것이다.

객체의 공유 참조는 피할 수 없다. 따라서 근본적인 해결책이 필요한데 가장 단순한 방법은 객체의 값을 수정하지 못하게 막으면 된다. ex) Address 의 setCity() 같은 수정 메서드를 모두 제거하는 것이다. 이렇게 한다면 공유 참조를 해도 값을 변경하지 못하므로 부작용의 발생을 막을 수 있다.

 

불변 객체

객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용의 원천을 차단할 수 있다. 따라서 값 타입은 될 수 있으면 불변 객체로 설계해야 한다. 불변 객체를 구현하는 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않으면 된다. Address를 불변 객체로 만들어보자.

주소 불변 객체
불변 객체 사용

위의 Address는 불변 객체이다. 값을 수정할 수 없으므로 공유해도 부작용이 발생하지 않는다. 값을 수정해야 하면 위처럼 새로운 객체를 생성해서 사용해야 한다.

**참고로 Integer, String은 대표적인 불변 객체다.

정리하자면 불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다.

 

4. 값 타입의 비교

Address 값 타입을 a == b 로 비교하면 서로 다른 인스턴스 이므로 결과는 거짓이다. 값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다. 따라서 값 타입을 비교할 때는 a.equals(b)를 사용해서 동등성 비교를 해야 한다. 물론 Address의 equals() 메서드를 재정의해야 한다. 값 타입의 equals() 메서드를 재정의 할 때는 모든 필드의 값을 비교하도록 구현해야 한다.

**추가로 equals() 를 재정의 하려면 hashCode() 도 재정의 하는 것이 안전하다.

5. 값 타입 컬렉션

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

값 타입 컬렉션
값 타입 UML
값 타입 컬렉션 ERD

관계형 DB 테이블은 컬럼 안에 컬렉션을 포함할 수 없다. 따라서 위 ERD 처럼 별도의 테이블을 추가 하고 @CollectionTable을 사용해서 추가한 테이블을 매핑해줘야 한다. 

그리고 favoriteFoods처럼 값으로 사용되는 컬럼이 하나면 @Column을 사용하여 컬럼명을 지정할 수 있다.

addressHistory는 임베디드 타입인 Address를 컬렉션으로 가저간다. 이것도 마찬가지로 별도의 테이블을 사용해야 한다. 매핑 정보는 @AttributeOverride를 사용해서 재정의 할 수 있다.

값 타입 컬렉션 사용

값 타입 컬렉션 등록

마지막에 member 엔티티만 영속화했다. JPA는 member 엔티티의 값 타입도 함께 저장한다.

 

실제 실행되는 INSERT SQL

  • member : INSERT SQL 1번
  • member.homeAddress : 임베디드 값 타입으로 회원 테이블을 저장하는 SQL에 포함된다.
  • member.favoriteFoods : INSERT SQL 3번
  • member.addressHistory : INSERT SQL 2번

따라서 총 6번의 INSERT SQL을 실행한다.

 

실행된 SQL

값 타입 컬렉션도 페치 전략을 선택할 수 있는데 LAZY가 기본이다.

지연 로딩
조회

위를 실행할 때 DB에 호출하는 SELECT SQL은 다음과 같다.

  1. member : 회원만 조회한다. 이때 임베디드 값 타입인 homeAddress도 함께 조회한다 SELECT SQL 1번 호출
  2. member.homeAddress : 1번에서 회원을 조회할 때 같이 조회해 둔다.
  3. member.favoriteFoods : LAZY로 설정해서 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.
  4. member.addressHistory : LAZY로 설정해서 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.

다음은 컬렉션을 수정해보자.

수정

  1. 임베디드 값 타입 수정 : homeAddress 임베디드 값 타입은 MEMBER 테이블과 매핑했으므로 MEMBER 테이블만 UPDATE 한다. 사실 member 엔티티를 수정하는 것과 같다.
  2. 기본값 타입 수정 : 탕수육을 제거하고 치킨을 추가해야 한다. 자바의 String 타입은 수정할 수 없다.
  3. 임베디드 값 타입 컬렉션 수정 : 값 타입은 불변해야 하므로 컬렉션에서 기존 주소를 삭제하고 새로운 주소를 등록했다. 참고로 값 타입은 equals, hashCode를 꼭 구현해야 한다.

 

값 타입 컬렉션 제약사항

엔티티는 식별자가 있으므로 식별자로 원본 데이터를 쉽게 찾을수 있지만 값 타입은 단순한 값들의 모음이므로 DB에서 원본 데이터를 찾기 어렵다. 이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면, 값 타입 컬렉션이 매핑된 테이블의 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 DB에 다시 저장한다.

 

따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.

추가로 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 따라서 DB 기본 키 제약 조건으로 인해 컬럼에 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약도 있다.

 

지금까지 설명한 문제들을 해결하려면 값 타입 컬렉션 대신 새로운 엔티티를 만들어 1: N 관계로 설정하고 추가로 Cascade, 고아 객체 제거 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.

값 타입 컬렉션 대신 1: N 관계 적용

 

설정 코드

값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다. 특히 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안 된다. 식별자가 필요하고 지속해서 값을 추적하고 구분하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.!

'Spring > JPA' 카테고리의 다른 글

객체지향 쿼리 언어(2) Criteria  (0) 2023.07.17
객체지향 쿼리 언어 (1) 객체지향 쿼리 소개, JPQL  (0) 2023.07.16
프록시와 연관관계 관리  (0) 2023.07.15
고급 매핑  (0) 2023.07.14
다양한 연관관계 매핑  (0) 2023.07.13
profile

응애개발자

@Eungae-D

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!