TL;DR

  • JPA 핵심 어노테이션 완벽 가이드의 핵심 개념과 용어를 한눈에 정리한다.
  • JPA 핵심 어노테이션 완벽 가이드이(가) 등장한 배경과 필요성을 요약한다.
  • JPA 핵심 어노테이션 완벽 가이드의 특징과 적용 포인트를 빠르게 확인한다.

1. 개념

JPA 핵심 어노테이션 완벽 가이드은(는) 핵심 용어와 정의를 정리한 주제로, 개발/운영 맥락에서 무엇을 의미하는지 설명한다.

2. 배경

기존 방식의 한계나 현업의 요구사항을 해결하기 위해 이 개념이 등장했다는 흐름을 이해하는 데 목적이 있다.

3. 이유

도입 이유는 보통 유지보수성, 성능, 안정성, 보안, 협업 효율 같은 실무 문제를 해결하기 위함이다.

4. 특징

  • 핵심 정의와 범위를 명확히 한다.
  • 실무 적용 시 선택 기준과 비교 포인트를 제공한다.
  • 예시 중심으로 빠른 이해를 돕는다.

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) 적용

참고 자료