자바에서는 클래스와 필드로, 데이터베이스에서는 테이블과 컬럼으로 데이터를 표현합니다. 이 둘의 구조가 다른데, JPA는 어떻게 자바 객체를 관계형 테이블에 대응시킬까요?

개념 정의

엔티티 매핑 은 자바 클래스와 데이터베이스 테이블 사이의 대응 관계를 어노테이션으로 정의하는 것입니다. JPA는 이 매핑 정보를 기반으로 SQL을 생성하고, 조회 결과를 객체로 변환합니다.

왜 필요한가

객체와 관계형 DB는 구조가 다릅니다.

  • 객체는 참조로 연결되지만, 테이블은 외래 키로 연결됩니다
  • 객체는 상속이 있지만, 테이블에는 상속이 없습니다
  • 객체는 값 타입을 포함할 수 있지만, 테이블은 모든 것이 컬럼입니다

이 차이를 객체-관계 불일치(Object-Relational Impedance Mismatch) 라고 합니다. 객체와 테이블의 구조가 다르기 때문에 둘 사이를 수동으로 변환하면 반복적인 코드가 대량으로 발생하고, 그래서 이 불일치를 어노테이션으로 선언적으로 해결하는 것이 엔티티 매핑의 역할입니다.

내부 동작

기본 매핑

JAVA
@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 허용 여부, 유니크 제약조건 등을 설정할 수 있습니다. 생략하면 필드명이 그대로 컬럼명이 됩니다.

JAVA
    @Enumerated(EnumType.STRING)  // Enum을 문자열로 저장
    private UserStatus status;

    @Lob                          // 대용량 데이터
    private byte[] profileImage;

    @Transient                    // DB에 매핑하지 않음
    private int sessionCount;

    // 기본 생성자 필수 (JPA 요구사항)
    protected User() {}
}

@Column 속성

JAVA
@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 생성 전략

JAVA
// 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로 값 타입 클래스를 정의합니다.

JAVA
@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로 컬럼명 충돌을 방지합니다.

JAVA
@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 — 공통 필드 상속

먼저 공통 필드를 가진 부모 클래스를 정의합니다.

JAVA
@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;
}

자식 엔티티는 이 클래스를 상속하면 자동으로 공통 컬럼이 포함됩니다.

JAVA
@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 (기본값, 권장)

JAVA
@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;
}

생성되는 테이블:

SQL
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

JAVA
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Payment {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private int amount;
}

생성되는 테이블:

SQL
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

JAVA
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Payment {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private int amount;
}

각 구체 클래스마다 완전한 테이블이 생성됩니다. UNION ALL 쿼리가 필요해서 다형성 조회 성능이 나쁩니다. 실무에서는 잘 사용하지 않습니다.

상속 전략 선택 가이드

PLAINTEXT
하위 타입이 적고 필드가 비슷한가?
├── 예 → SINGLE_TABLE (기본, 가장 빠름)
└── 아니오
    ├── 데이터 무결성이 중요한가?
    │   ├── 예 → JOINED (정규화)
    │   └── 아니오 → SINGLE_TABLE
    └── 다형성 조회가 거의 없는가?
        └── TABLE_PER_CLASS (비추천)

Enum 매핑 주의사항

JAVA
// 문자열로 저장 (권장)
@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
댓글 로딩 중...