Session 2JPA Workshop

기본 Entity Mapping

Entity와 테이블 매핑의 기본 어노테이션, 기본키 전략, 다양한 타입 매핑을 학습합니다.

학습 목표

  • @Entity, @Table 어노테이션의 역할과 옵션을 이해한다
  • 기본키 생성 전략(IDENTITY, SEQUENCE, TABLE, AUTO)을 구분하고 선택할 수 있다
  • @Column의 다양한 속성을 활용하여 정밀한 매핑을 할 수 있다
  • Enum, 날짜/시간 타입을 올바르게 매핑할 수 있다
  • @Embedded를 활용하여 값 타입을 설계할 수 있다

1. @Entity와 @Table

1.1 @Entity

클래스를 JPA가 관리하는 Entity로 지정합니다. 이 어노테이션이 붙은 클래스는 JPA가 테이블과 매핑하여 관리합니다.

@Entity 필수 요구사항

기본 생성자 필수 (public 또는 protected)

JPA가 리플렉션으로 객체를 생성하기 때문

final 클래스, enum, interface, inner 클래스 사용 불가

프록시 생성을 위해 상속 가능해야 함

저장할 필드에 final 사용 불가

값 변경이 가능해야 함

@Entity 속성
// name 속성: JPQL에서 사용할 엔티티 이름 지정
// 기본값은 클래스 이름
@Entity(name = "Product")  // JPQL: SELECT p FROM Product p
public class Product { ... }

// 같은 이름의 클래스가 다른 패키지에 있을 때 구분용
@Entity(name = "AdminProduct")
public class Product { ... }  // com.example.admin.Product

1.2 @Table

Entity와 매핑할 테이블을 지정합니다. 생략하면 Entity 이름을 테이블 이름으로 사용합니다.

@Table 속성
속성설명기본값
name매핑할 테이블 이름Entity 이름
catalog데이터베이스 catalog 매핑-
schema데이터베이스 schema 매핑-
uniqueConstraintsDDL 생성 시 유니크 제약조건-
indexesDDL 생성 시 인덱스-
기존 DB 테이블에 매핑하기

PHP 시스템의 기존 테이블 이름이 Java 네이밍 컨벤션과 다를 때:

// 기존 테이블: tbl_products (레거시 네이밍)
@Entity
@Table(
    name = "tbl_products",
    indexes = {
        @Index(name = "idx_product_name", columnList = "name"),
        @Index(name = "idx_product_category", columnList = "category_id")
    },
    uniqueConstraints = {
        @UniqueConstraint(name = "uk_product_sku", columnNames = {"sku"})
    }
)
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_id")
    private Long id;
    
    @Column(name = "product_name", nullable = false)
    private String name;
    
    // ...
}

1.3 네이밍 전략

Hibernate 네이밍 전략 설정

Spring Boot에서 기본적으로 camelCase를 snake_case로 변환합니다.

# application.yml
spring:
  jpa:
    hibernate:
      naming:
        # 논리적 이름 전략 (Entity → 논리명)
        implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl
        # 물리적 이름 전략 (논리명 → 실제 테이블/컬럼명)
        physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy

# 결과:
# productName → product_name
# OrderItem → order_item

기존 DB 사용 시: @Table(name="..."), @Column(name="...")으로 명시적 지정 권장

2. @Id와 기본키 생성 전략

모든 Entity는 반드시 식별자(@Id)를 가져야 합니다. 기본키 생성 전략은 데이터베이스와 성능에 큰 영향을 미치므로 신중하게 선택해야 합니다.

기본키 생성 전략 비교

IDENTITY

데이터베이스에 위임 (MySQL AUTO_INCREMENT, PostgreSQL SERIAL)

@GeneratedValue(strategy = GenerationType.IDENTITY)
장점: 간단, 널리 사용
단점: INSERT 후에야 ID 알 수 있음, 배치 INSERT 불가

SEQUENCE

데이터베이스 시퀀스 사용 (Oracle, PostgreSQL, H2)

@GeneratedValue(strategy = GenerationType.SEQUENCE,
               generator = "product_seq")
@SequenceGenerator(name = "product_seq",
                   sequenceName = "product_sequence",
                   allocationSize = 50)
장점: 배치 INSERT 가능, 성능 최적화
단점: MySQL 미지원

TABLE

키 생성 전용 테이블 사용 (모든 DB 지원)

@GeneratedValue(strategy = GenerationType.TABLE,
               generator = "product_table_gen")
@TableGenerator(name = "product_table_gen",
                table = "sequences",
                pkColumnValue = "product_seq",
                allocationSize = 50)
장점: 모든 DB에서 동일하게 동작
단점: 성능 이슈 (락 경합)

AUTO

JPA 구현체가 자동 선택 (Hibernate는 DB 방언에 따라 결정)

@GeneratedValue(strategy = GenerationType.AUTO)
주의: 예측 불가능할 수 있음. 명시적 전략 권장

MySQL 사용 시 권장 전략

MySQL은 SEQUENCE를 지원하지 않으므로 IDENTITY를 사용합니다. 단, IDENTITY는 persist() 시점에 즉시 INSERT가 실행되어 쓰기 지연이 동작하지 않습니다.

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    // persist() 호출 시 즉시 INSERT 실행
    // → ID 값을 얻기 위해 DB 접근 필요
}

2.1 IDENTITY 전략의 동작 원리

// IDENTITY 전략 동작 과정
@Transactional
public void createProducts() {
    Product p1 = new Product("상품1");
    Product p2 = new Product("상품2");
    Product p3 = new Product("상품3");
    
    // IDENTITY: persist() 시점에 즉시 INSERT
    em.persist(p1);  // INSERT 실행 → p1.id = 1
    em.persist(p2);  // INSERT 실행 → p2.id = 2
    em.persist(p3);  // INSERT 실행 → p3.id = 3
    
    // 이미 3번의 INSERT가 실행됨
    // 쓰기 지연 불가능
}

// SEQUENCE 전략이라면?
@Transactional
public void createProductsWithSequence() {
    Product p1 = new Product("상품1");
    Product p2 = new Product("상품2");
    Product p3 = new Product("상품3");
    
    // SEQUENCE: persist() 시점에 시퀀스만 조회
    em.persist(p1);  // SELECT nextval('seq') → p1.id = 1
    em.persist(p2);  // 캐시된 시퀀스 사용 → p2.id = 2
    em.persist(p3);  // 캐시된 시퀀스 사용 → p3.id = 3
    
    // 커밋 시점에 배치 INSERT 가능
    // INSERT INTO products VALUES (1, ...), (2, ...), (3, ...)
}

2.2 복합 기본키 (Composite Primary Key)

기존 DB에 복합키가 있는 경우

@IdClass 또는 @EmbeddedId 사용. @EmbeddedId가 더 객체지향적입니다.

// 방법 1: @IdClass
public class OrderItemId implements Serializable {
    private Long orderId;
    private Long productId;
    
    // 기본 생성자, equals(), hashCode() 필수
    @Override
    public boolean equals(Object o) { ... }
    @Override
    public int hashCode() { ... }
}

@Entity
@IdClass(OrderItemId.class)
public class OrderItem {
    @Id 
    @Column(name = "order_id")
    private Long orderId;
    
    @Id 
    @Column(name = "product_id")
    private Long productId;
    
    private int quantity;
}

// 방법 2: @EmbeddedId (권장)
@Embeddable
public class OrderItemId implements Serializable {
    @Column(name = "order_id")
    private Long orderId;
    
    @Column(name = "product_id")
    private Long productId;
    
    // equals(), hashCode() 필수
}

@Entity
public class OrderItem {
    @EmbeddedId
    private OrderItemId id;
    
    private int quantity;
    
    // 조회 시
    // OrderItem item = em.find(OrderItem.class, new OrderItemId(1L, 100L));
}

권장: 가능하면 복합키 대신 대리키(Surrogate Key) 사용. 복합키는 비즈니스 로직 변경에 취약합니다.

2.3 UUID 기본키

분산 시스템이나 보안이 중요한 경우 UUID 사용을 고려할 수 있습니다.

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
    
    // 또는 직접 생성
    @Id
    @Column(length = 36)
    private String id;
    
    @PrePersist
    public void generateId() {
        if (id == null) {
            id = UUID.randomUUID().toString();
        }
    }
}

장점

  • • DB 없이 ID 생성 가능
  • • 분산 환경에서 충돌 없음
  • • ID 예측 불가 (보안)

단점

  • • 인덱스 성능 저하
  • • 저장 공간 증가 (36자)
  • • 디버깅 어려움

3. @Column 상세 옵션

@Column 속성 전체
속성설명기본값
name컬럼 이름필드 이름
nullablenull 허용 여부 (DDL)true
unique유니크 제약조건 (DDL)false
length문자 길이 (String만, DDL)255
precision소수점 포함 전체 자릿수 (BigDecimal)0
scale소수점 자릿수 (BigDecimal)0
insertableINSERT 시 포함 여부true
updatableUPDATE 시 포함 여부true
columnDefinitionDDL 직접 지정-

3.1 실전 예제: 이커머스 Product

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 컬럼명 지정 + NOT NULL + 길이 제한
    @Column(name = "product_name", nullable = false, length = 200)
    private String name;

    // 유니크 제약조건
    @Column(nullable = false, unique = true, length = 50)
    private String sku;  // Stock Keeping Unit

    // 금액: precision(전체 자릿수), scale(소수점 자릿수)
    // DECIMAL(10,2) → 최대 99999999.99
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(name = "discount_price", precision = 10, scale = 2)
    private BigDecimal discountPrice;

    @Column(nullable = false)
    private Integer stockQuantity = 0;

    // 긴 텍스트
    @Column(length = 4000)
    private String description;

    // 읽기 전용 컬럼 (DB에서 자동 생성되는 값)
    @Column(name = "created_at", insertable = false, updatable = false)
    private LocalDateTime createdAt;

    // DDL 직접 지정 (DB 특화 타입)
    @Column(columnDefinition = "TEXT")
    private String detailHtml;
    
    // JSON 컬럼 (MySQL 5.7+)
    @Column(columnDefinition = "JSON")
    private String metadata;
}

3.2 insertable, updatable 활용

@Entity
public class Product {
    
    // 생성 시간: INSERT만 가능, UPDATE 불가
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;
    
    // DB 트리거로 자동 생성되는 값: INSERT/UPDATE 모두 제외
    @Column(name = "auto_generated_code", insertable = false, updatable = false)
    private String autoGeneratedCode;
    
    // 읽기 전용 (다른 테이블과 조인된 뷰에서 사용)
    @Column(name = "category_name", insertable = false, updatable = false)
    private String categoryName;
    
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }
}

3.3 DDL 생성 속성 vs 런타임 속성

DDL 생성 속성은 스키마 자동 생성 시에만 적용

DDL 생성 시에만 사용

  • • nullable
  • • unique
  • • length
  • • precision, scale
  • • columnDefinition

ddl-auto=none이면 무시됨

런타임에 항상 적용

  • • name
  • • insertable
  • • updatable

실제 SQL 생성에 영향

3.4 @Column 없이 매핑

@Column을 생략하면 필드명이 컬럼명으로 사용됩니다. 기본 타입은 자동으로 적절한 DB 타입으로 매핑됩니다.

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    // @Column 생략 가능
    private String name;           // VARCHAR(255)
    private Integer quantity;      // INT
    private BigDecimal price;      // DECIMAL(19,2)
    private Boolean active;        // TINYINT(1) / BOOLEAN
    private LocalDateTime createdAt; // DATETIME / TIMESTAMP
    
    // 단, 기존 DB 컬럼명이 다르면 @Column(name="...") 필수
}

4. 기본 타입 매핑

Java 타입 → DB 타입 매핑
Java 타입MySQLPostgreSQL비고
StringVARCHAR(255)VARCHAR(255)length로 조절
Integer/intINTINTEGER-2^31 ~ 2^31-1
Long/longBIGINTBIGINTID에 권장
Double/doubleDOUBLEDOUBLE PRECISION금액에 사용 금지
BigDecimalDECIMALNUMERIC금액에 권장
Boolean/booleanTINYINT(1)BOOLEAN0/1 또는 true/false
LocalDateDATEDATE날짜만
LocalDateTimeDATETIMETIMESTAMP날짜+시간
byte[]BLOBBYTEA바이너리

Wrapper vs Primitive 타입

Wrapper (Integer, Long, Boolean)

  • • null 허용
  • • DB의 NULL과 매핑 가능
  • • null 체크로 "값 없음" 표현

일반적으로 권장

Primitive (int, long, boolean)

  • • null 불가 (기본값 존재)
  • • int: 0, boolean: false
  • • NOT NULL 컬럼에 적합

0과 null 구분 불가

4.1 금액 처리: BigDecimal

@Entity
public class Product {
    // 금액은 반드시 BigDecimal 사용
    // Double/Float는 부동소수점 오차 발생!
    
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;  // DECIMAL(10,2): 최대 99,999,999.99
    
    @Column(precision = 10, scale = 2)
    private BigDecimal discountPrice;
    
    // 비율 (할인율 등)
    @Column(precision = 5, scale = 4)
    private BigDecimal discountRate;  // 0.0000 ~ 9.9999 (최대 999.99%)
}

// 사용 예시
Product product = new Product();
product.setPrice(new BigDecimal("15000.00"));
product.setDiscountRate(new BigDecimal("0.1500"));  // 15%

// 계산
BigDecimal finalPrice = product.getPrice()
    .multiply(BigDecimal.ONE.subtract(product.getDiscountRate()))
    .setScale(2, RoundingMode.HALF_UP);  // 12750.00

4.2 Boolean 매핑 주의사항

@Entity
public class Product {
    // MySQL: TINYINT(1) - 0 또는 1로 저장
    // PostgreSQL: BOOLEAN - true/false로 저장
    
    @Column(nullable = false)
    private Boolean active = true;  // Wrapper 권장
    
    // 기존 DB가 Y/N 문자로 저장하는 경우
    @Column(name = "is_active", length = 1)
    @Convert(converter = BooleanToYNConverter.class)
    private Boolean active;
}

// Y/N 변환 컨버터
@Converter
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
    @Override
    public String convertToDatabaseColumn(Boolean attribute) {
        return (attribute != null && attribute) ? "Y" : "N";
    }
    
    @Override
    public Boolean convertToEntityAttribute(String dbData) {
        return "Y".equals(dbData);
    }
}

이커머스에서 자주 사용하는 타입 매핑

@Entity
public class Product {
    @Id
    private Long id;                    // BIGINT
    private String name;                // VARCHAR(255)
    private String sku;                 // VARCHAR(50)
    private BigDecimal price;           // DECIMAL(10,2)
    private Integer stockQuantity;      // INT
    private Boolean active;             // TINYINT(1)
    private LocalDateTime createdAt;    // DATETIME
}

@Entity
public class Order {
    @Id
    private Long id;                    // BIGINT
    private BigDecimal totalAmount;     // DECIMAL(12,2)
    private LocalDate orderDate;        // DATE
    private LocalDateTime paidAt;       // DATETIME (nullable)
}

5. @Enumerated - Enum 타입 매핑

EnumType 비교

ORDINAL (기본값) - 사용 금지!

Enum 순서(0, 1, 2...)를 저장

@Enumerated(EnumType.ORDINAL)

Enum 순서 변경 시 데이터 의미가 바뀜!

STRING - 권장

Enum 이름(문자열)을 저장

@Enumerated(EnumType.STRING)

Enum 순서 변경해도 안전

ORDINAL의 위험성

// 초기 Enum
public enum OrderStatus {
    PENDING,    // 0
    PAID,       // 1
    SHIPPED     // 2
}

// DB에 저장된 값: 0, 1, 2

// 나중에 CANCELLED 추가
public enum OrderStatus {
    PENDING,    // 0
    CANCELLED,  // 1 ← 새로 추가!
    PAID,       // 2 ← 기존 1이 2로 변경
    SHIPPED     // 3 ← 기존 2가 3으로 변경
}

// 결과: 기존 PAID(1) 데이터가 CANCELLED로 해석됨!

5.1 이커머스 Enum 예제

// 주문 상태 Enum
public enum OrderStatus {
    PENDING,      // 결제 대기
    PAID,         // 결제 완료
    PREPARING,    // 상품 준비중
    SHIPPED,      // 배송중
    DELIVERED,    // 배송 완료
    CANCELLED,    // 취소됨
    REFUNDED      // 환불됨
}

// 상품 상태 Enum
public enum ProductStatus {
    DRAFT,        // 임시저장
    ACTIVE,       // 판매중
    OUT_OF_STOCK, // 품절
    DISCONTINUED  // 단종
}

// 결제 수단 Enum
public enum PaymentMethod {
    CREDIT_CARD,
    BANK_TRANSFER,
    VIRTUAL_ACCOUNT,
    KAKAO_PAY,
    NAVER_PAY,
    TOSS
}

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private OrderStatus status = OrderStatus.PENDING;
    
    @Enumerated(EnumType.STRING)
    @Column(length = 20)
    private PaymentMethod paymentMethod;
}

@Entity
public class Product {
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private ProductStatus status = ProductStatus.DRAFT;
}

5.2 Enum에 추가 정보 담기

public enum OrderStatus {
    PENDING("결제 대기", false),
    PAID("결제 완료", false),
    PREPARING("상품 준비중", false),
    SHIPPED("배송중", false),
    DELIVERED("배송 완료", true),
    CANCELLED("취소됨", true),
    REFUNDED("환불됨", true);
    
    private final String description;
    private final boolean finalStatus;  // 최종 상태 여부
    
    OrderStatus(String description, boolean finalStatus) {
        this.description = description;
        this.finalStatus = finalStatus;
    }
    
    public String getDescription() {
        return description;
    }
    
    public boolean isFinalStatus() {
        return finalStatus;
    }
    
    // 다음 가능한 상태 반환
    public List<OrderStatus> getNextPossibleStatuses() {
        return switch (this) {
            case PENDING -> List.of(PAID, CANCELLED);
            case PAID -> List.of(PREPARING, REFUNDED);
            case PREPARING -> List.of(SHIPPED);
            case SHIPPED -> List.of(DELIVERED);
            default -> List.of();
        };
    }
}

// 사용
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.getStatus().isFinalStatus()) {
    throw new IllegalStateException("이미 완료된 주문입니다.");
}

5.3 기존 DB의 코드값 매핑

AttributeConverter 사용

기존 DB가 숫자 코드(10, 20, 30...)나 문자 코드(P, A, C...)를 사용하는 경우:

// 기존 DB: status 컬럼에 10, 20, 30 저장
public enum OrderStatus {
    PENDING(10),
    PAID(20),
    SHIPPED(30),
    DELIVERED(40);
    
    private final int code;
    
    OrderStatus(int code) {
        this.code = code;
    }
    
    public int getCode() {
        return code;
    }
    
    public static OrderStatus fromCode(int code) {
        return Arrays.stream(values())
            .filter(s -> s.code == code)
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("Unknown code: " + code));
    }
}

@Converter(autoApply = true)  // 모든 OrderStatus에 자동 적용
public class OrderStatusConverter implements AttributeConverter<OrderStatus, Integer> {
    @Override
    public Integer convertToDatabaseColumn(OrderStatus status) {
        return status != null ? status.getCode() : null;
    }
    
    @Override
    public OrderStatus convertToEntityAttribute(Integer code) {
        return code != null ? OrderStatus.fromCode(code) : null;
    }
}

@Entity
public class Order {
    // @Convert 생략 가능 (autoApply = true)
    @Column(name = "status")
    private OrderStatus status;  // DB에는 10, 20, 30으로 저장
}

6. 날짜/시간 타입 매핑

Java 8+ 날짜/시간 API (권장)

Hibernate 5.x 이상에서는 Java 8 날짜/시간 타입을 자동 매핑합니다. @Temporal 불필요.

@Entity
public class Order {
    // 날짜만 (DATE)
    private LocalDate orderDate;

    // 날짜 + 시간 (DATETIME/TIMESTAMP)
    private LocalDateTime createdAt;

    // 시간만 (TIME) - 잘 사용 안함
    private LocalTime deliveryTime;

    // 타임존 포함 (TIMESTAMP WITH TIME ZONE)
    // PostgreSQL 지원, MySQL은 제한적
    private ZonedDateTime scheduledAt;

    // Instant (UTC 기준 TIMESTAMP)
    // 서버 간 시간 동기화에 유용
    private Instant processedAt;
    
    // 기간 (Period, Duration)은 직접 매핑 안됨
    // → String이나 Long으로 변환 필요
}
날짜/시간 타입 선택 가이드
용도Java 타입DB 타입
생년월일, 주문일LocalDateDATE
생성일시, 수정일시LocalDateTimeDATETIME
글로벌 서비스 시간InstantTIMESTAMP
예약 시간 (타임존 필요)ZonedDateTimeTIMESTAMP WITH TZ

6.1 자동 시간 설정 (Auditing)

JPA 콜백 사용
@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @Column(updatable = false)
    private LocalDateTime createdAt;
    
    private LocalDateTime updatedAt;
    
    // INSERT 전 호출
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }
    
    // UPDATE 전 호출
    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}
Spring Data JPA Auditing (권장)
// 1. 설정 클래스에 @EnableJpaAuditing 추가
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}

// 2. 공통 BaseEntity 생성
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {
    
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    // 작성자/수정자도 추가 가능
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;
    
    @LastModifiedBy
    private String updatedBy;
}

// 3. Entity에서 상속
@Entity
public class Product extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    // createdAt, updatedAt 자동 관리
}

// 4. 작성자 정보 제공 (선택)
@Configuration
public class AuditorConfig {
    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .map(Authentication::getName);
    }
}

레거시 Date 타입 (비권장)

기존 코드에서 java.util.Date를 사용하는 경우 @Temporal 필요:

// java.util.Date 사용 시 (비권장)
@Temporal(TemporalType.DATE)      // DATE만
@Temporal(TemporalType.TIME)      // TIME만
@Temporal(TemporalType.TIMESTAMP) // DATETIME

// 권장: Java 8 API로 마이그레이션
// Date → LocalDate 또는 LocalDateTime

6.2 타임존 처리

타임존 처리 전략

방법 1: UTC로 통일 (권장)

서버/DB는 UTC로 저장, 클라이언트에서 로컬 시간으로 변환

방법 2: 서버 타임존 사용

단일 지역 서비스에서 서버 타임존(Asia/Seoul) 사용

# application.yml - JVM 타임존 설정
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          time_zone: UTC  # 또는 Asia/Seoul

7. @Lob, @Transient, @Embedded

7.1 @Lob - 대용량 데이터

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    // CLOB: 대용량 문자 데이터
    // String, char[] → CLOB/TEXT
    @Lob
    private String detailDescription;
    
    // BLOB: 대용량 바이너리 데이터
    // byte[] → BLOB/BYTEA
    @Lob
    private byte[] image;
    
    // 또는 columnDefinition 사용
    @Column(columnDefinition = "TEXT")
    private String htmlContent;
    
    @Column(columnDefinition = "MEDIUMTEXT")  // MySQL: 최대 16MB
    private String longContent;
}

// MySQL LOB 타입 크기
// TINYTEXT:   255 bytes
// TEXT:       65KB
// MEDIUMTEXT: 16MB
// LONGTEXT:   4GB

7.2 @Transient - 매핑 제외

DB에 저장하지 않을 필드에 사용. 계산된 값이나 임시 데이터에 활용.

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private BigDecimal price;
    private BigDecimal discountRate;

    // DB에 저장하지 않음 - 계산된 값
    @Transient
    private BigDecimal finalPrice;

    public BigDecimal getFinalPrice() {
        if (discountRate != null && discountRate.compareTo(BigDecimal.ZERO) > 0) {
            return price.multiply(BigDecimal.ONE.subtract(discountRate));
        }
        return price;
    }
    
    // 임시 플래그
    @Transient
    private boolean selected;
    
    // 캐시된 계산 결과
    @Transient
    private Integer totalReviewCount;
}

// 또는 transient 키워드 사용 (Java 기본)
@Entity
public class Product {
    private transient String tempData;  // 직렬화/JPA 매핑 모두 제외
}

7.3 @Embedded / @Embeddable - 값 타입

임베디드 타입으로 응집도 높이기

관련 있는 필드들을 하나의 값 객체로 묶어 재사용성과 응집도를 높입니다.

// 값 타입 정의
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Address {
    @Column(length = 10)
    private String zipCode;
    
    @Column(length = 100)
    private String city;
    
    @Column(length = 100)
    private String street;
    
    @Column(length = 100)
    private String detail;
    
    public Address(String zipCode, String city, String street, String detail) {
        this.zipCode = zipCode;
        this.city = city;
        this.street = street;
        this.detail = detail;
    }
    
    // 값 타입은 불변으로 설계
    public Address changeDetail(String newDetail) {
        return new Address(this.zipCode, this.city, this.street, newDetail);
    }
}

// 금액 값 타입
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Money {
    @Column(precision = 12, scale = 2)
    private BigDecimal amount;
    
    @Column(length = 3)
    private String currency;
    
    public Money(BigDecimal amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
    
    public static Money krw(BigDecimal amount) {
        return new Money(amount, "KRW");
    }
    
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("통화가 다릅니다");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}
Entity에서 사용
@Entity
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    // 임베디드 타입 사용
    @Embedded
    private Address address;
    
    // 같은 타입을 여러 번 사용할 때 컬럼명 재정의
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "zipCode", column = @Column(name = "work_zip_code")),
        @AttributeOverride(name = "city", column = @Column(name = "work_city")),
        @AttributeOverride(name = "street", column = @Column(name = "work_street")),
        @AttributeOverride(name = "detail", column = @Column(name = "work_detail"))
    })
    private Address workAddress;
}

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "amount", column = @Column(name = "total_amount")),
        @AttributeOverride(name = "currency", column = @Column(name = "currency"))
    })
    private Money totalPrice;
    
    @Embedded
    private Address shippingAddress;
}

// 생성되는 테이블
// customers: id, name, zip_code, city, street, detail, 
//            work_zip_code, work_city, work_street, work_detail
// orders: id, total_amount, currency, zip_code, city, street, detail

임베디드 타입의 장점

  • 재사용성: Address, Money 등을 여러 Entity에서 사용
  • 응집도: 관련 필드를 하나의 의미 있는 단위로 묶음
  • 비즈니스 로직: 값 타입 내에 관련 메서드 정의 가능
  • 테이블 구조: 임베디드 타입의 필드들은 소유 Entity 테이블에 매핑
주의: 임베디드 타입은 불변(Immutable)으로 설계하세요. 값 변경 시 새 객체를 생성하는 것이 안전합니다.

8. 실습: 이커머스 Entity 매핑

기존 PHP 시스템의 테이블 구조를 가정하고, JPA Entity로 매핑해봅니다.

기존 테이블 구조 (가정)
-- 기존 PHP 시스템의 테이블
CREATE TABLE tbl_products (
    product_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    product_name VARCHAR(200) NOT NULL,
    sku VARCHAR(50) NOT NULL UNIQUE,
    price DECIMAL(10,2) NOT NULL,
    discount_price DECIMAL(10,2),
    stock_qty INT DEFAULT 0,
    status VARCHAR(20) DEFAULT 'DRAFT',
    description TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE tbl_categories (
    category_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    category_name VARCHAR(100) NOT NULL,
    parent_id BIGINT,
    sort_order INT DEFAULT 0,
    is_active TINYINT(1) DEFAULT 1
);

CREATE TABLE tbl_customers (
    customer_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(100) NOT NULL UNIQUE,
    name VARCHAR(50) NOT NULL,
    phone VARCHAR(20),
    zip_code VARCHAR(10),
    city VARCHAR(50),
    street VARCHAR(100),
    address_detail VARCHAR(100),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
JPA Entity 매핑 결과
// BaseEntity - 공통 필드
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {
    @CreatedDate
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}

// Product Entity
@Entity
@Table(name = "tbl_products")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_id")
    private Long id;

    @Column(name = "product_name", nullable = false, length = 200)
    private String name;

    @Column(nullable = false, unique = true, length = 50)
    private String sku;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(name = "discount_price", precision = 10, scale = 2)
    private BigDecimal discountPrice;

    @Column(name = "stock_qty", nullable = false)
    private Integer stockQuantity = 0;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private ProductStatus status = ProductStatus.DRAFT;

    @Lob
    private String description;
    
    // 생성 메서드
    public static Product create(String name, String sku, BigDecimal price) {
        Product product = new Product();
        product.name = name;
        product.sku = sku;
        product.price = price;
        product.status = ProductStatus.DRAFT;
        return product;
    }
    
    // 비즈니스 메서드
    public void activate() {
        this.status = ProductStatus.ACTIVE;
    }
    
    public void updatePrice(BigDecimal price, BigDecimal discountPrice) {
        this.price = price;
        this.discountPrice = discountPrice;
    }
}

// Category Entity
@Entity
@Table(name = "tbl_categories")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "category_id")
    private Long id;

    @Column(name = "category_name", nullable = false, length = 100)
    private String name;

    @Column(name = "parent_id")
    private Long parentId;  // 다음 세션에서 연관관계로 변경

    @Column(name = "sort_order")
    private Integer sortOrder = 0;

    @Column(name = "is_active", nullable = false)
    private Boolean active = true;
}

// Customer Entity with Embedded Address
@Entity
@Table(name = "tbl_customers")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Customer extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "customer_id")
    private Long id;

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Column(nullable = false, length = 50)
    private String name;

    @Column(length = 20)
    private String phone;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "zipCode", column = @Column(name = "zip_code")),
        @AttributeOverride(name = "city", column = @Column(name = "city")),
        @AttributeOverride(name = "street", column = @Column(name = "street")),
        @AttributeOverride(name = "detail", column = @Column(name = "address_detail"))
    })
    private Address address;
}

8.1 정리

@Entity는 기본 생성자 필수

protected 접근 제한자로 외부 생성 방지

@Table로 기존 테이블명 매핑

레거시 DB의 네이밍 컨벤션 대응

MySQL은 IDENTITY 전략 사용

AUTO_INCREMENT 활용

@Enumerated는 반드시 STRING 사용

ORDINAL은 데이터 무결성 위험

Java 8 날짜 타입은 자동 매핑

@Temporal 불필요

@Embedded로 값 타입 재사용

Address, Money 등 응집도 높은 설계

8.2 핵심 키워드

@Entity@Table@Id@GeneratedValueIDENTITYSEQUENCE@Column@EnumeratedEnumType.STRING@Lob@Transient@Embedded@EmbeddableBaseEntity@CreatedDate

8.3 다음 세션 예고

Session 3: 연관관계 매핑 기초

다음 세션에서는 Entity 간의 관계를 매핑하는 방법을 학습합니다.

@ManyToOne, @OneToMany

다대일, 일대다 연관관계

단방향 vs 양방향

연관관계 방향 설계

연관관계의 주인 (mappedBy)

외래키 관리 주체 결정

@JoinColumn

외래키 컬럼 매핑