본문 바로가기

Spring/JPA

[JPA] - 값 타입

728x90

<< JPA에서 제공하느 값 타입의 종류, 임베디드 타입에 대해 인지하고, 불변객체를 왜 사용하는지, 언제 어떻게 사용하는지 알아보자>>

 

1) 엔티티 타입

 - @Entity 로 정의하는 클래스 객체

- 데이터가 변해도 식별자로 지속 추적 가능 > PK 값으로 구분 가능

 

2) 값 타입

- 변경시 추적이 어려운, 단순한 값으로 사용되는 자바 기본 타입 / 객체

 

 

값타입 

기본값 / 임베디드 타입 / 컬렉션 타입 으로 나뉨 >> 값타입은 항상 불변객체로 한다라고 그냥 생각해도 됨 (아래 나옴)

 

기본값 - 생명 주기가 엔티티에 의존된다. 

 

기본값, 기본 타입 은(int, double) 은 공유가 안된다. 

int a= b

int b= a

a = 20;

출력해보면 a =20, b = 10 이 된다. 

 

하지만 클래스 (래퍼 클래스처럼) 

 

Integer a= new Integer(10);

Integer b = a;

a.setValue(20);  // 이런거 없는데 한다고 치자

출력해보면 a=b=20 이라고 한다.

레퍼런스가 넘어가서 같은 인스턴스를 a,b 가 공유하고, 그 주소를 받아서 사용하기 때문이다. 

 

 

<< 임베디드 타입 개요 >>

 

Member - id, name, startDate, endDate, zipcode, street, city 의 값들을 가지고 있다!

>> 뭔가 이상함. 이렇게 말하기 보단

멤버는 이름 / 근무 기간 / 집 주소에 대한 값을 가지고 있다! 라고 표현하는게 맞음. 

Member - id, name, Period workPeriod, Address homeAddress 이렇게 표현하는게 객체지향상 맞음!

 

JPA 에서는 이런 경우를 , Embedded Annotation 을 통해서 한다. 

 

>>>> 장점 : 당연히 객체지향 특징처럼 재사용이 가능하다. 응집도가 높다. 해당 값타입에서만 할 수 있는 논리 메소드를 만들 수 있다. ex) Period.isWorking() 등. 

 

<4min 40 초 대 설명>

회원테이블은 바뀔게 없음 >> 하지만 JPA Entity 클래스상에서 바뀌는 것임. 매핑을 해주는 방식에 차이가 있음. (이런게 객체와 테이블의 차이임!) 

 

@Entity
@Data
public class Employees {

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

    private String username;
    
    // 기간
    private LocalDateTime startDate;
    private LocalDateTime endDate;

    // 주소
    private String city;
    private String street;
    private String zipcode;
    
}

 다음과 같이 정의되었다고 해보자. DB를 실행해보면 그냥 생각하는대로 들어가 있는 것을 볼 수 있다. 

 기간을 Period period로, 주소를 Address address 로 변경후 생성을 해보자. 

@Data
@Embeddable
@NoArgsConstructor
public class Period {
    private LocalDateTime startDate;
    private LocalDateTime endDate;
}

@Data
@Embeddable
@NoArgsConstructor
public class Address {
    private String city;
    private String street;
    private String zipcode;
}

기본생성자는 필수이며, Embeddable annotation 을 사용할 수 있다. 그리고 Employee 클래스를 다음과 같이 바꿀 수 있다.

@Entity
@Data
public class Employees {

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

    private String username;

    @Embedded
    private Period workPeriod;
//    private LocalDateTime startDate;
//    private LocalDateTime endDate;

    @Embedded
    private Address homeAddress;
//    private String city;
//    private String street;
//    private String zipcode;

}

DB를 실행해보면, 임베디드 클래스를 만들기 전과 테이블 상 아무 차이가 없음을 알 수 있다. 

이게 제일 중요함 ==> 테이블은 동일함. 

 

> 하지만 테이블에 좀 더 객체지향스럽게 매핑이 가능해진 것!

> 매핑 테이블 수보다 내부 객체지향 클래스 수가 더 많을 수록 활용도가 높은 것!

 

만약에 두개를 써야 한다면? 

 

public class Employees {
    ...
    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;
    private Address workAddress;
}

이거 그대로 돌려보면 에러가 발생함. 칼럼명들이 다 똑같은 상태에서 동일하게 넣으려 하기 때문. 

@AttributeOverride 를 사용하면 됨. 

 

public class Employees {
    ...
    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;
    
    @Embedded
    @AttributeOverrides({ // 동일 칼럼 하나하나에 대해 다 지정해줘야 함
            @AttributeOverride(name = "city", column = @Column(name = "work_city")),
            @AttributeOverride(name = "street", column = @Column(name = "work_street"))
    })
    private Address workAddress;
}

동일한 칼럼 하나하나에 대해서 새로운 칼럼명들을 위와 같이 지정해주면 됨. 

 

 

 

<< 불변 객체 >>

 

임베디드 타입 같은 값을 여러 엔티티 상에서 공유하면 위험하다. 다음과 같은 예제를 보자. 

 

@Test
@DisplayName("Embedded Type Dangerous")
void test2() {
    tx.begin();
    try {
        Address address = new Address("city", "street");

        Employees emp1 = new Employees();
        emp1.setUsername("EMP1");
        emp1.setHomeAddress(address);

        Employees emp2 = new Employees();
        emp2.setUsername("EMP2");
        emp2.setHomeAddress(address);

        em.persist(emp1);
        em.persist(emp2);

        // 아 emp2 의 street 은 달랐지 하고 바꾸려 함
        emp2.getHomeAddress().setStreet("Another Street");
            
        tx.commit();
    } catch (Exception e) {
        e.printStackTrace();
        tx.rollback();
    } finally {
        em.close();
    }
    emf.close();
}

저걸 의도해서 같이 쓰고 싶은 거면, 값타입을 쓰면 안됨. 엔티티를 사용해야 하는 거임. 

위와 같이 공유를 하면 안되고, 복사해서 만들어서 사용해야 함. 

try {
    Address address = new Address("city", "street");

    Employees emp1 = new Employees();
    emp1.setUsername("EMP1");
    emp1.setHomeAddress(address);

    Address copiedAddress = new Address(address.getCity(), address.getStreet());
    Employees emp2 = new Employees();
    emp2.setUsername("EMP2");
    emp2.setHomeAddress(copiedAddress);

    em.persist(emp1);
    em.persist(emp2);

    // 아 emp2 의 street 은 달랐지 하고 바꾸려 함
    emp2.getHomeAddress().setStreet("Another Street");

    tx.commit();
}

다음과 같이 새로운 객체를 생성하고 복사값을 넣어준 뒤로 세팅을 해주게 되면, 나중에 바꿨을 때도 아무 문제가 없다. 

 

자 한번만 다시 복습하자면, 기본 값 타입은 공유시 값을 복사해서 넣어주고, 객체 값 타입은 공유시 참조값 (주소)  넣어준다. 이래서 객체 값타입은 같이 변경이 될 수 있는 거다. 

> 이건 막을 수가 없음, 위에 복사 객체를 만드는것도 조금 이상해보이기도 하고, 깜빡할 확률이 굉장히 높음. 

 

 따라서, 아예 못하게 해버려야 한다 . 그런 설계를 불변 객체라고 한다

값 타임은 불변 객체로 설계를 해야 함. 1) 생성자로만 값을 수정하고, Setter를 만들지 않는다. 2) Setter를 Private 으로 선언한다 (내부 활용시에만 바꿀 수 있도록) >> 그래야 위와 같이 해줬을 때 개발자가 "뭔가 잘못되었다", "이렇게 하면 안되는구나" 라는걸 인지할 수 있음. >> 참고로, String 과 Integer 는 대표적인 Java 가 제공하는 불변객체이다. 

 

이렇게 하면 (위에선 복사객체?를 만들었기 때문에 괜찮지만) 위에 emp2.getHomeAddress().setStreet("~~"); 은 에러가 발생한다. Address 는 불변객체이기 떄문이다.

 

그렇다면 바꾸고 싶은디 어떻게 바꿔야 함?? Address 하나만 써도 못바꿈!!

다음과 같이 바꾸면 됨. 

try {
    Address address = new Address("city", "street");

    Employees emp1 = new Employees();
    emp1.setUsername("EMP1");
    emp1.setHomeAddress(address);
    
    // 다시 만들어야함 ㅋㅋㅋㅋㅋ
    Address newAddress = new Address("NEW CITY", address.getStreet());
    emp1.setHomeAddress(newAddress); // 이건 당연히 됨. Address가 불변객체 
    
    em.persist(emp1);
    tx.commit();
}

다시 쓰면 됨 ㅋㅋㅋ. 이렇게 하는게 맞음. 

 

<< 값 타입의 비교>> 

 

int a = 10, int b = 10 >> a == b 시 TRUE (기본 값 타입은 그 내부 값이 같으면 같다고 취급) 

Address a = new Address("ㅈㅈ"), b = new Address("ㅈㅈ"), >> a ==b 시 당연히 False. 다른 객체이기 때문 ㅇㅇ (당연한거) 

 

객체랑 기본 값은 저장되는 위치가 다르기 때문에 비교되는 방식도 다름. 

 

-- 동일성 비교와 동등성 비교를 구분해야 함. 

동일성 (== 비교) , 동등성 (.eqauls() 비교) 

자 방금전에 한 a, b 비교, equals 로 하면 어떨까?

a.equals(b)  :  당연히 False 나옴. equals 비교는 기본이 == 비교이기 때문임. 

따라서 동등성 비교를 따로 구분 짓기 위해서, 동등성 비교를 구분하려는 객체 클래스 내에서 .equals 를 Overriding 해야 함. 

 

Address 내부의 equals 를 overriding 해보자. (Overriding 항목 보면 [Generate equals() and hashCode()] 라는 항목이 있음. IntelliJ에서 오버라이딩 하는거면 95% 동등성 확인하는 거임으로 자동으로 해줌. Address 에 대해서 equlas 를 만들어라라고 한다면, 다음과 같이 만들 것이고, 이는 내부 값들이 같으면 true를 반환하는 형식으로 짜여져 있음을 알 수 있다. 

...
    @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(city, address.city) && Objects.equals(street, address.street);
    }

    @Override
    public int hashCode() { // 이건 모른다면 나중에 알아보면 되는디, 그냥 hashMap 등 자료구조에서 효율적인 사용을 위한 것임. 알아보면 좋을 듯
        return Objects.hash(city, street);
    }
}

이 상태에서 위에 a.equals(b) 를 해본다면 이제서야 True 가 나온다. 오버라이딩 된 함수가 동등성 비교 함수로 바뀌었기 때문이다. String 이 == 가 아니라 equlas 를 쓰는 것을 권장하는 이유이다. 

 

임베디드 타입은 대부분 직접 다 해야하는데, 그런 경우 동일성 비교가 아니라 동등성 비교로 바꿔주는게 좋다. 근데 생각보다 .equals() 쓸 일은 많진 않음 ㅋㅋ. 그냥 하다가, 뭔가 임베디드 타입 같음을 비교하고 있는 것 같을 떄 아! 그거가 있었지! 하면서 생각나서 동등성 비교할 수 있으면 성공임 ㅇㅇ

 

 

<< 값 타입 컬렉션>>

 

그냥 말그대로 컬렉션인데 값 타입이 들어가는 컬렉션임. (List<Address> , List<Period> 등) 

값 타입을 하나 이상 저장할 때 사용 

List<Address>, Set<String> 등등

 

컬렉션은 기본적으로 1:N 관계이기 때문에 편법(All String 변환 따위)을 쓰지 않는 한 같은 테이블에 저장시킬 수 없기 때문에, @ElementCollection annotation 을 사용하여 이것은 [값 타입 컬렉션] 이다라는걸 말해주고, 

@CollectionTable 을 통해 그 값타입을 따로 컬렉션으로 만들어 줄건데, 어떻게 만들어줄건지 지정해주면 된다. 가령, 위 Employee Class 를 다음과 같이 수정해볼 수 있다. 

 

@Entity
@Data
public class Employees {

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

    private String username;

    @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<>();
}

이 상태에서 실행하면, favorite_food 라는 테이블과 address 라는 테이블이 생겨있고, 각각 String, Address(임베디드 저장되듯이) 에 해당하는 칼럼들이 형성된 모습을 확인할 수 있다. 그리고 Join 할 키들을 넣어줄 수 있다. (member_id) 

 

사용 예제를 살펴보자. 

{
..
try {

    Employees employees = new Employees();
    employees.setUsername("emp1");
    employees.setHomeAddress(new Address("city", "street"));
    employees.getFavoriteFoods().add("치킨");
    employees.getFavoriteFoods().add("피자");
    employees.getFavoriteFoods().add("족발");

    employees.getAddressHistory().add(new Address("old1", "old1"));
    employees.getAddressHistory().add(new Address("old2", "old2"));
    em.persist(employees);
    tx.commit();
} catch (Exception e) {
..
}

그냥 생각하듯이 해당 클래스의 get을 통해서 adding 을 해주면 다음과 같이 DB에 잘 들어가 있는 모습을 확인할 수 있따. 

 

<<DB 모습>>

 

>> collection 만 하니까 다른 테이블인데도 불구하고 favorite_food 와 address 가 같이 persist 됨. 라이프 사이클이 같이 돌아감. <값 타입 컬렉션> 이기 때문. 값타컬도 생명주기가 Employee 에 소속됨. (String 도 값타입, Long 도 값 타입. 모두 Employee 에 의존하기 때문에, 컬렉션 값타입도 마찬가지임) Cascade All 처럼 알아서 들어가 있음. (고아 객체 제거 기능까지 다 들어가 있음) 

 

이번엔 저장 말고 조회에 대해 살펴보자. 다음과 같이 Test를 수정한다. 

 

{ ...
try {
    // ... 동일
    
    em.flush();
    em.clear();

    System.out.println("==================START ==================");
    Employees employees1 = em.find(Employees.class, employees.getId());

    tx.commit();
} catch (Exception e) {
    ...
}

Query 날라가는 것을 확인해보면, Employee 에 대한 값들만 가져오는 것을 알 수 있음.  컬렉션들은 다 ~ 지연 로딩이란 뜻. 

 

그 이후로 가지고 오고 출력해보자. 

System.out.println("==================START ==================");
Employees foundEmployee = em.find(Employees.class, employees.getId());
for (Address address :
        foundEmployee.getAddressHistory()) {
    System.out.println("address.getCity() = " + address.getCity());
}

그러고 쿼리 날라가는걸 보면, 그 이후로 Address에 대한 select 문이 또 나가고, where member_id = 조회 유저. 이 쿼리가 날라가는 것을 확인할 수 잇다. 그렇게 값타입 컬렉션도 지연로딩 전략을 사용하여 프록시를 사용하는 것을 알 수 있음. (들어가보면 @ElementCollection = Fetchtype.LAZY 로 default 설정되어 있음) 

 

이제 수정을 보자. 이 부분이 그나마 이해가 필요.

위에서 바로 말했지만, 값 타입은 ALL IMMUTABLE 이다. 불변 객체들임. 

 

==== 잠깐 ====

값 타입은 기본타입 / 임베디드 타입 / Collection타입 으로 나뉜다고 했다 .

값타입이 ALL IMMUTABLE 이란 무슨 소리냐

그냥 다 함부로 set 하면 안된다는 소리다. 

즉 제일 처음에 말했던 것으로 돌아간다 -> Entity 에 대해서는 함부로 Setter를 열어두면 안된다. String 같은거야 값 자체가 그 value 니까 set을 써도 된다 하지만, (지금 말하는건 관례적인 set을 말하는거임 (this.~  = ~ ; ), 이렇게 수정하면 그 값을 또다른 어디서 참조하고 있으면 같이 변경되는 에러가 발생할 수도 있다. (사실 어떤 에런지 본 적이 없으니 정확히 모르겠음) 

>> 기본 값 타입에 대해선 그 값을 변경해 줌으로써 변경이 충분하긴 하니까 set을 하는거랑 큰 차이 없는 행위지만, 임베디드 타입 같은 경우 반드시 새로 만들어서 넣어줘야 한다) (뭐 이렇게 내 생각을 적고 보니까 setter가 크게 문제있는 건 아닌듯. 다만 어떤과정을 통해서 set을 해줘야하는지 정확,. 아주 정확하게 알고 있어야 할 뿐) 

===============

 

따라서, 수정할 떄 다음과 같이 해볼 수 있다. 

 

@Test
@DisplayName("Embedded Type Collection Usage : Save and Update")
void test4() {
    ...
    try{
    Employees foundEmployee = em.find(Employees.class, employees.getId());

    // 복습!! City 바꾸고 싶음
    // foundEmployee.getHomeAddress().setCity("new City"); 이렇게 바꾸면 안됩니다.
    Address preAddress = foundEmployee.getHomeAddress();
    foundEmployee.setHomeAddress(new Address("new City", preAddress.getStreet())); // 통째로 바꿔야 함

    // 이제 컬렉션 UPDATE
    // Set<String> 을 치킨 > 한식으로 바꾸고 싶음
    foundEmployee.getFavoriteFoods().remove("치킨");
    foundEmployee.getFavoriteFoods().add("한식");
    // String 도 결국 Immutable 성질을 적용시켜야 해서, 사실 UPDATE 따윈 없음
    // 삭제하고 추가, 결국 갈아끼우는 것임

    tx.commit();
    }catch(){
    ...
}

 

기본 값 타입 컬렉션은 위처럼 해줄 수 있다. 그렇다면 임베디드 값 컬렉션 수정은 어떻게 할까? 갈아끼워야 한다고 했다. 

 

{ ...
    // 이젠 임베디드 컬렉션 Update : EQUALS 가 매우 중요한 타이밍
    // 기존 컬렉션 내의 특정 객체를 그대로 넣어줌으로써 삭제하라고 하는 것
    // 이제 .Equals 오버라이딩이 제대로 안되어 있으면 개판 나는것
    foundEmployee.getAddressHistory().remove(new Address("old2", "old2"));
    // 새거 넣어준다.
    foundEmployee.getAddressHistory().remove(new Address("new City", "old2"));
    tx.commit();
    ...
}

아직 끝이 아니다. 이 원리를 파악하려면, 제대로 SQL 까지 봐야 한다. SQL 문이 다음과 같이 나가는 것을 볼 수 있다. 

 

...
Hibernate: 
    /* delete collection jpa.demo.type.Employees.addressHistory */ delete 
        from
            address 
        where
            member_id=?
...
Hibernate: 
    /* insert collection
        row jpa.demo.type.Employees.addressHistory */ insert 
        into
            address
            (member_id, city, street) 
        values
            (?, ?, ?)
Hibernate: 
    /* insert collection
        row jpa.demo.type.Employees.addressHistory */ insert 
        into
            address
            (member_id, city, street) 
        values
            (?, ?, ?)
...

기존 Address 테이블에서 현재 묶여있는 employee_id 값을 가진 애들을 모~~~~~~~~두 삭제하고, 그 다음에 현재 그 Employee 가 들고 있는 객체들을 다시 다 넣어준다. 

 

느낌이 와야겠지만. 쿼리가 문제가 있는 거다. 결론적으론 맞게 나왔지만, 이거 이런식으로 동작하면 진짜 overwhelming 이 큰거다. (이 느낌 온다면 잘 공부하고 있는 거인듯)

 

왜 이런 일이 일어나는 걸까??

 

값은 변경하면 추적이 어려움. 따라서 값 컬렉션에 변경 사항이 생기면 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다 .

 

>>>>>>>> 결론 ㅋㅋㅋㅋㅋㅋ 쓰지 말아라 ^_^

 

연관 컬렉션은 식별자가 없기 때문에, DB에서 어떤 방식으로든 추적하기가 굉장히 어려움. (임베디드 말하는게 아니라 임베디드 컬렉션 말하는거임!!!) > 임베디드는 원래 테이블이 없기 때문에 id 가 없음. @ElementCollection 이거가 문제라는거임. 

 

해결방안이 있긴 한데, 그것도 문제가 있음. >> 걍 쓰지마. 그냥 쓰지말라면 쓰지마. 이렇게 복잡하게 갈거면 설계를 잘못한거야. 걍 쓰지마 ㅇㅇ

 

이정도로 갈꺼면 Entity 를 새로 만들어서 PK를 쥐어주는게 맞음. 그리고 1:N관계로 풀어내는게 맞음. 시발아. 당연한거 아니냐. 

 

즉, 이렇게 대안을 마련할 수 있다. 

 

@Entity
public class AddressEntity {
    @Id
    @GeneratedValue
    private Long id;
    
    @Embedded
    private Address address;
}

@Entity
public class Employees{
    ...
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "member_id")
    private List<AddressEntity> addressHistory = new ArrayList<>();
    ...
    
}

이렇게 들어오면 맘대로 수정해도 됨. 

실무에서는 값타입 컬렉션 대신에 일대다 관계를 우선적으로 고려를 해야한다. 진짜 조오옹ㄴ나 단순한거면 써도 되는데, 그럴바엔 String 같은 경우 Enum 이용하는게 나을듯.. 

 

 

정리

- 값타입은 정말 값타입을 써야겠다 할 때만 사용해라

- 엔티티와 값타입을 혼동하면 안됨. >>> 엔티티를 값타입으로 만들면 안됨!!! (오히려 배워서 더 헷갈린거 같긴함 ㅋㅋ)

- 식별자가 필요하고, 추적해야할 필요성을 느낀다면 진짜 무조건 엔티티임. 

 

 

 

 

728x90

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

[JPA] JPQL 쿼리 2 (FETCH JOIN의 등장)  (0) 2023.01.03
[JPA] JPQL 쿼리  (0) 2022.12.29
[JPA] 프록시와 연관관계 - 2  (0) 2022.12.28
[JPA] 프록시와 연관관계 정리  (0) 2022.12.26
[JPA] 연관관계 매핑 -2  (0) 2022.12.26