Spring/이론

[트러블슈팅] JPA 상속관계 매핑 3가지 전략 - 실무에서는 언제 쓸까?

JuNo_12 2025. 6. 4. 20:46

"JPA 고급 기능, 정말 써야 할까?" 시리즈 2편

들어가며

1편에서 연관관계 매핑의 복잡성에 대해 이야기했다면, 이번에는 상속관계 매핑을 다뤄보려 합니다.

처음 상속관계 매핑 강의를 들을 때는 정말 이해가 안 됐습니다. @Inheritance, @DiscriminatorColumn, InheritanceType.JOINED... 무슨 말을 하고 있는지 전혀 모르겠더군요.

하지만 "왜 이게 필요한가?"부터 생각해보니 개념이 명확해졌습니다. 그리고 실무에서는 언제 써야 하는지도 보이기 시작했습니다.


문제 상황: 공통 속성과 개별 속성

쇼핑몰 상품 관리의 고민

온라인 쇼핑몰에서 다양한 상품을 관리한다고 생각해보겠습니다:

// 책
- 이름, 가격, 저자, ISBN

// 영화  
- 이름, 가격, 감독, 배우

// 앨범
- 이름, 가격, 아티스트, 장르

공통점: 이름, 가격 (모든 상품의 기본 정보)
차이점: 나머지는 상품 종류마다 완전히 다름

이걸 데이터베이스 테이블로 어떻게 설계할까요? 바로 이 고민에서 상속관계 매핑이 시작됩니다.

 

본질은 단순합니다

"공통 속성 + 개별 속성을 테이블로 어떻게 표현할까?"

결국 이것만 이해하면 됩니다. 복잡해 보이는 어노테이션들은 나중에 찾아봐도 충분합니다.


3가지 전략 비교

전략 1: SINGLE_TABLE (테이블 하나에 모든 정보)

CREATE TABLE item (
    id BIGINT PRIMARY KEY,
    item_type VARCHAR(255),    -- 구분용 (BOOK, MOVIE, ALBUM)
    name VARCHAR(255),         -- 공통 속성
    price INT,                 -- 공통 속성
    author VARCHAR(255),       -- 책만 사용 (영화, 앨범은 NULL)
    isbn VARCHAR(255),         -- 책만 사용  
    director VARCHAR(255),     -- 영화만 사용 (책, 앨범은 NULL)
    actor VARCHAR(255),        -- 영화만 사용
    artist VARCHAR(255),       -- 앨범만 사용 (책, 영화는 NULL)
    genre VARCHAR(255)         -- 앨범만 사용
);

 

JPA 코드:

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "item_type")
public abstract class Item {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int price;
}

@Entity
@DiscriminatorValue("BOOK")
public class Book extends Item {
    private String author;
    private String isbn;
}

 

장점:

  • 조회 성능 빠름 (JOIN 불필요)
  • 테이블 구조 단순

단점:

  • 빈 컬럼 많음 (각 행마다 사용하지 않는 컬럼들이 NULL)
  • 테이블 크기 증가

전략 2: JOINED (공통 테이블 + 개별 테이블)

-- 공통 정보 테이블
CREATE TABLE item (
    id BIGINT PRIMARY KEY,
    name VARCHAR(255),
    price INT,
    item_type VARCHAR(255)
);

-- 책 전용 테이블
CREATE TABLE book (
    id BIGINT PRIMARY KEY,  -- item.id와 1:1 관계
    author VARCHAR(255),
    isbn VARCHAR(255),
    FOREIGN KEY (id) REFERENCES item(id)
);

-- 영화 전용 테이블  
CREATE TABLE movie (
    id BIGINT PRIMARY KEY,
    director VARCHAR(255),
    actor VARCHAR(255),
    FOREIGN KEY (id) REFERENCES item(id)
);

 

JPA 코드:

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Item {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int price;
}

@Entity
public class Book extends Item {
    private String author;
    private String isbn;
}

 

장점:

  • 정규화된 구조 (데이터 중복 없음)
  • 저장 공간 효율적

단점:

  • JOIN 연산 필요 (조회 성능 저하)
  • 쿼리 복잡성 증가

전략 3: TABLE_PER_CLASS (완전히 분리된 테이블)

-- 각자 독립적인 테이블
CREATE TABLE book (
    id BIGINT PRIMARY KEY,
    name VARCHAR(255),      -- 공통 속성이지만 각 테이블에 중복
    price INT,              -- 공통 속성이지만 각 테이블에 중복  
    author VARCHAR(255),
    isbn VARCHAR(255)
);

CREATE TABLE movie (
    id BIGINT PRIMARY KEY,
    name VARCHAR(255),      -- 중복!
    price INT,              -- 중복!
    director VARCHAR(255),
    actor VARCHAR(255)
);

JPA 코드:

 
java
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int price;
}

장점:

  • 구조가 단순 (각 테이블이 독립적)
  • JOIN 불필요

단점:

  • 공통 속성 중복 (변경 시 모든 테이블 수정 필요)
  • 전체 조회 시 UNION 연산

실무에서의 현실

상속관계 매핑을 피하는 이유

1. 복잡성 증가

// 간단해 보이지만...
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Item { }

@Entity  
public class Book extends Item { }

// 실제로는 이런 쿼리가 나감
// SELECT i.*, b.* FROM item i JOIN book b ON i.id = b.id WHERE i.id = ?

 

문제점:

  • 쿼리 예측하기 어려움
  • 성능 튜닝 복잡
  • 디버깅 어려움

 

2. 요구사항 변경에 취약

// 처음에는 단순했지만...
public class Book extends Item {
    private String author;
}

// 나중에 요구사항 변경
public class Book extends Item {
    private String author;
    private String translator;    // 번역자 추가
    private String originalTitle; // 원제 추가  
    private List<String> categories; // 카테고리 여러 개로 변경
}

상속 구조가 복잡해질수록 테이블 변경의 파급효과가 커집니다.

 

3. 테스트 복잡성

@Test
public void testBook() {
    // 상속관계 때문에 복잡한 설정 필요
    Book book = new Book();
    book.setName("Clean Code");      // 부모 클래스 속성
    book.setPrice(30000);            // 부모 클래스 속성
    book.setAuthor("Robert Martin"); // 자식 클래스 속성
    
    // 저장할 때도 두 테이블에 INSERT
    bookRepository.save(book);
}

 

대안: 컴포지션 패턴

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int price;
    private ProductType type; // BOOK, MOVIE, ALBUM
}

// 타입별 상세 정보는 별도 테이블로
@Entity
public class BookDetail {
    @Id @GeneratedValue  
    private Long id;
    private Long productId; // Product와 연결
    private String author;
    private String isbn;
}

@Entity
public class MovieDetail {
    @Id @GeneratedValue
    private Long id; 
    private Long productId;
    private String director;
    private String actor;
}

 

장점:

  • 구조가 명확하고 이해하기 쉬움
  • 각 테이블이 독립적으로 변경 가능
  • 쿼리가 예측 가능
  • 테스트하기 쉬움

언제 상속관계 매핑을 써야 할까?

써도 되는 경우

1. 구조가 안정적일 때

// 도형 계산 시스템 (변경 가능성 낮음)
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public abstract class Shape {
    @Id @GeneratedValue
    private Long id;
    public abstract double calculateArea();
}

@Entity
public class Circle extends Shape {
    private double radius;
    public double calculateArea() { return Math.PI * radius * radius; }
}

@Entity
public class Rectangle extends Shape {
    private double width;
    private double height;
    public double calculateArea() { return width * height; }
}

수학적 공식처럼 변경 가능성이 낮은 도메인에서는 괜찮습니다.

 

2. 성능이 중요하고 타입 수가 적을 때

// 알림 시스템 (빠른 조회 필요, 타입 3-4개)
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public abstract class Notification {
    private String title;
    private String content;
    private LocalDateTime createdAt;
}

@Entity
public class EmailNotification extends Notification {
    private String email;
}

@Entity  
public class SmsNotification extends Notification {
    private String phoneNumber;
}

SINGLE_TABLE 전략으로 빠른 조회가 가능하고, 타입이 많지 않아서 관리 가능합니다.


피해야 하는 경우

1. 비즈니스 규칙이 자주 변하는 경우

// ❌ 이커머스 상품 (요구사항 변경 잦음)
// 새로운 상품 타입 추가, 속성 변경 등이 빈번

 

2. 타입이 많거나 계속 증가할 가능성

// ❌ 콘텐츠 관리 시스템
// 게시글, 이미지, 동영상, 문서, 댓글... (끝없이 확장)

 

3. 각 타입별 비즈니스 로직이 복잡한 경우

// ❌ 금융 상품
// 대출, 예금, 보험... 각각 완전히 다른 비즈니스 규칙

실무에서의 권장 사항

기본 원칙

  1. 의심스러우면 상속관계 매핑 안 쓰기
  2. 컴포지션 패턴을 먼저 고려
  3. 단순한 구조를 선호
  4. 성능 문제 생기면 그때 최적화

 

단계별 접근

// 1단계: 가장 단순한 형태
@Entity
public class Product {
    private String name;
    private int price;
    private String type;
    private String details; // JSON으로 타입별 정보 저장
}

// 2단계: 별도 테이블로 분리
@Entity  
public class ProductDetail {
    private Long productId;
    private String key;
    private String value;
}

// 3단계: 타입별 테이블 분리
@Entity
public class BookDetail { /* ... */ }

// 마지막 단계: 정말 필요하면 상속관계 매핑 고려

점진적으로 복잡도를 높여가는 접근이 안전합니다.


결론: "이런 기능이 있구나" 정도로

상속관계 매핑을 학습하며 가장 중요하게 느낀 점은, 정확한 어노테이션 이름을 외울 필요가 없다는 것입니다.

핵심만 기억하기

  • 3가지 방법이 있다: 테이블 하나, 분리된 테이블, 완전 독립
  • 각각 장단점이 있다: 성능 vs 정규화 vs 단순성
  • 실무에서는 신중하게: 컴포지션 패턴을 먼저 고려

1주차 철학 적용

"외우지 말고 이해하자"의 연장선입니다:

  • 언제 써야 하는지 판단력 기르기
  • 세부 문법은 필요시 검색
  • 단순한 해결책부터 시도하기

실무에서는 "할 수 있다"와 "해야 한다"를 구분하는 것이 중요합니다. JPA가 상속관계 매핑을 지원한다고 해서 반드시 써야 하는 건 아닙니다. 대부분의 경우 더 단순한 방법으로도 충분히 해결할 수 있습니다.


다음 편 예고

다음 편에서는 지연로딩과 Proxy에 대해 다뤄보겠습니다. Proxy가 지연로딩을 가능하게 하는 원리부터, 실무에서 자주 마주치는 LazyInitializationException 해결 방법까지 알아보겠습니다.

시리즈 목차

  1. 연관관계 매핑 vs Repository 패턴
  2. 상속관계 매핑 3가지 전략 ← 현재 글
  3. 지연로딩과 Proxy - LazyInitializationException 해결하기
  4. Cascade와 트랜잭션 전파 - 편의성 vs 안전성

"개발자도 다 찾아가면서 한다"는 말을 다시 한번 실감합니다. 모든 기능을 외우려 하기보다는, 상황에 맞는 적절한 선택을 할 수 있는 지혜를 기르는 것이 더 중요합니다.