값 타입

twocowsong·2023년 4월 30일
0

김영한_jpa

목록 보기
10/13
post-custom-banner

기본값 타입

JPA의 데이터 타입 분류

  • 엔티티 타입

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

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

값 타입 분류

  • 기본값 타입

    • 자바 기본 타입(int, double)
    • 래퍼 클래스(Integer, Long)
    • String
  • 임베디드 타입(embedded type, 복합 값 타입)

  • 컬렉션 값 타입(collection value type)

기본 값 타입

  • 예) 예): String name, int age
  • 생명주기를 엔티티의 의존
    • 예) 회원을 삭제하면 이름, 나이 필드도 함께 삭제
  • 값 타입은 공유하면 안됨
    • 예) 회원 이름 변경시 다른 회원의 이름도 함께 변경되면 안됨

임베디드 타입

  • 새로운 값 타입을 직접 정의할 수 있음
  • JPA는 임베디드 타입(embedded type)이라 함
  • 주로 기본 값 타입을 모아서 만들어서 복합 값 타입이라고도 함
  • int, String과 같은 값 타입
@Getter
@Setter
@Entity
public class Member {
	@Id @GeneratedValue
	@Column(name = "MEMBER_ID")
	private Long id;

	@Column(name = "USERNAME")
	private String username;

	// 기간 Period
	private LocalDateTime startDate;
	private LocalDateTime endDate;

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

위와 같은 Member엔티티가 존재할 경우 아래의 그림처럼 DB의 정보는 그대로 두고, 자바에서 Member엔티티의 정보를 묶고싶은 경우가 있습니다.
이때, 사용할수 있는방법이 임베디드입니다.

@Getter
@Setter
@Entity
public class Member {
	@Id @GeneratedValue
	@Column(name = "MEMBER_ID")
	private Long id;

	@Column(name = "USERNAME")
	private String username;

	// 기간 Period
	@Embedded
	private Period workPeriod;

	// 주소
	@Embedded
	private Adress homeAdress;
}
@Embeddable
public class Period {
	private LocalDateTime startDate;
	private LocalDateTime endDate;
}
@Embeddable
public class Adress {
	private String city;
	private String street;
	private String zipcode;
}

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

Member member = new Member();
member.setUsername("A");
member.setWorkPeriod(new Period());
member.setHomeAdress(new Adress("city_1", "street_2", "zipcode_3"));
em.persist(member);


자바 소스코드에서는 객체지향처럼 개발을 하기위해 Period, Adress로 나누었지만, DB에서는 전혀 상관없이 깔끔하게 테이블 1개로 형성된것을 확인할수있습니다.

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

객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능해집니다.
잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많습니다.

만약, 아래와같이 home과 work adress가 2개가 필요한 경우 @AttributeOverride사용하면됩니다.

	...
    @Embedded
	private Adress homeAdress;
	@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 Adress workAdress;
    ...

값 타입과 불변 객체

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

값 타입 공유 참조

Adress adress = new Adress("city_1", "street_2", "zipcode_3");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAdress(adress);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member1");
member2.setHomeAdress(adress);
em.persist(member2);

정상적으로 member1, member2에 Adress가 입력이 되었습니다.
그리고 member1에 city값을 newCity로 변경하고싶을때 아래와같은 코드를 입력하게됩니다.

	...
	member1.getHomeAdress().setCity("newCity");
    ...

이렇게 되면 아래와같이 member1, member2의 city값이 변경됩니다.
원하는 member1에 city값만 변경된것이 아닌, member2에 city값도 변경된걸 확인 할 수 있습니다.


이렇게 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험합니다.
이를 해결하기위해 사용하고자하는 Adress는 따로 생성시켜 사용해야합니다.

    ...
    Adress adress = new Adress("city_1", "street_2", "zipcode_3");
    ...
    Adress copyAdress = new Adress("city_1", "street_2", "zipcode_3");
    ...


그러면 수정해도 문제없이 member1만 newCity로 변경된걸 확인하실수 있습니다.

객체 타입의 한계

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

이를 해결하기위해 객체타입은 불변객체로 생성해야합니다.
불변객체란? 생성 시점 이후 절대 값을 변경할 수 없는 객체
생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 됩니다.

이를 통해 수정하고싶은경우 생성자를통해 새롭게 생성시켜 사용하시면 됩니다.

// 새로운 Adress로 변경하고싶은경우
Adress newCity = new Adress(adress.getCity(), adress.getStreet(), adress.getZipcode());
member1.setHomeAdress(newCity);

값 타입 비교

값 타입: 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 합니다.

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

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

  • 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야 함

  • 값 타입의 equals() 메소드를 적절하게 재정의(주로 모든 필드 사용)

	...for...Adress.class
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Adress adress = (Adress) o;
        return Objects.equals(city, adress.city) &&
                Objects.equals(street, adress.street) &&
                Objects.equals(zipcode, adress.zipcode);
    }

    @Override
    public int hashCode() {
        return Objects.hash(city, street, zipcode);
    }
    ....
	Adress adress1 = new Adress("city1","street1", "zipcode1");
	Adress adress2 = new Adress("city1","street1", "zipcode1");
	
    // true가 출력되는걸 확인할 수 있습니다.
	System.out.println("adress1 equals adress2 : " + adress1.equals(adress2));

값 타입 컬렉션


Member기반에 FAVORITE_FOOD와 ADDRESS테이블이 있는경우 예제 소스코드처럼 @ElementCollection을 사용하여 매핑이 가능합니다.

@Getter
@Setter
@Entity
public class Member {
	@Id @GeneratedValue
	@Column(name = "MEMBER_ID")
	private Long id;

	@Column(name = "USERNAME")
	private String username;

	@Embedded
	private Adress homeAdress;

	@ElementCollection
	@CollectionTable(name = "FAVORITE_FOOD",
			joinColumns = @JoinColumn(name = "MEMBER_ID"))
	@Column(name = "FOOD_NAME")
	private Set<String> favoritFoods = new HashSet<>();

	@ElementCollection
	@CollectionTable(name = "ADRESS",
			joinColumns = @JoinColumn(name = "MEMBER_ID"))
	private List<Adress> adress = new ArrayList<>();

}


테이블이 총 3개가 생성된걸 확인할수있습니다.
FAVORITE_FOOD테이블에 MEMBER_ID + FOOD_NAME으로 생성되었고,
ADRESS테이블에도 MEMBER_ID + Adress객체의 값들로 생성된걸 확인할수있습니다.
DB에서는 컬렉션같은 정보를 테이블 1개에 저장 할 수 없기때문에, 별도에 테이블로 생성 후에 저장하는 형식으로 만들게됩니다.

값 타입 컬렉션 사용

저장

Member member = new Member();
member.setUsername("member1");
member.setHomeAdress(new Adress("city1", "street1", "zipcode1"));
member.getFavoritFoods().add("치킨");
member.getFavoritFoods().add("족발");
member.getFavoritFoods().add("피자");
member.getAdressHistory().add(new Adress("old1", "old1", "old1"));
member.getAdressHistory().add(new Adress("old2", "old2", "old2"));
em.persist(member);

em.persist한번으로 모든 데이터가 INSERT되었습니다.

조회

System.out.println("==========================================");
Member findMember = em.find(Member.class, member.getId());

여기서, findMember를 실행하게된다면 아래와같이 쿼리가 실행됩니다.
한번에 데이터를 조회할때 즉, 값타입들은 지연로딩으로 데이터를 조회하게됩니다.

ElementCollection에 Defatul값이 LAZY인걸 확인할 수 있습니다.

수정

// homeAdress 수정
Adress homeAdress = findMember.getHomeAdress();
// 이뮤터블 객체임으로 new Adress로 새롭게 생성하여 작업
findMember.setHomeAdress(new Adress("newCity", homeAdress.getStreet(), homeAdress.getZipcode()));

// 치킨을 찾아 삭제 후 한식으로 변경
findMember.getFavoritFoods().remove("치킨");
findMember.getFavoritFoods().add("한식");

// Adress에 equals가 오버라이딩이 되어있다는 가정하에 remove가 정상작동 됨
findMember.getAdressHistory().remove(new Adress("old1", "old1", "old1"));
findMember.getAdressHistory().add(new Adress("newCity", "old1", "old1"));


정상적으로 수정된걸 확인할 수 있습니다.
단, 특이한 상황이있습니다.

findMember.getAdressHistory().remove(new Adress("old1", "old1", "old1"));
findMember.getAdressHistory().add(new Adress("newCity", "old1", "old1"));

소스코드를 보고는 개인적으로는 old1이 삭제되고 난 후 newCity가 INSERT되겠다 라고 생각하였지만, 실행 쿼리는 전혀 다르게 동작하였습니다.

해당 MEMBER에 ADRESS를 모두 삭제 후, INSERT를 모두 시키는 방식이였습니다.

값 타입 컬렉션의 제약사항

값 타입은 엔티티와 다르게 식별자 개념이 없습니다. 값은 변경하면 추적이 어렵습니다.
값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장합니다.

현재 상황과 똑같은 상황이죠.
값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 합니다.
지금 같은 상황이라면, 값타입을 사용하지않는걸 추천합니다.

값 타입 컬렉션 대안

실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야합니다.

@Entity
@Getter
@Setter
@Table(name = "ADRESS")
@NoArgsConstructor
public class AdressEntity {
	@Id @GeneratedValue
	@Column(name = "ADRESS_ID")
	private Long id;

	private Adress adress;
	public AdressEntity(String c, String s, String z) {
		this.adress = new Adress(c, s, z);
	}
}

AdressEntity를 한개 생성합니다. 이후 Member엔티티에서는 연결을 위해 아래와같이 수정합니다.

	...
	@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
	@JoinColumn(name = "MEMBER_ID")
	private List<AdressEntity> adressHistory = new ArrayList<>();
    ...


그림과같이 ADRESS_ID가 추가된걸 확인 할 수 있습니다.
그러면, 값타입은 간단한 selectBox에 멀티로 사용할때이며 그 외는 엔티티로 승격시켜서 사용하는것이 편합니다.
이력을 남긴다면 엔티티! 이력이 필요없이 단순 데이터면 값타입으로 하시는걸 추천합니다.

profile
생각하는 개발자
post-custom-banner

0개의 댓글