Programming-[Backend]/JPA

[JPA기본] 11. 값 타입

컴퓨터 탐험가 찰리 2021. 10. 23. 13:56
728x90
반응형

 

1. 값 타입 기본

 

엔티티 타입과 값 타입의 차이

이제껏 공부해온 엔티티 타입은 식별자(보통 id값)를 통해서 지속적으로 값을 추적할 수 있다. 어떤 엔티티의 특정 필드값이 setter 등으로 변경되면 JPA에서 식별자값을 통해 이를 감지하고 값을 변경해준다.

 

그러나 값 타입은 변경 시 추적이 불가하다. 그래서 유의해야될 점들이 있는데, 이 글에서는 여러 값 타입들의 종류와 사용법, 유의점에 대해서 알아본다. 사실, 추적이 안되는 값이기 때문에 결론적으로 값 타입을 잘 사용하지 않기에 JPA에서 사용하는 값 타입이라는 것이 있다는 정도만 알아도 될 것 같다.

 

 


 

2. 값 타입의 종류

 

 

값 타입은 다음의 3가지가 존재한다.

 

  1. 기본 값 타입 : int, Integer 등 자바 기본 타입이나 객체
  2. 임베디드 타입
  3. 컬렉션 타입

 

기본 값 타입

기본 값 타입은 자바에서 사용하는 것이기 때문에 익숙하다. 다만 이 기본 값 타입은 변경 시 추적이 안된다는 점이 primitive 클래스냐, Wrapper 클래스냐에 따라 조금은 원리가 다르다.

 

예를 들어 자바에서 배운바와 같이 int는 변경은 되지만 공유가 안된다. 다시 말해 변수 a의 10 -> 20으로 변경된 내용이 변수 b에 전달되지 않는다.

 

1
2
3
4
5
6
7
8
@Test
  void value_object() {
    int a = 10;
    int b = a;
 
    a = 20;
    System.out.println("b = " + b); // b = 10
  }
cs

 


 

임베디드 타입

 

여러 필드를 하나로 묶어 객체지향적 프로그래밍을 돕는다.

임베디드 타입은 아래와 같이 여러 필드값들을 묶는 하나의 필드를 생성할 수 있는 기능이다. 이렇게 임베디드 타입으로 필드값을 지정하면 의미적으로 하나의 필드로 여러 값을 묶을 수 있을 뿐아니라 다른 객체에서 참조하기도 편하기 때문에 객체지향적인 프로그래밍 방식에 더욱 부합한다고 할 수 있다.

 

프로젝트 전체에서 일관성 있는 공통된 객체를 사용할 수 있다.

여러 개발자가 프로젝트 전체에서 공통적인 의미로 하나의 필드를 사용할 수 있게 된다. 위와 같이 homeAddress 타입을 city, street, zipcode로 묶어내면 프로젝트 전체에서 city, street, zipcode라는 이름의 필드가 사용되어 일관성있게 개발할 수 있다. 또한 임베디드 타입에 공통 메서드를 만들어서 사용할 수도 있으므로 공통화에 더욱 유리하다.

 

 

사용법 : @Embeddable, @Embedded

값을 정의하는 클래스에 @Embeddable, 사용하는 부분에 @Embedded를 작성해주면 된다. 둘 중 하나만 사용해도 되지만, 통상 의미적으로 임베디드 타입을 사용한다는 것을 명시하기 위해 둘 다 써주는 것이 권장된다.

 

src/main/java/com/example/jpamain/domain/Address.java

 

1
2
3
4
5
6
7
8
9
10
@Embeddable
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Address {
  private String city;
  private String street;
  private Integer zipcode;
}
cs

 

src/main/java/com/example/jpamain/domain/Period.java

 

1
2
3
4
5
6
7
8
9
@Embeddable
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Period {
  private LocalDateTime startDate;
  private LocalDateTime endDate;
}
cs

 

src/main/java/com/example/jpamain/domain/Member.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Getter
@Setter
public class Member {
 
  @Id
  @GeneratedValue
  private Long id;
 
  private String name;
 
  @Embedded
  private Period period;
 
  @Embedded
  private Address homeAddress;
 
  @ManyToOne(fetch = FetchType.EAGER)
  @JoinColumn(name = "TEAM_ID")
  private Team team;
}
cs

 

기본 생성자가 필수로 작성되어야 한다.

각 임베디드 타입은 기본 생성자가 필수이다. 그래서 @NoArgsConstructor를 작성해주었다. 그리고 다른 곳에서 전체 필드값을 갖는 객체가 생성되도록 해주기 위해 @AllArgsConstructor를 작성했다. @Getter, @Setter를 작성하여 필드값을 조회하고 조작할 수 있도록 하였다.

 

 

실행 결과

Member 테이블에 임베디드 타입의 필드값들이 컬럼으로 지정되고, 작성해준 값들이 insert 된 것을 확인할 수 있다.

 

src/main/java/com/example/jpamain/Main3_valueObject.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@SpringBootApplication
public class Main3_valueObject {
 
  public static void main(String[] args) {
    EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
    EntityManager em = emf.createEntityManager();
 
    EntityTransaction tx = em.getTransaction();
    tx.begin();
 
    try {
 
      Member member = new Member();
      member.setName("member1");
      member.setHomeAddress(new Address("Seoul""road"12345));
 
      em.persist(member);
 
      tx.commit();
 
    } catch (Exception e) {
      tx.rollback();
      e.printStackTrace();
    } finally {
      em.close();
    }
    emf.close();
  }
 
}
cs

 

 

@AttributeOverrides

거의 없는 상황이지만, 만약에 같은 임베디드 타입의 객체가 하나의 클래스에서 2번 참조되어야 한다면 @AttributeOverrides를 사용하면 된다.

 

src/main/java/com/example/jpamain/domain/Member.java의 일부

 

1
2
3
4
5
6
7
8
9
@Embedded
  private Address homeAddress;
 
  @Embedded
  @AttributeOverrides({@AttributeOverride(name = "city", column = @Column(name = "WORK_CITY")),
          @AttributeOverride(name = "street", column = @Column(name = "WORK_STREET")),
          @AttributeOverride(name = "zipcode", column = @Column(name = "WORK_ZIPCODE"))
  })
  private Address workAddress;
cs

 

src/main/java/com/example/jpamain/Main3_valueObject.java의 일부

 

1
2
3
4
5
6
7
8
9
10
11
12
tx.begin();
 
    try {
 
      Member member = new Member();
      member.setName("member1");
      member.setHomeAddress(new Address("Seoul", "road", 12345));
      member.setWorkAddress(new Address("Busan", "beach", 33333));
 
      em.persist(member);
 
      tx.commit();
cs

 

실행 결과

 


 

3. 값 타입 사용 시 유의점 : 불변 객체로 만들자

 

공유된 값 타입 객체의 값 변경 시, 모든 곳에 변경사항이 적용된다.

임베디드 타입과 같은 값 타입을 다른 곳에서 공유되도록 만들면 위험하다. 예를 들어 아래와 같은 경우, 한번 생성된 Address가 여러 객체에서 공유되어 값이 변경되면 모든 객체에 변경된 정보가 들어가게 되므로 위험성을 내포하게 된다. 이런 문제는 컴파일 시에 잡아낼 수 있는 문제가 아니므로 상당히 중대한 에러를 발생시킬 수 있다. 따라서 값 타입 사용 시에 매우 주의하여야 한다.

 

src/main/java/com/example/jpamain/Main3_valueObject.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
tx.begin();
 
    try {
 
      //Address 생성
      Address address = new Address("Seoul""road"12345);
 
      Member member = new Member();
      member.setName("member1");
      member.setHomeAddress(address);
 
      Member member2 = new Member();
      member2.setName("member2");
      address.setCity("changedCity");
      member2.setHomeAddress(address);
 
      em.persist(member);
      em.persist(member2);
 
      System.out.println("member.getHomeAddress().getCity() = " + member.getHomeAddress().getCity());
 
      tx.commit();
cs

 

 

예시에서는 member와 member2 객체를 눈에 보이는 한 화면에서 만들어서 금방 문제를 알아차릴 수 있지만, 복잡한 코드 속에서 이런 객체들을 만들다보면 member2의 주소를 바꾼줄 알았는데, member의 주소까지 바뀌는 문제가 발생할 수 있을 것이다.

 

완전하지 않은 해결책 : 값 타입을 쓸 때는 반드시 복사해서 사용한다.

member2에서 HomeAddress를 새롭게 복사하여 사용하면 된다. 그러나 이 방법은 모든 사용자가 반드시 값 타입은 복사해서 사용한다는 전제가 필요하다. 실수로라도 복사를 하지 않으면, 치명적인 에러를 유발할 수 있기 때문에 완전한 해결책이라고 볼 수 없다.

 

src/main/java/com/example/jpamain/Main3_valueObject.java의 일부

1
2
3
4
5
6
7
8
9
10
11
12
Member member2 = new Member();
      member2.setName("member2");
      Address newAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());
      newAddress.setCity("new City");
      member2.setHomeAddress(newAddress);
 
      em.persist(member);
      em.persist(member2);
 
      System.out.println("member.getHomeAddress().getCity() = " + member.getHomeAddress().getCity());
 
      tx.commit();
cs

 

해결책 : setter를 만들지 않거나, private으로 제공한다

위의 예시처럼 address.setCity가 작동하지 않도록 하면 된다. @Setter를 사용하지말고, 직접 Setter를 호출해주고, private으로 만든다.

 

src/main/java/com/example/jpamain/domain/Address.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Embeddable
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Address {
  private String city;
  private String street;
  private Integer zipcode;
 
  private void setCity(String city) {
    this.city = city;
  }
 
  private void setStreet(String street) {
    this.street = street;
  }
 
  private void setZipcode(Integer zipcode) {
    this.zipcode = zipcode;
  }
}
cs

 

setter 적용시 에러가 발생한다. 그 아래 newAddress와 같이 새로운 객체를 만들어서 사용해야함을 알 수 있다.

 

 


 

4. 값 타입 비교

 

자바에서 다루는 동일성과 동등성의 문제가 비슷하게 적용된다. 다만 같은 값이면 같은 객체라고 판단하기 위해서, equals 메서드를 사용한다.

 

Equals와 hash 메서드 Override

JPA 뿐만 아니라 자바에서도 일반적으로 많이 사용되는 개념이므로 잘 알고 있어야 한다. 원래 equals 메서드는 주소가 다른 참조형 객체는 각 필드값이 같더라도 다른 값으로 판단한다. 그래서 객체의 필드값들이 같으면 같은 객체로 판단하도록 하기 위해서 equals 메서드를 재정의한다. 위에서 배운 바와 같이 Integer, String과 같이 기본 값 타입이나 임베디드 타입을 비교할 때 값의 동등성을 알기 위해 필수적인 방식이다.

 

equals 메서드 Override 적용 전 결과

1
2
3
4
5
6
7
8
9
10
11
12
13
tx.begin();
 
    try {
 
      Address address1 = new Address("city1""road1"1);
      Address address2 = new Address("city1""road1"1);
 
      System.out.println("== result = " + (address1 == address2));
      System.out.println("equals result = " + (address1.equals(address2)));
 
      tx.commit();
 
    } catch (Exception e) {
cs

 

 

hashMap 등에서 equals로 비교 시 값은 같지만 hashCode가 같은 경우 다른 값으로 판단할 수 있는데, 이를 방지하기 위해서 hash 메서드도 Override 해준다.

 

 

사용법

적용을 원하는 Address 객체에 생성자나 Getter, Setter를 넣던 것과 같이 [Alt + Insert]로 equals and hash 를 넣어준다.

 

 

Use getters... 옵션에 체크를 해주어, 값을 동등비교할 때 getter를 활용하도록 해준다.

 

src/main/java/com/example/jpamain/domain/Address.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Embeddable
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Address {
  private String city;
  private String street;
  private Integer zipcode;
 
  @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());
  }
}
cs

 

 

Override 후 코드를 재실행해보면, equals는 true를 반환하는 것을 확인할 수 있다.

 

equals 메서드 Override 적용 후 결과

 

 

 


 

5. 컬렉션

 

 

Set이나 List와 같은 컬렉션도 값 타입이다. 그리고 이런 컬렉션 타입들을 직접 필드 값으로 지정해줄 수 있다. 그러나 RDB의 경우 테이블 자체가 어떤 컬렉션에 담겨지는 구조는 없으므로 이 컬렉션 타입의 필드값들은 마치 앞서 배운 일대다(@OneToMany) 관계를 갖는 엔티티처럼 테이블을 만들게 된다.

 

나중에 나오겠지만, 사실 이 컬렉션 타입은 사용하지 않는 것이 좋다. 대신에 연관관계를 갖는 엔티티로 만들어서 사용하는 것이 좋다.

 

 

 

사용법

 

@ElementCollection, @CollectionTable 어노테이션을 사용하면 컬렉션 타입을 필드값으로 지정할 수 있다. 컬렉션에 들어가는 타입이 기본 값 타입이면, @Column을 적용하여 생성될 테이블의 컬럼 이름을 지정할 수도 있다.

 

Member.class 의 일부

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Member {
 
  @Id
  @GeneratedValue
  private Long id;
 
  private String name;
 
  @Embedded
  private Period period;
 
  @Embedded
  private Address homeAddress;
 
  @ElementCollection
  @CollectionTable(name = "FAVORITE_FOODS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
  @Column(name = "FOOD_NAME"//String이 기본 값 타입이므로 컬럼 이름 적용 가능
  private Set<String> favoriteFoods = new HashSet<>();
 
  @ElementCollection
  @CollectionTable(name = "ADDRESS_HISTORY", joinColumns = @JoinColumn(name = "MEMBER_ID"))
  private List<Address> addressHistory = new ArrayList<>();
cs

 

실행결과

 

 


 

컬렉션 타입의 특징 : CASCADE, orphanRemoval, fetch LAZY 

 

컬렉션 타입의 필드는 마치 다대일 연관관계의 엔티티처럼 작용하여 CASCADE, orphanRemoval, fetch LAZY가 기본으로 적용된다. 아래 코드를 보면, member 객체에 대한 persist만 적용하고, member 내부의 컬렉션 타입들을 불러와서 단순히 add만 해준다. 딱히 favoriteFoods와 addressHistory를 persist 해주진 않는다. 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
tx.begin();
 
    try {
 
      Member member = new Member();
      member.setName("member1");
      member.setHomeAddress(new Address("city1""road1"1234));
 
      member.getFavoriteFoods().add("해물탕");
      member.getFavoriteFoods().add("삼겹살");
      member.getFavoriteFoods().add("볶음밥");
 
      member.getAddressHistory().add(new Address("city1""road1"1234));
      member.getAddressHistory().add(new Address("city2""road2"12345));
 
      em.persist(member);
 
      tx.commit();
 
    } catch (Exception e) {
      tx.rollback();
cs

 

자동으로 insert 구문이 나가는 것을 확인할 수 있다.

 

 


 

컬렉션 타입 필드의 한계 : 식별자 개념이 없다.

컬렉션 타입은 결론적으로 사용하면 안된다. 왜냐하면 컬렉션 타입으로 맺어진 테이블에는 식별자가 없기 때문이다. 만약 DB에 저장 후, 1개 데이터를 제거하고 새로운 데이터를 삽입한다고 가정해보자. 이럴 경우, 컬렉션 타입에 관련된 리스트 전체가 삭제되고, 남아있던 데이터를 다시 모두 insert 하게 된다. 식별자가 없기 때문에 어떤 식별자를 가진 값을 관리해줘야하는지 모르고, 그에 따라 불필요하거나 이상한 쿼리가 나갈 경우가 많다. 따라서 컬렉션 타입을 사용하는 것보다는 컬렉션을 엔티티로 승격시키고, 앞서 배운 바와 같이 연관관계를 맺어서 관리해주는 것이 좋다.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
tx.begin();
 
    try {
 
      Member member = new Member();
      member.setName("member1");
      member.setHomeAddress(new Address("city1""road1"1234));
 
      member.getFavoriteFoods().add("해물탕");
      member.getFavoriteFoods().add("삼겹살");
      member.getFavoriteFoods().add("볶음밥");
 
      em.persist(member);
 
      em.flush();
      em.clear();
 
      System.out.println("=====================================");
      Member member1 = em.find(Member.class, member.getId());
      member1.getFavoriteFoods().remove("해물탕");
      member1.getFavoriteFoods().add("순두부찌개");
 
      em.persist(member1);
 
      tx.commit();
 
    } catch (Exception e) {
cs

 

 


 

참조

 

1. 인프런_자바 ORM 표준 JPA 프로그래밍 - 기본편_김영한 님 강의

https://www.inflearn.com/course/ORM-JPA-Basic/lecture/21735?tab=curriculum

728x90
반응형