엔티티(entity)는 테이블의 한 행을 표현하는 클래스다. 클래스에 붙이는 어노테이션은 클래스와 테이블 사이의 연결 규칙을 적는 자리다. 이 규칙을 보고 JPA 구현체인 Hibernate가 INSERT, SELECT 같은 SQL을 만든다.
@Entity
@Table(name = "bookings")
class Booking(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
val memberId: Long,
@Enumerated(EnumType.STRING)
var status: BookingStatus,
@Column(insertable = false, updatable = false)
val createdAt: LocalDateTime? = null,
)이름 변환 규칙
어노테이션으로 이름을 적지 않으면 Spring Boot의 기본 규칙(CamelCaseToUnderscoresNamingStrategy)이 이름을 정한다.
- 필드 이름은 카멜 케이스에서 스네이크 케이스로 바뀐다.
memberId는member_id,createdAt은created_at컬럼이 된다. - 클래스 이름도 스네이크 케이스로만 바뀌고 복수형이 되지 않는다.
BookingItem은booking_item테이블을 찾는다.
테이블 이름을 bookings처럼 복수형으로 지었다면 @Table(name = "bookings")로 직접 적어야 한다. 적지 않으면 Hibernate는 booking 테이블을 찾는다. 스키마 검사(ddl-auto: validate)를 켜 두면 이 어긋남은 애플리케이션 시작 시점에 Schema-validation: missing table 에러로 드러난다. 검사 설정은 ddl-auto와 Flyway에 정리했다.
어노테이션별 역할
@Entity: 이 클래스가 테이블과 연결된 엔티티라고 표시한다.@Table(name = ...): 연결할 테이블 이름을 직접 정한다.@Id: 기본 키(PK) 필드다.@GeneratedValue(strategy = GenerationType.IDENTITY): PK 값을 DB가 만든다. MySQL의AUTO_INCREMENT와 짝이다. 그래서id를Long? = null로 두고 저장하면 DB가 매긴 번호가 채워진다.@Column(insertable = false, updatable = false): INSERT와 UPDATE SQL에 이 컬럼을 넣지 않는다. 읽을 때는 정상으로 가져온다.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP처럼 DB가 채우는 컬럼에 insertable = false를 붙인다. 붙이지 않으면 Hibernate가 INSERT에 created_at을 넣고, 코드의 값이 null이면 NULL을 보낸다. 컬럼을 생략했을 때만 DB 기본값이 쓰이므로, 이 경우 기본값이 적용되지 않는다.
IDENTITY 방식의 저장이 바로 INSERT를 보내는 이유는 JPA 쓰기 시점과 영속성 컨텍스트에 정리했다.
enum은 STRING으로
@Enumerated의 기본값은 EnumType.ORDINAL이다. enum의 순서 번호(0, 1, 2)를 저장한다.
enum class BookingStatus { PENDING, CONFIRMED, CANCELED }
// ORDINAL이면 CONFIRMED는 1로 저장됨나중에 PENDING 앞에 DRAFT를 끼워 넣으면 번호가 하나씩 밀린다. 이미 저장된 1은 이제 CONFIRMED가 아니라 PENDING으로 읽힌다. 에러 없이 데이터의 의미가 바뀐다. EnumType.STRING은 "CONFIRMED"처럼 이름을 그대로 저장하므로 순서를 바꿔도 안전하다.
STRING은 읽을 때 저장된 문자열로 BookingStatus.valueOf("CONFIRMED")를 부른다. 그래서 DB 쪽 값과 Kotlin enum 이름의 철자가 정확히 같아야 한다. MySQL ENUM('confirmed', ...) 컬럼에 Kotlin이 "CONFIRMED"를 넣으면, MySQL은 대소문자를 구분하지 않고 정의된 표기인 'confirmed'로 바꿔 저장한다. 저장은 성공하지만, 읽을 때 valueOf("confirmed")가 실패한다. 저장 성공만으로는 이 문제를 잡을 수 없으므로 양쪽 철자를 같게 맞추고, 저장한 뒤 다시 읽는 테스트로 확인한다.
외래 키를 연관관계 대신 id로
@ManyToOne(fetch = FetchType.LAZY)
val member: Member // 연관관계로 매핑
val memberId: Long // id 값만 들고 있기@ManyToOne으로 매핑하면 booking.member.name처럼 객체 그래프를 따라갈 수 있다. 대신 지연 로딩 프록시, 목록 조회에서 연관 객체마다 쿼리가 하나씩 더 나가는 N+1 문제를 함께 다뤄야 한다. 연관 객체를 따라갈 일이 적다면 memberId: Long처럼 id 값만 들고 있는 편이 단순하다. 나중에 필요해지면 연관관계로 바꿀 수 있다.
연관관계 없이 두 테이블을 조회해야 할 때는 JPQL의 JOIN ... ON으로 필드 값을 이어 붙인다. Spring Data JPA 리포지토리에서 다룬다.
Kotlin 엔티티에 필요한 설정
JPA는 엔티티를 리플렉션으로 만들 때 인자 없는 생성자가 필요하고, Hibernate는 지연 로딩을 위해 엔티티를 상속한 프록시를 만든다. Kotlin 클래스에는 보통 인자 없는 생성자가 없고 기본이 final이라 둘 다 맞지 않는다. plugin.jpa(인자 없는 생성자)와 allOpen 설정(open 클래스)으로 해결한다. 설정 방법은 Kotlin과 Java 함께 쓰기에 정리했다.