TL;DR

  • JPA 핵심 어노테이션 완벽 가이드의 핵심 개념과 사용 범위를 한눈에 정리
  • 등장 배경과 필요한 이유를 짚고 실무 적용 포인트를 연결
  • 주요 특징과 체크리스트를 빠르게 확인

1. 개념

JPA는 Java 객체를 관계형 데이터베이스에 매핑하기 위한 표준 명세(Specification)입니다.

2. 배경

JPA 핵심 어노테이션 완벽 가이드이(가) 등장한 배경과 기존 한계를 정리한다.

3. 이유

대부분의 엔티티는 공통 필드를 갖습니다:

4. 특징

  • 배경: JPA와 어노테이션 기반 설정
  • @MappedSuperclass - 공통 필드 상속
  • @EntityListeners와 Auditing
  • 엔티티 라이프사이클 콜백
  • @Embeddable과 @Embedded

5. 상세 내용

작성일: 2026-02-02 키워드: JPA, @MappedSuperclass, @EntityListeners, Auditing, @CreatedDate, @LastModifiedDate, @Embeddable, @Inheritance


1. 배경: JPA와 어노테이션 기반 설정

1.1 JPA(Java Persistence API)란?

JPA는 Java 객체를 관계형 데이터베이스에 매핑하기 위한 표준 명세(Specification)입니다.

┌─────────────────────────────────────────────────────────────────────┐
│                        JPA 역사                                      │
├─────────────────────────────────────────────────────────────────────┤
│  2001년  │  Hibernate 등장 (Gavin King)                              │
│  2006년  │  JPA 1.0 (Java EE 5) - Hibernate를 표준화                 │
│  2009년  │  JPA 2.0 - Criteria API, JPQL 확장                        │
│  2013년  │  JPA 2.1 - Stored Procedure, Entity Graph                 │
│  2017년  │  JPA 2.2 - Java 8 Date/Time, Stream                       │
│  2020년  │  Jakarta Persistence 3.0 (javax → jakarta)                │
│  2022년  │  Jakarta Persistence 3.1 - UUID, 개선된 기능               │
└─────────────────────────────────────────────────────────────────────┘

1.2 어노테이션 기반 설정의 등장 배경

과거 (XML 설정):

<!-- hibernate.cfg.xml -->
<hibernate-mapping>
    <class name="com.example.User" table="users">
        <id name="id" column="id">
            <generator class="identity"/>
        </id>
        <property name="name" column="name"/>
        <property name="email" column="email"/>
    </class>
</hibernate-mapping>

문제점:

  • 엔티티와 설정 파일이 분리되어 유지보수 어려움
  • XML 파일이 비대해짐
  • 타입 안전성 없음 (문자열 기반)
  • IDE 지원 제한적

현재 (어노테이션 기반):

@Entity
@Table(name = "users")
class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Column(name = "name")
    var name: String,

    @Column(name = "email")
    var email: String
)

장점:

  • 엔티티와 메타데이터가 한 곳에
  • 컴파일 타임 타입 체크
  • IDE 자동 완성 및 리팩토링 지원
  • 코드 가독성 향상

2. @MappedSuperclass - 공통 필드 상속

2.1 왜 필요한가?

대부분의 엔티티는 공통 필드를 갖습니다:

┌──────────────────────────────────────────────────────────────┐
│                     공통 필드 패턴                            │
├──────────────────────────────────────────────────────────────┤
│  • id             - 기본 키                                   │
│  • createdAt      - 생성 시간                                 │
│  • updatedAt      - 수정 시간                                 │
│  • createdBy      - 생성자                                    │
│  • updatedBy      - 수정자                                    │
│  • deletedAt      - 삭제 시간 (Soft Delete)                   │
│  • version        - 낙관적 락킹 버전                          │
└──────────────────────────────────────────────────────────────┘

안티패턴 - 중복 코드:

// 이렇게 하면 모든 엔티티에 동일 코드가 반복됨
@Entity
class User(
    @Id @GeneratedValue val id: Long? = null,
    @Column val createdAt: LocalDateTime? = null,
    @Column val updatedAt: LocalDateTime? = null,
    // ... 비즈니스 필드
)

@Entity
class Order(
    @Id @GeneratedValue val id: Long? = null,
    @Column val createdAt: LocalDateTime? = null,  // 중복!
    @Column val updatedAt: LocalDateTime? = null,  // 중복!
    // ... 비즈니스 필드
)

2.2 @MappedSuperclass의 동작 원리

┌─────────────────────────────────────────────────────────────────────┐
│                    @MappedSuperclass vs @Entity                      │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  @MappedSuperclass                     @Entity                       │
│  ┌─────────────────┐                   ┌─────────────────┐          │
│  │  BaseEntity     │                   │  ParentEntity   │          │
│  │  ─────────────  │                   │  ─────────────  │          │
│  │  id             │                   │  id             │          │
│  │  createdAt      │                   │  createdAt      │          │
│  └────────┬────────┘                   └────────┬────────┘          │
│           │ extends                             │ extends            │
│  ┌────────▼────────┐                   ┌────────▼────────┐          │
│  │  User           │                   │  User           │          │
│  │  ─────────────  │                   │  ─────────────  │          │
│  │  name           │                   │  name           │          │
│  │  email          │                   │  email          │          │
│  └─────────────────┘                   └─────────────────┘          │
│                                                                      │
│  DB 테이블:                            DB 테이블:                    │
│  ┌─────────────────┐                   ┌─────────────────┐          │
│  │  users          │                   │  parent_entity  │          │
│  │  ─────────────  │                   │  ─────────────  │          │
│  │  id             │                   │  id             │          │
│  │  created_at     │                   │  created_at     │          │
│  │  name           │                   │  type           │          │
│  │  email          │                   └─────────────────┘          │
│  └─────────────────┘                   ┌─────────────────┐          │
│                                        │  users          │          │
│  ✅ 부모 테이블 없음                   │  ─────────────  │          │
│  ✅ 필드만 상속                        │  parent_id (FK) │          │
│                                        │  name           │          │
│                                        └─────────────────┘          │
│                                        ❌ 부모 테이블 생성           │
└─────────────────────────────────────────────────────────────────────┘

핵심 차이점:

특징 @MappedSuperclass @Entity 상속
테이블 생성 부모 테이블 없음 부모 테이블 생성
조회 가능 부모로 직접 조회 불가 부모로 조회 가능
용도 공통 필드/메서드 재사용 다형성, IS-A 관계
성능 조인 없음 상속 전략에 따라 조인 발생

2.3 실제 구현 예시

// 기본 추상 엔티티
@MappedSuperclass
abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    open var id: Long? = null

    @Column(name = "created_at", updatable = false)
    open var createdAt: LocalDateTime? = null

    @Column(name = "updated_at")
    open var updatedAt: LocalDateTime? = null

    @PrePersist
    fun prePersist() {
        createdAt = LocalDateTime.now()
        updatedAt = LocalDateTime.now()
    }

    @PreUpdate
    fun preUpdate() {
        updatedAt = LocalDateTime.now()
    }
}

// 실제 엔티티
@Entity
@Table(name = "users")
class User(
    @Column(nullable = false)
    var name: String,

    @Column(nullable = false, unique = true)
    var email: String
) : BaseEntity()

2.4 CCK 프로젝트의 MothershipBaseEntity 패턴

/**
 * CCK Mothership 프로젝트의 표준 Base Entity
 */
@MappedSuperclass
abstract class MothershipBaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @Column(name = "created_at", nullable = false, updatable = false)
    var createdAt: LocalDateTime? = null

    @Column(name = "updated_at", nullable = false)
    var updatedAt: LocalDateTime? = null

    @Column(name = "deleted_at")
    var deletedAt: LocalDateTime? = null  // Soft Delete 지원

    // 삭제 여부 확인
    fun isDeleted(): Boolean = deletedAt != null

    // Soft Delete 수행
    fun softDelete() {
        deletedAt = LocalDateTime.now()
    }
}

3. @EntityListeners와 Auditing

3.1 왜 AuditingEntityListener가 필요한가?

문제: @PrePersist, @PreUpdate로 생성/수정 시간은 설정 가능하지만…

// 이 방식의 한계
@PrePersist
fun prePersist() {
    createdAt = LocalDateTime.now()
    createdBy = ???  // 현재 로그인한 사용자를 어떻게 알지?
}

현재 사용자 정보는 엔티티가 직접 알 수 없습니다. 이를 위해 AuditorAwareEntityListeners 조합이 필요합니다.

3.2 Spring Data JPA Auditing 구조

┌─────────────────────────────────────────────────────────────────────┐
│                    Auditing 아키텍처                                  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌──────────────┐      ┌────────────────────┐                       │
│  │  Security    │──────│  AuditorAware<T>   │                       │
│  │  Context     │      │  getCurrentAuditor()│                      │
│  └──────────────┘      └─────────┬──────────┘                       │
│                                  │                                   │
│                                  │ provides current user             │
│                                  ▼                                   │
│  ┌────────────────────────────────────────────────────────────┐     │
│  │              AuditingEntityListener                         │     │
│  │  ────────────────────────────────────────────────────────  │     │
│  │  @PrePersist touchForCreate(Object target)                 │     │
│  │  @PreUpdate  touchForUpdate(Object target)                 │     │
│  └────────────────────────────────────────────────────────────┘     │
│                                  │                                   │
│                                  ▼                                   │
│  ┌────────────────────────────────────────────────────────────┐     │
│  │                   Auditable Entity                          │     │
│  │  ────────────────────────────────────────────────────────  │     │
│  │  @CreatedDate      createdAt                                │     │
│  │  @LastModifiedDate updatedAt                                │     │
│  │  @CreatedBy        createdBy                                │     │
│  │  @LastModifiedBy   updatedBy                                │     │
│  └────────────────────────────────────────────────────────────┘     │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

3.3 단계별 구현

Step 1: Auditing 활성화

@Configuration
@EnableJpaAuditing  // 이것이 핵심!
class JpaConfig

Step 2: AuditorAware 구현

@Component
class SecurityAuditorAware : AuditorAware<String> {
    override fun getCurrentAuditor(): Optional<String> {
        return Optional.ofNullable(SecurityContextHolder.getContext())
            .map { it.authentication }
            .filter { it.isAuthenticated }
            .map { it.name }
    }
}

Step 3: Auditable BaseEntity 정의

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class AuditableBaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    var createdAt: LocalDateTime? = null

    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    var updatedAt: LocalDateTime? = null

    @CreatedBy
    @Column(name = "created_by", updatable = false)
    var createdBy: String? = null

    @LastModifiedBy
    @Column(name = "updated_by")
    var updatedBy: String? = null
}

3.4 Auditing 어노테이션 정리

어노테이션 용도 설정 시점
@CreatedDate 생성 시간 INSERT 시점
@LastModifiedDate 수정 시간 INSERT/UPDATE 시점
@CreatedBy 생성자 INSERT 시점
@LastModifiedBy 수정자 INSERT/UPDATE 시점

3.5 커스텀 EntityListener

Spring Data JPA의 AuditingEntityListener 외에도 커스텀 리스너를 만들 수 있습니다:

// 커스텀 리스너 정의
class EncryptionListener {
    @PrePersist
    @PreUpdate
    fun encrypt(entity: Any) {
        if (entity is Encryptable) {
            entity.sensitiveFields.forEach { field ->
                // 민감 데이터 암호화
                encryptField(entity, field)
            }
        }
    }

    @PostLoad
    fun decrypt(entity: Any) {
        if (entity is Encryptable) {
            entity.sensitiveFields.forEach { field ->
                // 민감 데이터 복호화
                decryptField(entity, field)
            }
        }
    }
}

// 여러 리스너 적용
@Entity
@EntityListeners(
    AuditingEntityListener::class,
    EncryptionListener::class
)
class SensitiveUser(
    @Column
    var encryptedSsn: String  // 주민번호 암호화
) : AuditableBaseEntity()

4. 엔티티 라이프사이클 콜백

4.1 JPA 콜백 어노테이션

┌─────────────────────────────────────────────────────────────────────┐
│                   Entity Lifecycle Callbacks                         │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  영속성 컨텍스트 작업:                                                │
│                                                                      │
│  ┌─────────┐                                                        │
│  │  NEW    │                                                        │
│  └────┬────┘                                                        │
│       │ persist()                                                    │
│       ▼                                                              │
│  ┌─────────────────────────────┐                                    │
│  │      @PrePersist            │  ← INSERT 전                       │
│  └─────────────────────────────┘                                    │
│       │                                                              │
│       ▼  flush/commit                                               │
│  ┌─────────────────────────────┐                                    │
│  │      @PostPersist           │  ← INSERT 후                       │
│  └─────────────────────────────┘                                    │
│       │                                                              │
│  ┌────▼────┐                                                        │
│  │ MANAGED │                                                        │
│  └────┬────┘                                                        │
│       │ update                                                       │
│       ▼                                                              │
│  ┌─────────────────────────────┐                                    │
│  │      @PreUpdate             │  ← UPDATE 전                       │
│  └─────────────────────────────┘                                    │
│       │                                                              │
│       ▼  flush/commit                                               │
│  ┌─────────────────────────────┐                                    │
│  │      @PostUpdate            │  ← UPDATE 후                       │
│  └─────────────────────────────┘                                    │
│       │                                                              │
│       │ remove()                                                     │
│       ▼                                                              │
│  ┌─────────────────────────────┐                                    │
│  │      @PreRemove             │  ← DELETE 전                       │
│  └─────────────────────────────┘                                    │
│       │                                                              │
│       ▼  flush/commit                                               │
│  ┌─────────────────────────────┐                                    │
│  │      @PostRemove            │  ← DELETE 후                       │
│  └─────────────────────────────┘                                    │
│       │                                                              │
│  ┌────▼────┐                                                        │
│  │ REMOVED │                                                        │
│  └─────────┘                                                        │
│                                                                      │
│  조회 시:                                                            │
│                                                                      │
│  ┌─────────────────────────────┐                                    │
│  │      @PostLoad              │  ← SELECT 후 (엔티티 로드 시)       │
│  └─────────────────────────────┘                                    │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

4.2 콜백 사용 예시

@Entity
class Order(
    @Column
    var status: OrderStatus = OrderStatus.PENDING,

    @Column
    var totalAmount: BigDecimal = BigDecimal.ZERO
) : BaseEntity() {

    @Transient  // DB에 저장하지 않는 임시 필드
    var isNewOrder: Boolean = false

    @PrePersist
    fun onPrePersist() {
        // 주문 생성 전 초기화
        if (status == OrderStatus.PENDING) {
            isNewOrder = true
        }
        // 주문 번호 생성
        orderNumber = generateOrderNumber()
    }

    @PostPersist
    fun onPostPersist() {
        // 주문 생성 후 이벤트 발행
        if (isNewOrder) {
            EventPublisher.publish(OrderCreatedEvent(this))
        }
    }

    @PreUpdate
    fun onPreUpdate() {
        // 상태 변경 감지
        if (status == OrderStatus.SHIPPED) {
            shippedAt = LocalDateTime.now()
        }
    }

    @PostLoad
    fun onPostLoad() {
        // 조회 후 계산된 필드 초기화
        computedDiscount = calculateDiscount()
    }

    @PreRemove
    fun onPreRemove() {
        // 삭제 전 검증
        require(status == OrderStatus.CANCELLED) {
            "취소된 주문만 삭제할 수 있습니다"
        }
    }
}

4.3 콜백 vs EntityListener 비교

특성 엔티티 내 콜백 EntityListener
위치 엔티티 클래스 내부 별도 클래스
재사용성 해당 엔티티만 여러 엔티티에 재사용
의존성 this 직접 접근 파라미터로 전달
용도 단순 로직 복잡하거나 공통 로직

5. @Embeddable과 @Embedded

5.1 Value Object 패턴

┌─────────────────────────────────────────────────────────────────────┐
│                    @Embeddable 구조                                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  도메인 모델:                                                        │
│  ┌─────────────────────────┐                                        │
│  │         User            │                                        │
│  │  ──────────────────     │                                        │
│  │  id: Long               │                                        │
│  │  name: String           │                                        │
│  │  ┌─────────────────┐   │                                        │
│  │  │ address: Address │   │  ← Value Object                       │
│  │  │  ─────────────── │   │                                        │
│  │  │  street          │   │                                        │
│  │  │  city            │   │                                        │
│  │  │  zipCode         │   │                                        │
│  │  └─────────────────┘   │                                        │
│  └─────────────────────────┘                                        │
│                                                                      │
│  DB 테이블:                                                          │
│  ┌─────────────────────────┐                                        │
│  │        users            │                                        │
│  │  ──────────────────     │                                        │
│  │  id          BIGINT     │                                        │
│  │  name        VARCHAR    │                                        │
│  │  street      VARCHAR    │  ← Address 필드들이                    │
│  │  city        VARCHAR    │    같은 테이블에 포함                   │
│  │  zip_code    VARCHAR    │                                        │
│  └─────────────────────────┘                                        │
│                                                                      │
│  ✅ 별도 테이블 없음 (조인 불필요)                                    │
│  ✅ 논리적으로 그룹화된 필드                                          │
│  ✅ 재사용 가능한 Value Object                                        │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

5.2 구현 예시

// Value Object 정의
@Embeddable
class Address(
    @Column(name = "street")
    val street: String,

    @Column(name = "city")
    val city: String,

    @Column(name = "zip_code")
    val zipCode: String
) {
    // equals와 hashCode 구현 (Value Object 필수)
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Address) return false
        return street == other.street &&
               city == other.city &&
               zipCode == other.zipCode
    }

    override fun hashCode(): Int =
        Objects.hash(street, city, zipCode)
}

// Money Value Object
@Embeddable
class Money(
    @Column(name = "amount", precision = 19, scale = 4)
    val amount: BigDecimal,

    @Column(name = "currency", length = 3)
    @Enumerated(EnumType.STRING)
    val currency: Currency
) {
    operator fun plus(other: Money): Money {
        require(currency == other.currency) {
            "통화가 일치해야 합니다"
        }
        return Money(amount + other.amount, currency)
    }

    operator fun times(multiplier: Int): Money =
        Money(amount * multiplier.toBigDecimal(), currency)
}

// 엔티티에서 사용
@Entity
class Order(
    @Embedded
    @AttributeOverrides(
        AttributeOverride(name = "amount", column = Column(name = "total_amount")),
        AttributeOverride(name = "currency", column = Column(name = "total_currency"))
    )
    val totalPrice: Money,

    @Embedded
    val shippingAddress: Address
) : BaseEntity()

5.3 @Embeddable vs @Entity 비교

특성 @Embeddable @Entity
테이블 소유 엔티티에 포함 별도 테이블
ID 없음 필수
라이프사이클 소유 엔티티에 종속 독립적
관계 포함(Composition) 연관(Association)
공유 불가능 가능
조회 항상 함께 로드 Lazy 로딩 가능

6. @Inheritance - 상속 매핑 전략

6.1 세 가지 상속 전략

┌─────────────────────────────────────────────────────────────────────┐
│                    JPA 상속 전략                                      │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  도메인 모델:                                                        │
│        ┌──────────────┐                                             │
│        │   Payment    │  (abstract)                                  │
│        │  ──────────  │                                             │
│        │  id          │                                             │
│        │  amount      │                                             │
│        └──────┬───────┘                                             │
│               │                                                      │
│       ┌───────┼───────┐                                             │
│       │       │       │                                             │
│  ┌────▼───┐ ┌─▼────┐ ┌▼───────┐                                    │
│  │ Card   │ │Bank  │ │Virtual │                                     │
│  │Payment │ │Trans │ │Account │                                     │
│  └────────┘ └──────┘ └────────┘                                     │
│                                                                      │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1️⃣ SINGLE_TABLE (기본값) - 단일 테이블 전략                         │
│                                                                      │
│  ┌────────────────────────────────────────────────┐                 │
│  │               payments                          │                 │
│  │  ───────────────────────────────────────────   │                 │
│  │  id | dtype  | amount | card_num | bank_code   │                 │
│  │  1  | CARD   | 10000  | 1234...  | NULL        │                 │
│  │  2  | BANK   | 20000  | NULL     | KB          │                 │
│  │  3  | VIRT   | 30000  | NULL     | NULL        │                 │
│  └────────────────────────────────────────────────┘                 │
│                                                                      │
│  ✅ 조인 없음 (성능 최고)                                             │
│  ✅ 단순한 쿼리                                                       │
│  ❌ 많은 NULL 컬럼                                                    │
│  ❌ 자식 필드에 NOT NULL 제약 불가                                    │
│                                                                      │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  2️⃣ JOINED - 조인 전략                                               │
│                                                                      │
│  ┌──────────────────┐                                               │
│  │    payments      │                                               │
│  │  ──────────────  │                                               │
│  │  id  | amount    │                                               │
│  │  1   | 10000     │                                               │
│  │  2   | 20000     │                                               │
│  └────────┬─────────┘                                               │
│           │                                                          │
│   ┌───────┼───────┐                                                  │
│   │       │       │                                                  │
│  ┌▼───────┐ ┌────▼────┐ ┌─────▼─────┐                              │
│  │ card_  │ │ bank_   │ │ virtual_  │                              │
│  │payments│ │ trans   │ │ accounts  │                              │
│  │────────│ │─────────│ │───────────│                              │
│  │id      │ │id       │ │id         │                              │
│  │card_num│ │bank_code│ │account_id │                              │
│  └────────┘ └─────────┘ └───────────┘                              │
│                                                                      │
│  ✅ 정규화됨 (NULL 없음)                                              │
│  ✅ 저장 공간 효율적                                                  │
│  ✅ NOT NULL 제약 가능                                                │
│  ❌ 조인 필요 (성능 저하)                                             │
│  ❌ 복잡한 쿼리                                                       │
│                                                                      │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  3️⃣ TABLE_PER_CLASS - 구현 클래스별 테이블 전략                       │
│                                                                      │
│  ┌──────────────┐ ┌──────────────┐ ┌──────────────┐                │
│  │ card_payments│ │ bank_trans   │ │virtual_accts │                │
│  │ ──────────── │ │ ──────────── │ │ ──────────── │                │
│  │ id           │ │ id           │ │ id           │                │
│  │ amount       │ │ amount       │ │ amount       │                │
│  │ card_num     │ │ bank_code    │ │ account_id   │                │
│  └──────────────┘ └──────────────┘ └──────────────┘                │
│                                                                      │
│  ✅ 조인 없음                                                         │
│  ✅ 각 테이블 완전 독립                                               │
│  ❌ 부모 타입 조회 시 UNION 필요                                      │
│  ❌ 부모 테이블 없어서 다형성 쿼리 비효율                              │
│  ⚠️ 거의 사용하지 않음                                               │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

6.2 구현 예시

// 부모 엔티티
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type", discriminatorType = DiscriminatorType.STRING)
abstract class Payment(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    @Column(nullable = false)
    var amount: BigDecimal,

    @Column(nullable = false)
    var status: PaymentStatus = PaymentStatus.PENDING
) {
    abstract fun process(): PaymentResult
}

// 자식 엔티티
@Entity
@DiscriminatorValue("CARD")
class CardPayment(
    amount: BigDecimal,

    @Column(name = "card_number")
    var cardNumber: String,

    @Column(name = "expiry_date")
    var expiryDate: String
) : Payment(amount = amount) {
    override fun process(): PaymentResult {
        // 카드 결제 처리 로직
        return CardPaymentResult(...)
    }
}

@Entity
@DiscriminatorValue("BANK")
class BankTransfer(
    amount: BigDecimal,

    @Column(name = "bank_code")
    var bankCode: String,

    @Column(name = "account_number")
    var accountNumber: String
) : Payment(amount = amount) {
    override fun process(): PaymentResult {
        // 계좌 이체 처리 로직
        return BankTransferResult(...)
    }
}

6.3 상속 전략 선택 가이드

상황 권장 전략
자식 클래스가 적고 필드가 비슷함 SINGLE_TABLE
데이터 무결성이 중요함 JOINED
부모 타입으로 조회가 많음 SINGLE_TABLE
자식마다 필드가 매우 다름 JOINED
각 자식이 완전히 독립적 TABLE_PER_CLASS (비권장)

7. @Version - 낙관적 락킹

7.1 동시성 문제와 해결

┌─────────────────────────────────────────────────────────────────────┐
│                    동시성 문제 시나리오                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  시간 →                                                             │
│  ────────────────────────────────────────────────────────────       │
│                                                                      │
│  User A         │        Database        │        User B            │
│  ───────────────┼────────────────────────┼──────────────────        │
│                 │                        │                          │
│  READ           │  amount = 1000         │                          │
│  ───────────────┤◄───────────────────────┤                          │
│                 │                        │  READ                    │
│  (화면에서      │  amount = 1000         ├───────────────────►      │
│   작업 중)      │                        │  (화면에서 작업 중)       │
│                 │                        │                          │
│  UPDATE         │                        │                          │
│  amount = 900   ├───────────────────────►│                          │
│  (-100 차감)    │  amount = 900 ✓        │                          │
│                 │                        │                          │
│                 │                        │  UPDATE                  │
│                 │                        │  amount = 800            │
│                 │◄───────────────────────┤  (-200 차감)             │
│                 │  amount = 800 ✓        │                          │
│                 │                        │                          │
│  ──────────────────────────────────────────────────────────         │
│                                                                      │
│  ❌ 문제: User A의 변경(-100)이 유실됨!                               │
│     기대값: 1000 - 100 - 200 = 700                                   │
│     실제값: 800                                                      │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

7.2 @Version으로 해결

@Entity
class Account(
    @Column(nullable = false)
    var amount: BigDecimal,

    @Version  // 낙관적 락킹!
    var version: Long = 0L
) : BaseEntity()

동작 원리:

-- User A의 UPDATE
UPDATE account
SET amount = 900, version = 2
WHERE id = 1 AND version = 1;  -- version 체크!

-- User B의 UPDATE (동시에 실행)
UPDATE account
SET amount = 800, version = 2
WHERE id = 1 AND version = 1;  -- 이미 version=2로 변경됨!
-- 결과: 0 rows updated → OptimisticLockException 발생!
┌─────────────────────────────────────────────────────────────────────┐
│                    @Version 동작 흐름                                 │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  User A         │        Database        │        User B            │
│  ───────────────┼────────────────────────┼──────────────────        │
│                 │                        │                          │
│  READ           │  amount=1000, ver=1    │                          │
│  ───────────────┤◄───────────────────────┤                          │
│                 │                        │  READ                    │
│                 │  amount=1000, ver=1    ├───────────────────►      │
│                 │                        │                          │
│  UPDATE         │                        │                          │
│  ver=1 → ver=2  ├───────────────────────►│                          │
│                 │  amount=900, ver=2 ✓   │                          │
│                 │                        │                          │
│                 │                        │  UPDATE                  │
│                 │                        │  ver=1 → ver=2           │
│                 │  "WHERE ver=1" 실패!   │◄──────────────────       │
│                 │  0 rows affected       │                          │
│                 │                        │  ❌ Optimistic-          │
│                 │                        │     LockException        │
│                 │                        │                          │
│                 │                        │  RETRY: 다시 READ        │
│                 │  amount=900, ver=2     ├───────────────────►      │
│                 │                        │                          │
│                 │                        │  UPDATE                  │
│                 │  amount=700, ver=3 ✓   │◄──────────────────       │
│                 │                        │                          │
│  ──────────────────────────────────────────────────────────         │
│                                                                      │
│  ✅ 결과: 1000 - 100 - 200 = 700 (정확!)                             │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

7.3 @Version 사용 시 주의사항

@Service
class AccountService(
    private val accountRepository: AccountRepository
) {
    @Transactional
    fun withdraw(accountId: Long, amount: BigDecimal) {
        val account = accountRepository.findById(accountId)
            .orElseThrow { NotFoundException("계좌를 찾을 수 없습니다") }

        try {
            account.amount -= amount
            accountRepository.save(account)
        } catch (e: OptimisticLockException) {
            // 재시도 로직
            throw ConcurrentModificationException(
                "다른 사용자가 동시에 수정 중입니다. 다시 시도해주세요."
            )
        }
    }
}

8. 자주 사용하는 필드 어노테이션

8.1 컬럼 관련

@Entity
class Product(
    // 기본 컬럼 설정
    @Column(
        name = "product_name",    // 컬럼명
        nullable = false,         // NOT NULL
        length = 100,            // VARCHAR(100)
        unique = true            // UNIQUE 제약
    )
    var name: String,

    // 큰 텍스트
    @Lob
    @Column(columnDefinition = "TEXT")
    var description: String,

    // 정밀한 숫자
    @Column(precision = 19, scale = 4)
    var price: BigDecimal,

    // Enum 매핑
    @Enumerated(EnumType.STRING)  // ORDINAL은 위험!
    @Column(length = 20)
    var status: ProductStatus,

    // JSON 타입 (PostgreSQL)
    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "jsonb")
    var metadata: Map<String, Any>?,

    // 읽기 전용
    @Column(insertable = false, updatable = false)
    var calculatedField: String? = null
)

8.2 관계 매핑

@Entity
class Order(
    // 다대일 (Many Orders → One User)
    @ManyToOne(fetch = FetchType.LAZY)  // 항상 LAZY!
    @JoinColumn(name = "user_id", nullable = false)
    var user: User,

    // 일대다 (One Order → Many OrderItems)
    @OneToMany(
        mappedBy = "order",
        cascade = [CascadeType.ALL],
        orphanRemoval = true
    )
    var items: MutableList<OrderItem> = mutableListOf(),

    // 다대다 (Many Orders ↔ Many Products)
    @ManyToMany
    @JoinTable(
        name = "order_products",
        joinColumns = [JoinColumn(name = "order_id")],
        inverseJoinColumns = [JoinColumn(name = "product_id")]
    )
    var products: MutableSet<Product> = mutableSetOf()
)

8.3 주요 Fetch 전략

전략 @ManyToOne @OneToMany @ManyToMany
기본값 EAGER LAZY LAZY
권장 LAZY LAZY LAZY

💡 핵심 인사이트: 거의 모든 관계에 FetchType.LAZY를 사용하세요. 필요할 때만 JOIN FETCH@EntityGraph로 명시적 로딩!


9. CCK 프로젝트 적용 패턴 종합

9.1 HelloWorld BaseEntity 예시

/**
 * HelloWorld 프로젝트의 표준 Base Entity
 * 모든 Auditing과 Soft Delete를 포함
 */
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class HelloWorldBaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    var createdAt: LocalDateTime? = null

    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    var updatedAt: LocalDateTime? = null

    @CreatedBy
    @Column(name = "created_by", updatable = false, length = 50)
    var createdBy: String? = null

    @LastModifiedBy
    @Column(name = "updated_by", length = 50)
    var updatedBy: String? = null

    @Column(name = "deleted_at")
    var deletedAt: LocalDateTime? = null

    @Version
    var version: Long = 0L

    fun isDeleted(): Boolean = deletedAt != null

    fun softDelete() {
        deletedAt = LocalDateTime.now()
    }
}

// 실제 엔티티
@Entity
@Table(name = "reports")
@SQLDelete(sql = "UPDATE reports SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
class Report(
    @Column(nullable = false)
    var title: String,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    var status: ReportStatus = ReportStatus.DRAFT,

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "jsonb")
    var metadata: ReportMetadata? = null,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "project_id", nullable = false)
    var project: Project
) : HelloWorldBaseEntity()

10. 요약 및 체크리스트

10.1 어노테이션 용도 정리

어노테이션 용도 필수 여부
@MappedSuperclass 공통 필드 상속 권장
@EntityListeners 라이프사이클 리스너 Auditing 시 필수
@CreatedDate 생성 시간 자동 설정 Auditing 시
@LastModifiedDate 수정 시간 자동 설정 Auditing 시
@CreatedBy 생성자 자동 설정 선택
@LastModifiedBy 수정자 자동 설정 선택
@Embeddable Value Object 정의 DDD 시
@Inheritance 상속 전략 지정 상속 시
@Version 낙관적 락킹 동시성 필요 시

10.2 적용 체크리스트

  • @EnableJpaAuditing 설정 추가
  • AuditorAware 구현 (사용자 정보 필요 시)
  • BaseEntity with @MappedSuperclass 정의
  • 모든 @ManyToOneFetchType.LAZY 적용
  • @Enumerated(EnumType.STRING) 사용 (ORDINAL 금지)
  • Soft Delete 시 @SQLDelete, @SQLRestriction 적용
  • 동시성 필요 엔티티에 @Version 추가
  • PostgreSQL JSON 컬럼에 @JdbcTypeCode(SqlTypes.JSON) 적용

참고 자료