JPA 심화: 다양한 연관관계 매핑과 값 타입 활용법
김영한님의 자바 ORM 표준 JPA 프로그래밍을 정리한 내용입니다.
연관관계 맵핑 시 고려사항 3가지
다중성
• 다대일: @ManyToOne
• 일대다: @OneToMany
• 일대일: @OneToOne
• 다대다: @ManyToMany
단방향, 양방향
- 테이블
- 외래 키 하나로 양쪽으로 조인이 가능하다.
- 방향이라는 개념이 없다
- 객체
- 참조용 필드가 있는 쪽으로만 참조가 가능하다.
- 한쪽만 참조하면 단방향, 양쪽이 서로 참조하면 양방향이라고 한다. (사실 객체 입장에서는 단방향이다
다대일 N:1
- 가장 많이 사용하는 연관관계이다.
- 다 대 일 중
다
테이블에 외래키가 있어야 한다.- 양방향 맵핑의 주인이 된다.
- 양방향 맵핑의 주인이 아닌 곳은 읽기만 가능하다.
일대다
- 실무에서 권장하지 않은 모델이다.
- 일대다 단방향은
일(1)
이 연관관계의 주인이다. - 그러나 테이블의 외래키는
다
테이블에 있에 존재한다. - 연관관계의 주인 테이블에서 반대편 테이블의 외래키를 관리해야하기 때문에 업데이트 쿼리가 추가적으로 필요하다. 이럴 경우 운영에서 추적이 힘들어 진다.
@JoinColumn
을 꼭 사용해야하며, 만약 사용하지 않으면 중간에 테이블을 하나 추가하는 조인 테이블 방식을 사용한다
- 일대다 단방향 맵핑보다
다대일 양방향 맵핑
을 사용하는 것을 권장한다 - 일대다 양방향
- 공식적으로 존재하지 않다.
@JoinColumn(Insertable=false, updateable=false)
를 사용한다.- 읽기 전용 필드를 사용해서 양방향처럼 사용하는 방법이다.
- 다대일 양방향을 사용하는 것이 좋다.
일대일
- 주 테이블에 외래키를 넣거나 대상 테이블에 외래 키를 넣을 수 있다.
- 주 테이블에 외래키를 넣을 때
- 객체 지향 개발자가 선호하고 JPA맵핑이 편리하다
- 장점 : 주테이블만 조회해도 대상 테이블에 데이터가 있는지 확인 가능하다
- 단점 : 값이 없으면 외래 키에 null이 허용된다.
- 객체 지향 개발자가 선호하고 JPA맵핑이 편리하다
- 대상 테이블에 외래키
- 대상 테이블에 외래 키가 존재한다.
- 전통적인 데이터베이스 개발자가 선호한다.
- 장점 : 주 테이블과 대상 테이블을 일대일에서 일대다 관계로 변경할 때 테이블 구조가 유지된다.
- 단점 : 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩된다.
- 전통적인 데이터베이스 개발자가 선호한다.
- 대상 테이블에 외래 키가 존재한다.
- 주 테이블에 외래키를 넣을 때
- 외래 키에 데이터베이스 유니크 제약조건이 추가되어야 한다.
- 다대일 양방향 맵핑처럼 외래 키가 있는 곳이 연관관계의 주인이 된다.
- 반대편은 mappedBy를 적용한다
다대다
- 객체는 컬렉션을 사용해서 객체 2개로 다대다 관계가 가능하다
- 다대다 맵핑은 편리해 보이지만 실무에서는 사용하지 않는다.
- 다대다 한계를 극복하기 위해서는 연결 테이블용 엔티티를 추가해
@ManyToMany
를@OneToMany
,@ManyToOne
으로 맵핑을 나누는 것이 좋다
상속관계 맵핑
관계형 데이터베이스는 상속 관계가 없지만 슈퍼타입 서브 타입 관계라는 모델링 기법으로 객체 상속과 유사하게 사용할 수 있다.
슈퍼타입 서브타입 논리 모델을 실제 물리 모델로 구현하는 몇 가지 방법이 있다.
- 각각 테이블로 변환한다면
조인 전략
- 통합 테이블로 변환한다면
단일 테이블 전략
- 서브타입 테이블로 변환한다면
구현 클래스마다 테이블 전략
jpa에서 조인전략은 어노테이션으로 선택할 수 있다.
@Inheritance(strategy=InheritanceType.XXX)
- JOINED: 조인 전략
- SINGLE_TABLE: 단일 테이블 전략
- TABLE_PER_CLASS: 구현 클래스마다 테이블 전략
@DiscriminatorColumn(name=“DTYPE”)
테이블에 Dtype 컬럼이 생기고 조인하는 테이블 명이 들어간다. 이름은 바꿀 수 있고 실무에서 사용하기를 권장한다.@DiscriminatorValue(“XXX”)
자식 클래스의 DiscriminatorColumn에 들어가는 이름을 정할 수 있다.
조인 전략
장점은 테이블 정규화로 저장공간을 효율적으로 사용하고, 외래 키 참조 무결성 제약조건을 활용할 수 있다.
단점은 조회 시 조인을 많이 사용할 경우 성능이 저하되고, 쿼리가 복잡할 수 있다. 또한, 데이터 저장시 Insert sql이 2번 호출 된다.
단일 테이블 전략
장점은 조인이 필요없기 때문에 쿼리가 단순하고 빠르다.
단점은 자식 엔티티가 맵핑한 컬럼은 모두 null허용이여야하고, 테이블이 커질 수 있기 때문에 상황에 따라 조회 성능이 느릴 수 있다.
구현 클래스마다 테이블 전략
데이터베이스 설계자와 ORM 전문가 둘 다 추천하지 않는 전략이다. 장점은 서브 타입을 명확하게 구분해서 처리할 때 효과적이고, not null 제약 조건을 사용할 수 있다.
단점은 여러 자식 테이블을 함께 조인할 때 성능이 느리고, 자식 테이블을 통합해서 쿼리하기 어렵다.
MappedSuperClass
객체의 속성만 상속받아서 사용한다 (ex id, name 등 )
상솩관계, 엔티티, 테이블 맵핑과는 전혀 상관이 없고, 자식 클래스에서 매핑 정보만 제공한다. 조회 및 검색(em.find(BaseEntity))는 불가하며 직접 생성해서 사용 할 경우가 없기 때문에 추상 클래스를 권장한다.
프록시와 연관관계 관리
프록시
JPA에서는em.getReference()
로 데이터베이스 조회를 미루를 프록시 엔티티 객체 조회를 할 수 있다.
프록시 객체는
- 실세 클래스를 상속 받아 만들기 때문에 사용자 입장에서는 진짜 객체처럼 사용할 수 있다.
- 실제 객체의 참조(target)을 보관한다.
- 프록세 객체를 호출하면 실제 객체의 메소드를 호출한다.
프록시 객체는 영속성 컨텍스트에 target이 없으면 없으면 영속성 컨텍스트에 초기화를 요청하여 DB에서 값을 가져온다.
프록시의 특징
- 첫 사용 시 한 번만 초기화된다.
- 프록시 객체를 초기화해도 프록시 객체가 실제 엔티티로 바뀌는 것이 아니다. 프록시 객체를 통해 실제 엔티티에 접근이 가능한 것이다.
- 프록시 객체는 원본 엔티티를 상속받기 때문에 타입 체크 시 주의해야 한다. (== 대신 instance of)
- 두 동일 클래스의 getClass비교시 instance of 로 비교
- 영속성 컨텍스트에 찾는 엔티티가 있으면 em.getReference()를 호출 시 실제 엔티티를 반환한다.
- 1차 캐시에 이미 있기 때문에 굳이 프록시를 가져올 필요가 없다.
- jpa의 한 영속성 컨텍스트 안에서 같은 pk로 조회해서 가져오면 무조건 비교가 true가 되어야 한다.
- 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태 일 때 프록시를 초기화하면 예외가 발생한다
- 하이버네이트 org.hibernate.lazyInitializationException - no session
프록시 관련 유틸리티 메소드
- 프록시 인스턴스 초기화 여부 확인 : PersistenceUnitUtil.isLoaded(Object entity)
- entity manger factorty에서 getPersistenceUnitUtil를 선행
- 프록시 클래스 확인 : entity.getClass().getName()
- 프록시 강제 초기화 org.hibernate.Hibernate.initialize(entity)
- jpa 표준에서는 강제 초기화는 없고 멤버 필드의 get 메소드를 호출하여 강제 초기화 할 수 있다.
즉시 로딩과 지연 로딩
@ManyToOne(fetch = FetchType.LAZY)
멤버와 팀이 연관관계가 있을 때 멤버를 조회하면 객체를 가져오고 연관관계가 있는 팀은 프록시로 가져온다. 나중에 실제 팀을 사용하는 시점에 초기화(DB 조회)를 하여 실제 엔티티를 가져온다.
만약 멤버랑 팀을 자주 사용한다면 즉시로딩으로 사용한다. @ManyToOne(fetch = FetchType.EAGER)
. 이 때는 팀이 프록시가 아닌 진짜 객체를 사용한다.
@ManyToOne, @OneToOne은 기본이 즉시 로딩이기 때문에 Lazy를 설정해야하고, @OneToMany, @ManyToMany는 기본이 지연 로딩이다.
뒤에가 one이면 즉시로딩을 사용한다.
실무에서는 즉시로딩을 사용하면 좋지 않다. 왜냐하면 JPQL에서 N+1 문제를 일으킨다. JPQL이 sql로 번역이 되고 멤버필드에 다른 테이블이 필요하다면 각 리스트의 멤버 필드를 채우기 위해 SQL을 또 날리게 된다. 즉 처음 쿼리 1개와 추가 쿼리 N개를 날려 N+1 문제가 발생한다.
해결방법은 크게 3가지이다. 모든 연관 관계를 지연 로딩으로 설정한 뒤
- 1개의 조인 쿼리로 데이터를 가져오는 fetch join
- 대부분의 해결 방법
- 엔티티 그래프
- 배치 사이즈
지연 로딩 활용
- 모든 연관관계에 지연 로딩을 사용한다
- 실무에서는 즉시 로딩을 사용하지 않는다
- JPQL fetch 조인이나 엔티티 그래프 기능을 사용한다
영속성 전이(CASCADE)
특정 엔티티를 영속 상태로 만드는 시점에서 연관된 엔티티도 함께 영속 상태로 만들고 싶은 경우 사용한다. 연관관계 (ex oneToMany) 어노테이션에 cascade을 추가하여 사용한다.
영속성 전이는 연관관계를 맵핑하는 것과 아무 관련이 없고, 단순히 영속화의 편의성을 위해 제공하는 것이다.
언제 추가 하는 것이 좋을까?
하나의 부모가 자식들을 관리할 때 의미가 있다. 단, 여러 엔티티에서 child와 연관 관계가 있을 때에는 쓰면 안된다.
CASCADE의 종류 (cascade=CascadeType.???
)
- ALL
- PERSIST
- REMOVE
- MERGE
- REFRESH
- DETACH
고아 객체
고아 객체는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 뜻하며, 연관관계 맵핑에서 orphanRemoval= true
옵션을 추가하면 고아 객체는 자동적으로 삭제 된다.
주의점은 CASCADE처럼 엔티티를 참조하는 곳이 하나일 때 사용해야 한다. 즉, 특정 엔티티가 개인 소유할 때 사용한다.
- @OneToOne, @OneToMany만 사용이 가능하다
이 옵션을 추가하고 부모를 제거하면 자식은 당연히 고아가 되기 때문에 자식도 함께 제거 된다. (CascadeType.REMOVE 처럼 동작)
영속성 전이와 고아 객체, 생명 주기
CascadeType.ALL + orphanRemoval = true 으로 연관관계를 맵핑한다면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있게 된다. 즉 자식이 직접 persist, remove를 하지 않아도 되는 것이다. 이는 도메인 주도 설계의 Aggregate Root 개념을 구현할 때 유용하다. Aggregate Root : Repository는 Aggregate Root만 접근한다.
값 타입
JPA는 데이터를 엔티티 타입 or 값 타입으로 분류 한다.
엔티티 타입은 @Entity
로 정의하는 객체들로 데이터가 변해도 식별자로 추적이 가능하다. 예로 회원 엔티티의 나이, 주소 등을 변경해도 식별자로 인식이 가능하다.
값 타입은 자바의 int, Integer, String처럼 단순 값으로 사용하는 기본 타입이나 객체이다. 식별자가 없고 값만 있기 때문에 변경 시 추적이 불가하다.
기본값 타입
- 자바 기본 타입(int, double)
- 래퍼 클래스(Integer, Long)
- String
기본 값 타입은 생명주기는 엔티티에 의존하고 공유하면 안된다. (primitive type은 절대 공유(call by value)가 안되고, Integer같은 래퍼 클래스나 String같은 특수 클래스는 공유가 가능한 객체이지만 변경은 안된다.)
임베디드 타입
- embedded Type(ex 좌표 값 등)
새로운 값 타입을 직접 정의할 수 있다. 주로 기본 값 타입을 모아 만들기 때문에 복합 값 타입이라고 한다. 임베디드 타입도 값 타입이기 때문에 추적이 불가하다.
값 타입을 정의하는 곳에는 @Embeddable
을 표시하고, 값 타입을 사용하는 곳에는 @Embedded
를 표시한다. 기본 생성자는 필수이다.
임베디드 타입의 장점
- 재사용
- 높은 응집도
- 해당 값 타임만 사용하는 의미 있는 메소드 생성 가능
임베디드 타입을 사용하기 전과 후 맵핑하는 테이블은 같지만, 객체와 테이블을 아주 세밀하게 맵핑하는 것이 가능해진다. 잘 설계한 ORM 어플리케이션은 맵핑한 테이블의 수보다 클래스의 수가 더 많다.
속성 재정의 한 엔티티에서 같은 값을 사용하게 되면 컬럼명이 중복되어 에러가 발생한다. 이 경우 @AttributeOverrides
or @AttributeOverride
를 사용해서 name과 컬럼을 재정의 할 수 있다.
임베디트 타입 값이 null이면 맵핑한 값은 모두 null이다.
값 타입과 불변 객체
임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 사이드 이펙트가 발생할 수 있기 때문에 위험하다. 공유해서 사용하고 싶으면 값 타입이 아니라 엔티티를 만들거나 값 타임을 복사한 객체로 사용해야 한다.
임베디드 타입은 값이 아니라 참조를 복사하고, 참조 값을 직접 대입하는 것을 막을 방법이 없기 때문에 누군가 객체를 복사하지 않고 사용하는 것을 원천적으로 막을 수 없는 한계가 있다.
이런 한계를 극복하기 위해 생성 이후 절대 값을 변경하지 못하도록 생성자만 제공하고, Setter를 제공하지 않아 불변 객체 형태로 만들어 사용 할 순 있다. 그 예가 Integer와 String이다. 만약 값을 바꾸고 싶다면 객체를 새로 만드는 방법이 있다.
값 타입의 비교
값 타입은 인스턴스가 달라도 인스턴스가 가지고 있는 값이 같으면 같다고 판단한다.
인스턴스 참조 값을 비교 시엔 == 을 사용하여 동일성을 비교 하고, 인스턴스의 값을 비교하고 싶으면 equals()메소드로 동등성을 비교한다.
값 타입 비교는 값 타입의 equals() 메소드를 적절하게 재정의(주로 모든 필드)하여 사용한다.
equals를 재정의했다면 해시 맵, 컬렉션을 효율적으로 사용하기 위해 hashCode()도 재정의가 필요하다.
값 타입 컬렉션
값 타입을 하나 이상 지정할 때는 @ElementCollection
과 @ElementTable(name="테이블명", joinColumns = @JoinColumn(name="조인컬럼명"))
을 사용한다. 단 데이터베이스 테이블에 컬렉션 형태로 값을 저장하는 것은 아니다. 일 대 다로 저장하는 것이며 다에 해당하는 값을 저장할 별도의 테이블이 필요하다.
값 타입 컬렉션은 본인만의 라이프 사이클이 없기 때문에 소속된 엔티티의 라이프 사이클을 따라가기 때문에 cascade + 고아 객체 제거 기능을 활성화 시킨 상태와 유사하다. 로딩은 지연 로딩 전략을 사용한다.
값 타입 컬렉션의 제약사항
값 타입은 엔티티와 다르게 식별자 개념이 없기 때문에 old 값을 remove하고 새로운 값을 add하면 주인 엔티티에서 old 값을 모두 지우고, 새로운 값으로 모두 저장한다. 이렇게 동작하기 때문에 값 타입 컬렉션을 맵핑하는 테이블은 모든 컬럼을 묶어서 키를 구성해야하고, 이에 따라 null 입력과 중복 저장은 불가하다.
이런 값 타입 컬렉션의 대안으로 실무에서는 상황에 따라 값 타입 컬렉션 대신 일 대 다 관계를 고려한다. 일 대 다 관계를 위한 엔티티를 생성하고, 이 엔티티에 값 타입 + 영속성 전이 + 고아 제거 옵션을 추가하여 사용한다.
값 타입은 딱 값 타입이라고 판단될 때만 사용하는 것이 좋으며 엔티티와 값 타입을 혼동하여 엔티티를 값 타입으로 만들면 안된다.
식별이 가능해야하고, 계속 값을 추적하고 변경해야한다면 그것은 값 타입이 아니라 엔티티로 사용해야한다.