엔티티 매핑 — 자바 객체를 테이블에 어떻게 대응시킬까
자바에서는 클래스와 필드로, 데이터베이스에서는 테이블과 컬럼으로 데이터를 표현합니다. 이 둘의 구조가 다른데, JPA는 어떻게 자바 객체를 관계형 테이블에 대응시킬까요?
개념 정의
엔티티 매핑 은 자바 클래스와 데이터베이스 테이블 사이의 대응 관계를 어노테이션으로 정의하는 것입니다. JPA는 이 매핑 정보를 기반으로 SQL을 생성하고, 조회 결과를 객체로 변환합니다.
왜 필요한가
객체와 관계형 DB는 구조가 다릅니다.
- 객체는 참조로 연결되지만, 테이블은 외래 키로 연결됩니다
- 객체는 상속이 있지만, 테이블에는 상속이 없습니다
- 객체는 값 타입을 포함할 수 있지만, 테이블은 모든 것이 컬럼입니다
이 차이를 객체-관계 불일치(Object-Relational Impedance Mismatch) 라고 합니다. 객체와 테이블의 구조가 다르기 때문에 둘 사이를 수동으로 변환하면 반복적인 코드가 대량으로 발생하고, 그래서 이 불일치를 어노테이션으로 선언적으로 해결하는 것이 엔티티 매핑의 역할입니다.
내부 동작
기본 매핑
@Entity // JPA 엔티티 선언
@Table(name = "users") // 테이블명 지정 (생략 시 클래스명)
public class User {
@Id // 기본 키
@GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT
private Long id;
@Column(name = "user_name", nullable = false, length = 50)
private String name;
@Column(unique = true, nullable = false)
private String email;
@Column으로 컬럼명, null 허용 여부, 유니크 제약조건 등을 설정할 수 있습니다. 생략하면 필드명이 그대로 컬럼명이 됩니다.
@Enumerated(EnumType.STRING) // Enum을 문자열로 저장
private UserStatus status;
@Lob // 대용량 데이터
private byte[] profileImage;
@Transient // DB에 매핑하지 않음
private int sessionCount;
// 기본 생성자 필수 (JPA 요구사항)
protected User() {}
}
@Column 속성
@Column(
name = "user_name", // 컬럼명 (생략 시 필드명)
nullable = false, // NOT NULL
unique = true, // UNIQUE 제약조건
length = 100, // VARCHAR 길이 (기본 255)
columnDefinition = "TEXT",// DDL 직접 지정
insertable = true, // INSERT에 포함 (기본 true)
updatable = false // UPDATE에 포함하지 않음
)
private String name;
ID 생성 전략
// AUTO_INCREMENT (MySQL)
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 시퀀스 (PostgreSQL, Oracle)
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "user_sequence", allocationSize = 50)
private Long id;
// 테이블 전략 (모든 DB)
@Id @GeneratedValue(strategy = GenerationType.TABLE, generator = "user_gen")
@TableGenerator(name = "user_gen", table = "id_generator", allocationSize = 50)
private Long id;
// UUID
@Id @GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
IDENTITY 전략은 INSERT 실행 후 DB에서 ID를 받아오므로, 배치 INSERT가 불가능 합니다. 배치 처리가 중요하면 SEQUENCE 전략을 고려합니다.
코드 예제
@Embedded / @Embeddable — 값 타입
먼저 @Embeddable로 값 타입 클래스를 정의합니다.
@Embeddable
public class Address {
private String city;
private String street;
private String zipCode;
protected Address() {} // 기본 생성자 필수
public Address(String city, String street, String zipCode) {
this.city = city;
this.street = street;
this.zipCode = zipCode;
}
}
엔티티에서 @Embedded로 값 타입을 포함합니다. 같은 타입을 두 번 사용하면 @AttributeOverrides로 컬럼명 충돌을 방지합니다.
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Embedded
private Address homeAddress; // city, street, zipCode 컬럼 생성
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "work_city")),
@AttributeOverride(name = "street", column = @Column(name = "work_street")),
@AttributeOverride(name = "zipCode", column = @Column(name = "work_zip_code"))
})
private Address workAddress; // 컬럼명 충돌 방지
}
값 타입을 사용하면 주소 같은 관련 필드를 하나의 클래스로 묶으면서도 별도 테이블 없이 같은 테이블에 저장됩니다.
@MappedSuperclass — 공통 필드 상속
먼저 공통 필드를 가진 부모 클래스를 정의합니다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@CreatedBy
@Column(updatable = false)
private String createdBy;
}
자식 엔티티는 이 클래스를 상속하면 자동으로 공통 컬럼이 포함됩니다.
@Entity
public class User extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// createdAt, updatedAt, createdBy가 users 테이블에 포함됨
}
@MappedSuperclass는 엔티티가 아니므로 **별도 테이블이 생성되지 않고 **, 자식 엔티티의 테이블에 컬럼이 포함됩니다.
상속 전략
1. SINGLE_TABLE (기본값, 권장)
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
public abstract class Payment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int amount;
}
@Entity
@DiscriminatorValue("CARD")
public class CardPayment extends Payment {
private String cardNumber;
}
@Entity
@DiscriminatorValue("BANK")
public class BankTransfer extends Payment {
private String bankName;
private String accountNumber;
}
생성되는 테이블:
CREATE TABLE payment (
id BIGINT AUTO_INCREMENT,
dtype VARCHAR(31), -- 타입 구분 컬럼
amount INT,
card_number VARCHAR(255), -- CardPayment 전용 (null 허용)
bank_name VARCHAR(255), -- BankTransfer 전용 (null 허용)
account_number VARCHAR(255)
);
장점: 조인 없이 빠른 조회. 단점: null이 많아질 수 있음.
2. JOINED
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Payment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int amount;
}
생성되는 테이블:
CREATE TABLE payment (id BIGINT, amount INT);
CREATE TABLE card_payment (id BIGINT, card_number VARCHAR(255), FOREIGN KEY (id) REFERENCES payment);
CREATE TABLE bank_transfer (id BIGINT, bank_name VARCHAR(255), account_number VARCHAR(255), FOREIGN KEY (id) REFERENCES payment);
장점: 정규화된 구조. 단점: 조인이 필요해서 조회 성능이 떨어질 수 있음.
3. TABLE_PER_CLASS
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Payment {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private int amount;
}
각 구체 클래스마다 완전한 테이블이 생성됩니다. UNION ALL 쿼리가 필요해서 다형성 조회 성능이 나쁩니다. 실무에서는 잘 사용하지 않습니다.
상속 전략 선택 가이드
하위 타입이 적고 필드가 비슷한가?
├── 예 → SINGLE_TABLE (기본, 가장 빠름)
└── 아니오
├── 데이터 무결성이 중요한가?
│ ├── 예 → JOINED (정규화)
│ └── 아니오 → SINGLE_TABLE
└── 다형성 조회가 거의 없는가?
└── TABLE_PER_CLASS (비추천)
Enum 매핑 주의사항
// 문자열로 저장 (권장)
@Enumerated(EnumType.STRING)
private UserStatus status; // "ACTIVE", "INACTIVE" 저장
// 순서(숫자)로 저장 (비추천)
@Enumerated(EnumType.ORDINAL)
private UserStatus status; // 0, 1, 2 저장
// Enum에 값이 추가되면 기존 데이터의 의미가 바뀜!
EnumType.ORDINAL은 Enum 순서가 바뀌면 데이터 의미가 꼬이므로, 반드시 EnumType.STRING을 사용합니다.
주의할 점
EnumType.ORDINAL은 시한폭탄이다
@Enumerated(EnumType.ORDINAL)은 Enum의 순서(0, 1, 2)를 저장합니다. 나중에 Enum 값 사이에 새로운 값을 추가하면, ** 기존 데이터의 의미가 완전히 바뀝니다 **. 반드시 EnumType.STRING을 사용해야 합니다.
기본 생성자가 없으면 JPA가 엔티티를 생성할 수 없다
JPA는 리플렉션으로 엔티티 인스턴스를 생성하기 때문에 ** 기본 생성자(파라미터 없는 생성자)**가 필수입니다. protected로 선언하면 외부에서의 무분별한 생성을 막으면서 JPA 요구사항을 충족할 수 있습니다.
IDENTITY 전략은 배치 INSERT가 불가능하다
GenerationType.IDENTITY는 DB의 AUTO_INCREMENT에 의존하기 때문에, INSERT를 실행해야 ID를 알 수 있습니다. 이 때문에 JPA의 쓰기 지연(write-behind)이 동작하지 않아 ** 배치 INSERT가 불가능 **합니다. 대량 삽입이 중요한 엔티티에는 SEQUENCE 전략을 고려해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 기본 매핑 | @Entity, @Id, @Column으로 정의 |
| 값 타입 | @Embedded/@Embeddable로 테이블 분리 없이 사용 |
| 공통 필드 상속 | @MappedSuperclass는 테이블을 만들지 않음 |
| 상속 전략 | 대부분 SINGLE_TABLE 이 실무에 적합 |
| Enum 매핑 | 반드시 EnumType.STRING 사용 |
| ID 전략 | IDENTITY는 배치 INSERT 불가, 배치가 중요하면 SEQUENCE |