JPA 핵심 어노테이션 완벽 가이드
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 = ??? // 현재 로그인한 사용자를 어떻게 알지?
}
현재 사용자 정보는 엔티티가 직접 알 수 없습니다. 이를 위해 AuditorAware와 EntityListeners 조합이 필요합니다.
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구현 (사용자 정보 필요 시)BaseEntitywith@MappedSuperclass정의- 모든
@ManyToOne에FetchType.LAZY적용 @Enumerated(EnumType.STRING)사용 (ORDINAL 금지)- Soft Delete 시
@SQLDelete,@SQLRestriction적용 - 동시성 필요 엔티티에
@Version추가 - PostgreSQL JSON 컬럼에
@JdbcTypeCode(SqlTypes.JSON)적용