JPA 핵심 어노테이션 완벽 가이드
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 = ??? // 현재 로그인한 사용자를 어떻게 알지?
}
현재 사용자 정보는 엔티티가 직접 알 수 없습니다. 이를 위해 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)적용