Search
moon
sun

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

Tags
Study
JPA
Database
Last edited time
2025/07/21 08:02
2 more properties
Search
JPA의 사실과 오해 (9) 동시성 제어 / 애그리거트 완성하기
Study
JPA
Database
JPA의 사실과 오해 (9) 동시성 제어 / 애그리거트 완성하기
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
복사