JPA 에서는 데이터 타입은 크게 두 가지로 분류되는데, 1) 엔티티 타입 그리고 2) 값 타입 으로 나눌 수 있다. 여기서 오해하지말아야할게 지금 말한 JPA에서 말하는 데이터 타입은 자바에서 나누고 있는 원시타입, 참조변수 타입과 다른 개념이다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
private int age;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
@Embedded
private Address address;
@ElementCollection
@CollectionTable(
name ="ADDRESS",
joinColumns = @JoinColumn(name="MEMBER_ID"))
private List<Address> addressHistory = new ArrayList();
}
위와 같이 회원 엔티티가 정의 되어 있을때 각 필드들의 데이터 타입을 한번 확인해보자.
TEAM -> @Entity 로 정의 되어진 타입으로 JPA가 관리해주는 엔티티 타입 이다.
Long, String, int
이 필드들은 자바에서 기본적으로 제공해주는 원시타입 혹은 래퍼클래스들이며 JPA에서는 이를 기본값 타입 이라 부른다.
Address
'이것도 엔티티 타입 아니야?' 라는 생각이 들 수 있지만, 이것 또한 값 타입이다. 다른 말로는 임베디드 타입 이라고도 부르는데 자세한 설명은 뒤에서 하겠다.
List<Address>
마찬가지로 값타입이며 컬렉션 값 타입 이라 부른다.
임베디드 타입(Embedded Type)은 엔터티(Entity)에서 여러 속성들을 그룹화하여 관리 할 수 있도록 하는 방법
회원이라는 엔티티가 가져야할 속성들 중에는 '주소' 가 있을 수 있다. 하지만 '주소' 는 또 다시 시/군/구, 우편번호, 아파트/건물 이름 등 다양한 속성들로 구분되어질 수 있다.
문제는 이렇게 될 경우 2가지 큰 문제점이 발생한다.
첫번째는 엔티티 필드가 너무 많아져서 엔티티 객체 코드가 지저분해진다.
두번째는 필드 매핑 작업에 손이 너무 많이 가게 된다 는 것이다.
아래 예시 코드를 확인해보자.
@Entity
public class Member{
private Long id;
// 주소 영역
private String city;
private String gu;
private String dong;
private String street;
private String zipcode;
private String buildingName;
// 나머지 영역
private int age;
private String name;
private Integer phoneNo;
...
}
현재 회원엔티티는 주소 정보 필드 때문에 코드가 굉장히 길고 복잡해졌다. 물론 "겨우 이정도가지고?" 라는 생각을 할 수 있겠지만, 주소 말고도 다른 타입들이 더 생길 수 있다는걸 생각해보자.
@Embeddable
public class Address{
private String city;
private String gu;
private String dong;
private String street;
private String zipcode;
private String buildingName;
}
@Entity
public class Member{
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
// 주소 영역
@Embedded
private Address address;
private String name;
private int age;
private int phoneNo;
...
}
이렇게 작성하면 회원엔티티가 한결 더 깔끔해진걸 확인 할 수 있다.



임베디드 타입은 결국 값 타입이지 테이블을 할당받은 엔티티가 아니다.
그렇기 때문에 JPA가 쿼리를 날릴때 Address 라는 테이블을 찾아서 JOIN 하거나 그럴일 없이 그냥 Address의 필드들과 매핑되는 컬럼들로 다시 분해해서 INSERT 쿼리를 날리게된다.
※ 물론 컬렉션 값타입을 사용하면 실제 테이블을 생성하기도 한다.
임베디드 타입은 결국 참조 타입이다. 참조 타입의 특징은 주소를 통해 값들을 공유 할 수 있다는 것이다.
다음과 같은 요구사항이 있다고 가정해보자.
모든 회원의 주소 정보를 "홍길동" 회원의 주소 정보와 동일하게 저장해라. 단, 도시는 각각 다르게 저장해야한다.
// 1. "홍길동" 의 주소 정보 추출
Member hong = em.find("홍길동");
Address hongAddress = m1.getAddress();
// 2."서울" 로 변경
hongAddress.setCity("서울");
Member m1 = new Member("회원1", hongAddress)
// 3. "대전" 으로 변경
hongAddress.setCity("대전");
Member m2 = new Member("회원2", hongAddress)
// 4. "판교" 로 변경
hongAddress.setCity("판교");
Member m3 = new Member("회원3", hongAddress)
System.out.println("회원1 도시 : "+m1.getAddress().getCity());
System.out.println("회원2 도시 : "+m2.getAddress().getCity());
System.out.println("회원3 도시 : "+m3.getAddress().getCity());
// 출력 결과
// 회원1 도시 : 판교
// 회원2 도시 : 판교
// 회원3 도시 : 판교
요구 사항대로 도시를 제외한 나머지 주소 정보는 모두 같게 저장하기 위해 "홍길동" 회원엔티티에서 주소를 추출 한뒤 도시의 값만 setter 로 바꾸면서 각 회원들에게 저장을 하였다.
하지만 보시다시피 저장된 값은 모든 회원들의 도시정보가 가장 마지막에 세팅 해주었던 "판교" 였다. 왜냐하면 hongAddress 를 공유 하고 있었기 때문 이다.
요구사항대로 개발을 하기위해서는 hongAddress 인스턴스를 복사해야한다.
// 1. "홍길동" 의 주소 정보 추출
Member hong = em.find("홍길동");
Address hongAddress = m1.getAddress();
// 2."서울" 로 변경
m1Address = hongAddress.clone();
m1Address.setCity("서울");
Member m1 = new Member("회원1", m1Address)
// 3."대전" 로 변경
m2Address = hongAddress.clone();
m2Address.setCity("대전");
Member m1 = new Member("회원2", m1Address)
// 4."판교" 로 변경
m3Address = hongAddress.clone();
m3Address.setCity("판교");
Member m1 = new Member("회원3", m1Address)
System.out.println("회원1 도시 : "+m1.getAddress().getCity());
System.out.println("회원2 도시 : "+m2.getAddress().getCity());
System.out.println("회원3 도시 : "+m3.getAddress().getCity());
// 출력 결과
// 회원1 도시 : 서울
// 회원2 도시 : 대전
// 회원3 도시 : 판교
하지만 어떤 어리숙한 개발자가 인스턴스를 복사해서 사용해야한단 사실 자체를 까먹거나 알지 못한경우 이를 막을 방도가 없다. setter 를 없애기전까진 말이다,,,
원본의 값이 공유되어 도중에 값이 변경되는것을 막기위해서는 근본적인 문제점을 제거해야한다.
Address 의 경우 현재 setter 메서드를 구현하고 있다. 하지만 setter의 경우 앞에서 보았다시피 객체의 값을 도중에 언제든지 변경할 수 있다는 것이다.
그래서 실무에서는 정말 웬만한 경우가 아닌 이상 아예 setter 를 private으로 놓던가 아니면 아예구현 조차 하지 않는다.
즉, 값을 수정 못하게 아예 불변 객체로 만들어 버리는 것이다.
그러면 도대체 값을 어떻게 복제해야하는가?
코드를 살펴보자.
// 1. "홍길동" 의 주소 정보 추출
Member hong = em.find("홍길동");
Address hongAddress = m1.getAddress();
// 2."서울" 로 변경
Member m1 = new Member("회원1",
new Address("서울", hongAddress.getGu(), hongAddress.getStreet()))
// 2."대전" 로 변경
Member m2 = new Member("회원2",
new Address("대전", hongAddress.getGu(), hongAddress.getStreet()))
// 2."판교" 로 변경
Member m3 = new Member("회원3",
new Address("판교", hongAddress.getGu(), hongAddress.getStreet()))
System.out.println("회원1 도시 : "+m1.getAddress().getCity());
System.out.println("회원2 도시 : "+m2.getAddress().getCity());
System.out.println("회원3 도시 : "+m3.getAddress().getCity());
// 출력 결과
// 회원1 도시 : 서울
// 회원2 도시 : 대전
// 회원3 도시 : 판교
보시다시피 주소 정보를 생성자를 통해 주입하고 있다. 즉, 세터로 값을 바꾸는게 아니고 생성자를 이용해 아예 처음에 값을 정해버리는 것이다.
그럼 만일 도시를 또 수정해야 하는 상황이 온다면? 그때도 마찬가지로 새로운 인스턴스를 하나 또 생성한다음 생성자를 통해 위와 같이 값을 옮겨담아야 한다.
비록 불변이라는 작은 제약이 있지만 예상치 못하게 값의 변경되어버리는 큰 재앙을 막을 수 있다.
이번엔 다음과 같은 요구사항이 또 들어왔다고 가정하자.
"홍길동" 회원과 주소가 일치하는 회원을 찾으시오
// 서울, 동작구, 상도동
Address hong = em.find("홍길동").getAddress();
// 서울, 동작구, 상도동 (일치)
Member m1 = em.find("오길동").getAddress();
// 성남, 분당구, 판교동 (불일치)
Member m2 = em.find("박길동").getAddress();
// 서울, 관악구, 봉천동(불일치)
Member m3 = em.find("김길동").getAddress();
for(Member m : List.of(m1, m2, m3)){
if(hong.getAddress() == m1.getAddress()){
System.out.println(m.getName());
}
}
System.out.println("0명");
// 출력 결과
// 0명
예상대로라면 "오길동" 이 출력이되어야하지만 실제로는 그렇지 않다.
왜냐하면 == 연산자는 객체의 속성 값들을 비교하는게 아닌, 참조하고 있는 주소값을 비교하기 때문이다. (동일성 비교)
그래서 객체 타입의 경우 equals() 와 hashCode() 를 따로 재정의해준다음 그걸로 객체를 비교해줘야한다. (동등성 비교)
본 포스트는
김영한의 자바 ORM 표준 JPA프로그래밍 기본 강의 및 도서를 참고하여 정리했습니다.