Search

JPA의 사실과 오해 (6) 컬렉션 매핑, 상속 매핑

Tags
Study
JPA
Database
Last edited time
2025/07/21 08:02
2 more properties
Search
JPA의 사실과 오해 (8) 컬렉션로딩 문제 (지연/즉시 로딩)
Study
JPA
Database
JPA의 사실과 오해 (8) 컬렉션로딩 문제 (지연/즉시 로딩)
Study
JPA
Database

1. 컬렉션 매핑

1.1. 런타임 래퍼 클래스

단방향 OneToMany 컬렉션 매핑
Movie가 Screening의 컬렉션을 포함한다고 가정
1:N 관계 / 연관관계가 Movie → Screening인 상황
런타임 래퍼 클래스
Hibernate는 해당 컬렉션을 단순한 Java 컬렉션(List, Set 등)이 아니라, "특수한 감시용 래퍼 객체"로 바꿔치기(프록시)해서 관리
예) PersistentBag
지연로딩(Lazy Loading), 변경 감지(Dirty Checking), 관계 관리 자동화
초기화(선할당)와 Hibernate 래퍼의 관계
선할당 시, Hibernate가 엔티티를 영속화할 때 내부적으로 PersistentBag 같은 객체로 "덮어쓰기"해서 바꿔치기함 (프록시 패턴과 유사).
컬렉션에 대한 코드 호출이 정상 동작하며, 변경 감지도 잘 됨
초기화하지 않고 null로 남겨둘 경우,
영속화 시점에 Hibernate가 알아서 래퍼를 할당함
그러나, 엔티티가 영속화되기 전(persist() 전)에 컬렉션을 직접 사용하면 NPE가 발생할 수 있음
@Entity public class Movie { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private Money fee; @OneToMany(cascade = CascadeType.PERSIST) private Collection<Screening> screenings = new ArrayList(); public void addScreening(Screening screening) { this.screenings.add(screening); } }
Java
복사
@Entity public class Screening { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private int sequence; private LocalDateTime screeningTime; }
Java
복사

1.2. 컬렉션 타입

종류
(Bag) 컬렉션
중복 허용 / 순서 유지 하지 않음 / 가장 효율적인 성능 특징
셋(Set) 컬렉션
중복 허용하지 않음 / 순서 유지하지 않음
리스트 (List) + @OrderColumn 컬렉션
중복 허용 / 순서 유지
맵(Map) 컬렉션
중복 허용하지 않음 / 순서 유지하지 않음 / 키와 값 구성
// Bag 컬렉션 private Collection<Screening> screenings = new ArrayList<>(); // Set 컬렉션 private Set<Screening> screenings = new HashSet<>(); // List + OrderColumn 컬렉션 @OrderColunm(name=CONDITION_ORDER) private List<Screening> screenings = new ArrayList<>(); // Map 컬렉션 private Map<Screening> screenings = new HashMap<>();
TypeScript
복사

1.3. 컬렉션의 런타임 동작 방식

컬렉션은 매핑 방식보다 런타임의 동작 방식이 더 중요
컬렉션 요소 삭제 시 주의점
컬렉션에서 관계를 끊은 것 remove() . 요소 자체가 삭제되는 것이 아님
FK, 참조하는 것만 null로 바꿈
컬렉션은 두 객체간의 관계를 끊는거지 데이터를 삭제하는 것이 아님.
요소 자체를 삭제하고 싶다면?
@OneToMany(orphanRemoval = true)
컬렉션에서 요소가 제거될 때 테이블 로우도 함께 삭제

1.4. 단방향 컬렉션 매핑 → 많은 쿼리 실행

컬렉션은 FK를 안갖고 있음. FK는 자식쪽에 있음 (연관관계의 주인)
단방향 컬렉션은 INSERT 후 UPDATE 가 나올 수 밖에 없음
단방향 컬렉션 매핑은 엔티티 추가할 때 많은 쿼리가 실행될 수 있음
쿼리 횟수를 줄이고 싶다면 양방향 컬렉션 매핑 사용
mappedBy로 양방향 참조가 되었다면 FK를 알고 있기 때문에 UPDATE 쿼리 불필요
컬렉션 매핑에서 IINSERST 쿼리 횟수로 줄이려면 양방향 연관관계 매핑으로 설정
양방향 관계와 캡슐화
양방향의 관계라도 캡슐화의 경계로 묶었다면 괜찮음.
캡슐화의 경계? 외부에 보여주고 싶지않음. 캡슐화 경계라면 같이 코드를 수정하겠다는 의미

1.5. 리스트 + @OrderColumn 컬렉션

특징
중복 허용 + 순서 유지
결론
안쓰는게 좋음 / 일관성이 없음.
중간에 특정 데이터를 제거하는 경우 → 순서가 다 바뀌어야함
Order 컬럼과 FK 컬럼 모두 Null 처리 후 업데이트
Movie loadedMovie = em.find(Movie.class, movie.getId()); loadedMovie.getScreenings().remove(1); em.flush();
Java
복사
update screening set movie_id=null,play_order=null where movie_id=1 and id=3 update screening set movie_id=null,play_order=null where movie_id=1 and id=2 update screening set movie_id=1,play_order=1 where id=3
SQL
복사
리스트 양방향 매핑
리스트 인덱스 관리를 위해 Movie에서 외래키를 관리하므로 mappedBy 대신 @JoinColumn 지정
외래키를 Movie에 맡기기 때문에 updatable, Insertable을 null로 설정
@Entity public class Movie { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private Integer runningTime; private Money fee; @OneToMany(cascade = CascadeType.PERSIST) @JoinColumn(name = "MOVIE_ID") @OrderColumn(name = "PLAYING_ORDER") private List<Screening> screenings = new ArrayList<>(); public void addScreening(Screening screening) { this.screenings.add(screening); } }
SQL
복사
@Entity public class Screening { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private int sequence; private LocalDateTime screeningTime; @ManyToOne @JoinColumn(name = "MOVIE_ID", updatable = false, insertable = false) private Movie movie; }
SQL
복사

1.6. 값 객체 컬렉션 매핑

@ElementCollection
OneToMany 이므로 기본값은 지연 로딩
@CollectionTable
PK + 값 객체의 모든 속성을 가지고 JPA가 테이블을 만들어줌
값 객체 컬렉션을 저장할 테이블 이름과 FK 컬럼 지정
Embeddable을 담을 테이블이라는 것을 알려주는 것
@Entity @NoArgsConstructor public class Movie { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private Money fee; @ElementCollection @CollectionTable(name = "PRICES", joinColumns = @JoinColumn(name = "MOVIE_ID")) private Set<Money> prices = new HashSet<>(); public void addPrice(Money price) { this.prices.add(price); } }
Java
복사
@Embeddable public class Money { private BigDecimal fee; public void plus(Money other) { this.amount = this.amount.plus(other.fee); } }
Java
복사
erDiagram
    MOVIE {
        Long id PK
        String title
        Money fee
    }
    PRICES {
        Long movie_id FK, PK
        BigDecimal fee PK
    }

    MOVIE ||--o{ PRICES : "1:N"
Mermaid
복사
값 객체 컬렉션 데이터베이스 매핑
값 객체 컬렉션의 PK
값 객체 속성(fee) + 값 객체를 포함하는 테이블 ID(movie_id)
CasCasde 옵션이 따로 없음
이유? 라이프사이클이 무조건 종속적이기때문에 필요없는것
CascadeType.ALL, orphanRemoval = true 와 동일
값 객체는 식별성이 없음
1000개에서 1개 추가 → 999개 삭제하고 1개 추가함
값객체 컬렉션 관리 → 컬렉션 갯수가 적어야함
혹은 만든 순간 이뮤터블해야함
값 객체 컬렉션 전체 삭제
전체 삭제

1.7. 값 객체 컬렉션과 참조 객체 컬렉션 차이

구분
컬렉션 요소 추가 쿼리 동작
컬렉션 삭제 쿼리 동작
PRICES(값타입 컬렉션)
전체 요소 삭제 후 전체 요소를 다시 저장 delete from prices where movie_id=1 insert into prices (movie_id, fee) values (1, 10000) insert into prices (movie_id, fee) values (1, 2000)
하나의 쿼리로 전체 요소 삭제 delete from prices where movie_id=1
SCREENING(엔티티 컬렉션)
추가된 요소만 저장 insert into screening (screening_time, sequence, id) values ('2024-12-09 13:00', 3, default)
요소만큼 삭제 쿼리 실행 delete from screening where id=1 delete from screening where id=2 delete from screening where id=3
컬렉션과 관련된 두가지 문제점
즉시 로딩
데카르트 곱 문제
지연 로딩
N + 1 문제

2. 상속 매핑

Java interface는 @entity로 매핑 불가능
인터페이스를 추상클래스로 변경 후 매핑
classDiagram
    class DiscountPolicy {
        +calculateDiscount(screening)
    }
    class AmountDiscountPolicy
    class PercentDiscountPolicy
    class DiscountCondition {
        <<interface>>
        +isSatisfiedBy(screening)
    }
    class SequenceCondition
    class PeriodCondition

    DiscountPolicy <|-- AmountDiscountPolicy
    DiscountPolicy <|-- PercentDiscountPolicy

    DiscountPolicy "1" --> "1..*" DiscountCondition

    DiscountCondition <|.. SequenceCondition
    DiscountCondition <|.. PeriodCondition
Mermaid
복사

2.1. 상속의 특징

2.1.1. 상속의 용도

다형적인 연관관계 / Polymorphic Association
구체 클래스가 아닌 추상클래스에만 의존
DiscountPolicy는 DiscountCondition에만 의존

2.1.2. JPA는 적절한 구현 클래스의 인스턴스 조회

예) 할인 조건 → SequenceCondition, Period Condition

2.1.3. 의존성 주입

구체적인 타입을 내부에서 직접 생성하지 않고 외부에서 생성하여 주입

2.2. 단일 테이블 전략

성능이 중요하고 상속 계층 리팩터링이 자주 발생할 경우 사용
장점
빠른 조회 성능, 단순한 조회 쿼리
단점
nullable 제약 조건 강제 불가능
테이블의 크기가 커지면 성능이 느려질 수도 있음
classDiagram
    class DiscountCondition {
        +isSatisfiedBy(screening)
    }
    class SequenceCondition {
        sequence
    }
    class PeriodCondition {
        dayOfWeek
        startTime
        endTime
    }

    DiscountCondition <|-- SequenceCondition
    DiscountCondition <|-- PeriodCondition

    class DISCOUNT_CONDITION {
        ID
        CONDITION_TYPE
        SEQUENCE
        DAY_OF_WEEK
        START_TIME
        END_TIME
    }
Mermaid
복사

2.2.1. 클래스 매핑

JPA 단일 테이블 상속 전략(SINGLE_TABLE)
하나의 테이블(discount_condition)에 모든 서브 클래스의 데이터를 함께 저장하는 방식
모든 컬럼이 하나의 테이블(discount_condition)에 포함되며, 서브 클래스별 컬럼은 사용하지 않을 경우 NULL로 저장
condition_type 컬럼으로 각 객체의 타입을 구분
@Entity @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name="condition_type") public abstract class DiscountCondition { @Id @GeneratedValue(strategy = GenerationType.TABLE) private Long id; }
Java
복사
@Entity @DiscriminatorValue("PERIOD") public class PeriodCondition extends DiscountCondition { private DayOfWeek dayOfWeek; private LocalTime startTime, endTime; } @Entity @DiscriminatorValue("SEQUENCE") public class SequenceCondition extends DiscountCondition { private int sequence; }
Java
복사

2.2.2. 다형적 쿼리

모든 필드를 한 테이블에서 조회
select c from DiscountCondition c
SQL
복사
select dc1_0.id, dc1_0.condition_type, dc1_0.day_of_week, dc1_0.end_time, dc1_0.start_time, dc1_0.sequence from discount_condition dc1_0
SQL
복사

2.2.3. 서브클래스 별 쿼리

SequenceCondition만 조회
select c from SequenceCondition c
SQL
복사
jpql
select sc1_0.id, sc1_0.sequence from discount_condition sc1_0 where sc1_0.condition_type='SEQUENCE'
SQL
복사
PeriodCondition만 조회
select c from PeriodCondition c
SQL
복사
jpql
select pc1_0.id, pc1_0.day_of_week, pc1_0.end_time, pc1_0.start_time from discount_condition pc1_0 where pc1_0.condition_type='PERIOD'
SQL
복사

2.3. 조인 전략

데이터 무결성이 중요하고 상속 계층이 안정적일때 사용
장점
not nullable 제약조건 강제 가능
테이블 정규화
단점
상속 계층이 복잡할 경우 조인으로 인한 성능 저하 발생
조회 쿼리 복잡
classDiagram
    class DiscountCondition {
        +isSatisfiedBy(screening)
    }
    class SequenceCondition {
        sequence
    }
    class PeriodCondition {
        dayOfWeek
        startTime
        endTime
    }

    DiscountCondition <|-- SequenceCondition
    DiscountCondition <|-- PeriodCondition
Mermaid
복사
classDiagram
    class DISCOUNT_CONDITION {
        ID
    }
    class SEQUENCE_CONDITION {
        SEQUENCE_ID (FK, PK)
        SEQUENCE
    }
    class PERIOD_CONDITION {
        PERIOD_ID (FK, PK)
        DAY_OF_WEEK
        START_TIME
        END_TIME
    }

    DISCOUNT_CONDITION <|-- SEQUENCE_CONDITION : SEQUENCE_ID = ID
    DISCOUNT_CONDITION <|-- PERIOD_CONDITION : PERIOD_ID = ID
Mermaid
복사

2.3.1. 다형적 쿼리

discount_condition(부모) 테이블을 기준으로, 하위 테이블(period_condition, sequence_condition)을 LEFT JOIN
어떤 하위 타입인지 구분(case문 등)
모든 타입의 객체를 한 번에 조회 가능
select c from DiscountCondition c
SQL
복사
select dc1_0.id, case when dc1_1.period_id is not null then 1 when dc1_2.sequence_id is not null then 2 end, dc1_1.day_of_week, dc1_1.end_time, dc1_1.start_time, dc1_2.sequence from discount_condition dc1_0 left join period_condition dc1_1 on dc1_0.id = dc1_1.period_id left join sequence_condition dc1_2 on dc1_0.id = dc1_2.sequence_id
SQL
복사

2.3.2. 서브클래스별 쿼리

SequenceCondition만 조회
INNER JOIN
시퀀스 타입에 해당하는 조건만 조회
select c from SequenceCondition c
Plain Text
복사
select sc1_0.sequence_id, sc1_0.sequence from sequence_condition sc1_0 join discount_condition sc1_1 on sc1_0.sequence_id = sc1_1.id
SQL
복사
PeriodCondition만 조회
INNER JOIN
기간 타입에 해당하는 조건만 조회
select c from PeriodCondition c
SQL
복사
select pc1_0.period_id, pc1_0.day_of_week, pc1_0.end_time, pc1_0.start_time from period_condition pc1_0 join discount_condition pc1_1 on pc1_0.period_id = pc1_1.id
SQL
복사

2.4. 구현 클래스 마다 테이블 전략

테이블끼리 관계를 설정하지 않고 별도로 만듬
장점
서브 타입을 구분해서 처리할 때 효과적
not null 제약 조건 사용 가능
단점
여러 자식 테이블을 함께 조회할때 성능 저하
자식 테이블을 통합하여 쿼리하기 어려움
classDiagram
    class DiscountCondition {
        +isSatisfiedBy(screening)
    }
    class SequenceCondition {
        sequence
    }
    class PeriodCondition {
        dayOfWeek
        startTime
        endTime
    }

    DiscountCondition <|-- SequenceCondition
    DiscountCondition <|-- PeriodCondition
Mermaid
복사
classDiagram
    class SEQUENCE_CONDITION {
        ID
        SEQUENCE
    }
    class PERIOD_CONDITION {
        ID
        DAY_OF_WEEK
        START_TIME
        END_TIME
    }
Mermaid
복사

2.4.1. 클래스 매핑

상속 전략
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
IDENTITY 전략은 사용 불가. PK를 공유하지 못함
각 서브클래스마다 별도의 테이블 생성
(예: sequence_condition, period_condition)
@Entity @Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) public abstract class DiscountCondition { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "movie_sequence") private Long id; } @Entity public class PeriodCondition extends DiscountCondition { private DayOfWeek dayOfWeek; private LocalTime startTime, endTime; } @Entity public class SequenceCondition extends DiscountCondition { private int sequence; }
Java
복사

2.4.2. 다형적 쿼리

서브클래스별로 생성된 테이블을 UNION ALL로 합쳐 조회
공통되지 않은 컬럼은 NULL로 채움
서브타입 구분을 위해 clazz(타입값) 컬럼 추가
select c from DiscountCondition c
SQL
복사
select dc1_0.id, dc1_0.clazz_, dc1_0.day_of_week, dc1_0.end_time, dc1_0.start_time, dc1_0.sequence from ( select id, 1 as clazz_, day_of_week, end_time, start_time, null as sequence from period_condition union all select id, 2 as clazz_, null as day_of_week, null as end_time, null as start_time, sequence from sequence_condition ) dc1_0
SQL
복사

2.4.3. 서브클래스 별 쿼리

서브클래스 테이블에 직접 쿼리

2.5. 상속과 코드 재사용

2.5.1. 취약한 기반 클래스 문제

부모 클래스를 추가해야한다면?
다중 상속을 지원하지 않는 언어에서는 부모 클래스 추가 불가능
부모클래스의 구현이 바뀌면 자식 클래스의 구현이 깨짐
예) 할인이 적용 안될 때 Movie에서 정가 대신 기본 금액을 반환하려고 한다면??
@Entity public class Movie { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private Money fee; private Money baseFee; private Money discountAmount; private Set<DiscountCondition> conditions = new HashSet<>(); public Money calculateFee(Screening screening) { for (DiscountCondition each : conditions) { if (each.isSatisfiedBy(screening)) { return fee.minus(discountAmount); } } return fee.isLessThan(baseFee) ? fee : baseFee; } public Money getFee() { return fee; } }
Java
복사
@Entity public class PercentDiscountMovie extends Movie { private double discountPercent; public PercentDiscountMovie(String title, Money fee, double discountPercent) { super(title, fee, Money.ZERO); this.discountPercent = discountPercent; } @Override public Money calculateFee(Screening screening) { // 모든 경우에 할인이 적용되는 경우로 판단 if (super.calculateFee(screening).equals(getFee())) { return getFee().minus(getFee().times(discountPercent)); } return getFee(); } }
Java
복사

2.5.2. 상속 보다는 합성 사용

재사용하고싶다면? 합성을 써라
상속은 다형성을 위해서만 쓸것
재사용을 위해서는 합성을 쓸 것
classDiagram
    class Movie {
        id
        discountAmount
    }
    class PercentDiscountMovie {
        id
        discountPercent
    }

    Movie <|-- PercentDiscountMovie
Mermaid
복사
classDiagram
    class Movie {
        id
    }
    class DiscountPolicy {
        id
    }
    class AmountDiscountPolicy {
        discountAmount
    }
    class PercentDiscountPolicy {
        discountPercent
    }

    DiscountPolicy <|-- AmountDiscountPolicy
    DiscountPolicy <|-- PercentDiscountPolicy
    Movie "1" -- "0..1" DiscountPolicy

Mermaid
복사

2.5.3. 올바른 상속

다형적인 연관관계
구체적인 타입인 isSatisfiedBy 메서드 실행
DiscountCondition 는 인터페이스일뿐 실제 객체는 구체 클래스임
자바의 다형성에 따라 실제 객체의 타입에 맞는 isSatisfiedBy 가 자동으로 실행
즉, 부모 타입(DiscountCondition)을 참조하더라도 자식 타입(SequenceCondition , PeriodCondition ) 의 실제 구현이 동작한다는 것을 의미
올바른 상속 계층
특정 클래스 안에서 메서드를 구현하는 메서드는 단 한 하나만 존재
부모 클래스의 구체 메서드를 오버라이딩하면 동작이 이상해질 수 있음
클라이언트의 동작이 이상해질 수 있음. 리스코프 치환의 원칙 위반
상속 계층이 있을 때, 구체메서드는 하나만 있어야함
public abstract class DiscountPolicy { private Set<DiscountCondition> conditions = new HashSet<>(); // 특정 클래스 안에서 메서드를 구현하는 메서드는 단 한 하나만 존재 public Money calculateDiscount(Screening screening) { for (DiscountCondition each : conditions) { // 구체적인 타입인 isSatisfiedBy 메서드 실행 if (each.isSatisfiedBy(screening)) { return getDiscountAmount(screening); } } return Money.ZERO; } abstract protected Money getDiscountAmount(Screening screening); }
Java
복사
public abstract class DiscountCondition { public abstract boolean isSatisfiedBy(Screening screening); } public class SequenceCondition implements DiscountCondition { private int sequence; public boolean isSatisfiedBy(Screening screening) { return screening.isSequence(sequence); } } public class PeriodCondition implements DiscountCondition { private DayOfWeek dayOfWeek; private LocalTime startTime, endTime; public boolean isSatisfiedBy(Screening screening) { return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) && startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 && endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0; } }
Java
복사