"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 코드:
@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단계: 가장 단순한 형태
@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 해결 방법까지 알아보겠습니다.
시리즈 목차
- 연관관계 매핑 vs Repository 패턴
- 상속관계 매핑 3가지 전략 ← 현재 글
- 지연로딩과 Proxy - LazyInitializationException 해결하기
- Cascade와 트랜잭션 전파 - 편의성 vs 안전성
"개발자도 다 찾아가면서 한다"는 말을 다시 한번 실감합니다. 모든 기능을 외우려 하기보다는, 상황에 맞는 적절한 선택을 할 수 있는 지혜를 기르는 것이 더 중요합니다.
'Spring > 이론' 카테고리의 다른 글
[트러블슈팅] JPA Cascade와 트랜잭션 전파 - 편의성 vs 안전성 (0) | 2025.06.05 |
---|---|
[트러블슈팅] JPA 지연로딩과 Proxy - LazyInitializationException 해결하기 (0) | 2025.06.05 |
[트러블슈팅] JPA 연관관계 매핑 vs Repository 패턴 - 어떤 게 더 안전할까? (0) | 2025.06.04 |
Spring 데이터 변환 컴포넌트들 (0) | 2025.06.04 |
Spring Layered Architecture (0) | 2025.05.09 |