본문 바로가기

Spring/이론

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

"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 안전성

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