DB/JPA

[Hibernate/JPA] 상속

꾸준함. 2025. 6. 2. 17:13

1. JPA 상속 정리

 

1.1 JPA에서 상속이 중요한 이유

  • 상속은 도메인 모델에서 공통 속성과 비즈니스 로직을 추상 클래스 또는 인터페이스로 표현할 수 있게 해 줌
  • 엔티티 계층 구조를 통해 중복 코드와 설계 복잡도를 줄이고, 전략 패턴(Strategy Pattern), 방문자 패턴(Visitor Pattern) 등 행동 패턴 구현에 적합함
  • 데이터 구조(속성)의 재사용보다, 메서드의 다양화에 더 적합함
    • 데이터 재사용에는 컴포지션 패턴이 더 나음

 

1.2  상속과 전략 패턴 실전 예시

  • 구독자 발송 시스템을 예로 들면 다음과 같음
    • Subscriber(부모): firstName, lastName, createdOn 등 공통 속성
    • EmailSubscriber, SmsSubscriber(자식): 각자 email, phoneNumber 등 추가 속성

 

  • 서비스 계층은 Subscriber의 타입에 따라 EmailCampaignSender, SmsCampaignSender 등 알맞은 전략을 선택
    • Spring DI, @Autowired List 등으로 전략 자동 매핑

 

  • 새로운 구독자 유형이 추가되어도, 비즈니스 로직과 데이터 구조가 유연하게 확장 가능

 

// 공통 속성을 갖는 추상 엔티티
@Entity
@Table(name = "subscriber")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "type")
public abstract class Subscriber {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
@Column(nullable = false)
private LocalDateTime subscribedOn = LocalDateTime.now();
// getters / setters
}
// 이메일 구독자
@Entity
@DiscriminatorValue("EMAIL")
public class EmailSubscriber extends Subscriber {
@Column(nullable = false)
private String emailAddress;
// getters / setters
}
// SMS 구독자
@Entity
@DiscriminatorValue("SMS")
public class SmsSubscriber extends Subscriber {
@Column(nullable = false)
private String phoneNumber;
// getters / setters
}
// 구독자 조회용 Spring Data JPA 리포지토리
@Repository
public interface SubscriberRepository extends JpaRepository<Subscriber, Long> {
// 기본 CRUD 외에 필요시 메서드 추가
}
// 캠페인 발송자 전략 인터페이스
public interface CampaignSender<T extends Subscriber> {
/**
* 이 CampaignSender가 지원하는 Subscriber 타입
*/
Class<T> appliesTo();
/**
* 실제 발송 로직
*/
void send(String title, String message, T subscriber);
}
// 이메일 발송 전략 구현
@Component
public class EmailCampaignSender implements CampaignSender<EmailSubscriber> {
private static final Logger LOGGER = LoggerFactory.getLogger(EmailCampaignSender.class);
@Override
public Class<EmailSubscriber> appliesTo() {
return EmailSubscriber.class;
}
@Override
public void send(String title, String message, EmailSubscriber subscriber) {
// 실제 이메일 전송 로직 대신 로그로 출력
LOGGER.info("Send Email: {} - {} to address: {}",
title, message, subscriber.getEmailAddress());
}
}
// SMS 발송 전략 구현
@Component
public class SmsCampaignSender implements CampaignSender<SmsSubscriber> {
private static final Logger LOGGER = LoggerFactory.getLogger(SmsCampaignSender.class);
@Override
public Class<SmsSubscriber> appliesTo() {
return SmsSubscriber.class;
}
@Override
public void send(String title, String message, SmsSubscriber subscriber) {
// 실제 SMS 전송 로직 대신 로그로 출력
LOGGER.info("Send SMS: {} - {} to phone number: {}",
title, message, subscriber.getPhoneNumber());
}
}
// 캠페인 서비스 – 전략 선택 및 실행
@Service
public class CampaignService {
@Autowired
private SubscriberRepository subscriberRepository;
@Autowired
private List<CampaignSender<?>> campaignSenders;
private Map<Class<? extends Subscriber>, CampaignSender<?>> senderMap = new HashMap<>();
@PostConstruct
@SuppressWarnings("unchecked")
public void init() {
// 각 CampaignSender 빈의 appliesTo() 타입과 매핑
for (CampaignSender<?> sender : campaignSenders) {
senderMap.put(sender.appliesTo(), sender);
}
}
@Transactional
public void sendCampaign(String title, String message) {
// DB에서 모든 구독자 조회
List<Subscriber> subscribers = subscriberRepository.findAll();
// 구독자 타입별로 적절한 전략으로 전송
for (Subscriber subscriber : subscribers) {
CampaignSender<Subscriber> sender =
(CampaignSender<Subscriber>) senderMap.get(subscriber.getClass());
if (sender != null) {
sender.send(title, message, subscriber);
}
}
}
}
// 간단한 테스트 코드
@Component
public class DemoRunner implements CommandLineRunner {
private final SubscriberRepository subscriberRepository;
private final CampaignService campaignService;
public DemoRunner(SubscriberRepository subscriberRepository,
CampaignService campaignService) {
this.subscriberRepository = subscriberRepository;
this.campaignService = campaignService;
}
@Override
public void run(String... args) {
// 구독자 엔티티 생성 및 저장
EmailSubscriber email = new EmailSubscriber();
email.setFirstName("Vlad");
email.setLastName("Mihalcea");
email.setEmailAddress("vlad@example.com");
subscriberRepository.save(email);
SmsSubscriber sms = new SmsSubscriber();
sms.setFirstName("Jane");
sms.setLastName("Doe");
sms.setPhoneNumber("010-1234-5678");
subscriberRepository.save(sms);
// 캠페인 전송
campaignService.sendCampaign(
"Spring Boot Rocks!",
"High-Performance Java Persistence with Vlad Mihalcea");
}
}
view raw .java hosted with ❤ by GitHub

 

 

1.3 관계형 데이터베이스 내 세 가지 상속 매핑 전략

 

가. Single Table Inheritance

  • 계층 전체를 단일 테이블에 저장
  • discriminator column(구분자 컬럼)으로 실제 타입 구분
  • 장점: 쿼리 단순, 조인 불필요, 성능 우수
  • 단점: null 컬럼 많아지며 컬럼 관리가 불편해짐

 

나. Class Table Inheritance (Joined Strategy)

  • 부모, 자식 각각 별도 테이블이며 PK = FK로 동등 조인
  • 장점: 정규화, 데이터 무결성
  • 단점: 쿼리 시 조인 필요하며 성능 저하 유발할 수 있음

 

다. Concrete Table Inheritance (Table Per Class)

  • 각 구체 자식마다 모든 속성을 포함한 테이블 생성
  • 장점: 단순하고 자식마다 독립적 테이블을 가짐
  • 단점: 공통 속성이 중복되며 UNION 쿼리 필요

 

1.4 실무 권장사항

  • 비즈니스 로직 다양화가 목적이라면 상속을 이용하고 속성 구조의 재사용이 목적이라면 컴포지션 패턴 사용하는 것을 권장
    • 전략 패턴 등 유연한 서비스 구현에 상속이 매우 유용

 

  • JPA 상속 매핑 전략은 성능, 테이블 구조, 쿼리 복잡성에 따라 신중히 선택해야 됨
    • 대량 데이터/자식이 많을수록 Single Table
    • 정규화·무결성이 중요하면 Joined
    • 단순/테이블 독립이 필요하면 Table Per Class

 

  • 상속계층이 DB에 반영되지 않아도 된다면, @MappedSuperclass로 추상화
    • 상속은 오직 도메인 모델에서만, 테이블은 생성하지 않음
    • 실질적으로 추상 클래스에서 공통 속성, 매핑, 로직만 상속받을 때 사용

 

2. Single Table 상속 전략

  • 단일 테이블 상속은 JPA에서 상속 계층 전체를 하나의 테이블로 매핑하는 전략
  • DTYPE 등 구분자 컬럼으로 실제 엔티티 타입을 구분

 

장점

  • 단일 테이블이므로 SELECT/INSERT/UPDATE가 단순하고 빠름
  • 다형성 쿼리(JPQL에서 부모 타입으로 조회, 자식 타입만 조회 등)도 SQL 한 번으로 처리 가능
  • JOIN이 없으므로 I/O, 네트워크, 인덱스 비용이 최소화됨

 

단점

  • 테이블에 모든 자식 속성이 컬럼으로 존재하므로, 어떤 행에는 특정 컬럼이 null이어야 정상적인데 서브클래스 전용 컬럼에 NOT NULL을 둘 수 없음 
    • 자식 클래스 전용 컬럼은 모두 NULL 허용이어야 하고, 상위 클래스 컬럼만 NOT NULL 지정 가능
    • 해당 단점을 극복하기 위해 PostgreSQL에서는 CHECK 제약 조건 활용

 

ALTER TABLE topic ADD CONSTRAINT post_content_check
CHECK (DTYPE != 'Post' OR content IS NOT NULL);
view raw .sql hosted with ❤ by GitHub

 

 

  • 데이터 무결성(Consistency) 약화
    • ACID의 Consistency 보장 측면에서 명확한 한계
    • 애플리케이션/Bean Validation만으로는 불충분

 

2.1 실무 적용 팁 및 한계

  • 하위 클래스 수가 적고, 컬럼 수가 많지 않을 때 권장
  • 읽기/쓰기가 많은 OLTP 시스템에서 성능이 잘 나옴
  • 하지만 하위 클래스가 많아질수록 테이블 컬럼 수가 기하급수적으로 증가하는 단점 존재
    • null 컬럼이 많이 생기면 관리, 성능 및 인덱스 효율이 저하됨

 

  • 따라서 실무에서는 하위 타입이 몇 개 안 될 때 그리고 최적의 성능이 필요할 때만 Single Table 전략을 채택하는 것을 권장

 

3. JPA Discriminator Column 

  • Discriminator 컬럼은 JPA 상속 계층 (특히 SINGLE_TABLE 전략)에서 동일 테이블 내 각 행이 어떤 구체 서브클래스 엔티티에 해당하는지 식별하는 컬럼
    • 만약 해당 컬럼이 없다면, JPA/Hibernate는 각 행이 어떤 타입인지 알 수 없음

 

  • SINGLE_TABLE 상속에서는 자동으로 DTYPE(문자열, 엔티티 이름)이 생성됨
  • 명시적으로 @DiscriminatorColumn 혹은 @DiscriminatorValue 어노테이션을 선언하여 컬럼 이름, 타입, 길이, 값 등을 커스터마이징 할 수 있음
    • STRING (default 값): 엔티티 이름 또는 지정 문자열
    • CHAR: 한 글자 식별자
    • INTEGER: 정수형 식별자

 

@DiscriminatorColumn(
discriminatorType = DiscriminatorType.STRING,
name = "topic_type_id",
columnDefinition = "VARCHAR(3)"
)
@DiscriminatorValue("TPC")
view raw @Java hosted with ❤ by GitHub

 

부연 설명

  • CHAR/INTEGER 타입을 사용하면 메모리 및 인덱스는 절약되지만, 실제 의미 파악이 힘듦
    • i.g. 1=Post, 2=Announcement 등
    • 이를 해결하기 위해 컬럼 주석을 활용하거나 별도 설명 테이블을 생성하는 방법이 있음

 

 

3.1 Discriminator 컬럼과 엔티티 저장 및 조회

  • 엔티티 저장 시, 각 서브클래스별로 Discriminator 컬럼이 자동으로 채워짐
  • JPQL/SQL 조회 시, Discriminator 컬럼을 WHERE 절에서 활용해 특정 타입만 필터링 가능

 

 

SELECT ... FROM topic t WHERE t.DTYPE = 'Post'
SELECT ... FROM topic t WHERE t.topic_type_id = 'PST'
view raw .sql hosted with ❤ by GitHub

 

3.2 JOINED 상속 전략에서의 Discriminator 컬럼

  • JOINED 전략에서도 Discriminator 컬럼을 둘 수 있지만 필수는 아님
  • Hibernate는 기본 테이블(부모)에 DTYPE 등 구분 컬럼을 두지만 서브클래스 테이블에는 별도 컬럼이 없음
  • 다형성 쿼리 시 JOIN과 함께 DTYPE을 WHERE 절로 활용할 수 있음
  • 만약 Discriminator 컬럼이 없으면, LEFT JOIN 결과를 기반으로 CASE 구문으로 가상 구분 컬럼을 생성해 동적으로 타입을 식별함

 

3.3 실무 적용 팁

  • SINGLE_TABLE 상속에는 Discriminator 컬럼이 필수
    • 엔티티 타입 식별, 다형성 쿼리, 성능 모두에 중요

 

  • @DiscriminatorColumn/@DiscriminatorValue로 컬럼명, 타입, 값을 적절히 커스터마이징 하는 것을 권장
  • CHAR/INTEGER 타입 사용 시에는 반드시 주석 또는 설명 테이블로 값 의미를 명확히 문서화하는 것을 권장
  • JOINED 전략에서는 Discriminator 컬럼이 선택사항이지만, 다형성 쿼리의 효율성을 위해 사용하는 것을 권장

 

4. Joined 상속 전략 

  • Joined 상속은 JPA 상속 전략 중 하나로, 부모(기본) 클래스와 각 자식(서브클래스)마다 별도의 테이블을 생성함
    • 각 엔티티 테이블은 상속 구조에 맞게 공통 속성은 부모 테이블에, 하위 클래스 전용 속성은 각 자식 테이블에 저장
    • 각 자식 테이블의 PK는 부모 테이블의 PK와 동일함 (즉, 자식 PK는 부모 PK의 FK)

 

장점

  • 각 테이블에 중복된 속성 없음
  • 서브클래스 전용 컬럼의 null 허용을 할 필요가 없으므로 데이터 무결성 ↑
  • 확장성 측면에서 우수한 전략

 

단점

  • 엔티티 저장 시 부모 및 자식 테이블에 각각 INSERT 필요
  • N개의 서브클래스가 있을 때 N + 1개 테이블을 JOIN 하므로 쿼리/인덱스/실행계획이 복잡해지고 성능 저하를 유발할 수 있음
  • 각 테이블의 PK 인덱스를 유지해야 하므로 메모리 사용량 증가

 

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public class Topic {
@Id
@GeneratedValue
private Long id; // 모든 공통 컬럼
private String title; // 모든 공통 컬럼
// 기타 공통 속성
}
@Entity
public class Post extends Topic {
private String content;
}
@Entity
public class Announcement extends Topic {
private Date validUntil;
}
view raw .java hosted with ❤ by GitHub

 

 

부연 설명

  • Post/Announcement를 저장하면 부모 테이블(topic)에 한 번, 자식 테이블(post/announcement)에 한 번씩 총 2개의 INSERT 수행
  • 부모 타입으로 조회할 때 각 자식 테이블을 모두 JOIN 한 뒤 CASE 구문으로 실제 타입을 구분함

 

4.1 실무 적용 팁

  • Joined 상속 전략은 자식 클래스가 많고, 각 클래스별 구조가 많이 다르며, 다형성 쿼리가 자주 발생하지 않을 때 가장 적합함
    • 다형성 쿼리가 빈번하거나, 성능이 최우선이면 SINGLE_TABLE 전략 고려

 

  • INSERT/SELECT 시 2개 이상의 테이블에 접근/조인하므로 성능에 민감한 환경에서는 테스트 및 튜닝 필수

 

5. TABLE_PER_CLASS 상속 전략 

  • JPA 상속 전략 중 하나로, 상속 계층의 각 엔티티마다 별도의 테이블을 생성하는 전략
    • 각 자식 테이블은 부모의 모든 필드와 자신의 필드를 모두 가짐 (즉, 모든 속성을 포함하며 중복된 컬럼이 존재함)
    • 저장할 때마다 공통 속성 및 자식 전용 속성을 모두 별도로 삽입해야 하며 PK는 각 테이블마다 독립적으로 관리됨

 

  • @ManyToOne, @OneToOne 등 연관관계 매핑 시, 부모 테이블 PK를 FK로 직접 참조할 수 없고 대신 각 자식 테이블 PK를 사용해야 함
  • 다형성 쿼리 즉, 부모 타입으로 조회할 때는 UNION ALL 또는 UNION을 통해 여러 테이블을 합쳐 결과를 생성해야 함
    • 실제 서브클래스 엔티티(자식 테이블)만 조회할 때는 효율적이지만
    • 부모 타입을 통해 조회하는 다형성 쿼리는 서브클래스 수가 많아질수록 점점 느려짐
    • UNION ALL 지원이 중요한데, 일부 Hibernate Dialect(Hibernate 5.4.1 미만 MySQL, MariaDB 등)는 UNION만 지원하면 정렬(중복 제거)로 인해 성능이 더 저하될 수 있음

 

장점

  • 각 테이블이 모든 속성을 가지므로, 자식별로 NOT NULL, 제약조건 등 자유롭게 설정 가능
  • 단일 테이블에만 INSERT 하므로 쓰기 성능 우수
  • 데이터 정규화 및 자식별 독립성 측면에서 우수

 

단점

  • UNION ALL/UNION 사용 시 테이블이 많아질수록 다형성 쿼리 성능 저하
  • 각 테이블이 독립적으로 PK를 관리하므로 PK 및 인덱스가 중복됨
  • 부모/자식 테이블 간 FK가 없어, 다형성 관계 매핑이 번거로움

 

5.1 실무 적용 팁

  • TABLE_PER_CLASS 상속 전략은 다음과 같은 상황일 때 사용하는 것을 권장
    • 계층 구조가 작고, 각 자식 간 독립성이 매우 중요할 때
    • 실제 서브클래스(자식)만 자주 조회할 때
    • 부모/자식 간 연관관계가 단순할 때

 

  • 다형성 쿼리, 대규모 상속 계층, PK 충돌 방지, 쿼리 성능 등이 중요하다면 SINGLE_TABLE 또는 JOINED 전략 추천

 

6. @MappedSuperclass

  • @MappedSuperclass는 공통 속성과 매핑을 자식 엔티티에 물려주지만, 자신은 테이블로 매핑되지 않는 추상 클래스
    • 즉, DB에는 테이블이 생성되지 않지만, 하위 클래스(@Entity로 선언된 엔티티)들은 해당 슈퍼클래스의 모든 필드와 매핑을 그대로 상속받아 자신의 테이블에 컬럼으로 포함시킴

 

@MappedSuperclass
public abstract class Topic {
@Id
private Long id;
private String title;
private String owner;
@Temporal(TemporalType.TIMESTAMP)
private Date createdOn = new Date();
}
// Post, Announcement 엔티티는 Topic의 모든 속성을 물려받아
// 각자 post, announcement 테이블에 모든 컬럼을 포함
@Entity
public class Post extends Topic {
private String content;
}
@Entity
public class Announcement extends Topic {
private Date validUntil;
}
view raw .java hosted with ❤ by GitHub

 

장점

  • 쓰기/읽기 모두 단일 테이블에서 동작하므로 매우 효율적
  • 공통 매핑, 연관관계, 필드 관리가 상대적으로 쉬움
  • 테이블 구조 중복, PK 충돌, 다형성 쿼리 비효율 등 Table Per Class의 단점을 회피하므로 Table Per Class 전략보다 실용적

 

단점

  • 테이블이 존재하지 않으므로 부모 타입으로 직접 쿼리 및 연관관계 매핑이 불가하고 반드시 자식 엔티티로만 사용해야 함

 

6.1 실무 적용 팁

  • @MappedSuperclass는 다음과 같은 상황일 때 사용하는 것을 권장
    • 상위 클래스를 DB 테이블로 만들 필요가 없고 공통 매핑/필드를 여러 엔티티에 물려주고 싶을 때
    • 다형성 쿼리 및 연관관계 매핑이 필요 없는 경우

 

참고

인프런 - 고성능 JPA & Hibernate (High-Performance Java Persistence)

 

 

반응형

'DB > JPA' 카테고리의 다른 글

[Hibernate/JPA] Statement  (0) 2025.06.04
[Hibernate/JPA] 영속성 컨텍스트  (0) 2025.06.04
[Hibernate/JPA] 관계  (0) 2025.06.02
[Hibernate/JPA] 식별자 생성 최적화 전략  (0) 2025.05.30
[Hibernate/JPA] 타입  (0) 2025.05.29